From 1486db526851870074398507389e40113625edff Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:43:20 -0700 Subject: [PATCH 001/345] fix(button): read dsm_state for the GFE override guard, not the GFE The availability guard was `if gfe == "GRID": return False` -- do not offer the override when the panel already reports grid-connected. Under v1.0 that compares `None == "GRID"` and never fires: `dominant_power_source` is one of the fields the schema split, so the guard is dead code expressing a live intention. Reading `dsm_state` instead fixes it and is the better question anyway. "Are we already on the grid" is what `dsm_state` answers directly; the grid-forming entity answers it only by implication. And `dsm_state` is populated on both wire schemas -- flat derives it, v1.0 reads it from the MID -- so the guard stays free of any knowledge of which schema is underneath. Checking one field on one schema and a different field on the other would put the migration back above the adapter seam, which is the thing the seam exists to prevent. One useful consequence: the precedence behind `dsm_state` folds in the user's own `shed/asserted-islanding-state`, so once a press takes effect the button disables itself rather than inviting a second. The first guard is unchanged. `battery.connected` is populated on both schemas, and it mirrors what firmware itself enforces -- the enclosure model accepts an assertion only "while the enclosure has lost or degraded communication with its MID / BESS". Falsified: reverting the comparison to `dominant_power_source == "GRID"` fails all three availability tests. 665 passed. --- custom_components/span_panel/button.py | 17 +++++++++++++---- tests/test_button.py | 15 +++++++++++---- tests/test_dps_and_bess.py | 16 +++++++++++----- 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/custom_components/span_panel/button.py b/custom_components/span_panel/button.py index faf24fd4..f9b970cb 100644 --- a/custom_components/span_panel/button.py +++ b/custom_components/span_panel/button.py @@ -90,9 +90,19 @@ def available(self) -> bool: """Return entity availability. The override is only relevant when BESS communication is lost and the - panel is not already reporting grid-connected. When BESS is online or - GFE is already GRID, firmware is managing correctly and the button + panel is not already reporting grid-connected. When BESS is online or the + panel already reads on-grid, firmware is managing correctly and the button should not be pressable. + + The second check reads `dsm_state`, not the grid-forming entity. It asks + "are we already on the grid", which is what `dsm_state` answers directly and + the GFE answers only by implication -- and, unlike the GFE, it is populated on + both wire schemas, so this stays free of any knowledge of which one is + underneath. It used to compare `dominant_power_source` to `"GRID"`, which is + `None` under v1.0 and so never fired there at all. + + One useful consequence: `dsm_state` folds in the user's own assertion, so once + a press takes effect the button disables itself rather than inviting a second. """ if getattr(self.coordinator, "panel_offline", False): return False @@ -100,10 +110,9 @@ def available(self) -> bool: return False snapshot: SpanPanelSnapshot = self.coordinator.data bess_connected = snapshot.battery.connected if snapshot.battery else None - gfe = snapshot.dominant_power_source if bess_connected is True: return False - if gfe == "GRID": + if snapshot.dsm_state == "DSM_ON_GRID": return False return True diff --git a/tests/test_button.py b/tests/test_button.py index 521ad6cb..02de4eb4 100644 --- a/tests/test_button.py +++ b/tests/test_button.py @@ -101,10 +101,15 @@ async def test_gfe_override_button_server_error_creates_notification() -> None: def test_gfe_override_button_available_only_when_override_is_relevant() -> None: - """Availability should reflect panel state and whether firmware already has control.""" + """Availability should reflect panel state and whether firmware already has control. + + Keyed on `dsm_state` rather than `dominant_power_source`: the guard asks "are we + already on the grid", and that is populated on both wire schemas where the GFE is + not — under v1.0 it is `None`, so the old comparison never fired. + """ snapshot = SpanPanelSnapshotFactory.create( battery=SpanBatterySnapshotFactory.create(connected=False), - dominant_power_source="BATTERY", + dsm_state="DSM_OFF_GRID", ) coordinator = _make_button_coordinator(snapshot) button = SpanPanelGFEOverrideButton(coordinator, GFE_OVERRIDE_DESCRIPTION, "GRID") @@ -114,16 +119,18 @@ def test_gfe_override_button_available_only_when_override_is_relevant() -> None: coordinator.panel_offline = True assert button.available is False + # BESS reachable: firmware sets the grid state itself, so there is nothing to assert. coordinator.panel_offline = False coordinator.data = SpanPanelSnapshotFactory.create( battery=SpanBatterySnapshotFactory.create(connected=True), - dominant_power_source="BATTERY", + dsm_state="DSM_OFF_GRID", ) assert button.available is False + # Already on grid: the assertion would be a no-op, and firmware would reject it. coordinator.data = SpanPanelSnapshotFactory.create( battery=SpanBatterySnapshotFactory.create(connected=False), - dominant_power_source="GRID", + dsm_state="DSM_ON_GRID", ) assert button.available is False diff --git a/tests/test_dps_and_bess.py b/tests/test_dps_and_bess.py index 8985afa2..573c8f20 100644 --- a/tests/test_dps_and_bess.py +++ b/tests/test_dps_and_bess.py @@ -67,10 +67,16 @@ def test_bess_sensor_created_with_bess(self) -> None: def _make_gfe_coordinator( dominant_power_source: str | None = "GRID", battery: SpanBatterySnapshot | None = None, + dsm_state: str = "DSM_ON_GRID", ) -> MagicMock: - """Build a mock coordinator for GFE button tests.""" + """Build a mock coordinator for GFE button tests. + + `dsm_state` is what the button's availability guard reads; `dominant_power_source` + stays because other tests here assert on the GFE sensor itself. + """ snapshot = SpanPanelSnapshotFactory.create( dominant_power_source=dominant_power_source, + dsm_state=dsm_state, battery=battery if battery is not None else SpanBatterySnapshot(), ) @@ -154,9 +160,9 @@ async def test_button_server_error(self) -> None: mock_notification.assert_called_once() def test_available_when_bess_offline_and_not_grid(self) -> None: - """Button is available when BESS is offline and GFE is not GRID.""" + """Button is available when BESS is offline and the panel is not on grid.""" coordinator = _make_gfe_coordinator( - dominant_power_source="BATTERY", + dsm_state="DSM_OFF_GRID", battery=SpanBatterySnapshot(soe_percentage=50.0, connected=False), ) coordinator.panel_offline = False @@ -190,9 +196,9 @@ def test_unavailable_when_gfe_is_grid(self) -> None: assert button.available is False def test_available_when_no_bess(self) -> None: - """Button is available when no BESS is commissioned and GFE is not GRID.""" + """Button is available when no BESS is commissioned and the panel is not on grid.""" coordinator = _make_gfe_coordinator( - dominant_power_source="BATTERY", + dsm_state="DSM_OFF_GRID", battery=SpanBatterySnapshot(), ) coordinator.panel_offline = False From 4d8a723e2fe62786fa1d462fb09ff114cea5480b Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:25:00 -0700 Subject: [PATCH 002/345] feat(sensor): name the grid-forming device as an attribute on the GFE sensor v1.0 knows *which* device is forming the grid, which flat never did. The state stays the source class -- GRID / BATTERY / PV -- because that is the closed enum automations compare against and it must not change. The identity goes on an attribute instead: it refines a value already on screen, adds no entity-list noise, and cannot break an automation that never referenced it. Deliberately the display name, not the wire id. `sim-40t-001-SIM-BESS-40T-001` is a Homie device id, not a Home Assistant one, and an opaque string on a dashboard is worse than none. The id stays in the snapshot, where it is worth having for correlation and because it lands in a diagnostics dump -- which is how SpanPanel/span#214 was actually solved. Keyed on the GFE sensor specifically: `extra_state_attributes` here is shared by every panel-status sensor, so an unguarded addition would attach it to all of them. Reads `None` for now. `mid` arrived in span-panel-api 3.0.0b3 and this integration pins 2.6.4, so `getattr` degrades cleanly -- which is also the right answer on any flat panel, since no flat firmware publishes a MID at all. Falsified: returning the wire id instead of the display name fails the test named for that distinction. 668 passed. --- custom_components/span_panel/sensor_panel.py | 29 ++++++++++++++++ tests/test_dps_and_bess.py | 36 ++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/custom_components/span_panel/sensor_panel.py b/custom_components/span_panel/sensor_panel.py index da07c49a..b4c1f118 100644 --- a/custom_components/span_panel/sensor_panel.py +++ b/custom_components/span_panel/sensor_panel.py @@ -28,6 +28,30 @@ _LOGGER: logging.Logger = logging.getLogger(__name__) +def _grid_forming_device_name(snapshot: SpanPanelSnapshot) -> str | None: + """Return the forming device's readable name, when the library knows it. + + The state stays the source *class* — `GRID`, `BATTERY`, `PV` — because that is the + closed enum automations compare against and it must not change. v1.0 additionally + knows *which* device, which flat never did, and that belongs here: an attribute + refines a value already on screen without adding entity-list noise, and cannot break + an automation that never referenced it. + + Deliberately the display name and not the wire id. `sim-40t-001-SIM-BESS-40T-001` is + a Homie device id, not a Home Assistant one, and an opaque string on a dashboard is + worse than none. The id stays in the snapshot for correlation and diagnostics. + + `getattr` because `mid` arrived in span-panel-api 3.0.0b3 and this integration still + pins 2.6.4, where the field does not exist. Reads `None` there, which is also what it + reads on any flat panel — no flat firmware publishes a MID at all. + """ + mid = getattr(snapshot, "mid", None) + if mid is None: + return None + name: str | None = getattr(mid, "grid_forming_device_name", None) + return name + + class SpanPanelPanelStatus(SpanSensorBase[SpanPanelDataSensorEntityDescription, SpanPanelSnapshot]): """Span Panel data status sensor entity.""" @@ -114,6 +138,11 @@ def extra_state_attributes(self) -> dict[str, Any] | None: if snapshot.wifi_ssid is not None: attributes["wifi_ssid"] = snapshot.wifi_ssid + if self.entity_description.key == "grid_forming_entity": + forming = _grid_forming_device_name(snapshot) + if forming is not None: + attributes["grid_forming_device"] = forming + return attributes or None diff --git a/tests/test_dps_and_bess.py b/tests/test_dps_and_bess.py index 573c8f20..637284a5 100644 --- a/tests/test_dps_and_bess.py +++ b/tests/test_dps_and_bess.py @@ -1,5 +1,6 @@ """Tests for GFE override buttons and BESS connected binary sensor.""" +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -12,6 +13,7 @@ SpanPanelGFEOverrideButton, ) from custom_components.span_panel.helpers import has_bess +from custom_components.span_panel.sensor_panel import _grid_forming_device_name from .factories import SpanPanelSnapshotFactory @@ -206,3 +208,37 @@ def test_available_when_no_bess(self) -> None: coordinator, GFE_OVERRIDE_DESCRIPTION, "GRID" ) assert button.available is True + + +class TestGridFormingDeviceAttribute: + """The GFE sensor's `grid_forming_device` attribute.""" + + def test_the_attribute_is_absent_when_the_library_has_no_mid(self) -> None: + """Degrades to nothing against span-panel-api 2.6.4, which has no `mid` field. + + Also the correct answer on any flat panel: no flat firmware publishes a MID, so + there is no forming device to name. + """ + snapshot = SpanPanelSnapshotFactory.create(dominant_power_source="BATTERY") + + assert _grid_forming_device_name(snapshot) is None + + def test_the_attribute_is_the_display_name_not_the_wire_id(self) -> None: + """A Homie device id is not a Home Assistant device id. + + The state keeps the closed enum automations compare against; this refines it with + the part a person recognises. + """ + mid = SimpleNamespace( + grid_forming_entity="sim-40t-001-SIM-BESS-40T-001", + grid_forming_device_name="Battery", + ) + snapshot = SimpleNamespace(mid=mid) + + assert _grid_forming_device_name(snapshot) == "Battery" + + def test_no_name_when_the_grid_itself_is_forming(self) -> None: + """There is no device to name, so the attribute stays off the sensor.""" + snapshot = SimpleNamespace(mid=SimpleNamespace(grid_forming_device_name=None)) + + assert _grid_forming_device_name(snapshot) is None From 6635a8f3fe2ac6d603ca50a85972ab2122c76776 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:29:18 -0700 Subject: [PATCH 003/345] feat!: move onto span-panel-api 3.0.0b3 and the normalised DER identity BREAKING for the library boundary, invisible to users. `product_name` is retired on `battery`, `evse` and `pv`; `model` is the human designation and `part_number` the SKU, on all three. Flat was the inconsistent side -- it put the SKU in `bess/model` and in `evse/part-number` for the same concept -- and `schema_0` now translates flat into the normalised shape rather than mirroring it, so both wire schemas hand this integration the same fields with the same values. Seven source sites and eight test sites. The one that mattered is `util.py`: the device-registry `model` for the BESS and EVSE cards is built from this field, and the delta document flagged that leaving it would blank those cards. Checked rather than assumed -- `bess_device_info` renders "SPAN Battery", `evse_device_info` renders "SPAN Drive", and the "Battery Storage" fallback still fires on an empty snapshot. `schema_expectations.py` moves with them, since it names snapshot paths. The dev source override points at the schema-1 worktree rather than the sibling symlink, which resolves to the primary checkout on 2.6.4 -- this branch cannot build against that. The pin names 3.0.0b3, which is not published yet, so this branch is ahead of a release by design. 668 passed, mypy clean. The 91 ruff findings in tests/ are pre-existing and unchanged -- identical count with this work stashed. --- .../span_panel/schema_expectations.py | 4 +- .../span_panel/sensor_definitions.py | 4 +- custom_components/span_panel/util.py | 8 +- pyproject.toml | 8 +- tests/factories.py | 10 +- tests/test_evse_entities.py | 8 +- tests/test_promoted_sensors.py | 6 +- tests/test_sensor_platform.py | 2 +- uv.lock | 559 +++++++++++------- 9 files changed, 360 insertions(+), 249 deletions(-) diff --git a/custom_components/span_panel/schema_expectations.py b/custom_components/span_panel/schema_expectations.py index ed4d2050..4ebe1f34 100644 --- a/custom_components/span_panel/schema_expectations.py +++ b/custom_components/span_panel/schema_expectations.py @@ -75,12 +75,12 @@ "soe_kwh": "battery.soe_kwh", # --- BESS metadata sensors ----------------------------------------------- "vendor": "battery.vendor_name", - "model": "battery.product_name", + "model": "battery.model", "serial_number": "battery.serial_number", "firmware_version": "battery.software_version", # --- PV metadata sensors ------------------------------------------------- "pv_vendor": "pv.vendor_name", - "pv_product": "pv.product_name", + "pv_product": "pv.model", "pv_nameplate_capacity": "pv.nameplate_capacity_w", # --- EVSE sensors -------------------------------------------------------- "evse_status": "evse.status", diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py index e94d3ac6..d289141b 100644 --- a/custom_components/span_panel/sensor_definitions.py +++ b/custom_components/span_panel/sensor_definitions.py @@ -375,7 +375,7 @@ class SpanBessMetadataSensorEntityDescription( key="model", translation_key="bess_model", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda b: b.product_name, + value_fn=lambda b: b.model, ), SpanBessMetadataSensorEntityDescription( key="serial_number", @@ -443,7 +443,7 @@ class SpanPVMetadataSensorEntityDescription( key="pv_product", translation_key="pv_product", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda s: s.pv.product_name, + value_fn=lambda s: s.pv.model, ), SpanPVMetadataSensorEntityDescription( key="pv_nameplate_capacity", diff --git a/custom_components/span_panel/util.py b/custom_components/span_panel/util.py index a42ce0de..71f6999f 100644 --- a/custom_components/span_panel/util.py +++ b/custom_components/span_panel/util.py @@ -39,7 +39,9 @@ def bess_device_info( identifiers={(DOMAIN, f"{panel_identifier}_bess")}, name=name, manufacturer=battery.vendor_name or "Unknown", - model=battery.product_name or "Battery Storage", + # `model` is the human designation on both schemas now: v1.0 publishes it as + # `info/model` and schema_0 translates flat's `bess/product-name` into it. + model=battery.model or "Battery Storage", serial_number=battery.serial_number, sw_version=battery.software_version, via_device=(DOMAIN, panel_identifier), @@ -53,14 +55,14 @@ def evse_device_info( display_suffix: str | None = None, ) -> DeviceInfo: """Create DeviceInfo for an EVSE sub-device linked to the parent panel.""" - base_name = evse.product_name or "EV Charger" + base_name = evse.model or "EV Charger" name = f"{base_name} ({display_suffix})" if display_suffix else base_name name = f"{panel_name} {name}" return DeviceInfo( identifiers={(DOMAIN, f"{panel_identifier}_evse_{evse.node_id}")}, name=name, manufacturer=evse.vendor_name or "SPAN", - model=evse.product_name or "SPAN Drive", + model=evse.model or "SPAN Drive", serial_number=evse.serial_number, sw_version=evse.software_version, via_device=(DOMAIN, panel_identifier), diff --git a/pyproject.toml b/pyproject.toml index b3e559bc..482d7e98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ readme = "README.md" requires-python = ">=3.14.3,<3.15" dependencies = [ "homeassistant==2026.5.4", - "span-panel-api==2.6.4", + "span-panel-api==3.0.0b3", ] [dependency-groups] @@ -35,7 +35,9 @@ dev = [ package = false [tool.uv.sources] -span-panel-api = { path = "../span-panel-api", editable = true } +# This branch develops against the in-progress schema-1 adapter, which lives in a +# worktree rather than the primary checkout the symlink points at. +span-panel-api = { path = "../../span/span-panel-api-p2", editable = true } [tool.jscpd] path = ["custom_components/span_panel", "./*.{html,md}"] @@ -115,7 +117,7 @@ exclude = [ ] extraPaths = [ "./custom_components", - "../span-panel-api/src", + "../../span/span-panel-api-p2/src", "../ha-synthetic-sensors/src" ] pythonPlatform = "Darwin" diff --git a/tests/factories.py b/tests/factories.py index ad469691..5a2b37d9 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -140,7 +140,7 @@ def create( lock_state: str = "LOCKED", advertised_current_a: float | None = 32.0, vendor_name: str | None = "SPAN", - product_name: str | None = "SPAN Drive", + model: str | None = "SPAN Drive", part_number: str | None = None, serial_number: str | None = "SN-EVSE-001", software_version: str | None = "2.1.0", @@ -153,7 +153,7 @@ def create( lock_state=lock_state, advertised_current_a=advertised_current_a, vendor_name=vendor_name, - product_name=product_name, + model=model, part_number=part_number, serial_number=serial_number, software_version=software_version, @@ -176,7 +176,8 @@ def create( soe_percentage: float | None = 85.0, soe_kwh: float | None = None, vendor_name: str | None = None, - product_name: str | None = None, + model: str | None = None, + part_number: str | None = None, serial_number: str | None = None, software_version: str | None = None, nameplate_capacity_kwh: float | None = None, @@ -187,7 +188,8 @@ def create( soe_percentage=soe_percentage, soe_kwh=soe_kwh, vendor_name=vendor_name, - product_name=product_name, + model=model, + part_number=part_number, serial_number=serial_number, software_version=software_version, nameplate_capacity_kwh=nameplate_capacity_kwh, diff --git a/tests/test_evse_entities.py b/tests/test_evse_entities.py index de88028a..961a6112 100644 --- a/tests/test_evse_entities.py +++ b/tests/test_evse_entities.py @@ -172,7 +172,7 @@ def test_evse_device_info_full_metadata(self): evse = SpanEvseSnapshotFactory.create( node_id="evse-0", vendor_name="SPAN", - product_name="SPAN Drive", + model="SPAN Drive", serial_number="SN123", software_version="2.0.0", ) @@ -193,7 +193,7 @@ def test_evse_device_info_full_metadata(self): def test_evse_device_info_fallback_names(self): evse = SpanEvseSnapshotFactory.create( vendor_name=None, - product_name=None, + model=None, ) info = evse_device_info("panel-serial", evse, "Span Panel", display_suffix=None) assert info.get("name") == "Span Panel EV Charger" @@ -202,7 +202,7 @@ def test_evse_device_info_fallback_names(self): def test_evse_device_info_serial_suffix(self): evse = SpanEvseSnapshotFactory.create( - product_name="SPAN Drive", + model="SPAN Drive", serial_number="SN-EVSE-001", ) info = evse_device_info( @@ -263,7 +263,7 @@ def test_default_factory_values(self): assert evse.lock_state == "LOCKED" assert evse.advertised_current_a == 32.0 assert evse.vendor_name == "SPAN" - assert evse.product_name == "SPAN Drive" + assert evse.model == "SPAN Drive" def test_available_factory(self): evse = SpanEvseSnapshotFactory.create_available() diff --git a/tests/test_promoted_sensors.py b/tests/test_promoted_sensors.py index d1111c0c..fa6a697d 100644 --- a/tests/test_promoted_sensors.py +++ b/tests/test_promoted_sensors.py @@ -201,7 +201,7 @@ class TestBessDeviceInfo: def test_bess_device_info_basic(self): battery = SpanBatterySnapshotFactory.create( vendor_name="Tesla", - product_name="Powerwall 2", + model="Powerwall 2", serial_number="TW-001", software_version="2.1.0", ) @@ -247,7 +247,7 @@ def test_vendor_value_function(self): assert desc.value_fn(battery) == "Enphase" def test_model_value_function(self): - battery = SpanBatterySnapshotFactory.create(product_name="IQ Battery 10") + battery = SpanBatterySnapshotFactory.create(model="IQ Battery 10") desc = next(d for d in BESS_METADATA_SENSORS if d.key == "model") assert desc.value_fn(battery) == "IQ Battery 10" @@ -334,7 +334,7 @@ def test_pv_vendor_value_function(self): def test_pv_product_value_function(self): snapshot = SpanPanelSnapshotFactory.create( - pv=SpanPVSnapshot(product_name="SE7600H") + pv=SpanPVSnapshot(model="SE7600H") ) desc = next(d for d in PV_METADATA_SENSORS if d.key == "pv_product") assert desc.value_fn(snapshot) == "SE7600H" diff --git a/tests/test_sensor_platform.py b/tests/test_sensor_platform.py index 1bb17f56..bb68eb1d 100644 --- a/tests/test_sensor_platform.py +++ b/tests/test_sensor_platform.py @@ -317,7 +317,7 @@ def test_create_battery_sensors_returns_expected_entities_when_bess_present() -> """Battery helpers should create battery power, SoE, and metadata sensors.""" snapshot = SpanPanelSnapshotFactory.create( battery=SpanBatterySnapshotFactory.create( - soe_percentage=75.0, vendor_name="Tesla", product_name="Powerwall" + soe_percentage=75.0, vendor_name="Tesla", model="Powerwall" ) ) coordinator = MagicMock() diff --git a/uv.lock b/uv.lock index 195f6f51..367ef9e0 100644 --- a/uv.lock +++ b/uv.lock @@ -20,14 +20,14 @@ wheels = [ [[package]] name = "aiodns" -version = "4.0.0" +version = "4.0.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycares" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/10/da/97235e953109936bfeda62c1f9f1a7c5652d4dc49f2b5911f9ae1043afa9/aiodns-4.0.0.tar.gz", hash = "sha256:17be26a936ba788c849ba5fd20e0ba69d8c46e6273e846eb5430eae2630ce5b1", size = 26204, upload-time = "2026-01-10T22:33:27.211Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/22/a2d928e0e42baad0471d12ec44c71152ac870486e8298dddb2893b888c29/aiodns-4.0.4.tar.gz", hash = "sha256:cb10e0c0d2591636716ad2fe402e977c16d71bdaf76bb8cb49e8a6633596f736", size = 29918, upload-time = "2026-05-20T01:54:15.557Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/60/14ac40c03e8a26216e4f2642497b776e52f9e3214e4fd537628829bbb082/aiodns-4.0.0-py3-none-any.whl", hash = "sha256:a188a75fb8b2b7862ac8f84811a231402fb74f5b4e6f10766dc8a4544b0cf989", size = 11334, upload-time = "2026-01-10T22:33:25.65Z" }, + { url = "https://files.pythonhosted.org/packages/7f/70/72e4ab117425ccdc4d10bd523a94c1baa051a15586057d64a4c6888f9e3f/aiodns-4.0.4-py3-none-any.whl", hash = "sha256:c24dd605bac70a1676ce503f967a98483ff163507198557d8e9db16267e6cfd2", size = 12696, upload-time = "2026-05-20T01:54:14.134Z" }, ] [[package]] @@ -276,35 +276,51 @@ wheels = [ [[package]] name = "attrs" -version = "25.4.0" +version = "26.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] [[package]] name = "audioop-lts" -version = "0.2.1" +version = "0.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dd/3b/69ff8a885e4c1c42014c2765275c4bd91fe7bc9847e9d8543dbcbb09f820/audioop_lts-0.2.1.tar.gz", hash = "sha256:e81268da0baa880431b68b1308ab7257eb33f356e57a5f9b1f915dfb13dd1387", size = 30204, upload-time = "2024-08-04T21:14:43.957Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/01/91/a219253cc6e92db2ebeaf5cf8197f71d995df6f6b16091d1f3ce62cb169d/audioop_lts-0.2.1-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd1345ae99e17e6910f47ce7d52673c6a1a70820d78b67de1b7abb3af29c426a", size = 46252, upload-time = "2024-08-04T21:13:56.209Z" }, - { url = "https://files.pythonhosted.org/packages/ec/f6/3cb21e0accd9e112d27cee3b1477cd04dafe88675c54ad8b0d56226c1e0b/audioop_lts-0.2.1-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:e175350da05d2087e12cea8e72a70a1a8b14a17e92ed2022952a4419689ede5e", size = 27183, upload-time = "2024-08-04T21:13:59.966Z" }, - { url = "https://files.pythonhosted.org/packages/ea/7e/f94c8a6a8b2571694375b4cf94d3e5e0f529e8e6ba280fad4d8c70621f27/audioop_lts-0.2.1-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:4a8dd6a81770f6ecf019c4b6d659e000dc26571b273953cef7cd1d5ce2ff3ae6", size = 26726, upload-time = "2024-08-04T21:14:00.846Z" }, - { url = "https://files.pythonhosted.org/packages/ef/f8/a0e8e7a033b03fae2b16bc5aa48100b461c4f3a8a38af56d5ad579924a3a/audioop_lts-0.2.1-cp313-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cd3c0b6f2ca25c7d2b1c3adeecbe23e65689839ba73331ebc7d893fcda7ffe", size = 80718, upload-time = "2024-08-04T21:14:01.989Z" }, - { url = "https://files.pythonhosted.org/packages/8f/ea/a98ebd4ed631c93b8b8f2368862cd8084d75c77a697248c24437c36a6f7e/audioop_lts-0.2.1-cp313-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff3f97b3372c97782e9c6d3d7fdbe83bce8f70de719605bd7ee1839cd1ab360a", size = 88326, upload-time = "2024-08-04T21:14:03.509Z" }, - { url = "https://files.pythonhosted.org/packages/33/79/e97a9f9daac0982aa92db1199339bd393594d9a4196ad95ae088635a105f/audioop_lts-0.2.1-cp313-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a351af79edefc2a1bd2234bfd8b339935f389209943043913a919df4b0f13300", size = 80539, upload-time = "2024-08-04T21:14:04.679Z" }, - { url = "https://files.pythonhosted.org/packages/b2/d3/1051d80e6f2d6f4773f90c07e73743a1e19fcd31af58ff4e8ef0375d3a80/audioop_lts-0.2.1-cp313-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2aeb6f96f7f6da80354330470b9134d81b4cf544cdd1c549f2f45fe964d28059", size = 78577, upload-time = "2024-08-04T21:14:09.038Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1d/54f4c58bae8dc8c64a75071c7e98e105ddaca35449376fcb0180f6e3c9df/audioop_lts-0.2.1-cp313-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c589f06407e8340e81962575fcffbba1e92671879a221186c3d4662de9fe804e", size = 82074, upload-time = "2024-08-04T21:14:09.99Z" }, - { url = "https://files.pythonhosted.org/packages/36/89/2e78daa7cebbea57e72c0e1927413be4db675548a537cfba6a19040d52fa/audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fbae5d6925d7c26e712f0beda5ed69ebb40e14212c185d129b8dfbfcc335eb48", size = 84210, upload-time = "2024-08-04T21:14:11.468Z" }, - { url = "https://files.pythonhosted.org/packages/a5/57/3ff8a74df2ec2fa6d2ae06ac86e4a27d6412dbb7d0e0d41024222744c7e0/audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_i686.whl", hash = "sha256:d2d5434717f33117f29b5691fbdf142d36573d751716249a288fbb96ba26a281", size = 85664, upload-time = "2024-08-04T21:14:12.394Z" }, - { url = "https://files.pythonhosted.org/packages/16/01/21cc4e5878f6edbc8e54be4c108d7cb9cb6202313cfe98e4ece6064580dd/audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:f626a01c0a186b08f7ff61431c01c055961ee28769591efa8800beadd27a2959", size = 93255, upload-time = "2024-08-04T21:14:13.707Z" }, - { url = "https://files.pythonhosted.org/packages/3e/28/7f7418c362a899ac3b0bf13b1fde2d4ffccfdeb6a859abd26f2d142a1d58/audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_s390x.whl", hash = "sha256:05da64e73837f88ee5c6217d732d2584cf638003ac72df124740460531e95e47", size = 87760, upload-time = "2024-08-04T21:14:14.74Z" }, - { url = "https://files.pythonhosted.org/packages/6d/d8/577a8be87dc7dd2ba568895045cee7d32e81d85a7e44a29000fe02c4d9d4/audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:56b7a0a4dba8e353436f31a932f3045d108a67b5943b30f85a5563f4d8488d77", size = 84992, upload-time = "2024-08-04T21:14:19.155Z" }, - { url = "https://files.pythonhosted.org/packages/ef/9a/4699b0c4fcf89936d2bfb5425f55f1a8b86dff4237cfcc104946c9cd9858/audioop_lts-0.2.1-cp313-abi3-win32.whl", hash = "sha256:6e899eb8874dc2413b11926b5fb3857ec0ab55222840e38016a6ba2ea9b7d5e3", size = 26059, upload-time = "2024-08-04T21:14:20.438Z" }, - { url = "https://files.pythonhosted.org/packages/3a/1c/1f88e9c5dd4785a547ce5fd1eb83fff832c00cc0e15c04c1119b02582d06/audioop_lts-0.2.1-cp313-abi3-win_amd64.whl", hash = "sha256:64562c5c771fb0a8b6262829b9b4f37a7b886c01b4d3ecdbae1d629717db08b4", size = 30412, upload-time = "2024-08-04T21:14:21.342Z" }, - { url = "https://files.pythonhosted.org/packages/c4/e9/c123fd29d89a6402ad261516f848437472ccc602abb59bba522af45e281b/audioop_lts-0.2.1-cp313-abi3-win_arm64.whl", hash = "sha256:c45317debeb64002e980077642afbd977773a25fa3dfd7ed0c84dccfc1fafcb0", size = 23578, upload-time = "2024-08-04T21:14:22.193Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/38/53/946db57842a50b2da2e0c1e34bd37f36f5aadba1a929a3971c5d7841dbca/audioop_lts-0.2.2.tar.gz", hash = "sha256:64d0c62d88e67b98a1a5e71987b7aa7b5bcffc7dcee65b635823dbdd0a8dbbd0", size = 30686, upload-time = "2025-08-05T16:43:17.409Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/d4/94d277ca941de5a507b07f0b592f199c22454eeaec8f008a286b3fbbacd6/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd3d4602dc64914d462924a08c1a9816435a2155d74f325853c1f1ac3b2d9800", size = 46523, upload-time = "2025-08-05T16:42:20.836Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5a/656d1c2da4b555920ce4177167bfeb8623d98765594af59702c8873f60ec/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:550c114a8df0aafe9a05442a1162dfc8fec37e9af1d625ae6060fed6e756f303", size = 27455, upload-time = "2025-08-05T16:42:22.283Z" }, + { url = "https://files.pythonhosted.org/packages/1b/83/ea581e364ce7b0d41456fb79d6ee0ad482beda61faf0cab20cbd4c63a541/audioop_lts-0.2.2-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:9a13dc409f2564de15dd68be65b462ba0dde01b19663720c68c1140c782d1d75", size = 26997, upload-time = "2025-08-05T16:42:23.849Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3b/e8964210b5e216e5041593b7d33e97ee65967f17c282e8510d19c666dab4/audioop_lts-0.2.2-cp313-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:51c916108c56aa6e426ce611946f901badac950ee2ddaf302b7ed35d9958970d", size = 85844, upload-time = "2025-08-05T16:42:25.208Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2e/0a1c52faf10d51def20531a59ce4c706cb7952323b11709e10de324d6493/audioop_lts-0.2.2-cp313-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47eba38322370347b1c47024defbd36374a211e8dd5b0dcbce7b34fdb6f8847b", size = 85056, upload-time = "2025-08-05T16:42:26.559Z" }, + { url = "https://files.pythonhosted.org/packages/75/e8/cd95eef479656cb75ab05dfece8c1f8c395d17a7c651d88f8e6e291a63ab/audioop_lts-0.2.2-cp313-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba7c3a7e5f23e215cb271516197030c32aef2e754252c4c70a50aaff7031a2c8", size = 93892, upload-time = "2025-08-05T16:42:27.902Z" }, + { url = "https://files.pythonhosted.org/packages/5c/1e/a0c42570b74f83efa5cca34905b3eef03f7ab09fe5637015df538a7f3345/audioop_lts-0.2.2-cp313-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def246fe9e180626731b26e89816e79aae2276f825420a07b4a647abaa84becc", size = 96660, upload-time = "2025-08-05T16:42:28.9Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/8a0ae607ca07dbb34027bac8db805498ee7bfecc05fd2c148cc1ed7646e7/audioop_lts-0.2.2-cp313-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e160bf9df356d841bb6c180eeeea1834085464626dc1b68fa4e1d59070affdc3", size = 79143, upload-time = "2025-08-05T16:42:29.929Z" }, + { url = "https://files.pythonhosted.org/packages/12/17/0d28c46179e7910bfb0bb62760ccb33edb5de973052cb2230b662c14ca2e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4b4cd51a57b698b2d06cb9993b7ac8dfe89a3b2878e96bc7948e9f19ff51dba6", size = 84313, upload-time = "2025-08-05T16:42:30.949Z" }, + { url = "https://files.pythonhosted.org/packages/84/ba/bd5d3806641564f2024e97ca98ea8f8811d4e01d9b9f9831474bc9e14f9e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4a53aa7c16a60a6857e6b0b165261436396ef7293f8b5c9c828a3a203147ed4a", size = 93044, upload-time = "2025-08-05T16:42:31.959Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5e/435ce8d5642f1f7679540d1e73c1c42d933331c0976eb397d1717d7f01a3/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:3fc38008969796f0f689f1453722a0f463da1b8a6fbee11987830bfbb664f623", size = 78766, upload-time = "2025-08-05T16:42:33.302Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/b909e76b606cbfd53875693ec8c156e93e15a1366a012f0b7e4fb52d3c34/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_s390x.whl", hash = "sha256:15ab25dd3e620790f40e9ead897f91e79c0d3ce65fe193c8ed6c26cffdd24be7", size = 87640, upload-time = "2025-08-05T16:42:34.854Z" }, + { url = "https://files.pythonhosted.org/packages/30/e7/8f1603b4572d79b775f2140d7952f200f5e6c62904585d08a01f0a70393a/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:03f061a1915538fd96272bac9551841859dbb2e3bf73ebe4a23ef043766f5449", size = 86052, upload-time = "2025-08-05T16:42:35.839Z" }, + { url = "https://files.pythonhosted.org/packages/b5/96/c37846df657ccdda62ba1ae2b6534fa90e2e1b1742ca8dcf8ebd38c53801/audioop_lts-0.2.2-cp313-abi3-win32.whl", hash = "sha256:3bcddaaf6cc5935a300a8387c99f7a7fbbe212a11568ec6cf6e4bc458c048636", size = 26185, upload-time = "2025-08-05T16:42:37.04Z" }, + { url = "https://files.pythonhosted.org/packages/34/a5/9d78fdb5b844a83da8a71226c7bdae7cc638861085fff7a1d707cb4823fa/audioop_lts-0.2.2-cp313-abi3-win_amd64.whl", hash = "sha256:a2c2a947fae7d1062ef08c4e369e0ba2086049a5e598fda41122535557012e9e", size = 30503, upload-time = "2025-08-05T16:42:38.427Z" }, + { url = "https://files.pythonhosted.org/packages/34/25/20d8fde083123e90c61b51afb547bb0ea7e77bab50d98c0ab243d02a0e43/audioop_lts-0.2.2-cp313-abi3-win_arm64.whl", hash = "sha256:5f93a5db13927a37d2d09637ccca4b2b6b48c19cd9eda7b17a2e9f77edee6a6f", size = 24173, upload-time = "2025-08-05T16:42:39.704Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/413b5a2804091e2c7d5def1d618e4837f1cb82464e230f827226278556b7/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f9ee9b52f5f857fbaf9d605a360884f034c92c1c23021fb90b2e39b8e64bede6", size = 47104, upload-time = "2025-08-05T16:42:58.518Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/daa3308dc6593944410c2c68306a5e217f5c05b70a12e70228e7dd42dc5c/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:49ee1a41738a23e98d98b937a0638357a2477bc99e61b0f768a8f654f45d9b7a", size = 27754, upload-time = "2025-08-05T16:43:00.132Z" }, + { url = "https://files.pythonhosted.org/packages/4e/86/c2e0f627168fcf61781a8f72cab06b228fe1da4b9fa4ab39cfb791b5836b/audioop_lts-0.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b00be98ccd0fc123dcfad31d50030d25fcf31488cde9e61692029cd7394733b", size = 27332, upload-time = "2025-08-05T16:43:01.666Z" }, + { url = "https://files.pythonhosted.org/packages/c7/bd/35dce665255434f54e5307de39e31912a6f902d4572da7c37582809de14f/audioop_lts-0.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6d2e0f9f7a69403e388894d4ca5ada5c47230716a03f2847cfc7bd1ecb589d6", size = 92396, upload-time = "2025-08-05T16:43:02.991Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d2/deeb9f51def1437b3afa35aeb729d577c04bcd89394cb56f9239a9f50b6f/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9b0b8a03ef474f56d1a842af1a2e01398b8f7654009823c6d9e0ecff4d5cfbf", size = 91811, upload-time = "2025-08-05T16:43:04.096Z" }, + { url = "https://files.pythonhosted.org/packages/76/3b/09f8b35b227cee28cc8231e296a82759ed80c1a08e349811d69773c48426/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b267b70747d82125f1a021506565bdc5609a2b24bcb4773c16d79d2bb260bbd", size = 100483, upload-time = "2025-08-05T16:43:05.085Z" }, + { url = "https://files.pythonhosted.org/packages/0b/15/05b48a935cf3b130c248bfdbdea71ce6437f5394ee8533e0edd7cfd93d5e/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0337d658f9b81f4cd0fdb1f47635070cc084871a3d4646d9de74fdf4e7c3d24a", size = 103885, upload-time = "2025-08-05T16:43:06.197Z" }, + { url = "https://files.pythonhosted.org/packages/83/80/186b7fce6d35b68d3d739f228dc31d60b3412105854edb975aa155a58339/audioop_lts-0.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:167d3b62586faef8b6b2275c3218796b12621a60e43f7e9d5845d627b9c9b80e", size = 84899, upload-time = "2025-08-05T16:43:07.291Z" }, + { url = "https://files.pythonhosted.org/packages/49/89/c78cc5ac6cb5828f17514fb12966e299c850bc885e80f8ad94e38d450886/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0d9385e96f9f6da847f4d571ce3cb15b5091140edf3db97276872647ce37efd7", size = 89998, upload-time = "2025-08-05T16:43:08.335Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/6401888d0c010e586c2ca50fce4c903d70a6bb55928b16cfbdfd957a13da/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:48159d96962674eccdca9a3df280e864e8ac75e40a577cc97c5c42667ffabfc5", size = 99046, upload-time = "2025-08-05T16:43:09.367Z" }, + { url = "https://files.pythonhosted.org/packages/de/f8/c874ca9bb447dae0e2ef2e231f6c4c2b0c39e31ae684d2420b0f9e97ee68/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fefe5868cd082db1186f2837d64cfbfa78b548ea0d0543e9b28935ccce81ce9", size = 84843, upload-time = "2025-08-05T16:43:10.749Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/0323e66f3daebc13fd46b36b30c3be47e3fc4257eae44f1e77eb828c703f/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:58cf54380c3884fb49fdd37dfb7a772632b6701d28edd3e2904743c5e1773602", size = 94490, upload-time = "2025-08-05T16:43:12.131Z" }, + { url = "https://files.pythonhosted.org/packages/98/6b/acc7734ac02d95ab791c10c3f17ffa3584ccb9ac5c18fd771c638ed6d1f5/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:088327f00488cdeed296edd9215ca159f3a5a5034741465789cad403fcf4bec0", size = 92297, upload-time = "2025-08-05T16:43:13.139Z" }, + { url = "https://files.pythonhosted.org/packages/13/c3/c3dc3f564ce6877ecd2a05f8d751b9b27a8c320c2533a98b0c86349778d0/audioop_lts-0.2.2-cp314-cp314t-win32.whl", hash = "sha256:068aa17a38b4e0e7de771c62c60bbca2455924b67a8814f3b0dee92b5820c0b3", size = 27331, upload-time = "2025-08-05T16:43:14.19Z" }, + { url = "https://files.pythonhosted.org/packages/72/bb/b4608537e9ffcb86449091939d52d24a055216a36a8bf66b936af8c3e7ac/audioop_lts-0.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a5bf613e96f49712073de86f20dbdd4014ca18efd4d34ed18c75bd808337851b", size = 31697, upload-time = "2025-08-05T16:43:15.193Z" }, + { url = "https://files.pythonhosted.org/packages/f6/22/91616fe707a5c5510de2cac9b046a30defe7007ba8a0c04f9c08f27df312/audioop_lts-0.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:b492c3b040153e68b9fdaff5913305aaaba5bb433d8a7f73d5cf6a64ed3cc1dd", size = 25206, upload-time = "2025-08-05T16:43:16.444Z" }, ] [[package]] @@ -647,33 +663,41 @@ wheels = [ [[package]] name = "coverage" -version = "7.10.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/14/70/025b179c993f019105b79575ac6edb5e084fb0f0e63f15cdebef4e454fb5/coverage-7.10.6.tar.gz", hash = "sha256:f644a3ae5933a552a29dbb9aa2f90c677a875f80ebea028e5a52a4f429044b90", size = 823736, upload-time = "2025-08-29T15:35:16.668Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/aa/76cf0b5ec00619ef208da4689281d48b57f2c7fde883d14bf9441b74d59f/coverage-7.10.6-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6008a021907be8c4c02f37cdc3ffb258493bdebfeaf9a839f9e71dfdc47b018e", size = 217331, upload-time = "2025-08-29T15:34:20.846Z" }, - { url = "https://files.pythonhosted.org/packages/65/91/8e41b8c7c505d398d7730206f3cbb4a875a35ca1041efc518051bfce0f6b/coverage-7.10.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5e75e37f23eb144e78940b40395b42f2321951206a4f50e23cfd6e8a198d3ceb", size = 217607, upload-time = "2025-08-29T15:34:22.433Z" }, - { url = "https://files.pythonhosted.org/packages/87/7f/f718e732a423d442e6616580a951b8d1ec3575ea48bcd0e2228386805e79/coverage-7.10.6-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0f7cb359a448e043c576f0da00aa8bfd796a01b06aa610ca453d4dde09cc1034", size = 248663, upload-time = "2025-08-29T15:34:24.425Z" }, - { url = "https://files.pythonhosted.org/packages/e6/52/c1106120e6d801ac03e12b5285e971e758e925b6f82ee9b86db3aa10045d/coverage-7.10.6-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c68018e4fc4e14b5668f1353b41ccf4bc83ba355f0e1b3836861c6f042d89ac1", size = 251197, upload-time = "2025-08-29T15:34:25.906Z" }, - { url = "https://files.pythonhosted.org/packages/3d/ec/3a8645b1bb40e36acde9c0609f08942852a4af91a937fe2c129a38f2d3f5/coverage-7.10.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd4b2b0707fc55afa160cd5fc33b27ccbf75ca11d81f4ec9863d5793fc6df56a", size = 252551, upload-time = "2025-08-29T15:34:27.337Z" }, - { url = "https://files.pythonhosted.org/packages/a1/70/09ecb68eeb1155b28a1d16525fd3a9b65fbe75337311a99830df935d62b6/coverage-7.10.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cec13817a651f8804a86e4f79d815b3b28472c910e099e4d5a0e8a3b6a1d4cb", size = 250553, upload-time = "2025-08-29T15:34:29.065Z" }, - { url = "https://files.pythonhosted.org/packages/c6/80/47df374b893fa812e953b5bc93dcb1427a7b3d7a1a7d2db33043d17f74b9/coverage-7.10.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f2a6a8e06bbda06f78739f40bfb56c45d14eb8249d0f0ea6d4b3d48e1f7c695d", size = 248486, upload-time = "2025-08-29T15:34:30.897Z" }, - { url = "https://files.pythonhosted.org/packages/4a/65/9f98640979ecee1b0d1a7164b589de720ddf8100d1747d9bbdb84be0c0fb/coverage-7.10.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:081b98395ced0d9bcf60ada7661a0b75f36b78b9d7e39ea0790bb4ed8da14747", size = 249981, upload-time = "2025-08-29T15:34:32.365Z" }, - { url = "https://files.pythonhosted.org/packages/1f/55/eeb6603371e6629037f47bd25bef300387257ed53a3c5fdb159b7ac8c651/coverage-7.10.6-cp314-cp314-win32.whl", hash = "sha256:6937347c5d7d069ee776b2bf4e1212f912a9f1f141a429c475e6089462fcecc5", size = 220054, upload-time = "2025-08-29T15:34:34.124Z" }, - { url = "https://files.pythonhosted.org/packages/15/d1/a0912b7611bc35412e919a2cd59ae98e7ea3b475e562668040a43fb27897/coverage-7.10.6-cp314-cp314-win_amd64.whl", hash = "sha256:adec1d980fa07e60b6ef865f9e5410ba760e4e1d26f60f7e5772c73b9a5b0713", size = 220851, upload-time = "2025-08-29T15:34:35.651Z" }, - { url = "https://files.pythonhosted.org/packages/ef/2d/11880bb8ef80a45338e0b3e0725e4c2d73ffbb4822c29d987078224fd6a5/coverage-7.10.6-cp314-cp314-win_arm64.whl", hash = "sha256:a80f7aef9535442bdcf562e5a0d5a5538ce8abe6bb209cfbf170c462ac2c2a32", size = 219429, upload-time = "2025-08-29T15:34:37.16Z" }, - { url = "https://files.pythonhosted.org/packages/83/c0/1f00caad775c03a700146f55536ecd097a881ff08d310a58b353a1421be0/coverage-7.10.6-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0de434f4fbbe5af4fa7989521c655c8c779afb61c53ab561b64dcee6149e4c65", size = 218080, upload-time = "2025-08-29T15:34:38.919Z" }, - { url = "https://files.pythonhosted.org/packages/a9/c4/b1c5d2bd7cc412cbeb035e257fd06ed4e3e139ac871d16a07434e145d18d/coverage-7.10.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6e31b8155150c57e5ac43ccd289d079eb3f825187d7c66e755a055d2c85794c6", size = 218293, upload-time = "2025-08-29T15:34:40.425Z" }, - { url = "https://files.pythonhosted.org/packages/3f/07/4468d37c94724bf6ec354e4ec2f205fda194343e3e85fd2e59cec57e6a54/coverage-7.10.6-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:98cede73eb83c31e2118ae8d379c12e3e42736903a8afcca92a7218e1f2903b0", size = 259800, upload-time = "2025-08-29T15:34:41.996Z" }, - { url = "https://files.pythonhosted.org/packages/82/d8/f8fb351be5fee31690cd8da768fd62f1cfab33c31d9f7baba6cd8960f6b8/coverage-7.10.6-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f863c08f4ff6b64fa8045b1e3da480f5374779ef187f07b82e0538c68cb4ff8e", size = 261965, upload-time = "2025-08-29T15:34:43.61Z" }, - { url = "https://files.pythonhosted.org/packages/e8/70/65d4d7cfc75c5c6eb2fed3ee5cdf420fd8ae09c4808723a89a81d5b1b9c3/coverage-7.10.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b38261034fda87be356f2c3f42221fdb4171c3ce7658066ae449241485390d5", size = 264220, upload-time = "2025-08-29T15:34:45.387Z" }, - { url = "https://files.pythonhosted.org/packages/98/3c/069df106d19024324cde10e4ec379fe2fb978017d25e97ebee23002fbadf/coverage-7.10.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e93b1476b79eae849dc3872faeb0bf7948fd9ea34869590bc16a2a00b9c82a7", size = 261660, upload-time = "2025-08-29T15:34:47.288Z" }, - { url = "https://files.pythonhosted.org/packages/fc/8a/2974d53904080c5dc91af798b3a54a4ccb99a45595cc0dcec6eb9616a57d/coverage-7.10.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ff8a991f70f4c0cf53088abf1e3886edcc87d53004c7bb94e78650b4d3dac3b5", size = 259417, upload-time = "2025-08-29T15:34:48.779Z" }, - { url = "https://files.pythonhosted.org/packages/30/38/9616a6b49c686394b318974d7f6e08f38b8af2270ce7488e879888d1e5db/coverage-7.10.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ac765b026c9f33044419cbba1da913cfb82cca1b60598ac1c7a5ed6aac4621a0", size = 260567, upload-time = "2025-08-29T15:34:50.718Z" }, - { url = "https://files.pythonhosted.org/packages/76/16/3ed2d6312b371a8cf804abf4e14895b70e4c3491c6e53536d63fd0958a8d/coverage-7.10.6-cp314-cp314t-win32.whl", hash = "sha256:441c357d55f4936875636ef2cfb3bee36e466dcf50df9afbd398ce79dba1ebb7", size = 220831, upload-time = "2025-08-29T15:34:52.653Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e5/d38d0cb830abede2adb8b147770d2a3d0e7fecc7228245b9b1ae6c24930a/coverage-7.10.6-cp314-cp314t-win_amd64.whl", hash = "sha256:073711de3181b2e204e4870ac83a7c4853115b42e9cd4d145f2231e12d670930", size = 221950, upload-time = "2025-08-29T15:34:54.212Z" }, - { url = "https://files.pythonhosted.org/packages/f4/51/e48e550f6279349895b0ffcd6d2a690e3131ba3a7f4eafccc141966d4dea/coverage-7.10.6-cp314-cp314t-win_arm64.whl", hash = "sha256:137921f2bac5559334ba66122b753db6dc5d1cf01eb7b64eb412bb0d064ef35b", size = 219969, upload-time = "2025-08-29T15:34:55.83Z" }, - { url = "https://files.pythonhosted.org/packages/44/0c/50db5379b615854b5cf89146f8f5bd1d5a9693d7f3a987e269693521c404/coverage-7.10.6-py3-none-any.whl", hash = "sha256:92c4ecf6bf11b2e85fd4d8204814dc26e6a19f0c9d938c207c5cb0eadfcabbe3", size = 208986, upload-time = "2025-08-29T15:35:14.506Z" }, +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, ] [[package]] @@ -686,55 +710,55 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.7" +version = "47.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, - { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, - { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, - { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, - { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, - { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, - { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, - { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, - { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, - { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, - { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, - { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, - { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, - { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, - { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, - { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, - { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, - { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, - { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, - { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, - { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, - { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, - { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, - { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, - { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, - { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, - { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, - { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, - { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, - { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, - { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, - { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, - { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, - { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, - { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, - { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, - { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/98/40dfe932134bdcae4f6ab5927c87488754bf9eb79297d7e0070b78dd58e9/cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", size = 7912214, upload-time = "2026-04-24T19:53:03.864Z" }, + { url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" }, + { url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" }, + { url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" }, + { url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" }, + { url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" }, + { url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" }, + { url = "https://files.pythonhosted.org/packages/54/ed/5f524db1fade9c013aa618e1c99c6ed05e8ffc9ceee6cda22fed22dda3f4/cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", size = 3258581, upload-time = "2026-04-24T19:53:31.058Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dc/1b901990b174786569029f67542b3edf72ac068b6c3c8683c17e6a2f5363/cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", size = 3775309, upload-time = "2026-04-24T19:53:33.054Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/7aa18ad9c11bc87689affa5ce4368d884b517502d75739d475fc6f4a03c7/cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0", size = 7904299, upload-time = "2026-04-24T19:53:35.003Z" }, + { url = "https://files.pythonhosted.org/packages/07/55/c18f75724544872f234678fdedc871391722cb34a2aee19faa9f63100bb2/cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", size = 4631180, upload-time = "2026-04-24T19:53:37.517Z" }, + { url = "https://files.pythonhosted.org/packages/ee/65/31a5cc0eaca99cec5bafffe155d407115d96136bb161e8b49e0ef73f09a7/cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", size = 4653529, upload-time = "2026-04-24T19:53:39.775Z" }, + { url = "https://files.pythonhosted.org/packages/e5/bc/641c0519a495f3bfd0421b48d7cd325c4336578523ccd76ea322b6c29c7a/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", size = 4638570, upload-time = "2026-04-24T19:53:42.129Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f2/300327b0a47f6dc94dd8b71b57052aefe178bb51745073d73d80604f11ab/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", size = 5238019, upload-time = "2026-04-24T19:53:44.577Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/5b5cf994391d4bf9d9c7efd4c66aabe4d95227256627f8fea6cff7dfadbd/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", size = 4686832, upload-time = "2026-04-24T19:53:47.015Z" }, + { url = "https://files.pythonhosted.org/packages/dc/2c/ae950e28fd6475c852fc21a44db3e6b5bcc1261d1e370f2b6e42fa800fef/cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", size = 4269301, upload-time = "2026-04-24T19:53:48.97Z" }, + { url = "https://files.pythonhosted.org/packages/67/fb/6a39782e150ffe5cc1b0018cb6ddc48bf7ca62b498d7539ffc8a758e977d/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", size = 4638110, upload-time = "2026-04-24T19:53:51.011Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d7/0b3c71090a76e5c203164a47688b697635ece006dcd2499ab3a4dbd3f0bd/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736", size = 5194988, upload-time = "2026-04-24T19:53:52.962Z" }, + { url = "https://files.pythonhosted.org/packages/63/33/63a961498a9df51721ab578c5a2622661411fc520e00bd83b0cc64eb20c4/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", size = 4686563, upload-time = "2026-04-24T19:53:55.274Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/5ee5b145248f92250de86145d1c1d6edebbd57a7fe7caa4dedb5d4cf06a1/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", size = 4770094, upload-time = "2026-04-24T19:53:57.753Z" }, + { url = "https://files.pythonhosted.org/packages/92/43/21d220b2da5d517773894dacdcdb5c682c28d3fffce65548cb06e87d5501/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", size = 4913811, upload-time = "2026-04-24T19:54:00.236Z" }, + { url = "https://files.pythonhosted.org/packages/31/98/dc4ad376ac5f1a1a7d4a83f7b0c6f2bcad36b5d2d8f30aeb482d3a7d9582/cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63", size = 3237158, upload-time = "2026-04-24T19:54:02.606Z" }, + { url = "https://files.pythonhosted.org/packages/bc/da/97f62d18306b5133468bc3f8cc73a3111e8cdc8cf8d3e69474d6e5fd2d1b/cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b", size = 3758706, upload-time = "2026-04-24T19:54:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e0/34/a4fae8ae7c3bc227460c9ae43f56abf1b911da0ec29e0ebac53bb0a4b6b7/cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", size = 7904072, upload-time = "2026-04-24T19:54:06.411Z" }, + { url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" }, + { url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" }, + { url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" }, + { url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" }, + { url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" }, + { url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" }, + { url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" }, + { url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" }, + { url = "https://files.pythonhosted.org/packages/06/bd/0a9d3edbf5eadbac926d7b9b3cd0c4be584eeeae4a003d24d9eda4affbbd/cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", size = 3248487, upload-time = "2026-04-24T19:54:33.494Z" }, + { url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" }, ] [[package]] @@ -783,14 +807,28 @@ wheels = [ [[package]] name = "fnv-hash-fast" -version = "2.0.0" +version = "2.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fnvhash" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8d/d3/a7af16d8e1d43d3c1d1cbde7901229773b62696e377f89c6834183680ede/fnv_hash_fast-2.0.0.tar.gz", hash = "sha256:e830b6316be36b2aa629f8a2c6b83833f1f3cc8ecf1da93c7e9e934844826646", size = 5718, upload-time = "2026-03-15T00:17:39.143Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/52/68d217fe739dea6712719effa8e1287a270d924e22d0ad31886cf1af04b1/fnv_hash_fast-2.0.2.tar.gz", hash = "sha256:8d5f6ac760a7ca091ef9d085b22b12c76caa8fa2897de81cee58f50269c0eb65", size = 5720, upload-time = "2026-03-15T09:33:58.352Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/6e/03f61d06f3ed61b10d0a07ac921d077045052db6fdd6847107e4c3405727/fnv_hash_fast-2.0.0-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:ddf25f8f8b5cf54f068eaf5e3700c96f6c8da679e6efaf2084e57bdbad9d92ee", size = 7756, upload-time = "2026-03-15T00:17:37.392Z" }, + { url = "https://files.pythonhosted.org/packages/4f/3f/d7a250972ca3528b10cf83b09ffe93bc057f4a6f100c62a222586fc6bf7c/fnv_hash_fast-2.0.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e7e046e021bf3ade896999f1e4308611102c98a9e930729dae80008bfe096ad0", size = 7464, upload-time = "2026-03-15T09:42:16.696Z" }, + { url = "https://files.pythonhosted.org/packages/d0/48/c5f2fc199efce58fb512902f91ad549ca25a96b89a444c1dfad0e602816e/fnv_hash_fast-2.0.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14ff86c79ec7f44fcb15488d44362e1f576a363c9c8db5b97bd52eb7017eebf6", size = 8379, upload-time = "2026-03-15T09:42:17.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/23/9f95735e80411024200ce0f4a638b818fe42d0701448a6244f74ccecc2ff/fnv_hash_fast-2.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97ef72953a74e9bc918ccb7e7d6229e533054226fdd4ee933997e8dcd11f6108", size = 9216, upload-time = "2026-03-15T09:42:18.721Z" }, + { url = "https://files.pythonhosted.org/packages/36/0f/b9ae42639b7529438d8a329fb25a17c3f05905fbdd5829cfa5e6a7f550d9/fnv_hash_fast-2.0.2-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:f2c938907c2140b86e778e571ebefcc15377996c0e1c85d8a60ef6cdc5b0fd9c", size = 7755, upload-time = "2026-03-15T09:33:57.092Z" }, + { url = "https://files.pythonhosted.org/packages/1d/6c/31db3cd922c34cbcc47265e15ae110bee131d61b9e6857c370c0a3314935/fnv_hash_fast-2.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:faf4d478f2b68afacfcf12b2737c834a63cc0d48f0c8ff3a5b39487de78bc312", size = 9125, upload-time = "2026-03-15T09:42:19.589Z" }, + { url = "https://files.pythonhosted.org/packages/5f/88/4bac5c92f2bf8f3c9f06b1fd9d3c1becc23a34c2c5c2e3e67eb264b10b58/fnv_hash_fast-2.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ad9c8008158e8b4acd19e9d61805bff58b6bc86fd459f030053a0e4aa6a2c1d", size = 8618, upload-time = "2026-03-15T09:42:20.782Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/71fe6bbd8907dd07aad205e40dbe8d0c5927d949c765c438ae31b550773b/fnv_hash_fast-2.0.2-cp314-cp314-win32.whl", hash = "sha256:fecd6c9026ff8177a813daf680ef7849ec3463ece8456c15809fd1a8c5aa72f0", size = 10371, upload-time = "2026-03-15T09:42:21.716Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d9/afe630f1441e8bad36d0d0e8e5605ee27b1547bc0f8fa64e588702268024/fnv_hash_fast-2.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:7cdf55ed4a002e99f0c14ef009114e85a73221bb6267f589e43a36b6422908df", size = 10766, upload-time = "2026-03-15T09:42:22.574Z" }, + { url = "https://files.pythonhosted.org/packages/d4/7c/1dad8f50f07b255fdee9c9af64b90c06d9c32c112469ee85beb956a77954/fnv_hash_fast-2.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:83273853e040343e018f82fd9fcfa5a2fd18ec840f8abdd29240702fad5904bc", size = 9095, upload-time = "2026-03-15T09:42:23.79Z" }, + { url = "https://files.pythonhosted.org/packages/80/0e/472e5f55beca0b3498a6a58b4b9ca367b97fac94ca88af0808cdbc3d9b23/fnv_hash_fast-2.0.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:415f66d4e5d47f6d018e15d95851c700bef4dcb26f85d8a1238d0150ea62a763", size = 10909, upload-time = "2026-03-15T09:42:24.728Z" }, + { url = "https://files.pythonhosted.org/packages/fa/77/fd93ba63b953a8c8b8116966e513adab789303ca0e172d926fde05980c24/fnv_hash_fast-2.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:270c64fc8f02179f7aff5f921c08115357bc038b5057da237e5b290c38de2a13", size = 12586, upload-time = "2026-03-15T09:42:25.702Z" }, + { url = "https://files.pythonhosted.org/packages/f6/67/413d3ac61194702c4b20785831c27e6968824183fe168e343579b4b92e0b/fnv_hash_fast-2.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c421c4519694c9f54d4110e7ffc31e6cc4def2c09e1d6009bfbc14d980a978b4", size = 12414, upload-time = "2026-03-15T09:42:26.594Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7f/84e5652a554b61b77d30799e23d445cfc70673d60dd1de86b86b11056b4e/fnv_hash_fast-2.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:620095dd5ca6d89f4394280b71a9d6f29c0ffbba85612ef5ae96e43eb27da707", size = 11390, upload-time = "2026-03-15T09:42:27.798Z" }, + { url = "https://files.pythonhosted.org/packages/00/04/570904efb0586f94c66b4592047d2867ad7eb668c4d0709e97320004db05/fnv_hash_fast-2.0.2-cp314-cp314t-win32.whl", hash = "sha256:2c3d4d3a3d6d06ece900663806b3a3ff4649e3ac7fb5e258634e6ccca50a139c", size = 15286, upload-time = "2026-03-15T09:42:28.992Z" }, + { url = "https://files.pythonhosted.org/packages/e1/17/1f64a2cea0775c4ca606eaa3e6e9e878f6329b5039d3e9d0e8573758cca6/fnv_hash_fast-2.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:20f621be9b22763feebd2d9a898a8256220a4aca654092b41372b7036e1f6d6f", size = 16062, upload-time = "2026-03-15T09:42:29.91Z" }, ] [[package]] @@ -804,14 +842,14 @@ wheels = [ [[package]] name = "freezegun" -version = "1.5.2" +version = "1.5.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "python-dateutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/75/0455fa5029507a2150da59db4f165fbc458ff8bb1c4f4d7e8037a14ad421/freezegun-1.5.2.tar.gz", hash = "sha256:a54ae1d2f9c02dbf42e02c18a3ab95ab4295818b549a34dac55592d72a905181", size = 34855, upload-time = "2025-05-24T12:38:47.051Z" } +sdist = { url = "https://files.pythonhosted.org/packages/95/dd/23e2f4e357f8fd3bdff613c1fe4466d21bfb00a6177f238079b17f7b1c84/freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a", size = 35914, upload-time = "2025-08-09T10:39:08.338Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/b2/68d4c9b6431121b6b6aa5e04a153cac41dcacc79600ed6e2e7c3382156f5/freezegun-1.5.2-py3-none-any.whl", hash = "sha256:5aaf3ba229cda57afab5bd311f0108d86b6fb119ae89d2cd9c43ec8c1733c85b", size = 18715, upload-time = "2025-05-24T12:38:45.274Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" }, ] [[package]] @@ -855,6 +893,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] +[[package]] +name = "greenlet" +version = "3.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/d8/7cc97c142388aef03f622e001c572c4f84e9252a439549d483f555771970/greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c", size = 207585, upload-time = "2026-08-10T15:09:36.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/8c/080e881fa2be95ff1ddbd6994b2bab3b1a78df3b3fcab39306011764fcc7/greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d", size = 295309, upload-time = "2026-08-10T13:26:03.032Z" }, + { url = "https://files.pythonhosted.org/packages/25/cc/0ac614e6586c0e42d4cc281a5819150f4f43685744a4c5ff77139286409d/greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328", size = 661185, upload-time = "2026-08-10T14:14:37.867Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b9/6808725354be8ad305dfe5172377664fc9642d4fc043be246b3314cf4482/greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926", size = 673419, upload-time = "2026-08-10T14:27:28.652Z" }, + { url = "https://files.pythonhosted.org/packages/42/2e/40c509967da7f254680826a2fa0dd22138ec79946c70b97542d74cde8b43/greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e", size = 670822, upload-time = "2026-08-10T13:40:51.833Z" }, + { url = "https://files.pythonhosted.org/packages/2d/22/c3c2eee4a8fe191d6d1d183086c56133d646024e3d70bfd414829f64560b/greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc", size = 1628469, upload-time = "2026-08-10T14:15:08.11Z" }, + { url = "https://files.pythonhosted.org/packages/f7/87/25babd09b94cb1f03e71db815fde463f0262e40cfbd953d58a8d77311351/greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9", size = 1691952, upload-time = "2026-08-10T13:40:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/5cc9701117ea4dc0eb7bf1f4f9b7888a6e2e5277ddfae095805ace50f2b6/greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1", size = 327458, upload-time = "2026-08-10T13:27:02.868Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6b/594fa2de7fae7629168a404a4305d7d7e31a5742c50a801b1839543cb93d/greenlet-3.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07", size = 311146, upload-time = "2026-08-10T13:27:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/24/e0/50cd600b469e5734c72709b6b1838b6bc63f307b573c772c3132d6ecfe92/greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277", size = 305471, upload-time = "2026-08-10T13:26:20.568Z" }, + { url = "https://files.pythonhosted.org/packages/75/a3/77acd66dfc6387b5219b2080806c0cabb73c10eb1bb44b413c40a62015ba/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b", size = 672470, upload-time = "2026-08-10T14:14:39.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/71/0d178142dca3ec19f46fb2212ae73d30ad53b9d548dc64804086033a7089/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272", size = 679973, upload-time = "2026-08-10T14:27:30.072Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/46eb8567302eaf787abf88d09df014e14ae3baf460af1b8b0efdbd3efcd5/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476", size = 676634, upload-time = "2026-08-10T13:40:53.004Z" }, + { url = "https://files.pythonhosted.org/packages/a3/e9/b88bbf5b29970cb84172dc2c32aa3e5e579ceb94c808e81c826454138850/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874", size = 1637320, upload-time = "2026-08-10T14:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/6d/8c/7631ed29cc6f0392f11830076e172ce4885e70b0bc2c1bce1731176d4b4e/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71", size = 1697412, upload-time = "2026-08-10T13:40:34.924Z" }, + { url = "https://files.pythonhosted.org/packages/da/0f/f7dd935f9c4cb1be49098770587f54d8a78518e55c89bce86c4fb4109057/greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0", size = 331514, upload-time = "2026-08-10T13:29:20.611Z" }, +] + [[package]] name = "grpcio" version = "1.78.0" @@ -951,19 +1012,19 @@ wheels = [ [[package]] name = "home-assistant-bluetooth" -version = "1.13.1" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "habluetooth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b4/0e/c05ee603cab1adb847a305bc8f1034cbdbc0a5d15169fcf68c0d6d21e33f/home_assistant_bluetooth-1.13.1.tar.gz", hash = "sha256:0ae0e2a8491cc762ee9e694b8bc7665f1e2b4618926f63969a23a2e3a48ce55e", size = 7607, upload-time = "2025-02-04T16:11:15.259Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/6f/914fd8086f502748907751766e26e3f7fe5204347a679703ee9954e55e4d/home_assistant_bluetooth-2.0.0.tar.gz", hash = "sha256:3febd16b194812b156024d9bc8ce8ec7622cdd8d159047cac504cc77ca034ea1", size = 7604, upload-time = "2025-07-09T18:01:47.541Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/85/9b/9904cec885cc32c45e8c22cd7e19d9c342e30074fdb7c58f3d5b33ea1adb/home_assistant_bluetooth-1.13.1-py3-none-any.whl", hash = "sha256:cdf13b5b45f7744165677831e309ee78fbaf0c2866c6b5931e14d1e4e7dae5d7", size = 7915, upload-time = "2025-02-04T16:11:13.163Z" }, + { url = "https://files.pythonhosted.org/packages/15/3e/2ed9e59da8fc26c43cbd9d6abbd27217138a31c608b1d334cc5e680e4069/home_assistant_bluetooth-2.0.0-py3-none-any.whl", hash = "sha256:8d7c2ac48c303adacad73cb6cdfe7710f2fccd2a367d4c3106acf301b0d22b91", size = 7907, upload-time = "2025-07-09T18:01:46.506Z" }, ] [[package]] name = "homeassistant" -version = "2026.4.4" +version = "2026.5.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiodns" }, @@ -1017,21 +1078,21 @@ dependencies = [ { name = "yarl" }, { name = "zeroconf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/3d/041a66485642537286c4b1a3ee66ace7ce43cfb61737e224cf01e6b59c97/homeassistant-2026.4.4.tar.gz", hash = "sha256:10f997fb7c00b2f8abbe30f343469428665b68d5a1e665feee2b827d9d815212", size = 32333699, upload-time = "2026-04-24T18:58:32.073Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/ef/00fe5ef1f40daec592f8a28931eb6defa849d6cc3564a00f1d11b6f9d1ba/homeassistant-2026.5.4.tar.gz", hash = "sha256:a394baae7393ab33cb669301c609a9f48a662e51072ef1d31faf2a97612455b2", size = 33170704, upload-time = "2026-05-22T17:53:31.337Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/cb/120eb528c6eb5a21bc726aff1bc47898e5353a677ec18d33d65b11b8f3f8/homeassistant-2026.4.4-py3-none-any.whl", hash = "sha256:cd83240d320e9842822810b4c4aa13a132dbf7954d10576f406e66fca645467f", size = 53499095, upload-time = "2026-04-24T18:58:24.834Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a2/6956f1f9603bbd0af581a4afe227e86fb819fbfcf21ee0cee64e129f0659/homeassistant-2026.5.4-py3-none-any.whl", hash = "sha256:3ba68dec2e9b9f67b3b0c67654cd5d138f14d31924b9701b840beccd00f8e85a", size = 54712939, upload-time = "2026-05-22T17:53:24.137Z" }, ] [[package]] name = "homeassistant-stubs" -version = "2026.4.4" +version = "2026.5.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "homeassistant" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/2cc8df737b883498964e1a96d00497b3dfb04de5aa0ef17b567973584837/homeassistant_stubs-2026.4.4.tar.gz", hash = "sha256:5bfd79ba36b87d594d403185fbf7885b81f5f6967cc3ea4d641e825a8b469b8f", size = 1310585, upload-time = "2026-04-25T03:36:20.52Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/22/6d714a16b7698beadc0740d105be504f67f445678ae84ff968b19c745e56/homeassistant_stubs-2026.5.4.tar.gz", hash = "sha256:cbccdc0b6dbf276fa23d0e06830c53afc294f3358503018fd5c3f330334312ae", size = 1341584, upload-time = "2026-05-23T04:11:08.35Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/d2/7bf64e4a1cd1fa6f188e5378cd72339de1b6424676000279561a1d3ae598/homeassistant_stubs-2026.4.4-py3-none-any.whl", hash = "sha256:53ecbe27c5428dc21506c111a9710d7f8c48ed8c112404f2c41d132f4896b88d", size = 3700990, upload-time = "2026-04-25T03:36:17.791Z" }, + { url = "https://files.pythonhosted.org/packages/59/77/2d81e22358aca76188a5b641d7a2b5e0458ad7745667d4e9a5e65cbc466c/homeassistant_stubs-2026.5.4-py3-none-any.whl", hash = "sha256:3fcf7a614c032e3f413a77144477f280e4e43706cded830f315d2c5a75b308eb", size = 3794188, upload-time = "2026-05-23T04:11:05.795Z" }, ] [[package]] @@ -1188,9 +1249,36 @@ wheels = [ [[package]] name = "lru-dict" -version = "1.3.0" +version = "1.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/e3/42c87871920602a3c8300915bd0292f76eccc66c38f782397acbf8a62088/lru-dict-1.3.0.tar.gz", hash = "sha256:54fd1966d6bd1fcde781596cb86068214edeebff1db13a2cea11079e3fd07b6b", size = 13123, upload-time = "2023-11-06T01:40:12.951Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/0a/dec86efe38b350314c49a8d39ef01ba7cf8bbbef1d177646320eedea7159/lru_dict-1.4.1.tar.gz", hash = "sha256:cc518ff2d38cc7a8ab56f9a6ae557f91e2e1524b57ed8e598e97f45a2bd708fc", size = 13439, upload-time = "2025-11-02T10:02:13.548Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/02/8e04a8d744b466d4153502e2d92b453c2e5a549d49bf7fabfdca1621828a/lru_dict-1.4.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b770c7db258625e57b6ea8e2e0503ba0fbbdcde374baacf9adb256eb9c5adfa", size = 11119, upload-time = "2025-11-02T10:01:36.239Z" }, + { url = "https://files.pythonhosted.org/packages/50/f8/ee96f30127ff47c29966603f040e0485700fe0ca0e7d7b1ecbc9bf999eea/lru_dict-1.4.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:45d4dc338237cedcbacedab1afd9707b8f9867d8b601ec04e0395ec73f57405c", size = 11435, upload-time = "2025-11-02T10:01:36.857Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5a/897b33ba1974b6487848cafa5de7e93a7c4f5d9d3f43319ee010f6882830/lru_dict-1.4.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:5b31e9b6636f8945ad69c630c1891d810d62a91d99e792ef0b9ca865b6c26745", size = 10988, upload-time = "2025-11-02T10:01:37.531Z" }, + { url = "https://files.pythonhosted.org/packages/19/8e/b87d0f2bfcad0169afc00e23e014bad9af252206ec2cbc6079f12bece58e/lru_dict-1.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:f9335d46c83882a1b5deffed8098a2dd9ad66d2bd6263f416fc4c73f63e26904", size = 16733, upload-time = "2025-11-02T10:01:38.194Z" }, + { url = "https://files.pythonhosted.org/packages/ab/19/d2384266864b1e5b1cc20527ae468550d3b23a71636371b40e4663276294/lru_dict-1.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:17844b4f8dd996144d53380395d73832e2508159ad49ed4fbcb62f1787a5feaf", size = 11222, upload-time = "2025-11-02T10:01:39.093Z" }, + { url = "https://files.pythonhosted.org/packages/66/8a/94dec42ae6b5c8bdc53a86867924fa22634516434f129dca187ccc0853b8/lru_dict-1.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2b569c7813adb753b7b631097c34e6dbc194cb1814f22299c2d2a94894779877", size = 11733, upload-time = "2025-11-02T10:01:39.739Z" }, + { url = "https://files.pythonhosted.org/packages/3f/19/0b6de1db804cf094e201c5541d58e6a96359eb5beed048fa64d0589b6520/lru_dict-1.4.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:33cf1eb368d3989b8f00945937cfbfc2095d8ad2b1d2274ce1bde0af6f6d1e66", size = 32251, upload-time = "2025-11-02T10:01:40.421Z" }, + { url = "https://files.pythonhosted.org/packages/97/38/89d9425dde436b9bd894234171988289b259aeeab5965bd2c21d5104cb41/lru_dict-1.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22d5879ec5d5955f9dde105997bdf7ec9e0522bf99612a80b55b09f356a08368", size = 33405, upload-time = "2025-11-02T10:01:41.917Z" }, + { url = "https://files.pythonhosted.org/packages/0e/24/f1a189399ee107a64f955c9d6c84d3b0aee9b64b31fc5684b1eaeb3a6fc0/lru_dict-1.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2084363e4488aa5b4f8b26bd3cc148d70a15be92e3d347621a5b830b2b1e0a82", size = 35135, upload-time = "2025-11-02T10:01:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/f8/57/58e9dcf0853d639e2995e5d9f84649ff8d6792a04a418628672a130137f4/lru_dict-1.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8198ab8ad7cc81b86340243ddd5cca882ead87daed0c9fa6cce377a10a7f2e47", size = 32620, upload-time = "2025-11-02T10:01:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/a9/bb/664922f0cf076b1e3c2e43e8258582d507b07c19bd441a72dd5547a483e9/lru_dict-1.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1f4ae6967d5873e684ce8b986e2e43985d0a1be735b09584737ad5634ff48f3", size = 34077, upload-time = "2025-11-02T10:01:44.694Z" }, + { url = "https://files.pythonhosted.org/packages/58/38/b7a6fa85b150232cada26a50c89dc4bcf9acd6ada00e987b074c3b4e57f2/lru_dict-1.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a9bb130b5eaddd6453ca3dc38ce4a75f743512ad135b6f3994999dde0680bd79", size = 31905, upload-time = "2025-11-02T10:01:45.668Z" }, + { url = "https://files.pythonhosted.org/packages/86/7d/9c86393946d621f4aec852d543df4023241d85106e9e1e2a0e4057861f71/lru_dict-1.4.1-cp314-cp314-win32.whl", hash = "sha256:5534c69a52add5757714456d08ce3831d36b86c98972394ba900493bb0bd97f8", size = 13435, upload-time = "2025-11-02T10:01:46.397Z" }, + { url = "https://files.pythonhosted.org/packages/20/3f/b017cbeea55a8a1d18037840a8a9c9cdae29554e9985b55d4e8694305035/lru_dict-1.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:96fd677b6d912229f2d02ba61a5a1210176963c4770c1bb765b8da937cec3834", size = 14423, upload-time = "2025-11-02T10:01:47.473Z" }, + { url = "https://files.pythonhosted.org/packages/aa/73/13132af7a5155edde66979b53eb509465304e6e5a2b00769246448479c73/lru_dict-1.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6699bfebbf11dd9ff1387be7996fac6d1009fe6a6f48091ef6e069e6f19c7bce", size = 17184, upload-time = "2025-11-02T10:01:48.161Z" }, + { url = "https://files.pythonhosted.org/packages/ef/82/094985beb3e49461bf65a3c40df8de2018b8484e4ef129295090460ca5d9/lru_dict-1.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a276f8f6f43861c3f05986824741d00e3133a973c3396598375310129535382d", size = 11459, upload-time = "2025-11-02T10:01:48.823Z" }, + { url = "https://files.pythonhosted.org/packages/14/29/836abc49f8c2b6c2efccd2ac2b2c0ad3e55b7d75a05a20cc061f17871e39/lru_dict-1.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:090c7b6a3d54fa7f3d69ba4802abe2f33c9583b16b33f52bcb521c701f7ea46c", size = 11938, upload-time = "2025-11-02T10:01:49.448Z" }, + { url = "https://files.pythonhosted.org/packages/c2/83/1bb4e8fbc0b753fea825564d9d96180813a71715d46a9b6bb30a6dea4ce0/lru_dict-1.4.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b21d06dec64fb1952385262d9fcefaec147921dc0b55210007091a79da440d93", size = 36389, upload-time = "2025-11-02T10:01:50.49Z" }, + { url = "https://files.pythonhosted.org/packages/54/f2/7df3b6d0dbc66f3be9aa6261750967cdc5619c89a563420c52200d2dd547/lru_dict-1.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9613908a38cf8aa47f6c138ba031a8ac4ed38460299e84a2b07dba7b3b45aae", size = 38706, upload-time = "2025-11-02T10:01:51.197Z" }, + { url = "https://files.pythonhosted.org/packages/97/5f/e3ba3eeb9b864a09b92e24fbf179aef4ef48588e763a9d8d2bc10bd2c6f8/lru_dict-1.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7558302ce8bbfcd29f08e695e07bf7a0d799c2979636d6a6a0b4e207f840969f", size = 38892, upload-time = "2025-11-02T10:01:51.899Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e3/12e0888aab0bf3ab9ce35e9849f239994a0feff6fe49380859bf57124a17/lru_dict-1.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3910396142322fb2718546115bb2a56f50ebc9144b5140327053cca084e0d375", size = 37254, upload-time = "2025-11-02T10:01:52.587Z" }, + { url = "https://files.pythonhosted.org/packages/cc/dc/06cd981718d039eb07a9c03263094aca6269721c470f765a292b24381a20/lru_dict-1.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f3f4fad5c4a9458954b275de6a6e31c67a26fbef7037c6a7354e22523a77db26", size = 37422, upload-time = "2025-11-02T10:01:53.271Z" }, + { url = "https://files.pythonhosted.org/packages/e8/82/ea88e618f39d78ff3c15b71f01a0b1a6c6ac2034ce5c6428ae41b1c30ea5/lru_dict-1.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85fc29363e2d3ba0a5f87b5e17f54b1078aea6d24c6dfc792725854b9d0f8d17", size = 35909, upload-time = "2025-11-02T10:01:54.356Z" }, + { url = "https://files.pythonhosted.org/packages/89/36/1dd91c602f623839cec24d6c77fa3fd1a8878bf2d716871197cd3bf084dc/lru_dict-1.4.1-cp314-cp314t-win32.whl", hash = "sha256:b3853518dfa50f28af0d6e2dcf8bb8b0a1687c5f4eb913c0b35b0da5c6d276ce", size = 13816, upload-time = "2025-11-02T10:01:55.417Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a3/113410f7b2e61e9d6f13f1f17c584dbd08b5796e65d772ecd5b063fab3af/lru_dict-1.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ff3af42922205620fdc920dcdf580c4c16b32c84a537a03b04b523e5c641a8a9", size = 15204, upload-time = "2025-11-02T10:01:56.06Z" }, +] [[package]] name = "mando" @@ -1406,25 +1494,25 @@ wheels = [ [[package]] name = "orjson" -version = "3.11.7" +version = "3.11.8" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/45/b268004f745ede84e5798b48ee12b05129d19235d0e15267aa57dcdb400b/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49", size = 6144992, upload-time = "2026-02-02T15:38:49.29Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/1b/2024d06792d0779f9dbc51531b61c24f76c75b9f4ce05e6f3377a1814cea/orjson-3.11.8.tar.gz", hash = "sha256:96163d9cdc5a202703e9ad1b9ae757d5f0ca62f4fa0cc93d1f27b0e180cc404e", size = 5603832, upload-time = "2026-03-31T16:16:27.878Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/1e/745565dca749813db9a093c5ebc4bac1a9475c64d54b95654336ac3ed961/orjson-3.11.7-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:de0a37f21d0d364954ad5de1970491d7fbd0fb1ef7417d4d56a36dc01ba0c0a0", size = 228391, upload-time = "2026-02-02T15:38:27.757Z" }, - { url = "https://files.pythonhosted.org/packages/46/19/e40f6225da4d3aa0c8dc6e5219c5e87c2063a560fe0d72a88deb59776794/orjson-3.11.7-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c2428d358d85e8da9d37cba18b8c4047c55222007a84f97156a5b22028dfbfc0", size = 125188, upload-time = "2026-02-02T15:38:29.241Z" }, - { url = "https://files.pythonhosted.org/packages/9d/7e/c4de2babef2c0817fd1f048fd176aa48c37bec8aef53d2fa932983032cce/orjson-3.11.7-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c4bc6c6ac52cdaa267552544c73e486fecbd710b7ac09bc024d5a78555a22f6", size = 128097, upload-time = "2026-02-02T15:38:30.618Z" }, - { url = "https://files.pythonhosted.org/packages/eb/74/233d360632bafd2197f217eee7fb9c9d0229eac0c18128aee5b35b0014fe/orjson-3.11.7-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd0d68edd7dfca1b2eca9361a44ac9f24b078de3481003159929a0573f21a6bf", size = 123364, upload-time = "2026-02-02T15:38:32.363Z" }, - { url = "https://files.pythonhosted.org/packages/79/51/af79504981dd31efe20a9e360eb49c15f06df2b40e7f25a0a52d9ae888e8/orjson-3.11.7-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:623ad1b9548ef63886319c16fa317848e465a21513b31a6ad7b57443c3e0dcf5", size = 129076, upload-time = "2026-02-02T15:38:33.68Z" }, - { url = "https://files.pythonhosted.org/packages/67/e2/da898eb68b72304f8de05ca6715870d09d603ee98d30a27e8a9629abc64b/orjson-3.11.7-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6e776b998ac37c0396093d10290e60283f59cfe0fc3fccbd0ccc4bd04dd19892", size = 141705, upload-time = "2026-02-02T15:38:34.989Z" }, - { url = "https://files.pythonhosted.org/packages/c5/89/15364d92acb3d903b029e28d834edb8780c2b97404cbf7929aa6b9abdb24/orjson-3.11.7-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:652c6c3af76716f4a9c290371ba2e390ede06f6603edb277b481daf37f6f464e", size = 130855, upload-time = "2026-02-02T15:38:36.379Z" }, - { url = "https://files.pythonhosted.org/packages/c2/8b/ecdad52d0b38d4b8f514be603e69ccd5eacf4e7241f972e37e79792212ec/orjson-3.11.7-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a56df3239294ea5964adf074c54bcc4f0ccd21636049a2cf3ca9cf03b5d03cf1", size = 133386, upload-time = "2026-02-02T15:38:37.704Z" }, - { url = "https://files.pythonhosted.org/packages/b9/0e/45e1dcf10e17d0924b7c9162f87ec7b4ca79e28a0548acf6a71788d3e108/orjson-3.11.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bda117c4148e81f746655d5a3239ae9bd00cb7bc3ca178b5fc5a5997e9744183", size = 138295, upload-time = "2026-02-02T15:38:39.096Z" }, - { url = "https://files.pythonhosted.org/packages/63/d7/4d2e8b03561257af0450f2845b91fbd111d7e526ccdf737267108075e0ba/orjson-3.11.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:23d6c20517a97a9daf1d48b580fcdc6f0516c6f4b5038823426033690b4d2650", size = 408720, upload-time = "2026-02-02T15:38:40.634Z" }, - { url = "https://files.pythonhosted.org/packages/78/cf/d45343518282108b29c12a65892445fc51f9319dc3c552ceb51bb5905ed2/orjson-3.11.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8ff206156006da5b847c9304b6308a01e8cdbc8cce824e2779a5ba71c3def141", size = 144152, upload-time = "2026-02-02T15:38:42.262Z" }, - { url = "https://files.pythonhosted.org/packages/a9/3a/d6001f51a7275aacd342e77b735c71fa04125a3f93c36fee4526bc8c654e/orjson-3.11.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:962d046ee1765f74a1da723f4b33e3b228fe3a48bd307acce5021dfefe0e29b2", size = 134814, upload-time = "2026-02-02T15:38:43.627Z" }, - { url = "https://files.pythonhosted.org/packages/1d/d3/f19b47ce16820cc2c480f7f1723e17f6d411b3a295c60c8ad3aa9ff1c96a/orjson-3.11.7-cp314-cp314-win32.whl", hash = "sha256:89e13dd3f89f1c38a9c9eba5fbf7cdc2d1feca82f5f290864b4b7a6aac704576", size = 127997, upload-time = "2026-02-02T15:38:45.06Z" }, - { url = "https://files.pythonhosted.org/packages/12/df/172771902943af54bf661a8d102bdf2e7f932127968080632bda6054b62c/orjson-3.11.7-cp314-cp314-win_amd64.whl", hash = "sha256:845c3e0d8ded9c9271cd79596b9b552448b885b97110f628fb687aee2eed11c1", size = 124985, upload-time = "2026-02-02T15:38:46.388Z" }, - { url = "https://files.pythonhosted.org/packages/6f/1c/f2a8d8a1b17514660a614ce5f7aac74b934e69f5abc2700cc7ced882a009/orjson-3.11.7-cp314-cp314-win_arm64.whl", hash = "sha256:4a2e9c5be347b937a2e0203866f12bba36082e89b402ddb9e927d5822e43088d", size = 126038, upload-time = "2026-02-02T15:38:47.703Z" }, + { url = "https://files.pythonhosted.org/packages/6d/35/b01910c3d6b85dc882442afe5060cbf719c7d1fc85749294beda23d17873/orjson-3.11.8-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:ec795530a73c269a55130498842aaa762e4a939f6ce481a7e986eeaa790e9da4", size = 229171, upload-time = "2026-03-31T16:16:00.651Z" }, + { url = "https://files.pythonhosted.org/packages/c2/56/c9ec97bd11240abef39b9e5d99a15462809c45f677420fd148a6c5e6295e/orjson-3.11.8-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c492a0e011c0f9066e9ceaa896fbc5b068c54d365fea5f3444b697ee01bc8625", size = 128746, upload-time = "2026-03-31T16:16:02.673Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/66d4f30a90de45e2f0cbd9623588e8ae71eef7679dbe2ae954ed6d66a41f/orjson-3.11.8-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:883206d55b1bd5f5679ad5e6ddd3d1a5e3cac5190482927fdb8c78fb699193b5", size = 131867, upload-time = "2026-03-31T16:16:04.342Z" }, + { url = "https://files.pythonhosted.org/packages/19/30/2a645fc9286b928675e43fa2a3a16fb7b6764aa78cc719dc82141e00f30b/orjson-3.11.8-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5774c1fdcc98b2259800b683b19599c133baeb11d60033e2095fd9d4667b82db", size = 124664, upload-time = "2026-03-31T16:16:05.837Z" }, + { url = "https://files.pythonhosted.org/packages/db/44/77b9a86d84a28d52ba3316d77737f6514e17118119ade3f91b639e859029/orjson-3.11.8-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ac7381c83dd3d4a6347e6635950aa448f54e7b8406a27c7ecb4a37e9f1ae08b", size = 129701, upload-time = "2026-03-31T16:16:07.407Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ea/eff3d9bfe47e9bc6969c9181c58d9f71237f923f9c86a2d2f490cd898c82/orjson-3.11.8-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:14439063aebcb92401c11afc68ee4e407258d2752e62d748b6942dad20d2a70d", size = 141202, upload-time = "2026-03-31T16:16:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/52/c8/90d4b4c60c84d62068d0cf9e4d8f0a4e05e76971d133ac0c60d818d4db20/orjson-3.11.8-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fa72e71977bff96567b0f500fc5bfd2fdf915f34052c782a4c6ebbdaa97aa858", size = 127194, upload-time = "2026-03-31T16:16:11.02Z" }, + { url = "https://files.pythonhosted.org/packages/8d/c7/ea9e08d1f0ba981adffb629811148b44774d935171e7b3d780ae43c4c254/orjson-3.11.8-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7679bc2f01bb0d219758f1a5f87bb7c8a81c0a186824a393b366876b4948e14f", size = 133639, upload-time = "2026-03-31T16:16:13.434Z" }, + { url = "https://files.pythonhosted.org/packages/6c/8c/ddbbfd6ba59453c8fc7fe1d0e5983895864e264c37481b2a791db635f046/orjson-3.11.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:14f7b8fcb35ef403b42fa5ecfa4ed032332a91f3dc7368fbce4184d59e1eae0d", size = 141914, upload-time = "2026-03-31T16:16:14.955Z" }, + { url = "https://files.pythonhosted.org/packages/4e/31/dbfbefec9df060d34ef4962cd0afcb6fa7a9ec65884cb78f04a7859526c3/orjson-3.11.8-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c2bdf7b2facc80b5e34f48a2d557727d5c5c57a8a450de122ae81fa26a81c1bc", size = 423800, upload-time = "2026-03-31T16:16:16.594Z" }, + { url = "https://files.pythonhosted.org/packages/87/cf/f74e9ae9803d4ab46b163494adba636c6d7ea955af5cc23b8aaa94cfd528/orjson-3.11.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ccd7ba1b0605813a0715171d39ec4c314cb97a9c85893c2c5c0c3a3729df38bf", size = 147837, upload-time = "2026-03-31T16:16:18.585Z" }, + { url = "https://files.pythonhosted.org/packages/64/e6/9214f017b5db85e84e68602792f742e5dc5249e963503d1b356bee611e01/orjson-3.11.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbc8c9c02463fef4d3c53a9ba3336d05496ec8e1f1c53326a1e4acc11f5c600", size = 136441, upload-time = "2026-03-31T16:16:20.151Z" }, + { url = "https://files.pythonhosted.org/packages/24/dd/3590348818f58f837a75fb969b04cdf187ae197e14d60b5e5a794a38b79d/orjson-3.11.8-cp314-cp314-win32.whl", hash = "sha256:0b57f67710a8cd459e4e54eb96d5f77f3624eba0c661ba19a525807e42eccade", size = 131983, upload-time = "2026-03-31T16:16:21.823Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0f/b6cb692116e05d058f31ceee819c70f097fa9167c82f67fabe7516289abc/orjson-3.11.8-cp314-cp314-win_amd64.whl", hash = "sha256:735e2262363dcbe05c35e3a8869898022af78f89dde9e256924dc02e99fe69ca", size = 127396, upload-time = "2026-03-31T16:16:23.685Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d1/facb5b5051fabb0ef9d26c6544d87ef19a939a9a001198655d0d891062dd/orjson-3.11.8-cp314-cp314-win_arm64.whl", hash = "sha256:6ccdea2c213cf9f3d9490cbd5d427693c870753df41e6cb375bd79bcbafc8817", size = 127330, upload-time = "2026-03-31T16:16:25.496Z" }, ] [[package]] @@ -1694,7 +1782,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.12.2" +version = "2.13.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -1702,39 +1790,50 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8d/35/d319ed522433215526689bad428a94058b6dd12190ce7ddd78618ac14b28/pydantic-2.12.2.tar.gz", hash = "sha256:7b8fa15b831a4bbde9d5b84028641ac3080a4ca2cbd4a621a661687e741624fd", size = 816358, upload-time = "2025-10-14T15:02:21.842Z" } +sdist = { url = "https://files.pythonhosted.org/packages/09/e5/06d23afac9973109d1e3c8ad38e1547a12e860610e327c05ee686827dc37/pydantic-2.13.2.tar.gz", hash = "sha256:b418196607e61081c3226dcd4f0672f2a194828abb9109e9cfb84026564df2d1", size = 843836, upload-time = "2026-04-17T09:31:59.636Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/98/468cb649f208a6f1279448e6e5247b37ae79cf5e4041186f1e2ef3d16345/pydantic-2.12.2-py3-none-any.whl", hash = "sha256:25ff718ee909acd82f1ff9b1a4acfd781bb23ab3739adaa7144f19a6a4e231ae", size = 460628, upload-time = "2025-10-14T15:02:19.623Z" }, + { url = "https://files.pythonhosted.org/packages/77/ca/b45c378e6e8d0b90577288b533e04e95b7afd61bb1d51b6c263176435489/pydantic-2.13.2-py3-none-any.whl", hash = "sha256:a525087f4c03d7e7456a3de89b64cd693d2229933bb1068b9af6befd5563694e", size = 471947, upload-time = "2026-04-17T09:31:57.541Z" }, ] [[package]] name = "pydantic-core" -version = "2.41.4" +version = "2.46.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/18/d0944e8eaaa3efd0a91b0f1fc537d3be55ad35091b6a87638211ba691964/pydantic_core-2.41.4.tar.gz", hash = "sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5", size = 457557, upload-time = "2025-10-14T10:23:47.909Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/28/d3325da57d413b9819365546eb9a6e8b7cbd9373d9380efd5f74326143e6/pydantic_core-2.41.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e9205d97ed08a82ebb9a307e92914bb30e18cdf6f6b12ca4bedadb1588a0bfe1", size = 2102022, upload-time = "2025-10-14T10:21:32.809Z" }, - { url = "https://files.pythonhosted.org/packages/9e/24/b58a1bc0d834bf1acc4361e61233ee217169a42efbdc15a60296e13ce438/pydantic_core-2.41.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:82df1f432b37d832709fbcc0e24394bba04a01b6ecf1ee87578145c19cde12ac", size = 1905495, upload-time = "2025-10-14T10:21:34.812Z" }, - { url = "https://files.pythonhosted.org/packages/fb/a4/71f759cc41b7043e8ecdaab81b985a9b6cad7cec077e0b92cff8b71ecf6b/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3b4cc4539e055cfa39a3763c939f9d409eb40e85813257dcd761985a108554", size = 1956131, upload-time = "2025-10-14T10:21:36.924Z" }, - { url = "https://files.pythonhosted.org/packages/b0/64/1e79ac7aa51f1eec7c4cda8cbe456d5d09f05fdd68b32776d72168d54275/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b1eb1754fce47c63d2ff57fdb88c351a6c0150995890088b33767a10218eaa4e", size = 2052236, upload-time = "2025-10-14T10:21:38.927Z" }, - { url = "https://files.pythonhosted.org/packages/e9/e3/a3ffc363bd4287b80f1d43dc1c28ba64831f8dfc237d6fec8f2661138d48/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6ab5ab30ef325b443f379ddb575a34969c333004fca5a1daa0133a6ffaad616", size = 2223573, upload-time = "2025-10-14T10:21:41.574Z" }, - { url = "https://files.pythonhosted.org/packages/28/27/78814089b4d2e684a9088ede3790763c64693c3d1408ddc0a248bc789126/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:31a41030b1d9ca497634092b46481b937ff9397a86f9f51bd41c4767b6fc04af", size = 2342467, upload-time = "2025-10-14T10:21:44.018Z" }, - { url = "https://files.pythonhosted.org/packages/92/97/4de0e2a1159cb85ad737e03306717637842c88c7fd6d97973172fb183149/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a44ac1738591472c3d020f61c6df1e4015180d6262ebd39bf2aeb52571b60f12", size = 2063754, upload-time = "2025-10-14T10:21:46.466Z" }, - { url = "https://files.pythonhosted.org/packages/0f/50/8cb90ce4b9efcf7ae78130afeb99fd1c86125ccdf9906ef64b9d42f37c25/pydantic_core-2.41.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d72f2b5e6e82ab8f94ea7d0d42f83c487dc159c5240d8f83beae684472864e2d", size = 2196754, upload-time = "2025-10-14T10:21:48.486Z" }, - { url = "https://files.pythonhosted.org/packages/34/3b/ccdc77af9cd5082723574a1cc1bcae7a6acacc829d7c0a06201f7886a109/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c4d1e854aaf044487d31143f541f7aafe7b482ae72a022c664b2de2e466ed0ad", size = 2137115, upload-time = "2025-10-14T10:21:50.63Z" }, - { url = "https://files.pythonhosted.org/packages/ca/ba/e7c7a02651a8f7c52dc2cff2b64a30c313e3b57c7d93703cecea76c09b71/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b568af94267729d76e6ee5ececda4e283d07bbb28e8148bb17adad93d025d25a", size = 2317400, upload-time = "2025-10-14T10:21:52.959Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ba/6c533a4ee8aec6b812c643c49bb3bd88d3f01e3cebe451bb85512d37f00f/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6d55fb8b1e8929b341cc313a81a26e0d48aa3b519c1dbaadec3a6a2b4fcad025", size = 2312070, upload-time = "2025-10-14T10:21:55.419Z" }, - { url = "https://files.pythonhosted.org/packages/22/ae/f10524fcc0ab8d7f96cf9a74c880243576fd3e72bd8ce4f81e43d22bcab7/pydantic_core-2.41.4-cp314-cp314-win32.whl", hash = "sha256:5b66584e549e2e32a1398df11da2e0a7eff45d5c2d9db9d5667c5e6ac764d77e", size = 1982277, upload-time = "2025-10-14T10:21:57.474Z" }, - { url = "https://files.pythonhosted.org/packages/b4/dc/e5aa27aea1ad4638f0c3fb41132f7eb583bd7420ee63204e2d4333a3bbf9/pydantic_core-2.41.4-cp314-cp314-win_amd64.whl", hash = "sha256:557a0aab88664cc552285316809cab897716a372afaf8efdbef756f8b890e894", size = 2024608, upload-time = "2025-10-14T10:21:59.557Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/51d89cc2612bd147198e120a13f150afbf0bcb4615cddb049ab10b81b79e/pydantic_core-2.41.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f1ea6f48a045745d0d9f325989d8abd3f1eaf47dd00485912d1a3a63c623a8d", size = 1967614, upload-time = "2025-10-14T10:22:01.847Z" }, - { url = "https://files.pythonhosted.org/packages/0d/c2/472f2e31b95eff099961fa050c376ab7156a81da194f9edb9f710f68787b/pydantic_core-2.41.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6c1fe4c5404c448b13188dd8bd2ebc2bdd7e6727fa61ff481bcc2cca894018da", size = 1876904, upload-time = "2025-10-14T10:22:04.062Z" }, - { url = "https://files.pythonhosted.org/packages/4a/07/ea8eeb91173807ecdae4f4a5f4b150a520085b35454350fc219ba79e66a3/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:523e7da4d43b113bf8e7b49fa4ec0c35bf4fe66b2230bfc5c13cc498f12c6c3e", size = 1882538, upload-time = "2025-10-14T10:22:06.39Z" }, - { url = "https://files.pythonhosted.org/packages/1e/29/b53a9ca6cd366bfc928823679c6a76c7a4c69f8201c0ba7903ad18ebae2f/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5729225de81fb65b70fdb1907fcf08c75d498f4a6f15af005aabb1fdadc19dfa", size = 2041183, upload-time = "2025-10-14T10:22:08.812Z" }, - { url = "https://files.pythonhosted.org/packages/c7/3d/f8c1a371ceebcaf94d6dd2d77c6cf4b1c078e13a5837aee83f760b4f7cfd/pydantic_core-2.41.4-cp314-cp314t-win_amd64.whl", hash = "sha256:de2cfbb09e88f0f795fd90cf955858fc2c691df65b1f21f0aa00b99f3fbc661d", size = 1993542, upload-time = "2025-10-14T10:22:11.332Z" }, - { url = "https://files.pythonhosted.org/packages/8a/ac/9fc61b4f9d079482a290afe8d206b8f490e9fd32d4fc03ed4fc698214e01/pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0", size = 1973897, upload-time = "2025-10-14T10:22:13.444Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/43/bb/4742f05b739b2478459bb16fa8470549518c802e06ddcf3f106c5081315e/pydantic_core-2.46.2.tar.gz", hash = "sha256:37bb079f9ee3f1a519392b73fda2a96379b31f2013c6b467fe693e7f2987f596", size = 471269, upload-time = "2026-04-17T09:10:07.017Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/96/a50ccb6b539ae780f73cea74905468777680e30c6c3bdf714b9d4c116ea0/pydantic_core-2.46.2-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:4f59b45f3ef8650c0c736a57f59031d47ed9df4c0a64e83796849d7d14863a2d", size = 2097111, upload-time = "2026-04-17T09:10:49.617Z" }, + { url = "https://files.pythonhosted.org/packages/34/5f/fdead7b3afa822ab6e5a18ee0ecffd54937de1877c01ed13a342e0fb3f07/pydantic_core-2.46.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3a075a29ebef752784a91532a1a85be6b234ccffec0a9d7978a92696387c3da6", size = 1951904, upload-time = "2026-04-17T09:12:32.062Z" }, + { url = "https://files.pythonhosted.org/packages/95/e0/1c5d547e550cdab1bec737492aa08865337af6fe7fc9b96f7f45f17d9519/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d12d786e30c04a9d307c5d7080bf720d9bac7f1668191d8e37633a9562749e2", size = 1978667, upload-time = "2026-04-17T09:11:35.589Z" }, + { url = "https://files.pythonhosted.org/packages/0e/cb/665ce629e218c8228302cb94beff4f6531082a2c87d3ecc3d5e63a26f392/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0d5e6d6343b0b5dcacb3503b5de90022968da8ed0ab9ab39d3eda71c20cbf84e", size = 2046721, upload-time = "2026-04-17T09:11:47.725Z" }, + { url = "https://files.pythonhosted.org/packages/77/e9/6cb2cf60f54c1472bbdfce19d957553b43dbba79d1d7b2930a195c594785/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:233eebac0999b6b9ba76eb56f3ec8fce13164aa16b6d2225a36a79e0f95b5973", size = 2228483, upload-time = "2026-04-17T09:12:08.837Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/93e018dd5571f781ebaeda8c0cf65398489d5bee9b1f484df0b6149b43b9/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9cc0eee720dd2f14f3b7c349469402b99ad81a174ab49d3533974529e9d93992", size = 2294663, upload-time = "2026-04-17T09:12:52.053Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4f/49e57ca55c770c93d9bb046666a54949b42e3c9099a0c5fe94557873fe30/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83ee76bf2c9910513dbc19e7d82367131fa7508dedd6186a462393071cc11059", size = 2098742, upload-time = "2026-04-17T09:13:45.472Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b0/6e46b5cd3332af665f794b8cdeea206618a8630bd9e7bcc36864518fce81/pydantic_core-2.46.2-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:d61db38eb4ee5192f0c261b7f2d38e420b554df8912245e3546aee5c45e2fd78", size = 2125922, upload-time = "2026-04-17T09:12:54.304Z" }, + { url = "https://files.pythonhosted.org/packages/06/d1/40850c81585be443a2abfdf7f795f8fae831baf8e2f9b2133c8246ac671c/pydantic_core-2.46.2-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8f09a713d17bcd55da8ab02ebd9110c5246a49c44182af213b5212800af8bc83", size = 2183000, upload-time = "2026-04-17T09:10:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/af/8493d7dfa03ebb7866909e577c6aa65ea0de7377b86023cc51d0c8e11db3/pydantic_core-2.46.2-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:30cacc5fb696e64b8ef6fd31d9549d394dd7d52760db072eecb98e37e3af1677", size = 2180335, upload-time = "2026-04-17T09:12:57.01Z" }, + { url = "https://files.pythonhosted.org/packages/72/5b/1f6a344c4ffdf284da41c6067b82d5ebcbd11ce1b515ae4b662d4adb6f61/pydantic_core-2.46.2-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:7ccfb105fcfe91a22bbb5563ad3dc124bc1aa75bfd2e53a780ab05f78cdf6108", size = 2330002, upload-time = "2026-04-17T09:12:02.958Z" }, + { url = "https://files.pythonhosted.org/packages/25/ff/9a694126c12d6d2f48a0cafa6f8eef88ef0d8825600e18d03ff2e896c3b2/pydantic_core-2.46.2-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:13ffef637dc8370c249e5b26bd18e9a80a4fca3d809618c44e18ec834a7ca7a8", size = 2359920, upload-time = "2026-04-17T09:10:27.764Z" }, + { url = "https://files.pythonhosted.org/packages/51/c8/3a35c763d68a9cb2675eb10ef242cf66c5d4701b28ae12e688d67d2c180e/pydantic_core-2.46.2-cp314-cp314-win32.whl", hash = "sha256:1b0ab6d756ca2704a938e6c31b53f290c2f9c10d3914235410302a149de1a83e", size = 1953701, upload-time = "2026-04-17T09:13:30.021Z" }, + { url = "https://files.pythonhosted.org/packages/1a/6a/f2726a780365f7dfd89d62036f984f7acb99978c60c5e1fa7c0cb898ed11/pydantic_core-2.46.2-cp314-cp314-win_amd64.whl", hash = "sha256:99ebade8c9ada4df975372d8dd25883daa0e379a05f1cd0c99aa0c04368d01a6", size = 2071867, upload-time = "2026-04-17T09:10:39.205Z" }, + { url = "https://files.pythonhosted.org/packages/e1/79/76baacb9feba3d7c399b245ca1a29c74ea0db04ea693811374827eec2290/pydantic_core-2.46.2-cp314-cp314-win_arm64.whl", hash = "sha256:de87422197cf7f83db91d89c86a21660d749b3cd76cd8a45d115b8e675670f02", size = 2017252, upload-time = "2026-04-17T09:10:26.175Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3b/77c26938f817668d9ad9bab1a905cb23f11d9a3d4bf724d429b3e55a8eaf/pydantic_core-2.46.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:236f22b4a206b5b61db955396b7cf9e2e1ff77f372efe9570128ccfcd6a525eb", size = 2094545, upload-time = "2026-04-17T09:12:19.339Z" }, + { url = "https://files.pythonhosted.org/packages/fe/de/42c13f590e3c260966aa49bcdb1674774f975467c49abd51191e502bea28/pydantic_core-2.46.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c2012f64d2cd7cca50f49f22445aa5a88691ac2b4498ee0a9a977f8ca4f7289f", size = 1933953, upload-time = "2026-04-17T09:09:55.889Z" }, + { url = "https://files.pythonhosted.org/packages/4e/84/ebe3ebb3e2d8db656937cfa6f97f544cb7132f2307a4a7dfdcd0ea102a12/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d07d6c63106d3a9c9a333e2636f9c82c703b1a9e3b079299e58747964e4fdb72", size = 1974435, upload-time = "2026-04-17T09:10:12.371Z" }, + { url = "https://files.pythonhosted.org/packages/b9/15/0bf51ca6709477cd4ef86148b6d7844f3308f029eac361dd0383f1e17b1a/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c326a2b4b85e959d9a1fc3a11f32f84611b6ec07c053e1828a860edf8d068208", size = 2031113, upload-time = "2026-04-17T09:10:00.752Z" }, + { url = "https://files.pythonhosted.org/packages/02/ae/b7b5af9b79db036d9e61a44c481c17a213dc8fc4b8b71fe6875a72fc778b/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac8a65e798f2462552c00d2e013d532c94d646729dda98458beaf51f9ec7b120", size = 2236325, upload-time = "2026-04-17T09:10:33.227Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ae/ecef7477b5a03d4a499708f7e75d2836452ebb70b776c2d64612b334f57a/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a3c2bc1cc8164bedbc160b7bb1e8cc1e8b9c27f69ae4f9ae2b976cdae02b2dd", size = 2278135, upload-time = "2026-04-17T09:10:23.287Z" }, + { url = "https://files.pythonhosted.org/packages/db/e4/2f9d82faa47af6c39fc3f120145fd915971e1e0cb6b55b494fad9fdf8275/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e69aa5e10b7e8b1bb4a6888650fd12fcbf11d396ca11d4a44de1450875702830", size = 2109071, upload-time = "2026-04-17T09:11:06.149Z" }, + { url = "https://files.pythonhosted.org/packages/f1/9c/677cf10873fbd0b116575ab7b97c90482b21564f8a8040beb18edef7a577/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4e6df5c3301e65fb42bc5338bf9a1027a02b0a31dc7f54c33775229af474daf0", size = 2106028, upload-time = "2026-04-17T09:10:51.525Z" }, + { url = "https://files.pythonhosted.org/packages/d6/53/6a06183544daba51c059123a2064a99039df25f115a06bdb26f2ea177038/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2c2f6e32548ac8d559b47944effcf8ae4d81c161f6b6c885edc53bc08b8f192d", size = 2164816, upload-time = "2026-04-17T09:11:56.187Z" }, + { url = "https://files.pythonhosted.org/packages/57/6f/10fcdd9e3eca66fc828eef0f6f5850f2dd3bca2c59e6e041fb8bc3da39be/pydantic_core-2.46.2-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:b089a81c58e6ea0485562bbbbbca4f65c0549521606d5ef27fba217aac9b665a", size = 2166130, upload-time = "2026-04-17T09:10:03.804Z" }, + { url = "https://files.pythonhosted.org/packages/29/83/92d3fd0e0156cad2e3cb5c26de73794af78ac9fa0c22ab666e566dd67061/pydantic_core-2.46.2-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:7f700a6d6f64112ae9193709b84303bbab84424ad4b47d0253301aabce9dfc70", size = 2316605, upload-time = "2026-04-17T09:12:45.249Z" }, + { url = "https://files.pythonhosted.org/packages/97/f1/facffdb970981068219582e499b8d0871ed163ffcc6b347de5c412669e4c/pydantic_core-2.46.2-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:67db6814beaa5fefe91101ec7eb9efda613795767be96f7cf58b1ca8c9ca9972", size = 2358385, upload-time = "2026-04-17T09:09:54.657Z" }, + { url = "https://files.pythonhosted.org/packages/8b/a1/b8160b2f22b2199467bc68581a4ed380643c16b348a27d6165c6c242d694/pydantic_core-2.46.2-cp314-cp314t-win32.whl", hash = "sha256:32fbc7447be8e3be99bf7869f7066308f16be55b61f9882c2cefc7931f5c7664", size = 1942373, upload-time = "2026-04-17T09:12:59.594Z" }, + { url = "https://files.pythonhosted.org/packages/0d/90/db89acabe5b150e11d1b59fe3d947dda2ef6abbfef5c82f056ff63802f5d/pydantic_core-2.46.2-cp314-cp314t-win_amd64.whl", hash = "sha256:b317a2b97019c0b95ce99f4f901ae383f40132da6706cdf1731066a73394c25c", size = 2052078, upload-time = "2026-04-17T09:10:19.96Z" }, + { url = "https://files.pythonhosted.org/packages/97/32/e19b83ceb07a3f1bb21798407790bbc9a31740158fd132b94139cb84e16c/pydantic_core-2.46.2-cp314-cp314t-win_arm64.whl", hash = "sha256:7dcb9d40930dfad7ab6b20bcc6ca9d2b030b0f347a0cd9909b54bd53ead521b1", size = 2016941, upload-time = "2026-04-17T09:12:34.447Z" }, ] [[package]] @@ -1748,11 +1847,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.10.1" +version = "2.12.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, ] [package.optional-dependencies] @@ -1778,15 +1877,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d5/6f/9ac2548e290764781f9e7e2aaf0685b086379dabfb29ca38536985471eaf/pylint-4.0.5-py3-none-any.whl", hash = "sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2", size = 536694, upload-time = "2026-02-20T09:07:31.028Z" }, ] -[[package]] -name = "pylint-per-file-ignores" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a5/3d/21bec2f2f432519616c34a64ba0766ef972fdfb6234a86bb1b8baf4b0c7c/pylint_per_file_ignores-1.4.0.tar.gz", hash = "sha256:c0de7b3d0169571aefaa1ac3a82a265641b8825b54a0b6f5ef27c3b76b988609", size = 4419, upload-time = "2025-01-17T21:35:02.383Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/0e/bf3473d86648a17e6dd6ee9e6abce526b077169031177f4f2031368f864a/pylint_per_file_ignores-1.4.0-py3-none-any.whl", hash = "sha256:0cd82d22551738b4e63a0aa1dab2a1fc4016e8f27f1429159616483711e122fd", size = 4888, upload-time = "2025-01-17T21:35:00.371Z" }, -] - [[package]] name = "pynacl" version = "1.6.2" @@ -1875,14 +1965,14 @@ wheels = [ [[package]] name = "pyopenssl" -version = "26.0.0" +version = "26.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8e/11/a62e1d33b373da2b2c2cd9eb508147871c80f12b1cacde3c5d314922afdd/pyopenssl-26.0.0.tar.gz", hash = "sha256:f293934e52936f2e3413b89c6ce36df66a0b34ae1ea3a053b8c5020ff2f513fc", size = 185534, upload-time = "2026-03-15T14:28:26.353Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/a8/26d36401e3ab8eed9030ad33f381da7856fcfad5691780fccd1b019718fc/pyopenssl-26.1.0.tar.gz", hash = "sha256:737f0a2275c5bc54f3b02137687e1a765931fb3949b9a92a825e4d33b9eec08b", size = 186181, upload-time = "2026-04-24T20:23:48.115Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/7d/d4f7d908fa8415571771b30669251d57c3cf313b36a856e6d7548ae01619/pyopenssl-26.0.0-py3-none-any.whl", hash = "sha256:df94d28498848b98cc1c0ffb8ef1e71e40210d3b0a8064c9d29571ed2904bf81", size = 57969, upload-time = "2026-03-15T14:28:24.864Z" }, + { url = "https://files.pythonhosted.org/packages/a8/41/52f3a3e812b816a91e89aa504199d8bf989a1f873192b10762be66cf2009/pyopenssl-26.1.0-py3-none-any.whl", hash = "sha256:115563879b2c8ccb207975705d3e491434d8c9d7c79667c902ecbf5f3bbd2ece", size = 58109, upload-time = "2026-04-24T20:23:46.273Z" }, ] [[package]] @@ -1915,7 +2005,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.0" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -1924,9 +2014,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/1d/eb34f286b164c5e431a810a38697409cca1112cee04b287bb56ac486730b/pytest-9.0.0.tar.gz", hash = "sha256:8f44522eafe4137b0f35c9ce3072931a788a21ee40a2ed279e817d3cc16ed21e", size = 1562764, upload-time = "2025-11-08T17:25:33.34Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/72/99/cafef234114a3b6d9f3aaed0723b437c40c57bdb7b3e4c3a575bc4890052/pytest-9.0.0-py3-none-any.whl", hash = "sha256:e5ccdf10b0bac554970ee88fc1a4ad0ee5d221f8ef22321f9b7e4584e19d7f96", size = 373364, upload-time = "2025-11-08T17:25:31.811Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] @@ -1957,16 +2047,16 @@ wheels = [ [[package]] name = "pytest-cov" -version = "7.0.0" +version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage" }, { name = "pluggy" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] [[package]] @@ -1984,19 +2074,19 @@ wheels = [ [[package]] name = "pytest-github-actions-annotate-failures" -version = "0.3.0" +version = "0.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/39/d4/c54ee6a871eee4a7468e3a8c0dead28e634c0bc2110c694309dcb7563a66/pytest_github_actions_annotate_failures-0.3.0.tar.gz", hash = "sha256:d4c3177c98046c3900a7f8ddebb22ea54b9f6822201b5d3ab8fcdea51e010db7", size = 11248, upload-time = "2025-01-17T22:39:32.722Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/e1/8f2c242e6d75a26a8e5ddcc23f652a411e4aac3eedc4b923808ac0582685/pytest_github_actions_annotate_failures-0.4.0.tar.gz", hash = "sha256:77d6baa29c8c61c2dacc494fa76eb95a185f0ee61666714ac43fb12ea672d217", size = 10857, upload-time = "2026-03-02T18:57:40.919Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/73/7b0b15cb8605ee967b34aa1d949737ab664f94e6b0f1534e8339d9e64ab2/pytest_github_actions_annotate_failures-0.3.0-py3-none-any.whl", hash = "sha256:41ea558ba10c332c0bfc053daeee0c85187507b2034e990f21e4f7e5fef044cf", size = 6030, upload-time = "2025-01-17T22:39:31.701Z" }, + { url = "https://files.pythonhosted.org/packages/0f/bd/11809f5c78d5d8da2d0e004845d2382768bc20798b3e0988bca61efd349f/pytest_github_actions_annotate_failures-0.4.0-py3-none-any.whl", hash = "sha256:285fed86e16b0b7a8eac6acdcde31913798fb739b15ef5b86895b4f5e32bf237", size = 6039, upload-time = "2026-03-02T18:57:39.991Z" }, ] [[package]] name = "pytest-homeassistant-custom-component" -version = "0.13.325" +version = "0.13.333" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohasupervisor" }, @@ -2009,7 +2099,6 @@ dependencies = [ { name = "paho-mqtt" }, { name = "pipdeptree" }, { name = "pydantic" }, - { name = "pylint-per-file-ignores" }, { name = "pytest" }, { name = "pytest-aiohttp" }, { name = "pytest-asyncio" }, @@ -2027,9 +2116,9 @@ dependencies = [ { name = "syrupy" }, { name = "tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f2/40/e14846938decc2073b78d2c88a08444973aae82b7fb263ba0073aca89793/pytest_homeassistant_custom_component-0.13.325.tar.gz", hash = "sha256:12924ad407b6601748d3a3c7883423fd0d34b1f782f14b615d1b61d994f371fc", size = 69950, upload-time = "2026-04-25T05:37:23.337Z" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bd/33710be5657204c4128c47e0e6da2b4b5bf43e685ad83ec2bed056d1076f/pytest_homeassistant_custom_component-0.13.333.tar.gz", hash = "sha256:e8326bc96cdf0d8147a15ba99c10199e01fff98db96065258f6ac6921da411b6", size = 77794, upload-time = "2026-05-23T06:04:38.997Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/c7/51cac710f1b6679550d04e162899dbdf4e7289ba715e5c4a9bff6bec7cfa/pytest_homeassistant_custom_component-0.13.325-py3-none-any.whl", hash = "sha256:9b758a092f74722e060d252ac80d71b6ae1ec2a712d55d0c9d378266e74e2c05", size = 75792, upload-time = "2026-04-25T05:37:21.529Z" }, + { url = "https://files.pythonhosted.org/packages/ca/51/1d15cceb7bd00ac50bdef5d40de4b17c2d5773f79bb16a73a4e4f58b2f7a/pytest_homeassistant_custom_component-0.13.333-py3-none-any.whl", hash = "sha256:53b6f110a1ea0c8fac52d131bbc1d4cb8d623afec602a62febd58e6939eb5eb8", size = 83728, upload-time = "2026-05-23T06:04:36.909Z" }, ] [[package]] @@ -2243,14 +2332,14 @@ wheels = [ [[package]] name = "respx" -version = "0.22.0" +version = "0.23.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f4/7c/96bd0bc759cf009675ad1ee1f96535edcb11e9666b985717eb8c87192a95/respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91", size = 28439, upload-time = "2024-12-19T22:33:59.374Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", size = 29243, upload-time = "2026-04-08T14:37:16.008Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127, upload-time = "2024-12-19T22:33:57.837Z" }, + { url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" }, ] [[package]] @@ -2305,15 +2394,15 @@ wheels = [ [[package]] name = "securetar" -version = "2026.4.0" +version = "2026.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pynacl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cb/25/138e19209d8f04765e6e51f94997d7e1141448103bae8ba30af98a755595/securetar-2026.4.0.tar.gz", hash = "sha256:d6e8d8c747655ac4564543f35f7fc70a2ebc7426f23c77f16238e7cb763691e8", size = 28254, upload-time = "2026-04-07T13:25:20.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/90/54/c7084733ce23f33c5f863734d6fd0c1789ba9ec771257cc54529c7a999ac/securetar-2026.4.1.tar.gz", hash = "sha256:536108c7cec0bdd6fb51abd8d293deba02930124d76ec5dd4599ae5e0c755638", size = 28289, upload-time = "2026-04-07T17:02:07.231Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/16/47a417d1264ad238191185ddd1a1d6585c6f33462e60408be09aee74147b/securetar-2026.4.0-py3-none-any.whl", hash = "sha256:72e09a864282ec96b05b5a61cc4febca7a1a7978b4083687369b6eb4835abe27", size = 19176, upload-time = "2026-04-07T13:25:19.803Z" }, + { url = "https://files.pythonhosted.org/packages/fb/56/4ef84868d1ff2f77ac080929117283ac9d18f7d018ae64fa1e7f957a8d20/securetar-2026.4.1-py3-none-any.whl", hash = "sha256:7cf66c204d678b9d1e2d17814c83048a6468e928ea00edb7e6c1efe72aab0540", size = 19339, upload-time = "2026-04-07T17:02:05.635Z" }, ] [[package]] @@ -2381,21 +2470,21 @@ dev = [ [package.metadata] requires-dist = [ - { name = "homeassistant", specifier = "==2026.4.4" }, - { name = "span-panel-api", editable = "../span-panel-api" }, + { name = "homeassistant", specifier = "==2026.5.4" }, + { name = "span-panel-api", editable = "../../span/span-panel-api-p2" }, ] [package.metadata.requires-dev] dev = [ { name = "bandit", extras = ["toml"], specifier = "==1.9.4" }, - { name = "homeassistant-stubs", specifier = "==2026.4.4" }, + { name = "homeassistant-stubs", specifier = "==2026.5.4" }, { name = "isort" }, { name = "mypy", specifier = "==1.20.2" }, { name = "prek", specifier = ">=0.3.11" }, { name = "prettier" }, { name = "pylint", specifier = "==4.0.5" }, { name = "pyright", specifier = "==1.1.409" }, - { name = "pytest-homeassistant-custom-component", specifier = ">=0.13.325" }, + { name = "pytest-homeassistant-custom-component", specifier = ">=0.13.333" }, { name = "python-direnv" }, { name = "radon", specifier = "==6.0.1" }, { name = "ruff", specifier = "==0.15.12" }, @@ -2407,8 +2496,8 @@ dev = [ [[package]] name = "span-panel-api" -version = "2.6.4" -source = { editable = "../span-panel-api" } +version = "3.0.0b3" +source = { editable = "../../span/span-panel-api-p2" } dependencies = [ { name = "httpx" }, { name = "paho-mqtt" }, @@ -2435,6 +2524,8 @@ dev = [ { name = "pytest-cov" }, { name = "radon" }, { name = "ruff", specifier = ">=0.15.5" }, + { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api-p2/packages/schema-0" }, + { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api-p2/packages/schema-1" }, { name = "twine" }, { name = "types-pyyaml", specifier = ">=6.0.12.20250915" }, { name = "vulture", specifier = ">=2.14" }, @@ -2442,14 +2533,28 @@ dev = [ [[package]] name = "sqlalchemy" -version = "2.0.41" +version = "2.0.49" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/63/66/45b165c595ec89aa7dcc2c1cd222ab269bc753f1fc7a1e68f8481bd957bf/sqlalchemy-2.0.41.tar.gz", hash = "sha256:edba70118c4be3c2b1f90754d308d0b79c6fe2c0fdc52d8ddf603916f83f4db9", size = 9689424, upload-time = "2025-05-14T17:10:32.339Z" } +sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/fc/9ba22f01b5cdacc8f5ed0d22304718d2c758fce3fd49a5372b886a86f37c/sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576", size = 1911224, upload-time = "2025-05-14T17:39:42.154Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e", size = 2156356, upload-time = "2026-04-03T16:53:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a7/5f476227576cb8644650eff68cc35fa837d3802b997465c96b8340ced1e2/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a", size = 3276486, upload-time = "2026-04-03T17:07:46.9Z" }, + { url = "https://files.pythonhosted.org/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066", size = 3281479, upload-time = "2026-04-03T17:12:32.226Z" }, + { url = "https://files.pythonhosted.org/packages/91/68/bb406fa4257099c67bd75f3f2261b129c63204b9155de0d450b37f004698/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187", size = 3226269, upload-time = "2026-04-03T17:07:48.678Z" }, + { url = "https://files.pythonhosted.org/packages/67/84/acb56c00cca9f251f437cb49e718e14f7687505749ea9255d7bd8158a6df/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401", size = 3248260, upload-time = "2026-04-03T17:12:34.381Z" }, + { url = "https://files.pythonhosted.org/packages/56/19/6a20ea25606d1efd7bd1862149bb2a22d1451c3f851d23d887969201633f/sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5", size = 2118463, upload-time = "2026-04-03T17:05:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5", size = 2144204, upload-time = "2026-04-03T17:05:48.694Z" }, + { url = "https://files.pythonhosted.org/packages/1f/33/95e7216df810c706e0cd3655a778604bbd319ed4f43333127d465a46862d/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977", size = 3565474, upload-time = "2026-04-03T16:58:35.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/a4/ed7b18d8ccf7f954a83af6bb73866f5bc6f5636f44c7731fbb741f72cc4f/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01", size = 3530567, upload-time = "2026-04-03T17:06:04.587Z" }, + { url = "https://files.pythonhosted.org/packages/73/a3/20faa869c7e21a827c4a2a42b41353a54b0f9f5e96df5087629c306df71e/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61", size = 3474282, upload-time = "2026-04-03T16:58:37.131Z" }, + { url = "https://files.pythonhosted.org/packages/b7/50/276b9a007aa0764304ad467eceb70b04822dc32092492ee5f322d559a4dc/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a", size = 3480406, upload-time = "2026-04-03T17:06:07.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/c3/c80fcdb41905a2df650c2a3e0337198b6848876e63d66fe9188ef9003d24/sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158", size = 2149151, upload-time = "2026-04-03T17:02:07.281Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/9f1a62feab6ed368aff068524ff414f26a6daebc7361861035ae00b05530/sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7", size = 2184178, upload-time = "2026-04-03T17:02:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" }, ] [[package]] @@ -2494,14 +2599,14 @@ wheels = [ [[package]] name = "syrupy" -version = "5.0.0" +version = "5.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c1/90/1a442d21527009d4b40f37fe50b606ebb68a6407142c2b5cc508c34b696b/syrupy-5.0.0.tar.gz", hash = "sha256:3282fe963fa5d4d3e47231b16d1d4d0f4523705e8199eeb99a22a1bc9f5942f2", size = 48881, upload-time = "2025-09-28T21:15:12.783Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/b0/24bca682d6a6337854be37f242d116cceeda9942571d5804c44bc1bdd427/syrupy-5.1.0.tar.gz", hash = "sha256:df543c7aa50d3cf1246e83d58fe490afe5f7dab7b41e74ecc0d8d23ae19bd4b8", size = 50495, upload-time = "2026-01-25T14:53:06.2Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/9a/6c68aad2ccfce6e2eeebbf5bb709d0240592eb51ff142ec4c8fbf3c2460a/syrupy-5.0.0-py3-none-any.whl", hash = "sha256:c848e1a980ca52a28715cd2d2b4d434db424699c05653bd1158fb31cf56e9546", size = 49087, upload-time = "2025-09-28T21:15:11.639Z" }, + { url = "https://files.pythonhosted.org/packages/de/70/cf880c3b95a6034ef673e74b369941b42315c01f1554a5637a4f8b911009/syrupy-5.1.0-py3-none-any.whl", hash = "sha256:95162d2b05e61ed3e13f117b88dfab7c58bd6f90e66ebbf918e8a77114ad51c5", size = 51658, upload-time = "2026-01-25T14:53:05.105Z" }, ] [[package]] @@ -2641,28 +2746,28 @@ wheels = [ [[package]] name = "uv" -version = "0.11.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dd/f3/8aceeab67ea69805293ab290e7ca8cc1b61a064d28b8a35c76d8eba063dd/uv-0.11.6.tar.gz", hash = "sha256:e3b21b7e80024c95ff339fcd147ac6fc3dd98d3613c9d45d3a1f4fd1057f127b", size = 4073298, upload-time = "2026-04-09T12:09:01.738Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/fe/4b61a3d5ad9d02e8a4405026ccd43593d7044598e0fa47d892d4dafe44c9/uv-0.11.6-py3-none-linux_armv6l.whl", hash = "sha256:ada04dcf89ddea5b69d27ac9cdc5ef575a82f90a209a1392e930de504b2321d6", size = 23780079, upload-time = "2026-04-09T12:08:56.609Z" }, - { url = "https://files.pythonhosted.org/packages/52/db/d27519a9e1a5ffee9d71af1a811ad0e19ce7ab9ae815453bef39dd479389/uv-0.11.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5be013888420f96879c6e0d3081e7bcf51b539b034a01777041934457dfbedf3", size = 23214721, upload-time = "2026-04-09T12:09:32.228Z" }, - { url = "https://files.pythonhosted.org/packages/a6/8f/4399fa8b882bd7e0efffc829f73ab24d117d490a93e6bc7104a50282b854/uv-0.11.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ffa5dc1cbb52bdce3b8447e83d1601a57ad4da6b523d77d4b47366db8b1ceb18", size = 21750109, upload-time = "2026-04-09T12:09:24.357Z" }, - { url = "https://files.pythonhosted.org/packages/32/07/5a12944c31c3dda253632da7a363edddb869ed47839d4d92a2dc5f546c93/uv-0.11.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:bfb107b4dade1d2c9e572992b06992d51dd5f2136eb8ceee9e62dd124289e825", size = 23551146, upload-time = "2026-04-09T12:09:10.439Z" }, - { url = "https://files.pythonhosted.org/packages/79/5b/2ec8b0af80acd1016ed596baf205ddc77b19ece288473b01926c4a9cf6db/uv-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:9e2fe7ce12161d8016b7deb1eaad7905a76ff7afec13383333ca75e0c4b5425d", size = 23331192, upload-time = "2026-04-09T12:09:34.792Z" }, - { url = "https://files.pythonhosted.org/packages/62/7d/eea35935f2112b21c296a3e42645f3e4b1aa8bcd34dcf13345fbd55134b7/uv-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ed9c6f70c25e8dfeedddf4eddaf14d353f5e6b0eb43da9a14d3a1033d51d915", size = 23337686, upload-time = "2026-04-09T12:09:18.522Z" }, - { url = "https://files.pythonhosted.org/packages/21/47/2584f5ab618f6ebe9bdefb2f765f2ca8540e9d739667606a916b35449eec/uv-0.11.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d68a013e609cebf82077cbeeb0809ed5e205257814273bfd31e02fc0353bbfc2", size = 25008139, upload-time = "2026-04-09T12:09:03.983Z" }, - { url = "https://files.pythonhosted.org/packages/95/81/497ae5c1d36355b56b97dc59f550c7e89d0291c163a3f203c6f341dff195/uv-0.11.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:93f736dddca03dae732c6fdea177328d3bc4bf137c75248f3d433c57416a4311", size = 25712458, upload-time = "2026-04-09T12:09:07.598Z" }, - { url = "https://files.pythonhosted.org/packages/3c/1c/74083238e4fab2672b63575b9008f1ea418b02a714bcfcf017f4f6a309b6/uv-0.11.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e96a66abe53fced0e3389008b8d2eff8278cfa8bb545d75631ae8ceb9c929aba", size = 24915507, upload-time = "2026-04-09T12:08:50.892Z" }, - { url = "https://files.pythonhosted.org/packages/5a/ee/e14fe10ba455a823ed18233f12de6699a601890905420b5c504abf115116/uv-0.11.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b096311b2743b228df911a19532b3f18fa420bf9530547aecd6a8e04bbfaccd", size = 24971011, upload-time = "2026-04-09T12:08:54.016Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/7b9c83eaadf98e343317ff6384a7227a4855afd02cdaf9696bcc71ee6155/uv-0.11.6-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:904d537b4a6e798015b4a64ff5622023bd4601b43b6cd1e5f423d63471f5e948", size = 23640234, upload-time = "2026-04-09T12:09:15.735Z" }, - { url = "https://files.pythonhosted.org/packages/d6/51/75ccdd23e76ff1703b70eb82881cd5b4d2a954c9679f8ef7e0136ef2cfab/uv-0.11.6-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:4ed8150c26b5e319381d75ae2ce6aba1e9c65888f4850f4e3b3fa839953c90a5", size = 24452664, upload-time = "2026-04-09T12:09:26.875Z" }, - { url = "https://files.pythonhosted.org/packages/4d/86/ace80fe47d8d48b5e3b5aee0b6eb1a49deaacc2313782870250b3faa36f5/uv-0.11.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1c9218c8d4ac35ca6e617fb0951cc0ab2d907c91a6aea2617de0a5494cf162c0", size = 24494599, upload-time = "2026-04-09T12:09:37.368Z" }, - { url = "https://files.pythonhosted.org/packages/05/2d/4b642669b56648194f026de79bc992cbfc3ac2318b0a8d435f3c284934e8/uv-0.11.6-py3-none-musllinux_1_1_i686.whl", hash = "sha256:9e211c83cc890c569b86a4183fcf5f8b6f0c7adc33a839b699a98d30f1310d3a", size = 24159150, upload-time = "2026-04-09T12:09:13.17Z" }, - { url = "https://files.pythonhosted.org/packages/ae/24/7eecd76fe983a74fed1fc700a14882e70c4e857f1d562a9f2303d4286c12/uv-0.11.6-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:d2a1d2089afdf117ad19a4c1dd36b8189c00ae1ad4135d3bfbfced82342595cf", size = 25164324, upload-time = "2026-04-09T12:08:59.56Z" }, - { url = "https://files.pythonhosted.org/packages/27/e0/bbd4ba7c2e5067bbba617d87d306ec146889edaeeaa2081d3e122178ca08/uv-0.11.6-py3-none-win32.whl", hash = "sha256:6e8344f38fa29f85dcfd3e62dc35a700d2448f8e90381077ef393438dcd5012e", size = 22865693, upload-time = "2026-04-09T12:09:21.415Z" }, - { url = "https://files.pythonhosted.org/packages/a5/33/1983ce113c538a856f2d620d16e39691962ecceef091a84086c5785e32e5/uv-0.11.6-py3-none-win_amd64.whl", hash = "sha256:a28bea69c1186303d1200f155c7a28c449f8a4431e458fcf89360cc7ef546e40", size = 25371258, upload-time = "2026-04-09T12:09:40.52Z" }, - { url = "https://files.pythonhosted.org/packages/35/01/be0873f44b9c9bc250fcbf263367fcfc1f59feab996355bcb6b52fff080d/uv-0.11.6-py3-none-win_arm64.whl", hash = "sha256:a78f6d64b9950e24061bc7ec7f15ff8089ad7f5a976e7b65fcadce58fe02f613", size = 23869585, upload-time = "2026-04-09T12:09:29.425Z" }, +version = "0.11.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c1/cd/4393fecb083897e956f016d4e66d0b8a496a08fe2e03cbda32a1e91da7ee/uv-0.11.8.tar.gz", hash = "sha256:bb2cf302b8503629aab6f0090a05551e6f8cfc2d687ca059cad7ec9e11214335", size = 4098020, upload-time = "2026-04-27T13:15:31.625Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/84/dcb676a3e36a3a2b44dc2e4dfea471b8cd709025e27cce3e588b176fd899/uv-0.11.8-py3-none-linux_armv6l.whl", hash = "sha256:a53e704a780a9e78a50f5a880e99a690f84e6fb9e82610903ce26f47c271d74c", size = 23664296, upload-time = "2026-04-27T13:15:15.644Z" }, + { url = "https://files.pythonhosted.org/packages/86/05/557aa070fda7b8460bbbe1e867e8e5b80602c5b30ed77d1d94fc5acae518/uv-0.11.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d414fc3795b6f56fb6b1fa359537930924fdfe857750a144d2aedf3077be3f1d", size = 23087321, upload-time = "2026-04-27T13:15:36.193Z" }, + { url = "https://files.pythonhosted.org/packages/d5/62/82953018801a250e16b091ef4b5e95e939b2f01224363d6fc80f600b7eff/uv-0.11.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f0d402e182ab581e934c159cc9edf25ec6e08d32f29aa797980e949afefc87cd", size = 21747142, upload-time = "2026-04-27T13:15:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/af/4c/477f2abe16f9a3d3c73077f15615878a303eef3760115ec946be58ecb9b2/uv-0.11.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:877c9af3b3955a35ef739e5b2ba79c56dae5c4d50420a7ed908c0901e1c8c807", size = 23425861, upload-time = "2026-04-27T13:15:10.374Z" }, + { url = "https://files.pythonhosted.org/packages/2a/63/19f46193e49f0c9bf33346a4d726313871864db16e7cdd1c0a63bc112000/uv-0.11.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:8278144df8d80a83f770c264a5e79ea50791316d2a0dda869e53b3c1174142a8", size = 23215551, upload-time = "2026-04-27T13:15:38.706Z" }, + { url = "https://files.pythonhosted.org/packages/72/3e/5595b265df848a33cd060b10e8f763a46d67521ac9f6c314e8a4ad5329d7/uv-0.11.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3494ad32465f4e02259cfb104d24efe5bb8f7a782351f0354de9385415fb310", size = 23224170, upload-time = "2026-04-27T13:15:18.083Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b3/6ca95e690b52542caa1dae10ede57732f90c629946ab5f027ff746f87deb/uv-0.11.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a4421e27e81f85bce3bdb75986c38b5f9bfab9cdccaf3d977cf124b3f0f0b989", size = 24730048, upload-time = "2026-04-27T13:15:13.254Z" }, + { url = "https://files.pythonhosted.org/packages/ea/49/71b7322067c85a3736a22a300072b0566991fe3f95b81bed793508ff5315/uv-0.11.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91943e77fc962752d4f64ad5739219858395981078051c740b28b52963b366aa", size = 25585906, upload-time = "2026-04-27T13:15:41.455Z" }, + { url = "https://files.pythonhosted.org/packages/37/16/4e84cd5131327fe86d4784ebfc8a983149f4e6b811476ef271fc548b29e6/uv-0.11.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:41fbba287efcc9bc9505a60549b3a223220da720eacd03be8c23d9daaafa44f4", size = 24795740, upload-time = "2026-04-27T13:15:49.842Z" }, + { url = "https://files.pythonhosted.org/packages/5b/01/df175979018743cc5ba6e2fb9dcec916868271e8d88cf0b9df8fd805a0df/uv-0.11.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d97bb2920d6cddc07faa475013461294cc09b77ec8139278416c6e54b938d037", size = 24824980, upload-time = "2026-04-27T13:15:53.506Z" }, + { url = "https://files.pythonhosted.org/packages/1c/95/93c7f595f7136fb32807442860c55d0faed2cd3d7da4b7105ed3c2535d5f/uv-0.11.8-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:fb6a755305eb1e081dfe6a8bc007dbae2d26fe75e551656ca7c9cd08fba21d26", size = 23526790, upload-time = "2026-04-27T13:15:04.955Z" }, + { url = "https://files.pythonhosted.org/packages/04/02/77430b89e172c20cc549b07a5b1dfda0c882c161b6d82781d3150a7063ac/uv-0.11.8-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:841ecbb38532698f73b14b49dc5f0c5e756194c7fcf6e5c6b7ed3859200fe91b", size = 24280498, upload-time = "2026-04-27T13:15:43.978Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e3/23e4a2bb91e3880e017e6116886e2d0bde14ba6aa95ddc458160ee630e7c/uv-0.11.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b3ff2b20c1897105ebe7ed7f9b1b331c7171da029bc1e35970ce31dc086141c1", size = 24375233, upload-time = "2026-04-27T13:15:25.753Z" }, + { url = "https://files.pythonhosted.org/packages/d9/67/fb7dc17cea816a667d1be2632525aa1687566bfafd17bdac561a7a6c9484/uv-0.11.8-py3-none-musllinux_1_1_i686.whl", hash = "sha256:ad381228b0170ef9646902c7e908d4a10a7ecc3da8139450506cf70c7e7f3e80", size = 23904818, upload-time = "2026-04-27T13:15:23.21Z" }, + { url = "https://files.pythonhosted.org/packages/4b/91/b920e35f54f8c6b51f2c639e8170bb80a47a739a1442fea33a479bc93a3d/uv-0.11.8-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:0172b5215544844cd3db0fa3c73a2eb74999b3f00cd2527dde578725076d7b65", size = 25015448, upload-time = "2026-04-27T13:15:46.666Z" }, + { url = "https://files.pythonhosted.org/packages/05/e8/3771956dc1c94b8484789bb8070d91872080d0af99332b8bdec7218c2bfd/uv-0.11.8-py3-none-win32.whl", hash = "sha256:e71c1dd23cbb480f3952c3a95b4fd00f96bd618e2a94583fc9388c500af3070d", size = 22823583, upload-time = "2026-04-27T13:15:33.674Z" }, + { url = "https://files.pythonhosted.org/packages/f9/9b/a91a9c60dcae0e1e3da06377d38f32118a523697d461fe41bc9f117ecf59/uv-0.11.8-py3-none-win_amd64.whl", hash = "sha256:306c624c68d95dd7ea3647675323d72c1abc25f91c3e92ae4cd6f0f11b508726", size = 25407438, upload-time = "2026-04-27T13:15:28.957Z" }, + { url = "https://files.pythonhosted.org/packages/61/5d/defa29fe617e6f07d4e514089e9d36fd9f44ede869e597e39ff7d69f6917/uv-0.11.8-py3-none-win_arm64.whl", hash = "sha256:a9853456696d579f206135c9dda7227a6ed8311b8a9a0b9b2008c4ae81950efe", size = 23914243, upload-time = "2026-04-27T13:15:07.717Z" }, ] [[package]] @@ -2676,14 +2781,14 @@ wheels = [ [[package]] name = "voluptuous-openapi" -version = "0.2.0" +version = "0.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "voluptuous" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/15/ac7a98afd478e9afc804354fe9d9715e0e560a590fdd425b22b65a152bb3/voluptuous_openapi-0.2.0.tar.gz", hash = "sha256:2366be934c37bb5fd8ed6bd5a2a46b1079b57dfbdf8c6c02e88f4ca13e975073", size = 15789, upload-time = "2025-08-21T04:49:16.755Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/5f/e77f088f4eff2e0f572f651bb9c2259561ec32d0bff20f59dd107e392ad8/voluptuous_openapi-0.3.0.tar.gz", hash = "sha256:1ecd2386e63b59b791e0f64715e317c7018a2e5598333138bd1f28adfd348d5c", size = 17467, upload-time = "2025-12-31T00:40:24.296Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/72/eb/2ae58431a078318f03267f196137282ea6f01ea7f7e0fcba2b25a30b0bf2/voluptuous_openapi-0.2.0-py3-none-any.whl", hash = "sha256:d51f07be8af44b11570b7366785d90daa716b7fd11ea2845803763ae551f35cf", size = 10180, upload-time = "2025-08-21T04:49:15.885Z" }, + { url = "https://files.pythonhosted.org/packages/ad/2d/3a8728e638bac8fd5857bb7775e39a9a520ae90b1d6dffd8badfa9852ccc/voluptuous_openapi-0.3.0-py3-none-any.whl", hash = "sha256:db35598e9fc711cbaf7d7732de160de3edf89cc7411b6febe5a132f1f54ea931", size = 10896, upload-time = "2025-12-31T00:40:23.067Z" }, ] [[package]] From a074d6bc783d89b7f070e0c38743021b3eb54f58 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:47:18 -0700 Subject: [PATCH 004/345] feat: surface the MID as a device, with utility-supply health as its one new entity v1.0 publishes a Microgrid Interconnect Device and puts the `grid` capability on it rather than on the enclosure -- "the enclosure device itself does not publish them" -- so islanding decisions belong to hardware with an identity of its own. The library started carrying it in 3.0.0b3; this registers it as a Home Assistant device and gives it the one fact that is genuinely new. **Only `grid_state`.** Utility-supply health -- UP / DOWN / DEGRADED -- has no flat equivalent. Islanding state and the grid-forming entity are deliberately *not* duplicated onto this device: both already reach a user as `dsm_state` and `grid_forming_entity` on the panel, and those must keep their ids and their history. Showing a user the same fact twice is not the benign cell of the absorb-or-surface policy; adding a fact nobody had is. **Gated on what the snapshot carries, never on a version or a flag.** This integration has to serve flat and parent/child panels side by side until every panel has hot-loaded v1.0, and a flat panel publishes no MID at all -- so `has_mid` is false everywhere today and nothing a user has changes. A panel that hot-loads v1.0 mid-life gains `mid` as a capability, the coordinator reloads, and the device appears; one that never does never sees it. Every such branch is marked `DUAL-SCHEMA` and a test asserts the exact set of modules carrying that marker. Asserted rather than left to convention because the failure mode is silent: a later addition that assumed parent/child would work on a developer's panel and break on everyone else's, with nothing to say so. When flat support is finally dropped, grepping the token finds every conditional to make unconditional. `has_mid` needs no sentinel, unlike `has_bess`, which infers presence from `soe_percentage is not None` because the battery field is always there. The library made `mid` optional precisely so presence is unambiguous. `via_device` is the panel, matching BESS and EVSE, even though the wire tree makes the MID a child of the BESS. Home Assistant's device graph is what a user navigates and every SPAN sub-device hangs off the panel there; mirroring Homie parentage would put the MID a level deeper than its siblings for no reader's benefit. Also drops the `getattr` from `_grid_forming_device_name`: with the pin at 3.0.0b3 the field always exists, so the remaining check is about what the panel publishes rather than which library is installed. Translations for all five locales, since the enum states render to users. 676 passed, mypy clean. Falsified: removing the `has_mid` gate fails both flat-panel tests. --- .../span_panel/entity_resolver.py | 12 ++ custom_components/span_panel/helpers.py | 27 +++- custom_components/span_panel/id_builder.py | 12 ++ custom_components/span_panel/sensor.py | 45 +++++- .../span_panel/sensor_definitions.py | 36 +++++ custom_components/span_panel/sensor_panel.py | 63 +++++++- custom_components/span_panel/strings.json | 9 ++ .../span_panel/translations/en.json | 9 ++ .../span_panel/translations/es.json | 9 ++ .../span_panel/translations/fr.json | 9 ++ .../span_panel/translations/ja.json | 9 ++ .../span_panel/translations/pt.json | 9 ++ custom_components/span_panel/util.py | 37 ++++- tests/factories.py | 4 + tests/test_mid_device.py | 148 ++++++++++++++++++ 15 files changed, 428 insertions(+), 10 deletions(-) create mode 100644 tests/test_mid_device.py diff --git a/custom_components/span_panel/entity_resolver.py b/custom_components/span_panel/entity_resolver.py index 7cd2032d..88458876 100644 --- a/custom_components/span_panel/entity_resolver.py +++ b/custom_components/span_panel/entity_resolver.py @@ -20,6 +20,7 @@ build_binary_sensor_unique_id, build_circuit_unique_id, build_evse_unique_id, + build_mid_unique_id, build_panel_unique_id, build_select_unique_id, build_switch_unique_id, @@ -154,6 +155,17 @@ def build_bess_unique_id_for_entry( return build_bess_unique_id(identifier, description_key) +def build_mid_unique_id_for_entry( + coordinator: SpanPanelCoordinator, + snapshot: SpanPanelSnapshot, + description_key: str, + device_name: str | None = None, +) -> str: + """Build MID unique_id using the panel serial from the snapshot.""" + identifier = _get_device_identifier_for_unique_ids(coordinator, snapshot, device_name) + return build_mid_unique_id(identifier, description_key) + + def get_device_identifier_for_entry( coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot, diff --git a/custom_components/span_panel/helpers.py b/custom_components/span_panel/helpers.py index 2620c82b..2bcc2e20 100644 --- a/custom_components/span_panel/helpers.py +++ b/custom_components/span_panel/helpers.py @@ -15,6 +15,7 @@ build_bess_unique_id_for_entry, build_binary_sensor_unique_id_for_entry, build_evse_unique_id_for_entry, + build_mid_unique_id_for_entry, build_select_unique_id_for_entry, build_switch_unique_id_for_entry, construct_circuit_unique_id_for_entry, @@ -37,6 +38,7 @@ build_binary_sensor_unique_id, build_circuit_unique_id, build_evse_unique_id, + build_mid_unique_id, build_panel_unique_id, build_select_unique_id, build_switch_unique_id, @@ -59,7 +61,9 @@ "PANEL_SUFFIX_MAPPING", "async_create_span_notification", "build_bess_unique_id", + "build_mid_unique_id", "build_bess_unique_id_for_entry", + "build_mid_unique_id_for_entry", "build_binary_sensor_unique_id", "build_binary_sensor_unique_id_for_entry", "build_circuit_unique_id", @@ -95,6 +99,7 @@ "get_user_friendly_suffix", "has_bess", "has_evse", + "has_mid", "has_power_flows", "has_pv", "is_panel_level_sensor_key", @@ -251,6 +256,24 @@ def has_power_flows(snapshot: SpanPanelSnapshot) -> bool: return snapshot.power_flow_site is not None +def has_mid(snapshot: SpanPanelSnapshot) -> bool: + """Detect whether a Microgrid Interconnect Device is published. + + Unambiguous, unlike `has_bess`, which has to infer presence from + `soe_percentage is not None` because the battery field is always there. The library + makes `mid` optional precisely so presence needs no sentinel. + + Always false on flat firmware, which publishes no MID at all. + + DUAL-SCHEMA: this integration must serve flat and parent/child panels side by side until + every panel has hot-loaded v1.0. Grep this token to find every place that branches on + which schema a panel is publishing; when the flat path is finally retired, these are + the conditionals that become unconditional and the flat branches that get deleted. + Nothing here may *assume* parent/child before then. + """ + return snapshot.mid is not None + + def has_evse(snapshot: SpanPanelSnapshot) -> bool: """Detect whether an EVSE (EV charger) is commissioned.""" return len(snapshot.evse) > 0 @@ -259,7 +282,7 @@ def has_evse(snapshot: SpanPanelSnapshot) -> bool: def detect_capabilities(snapshot: SpanPanelSnapshot) -> frozenset[str]: """Derive the set of optional capabilities present in the snapshot. - Used by the coordinator to detect when new hardware (BESS, PV, EVSE) appears + Used by the coordinator to detect when new hardware (BESS, PV, EVSE, MID) appears and trigger a reload so new sensors are created. """ caps: set[str] = set() @@ -271,4 +294,6 @@ def detect_capabilities(snapshot: SpanPanelSnapshot) -> frozenset[str]: caps.add("power_flows") if has_evse(snapshot): caps.add("evse") + if has_mid(snapshot): + caps.add("mid") return frozenset(caps) diff --git a/custom_components/span_panel/id_builder.py b/custom_components/span_panel/id_builder.py index b790883c..b474a733 100644 --- a/custom_components/span_panel/id_builder.py +++ b/custom_components/span_panel/id_builder.py @@ -314,6 +314,18 @@ def build_bess_unique_id(serial: str, description_key: str) -> str: return f"span_{serial}_bess_{description_key}" +def build_mid_unique_id(serial: str, description_key: str) -> str: + """Build unique ID for Microgrid Interconnect Device sensors (pure function). + + Returns: "span_{serial}_mid_{description_key}" + + Keyed on the panel serial rather than the MID's own, matching `build_bess_unique_id`. + The MID is one per enclosure, so the panel serial already makes it unique, and a + device-derived key would move if the BESS the MID ships with were ever replaced. + """ + return f"span_{serial}_mid_{description_key}" + + def build_evse_unique_id(serial: str, evse_id: str, description_key: str) -> str: """Build unique ID for EVSE sensor/binary_sensor entities (pure function). diff --git a/custom_components/span_panel/sensor.py b/custom_components/span_panel/sensor.py index 928c6b88..e546d1a0 100644 --- a/custom_components/span_panel/sensor.py +++ b/custom_components/span_panel/sensor.py @@ -22,6 +22,7 @@ from .helpers import ( has_bess, has_evse, + has_mid, has_power_flows, has_pv, resolve_evse_display_suffix, @@ -46,6 +47,7 @@ L1_VOLTAGE_SENSOR, L2_VOLTAGE_SENSOR, MAIN_BREAKER_RATING_SENSOR, + MID_SENSORS, PANEL_DATA_STATUS_SENSORS, PANEL_ENERGY_SENSORS, PANEL_POWER_SENSORS, @@ -60,6 +62,7 @@ from .sensor_evse import SpanEvseSensor from .sensor_panel import ( SpanBessMetadataSensor, + SpanMidSensor, SpanPanelBattery, SpanPanelEnergySensor, SpanPanelPanelStatus, @@ -67,11 +70,12 @@ SpanPanelStatus, SpanPVMetadataSensor, ) -from .util import bess_device_info, evse_device_info +from .util import bess_device_info, evse_device_info, mid_device_info # Export the sensor classes for backward compatibility with tests __all__ = [ "SpanBessMetadataSensor", + "SpanMidSensor", "SpanCircuitEnergySensor", "SpanCircuitPowerSensor", "SpanEnergySensorBase", @@ -304,6 +308,42 @@ def _build_bess_device_info( return bess_device_info(snapshot.serial_number, snapshot.battery, panel_name) +def _build_mid_device_info( + coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot +) -> DeviceInfo: + """DeviceInfo for the Microgrid Interconnect sub-device.""" + panel_name = ( + coordinator.config_entry.data.get(CONF_DEVICE_NAME, coordinator.config_entry.title) + or "Span Panel" + ) + mid = snapshot.mid + if mid is None: + raise ValueError("cannot build MID device info for a snapshot with no MID") + return mid_device_info(snapshot.serial_number, mid, panel_name) + + +def create_mid_sensors( + coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot +) -> list[SpanMidSensor]: + """Create the Microgrid Interconnect sub-device and its sensors. + + v1.0 only, and purely additive: no flat panel publishes a MID, so `has_mid` is false + everywhere today and nothing a user has changes. Presence is the library's optional + `mid` field rather than a sentinel value, so there is nothing to infer. + + DUAL-SCHEMA: gated on what the snapshot carries, never on a version or a config flag. A + panel that hot-loads parent/child mid-life gains the MID as a capability change, the + coordinator reloads, and the device appears; one that never does simply never sees + it. When the flat path retires, this gate can go and the sensors become + unconditional. + """ + if not has_mid(snapshot): + return [] + + mid_info = _build_mid_device_info(coordinator, snapshot) + return [SpanMidSensor(coordinator, desc, snapshot, mid_info) for desc in MID_SENSORS] + + def create_battery_sensors( coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot ) -> list[SpanPanelBattery | SpanPanelPowerSensor | SpanBessMetadataSensor]: @@ -389,6 +429,7 @@ def create_native_sensors( | SpanBessMetadataSensor | SpanPVMetadataSensor | SpanEvseSensor + | SpanMidSensor ]: """Create all native sensors for the platform.""" entities: list[ @@ -403,6 +444,7 @@ def create_native_sensors( | SpanBessMetadataSensor | SpanPVMetadataSensor | SpanEvseSensor + | SpanMidSensor ] = [] # Create different sensor types @@ -411,6 +453,7 @@ def create_native_sensors( if config_entry.options.get(ENABLE_UNMAPPED_CIRCUIT_SENSORS, False): entities.extend(create_unmapped_circuit_sensors(coordinator, snapshot)) entities.extend(create_battery_sensors(coordinator, snapshot)) + entities.extend(create_mid_sensors(coordinator, snapshot)) entities.extend(create_power_flow_sensors(coordinator, snapshot)) entities.extend(create_evse_sensors(coordinator, snapshot)) diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py index d289141b..87b3d0c5 100644 --- a/custom_components/span_panel/sensor_definitions.py +++ b/custom_components/span_panel/sensor_definitions.py @@ -31,6 +31,7 @@ SpanBatterySnapshot, SpanCircuitSnapshot, SpanEvseSnapshot, + SpanMidSnapshot, SpanPanelSnapshot, ) @@ -357,6 +358,41 @@ class SpanBessMetadataSensorEntityDescription( """Describes a BESS metadata sensor entity.""" +@dataclass(frozen=True) +class SpanMidRequiredKeysMixin: + """Required keys mixin for MID sensors.""" + + value_fn: Callable[[SpanMidSnapshot], str | None] + + +@dataclass(frozen=True) +class SpanMidSensorEntityDescription(SensorEntityDescription, SpanMidRequiredKeysMixin): + """Describes a sensor on the Microgrid Interconnect Device.""" + + +MID_SENSORS: tuple[SpanMidSensorEntityDescription, ...] = ( + SpanMidSensorEntityDescription( + key="mid_grid_state", + translation_key="mid_grid_state", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=["up", "down", "degraded", "unknown"], + value_fn=lambda m: (m.grid_state or "unknown").lower(), + ), +) +"""Sensors on the MID device. + +Only `grid-state` — utility-supply health, genuinely new in v1.0 with no flat +equivalent, and the one non-metadata addition the MID brings. + +Islanding state and the grid-forming entity are deliberately *not* duplicated here. +Both already reach a user through entities that predate v1.0 — `dsm_state` and +`grid_forming_entity` on the panel — and those must keep their ids and their history. +Showing the same fact twice is not the benign cell of the absorb-or-surface policy; +adding a fact nobody had is. +""" + + BESS_METADATA_SENSORS: tuple[ SpanBessMetadataSensorEntityDescription, SpanBessMetadataSensorEntityDescription, diff --git a/custom_components/span_panel/sensor_panel.py b/custom_components/span_panel/sensor_panel.py index b4c1f118..e49539ed 100644 --- a/custom_components/span_panel/sensor_panel.py +++ b/custom_components/span_panel/sensor_panel.py @@ -7,11 +7,12 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.typing import UNDEFINED -from span_panel_api import SpanBatterySnapshot, SpanPanelSnapshot +from span_panel_api import SpanBatterySnapshot, SpanMidSnapshot, SpanPanelSnapshot from .coordinator import SpanPanelCoordinator from .helpers import ( build_bess_unique_id_for_entry, + build_mid_unique_id_for_entry, construct_panel_unique_id_for_entry, construct_synthetic_unique_id_for_entry, get_panel_entity_suffix, @@ -19,6 +20,7 @@ from .sensor_base import SpanEnergySensorBase, SpanSensorBase from .sensor_definitions import ( SpanBessMetadataSensorEntityDescription, + SpanMidSensorEntityDescription, SpanPanelBatterySensorEntityDescription, SpanPanelDataSensorEntityDescription, SpanPanelStatusSensorEntityDescription, @@ -41,15 +43,15 @@ def _grid_forming_device_name(snapshot: SpanPanelSnapshot) -> str | None: a Homie device id, not a Home Assistant one, and an opaque string on a dashboard is worse than none. The id stays in the snapshot for correlation and diagnostics. - `getattr` because `mid` arrived in span-panel-api 3.0.0b3 and this integration still - pins 2.6.4, where the field does not exist. Reads `None` there, which is also what it - reads on any flat panel — no flat firmware publishes a MID at all. + DUAL-SCHEMA: `None` on any flat panel, which publishes no MID, so the attribute simply + does not appear there. The library field is always present now that the pin is + 3.0.0b3 — the conditional is about what the *panel* publishes, not about which + library is installed, and the earlier `getattr` guarding the latter has gone. """ - mid = getattr(snapshot, "mid", None) + mid = snapshot.mid if mid is None: return None - name: str | None = getattr(mid, "grid_forming_device_name", None) - return name + return mid.grid_forming_device_name class SpanPanelPanelStatus(SpanSensorBase[SpanPanelDataSensorEntityDescription, SpanPanelSnapshot]): @@ -348,6 +350,53 @@ def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanBatterySnapshot: return snapshot.battery +class SpanMidSensor(SpanSensorBase[SpanMidSensorEntityDescription, SpanMidSnapshot]): + """A sensor on the Microgrid Interconnect Device sub-device.""" + + def __init__( + self, + data_coordinator: SpanPanelCoordinator, + description: SpanMidSensorEntityDescription, + snapshot: SpanPanelSnapshot, + device_info_override: DeviceInfo, + ) -> None: + """Initialize the MID sensor.""" + super().__init__(data_coordinator, description, snapshot) + self._attr_device_info = device_info_override + + def _generate_unique_id( + self, + snapshot: SpanPanelSnapshot, + description: SpanMidSensorEntityDescription, + ) -> str: + """Generate unique ID for MID sensors.""" + return build_mid_unique_id_for_entry( + self.coordinator, snapshot, description.key, self._device_name + ) + + def _generate_friendly_name( + self, + snapshot: SpanPanelSnapshot, + description: SpanMidSensorEntityDescription, + ) -> str: + """Generate friendly name for MID sensors.""" + if description.name is not None and description.name is not UNDEFINED: + return str(description.name) + return "MID Sensor" + + def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanMidSnapshot: + """Get the data source for the MID sensor. + + The MID is optional, so a snapshot without one has no data source. Entities are + only created when `has_mid` is true, and a panel that stops publishing its MID + makes them unavailable rather than reaching this. + """ + mid = snapshot.mid + if mid is None: + raise ValueError("MID sensor asked for a data source on a snapshot with no MID") + return mid + + class SpanPVMetadataSensor( SpanSensorBase[SpanPVMetadataSensorEntityDescription, SpanPanelSnapshot] ): diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index e374babf..d0944208 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -189,6 +189,15 @@ "bess_vendor": { "name": "Vendor" }, + "mid_grid_state": { + "name": "Grid State", + "state": { + "up": "Up", + "down": "Down", + "degraded": "Degraded", + "unknown": "Unknown" + } + }, "current_run_config": { "name": "Current Run Config", "state": { diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index 040332a9..f3427945 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -189,6 +189,15 @@ "bess_vendor": { "name": "Vendor" }, + "mid_grid_state": { + "name": "Grid State", + "state": { + "up": "Up", + "down": "Down", + "degraded": "Degraded", + "unknown": "Unknown" + } + }, "current_run_config": { "name": "Current Run Config", "state": { diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index 3bb65e2e..7a752fa7 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -301,6 +301,15 @@ "unknown": "Desconocido" } }, + "mid_grid_state": { + "name": "Estado de la red", + "state": { + "up": "Activa", + "down": "Caída", + "degraded": "Degradada", + "unknown": "Desconocido" + } + }, "pv_nameplate_capacity": { "name": "Capacidad Nominal PV" }, diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index 712bef06..21a7803d 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -301,6 +301,15 @@ "unknown": "Inconnu" } }, + "mid_grid_state": { + "name": "État du réseau", + "state": { + "up": "Actif", + "down": "Coupé", + "degraded": "Dégradé", + "unknown": "Inconnu" + } + }, "pv_nameplate_capacity": { "name": "Capacité Nominale PV" }, diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index 8879fc0e..f18c9104 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -301,6 +301,15 @@ "unknown": "不明" } }, + "mid_grid_state": { + "name": "系統状態", + "state": { + "up": "正常", + "down": "停電", + "degraded": "劣化", + "unknown": "不明" + } + }, "pv_nameplate_capacity": { "name": "PV定格容量" }, diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index 3898d7f0..28be7c60 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -301,6 +301,15 @@ "unknown": "Desconhecido" } }, + "mid_grid_state": { + "name": "Estado da rede", + "state": { + "up": "Ativa", + "down": "Em falha", + "degraded": "Degradada", + "unknown": "Desconhecido" + } + }, "pv_nameplate_capacity": { "name": "Capacidade Nominal PV" }, diff --git a/custom_components/span_panel/util.py b/custom_components/span_panel/util.py index 71f6999f..889f6776 100644 --- a/custom_components/span_panel/util.py +++ b/custom_components/span_panel/util.py @@ -3,7 +3,12 @@ import logging from homeassistant.helpers.device_registry import DeviceInfo -from span_panel_api import SpanBatterySnapshot, SpanEvseSnapshot, SpanPanelSnapshot +from span_panel_api import ( + SpanBatterySnapshot, + SpanEvseSnapshot, + SpanMidSnapshot, + SpanPanelSnapshot, +) from .const import DOMAIN @@ -48,6 +53,36 @@ def bess_device_info( ) +def mid_device_info( + panel_identifier: str, + mid: SpanMidSnapshot, + panel_name: str, +) -> DeviceInfo: + """Create DeviceInfo for the Microgrid Interconnect Device. + + v1.0 publishes the MID as a device of its own and puts the `grid` capability on it + rather than on the enclosure — "the enclosure device itself does not publish them" — + so islanding decisions belong to hardware with its own identity. Registering it here + keeps the integration's model in step with the library's rather than folding a + device's properties onto the panel. + + Purely additive: no flat panel publishes a MID, so nothing a user has today changes. + + `via_device` the panel, matching BESS and EVSE, even though the wire tree makes the + MID a child of the BESS. Home Assistant's device graph is about what a user navigates, + and every SPAN sub-device hangs off the panel there; mirroring the Homie parentage + would put the MID one level deeper than its siblings for no reader's benefit. + """ + return DeviceInfo( + identifiers={(DOMAIN, f"{panel_identifier}_mid")}, + name=f"{panel_name} Microgrid Interconnect", + manufacturer=mid.vendor_name or "Unknown", + model=mid.model or "Microgrid Interconnect Device", + serial_number=mid.serial_number, + via_device=(DOMAIN, panel_identifier), + ) + + def evse_device_info( panel_identifier: str, evse: SpanEvseSnapshot, diff --git a/tests/factories.py b/tests/factories.py index 5a2b37d9..2594a44c 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -10,6 +10,7 @@ SpanBatterySnapshot, SpanCircuitSnapshot, SpanEvseSnapshot, + SpanMidSnapshot, SpanPanelSnapshot, SpanPVSnapshot, ) @@ -221,6 +222,8 @@ def create( wwan_link: bool = False, circuits: dict[str, SpanCircuitSnapshot] | None = None, battery: SpanBatterySnapshot | None = None, + # v1.0 only. Defaults to None so every existing fixture stays a flat panel. + mid: SpanMidSnapshot | None = None, dominant_power_source: str | None = None, grid_state: str | None = None, grid_islandable: bool | None = None, @@ -270,6 +273,7 @@ def create( wwan_link=wwan_link, circuits=circuits, battery=battery, + mid=mid, dominant_power_source=dominant_power_source, grid_state=grid_state, grid_islandable=grid_islandable, diff --git a/tests/test_mid_device.py b/tests/test_mid_device.py new file mode 100644 index 00000000..fa221b17 --- /dev/null +++ b/tests/test_mid_device.py @@ -0,0 +1,148 @@ +"""The Microgrid Interconnect Device, surfaced as its own device. + +v1.0 publishes a MID and puts the `grid` capability on it rather than on the enclosure. +Everything here is additive: no flat panel publishes a MID, so `has_mid` is false on +every existing install and nothing a user has today changes. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock + +from span_panel_api import SpanMidSnapshot + +from custom_components.span_panel.helpers import detect_capabilities, has_mid +from custom_components.span_panel.sensor import create_mid_sensors +from custom_components.span_panel.sensor_definitions import MID_SENSORS +from custom_components.span_panel.sensor_panel import _grid_forming_device_name +from custom_components.span_panel.util import mid_device_info + +from .factories import SpanPanelSnapshotFactory + + +def _mid(**overrides: str | None) -> SpanMidSnapshot: + defaults: dict[str, str | None] = { + "node_id": "SIM-BESS-40T-001-mid", + "serial_number": "SIM-BESS-40T-001-mid", + "vendor_name": "Span", + "model": None, + "islanding_state": "ON_GRID", + "grid_state": "UP", + "grid_forming_entity": "GRID", + } + defaults.update(overrides) + return SpanMidSnapshot(**defaults) # type: ignore[arg-type] + + +def _coordinator() -> MagicMock: + coordinator = MagicMock() + coordinator.config_entry = MagicMock() + coordinator.config_entry.title = "SPAN Panel" + coordinator.config_entry.data = {} + return coordinator + + +def test_a_flat_panel_has_no_mid() -> None: + """The whole reason this is additive rather than a migration risk.""" + assert has_mid(SpanPanelSnapshotFactory.create()) is False + assert create_mid_sensors(_coordinator(), SpanPanelSnapshotFactory.create()) == [] + + +def test_presence_needs_no_sentinel() -> None: + """`snapshot.mid is not None`, unlike `has_bess`, which infers from soe_percentage + because the battery field is always present. + """ + assert has_mid(SpanPanelSnapshotFactory.create(mid=_mid())) is True + + +def test_the_mid_becomes_its_own_device_hung_off_the_panel() -> None: + """Identity renders on a device card rather than being folded onto the panel. + + `via_device` is the panel even though the wire tree makes the MID a child of the + BESS: Home Assistant's device graph is what a user navigates, and every SPAN + sub-device hangs off the panel there. + """ + info = mid_device_info("sim-40t-001", _mid(), "SPAN Panel") + + assert info["identifiers"] == {("span_panel", "sim-40t-001_mid")} + assert info["name"] == "SPAN Panel Microgrid Interconnect" + assert info["manufacturer"] == "Span" + assert info["serial_number"] == "SIM-BESS-40T-001-mid" + assert info["via_device"] == ("span_panel", "sim-40t-001") + # The producer publishes no MID model today; the card still needs a legible one. + assert info["model"] == "Microgrid Interconnect Device" + + +def test_the_mid_carries_grid_state_and_nothing_already_surfaced() -> None: + """Utility-supply health is genuinely new; islanding state is not. + + `dsm_state` and `grid_forming_entity` already reach a user from the panel and must + keep their ids and history. Duplicating them here would show the same fact twice, + which is not the benign cell of the absorb-or-surface policy — adding a fact nobody + had is. + """ + keys = {desc.key for desc in MID_SENSORS} + + assert keys == {"mid_grid_state"} + + +def test_grid_state_is_lowercased_into_its_enum_options() -> None: + """Home Assistant validates an ENUM sensor against `options`, and the wire sends + `UP` / `DOWN` / `DEGRADED`. + """ + (grid_state,) = MID_SENSORS + + assert grid_state.value_fn(_mid(grid_state="UP")) == "up" + assert grid_state.value_fn(_mid(grid_state="DEGRADED")) == "degraded" + # A MID mid-discovery has a description and no values yet. + assert grid_state.value_fn(_mid(grid_state=None)) == "unknown" + assert set(grid_state.options or []) >= {"up", "down", "degraded", "unknown"} + + +def test_a_mid_appearing_is_a_capability_change() -> None: + """The coordinator reloads on a new capability, which is how the device and its + sensors get created on a panel that gains a MID mid-life. + """ + without = detect_capabilities(SpanPanelSnapshotFactory.create()) + with_mid = detect_capabilities(SpanPanelSnapshotFactory.create(mid=_mid())) + + assert "mid" not in without + assert "mid" in with_mid + + +def test_every_schema_conditional_is_findable() -> None: + """The integration serves flat and parent/child side by side until every panel has + hot-loaded v1.0, and the branches that make that work have to be findable when the + flat path is finally retired. + + Asserted rather than left to a convention, because the failure mode is silent: a + later addition that assumes parent/child would work on the developer's panel and + break on everyone else's, and nothing would say so. + """ + component = Path(__file__).resolve().parent.parent / "custom_components" / "span_panel" + marked = sorted( + path.name for path in component.rglob("*.py") if "DUAL-SCHEMA" in path.read_text() + ) + + assert marked == ["helpers.py", "sensor.py", "sensor_panel.py"], ( + "the set of schema-conditional modules moved. If a conditional was added, mark it " + "DUAL-SCHEMA so it can be found when flat support is dropped; if one was removed, " + "update this list." + ) + + +def test_nothing_reads_the_mid_without_checking_it_is_there() -> None: + """A flat snapshot must survive every MID code path untouched. + + This is the constraint that matters more than any single feature: the integration + has to keep working on a panel that will never publish a MID, for as long as such + panels exist. + """ + flat = SpanPanelSnapshotFactory.create() + + assert flat.mid is None + assert has_mid(flat) is False + assert create_mid_sensors(_coordinator(), flat) == [] + assert _grid_forming_device_name(flat) is None + assert "mid" not in detect_capabilities(flat) From d45b5edac6169f0dedb4c777626c7f7a181bc246 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:56:30 -0700 Subject: [PATCH 005/345] fix: declare both schema adapters in the manifest, not just the bootstrap `manifest.json` still said `span-panel-api==2.6.4` after the move to 3.0.0b3. Only `pyproject.toml` had been updated, and that is dev tooling -- **manifest.json is the list Home Assistant reads and installs from**, so the branch as it stood would have installed the old bootstrap and found no schema-1 adapter at all. It also needed more than a version bump. The schema packages are *not* dependencies of `span-panel-api`; they are separate distributions found through the `span_panel_api.schema_adapters` entry-point group, so each has to be installed for discovery to see it. Installing the bootstrap alone yields zero adapters and a panel that cannot be parsed on either schema. span-panel-api==3.0.0b3 span-panel-api-schema-0==1.0.0b3 span-panel-api-schema-1==0.1.0b3 Verified rather than assumed -- after syncing, `discover_adapters()` resolves both `schema_0` and `schema_1`. Flat panels need schema-0 exactly as much as v1.0 panels need schema-1, so this is not a v1.0-only addition: it is what makes dual-schema support installable at all. 676 passed. --- custom_components/span_panel/manifest.json | 4 +- pyproject.toml | 8 ++++ uv.lock | 54 ++++++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 022832c9..0e6c92a8 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -22,7 +22,9 @@ ], "quality_scale": "gold", "requirements": [ - "span-panel-api==2.6.4" + "span-panel-api==3.0.0b3", + "span-panel-api-schema-0==1.0.0b3", + "span-panel-api-schema-1==0.1.0b3" ], "version": "2.0.8", "zeroconf": [ diff --git a/pyproject.toml b/pyproject.toml index 482d7e98..ba5fef2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,12 @@ requires-python = ">=3.14.3,<3.15" dependencies = [ "homeassistant==2026.5.4", "span-panel-api==3.0.0b3", + # 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.0.0b3", + "span-panel-api-schema-1==0.1.0b3", ] [dependency-groups] @@ -38,6 +44,8 @@ package = false # This branch develops against the in-progress schema-1 adapter, which lives in a # worktree rather than the primary checkout the symlink points at. span-panel-api = { path = "../../span/span-panel-api-p2", editable = true } +span-panel-api-schema-0 = { path = "../../span/span-panel-api-p2/packages/schema-0", editable = true } +span-panel-api-schema-1 = { path = "../../span/span-panel-api-p2/packages/schema-1", editable = true } [tool.jscpd] path = ["custom_components/span_panel", "./*.{html,md}"] diff --git a/uv.lock b/uv.lock index 367ef9e0..a9a6a2e4 100644 --- a/uv.lock +++ b/uv.lock @@ -787,6 +787,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, ] +[[package]] +name = "ebus-mqtt-client" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "paho-mqtt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/05/43c255aac2fe76e51642080315897306751fee1d4414fc6a099a1a5d9af5/ebus_mqtt_client-0.4.0.tar.gz", hash = "sha256:83ac9cfe4672fbbc1622d46ad7fe53d345654ca0dd85109c0894cb9fea8c73b1", size = 27690, upload-time = "2026-08-03T19:58:51.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/f3/e5549b9d340c958bf9ee8927b23ce724b7296a76b395ff0f5cbe55be1f77/ebus_mqtt_client-0.4.0-py3-none-any.whl", hash = "sha256:d64d6ac7f39f42791a59c932ce1cebefadb35accf88b1b3367258fa5fb7f54ff", size = 16625, upload-time = "2026-08-03T19:58:50.606Z" }, +] + +[[package]] +name = "ebus-sdk" +version = "0.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ebus-mqtt-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/45/044c4cd557850d7dc76e7ce664fedb5ce3ee4dbc2a58e97fb17e24991993/ebus_sdk-0.19.0.tar.gz", hash = "sha256:7987d3cae7c86e31656df9cd6e31e5a2ef950c757ba24f433adf019ca9aaa51c", size = 151155, upload-time = "2026-08-07T14:42:08.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/86/aad23b5bd10abb72c3d6bc659ffd67f6b19384aa9b5507050df707ee6cbb/ebus_sdk-0.19.0-py3-none-any.whl", hash = "sha256:33aeec8d61b88373b8d1902bb8338449644d0d5aa4d75835567ad76ffebefd10", size = 95231, upload-time = "2026-08-07T14:42:07.211Z" }, +] + [[package]] name = "envs" version = "1.4" @@ -2446,6 +2470,8 @@ source = { virtual = "." } dependencies = [ { name = "homeassistant" }, { name = "span-panel-api" }, + { name = "span-panel-api-schema-0" }, + { name = "span-panel-api-schema-1" }, ] [package.dev-dependencies] @@ -2472,6 +2498,8 @@ dev = [ requires-dist = [ { name = "homeassistant", specifier = "==2026.5.4" }, { name = "span-panel-api", editable = "../../span/span-panel-api-p2" }, + { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api-p2/packages/schema-0" }, + { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api-p2/packages/schema-1" }, ] [package.metadata.requires-dev] @@ -2531,6 +2559,32 @@ dev = [ { name = "vulture", specifier = ">=2.14" }, ] +[[package]] +name = "span-panel-api-schema-0" +version = "1.0.0b3" +source = { editable = "../../span/span-panel-api-p2/packages/schema-0" } +dependencies = [ + { name = "span-panel-api" }, +] + +[package.metadata] +requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-api-p2" }] + +[[package]] +name = "span-panel-api-schema-1" +version = "0.1.0b3" +source = { editable = "../../span/span-panel-api-p2/packages/schema-1" } +dependencies = [ + { name = "ebus-sdk" }, + { name = "span-panel-api" }, +] + +[package.metadata] +requires-dist = [ + { name = "ebus-sdk", specifier = ">=0.19.0,<1.0" }, + { name = "span-panel-api", editable = "../../span/span-panel-api-p2" }, +] + [[package]] name = "sqlalchemy" version = "2.0.49" From d176ff07e6b7200bf24e6546fcc15baa74045337 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:40:41 -0700 Subject: [PATCH 006/345] Reload on a firmware schema change, and keep Grid Islandable alive across it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The library now rebuilds its parser when a panel returns as a different schema generation, which restores *reading*. It cannot restore *topology*: devices and entities are built once in `async_setup_entry` from the tree as it looked then. v1.0 adds a MID the flat tree has no equivalent for and re-keys the EVSEs, so a live upgrade left the panel reading correctly while still showing the old device set, and a manual reload was needed to see anything new. The coordinator now subscribes to the library's schema-change callback and reloads the entry itself, with a persistent notification saying why. The reload is scheduled rather than awaited: it runs inside the client's own callback fan-out, and reloading tears that client down. **Grid Islandable survives instead of orphaning.** Flat publishes `core/grid-islandable`; v1.0 publishes nothing equivalent, deliberately -- `devices/bess.md` says a consumer reads backup capability from the capability set, "a MID `grid` child means premises-segment backup", and "there is no single 'islanded?' bit to reconcile". So under v1.0 the sensor reads MID presence, which is the classifier the spec nominates. That is a consumer deriving a convenience from what is published, not a producer inventing a claim. Without it the entity went `unavailable` with `restored: true` after an upgrade, which reaches a user as a sensor that broke rather than one whose source moved. A repair issue carries the part that a derived value cannot: `grid_islandable` answers "could this panel island" while the MID answers "is it islanding right now", and an automation written against the former on flat firmware may well have meant the latter. A repair rather than a notification because it persists until acted on, which suits a change the user has to make in their own automations. Raised only on upgrades to v1.0 — panel firmware does not roll back, so the reverse happens solely in the upgrade rehearsal. --- custom_components/span_panel/binary_sensor.py | 36 +++++++- custom_components/span_panel/coordinator.py | 88 +++++++++++++++++++ custom_components/span_panel/strings.json | 10 ++- .../span_panel/translations/en.json | 6 ++ .../span_panel/translations/es.json | 6 ++ .../span_panel/translations/fr.json | 6 ++ .../span_panel/translations/ja.json | 6 ++ .../span_panel/translations/pt.json | 6 ++ tests/test_mid_device.py | 2 +- tests/test_promoted_sensors.py | 25 +++++- 10 files changed, 184 insertions(+), 7 deletions(-) diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py index 4c899e00..7947d3b1 100644 --- a/custom_components/span_panel/binary_sensor.py +++ b/custom_components/span_panel/binary_sensor.py @@ -34,6 +34,7 @@ build_binary_sensor_unique_id_for_entry, build_evse_unique_id_for_entry, has_bess, + has_mid, resolve_evse_display_suffix, ) from .util import bess_device_info, evse_device_info @@ -103,12 +104,40 @@ class SpanPanelBinarySensorEntityDescription( ), ) + +def _grid_islandable(snapshot: SpanPanelSnapshot) -> bool | None: + """Whether this panel can island, across both schema generations. + + Flat publishes `core/grid-islandable` outright. v1.0 has no such property and + that is deliberate -- `devices/bess.md` says a consumer "distinguishes the + variants without any dedicated type property: a MID `grid` child means + premises-segment backup ... neither means no backup", and "there is no single + 'islanded?' bit to reconcile". + + So under v1.0 the answer is read from the classifier the spec nominates rather + than from a property that no longer exists. That is a consumer deriving a + convenience from what is published, not a producer inventing a claim -- the + panel still says exactly what the spec says it should. + + Keeping the entity alive matters because the alternative is what a firmware + upgrade did in practice: it went `unavailable` with `restored: true`, which + reaches a user as a sensor that broke rather than one whose source moved. The + deprecation is announced separately, as a repair issue. + + DUAL-SCHEMA: when the flat path retires this becomes `has_mid(snapshot)` and the + first branch goes. + """ + if snapshot.grid_islandable is not None: + return snapshot.grid_islandable + return has_mid(snapshot) + + GRID_ISLANDABLE_SENSOR = SpanPanelBinarySensorEntityDescription( key="grid_islandable", translation_key="grid_islandable", device_class=BinarySensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda s: s.grid_islandable, + value_fn=_grid_islandable, ) BESS_CONNECTED_SENSOR = SpanPanelBinarySensorEntityDescription( @@ -358,7 +387,10 @@ async def async_setup_entry( # Add grid islandable binary sensor when v2 data is available snapshot: SpanPanelSnapshot = coordinator.data - if snapshot.grid_islandable is not None: + # Created on either generation. Gating on the flat property alone is what left this + # entity `unavailable` after a v1.0 upgrade -- the panel had not lost the + # capability, only the property that used to report it. + if snapshot.grid_islandable is not None or has_mid(snapshot): entities.append(SpanPanelBinarySensor(coordinator, GRID_ISLANDABLE_SENSOR)) # Add BESS connected sensor on the BESS sub-device when battery is commissioned diff --git a/custom_components/span_panel/coordinator.py b/custom_components/span_panel/coordinator.py index 0c08410d..10dbb962 100644 --- a/custom_components/span_panel/coordinator.py +++ b/custom_components/span_panel/coordinator.py @@ -21,6 +21,7 @@ HomeAssistantError, ) from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from span_panel_api import SpanMqttClient, SpanPanelSnapshot from span_panel_api.exceptions import SpanPanelAuthError, SpanPanelStaleDataError @@ -81,6 +82,7 @@ def __init__( # Streaming state self._unregister_streaming: Callable[[], None] | None = None self._unregister_connection: Callable[[], None] | None = None + self._unregister_schema_change: Callable[[], None] | None = None # Hardware capability tracking — detect when BESS/PV are commissioned # and trigger a reload so the factory creates the appropriate sensors. @@ -218,10 +220,92 @@ async def async_setup_streaming(self) -> None: self._unregister_connection = self._client.register_connection_callback( self._on_connection_change ) + self._unregister_schema_change = self._client.register_schema_change_callback( + self._on_schema_generation_change + ) self._unregister_streaming = self._client.register_snapshot_callback(self._on_snapshot_push) await self._client.start_streaming() _LOGGER.info("MQTT push streaming started") + def _on_schema_generation_change(self, previous: str | None, current: str | None) -> None: + """Reload the entry when the panel changes schema generation underneath us. + + The library rebuilds its parser on its own, which restores *reading* — values + resolve again straight away. It cannot restore *topology*: devices and + entities are created in `async_setup_entry` from the tree as it looked then. + v1.0 introduces a MID the flat tree has no equivalent for and re-keys the + EVSEs, so without a reload the panel reads correctly and still shows the old + device set. Observed exactly that on a live upgrade — data flowed, entities + did not appear, and a manual reload was needed. + + Scheduled rather than awaited: this is called from the client's own callback + fan-out, and reloading the entry tears down that client. `async_schedule_reload` + defers to the loop so the teardown does not run inside the object being torn + down. + """ + _LOGGER.warning( + "SPAN panel firmware upgraded its eBus schema generation: data-model-version " + "%s -> %s. Reloading the integration so devices and entities match the new " + "tree; the MID and any re-keyed chargers appear after the reload.", + previous or "absent (flat)", + current or "absent (flat)", + ) + async_create( + self.hass, + ( + f"Your SPAN Panel reported a new eBus data model " + f"(**{previous or 'flat'} → {current or 'flat'}**), which happens after a " + "firmware upgrade.\n\n" + "The integration is reloading so its devices and entities match what the " + "panel now publishes. New devices — such as the Microgrid Interconnect " + "Device — appear once that finishes.\n\n" + "Entities that were renamed or replaced by the upgrade may need to be " + "removed manually if they remain unavailable." + ), + title="SPAN Panel firmware upgraded", + notification_id=f"span_schema_upgrade_{self.config_entry.entry_id}", + ) + self._flag_retired_entities(current) + self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id) + + def _flag_retired_entities(self, current: str | None) -> None: + """Raise a repair for entities whose source moved in the new schema. + + `binary_sensor.*_grid_islandable` is the worked example. Flat publishes + `core/grid-islandable`; v1.0 has no such property, on purpose -- + `devices/bess.md` says a consumer reads backup capability from the capability + set, "a MID `grid` child means premises-segment backup", and that "there is no + single 'islanded?' bit to reconcile". + + The entity itself survives: it now reads MID presence, which is the classifier + the spec nominates. What the repair explains is that the *live* islanding + state has moved onto the MID, because `grid_islandable` answers "could this + panel island" and the MID answers "is it islanding right now" -- an automation + written against the former on flat firmware may well have meant the latter. + + A repair rather than a notification: notifications are dismissed and gone, + while this stays until acted on, which suits a change a user has to make in + their own automations. + """ + # Absent means flat, present means parent/child -- the migration guide's own + # detection rule. + # + # Guarding on the direction even though panel firmware does not roll back: + # once a panel is on v1.0 it stays there, so in the field this only ever fires + # one way. The reverse happens solely in the upgrade rehearsal, where the two + # simulators are swapped under a live client, and raising a retirement repair + # there would be noise about a transition no user experiences. + if current is None: + return + async_create_issue( + self.hass, + DOMAIN, + f"grid_islandable_moved_to_mid_{self.config_entry.entry_id}", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="grid_islandable_moved_to_mid", + ) + def _on_connection_change(self, connected: bool) -> None: """Handle a broker connection state edge from the MQTT client. @@ -255,6 +339,10 @@ async def async_shutdown(self) -> None: self._unregister_connection() self._unregister_connection = None + if self._unregister_schema_change is not None: + self._unregister_schema_change() + self._unregister_schema_change = None + if self._unregister_streaming is not None: self._unregister_streaming() self._unregister_streaming = None diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index d0944208..f3dc6839 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -408,8 +408,8 @@ "enable_energy_dip_compensation": "Compensate when panel reports lower energy readings. Disabling clears offsets.", "enable_panel_net_energy_sensors": "Net energy for main meter and feed-through, accounting for bidirectional flows.", "enable_unmapped_circuit_sensors": "Backing sensors for tabs not assigned to named breakers.", - "energy_reporting_grace_period": "Minutes sensors hold last value when panel is offline (0\u201360). Default: 15.", - "snapshot_update_interval": "Seconds between snapshot rebuilds (0\u201315). Lower = faster, more CPU. 0 = no debounce." + "energy_reporting_grace_period": "Minutes sensors hold last value when panel is offline (0–60). Default: 15.", + "snapshot_update_interval": "Seconds between snapshot rebuilds (0–15). Lower = faster, more CPU. 0 = no debounce." }, "description": "Configure SPAN Panel integration settings.", "title": "SPAN Panel Options" @@ -703,5 +703,11 @@ } } } + }, + "issues": { + "grid_islandable_moved_to_mid": { + "title": "Islanding state has moved to the Microgrid Interconnect Device", + "description": "Your SPAN Panel upgraded to the eBus v1.0 data model, which no longer publishes a panel-level `grid-islandable` property. The **Grid Islandable** sensor still works — it now reports whether a Microgrid Interconnect Device (MID) is present, which is how v1.0 says backup capability is detected.\n\nWhat changed is where *live* islanding state lives. `Grid Islandable` answers \"could this panel island\"; the MID answers \"is it islanding right now\". If you have automations that used Grid Islandable to detect an actual outage, point them at the MID device's grid entities instead.\n\nThis notice does not need to be fixed and can be dismissed once your automations are checked." + } } } diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index f3427945..f3dc6839 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -703,5 +703,11 @@ } } } + }, + "issues": { + "grid_islandable_moved_to_mid": { + "title": "Islanding state has moved to the Microgrid Interconnect Device", + "description": "Your SPAN Panel upgraded to the eBus v1.0 data model, which no longer publishes a panel-level `grid-islandable` property. The **Grid Islandable** sensor still works — it now reports whether a Microgrid Interconnect Device (MID) is present, which is how v1.0 says backup capability is detected.\n\nWhat changed is where *live* islanding state lives. `Grid Islandable` answers \"could this panel island\"; the MID answers \"is it islanding right now\". If you have automations that used Grid Islandable to detect an actual outage, point them at the MID device's grid entities instead.\n\nThis notice does not need to be fixed and can be dismissed once your automations are checked." + } } } diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index 7a752fa7..9b865d01 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -703,5 +703,11 @@ } } } + }, + "issues": { + "grid_islandable_moved_to_mid": { + "title": "El estado de aislamiento se ha trasladado al Dispositivo de Interconexión de Microrred", + "description": "Su Panel SPAN se ha actualizado al modelo de datos eBus v1.0, que ya no publica una propiedad `grid-islandable` a nivel de panel. El sensor **Aislable de la Red** sigue funcionando: ahora indica si hay presente un Dispositivo de Interconexión de Microrred (MID), que es como v1.0 determina la capacidad de respaldo.\n\nLo que ha cambiado es dónde reside el estado de aislamiento *en tiempo real*. «Aislable de la Red» responde a «¿podría aislarse este panel?»; el MID responde a «¿se está aislando ahora mismo?». Si tiene automatizaciones que usaban Aislable de la Red para detectar un corte real, apúntelas a las entidades de red del dispositivo MID.\n\nEste aviso no requiere ninguna corrección y puede descartarse una vez revisadas sus automatizaciones." + } } } diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index 21a7803d..d19919cb 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -703,5 +703,11 @@ } } } + }, + "issues": { + "grid_islandable_moved_to_mid": { + "title": "L'état d'îlotage a été déplacé vers le Dispositif d'Interconnexion de Micro-réseau", + "description": "Votre Panneau SPAN est passé au modèle de données eBus v1.0, qui ne publie plus de propriété `grid-islandable` au niveau du panneau. Le capteur **Îlotage Réseau Possible** fonctionne toujours : il indique désormais la présence d'un Dispositif d'Interconnexion de Micro-réseau (MID), ce qui est la façon dont v1.0 détermine la capacité de secours.\n\nCe qui a changé, c'est l'emplacement de l'état d'îlotage *en temps réel*. « Îlotage Réseau Possible » répond à « ce panneau pourrait-il s'îloter ? » ; le MID répond à « s'îlote-t-il en ce moment ? ». Si vous avez des automatisations qui utilisaient Îlotage Réseau Possible pour détecter une panne réelle, dirigez-les vers les entités réseau du dispositif MID.\n\nCet avis ne nécessite aucune correction et peut être ignoré une fois vos automatisations vérifiées." + } } } diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index f18c9104..6b135cfe 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -703,5 +703,11 @@ } } } + }, + "issues": { + "grid_islandable_moved_to_mid": { + "title": "自立運転状態はマイクログリッド相互接続装置に移動しました", + "description": "SPAN パネルが eBus v1.0 データモデルにアップグレードされました。v1.0 ではパネル単位の `grid-islandable` プロパティは公開されません。**系統自立運転可能** センサーは引き続き動作し、現在はマイクログリッド相互接続装置 (MID) の有無を示します。これが v1.0 におけるバックアップ能力の判定方法です。\n\n変更されたのは、*現在の*自立運転状態がどこにあるかです。「系統自立運転可能」は「このパネルは自立運転できるか」に答え、MID は「今まさに自立運転しているか」に答えます。実際の停電検知に「系統自立運転可能」を使用しているオートメーションがある場合は、MID デバイスの系統エンティティを参照するように変更してください。\n\nこの通知は修正を必要とせず、オートメーションを確認した後に閉じることができます。" + } } } diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index 28be7c60..79d0499d 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -703,5 +703,11 @@ } } } + }, + "issues": { + "grid_islandable_moved_to_mid": { + "title": "O estado de ilhamento foi movido para o Dispositivo de Interligação de Microrrede", + "description": "O seu Painel SPAN foi atualizado para o modelo de dados eBus v1.0, que já não publica uma propriedade `grid-islandable` ao nível do painel. O sensor **Isolável da Rede** continua a funcionar: agora indica se existe um Dispositivo de Interligação de Microrrede (MID), que é como a v1.0 determina a capacidade de reserva.\n\nO que mudou foi onde reside o estado de ilhamento *em tempo real*. «Isolável da Rede» responde a «este painel poderia ilhar-se?»; o MID responde a «está a ilhar-se neste momento?». Se tiver automações que usavam Isolável da Rede para detetar uma falha real, aponte-as para as entidades de rede do dispositivo MID.\n\nEste aviso não precisa de ser corrigido e pode ser dispensado depois de verificar as suas automações." + } } } diff --git a/tests/test_mid_device.py b/tests/test_mid_device.py index fa221b17..9dad1f9a 100644 --- a/tests/test_mid_device.py +++ b/tests/test_mid_device.py @@ -125,7 +125,7 @@ def test_every_schema_conditional_is_findable() -> None: path.name for path in component.rglob("*.py") if "DUAL-SCHEMA" in path.read_text() ) - assert marked == ["helpers.py", "sensor.py", "sensor_panel.py"], ( + assert marked == ["binary_sensor.py", "helpers.py", "sensor.py", "sensor_panel.py"], ( "the set of schema-conditional modules moved. If a conditional was added, mark it " "DUAL-SCHEMA so it can be found when flat support is dropped; if one was removed, " "update this list." diff --git a/tests/test_promoted_sensors.py b/tests/test_promoted_sensors.py index fa6a697d..f29eaabe 100644 --- a/tests/test_promoted_sensors.py +++ b/tests/test_promoted_sensors.py @@ -161,9 +161,30 @@ def test_value_function_false(self): snapshot = SpanPanelSnapshotFactory.create(grid_islandable=False) assert GRID_ISLANDABLE_SENSOR.value_fn(snapshot) is False - def test_value_function_none(self): + def test_absent_flat_property_falls_back_to_mid_presence(self): + """v1.0 publishes no `grid-islandable`, and that is not "unknown". + + `devices/bess.md` retires the panel-level boolean deliberately -- "there is no + single 'islanded?' bit to reconcile" -- and nominates the capability set as the + classifier: "a MID `grid` child means premises-segment backup ... neither means + no backup". So an absent property with no MID is a definite *no*, not a + missing reading. + + This used to return None, which reached a user as the sensor going + `unavailable` with `restored: true` after a firmware upgrade -- indistinguishable + from a sensor that broke. + """ snapshot = SpanPanelSnapshotFactory.create(grid_islandable=None) - assert GRID_ISLANDABLE_SENSOR.value_fn(snapshot) is None + assert GRID_ISLANDABLE_SENSOR.value_fn(snapshot) is False + + def test_a_published_flat_value_still_wins(self): + """Flat firmware keeps answering for itself. + + DUAL-SCHEMA: a flat panel publishes the property and has no MID, so reading MID + presence first would invert the answer for every panel not yet upgraded. + """ + snapshot = SpanPanelSnapshotFactory.create(grid_islandable=True) + assert GRID_ISLANDABLE_SENSOR.value_fn(snapshot) is True # --------------------------------------------------------------------------- From 566438385e8416bae4810f81305348a122614645 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:07:06 -0700 Subject: [PATCH 007/345] Declare each enum sensor's real states instead of discovering them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine enum sensors declared `options=["unknown"]` and nothing else, so a panel sitting at `dsm_on_grid` rendered as "Possible states: Unknown" — an entity reporting a live state it said was impossible. Home Assistant validates the state against `options` and renders that list as the entity's possible states, so the declaration was wrong in the one place a user can see it. `sensor_base` tried to cover this at runtime, appending each value the first time it was observed. That could not work even in principle: options would only ever list states the panel had already reached, so `dsm_off_grid` stays absent until the day of an actual outage, and two identical panels advertise different sets depending on what each has lived through. `evse_status` makes it plainest — ten states, of which a charger shows maybe three in normal service. These are closed domains, known when the code is written, and `en.json` already spells every one of them out. The declarations now say what the translations render. The runtime append stays as a last resort so an undeclared value degrades to a shown state rather than a ValueError, but it logs at warning now: reaching it means the panel published outside the enum its own catalog declares. That is a producer defect of exactly the kind found this week on the emitter side — `grid-state` carrying the islanding vocabulary, `relay-requester` inventing `BACKUP` and `NEVER` — and this is the same class caught from the consumer end. `test_enum_sensor_options.py` derives its expectations from the translations rather than a second hand-maintained list, and covers all eight sensor groups so a new enum is checked the day it lands. Set equality in both directions: an option with no translation reaches a user as a raw key, and a translated state missing from options is one Home Assistant will reject when the panel reports it. Four EVSE tests asserted the seeded `["unknown"]` — they pinned the bug rather than a contract, and now assert the states those sensors can actually report. 696 passed; all hooks green. --- custom_components/span_panel/sensor_base.py | 30 +++-- .../span_panel/sensor_definitions.py | 27 +++-- tests/test_enum_sensor_options.py | 111 ++++++++++++++++++ tests/test_evse_entities.py | 28 +++-- 4 files changed, 174 insertions(+), 22 deletions(-) create mode 100644 tests/test_enum_sensor_options.py diff --git a/custom_components/span_panel/sensor_base.py b/custom_components/span_panel/sensor_base.py index fddec4d7..1d86f1d1 100644 --- a/custom_components/span_panel/sensor_base.py +++ b/custom_components/span_panel/sensor_base.py @@ -441,20 +441,36 @@ def _process_raw_value(self, raw_value: float | str | None) -> None: str_value = str(raw_value) # For enum sensors, ensure the value is in the options list before # setting it — HA raises ValueError if the state is not in options. - # Options are built dynamically from observed MQTT values. - # Values are normalized to lowercase to satisfy HA's translation - # key requirement ([a-z0-9-_]+). HA uses the state value directly - # as the translation key lookup. + # Values are normalized to lowercase to satisfy HA's translation key + # requirement ([a-z0-9-_]+); HA uses the state value directly as the + # translation key lookup. + # + # Options are declared statically on each description, from the states + # `en.json` renders. They used to be discovered here instead, which could + # not work: options would only ever list states the panel had already + # reached, so a state it had not yet visited was absent from its own + # "Possible states" — and every panel advertised a different set + # depending on what it had lived through. + # + # The append survives as a last resort so an undeclared value degrades to + # a shown state rather than a ValueError. It is a warning rather than a + # debug line because reaching it means the panel published outside the + # enum its own catalog declares, which is a producer defect and not + # something a consumer should absorb quietly. It also renders untranslated, + # as a raw key. if self._attr_device_class is SensorDeviceClass.ENUM: str_value = str_value.lower() if not hasattr(self, "_attr_options") or self._attr_options is None: self._attr_options = [] if str_value not in self._attr_options: self._attr_options.append(str_value) - _LOGGER.debug( - "Added enum option '%s' for %s", - str_value, + _LOGGER.warning( + "%s reported '%s', which is not one of its declared states %s. " + "Showing it untranslated; the panel is publishing outside the " + "enum its catalog declares.", self.entity_id or self._attr_unique_id, + str_value, + sorted(o for o in self._attr_options if o != str_value), ) self._attr_native_value = str_value diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py index 87b3d0c5..286ed920 100644 --- a/custom_components/span_panel/sensor_definitions.py +++ b/custom_components/span_panel/sensor_definitions.py @@ -105,7 +105,7 @@ class SpanPanelBatterySensorEntityDescription( device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - options=["unknown"], + options=["dsm_off_grid", "dsm_on_grid", "unknown"], value_fn=lambda s: s.dsm_state, ), SpanPanelDataSensorEntityDescription( @@ -113,7 +113,7 @@ class SpanPanelBatterySensorEntityDescription( translation_key="dsm_grid_state", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, - options=["unknown"], + options=["dsm_off_grid", "dsm_on_grid", "unknown"], value_fn=lambda s: s.dsm_state, # deprecated alias — reads dsm_state ), SpanPanelDataSensorEntityDescription( @@ -121,7 +121,7 @@ class SpanPanelBatterySensorEntityDescription( translation_key="current_run_config", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, - options=["unknown"], + options=["panel_backup", "panel_off_grid", "panel_on_grid", "unknown"], value_fn=lambda s: s.current_run_config, ), SpanPanelDataSensorEntityDescription( @@ -129,7 +129,7 @@ class SpanPanelBatterySensorEntityDescription( translation_key="main_relay_state", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, - options=["unknown"], + options=["closed", "open", "unknown"], value_fn=lambda s: s.main_relay_state, ), SpanPanelDataSensorEntityDescription( @@ -137,7 +137,7 @@ class SpanPanelBatterySensorEntityDescription( translation_key="grid_forming_entity", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, - options=["unknown"], + options=["battery", "generator", "grid", "none", "pv", "unknown"], value_fn=lambda s: s.dominant_power_source or "unknown", ), SpanPanelDataSensorEntityDescription( @@ -146,7 +146,7 @@ class SpanPanelBatterySensorEntityDescription( device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - options=["unknown"], + options=["connected", "unconnected", "unknown"], value_fn=lambda s: s.vendor_cloud or "unknown", ), ) @@ -718,7 +718,18 @@ class SpanEvseSensorEntityDescription(SensorEntityDescription, SpanEvseRequiredK key="evse_status", translation_key="evse_status", device_class=SensorDeviceClass.ENUM, - options=["unknown"], + options=[ + "available", + "charging", + "faulted", + "finishing", + "preparing", + "reserved", + "suspended_ev", + "suspended_evse", + "unavailable", + "unknown", + ], value_fn=lambda e: e.status or "unknown", ), SpanEvseSensorEntityDescription( @@ -734,7 +745,7 @@ class SpanEvseSensorEntityDescription(SensorEntityDescription, SpanEvseRequiredK key="evse_lock_state", translation_key="evse_lock_state", device_class=SensorDeviceClass.ENUM, - options=["unknown"], + options=["locked", "unlocked", "unknown"], value_fn=lambda e: e.lock_state or "unknown", ), ) diff --git a/tests/test_enum_sensor_options.py b/tests/test_enum_sensor_options.py new file mode 100644 index 00000000..82941ab7 --- /dev/null +++ b/tests/test_enum_sensor_options.py @@ -0,0 +1,111 @@ +"""Every enum sensor must declare the states it can actually report. + +Home Assistant renders `options` as the entity's "Possible states" and validates the +state against it. Eight sensors declared `["unknown"]` and nothing else, so a panel +reporting `dsm_on_grid` showed a live state that its own entity said was impossible. + +`sensor_base` tried to fix this at runtime, appending each value as it was first +observed. That does not work, and could not: options would only ever list states the +panel had already reached, so `dsm_off_grid` stays absent until the day of an actual +outage, and the advertised set differs between two identical panels depending on what +each has lived through. A value domain that is known when the code is written should +be declared there. + +The translations are the authority rather than a second hand-maintained list: a state +with no translation renders to a user as a raw key like `dsm_on_grid`, and an option +with no state to match is dead weight. Both are the same defect seen from either end, +so one list is derived from the other. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +from homeassistant.components.sensor import SensorDeviceClass + +from custom_components.span_panel.sensor_definitions import ( + BESS_METADATA_SENSORS, + CIRCUIT_SENSORS, + EVSE_SENSORS, + MID_SENSORS, + PANEL_DATA_STATUS_SENSORS, + PV_METADATA_SENSORS, + STATUS_SENSORS, + UNMAPPED_SENSORS, +) + +_COMPONENT = Path(__file__).resolve().parent.parent / "custom_components" / "span_panel" + + +def _translated_states() -> dict[str, set[str]]: + """The state keys `en.json` renders, per sensor translation key.""" + entities = json.loads((_COMPONENT / "translations" / "en.json").read_text()) + sensors = entities["entity"]["sensor"] + return {key: set(body.get("state", {})) for key, body in sensors.items()} + + +def _enum_descriptions() -> list[Any]: + # Every published group, so a new enum in any of them is covered the day it + # lands rather than the day someone remembers this file. + groups = ( + PANEL_DATA_STATUS_SENSORS, + STATUS_SENSORS, + UNMAPPED_SENSORS, + MID_SENSORS, + BESS_METADATA_SENSORS, + PV_METADATA_SENSORS, + CIRCUIT_SENSORS, + EVSE_SENSORS, + ) + return [ + description + for group in groups + for description in group + if description.device_class is SensorDeviceClass.ENUM + ] + + +def test_there_are_enum_sensors_to_check() -> None: + """Guard the guard. + + Every assertion below is a loop over discovered descriptions, so an import that + silently stopped finding any would leave this file passing while checking nothing. + """ + assert len(_enum_descriptions()) >= 8 + + +@pytest.mark.parametrize("description", _enum_descriptions(), ids=lambda d: str(d.key)) +def test_declared_options_match_the_states_the_ui_can_render(description: Any) -> None: + """Declared options and translated states are the same set. + + Not a subset in either direction. An option with no translation reaches a user as + a raw key; a translated state absent from options is one Home Assistant will + reject when the panel reports it -- which is how `dsm_grid_state` came to show + "Possible states: Unknown" while sitting at `dsm_on_grid`. + """ + translated = _translated_states().get(description.translation_key) + assert translated, f"{description.key} has no translated states to compare against" + + declared = set(description.options or []) + + assert declared == translated, ( + f"{description.key}: options {sorted(declared)} do not match the states " + f"en.json renders {sorted(translated)}. Add the missing ones to whichever " + "side is short; the two lists describe the same thing." + ) + + +@pytest.mark.parametrize("description", _enum_descriptions(), ids=lambda d: str(d.key)) +def test_unknown_is_offered_as_a_fallback(description: Any) -> None: + """Every enum can be unknown, so every enum must say so. + + The value functions fall back to `"unknown"` when the panel publishes nothing, and + Home Assistant rejects a state outside `options`. A set that omits it is one + missing reading away from an error. + """ + assert "unknown" in (description.options or []), ( + f"{description.key} can report 'unknown' but does not declare it" + ) diff --git a/tests/test_evse_entities.py b/tests/test_evse_entities.py index 961a6112..31301810 100644 --- a/tests/test_evse_entities.py +++ b/tests/test_evse_entities.py @@ -83,13 +83,13 @@ def test_evse_status_sensor_is_enum(self): status_desc = next(d for d in EVSE_SENSORS if d.key == "evse_status") assert status_desc.device_class is not None assert status_desc.device_class.value == "enum" - assert status_desc.options == ["unknown"] + assert "charging" in (status_desc.options or []) def test_evse_lock_state_sensor_is_enum(self): lock_desc = next(d for d in EVSE_SENSORS if d.key == "evse_lock_state") assert lock_desc.device_class is not None assert lock_desc.device_class.value == "enum" - assert lock_desc.options == ["unknown"] + assert "locked" in (lock_desc.options or []) def test_evse_advertised_current_is_measurement(self): current_desc = next( @@ -217,15 +217,29 @@ def test_evse_device_info_no_serial(self): class TestEvseStatusOptions: - """Test EVSE enum options seed with 'unknown' only.""" + """EVSE enums declare every state they can report. - def test_status_options_seed_with_unknown(self): + These asserted the opposite -- that both seeded with `["unknown"]` and nothing + else -- pinning a bug rather than a contract. A sensor sitting at `charging` while + declaring only `unknown` as possible is what Home Assistant renders as + "Possible states: Unknown", and the runtime discovery meant to fill the gap could + only ever list states the charger had already reached. + + The exhaustive comparison against the translations lives in + `test_enum_sensor_options.py`; these keep the EVSE-specific expectations local. + """ + + def test_status_declares_the_full_charging_lifecycle(self): status_desc = next(d for d in EVSE_SENSORS if d.key == "evse_status") - assert status_desc.options == ["unknown"] + options = set(status_desc.options or []) - def test_lock_state_options_seed_with_unknown(self): + assert {"available", "charging", "preparing", "finishing", "faulted"} <= options + assert "unknown" in options + + def test_lock_state_declares_both_positions(self): lock_desc = next(d for d in EVSE_SENSORS if d.key == "evse_lock_state") - assert lock_desc.options == ["unknown"] + + assert set(lock_desc.options or []) == {"locked", "unlocked", "unknown"} class TestEvseMultipleDevices: From ff7351b8e7f1b90afaaaa292c197573343c11fd7 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:14:58 -0700 Subject: [PATCH 008/345] Say what the upgrade actually changed, which is less than the repair claimed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repair told users to point their automations at "the MID device's grid entities". That advice was wrong twice: the MID deliberately carries only `grid-state`, and the islanding state those automations want never moved off the panel. `sensor.*_dsm_grid_state` keeps its entity id and its history across the upgrade, and gets *more* trustworthy. Under flat, `schema_0` derived it — the battery's `grid-state` when one was commissioned, otherwise an inference from `dominant-power-source` and whether any power was crossing the grid connection. Under v1.0 it reads the islanding state the MID actually senses. That is the heuristic v1.0 exists to retire, retired, and it is an improvement a user should be told about rather than a migration they must survive. So the text now says what is true: nothing you depend on has gone away, no automation changes are needed, `Grid Islandable` still answers the same question via MID presence, and a new MID device reports the health of the utility supply — which flat did not report at all. The key is renamed `panel_upgraded_to_ebus_v1`. `grid_islandable_moved_to_mid` described the belief rather than the behaviour, and nothing moved to the MID; a key is what the next reader greps for, so a misleading one outlives the text that explained it. Retitled and rewritten in all five locales, and the method docstring rewritten with it — it carried the same wrong claim, in more detail. 696 passed; all hooks green. --- custom_components/span_panel/coordinator.py | 44 +++++++++++-------- custom_components/span_panel/strings.json | 6 +-- .../span_panel/translations/en.json | 6 +-- .../span_panel/translations/es.json | 6 +-- .../span_panel/translations/fr.json | 6 +-- .../span_panel/translations/ja.json | 6 +-- .../span_panel/translations/pt.json | 6 +-- 7 files changed, 44 insertions(+), 36 deletions(-) diff --git a/custom_components/span_panel/coordinator.py b/custom_components/span_panel/coordinator.py index 10dbb962..fb2ec28a 100644 --- a/custom_components/span_panel/coordinator.py +++ b/custom_components/span_panel/coordinator.py @@ -265,27 +265,35 @@ def _on_schema_generation_change(self, previous: str | None, current: str | None title="SPAN Panel firmware upgraded", notification_id=f"span_schema_upgrade_{self.config_entry.entry_id}", ) - self._flag_retired_entities(current) + self._explain_the_upgrade(current) self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id) - def _flag_retired_entities(self, current: str | None) -> None: - """Raise a repair for entities whose source moved in the new schema. + def _explain_the_upgrade(self, current: str | None) -> None: + """Raise a repair describing what the new schema changed for the user. - `binary_sensor.*_grid_islandable` is the worked example. Flat publishes - `core/grid-islandable`; v1.0 has no such property, on purpose -- - `devices/bess.md` says a consumer reads backup capability from the capability - set, "a MID `grid` child means premises-segment backup", and that "there is no - single 'islanded?' bit to reconcile". + Nothing they depend on goes away, which is worth saying plainly because a + firmware upgrade invites the opposite assumption. - The entity itself survives: it now reads MID presence, which is the classifier - the spec nominates. What the repair explains is that the *live* islanding - state has moved onto the MID, because `grid_islandable` answers "could this - panel island" and the MID answers "is it islanding right now" -- an automation - written against the former on flat firmware may well have meant the latter. + `sensor.*_dsm_grid_state` keeps its entity id and its history and gets *more* + trustworthy. Under flat, `schema_0` derived it: the battery's `grid-state` when + one was commissioned, otherwise an inference from `dominant-power-source` and + whether any power was crossing the grid connection. Under v1.0 it reads the + islanding state the MID actually senses -- the heuristic v1.0 exists to retire, + retired. - A repair rather than a notification: notifications are dismissed and gone, - while this stays until acted on, which suits a change a user has to make in - their own automations. + `binary_sensor.*_grid_islandable` also survives. v1.0 publishes no panel-level + `grid-islandable`, on purpose: `devices/bess.md` reads backup capability from + the capability set, "a MID `grid` child means premises-segment backup", and + "there is no single 'islanded?' bit to reconcile". So it now reflects MID + presence, the classifier the spec nominates. + + What is genuinely new is the MID device itself and its `grid-state`, the health + of the utility supply, which flat did not report at all. + + A repair rather than only a notification because notifications are dismissed + and gone: a user who was away when the panel upgraded should still find out + that a device appeared and why a sensor changed provenance. It asks for no + action, which is why it is the mildest severity available. """ # Absent means flat, present means parent/child -- the migration guide's own # detection rule. @@ -300,10 +308,10 @@ def _flag_retired_entities(self, current: str | None) -> None: async_create_issue( self.hass, DOMAIN, - f"grid_islandable_moved_to_mid_{self.config_entry.entry_id}", + f"panel_upgraded_to_ebus_v1_{self.config_entry.entry_id}", is_fixable=False, severity=IssueSeverity.WARNING, - translation_key="grid_islandable_moved_to_mid", + translation_key="panel_upgraded_to_ebus_v1", ) def _on_connection_change(self, connected: bool) -> None: diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index f3dc6839..83e427c2 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -705,9 +705,9 @@ } }, "issues": { - "grid_islandable_moved_to_mid": { - "title": "Islanding state has moved to the Microgrid Interconnect Device", - "description": "Your SPAN Panel upgraded to the eBus v1.0 data model, which no longer publishes a panel-level `grid-islandable` property. The **Grid Islandable** sensor still works — it now reports whether a Microgrid Interconnect Device (MID) is present, which is how v1.0 says backup capability is detected.\n\nWhat changed is where *live* islanding state lives. `Grid Islandable` answers \"could this panel island\"; the MID answers \"is it islanding right now\". If you have automations that used Grid Islandable to detect an actual outage, point them at the MID device's grid entities instead.\n\nThis notice does not need to be fixed and can be dismissed once your automations are checked." + "panel_upgraded_to_ebus_v1": { + "title": "Your SPAN Panel now reports islanding from its Microgrid Interconnect Device", + "description": "Your SPAN Panel upgraded to the eBus v1.0 data model. Nothing you rely on has gone away, and no automation changes are required — this is a note about what improved.\n\n**DSM Grid State** keeps its entity ID and its history, and is now more trustworthy. On the previous firmware it was *inferred* — from the battery when one was fitted, otherwise from the dominant power source and whether any power was crossing the grid connection. It now reads the islanding state the Microgrid Interconnect Device (MID) actually senses.\n\n**Grid Islandable** also keeps working. v1.0 publishes no panel-level `grid-islandable` property, so it now reflects whether a MID is present, which is how v1.0 says backup capability is detected.\n\n**New:** a Microgrid Interconnect Device appears among your devices, carrying **Grid State** — the health of the utility supply itself, which the previous firmware did not report.\n\nDismiss this once you have had a look." } } } diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index f3dc6839..83e427c2 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -705,9 +705,9 @@ } }, "issues": { - "grid_islandable_moved_to_mid": { - "title": "Islanding state has moved to the Microgrid Interconnect Device", - "description": "Your SPAN Panel upgraded to the eBus v1.0 data model, which no longer publishes a panel-level `grid-islandable` property. The **Grid Islandable** sensor still works — it now reports whether a Microgrid Interconnect Device (MID) is present, which is how v1.0 says backup capability is detected.\n\nWhat changed is where *live* islanding state lives. `Grid Islandable` answers \"could this panel island\"; the MID answers \"is it islanding right now\". If you have automations that used Grid Islandable to detect an actual outage, point them at the MID device's grid entities instead.\n\nThis notice does not need to be fixed and can be dismissed once your automations are checked." + "panel_upgraded_to_ebus_v1": { + "title": "Your SPAN Panel now reports islanding from its Microgrid Interconnect Device", + "description": "Your SPAN Panel upgraded to the eBus v1.0 data model. Nothing you rely on has gone away, and no automation changes are required — this is a note about what improved.\n\n**DSM Grid State** keeps its entity ID and its history, and is now more trustworthy. On the previous firmware it was *inferred* — from the battery when one was fitted, otherwise from the dominant power source and whether any power was crossing the grid connection. It now reads the islanding state the Microgrid Interconnect Device (MID) actually senses.\n\n**Grid Islandable** also keeps working. v1.0 publishes no panel-level `grid-islandable` property, so it now reflects whether a MID is present, which is how v1.0 says backup capability is detected.\n\n**New:** a Microgrid Interconnect Device appears among your devices, carrying **Grid State** — the health of the utility supply itself, which the previous firmware did not report.\n\nDismiss this once you have had a look." } } } diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index 9b865d01..ed94a353 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -705,9 +705,9 @@ } }, "issues": { - "grid_islandable_moved_to_mid": { - "title": "El estado de aislamiento se ha trasladado al Dispositivo de Interconexión de Microrred", - "description": "Su Panel SPAN se ha actualizado al modelo de datos eBus v1.0, que ya no publica una propiedad `grid-islandable` a nivel de panel. El sensor **Aislable de la Red** sigue funcionando: ahora indica si hay presente un Dispositivo de Interconexión de Microrred (MID), que es como v1.0 determina la capacidad de respaldo.\n\nLo que ha cambiado es dónde reside el estado de aislamiento *en tiempo real*. «Aislable de la Red» responde a «¿podría aislarse este panel?»; el MID responde a «¿se está aislando ahora mismo?». Si tiene automatizaciones que usaban Aislable de la Red para detectar un corte real, apúntelas a las entidades de red del dispositivo MID.\n\nEste aviso no requiere ninguna corrección y puede descartarse una vez revisadas sus automatizaciones." + "panel_upgraded_to_ebus_v1": { + "title": "Su Panel SPAN ahora informa del aislamiento desde su Dispositivo de Interconexión de Microrred", + "description": "Su Panel SPAN se ha actualizado al modelo de datos eBus v1.0. Nada de lo que usted utiliza ha desaparecido y no es necesario modificar ninguna automatización: este aviso solo explica lo que ha mejorado.\n\n**Estado de Red DSM** conserva su ID de entidad y su historial, y ahora es más fiable. Con el firmware anterior se *deducía*: a partir de la batería cuando había una instalada y, en su defecto, de la fuente de energía dominante y de si circulaba potencia por la conexión a la red. Ahora lee el estado de aislamiento que el Dispositivo de Interconexión de Microrred (MID) detecta realmente.\n\n**Aislable de la Red** también sigue funcionando. La v1.0 no publica ninguna propiedad `grid-islandable` a nivel de panel, por lo que ahora refleja si hay un MID presente, que es como la v1.0 determina la capacidad de respaldo.\n\n**Novedad:** aparece un Dispositivo de Interconexión de Microrred entre sus dispositivos, con **Estado de la Red**, es decir, el estado del suministro eléctrico, que el firmware anterior no informaba.\n\nPuede descartar este aviso cuando lo haya revisado." } } } diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index d19919cb..8061af76 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -705,9 +705,9 @@ } }, "issues": { - "grid_islandable_moved_to_mid": { - "title": "L'état d'îlotage a été déplacé vers le Dispositif d'Interconnexion de Micro-réseau", - "description": "Votre Panneau SPAN est passé au modèle de données eBus v1.0, qui ne publie plus de propriété `grid-islandable` au niveau du panneau. Le capteur **Îlotage Réseau Possible** fonctionne toujours : il indique désormais la présence d'un Dispositif d'Interconnexion de Micro-réseau (MID), ce qui est la façon dont v1.0 détermine la capacité de secours.\n\nCe qui a changé, c'est l'emplacement de l'état d'îlotage *en temps réel*. « Îlotage Réseau Possible » répond à « ce panneau pourrait-il s'îloter ? » ; le MID répond à « s'îlote-t-il en ce moment ? ». Si vous avez des automatisations qui utilisaient Îlotage Réseau Possible pour détecter une panne réelle, dirigez-les vers les entités réseau du dispositif MID.\n\nCet avis ne nécessite aucune correction et peut être ignoré une fois vos automatisations vérifiées." + "panel_upgraded_to_ebus_v1": { + "title": "Votre Panneau SPAN signale désormais l'îlotage depuis son Dispositif d'Interconnexion de Micro-réseau", + "description": "Votre Panneau SPAN est passé au modèle de données eBus v1.0. Rien de ce que vous utilisez n'a disparu et aucune modification de vos automatisations n'est nécessaire : cet avis explique simplement ce qui s'est amélioré.\n\n**État Réseau DSM** conserve son identifiant d'entité et son historique, et gagne en fiabilité. Avec le micrologiciel précédent, il était *déduit* : à partir de la batterie lorsqu'il y en avait une, sinon à partir de la source d'énergie dominante et de la présence de puissance transitant par le raccordement réseau. Il lit désormais l'état d'îlotage réellement mesuré par le Dispositif d'Interconnexion de Micro-réseau (MID).\n\n**Îlotage Réseau Possible** continue également de fonctionner. La v1.0 ne publie aucune propriété `grid-islandable` au niveau du panneau ; ce capteur indique donc maintenant la présence d'un MID, ce qui est la façon dont v1.0 détermine la capacité de secours.\n\n**Nouveau :** un Dispositif d'Interconnexion de Micro-réseau apparaît parmi vos appareils, avec **État du Réseau** — l'état de l'alimentation du réseau public, que le micrologiciel précédent ne signalait pas.\n\nVous pouvez ignorer cet avis après consultation." } } } diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index 6b135cfe..4e4e6458 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -705,9 +705,9 @@ } }, "issues": { - "grid_islandable_moved_to_mid": { - "title": "自立運転状態はマイクログリッド相互接続装置に移動しました", - "description": "SPAN パネルが eBus v1.0 データモデルにアップグレードされました。v1.0 ではパネル単位の `grid-islandable` プロパティは公開されません。**系統自立運転可能** センサーは引き続き動作し、現在はマイクログリッド相互接続装置 (MID) の有無を示します。これが v1.0 におけるバックアップ能力の判定方法です。\n\n変更されたのは、*現在の*自立運転状態がどこにあるかです。「系統自立運転可能」は「このパネルは自立運転できるか」に答え、MID は「今まさに自立運転しているか」に答えます。実際の停電検知に「系統自立運転可能」を使用しているオートメーションがある場合は、MID デバイスの系統エンティティを参照するように変更してください。\n\nこの通知は修正を必要とせず、オートメーションを確認した後に閉じることができます。" + "panel_upgraded_to_ebus_v1": { + "title": "SPAN パネルは自立運転状態をマイクログリッド相互接続装置から取得するようになりました", + "description": "SPAN パネルが eBus v1.0 データモデルにアップグレードされました。ご利用中のものが失われることはなく、オートメーションの変更も必要ありません。この通知は改善点をお知らせするものです。\n\n**DSM 系統状態** はエンティティ ID と履歴をそのまま維持し、さらに信頼性が向上しました。以前のファームウェアでは *推定* されていました。バッテリーがある場合はバッテリーから、ない場合は主電源と系統接続を流れる電力の有無から判断していました。現在はマイクログリッド相互接続装置 (MID) が実際に検知した自立運転状態を読み取ります。\n\n**系統自立運転可能** も引き続き動作します。v1.0 ではパネル単位の `grid-islandable` プロパティは公開されないため、現在は MID の有無を示します。これが v1.0 におけるバックアップ能力の判定方法です。\n\n**新規:** デバイス一覧にマイクログリッド相互接続装置が追加され、**系統状態** (電力会社からの供給状態) を報告します。これは以前のファームウェアにはなかった情報です。\n\n内容を確認したら、この通知は閉じて構いません。" } } } diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index 79d0499d..166aa539 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -705,9 +705,9 @@ } }, "issues": { - "grid_islandable_moved_to_mid": { - "title": "O estado de ilhamento foi movido para o Dispositivo de Interligação de Microrrede", - "description": "O seu Painel SPAN foi atualizado para o modelo de dados eBus v1.0, que já não publica uma propriedade `grid-islandable` ao nível do painel. O sensor **Isolável da Rede** continua a funcionar: agora indica se existe um Dispositivo de Interligação de Microrrede (MID), que é como a v1.0 determina a capacidade de reserva.\n\nO que mudou foi onde reside o estado de ilhamento *em tempo real*. «Isolável da Rede» responde a «este painel poderia ilhar-se?»; o MID responde a «está a ilhar-se neste momento?». Se tiver automações que usavam Isolável da Rede para detetar uma falha real, aponte-as para as entidades de rede do dispositivo MID.\n\nEste aviso não precisa de ser corrigido e pode ser dispensado depois de verificar as suas automações." + "panel_upgraded_to_ebus_v1": { + "title": "O seu Painel SPAN comunica agora o ilhamento a partir do Dispositivo de Interligação de Microrrede", + "description": "O seu Painel SPAN foi atualizado para o modelo de dados eBus v1.0. Nada do que utiliza desapareceu e não é necessário alterar automações — este aviso apenas explica o que melhorou.\n\n**Estado da Rede DSM** mantém o seu ID de entidade e o seu histórico, e passa a ser mais fiável. No firmware anterior era *inferido*: a partir da bateria quando existia uma e, caso contrário, a partir da fonte de energia dominante e da existência de potência a atravessar a ligação à rede. Agora lê o estado de ilhamento que o Dispositivo de Interligação de Microrrede (MID) realmente deteta.\n\n**Isolável da Rede** também continua a funcionar. A v1.0 não publica qualquer propriedade `grid-islandable` ao nível do painel, pelo que agora reflete a presença de um MID, que é como a v1.0 determina a capacidade de reserva.\n\n**Novidade:** aparece um Dispositivo de Interligação de Microrrede entre os seus dispositivos, com **Estado da Rede** — o estado do fornecimento da rede elétrica, que o firmware anterior não comunicava.\n\nPode dispensar este aviso depois de o consultar." } } } From 9a4c0843a45b2d472c14fd812683f6483a44da79 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:01:34 -0700 Subject: [PATCH 009/345] Link sub-devices by registry id, and scope the panel lookup to its entry Home Assistant deprecated `via_device=(DOMAIN, serial)` and the unscoped `async_get_device(identifiers=...)` together in 2026.8, for one reason: device identifiers are unique only within a config entry, so anything treating them as globally unique is ambiguous by construction. Both stop working in 2027.8. Both fired on every startup. `via_device_id` takes a registry id, which exists only once the panel device does. Rather than have six call sites each hit the registry, setup resolves it at the one place the device is known to exist -- `ensure_device_registered` either just created it or just found it -- and carries it on SpanPanelRuntimeData. That is what lets the three sub-device builders take a plain `str` and leaves no caller with an absence to handle. runtime_data is now built after registration rather than before. Requires Home Assistant 2026.8: neither replacement exists in 2026.5.4, which this repo pinned, so the floor moves and 2026.5-2026.7 are dropped. 2026.8.0 is the floor rather than 2026.8.1 because both APIs are already there, verified against an installed 2026.8.0 rather than assumed. The test pins follow it: pytest-homeassistant-custom-component pins one exact Home Assistant per release, so `==0.13.354` is not a preference but the only release that pins 2026.8.0 -- a `>=` there would imply a freedom that does not exist. Testing at the declared floor is what catches an accidental dependency on a later patch. Three minor versions of churn produced no unrelated failures; every initial break was this change's own. hacs.json's stated minimum was 2026.8.0 -- it claimed 2023.3.0, which the `type` statements and runtime_data alone have made untrue for a long time. Each deprecation is falsified separately in tests/test_device_links.py: restoring `via_device` fails the builder test for all three sub-devices, and restoring the unscoped lookup fails on adopting another entry's device. The end-to-end registry test deliberately does not guard the deprecation -- Home Assistant resolves the old form to the same id -- it guards the id being right, which is the failure the registry swallows silently by dropping a `via_device_id` it cannot resolve. --- custom_components/span_panel/__init__.py | 45 +- custom_components/span_panel/binary_sensor.py | 13 +- custom_components/span_panel/sensor.py | 22 +- custom_components/span_panel/sensor_evse.py | 6 +- custom_components/span_panel/util.py | 30 +- hacs.json | 2 +- pyproject.toml | 10 +- requirements_test.txt | 4 +- tests/test_binary_sensor_platform.py | 4 + tests/test_circuit_manifest_service.py | 30 +- tests/test_device_links.py | 221 +++++ tests/test_diagnostics.py | 4 +- tests/test_evse_entities.py | 25 +- tests/test_init_helpers.py | 6 +- tests/test_mid_device.py | 8 +- tests/test_promoted_sensors.py | 8 +- tests/test_sensor_entities.py | 10 + tests/test_sensor_platform.py | 19 + tests/test_setup_entry.py | 8 +- tests/test_websocket.py | 24 +- uv.lock | 881 +++++++++--------- 21 files changed, 892 insertions(+), 488 deletions(-) create mode 100644 tests/test_device_links.py diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py index dba10167..4bfcb60f 100644 --- a/custom_components/span_panel/__init__.py +++ b/custom_components/span_panel/__init__.py @@ -74,6 +74,12 @@ class SpanPanelRuntimeData: """Runtime data for a Span Panel config entry.""" coordinator: SpanPanelCoordinator + # Registry id of the panel device, which every sub-device (BESS, MID, EVSE) + # hangs off with `via_device_id`. Resolved once during setup rather than + # per platform: sub-devices are only ever built after the panel device + # exists, so an id looked up here is one no caller has to handle the absence + # of. See `ensure_device_registered`. + panel_device_id: str type SpanPanelConfigEntry = ConfigEntry[SpanPanelRuntimeData] @@ -211,8 +217,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> entry.async_on_unload(entry.add_update_listener(update_listener)) - entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator) - snapshot: SpanPanelSnapshot = coordinator.data serial_number = snapshot.serial_number @@ -236,7 +240,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> if entry.title == serial_number: hass.config_entries.async_update_entry(entry, title=smart_device_name) - await ensure_device_registered(hass, entry, snapshot, smart_device_name) + # Populated here rather than earlier because the panel's registry id is + # part of it, and that only exists once the device is registered. Nothing + # between the coordinator's first refresh and this point reads + # runtime_data, and platforms — which all do — are forwarded below. + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id=await ensure_device_registered( + hass, entry, snapshot, smart_device_name + ), + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) except Exception: @@ -318,22 +331,34 @@ async def ensure_device_registered( entry: SpanPanelConfigEntry, snapshot: SpanPanelSnapshot, device_name: str, -) -> None: +) -> str: """Register or reconcile the HA Device before creating sensors. Ensures the device exists in the device registry with proper naming and - identifiers. + identifiers, and returns its registry id — the value every sub-device links + to. Returned rather than looked up again by each platform because this is + the one place the device is known to exist: either it already did, or this + call is what created it. """ device_registry = dr.async_get(hass) serial_number = snapshot.serial_number host = entry.data.get(CONF_HOST) - existing_device = device_registry.async_get_device(identifiers={(DOMAIN, serial_number)}) + # Scoped to this config entry rather than searching every one. Identifiers + # are unique only within an entry, so the unscoped lookup is ambiguous by + # construction — it could answer with another integration's device that + # happens to share the identifier — which is why it is deprecated and stops + # working in 2027.8. + existing_device = device_registry.async_get_device_by_identifier( + (DOMAIN, serial_number), entry.entry_id + ) - if existing_device: + if existing_device is not None: if existing_device.name == serial_number: device_registry.async_update_device(existing_device.id, name=device_name) - else: - device_info = snapshot_to_device_info(snapshot, device_name, host=host) - device_registry.async_get_or_create(config_entry_id=entry.entry_id, **device_info) + return existing_device.id + + device_info = snapshot_to_device_info(snapshot, device_name, host=host) + created = device_registry.async_get_or_create(config_entry_id=entry.entry_id, **device_info) + return created.id diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py index 7947d3b1..59a125b8 100644 --- a/custom_components/span_panel/binary_sensor.py +++ b/custom_components/span_panel/binary_sensor.py @@ -347,7 +347,11 @@ def __init__( use_circuit_numbers = data_coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) display_suffix = resolve_evse_display_suffix(evse, snapshot, use_circuit_numbers) self._attr_device_info = evse_device_info( - panel_identifier, evse, panel_name, display_suffix + panel_identifier, + evse, + panel_name, + display_suffix, + panel_device_id=data_coordinator.config_entry.runtime_data.panel_device_id, ) device_name = data_coordinator.config_entry.data.get( @@ -400,7 +404,12 @@ async def async_setup_entry( or "Span Panel" ) - bess_info = bess_device_info(snapshot.serial_number, snapshot.battery, panel_name) + bess_info = bess_device_info( + snapshot.serial_number, + snapshot.battery, + panel_name, + panel_device_id=config_entry.runtime_data.panel_device_id, + ) entities.append( SpanPanelBinarySensor( coordinator, BESS_CONNECTED_SENSOR, device_info_override=bess_info diff --git a/custom_components/span_panel/sensor.py b/custom_components/span_panel/sensor.py index e546d1a0..66acba12 100644 --- a/custom_components/span_panel/sensor.py +++ b/custom_components/span_panel/sensor.py @@ -196,7 +196,13 @@ def _build_evse_device_info_map( mapping: dict[str, DeviceInfo] = {} for evse in snapshot.evse.values(): display_suffix = resolve_evse_display_suffix(evse, snapshot, use_circuit_numbers) - info = evse_device_info(panel_identifier, evse, panel_name, display_suffix) + info = evse_device_info( + panel_identifier, + evse, + panel_name, + display_suffix, + panel_device_id=coordinator.config_entry.runtime_data.panel_device_id, + ) mapping[evse.feed_circuit_id] = info return mapping @@ -305,7 +311,12 @@ def _build_bess_device_info( or "Span Panel" ) - return bess_device_info(snapshot.serial_number, snapshot.battery, panel_name) + return bess_device_info( + snapshot.serial_number, + snapshot.battery, + panel_name, + panel_device_id=coordinator.config_entry.runtime_data.panel_device_id, + ) def _build_mid_device_info( @@ -319,7 +330,12 @@ def _build_mid_device_info( mid = snapshot.mid if mid is None: raise ValueError("cannot build MID device info for a snapshot with no MID") - return mid_device_info(snapshot.serial_number, mid, panel_name) + return mid_device_info( + snapshot.serial_number, + mid, + panel_name, + panel_device_id=coordinator.config_entry.runtime_data.panel_device_id, + ) def create_mid_sensors( diff --git a/custom_components/span_panel/sensor_evse.py b/custom_components/span_panel/sensor_evse.py index f45d6a66..4d9bdf15 100644 --- a/custom_components/span_panel/sensor_evse.py +++ b/custom_components/span_panel/sensor_evse.py @@ -46,7 +46,11 @@ def __init__( use_circuit_numbers = data_coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) display_suffix = resolve_evse_display_suffix(evse, snapshot, use_circuit_numbers) self._attr_device_info = evse_device_info( - panel_identifier, evse, panel_name, display_suffix + panel_identifier, + evse, + panel_name, + display_suffix, + panel_device_id=data_coordinator.config_entry.runtime_data.panel_device_id, ) def _generate_unique_id( diff --git a/custom_components/span_panel/util.py b/custom_components/span_panel/util.py index 889f6776..8062c190 100644 --- a/custom_components/span_panel/util.py +++ b/custom_components/span_panel/util.py @@ -14,6 +14,15 @@ _LOGGER = logging.getLogger(__name__) +# Every SPAN sub-device hangs off the panel and says so with `via_device_id` -- +# the panel's *registry* id, not its identifiers. Identifiers are unique only +# within a config entry, so linking by them is ambiguous by construction; Home +# Assistant deprecated `via_device` for that reason and stops honouring it in +# 2027.8. The id is resolved once during setup and carried on the entry's +# runtime data, because a sub-device is only ever built after the panel device +# exists -- which is what lets the builders below take a plain `str` and leaves +# no caller with an absence to handle. + def snapshot_to_device_info( snapshot: SpanPanelSnapshot, @@ -37,8 +46,15 @@ def bess_device_info( panel_identifier: str, battery: SpanBatterySnapshot, panel_name: str, + *, + panel_device_id: str, ) -> DeviceInfo: - """Create DeviceInfo for a BESS sub-device linked to the parent panel.""" + """Create DeviceInfo for a BESS sub-device linked to the parent panel. + + Two panel-shaped arguments doing different jobs: `panel_identifier` is the + serial this sub-device namespaces its own identity under, `panel_device_id` + is the registry id it links to. + """ name = f"{panel_name} Battery" return DeviceInfo( identifiers={(DOMAIN, f"{panel_identifier}_bess")}, @@ -49,7 +65,7 @@ def bess_device_info( model=battery.model or "Battery Storage", serial_number=battery.serial_number, sw_version=battery.software_version, - via_device=(DOMAIN, panel_identifier), + via_device_id=panel_device_id, ) @@ -57,6 +73,8 @@ def mid_device_info( panel_identifier: str, mid: SpanMidSnapshot, panel_name: str, + *, + panel_device_id: str, ) -> DeviceInfo: """Create DeviceInfo for the Microgrid Interconnect Device. @@ -68,7 +86,7 @@ def mid_device_info( Purely additive: no flat panel publishes a MID, so nothing a user has today changes. - `via_device` the panel, matching BESS and EVSE, even though the wire tree makes the + Linked to the panel, matching BESS and EVSE, even though the wire tree makes the MID a child of the BESS. Home Assistant's device graph is about what a user navigates, and every SPAN sub-device hangs off the panel there; mirroring the Homie parentage would put the MID one level deeper than its siblings for no reader's benefit. @@ -79,7 +97,7 @@ def mid_device_info( manufacturer=mid.vendor_name or "Unknown", model=mid.model or "Microgrid Interconnect Device", serial_number=mid.serial_number, - via_device=(DOMAIN, panel_identifier), + via_device_id=panel_device_id, ) @@ -88,6 +106,8 @@ def evse_device_info( evse: SpanEvseSnapshot, panel_name: str, display_suffix: str | None = None, + *, + panel_device_id: str, ) -> DeviceInfo: """Create DeviceInfo for an EVSE sub-device linked to the parent panel.""" base_name = evse.model or "EV Charger" @@ -100,5 +120,5 @@ def evse_device_info( model=evse.model or "SPAN Drive", serial_number=evse.serial_number, sw_version=evse.software_version, - via_device=(DOMAIN, panel_identifier), + via_device_id=panel_device_id, ) diff --git a/hacs.json b/hacs.json index 1d29ef7d..77764d3c 100644 --- a/hacs.json +++ b/hacs.json @@ -1,7 +1,7 @@ { "name": "Span Panel", "content_in_root": false, - "homeassistant": "2023.3.0", + "homeassistant": "2026.8.0", "render_readme": true, "zip_release": false, "filename": "custom_components/span_panel" diff --git a/pyproject.toml b/pyproject.toml index ba5fef2a..7d8e974b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ license = {text = "MIT"} readme = "README.md" requires-python = ">=3.14.3,<3.15" dependencies = [ - "homeassistant==2026.5.4", + "homeassistant==2026.8.0", "span-panel-api==3.0.0b3", # Separate distributions, not dependencies of the bootstrap: adapters are found # through the `span_panel_api.schema_adapters` entry-point group, so each one has @@ -19,7 +19,7 @@ dependencies = [ [dependency-groups] dev = [ - "homeassistant-stubs==2026.5.4", + "homeassistant-stubs==2026.8.0", "types-requests", "types-PyYAML", "mypy==1.20.2", @@ -32,7 +32,11 @@ dev = [ "prettier", "radon==6.0.1", "pylint==4.0.5", - "pytest-homeassistant-custom-component>=0.13.333", + # Pinned, not floored: each release pins one exact Home Assistant, so this + # and the `homeassistant` pin above are a single choice rather than two. + # 0.13.354 is the release that pins 2026.8.0, the version this integration + # declares as its floor and therefore the one worth testing against. + "pytest-homeassistant-custom-component==0.13.354", "isort", "vulture>=2.14", ] diff --git a/requirements_test.txt b/requirements_test.txt index 0733eb9d..250967b2 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,5 +1,5 @@ pytest>=9.0.3 pytest-asyncio>=1.3.0 setuptools>=65.7.0 -pytest-homeassistant-custom-component>=0.13.333 -homeassistant>=2026.5.4 +pytest-homeassistant-custom-component==0.13.354 +homeassistant==2026.8.0 diff --git a/tests/test_binary_sensor_platform.py b/tests/test_binary_sensor_platform.py index 5c8ac2b8..297160ef 100644 --- a/tests/test_binary_sensor_platform.py +++ b/tests/test_binary_sensor_platform.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, MagicMock +from custom_components.span_panel import SpanPanelRuntimeData from custom_components.span_panel.binary_sensor import ( BESS_CONNECTED_SENSOR, BINARY_SENSORS, @@ -42,6 +43,9 @@ def _make_coordinator(snapshot) -> MagicMock: title="SPAN Panel", unique_id=snapshot.serial_number, ) + coordinator.config_entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, panel_device_id="panel-device-id" + ) coordinator.async_request_refresh = AsyncMock() return coordinator diff --git a/tests/test_circuit_manifest_service.py b/tests/test_circuit_manifest_service.py index 922ef680..b79da009 100644 --- a/tests/test_circuit_manifest_service.py +++ b/tests/test_circuit_manifest_service.py @@ -90,7 +90,8 @@ async def test_basic_manifest(self, hass: HomeAssistant): entry.add_to_hass(hass) entry.mock_state(hass, ConfigEntryState.LOADED) entry.runtime_data = SpanPanelRuntimeData( - coordinator=_make_coordinator(snapshot) + coordinator=_make_coordinator(snapshot), + panel_device_id="panel-device-id", ) _register_power_entity( @@ -166,10 +167,12 @@ async def test_multiple_panels(self, hass: HomeAssistant): entry_a.mock_state(hass, ConfigEntryState.LOADED) entry_b.mock_state(hass, ConfigEntryState.LOADED) entry_a.runtime_data = SpanPanelRuntimeData( - coordinator=_make_coordinator(snapshot_a) + coordinator=_make_coordinator(snapshot_a), + panel_device_id="panel-device-id", ) entry_b.runtime_data = SpanPanelRuntimeData( - coordinator=_make_coordinator(snapshot_b) + coordinator=_make_coordinator(snapshot_b), + panel_device_id="panel-device-id", ) _register_power_entity( @@ -211,7 +214,8 @@ async def test_unmapped_tabs_excluded(self, hass: HomeAssistant): entry.add_to_hass(hass) entry.mock_state(hass, ConfigEntryState.LOADED) entry.runtime_data = SpanPanelRuntimeData( - coordinator=_make_coordinator(snapshot) + coordinator=_make_coordinator(snapshot), + panel_device_id="panel-device-id", ) _register_power_entity( @@ -254,7 +258,8 @@ async def test_circuit_without_entity_excluded(self, hass: HomeAssistant): entry.add_to_hass(hass) entry.mock_state(hass, ConfigEntryState.LOADED) entry.runtime_data = SpanPanelRuntimeData( - coordinator=_make_coordinator(snapshot) + coordinator=_make_coordinator(snapshot), + panel_device_id="panel-device-id", ) # Only register entity for one circuit @@ -315,7 +320,8 @@ async def test_all_device_types_included(self, hass: HomeAssistant): entry.add_to_hass(hass) entry.mock_state(hass, ConfigEntryState.LOADED) entry.runtime_data = SpanPanelRuntimeData( - coordinator=_make_coordinator(snapshot) + coordinator=_make_coordinator(snapshot), + panel_device_id="panel-device-id", ) _register_power_entity( @@ -361,7 +367,8 @@ async def test_bess_device_type_mapped_to_battery(self, hass: HomeAssistant): entry.add_to_hass(hass) entry.mock_state(hass, ConfigEntryState.LOADED) entry.runtime_data = SpanPanelRuntimeData( - coordinator=_make_coordinator(snapshot) + coordinator=_make_coordinator(snapshot), + panel_device_id="panel-device-id", ) _register_power_entity( @@ -395,7 +402,8 @@ async def test_panel_with_no_resolvable_circuits_omitted(self, hass: HomeAssista entry.add_to_hass(hass) entry.mock_state(hass, ConfigEntryState.LOADED) entry.runtime_data = SpanPanelRuntimeData( - coordinator=_make_coordinator(snapshot) + coordinator=_make_coordinator(snapshot), + panel_device_id="panel-device-id", ) # No entities registered @@ -426,7 +434,8 @@ async def test_template_uses_min_tab(self, hass: HomeAssistant): entry.add_to_hass(hass) entry.mock_state(hass, ConfigEntryState.LOADED) entry.runtime_data = SpanPanelRuntimeData( - coordinator=_make_coordinator(snapshot) + coordinator=_make_coordinator(snapshot), + panel_device_id="panel-device-id", ) _register_power_entity( @@ -458,7 +467,8 @@ async def test_host_included_from_config_entry(self, hass: HomeAssistant): entry.add_to_hass(hass) entry.mock_state(hass, ConfigEntryState.LOADED) entry.runtime_data = SpanPanelRuntimeData( - coordinator=_make_coordinator(snapshot) + coordinator=_make_coordinator(snapshot), + panel_device_id="panel-device-id", ) _register_power_entity( diff --git a/tests/test_device_links.py b/tests/test_device_links.py new file mode 100644 index 00000000..0a4b04e5 --- /dev/null +++ b/tests/test_device_links.py @@ -0,0 +1,221 @@ +"""Sub-devices hang off the panel by registry id, not by identifiers. + +Home Assistant deprecated `via_device=(DOMAIN, serial)` and the unscoped +`async_get_device(identifiers=...)` together in 2026.8, for one reason: device +identifiers are unique only *within* a config entry, so anything treating them as +globally unique is ambiguous by construction. Both stop working in 2027.8. + +The replacement is a registry id, which only exists once the panel device does. +That is the whole design constraint here — a sub-device cannot name its parent +until its parent is registered — and it is why setup resolves the id once and +carries it on the entry's runtime data instead of every platform looking it up. + +The end-to-end test below is the one that matters. `via_device_id` naming a +device that does not exist is dropped by the registry rather than rejected, so a +wrong id threaded through these builders would not raise anywhere: the device +would simply appear unparented in the UI, which is exactly the kind of failure +a unit test asserting on a dict cannot see. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from custom_components.span_panel import ensure_device_registered +from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.util import ( + bess_device_info, + evse_device_info, + mid_device_info, +) +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from span_panel_api import SpanMidSnapshot + +from .factories import ( + SpanBatterySnapshotFactory, + SpanEvseSnapshotFactory, + SpanPanelSnapshotFactory, +) + +from pytest_homeassistant_custom_component.common import MockConfigEntry + +_PANEL_ID = "a-registry-id" + + +def _mid() -> SpanMidSnapshot: + return SpanMidSnapshot( + node_id="sp3-link-001-mid", + serial_number="sp3-link-001-mid", + vendor_name="Span", + model=None, + islanding_state="ON_GRID", + grid_state="UP", + grid_forming_entity="GRID", + ) + + +def _builders() -> list[tuple[str, Any]]: + """Every sub-device builder, so a new one is covered the day it lands.""" + return [ + ( + "bess", + lambda: bess_device_info( + "sp3-link-001", + SpanBatterySnapshotFactory.create(), + "Panel", + panel_device_id=_PANEL_ID, + ), + ), + ( + "mid", + lambda: mid_device_info( + "sp3-link-001", _mid(), "Panel", panel_device_id=_PANEL_ID + ), + ), + ( + "evse", + lambda: evse_device_info( + "sp3-link-001", + SpanEvseSnapshotFactory.create(), + "Panel", + panel_device_id=_PANEL_ID, + ), + ), + ] + + +@pytest.mark.parametrize(("label", "build"), _builders(), ids=lambda v: v if isinstance(v, str) else "") +def test_sub_devices_link_by_registry_id(label: str, build: Any) -> None: + """Both halves asserted: the new key is set and the old one is gone. + + Checking only that `via_device_id` is present would pass a builder that + passes both, which Home Assistant accepts today and stops accepting in + 2027.8 -- a regression that would sit unnoticed until the deadline. + """ + info = build() + + assert info.get("via_device_id") == _PANEL_ID, label + assert "via_device" not in info, f"{label} still links by identifiers" + + +async def test_registering_the_panel_answers_with_the_id_sub_devices_need( + hass: HomeAssistant, +) -> None: + """The returned id is the created device's, on the branch that creates it.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.30"}, + entry_id="entry-link-new", + unique_id="sp3-link-new", + ) + entry.add_to_hass(hass) + snapshot = SpanPanelSnapshotFactory.create(serial_number="sp3-link-new") + + panel_device_id = await ensure_device_registered(hass, entry, snapshot, "SPAN Panel") + + created = dr.async_get(hass).async_get_device_by_identifier( + (DOMAIN, "sp3-link-new"), entry.entry_id + ) + assert created is not None + assert panel_device_id == created.id + + +async def test_an_already_registered_panel_answers_with_the_same_id( + hass: HomeAssistant, +) -> None: + """And on the branch that finds one, which is every reload after the first. + + A second registration must not mint a new id: sub-devices registered against + the old one would be orphaned, and a user would watch their battery and + chargers detach from the panel on a restart. + """ + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.31"}, + entry_id="entry-link-existing", + unique_id="sp3-link-existing", + ) + entry.add_to_hass(hass) + snapshot = SpanPanelSnapshotFactory.create(serial_number="sp3-link-existing") + + first = await ensure_device_registered(hass, entry, snapshot, "SPAN Panel") + second = await ensure_device_registered(hass, entry, snapshot, "SPAN Panel") + + assert first == second + + +async def test_a_foreign_device_sharing_the_identifier_is_not_adopted( + hass: HomeAssistant, +) -> None: + """The ambiguity the deprecation exists to remove, made concrete. + + Identifiers are unique within a config entry and nowhere else, so a lookup + that searches every entry can answer with a device this entry does not own -- + and then every sub-device built from that answer hangs off somebody else's + panel. Scoping the lookup is what makes the answer unambiguous. + + A second SPAN entry cannot reach this state today, because the serial is the + entry's unique_id. That is a property of our config flow rather than of the + registry, though, and it is not the assumption the sub-device links should + rest on. + """ + other = MockConfigEntry( + domain=DOMAIN, data={}, entry_id="entry-other", unique_id="other" + ) + other.add_to_hass(hass) + registry = dr.async_get(hass) + foreign = registry.async_get_or_create( + config_entry_id=other.entry_id, + identifiers={(DOMAIN, "sp3-link-shared")}, + name="Somebody else's panel", + ) + + mine = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.33"}, + entry_id="entry-mine", + unique_id="sp3-link-shared", + ) + mine.add_to_hass(hass) + snapshot = SpanPanelSnapshotFactory.create(serial_number="sp3-link-shared") + + panel_device_id = await ensure_device_registered(hass, mine, snapshot, "SPAN Panel") + + assert panel_device_id != foreign.id + assert registry.async_get(panel_device_id) is not None + + +async def test_a_sub_device_really_lands_under_the_panel(hass: HomeAssistant) -> None: + """End to end through the registry, because a bad id fails silently. + + The registry drops a `via_device_id` it cannot resolve instead of raising, so + the only way to know the id threaded from setup through runtime data into a + builder is the right one is to register a device with it and read the link + back. + """ + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.32"}, + entry_id="entry-link-e2e", + unique_id="sp3-link-e2e", + ) + entry.add_to_hass(hass) + snapshot = SpanPanelSnapshotFactory.create(serial_number="sp3-link-e2e") + registry = dr.async_get(hass) + + panel_device_id = await ensure_device_registered(hass, entry, snapshot, "SPAN Panel") + bess = registry.async_get_or_create( + config_entry_id=entry.entry_id, + **bess_device_info( + "sp3-link-e2e", + SpanBatterySnapshotFactory.create(), + "SPAN Panel", + panel_device_id=panel_device_id, + ), + ) + + assert bess.via_device_id == panel_device_id diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index c5af3331..f257c2e8 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -76,7 +76,7 @@ async def test_config_entry_diagnostics_includes_redacted_runtime_data( title="SPAN Panel", unique_id="sp3-diag-001", ) - entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator) + entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator, panel_device_id="panel-device-id") result = await async_get_config_entry_diagnostics(hass, entry) @@ -157,7 +157,7 @@ async def test_config_entry_diagnostics_omits_optional_sections_when_unavailable coordinator.last_update_success = False entry = MockConfigEntry(domain=DOMAIN, data={}, title="SPAN Panel") - entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator) + entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator, panel_device_id="panel-device-id") result = await async_get_config_entry_diagnostics(hass, entry) diff --git a/tests/test_evse_entities.py b/tests/test_evse_entities.py index 31301810..87de36b2 100644 --- a/tests/test_evse_entities.py +++ b/tests/test_evse_entities.py @@ -177,7 +177,11 @@ def test_evse_device_info_full_metadata(self): software_version="2.0.0", ) info = evse_device_info( - "panel-serial", evse, "Main House", display_suffix="Garage" + "panel-serial", + evse, + "Main House", + display_suffix="Garage", + panel_device_id="panel-device-id", ) identifiers = info.get("identifiers") assert identifiers is not None @@ -187,15 +191,16 @@ def test_evse_device_info_full_metadata(self): assert info.get("model") == "SPAN Drive" assert info.get("serial_number") == "SN123" assert info.get("sw_version") == "2.0.0" - via = info.get("via_device") - assert via == ("span_panel", "panel-serial") + assert info.get("via_device_id") == "panel-device-id" def test_evse_device_info_fallback_names(self): evse = SpanEvseSnapshotFactory.create( vendor_name=None, model=None, ) - info = evse_device_info("panel-serial", evse, "Span Panel", display_suffix=None) + info = evse_device_info( + "panel-serial", evse, "Span Panel", display_suffix=None, panel_device_id="pd" + ) assert info.get("name") == "Span Panel EV Charger" assert info.get("manufacturer") == "SPAN" assert info.get("model") == "SPAN Drive" @@ -206,13 +211,17 @@ def test_evse_device_info_serial_suffix(self): serial_number="SN-EVSE-001", ) info = evse_device_info( - "panel-serial", evse, "Museum Garage", display_suffix="SN-EVSE-001" + "panel-serial", + evse, + "Museum Garage", + display_suffix="SN-EVSE-001", + panel_device_id="pd", ) assert info.get("name") == "Museum Garage SPAN Drive (SN-EVSE-001)" def test_evse_device_info_no_serial(self): evse = SpanEvseSnapshotFactory.create(serial_number=None) - info = evse_device_info("panel-serial", evse, "Span Panel") + info = evse_device_info("panel-serial", evse, "Span Panel", panel_device_id="pd") assert info.get("serial_number") is None @@ -262,8 +271,8 @@ def test_multiple_evse_in_snapshot(self): def test_multiple_evse_device_infos_are_distinct(self): evse_a = SpanEvseSnapshotFactory.create(node_id="evse-0") evse_b = SpanEvseSnapshotFactory.create(node_id="evse-1") - info_a = evse_device_info("panel", evse_a, "Span Panel") - info_b = evse_device_info("panel", evse_b, "Span Panel") + info_a = evse_device_info("panel", evse_a, "Span Panel", panel_device_id="pd") + info_b = evse_device_info("panel", evse_b, "Span Panel", panel_device_id="pd") assert info_a.get("identifiers") != info_b.get("identifiers") diff --git a/tests/test_init_helpers.py b/tests/test_init_helpers.py index a13774aa..327420a2 100644 --- a/tests/test_init_helpers.py +++ b/tests/test_init_helpers.py @@ -42,7 +42,7 @@ async def test_async_remove_config_entry_device_rejects_main_panel_device( coordinator = MagicMock() coordinator.data = snapshot entry = MockConfigEntry(domain=DOMAIN, data={}) - entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator) + entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator, panel_device_id="panel-device-id") device = MagicMock() device.identifiers = {(DOMAIN, "sp3-main-001")} @@ -57,7 +57,7 @@ async def test_async_remove_config_entry_device_allows_subdevice_removal( coordinator = MagicMock() coordinator.data = snapshot entry = MockConfigEntry(domain=DOMAIN, data={}) - entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator) + entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator, panel_device_id="panel-device-id") device = MagicMock() device.identifiers = {(DOMAIN, "sp3-main-001_evse")} @@ -176,7 +176,7 @@ async def test_async_unload_entry_shuts_down_runtime_data( coordinator = MagicMock() coordinator.async_shutdown = AsyncMock() entry = MockConfigEntry(domain=DOMAIN, data={}, entry_id="entry-789") - entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator) + entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator, panel_device_id="panel-device-id") with patch.object( hass.config_entries, "async_unload_platforms", AsyncMock(return_value=True) diff --git a/tests/test_mid_device.py b/tests/test_mid_device.py index 9dad1f9a..b4cbb1f3 100644 --- a/tests/test_mid_device.py +++ b/tests/test_mid_device.py @@ -59,17 +59,19 @@ def test_presence_needs_no_sentinel() -> None: def test_the_mid_becomes_its_own_device_hung_off_the_panel() -> None: """Identity renders on a device card rather than being folded onto the panel. - `via_device` is the panel even though the wire tree makes the MID a child of the + Linked to the panel even though the wire tree makes the MID a child of the BESS: Home Assistant's device graph is what a user navigates, and every SPAN sub-device hangs off the panel there. """ - info = mid_device_info("sim-40t-001", _mid(), "SPAN Panel") + info = mid_device_info( + "sim-40t-001", _mid(), "SPAN Panel", panel_device_id="panel-device-id" + ) assert info["identifiers"] == {("span_panel", "sim-40t-001_mid")} assert info["name"] == "SPAN Panel Microgrid Interconnect" assert info["manufacturer"] == "Span" assert info["serial_number"] == "SIM-BESS-40T-001-mid" - assert info["via_device"] == ("span_panel", "sim-40t-001") + assert info["via_device_id"] == "panel-device-id" # The producer publishes no MID model today; the card still needs a legible one. assert info["model"] == "Microgrid Interconnect Device" diff --git a/tests/test_promoted_sensors.py b/tests/test_promoted_sensors.py index f29eaabe..ee2ec445 100644 --- a/tests/test_promoted_sensors.py +++ b/tests/test_promoted_sensors.py @@ -226,18 +226,20 @@ def test_bess_device_info_basic(self): serial_number="TW-001", software_version="2.1.0", ) - info = bess_device_info("sp3-242424-001", battery, "My Panel") + info = bess_device_info( + "sp3-242424-001", battery, "My Panel", panel_device_id="panel-device-id" + ) assert info.get("identifiers") == {(DOMAIN, "sp3-242424-001_bess")} assert info.get("name") == "My Panel Battery" assert info.get("manufacturer") == "Tesla" assert info.get("model") == "Powerwall 2" assert info.get("serial_number") == "TW-001" assert info.get("sw_version") == "2.1.0" - assert info.get("via_device") == (DOMAIN, "sp3-242424-001") + assert info.get("via_device_id") == "panel-device-id" def test_bess_device_info_defaults_when_none(self): battery = SpanBatterySnapshotFactory.create() - info = bess_device_info("serial", battery, "Panel") + info = bess_device_info("serial", battery, "Panel", panel_device_id="pd") assert info.get("manufacturer") == "Unknown" assert info.get("model") == "Battery Storage" assert info.get("serial_number") is None diff --git a/tests/test_sensor_entities.py b/tests/test_sensor_entities.py index 61def958..acc3e599 100644 --- a/tests/test_sensor_entities.py +++ b/tests/test_sensor_entities.py @@ -10,6 +10,7 @@ from span_panel_api import SpanPVSnapshot from homeassistant.components.sensor import SensorDeviceClass +from custom_components.span_panel import SpanPanelRuntimeData from custom_components.span_panel.const import ( ENABLE_ENERGY_DIP_COMPENSATION, USE_CIRCUIT_NUMBERS, @@ -89,6 +90,9 @@ def _make_coordinator(snapshot, *, options: dict | None = None) -> MagicMock: title="SPAN Panel", unique_id=snapshot.serial_number, ) + coordinator.config_entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, panel_device_id="panel-device-id" + ) coordinator.request_reload = MagicMock() coordinator.register_circuit_energy_sensor = MagicMock() coordinator.get_circuit_dip_offset = MagicMock(return_value=0.0) @@ -589,6 +593,9 @@ def test_energy_sensor_coerces_invalid_grace_period_value() -> None: title="SPAN Panel", unique_id=snapshot.serial_number, ) + coordinator.config_entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, panel_device_id="panel-device-id" + ) description = next( desc for desc in PANEL_ENERGY_SENSORS if desc.key == "mainMeterEnergyConsumedWh" ) @@ -658,6 +665,9 @@ def test_evse_sensor_uses_evse_subdevice_info_and_name() -> None: title="SPAN Panel", unique_id=snapshot.serial_number, ) + coordinator.config_entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, panel_device_id="panel-device-id" + ) description = next(desc for desc in EVSE_SENSORS if desc.key == "evse_status") sensor = SpanEvseSensor(coordinator, description, snapshot, "evse-0") diff --git a/tests/test_sensor_platform.py b/tests/test_sensor_platform.py index bb68eb1d..e397ebbb 100644 --- a/tests/test_sensor_platform.py +++ b/tests/test_sensor_platform.py @@ -7,6 +7,7 @@ import pytest +from custom_components.span_panel import SpanPanelRuntimeData from custom_components.span_panel.const import ( DOMAIN, ENABLE_CIRCUIT_NET_ENERGY_SENSORS, @@ -236,6 +237,9 @@ def test_build_evse_device_info_map_uses_feed_circuit_and_display_suffix() -> No title="SPAN Panel", options={USE_CIRCUIT_NUMBERS: False}, ) + coordinator.config_entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, panel_device_id="panel-device-id" + ) mapping = _build_evse_device_info_map(coordinator, snapshot) @@ -274,6 +278,9 @@ def test_create_circuit_sensors_skips_unmapped_and_optional_net_sensors() -> Non unique_id=snapshot.serial_number, ) coordinator.config_entry = entry + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, panel_device_id="panel-device-id" + ) entities = create_circuit_sensors(coordinator, snapshot, entry) keys = [entity.original_key for entity in entities] @@ -306,6 +313,9 @@ def test_create_unmapped_circuit_sensors_only_creates_unmapped_entities() -> Non coordinator.config_entry = MockConfigEntry( domain=DOMAIN, data={}, title="SPAN Panel" ) + coordinator.config_entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, panel_device_id="panel-device-id" + ) entities = create_unmapped_circuit_sensors(coordinator, snapshot) @@ -328,6 +338,9 @@ def test_create_battery_sensors_returns_expected_entities_when_bess_present() -> data={"device_name": "Main House"}, title="SPAN Panel", ) + coordinator.config_entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, panel_device_id="panel-device-id" + ) entities = create_battery_sensors(coordinator, snapshot) keys = [entity.entity_description.key for entity in entities] @@ -352,6 +365,9 @@ def test_create_power_flow_sensors_gate_pv_and_site_flow() -> None: coordinator.config_entry = MockConfigEntry( domain=DOMAIN, data={}, title="SPAN Panel" ) + coordinator.config_entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, panel_device_id="panel-device-id" + ) entities = create_power_flow_sensors(coordinator, snapshot) keys = [entity.entity_description.key for entity in entities] @@ -376,6 +392,9 @@ def test_create_evse_sensors_creates_all_descriptions_for_each_charger() -> None coordinator.config_entry = MockConfigEntry( domain=DOMAIN, data={}, title="SPAN Panel" ) + coordinator.config_entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, panel_device_id="panel-device-id" + ) entities = create_evse_sensors(coordinator, snapshot) diff --git a/tests/test_setup_entry.py b/tests/test_setup_entry.py index 2152db52..af04a161 100644 --- a/tests/test_setup_entry.py +++ b/tests/test_setup_entry.py @@ -76,7 +76,7 @@ async def test_async_setup_entry_v2_success_sets_runtime_data_and_title( ) as mock_coordinator_cls, patch( "custom_components.span_panel.ensure_device_registered", - AsyncMock(), + AsyncMock(return_value="panel-device-id"), ) as mock_ensure_device, patch.object( hass.config_entries, "async_forward_entry_setups", AsyncMock() @@ -85,7 +85,11 @@ async def test_async_setup_entry_v2_success_sets_runtime_data_and_title( ): assert await async_setup_entry(hass, entry) is True - assert entry.runtime_data == SpanPanelRuntimeData(coordinator=coordinator) + # The panel's registry id is carried forward, not recomputed: every sub-device + # links to it with `via_device_id`, and registration is the only place it is known. + assert entry.runtime_data == SpanPanelRuntimeData( + coordinator=coordinator, panel_device_id="panel-device-id" + ) assert hass.data[DOMAIN]["websocket_registered"] is True mock_ws.assert_called_once_with(hass) mock_client_cls.assert_called_once() diff --git a/tests/test_websocket.py b/tests/test_websocket.py index ea45eb5a..188de7cf 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -392,7 +392,8 @@ async def test_sub_device_id_rejected(self, hass: HomeAssistant): entry.add_to_hass(hass) entry.mock_state(hass, ConfigEntryState.LOADED) entry.runtime_data = SpanPanelRuntimeData( - coordinator=_make_coordinator(SpanPanelSnapshotFactory.create()) + coordinator=_make_coordinator(SpanPanelSnapshotFactory.create()), + panel_device_id="panel-device-id", ) panel_device = _register_panel_device( @@ -479,7 +480,8 @@ async def test_successful_topology_basic(self, hass: HomeAssistant): entry.add_to_hass(hass) entry.mock_state(hass, ConfigEntryState.LOADED) entry.runtime_data = SpanPanelRuntimeData( - coordinator=_make_coordinator(snapshot) + coordinator=_make_coordinator(snapshot), + panel_device_id="panel-device-id", ) device = _register_panel_device(hass, "span_entry", serial="sp3-test-001") @@ -542,7 +544,8 @@ async def test_unmapped_circuits_excluded(self, hass: HomeAssistant): entry.add_to_hass(hass) entry.mock_state(hass, ConfigEntryState.LOADED) entry.runtime_data = SpanPanelRuntimeData( - coordinator=_make_coordinator(snapshot) + coordinator=_make_coordinator(snapshot), + panel_device_id="panel-device-id", ) device = _register_panel_device(hass, "span_entry") @@ -571,7 +574,8 @@ async def test_sub_devices_included(self, hass: HomeAssistant): entry.add_to_hass(hass) entry.mock_state(hass, ConfigEntryState.LOADED) entry.runtime_data = SpanPanelRuntimeData( - coordinator=_make_coordinator(snapshot) + coordinator=_make_coordinator(snapshot), + panel_device_id="panel-device-id", ) panel_device = _register_panel_device(hass, "span_entry", serial="sp3-sub-001") @@ -649,7 +653,8 @@ async def test_evse_feed_circuit_entities_found(self, hass: HomeAssistant): entry.add_to_hass(hass) entry.mock_state(hass, ConfigEntryState.LOADED) entry.runtime_data = SpanPanelRuntimeData( - coordinator=_make_coordinator(snapshot) + coordinator=_make_coordinator(snapshot), + panel_device_id="panel-device-id", ) panel_device = _register_panel_device(hass, "span_entry", serial="sp3-evse-001") @@ -722,7 +727,8 @@ async def test_topology_includes_always_on_and_priority(self, hass: HomeAssistan entry.add_to_hass(hass) entry.mock_state(hass, ConfigEntryState.LOADED) entry.runtime_data = SpanPanelRuntimeData( - coordinator=_make_coordinator(snapshot) + coordinator=_make_coordinator(snapshot), + panel_device_id="panel-device-id", ) device = _register_panel_device(hass, "span_entry", serial="sp3-prio-001") @@ -758,7 +764,8 @@ async def test_topology_includes_panel_status_entity(self, hass: HomeAssistant): entry.add_to_hass(hass) entry.mock_state(hass, ConfigEntryState.LOADED) entry.runtime_data = SpanPanelRuntimeData( - coordinator=_make_coordinator(snapshot) + coordinator=_make_coordinator(snapshot), + panel_device_id="panel-device-id", ) panel_device = _register_panel_device(hass, "span_entry", serial="sp3-242424-001") @@ -797,7 +804,8 @@ async def test_topology_omits_panel_status_when_entity_missing(self, hass: HomeA entry.add_to_hass(hass) entry.mock_state(hass, ConfigEntryState.LOADED) entry.runtime_data = SpanPanelRuntimeData( - coordinator=_make_coordinator(snapshot) + coordinator=_make_coordinator(snapshot), + panel_device_id="panel-device-id", ) panel_device = _register_panel_device(hass, "span_entry", serial="sp3-242424-001") diff --git a/uv.lock b/uv.lock index a9a6a2e4..1c279442 100644 --- a/uv.lock +++ b/uv.lock @@ -54,21 +54,21 @@ wheels = [ [[package]] name = "aiohasupervisor" -version = "0.4.3" +version = "0.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, { name = "mashumaro" }, { name = "orjson" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/35/74/529860af5e305647c469e6ec2ac78f4d61b17349d201ab03a8073e683656/aiohasupervisor-0.4.3.tar.gz", hash = "sha256:8b6e56f385879616a6e520a092f57f41c8ba3bd3cd05b33a87e89b62604d14b8", size = 46583, upload-time = "2026-03-24T08:41:45.457Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/76/24e578dcf937b27017befb2fc2402565810a7716cdd43abbb8cefc2f425c/aiohasupervisor-0.6.0.tar.gz", hash = "sha256:debee1dc3b99114f5b653959cb828a74a13181011cae3ac013ae9aefb0cda062", size = 47453, upload-time = "2026-07-20T15:15:23.821Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/35/bf/0a61a4632930a19601c99044c2f293f98ee44c0b4a9907a7bc83e70307aa/aiohasupervisor-0.4.3-py3-none-any.whl", hash = "sha256:ae05a59e890c6ed83dd801e9ebb7e12ca67ac5aed7cef29315f5422fdf7022b5", size = 42070, upload-time = "2026-03-24T08:41:44.177Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0d/71721a72bf81c00039c147b5f2dba913213de526940f82c697fbeed65ee7/aiohasupervisor-0.6.0-py3-none-any.whl", hash = "sha256:6f78f474160e6f90fda1879f0fe7d341d8cfd5f69eec4cb0cb60483816f73d60", size = 42596, upload-time = "2026-07-20T15:15:22.604Z" }, ] [[package]] name = "aiohttp" -version = "3.13.5" +version = "3.14.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -79,56 +79,63 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" }, - { url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" }, - { url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" }, - { url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" }, - { url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" }, - { url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" }, - { url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" }, - { url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" }, - { url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" }, - { url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" }, - { url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" }, - { url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" }, - { url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" }, - { url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" }, - { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" }, - { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" }, - { url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" }, - { url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" }, - { url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" }, - { url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" }, - { url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" }, - { url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" }, - { url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" }, - { url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" }, - { url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" }, - { url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" }, - { url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" }, - { url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" }, - { url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" }, - { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" }, - { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, ] [[package]] name = "aiohttp-asyncmdnsresolver" -version = "0.1.1" +version = "0.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiodns" }, { name = "aiohttp" }, { name = "zeroconf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/05/83/09fb97705e7308f94197a09b486669696ea20f28074c14b5811a38bdedc3/aiohttp_asyncmdnsresolver-0.1.1.tar.gz", hash = "sha256:8c65d4b08b42c8a260717a2766bd5967a1d437cee852a9b21f3928b5171a7c81", size = 36129, upload-time = "2025-02-14T14:46:44.402Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c1/86/1b94f5669df188f5267e26d48c4cc734b704310e53d695af5f39ee1af67d/aiohttp_asyncmdnsresolver-0.2.0.tar.gz", hash = "sha256:aab2cef7ef8ae45b94abf1fc92aa9f4d70cbef7ac0a3d495a86fb96fce357262", size = 37744, upload-time = "2026-05-20T20:18:27.183Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/d1/4f61508a43de82bb5c60cede3bb89cc57c5e8af7978d93ca03ad60b99368/aiohttp_asyncmdnsresolver-0.1.1-py3-none-any.whl", hash = "sha256:d04ded993e9f0e07c07a1bc687cde447d9d32e05bcf55ecbf94f63b33dcab93e", size = 13582, upload-time = "2025-02-14T14:46:41.985Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d5/c08b4e242d50d2aded314a16105ee708946fa3dfd37b837b1ca625d26397/aiohttp_asyncmdnsresolver-0.2.0-py3-none-any.whl", hash = "sha256:1e6dc01719dc563346ae051122fa7dc3a757317e3e6bf4ef8b23cfd034089c22", size = 10806, upload-time = "2026-05-20T20:18:25.58Z" }, ] [[package]] @@ -663,41 +670,41 @@ wheels = [ [[package]] name = "coverage" -version = "7.13.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, - { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, - { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, - { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, - { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, - { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, - { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, - { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, - { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, - { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, - { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, - { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, - { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, - { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, - { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, - { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, - { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, - { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, - { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, - { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, - { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, - { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, - { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, - { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, - { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, - { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +version = "7.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" }, + { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" }, + { url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" }, + { url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" }, + { url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" }, + { url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" }, + { url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" }, + { url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" }, + { url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" }, + { url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" }, + { url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" }, + { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, ] [[package]] @@ -710,55 +717,55 @@ wheels = [ [[package]] name = "cryptography" -version = "47.0.0" +version = "48.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/98/40dfe932134bdcae4f6ab5927c87488754bf9eb79297d7e0070b78dd58e9/cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", size = 7912214, upload-time = "2026-04-24T19:53:03.864Z" }, - { url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" }, - { url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" }, - { url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" }, - { url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" }, - { url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" }, - { url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" }, - { url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" }, - { url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" }, - { url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" }, - { url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" }, - { url = "https://files.pythonhosted.org/packages/54/ed/5f524db1fade9c013aa618e1c99c6ed05e8ffc9ceee6cda22fed22dda3f4/cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", size = 3258581, upload-time = "2026-04-24T19:53:31.058Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dc/1b901990b174786569029f67542b3edf72ac068b6c3c8683c17e6a2f5363/cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", size = 3775309, upload-time = "2026-04-24T19:53:33.054Z" }, - { url = "https://files.pythonhosted.org/packages/14/88/7aa18ad9c11bc87689affa5ce4368d884b517502d75739d475fc6f4a03c7/cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0", size = 7904299, upload-time = "2026-04-24T19:53:35.003Z" }, - { url = "https://files.pythonhosted.org/packages/07/55/c18f75724544872f234678fdedc871391722cb34a2aee19faa9f63100bb2/cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", size = 4631180, upload-time = "2026-04-24T19:53:37.517Z" }, - { url = "https://files.pythonhosted.org/packages/ee/65/31a5cc0eaca99cec5bafffe155d407115d96136bb161e8b49e0ef73f09a7/cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", size = 4653529, upload-time = "2026-04-24T19:53:39.775Z" }, - { url = "https://files.pythonhosted.org/packages/e5/bc/641c0519a495f3bfd0421b48d7cd325c4336578523ccd76ea322b6c29c7a/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", size = 4638570, upload-time = "2026-04-24T19:53:42.129Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f2/300327b0a47f6dc94dd8b71b57052aefe178bb51745073d73d80604f11ab/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", size = 5238019, upload-time = "2026-04-24T19:53:44.577Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5a/5b5cf994391d4bf9d9c7efd4c66aabe4d95227256627f8fea6cff7dfadbd/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", size = 4686832, upload-time = "2026-04-24T19:53:47.015Z" }, - { url = "https://files.pythonhosted.org/packages/dc/2c/ae950e28fd6475c852fc21a44db3e6b5bcc1261d1e370f2b6e42fa800fef/cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", size = 4269301, upload-time = "2026-04-24T19:53:48.97Z" }, - { url = "https://files.pythonhosted.org/packages/67/fb/6a39782e150ffe5cc1b0018cb6ddc48bf7ca62b498d7539ffc8a758e977d/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", size = 4638110, upload-time = "2026-04-24T19:53:51.011Z" }, - { url = "https://files.pythonhosted.org/packages/8e/d7/0b3c71090a76e5c203164a47688b697635ece006dcd2499ab3a4dbd3f0bd/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736", size = 5194988, upload-time = "2026-04-24T19:53:52.962Z" }, - { url = "https://files.pythonhosted.org/packages/63/33/63a961498a9df51721ab578c5a2622661411fc520e00bd83b0cc64eb20c4/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", size = 4686563, upload-time = "2026-04-24T19:53:55.274Z" }, - { url = "https://files.pythonhosted.org/packages/b7/bf/5ee5b145248f92250de86145d1c1d6edebbd57a7fe7caa4dedb5d4cf06a1/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", size = 4770094, upload-time = "2026-04-24T19:53:57.753Z" }, - { url = "https://files.pythonhosted.org/packages/92/43/21d220b2da5d517773894dacdcdb5c682c28d3fffce65548cb06e87d5501/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", size = 4913811, upload-time = "2026-04-24T19:54:00.236Z" }, - { url = "https://files.pythonhosted.org/packages/31/98/dc4ad376ac5f1a1a7d4a83f7b0c6f2bcad36b5d2d8f30aeb482d3a7d9582/cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63", size = 3237158, upload-time = "2026-04-24T19:54:02.606Z" }, - { url = "https://files.pythonhosted.org/packages/bc/da/97f62d18306b5133468bc3f8cc73a3111e8cdc8cf8d3e69474d6e5fd2d1b/cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b", size = 3758706, upload-time = "2026-04-24T19:54:04.433Z" }, - { url = "https://files.pythonhosted.org/packages/e0/34/a4fae8ae7c3bc227460c9ae43f56abf1b911da0ec29e0ebac53bb0a4b6b7/cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", size = 7904072, upload-time = "2026-04-24T19:54:06.411Z" }, - { url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" }, - { url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" }, - { url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" }, - { url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" }, - { url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" }, - { url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" }, - { url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" }, - { url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" }, - { url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" }, - { url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" }, - { url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" }, - { url = "https://files.pythonhosted.org/packages/06/bd/0a9d3edbf5eadbac926d7b9b3cd0c4be584eeeae4a003d24d9eda4affbbd/cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", size = 3248487, upload-time = "2026-04-24T19:54:33.494Z" }, - { url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" }, + { url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" }, + { url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" }, + { url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" }, + { url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" }, + { url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" }, + { url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" }, + { url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" }, + { url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" }, + { url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" }, + { url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/3e768b4c3bc78201583fa35a0e18f640dd782ff41afba88f8545481a8874/cryptography-48.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345", size = 7989830, upload-time = "2026-06-09T22:31:07.8Z" }, + { url = "https://files.pythonhosted.org/packages/8a/13/6476736484b94041110c8340a3eb63962fea4975baea8cb4a512adb44d4d/cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4", size = 4689201, upload-time = "2026-06-09T22:31:09.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/62/65a87f34d2a431546e2509b85d55e8c90df86d668f6731da64d538512ac2/cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991", size = 4702822, upload-time = "2026-06-09T22:32:24.409Z" }, + { url = "https://files.pythonhosted.org/packages/7f/59/810b5204b0a9b10f4b6bc06bd551a8b609803cd931806bc3b71884b225e5/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265", size = 4694875, upload-time = "2026-06-09T22:32:08.737Z" }, + { url = "https://files.pythonhosted.org/packages/24/dc/d8ca05ffea724eec6d232ea6f18e74c269eb6bdfdcc9bfba689790d1325f/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17", size = 5290385, upload-time = "2026-06-09T22:31:15.212Z" }, + { url = "https://files.pythonhosted.org/packages/03/8c/3be6cb4da181f5bb6c19cf560c2359d60644a6b5fc5b57854e528f47b296/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411", size = 4737082, upload-time = "2026-06-09T22:32:22.66Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f6/d5f60a5a1434dbfd949e227fd0065d194c7e6b6ac526b17f5c06152b8231/cryptography-48.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02", size = 4325328, upload-time = "2026-06-09T22:32:10.777Z" }, + { url = "https://files.pythonhosted.org/packages/17/b7/ba75dd947a14b6ad907b01ae8f6b5b348cdd1b48142f0063dee9e20c1d9d/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa", size = 4694530, upload-time = "2026-06-09T22:31:53.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/50d6b9e8aff12d8b67afaeb3569335e32dc83a5723e3bbded24fdac9f809/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3", size = 5245046, upload-time = "2026-06-09T22:31:25.774Z" }, + { url = "https://files.pythonhosted.org/packages/9f/04/618f4115cfc0add0838c82507aa18a346089428da8653ad38b3ff36f5cb3/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c", size = 4736660, upload-time = "2026-06-09T22:32:12.676Z" }, + { url = "https://files.pythonhosted.org/packages/24/9c/06e062462a0de28a3b3911322eded4c16deb9f441b1b7575d3dc59488ab5/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72", size = 4822229, upload-time = "2026-06-09T22:31:17.062Z" }, + { url = "https://files.pythonhosted.org/packages/f4/be/0561971eaaee4b8a0e7d5113c536921063ab91aaf23278ac374eaf881e11/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9", size = 4966364, upload-time = "2026-06-09T22:31:32.842Z" }, + { url = "https://files.pythonhosted.org/packages/a4/27/728c77876f12b000820b69ae490f3c4083775e79e07827e9e60be07ad209/cryptography-48.0.1-cp314-cp314t-win32.whl", hash = "sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471", size = 3278498, upload-time = "2026-06-09T22:31:29.154Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/79a612c6d7b1e6ee0edd43633d53035bec2cfb78c82b76f7864f39e36f34/cryptography-48.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2", size = 3798790, upload-time = "2026-06-09T22:31:56.697Z" }, + { url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" }, + { url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" }, + { url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" }, + { url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" }, + { url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" }, + { url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" }, + { url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" }, ] [[package]] @@ -831,28 +838,28 @@ wheels = [ [[package]] name = "fnv-hash-fast" -version = "2.0.2" +version = "2.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fnvhash" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/65/52/68d217fe739dea6712719effa8e1287a270d924e22d0ad31886cf1af04b1/fnv_hash_fast-2.0.2.tar.gz", hash = "sha256:8d5f6ac760a7ca091ef9d085b22b12c76caa8fa2897de81cee58f50269c0eb65", size = 5720, upload-time = "2026-03-15T09:33:58.352Z" } +sdist = { url = "https://files.pythonhosted.org/packages/48/ef/7d94271729042f4905800f9034adb149f2a716af26f2cfe9c46343637322/fnv_hash_fast-2.0.3.tar.gz", hash = "sha256:57b499a80ea8f7daf901aff047377264ef21577b40575183807dba37bcc00d6f", size = 5765, upload-time = "2026-05-16T23:52:11.417Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/3f/d7a250972ca3528b10cf83b09ffe93bc057f4a6f100c62a222586fc6bf7c/fnv_hash_fast-2.0.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e7e046e021bf3ade896999f1e4308611102c98a9e930729dae80008bfe096ad0", size = 7464, upload-time = "2026-03-15T09:42:16.696Z" }, - { url = "https://files.pythonhosted.org/packages/d0/48/c5f2fc199efce58fb512902f91ad549ca25a96b89a444c1dfad0e602816e/fnv_hash_fast-2.0.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14ff86c79ec7f44fcb15488d44362e1f576a363c9c8db5b97bd52eb7017eebf6", size = 8379, upload-time = "2026-03-15T09:42:17.569Z" }, - { url = "https://files.pythonhosted.org/packages/ea/23/9f95735e80411024200ce0f4a638b818fe42d0701448a6244f74ccecc2ff/fnv_hash_fast-2.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97ef72953a74e9bc918ccb7e7d6229e533054226fdd4ee933997e8dcd11f6108", size = 9216, upload-time = "2026-03-15T09:42:18.721Z" }, - { url = "https://files.pythonhosted.org/packages/36/0f/b9ae42639b7529438d8a329fb25a17c3f05905fbdd5829cfa5e6a7f550d9/fnv_hash_fast-2.0.2-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:f2c938907c2140b86e778e571ebefcc15377996c0e1c85d8a60ef6cdc5b0fd9c", size = 7755, upload-time = "2026-03-15T09:33:57.092Z" }, - { url = "https://files.pythonhosted.org/packages/1d/6c/31db3cd922c34cbcc47265e15ae110bee131d61b9e6857c370c0a3314935/fnv_hash_fast-2.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:faf4d478f2b68afacfcf12b2737c834a63cc0d48f0c8ff3a5b39487de78bc312", size = 9125, upload-time = "2026-03-15T09:42:19.589Z" }, - { url = "https://files.pythonhosted.org/packages/5f/88/4bac5c92f2bf8f3c9f06b1fd9d3c1becc23a34c2c5c2e3e67eb264b10b58/fnv_hash_fast-2.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ad9c8008158e8b4acd19e9d61805bff58b6bc86fd459f030053a0e4aa6a2c1d", size = 8618, upload-time = "2026-03-15T09:42:20.782Z" }, - { url = "https://files.pythonhosted.org/packages/20/53/71fe6bbd8907dd07aad205e40dbe8d0c5927d949c765c438ae31b550773b/fnv_hash_fast-2.0.2-cp314-cp314-win32.whl", hash = "sha256:fecd6c9026ff8177a813daf680ef7849ec3463ece8456c15809fd1a8c5aa72f0", size = 10371, upload-time = "2026-03-15T09:42:21.716Z" }, - { url = "https://files.pythonhosted.org/packages/5b/d9/afe630f1441e8bad36d0d0e8e5605ee27b1547bc0f8fa64e588702268024/fnv_hash_fast-2.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:7cdf55ed4a002e99f0c14ef009114e85a73221bb6267f589e43a36b6422908df", size = 10766, upload-time = "2026-03-15T09:42:22.574Z" }, - { url = "https://files.pythonhosted.org/packages/d4/7c/1dad8f50f07b255fdee9c9af64b90c06d9c32c112469ee85beb956a77954/fnv_hash_fast-2.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:83273853e040343e018f82fd9fcfa5a2fd18ec840f8abdd29240702fad5904bc", size = 9095, upload-time = "2026-03-15T09:42:23.79Z" }, - { url = "https://files.pythonhosted.org/packages/80/0e/472e5f55beca0b3498a6a58b4b9ca367b97fac94ca88af0808cdbc3d9b23/fnv_hash_fast-2.0.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:415f66d4e5d47f6d018e15d95851c700bef4dcb26f85d8a1238d0150ea62a763", size = 10909, upload-time = "2026-03-15T09:42:24.728Z" }, - { url = "https://files.pythonhosted.org/packages/fa/77/fd93ba63b953a8c8b8116966e513adab789303ca0e172d926fde05980c24/fnv_hash_fast-2.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:270c64fc8f02179f7aff5f921c08115357bc038b5057da237e5b290c38de2a13", size = 12586, upload-time = "2026-03-15T09:42:25.702Z" }, - { url = "https://files.pythonhosted.org/packages/f6/67/413d3ac61194702c4b20785831c27e6968824183fe168e343579b4b92e0b/fnv_hash_fast-2.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c421c4519694c9f54d4110e7ffc31e6cc4def2c09e1d6009bfbc14d980a978b4", size = 12414, upload-time = "2026-03-15T09:42:26.594Z" }, - { url = "https://files.pythonhosted.org/packages/f4/7f/84e5652a554b61b77d30799e23d445cfc70673d60dd1de86b86b11056b4e/fnv_hash_fast-2.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:620095dd5ca6d89f4394280b71a9d6f29c0ffbba85612ef5ae96e43eb27da707", size = 11390, upload-time = "2026-03-15T09:42:27.798Z" }, - { url = "https://files.pythonhosted.org/packages/00/04/570904efb0586f94c66b4592047d2867ad7eb668c4d0709e97320004db05/fnv_hash_fast-2.0.2-cp314-cp314t-win32.whl", hash = "sha256:2c3d4d3a3d6d06ece900663806b3a3ff4649e3ac7fb5e258634e6ccca50a139c", size = 15286, upload-time = "2026-03-15T09:42:28.992Z" }, - { url = "https://files.pythonhosted.org/packages/e1/17/1f64a2cea0775c4ca606eaa3e6e9e878f6329b5039d3e9d0e8573758cca6/fnv_hash_fast-2.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:20f621be9b22763feebd2d9a898a8256220a4aca654092b41372b7036e1f6d6f", size = 16062, upload-time = "2026-03-15T09:42:29.91Z" }, + { url = "https://files.pythonhosted.org/packages/03/35/7c68c9f8d51e31200a1371b396b772da4d32f269e02cdff7cada22594656/fnv_hash_fast-2.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:381df4fd4891fb20483673d7ff1973395a5a81c4fa736a685ad05e74653c4dbf", size = 7457, upload-time = "2026-05-16T23:58:42.243Z" }, + { url = "https://files.pythonhosted.org/packages/bc/14/5b5cea95c30a51bb76b84661be9de3cc7625fb0edc3252b7509864d36f4d/fnv_hash_fast-2.0.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2f50c18835bdc7c92a62801778dd5fdc6593c603b4cbadd7adae9d5f17305a52", size = 8374, upload-time = "2026-05-16T23:58:43.268Z" }, + { url = "https://files.pythonhosted.org/packages/00/c9/1d0a2299ddcbcc9168c3bdc99dcae08de1dae276705baddab323b912468a/fnv_hash_fast-2.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45838d0383438696d815b6de92c841215d4f9f39b8dbaf58071a1af84ea35fc", size = 9207, upload-time = "2026-05-16T23:58:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/18/8b/356df1dc62dd05cfdc3fec24e8e66101be1544f03a388a8043f7eb6d9577/fnv_hash_fast-2.0.3-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:cabf3cc36b0f5eb26eb19716842edf709b032a54e1d18a72981c21458c52c632", size = 7745, upload-time = "2026-05-16T23:52:09.972Z" }, + { url = "https://files.pythonhosted.org/packages/0e/3c/c740792c230608204a4dac1ba2aaaec1615115778ba33836143a937c7996/fnv_hash_fast-2.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe4da9446b554365a24d026c0f08b6ee96e30f31b8db7eb683ea167c923755e6", size = 9117, upload-time = "2026-05-16T23:58:45.351Z" }, + { url = "https://files.pythonhosted.org/packages/f0/09/f859b56caab1e3b564d9ec05771f380f7965e39a1b8c2e3fdaa341655a26/fnv_hash_fast-2.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c89aa84df2bfe2cc280694a8cf9fca3955640ce5f5c6f30f6b1bb020b30f2175", size = 8609, upload-time = "2026-05-16T23:58:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/1e/fa/3f7aeae26885762d7ad20c77a643c8671f7ae63069dc70cddc77483eb5ee/fnv_hash_fast-2.0.3-cp314-cp314-win32.whl", hash = "sha256:f25e37526b2898677f0b6a99a441bf0a86eab9e354d5d9222c4960f49e3795d7", size = 9813, upload-time = "2026-05-16T23:58:47.436Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/c9ed5f7e54889103de1754f53d8d294510d7e7f04c1075ec5dcaeee1562a/fnv_hash_fast-2.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:7a45f7050c4e99e2f04122508ec4efb568c93fcbece8482371a177ac89e6db8c", size = 10052, upload-time = "2026-05-16T23:58:48.47Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a3/a54bdc9a843e3d2988c8c35224ae51cdcffd8e55d9031039ccbf51b8e2f6/fnv_hash_fast-2.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:642bc26dbc906f86ee7a888cb66c149455c1a2a841a7ea72f19ffb969d0f0b14", size = 9085, upload-time = "2026-05-16T23:58:49.54Z" }, + { url = "https://files.pythonhosted.org/packages/75/45/bbb6e0741d945569ae13e91d774b613e06fb184841e25bfc3f188b98888f/fnv_hash_fast-2.0.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d63dcae2fd15c307f8a333f77aa6a5146538e9e9ef606b1883c8b5f06d5f86e8", size = 10910, upload-time = "2026-05-16T23:58:50.572Z" }, + { url = "https://files.pythonhosted.org/packages/37/84/e95767cbba632270b6d7f14108c800bd9883b3596b939c52c457b671b779/fnv_hash_fast-2.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a852d5c3278153378c04dcf5e45514477beeccd4268ceb775a3cf553dc5102b0", size = 12580, upload-time = "2026-05-16T23:58:51.691Z" }, + { url = "https://files.pythonhosted.org/packages/ef/37/51d20e8444eb87a6396762d0bc160c6698963753e3cff6f765f76a58dbec/fnv_hash_fast-2.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a805cc43c45e3452ff4a4b76e77fb40b452c795bf04d0f6f76c06fb7b02f755", size = 12409, upload-time = "2026-05-16T23:58:52.827Z" }, + { url = "https://files.pythonhosted.org/packages/28/37/0d6a033e957cc2976cc453db5271976107168ddf0a49361f4b4187a1fc47/fnv_hash_fast-2.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd413f1b171188cbf8ea6bb10f1e8c3aed3a88d239a682870e3727021b246700", size = 11380, upload-time = "2026-05-16T23:58:53.86Z" }, + { url = "https://files.pythonhosted.org/packages/93/fa/962407e8d475f8b502c5df7f8f9bcc4d73386628b76cf836f6a739d9982a/fnv_hash_fast-2.0.3-cp314-cp314t-win32.whl", hash = "sha256:13f4284c5521df53b2fc54e2f595e8534fba03656ef3d92f099503980c09b15b", size = 14180, upload-time = "2026-05-16T23:58:54.894Z" }, + { url = "https://files.pythonhosted.org/packages/84/19/e0b9e51d9f69efb6a6d155c2a5aa489f4366e34211ca2e843954c9053b44/fnv_hash_fast-2.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b4e44c9764bf0fa8387251bb806538976b0c641b89bfb6de3c8f242eb6bffdda", size = 14645, upload-time = "2026-05-16T23:58:56.033Z" }, ] [[package]] @@ -1048,7 +1055,7 @@ wheels = [ [[package]] name = "homeassistant" -version = "2026.5.4" +version = "2026.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiodns" }, @@ -1102,21 +1109,21 @@ dependencies = [ { name = "yarl" }, { name = "zeroconf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0b/ef/00fe5ef1f40daec592f8a28931eb6defa849d6cc3564a00f1d11b6f9d1ba/homeassistant-2026.5.4.tar.gz", hash = "sha256:a394baae7393ab33cb669301c609a9f48a662e51072ef1d31faf2a97612455b2", size = 33170704, upload-time = "2026-05-22T17:53:31.337Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/30/1e25c48871a24942de234439ac66ddbe649138c217b358c20162c1d29481/homeassistant-2026.8.0.tar.gz", hash = "sha256:2e966cf00edda6ba04ff1c6f707c073b9df5e4480b52dbd59451cec8f30610f9", size = 36732240, upload-time = "2026-08-05T15:34:54.073Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/a2/6956f1f9603bbd0af581a4afe227e86fb819fbfcf21ee0cee64e129f0659/homeassistant-2026.5.4-py3-none-any.whl", hash = "sha256:3ba68dec2e9b9f67b3b0c67654cd5d138f14d31924b9701b840beccd00f8e85a", size = 54712939, upload-time = "2026-05-22T17:53:24.137Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b7/4fb4e27596058f0a7014140b4fb75056d518fdda8f04c8aeecca5bf6fd4b/homeassistant-2026.8.0-py3-none-any.whl", hash = "sha256:3b1ff7a5db94d4c33a4228c08950bde1bcecb08893fae3c7513e743fba20c059", size = 59723818, upload-time = "2026-08-05T15:34:49.578Z" }, ] [[package]] name = "homeassistant-stubs" -version = "2026.5.4" +version = "2026.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "homeassistant" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/26/22/6d714a16b7698beadc0740d105be504f67f445678ae84ff968b19c745e56/homeassistant_stubs-2026.5.4.tar.gz", hash = "sha256:cbccdc0b6dbf276fa23d0e06830c53afc294f3358503018fd5c3f330334312ae", size = 1341584, upload-time = "2026-05-23T04:11:08.35Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/27/e0af3058503214caeed1767bea22f1c4e41bc876e961cd9cbc2fd49fbe2c/homeassistant_stubs-2026.8.0.tar.gz", hash = "sha256:ea1a418c126aed6f2894bea17ffacdeaa4c8073e57b05417fb76e88ce1c5452e", size = 1435706, upload-time = "2026-08-06T03:43:53.938Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/77/2d81e22358aca76188a5b641d7a2b5e0458ad7745667d4e9a5e65cbc466c/homeassistant_stubs-2026.5.4-py3-none-any.whl", hash = "sha256:3fcf7a614c032e3f413a77144477f280e4e43706cded830f315d2c5a75b308eb", size = 3794188, upload-time = "2026-05-23T04:11:05.795Z" }, + { url = "https://files.pythonhosted.org/packages/b7/9c/c73a3ff954b9525ad8c051136a5600b5ef865827e848cdb2f70fae27d39c/homeassistant_stubs-2026.8.0-py3-none-any.whl", hash = "sha256:034c6c62b9d96d4a7b3b5f367e8a3c6e250193cf4389aaf882a19c6de664b5a0", size = 4080910, upload-time = "2026-08-06T03:43:51.928Z" }, ] [[package]] @@ -1518,25 +1525,25 @@ wheels = [ [[package]] name = "orjson" -version = "3.11.8" +version = "3.11.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/1b/2024d06792d0779f9dbc51531b61c24f76c75b9f4ce05e6f3377a1814cea/orjson-3.11.8.tar.gz", hash = "sha256:96163d9cdc5a202703e9ad1b9ae757d5f0ca62f4fa0cc93d1f27b0e180cc404e", size = 5603832, upload-time = "2026-03-31T16:16:27.878Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/35/b01910c3d6b85dc882442afe5060cbf719c7d1fc85749294beda23d17873/orjson-3.11.8-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:ec795530a73c269a55130498842aaa762e4a939f6ce481a7e986eeaa790e9da4", size = 229171, upload-time = "2026-03-31T16:16:00.651Z" }, - { url = "https://files.pythonhosted.org/packages/c2/56/c9ec97bd11240abef39b9e5d99a15462809c45f677420fd148a6c5e6295e/orjson-3.11.8-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c492a0e011c0f9066e9ceaa896fbc5b068c54d365fea5f3444b697ee01bc8625", size = 128746, upload-time = "2026-03-31T16:16:02.673Z" }, - { url = "https://files.pythonhosted.org/packages/3b/e4/66d4f30a90de45e2f0cbd9623588e8ae71eef7679dbe2ae954ed6d66a41f/orjson-3.11.8-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:883206d55b1bd5f5679ad5e6ddd3d1a5e3cac5190482927fdb8c78fb699193b5", size = 131867, upload-time = "2026-03-31T16:16:04.342Z" }, - { url = "https://files.pythonhosted.org/packages/19/30/2a645fc9286b928675e43fa2a3a16fb7b6764aa78cc719dc82141e00f30b/orjson-3.11.8-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5774c1fdcc98b2259800b683b19599c133baeb11d60033e2095fd9d4667b82db", size = 124664, upload-time = "2026-03-31T16:16:05.837Z" }, - { url = "https://files.pythonhosted.org/packages/db/44/77b9a86d84a28d52ba3316d77737f6514e17118119ade3f91b639e859029/orjson-3.11.8-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ac7381c83dd3d4a6347e6635950aa448f54e7b8406a27c7ecb4a37e9f1ae08b", size = 129701, upload-time = "2026-03-31T16:16:07.407Z" }, - { url = "https://files.pythonhosted.org/packages/b3/ea/eff3d9bfe47e9bc6969c9181c58d9f71237f923f9c86a2d2f490cd898c82/orjson-3.11.8-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:14439063aebcb92401c11afc68ee4e407258d2752e62d748b6942dad20d2a70d", size = 141202, upload-time = "2026-03-31T16:16:09.48Z" }, - { url = "https://files.pythonhosted.org/packages/52/c8/90d4b4c60c84d62068d0cf9e4d8f0a4e05e76971d133ac0c60d818d4db20/orjson-3.11.8-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fa72e71977bff96567b0f500fc5bfd2fdf915f34052c782a4c6ebbdaa97aa858", size = 127194, upload-time = "2026-03-31T16:16:11.02Z" }, - { url = "https://files.pythonhosted.org/packages/8d/c7/ea9e08d1f0ba981adffb629811148b44774d935171e7b3d780ae43c4c254/orjson-3.11.8-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7679bc2f01bb0d219758f1a5f87bb7c8a81c0a186824a393b366876b4948e14f", size = 133639, upload-time = "2026-03-31T16:16:13.434Z" }, - { url = "https://files.pythonhosted.org/packages/6c/8c/ddbbfd6ba59453c8fc7fe1d0e5983895864e264c37481b2a791db635f046/orjson-3.11.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:14f7b8fcb35ef403b42fa5ecfa4ed032332a91f3dc7368fbce4184d59e1eae0d", size = 141914, upload-time = "2026-03-31T16:16:14.955Z" }, - { url = "https://files.pythonhosted.org/packages/4e/31/dbfbefec9df060d34ef4962cd0afcb6fa7a9ec65884cb78f04a7859526c3/orjson-3.11.8-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c2bdf7b2facc80b5e34f48a2d557727d5c5c57a8a450de122ae81fa26a81c1bc", size = 423800, upload-time = "2026-03-31T16:16:16.594Z" }, - { url = "https://files.pythonhosted.org/packages/87/cf/f74e9ae9803d4ab46b163494adba636c6d7ea955af5cc23b8aaa94cfd528/orjson-3.11.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ccd7ba1b0605813a0715171d39ec4c314cb97a9c85893c2c5c0c3a3729df38bf", size = 147837, upload-time = "2026-03-31T16:16:18.585Z" }, - { url = "https://files.pythonhosted.org/packages/64/e6/9214f017b5db85e84e68602792f742e5dc5249e963503d1b356bee611e01/orjson-3.11.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbc8c9c02463fef4d3c53a9ba3336d05496ec8e1f1c53326a1e4acc11f5c600", size = 136441, upload-time = "2026-03-31T16:16:20.151Z" }, - { url = "https://files.pythonhosted.org/packages/24/dd/3590348818f58f837a75fb969b04cdf187ae197e14d60b5e5a794a38b79d/orjson-3.11.8-cp314-cp314-win32.whl", hash = "sha256:0b57f67710a8cd459e4e54eb96d5f77f3624eba0c661ba19a525807e42eccade", size = 131983, upload-time = "2026-03-31T16:16:21.823Z" }, - { url = "https://files.pythonhosted.org/packages/3f/0f/b6cb692116e05d058f31ceee819c70f097fa9167c82f67fabe7516289abc/orjson-3.11.8-cp314-cp314-win_amd64.whl", hash = "sha256:735e2262363dcbe05c35e3a8869898022af78f89dde9e256924dc02e99fe69ca", size = 127396, upload-time = "2026-03-31T16:16:23.685Z" }, - { url = "https://files.pythonhosted.org/packages/c0/d1/facb5b5051fabb0ef9d26c6544d87ef19a939a9a001198655d0d891062dd/orjson-3.11.8-cp314-cp314-win_arm64.whl", hash = "sha256:6ccdea2c213cf9f3d9490cbd5d427693c870753df41e6cb375bd79bcbafc8817", size = 127330, upload-time = "2026-03-31T16:16:25.496Z" }, + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, ] [[package]] @@ -1568,35 +1575,31 @@ wheels = [ [[package]] name = "pillow" -version = "12.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, - { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, - { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, - { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, - { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, - { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, - { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, - { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, - { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, - { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, - { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, - { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, - { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, - { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, - { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, - { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, - { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, - { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, - { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, ] [[package]] @@ -1674,41 +1677,45 @@ wheels = [ [[package]] name = "propcache" -version = "0.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, - { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, - { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, - { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, - { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, - { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, - { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, - { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, - { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, - { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, - { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, - { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, - { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, - { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, - { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, - { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, - { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, - { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, - { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, - { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, - { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, - { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, - { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, - { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, - { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, - { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, - { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, ] [[package]] @@ -1806,7 +1813,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.13.2" +version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -1814,50 +1821,66 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/09/e5/06d23afac9973109d1e3c8ad38e1547a12e860610e327c05ee686827dc37/pydantic-2.13.2.tar.gz", hash = "sha256:b418196607e61081c3226dcd4f0672f2a194828abb9109e9cfb84026564df2d1", size = 843836, upload-time = "2026-04-17T09:31:59.636Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/ca/b45c378e6e8d0b90577288b533e04e95b7afd61bb1d51b6c263176435489/pydantic-2.13.2-py3-none-any.whl", hash = "sha256:a525087f4c03d7e7456a3de89b64cd693d2229933bb1068b9af6befd5563694e", size = 471947, upload-time = "2026-04-17T09:31:57.541Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] [[package]] name = "pydantic-core" -version = "2.46.2" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, +] + +[[package]] +name = "pygithub" +version = "2.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "pyjwt", extra = ["crypto"] }, + { name = "pynacl" }, + { name = "requests" }, { name = "typing-extensions" }, + { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/bb/4742f05b739b2478459bb16fa8470549518c802e06ddcf3f106c5081315e/pydantic_core-2.46.2.tar.gz", hash = "sha256:37bb079f9ee3f1a519392b73fda2a96379b31f2013c6b467fe693e7f2987f596", size = 471269, upload-time = "2026-04-17T09:10:07.017Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/96/a50ccb6b539ae780f73cea74905468777680e30c6c3bdf714b9d4c116ea0/pydantic_core-2.46.2-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:4f59b45f3ef8650c0c736a57f59031d47ed9df4c0a64e83796849d7d14863a2d", size = 2097111, upload-time = "2026-04-17T09:10:49.617Z" }, - { url = "https://files.pythonhosted.org/packages/34/5f/fdead7b3afa822ab6e5a18ee0ecffd54937de1877c01ed13a342e0fb3f07/pydantic_core-2.46.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3a075a29ebef752784a91532a1a85be6b234ccffec0a9d7978a92696387c3da6", size = 1951904, upload-time = "2026-04-17T09:12:32.062Z" }, - { url = "https://files.pythonhosted.org/packages/95/e0/1c5d547e550cdab1bec737492aa08865337af6fe7fc9b96f7f45f17d9519/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d12d786e30c04a9d307c5d7080bf720d9bac7f1668191d8e37633a9562749e2", size = 1978667, upload-time = "2026-04-17T09:11:35.589Z" }, - { url = "https://files.pythonhosted.org/packages/0e/cb/665ce629e218c8228302cb94beff4f6531082a2c87d3ecc3d5e63a26f392/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0d5e6d6343b0b5dcacb3503b5de90022968da8ed0ab9ab39d3eda71c20cbf84e", size = 2046721, upload-time = "2026-04-17T09:11:47.725Z" }, - { url = "https://files.pythonhosted.org/packages/77/e9/6cb2cf60f54c1472bbdfce19d957553b43dbba79d1d7b2930a195c594785/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:233eebac0999b6b9ba76eb56f3ec8fce13164aa16b6d2225a36a79e0f95b5973", size = 2228483, upload-time = "2026-04-17T09:12:08.837Z" }, - { url = "https://files.pythonhosted.org/packages/0d/2a/93e018dd5571f781ebaeda8c0cf65398489d5bee9b1f484df0b6149b43b9/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9cc0eee720dd2f14f3b7c349469402b99ad81a174ab49d3533974529e9d93992", size = 2294663, upload-time = "2026-04-17T09:12:52.053Z" }, - { url = "https://files.pythonhosted.org/packages/5e/4f/49e57ca55c770c93d9bb046666a54949b42e3c9099a0c5fe94557873fe30/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83ee76bf2c9910513dbc19e7d82367131fa7508dedd6186a462393071cc11059", size = 2098742, upload-time = "2026-04-17T09:13:45.472Z" }, - { url = "https://files.pythonhosted.org/packages/c6/b0/6e46b5cd3332af665f794b8cdeea206618a8630bd9e7bcc36864518fce81/pydantic_core-2.46.2-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:d61db38eb4ee5192f0c261b7f2d38e420b554df8912245e3546aee5c45e2fd78", size = 2125922, upload-time = "2026-04-17T09:12:54.304Z" }, - { url = "https://files.pythonhosted.org/packages/06/d1/40850c81585be443a2abfdf7f795f8fae831baf8e2f9b2133c8246ac671c/pydantic_core-2.46.2-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8f09a713d17bcd55da8ab02ebd9110c5246a49c44182af213b5212800af8bc83", size = 2183000, upload-time = "2026-04-17T09:10:59.027Z" }, - { url = "https://files.pythonhosted.org/packages/04/af/8493d7dfa03ebb7866909e577c6aa65ea0de7377b86023cc51d0c8e11db3/pydantic_core-2.46.2-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:30cacc5fb696e64b8ef6fd31d9549d394dd7d52760db072eecb98e37e3af1677", size = 2180335, upload-time = "2026-04-17T09:12:57.01Z" }, - { url = "https://files.pythonhosted.org/packages/72/5b/1f6a344c4ffdf284da41c6067b82d5ebcbd11ce1b515ae4b662d4adb6f61/pydantic_core-2.46.2-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:7ccfb105fcfe91a22bbb5563ad3dc124bc1aa75bfd2e53a780ab05f78cdf6108", size = 2330002, upload-time = "2026-04-17T09:12:02.958Z" }, - { url = "https://files.pythonhosted.org/packages/25/ff/9a694126c12d6d2f48a0cafa6f8eef88ef0d8825600e18d03ff2e896c3b2/pydantic_core-2.46.2-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:13ffef637dc8370c249e5b26bd18e9a80a4fca3d809618c44e18ec834a7ca7a8", size = 2359920, upload-time = "2026-04-17T09:10:27.764Z" }, - { url = "https://files.pythonhosted.org/packages/51/c8/3a35c763d68a9cb2675eb10ef242cf66c5d4701b28ae12e688d67d2c180e/pydantic_core-2.46.2-cp314-cp314-win32.whl", hash = "sha256:1b0ab6d756ca2704a938e6c31b53f290c2f9c10d3914235410302a149de1a83e", size = 1953701, upload-time = "2026-04-17T09:13:30.021Z" }, - { url = "https://files.pythonhosted.org/packages/1a/6a/f2726a780365f7dfd89d62036f984f7acb99978c60c5e1fa7c0cb898ed11/pydantic_core-2.46.2-cp314-cp314-win_amd64.whl", hash = "sha256:99ebade8c9ada4df975372d8dd25883daa0e379a05f1cd0c99aa0c04368d01a6", size = 2071867, upload-time = "2026-04-17T09:10:39.205Z" }, - { url = "https://files.pythonhosted.org/packages/e1/79/76baacb9feba3d7c399b245ca1a29c74ea0db04ea693811374827eec2290/pydantic_core-2.46.2-cp314-cp314-win_arm64.whl", hash = "sha256:de87422197cf7f83db91d89c86a21660d749b3cd76cd8a45d115b8e675670f02", size = 2017252, upload-time = "2026-04-17T09:10:26.175Z" }, - { url = "https://files.pythonhosted.org/packages/f1/3b/77c26938f817668d9ad9bab1a905cb23f11d9a3d4bf724d429b3e55a8eaf/pydantic_core-2.46.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:236f22b4a206b5b61db955396b7cf9e2e1ff77f372efe9570128ccfcd6a525eb", size = 2094545, upload-time = "2026-04-17T09:12:19.339Z" }, - { url = "https://files.pythonhosted.org/packages/fe/de/42c13f590e3c260966aa49bcdb1674774f975467c49abd51191e502bea28/pydantic_core-2.46.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c2012f64d2cd7cca50f49f22445aa5a88691ac2b4498ee0a9a977f8ca4f7289f", size = 1933953, upload-time = "2026-04-17T09:09:55.889Z" }, - { url = "https://files.pythonhosted.org/packages/4e/84/ebe3ebb3e2d8db656937cfa6f97f544cb7132f2307a4a7dfdcd0ea102a12/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d07d6c63106d3a9c9a333e2636f9c82c703b1a9e3b079299e58747964e4fdb72", size = 1974435, upload-time = "2026-04-17T09:10:12.371Z" }, - { url = "https://files.pythonhosted.org/packages/b9/15/0bf51ca6709477cd4ef86148b6d7844f3308f029eac361dd0383f1e17b1a/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c326a2b4b85e959d9a1fc3a11f32f84611b6ec07c053e1828a860edf8d068208", size = 2031113, upload-time = "2026-04-17T09:10:00.752Z" }, - { url = "https://files.pythonhosted.org/packages/02/ae/b7b5af9b79db036d9e61a44c481c17a213dc8fc4b8b71fe6875a72fc778b/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac8a65e798f2462552c00d2e013d532c94d646729dda98458beaf51f9ec7b120", size = 2236325, upload-time = "2026-04-17T09:10:33.227Z" }, - { url = "https://files.pythonhosted.org/packages/a6/ae/ecef7477b5a03d4a499708f7e75d2836452ebb70b776c2d64612b334f57a/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a3c2bc1cc8164bedbc160b7bb1e8cc1e8b9c27f69ae4f9ae2b976cdae02b2dd", size = 2278135, upload-time = "2026-04-17T09:10:23.287Z" }, - { url = "https://files.pythonhosted.org/packages/db/e4/2f9d82faa47af6c39fc3f120145fd915971e1e0cb6b55b494fad9fdf8275/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e69aa5e10b7e8b1bb4a6888650fd12fcbf11d396ca11d4a44de1450875702830", size = 2109071, upload-time = "2026-04-17T09:11:06.149Z" }, - { url = "https://files.pythonhosted.org/packages/f1/9c/677cf10873fbd0b116575ab7b97c90482b21564f8a8040beb18edef7a577/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4e6df5c3301e65fb42bc5338bf9a1027a02b0a31dc7f54c33775229af474daf0", size = 2106028, upload-time = "2026-04-17T09:10:51.525Z" }, - { url = "https://files.pythonhosted.org/packages/d6/53/6a06183544daba51c059123a2064a99039df25f115a06bdb26f2ea177038/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2c2f6e32548ac8d559b47944effcf8ae4d81c161f6b6c885edc53bc08b8f192d", size = 2164816, upload-time = "2026-04-17T09:11:56.187Z" }, - { url = "https://files.pythonhosted.org/packages/57/6f/10fcdd9e3eca66fc828eef0f6f5850f2dd3bca2c59e6e041fb8bc3da39be/pydantic_core-2.46.2-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:b089a81c58e6ea0485562bbbbbca4f65c0549521606d5ef27fba217aac9b665a", size = 2166130, upload-time = "2026-04-17T09:10:03.804Z" }, - { url = "https://files.pythonhosted.org/packages/29/83/92d3fd0e0156cad2e3cb5c26de73794af78ac9fa0c22ab666e566dd67061/pydantic_core-2.46.2-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:7f700a6d6f64112ae9193709b84303bbab84424ad4b47d0253301aabce9dfc70", size = 2316605, upload-time = "2026-04-17T09:12:45.249Z" }, - { url = "https://files.pythonhosted.org/packages/97/f1/facffdb970981068219582e499b8d0871ed163ffcc6b347de5c412669e4c/pydantic_core-2.46.2-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:67db6814beaa5fefe91101ec7eb9efda613795767be96f7cf58b1ca8c9ca9972", size = 2358385, upload-time = "2026-04-17T09:09:54.657Z" }, - { url = "https://files.pythonhosted.org/packages/8b/a1/b8160b2f22b2199467bc68581a4ed380643c16b348a27d6165c6c242d694/pydantic_core-2.46.2-cp314-cp314t-win32.whl", hash = "sha256:32fbc7447be8e3be99bf7869f7066308f16be55b61f9882c2cefc7931f5c7664", size = 1942373, upload-time = "2026-04-17T09:12:59.594Z" }, - { url = "https://files.pythonhosted.org/packages/0d/90/db89acabe5b150e11d1b59fe3d947dda2ef6abbfef5c82f056ff63802f5d/pydantic_core-2.46.2-cp314-cp314t-win_amd64.whl", hash = "sha256:b317a2b97019c0b95ce99f4f901ae383f40132da6706cdf1731066a73394c25c", size = 2052078, upload-time = "2026-04-17T09:10:19.96Z" }, - { url = "https://files.pythonhosted.org/packages/97/32/e19b83ceb07a3f1bb21798407790bbc9a31740158fd132b94139cb84e16c/pydantic_core-2.46.2-cp314-cp314t-win_arm64.whl", hash = "sha256:7dcb9d40930dfad7ab6b20bcc6ca9d2b030b0f347a0cd9909b54bd53ead521b1", size = 2016941, upload-time = "2026-04-17T09:12:34.447Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/ab/c3/8465a311197e16cf5ab68789fe689535e90f6b61ab524cc32a39e67237ae/pygithub-2.9.1.tar.gz", hash = "sha256:59771d7ff63d54d427be2e7d0dad2208dfffc2b0a045fec959263787739b611c", size = 2594989, upload-time = "2026-04-14T07:26:13.622Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/aa/81a5506f089a26338bff17535e4339b3b22049ebd1bcdeff756c4d7a7559/pygithub-2.9.1-py3-none-any.whl", hash = "sha256:2ec78fca30092d51a42d76f4ddb02131b6f0c666a35dfdf364cf302cdda115b9", size = 449710, upload-time = "2026-04-14T07:26:12.382Z" }, ] [[package]] @@ -1871,11 +1894,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.12.1" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, ] [package.optional-dependencies] @@ -1989,14 +2012,14 @@ wheels = [ [[package]] name = "pyopenssl" -version = "26.1.0" +version = "26.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8c/a8/26d36401e3ab8eed9030ad33f381da7856fcfad5691780fccd1b019718fc/pyopenssl-26.1.0.tar.gz", hash = "sha256:737f0a2275c5bc54f3b02137687e1a765931fb3949b9a92a825e4d33b9eec08b", size = 186181, upload-time = "2026-04-24T20:23:48.115Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/51/27a5ad5f939d08f690a326ef9582cda7140555180db71695f6fb747d6a36/pyopenssl-26.2.0.tar.gz", hash = "sha256:8c6fcecd1183a7fc897548dfe388b0cdb7f37e018200d8409cf33959dbe35387", size = 182195, upload-time = "2026-05-04T23:06:09.72Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/41/52f3a3e812b816a91e89aa504199d8bf989a1f873192b10762be66cf2009/pyopenssl-26.1.0-py3-none-any.whl", hash = "sha256:115563879b2c8ccb207975705d3e491434d8c9d7c79667c902ecbf5f3bbd2ece", size = 58109, upload-time = "2026-04-24T20:23:46.273Z" }, + { url = "https://files.pythonhosted.org/packages/73/b8/a0e2790ae249d6f38c9f66de7a211621a7ab2650217bcd04e1262f578a56/pyopenssl-26.2.0-py3-none-any.whl", hash = "sha256:4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70", size = 55823, upload-time = "2026-05-04T23:06:08.395Z" }, ] [[package]] @@ -2045,28 +2068,28 @@ wheels = [ [[package]] name = "pytest-aiohttp" -version = "1.1.0" +version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, { name = "pytest" }, { name = "pytest-asyncio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/4b/d326890c153f2c4ce1bf45d07683c08c10a1766058a22934620bc6ac6592/pytest_aiohttp-1.1.0.tar.gz", hash = "sha256:147de8cb164f3fc9d7196967f109ab3c0b93ea3463ab50631e56438eab7b5adc", size = 12842, upload-time = "2025-01-23T12:44:04.465Z" } +sdist = { url = "https://files.pythonhosted.org/packages/51/4d/c6621fc79022f6c84a806e23d9b7eca24fae4f3ee779219bbe524339d666/pytest_aiohttp-1.1.1.tar.gz", hash = "sha256:3aa9c9fe26e543eaccc7eb0add381c685ba3ed3e2fed0af74540f63bcd31458d", size = 13704, upload-time = "2026-06-07T23:56:34.173Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/0f/e6af71c02e0f1098eaf7d2dbf3ffdf0a69fc1e0ef174f96af05cef161f1b/pytest_aiohttp-1.1.0-py3-none-any.whl", hash = "sha256:f39a11693a0dce08dd6c542d241e199dd8047a6e6596b2bcfa60d373f143456d", size = 8932, upload-time = "2025-01-23T12:44:03.27Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b0/5056ed4c3f68a4db2b4a39fb0ec61b1e4cf1d89ee14effe5261cc587264c/pytest_aiohttp-1.1.1-py3-none-any.whl", hash = "sha256:f293441ad4f8446a1e12257130c26c7de03a615c2a5572a8cb046e5b3b4e5211", size = 9007, upload-time = "2026-06-07T23:56:33.333Z" }, ] [[package]] name = "pytest-asyncio" -version = "1.3.0" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, ] [[package]] @@ -2098,19 +2121,19 @@ wheels = [ [[package]] name = "pytest-github-actions-annotate-failures" -version = "0.4.0" +version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/e1/8f2c242e6d75a26a8e5ddcc23f652a411e4aac3eedc4b923808ac0582685/pytest_github_actions_annotate_failures-0.4.0.tar.gz", hash = "sha256:77d6baa29c8c61c2dacc494fa76eb95a185f0ee61666714ac43fb12ea672d217", size = 10857, upload-time = "2026-03-02T18:57:40.919Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/a0/bdb91581b03c41016c78e16b8ec36c34d8508206fcb30f1951c9cdff2e97/pytest_github_actions_annotate_failures-0.4.2.tar.gz", hash = "sha256:5dd18304512361788bc7b5c5c805db853f03f4950c6be09b088de6bab8e2e6c9", size = 12158, upload-time = "2026-06-19T15:59:17.445Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/bd/11809f5c78d5d8da2d0e004845d2382768bc20798b3e0988bca61efd349f/pytest_github_actions_annotate_failures-0.4.0-py3-none-any.whl", hash = "sha256:285fed86e16b0b7a8eac6acdcde31913798fb739b15ef5b86895b4f5e32bf237", size = 6039, upload-time = "2026-03-02T18:57:39.991Z" }, + { url = "https://files.pythonhosted.org/packages/3c/09/c44e658f3a27c588c2017d858c8b0fa962612af9b74326beabbf010c839c/pytest_github_actions_annotate_failures-0.4.2-py3-none-any.whl", hash = "sha256:02911cd3b55f235328a334f8ca6037a89944398b8e8b028c82de97111eff4071", size = 6151, upload-time = "2026-06-19T15:59:16.486Z" }, ] [[package]] name = "pytest-homeassistant-custom-component" -version = "0.13.333" +version = "0.13.354" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohasupervisor" }, @@ -2123,6 +2146,7 @@ dependencies = [ { name = "paho-mqtt" }, { name = "pipdeptree" }, { name = "pydantic" }, + { name = "pygithub" }, { name = "pytest" }, { name = "pytest-aiohttp" }, { name = "pytest-asyncio" }, @@ -2134,15 +2158,17 @@ dependencies = [ { name = "pytest-timeout" }, { name = "pytest-unordered" }, { name = "pytest-xdist" }, + { name = "requests" }, { name = "requests-mock" }, { name = "respx" }, { name = "sqlalchemy" }, { name = "syrupy" }, { name = "tqdm" }, + { name = "unidiff" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/24/bd/33710be5657204c4128c47e0e6da2b4b5bf43e685ad83ec2bed056d1076f/pytest_homeassistant_custom_component-0.13.333.tar.gz", hash = "sha256:e8326bc96cdf0d8147a15ba99c10199e01fff98db96065258f6ac6921da411b6", size = 77794, upload-time = "2026-05-23T06:04:38.997Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/48/ac4af2bb13ad79148cd62ca0ef92cb3b36d6d5fc2f9e8703880e27e298d3/pytest_homeassistant_custom_component-0.13.354.tar.gz", hash = "sha256:c2522b9b05143dcca4034d34db7bf65fab19f63efa4f4b0307e6ec36d55251bc", size = 76934, upload-time = "2026-08-06T07:36:43.766Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/51/1d15cceb7bd00ac50bdef5d40de4b17c2d5773f79bb16a73a4e4f58b2f7a/pytest_homeassistant_custom_component-0.13.333-py3-none-any.whl", hash = "sha256:53b6f110a1ea0c8fac52d131bbc1d4cb8d623afec602a62febd58e6939eb5eb8", size = 83728, upload-time = "2026-05-23T06:04:36.909Z" }, + { url = "https://files.pythonhosted.org/packages/68/b0/2284352838e69d4261abfceb120266afcd17d44a3d143342becfd92ada0a/pytest_homeassistant_custom_component-0.13.354-py3-none-any.whl", hash = "sha256:9dff5671d081612221905ad937da2ff2c025908314ccadb9c58f64f89754bfbe", size = 82841, upload-time = "2026-08-06T07:36:42.213Z" }, ] [[package]] @@ -2159,14 +2185,14 @@ wheels = [ [[package]] name = "pytest-socket" -version = "0.7.0" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/05/ff/90c7e1e746baf3d62ce864c479fd53410b534818b9437413903596f81580/pytest_socket-0.7.0.tar.gz", hash = "sha256:71ab048cbbcb085c15a4423b73b619a8b35d6a307f46f78ea46be51b1b7e11b3", size = 12389, upload-time = "2024-01-28T20:17:23.177Z" } +sdist = { url = "https://files.pythonhosted.org/packages/59/3c/f9b58e57830e58980dbe8867d0e348f45701d3f3ea065672d448f4366da5/pytest_socket-0.8.0.tar.gz", hash = "sha256:af9bb5f487da72be63573a6194cfac033b6c7a1c1561e150521105970f9e99f2", size = 13912, upload-time = "2026-05-21T16:50:22.552Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/58/5d14cb5cb59409e491ebe816c47bf81423cd03098ea92281336320ae5681/pytest_socket-0.7.0-py3-none-any.whl", hash = "sha256:7e0f4642177d55d317bbd58fc68c6bd9048d6eadb2d46a89307fa9221336ce45", size = 6754, upload-time = "2024-01-28T20:17:22.105Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e8/4a8568580bae3dcd678599ed8e86a82d505a44df71c1ced4246c1aa14b4b/pytest_socket-0.8.0-py3-none-any.whl", hash = "sha256:81821ba59f07d7600fe2b551d8714f40b068bd46e8b6704c48664e9d60cdacb8", size = 8414, upload-time = "2026-05-21T16:50:21.022Z" }, ] [[package]] @@ -2183,14 +2209,14 @@ wheels = [ [[package]] name = "pytest-unordered" -version = "0.7.0" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bd/3e/6ec9ec74551804c9e005d5b3cbe1fd663f03ed3bd4bdb1ce764c3d334d8e/pytest_unordered-0.7.0.tar.gz", hash = "sha256:0f953a438db00a9f6f99a0f4727f2d75e72dd93319b3d548a97ec9db4903a44f", size = 7930, upload-time = "2025-06-03T12:56:04.289Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/0c/ab409d508c92ea6737875cfc39a696a0e0bd44359915e1376831dfb5a456/pytest_unordered-0.8.0.tar.gz", hash = "sha256:3c369ed86919d3eb35e11fd27bb679c1e3506ead9327e25aa8a07307256be65a", size = 7978, upload-time = "2026-06-16T02:48:53.209Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/95/ae2875e19472797e9672b65412858ab6639d8e55defd9859241e5ff80d02/pytest_unordered-0.7.0-py3-none-any.whl", hash = "sha256:486b26d24a2d3b879a275c3d16d14eda1bd9c32aafddbb17b98ac755daba7584", size = 6210, upload-time = "2025-06-03T12:36:06.66Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d4/2f63db7b6292856f51a76e3eb0acb5e53fd63ae6b6ec4dd11af24985f5b0/pytest_unordered-0.8.0-py3-none-any.whl", hash = "sha256:c792b879032b33141205e1a38a80234eb0117474bf9459ae65557e29837c2a1c", size = 6280, upload-time = "2026-06-16T02:48:52.399Z" }, ] [[package]] @@ -2329,7 +2355,7 @@ wheels = [ [[package]] name = "requests" -version = "2.33.1" +version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -2337,9 +2363,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] @@ -2496,7 +2522,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "homeassistant", specifier = "==2026.5.4" }, + { name = "homeassistant", specifier = "==2026.8.0" }, { name = "span-panel-api", editable = "../../span/span-panel-api-p2" }, { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api-p2/packages/schema-0" }, { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api-p2/packages/schema-1" }, @@ -2505,14 +2531,14 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "bandit", extras = ["toml"], specifier = "==1.9.4" }, - { name = "homeassistant-stubs", specifier = "==2026.5.4" }, + { name = "homeassistant-stubs", specifier = "==2026.8.0" }, { name = "isort" }, { name = "mypy", specifier = "==1.20.2" }, { name = "prek", specifier = ">=0.3.11" }, { name = "prettier" }, { name = "pylint", specifier = "==4.0.5" }, { name = "pyright", specifier = "==1.1.409" }, - { name = "pytest-homeassistant-custom-component", specifier = ">=0.13.333" }, + { name = "pytest-homeassistant-custom-component", specifier = "==0.13.354" }, { name = "python-direnv" }, { name = "radon", specifier = "==6.0.1" }, { name = "ruff", specifier = "==0.15.12" }, @@ -2587,28 +2613,29 @@ requires-dist = [ [[package]] name = "sqlalchemy" -version = "2.0.49" +version = "2.0.51" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" } +sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e", size = 2156356, upload-time = "2026-04-03T16:53:09.914Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a7/5f476227576cb8644650eff68cc35fa837d3802b997465c96b8340ced1e2/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a", size = 3276486, upload-time = "2026-04-03T17:07:46.9Z" }, - { url = "https://files.pythonhosted.org/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066", size = 3281479, upload-time = "2026-04-03T17:12:32.226Z" }, - { url = "https://files.pythonhosted.org/packages/91/68/bb406fa4257099c67bd75f3f2261b129c63204b9155de0d450b37f004698/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187", size = 3226269, upload-time = "2026-04-03T17:07:48.678Z" }, - { url = "https://files.pythonhosted.org/packages/67/84/acb56c00cca9f251f437cb49e718e14f7687505749ea9255d7bd8158a6df/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401", size = 3248260, upload-time = "2026-04-03T17:12:34.381Z" }, - { url = "https://files.pythonhosted.org/packages/56/19/6a20ea25606d1efd7bd1862149bb2a22d1451c3f851d23d887969201633f/sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5", size = 2118463, upload-time = "2026-04-03T17:05:47.093Z" }, - { url = "https://files.pythonhosted.org/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5", size = 2144204, upload-time = "2026-04-03T17:05:48.694Z" }, - { url = "https://files.pythonhosted.org/packages/1f/33/95e7216df810c706e0cd3655a778604bbd319ed4f43333127d465a46862d/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977", size = 3565474, upload-time = "2026-04-03T16:58:35.128Z" }, - { url = "https://files.pythonhosted.org/packages/0c/a4/ed7b18d8ccf7f954a83af6bb73866f5bc6f5636f44c7731fbb741f72cc4f/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01", size = 3530567, upload-time = "2026-04-03T17:06:04.587Z" }, - { url = "https://files.pythonhosted.org/packages/73/a3/20faa869c7e21a827c4a2a42b41353a54b0f9f5e96df5087629c306df71e/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61", size = 3474282, upload-time = "2026-04-03T16:58:37.131Z" }, - { url = "https://files.pythonhosted.org/packages/b7/50/276b9a007aa0764304ad467eceb70b04822dc32092492ee5f322d559a4dc/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a", size = 3480406, upload-time = "2026-04-03T17:06:07.176Z" }, - { url = "https://files.pythonhosted.org/packages/e5/c3/c80fcdb41905a2df650c2a3e0337198b6848876e63d66fe9188ef9003d24/sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158", size = 2149151, upload-time = "2026-04-03T17:02:07.281Z" }, - { url = "https://files.pythonhosted.org/packages/05/52/9f1a62feab6ed368aff068524ff414f26a6daebc7361861035ae00b05530/sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7", size = 2184178, upload-time = "2026-04-03T17:02:08.623Z" }, - { url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" }, + { url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" }, + { url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" }, + { url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" }, + { url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, ] [[package]] @@ -2653,14 +2680,14 @@ wheels = [ [[package]] name = "syrupy" -version = "5.1.0" +version = "5.5.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2e/b0/24bca682d6a6337854be37f242d116cceeda9942571d5804c44bc1bdd427/syrupy-5.1.0.tar.gz", hash = "sha256:df543c7aa50d3cf1246e83d58fe490afe5f7dab7b41e74ecc0d8d23ae19bd4b8", size = 50495, upload-time = "2026-01-25T14:53:06.2Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/39/17dde9f0c76cc5abcdeef1b2243791bb850d498f784147b8e460dd23abe8/syrupy-5.5.3.tar.gz", hash = "sha256:fa21e4ae77c89ec5abfca513338d8a7eb916da6618ca0f8db301476e0768e57a", size = 91614, upload-time = "2026-07-11T15:54:31.46Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/70/cf880c3b95a6034ef673e74b369941b42315c01f1554a5637a4f8b911009/syrupy-5.1.0-py3-none-any.whl", hash = "sha256:95162d2b05e61ed3e13f117b88dfab7c58bd6f90e66ebbf918e8a77114ad51c5", size = 51658, upload-time = "2026-01-25T14:53:05.105Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f9/617a194c1a4203279998e1859426cf2635042533a99a93d63d3ee1fb967e/syrupy-5.5.3-py3-none-any.whl", hash = "sha256:0b260a0c9dad55e1fb83818973dc36fbc1aea3fd5381592f442a986686c4171a", size = 54959, upload-time = "2026-07-11T15:54:29.836Z" }, ] [[package]] @@ -2755,29 +2782,38 @@ wheels = [ [[package]] name = "ulid-transform" -version = "2.2.0" +version = "2.2.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9f/c2/8548ec850a03fdfbc84ba4728bed5d3b5570ec913502954b40830e84f5aa/ulid_transform-2.2.0.tar.gz", hash = "sha256:cef07d688483e7573b835423f242d0d6700d92429abc438c0941de61eacdf215", size = 15159, upload-time = "2026-03-18T18:18:14.101Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/47/f2d1b8c04a5b7276198ff4f0dadfea463fd6f73aa4e1ddc3589aff073ca3/ulid_transform-2.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ae98a8510ea6498c67d7810d9030c36dfd3e56d79232144fdd9e9430db8827a9", size = 23114, upload-time = "2026-03-18T18:25:15.783Z" }, - { url = "https://files.pythonhosted.org/packages/91/d3/479dddda0e7446a1f444c5df72941dd88611dcfc8fb3f8a1056d54be68fd/ulid_transform-2.2.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d44baef447136f0d3216dc63066f4999c4e81fe10d08620300685d59155c9a17", size = 25359, upload-time = "2026-03-18T18:25:17.719Z" }, - { url = "https://files.pythonhosted.org/packages/63/e0/3ffd17d17d78aceb903d3a11fcc494b97f0e313e3692b7bdac43fcb18972/ulid_transform-2.2.0-cp314-cp314-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dc6b2c01b6922f8c7c75d6504d428d66d0caabbcafbb4c5083d42aec23bf3a93", size = 25745, upload-time = "2026-03-18T18:25:19.179Z" }, - { url = "https://files.pythonhosted.org/packages/06/1c/60c4cb168710bc51f896d532a37e6d2c291d2eba7e5997ffaf532abfdf2a/ulid_transform-2.2.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4e96f23690491397218ecff013d44efa5b868ca393c497c83cd1d6844d29e14", size = 24344, upload-time = "2026-03-18T18:25:20.65Z" }, - { url = "https://files.pythonhosted.org/packages/2a/7a/8b6b7216aca6102fbede41249b6dbd203d17eccaef221e36440307948bb9/ulid_transform-2.2.0-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:0faf6937d2a36392af323292819aeaca3ce12161ba414a265fd1f5ea13f5efef", size = 15101, upload-time = "2026-03-18T18:18:12.987Z" }, - { url = "https://files.pythonhosted.org/packages/f0/27/54063f2df35713bc0f5e70dad04ff609e788ef8109583a6666e08a48149d/ulid_transform-2.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41d05fd5191c01a751d222586a88945806573b036a4a31b14952834c3e085654", size = 1005342, upload-time = "2026-03-18T18:25:22.02Z" }, - { url = "https://files.pythonhosted.org/packages/a8/2b/6909e1f875eb88612718eaad0259831d1d17908c38047e710d451b0a52b7/ulid_transform-2.2.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:e01a2168b702277c78b313bd126c34165ba8f6b4def3db389de3a125d1348521", size = 875203, upload-time = "2026-03-18T18:25:23.461Z" }, - { url = "https://files.pythonhosted.org/packages/9f/60/a1217d58e8591f6d76d6ef05d8296abc734acf61e1a8aef3ae09a2d1f933/ulid_transform-2.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8779b38f6a0c788087990c4c54ae9034beef4117936a5dd1a2d43b8cbb4f3203", size = 1056867, upload-time = "2026-03-18T18:25:24.955Z" }, - { url = "https://files.pythonhosted.org/packages/68/ab/cc26752afdf2e8cc643f1aa6b4514715d249d9ebf5c57185c6f68ba1dc51/ulid_transform-2.2.0-cp314-cp314-win32.whl", hash = "sha256:b0782a5a399b44d65ad2fc9c8f8b61f455af5d4cca335eafb2927e3dac6e92e6", size = 24255, upload-time = "2026-03-18T18:25:26.35Z" }, - { url = "https://files.pythonhosted.org/packages/a3/36/0cd5c74a77e72f80e2ffbf4d08fb89fe641b1f6e683421a335774731db6f/ulid_transform-2.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:b904d40eab4f3efced841cfb5b80777dd36c0db99a66261fc1b37e0b98d68167", size = 24280, upload-time = "2026-03-18T18:25:27.519Z" }, - { url = "https://files.pythonhosted.org/packages/b9/ac/44abd959a306c84a8917a65e731e2b25d4aaeafa29a2b15deaa1a62041d2/ulid_transform-2.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e737a0b06be45585806bd71f91a63c846b0aa3bf90dd787544d5d8482f324c61", size = 30697, upload-time = "2026-03-18T18:25:28.632Z" }, - { url = "https://files.pythonhosted.org/packages/88/cd/f4625a6c4be6f6b217c9116ede638fd969a1a27fc11be76997c0381e3f20/ulid_transform-2.2.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f61b636694420afd6e5a2050e4fecca4f1ca17ef0673a9d4653522900b2d7132", size = 35171, upload-time = "2026-03-18T18:25:29.919Z" }, - { url = "https://files.pythonhosted.org/packages/e0/3e/cddcccf159527d028c49b2cd88abc9d323fd9fd6ef8fcc1960f9c57bb417/ulid_transform-2.2.0-cp314-cp314t-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a815c51ef310c4594792864fbd8845cfe935005b631ae1c099fd3b2d1404062c", size = 35956, upload-time = "2026-03-18T18:25:31.03Z" }, - { url = "https://files.pythonhosted.org/packages/02/ef/f67e94817dcfea4699cf764db8ba427675c879bcc53610a490fcc5ab38b8/ulid_transform-2.2.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1628ca45e5ea9ec79bf90f9f7e3a2f036faca7bb081db143f7a36e54c1ef184", size = 33127, upload-time = "2026-03-18T18:25:32.162Z" }, - { url = "https://files.pythonhosted.org/packages/29/64/8c94866f6cf635437c6c106548f7b70023e0d4e82f0c95503551c3c3c27b/ulid_transform-2.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0c56296f82be1e272d58e53dfe231a789458758ce01addeca47a1671d73bfaa9", size = 1015637, upload-time = "2026-03-18T18:25:33.908Z" }, - { url = "https://files.pythonhosted.org/packages/3a/3a/6beaccdf3acb27a2197cbb1fff44391ff92100ffe0ad0ae3273cb827cdf5/ulid_transform-2.2.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:9fd78e7002a10a47ceb2ede42f0aaca428d0ae7ed712fd6bacf46406f806c3f4", size = 885015, upload-time = "2026-03-18T18:25:36.043Z" }, - { url = "https://files.pythonhosted.org/packages/55/b1/258765b8d209187a19228c89733400b751db223b7b68c7b79e4c924962a9/ulid_transform-2.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3adf5a417a2250264c6b6b290f1393d41efff0ac692ea19d4fa677bf29a67137", size = 1066233, upload-time = "2026-03-18T18:25:38.194Z" }, - { url = "https://files.pythonhosted.org/packages/21/f5/c29d4e164f5a6302b3f2025d16dfde9a6a8704efaf01c7d3c70ee69a4a12/ulid_transform-2.2.0-cp314-cp314t-win32.whl", hash = "sha256:66b66189322bccb54491b91a9c0e25c9240156d5d5671ca59860d8db1ce4ff9f", size = 33249, upload-time = "2026-03-18T18:25:39.673Z" }, - { url = "https://files.pythonhosted.org/packages/c6/45/f9c7400646f89d71dd5c04693ed259feb459e13b036c98193ecc8467cb4d/ulid_transform-2.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:212ba7218ea61cafb06772cd3967f60f16d4b028869a470cddcffad3ca5fac1c", size = 33277, upload-time = "2026-03-18T18:25:41.235Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/d4/69/80e64aff3bbfd719ef3519774cf489cd972aac89cf8f85cd52009757be71/ulid_transform-2.2.9.tar.gz", hash = "sha256:6a8b573de0158ab4bd0424f26ed4d1cef42f15afaa718c924a8427fe24a3838f", size = 17886, upload-time = "2026-05-25T05:39:04.274Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/2e/6351361aefb2bfe44036519bb60ef9689511c7351c42e2edb7f86e28cb0a/ulid_transform-2.2.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b64afbdb9143210e7f9f240a91fa8fd54a559c6627e8f4cdb9f40771f8b8a498", size = 25212, upload-time = "2026-05-25T05:45:49.977Z" }, + { url = "https://files.pythonhosted.org/packages/7e/2c/db41412391950083c387c8185641db32e6f67862b52391c992cdd1824b5c/ulid_transform-2.2.9-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fcbef4c1c537da14fa098529167f64f7d351e7aebeeeafd6779ed2ffe10f4649", size = 27462, upload-time = "2026-05-25T05:45:51.311Z" }, + { url = "https://files.pythonhosted.org/packages/71/aa/8a2b3b3b6fd638f88117e6852f5b1aaaf5f73de25e2d7a8bb483a9adaa58/ulid_transform-2.2.9-cp314-cp314-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:73fef8bdfae646ec4c54f5cc96dc27510fdb55c1aaed3c20d6661e8b51d01bd9", size = 27809, upload-time = "2026-05-25T05:45:52.782Z" }, + { url = "https://files.pythonhosted.org/packages/78/f5/520fc50bfd97a616e5c367cd820eee65ffc3537acc68fdacb09f31fef149/ulid_transform-2.2.9-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6db97794ce8638ee2bd11ace5ddc9e23e450e971b264556c2c42ab43fc3d580", size = 26406, upload-time = "2026-05-25T05:45:54.1Z" }, + { url = "https://files.pythonhosted.org/packages/9a/33/27c3f9ed60426b514b1501c8acd47ab05a305fbb1e294dce6a13c52a88be/ulid_transform-2.2.9-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:f62d23bf7ed08e086e27dc5f5c89f9aec4d79316174529070073505bc367d8fa", size = 16808, upload-time = "2026-05-25T05:39:02.653Z" }, + { url = "https://files.pythonhosted.org/packages/5f/43/b246e2c3e4936789f9797c5e61ebde1d1c6fecbc9a1d6cdf6c427518006e/ulid_transform-2.2.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ae8473948da794f27ecf800511ff7e9bc340e1e5d07f28f1f640a860240d1d39", size = 1007530, upload-time = "2026-05-25T05:45:55.2Z" }, + { url = "https://files.pythonhosted.org/packages/bc/17/c1ab2d3901899962afcbddce47dc796f7eaa6694f96c3f4977ab43883712/ulid_transform-2.2.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a978e55081259c95a14bd7d48db6540645ca228c2d07da6c176c291cd955e85b", size = 877349, upload-time = "2026-05-25T05:45:57.625Z" }, + { url = "https://files.pythonhosted.org/packages/af/b1/2bd4509651123caa37b447e0d4e5bb321067fd79a1bcaefa2dfc7fe41206/ulid_transform-2.2.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:364651ca7bf17e56b7570855d185e10ae461d5bfe15943936ee6f79fde71bb02", size = 1058955, upload-time = "2026-05-25T05:45:59.075Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d6/e48135a3992562630043f57a85ba36109787b91d27da842ae8e7136fd24f/ulid_transform-2.2.9-cp314-cp314-win32.whl", hash = "sha256:6daaaadfe2d7e465f0850c05dfcd5a785426571361433e84fc3f5a8ed78321ae", size = 25741, upload-time = "2026-05-25T05:46:00.438Z" }, + { url = "https://files.pythonhosted.org/packages/a3/8e/ff96df79000ed277a9fc73e8825f27c81fba2446acb7fe33f53a1489ba70/ulid_transform-2.2.9-cp314-cp314-win_amd64.whl", hash = "sha256:f586c25a2457b88511e1acceae9aeb90f002aee06a4d37e68280ba0ff6af4b9d", size = 25837, upload-time = "2026-05-25T05:46:01.955Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c6/59879019153dbbd34eff5f3c633f329b5d75519c1a95f3271948ea51715f/ulid_transform-2.2.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a873a51c8ec7f3d51c6d3d6ecdf3720d29637d5619e7c7df16dbd451f19ceb82", size = 25198, upload-time = "2026-05-25T05:46:03.022Z" }, + { url = "https://files.pythonhosted.org/packages/51/c6/293ba411f0e32e17086baed7c955e4287e368275f31f59ef7a005d401eac/ulid_transform-2.2.9-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65711af19211c89c115954aab2965abad5287596d24f5754fcd142e80dd7656c", size = 27474, upload-time = "2026-05-25T05:46:04.058Z" }, + { url = "https://files.pythonhosted.org/packages/57/17/2eabe1ba6e1ed341d372c996380a812a5278217582349fc609d61fb57342/ulid_transform-2.2.9-cp314-cp314t-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c9456b9bd8a572448cbddd9f3c5266d7354c68a3c0dc25209108ea904846fac3", size = 27826, upload-time = "2026-05-25T05:46:05.166Z" }, + { url = "https://files.pythonhosted.org/packages/65/36/1f3f2e6c1243cafd75d9c2736b6b73e5530d96b49f955493e8917b2fe716/ulid_transform-2.2.9-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12639c5dda80d6d90437e9427dd87c6bb71bb291dcfe8d6063703394c2e15e3a", size = 26405, upload-time = "2026-05-25T05:46:06.268Z" }, + { url = "https://files.pythonhosted.org/packages/00/6b/200c79dc081b4c9673923dd9d9bc9dc0e47ccdc90eeae8e85acbb1914751/ulid_transform-2.2.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8cf746c642569dfe3efd94fe45672954f6257ff910f993bca80ab6dcb5a34f92", size = 1007528, upload-time = "2026-05-25T05:46:07.417Z" }, + { url = "https://files.pythonhosted.org/packages/7f/24/228a44957e37a3c7541989d47c481c9f51ae045ad07fdacc0deb49d4e120/ulid_transform-2.2.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:5c19d10c7ad9a24c456aba5cbd74aeeca84a4b33cd35077b4bec61d2cf92e30c", size = 877361, upload-time = "2026-05-25T05:46:09.145Z" }, + { url = "https://files.pythonhosted.org/packages/a9/7e/4beaa80ac820b3243e376f79fde4a8bca2b477d8b6529ff5afcfd83f5f74/ulid_transform-2.2.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:35084b9ade29b462472485966789e5edc66dcbc376ffaf89759e7ada0c7d1f6c", size = 1058912, upload-time = "2026-05-25T05:46:10.736Z" }, + { url = "https://files.pythonhosted.org/packages/39/f8/ab3af64aa6ced7c65b09aa4b1c33d2cd4bf48646c9957a17659cea69e1c5/ulid_transform-2.2.9-cp314-cp314t-win32.whl", hash = "sha256:ccf6b173ae1e90a589935fd99176dc9d71d461c9f413b1a754d542ba2d399860", size = 25725, upload-time = "2026-05-25T05:46:12.117Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c8/cbb9374e8a0e26d18e94ffb3e1f861beab5312b3e0be4fbad1bf60cdb47c/ulid_transform-2.2.9-cp314-cp314t-win_amd64.whl", hash = "sha256:fed352965a8682a156000d29e0efff8b85e7c2b8cb74f2e953a1e6d0157648b9", size = 25827, upload-time = "2026-05-25T05:46:13.164Z" }, +] + +[[package]] +name = "unidiff" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/48/6ebfbda867e1a07bab3bbffe820e980bff8262c97ff77d1496a4fa15e711/unidiff-1.0.0.tar.gz", hash = "sha256:5e5d5cfab2dc98be819b74747ab7d9f5af8695369ec8710b93f9ab0f0ae6a449", size = 29365, upload-time = "2026-07-25T19:13:59.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/ca/860142913b2fee25c78b3af733054e248c488bd83cf6cfb97969e98e3bcf/unidiff-1.0.0-py3-none-any.whl", hash = "sha256:2e1fb4eebe2354a26a1f3d51efe2e5d504cae5764b98ed8bdbb4e7a000baff28", size = 18279, upload-time = "2026-07-25T19:13:58.797Z" }, ] [[package]] @@ -2800,28 +2836,28 @@ wheels = [ [[package]] name = "uv" -version = "0.11.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c1/cd/4393fecb083897e956f016d4e66d0b8a496a08fe2e03cbda32a1e91da7ee/uv-0.11.8.tar.gz", hash = "sha256:bb2cf302b8503629aab6f0090a05551e6f8cfc2d687ca059cad7ec9e11214335", size = 4098020, upload-time = "2026-04-27T13:15:31.625Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/99/84/dcb676a3e36a3a2b44dc2e4dfea471b8cd709025e27cce3e588b176fd899/uv-0.11.8-py3-none-linux_armv6l.whl", hash = "sha256:a53e704a780a9e78a50f5a880e99a690f84e6fb9e82610903ce26f47c271d74c", size = 23664296, upload-time = "2026-04-27T13:15:15.644Z" }, - { url = "https://files.pythonhosted.org/packages/86/05/557aa070fda7b8460bbbe1e867e8e5b80602c5b30ed77d1d94fc5acae518/uv-0.11.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d414fc3795b6f56fb6b1fa359537930924fdfe857750a144d2aedf3077be3f1d", size = 23087321, upload-time = "2026-04-27T13:15:36.193Z" }, - { url = "https://files.pythonhosted.org/packages/d5/62/82953018801a250e16b091ef4b5e95e939b2f01224363d6fc80f600b7eff/uv-0.11.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f0d402e182ab581e934c159cc9edf25ec6e08d32f29aa797980e949afefc87cd", size = 21747142, upload-time = "2026-04-27T13:15:20.4Z" }, - { url = "https://files.pythonhosted.org/packages/af/4c/477f2abe16f9a3d3c73077f15615878a303eef3760115ec946be58ecb9b2/uv-0.11.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:877c9af3b3955a35ef739e5b2ba79c56dae5c4d50420a7ed908c0901e1c8c807", size = 23425861, upload-time = "2026-04-27T13:15:10.374Z" }, - { url = "https://files.pythonhosted.org/packages/2a/63/19f46193e49f0c9bf33346a4d726313871864db16e7cdd1c0a63bc112000/uv-0.11.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:8278144df8d80a83f770c264a5e79ea50791316d2a0dda869e53b3c1174142a8", size = 23215551, upload-time = "2026-04-27T13:15:38.706Z" }, - { url = "https://files.pythonhosted.org/packages/72/3e/5595b265df848a33cd060b10e8f763a46d67521ac9f6c314e8a4ad5329d7/uv-0.11.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3494ad32465f4e02259cfb104d24efe5bb8f7a782351f0354de9385415fb310", size = 23224170, upload-time = "2026-04-27T13:15:18.083Z" }, - { url = "https://files.pythonhosted.org/packages/a6/b3/6ca95e690b52542caa1dae10ede57732f90c629946ab5f027ff746f87deb/uv-0.11.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a4421e27e81f85bce3bdb75986c38b5f9bfab9cdccaf3d977cf124b3f0f0b989", size = 24730048, upload-time = "2026-04-27T13:15:13.254Z" }, - { url = "https://files.pythonhosted.org/packages/ea/49/71b7322067c85a3736a22a300072b0566991fe3f95b81bed793508ff5315/uv-0.11.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91943e77fc962752d4f64ad5739219858395981078051c740b28b52963b366aa", size = 25585906, upload-time = "2026-04-27T13:15:41.455Z" }, - { url = "https://files.pythonhosted.org/packages/37/16/4e84cd5131327fe86d4784ebfc8a983149f4e6b811476ef271fc548b29e6/uv-0.11.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:41fbba287efcc9bc9505a60549b3a223220da720eacd03be8c23d9daaafa44f4", size = 24795740, upload-time = "2026-04-27T13:15:49.842Z" }, - { url = "https://files.pythonhosted.org/packages/5b/01/df175979018743cc5ba6e2fb9dcec916868271e8d88cf0b9df8fd805a0df/uv-0.11.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d97bb2920d6cddc07faa475013461294cc09b77ec8139278416c6e54b938d037", size = 24824980, upload-time = "2026-04-27T13:15:53.506Z" }, - { url = "https://files.pythonhosted.org/packages/1c/95/93c7f595f7136fb32807442860c55d0faed2cd3d7da4b7105ed3c2535d5f/uv-0.11.8-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:fb6a755305eb1e081dfe6a8bc007dbae2d26fe75e551656ca7c9cd08fba21d26", size = 23526790, upload-time = "2026-04-27T13:15:04.955Z" }, - { url = "https://files.pythonhosted.org/packages/04/02/77430b89e172c20cc549b07a5b1dfda0c882c161b6d82781d3150a7063ac/uv-0.11.8-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:841ecbb38532698f73b14b49dc5f0c5e756194c7fcf6e5c6b7ed3859200fe91b", size = 24280498, upload-time = "2026-04-27T13:15:43.978Z" }, - { url = "https://files.pythonhosted.org/packages/8a/e3/23e4a2bb91e3880e017e6116886e2d0bde14ba6aa95ddc458160ee630e7c/uv-0.11.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b3ff2b20c1897105ebe7ed7f9b1b331c7171da029bc1e35970ce31dc086141c1", size = 24375233, upload-time = "2026-04-27T13:15:25.753Z" }, - { url = "https://files.pythonhosted.org/packages/d9/67/fb7dc17cea816a667d1be2632525aa1687566bfafd17bdac561a7a6c9484/uv-0.11.8-py3-none-musllinux_1_1_i686.whl", hash = "sha256:ad381228b0170ef9646902c7e908d4a10a7ecc3da8139450506cf70c7e7f3e80", size = 23904818, upload-time = "2026-04-27T13:15:23.21Z" }, - { url = "https://files.pythonhosted.org/packages/4b/91/b920e35f54f8c6b51f2c639e8170bb80a47a739a1442fea33a479bc93a3d/uv-0.11.8-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:0172b5215544844cd3db0fa3c73a2eb74999b3f00cd2527dde578725076d7b65", size = 25015448, upload-time = "2026-04-27T13:15:46.666Z" }, - { url = "https://files.pythonhosted.org/packages/05/e8/3771956dc1c94b8484789bb8070d91872080d0af99332b8bdec7218c2bfd/uv-0.11.8-py3-none-win32.whl", hash = "sha256:e71c1dd23cbb480f3952c3a95b4fd00f96bd618e2a94583fc9388c500af3070d", size = 22823583, upload-time = "2026-04-27T13:15:33.674Z" }, - { url = "https://files.pythonhosted.org/packages/f9/9b/a91a9c60dcae0e1e3da06377d38f32118a523697d461fe41bc9f117ecf59/uv-0.11.8-py3-none-win_amd64.whl", hash = "sha256:306c624c68d95dd7ea3647675323d72c1abc25f91c3e92ae4cd6f0f11b508726", size = 25407438, upload-time = "2026-04-27T13:15:28.957Z" }, - { url = "https://files.pythonhosted.org/packages/61/5d/defa29fe617e6f07d4e514089e9d36fd9f44ede869e597e39ff7d69f6917/uv-0.11.8-py3-none-win_arm64.whl", hash = "sha256:a9853456696d579f206135c9dda7227a6ed8311b8a9a0b9b2008c4ae81950efe", size = 23914243, upload-time = "2026-04-27T13:15:07.717Z" }, +version = "0.11.31" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/f0/501fe8a234ac96ea8869e84cb47b3bd77e39a0e80ee01950713e24fe1c4a/uv-0.11.31.tar.gz", hash = "sha256:763609d59721af5b8522e16deac6cffe8055f82bb837740c708917506f305185", size = 6045932, upload-time = "2026-07-22T01:48:45.407Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/6a/065e1e7feaf375eee8d1bb05e5276185708149dd48c27a230f320a0fc8bf/uv-0.11.31-py3-none-linux_armv6l.whl", hash = "sha256:6adaaf151f53fef04dec685f0816d304c09a091b2b609746f86ee7c55ada6bcd", size = 25838313, upload-time = "2026-07-22T01:47:21.787Z" }, + { url = "https://files.pythonhosted.org/packages/e1/15/529b573723a36badbda1e13a432c3b21a7554b8ddef3b20a2200037051c2/uv-0.11.31-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2d84b6dd6b1eaf42fc923203d21a5efd052e1982e4f961eccecc2a6905ffbecd", size = 24795386, upload-time = "2026-07-22T01:47:26.882Z" }, + { url = "https://files.pythonhosted.org/packages/52/be/a809b3fe20c3d37bc667de33f38475c4c94f860979d07049ccddb6d91801/uv-0.11.31-py3-none-macosx_11_0_arm64.whl", hash = "sha256:335f3262c4350c004cf6e3b7061200148d670e579bcee7ba0e31c7535f125018", size = 23410594, upload-time = "2026-07-22T01:47:31.43Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9a/ebaacd8b7713fd755d23623e0e8de78dfd001f6abc818034f2e9058035c7/uv-0.11.31-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:e1cf5803c39221387b2fe8be2b522b0529ac732831a2e52a92330e053539995e", size = 25358933, upload-time = "2026-07-22T01:47:36.544Z" }, + { url = "https://files.pythonhosted.org/packages/81/34/c30568a0f9e556be766c341106bf6ca2ef5c8067be6c11665a53df0549f1/uv-0.11.31-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:68ae6974ffbd04703e138654e83220a16e7b0b679271a8f209f928928dd399f8", size = 25346175, upload-time = "2026-07-22T01:47:41.132Z" }, + { url = "https://files.pythonhosted.org/packages/b5/63/18467b66f578dc121ec6d4af78074a0db06b27627b072fc433226a99a384/uv-0.11.31-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:48f7ec906eaebf9717a01ba0f7635cd0cac648ff5c8fff3a57b8805e6bd49078", size = 25381240, upload-time = "2026-07-22T01:47:45.659Z" }, + { url = "https://files.pythonhosted.org/packages/b8/43/b51d6b8ad1307f51dd75154d623d6a527c6de600086bb0446251047d2e5e/uv-0.11.31-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a2cfd1638420f9a2a7dbca71c808edaf3929b6d8f4ec2ceac2f27014150d0e3", size = 26661822, upload-time = "2026-07-22T01:47:50.42Z" }, + { url = "https://files.pythonhosted.org/packages/30/9f/008c859ea3fc0d25d6ac32e1293a0795c737b0a472a8603b5e511b56659c/uv-0.11.31-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aec65d8f54403e60f32c50e44d98b6420de55211ad22a340927efc5db6ef4205", size = 27594901, upload-time = "2026-07-22T01:47:55.444Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ca/65a2856e79a208f8a1ece0ac077fbee531db7455608c06ab677b2513cbc4/uv-0.11.31-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5610fea306dc6ce5021482d272e6372f0c3dfd1e24ec061f90b1b9287263ac58", size = 26708620, upload-time = "2026-07-22T01:48:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c3/019ecbf3564d909c55fcf065592aff90b8b386d679e379caf356de4473f9/uv-0.11.31-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44ac79fca5807122676701279a1f36d7917a922f25a0ab5c5cf58a252f666e7e", size = 26894006, upload-time = "2026-07-22T01:48:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9b/b67aa8736f9f82a9f99cec93c28d66d77ff42126914784a3f680bc737b56/uv-0.11.31-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:c4d4b34264017dc9047d0d49f09363a5e20b388481cddc39d5c44b16b3c2a57c", size = 25504398, upload-time = "2026-07-22T01:48:09.859Z" }, + { url = "https://files.pythonhosted.org/packages/44/d1/37e3a30f55e1c623fca484efbb80b6e157b922ee79f5cb7b1c0ff5005f0f/uv-0.11.31-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:9ce168c7323aee61ef07220c815f1b3e3a1b74241acb9f56c0b7fc4794dad600", size = 26307040, upload-time = "2026-07-22T01:48:14.555Z" }, + { url = "https://files.pythonhosted.org/packages/00/cc/f607ba28a93100c55b3e048838f85481f8b55a24e3a338e42c151f5884ae/uv-0.11.31-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:f3f8f58030ba4f711542d581b5fc3cde54db75a773fc873178f7b353f68f8711", size = 26425088, upload-time = "2026-07-22T01:48:19.204Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ae/dd865e1d680799f05ff32895689700a23f37e905aac9807c93521fc76d8c/uv-0.11.31-py3-none-musllinux_1_1_i686.whl", hash = "sha256:b1384887f8a4a0b0dfb8c6c81b2f819d1771015a96c70f89ef12559df8206b28", size = 25920399, upload-time = "2026-07-22T01:48:23.866Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0a/45ebfd783235a7a39ae1e99dc0bf26c083ea24a37584e530c7fbb6e38a21/uv-0.11.31-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:c6e052de498086b2014020536829b7e2b6f173ba95b07e55e9e0f85ac00a3927", size = 27126383, upload-time = "2026-07-22T01:48:28.376Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c7/4cf78823c123efd3bdac50eb26f4b8fc2c222962d47918a7bb2b465b6522/uv-0.11.31-py3-none-win32.whl", hash = "sha256:03e18e463ecf0e1c347f901f9a8739059d07e2e2ebce72c0f8f1b9328a349c6f", size = 24644301, upload-time = "2026-07-22T01:48:33.094Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4f/f2c3d0993ebab255a2dd7c476678c0307da03d890fb98761e8221d7bb043/uv-0.11.31-py3-none-win_amd64.whl", hash = "sha256:1a4bb0030d9070a4831a4f3115c5489998da7ca936e569a72696c90af469177a", size = 27699662, upload-time = "2026-07-22T01:48:37.708Z" }, + { url = "https://files.pythonhosted.org/packages/4e/8b/259e12b510c655f743f9a0e3171e6e9276dfd35a058d04d6aeef1fc4a897/uv-0.11.31-py3-none-win_arm64.whl", hash = "sha256:88ab5fdbeff4ab10ac890ab2dd01b7ad62b92251665423e4f68b1cf977fbe635", size = 25849721, upload-time = "2026-07-22T01:48:42.513Z" }, ] [[package]] @@ -2835,14 +2871,14 @@ wheels = [ [[package]] name = "voluptuous-openapi" -version = "0.3.0" +version = "0.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "voluptuous" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ed/5f/e77f088f4eff2e0f572f651bb9c2259561ec32d0bff20f59dd107e392ad8/voluptuous_openapi-0.3.0.tar.gz", hash = "sha256:1ecd2386e63b59b791e0f64715e317c7018a2e5598333138bd1f28adfd348d5c", size = 17467, upload-time = "2025-12-31T00:40:24.296Z" } +sdist = { url = "https://files.pythonhosted.org/packages/60/a5/517c143607fd82cfc8842fcdccf5cf2c07cdd96528c936bde196e05dece3/voluptuous_openapi-0.4.1.tar.gz", hash = "sha256:745d0d60940a851d9a90c87eebf659c57072c9ef305c0f6f04f4fd7c063dc0ac", size = 21432, upload-time = "2026-06-26T13:31:35.924Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/2d/3a8728e638bac8fd5857bb7775e39a9a520ae90b1d6dffd8badfa9852ccc/voluptuous_openapi-0.3.0-py3-none-any.whl", hash = "sha256:db35598e9fc711cbaf7d7732de160de3edf89cc7411b6febe5a132f1f54ea931", size = 10896, upload-time = "2025-12-31T00:40:23.067Z" }, + { url = "https://files.pythonhosted.org/packages/bc/bb/64db054ad3852e3b63841a570942d3ce29e6b44979d5ac4be806973a2338/voluptuous_openapi-0.4.1-py3-none-any.whl", hash = "sha256:b30abc527c622cd62dba1269a05099e6b6170678c2838908d347fa85ab0cdb44", size = 12824, upload-time = "2026-06-26T13:31:35.126Z" }, ] [[package]] @@ -3020,81 +3056,82 @@ wheels = [ [[package]] name = "yarl" -version = "1.23.0" +version = "1.24.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" }, - { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" }, - { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" }, - { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" }, - { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" }, - { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" }, - { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" }, - { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" }, - { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" }, - { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" }, - { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" }, - { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" }, - { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" }, - { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" }, - { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" }, - { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" }, - { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" }, - { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" }, - { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" }, - { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" }, - { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" }, - { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" }, - { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" }, - { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" }, - { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" }, - { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" }, - { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" }, - { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" }, - { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" }, - { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" }, - { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" }, - { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" }, - { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" }, - { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" }, - { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, - { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/08/5f3085fef9564217074db9dd8573de1795bc82cde61a7ad10b6a7234a569/yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76", size = 135680, upload-time = "2026-07-20T02:06:33.273Z" }, + { url = "https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d", size = 97395, upload-time = "2026-07-20T02:06:35.259Z" }, + { url = "https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75", size = 97223, upload-time = "2026-07-20T02:06:37.216Z" }, + { url = "https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9", size = 108777, upload-time = "2026-07-20T02:06:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/2f/44/e0b86c302471fabd6f02808ecf2ac52b8412b624787849d4bf2cdb466f6f/yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede", size = 103119, upload-time = "2026-07-20T02:06:41.456Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/9c16d180bf8faaf223225eb50e1245870ff1ae0e302a27153988e65c51fd/yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca", size = 116471, upload-time = "2026-07-20T02:06:43.696Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8d/b219b9df28a02ce95cfbdd41d2f7caa5669d0ff979c1c9975697145e33c5/yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027", size = 115974, upload-time = "2026-07-20T02:06:45.874Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9", size = 110830, upload-time = "2026-07-20T02:06:47.852Z" }, + { url = "https://files.pythonhosted.org/packages/db/18/199b85109a53eeca64ee19c9cca228287e8e4ab0cc1a09b28f530e65cce0/yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41", size = 110054, upload-time = "2026-07-20T02:06:49.84Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/ed28147f8cd7f48c49367c90713b30a555284b6105a6a56f3a05568da795/yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373", size = 108312, upload-time = "2026-07-20T02:06:51.835Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c5/55e16ae0a5c227cea8df1c6871ba57d614a34243146c05729caf2a1bd9c5/yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36", size = 103662, upload-time = "2026-07-20T02:06:54.061Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ea/dbd7c2caec459c9a426f18b02688ecbfb58620d0f6a3422d24769fbaf8ab/yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0", size = 116090, upload-time = "2026-07-20T02:06:56.015Z" }, + { url = "https://files.pythonhosted.org/packages/06/84/39ce4ce3059e07fece5fbdbee8c4053406af9aca911ce9fa5f8548aab6af/yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5", size = 109523, upload-time = "2026-07-20T02:06:57.926Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8b/71ff44137b405c64a7788075669c24010019f57a7464b78c3a6cbee539d9/yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5", size = 116084, upload-time = "2026-07-20T02:06:59.868Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/423078fdd4042e1862c11f0ffd977a0ffa393783c12bee94685923bc189e/yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4", size = 111006, upload-time = "2026-07-20T02:07:01.907Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad", size = 99215, upload-time = "2026-07-20T02:07:03.852Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0e/464a847d7359e0da75dd9fc5c1d1aa35d0159ea31e5f8e66a3c1c29ff3d0/yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f", size = 94566, upload-time = "2026-07-20T02:07:06.074Z" }, + { url = "https://files.pythonhosted.org/packages/e2/55/e03acc4446772660bc335e86e41ef31e4d0d838fd641531a11a5ee33b493/yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88", size = 142533, upload-time = "2026-07-20T02:07:08.284Z" }, + { url = "https://files.pythonhosted.org/packages/ae/71/4acd3a1fc7cf14345cdb302665ecd2097f62c365b4f14ca17d4f37775cf9/yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba", size = 100776, upload-time = "2026-07-20T02:07:10.197Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/cfb76b7fe99686db264bff829779a539d923e7564ffd7ef18da6c54c3774/yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928", size = 100913, upload-time = "2026-07-20T02:07:12.357Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3f/7116e782992abbd4fb6948488aec72078895e929a23078290739e8396fce/yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f", size = 106507, upload-time = "2026-07-20T02:07:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/33/90/d4d2d73ee78229cc889872eb8e085d8f5c6f51abdb178409fd9b23cf74fd/yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95", size = 99219, upload-time = "2026-07-20T02:07:16.019Z" }, + { url = "https://files.pythonhosted.org/packages/3e/fa/a6df1a9bccd644eec00abee0dff4277416222cec435330fd1f2858523ec1/yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc", size = 111804, upload-time = "2026-07-20T02:07:18.141Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/7b2a1f4bcc20e9447156dd2b1c4d01f70d9df0759025ee7d09a84ffae134/yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da", size = 110943, upload-time = "2026-07-20T02:07:20.06Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/22c92affb0f9b623ca753d27d968b5625b868f12c6378d049d55ae247643/yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a", size = 108251, upload-time = "2026-07-20T02:07:22.217Z" }, + { url = "https://files.pythonhosted.org/packages/45/44/5769b96298c1e195fb412997b6090af2a84105cf59c17613558a2d011d1f/yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0", size = 106025, upload-time = "2026-07-20T02:07:24.083Z" }, + { url = "https://files.pythonhosted.org/packages/4c/40/009e8e791fd9762c0e1567e69248acb4f49064597e1680874c16dd8bb798/yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498", size = 106573, upload-time = "2026-07-20T02:07:26.248Z" }, + { url = "https://files.pythonhosted.org/packages/20/c6/b7480578f8a0a80946f36ad6df547ecec704f9ba69d2de60f8aa6f1c1cbf/yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104", size = 100751, upload-time = "2026-07-20T02:07:28.098Z" }, + { url = "https://files.pythonhosted.org/packages/d4/27/4476f3360b91a48c5cf125e91f59a3bd35299d84a431a258d57f5977bb11/yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331", size = 111643, upload-time = "2026-07-20T02:07:30.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/5cdd3e5ee944e8af31e52f6cd3d3af5fd7b937e036ccbbba2c9ffebede95/yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550", size = 106312, upload-time = "2026-07-20T02:07:33.06Z" }, + { url = "https://files.pythonhosted.org/packages/18/86/f406b0c2a6f99575de2da671ef47aa06f89a5be83a27a46971c3b86cecdb/yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6", size = 110379, upload-time = "2026-07-20T02:07:35.155Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6c/9f3adfbd3b30b4fa0f7ccb3a83eba2c1152d3fff554d535e640ba0f7ba2b/yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047", size = 108497, upload-time = "2026-07-20T02:07:37.35Z" }, + { url = "https://files.pythonhosted.org/packages/dd/37/91eb2e5ca883a529c1b390348a74cd9fc0512171727f547ce70bfe02be5c/yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104", size = 102450, upload-time = "2026-07-20T02:07:39.578Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f4/ed5c402ac8fde4403ed3366c2716bfddc8a6677ebd59f3d62772cc7fe468/yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688", size = 97222, upload-time = "2026-07-20T02:07:41.55Z" }, + { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" }, ] [[package]] name = "zeroconf" -version = "0.148.0" +version = "0.150.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ifaddr" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/67/46/10db987799629d01930176ae523f70879b63577060d63e05ebf9214aba4b/zeroconf-0.148.0.tar.gz", hash = "sha256:03fcca123df3652e23d945112d683d2f605f313637611b7d4adf31056f681702", size = 164447, upload-time = "2025-10-05T00:21:19.199Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/46/ac86e3a3ff355058cd0818b01a3a97ca3f2abc0a034f1edb8eea27cea65c/zeroconf-0.148.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:2158d8bfefcdb90237937df65b2235870ccef04644497e4e29d3ab5a4b3199b6", size = 1714870, upload-time = "2025-10-05T01:08:47.624Z" }, - { url = "https://files.pythonhosted.org/packages/de/02/c5e8cd8dfda0ca16c7309c8d12c09a3114e5b50054bce3c93da65db8b8e4/zeroconf-0.148.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:695f6663bf8df30fe1826a2c4d5acd8213d9cbd9111f59d375bf1ad635790e98", size = 1697756, upload-time = "2025-10-05T01:08:49.472Z" }, - { url = "https://files.pythonhosted.org/packages/63/04/a66c1011d05d7bb8ae6a847d41ac818271a942390f3d8c83c776389ca094/zeroconf-0.148.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa65a24ec055be0a1cba2b986ac3e1c5d97a40abe164991aabc6a6416cc9df02", size = 2146784, upload-time = "2025-10-05T01:08:51.766Z" }, - { url = "https://files.pythonhosted.org/packages/7c/d4/2239d87c3f60f886bd2dd299e9c63b811efd58b8b6fc659d8fd0900db3bc/zeroconf-0.148.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:79890df4ff696a5cdc4a59152957be568bea1423ed13632fc09e2a196c6721d5", size = 1899394, upload-time = "2025-10-05T01:08:53.457Z" }, - { url = "https://files.pythonhosted.org/packages/fb/60/534a4b576a8f9f5edff648ac9a5417323bef3086a77397f2f2058125a3c8/zeroconf-0.148.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c0ca6e8e063eb5a385469bb8d8dec12381368031cb3a82c446225511863ede3", size = 2221319, upload-time = "2025-10-05T01:08:55.271Z" }, - { url = "https://files.pythonhosted.org/packages/b5/8c/1c8e9b7d604910830243ceb533d796dae98ed0c72902624a642487edfd61/zeroconf-0.148.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ece6f030cc7a771199760963c11ce4e77ed95011eedffb1ca5186247abfec24a", size = 2178586, upload-time = "2025-10-05T01:08:56.966Z" }, - { url = "https://files.pythonhosted.org/packages/16/55/178c4b95840dc687d45e413a74d2236a25395ab036f4813628271306ab9d/zeroconf-0.148.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c3f860ad0003a8999736fa2ae4c2051dd3c2e5df1bc1eaea2f872f5fcbd1f1c1", size = 1972371, upload-time = "2025-10-05T01:08:59.103Z" }, - { url = "https://files.pythonhosted.org/packages/fb/86/b599421fe634d9f3a2799f69e6e7db9f13f77d326331fa2bb5982e936665/zeroconf-0.148.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ab8e687255cf54ebeae7ede6a8be0566aec752c570e16dbea84b3f9b149ba829", size = 2244286, upload-time = "2025-10-05T01:09:01.029Z" }, - { url = "https://files.pythonhosted.org/packages/3e/cb/a30c42057be5da6bb4cbe1ab53bc3a7d9a29cd59caae097d3072a9375c14/zeroconf-0.148.0-cp314-cp314-win32.whl", hash = "sha256:6b1a6ddba3328d741798c895cecff21481863eb945c3e5d30a679461f4435684", size = 1321693, upload-time = "2025-10-05T01:09:02.715Z" }, - { url = "https://files.pythonhosted.org/packages/2c/38/06873cdf769130af463ef5acadbaf4a50826a7274374bc3b9a4ec5d32678/zeroconf-0.148.0-cp314-cp314-win_amd64.whl", hash = "sha256:2588f1ca889f57cdc09b3da0e51175f1b6153ce0f060bf5eb2a8804c5953b135", size = 1563980, upload-time = "2025-10-05T01:09:04.857Z" }, - { url = "https://files.pythonhosted.org/packages/36/fb/53d749793689279bc9657d818615176577233ad556d62f76f719e86ead1d/zeroconf-0.148.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:40fe100381365c983a89e4b219a7ececcc2a789ac179cd26d4a6bbe00ae3e8fe", size = 3418152, upload-time = "2025-10-05T01:09:06.71Z" }, - { url = "https://files.pythonhosted.org/packages/b9/19/5eb647f7277378cbfdb6943dc8e60c3b17cdd1556f5082ccfdd6813e1ce8/zeroconf-0.148.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0b9c7bcae8af8e27593bad76ee0f0c21d43c6a2324cd1e34d06e6e08cb3fd922", size = 3389671, upload-time = "2025-10-05T01:09:08.903Z" }, - { url = "https://files.pythonhosted.org/packages/86/12/3134aa54d30a9ae2e2473212eab586fe1779f845bf241e68729eca63d2ab/zeroconf-0.148.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf8ba75dacd58558769afb5da24d83da4fdc2a5c43a52f619aaa107fa55d3fdc", size = 4123125, upload-time = "2025-10-05T01:09:11.064Z" }, - { url = "https://files.pythonhosted.org/packages/12/23/4a0284254ebce373ff1aee7240932a0599ecf47e3c711f93242a861aa382/zeroconf-0.148.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75f9a8212c541a4447c064433862fd4b23d75d47413912a28204d2f9c4929a59", size = 3651426, upload-time = "2025-10-05T01:09:13.725Z" }, - { url = "https://files.pythonhosted.org/packages/76/9a/7b79ef986b5467bb8f17b9a9e6eea887b0b56ecafc00515c81d118e681b4/zeroconf-0.148.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be64c0eb48efa1972c13f7f17a7ac0ed7932ebb9672e57f55b17536412146206", size = 4263151, upload-time = "2025-10-05T01:09:15.732Z" }, - { url = "https://files.pythonhosted.org/packages/dd/0a/caa6d05548ca7cf28a0b8aa20a9dbb0f8176172f28799e53ea11f78692a3/zeroconf-0.148.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac1d4ee1d5bac71c27aea6d1dc1e1485423a1631a81be1ea65fb45ac280ade96", size = 4191717, upload-time = "2025-10-05T01:09:18.071Z" }, - { url = "https://files.pythonhosted.org/packages/46/f6/dbafa3b0f2d7a09315ed3ad588d36de79776ce49e00ec945c6195cad3f18/zeroconf-0.148.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8da9bdb39ead9d5971136046146cd5e11413cb979c011e19f717b098788b5c37", size = 3793490, upload-time = "2025-10-05T01:09:20.045Z" }, - { url = "https://files.pythonhosted.org/packages/c4/05/f8b88937659075116c122355bdd9ce52376cc46e2269d91d7d4f10c9a658/zeroconf-0.148.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f6e3dd22732df47a126aefb5ca4b267e828b47098a945d4468d38c72843dd6df", size = 4311455, upload-time = "2025-10-05T01:09:22.042Z" }, - { url = "https://files.pythonhosted.org/packages/58/c0/359bdb3b435d9c573aec1f877f8a63d5e81145deb6c160de89647b237363/zeroconf-0.148.0-cp314-cp314t-win32.whl", hash = "sha256:cdc8083f0b5efa908ab6c8e41687bcb75fd3d23f49ee0f34cbc58422437a456f", size = 2755961, upload-time = "2025-10-05T01:09:24.041Z" }, - { url = "https://files.pythonhosted.org/packages/d8/ab/7b487afd5d1fd053c5a018565be734ac6d5e554bce938c7cc126154adcfc/zeroconf-0.148.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f72c1f77a89638e87f243a63979f0fd921ce391f83e18e17ec88f9f453717701", size = 3309977, upload-time = "2025-10-05T01:09:26.039Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/09/ea/34bb185645ecaa18d34e5883bffea71aa9bffbbb994634884e8b2f3ad0c4/zeroconf-0.150.0.tar.gz", hash = "sha256:a5fe7feab1de6ef5e541e0a3d07e534fd91629b813fc27281593584100f63164", size = 213634, upload-time = "2026-06-22T17:52:37.982Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/08/22ccf6d3ba5f29d343dc8f9d45e2d8adbfc80b780c55a601fa7811d30d6f/zeroconf-0.150.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcfba6f9d344ade1f60aa8b5621a2fa8f65fbe740edc40b05627516bc55eec8f", size = 1686768, upload-time = "2026-06-22T18:24:50.421Z" }, + { url = "https://files.pythonhosted.org/packages/de/4a/f4df1ee10bf74cff25c8825d3490b8b71f57d7ec559c84b76bae42ee3444/zeroconf-0.150.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:31832790ed1bcf829b91d4697c6497fade20dbe1094c35d10d5af456cd0e8360", size = 2105247, upload-time = "2026-06-22T18:24:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/ed/d6/8af2192c3ffd8d67c36a369cdbf1ab388b672ef1a94e5baf73c8f003f08f/zeroconf-0.150.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9ed611401d1e39f7d27e9cec8ef0b06fffad06955cc3c54714075b037d7b6a8b", size = 1859144, upload-time = "2026-06-22T18:24:54.615Z" }, + { url = "https://files.pythonhosted.org/packages/52/d8/09ae5f45ad6bde1decf246152c5a3c5183829ab3966d90cec2b06e9dc468/zeroconf-0.150.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0aac92f26cfe8075d6e210e5edb8194cc0b1d16eecdcdb04f31056a14cbc6466", size = 2191079, upload-time = "2026-06-22T18:24:56.439Z" }, + { url = "https://files.pythonhosted.org/packages/60/78/3d46201c05a22b3eae1e48e82540590c8689a7646183f9f2c28b70e6edfa/zeroconf-0.150.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2fdaffee6f71432c64b609ade44e48763484b6084c7954eafeeff0cf4dbc4aef", size = 2125023, upload-time = "2026-06-22T18:24:58.322Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d9/65bf08d612551306227cc0843d0d468678d88dc1b101fdc2861c6938c40b/zeroconf-0.150.0-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:2bce10cb490119ad051f3caf74088317fec9f5e9acbe36e3690abe8d3de2ac31", size = 2187807, upload-time = "2026-06-22T17:52:35.597Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a6/fa1f712409f438d6a5b8f61c6592747f54894ae770841f8a2d659d79c638/zeroconf-0.150.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:048533286135c467a3bb90a268a9ca3ee3b98c3809d0106359e4d4c0e375e8ad", size = 2136974, upload-time = "2026-06-22T18:25:00.098Z" }, + { url = "https://files.pythonhosted.org/packages/35/a4/e8075345b6a61cbc8c7d75ef4d6bb58d0055dbb75887c6f3789f64214b60/zeroconf-0.150.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:12f14afb42c08b8365d638ba2a01e5a73641dc8d5433fcdf8751d636210db053", size = 1944626, upload-time = "2026-06-22T18:25:02.376Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b7/417e588bb208d9298e081f00aa41134becf6d05738169f67f68f9a0faa0f/zeroconf-0.150.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cf19d67827af63c54175ef76a62a004734c0d18ac67ff383a5df36c26049707e", size = 2133245, upload-time = "2026-06-22T18:25:04.358Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/6de53f203e36275f2ea97566c36af7ad28e0def6a84da639ad4eef84910d/zeroconf-0.150.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:49ae4f9109b2b7aa04fd75677ce33d552bb3db6f9e757acc6e2f6a547cbe26b2", size = 2213535, upload-time = "2026-06-22T18:25:06.239Z" }, + { url = "https://files.pythonhosted.org/packages/32/2d/e3509c21b146048759ea63af392972d5d3b39a218db58415ae84fbf3cfce/zeroconf-0.150.0-cp314-cp314-win32.whl", hash = "sha256:afad9aae3a10c4e54a0b55925c29b9d82d1e807f996e5e106a2b2fade6a9e3cc", size = 1306214, upload-time = "2026-06-22T18:25:08.291Z" }, + { url = "https://files.pythonhosted.org/packages/eb/97/d0d1221906963596d59f7a76086d22392a530f5851278fbe093db46809bf/zeroconf-0.150.0-cp314-cp314-win_amd64.whl", hash = "sha256:cd09bf1242af33769c88e1f0236ce52f6c74144a741c848fe38a79e68df80414", size = 1549661, upload-time = "2026-06-22T18:25:11.245Z" }, + { url = "https://files.pythonhosted.org/packages/81/4f/631f56f09dbe1aad76a3dd38bb92d4e5cce50a6630064590926d578d21da/zeroconf-0.150.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fed39ccd107ee219ad805ee4c38dca253effc1c96541b4954dee3c70dca13a71", size = 3362888, upload-time = "2026-06-22T18:25:13.066Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9a/53d1111468a69f395272531cb292c09e7b03ecf6186476d2c4a6dbe78198/zeroconf-0.150.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55d0c37be291364268688fe5dcd33bd5156f669289bb9b71a23b041ca53c9017", size = 4023043, upload-time = "2026-06-22T18:25:15.023Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d7/4ce3504120afa854fb55799b4f0e36c27b8d3def3d41f2d1731179bd7c19/zeroconf-0.150.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b97234eda197d4243d859b64004d175de76353dbca17527d11705f841fb02977", size = 3560681, upload-time = "2026-06-22T18:25:17.158Z" }, + { url = "https://files.pythonhosted.org/packages/82/60/5804e6362e4e3dae64923423b46c0fcc291a341d1e8ed50fec7af1cd73e7/zeroconf-0.150.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01a31f0ec4bc3f125ae541fb96259a081f3f83d201f1fca59929155b90185df4", size = 4178405, upload-time = "2026-06-22T18:25:19.735Z" }, + { url = "https://files.pythonhosted.org/packages/2a/2c/502e92caa81a7932fca22f78778e622ddca318f8d2243eddcf24d24ec0e7/zeroconf-0.150.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ee162b43e455043e22753cdc1fae0f2990d3fe3767f2e88695fb92273d7229b", size = 4044586, upload-time = "2026-06-22T18:25:21.812Z" }, + { url = "https://files.pythonhosted.org/packages/45/a3/a37dd7fc0b22e0c2e13b279611ae8060fb046d3712a7d33313c8b7b556a9/zeroconf-0.150.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:178b6141e34a777593a16b8bf986b2d7ae806e47849a3ad517597f63bcaade47", size = 4089064, upload-time = "2026-06-22T18:25:24.169Z" }, + { url = "https://files.pythonhosted.org/packages/41/7f/c2e7aeb8feb1a6ef3474d66774fad1c7b6040ac1bc48b188110d3c9d17a2/zeroconf-0.150.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:410e30a80db9b73bfdee3c54dfe306ebb140e053dfe3b28cefd54756a19a73fe", size = 3717837, upload-time = "2026-06-22T18:25:26.305Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ee/b6b5b5f4f51296b7c686f57642003369478fe60a33a917f4421a6e20fc85/zeroconf-0.150.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c539950bba739bc3980dd0f01187637e6237ada8bd70d64f93d8eb5eb2837a7b", size = 4072641, upload-time = "2026-06-22T18:25:28.356Z" }, + { url = "https://files.pythonhosted.org/packages/54/77/3c292c091b636c3a0af5c69e1d1ffe83261737d34075efc889fe25380b32/zeroconf-0.150.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c8ccc9e1772ce0030f77e6a49542e9b7ca4e8ae9ce1bef84a3a983be68ddf81c", size = 4225357, upload-time = "2026-06-22T18:25:30.468Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5b/54758b003fe687757f9d8313a8ceaa7560e1c392ac109890782283ba1840/zeroconf-0.150.0-cp314-cp314t-win32.whl", hash = "sha256:576e72a9e9140d96ae110ceb0935f06fa41eeea8b2be53e442a6a00c84035194", size = 2599274, upload-time = "2026-06-22T18:25:32.463Z" }, + { url = "https://files.pythonhosted.org/packages/ff/be/9eb576ae5eeede25cd1e59f0be1ce6b2fcf7aa5a4ab4d6d05de2d30efc4a/zeroconf-0.150.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bdae152e80b848117691ab59aa8b16bf15323c0e9f3ddc38c6345af95a700a84", size = 3106484, upload-time = "2026-06-22T18:25:34.89Z" }, ] From 4ccaff047553c538fe9a956f2b298c684fed6862 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:34:18 -0700 Subject: [PATCH 010/345] Classify the MID, and stop reading the identifier grammar in two places `_classify_sub_device` knew `bess` and `evse`, so the Microgrid Interconnect reached the topology payload as `unknown` and a card rendered it as a device with a name and no type. Adding a third branch would have fixed this instance and left the mechanism. The grammar -- `{panel serial}_{kind}`, with EVSE carrying a node id after the kind -- was written by the builders in `util` and restated from memory by the topology command, so the two ends could drift, and did, the moment a third kind existed. It is now declared once beside the builders, which compose from it, and read back through `classify_sub_device_identifier`. The `_bess` and `_mid` tests tighten to suffix matches while they are being moved; EVSE stays an infix because its node id follows. The drift guard is parametrised over the same builder list as the link test, so a fourth sub-device added to `util` without teaching the classifier fails in CI rather than in a dashboard. Removing the MID branch fails it with `mid identifier classifies as {None}`. Card: a sub-device is skipped when it has no power reading, no charts and no visible entities. Tested on emptiness rather than on type -- excluding the MID by name would fix one device and leave the next to rediscover it, and v1.0 has more classes coming. The MID qualifies today only because its one entity is a diagnostic enum; opt that entity in and the tile returns with no code change, which is what a test pins. Bundles rebuilt. --- .../frontend/dist/span-panel-card.js | 2 +- .../span_panel/frontend/dist/span-panel.js | 2 +- custom_components/span_panel/util.py | 37 +++++++++++++++++-- custom_components/span_panel/websocket.py | 18 ++++++--- tests/test_device_links.py | 30 +++++++++++++++ websocket-api.md | 6 +-- 6 files changed, 82 insertions(+), 13 deletions(-) diff --git a/custom_components/span_panel/frontend/dist/span-panel-card.js b/custom_components/span_panel/frontend/dist/span-panel-card.js index b5d260dc..1e875aac 100644 --- a/custom_components/span_panel/frontend/dist/span-panel-card.js +++ b/custom_components/span_panel/frontend/dist/span-panel-card.js @@ -204,7 +204,7 @@ var Ga={},qa={};var ja,Xa=function(){function t(t,e,n){var i=this;this._sleepAft
- `}updated(t){if(t.has("hass")&&this.hass&&(n(this.hass.language),this._ctrl.hass=this.hass,this._errorStore.updateHass(this.hass),this._config.device_id))if(this._discovered||this._discovering){if(this._discovered){this._ctrl.recordSamples(),this._ctrl.updateDOM(this._root);const t=this._root.querySelector("span-side-panel");t&&(t.hass=this.hass,t.errorStore=this._errorStore)}this._discovered&&"panel"!==this._activeTab&&this._topology&&this._listCtrl.updateCollapsedRows(this._root,this.hass,this._topology,this._config)}else this._startDiscovery()}async _startDiscovery(){this._discovering||(this._discovering=!0,await this._discoverTopology(),this._errorStore.hasPersistent("discovery-failed")?this._discovering=!1:(this._discovered=!0,this._discovering=!1,this._ctrl.init(this._topology,this._config,this.hass,this._configEntryId),this._topology?.panel_entities?.panel_status&&(this._errorStore.watchPanelStatus(this._topology.panel_entities.panel_status),this._errorStore.updateHass(this.hass)),this._topology&&(this._areaSubscribing=!0,async function(t,e,n,r){if(!t.connection)return()=>{};const o=async()=>{try{const i=new Map;for(const[t,n]of Object.entries(e.circuits))i.set(t,n.area);await Kk(t,e);for(const[t,r]of Object.entries(e.circuits))if(r.area!==i.get(t))return void n()}catch(t){console.warn("[span-panel] area registry update failed:",t),r?.add({key:"fetch:areas",level:"warning",message:i("error.areas_failed"),persistent:!1})}},[a,s]=await Promise.all([t.connection.subscribeEvents(o,"entity_registry_updated"),t.connection.subscribeEvents(o,"area_registry_updated")]);return()=>{a(),s()}}(this.hass,this._topology,()=>{"area"===this._activeTab&&this._discovered&&this._populateCardContent()},this._errorStore).then(t=>{this._areaSubscribing?this._areaUnsub=t:t()}).catch(t=>{this._areaSubscribing=!1,console.warn("SPAN Panel: area subscription failed",t),this._errorStore.add({key:"subscribe:area",level:"warning",message:i("error.areas_failed"),persistent:!1})})),await this.updateComplete,this._populateCardContent(),this._loadHistory(),this._ctrl.monitoringCache.fetch(this.hass,this._configEntryId).then(()=>{this._discovered&&this._ctrl.updateDOM(this._root)})))}async _discoverTopology(){if(!this.hass)return;const t=new Qt(this._errorStore);try{const e=await async function(t,e,n){if(!e)throw new Error(i("card.device_not_found"));const r={type:`${l}/panel_topology`,device_id:e},o=n?await n.callWS(t,r,{errorId:"fetch:topology"}):await t.callWS(r),a=o.panel_size??Jk(o.circuits);if(!a)throw new Error(i("card.topology_error"));const s={type:"config/device_registry/list"},c=tT((n?await n.callWS(t,s,{errorId:"fetch:topology"}):await t.callWS(s)).find(t=>t.id===e));return await Kk(t,o),{topology:o,panelDevice:c,panelSize:a}}(this.hass,this._config.device_id,t);this._topology=e.topology,this._panelDevice=e.panelDevice,this._panelSize=e.panelSize}catch(e){console.error("SPAN Panel: topology fetch failed, falling back to entity discovery",e);try{const e=await async function(t,e,n){const r={type:"config/device_registry/list"},o={type:"config/entity_registry/list"},[a,s]=await Promise.all([n?n.callWS(t,r,{errorId:"fetch:topology"}):t.callWS(r),n?n.callWS(t,o,{errorId:"fetch:topology"}):t.callWS(o)]),c=tT(a.find(t=>t.id===e));if(!c)return{topology:null,panelDevice:null,panelSize:0};const u=s.filter(t=>t.device_id===e),h=a.filter(t=>t.via_device_id===e),d=new Set(h.map(t=>t.id)),p=s.filter(t=>void 0!==t.device_id&&d.has(t.device_id)),f={},g=c.name_by_user??c.name??"";for(const e of[...u,...p]){const n=t.states[e.entity_id];if(!n)continue;const i=n.attributes,r=i.tabs;if("string"!=typeof r||!r.startsWith("tabs ["))continue;const o=r.slice(6,-1);let a;if(a=o.includes(":")?o.split(":").map(Number):[Number(o)],!a.every(Number.isFinite))continue;const s=e.unique_id.split("_");let l=null;for(let t=2;t=16&&/^[a-f0-9]+$/i.test(e)){l=e;break}}if(!l)continue;let c=("string"==typeof i.friendly_name?i.friendly_name:void 0)??e.entity_id;for(const t of[" Power"," Consumed Energy"," Produced Energy"])if(c.endsWith(t)){c=c.slice(0,-t.length);break}g&&c.startsWith(g+" ")&&(c=c.slice(g.length+1));const u=e.entity_id.replace(/^sensor\./,"").replace(/_power$/,""),h="number"==typeof i.voltage?i.voltage:2===a.length?240:120,d={power:e.entity_id,switch:`switch.${u}_breaker`,breaker_rating:`sensor.${u}_breaker_rating`};f[l]={tabs:a,name:c,voltage:h,device_type:"string"==typeof i.device_type?i.device_type:"circuit",relay_state:"string"==typeof i.relay_state?i.relay_state:"UNKNOWN",is_user_controllable:!0,breaker_rating_a:null,entities:d}}let v="";if(c.identifiers)for(const t of c.identifiers){if(!Array.isArray(t)||t.length<2)continue;const[e,n]=t;e===l&&"string"==typeof n&&(v=n)}let m=0;for(const e of u){const n=t.states[e.entity_id];if(n&&"number"==typeof n.attributes.panel_size){m=n.attributes.panel_size;break}}if(m||(m=Jk(f)),!m)throw new Error(i("card.panel_size_error"));const y={};for(const e of h){const n=s.filter(t=>t.device_id===e.id),i=(e.model??"").toLowerCase(),r=i.includes("battery")||(e.identifiers??[]).some(t=>t[1].toLowerCase().includes("bess")),o=i.includes("drive")||(e.identifiers??[]).some(t=>t[1].toLowerCase().includes("evse")),a={};for(const e of n){const n=e.entity_id.split(".")[0],i=t.states[e.entity_id],r=i?.attributes?.friendly_name;a[e.entity_id]={domain:n??"",original_name:"string"==typeof r?r:e.entity_id}}y[e.id]={name:e.name_by_user??e.name??"",type:r?"bess":o?"evse":"unknown",entities:a}}const _={serial:v,firmware:c.sw_version??"",panel_size:m,device_id:e,device_name:c.name_by_user??c.name??i("header.default_name"),circuits:f,sub_devices:y};return await Kk(t,_),{topology:_,panelDevice:c,panelSize:m}}(this.hass,this._config.device_id,t);this._topology=e.topology,this._panelDevice=e.panelDevice,this._panelSize=e.panelSize}catch(t){console.error("SPAN Panel: fallback discovery also failed",t),this._errorStore.add({key:"discovery-failed",level:"error",message:i("error.discovery_failed"),persistent:!0,retryFn:()=>{this._errorStore.remove("discovery-failed"),this._startDiscovery()}})}}}async _loadHistory(){if(!this._historyLoaded&&this._topology&&this.hass){this._historyLoaded=!0,await this._ctrl.fetchAndBuildHorizonMaps();try{await this._ctrl.loadHistory(),this._ctrl.updateDOM(this._root)}catch(t){console.warn("SPAN Panel: history fetch failed, charts will populate live",t)}}}_populateCardContent(){const t=this._root.querySelector("#card-content");if(!(t&&this.hass&&this._topology&&this._panelSize))return;const e=this._root.querySelector("#card-tabs");if(e){const t=[{id:"panel",label:i("tab.by_panel"),icon:"mdi:view-dashboard"},{id:"activity",label:i("tab.by_activity"),icon:"mdi:sort-descending"},{id:"area",label:i("tab.by_area"),icon:"mdi:home-group"}];e.innerHTML=(n=t,r=this._activeTab,o=this._config.tab_style??"text",`
${n.map(t=>{const e=t.id===r?" active":"",n=Rt(t.id);return"icon"===o?``:``}).join("")}
`),this._tabBarCleanup&&(this._tabBarCleanup(),this._tabBarCleanup=null),this._tabBarCleanup=function(t,e){const n=t=>{const n=t.target.closest(".shared-tab");if(n){const t=n.dataset.tab;t&&e(t)}};return t.addEventListener("click",n),()=>{t.removeEventListener("click",n)}}(e,t=>{["panel","activity","area"].includes(t)&&(this._activeTab=t,this._listCtrl.stop(),this._populateCardContent())})}var n,r,o;if("panel"===this._activeTab){const e=Math.ceil(this._panelSize/2),n=$t(this._topology,this._config),r=this._ctrl.monitoringCache.status,o=function(t){if(!t)return"";const e=Object.values(t.circuits??{}),n=Object.values(t.mains??{}),r=[...e,...n],o=r.filter(t=>void 0!==t.utilization_pct&&t.utilization_pct>=80&&t.utilization_pct<100).length,a=r.filter(t=>void 0!==t.utilization_pct&&t.utilization_pct>=100).length,s=r.filter(t=>t.has_override).length;return`\n
\n ✓ ${i("status.monitoring")} · ${e.length} ${i("status.circuits")} · ${n.length} ${i("status.mains")}\n \n ${o>0?`${o} ${i(o>1?"status.warnings":"status.warning")}`:""}\n ${a>0?`${a} ${i(a>1?"status.alerts":"status.alert")}`:""}\n ${s>0?`${s} ${i(s>1?"status.overrides":"status.override")}`:""}\n \n
\n `}(r),a=function(t,e,n,i,r){const o=new Map,a=new Set;for(const[e,n]of Object.entries(t.circuits)){const t=n.tabs;if(!t||0===t.length)continue;const i=Math.min(...t),r=1===t.length?"single":Xt(t)??"single";o.set(i,{uuid:e,circuit:n,layout:r});for(const e of t)a.add(e)}const s=new Set,l=new Set;for(const[t,e]of o)if("col-span"===e.layout){const n=e.circuit.tabs,i=qt(Math.max(...n));0===jt(t)?s.add(i):l.add(i)}function c(t){const e=t.circuit.entities?.current??t.circuit.entities?.power,i=r?ee(r,e??""):null;let o;if(t.circuit.always_on)o="always_on";else{const e=t.circuit.entities?.select;o=e&&n.states[e]?n.states[e].state:"unknown"}return{monInfo:i,sheddingPriority:o}}let u="";for(let t=1;t<=e;t++){const e=2*t-1,r=2*t,h=o.get(e),d=o.get(r);if(u+=`
${e}
`,h&&"row-span"===h.layout){const{monInfo:e,sheddingPriority:o}=c(h);u+=ie(h.uuid,h.circuit,t,"2 / 5","row-span",n,i,e,o),u+=`
${r}
`;continue}if(!s.has(t))if(!h||"col-span"!==h.layout&&"single"!==h.layout)a.has(e)||(u+=re(t,"2"));else{const{monInfo:e,sheddingPriority:r}=c(h);u+=ie(h.uuid,h.circuit,t,"2",h.layout,n,i,e,r)}if(!l.has(t))if(!d||"col-span"!==d.layout&&"single"!==d.layout)a.has(r)||(u+=re(t,"4"));else{const{monInfo:e,sheddingPriority:r}=c(d);u+=ie(d.uuid,d.circuit,t,"4",d.layout,n,i,e,r)}u+=`
${r}
`}return u}(this._topology,e,this.hass,this._config,r),s=function(t,e,n){const r=!1!==n.show_battery,o=!1!==n.show_evse;if(!t.sub_devices)return"";const a=Object.entries(t.sub_devices).filter(([,t])=>!(t.type===h&&!r||t.type===d&&!o));if(0===a.length)return"";const s=a.filter(([,t])=>t.type===d).length;let l=0,c="";for(const[t,r]of a){const o=r.type===d?i("subdevice.ev_charger"):r.type===h?i("subdevice.battery"):i("subdevice.fallback"),a=ue(r),u=a?e.states[a]:void 0,p=u&&parseFloat(u.state)||0,f=r.type===h,g=r.type===d,v=f?he(r):null,m=f?de(r):null,y=f?pe(r):null,_=fe(r,e,n,new Set([a,v,m,y].filter(t=>null!==t))),x=ge(t,0,f,a,v,m);let b="";f?b="sub-device-bess":g&&(l++,l===s&&s%2==1&&(b="sub-device-full")),c+=`\n
\n
\n ${Rt(o)}\n ${Rt(r.name||"")}\n ${a?`${Ut(p)} ${Wt(p)}`:""}\n \n
\n ${x}\n ${_}\n
\n `}return c}(this._topology,this.hass,this._config);t.innerHTML=`\n ${n}\n ${o}\n ${s?`
${s}
`:""}\n ${!1!==this._config.show_panel?`
${a}
`:""}\n `;const l=t.querySelector(".slide-confirm");if(l){const t=this._root.querySelector(".span-card");this._ctrl.bindSlideConfirm(l,t),t&&t.classList.add("switches-disabled")}const c=this._root.querySelector("span-side-panel");c&&(c.hass=this.hass,c.errorStore=this._errorStore),this._ctrl.recordSamples(),this._ctrl.updateDOM(this._root),this._ctrl.setupResizeObserver(this._root,this._root.querySelector(".span-card"))}else if("activity"===this._activeTab){t.innerHTML="";const e=$t(this._topology,this._config);this._listCtrl.setColumns(Bt()),this._listCtrl.renderActivityView(t,this.hass,this._topology,this._config,this._ctrl.monitoringCache.status,e),this._ctrl.updateDOM(this._root)}else if("area"===this._activeTab){t.innerHTML="";const e=$t(this._topology,this._config);this._listCtrl.setColumns(Bt()),this._listCtrl.renderAreaView(t,this.hass,this._topology,this._config,this._ctrl.monitoringCache.status,e),this._ctrl.updateDOM(this._root)}}_onCardClick(t){if("panel"!==this._activeTab)return;const e=t.target;if(!e)return;const n=e.closest(".unit-btn");if(n)return void this._onUnitToggle(n);if(e.closest(".toggle-pill"))return void this._ctrl.onToggleClick(t,this._root);e.closest(".gear-icon")&&this._ctrl.onGearClick(t,this._root)}async _onUnitToggle(t){const e=t.dataset.unit;e&&e!==(this._config.chart_metric??"power")&&(this._config={...this._config,chart_metric:e},this._ctrl.setConfig(this._config),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config},bubbles:!0,composed:!0})),this._ctrl.powerHistory.clear(),this._historyLoaded=!1,this._populateCardContent(),await this._loadHistory(),this._ctrl.updateDOM(this._root))}async _onListUnitChanged(t){const e=t.detail;e&&e!==(this._config.chart_metric??"power")&&(this._config={...this._config,chart_metric:e},this._ctrl.setConfig(this._config),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config},bubbles:!0,composed:!0})),this._ctrl.powerHistory.clear(),this._historyLoaded=!1,this._populateCardContent(),await this._loadHistory(),this._ctrl.updateDOM(this._root))}_onGraphSettingsChanged(){this._ctrl.onGraphSettingsChanged(this._root)}_onListColumnsChanged(t){const e=t.detail;"number"!=typeof e||1!==e&&2!==e&&3!==e||"activity"!==this._activeTab&&"area"!==this._activeTab||this._populateCardContent()}_onSidePanelClosed(){this._ctrl.monitoringCache.invalidate(),this._ctrl.graphSettingsCache.invalidate()}_renderPreview(){const t=uT.map(t=>ht` + `}updated(t){if(t.has("hass")&&this.hass&&(n(this.hass.language),this._ctrl.hass=this.hass,this._errorStore.updateHass(this.hass),this._config.device_id))if(this._discovered||this._discovering){if(this._discovered){this._ctrl.recordSamples(),this._ctrl.updateDOM(this._root);const t=this._root.querySelector("span-side-panel");t&&(t.hass=this.hass,t.errorStore=this._errorStore)}this._discovered&&"panel"!==this._activeTab&&this._topology&&this._listCtrl.updateCollapsedRows(this._root,this.hass,this._topology,this._config)}else this._startDiscovery()}async _startDiscovery(){this._discovering||(this._discovering=!0,await this._discoverTopology(),this._errorStore.hasPersistent("discovery-failed")?this._discovering=!1:(this._discovered=!0,this._discovering=!1,this._ctrl.init(this._topology,this._config,this.hass,this._configEntryId),this._topology?.panel_entities?.panel_status&&(this._errorStore.watchPanelStatus(this._topology.panel_entities.panel_status),this._errorStore.updateHass(this.hass)),this._topology&&(this._areaSubscribing=!0,async function(t,e,n,r){if(!t.connection)return()=>{};const o=async()=>{try{const i=new Map;for(const[t,n]of Object.entries(e.circuits))i.set(t,n.area);await Kk(t,e);for(const[t,r]of Object.entries(e.circuits))if(r.area!==i.get(t))return void n()}catch(t){console.warn("[span-panel] area registry update failed:",t),r?.add({key:"fetch:areas",level:"warning",message:i("error.areas_failed"),persistent:!1})}},[a,s]=await Promise.all([t.connection.subscribeEvents(o,"entity_registry_updated"),t.connection.subscribeEvents(o,"area_registry_updated")]);return()=>{a(),s()}}(this.hass,this._topology,()=>{"area"===this._activeTab&&this._discovered&&this._populateCardContent()},this._errorStore).then(t=>{this._areaSubscribing?this._areaUnsub=t:t()}).catch(t=>{this._areaSubscribing=!1,console.warn("SPAN Panel: area subscription failed",t),this._errorStore.add({key:"subscribe:area",level:"warning",message:i("error.areas_failed"),persistent:!1})})),await this.updateComplete,this._populateCardContent(),this._loadHistory(),this._ctrl.monitoringCache.fetch(this.hass,this._configEntryId).then(()=>{this._discovered&&this._ctrl.updateDOM(this._root)})))}async _discoverTopology(){if(!this.hass)return;const t=new Qt(this._errorStore);try{const e=await async function(t,e,n){if(!e)throw new Error(i("card.device_not_found"));const r={type:`${l}/panel_topology`,device_id:e},o=n?await n.callWS(t,r,{errorId:"fetch:topology"}):await t.callWS(r),a=o.panel_size??Jk(o.circuits);if(!a)throw new Error(i("card.topology_error"));const s={type:"config/device_registry/list"},c=tT((n?await n.callWS(t,s,{errorId:"fetch:topology"}):await t.callWS(s)).find(t=>t.id===e));return await Kk(t,o),{topology:o,panelDevice:c,panelSize:a}}(this.hass,this._config.device_id,t);this._topology=e.topology,this._panelDevice=e.panelDevice,this._panelSize=e.panelSize}catch(e){console.error("SPAN Panel: topology fetch failed, falling back to entity discovery",e);try{const e=await async function(t,e,n){const r={type:"config/device_registry/list"},o={type:"config/entity_registry/list"},[a,s]=await Promise.all([n?n.callWS(t,r,{errorId:"fetch:topology"}):t.callWS(r),n?n.callWS(t,o,{errorId:"fetch:topology"}):t.callWS(o)]),c=tT(a.find(t=>t.id===e));if(!c)return{topology:null,panelDevice:null,panelSize:0};const u=s.filter(t=>t.device_id===e),h=a.filter(t=>t.via_device_id===e),d=new Set(h.map(t=>t.id)),p=s.filter(t=>void 0!==t.device_id&&d.has(t.device_id)),f={},g=c.name_by_user??c.name??"";for(const e of[...u,...p]){const n=t.states[e.entity_id];if(!n)continue;const i=n.attributes,r=i.tabs;if("string"!=typeof r||!r.startsWith("tabs ["))continue;const o=r.slice(6,-1);let a;if(a=o.includes(":")?o.split(":").map(Number):[Number(o)],!a.every(Number.isFinite))continue;const s=e.unique_id.split("_");let l=null;for(let t=2;t=16&&/^[a-f0-9]+$/i.test(e)){l=e;break}}if(!l)continue;let c=("string"==typeof i.friendly_name?i.friendly_name:void 0)??e.entity_id;for(const t of[" Power"," Consumed Energy"," Produced Energy"])if(c.endsWith(t)){c=c.slice(0,-t.length);break}g&&c.startsWith(g+" ")&&(c=c.slice(g.length+1));const u=e.entity_id.replace(/^sensor\./,"").replace(/_power$/,""),h="number"==typeof i.voltage?i.voltage:2===a.length?240:120,d={power:e.entity_id,switch:`switch.${u}_breaker`,breaker_rating:`sensor.${u}_breaker_rating`};f[l]={tabs:a,name:c,voltage:h,device_type:"string"==typeof i.device_type?i.device_type:"circuit",relay_state:"string"==typeof i.relay_state?i.relay_state:"UNKNOWN",is_user_controllable:!0,breaker_rating_a:null,entities:d}}let v="";if(c.identifiers)for(const t of c.identifiers){if(!Array.isArray(t)||t.length<2)continue;const[e,n]=t;e===l&&"string"==typeof n&&(v=n)}let m=0;for(const e of u){const n=t.states[e.entity_id];if(n&&"number"==typeof n.attributes.panel_size){m=n.attributes.panel_size;break}}if(m||(m=Jk(f)),!m)throw new Error(i("card.panel_size_error"));const y={};for(const e of h){const n=s.filter(t=>t.device_id===e.id),i=(e.model??"").toLowerCase(),r=i.includes("battery")||(e.identifiers??[]).some(t=>t[1].toLowerCase().includes("bess")),o=i.includes("drive")||(e.identifiers??[]).some(t=>t[1].toLowerCase().includes("evse")),a={};for(const e of n){const n=e.entity_id.split(".")[0],i=t.states[e.entity_id],r=i?.attributes?.friendly_name;a[e.entity_id]={domain:n??"",original_name:"string"==typeof r?r:e.entity_id}}y[e.id]={name:e.name_by_user??e.name??"",type:r?"bess":o?"evse":"unknown",entities:a}}const _={serial:v,firmware:c.sw_version??"",panel_size:m,device_id:e,device_name:c.name_by_user??c.name??i("header.default_name"),circuits:f,sub_devices:y};return await Kk(t,_),{topology:_,panelDevice:c,panelSize:m}}(this.hass,this._config.device_id,t);this._topology=e.topology,this._panelDevice=e.panelDevice,this._panelSize=e.panelSize}catch(t){console.error("SPAN Panel: fallback discovery also failed",t),this._errorStore.add({key:"discovery-failed",level:"error",message:i("error.discovery_failed"),persistent:!0,retryFn:()=>{this._errorStore.remove("discovery-failed"),this._startDiscovery()}})}}}async _loadHistory(){if(!this._historyLoaded&&this._topology&&this.hass){this._historyLoaded=!0,await this._ctrl.fetchAndBuildHorizonMaps();try{await this._ctrl.loadHistory(),this._ctrl.updateDOM(this._root)}catch(t){console.warn("SPAN Panel: history fetch failed, charts will populate live",t)}}}_populateCardContent(){const t=this._root.querySelector("#card-content");if(!(t&&this.hass&&this._topology&&this._panelSize))return;const e=this._root.querySelector("#card-tabs");if(e){const t=[{id:"panel",label:i("tab.by_panel"),icon:"mdi:view-dashboard"},{id:"activity",label:i("tab.by_activity"),icon:"mdi:sort-descending"},{id:"area",label:i("tab.by_area"),icon:"mdi:home-group"}];e.innerHTML=(n=t,r=this._activeTab,o=this._config.tab_style??"text",`
${n.map(t=>{const e=t.id===r?" active":"",n=Rt(t.id);return"icon"===o?``:``}).join("")}
`),this._tabBarCleanup&&(this._tabBarCleanup(),this._tabBarCleanup=null),this._tabBarCleanup=function(t,e){const n=t=>{const n=t.target.closest(".shared-tab");if(n){const t=n.dataset.tab;t&&e(t)}};return t.addEventListener("click",n),()=>{t.removeEventListener("click",n)}}(e,t=>{["panel","activity","area"].includes(t)&&(this._activeTab=t,this._listCtrl.stop(),this._populateCardContent())})}var n,r,o;if("panel"===this._activeTab){const e=Math.ceil(this._panelSize/2),n=$t(this._topology,this._config),r=this._ctrl.monitoringCache.status,o=function(t){if(!t)return"";const e=Object.values(t.circuits??{}),n=Object.values(t.mains??{}),r=[...e,...n],o=r.filter(t=>void 0!==t.utilization_pct&&t.utilization_pct>=80&&t.utilization_pct<100).length,a=r.filter(t=>void 0!==t.utilization_pct&&t.utilization_pct>=100).length,s=r.filter(t=>t.has_override).length;return`\n
\n ✓ ${i("status.monitoring")} · ${e.length} ${i("status.circuits")} · ${n.length} ${i("status.mains")}\n \n ${o>0?`${o} ${i(o>1?"status.warnings":"status.warning")}`:""}\n ${a>0?`${a} ${i(a>1?"status.alerts":"status.alert")}`:""}\n ${s>0?`${s} ${i(s>1?"status.overrides":"status.override")}`:""}\n \n
\n `}(r),a=function(t,e,n,i,r){const o=new Map,a=new Set;for(const[e,n]of Object.entries(t.circuits)){const t=n.tabs;if(!t||0===t.length)continue;const i=Math.min(...t),r=1===t.length?"single":Xt(t)??"single";o.set(i,{uuid:e,circuit:n,layout:r});for(const e of t)a.add(e)}const s=new Set,l=new Set;for(const[t,e]of o)if("col-span"===e.layout){const n=e.circuit.tabs,i=qt(Math.max(...n));0===jt(t)?s.add(i):l.add(i)}function c(t){const e=t.circuit.entities?.current??t.circuit.entities?.power,i=r?ee(r,e??""):null;let o;if(t.circuit.always_on)o="always_on";else{const e=t.circuit.entities?.select;o=e&&n.states[e]?n.states[e].state:"unknown"}return{monInfo:i,sheddingPriority:o}}let u="";for(let t=1;t<=e;t++){const e=2*t-1,r=2*t,h=o.get(e),d=o.get(r);if(u+=`
${e}
`,h&&"row-span"===h.layout){const{monInfo:e,sheddingPriority:o}=c(h);u+=ie(h.uuid,h.circuit,t,"2 / 5","row-span",n,i,e,o),u+=`
${r}
`;continue}if(!s.has(t))if(!h||"col-span"!==h.layout&&"single"!==h.layout)a.has(e)||(u+=re(t,"2"));else{const{monInfo:e,sheddingPriority:r}=c(h);u+=ie(h.uuid,h.circuit,t,"2",h.layout,n,i,e,r)}if(!l.has(t))if(!d||"col-span"!==d.layout&&"single"!==d.layout)a.has(r)||(u+=re(t,"4"));else{const{monInfo:e,sheddingPriority:r}=c(d);u+=ie(d.uuid,d.circuit,t,"4",d.layout,n,i,e,r)}u+=`
${r}
`}return u}(this._topology,e,this.hass,this._config,r),s=function(t,e,n){const r=!1!==n.show_battery,o=!1!==n.show_evse;if(!t.sub_devices)return"";const a=Object.entries(t.sub_devices).filter(([,t])=>!(t.type===h&&!r||t.type===d&&!o));if(0===a.length)return"";const s=[];for(const[t,i]of a){const r=ue(i),o=i.type===h,a=o?he(i):null,l=o?de(i):null,c=o?pe(i):null,u=fe(i,e,n,new Set([r,a,l,c].filter(t=>null!==t))),d=ge(t,0,o,r,a,l);(r||d||u)&&s.push({devId:t,sub:i,powerEid:r,chartsHTML:d,entHTML:u})}if(0===s.length)return"";const l=s.filter(t=>t.sub.type===d).length;let c=0,u="";for(const{devId:t,sub:n,powerEid:r,chartsHTML:o,entHTML:a}of s){const s=n.type===d?i("subdevice.ev_charger"):n.type===h?i("subdevice.battery"):i("subdevice.fallback"),p=r?e.states[r]:void 0,f=p&&parseFloat(p.state)||0,g=n.type===h,v=n.type===d;let m="";g?m="sub-device-bess":v&&(c++,c===l&&l%2==1&&(m="sub-device-full")),u+=`\n
\n
\n ${Rt(s)}\n ${Rt(n.name||"")}\n ${r?`${Ut(f)} ${Wt(f)}`:""}\n \n
\n ${o}\n ${a}\n
\n `}return u}(this._topology,this.hass,this._config);t.innerHTML=`\n ${n}\n ${o}\n ${s?`
${s}
`:""}\n ${!1!==this._config.show_panel?`
${a}
`:""}\n `;const l=t.querySelector(".slide-confirm");if(l){const t=this._root.querySelector(".span-card");this._ctrl.bindSlideConfirm(l,t),t&&t.classList.add("switches-disabled")}const c=this._root.querySelector("span-side-panel");c&&(c.hass=this.hass,c.errorStore=this._errorStore),this._ctrl.recordSamples(),this._ctrl.updateDOM(this._root),this._ctrl.setupResizeObserver(this._root,this._root.querySelector(".span-card"))}else if("activity"===this._activeTab){t.innerHTML="";const e=$t(this._topology,this._config);this._listCtrl.setColumns(Bt()),this._listCtrl.renderActivityView(t,this.hass,this._topology,this._config,this._ctrl.monitoringCache.status,e),this._ctrl.updateDOM(this._root)}else if("area"===this._activeTab){t.innerHTML="";const e=$t(this._topology,this._config);this._listCtrl.setColumns(Bt()),this._listCtrl.renderAreaView(t,this.hass,this._topology,this._config,this._ctrl.monitoringCache.status,e),this._ctrl.updateDOM(this._root)}}_onCardClick(t){if("panel"!==this._activeTab)return;const e=t.target;if(!e)return;const n=e.closest(".unit-btn");if(n)return void this._onUnitToggle(n);if(e.closest(".toggle-pill"))return void this._ctrl.onToggleClick(t,this._root);e.closest(".gear-icon")&&this._ctrl.onGearClick(t,this._root)}async _onUnitToggle(t){const e=t.dataset.unit;e&&e!==(this._config.chart_metric??"power")&&(this._config={...this._config,chart_metric:e},this._ctrl.setConfig(this._config),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config},bubbles:!0,composed:!0})),this._ctrl.powerHistory.clear(),this._historyLoaded=!1,this._populateCardContent(),await this._loadHistory(),this._ctrl.updateDOM(this._root))}async _onListUnitChanged(t){const e=t.detail;e&&e!==(this._config.chart_metric??"power")&&(this._config={...this._config,chart_metric:e},this._ctrl.setConfig(this._config),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config},bubbles:!0,composed:!0})),this._ctrl.powerHistory.clear(),this._historyLoaded=!1,this._populateCardContent(),await this._loadHistory(),this._ctrl.updateDOM(this._root))}_onGraphSettingsChanged(){this._ctrl.onGraphSettingsChanged(this._root)}_onListColumnsChanged(t){const e=t.detail;"number"!=typeof e||1!==e&&2!==e&&3!==e||"activity"!==this._activeTab&&"area"!==this._activeTab||this._populateCardContent()}_onSidePanelClosed(){this._ctrl.monitoringCache.invalidate(),this._ctrl.graphSettingsCache.invalidate()}_renderPreview(){const t=uT.map(t=>ht`
${t.name} diff --git a/custom_components/span_panel/frontend/dist/span-panel.js b/custom_components/span_panel/frontend/dist/span-panel.js index cf302770..ff64f65c 100644 --- a/custom_components/span_panel/frontend/dist/span-panel.js +++ b/custom_components/span_panel/frontend/dist/span-panel.js @@ -214,7 +214,7 @@ const Pt={attribute:!0,type:String,converter:H,reflect:!1,hasChanged:F},Lt=(t=Pt height: 24px; fill: currentColor; } - `,_([Et({type:Boolean,reflect:!0})],oe.prototype,"narrow",void 0);try{customElements.get("span-menu-button")||customElements.define("span-menu-button",oe)}catch{}async function ae(t,e){const[n,i,r]=await Promise.all([t.callWS({type:"config/area_registry/list"}),t.callWS({type:"config/entity_registry/list"}),t.callWS({type:"config/device_registry/list"})]),o=new Map;for(const t of n)o.set(t.area_id,t.name);const a=new Map;for(const t of i)t.area_id&&a.set(t.entity_id,t.area_id);const s=new Map;for(const t of r)s.set(t.id,t.area_id);let l;if(e.device_id){const t=s.get(e.device_id);t&&(l=o.get(t))}for(const t of Object.values(e.circuits)){let e;for(const n of Object.values(t.entities)){if(!n)continue;const t=a.get(n);if(t){e=o.get(t);break}}e||(e=l),t.area=e}}async function se(t,e,i){if(!e)throw new Error(n("card.device_not_found"));const r={type:`${s}/panel_topology`,device_id:e},o=i?await i.callWS(t,r,{errorId:"fetch:topology"}):await t.callWS(r),a=o.panel_size??function(t){let e=0;for(const n of Object.values(t))if(n)for(const t of n.tabs)t>e&&(e=t);return e>0?e+e%2:0}(o.circuits);if(!a)throw new Error(n("card.topology_error"));const l={type:"config/device_registry/list"},c=i?await i.callWS(t,l,{errorId:"fetch:topology"}):await t.callWS(l),u=(h=c.find(t=>t.id===e),h?{id:h.id,name:h.name,name_by_user:h.name_by_user,config_entries:h.config_entries,identifiers:h.identifiers,via_device_id:h.via_device_id,sw_version:h.sw_version,model:h.model}:null);var h;return await ae(t,o),{topology:o,panelDevice:u,panelSize:a}}function le(){return`
\n ${Object.entries(v).filter(([t])=>"unknown"!==t).map(([,t])=>{const e=Bt(t.icon),n=Bt(t.color),i=Bt(t.label());let r;if(t.icon2){r=``}else if(t.textLabel){r=`${Bt(t.textLabel)}`}else r=``;return`
${r}${i}
`}).join("")}\n
`}function ce(t,e,i){const r="current"===(e.chart_metric||"power"),o=!!t.panel_entities?.site_power,a=!!t.panel_entities?.dsm_state,s=!!t.panel_entities?.current_power,l=!!t.panel_entities?.feedthrough_power,c=!!t.panel_entities?.pv_power,u=!!t.panel_entities?.battery_level;return`\n
\n ${o?`\n
\n ${n("header.site")}\n
\n 0\n ${r?"A":"kW"}\n
\n
`:""}\n ${a?`\n
\n ${n("header.grid")}\n
\n --\n
\n
`:""}\n ${s?`\n
\n ${n("header.upstream")}\n
\n --\n ${r?"A":"kW"}\n
\n
`:""}\n ${l?`\n
\n ${n("header.downstream")}\n
\n --\n ${r?"A":"kW"}\n
\n
`:""}\n ${c?`\n
\n ${n("header.solar")}\n
\n --\n ${r?"A":"kW"}\n
\n
`:""}\n ${u?`\n
\n ${n("header.battery")}\n
\n —\n %\n
\n
`:""}\n
\n `}function ue(t,e,i={}){const r=Bt(t.device_name||n("header.default_name")),o=Bt(t.serial||""),a=Bt(t.firmware||""),s="current"===(e.chart_metric||"power"),l=!1!==i.showSwitches;return`\n
\n
\n
\n

${r}

\n ${o}\n \n ${l?`
\n ${Bt(n("header.enable_switches"))}\n
\n \n
\n
`:""}\n
\n ${ce(t,e)}\n
\n
\n
\n ${a}\n
\n \n \n
\n
\n ${le()}\n
\n
\n `}const he=f.power;function de(t){return he.unit(t)}function pe(t){return(t<0?"-":"")+he.format(t)}function fe(t){return(Math.abs(t)/1e3).toFixed(1)}function ge(t){return Math.ceil(t/2)}function ve(t){return t%2==0?1:0}function me(t){if(2!==t.length)return null;const[e,n]=[Math.min(...t),Math.max(...t)];return ge(e)===ge(n)?"row-span":ve(e)===ve(n)?"col-span":"row-span"}function ye(t){const e=t.chart_metric??r;return f[e]??f[r]}function _e(t,e){const n=function(t){return ye(t).entityRole}(e);return t.entities?.[n]??t.entities?.power??null}class be{constructor(){this._status=null,this._lastFetch=0,this._inflight=null,this._generation=0,this._errorStore=null,this._retry=null}get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t,this._retry=t?new qt(t):null}async fetch(t,e){const i=Date.now();if(this._inflight&&this._inflight.gen===this._generation)return this._inflight.promise;if(this._status&&i-this._lastFetch<3e4)return this._status;const r=this._generation,o=(async()=>{try{const i={};e&&(i.config_entry_id=e);const o={type:"call_service",domain:s,service:"get_monitoring_status",service_data:i,return_response:!0},a=this._retry?await this._retry.callWS(t,o,{errorId:"fetch:monitoring",errorMessage:n("error.monitoring_failed")}):await t.callWS(o),l=a?.response??null;return r===this._generation&&(this._status=l,this._lastFetch=Date.now()),l}catch(t){return console.warn("SPAN Panel: monitoring status fetch failed",t),r===this._generation&&(this._status=null),this._retry||this._errorStore?.add({key:"fetch:monitoring",level:"warning",message:n("error.monitoring_failed"),persistent:!1}),null}finally{this._inflight?.gen===r&&(this._inflight=null)}})();return this._inflight={gen:r,promise:o},o}invalidate(){this._lastFetch=0,this._generation++}get status(){return this._status}clear(){this._status=null,this._lastFetch=0,this._generation++}}class xe{constructor(){this._caches=new Map,this._errorStore=null}get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t;for(const e of this._caches.values())e.errorStore=t}async fetchOne(t,e){let n=this._caches.get(e);return n||(n=new be,n.errorStore=this._errorStore,this._caches.set(e,n)),n.fetch(t,e)}invalidate(){for(const t of this._caches.values())t.invalidate()}clear(){this._caches.clear()}}function we(t,e){return t?.circuits?t.circuits[e]??null:null}function Se(t,e,n,i){const r=[];return n||r.push("circuit-off"),i&&r.push("circuit-producer"),function(t){return!!t&&null!=t.over_threshold_since}(e)&&r.push("circuit-alert"),r.join(" ")}function Ce(t,e,i,r,o,a,s,u,h,d=!1){const p=e.entities?.power,f=p?a.states[p]:null,g=f&&parseFloat(f.state)||0,m=e.device_type===c||g<0,y=e.entities?.switch,_=y?a.states[y]:null,b=_?"on"===_.state:(f?.attributes?.relay_state||e.relay_state)===l,x=e.breaker_rating_a,w=x?`${Math.round(x)}A`:"",S=Bt(e.name||n("grid.unknown")),C=ye(s);let k;if("current"===C.entityRole){const t=e.entities?.current,n=t?a.states[t]:null,i=n&&parseFloat(n.state)||0;k=`${C.format(i)}A`}else k=`${pe(g)}${de(g)}`;const M=h||"unknown";let T="";if("unknown"!==M){const t=v[M]??v.unknown??{icon:"mdi:help",color:"#999",label:()=>"Unknown"},e=Bt(t.label()),n=Bt(t.icon),i=Bt(t.color);if(t.icon2){T=`\n \n \n `}else if(t.textLabel){T=`\n \n ${Bt(t.textLabel)}\n `}else T=``}const I=``;let D="",A=u?.utilization_pct??null;if(null==A&&e.breaker_rating_a){const t=e.entities?.current,n=t?a.states[t]:null,i=n?Math.abs(parseFloat(n.state)||0):0;A=Math.round(i/e.breaker_rating_a*1e3)/10}if(null!=A){D=`=80?"utilization-warning":"utilization-normal"}">${Math.round(A)}%`}return`\n
\n
\n
\n ${w?`${w}`:""}\n ${D}\n ${S}\n
\n
\n \n ${k}\n \n ${!1!==e.is_user_controllable&&e.entities?.switch?`\n
\n ${n(b?"grid.on":"grid.off")}\n \n
\n `:""}\n
\n
\n
\n ${T}\n ${I}\n
\n
\n
\n `}function ke(t,e){return`\n
\n —\n
\n `}const Me={names:["power","battery power"],suffixes:["_power"]},Te={names:["battery level","battery percentage"],suffixes:["_battery_level","_battery_percentage"]},Ie={names:["state of energy"],suffixes:["_soe_kwh"]},De={names:["nameplate capacity"],suffixes:["_nameplate_capacity"]};function Ae(t,e){if(!t.entities)return null;for(const[n,i]of Object.entries(t.entities)){if("sensor"!==i.domain)continue;const t=(i.original_name??"").toLowerCase();if(e.names.some(e=>t===e))return n;if(i.unique_id&&e.suffixes.some(t=>i.unique_id.endsWith(t)))return n}return null}function Pe(t){return Ae(t,Me)}function Le(t){return Ae(t,Te)}function Ee(t){return Ae(t,Ie)}function ze(t){return Ae(t,De)}function Ne(t,e,i){const r=!1!==i.show_battery,o=!1!==i.show_evse;if(!t.sub_devices)return"";const a=Object.entries(t.sub_devices).filter(([,t])=>!(t.type===u&&!r)&&!(t.type===h&&!o));if(0===a.length)return"";const s=a.filter(([,t])=>t.type===h).length;let l=0,c="";for(const[t,r]of a){const o=r.type===h?n("subdevice.ev_charger"):r.type===u?n("subdevice.battery"):n("subdevice.fallback"),a=Pe(r),d=a?e.states[a]:void 0,p=d&&parseFloat(d.state)||0,f=r.type===u,g=r.type===h,v=f?Le(r):null,m=f?Ee(r):null,y=f?ze(r):null,_=Oe(r,e,i,new Set([a,v,m,y].filter(t=>null!==t))),b=Re(t,r,f,a,v,m);let x="";f?x="sub-device-bess":g&&(l++,l===s&&s%2==1&&(x="sub-device-full")),c+=`\n
\n
\n ${Bt(o)}\n ${Bt(r.name||"")}\n ${a?`${pe(p)} ${de(p)}`:""}\n \n
\n ${b}\n ${_}\n
\n `}return c}function Oe(t,e,n,i){const r=n.visible_sub_entities||{};let o="";if(!t.entities)return o;for(const[n,a]of Object.entries(t.entities)){if(i.has(n))continue;if(!0!==r[n])continue;const s=e.states[n];if(!s)continue;let l=a.original_name||s.attributes.friendly_name||n;const c=t.name||"";let u;if(l.startsWith(c+" ")&&(l=l.slice(c.length+1)),e.formatEntityState)u=e.formatEntityState(s);else{u=s.state;const t=s.attributes.unit_of_measurement||"";t&&(u+=" "+t)}if("Wh"===(s.attributes.unit_of_measurement||"")){const t=parseFloat(s.state);isNaN(t)||(u=(t/1e3).toFixed(1)+" kWh")}o+=`\n
\n ${Bt(l)}:\n ${Bt(u)}\n
\n `}return o}function Re(t,e,i,r,o,a){if(i){const e=[{key:`${d}${t}_soc`,title:n("subdevice.soc"),available:!!o},{key:`${d}${t}_soe`,title:n("subdevice.soe"),available:!!a},{key:`${d}${t}_power`,title:n("subdevice.power"),available:!!r}].filter(t=>t.available);return`\n
\n ${e.map(t=>`\n
\n
${Bt(t.title)}
\n
\n
\n `).join("")}\n
\n `}return r?`
`:""}function $e(t){const e=void 0!==t.history_days||void 0!==t.history_hours||void 0!==t.history_minutes,n=60*(60*(24*(e&&parseInt(String(t.history_days))||0)+(e&&parseInt(String(t.history_hours))||0))+(e?parseInt(String(t.history_minutes))||0:5))*1e3;return Math.max(n,6e4)}function He(t){const e=a[t];return e?e.ms:a[o].ms}function Fe(t){const e=t/1e3;return e<=600?Math.ceil(e):Math.min(5e3,Math.ceil(e/5))}function Be(t){return Math.max(500,Math.floor(t/5e3))}function Ve(t,e,n,i,r,o){t.has(e)||t.set(e,[]);const a=t.get(e);a.push({time:i,value:n});const s=a.findIndex(t=>t.time>=r);s>0?a.splice(0,s):-1===s&&(a.length=0),a.length>o&&a.splice(0,a.length-o)}function We(t,e,n=500){if(0===t.length)return t;t.sort((t,e)=>t.time-e.time);const i=[t[0]];for(let e=1;e=n&&i.push(t[e]);return i.length>e&&i.splice(0,i.length-e),i}async function Ue(t,e,n,i,r){const o=new Date(Date.now()-i).toISOString(),a=i/36e5>72?"hour":"5minute",s=await t.callWS({type:"recorder/statistics_during_period",start_time:o,statistic_ids:e,period:a,types:["mean"]});for(const[t,e]of Object.entries(s)){const i=n.get(t);if(!i||!e)continue;const o=[];for(const t of e){const e=t.mean;if(null==e||!Number.isFinite(e))continue;const n=t.start;n>0&&o.push({time:n,value:e})}if(o.length>0){const t=r.get(i)||[],e=[...o,...t];e.sort((t,e)=>t.time-e.time),r.set(i,e)}}}async function Ge(t,e,n,i,r){const o=new Date(Date.now()-i).toISOString(),a=await t.callWS({type:"history/history_during_period",start_time:o,entity_ids:e,minimal_response:!0,significant_changes_only:!0,no_attributes:!0}),s=Fe(i),l=Be(i);for(const[t,e]of Object.entries(a)){const i=n.get(t);if(!i||!e)continue;const o=[];for(const t of e){const e=parseFloat(t.s);if(!Number.isFinite(e))continue;const n=1e3*(t.lu||t.lc||0);n>0&&o.push({time:n,value:e})}if(o.length>0){const t=r.get(i)||[],e=[...o,...t];r.set(i,We(e,s,l))}}}function qe(t){if(!t.sub_devices)return[];const e=[];for(const[n,i]of Object.entries(t.sub_devices)){const t={power:Pe(i)};i.type===u&&(t.soc=Le(i),t.soe=Ee(i));for(const[i,r]of Object.entries(t))r&&e.push({entityId:r,key:`${d}${n}_${i}`,devId:n})}return e}async function je(t,e,n,i,r,o){if(!e||!t)return;const a=new Map;for(const[t,i]of Object.entries(e.circuits)){const e=_e(i,n);if(!e)continue;let o;o=r&&r.has(t)?He(r.get(t)):$e(n),a.has(o)||a.set(o,{entityIds:[],uuidByEntity:new Map});const s=a.get(o);s.entityIds.push(e),s.uuidByEntity.set(e,t)}for(const{entityId:t,key:i,devId:r}of qe(e)){let e;e=o&&o.has(r)?He(o.get(r)):$e(n),a.has(e)||a.set(e,{entityIds:[],uuidByEntity:new Map});const s=a.get(e);s.entityIds.push(t),s.uuidByEntity.set(t,i)}const s=[];for(const[e,n]of a){if(0===n.entityIds.length)continue;e>2592e5?s.push(Ue(t,n.entityIds,n.uuidByEntity,e,i)):s.push(Ge(t,n.entityIds,n.uuidByEntity,e,i))}await Promise.all(s)}var Xe=function(){this.firefox=!1,this.ie=!1,this.edge=!1,this.newEdge=!1,this.weChat=!1},Ye=new function(){this.browser=new Xe,this.node=!1,this.wxa=!1,this.worker=!1,this.svgSupported=!1,this.touchEventsSupported=!1,this.pointerEventsSupported=!1,this.domSupported=!1,this.transformSupported=!1,this.transform3dSupported=!1,this.hasGlobalWindow="undefined"!=typeof window};"object"==typeof wx&&"function"==typeof wx.getSystemInfoSync?(Ye.wxa=!0,Ye.touchEventsSupported=!0):"undefined"==typeof document&&"undefined"!=typeof self?Ye.worker=!0:!Ye.hasGlobalWindow||"Deno"in window||"undefined"!=typeof navigator&&"string"==typeof navigator.userAgent&&navigator.userAgent.indexOf("Node.js")>-1?(Ye.node=!0,Ye.svgSupported=!0):function(t,e){var n=e.browser,i=t.match(/Firefox\/([\d.]+)/),r=t.match(/MSIE\s([\d.]+)/)||t.match(/Trident\/.+?rv:(([\d.]+))/),o=t.match(/Edge?\/([\d.]+)/),a=/micromessenger/i.test(t);i&&(n.firefox=!0,n.version=i[1]);r&&(n.ie=!0,n.version=r[1]);o&&(n.edge=!0,n.version=o[1],n.newEdge=+o[1].split(".")[0]>18);a&&(n.weChat=!0);e.svgSupported="undefined"!=typeof SVGRect,e.touchEventsSupported="ontouchstart"in window&&!n.ie&&!n.edge,e.pointerEventsSupported="onpointerdown"in window&&(n.edge||n.ie&&+n.version>=11);var s=e.domSupported="undefined"!=typeof document;if(s){var l=document.documentElement.style;e.transform3dSupported=(n.ie&&"transition"in l||n.edge||"WebKitCSSMatrix"in window&&"m11"in new WebKitCSSMatrix||"MozPerspective"in l)&&!("OTransition"in l),e.transformSupported=e.transform3dSupported||n.ie&&+n.version>=9}}(navigator.userAgent,Ye);var Ze="sans-serif",Ke="12px "+Ze;var Qe,Je,tn=function(t){var e={};if("undefined"==typeof JSON)return e;for(var n=0;n=0)o=r*t.length;else for(var a=0;a>1)%2;a.style.cssText=["position: absolute","visibility: hidden","padding: 0","margin: 0","border-width: 0","user-select: none","width:0","height:0",i[s]+":0",r[l]+":0",i[1-s]+":auto",r[1-l]+":auto",""].join("!important;"),t.appendChild(a),n.push(a)}return e.clearMarkers=function(){Cn(n,function(t){t.parentNode&&t.parentNode.removeChild(t)})},n}(e,o),s=function(t,e,n){for(var i=n?"invTrans":"trans",r=e[i],o=e.srcCoords,a=[],s=[],l=!0,c=0;c<4;c++){var u=t[c].getBoundingClientRect(),h=2*c,d=u.left,p=u.top;a.push(d,p),l=l&&o&&d===o[h]&&p===o[h+1],s.push(t[c].offsetLeft,t[c].offsetTop)}return l&&r?r:(e.srcCoords=a,e[i]=n?Si(s,a):Si(a,s))}(a,o,r);if(s)return s(t,n,i),!0}return!1}function Ti(t){return"CANVAS"===t.nodeName.toUpperCase()}var Ii=/([&<>"'])/g,Di={"&":"&","<":"<",">":">",'"':""","'":"'"};function Ai(t){return null==t?"":(t+"").replace(Ii,function(t,e){return Di[e]})}var Pi=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,Li=[],Ei=Ye.browser.firefox&&+Ye.browser.version.split(".")[0]<39;function zi(t,e,n,i){return n=n||{},i?Ni(t,e,n):Ei&&null!=e.layerX&&e.layerX!==e.offsetX?(n.zrX=e.layerX,n.zrY=e.layerY):null!=e.offsetX?(n.zrX=e.offsetX,n.zrY=e.offsetY):Ni(t,e,n),n}function Ni(t,e,n){if(Ye.domSupported&&t.getBoundingClientRect){var i=e.clientX,r=e.clientY;if(Ti(t)){var o=t.getBoundingClientRect();return n.zrX=i-o.left,void(n.zrY=r-o.top)}if(Mi(Li,t,i,r))return n.zrX=Li[0],void(n.zrY=Li[1])}n.zrX=n.zrY=0}function Oi(t){return t||window.event}function Ri(t,e,n){if(null!=(e=Oi(e)).zrX)return e;var i=e.type;if(i&&i.indexOf("touch")>=0){var r="touchend"!==i?e.targetTouches[0]:e.changedTouches[0];r&&zi(t,r,e,n)}else{zi(t,e,e,n);var o=function(t){var e=t.wheelDelta;if(e)return e;var n=t.deltaX,i=t.deltaY;if(null==n||null==i)return e;return 3*(0!==i?Math.abs(i):Math.abs(n))*(i>0?-1:i<0?1:n>0?-1:1)}(e);e.zrDelta=o?o/120:-(e.detail||0)/3}var a=e.button;return null==e.which&&void 0!==a&&Pi.test(e.type)&&(e.which=1&a?1:2&a?3:4&a?2:0),e}function $i(t,e,n,i){t.removeEventListener(e,n,i)}var Hi=function(t){t.preventDefault(),t.stopPropagation(),t.cancelBubble=!0},Fi=function(){function t(){this._track=[]}return t.prototype.recognize=function(t,e,n){return this._doTrack(t,e,n),this._recognize(t)},t.prototype.clear=function(){return this._track.length=0,this},t.prototype._doTrack=function(t,e,n){var i=t.touches;if(i){for(var r={points:[],touches:[],target:e,event:t},o=0,a=i.length;o1&&r&&r.length>1){var a=Bi(r)/Bi(o);!isFinite(a)&&(a=1),e.pinchScale=a;var s=[((i=r)[0][0]+i[1][0])/2,(i[0][1]+i[1][1])/2];return e.pinchX=s[0],e.pinchY=s[1],{type:"pinch",target:t[0].target,event:e}}}}};function Wi(t){return t[0]=1,t[1]=0,t[2]=0,t[3]=1,t[4]=0,t[5]=0,t}function Ui(t,e){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t[4]=e[4],t[5]=e[5],t}function Gi(t,e,n){var i=e[0]*n[0]+e[2]*n[1],r=e[1]*n[0]+e[3]*n[1],o=e[0]*n[2]+e[2]*n[3],a=e[1]*n[2]+e[3]*n[3],s=e[0]*n[4]+e[2]*n[5]+e[4],l=e[1]*n[4]+e[3]*n[5]+e[5];return t[0]=i,t[1]=r,t[2]=o,t[3]=a,t[4]=s,t[5]=l,t}function qi(t,e,n){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t[4]=e[4]+n[0],t[5]=e[5]+n[1],t}function ji(t,e,n,i){void 0===i&&(i=[0,0]);var r=e[0],o=e[2],a=e[4],s=e[1],l=e[3],c=e[5],u=Math.sin(n),h=Math.cos(n);return t[0]=r*h+s*u,t[1]=-r*u+s*h,t[2]=o*h+l*u,t[3]=-o*u+h*l,t[4]=h*(a-i[0])+u*(c-i[1])+i[0],t[5]=h*(c-i[1])-u*(a-i[0])+i[1],t}function Xi(t,e){var n=e[0],i=e[2],r=e[4],o=e[1],a=e[3],s=e[5],l=n*a-o*i;return l?(l=1/l,t[0]=a*l,t[1]=-o*l,t[2]=-i*l,t[3]=n*l,t[4]=(i*s-a*r)*l,t[5]=(o*r-n*s)*l,t):null}var Yi=function(){function t(t,e){this.x=t||0,this.y=e||0}return t.prototype.copy=function(t){return this.x=t.x,this.y=t.y,this},t.prototype.clone=function(){return new t(this.x,this.y)},t.prototype.set=function(t,e){return this.x=t,this.y=e,this},t.prototype.equal=function(t){return t.x===this.x&&t.y===this.y},t.prototype.add=function(t){return this.x+=t.x,this.y+=t.y,this},t.prototype.scale=function(t){this.x*=t,this.y*=t},t.prototype.scaleAndAdd=function(t,e){this.x+=t.x*e,this.y+=t.y*e},t.prototype.sub=function(t){return this.x-=t.x,this.y-=t.y,this},t.prototype.dot=function(t){return this.x*t.x+this.y*t.y},t.prototype.len=function(){return Math.sqrt(this.x*this.x+this.y*this.y)},t.prototype.lenSquare=function(){return this.x*this.x+this.y*this.y},t.prototype.normalize=function(){var t=this.len();return this.x/=t,this.y/=t,this},t.prototype.distance=function(t){var e=this.x-t.x,n=this.y-t.y;return Math.sqrt(e*e+n*n)},t.prototype.distanceSquare=function(t){var e=this.x-t.x,n=this.y-t.y;return e*e+n*n},t.prototype.negate=function(){return this.x=-this.x,this.y=-this.y,this},t.prototype.transform=function(t){if(t){var e=this.x,n=this.y;return this.x=t[0]*e+t[2]*n+t[4],this.y=t[1]*e+t[3]*n+t[5],this}},t.prototype.toArray=function(t){return t[0]=this.x,t[1]=this.y,t},t.prototype.fromArray=function(t){this.x=t[0],this.y=t[1]},t.set=function(t,e,n){t.x=e,t.y=n},t.copy=function(t,e){t.x=e.x,t.y=e.y},t.len=function(t){return Math.sqrt(t.x*t.x+t.y*t.y)},t.lenSquare=function(t){return t.x*t.x+t.y*t.y},t.dot=function(t,e){return t.x*e.x+t.y*e.y},t.add=function(t,e,n){t.x=e.x+n.x,t.y=e.y+n.y},t.sub=function(t,e,n){t.x=e.x-n.x,t.y=e.y-n.y},t.scale=function(t,e,n){t.x=e.x*n,t.y=e.y*n},t.scaleAndAdd=function(t,e,n,i){t.x=e.x+n.x*i,t.y=e.y+n.y*i},t.lerp=function(t,e,n,i){var r=1-i;t.x=r*e.x+i*n.x,t.y=r*e.y+i*n.y},t}(),Zi=Math.min,Ki=Math.max,Qi=Math.abs,Ji=["x","y"],tr=["width","height"],er=new Yi,nr=new Yi,ir=new Yi,rr=new Yi,or=pr(),ar=or.minTv,sr=or.maxTv,lr=[0,0],cr=function(){function t(e,n,i,r){t.set(this,e,n,i,r)}return t.set=function(t,e,n,i,r){return i<0&&(e+=i,i=-i),r<0&&(n+=r,r=-r),t.x=e,t.y=n,t.width=i,t.height=r,t},t.prototype.union=function(t){var e=Zi(t.x,this.x),n=Zi(t.y,this.y);isFinite(this.x)&&isFinite(this.width)?this.width=Ki(t.x+t.width,this.x+this.width)-e:this.width=t.width,isFinite(this.y)&&isFinite(this.height)?this.height=Ki(t.y+t.height,this.y+this.height)-n:this.height=t.height,this.x=e,this.y=n},t.prototype.applyTransform=function(e){t.applyTransform(this,this,e)},t.prototype.calculateTransform=function(t){var e=this,n=t.width/e.width,i=t.height/e.height,r=[1,0,0,1,0,0];return qi(r,r,[-e.x,-e.y]),function(t,e,n){var i=n[0],r=n[1];t[0]=e[0]*i,t[1]=e[1]*r,t[2]=e[2]*i,t[3]=e[3]*r,t[4]=e[4]*i,t[5]=e[5]*r}(r,r,[n,i]),qi(r,r,[t.x,t.y]),r},t.prototype.intersect=function(e,n,i){return t.intersect(this,e,n,i)},t.intersect=function(e,n,i,r){i&&Yi.set(i,0,0);var o=r&&r.outIntersectRect||null,a=r&&r.clamp;if(o&&(o.x=o.y=o.width=o.height=NaN),!e||!n)return!1;e instanceof t||(e=t.set(ur,e.x,e.y,e.width,e.height)),n instanceof t||(n=t.set(hr,n.x,n.y,n.width,n.height));var s=!!i;or.reset(r,s);var l=or.touchThreshold,c=e.x+l,u=e.x+e.width-l,h=e.y+l,d=e.y+e.height-l,p=n.x+l,f=n.x+n.width-l,g=n.y+l,v=n.y+n.height-l;if(c>u||h>d||p>f||g>v)return!1;var m=!(u=t.x&&e<=t.x+t.width&&n>=t.y&&n<=t.y+t.height},t.prototype.contain=function(e,n){return t.contain(this,e,n)},t.prototype.clone=function(){return new t(this.x,this.y,this.width,this.height)},t.prototype.copy=function(e){t.copy(this,e)},t.prototype.plain=function(){return{x:this.x,y:this.y,width:this.width,height:this.height}},t.prototype.isFinite=function(){return isFinite(this.x)&&isFinite(this.y)&&isFinite(this.width)&&isFinite(this.height)},t.prototype.isZero=function(){return 0===this.width||0===this.height},t.create=function(e){return new t(e.x,e.y,e.width,e.height)},t.copy=function(t,e){return t.x=e.x,t.y=e.y,t.width=e.width,t.height=e.height,t},t.applyTransform=function(e,n,i){if(i){if(i[1]<1e-5&&i[1]>-1e-5&&i[2]<1e-5&&i[2]>-1e-5){var r=i[0],o=i[3],a=i[4],s=i[5];return e.x=n.x*r+a,e.y=n.y*o+s,e.width=n.width*r,e.height=n.height*o,e.width<0&&(e.x+=e.width,e.width=-e.width),void(e.height<0&&(e.y+=e.height,e.height=-e.height))}er.x=ir.x=n.x,er.y=rr.y=n.y,nr.x=rr.x=n.x+n.width,nr.y=ir.y=n.y+n.height,er.transform(i),rr.transform(i),nr.transform(i),ir.transform(i),e.x=Zi(er.x,nr.x,ir.x,rr.x),e.y=Zi(er.y,nr.y,ir.y,rr.y);var l=Ki(er.x,nr.x,ir.x,rr.x),c=Ki(er.y,nr.y,ir.y,rr.y);e.width=l-e.x,e.height=c-e.y}else e!==n&&t.copy(e,n)},t}(),ur=new cr(0,0,0,0),hr=new cr(0,0,0,0);function dr(t,e,n,i,r,o,a,s){var l=Qi(e-n),c=Qi(i-t),u=Zi(l,c),h=Ji[r],d=Ji[1-r],p=tr[r];e=c||!or.bidirectional)&&(ar[h]=-c,ar[d]=0,or.useDir&&or.calcDirMTV())))}function pr(){var t=0,e=new Yi,n=new Yi,i={minTv:new Yi,maxTv:new Yi,useDir:!1,dirMinTv:new Yi,touchThreshold:0,bidirectional:!0,negativeSize:!1,reset:function(r,o){i.touchThreshold=0,r&&null!=r.touchThreshold&&(i.touchThreshold=Ki(0,r.touchThreshold)),i.negativeSize=!1,o&&(i.minTv.set(1/0,1/0),i.maxTv.set(0,0),i.useDir=!1,r&&null!=r.direction&&(i.useDir=!0,i.dirMinTv.copy(i.minTv),n.copy(i.minTv),t=r.direction,i.bidirectional=null==r.bidirectional||!!r.bidirectional,i.bidirectional||e.set(Math.cos(t),Math.sin(t))))},calcDirMTV:function(){var o=i.minTv,a=i.dirMinTv,s=o.y*o.y+o.x*o.x,l=Math.sin(t),c=Math.cos(t),u=l*o.y+c*o.x;r(u)?r(o.x)&&r(o.y)&&a.set(0,0):(n.x=s*c/u,n.y=s*l/u,r(n.x)&&r(n.y)?a.set(0,0):(i.bidirectional||e.dot(n)>0)&&n.len()=0;c--){var u=i[c];u===n||u.ignore||u.ignoreCoarsePointer||u.parent&&u.parent.ignoreCoarsePointer||(_r.copy(u.getBoundingRect()),u.transform&&_r.applyTransform(u.transform),_r.intersect(l)&&o.push(u))}if(o.length)for(var h=Math.PI/12,d=2*Math.PI,p=0;p=0;o--){var a=t[o],s=void 0;if(a!==r&&!a.ignore&&(s=xr(a,n,i))&&(!e.topTarget&&(e.topTarget=a),s!==fr)){e.target=a;break}}}function Sr(t,e,n){var i=t.painter;return e<0||e>i.getWidth()||n<0||n>i.getHeight()}Cn(["click","mousedown","mouseup","mousewheel","dblclick","contextmenu"],function(t){br.prototype[t]=function(e){var n,i,r=e.zrX,o=e.zrY,a=Sr(this,r,o);if("mouseup"===t&&a||(i=(n=this.findHover(r,o)).target),"mousedown"===t)this._downEl=i,this._downPoint=[e.zrX,e.zrY],this._upEl=i;else if("mouseup"===t)this._upEl=i;else if("click"===t){if(this._downEl!==this._upEl||!this._downPoint||pi(this._downPoint,[e.zrX,e.zrY])>4)return;this._downPoint=null}this.dispatchToElement(n,t,e)}});function Cr(t,e,n,i){var r=e+1;if(r===n)return 1;if(i(t[r++],t[e])<0){for(;r=0;)r++;return r-e}function kr(t,e,n,i,r){for(i===e&&i++;i>>1])<0?l=o:s=o+1;var c=i-s;switch(c){case 3:t[s+3]=t[s+2];case 2:t[s+2]=t[s+1];case 1:t[s+1]=t[s];break;default:for(;c>0;)t[s+c]=t[s+c-1],c--}t[s]=a}}function Mr(t,e,n,i,r,o){var a=0,s=0,l=1;if(o(t,e[n+r])>0){for(s=i-r;l0;)a=l,(l=1+(l<<1))<=0&&(l=s);l>s&&(l=s),a+=r,l+=r}else{for(s=r+1;ls&&(l=s);var c=a;a=r-l,l=r-c}for(a++;a>>1);o(t,e[n+u])>0?a=u+1:l=u}return l}function Tr(t,e,n,i,r,o){var a=0,s=0,l=1;if(o(t,e[n+r])<0){for(s=r+1;ls&&(l=s);var c=a;a=r-l,l=r-c}else{for(s=i-r;l=0;)a=l,(l=1+(l<<1))<=0&&(l=s);l>s&&(l=s),a+=r,l+=r}for(a++;a>>1);o(t,e[n+u])<0?l=u:a=u+1}return l}function Ir(t,e){var n,i,r=7,o=0,a=[];function s(s){var l=n[s],c=i[s],u=n[s+1],h=i[s+1];i[s]=c+h,s===o-3&&(n[s+1]=n[s+2],i[s+1]=i[s+2]),o--;var d=Tr(t[u],t,l,c,0,e);l+=d,0!==(c-=d)&&0!==(h=Mr(t[l+c-1],t,u,h,h-1,e))&&(c<=h?function(n,i,o,s){var l=0;for(l=0;l=7||p>=7);if(f)break;g<0&&(g=0),g+=2}if((r=g)<1&&(r=1),1===i){for(l=0;l=0;l--)t[p+l]=t[d+l];return void(t[h]=a[u])}var f=r;for(;;){var g=0,v=0,m=!1;do{if(e(a[u],t[c])<0){if(t[h--]=t[c--],g++,v=0,0===--i){m=!0;break}}else if(t[h--]=a[u--],v++,g=0,1===--s){m=!0;break}}while((g|v)=0;l--)t[p+l]=t[d+l];if(0===i){m=!0;break}}if(t[h--]=a[u--],1===--s){m=!0;break}if(0!==(v=s-Mr(t[c],a,0,s,s-1,e))){for(s-=v,p=(h-=v)+1,d=(u-=v)+1,l=0;l=7||v>=7);if(m)break;f<0&&(f=0),f+=2}(r=f)<1&&(r=1);if(1===s){for(p=(h-=i)+1,d=(c-=i)+1,l=i-1;l>=0;l--)t[p+l]=t[d+l];t[h]=a[u]}else{if(0===s)throw new Error;for(d=h-(s-1),l=0;l1;){var t=o-2;if(t>=1&&i[t-1]<=i[t]+i[t+1]||t>=2&&i[t-2]<=i[t]+i[t-1])i[t-1]i[t+1])break;s(t)}},forceMergeRuns:function(){for(;o>1;){var t=o-2;t>0&&i[t-1]=32;)e|=1&t,t>>=1;return t+e}(r);do{if((o=Cr(t,n,i,e))s&&(l=s),kr(t,n,n+l,n+o,e),o=l}a.pushRun(n,o),a.mergeRuns(),r-=o,n+=o}while(0!==r);a.forceMergeRuns()}}}var Ar=!1;function Pr(){Ar||(Ar=!0,console.warn("z / z2 / zlevel of displayable is invalid, which may cause unexpected errors"))}function Lr(t,e){return t.zlevel===e.zlevel?t.z===e.z?t.z2-e.z2:t.z-e.z:t.zlevel-e.zlevel}var Er,zr=function(){function t(){this._roots=[],this._displayList=[],this._displayListLen=0,this.displayableSortFunc=Lr}return t.prototype.traverse=function(t,e){for(var n=0;n=0&&this._roots.splice(i,1)}},t.prototype.delAllRoots=function(){this._roots=[],this._displayList=[],this._displayListLen=0},t.prototype.getRoots=function(){return this._roots},t.prototype.dispose=function(){this._displayList=null,this._roots=null},t}();Er=Ye.hasGlobalWindow&&(window.requestAnimationFrame&&window.requestAnimationFrame.bind(window)||window.msRequestAnimationFrame&&window.msRequestAnimationFrame.bind(window)||window.mozRequestAnimationFrame||window.webkitRequestAnimationFrame)||function(t){return setTimeout(t,16)};var Nr={linear:function(t){return t},quadraticIn:function(t){return t*t},quadraticOut:function(t){return t*(2-t)},quadraticInOut:function(t){return(t*=2)<1?.5*t*t:-.5*(--t*(t-2)-1)},cubicIn:function(t){return t*t*t},cubicOut:function(t){return--t*t*t+1},cubicInOut:function(t){return(t*=2)<1?.5*t*t*t:.5*((t-=2)*t*t+2)},quarticIn:function(t){return t*t*t*t},quarticOut:function(t){return 1- --t*t*t*t},quarticInOut:function(t){return(t*=2)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2)},quinticIn:function(t){return t*t*t*t*t},quinticOut:function(t){return--t*t*t*t*t+1},quinticInOut:function(t){return(t*=2)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2)},sinusoidalIn:function(t){return 1-Math.cos(t*Math.PI/2)},sinusoidalOut:function(t){return Math.sin(t*Math.PI/2)},sinusoidalInOut:function(t){return.5*(1-Math.cos(Math.PI*t))},exponentialIn:function(t){return 0===t?0:Math.pow(1024,t-1)},exponentialOut:function(t){return 1===t?1:1-Math.pow(2,-10*t)},exponentialInOut:function(t){return 0===t?0:1===t?1:(t*=2)<1?.5*Math.pow(1024,t-1):.5*(2-Math.pow(2,-10*(t-1)))},circularIn:function(t){return 1-Math.sqrt(1-t*t)},circularOut:function(t){return Math.sqrt(1- --t*t)},circularInOut:function(t){return(t*=2)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1)},elasticIn:function(t){var e,n=.1;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=.4*Math.asin(1/n)/(2*Math.PI),-n*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/.4))},elasticOut:function(t){var e,n=.1;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=.4*Math.asin(1/n)/(2*Math.PI),n*Math.pow(2,-10*t)*Math.sin((t-e)*(2*Math.PI)/.4)+1)},elasticInOut:function(t){var e,n=.1,i=.4;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=i*Math.asin(1/n)/(2*Math.PI),(t*=2)<1?n*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/i)*-.5:n*Math.pow(2,-10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/i)*.5+1)},backIn:function(t){var e=1.70158;return t*t*((e+1)*t-e)},backOut:function(t){var e=1.70158;return--t*t*((e+1)*t+e)+1},backInOut:function(t){var e=2.5949095;return(t*=2)<1?t*t*((e+1)*t-e)*.5:.5*((t-=2)*t*((e+1)*t+e)+2)},bounceIn:function(t){return 1-Nr.bounceOut(1-t)},bounceOut:function(t){return t<1/2.75?7.5625*t*t:t<2/2.75?7.5625*(t-=1.5/2.75)*t+.75:t<2.5/2.75?7.5625*(t-=2.25/2.75)*t+.9375:7.5625*(t-=2.625/2.75)*t+.984375},bounceInOut:function(t){return t<.5?.5*Nr.bounceIn(2*t):.5*Nr.bounceOut(2*t-1)+.5}},Or=Math.pow,Rr=Math.sqrt,$r=1e-8,Hr=1e-4,Fr=Rr(3),Br=1/3,Vr=ai(),Wr=ai(),Ur=ai();function Gr(t){return t>-1e-8&&t<$r}function qr(t){return t>$r||t<-1e-8}function jr(t,e,n,i,r){var o=1-r;return o*o*(o*t+3*r*e)+r*r*(r*i+3*o*n)}function Xr(t,e,n,i,r){var o=1-r;return 3*(((e-t)*o+2*(n-e)*r)*o+(i-n)*r*r)}function Yr(t,e,n,i,r,o){var a=i+3*(e-n)-t,s=3*(n-2*e+t),l=3*(e-t),c=t-r,u=s*s-3*a*l,h=s*l-9*a*c,d=l*l-3*s*c,p=0;if(Gr(u)&&Gr(h)){if(Gr(s))o[0]=0;else(C=-l/s)>=0&&C<=1&&(o[p++]=C)}else{var f=h*h-4*u*d;if(Gr(f)){var g=h/u,v=-g/2;(C=-s/a+g)>=0&&C<=1&&(o[p++]=C),v>=0&&v<=1&&(o[p++]=v)}else if(f>0){var m=Rr(f),y=u*s+1.5*a*(-h+m),_=u*s+1.5*a*(-h-m);(C=(-s-((y=y<0?-Or(-y,Br):Or(y,Br))+(_=_<0?-Or(-_,Br):Or(_,Br))))/(3*a))>=0&&C<=1&&(o[p++]=C)}else{var b=(2*u*s-3*a*h)/(2*Rr(u*u*u)),x=Math.acos(b)/3,w=Rr(u),S=Math.cos(x),C=(-s-2*w*S)/(3*a),k=(v=(-s+w*(S+Fr*Math.sin(x)))/(3*a),(-s+w*(S-Fr*Math.sin(x)))/(3*a));C>=0&&C<=1&&(o[p++]=C),v>=0&&v<=1&&(o[p++]=v),k>=0&&k<=1&&(o[p++]=k)}}return p}function Zr(t,e,n,i,r){var o=6*n-12*e+6*t,a=9*e+3*i-3*t-9*n,s=3*e-3*t,l=0;if(Gr(a)){if(qr(o))(u=-s/o)>=0&&u<=1&&(r[l++]=u)}else{var c=o*o-4*a*s;if(Gr(c))r[0]=-o/(2*a);else if(c>0){var u,h=Rr(c),d=(-o-h)/(2*a);(u=(-o+h)/(2*a))>=0&&u<=1&&(r[l++]=u),d>=0&&d<=1&&(r[l++]=d)}}return l}function Kr(t,e,n,i,r,o){var a=(e-t)*r+t,s=(n-e)*r+e,l=(i-n)*r+n,c=(s-a)*r+a,u=(l-s)*r+s,h=(u-c)*r+c;o[0]=t,o[1]=a,o[2]=c,o[3]=h,o[4]=h,o[5]=u,o[6]=l,o[7]=i}function Qr(t,e,n,i,r,o,a,s,l){for(var c=t,u=e,h=0,d=1/l,p=1;p<=l;p++){var f=p*d,g=jr(t,n,r,a,f),v=jr(e,i,o,s,f),m=g-c,y=v-u;h+=Math.sqrt(m*m+y*y),c=g,u=v}return h}function Jr(t,e,n,i){var r=1-i;return r*(r*t+2*i*e)+i*i*n}function to(t,e,n,i){return 2*((1-i)*(e-t)+i*(n-e))}function eo(t,e,n){var i=t+n-2*e;return 0===i?.5:(t-e)/i}function no(t,e,n,i,r){var o=(e-t)*i+t,a=(n-e)*i+e,s=(a-o)*i+o;r[0]=t,r[1]=o,r[2]=s,r[3]=s,r[4]=a,r[5]=n}function io(t,e,n,i,r,o,a){for(var s=t,l=e,c=0,u=1/a,h=1;h<=a;h++){var d=h*u,p=Jr(t,n,r,d),f=Jr(e,i,o,d),g=p-s,v=f-l;c+=Math.sqrt(g*g+v*v),s=p,l=f}return c}var ro=/cubic-bezier\(([0-9,\.e ]+)\)/;function oo(t){var e=t&&ro.exec(t);if(e){var n=e[1].split(","),i=+Xn(n[0]),r=+Xn(n[1]),o=+Xn(n[2]),a=+Xn(n[3]);if(isNaN(i+r+o+a))return;var s=[];return function(t){return t<=0?0:t>=1?1:Yr(0,i,o,1,t,s)&&jr(0,r,a,1,s[0])}}}var ao=function(){function t(t){this._inited=!1,this._startTime=0,this._pausedTime=0,this._paused=!1,this._life=t.life||1e3,this._delay=t.delay||0,this.loop=t.loop||!1,this.onframe=t.onframe||ri,this.ondestroy=t.ondestroy||ri,this.onrestart=t.onrestart||ri,t.easing&&this.setEasing(t.easing)}return t.prototype.step=function(t,e){if(this._inited||(this._startTime=t+this._delay,this._inited=!0),!this._paused){var n=this._life,i=t-this._startTime-this._pausedTime,r=i/n;r<0&&(r=0),r=Math.min(r,1);var o=this.easingFunc,a=o?o(r):r;if(this.onframe(a),1===r){if(!this.loop)return!0;var s=i%n;this._startTime=t-s,this._pausedTime=0,this.onrestart()}return!1}this._pausedTime+=e},t.prototype.pause=function(){this._paused=!0},t.prototype.resume=function(){this._paused=!1},t.prototype.setEasing=function(t){this.easing=t,this.easingFunc=Ln(t)?t:Nr[t]||oo(t)},t}(),so=function(t){this.value=t},lo=function(){function t(){this._len=0}return t.prototype.insert=function(t){var e=new so(t);return this.insertEntry(e),e},t.prototype.insertEntry=function(t){this.head?(this.tail.next=t,t.prev=this.tail,t.next=null,this.tail=t):this.head=this.tail=t,this._len++},t.prototype.remove=function(t){var e=t.prev,n=t.next;e?e.next=n:this.head=n,n?n.prev=e:this.tail=e,t.next=t.prev=null,this._len--},t.prototype.len=function(){return this._len},t.prototype.clear=function(){this.head=this.tail=null,this._len=0},t}(),co=function(){function t(t){this._list=new lo,this._maxSize=10,this._map={},this._maxSize=t}return t.prototype.put=function(t,e){var n=this._list,i=this._map,r=null;if(null==i[t]){var o=n.len(),a=this._lastRemovedEntry;if(o>=this._maxSize&&o>0){var s=n.head;n.remove(s),delete i[s.key],r=s.value,this._lastRemovedEntry=s}a?a.value=e:a=new so(e),a.key=t,n.insertEntry(a),i[t]=a}return r},t.prototype.get=function(t){var e=this._map[t],n=this._list;if(null!=e)return e!==n.tail&&(n.remove(e),n.insertEntry(e)),e.value},t.prototype.clear=function(){this._list.clear(),this._map={}},t.prototype.len=function(){return this._list.len()},t}(),uo={transparent:[0,0,0,0],aliceblue:[240,248,255,1],antiquewhite:[250,235,215,1],aqua:[0,255,255,1],aquamarine:[127,255,212,1],azure:[240,255,255,1],beige:[245,245,220,1],bisque:[255,228,196,1],black:[0,0,0,1],blanchedalmond:[255,235,205,1],blue:[0,0,255,1],blueviolet:[138,43,226,1],brown:[165,42,42,1],burlywood:[222,184,135,1],cadetblue:[95,158,160,1],chartreuse:[127,255,0,1],chocolate:[210,105,30,1],coral:[255,127,80,1],cornflowerblue:[100,149,237,1],cornsilk:[255,248,220,1],crimson:[220,20,60,1],cyan:[0,255,255,1],darkblue:[0,0,139,1],darkcyan:[0,139,139,1],darkgoldenrod:[184,134,11,1],darkgray:[169,169,169,1],darkgreen:[0,100,0,1],darkgrey:[169,169,169,1],darkkhaki:[189,183,107,1],darkmagenta:[139,0,139,1],darkolivegreen:[85,107,47,1],darkorange:[255,140,0,1],darkorchid:[153,50,204,1],darkred:[139,0,0,1],darksalmon:[233,150,122,1],darkseagreen:[143,188,143,1],darkslateblue:[72,61,139,1],darkslategray:[47,79,79,1],darkslategrey:[47,79,79,1],darkturquoise:[0,206,209,1],darkviolet:[148,0,211,1],deeppink:[255,20,147,1],deepskyblue:[0,191,255,1],dimgray:[105,105,105,1],dimgrey:[105,105,105,1],dodgerblue:[30,144,255,1],firebrick:[178,34,34,1],floralwhite:[255,250,240,1],forestgreen:[34,139,34,1],fuchsia:[255,0,255,1],gainsboro:[220,220,220,1],ghostwhite:[248,248,255,1],gold:[255,215,0,1],goldenrod:[218,165,32,1],gray:[128,128,128,1],green:[0,128,0,1],greenyellow:[173,255,47,1],grey:[128,128,128,1],honeydew:[240,255,240,1],hotpink:[255,105,180,1],indianred:[205,92,92,1],indigo:[75,0,130,1],ivory:[255,255,240,1],khaki:[240,230,140,1],lavender:[230,230,250,1],lavenderblush:[255,240,245,1],lawngreen:[124,252,0,1],lemonchiffon:[255,250,205,1],lightblue:[173,216,230,1],lightcoral:[240,128,128,1],lightcyan:[224,255,255,1],lightgoldenrodyellow:[250,250,210,1],lightgray:[211,211,211,1],lightgreen:[144,238,144,1],lightgrey:[211,211,211,1],lightpink:[255,182,193,1],lightsalmon:[255,160,122,1],lightseagreen:[32,178,170,1],lightskyblue:[135,206,250,1],lightslategray:[119,136,153,1],lightslategrey:[119,136,153,1],lightsteelblue:[176,196,222,1],lightyellow:[255,255,224,1],lime:[0,255,0,1],limegreen:[50,205,50,1],linen:[250,240,230,1],magenta:[255,0,255,1],maroon:[128,0,0,1],mediumaquamarine:[102,205,170,1],mediumblue:[0,0,205,1],mediumorchid:[186,85,211,1],mediumpurple:[147,112,219,1],mediumseagreen:[60,179,113,1],mediumslateblue:[123,104,238,1],mediumspringgreen:[0,250,154,1],mediumturquoise:[72,209,204,1],mediumvioletred:[199,21,133,1],midnightblue:[25,25,112,1],mintcream:[245,255,250,1],mistyrose:[255,228,225,1],moccasin:[255,228,181,1],navajowhite:[255,222,173,1],navy:[0,0,128,1],oldlace:[253,245,230,1],olive:[128,128,0,1],olivedrab:[107,142,35,1],orange:[255,165,0,1],orangered:[255,69,0,1],orchid:[218,112,214,1],palegoldenrod:[238,232,170,1],palegreen:[152,251,152,1],paleturquoise:[175,238,238,1],palevioletred:[219,112,147,1],papayawhip:[255,239,213,1],peachpuff:[255,218,185,1],peru:[205,133,63,1],pink:[255,192,203,1],plum:[221,160,221,1],powderblue:[176,224,230,1],purple:[128,0,128,1],red:[255,0,0,1],rosybrown:[188,143,143,1],royalblue:[65,105,225,1],saddlebrown:[139,69,19,1],salmon:[250,128,114,1],sandybrown:[244,164,96,1],seagreen:[46,139,87,1],seashell:[255,245,238,1],sienna:[160,82,45,1],silver:[192,192,192,1],skyblue:[135,206,235,1],slateblue:[106,90,205,1],slategray:[112,128,144,1],slategrey:[112,128,144,1],snow:[255,250,250,1],springgreen:[0,255,127,1],steelblue:[70,130,180,1],tan:[210,180,140,1],teal:[0,128,128,1],thistle:[216,191,216,1],tomato:[255,99,71,1],turquoise:[64,224,208,1],violet:[238,130,238,1],wheat:[245,222,179,1],white:[255,255,255,1],whitesmoke:[245,245,245,1],yellow:[255,255,0,1],yellowgreen:[154,205,50,1]};function ho(t){return(t=Math.round(t))<0?0:t>255?255:t}function po(t){return t<0?0:t>1?1:t}function fo(t){var e=t;return e.length&&"%"===e.charAt(e.length-1)?ho(parseFloat(e)/100*255):ho(parseInt(e,10))}function go(t){var e=t;return e.length&&"%"===e.charAt(e.length-1)?po(parseFloat(e)/100):po(parseFloat(e))}function vo(t,e,n){return n<0?n+=1:n>1&&(n-=1),6*n<1?t+(e-t)*n*6:2*n<1?e:3*n<2?t+(e-t)*(2/3-n)*6:t}function mo(t,e,n){return t+(e-t)*n}function yo(t,e,n,i,r){return t[0]=e,t[1]=n,t[2]=i,t[3]=r,t}function _o(t,e){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t}var bo=new co(20),xo=null;function wo(t,e){xo&&_o(xo,e),xo=bo.put(t,xo||e.slice())}function So(t,e){if(t){e=e||[];var n=bo.get(t);if(n)return _o(e,n);var i=(t+="").replace(/ /g,"").toLowerCase();if(i in uo)return _o(e,uo[i]),wo(t,e),e;var r,o=i.length;if("#"===i.charAt(0))return 4===o||5===o?(r=parseInt(i.slice(1,4),16))>=0&&r<=4095?(yo(e,(3840&r)>>4|(3840&r)>>8,240&r|(240&r)>>4,15&r|(15&r)<<4,5===o?parseInt(i.slice(4),16)/15:1),wo(t,e),e):void yo(e,0,0,0,1):7===o||9===o?(r=parseInt(i.slice(1,7),16))>=0&&r<=16777215?(yo(e,(16711680&r)>>16,(65280&r)>>8,255&r,9===o?parseInt(i.slice(7),16)/255:1),wo(t,e),e):void yo(e,0,0,0,1):void 0;var a=i.indexOf("("),s=i.indexOf(")");if(-1!==a&&s+1===o){var l=i.substr(0,a),c=i.substr(a+1,s-(a+1)).split(","),u=1;switch(l){case"rgba":if(4!==c.length)return 3===c.length?yo(e,+c[0],+c[1],+c[2],1):yo(e,0,0,0,1);u=go(c.pop());case"rgb":return c.length>=3?(yo(e,fo(c[0]),fo(c[1]),fo(c[2]),3===c.length?u:go(c[3])),wo(t,e),e):void yo(e,0,0,0,1);case"hsla":return 4!==c.length?void yo(e,0,0,0,1):(c[3]=go(c[3]),Co(c,e),wo(t,e),e);case"hsl":return 3!==c.length?void yo(e,0,0,0,1):(Co(c,e),wo(t,e),e);default:return}}yo(e,0,0,0,1)}}function Co(t,e){var n=(parseFloat(t[0])%360+360)%360/360,i=go(t[1]),r=go(t[2]),o=r<=.5?r*(i+1):r+i-r*i,a=2*r-o;return yo(e=e||[],ho(255*vo(a,o,n+1/3)),ho(255*vo(a,o,n)),ho(255*vo(a,o,n-1/3)),1),4===t.length&&(e[3]=t[3]),e}function ko(t,e){var n=So(t);if(n){for(var i=0;i<3;i++)n[i]=n[i]*(1-e)|0,n[i]>255?n[i]=255:n[i]<0&&(n[i]=0);return To(n,4===n.length?"rgba":"rgb")}}function Mo(t,e,n,i){var r=So(t);if(t)return r=function(t){if(t){var e,n,i=t[0]/255,r=t[1]/255,o=t[2]/255,a=Math.min(i,r,o),s=Math.max(i,r,o),l=s-a,c=(s+a)/2;if(0===l)e=0,n=0;else{n=c<.5?l/(s+a):l/(2-s-a);var u=((s-i)/6+l/2)/l,h=((s-r)/6+l/2)/l,d=((s-o)/6+l/2)/l;i===s?e=d-h:r===s?e=1/3+u-d:o===s&&(e=2/3+h-u),e<0&&(e+=1),e>1&&(e-=1)}var p=[360*e,n,c];return null!=t[3]&&p.push(t[3]),p}}(r),null!=n&&(r[1]=go(Ln(n)?n(r[1]):n)),null!=i&&(r[2]=go(Ln(i)?i(r[2]):i)),To(Co(r),"rgba")}function To(t,e){if(t&&t.length){var n=t[0]+","+t[1]+","+t[2];return"rgba"!==e&&"hsva"!==e&&"hsla"!==e||(n+=","+t[3]),e+"("+n+")"}}function Io(t,e){var n=So(t);return n?(.299*n[0]+.587*n[1]+.114*n[2])*n[3]/255+(1-n[3])*e:0}var Do=new co(100);function Ao(t){if(En(t)){var e=Do.get(t);return e||(e=ko(t,-.1),Do.put(t,e)),e}if(Fn(t)){var n=_n({},t);return n.colorStops=kn(t.colorStops,function(t){return{offset:t.offset,color:ko(t.color,-.1)}}),n}return t}var Po=Math.round;function Lo(t){var e;if(t&&"transparent"!==t){if("string"==typeof t&&t.indexOf("rgba")>-1){var n=So(t);n&&(t="rgb("+n[0]+","+n[1]+","+n[2]+")",e=n[3])}}else t="none";return{color:t,opacity:null==e?1:e}}var Eo=1e-4;function zo(t){return t-1e-4}function No(t){return Po(1e3*t)/1e3}function Oo(t){return Po(1e4*t)/1e4}var Ro={left:"start",right:"end",center:"middle",middle:"middle"};function $o(t){return t&&!!t.image}function Ho(t){return $o(t)||function(t){return t&&!!t.svgElement}(t)}function Fo(t){return"linear"===t.type}function Bo(t){return"radial"===t.type}function Vo(t){return t&&("linear"===t.type||"radial"===t.type)}function Wo(t){return"url(#"+t+")"}function Uo(t){var e=t.getGlobalScale(),n=Math.max(e[0],e[1]);return Math.max(Math.ceil(Math.log(n)/Math.log(10)),1)}function Go(t){var e=t.x||0,n=t.y||0,i=(t.rotation||0)*oi,r=Wn(t.scaleX,1),o=Wn(t.scaleY,1),a=t.skewX||0,s=t.skewY||0,l=[];return(e||n)&&l.push("translate("+e+"px,"+n+"px)"),i&&l.push("rotate("+i+")"),1===r&&1===o||l.push("scale("+r+","+o+")"),(a||s)&&l.push("skew("+Po(a*oi)+"deg, "+Po(s*oi)+"deg)"),l.join(" ")}var qo=Ye.hasGlobalWindow&&Ln(window.btoa)?function(t){return window.btoa(unescape(encodeURIComponent(t)))}:"undefined"!=typeof Buffer?function(t){return Buffer.from(t).toString("base64")}:function(t){return null},jo=Array.prototype.slice;function Xo(t,e,n){return(e-t)*n+t}function Yo(t,e,n,i){for(var r=e.length,o=0;oi?e:t,o=Math.min(n,i),a=r[o-1]||{color:[0,0,0,0],offset:0},s=o;sa)i.length=a;else for(var s=o;s=1},t.prototype.getAdditiveTrack=function(){return this._additiveTrack},t.prototype.addKeyframe=function(t,e,n){this._needsSort=!0;var i=this.keyframes,r=i.length,o=!1,a=6,s=e;if(Sn(e)){var l=function(t){return Sn(t&&t[0])?2:1}(e);a=l,(1===l&&!Nn(e[0])||2===l&&!Nn(e[0][0]))&&(o=!0)}else if(Nn(e)&&!Bn(e))a=0;else if(En(e))if(isNaN(+e)){var c=So(e);c&&(s=c,a=3)}else a=0;else if(Fn(e)){var u=_n({},s);u.colorStops=kn(e.colorStops,function(t){return{offset:t.offset,color:So(t.color)}}),Fo(e)?a=4:Bo(e)&&(a=5),s=u}0===r?this.valType=a:a===this.valType&&6!==a||(o=!0),this.discrete=this.discrete||o;var h={time:t,value:s,rawValue:e,percent:0};return n&&(h.easing=n,h.easingFunc=Ln(n)?n:Nr[n]||oo(n)),i.push(h),h},t.prototype.prepare=function(t,e){var n=this.keyframes;this._needsSort&&n.sort(function(t,e){return t.time-e.time});for(var i=this.valType,r=n.length,o=n[r-1],a=this.discrete,s=ia(i),l=na(i),c=0;c=0&&!(l[n].percent<=e);n--);n=p(n,c-2)}else{for(n=d;ne);n++);n=p(n-1,c-2)}r=l[n+1],i=l[n]}if(i&&r){this._lastFr=n,this._lastFrP=e;var f=r.percent-i.percent,g=0===f?1:p((e-i.percent)/f,1);r.easingFunc&&(g=r.easingFunc(g));var v=o?this._additiveValue:h?ra:t[u];if(!ia(s)&&!h||v||(v=this._additiveValue=[]),this.discrete)t[u]=g<1?i.rawValue:r.rawValue;else if(ia(s))1===s?Yo(v,i[a],r[a],g):function(t,e,n,i){for(var r=e.length,o=r&&e[0].length,a=0;a0&&s.addKeyframe(0,ta(l),i),this._trackKeys.push(a)}s.addKeyframe(t,ta(e[a]),i)}return this._maxTime=Math.max(this._maxTime,t),this},t.prototype.pause=function(){this._clip.pause(),this._paused=!0},t.prototype.resume=function(){this._clip.resume(),this._paused=!1},t.prototype.isPaused=function(){return!!this._paused},t.prototype.duration=function(t){return this._maxTime=t,this._force=!0,this},t.prototype._doneCallback=function(){this._setTracksFinished(),this._clip=null;var t=this._doneCbs;if(t)for(var e=t.length,n=0;n0)){this._started=1;for(var e=this,n=[],i=this._maxTime||0,r=0;r1){var a=o.pop();r.addKeyframe(a.time,t[i]),r.prepare(this._maxTime,r.getAdditiveTrack())}}}},t}();function sa(){return(new Date).getTime()}var la,ca,ua=function(t){function e(e){var n=t.call(this)||this;return n._running=!1,n._time=0,n._pausedTime=0,n._pauseStart=0,n._paused=!1,e=e||{},n.stage=e.stage||{},n}return y(e,t),e.prototype.addClip=function(t){t.animation&&this.removeClip(t),this._head?(this._tail.next=t,t.prev=this._tail,t.next=null,this._tail=t):this._head=this._tail=t,t.animation=this},e.prototype.addAnimator=function(t){t.animation=this;var e=t.getClip();e&&this.addClip(e)},e.prototype.removeClip=function(t){if(t.animation){var e=t.prev,n=t.next;e?e.next=n:this._head=n,n?n.prev=e:this._tail=e,t.next=t.prev=t.animation=null}},e.prototype.removeAnimator=function(t){var e=t.getClip();e&&this.removeClip(e),t.animation=null},e.prototype.update=function(t){for(var e=sa()-this._pausedTime,n=e-this._time,i=this._head;i;){var r=i.next;i.step(e,n)?(i.ondestroy(),this.removeClip(i),i=r):i=r}this._time=e,t||(this.trigger("frame",n),this.stage.update&&this.stage.update())},e.prototype._startLoop=function(){var t=this;this._running=!0,Er(function e(){t._running&&(Er(e),!t._paused&&t.update())})},e.prototype.start=function(){this._running||(this._time=sa(),this._pausedTime=0,this._startLoop())},e.prototype.stop=function(){this._running=!1},e.prototype.pause=function(){this._paused||(this._pauseStart=sa(),this._paused=!0)},e.prototype.resume=function(){this._paused&&(this._pausedTime+=sa()-this._pauseStart,this._paused=!1)},e.prototype.clear=function(){for(var t=this._head;t;){var e=t.next;t.prev=t.next=t.animation=null,t=e}this._head=this._tail=null},e.prototype.isFinished=function(){return null==this._head},e.prototype.animate=function(t,e){e=e||{},this.start();var n=new aa(t,e.loop);return this.addAnimator(n),n},e}(bi),ha=Ye.domSupported,da=(ca={pointerdown:1,pointerup:1,pointermove:1,pointerout:1},{mouse:la=["click","dblclick","mousewheel","wheel","mouseout","mouseup","mousedown","mousemove","contextmenu"],touch:["touchstart","touchend","touchmove"],pointer:kn(la,function(t){var e=t.replace("mouse","pointer");return ca.hasOwnProperty(e)?e:t})}),pa=["mousemove","mouseup"],fa=["pointermove","pointerup"],ga=!1;function va(t){var e=t.pointerType;return"pen"===e||"touch"===e}function ma(t){t&&(t.zrByTouch=!0)}function ya(t,e){for(var n=e,i=!1;n&&9!==n.nodeType&&!(i=n.domBelongToZr||n!==e&&n===t.painterRoot);)n=n.parentNode;return i}var _a=function(t,e){this.stopPropagation=ri,this.stopImmediatePropagation=ri,this.preventDefault=ri,this.type=e.type,this.target=this.currentTarget=t.dom,this.pointerType=e.pointerType,this.clientX=e.clientX,this.clientY=e.clientY},ba={mousedown:function(t){t=Ri(this.dom,t),this.__mayPointerCapture=[t.zrX,t.zrY],this.trigger("mousedown",t)},mousemove:function(t){t=Ri(this.dom,t);var e=this.__mayPointerCapture;!e||t.zrX===e[0]&&t.zrY===e[1]||this.__togglePointerCapture(!0),this.trigger("mousemove",t)},mouseup:function(t){t=Ri(this.dom,t),this.__togglePointerCapture(!1),this.trigger("mouseup",t)},mouseout:function(t){ya(this,(t=Ri(this.dom,t)).toElement||t.relatedTarget)||(this.__pointerCapturing&&(t.zrEventControl="no_globalout"),this.trigger("mouseout",t))},wheel:function(t){ga=!0,t=Ri(this.dom,t),this.trigger("mousewheel",t)},mousewheel:function(t){ga||(t=Ri(this.dom,t),this.trigger("mousewheel",t))},touchstart:function(t){ma(t=Ri(this.dom,t)),this.__lastTouchMoment=new Date,this.handler.processGesture(t,"start"),ba.mousemove.call(this,t),ba.mousedown.call(this,t)},touchmove:function(t){ma(t=Ri(this.dom,t)),this.handler.processGesture(t,"change"),ba.mousemove.call(this,t)},touchend:function(t){ma(t=Ri(this.dom,t)),this.handler.processGesture(t,"end"),ba.mouseup.call(this,t),+new Date-+this.__lastTouchMoment<300&&ba.click.call(this,t)},pointerdown:function(t){ba.mousedown.call(this,t)},pointermove:function(t){va(t)||ba.mousemove.call(this,t)},pointerup:function(t){ba.mouseup.call(this,t)},pointerout:function(t){va(t)||ba.mouseout.call(this,t)}};Cn(["click","dblclick","contextmenu"],function(t){ba[t]=function(e){e=Ri(this.dom,e),this.trigger(t,e)}});var xa={pointermove:function(t){va(t)||xa.mousemove.call(this,t)},pointerup:function(t){xa.mouseup.call(this,t)},mousemove:function(t){this.trigger("mousemove",t)},mouseup:function(t){var e=this.__pointerCapturing;this.__togglePointerCapture(!1),this.trigger("mouseup",t),e&&(t.zrEventControl="only_globalout",this.trigger("mouseout",t))}};function wa(t,e){var n=e.domHandlers;Ye.pointerEventsSupported?Cn(da.pointer,function(i){Ca(e,i,function(e){n[i].call(t,e)})}):(Ye.touchEventsSupported&&Cn(da.touch,function(i){Ca(e,i,function(r){n[i].call(t,r),function(t){t.touching=!0,null!=t.touchTimer&&(clearTimeout(t.touchTimer),t.touchTimer=null),t.touchTimer=setTimeout(function(){t.touching=!1,t.touchTimer=null},700)}(e)})}),Cn(da.mouse,function(i){Ca(e,i,function(r){r=Oi(r),e.touching||n[i].call(t,r)})}))}function Sa(t,e){function n(n){Ca(e,n,function(i){i=Oi(i),ya(t,i.target)||(i=function(t,e){return Ri(t.dom,new _a(t,e),!0)}(t,i),e.domHandlers[n].call(t,i))},{capture:!0})}Ye.pointerEventsSupported?Cn(fa,n):Ye.touchEventsSupported||Cn(pa,n)}function Ca(t,e,n,i){t.mounted[e]=n,t.listenerOpts[e]=i,function(t,e,n,i){t.addEventListener(e,n,i)}(t.domTarget,e,n,i)}function ka(t){var e=t.mounted;for(var n in e)e.hasOwnProperty(n)&&$i(t.domTarget,n,e[n],t.listenerOpts[n]);t.mounted={}}var Ma=function(t,e){this.mounted={},this.listenerOpts={},this.touching=!1,this.domTarget=t,this.domHandlers=e},Ta=function(t){function e(e,n){var i=t.call(this)||this;return i.__pointerCapturing=!1,i.dom=e,i.painterRoot=n,i._localHandlerScope=new Ma(e,ba),ha&&(i._globalHandlerScope=new Ma(document,xa)),wa(i,i._localHandlerScope),i}return y(e,t),e.prototype.dispose=function(){ka(this._localHandlerScope),ha&&ka(this._globalHandlerScope)},e.prototype.setCursor=function(t){this.dom.style&&(this.dom.style.cursor=t||"default")},e.prototype.__togglePointerCapture=function(t){if(this.__mayPointerCapture=null,ha&&+this.__pointerCapturing^+t){this.__pointerCapturing=t;var e=this._globalHandlerScope;t?Sa(this,e):ka(e)}},e}(bi),Ia=1;Ye.hasGlobalWindow&&(Ia=Math.max(window.devicePixelRatio||window.screen&&window.screen.deviceXDPI/window.screen.logicalXDPI||1,1));var Da=Ia,Aa="#333",Pa="#ccc",La=Wi,Ea=5e-5;function za(t){return t>Ea||t<-5e-5}var Na,Oa=[],Ra=[],$a=[1,0,0,1,0,0],Ha=Math.abs,Fa=function(){function t(){}var e;return t.prototype.getLocalTransform=function(e){return t.getLocalTransform(this,e)},t.prototype.setPosition=function(t){this.x=t[0],this.y=t[1]},t.prototype.setScale=function(t){this.scaleX=t[0],this.scaleY=t[1]},t.prototype.setSkew=function(t){this.skewX=t[0],this.skewY=t[1]},t.prototype.setOrigin=function(t){this.originX=t[0],this.originY=t[1]},t.prototype.needLocalTransform=function(){return za(this.rotation)||za(this.x)||za(this.y)||za(this.scaleX-1)||za(this.scaleY-1)||za(this.skewX)||za(this.skewY)},t.prototype.updateTransform=function(){var t=this.parent&&this.parent.transform,e=this.needLocalTransform(),n=this.transform;e||t?(n=n||[1,0,0,1,0,0],e?this.getLocalTransform(n):La(n),t&&(e?Gi(n,t,n):Ui(n,t)),this.transform=n,this._resolveGlobalScaleRatio(n)):n&&(La(n),this.invTransform=null)},t.prototype._resolveGlobalScaleRatio=function(t){var e=this.globalScaleRatio;if(null!=e&&1!==e){this.getGlobalScale(Oa);var n=Oa[0]<0?-1:1,i=Oa[1]<0?-1:1,r=((Oa[0]-n)*e+n)/Oa[0]||0,o=((Oa[1]-i)*e+i)/Oa[1]||0;t[0]*=r,t[1]*=r,t[2]*=o,t[3]*=o}this.invTransform=this.invTransform||[1,0,0,1,0,0],Xi(this.invTransform,t)},t.prototype.getComputedTransform=function(){for(var t=this,e=[];t;)e.push(t),t=t.parent;for(;t=e.pop();)t.updateTransform();return this.transform},t.prototype.setLocalTransform=function(t){if(t){var e=t[0]*t[0]+t[1]*t[1],n=t[2]*t[2]+t[3]*t[3],i=Math.atan2(t[1],t[0]),r=Math.PI/2+i-Math.atan2(t[3],t[2]);n=Math.sqrt(n)*Math.cos(r),e=Math.sqrt(e),this.skewX=r,this.skewY=0,this.rotation=-i,this.x=+t[4],this.y=+t[5],this.scaleX=e,this.scaleY=n,this.originX=0,this.originY=0}},t.prototype.decomposeTransform=function(){if(this.transform){var t=this.parent,e=this.transform;t&&t.transform&&(t.invTransform=t.invTransform||[1,0,0,1,0,0],Gi(Ra,t.invTransform,e),e=Ra);var n=this.originX,i=this.originY;(n||i)&&($a[4]=n,$a[5]=i,Gi(Ra,e,$a),Ra[4]-=n,Ra[5]-=i,e=Ra),this.setLocalTransform(e)}},t.prototype.getGlobalScale=function(t){var e=this.transform;return t=t||[],e?(t[0]=Math.sqrt(e[0]*e[0]+e[1]*e[1]),t[1]=Math.sqrt(e[2]*e[2]+e[3]*e[3]),e[0]<0&&(t[0]=-t[0]),e[3]<0&&(t[1]=-t[1]),t):(t[0]=1,t[1]=1,t)},t.prototype.transformCoordToLocal=function(t,e){var n=[t,e],i=this.invTransform;return i&&gi(n,n,i),n},t.prototype.transformCoordToGlobal=function(t,e){var n=[t,e],i=this.transform;return i&&gi(n,n,i),n},t.prototype.getLineScale=function(){var t=this.transform;return t&&Ha(t[0]-1)>1e-10&&Ha(t[3]-1)>1e-10?Math.sqrt(Ha(t[0]*t[3]-t[2]*t[1])):1},t.prototype.copyTransform=function(t){Va(this,t)},t.getLocalTransform=function(t,e){e=e||[];var n=t.originX||0,i=t.originY||0,r=t.scaleX,o=t.scaleY,a=t.anchorX,s=t.anchorY,l=t.rotation||0,c=t.x,u=t.y,h=t.skewX?Math.tan(t.skewX):0,d=t.skewY?Math.tan(-t.skewY):0;if(n||i||a||s){var p=n+a,f=i+s;e[4]=-p*r-h*f*o,e[5]=-f*o-d*p*r}else e[4]=e[5]=0;return e[0]=r,e[3]=o,e[1]=d*r,e[2]=h*o,l&&ji(e,e,l),e[4]+=n+c,e[5]+=i+u,e},t.initDefaultProps=((e=t.prototype).scaleX=e.scaleY=e.globalScaleRatio=1,void(e.x=e.y=e.originX=e.originY=e.skewX=e.skewY=e.rotation=e.anchorX=e.anchorY=0)),t}(),Ba=["x","y","originX","originY","anchorX","anchorY","rotation","scaleX","scaleY","skewX","skewY"];function Va(t,e){for(var n=0;n=Ga)){t=t||Ke;for(var e=[],n=+new Date,i=0;i<=127;i++)e[i]=en.measureText(String.fromCharCode(i),t).width;var r=+new Date-n;return r>16?Ua=Ga:r>2&&Ua++,e}}(t.font),t.asciiWidthMapTried=!0),0<=e&&e<=127?null!=t.asciiWidthMap?t.asciiWidthMap[e]:t.asciiCharWidth:t.stWideCharWidth}function ja(t,e){var n=t.strWidthCache,i=n.get(e);return null==i&&(i=en.measureText(e,t.font).width,n.put(e,i)),i}function Xa(t,e,n,i){var r=ja(Wa(e),t),o=Qa(e),a=Za(0,r,n),s=Ka(0,o,i);return new cr(a,s,r,o)}function Ya(t,e,n,i){var r=((t||"")+"").split("\n");if(1===r.length)return Xa(r[0],e,n,i);for(var o=new cr(0,0,0,0),a=0;a=0?parseFloat(t)/100*e:parseFloat(t):t}function ts(t,e,n){var i=e.position||"inside",r=null!=e.distance?e.distance:5,o=n.height,a=n.width,s=o/2,l=n.x,c=n.y,u="left",h="top";if(i instanceof Array)l+=Ja(i[0],n.width),c+=Ja(i[1],n.height),u=null,h=null;else switch(i){case"left":l-=r,c+=s,u="right",h="middle";break;case"right":l+=r+a,c+=s,h="middle";break;case"top":l+=a/2,c-=r,u="center",h="bottom";break;case"bottom":l+=a/2,c+=o+r,u="center";break;case"inside":l+=a/2,c+=s,u="center",h="middle";break;case"insideLeft":l+=r,c+=s,h="middle";break;case"insideRight":l+=a-r,c+=s,u="right",h="middle";break;case"insideTop":l+=a/2,c+=r,u="center";break;case"insideBottom":l+=a/2,c+=o-r,u="center",h="bottom";break;case"insideTopLeft":l+=r,c+=r;break;case"insideTopRight":l+=a-r,c+=r,u="right";break;case"insideBottomLeft":l+=r,c+=o-r,h="bottom";break;case"insideBottomRight":l+=a-r,c+=o-r,u="right",h="bottom"}return(t=t||{}).x=l,t.y=c,t.align=u,t.verticalAlign=h,t}var es="__zr_normal__",ns=Ba.concat(["ignore"]),is=Mn(Ba,function(t,e){return t[e]=!0,t},{ignore:!1}),rs={},os=new cr(0,0,0,0),as=[],ss=function(){function t(t){this.id=gn(),this.animators=[],this.currentStates=[],this.states={},this._init(t)}return t.prototype._init=function(t){this.attr(t)},t.prototype.drift=function(t,e,n){switch(this.draggable){case"horizontal":e=0;break;case"vertical":t=0}var i=this.transform;i||(i=this.transform=[1,0,0,1,0,0]),i[4]+=t,i[5]+=e,this.decomposeTransform(),this.markRedraw()},t.prototype.beforeUpdate=function(){},t.prototype.afterUpdate=function(){},t.prototype.update=function(){this.updateTransform(),this.__dirty&&this.updateInnerText()},t.prototype.updateInnerText=function(t){var e=this._textContent;if(e&&(!e.ignore||t)){this.textConfig||(this.textConfig={});var n=this.textConfig,i=n.local,r=e.innerTransformable,o=void 0,a=void 0,s=!1;r.parent=i?this:null;var l=!1;r.copyTransform(e);var c=null!=n.position,u=n.autoOverflowArea,h=void 0;if((u||c)&&(h=os,n.layoutRect?h.copy(n.layoutRect):h.copy(this.getBoundingRect()),i||h.applyTransform(this.transform)),c){this.calculateTextPosition?this.calculateTextPosition(rs,n,h):ts(rs,n,h),r.x=rs.x,r.y=rs.y,o=rs.align,a=rs.verticalAlign;var d=n.origin;if(d&&null!=n.rotation){var p=void 0,f=void 0;"center"===d?(p=.5*h.width,f=.5*h.height):(p=Ja(d[0],h.width),f=Ja(d[1],h.height)),l=!0,r.originX=-r.x+p+(i?0:h.x),r.originY=-r.y+f+(i?0:h.y)}}null!=n.rotation&&(r.rotation=n.rotation);var g=n.offset;g&&(r.x+=g[0],r.y+=g[1],l||(r.originX=-g[0],r.originY=-g[1]));var v=this._innerTextDefaultStyle||(this._innerTextDefaultStyle={});if(u){var m=v.overflowRect=v.overflowRect||new cr(0,0,0,0);r.getLocalTransform(as),Xi(as,as),cr.copy(m,h),m.applyTransform(as)}else v.overflowRect=null;var y=void 0,_=void 0,b=void 0;(null==n.inside?"string"==typeof n.position&&n.position.indexOf("inside")>=0:n.inside)&&this.canBeInsideText()?(y=n.insideFill,_=n.insideStroke,null!=y&&"auto"!==y||(y=this.getInsideTextFill()),null!=_&&"auto"!==_||(_=this.getInsideTextStroke(y),b=!0)):(y=n.outsideFill,_=n.outsideStroke,null!=y&&"auto"!==y||(y=this.getOutsideFill()),null!=_&&"auto"!==_||(_=this.getOutsideStroke(y),b=!0)),(y=y||"#000")===v.fill&&_===v.stroke&&b===v.autoStroke&&o===v.align&&a===v.verticalAlign||(s=!0,v.fill=y,v.stroke=_,v.autoStroke=b,v.align=o,v.verticalAlign=a,e.setDefaultTextStyle(v)),e.__dirty|=1,s&&e.dirtyStyle(!0)}},t.prototype.canBeInsideText=function(){return!0},t.prototype.getInsideTextFill=function(){return"#fff"},t.prototype.getInsideTextStroke=function(t){return"#000"},t.prototype.getOutsideFill=function(){return this.__zr&&this.__zr.isDarkMode()?Pa:Aa},t.prototype.getOutsideStroke=function(t){var e=this.__zr&&this.__zr.getBackgroundColor(),n="string"==typeof e&&So(e);n||(n=[255,255,255,1]);for(var i=n[3],r=this.__zr.isDarkMode(),o=0;o<3;o++)n[o]=n[o]*i+(r?0:255)*(1-i);return n[3]=1,To(n,"rgba")},t.prototype.traverse=function(t,e){},t.prototype.attrKV=function(t,e){"textConfig"===t?this.setTextConfig(e):"textContent"===t?this.setTextContent(e):"clipPath"===t?this.setClipPath(e):"extra"===t?(this.extra=this.extra||{},_n(this.extra,e)):this[t]=e},t.prototype.hide=function(){this.ignore=!0,this.markRedraw()},t.prototype.show=function(){this.ignore=!1,this.markRedraw()},t.prototype.attr=function(t,e){if("string"==typeof t)this.attrKV(t,e);else if(On(t))for(var n=In(t),i=0;i0},t.prototype.getState=function(t){return this.states[t]},t.prototype.ensureState=function(t){var e=this.states;return e[t]||(e[t]={}),e[t]},t.prototype.clearStates=function(t){this.useState(es,!1,t)},t.prototype.useState=function(t,e,n,i){var r=t===es;if(this.hasState()||!r){var o=this.currentStates,a=this.stateTransition;if(!(xn(o,t)>=0)||!e&&1!==o.length){var s;if(this.stateProxy&&!r&&(s=this.stateProxy(t)),s||(s=this.states&&this.states[t]),s||r){r||this.saveCurrentToNormalState(s);var l=!!(s&&s.hoverLayer||i);l&&this._toggleHoverLayerFlag(!0),this._applyStateObj(t,s,this._normalState,e,!n&&!this.__inHover&&a&&a.duration>0,a);var c=this._textContent,u=this._textGuide;return c&&c.useState(t,e,n,l),u&&u.useState(t,e,n,l),r?(this.currentStates=[],this._normalState={}):e?this.currentStates.push(t):this.currentStates=[t],this._updateAnimationTargets(),this.markRedraw(),!l&&this.__inHover&&(this._toggleHoverLayerFlag(!1),this.__dirty&=-2),s}vn("State "+t+" not exists.")}}},t.prototype.useStates=function(t,e,n){if(t.length){var i=[],r=this.currentStates,o=t.length,a=o===r.length;if(a)for(var s=0;s0,p);var f=this._textContent,g=this._textGuide;f&&f.useStates(t,e,h),g&&g.useStates(t,e,h),this._updateAnimationTargets(),this.currentStates=t.slice(),this.markRedraw(),!h&&this.__inHover&&(this._toggleHoverLayerFlag(!1),this.__dirty&=-2)}else this.clearStates()},t.prototype.isSilent=function(){for(var t=this;t;){if(t.silent)return!0;var e=t.__hostTarget;t=e?t.ignoreHostSilent?null:e:t.parent}return!1},t.prototype._updateAnimationTargets=function(){for(var t=0;t=0){var n=this.currentStates.slice();n.splice(e,1),this.useStates(n)}},t.prototype.replaceState=function(t,e,n){var i=this.currentStates.slice(),r=xn(i,t),o=xn(i,e)>=0;r>=0?o?i.splice(r,1):i[r]=e:n&&!o&&i.push(e),this.useStates(i)},t.prototype.toggleState=function(t,e){e?this.useState(t,!0):this.removeState(t)},t.prototype._mergeStates=function(t){for(var e,n={},i=0;i=0&&e.splice(n,1)}),this.animators.push(t),n&&n.animation.addAnimator(t),n&&n.wakeUp()},t.prototype.updateDuringAnimation=function(t){this.markRedraw()},t.prototype.stopAnimation=function(t,e){for(var n=this.animators,i=n.length,r=[],o=0;o0&&n.during&&o[0].during(function(t,e){n.during(e)});for(var d=0;d0||r.force&&!a.length){var w,S=void 0,C=void 0,k=void 0;if(s){C={},d&&(S={});for(b=0;b<_;b++){C[m=g[b]]=n[m],d?S[m]=i[m]:n[m]=i[m]}}else if(d){k={};for(b=0;b<_;b++){k[m=g[b]]=ta(n[m]),us(n,i,m)}}(w=new aa(n,!1,!1,h?Tn(f,function(t){return t.targetName===e}):null)).targetName=e,r.scope&&(w.scope=r.scope),d&&S&&w.whenWithKeys(0,S,g),k&&w.whenWithKeys(0,k,g),w.whenWithKeys(null==c?500:c,s?C:i,g).delay(u||0),t.addAnimator(w,e),a.push(w)}}wn(ss,bi),wn(ss,Fa);var ds=function(t){function e(e){var n=t.call(this)||this;return n.isGroup=!0,n._children=[],n.attr(e),n}return y(e,t),e.prototype.childrenRef=function(){return this._children},e.prototype.children=function(){return this._children.slice()},e.prototype.childAt=function(t){return this._children[t]},e.prototype.childOfName=function(t){for(var e=this._children,n=0;n=0&&(n.splice(i,0,t),this._doAdd(t))}return this},e.prototype.replace=function(t,e){var n=xn(this._children,t);return n>=0&&this.replaceAt(e,n),this},e.prototype.replaceAt=function(t,e){var n=this._children,i=n[e];if(t&&t!==this&&t.parent!==this&&t!==i){n[e]=t,i.parent=null;var r=this.__zr;r&&i.removeSelfFromZr(r),this._doAdd(t)}return this},e.prototype._doAdd=function(t){t.parent&&t.parent.remove(t),t.parent=this;var e=this.__zr;e&&e!==t.__zr&&t.addSelfToZr(e),e&&e.refresh()},e.prototype.remove=function(t){var e=this.__zr,n=this._children,i=xn(n,t);return i<0||(n.splice(i,1),t.parent=null,e&&t.removeSelfFromZr(e),e&&e.refresh()),this},e.prototype.removeAll=function(){for(var t=this._children,e=this.__zr,n=0;ne&&(e=t);return e>0?e+e%2:0}(o.circuits);if(!a)throw new Error(n("card.topology_error"));const l={type:"config/device_registry/list"},c=i?await i.callWS(t,l,{errorId:"fetch:topology"}):await t.callWS(l),u=(h=c.find(t=>t.id===e),h?{id:h.id,name:h.name,name_by_user:h.name_by_user,config_entries:h.config_entries,identifiers:h.identifiers,via_device_id:h.via_device_id,sw_version:h.sw_version,model:h.model}:null);var h;return await ae(t,o),{topology:o,panelDevice:u,panelSize:a}}function le(){return`
\n ${Object.entries(v).filter(([t])=>"unknown"!==t).map(([,t])=>{const e=Bt(t.icon),n=Bt(t.color),i=Bt(t.label());let r;if(t.icon2){r=``}else if(t.textLabel){r=`${Bt(t.textLabel)}`}else r=``;return`
${r}${i}
`}).join("")}\n
`}function ce(t,e,i){const r="current"===(e.chart_metric||"power"),o=!!t.panel_entities?.site_power,a=!!t.panel_entities?.dsm_state,s=!!t.panel_entities?.current_power,l=!!t.panel_entities?.feedthrough_power,c=!!t.panel_entities?.pv_power,u=!!t.panel_entities?.battery_level;return`\n
\n ${o?`\n
\n ${n("header.site")}\n
\n 0\n ${r?"A":"kW"}\n
\n
`:""}\n ${a?`\n
\n ${n("header.grid")}\n
\n --\n
\n
`:""}\n ${s?`\n
\n ${n("header.upstream")}\n
\n --\n ${r?"A":"kW"}\n
\n
`:""}\n ${l?`\n
\n ${n("header.downstream")}\n
\n --\n ${r?"A":"kW"}\n
\n
`:""}\n ${c?`\n
\n ${n("header.solar")}\n
\n --\n ${r?"A":"kW"}\n
\n
`:""}\n ${u?`\n
\n ${n("header.battery")}\n
\n —\n %\n
\n
`:""}\n
\n `}function ue(t,e,i={}){const r=Bt(t.device_name||n("header.default_name")),o=Bt(t.serial||""),a=Bt(t.firmware||""),s="current"===(e.chart_metric||"power"),l=!1!==i.showSwitches;return`\n
\n
\n
\n

${r}

\n ${o}\n \n ${l?`
\n ${Bt(n("header.enable_switches"))}\n
\n \n
\n
`:""}\n
\n ${ce(t,e)}\n
\n
\n
\n ${a}\n
\n \n \n
\n
\n ${le()}\n
\n
\n `}const he=f.power;function de(t){return he.unit(t)}function pe(t){return(t<0?"-":"")+he.format(t)}function fe(t){return(Math.abs(t)/1e3).toFixed(1)}function ge(t){return Math.ceil(t/2)}function ve(t){return t%2==0?1:0}function me(t){if(2!==t.length)return null;const[e,n]=[Math.min(...t),Math.max(...t)];return ge(e)===ge(n)?"row-span":ve(e)===ve(n)?"col-span":"row-span"}function ye(t){const e=t.chart_metric??r;return f[e]??f[r]}function _e(t,e){const n=function(t){return ye(t).entityRole}(e);return t.entities?.[n]??t.entities?.power??null}class be{constructor(){this._status=null,this._lastFetch=0,this._inflight=null,this._generation=0,this._errorStore=null,this._retry=null}get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t,this._retry=t?new qt(t):null}async fetch(t,e){const i=Date.now();if(this._inflight&&this._inflight.gen===this._generation)return this._inflight.promise;if(this._status&&i-this._lastFetch<3e4)return this._status;const r=this._generation,o=(async()=>{try{const i={};e&&(i.config_entry_id=e);const o={type:"call_service",domain:s,service:"get_monitoring_status",service_data:i,return_response:!0},a=this._retry?await this._retry.callWS(t,o,{errorId:"fetch:monitoring",errorMessage:n("error.monitoring_failed")}):await t.callWS(o),l=a?.response??null;return r===this._generation&&(this._status=l,this._lastFetch=Date.now()),l}catch(t){return console.warn("SPAN Panel: monitoring status fetch failed",t),r===this._generation&&(this._status=null),this._retry||this._errorStore?.add({key:"fetch:monitoring",level:"warning",message:n("error.monitoring_failed"),persistent:!1}),null}finally{this._inflight?.gen===r&&(this._inflight=null)}})();return this._inflight={gen:r,promise:o},o}invalidate(){this._lastFetch=0,this._generation++}get status(){return this._status}clear(){this._status=null,this._lastFetch=0,this._generation++}}class xe{constructor(){this._caches=new Map,this._errorStore=null}get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t;for(const e of this._caches.values())e.errorStore=t}async fetchOne(t,e){let n=this._caches.get(e);return n||(n=new be,n.errorStore=this._errorStore,this._caches.set(e,n)),n.fetch(t,e)}invalidate(){for(const t of this._caches.values())t.invalidate()}clear(){this._caches.clear()}}function we(t,e){return t?.circuits?t.circuits[e]??null:null}function Se(t,e,n,i){const r=[];return n||r.push("circuit-off"),i&&r.push("circuit-producer"),function(t){return!!t&&null!=t.over_threshold_since}(e)&&r.push("circuit-alert"),r.join(" ")}function Ce(t,e,i,r,o,a,s,u,h,d=!1){const p=e.entities?.power,f=p?a.states[p]:null,g=f&&parseFloat(f.state)||0,m=e.device_type===c||g<0,y=e.entities?.switch,_=y?a.states[y]:null,b=_?"on"===_.state:(f?.attributes?.relay_state||e.relay_state)===l,x=e.breaker_rating_a,w=x?`${Math.round(x)}A`:"",S=Bt(e.name||n("grid.unknown")),C=ye(s);let k;if("current"===C.entityRole){const t=e.entities?.current,n=t?a.states[t]:null,i=n&&parseFloat(n.state)||0;k=`${C.format(i)}A`}else k=`${pe(g)}${de(g)}`;const M=h||"unknown";let T="";if("unknown"!==M){const t=v[M]??v.unknown??{icon:"mdi:help",color:"#999",label:()=>"Unknown"},e=Bt(t.label()),n=Bt(t.icon),i=Bt(t.color);if(t.icon2){T=`\n \n \n `}else if(t.textLabel){T=`\n \n ${Bt(t.textLabel)}\n `}else T=``}const I=``;let D="",A=u?.utilization_pct??null;if(null==A&&e.breaker_rating_a){const t=e.entities?.current,n=t?a.states[t]:null,i=n?Math.abs(parseFloat(n.state)||0):0;A=Math.round(i/e.breaker_rating_a*1e3)/10}if(null!=A){D=`=80?"utilization-warning":"utilization-normal"}">${Math.round(A)}%`}return`\n
\n
\n
\n ${w?`${w}`:""}\n ${D}\n ${S}\n
\n
\n \n ${k}\n \n ${!1!==e.is_user_controllable&&e.entities?.switch?`\n
\n ${n(b?"grid.on":"grid.off")}\n \n
\n `:""}\n
\n
\n
\n ${T}\n ${I}\n
\n
\n
\n `}function ke(t,e){return`\n
\n —\n
\n `}const Me={names:["power","battery power"],suffixes:["_power"]},Te={names:["battery level","battery percentage"],suffixes:["_battery_level","_battery_percentage"]},Ie={names:["state of energy"],suffixes:["_soe_kwh"]},De={names:["nameplate capacity"],suffixes:["_nameplate_capacity"]};function Ae(t,e){if(!t.entities)return null;for(const[n,i]of Object.entries(t.entities)){if("sensor"!==i.domain)continue;const t=(i.original_name??"").toLowerCase();if(e.names.some(e=>t===e))return n;if(i.unique_id&&e.suffixes.some(t=>i.unique_id.endsWith(t)))return n}return null}function Pe(t){return Ae(t,Me)}function Le(t){return Ae(t,Te)}function Ee(t){return Ae(t,Ie)}function ze(t){return Ae(t,De)}function Ne(t,e,i){const r=!1!==i.show_battery,o=!1!==i.show_evse;if(!t.sub_devices)return"";const a=Object.entries(t.sub_devices).filter(([,t])=>!(t.type===u&&!r)&&!(t.type===h&&!o));if(0===a.length)return"";const s=[];for(const[t,n]of a){const r=Pe(n),o=n.type===u,a=o?Le(n):null,l=o?Ee(n):null,c=o?ze(n):null,h=Oe(n,e,i,new Set([r,a,l,c].filter(t=>null!==t))),d=Re(t,n,o,r,a,l);(r||d||h)&&s.push({devId:t,sub:n,powerEid:r,chartsHTML:d,entHTML:h})}if(0===s.length)return"";const l=s.filter(t=>t.sub.type===h).length;let c=0,d="";for(const{devId:t,sub:i,powerEid:r,chartsHTML:o,entHTML:a}of s){const s=i.type===h?n("subdevice.ev_charger"):i.type===u?n("subdevice.battery"):n("subdevice.fallback"),p=r?e.states[r]:void 0,f=p&&parseFloat(p.state)||0,g=i.type===u,v=i.type===h;let m="";g?m="sub-device-bess":v&&(c++,c===l&&l%2==1&&(m="sub-device-full")),d+=`\n
\n
\n ${Bt(s)}\n ${Bt(i.name||"")}\n ${r?`${pe(f)} ${de(f)}`:""}\n \n
\n ${o}\n ${a}\n
\n `}return d}function Oe(t,e,n,i){const r=n.visible_sub_entities||{};let o="";if(!t.entities)return o;for(const[n,a]of Object.entries(t.entities)){if(i.has(n))continue;if(!0!==r[n])continue;const s=e.states[n];if(!s)continue;let l=a.original_name||s.attributes.friendly_name||n;const c=t.name||"";let u;if(l.startsWith(c+" ")&&(l=l.slice(c.length+1)),e.formatEntityState)u=e.formatEntityState(s);else{u=s.state;const t=s.attributes.unit_of_measurement||"";t&&(u+=" "+t)}if("Wh"===(s.attributes.unit_of_measurement||"")){const t=parseFloat(s.state);isNaN(t)||(u=(t/1e3).toFixed(1)+" kWh")}o+=`\n
\n ${Bt(l)}:\n ${Bt(u)}\n
\n `}return o}function Re(t,e,i,r,o,a){if(i){const e=[{key:`${d}${t}_soc`,title:n("subdevice.soc"),available:!!o},{key:`${d}${t}_soe`,title:n("subdevice.soe"),available:!!a},{key:`${d}${t}_power`,title:n("subdevice.power"),available:!!r}].filter(t=>t.available);return`\n
\n ${e.map(t=>`\n
\n
${Bt(t.title)}
\n
\n
\n `).join("")}\n
\n `}return r?`
`:""}function $e(t){const e=void 0!==t.history_days||void 0!==t.history_hours||void 0!==t.history_minutes,n=60*(60*(24*(e&&parseInt(String(t.history_days))||0)+(e&&parseInt(String(t.history_hours))||0))+(e?parseInt(String(t.history_minutes))||0:5))*1e3;return Math.max(n,6e4)}function He(t){const e=a[t];return e?e.ms:a[o].ms}function Fe(t){const e=t/1e3;return e<=600?Math.ceil(e):Math.min(5e3,Math.ceil(e/5))}function Be(t){return Math.max(500,Math.floor(t/5e3))}function Ve(t,e,n,i,r,o){t.has(e)||t.set(e,[]);const a=t.get(e);a.push({time:i,value:n});const s=a.findIndex(t=>t.time>=r);s>0?a.splice(0,s):-1===s&&(a.length=0),a.length>o&&a.splice(0,a.length-o)}function We(t,e,n=500){if(0===t.length)return t;t.sort((t,e)=>t.time-e.time);const i=[t[0]];for(let e=1;e=n&&i.push(t[e]);return i.length>e&&i.splice(0,i.length-e),i}async function Ue(t,e,n,i,r){const o=new Date(Date.now()-i).toISOString(),a=i/36e5>72?"hour":"5minute",s=await t.callWS({type:"recorder/statistics_during_period",start_time:o,statistic_ids:e,period:a,types:["mean"]});for(const[t,e]of Object.entries(s)){const i=n.get(t);if(!i||!e)continue;const o=[];for(const t of e){const e=t.mean;if(null==e||!Number.isFinite(e))continue;const n=t.start;n>0&&o.push({time:n,value:e})}if(o.length>0){const t=r.get(i)||[],e=[...o,...t];e.sort((t,e)=>t.time-e.time),r.set(i,e)}}}async function Ge(t,e,n,i,r){const o=new Date(Date.now()-i).toISOString(),a=await t.callWS({type:"history/history_during_period",start_time:o,entity_ids:e,minimal_response:!0,significant_changes_only:!0,no_attributes:!0}),s=Fe(i),l=Be(i);for(const[t,e]of Object.entries(a)){const i=n.get(t);if(!i||!e)continue;const o=[];for(const t of e){const e=parseFloat(t.s);if(!Number.isFinite(e))continue;const n=1e3*(t.lu||t.lc||0);n>0&&o.push({time:n,value:e})}if(o.length>0){const t=r.get(i)||[],e=[...o,...t];r.set(i,We(e,s,l))}}}function qe(t){if(!t.sub_devices)return[];const e=[];for(const[n,i]of Object.entries(t.sub_devices)){const t={power:Pe(i)};i.type===u&&(t.soc=Le(i),t.soe=Ee(i));for(const[i,r]of Object.entries(t))r&&e.push({entityId:r,key:`${d}${n}_${i}`,devId:n})}return e}async function je(t,e,n,i,r,o){if(!e||!t)return;const a=new Map;for(const[t,i]of Object.entries(e.circuits)){const e=_e(i,n);if(!e)continue;let o;o=r&&r.has(t)?He(r.get(t)):$e(n),a.has(o)||a.set(o,{entityIds:[],uuidByEntity:new Map});const s=a.get(o);s.entityIds.push(e),s.uuidByEntity.set(e,t)}for(const{entityId:t,key:i,devId:r}of qe(e)){let e;e=o&&o.has(r)?He(o.get(r)):$e(n),a.has(e)||a.set(e,{entityIds:[],uuidByEntity:new Map});const s=a.get(e);s.entityIds.push(t),s.uuidByEntity.set(t,i)}const s=[];for(const[e,n]of a){if(0===n.entityIds.length)continue;e>2592e5?s.push(Ue(t,n.entityIds,n.uuidByEntity,e,i)):s.push(Ge(t,n.entityIds,n.uuidByEntity,e,i))}await Promise.all(s)}var Xe=function(){this.firefox=!1,this.ie=!1,this.edge=!1,this.newEdge=!1,this.weChat=!1},Ye=new function(){this.browser=new Xe,this.node=!1,this.wxa=!1,this.worker=!1,this.svgSupported=!1,this.touchEventsSupported=!1,this.pointerEventsSupported=!1,this.domSupported=!1,this.transformSupported=!1,this.transform3dSupported=!1,this.hasGlobalWindow="undefined"!=typeof window};"object"==typeof wx&&"function"==typeof wx.getSystemInfoSync?(Ye.wxa=!0,Ye.touchEventsSupported=!0):"undefined"==typeof document&&"undefined"!=typeof self?Ye.worker=!0:!Ye.hasGlobalWindow||"Deno"in window||"undefined"!=typeof navigator&&"string"==typeof navigator.userAgent&&navigator.userAgent.indexOf("Node.js")>-1?(Ye.node=!0,Ye.svgSupported=!0):function(t,e){var n=e.browser,i=t.match(/Firefox\/([\d.]+)/),r=t.match(/MSIE\s([\d.]+)/)||t.match(/Trident\/.+?rv:(([\d.]+))/),o=t.match(/Edge?\/([\d.]+)/),a=/micromessenger/i.test(t);i&&(n.firefox=!0,n.version=i[1]);r&&(n.ie=!0,n.version=r[1]);o&&(n.edge=!0,n.version=o[1],n.newEdge=+o[1].split(".")[0]>18);a&&(n.weChat=!0);e.svgSupported="undefined"!=typeof SVGRect,e.touchEventsSupported="ontouchstart"in window&&!n.ie&&!n.edge,e.pointerEventsSupported="onpointerdown"in window&&(n.edge||n.ie&&+n.version>=11);var s=e.domSupported="undefined"!=typeof document;if(s){var l=document.documentElement.style;e.transform3dSupported=(n.ie&&"transition"in l||n.edge||"WebKitCSSMatrix"in window&&"m11"in new WebKitCSSMatrix||"MozPerspective"in l)&&!("OTransition"in l),e.transformSupported=e.transform3dSupported||n.ie&&+n.version>=9}}(navigator.userAgent,Ye);var Ze="sans-serif",Ke="12px "+Ze;var Qe,Je,tn=function(t){var e={};if("undefined"==typeof JSON)return e;for(var n=0;n=0)o=r*t.length;else for(var a=0;a>1)%2;a.style.cssText=["position: absolute","visibility: hidden","padding: 0","margin: 0","border-width: 0","user-select: none","width:0","height:0",i[s]+":0",r[l]+":0",i[1-s]+":auto",r[1-l]+":auto",""].join("!important;"),t.appendChild(a),n.push(a)}return e.clearMarkers=function(){Cn(n,function(t){t.parentNode&&t.parentNode.removeChild(t)})},n}(e,o),s=function(t,e,n){for(var i=n?"invTrans":"trans",r=e[i],o=e.srcCoords,a=[],s=[],l=!0,c=0;c<4;c++){var u=t[c].getBoundingClientRect(),h=2*c,d=u.left,p=u.top;a.push(d,p),l=l&&o&&d===o[h]&&p===o[h+1],s.push(t[c].offsetLeft,t[c].offsetTop)}return l&&r?r:(e.srcCoords=a,e[i]=n?Si(s,a):Si(a,s))}(a,o,r);if(s)return s(t,n,i),!0}return!1}function Ti(t){return"CANVAS"===t.nodeName.toUpperCase()}var Ii=/([&<>"'])/g,Di={"&":"&","<":"<",">":">",'"':""","'":"'"};function Ai(t){return null==t?"":(t+"").replace(Ii,function(t,e){return Di[e]})}var Pi=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,Li=[],Ei=Ye.browser.firefox&&+Ye.browser.version.split(".")[0]<39;function zi(t,e,n,i){return n=n||{},i?Ni(t,e,n):Ei&&null!=e.layerX&&e.layerX!==e.offsetX?(n.zrX=e.layerX,n.zrY=e.layerY):null!=e.offsetX?(n.zrX=e.offsetX,n.zrY=e.offsetY):Ni(t,e,n),n}function Ni(t,e,n){if(Ye.domSupported&&t.getBoundingClientRect){var i=e.clientX,r=e.clientY;if(Ti(t)){var o=t.getBoundingClientRect();return n.zrX=i-o.left,void(n.zrY=r-o.top)}if(Mi(Li,t,i,r))return n.zrX=Li[0],void(n.zrY=Li[1])}n.zrX=n.zrY=0}function Oi(t){return t||window.event}function Ri(t,e,n){if(null!=(e=Oi(e)).zrX)return e;var i=e.type;if(i&&i.indexOf("touch")>=0){var r="touchend"!==i?e.targetTouches[0]:e.changedTouches[0];r&&zi(t,r,e,n)}else{zi(t,e,e,n);var o=function(t){var e=t.wheelDelta;if(e)return e;var n=t.deltaX,i=t.deltaY;if(null==n||null==i)return e;return 3*(0!==i?Math.abs(i):Math.abs(n))*(i>0?-1:i<0?1:n>0?-1:1)}(e);e.zrDelta=o?o/120:-(e.detail||0)/3}var a=e.button;return null==e.which&&void 0!==a&&Pi.test(e.type)&&(e.which=1&a?1:2&a?3:4&a?2:0),e}function $i(t,e,n,i){t.removeEventListener(e,n,i)}var Hi=function(t){t.preventDefault(),t.stopPropagation(),t.cancelBubble=!0},Fi=function(){function t(){this._track=[]}return t.prototype.recognize=function(t,e,n){return this._doTrack(t,e,n),this._recognize(t)},t.prototype.clear=function(){return this._track.length=0,this},t.prototype._doTrack=function(t,e,n){var i=t.touches;if(i){for(var r={points:[],touches:[],target:e,event:t},o=0,a=i.length;o1&&r&&r.length>1){var a=Bi(r)/Bi(o);!isFinite(a)&&(a=1),e.pinchScale=a;var s=[((i=r)[0][0]+i[1][0])/2,(i[0][1]+i[1][1])/2];return e.pinchX=s[0],e.pinchY=s[1],{type:"pinch",target:t[0].target,event:e}}}}};function Wi(t){return t[0]=1,t[1]=0,t[2]=0,t[3]=1,t[4]=0,t[5]=0,t}function Ui(t,e){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t[4]=e[4],t[5]=e[5],t}function Gi(t,e,n){var i=e[0]*n[0]+e[2]*n[1],r=e[1]*n[0]+e[3]*n[1],o=e[0]*n[2]+e[2]*n[3],a=e[1]*n[2]+e[3]*n[3],s=e[0]*n[4]+e[2]*n[5]+e[4],l=e[1]*n[4]+e[3]*n[5]+e[5];return t[0]=i,t[1]=r,t[2]=o,t[3]=a,t[4]=s,t[5]=l,t}function qi(t,e,n){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t[4]=e[4]+n[0],t[5]=e[5]+n[1],t}function ji(t,e,n,i){void 0===i&&(i=[0,0]);var r=e[0],o=e[2],a=e[4],s=e[1],l=e[3],c=e[5],u=Math.sin(n),h=Math.cos(n);return t[0]=r*h+s*u,t[1]=-r*u+s*h,t[2]=o*h+l*u,t[3]=-o*u+h*l,t[4]=h*(a-i[0])+u*(c-i[1])+i[0],t[5]=h*(c-i[1])-u*(a-i[0])+i[1],t}function Xi(t,e){var n=e[0],i=e[2],r=e[4],o=e[1],a=e[3],s=e[5],l=n*a-o*i;return l?(l=1/l,t[0]=a*l,t[1]=-o*l,t[2]=-i*l,t[3]=n*l,t[4]=(i*s-a*r)*l,t[5]=(o*r-n*s)*l,t):null}var Yi=function(){function t(t,e){this.x=t||0,this.y=e||0}return t.prototype.copy=function(t){return this.x=t.x,this.y=t.y,this},t.prototype.clone=function(){return new t(this.x,this.y)},t.prototype.set=function(t,e){return this.x=t,this.y=e,this},t.prototype.equal=function(t){return t.x===this.x&&t.y===this.y},t.prototype.add=function(t){return this.x+=t.x,this.y+=t.y,this},t.prototype.scale=function(t){this.x*=t,this.y*=t},t.prototype.scaleAndAdd=function(t,e){this.x+=t.x*e,this.y+=t.y*e},t.prototype.sub=function(t){return this.x-=t.x,this.y-=t.y,this},t.prototype.dot=function(t){return this.x*t.x+this.y*t.y},t.prototype.len=function(){return Math.sqrt(this.x*this.x+this.y*this.y)},t.prototype.lenSquare=function(){return this.x*this.x+this.y*this.y},t.prototype.normalize=function(){var t=this.len();return this.x/=t,this.y/=t,this},t.prototype.distance=function(t){var e=this.x-t.x,n=this.y-t.y;return Math.sqrt(e*e+n*n)},t.prototype.distanceSquare=function(t){var e=this.x-t.x,n=this.y-t.y;return e*e+n*n},t.prototype.negate=function(){return this.x=-this.x,this.y=-this.y,this},t.prototype.transform=function(t){if(t){var e=this.x,n=this.y;return this.x=t[0]*e+t[2]*n+t[4],this.y=t[1]*e+t[3]*n+t[5],this}},t.prototype.toArray=function(t){return t[0]=this.x,t[1]=this.y,t},t.prototype.fromArray=function(t){this.x=t[0],this.y=t[1]},t.set=function(t,e,n){t.x=e,t.y=n},t.copy=function(t,e){t.x=e.x,t.y=e.y},t.len=function(t){return Math.sqrt(t.x*t.x+t.y*t.y)},t.lenSquare=function(t){return t.x*t.x+t.y*t.y},t.dot=function(t,e){return t.x*e.x+t.y*e.y},t.add=function(t,e,n){t.x=e.x+n.x,t.y=e.y+n.y},t.sub=function(t,e,n){t.x=e.x-n.x,t.y=e.y-n.y},t.scale=function(t,e,n){t.x=e.x*n,t.y=e.y*n},t.scaleAndAdd=function(t,e,n,i){t.x=e.x+n.x*i,t.y=e.y+n.y*i},t.lerp=function(t,e,n,i){var r=1-i;t.x=r*e.x+i*n.x,t.y=r*e.y+i*n.y},t}(),Zi=Math.min,Ki=Math.max,Qi=Math.abs,Ji=["x","y"],tr=["width","height"],er=new Yi,nr=new Yi,ir=new Yi,rr=new Yi,or=pr(),ar=or.minTv,sr=or.maxTv,lr=[0,0],cr=function(){function t(e,n,i,r){t.set(this,e,n,i,r)}return t.set=function(t,e,n,i,r){return i<0&&(e+=i,i=-i),r<0&&(n+=r,r=-r),t.x=e,t.y=n,t.width=i,t.height=r,t},t.prototype.union=function(t){var e=Zi(t.x,this.x),n=Zi(t.y,this.y);isFinite(this.x)&&isFinite(this.width)?this.width=Ki(t.x+t.width,this.x+this.width)-e:this.width=t.width,isFinite(this.y)&&isFinite(this.height)?this.height=Ki(t.y+t.height,this.y+this.height)-n:this.height=t.height,this.x=e,this.y=n},t.prototype.applyTransform=function(e){t.applyTransform(this,this,e)},t.prototype.calculateTransform=function(t){var e=this,n=t.width/e.width,i=t.height/e.height,r=[1,0,0,1,0,0];return qi(r,r,[-e.x,-e.y]),function(t,e,n){var i=n[0],r=n[1];t[0]=e[0]*i,t[1]=e[1]*r,t[2]=e[2]*i,t[3]=e[3]*r,t[4]=e[4]*i,t[5]=e[5]*r}(r,r,[n,i]),qi(r,r,[t.x,t.y]),r},t.prototype.intersect=function(e,n,i){return t.intersect(this,e,n,i)},t.intersect=function(e,n,i,r){i&&Yi.set(i,0,0);var o=r&&r.outIntersectRect||null,a=r&&r.clamp;if(o&&(o.x=o.y=o.width=o.height=NaN),!e||!n)return!1;e instanceof t||(e=t.set(ur,e.x,e.y,e.width,e.height)),n instanceof t||(n=t.set(hr,n.x,n.y,n.width,n.height));var s=!!i;or.reset(r,s);var l=or.touchThreshold,c=e.x+l,u=e.x+e.width-l,h=e.y+l,d=e.y+e.height-l,p=n.x+l,f=n.x+n.width-l,g=n.y+l,v=n.y+n.height-l;if(c>u||h>d||p>f||g>v)return!1;var m=!(u=t.x&&e<=t.x+t.width&&n>=t.y&&n<=t.y+t.height},t.prototype.contain=function(e,n){return t.contain(this,e,n)},t.prototype.clone=function(){return new t(this.x,this.y,this.width,this.height)},t.prototype.copy=function(e){t.copy(this,e)},t.prototype.plain=function(){return{x:this.x,y:this.y,width:this.width,height:this.height}},t.prototype.isFinite=function(){return isFinite(this.x)&&isFinite(this.y)&&isFinite(this.width)&&isFinite(this.height)},t.prototype.isZero=function(){return 0===this.width||0===this.height},t.create=function(e){return new t(e.x,e.y,e.width,e.height)},t.copy=function(t,e){return t.x=e.x,t.y=e.y,t.width=e.width,t.height=e.height,t},t.applyTransform=function(e,n,i){if(i){if(i[1]<1e-5&&i[1]>-1e-5&&i[2]<1e-5&&i[2]>-1e-5){var r=i[0],o=i[3],a=i[4],s=i[5];return e.x=n.x*r+a,e.y=n.y*o+s,e.width=n.width*r,e.height=n.height*o,e.width<0&&(e.x+=e.width,e.width=-e.width),void(e.height<0&&(e.y+=e.height,e.height=-e.height))}er.x=ir.x=n.x,er.y=rr.y=n.y,nr.x=rr.x=n.x+n.width,nr.y=ir.y=n.y+n.height,er.transform(i),rr.transform(i),nr.transform(i),ir.transform(i),e.x=Zi(er.x,nr.x,ir.x,rr.x),e.y=Zi(er.y,nr.y,ir.y,rr.y);var l=Ki(er.x,nr.x,ir.x,rr.x),c=Ki(er.y,nr.y,ir.y,rr.y);e.width=l-e.x,e.height=c-e.y}else e!==n&&t.copy(e,n)},t}(),ur=new cr(0,0,0,0),hr=new cr(0,0,0,0);function dr(t,e,n,i,r,o,a,s){var l=Qi(e-n),c=Qi(i-t),u=Zi(l,c),h=Ji[r],d=Ji[1-r],p=tr[r];e=c||!or.bidirectional)&&(ar[h]=-c,ar[d]=0,or.useDir&&or.calcDirMTV())))}function pr(){var t=0,e=new Yi,n=new Yi,i={minTv:new Yi,maxTv:new Yi,useDir:!1,dirMinTv:new Yi,touchThreshold:0,bidirectional:!0,negativeSize:!1,reset:function(r,o){i.touchThreshold=0,r&&null!=r.touchThreshold&&(i.touchThreshold=Ki(0,r.touchThreshold)),i.negativeSize=!1,o&&(i.minTv.set(1/0,1/0),i.maxTv.set(0,0),i.useDir=!1,r&&null!=r.direction&&(i.useDir=!0,i.dirMinTv.copy(i.minTv),n.copy(i.minTv),t=r.direction,i.bidirectional=null==r.bidirectional||!!r.bidirectional,i.bidirectional||e.set(Math.cos(t),Math.sin(t))))},calcDirMTV:function(){var o=i.minTv,a=i.dirMinTv,s=o.y*o.y+o.x*o.x,l=Math.sin(t),c=Math.cos(t),u=l*o.y+c*o.x;r(u)?r(o.x)&&r(o.y)&&a.set(0,0):(n.x=s*c/u,n.y=s*l/u,r(n.x)&&r(n.y)?a.set(0,0):(i.bidirectional||e.dot(n)>0)&&n.len()=0;c--){var u=i[c];u===n||u.ignore||u.ignoreCoarsePointer||u.parent&&u.parent.ignoreCoarsePointer||(_r.copy(u.getBoundingRect()),u.transform&&_r.applyTransform(u.transform),_r.intersect(l)&&o.push(u))}if(o.length)for(var h=Math.PI/12,d=2*Math.PI,p=0;p=0;o--){var a=t[o],s=void 0;if(a!==r&&!a.ignore&&(s=xr(a,n,i))&&(!e.topTarget&&(e.topTarget=a),s!==fr)){e.target=a;break}}}function Sr(t,e,n){var i=t.painter;return e<0||e>i.getWidth()||n<0||n>i.getHeight()}Cn(["click","mousedown","mouseup","mousewheel","dblclick","contextmenu"],function(t){br.prototype[t]=function(e){var n,i,r=e.zrX,o=e.zrY,a=Sr(this,r,o);if("mouseup"===t&&a||(i=(n=this.findHover(r,o)).target),"mousedown"===t)this._downEl=i,this._downPoint=[e.zrX,e.zrY],this._upEl=i;else if("mouseup"===t)this._upEl=i;else if("click"===t){if(this._downEl!==this._upEl||!this._downPoint||pi(this._downPoint,[e.zrX,e.zrY])>4)return;this._downPoint=null}this.dispatchToElement(n,t,e)}});function Cr(t,e,n,i){var r=e+1;if(r===n)return 1;if(i(t[r++],t[e])<0){for(;r=0;)r++;return r-e}function kr(t,e,n,i,r){for(i===e&&i++;i>>1])<0?l=o:s=o+1;var c=i-s;switch(c){case 3:t[s+3]=t[s+2];case 2:t[s+2]=t[s+1];case 1:t[s+1]=t[s];break;default:for(;c>0;)t[s+c]=t[s+c-1],c--}t[s]=a}}function Mr(t,e,n,i,r,o){var a=0,s=0,l=1;if(o(t,e[n+r])>0){for(s=i-r;l0;)a=l,(l=1+(l<<1))<=0&&(l=s);l>s&&(l=s),a+=r,l+=r}else{for(s=r+1;ls&&(l=s);var c=a;a=r-l,l=r-c}for(a++;a>>1);o(t,e[n+u])>0?a=u+1:l=u}return l}function Tr(t,e,n,i,r,o){var a=0,s=0,l=1;if(o(t,e[n+r])<0){for(s=r+1;ls&&(l=s);var c=a;a=r-l,l=r-c}else{for(s=i-r;l=0;)a=l,(l=1+(l<<1))<=0&&(l=s);l>s&&(l=s),a+=r,l+=r}for(a++;a>>1);o(t,e[n+u])<0?l=u:a=u+1}return l}function Ir(t,e){var n,i,r=7,o=0,a=[];function s(s){var l=n[s],c=i[s],u=n[s+1],h=i[s+1];i[s]=c+h,s===o-3&&(n[s+1]=n[s+2],i[s+1]=i[s+2]),o--;var d=Tr(t[u],t,l,c,0,e);l+=d,0!==(c-=d)&&0!==(h=Mr(t[l+c-1],t,u,h,h-1,e))&&(c<=h?function(n,i,o,s){var l=0;for(l=0;l=7||p>=7);if(f)break;g<0&&(g=0),g+=2}if((r=g)<1&&(r=1),1===i){for(l=0;l=0;l--)t[p+l]=t[d+l];return void(t[h]=a[u])}var f=r;for(;;){var g=0,v=0,m=!1;do{if(e(a[u],t[c])<0){if(t[h--]=t[c--],g++,v=0,0===--i){m=!0;break}}else if(t[h--]=a[u--],v++,g=0,1===--s){m=!0;break}}while((g|v)=0;l--)t[p+l]=t[d+l];if(0===i){m=!0;break}}if(t[h--]=a[u--],1===--s){m=!0;break}if(0!==(v=s-Mr(t[c],a,0,s,s-1,e))){for(s-=v,p=(h-=v)+1,d=(u-=v)+1,l=0;l=7||v>=7);if(m)break;f<0&&(f=0),f+=2}(r=f)<1&&(r=1);if(1===s){for(p=(h-=i)+1,d=(c-=i)+1,l=i-1;l>=0;l--)t[p+l]=t[d+l];t[h]=a[u]}else{if(0===s)throw new Error;for(d=h-(s-1),l=0;l1;){var t=o-2;if(t>=1&&i[t-1]<=i[t]+i[t+1]||t>=2&&i[t-2]<=i[t]+i[t-1])i[t-1]i[t+1])break;s(t)}},forceMergeRuns:function(){for(;o>1;){var t=o-2;t>0&&i[t-1]=32;)e|=1&t,t>>=1;return t+e}(r);do{if((o=Cr(t,n,i,e))s&&(l=s),kr(t,n,n+l,n+o,e),o=l}a.pushRun(n,o),a.mergeRuns(),r-=o,n+=o}while(0!==r);a.forceMergeRuns()}}}var Ar=!1;function Pr(){Ar||(Ar=!0,console.warn("z / z2 / zlevel of displayable is invalid, which may cause unexpected errors"))}function Lr(t,e){return t.zlevel===e.zlevel?t.z===e.z?t.z2-e.z2:t.z-e.z:t.zlevel-e.zlevel}var Er,zr=function(){function t(){this._roots=[],this._displayList=[],this._displayListLen=0,this.displayableSortFunc=Lr}return t.prototype.traverse=function(t,e){for(var n=0;n=0&&this._roots.splice(i,1)}},t.prototype.delAllRoots=function(){this._roots=[],this._displayList=[],this._displayListLen=0},t.prototype.getRoots=function(){return this._roots},t.prototype.dispose=function(){this._displayList=null,this._roots=null},t}();Er=Ye.hasGlobalWindow&&(window.requestAnimationFrame&&window.requestAnimationFrame.bind(window)||window.msRequestAnimationFrame&&window.msRequestAnimationFrame.bind(window)||window.mozRequestAnimationFrame||window.webkitRequestAnimationFrame)||function(t){return setTimeout(t,16)};var Nr={linear:function(t){return t},quadraticIn:function(t){return t*t},quadraticOut:function(t){return t*(2-t)},quadraticInOut:function(t){return(t*=2)<1?.5*t*t:-.5*(--t*(t-2)-1)},cubicIn:function(t){return t*t*t},cubicOut:function(t){return--t*t*t+1},cubicInOut:function(t){return(t*=2)<1?.5*t*t*t:.5*((t-=2)*t*t+2)},quarticIn:function(t){return t*t*t*t},quarticOut:function(t){return 1- --t*t*t*t},quarticInOut:function(t){return(t*=2)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2)},quinticIn:function(t){return t*t*t*t*t},quinticOut:function(t){return--t*t*t*t*t+1},quinticInOut:function(t){return(t*=2)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2)},sinusoidalIn:function(t){return 1-Math.cos(t*Math.PI/2)},sinusoidalOut:function(t){return Math.sin(t*Math.PI/2)},sinusoidalInOut:function(t){return.5*(1-Math.cos(Math.PI*t))},exponentialIn:function(t){return 0===t?0:Math.pow(1024,t-1)},exponentialOut:function(t){return 1===t?1:1-Math.pow(2,-10*t)},exponentialInOut:function(t){return 0===t?0:1===t?1:(t*=2)<1?.5*Math.pow(1024,t-1):.5*(2-Math.pow(2,-10*(t-1)))},circularIn:function(t){return 1-Math.sqrt(1-t*t)},circularOut:function(t){return Math.sqrt(1- --t*t)},circularInOut:function(t){return(t*=2)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1)},elasticIn:function(t){var e,n=.1;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=.4*Math.asin(1/n)/(2*Math.PI),-n*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/.4))},elasticOut:function(t){var e,n=.1;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=.4*Math.asin(1/n)/(2*Math.PI),n*Math.pow(2,-10*t)*Math.sin((t-e)*(2*Math.PI)/.4)+1)},elasticInOut:function(t){var e,n=.1,i=.4;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=i*Math.asin(1/n)/(2*Math.PI),(t*=2)<1?n*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/i)*-.5:n*Math.pow(2,-10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/i)*.5+1)},backIn:function(t){var e=1.70158;return t*t*((e+1)*t-e)},backOut:function(t){var e=1.70158;return--t*t*((e+1)*t+e)+1},backInOut:function(t){var e=2.5949095;return(t*=2)<1?t*t*((e+1)*t-e)*.5:.5*((t-=2)*t*((e+1)*t+e)+2)},bounceIn:function(t){return 1-Nr.bounceOut(1-t)},bounceOut:function(t){return t<1/2.75?7.5625*t*t:t<2/2.75?7.5625*(t-=1.5/2.75)*t+.75:t<2.5/2.75?7.5625*(t-=2.25/2.75)*t+.9375:7.5625*(t-=2.625/2.75)*t+.984375},bounceInOut:function(t){return t<.5?.5*Nr.bounceIn(2*t):.5*Nr.bounceOut(2*t-1)+.5}},Or=Math.pow,Rr=Math.sqrt,$r=1e-8,Hr=1e-4,Fr=Rr(3),Br=1/3,Vr=ai(),Wr=ai(),Ur=ai();function Gr(t){return t>-1e-8&&t<$r}function qr(t){return t>$r||t<-1e-8}function jr(t,e,n,i,r){var o=1-r;return o*o*(o*t+3*r*e)+r*r*(r*i+3*o*n)}function Xr(t,e,n,i,r){var o=1-r;return 3*(((e-t)*o+2*(n-e)*r)*o+(i-n)*r*r)}function Yr(t,e,n,i,r,o){var a=i+3*(e-n)-t,s=3*(n-2*e+t),l=3*(e-t),c=t-r,u=s*s-3*a*l,h=s*l-9*a*c,d=l*l-3*s*c,p=0;if(Gr(u)&&Gr(h)){if(Gr(s))o[0]=0;else(C=-l/s)>=0&&C<=1&&(o[p++]=C)}else{var f=h*h-4*u*d;if(Gr(f)){var g=h/u,v=-g/2;(C=-s/a+g)>=0&&C<=1&&(o[p++]=C),v>=0&&v<=1&&(o[p++]=v)}else if(f>0){var m=Rr(f),y=u*s+1.5*a*(-h+m),_=u*s+1.5*a*(-h-m);(C=(-s-((y=y<0?-Or(-y,Br):Or(y,Br))+(_=_<0?-Or(-_,Br):Or(_,Br))))/(3*a))>=0&&C<=1&&(o[p++]=C)}else{var b=(2*u*s-3*a*h)/(2*Rr(u*u*u)),x=Math.acos(b)/3,w=Rr(u),S=Math.cos(x),C=(-s-2*w*S)/(3*a),k=(v=(-s+w*(S+Fr*Math.sin(x)))/(3*a),(-s+w*(S-Fr*Math.sin(x)))/(3*a));C>=0&&C<=1&&(o[p++]=C),v>=0&&v<=1&&(o[p++]=v),k>=0&&k<=1&&(o[p++]=k)}}return p}function Zr(t,e,n,i,r){var o=6*n-12*e+6*t,a=9*e+3*i-3*t-9*n,s=3*e-3*t,l=0;if(Gr(a)){if(qr(o))(u=-s/o)>=0&&u<=1&&(r[l++]=u)}else{var c=o*o-4*a*s;if(Gr(c))r[0]=-o/(2*a);else if(c>0){var u,h=Rr(c),d=(-o-h)/(2*a);(u=(-o+h)/(2*a))>=0&&u<=1&&(r[l++]=u),d>=0&&d<=1&&(r[l++]=d)}}return l}function Kr(t,e,n,i,r,o){var a=(e-t)*r+t,s=(n-e)*r+e,l=(i-n)*r+n,c=(s-a)*r+a,u=(l-s)*r+s,h=(u-c)*r+c;o[0]=t,o[1]=a,o[2]=c,o[3]=h,o[4]=h,o[5]=u,o[6]=l,o[7]=i}function Qr(t,e,n,i,r,o,a,s,l){for(var c=t,u=e,h=0,d=1/l,p=1;p<=l;p++){var f=p*d,g=jr(t,n,r,a,f),v=jr(e,i,o,s,f),m=g-c,y=v-u;h+=Math.sqrt(m*m+y*y),c=g,u=v}return h}function Jr(t,e,n,i){var r=1-i;return r*(r*t+2*i*e)+i*i*n}function to(t,e,n,i){return 2*((1-i)*(e-t)+i*(n-e))}function eo(t,e,n){var i=t+n-2*e;return 0===i?.5:(t-e)/i}function no(t,e,n,i,r){var o=(e-t)*i+t,a=(n-e)*i+e,s=(a-o)*i+o;r[0]=t,r[1]=o,r[2]=s,r[3]=s,r[4]=a,r[5]=n}function io(t,e,n,i,r,o,a){for(var s=t,l=e,c=0,u=1/a,h=1;h<=a;h++){var d=h*u,p=Jr(t,n,r,d),f=Jr(e,i,o,d),g=p-s,v=f-l;c+=Math.sqrt(g*g+v*v),s=p,l=f}return c}var ro=/cubic-bezier\(([0-9,\.e ]+)\)/;function oo(t){var e=t&&ro.exec(t);if(e){var n=e[1].split(","),i=+Xn(n[0]),r=+Xn(n[1]),o=+Xn(n[2]),a=+Xn(n[3]);if(isNaN(i+r+o+a))return;var s=[];return function(t){return t<=0?0:t>=1?1:Yr(0,i,o,1,t,s)&&jr(0,r,a,1,s[0])}}}var ao=function(){function t(t){this._inited=!1,this._startTime=0,this._pausedTime=0,this._paused=!1,this._life=t.life||1e3,this._delay=t.delay||0,this.loop=t.loop||!1,this.onframe=t.onframe||ri,this.ondestroy=t.ondestroy||ri,this.onrestart=t.onrestart||ri,t.easing&&this.setEasing(t.easing)}return t.prototype.step=function(t,e){if(this._inited||(this._startTime=t+this._delay,this._inited=!0),!this._paused){var n=this._life,i=t-this._startTime-this._pausedTime,r=i/n;r<0&&(r=0),r=Math.min(r,1);var o=this.easingFunc,a=o?o(r):r;if(this.onframe(a),1===r){if(!this.loop)return!0;var s=i%n;this._startTime=t-s,this._pausedTime=0,this.onrestart()}return!1}this._pausedTime+=e},t.prototype.pause=function(){this._paused=!0},t.prototype.resume=function(){this._paused=!1},t.prototype.setEasing=function(t){this.easing=t,this.easingFunc=Ln(t)?t:Nr[t]||oo(t)},t}(),so=function(t){this.value=t},lo=function(){function t(){this._len=0}return t.prototype.insert=function(t){var e=new so(t);return this.insertEntry(e),e},t.prototype.insertEntry=function(t){this.head?(this.tail.next=t,t.prev=this.tail,t.next=null,this.tail=t):this.head=this.tail=t,this._len++},t.prototype.remove=function(t){var e=t.prev,n=t.next;e?e.next=n:this.head=n,n?n.prev=e:this.tail=e,t.next=t.prev=null,this._len--},t.prototype.len=function(){return this._len},t.prototype.clear=function(){this.head=this.tail=null,this._len=0},t}(),co=function(){function t(t){this._list=new lo,this._maxSize=10,this._map={},this._maxSize=t}return t.prototype.put=function(t,e){var n=this._list,i=this._map,r=null;if(null==i[t]){var o=n.len(),a=this._lastRemovedEntry;if(o>=this._maxSize&&o>0){var s=n.head;n.remove(s),delete i[s.key],r=s.value,this._lastRemovedEntry=s}a?a.value=e:a=new so(e),a.key=t,n.insertEntry(a),i[t]=a}return r},t.prototype.get=function(t){var e=this._map[t],n=this._list;if(null!=e)return e!==n.tail&&(n.remove(e),n.insertEntry(e)),e.value},t.prototype.clear=function(){this._list.clear(),this._map={}},t.prototype.len=function(){return this._list.len()},t}(),uo={transparent:[0,0,0,0],aliceblue:[240,248,255,1],antiquewhite:[250,235,215,1],aqua:[0,255,255,1],aquamarine:[127,255,212,1],azure:[240,255,255,1],beige:[245,245,220,1],bisque:[255,228,196,1],black:[0,0,0,1],blanchedalmond:[255,235,205,1],blue:[0,0,255,1],blueviolet:[138,43,226,1],brown:[165,42,42,1],burlywood:[222,184,135,1],cadetblue:[95,158,160,1],chartreuse:[127,255,0,1],chocolate:[210,105,30,1],coral:[255,127,80,1],cornflowerblue:[100,149,237,1],cornsilk:[255,248,220,1],crimson:[220,20,60,1],cyan:[0,255,255,1],darkblue:[0,0,139,1],darkcyan:[0,139,139,1],darkgoldenrod:[184,134,11,1],darkgray:[169,169,169,1],darkgreen:[0,100,0,1],darkgrey:[169,169,169,1],darkkhaki:[189,183,107,1],darkmagenta:[139,0,139,1],darkolivegreen:[85,107,47,1],darkorange:[255,140,0,1],darkorchid:[153,50,204,1],darkred:[139,0,0,1],darksalmon:[233,150,122,1],darkseagreen:[143,188,143,1],darkslateblue:[72,61,139,1],darkslategray:[47,79,79,1],darkslategrey:[47,79,79,1],darkturquoise:[0,206,209,1],darkviolet:[148,0,211,1],deeppink:[255,20,147,1],deepskyblue:[0,191,255,1],dimgray:[105,105,105,1],dimgrey:[105,105,105,1],dodgerblue:[30,144,255,1],firebrick:[178,34,34,1],floralwhite:[255,250,240,1],forestgreen:[34,139,34,1],fuchsia:[255,0,255,1],gainsboro:[220,220,220,1],ghostwhite:[248,248,255,1],gold:[255,215,0,1],goldenrod:[218,165,32,1],gray:[128,128,128,1],green:[0,128,0,1],greenyellow:[173,255,47,1],grey:[128,128,128,1],honeydew:[240,255,240,1],hotpink:[255,105,180,1],indianred:[205,92,92,1],indigo:[75,0,130,1],ivory:[255,255,240,1],khaki:[240,230,140,1],lavender:[230,230,250,1],lavenderblush:[255,240,245,1],lawngreen:[124,252,0,1],lemonchiffon:[255,250,205,1],lightblue:[173,216,230,1],lightcoral:[240,128,128,1],lightcyan:[224,255,255,1],lightgoldenrodyellow:[250,250,210,1],lightgray:[211,211,211,1],lightgreen:[144,238,144,1],lightgrey:[211,211,211,1],lightpink:[255,182,193,1],lightsalmon:[255,160,122,1],lightseagreen:[32,178,170,1],lightskyblue:[135,206,250,1],lightslategray:[119,136,153,1],lightslategrey:[119,136,153,1],lightsteelblue:[176,196,222,1],lightyellow:[255,255,224,1],lime:[0,255,0,1],limegreen:[50,205,50,1],linen:[250,240,230,1],magenta:[255,0,255,1],maroon:[128,0,0,1],mediumaquamarine:[102,205,170,1],mediumblue:[0,0,205,1],mediumorchid:[186,85,211,1],mediumpurple:[147,112,219,1],mediumseagreen:[60,179,113,1],mediumslateblue:[123,104,238,1],mediumspringgreen:[0,250,154,1],mediumturquoise:[72,209,204,1],mediumvioletred:[199,21,133,1],midnightblue:[25,25,112,1],mintcream:[245,255,250,1],mistyrose:[255,228,225,1],moccasin:[255,228,181,1],navajowhite:[255,222,173,1],navy:[0,0,128,1],oldlace:[253,245,230,1],olive:[128,128,0,1],olivedrab:[107,142,35,1],orange:[255,165,0,1],orangered:[255,69,0,1],orchid:[218,112,214,1],palegoldenrod:[238,232,170,1],palegreen:[152,251,152,1],paleturquoise:[175,238,238,1],palevioletred:[219,112,147,1],papayawhip:[255,239,213,1],peachpuff:[255,218,185,1],peru:[205,133,63,1],pink:[255,192,203,1],plum:[221,160,221,1],powderblue:[176,224,230,1],purple:[128,0,128,1],red:[255,0,0,1],rosybrown:[188,143,143,1],royalblue:[65,105,225,1],saddlebrown:[139,69,19,1],salmon:[250,128,114,1],sandybrown:[244,164,96,1],seagreen:[46,139,87,1],seashell:[255,245,238,1],sienna:[160,82,45,1],silver:[192,192,192,1],skyblue:[135,206,235,1],slateblue:[106,90,205,1],slategray:[112,128,144,1],slategrey:[112,128,144,1],snow:[255,250,250,1],springgreen:[0,255,127,1],steelblue:[70,130,180,1],tan:[210,180,140,1],teal:[0,128,128,1],thistle:[216,191,216,1],tomato:[255,99,71,1],turquoise:[64,224,208,1],violet:[238,130,238,1],wheat:[245,222,179,1],white:[255,255,255,1],whitesmoke:[245,245,245,1],yellow:[255,255,0,1],yellowgreen:[154,205,50,1]};function ho(t){return(t=Math.round(t))<0?0:t>255?255:t}function po(t){return t<0?0:t>1?1:t}function fo(t){var e=t;return e.length&&"%"===e.charAt(e.length-1)?ho(parseFloat(e)/100*255):ho(parseInt(e,10))}function go(t){var e=t;return e.length&&"%"===e.charAt(e.length-1)?po(parseFloat(e)/100):po(parseFloat(e))}function vo(t,e,n){return n<0?n+=1:n>1&&(n-=1),6*n<1?t+(e-t)*n*6:2*n<1?e:3*n<2?t+(e-t)*(2/3-n)*6:t}function mo(t,e,n){return t+(e-t)*n}function yo(t,e,n,i,r){return t[0]=e,t[1]=n,t[2]=i,t[3]=r,t}function _o(t,e){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t}var bo=new co(20),xo=null;function wo(t,e){xo&&_o(xo,e),xo=bo.put(t,xo||e.slice())}function So(t,e){if(t){e=e||[];var n=bo.get(t);if(n)return _o(e,n);var i=(t+="").replace(/ /g,"").toLowerCase();if(i in uo)return _o(e,uo[i]),wo(t,e),e;var r,o=i.length;if("#"===i.charAt(0))return 4===o||5===o?(r=parseInt(i.slice(1,4),16))>=0&&r<=4095?(yo(e,(3840&r)>>4|(3840&r)>>8,240&r|(240&r)>>4,15&r|(15&r)<<4,5===o?parseInt(i.slice(4),16)/15:1),wo(t,e),e):void yo(e,0,0,0,1):7===o||9===o?(r=parseInt(i.slice(1,7),16))>=0&&r<=16777215?(yo(e,(16711680&r)>>16,(65280&r)>>8,255&r,9===o?parseInt(i.slice(7),16)/255:1),wo(t,e),e):void yo(e,0,0,0,1):void 0;var a=i.indexOf("("),s=i.indexOf(")");if(-1!==a&&s+1===o){var l=i.substr(0,a),c=i.substr(a+1,s-(a+1)).split(","),u=1;switch(l){case"rgba":if(4!==c.length)return 3===c.length?yo(e,+c[0],+c[1],+c[2],1):yo(e,0,0,0,1);u=go(c.pop());case"rgb":return c.length>=3?(yo(e,fo(c[0]),fo(c[1]),fo(c[2]),3===c.length?u:go(c[3])),wo(t,e),e):void yo(e,0,0,0,1);case"hsla":return 4!==c.length?void yo(e,0,0,0,1):(c[3]=go(c[3]),Co(c,e),wo(t,e),e);case"hsl":return 3!==c.length?void yo(e,0,0,0,1):(Co(c,e),wo(t,e),e);default:return}}yo(e,0,0,0,1)}}function Co(t,e){var n=(parseFloat(t[0])%360+360)%360/360,i=go(t[1]),r=go(t[2]),o=r<=.5?r*(i+1):r+i-r*i,a=2*r-o;return yo(e=e||[],ho(255*vo(a,o,n+1/3)),ho(255*vo(a,o,n)),ho(255*vo(a,o,n-1/3)),1),4===t.length&&(e[3]=t[3]),e}function ko(t,e){var n=So(t);if(n){for(var i=0;i<3;i++)n[i]=n[i]*(1-e)|0,n[i]>255?n[i]=255:n[i]<0&&(n[i]=0);return To(n,4===n.length?"rgba":"rgb")}}function Mo(t,e,n,i){var r=So(t);if(t)return r=function(t){if(t){var e,n,i=t[0]/255,r=t[1]/255,o=t[2]/255,a=Math.min(i,r,o),s=Math.max(i,r,o),l=s-a,c=(s+a)/2;if(0===l)e=0,n=0;else{n=c<.5?l/(s+a):l/(2-s-a);var u=((s-i)/6+l/2)/l,h=((s-r)/6+l/2)/l,d=((s-o)/6+l/2)/l;i===s?e=d-h:r===s?e=1/3+u-d:o===s&&(e=2/3+h-u),e<0&&(e+=1),e>1&&(e-=1)}var p=[360*e,n,c];return null!=t[3]&&p.push(t[3]),p}}(r),null!=n&&(r[1]=go(Ln(n)?n(r[1]):n)),null!=i&&(r[2]=go(Ln(i)?i(r[2]):i)),To(Co(r),"rgba")}function To(t,e){if(t&&t.length){var n=t[0]+","+t[1]+","+t[2];return"rgba"!==e&&"hsva"!==e&&"hsla"!==e||(n+=","+t[3]),e+"("+n+")"}}function Io(t,e){var n=So(t);return n?(.299*n[0]+.587*n[1]+.114*n[2])*n[3]/255+(1-n[3])*e:0}var Do=new co(100);function Ao(t){if(En(t)){var e=Do.get(t);return e||(e=ko(t,-.1),Do.put(t,e)),e}if(Fn(t)){var n=_n({},t);return n.colorStops=kn(t.colorStops,function(t){return{offset:t.offset,color:ko(t.color,-.1)}}),n}return t}var Po=Math.round;function Lo(t){var e;if(t&&"transparent"!==t){if("string"==typeof t&&t.indexOf("rgba")>-1){var n=So(t);n&&(t="rgb("+n[0]+","+n[1]+","+n[2]+")",e=n[3])}}else t="none";return{color:t,opacity:null==e?1:e}}var Eo=1e-4;function zo(t){return t-1e-4}function No(t){return Po(1e3*t)/1e3}function Oo(t){return Po(1e4*t)/1e4}var Ro={left:"start",right:"end",center:"middle",middle:"middle"};function $o(t){return t&&!!t.image}function Ho(t){return $o(t)||function(t){return t&&!!t.svgElement}(t)}function Fo(t){return"linear"===t.type}function Bo(t){return"radial"===t.type}function Vo(t){return t&&("linear"===t.type||"radial"===t.type)}function Wo(t){return"url(#"+t+")"}function Uo(t){var e=t.getGlobalScale(),n=Math.max(e[0],e[1]);return Math.max(Math.ceil(Math.log(n)/Math.log(10)),1)}function Go(t){var e=t.x||0,n=t.y||0,i=(t.rotation||0)*oi,r=Wn(t.scaleX,1),o=Wn(t.scaleY,1),a=t.skewX||0,s=t.skewY||0,l=[];return(e||n)&&l.push("translate("+e+"px,"+n+"px)"),i&&l.push("rotate("+i+")"),1===r&&1===o||l.push("scale("+r+","+o+")"),(a||s)&&l.push("skew("+Po(a*oi)+"deg, "+Po(s*oi)+"deg)"),l.join(" ")}var qo=Ye.hasGlobalWindow&&Ln(window.btoa)?function(t){return window.btoa(unescape(encodeURIComponent(t)))}:"undefined"!=typeof Buffer?function(t){return Buffer.from(t).toString("base64")}:function(t){return null},jo=Array.prototype.slice;function Xo(t,e,n){return(e-t)*n+t}function Yo(t,e,n,i){for(var r=e.length,o=0;oi?e:t,o=Math.min(n,i),a=r[o-1]||{color:[0,0,0,0],offset:0},s=o;sa)i.length=a;else for(var s=o;s=1},t.prototype.getAdditiveTrack=function(){return this._additiveTrack},t.prototype.addKeyframe=function(t,e,n){this._needsSort=!0;var i=this.keyframes,r=i.length,o=!1,a=6,s=e;if(Sn(e)){var l=function(t){return Sn(t&&t[0])?2:1}(e);a=l,(1===l&&!Nn(e[0])||2===l&&!Nn(e[0][0]))&&(o=!0)}else if(Nn(e)&&!Bn(e))a=0;else if(En(e))if(isNaN(+e)){var c=So(e);c&&(s=c,a=3)}else a=0;else if(Fn(e)){var u=_n({},s);u.colorStops=kn(e.colorStops,function(t){return{offset:t.offset,color:So(t.color)}}),Fo(e)?a=4:Bo(e)&&(a=5),s=u}0===r?this.valType=a:a===this.valType&&6!==a||(o=!0),this.discrete=this.discrete||o;var h={time:t,value:s,rawValue:e,percent:0};return n&&(h.easing=n,h.easingFunc=Ln(n)?n:Nr[n]||oo(n)),i.push(h),h},t.prototype.prepare=function(t,e){var n=this.keyframes;this._needsSort&&n.sort(function(t,e){return t.time-e.time});for(var i=this.valType,r=n.length,o=n[r-1],a=this.discrete,s=ia(i),l=na(i),c=0;c=0&&!(l[n].percent<=e);n--);n=p(n,c-2)}else{for(n=d;ne);n++);n=p(n-1,c-2)}r=l[n+1],i=l[n]}if(i&&r){this._lastFr=n,this._lastFrP=e;var f=r.percent-i.percent,g=0===f?1:p((e-i.percent)/f,1);r.easingFunc&&(g=r.easingFunc(g));var v=o?this._additiveValue:h?ra:t[u];if(!ia(s)&&!h||v||(v=this._additiveValue=[]),this.discrete)t[u]=g<1?i.rawValue:r.rawValue;else if(ia(s))1===s?Yo(v,i[a],r[a],g):function(t,e,n,i){for(var r=e.length,o=r&&e[0].length,a=0;a0&&s.addKeyframe(0,ta(l),i),this._trackKeys.push(a)}s.addKeyframe(t,ta(e[a]),i)}return this._maxTime=Math.max(this._maxTime,t),this},t.prototype.pause=function(){this._clip.pause(),this._paused=!0},t.prototype.resume=function(){this._clip.resume(),this._paused=!1},t.prototype.isPaused=function(){return!!this._paused},t.prototype.duration=function(t){return this._maxTime=t,this._force=!0,this},t.prototype._doneCallback=function(){this._setTracksFinished(),this._clip=null;var t=this._doneCbs;if(t)for(var e=t.length,n=0;n0)){this._started=1;for(var e=this,n=[],i=this._maxTime||0,r=0;r1){var a=o.pop();r.addKeyframe(a.time,t[i]),r.prepare(this._maxTime,r.getAdditiveTrack())}}}},t}();function sa(){return(new Date).getTime()}var la,ca,ua=function(t){function e(e){var n=t.call(this)||this;return n._running=!1,n._time=0,n._pausedTime=0,n._pauseStart=0,n._paused=!1,e=e||{},n.stage=e.stage||{},n}return y(e,t),e.prototype.addClip=function(t){t.animation&&this.removeClip(t),this._head?(this._tail.next=t,t.prev=this._tail,t.next=null,this._tail=t):this._head=this._tail=t,t.animation=this},e.prototype.addAnimator=function(t){t.animation=this;var e=t.getClip();e&&this.addClip(e)},e.prototype.removeClip=function(t){if(t.animation){var e=t.prev,n=t.next;e?e.next=n:this._head=n,n?n.prev=e:this._tail=e,t.next=t.prev=t.animation=null}},e.prototype.removeAnimator=function(t){var e=t.getClip();e&&this.removeClip(e),t.animation=null},e.prototype.update=function(t){for(var e=sa()-this._pausedTime,n=e-this._time,i=this._head;i;){var r=i.next;i.step(e,n)?(i.ondestroy(),this.removeClip(i),i=r):i=r}this._time=e,t||(this.trigger("frame",n),this.stage.update&&this.stage.update())},e.prototype._startLoop=function(){var t=this;this._running=!0,Er(function e(){t._running&&(Er(e),!t._paused&&t.update())})},e.prototype.start=function(){this._running||(this._time=sa(),this._pausedTime=0,this._startLoop())},e.prototype.stop=function(){this._running=!1},e.prototype.pause=function(){this._paused||(this._pauseStart=sa(),this._paused=!0)},e.prototype.resume=function(){this._paused&&(this._pausedTime+=sa()-this._pauseStart,this._paused=!1)},e.prototype.clear=function(){for(var t=this._head;t;){var e=t.next;t.prev=t.next=t.animation=null,t=e}this._head=this._tail=null},e.prototype.isFinished=function(){return null==this._head},e.prototype.animate=function(t,e){e=e||{},this.start();var n=new aa(t,e.loop);return this.addAnimator(n),n},e}(bi),ha=Ye.domSupported,da=(ca={pointerdown:1,pointerup:1,pointermove:1,pointerout:1},{mouse:la=["click","dblclick","mousewheel","wheel","mouseout","mouseup","mousedown","mousemove","contextmenu"],touch:["touchstart","touchend","touchmove"],pointer:kn(la,function(t){var e=t.replace("mouse","pointer");return ca.hasOwnProperty(e)?e:t})}),pa=["mousemove","mouseup"],fa=["pointermove","pointerup"],ga=!1;function va(t){var e=t.pointerType;return"pen"===e||"touch"===e}function ma(t){t&&(t.zrByTouch=!0)}function ya(t,e){for(var n=e,i=!1;n&&9!==n.nodeType&&!(i=n.domBelongToZr||n!==e&&n===t.painterRoot);)n=n.parentNode;return i}var _a=function(t,e){this.stopPropagation=ri,this.stopImmediatePropagation=ri,this.preventDefault=ri,this.type=e.type,this.target=this.currentTarget=t.dom,this.pointerType=e.pointerType,this.clientX=e.clientX,this.clientY=e.clientY},ba={mousedown:function(t){t=Ri(this.dom,t),this.__mayPointerCapture=[t.zrX,t.zrY],this.trigger("mousedown",t)},mousemove:function(t){t=Ri(this.dom,t);var e=this.__mayPointerCapture;!e||t.zrX===e[0]&&t.zrY===e[1]||this.__togglePointerCapture(!0),this.trigger("mousemove",t)},mouseup:function(t){t=Ri(this.dom,t),this.__togglePointerCapture(!1),this.trigger("mouseup",t)},mouseout:function(t){ya(this,(t=Ri(this.dom,t)).toElement||t.relatedTarget)||(this.__pointerCapturing&&(t.zrEventControl="no_globalout"),this.trigger("mouseout",t))},wheel:function(t){ga=!0,t=Ri(this.dom,t),this.trigger("mousewheel",t)},mousewheel:function(t){ga||(t=Ri(this.dom,t),this.trigger("mousewheel",t))},touchstart:function(t){ma(t=Ri(this.dom,t)),this.__lastTouchMoment=new Date,this.handler.processGesture(t,"start"),ba.mousemove.call(this,t),ba.mousedown.call(this,t)},touchmove:function(t){ma(t=Ri(this.dom,t)),this.handler.processGesture(t,"change"),ba.mousemove.call(this,t)},touchend:function(t){ma(t=Ri(this.dom,t)),this.handler.processGesture(t,"end"),ba.mouseup.call(this,t),+new Date-+this.__lastTouchMoment<300&&ba.click.call(this,t)},pointerdown:function(t){ba.mousedown.call(this,t)},pointermove:function(t){va(t)||ba.mousemove.call(this,t)},pointerup:function(t){ba.mouseup.call(this,t)},pointerout:function(t){va(t)||ba.mouseout.call(this,t)}};Cn(["click","dblclick","contextmenu"],function(t){ba[t]=function(e){e=Ri(this.dom,e),this.trigger(t,e)}});var xa={pointermove:function(t){va(t)||xa.mousemove.call(this,t)},pointerup:function(t){xa.mouseup.call(this,t)},mousemove:function(t){this.trigger("mousemove",t)},mouseup:function(t){var e=this.__pointerCapturing;this.__togglePointerCapture(!1),this.trigger("mouseup",t),e&&(t.zrEventControl="only_globalout",this.trigger("mouseout",t))}};function wa(t,e){var n=e.domHandlers;Ye.pointerEventsSupported?Cn(da.pointer,function(i){Ca(e,i,function(e){n[i].call(t,e)})}):(Ye.touchEventsSupported&&Cn(da.touch,function(i){Ca(e,i,function(r){n[i].call(t,r),function(t){t.touching=!0,null!=t.touchTimer&&(clearTimeout(t.touchTimer),t.touchTimer=null),t.touchTimer=setTimeout(function(){t.touching=!1,t.touchTimer=null},700)}(e)})}),Cn(da.mouse,function(i){Ca(e,i,function(r){r=Oi(r),e.touching||n[i].call(t,r)})}))}function Sa(t,e){function n(n){Ca(e,n,function(i){i=Oi(i),ya(t,i.target)||(i=function(t,e){return Ri(t.dom,new _a(t,e),!0)}(t,i),e.domHandlers[n].call(t,i))},{capture:!0})}Ye.pointerEventsSupported?Cn(fa,n):Ye.touchEventsSupported||Cn(pa,n)}function Ca(t,e,n,i){t.mounted[e]=n,t.listenerOpts[e]=i,function(t,e,n,i){t.addEventListener(e,n,i)}(t.domTarget,e,n,i)}function ka(t){var e=t.mounted;for(var n in e)e.hasOwnProperty(n)&&$i(t.domTarget,n,e[n],t.listenerOpts[n]);t.mounted={}}var Ma=function(t,e){this.mounted={},this.listenerOpts={},this.touching=!1,this.domTarget=t,this.domHandlers=e},Ta=function(t){function e(e,n){var i=t.call(this)||this;return i.__pointerCapturing=!1,i.dom=e,i.painterRoot=n,i._localHandlerScope=new Ma(e,ba),ha&&(i._globalHandlerScope=new Ma(document,xa)),wa(i,i._localHandlerScope),i}return y(e,t),e.prototype.dispose=function(){ka(this._localHandlerScope),ha&&ka(this._globalHandlerScope)},e.prototype.setCursor=function(t){this.dom.style&&(this.dom.style.cursor=t||"default")},e.prototype.__togglePointerCapture=function(t){if(this.__mayPointerCapture=null,ha&&+this.__pointerCapturing^+t){this.__pointerCapturing=t;var e=this._globalHandlerScope;t?Sa(this,e):ka(e)}},e}(bi),Ia=1;Ye.hasGlobalWindow&&(Ia=Math.max(window.devicePixelRatio||window.screen&&window.screen.deviceXDPI/window.screen.logicalXDPI||1,1));var Da=Ia,Aa="#333",Pa="#ccc",La=Wi,Ea=5e-5;function za(t){return t>Ea||t<-5e-5}var Na,Oa=[],Ra=[],$a=[1,0,0,1,0,0],Ha=Math.abs,Fa=function(){function t(){}var e;return t.prototype.getLocalTransform=function(e){return t.getLocalTransform(this,e)},t.prototype.setPosition=function(t){this.x=t[0],this.y=t[1]},t.prototype.setScale=function(t){this.scaleX=t[0],this.scaleY=t[1]},t.prototype.setSkew=function(t){this.skewX=t[0],this.skewY=t[1]},t.prototype.setOrigin=function(t){this.originX=t[0],this.originY=t[1]},t.prototype.needLocalTransform=function(){return za(this.rotation)||za(this.x)||za(this.y)||za(this.scaleX-1)||za(this.scaleY-1)||za(this.skewX)||za(this.skewY)},t.prototype.updateTransform=function(){var t=this.parent&&this.parent.transform,e=this.needLocalTransform(),n=this.transform;e||t?(n=n||[1,0,0,1,0,0],e?this.getLocalTransform(n):La(n),t&&(e?Gi(n,t,n):Ui(n,t)),this.transform=n,this._resolveGlobalScaleRatio(n)):n&&(La(n),this.invTransform=null)},t.prototype._resolveGlobalScaleRatio=function(t){var e=this.globalScaleRatio;if(null!=e&&1!==e){this.getGlobalScale(Oa);var n=Oa[0]<0?-1:1,i=Oa[1]<0?-1:1,r=((Oa[0]-n)*e+n)/Oa[0]||0,o=((Oa[1]-i)*e+i)/Oa[1]||0;t[0]*=r,t[1]*=r,t[2]*=o,t[3]*=o}this.invTransform=this.invTransform||[1,0,0,1,0,0],Xi(this.invTransform,t)},t.prototype.getComputedTransform=function(){for(var t=this,e=[];t;)e.push(t),t=t.parent;for(;t=e.pop();)t.updateTransform();return this.transform},t.prototype.setLocalTransform=function(t){if(t){var e=t[0]*t[0]+t[1]*t[1],n=t[2]*t[2]+t[3]*t[3],i=Math.atan2(t[1],t[0]),r=Math.PI/2+i-Math.atan2(t[3],t[2]);n=Math.sqrt(n)*Math.cos(r),e=Math.sqrt(e),this.skewX=r,this.skewY=0,this.rotation=-i,this.x=+t[4],this.y=+t[5],this.scaleX=e,this.scaleY=n,this.originX=0,this.originY=0}},t.prototype.decomposeTransform=function(){if(this.transform){var t=this.parent,e=this.transform;t&&t.transform&&(t.invTransform=t.invTransform||[1,0,0,1,0,0],Gi(Ra,t.invTransform,e),e=Ra);var n=this.originX,i=this.originY;(n||i)&&($a[4]=n,$a[5]=i,Gi(Ra,e,$a),Ra[4]-=n,Ra[5]-=i,e=Ra),this.setLocalTransform(e)}},t.prototype.getGlobalScale=function(t){var e=this.transform;return t=t||[],e?(t[0]=Math.sqrt(e[0]*e[0]+e[1]*e[1]),t[1]=Math.sqrt(e[2]*e[2]+e[3]*e[3]),e[0]<0&&(t[0]=-t[0]),e[3]<0&&(t[1]=-t[1]),t):(t[0]=1,t[1]=1,t)},t.prototype.transformCoordToLocal=function(t,e){var n=[t,e],i=this.invTransform;return i&&gi(n,n,i),n},t.prototype.transformCoordToGlobal=function(t,e){var n=[t,e],i=this.transform;return i&&gi(n,n,i),n},t.prototype.getLineScale=function(){var t=this.transform;return t&&Ha(t[0]-1)>1e-10&&Ha(t[3]-1)>1e-10?Math.sqrt(Ha(t[0]*t[3]-t[2]*t[1])):1},t.prototype.copyTransform=function(t){Va(this,t)},t.getLocalTransform=function(t,e){e=e||[];var n=t.originX||0,i=t.originY||0,r=t.scaleX,o=t.scaleY,a=t.anchorX,s=t.anchorY,l=t.rotation||0,c=t.x,u=t.y,h=t.skewX?Math.tan(t.skewX):0,d=t.skewY?Math.tan(-t.skewY):0;if(n||i||a||s){var p=n+a,f=i+s;e[4]=-p*r-h*f*o,e[5]=-f*o-d*p*r}else e[4]=e[5]=0;return e[0]=r,e[3]=o,e[1]=d*r,e[2]=h*o,l&&ji(e,e,l),e[4]+=n+c,e[5]+=i+u,e},t.initDefaultProps=((e=t.prototype).scaleX=e.scaleY=e.globalScaleRatio=1,void(e.x=e.y=e.originX=e.originY=e.skewX=e.skewY=e.rotation=e.anchorX=e.anchorY=0)),t}(),Ba=["x","y","originX","originY","anchorX","anchorY","rotation","scaleX","scaleY","skewX","skewY"];function Va(t,e){for(var n=0;n=Ga)){t=t||Ke;for(var e=[],n=+new Date,i=0;i<=127;i++)e[i]=en.measureText(String.fromCharCode(i),t).width;var r=+new Date-n;return r>16?Ua=Ga:r>2&&Ua++,e}}(t.font),t.asciiWidthMapTried=!0),0<=e&&e<=127?null!=t.asciiWidthMap?t.asciiWidthMap[e]:t.asciiCharWidth:t.stWideCharWidth}function ja(t,e){var n=t.strWidthCache,i=n.get(e);return null==i&&(i=en.measureText(e,t.font).width,n.put(e,i)),i}function Xa(t,e,n,i){var r=ja(Wa(e),t),o=Qa(e),a=Za(0,r,n),s=Ka(0,o,i);return new cr(a,s,r,o)}function Ya(t,e,n,i){var r=((t||"")+"").split("\n");if(1===r.length)return Xa(r[0],e,n,i);for(var o=new cr(0,0,0,0),a=0;a=0?parseFloat(t)/100*e:parseFloat(t):t}function ts(t,e,n){var i=e.position||"inside",r=null!=e.distance?e.distance:5,o=n.height,a=n.width,s=o/2,l=n.x,c=n.y,u="left",h="top";if(i instanceof Array)l+=Ja(i[0],n.width),c+=Ja(i[1],n.height),u=null,h=null;else switch(i){case"left":l-=r,c+=s,u="right",h="middle";break;case"right":l+=r+a,c+=s,h="middle";break;case"top":l+=a/2,c-=r,u="center",h="bottom";break;case"bottom":l+=a/2,c+=o+r,u="center";break;case"inside":l+=a/2,c+=s,u="center",h="middle";break;case"insideLeft":l+=r,c+=s,h="middle";break;case"insideRight":l+=a-r,c+=s,u="right",h="middle";break;case"insideTop":l+=a/2,c+=r,u="center";break;case"insideBottom":l+=a/2,c+=o-r,u="center",h="bottom";break;case"insideTopLeft":l+=r,c+=r;break;case"insideTopRight":l+=a-r,c+=r,u="right";break;case"insideBottomLeft":l+=r,c+=o-r,h="bottom";break;case"insideBottomRight":l+=a-r,c+=o-r,u="right",h="bottom"}return(t=t||{}).x=l,t.y=c,t.align=u,t.verticalAlign=h,t}var es="__zr_normal__",ns=Ba.concat(["ignore"]),is=Mn(Ba,function(t,e){return t[e]=!0,t},{ignore:!1}),rs={},os=new cr(0,0,0,0),as=[],ss=function(){function t(t){this.id=gn(),this.animators=[],this.currentStates=[],this.states={},this._init(t)}return t.prototype._init=function(t){this.attr(t)},t.prototype.drift=function(t,e,n){switch(this.draggable){case"horizontal":e=0;break;case"vertical":t=0}var i=this.transform;i||(i=this.transform=[1,0,0,1,0,0]),i[4]+=t,i[5]+=e,this.decomposeTransform(),this.markRedraw()},t.prototype.beforeUpdate=function(){},t.prototype.afterUpdate=function(){},t.prototype.update=function(){this.updateTransform(),this.__dirty&&this.updateInnerText()},t.prototype.updateInnerText=function(t){var e=this._textContent;if(e&&(!e.ignore||t)){this.textConfig||(this.textConfig={});var n=this.textConfig,i=n.local,r=e.innerTransformable,o=void 0,a=void 0,s=!1;r.parent=i?this:null;var l=!1;r.copyTransform(e);var c=null!=n.position,u=n.autoOverflowArea,h=void 0;if((u||c)&&(h=os,n.layoutRect?h.copy(n.layoutRect):h.copy(this.getBoundingRect()),i||h.applyTransform(this.transform)),c){this.calculateTextPosition?this.calculateTextPosition(rs,n,h):ts(rs,n,h),r.x=rs.x,r.y=rs.y,o=rs.align,a=rs.verticalAlign;var d=n.origin;if(d&&null!=n.rotation){var p=void 0,f=void 0;"center"===d?(p=.5*h.width,f=.5*h.height):(p=Ja(d[0],h.width),f=Ja(d[1],h.height)),l=!0,r.originX=-r.x+p+(i?0:h.x),r.originY=-r.y+f+(i?0:h.y)}}null!=n.rotation&&(r.rotation=n.rotation);var g=n.offset;g&&(r.x+=g[0],r.y+=g[1],l||(r.originX=-g[0],r.originY=-g[1]));var v=this._innerTextDefaultStyle||(this._innerTextDefaultStyle={});if(u){var m=v.overflowRect=v.overflowRect||new cr(0,0,0,0);r.getLocalTransform(as),Xi(as,as),cr.copy(m,h),m.applyTransform(as)}else v.overflowRect=null;var y=void 0,_=void 0,b=void 0;(null==n.inside?"string"==typeof n.position&&n.position.indexOf("inside")>=0:n.inside)&&this.canBeInsideText()?(y=n.insideFill,_=n.insideStroke,null!=y&&"auto"!==y||(y=this.getInsideTextFill()),null!=_&&"auto"!==_||(_=this.getInsideTextStroke(y),b=!0)):(y=n.outsideFill,_=n.outsideStroke,null!=y&&"auto"!==y||(y=this.getOutsideFill()),null!=_&&"auto"!==_||(_=this.getOutsideStroke(y),b=!0)),(y=y||"#000")===v.fill&&_===v.stroke&&b===v.autoStroke&&o===v.align&&a===v.verticalAlign||(s=!0,v.fill=y,v.stroke=_,v.autoStroke=b,v.align=o,v.verticalAlign=a,e.setDefaultTextStyle(v)),e.__dirty|=1,s&&e.dirtyStyle(!0)}},t.prototype.canBeInsideText=function(){return!0},t.prototype.getInsideTextFill=function(){return"#fff"},t.prototype.getInsideTextStroke=function(t){return"#000"},t.prototype.getOutsideFill=function(){return this.__zr&&this.__zr.isDarkMode()?Pa:Aa},t.prototype.getOutsideStroke=function(t){var e=this.__zr&&this.__zr.getBackgroundColor(),n="string"==typeof e&&So(e);n||(n=[255,255,255,1]);for(var i=n[3],r=this.__zr.isDarkMode(),o=0;o<3;o++)n[o]=n[o]*i+(r?0:255)*(1-i);return n[3]=1,To(n,"rgba")},t.prototype.traverse=function(t,e){},t.prototype.attrKV=function(t,e){"textConfig"===t?this.setTextConfig(e):"textContent"===t?this.setTextContent(e):"clipPath"===t?this.setClipPath(e):"extra"===t?(this.extra=this.extra||{},_n(this.extra,e)):this[t]=e},t.prototype.hide=function(){this.ignore=!0,this.markRedraw()},t.prototype.show=function(){this.ignore=!1,this.markRedraw()},t.prototype.attr=function(t,e){if("string"==typeof t)this.attrKV(t,e);else if(On(t))for(var n=In(t),i=0;i0},t.prototype.getState=function(t){return this.states[t]},t.prototype.ensureState=function(t){var e=this.states;return e[t]||(e[t]={}),e[t]},t.prototype.clearStates=function(t){this.useState(es,!1,t)},t.prototype.useState=function(t,e,n,i){var r=t===es;if(this.hasState()||!r){var o=this.currentStates,a=this.stateTransition;if(!(xn(o,t)>=0)||!e&&1!==o.length){var s;if(this.stateProxy&&!r&&(s=this.stateProxy(t)),s||(s=this.states&&this.states[t]),s||r){r||this.saveCurrentToNormalState(s);var l=!!(s&&s.hoverLayer||i);l&&this._toggleHoverLayerFlag(!0),this._applyStateObj(t,s,this._normalState,e,!n&&!this.__inHover&&a&&a.duration>0,a);var c=this._textContent,u=this._textGuide;return c&&c.useState(t,e,n,l),u&&u.useState(t,e,n,l),r?(this.currentStates=[],this._normalState={}):e?this.currentStates.push(t):this.currentStates=[t],this._updateAnimationTargets(),this.markRedraw(),!l&&this.__inHover&&(this._toggleHoverLayerFlag(!1),this.__dirty&=-2),s}vn("State "+t+" not exists.")}}},t.prototype.useStates=function(t,e,n){if(t.length){var i=[],r=this.currentStates,o=t.length,a=o===r.length;if(a)for(var s=0;s0,p);var f=this._textContent,g=this._textGuide;f&&f.useStates(t,e,h),g&&g.useStates(t,e,h),this._updateAnimationTargets(),this.currentStates=t.slice(),this.markRedraw(),!h&&this.__inHover&&(this._toggleHoverLayerFlag(!1),this.__dirty&=-2)}else this.clearStates()},t.prototype.isSilent=function(){for(var t=this;t;){if(t.silent)return!0;var e=t.__hostTarget;t=e?t.ignoreHostSilent?null:e:t.parent}return!1},t.prototype._updateAnimationTargets=function(){for(var t=0;t=0){var n=this.currentStates.slice();n.splice(e,1),this.useStates(n)}},t.prototype.replaceState=function(t,e,n){var i=this.currentStates.slice(),r=xn(i,t),o=xn(i,e)>=0;r>=0?o?i.splice(r,1):i[r]=e:n&&!o&&i.push(e),this.useStates(i)},t.prototype.toggleState=function(t,e){e?this.useState(t,!0):this.removeState(t)},t.prototype._mergeStates=function(t){for(var e,n={},i=0;i=0&&e.splice(n,1)}),this.animators.push(t),n&&n.animation.addAnimator(t),n&&n.wakeUp()},t.prototype.updateDuringAnimation=function(t){this.markRedraw()},t.prototype.stopAnimation=function(t,e){for(var n=this.animators,i=n.length,r=[],o=0;o0&&n.during&&o[0].during(function(t,e){n.during(e)});for(var d=0;d0||r.force&&!a.length){var w,S=void 0,C=void 0,k=void 0;if(s){C={},d&&(S={});for(b=0;b<_;b++){C[m=g[b]]=n[m],d?S[m]=i[m]:n[m]=i[m]}}else if(d){k={};for(b=0;b<_;b++){k[m=g[b]]=ta(n[m]),us(n,i,m)}}(w=new aa(n,!1,!1,h?Tn(f,function(t){return t.targetName===e}):null)).targetName=e,r.scope&&(w.scope=r.scope),d&&S&&w.whenWithKeys(0,S,g),k&&w.whenWithKeys(0,k,g),w.whenWithKeys(null==c?500:c,s?C:i,g).delay(u||0),t.addAnimator(w,e),a.push(w)}}wn(ss,bi),wn(ss,Fa);var ds=function(t){function e(e){var n=t.call(this)||this;return n.isGroup=!0,n._children=[],n.attr(e),n}return y(e,t),e.prototype.childrenRef=function(){return this._children},e.prototype.children=function(){return this._children.slice()},e.prototype.childAt=function(t){return this._children[t]},e.prototype.childOfName=function(t){for(var e=this._children,n=0;n=0&&(n.splice(i,0,t),this._doAdd(t))}return this},e.prototype.replace=function(t,e){var n=xn(this._children,t);return n>=0&&this.replaceAt(e,n),this},e.prototype.replaceAt=function(t,e){var n=this._children,i=n[e];if(t&&t!==this&&t.parent!==this&&t!==i){n[e]=t,i.parent=null;var r=this.__zr;r&&i.removeSelfFromZr(r),this._doAdd(t)}return this},e.prototype._doAdd=function(t){t.parent&&t.parent.remove(t),t.parent=this;var e=this.__zr;e&&e!==t.__zr&&t.addSelfToZr(e),e&&e.refresh()},e.prototype.remove=function(t){var e=this.__zr,n=this._children,i=xn(n,t);return i<0||(n.splice(i,1),t.parent=null,e&&t.removeSelfFromZr(e),e&&e.refresh()),this},e.prototype.removeAll=function(){for(var t=this._children,e=this.__zr,n=0;n str | None: + """Return the kind of sub-device an identifier names, or None if it names none. + + None rather than an "unknown" string: the caller knows whether it is looking + at something that must be a sub-device, and a sentinel that reads like a kind + is what let an unclassified device render as a device with no type. + """ + if identifier.endswith(f"_{SUB_DEVICE_BESS}"): + return SUB_DEVICE_BESS + if identifier.endswith(f"_{SUB_DEVICE_MID}"): + return SUB_DEVICE_MID + # Infix, not suffix: the node id follows, and it is what distinguishes one + # charger from another on the same panel. + if f"_{SUB_DEVICE_EVSE}_" in identifier: + return SUB_DEVICE_EVSE + return None + def snapshot_to_device_info( snapshot: SpanPanelSnapshot, @@ -57,7 +88,7 @@ def bess_device_info( """ name = f"{panel_name} Battery" return DeviceInfo( - identifiers={(DOMAIN, f"{panel_identifier}_bess")}, + identifiers={(DOMAIN, f"{panel_identifier}_{SUB_DEVICE_BESS}")}, name=name, manufacturer=battery.vendor_name or "Unknown", # `model` is the human designation on both schemas now: v1.0 publishes it as @@ -92,7 +123,7 @@ def mid_device_info( would put the MID one level deeper than its siblings for no reader's benefit. """ return DeviceInfo( - identifiers={(DOMAIN, f"{panel_identifier}_mid")}, + identifiers={(DOMAIN, f"{panel_identifier}_{SUB_DEVICE_MID}")}, name=f"{panel_name} Microgrid Interconnect", manufacturer=mid.vendor_name or "Unknown", model=mid.model or "Microgrid Interconnect Device", @@ -114,7 +145,7 @@ def evse_device_info( name = f"{base_name} ({display_suffix})" if display_suffix else base_name name = f"{panel_name} {name}" return DeviceInfo( - identifiers={(DOMAIN, f"{panel_identifier}_evse_{evse.node_id}")}, + identifiers={(DOMAIN, f"{panel_identifier}_{SUB_DEVICE_EVSE}_{evse.node_id}")}, name=name, manufacturer=evse.vendor_name or "SPAN", model=evse.model or "SPAN Drive", diff --git a/custom_components/span_panel/websocket.py b/custom_components/span_panel/websocket.py index aae65dbb..f2969a60 100644 --- a/custom_components/span_panel/websocket.py +++ b/custom_components/span_panel/websocket.py @@ -13,6 +13,7 @@ from .const import DOMAIN from .helpers import build_panel_unique_id from .id_builder import build_binary_sensor_unique_id +from .util import classify_sub_device_identifier if TYPE_CHECKING: from . import SpanPanelRuntimeData @@ -224,12 +225,19 @@ def _find_config_entry_id(device_entry: dr.DeviceEntry) -> str | None: def _classify_sub_device(device_entry: dr.DeviceEntry) -> str: - """Classify a sub-device as 'bess' or 'evse' based on its identifiers.""" + """Classify a sub-device by its identifiers: 'bess', 'mid', 'evse' or 'unknown'. + + The grammar lives with the builders that write it, in `util`, rather than + being restated here. Restating it is how the MID went out as 'unknown' for a + release — it was added to the writing end and not to this one — and how a + consumer ended up rendering a device with a name and no type. + + 'unknown' is still returned rather than propagated as None, because it is the + documented wire value for this field and a consumer distinguishes on it. + """ for _, identifier in device_entry.identifiers: - if "_bess" in identifier: - return "bess" - if "_evse_" in identifier: - return "evse" + if (kind := classify_sub_device_identifier(identifier)) is not None: + return kind return "unknown" diff --git a/tests/test_device_links.py b/tests/test_device_links.py index 0a4b04e5..de91e06c 100644 --- a/tests/test_device_links.py +++ b/tests/test_device_links.py @@ -27,6 +27,7 @@ from custom_components.span_panel.const import DOMAIN from custom_components.span_panel.util import ( bess_device_info, + classify_sub_device_identifier, evse_device_info, mid_device_info, ) @@ -88,6 +89,35 @@ def _builders() -> list[tuple[str, Any]]: ] +@pytest.mark.parametrize(("label", "build"), _builders(), ids=lambda v: v if isinstance(v, str) else "") +def test_every_builders_identifier_classifies_as_its_own_kind(label: str, build: Any) -> None: + """The writing end and the reading end must agree, for every kind. + + They did not: the topology command restated the identifier grammar instead of + reading it back, so when the MID was added it classified as `unknown` and a + card rendered it as a device with a name and no type. Parametrised over the + same builder list as the link test, so a fourth sub-device added to `util` + without teaching `classify_sub_device_identifier` fails here rather than in + someone's dashboard. + """ + info = build() + identifiers = info.get("identifiers") or set() + kinds = {classify_sub_device_identifier(identifier) for _domain, identifier in identifiers} + + assert kinds == {label}, f"{label} identifier classifies as {kinds}" + + +def test_the_panel_is_not_mistaken_for_a_sub_device() -> None: + """A bare serial names the panel, and the panel is nobody's child. + + The classifier reads suffixes, so the guard worth having is that an + identifier carrying no kind at all comes back as None rather than matching + something by accident. + """ + assert classify_sub_device_identifier("sp3-link-001") is None + assert classify_sub_device_identifier("sim-40t-001") is None + + @pytest.mark.parametrize(("label", "build"), _builders(), ids=lambda v: v if isinstance(v, str) else "") def test_sub_devices_link_by_registry_id(label: str, build: Any) -> None: """Both halves asserted: the new key is set and the old one is gone. diff --git a/websocket-api.md b/websocket-api.md index 79e95fc7..4e74b8f4 100644 --- a/websocket-api.md +++ b/websocket-api.md @@ -6,7 +6,7 @@ AppDaemon scripts, or any WebSocket client connected to Home Assistant. ## `span_panel/panel_topology` Returns the full physical layout of a SPAN panel in a single call — circuits with their breaker slot positions, entity IDs grouped by role (power, energy, -switch, select), and sub-devices (BESS, EVSE) with their entities. +switch, select), and sub-devices (BESS, MID, EVSE) with their entities. A custom card rendering the physical panel needs to know which breaker slot each circuit occupies, which entity provides its power reading, which switch controls its relay, and so on. Without this command, the card would need to query the device registry, entity registry, and individual entity states in separate @@ -117,7 +117,7 @@ relationships explicitly, keyed by circuit UUID, so the card reads a single stru | `device_id` | string | HA device registry ID (echoed from request) | | `device_name` | string | HA device display name | | `circuits` | object | Circuit UUID keyed map (see below) | -| `sub_devices` | object | HA device ID keyed map of BESS/EVSE (see below) | +| `sub_devices` | object | HA device ID keyed map of BESS/MID/EVSE (see below) | #### Circuit Object @@ -153,7 +153,7 @@ current, `switch` is absent for always-on circuits). | Field | Type | Description | | --------------- | ----------- | ------------------------------------- | | `name` | string | HA device display name | -| `type` | string | `bess`, `evse`, or `unknown` | +| `type` | string | `bess`, `mid`, `evse`, or `unknown` | | `manufacturer` | string/null | Device manufacturer | | `model` | string/null | Device model | | `serial_number` | string/null | Device serial number | From 987f29b0bc71f52382a5b21178fda1f9f095f9be Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:21:47 -0700 Subject: [PATCH 011/345] Release 2.1.0, and say that the Home Assistant floor moved v2.0.8 is the last release; sixteen commits of v1.0 schema work have landed since with no version to file them under, and `pyproject.toml` had drifted a patch behind `manifest.json` besides. Both now read 2.1.0. The entry leads with the Home Assistant requirement because it is the only change here a user can be hurt by. 2026.8 replaced the two device-registry calls this integration depends on and the replacements do not exist in 2026.5-2026.7, so no single release runs on both; anyone older stays on 2.0.8 and HACS will not offer them this. That is worth a sentence in a changelog rather than a silent failure to update. Everything else is additive or an improvement, and is written for someone reading release notes rather than a diff: the MID device and its Grid State entity, self-reload on a firmware schema change, `DSM Grid State` becoming sensed rather than inferred, `Grid Islandable` surviving on MID presence, and the enum sensors that used to advertise only "unknown". `battery.model` changing value is called out, because it is the one existing entity whose reading moves. Emphasis in the new section uses underscores to match the file. MD049 is first-wins, so an asterisk above the existing `_must_` lines silently made every one of them a violation. --- CHANGELOG.md | 38 ++++++++++++++++++++++ custom_components/span_panel/manifest.json | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a84ef89a..42b75d54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,44 @@ All notable changes to this project will be documented in this file. +## [2.1.0] - 8/2026 + +Support for the eBus v1.0 (parent/child) data model your panel moves to on firmware r202633 and later, and a clean transition when it does. + +### Requires Home Assistant 2026.8.0 or newer + +This release raises the minimum from 2026.5.4. Home Assistant 2026.8 replaced the two device-registry calls this integration relies on — the old forms stop +working entirely in 2027.8 — and their replacements do not exist in 2026.5 through 2026.7, so there is no version of this release that runs on both. If you are +on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not offer you this release. + +### Added + +- **Your panel's Microgrid Interconnect Device appears as its own device** on the new data model, carrying **Grid State** — the health of the utility supply + itself, which the previous firmware never reported. Everything about it is additive; no existing entity moves or changes id. +- **The integration notices a firmware upgrade and reloads itself.** A panel that becomes v1.0 while Home Assistant is running used to keep reading the tree + with the old parser, reporting every circuit as missing until you reloaded by hand. It now detects the change, reloads, writes a log line, and raises a + one-time notice explaining what changed. +- **Grid-forming device name** as an attribute on the GFE sensor. + +### Changed + +- **`DSM Grid State` is now more trustworthy on the new data model.** It keeps its entity id and all of its history. Previously it was _inferred_ — from the + battery if one was fitted, otherwise from the dominant power source and whether power was crossing the grid connection. It now reads the islanding state the + Microgrid Interconnect Device actually senses. +- **`Grid Islandable` keeps working** across the upgrade. v1.0 publishes no panel-level islandable property, so the entity now reflects whether a Microgrid + Interconnect Device is present, which is how v1.0 says backup capability is detected. +- **Battery model** may read differently after upgrading: the new data model separates the human-readable designation from the SKU, and this entity now shows + the designation. This is a library-level normalisation applied to both data models, so it happens once, at this release, rather than unpredictably during a + firmware update. + +### Fixed + +- **Enum sensors advertise the states they can actually report.** Nine sensors declared only `unknown`, so `DSM Grid State` sitting at `On Grid` showed + "Possible states: Unknown". The lists are now derived from the translations and checked against them by a test. +- **The GFE override button reads the right signal** for deciding when it applies, so it is no longer permanently enabled on the new data model. +- **Sub-devices link to the panel by registry id**, replacing a form Home Assistant deprecated in 2026.8. No user-visible effect; required for the version bump + above. + ## [2.0.8] - 5/2026 ### Fixed diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 0e6c92a8..267b2db6 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -26,7 +26,7 @@ "span-panel-api-schema-0==1.0.0b3", "span-panel-api-schema-1==0.1.0b3" ], - "version": "2.0.8", + "version": "2.1.0", "zeroconf": [ { "type": "_span._tcp.local." diff --git a/pyproject.toml b/pyproject.toml index 7d8e974b..4cacfe27 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span" -version = "2.0.7" +version = "2.1.0" description = "Span Panel Custom Integration for Home Assistant" authors = [{name = "SpanPanel"}] license = {text = "MIT"} diff --git a/uv.lock b/uv.lock index 1c279442..8108e62c 100644 --- a/uv.lock +++ b/uv.lock @@ -2491,7 +2491,7 @@ wheels = [ [[package]] name = "span" -version = "2.0.7" +version = "2.1.0" source = { virtual = "." } dependencies = [ { name = "homeassistant" }, From 6c1bf4d6e1fba60349478005cce3016df14be563 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:09:06 -0700 Subject: [PATCH 012/345] feat(mid): show the firmware and hardware revision the MID publishes r202633 documents model, firmware-version and hardware-version on the MID's info node. mid_device_info could set a model and a serial and nothing else, so a user saw a Microgrid Interconnect card with no firmware row beside a battery that had one -- the battery's identical property having been mapped from the start. The library now carries both (span-panel-api SpanMidSnapshot.software_version / hardware_version); this reads them. Passed through unguarded, exactly as bess_device_info does. DeviceInfo omits a None field and renders an empty string as a present-but-blank row, so `or ""` would invent a firmware row reading empty for a panel that published nothing -- worse than no row, because it asserts the panel answered and the answer was nothing. A test pins that distinction. No schema conditional, deliberately: flat publishes no MID at all, so has_mid keeps every caller of this builder off a flat panel and a branch here would be unreachable code implying a case that cannot arise. The comment saying so originally used the DUAL-SCHEMA token and was caught by test_every_schema_conditional_is_findable, which asserts the exact set of modules that branch on schema -- util.py does not, and the marker is a grep target rather than prose. Reworded rather than widening the list. Corrects a stale comment in the existing card test. It read "the producer publishes no MID model today", which stopped being true when panelbench started valuing info/model; that case is the fallback path now, not the only path. PV is deliberately not included. The integration has no PV device -- PV metadata reaches a user as three diagnostic sensors on the panel -- so consuming SpanPVSnapshot.software_version would mean adding a fourth, which is a new user-visible entity and a product decision rather than a mapping change. --- custom_components/span_panel/util.py | 11 ++++++ tests/test_mid_device.py | 50 +++++++++++++++++++++++++++- uv.lock | 2 +- 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/custom_components/span_panel/util.py b/custom_components/span_panel/util.py index 5ff7aa4b..9462180e 100644 --- a/custom_components/span_panel/util.py +++ b/custom_components/span_panel/util.py @@ -128,6 +128,17 @@ def mid_device_info( manufacturer=mid.vendor_name or "Unknown", model=mid.model or "Microgrid Interconnect Device", serial_number=mid.serial_number, + # Passed through unguarded, exactly as `bess_device_info` does: `DeviceInfo` + # omits a `None` field and renders an empty string as a present-but-blank row, + # so `or ""` here would invent a row for a panel that published nothing. The + # library preserves that distinction for the same reason. + # + # Unguarded on schema, deliberately: r202633 documents both on the MID's `info` + # node, and flat publishes no MID at all, so `has_mid` keeps every caller of this + # builder off a flat panel. A conditional here would be unreachable code implying + # a case that cannot arise. + sw_version=mid.software_version, + hw_version=mid.hardware_version, via_device_id=panel_device_id, ) diff --git a/tests/test_mid_device.py b/tests/test_mid_device.py index b4cbb1f3..ac203365 100644 --- a/tests/test_mid_device.py +++ b/tests/test_mid_device.py @@ -72,8 +72,13 @@ def test_the_mid_becomes_its_own_device_hung_off_the_panel() -> None: assert info["manufacturer"] == "Span" assert info["serial_number"] == "SIM-BESS-40T-001-mid" assert info["via_device_id"] == "panel-device-id" - # The producer publishes no MID model today; the card still needs a legible one. + # A panel that publishes no MID model still needs a legible card. This was written + # when no producer published one at all; panelbench does now, so this is the + # fallback path rather than the only path — `test_the_mid_card_carries_the_identity + # _a_producer_publishes` covers the other. assert info["model"] == "Microgrid Interconnect Device" + assert "sw_version" not in info or info["sw_version"] is None + assert "hw_version" not in info or info["hw_version"] is None def test_the_mid_carries_grid_state_and_nothing_already_surfaced() -> None: @@ -148,3 +153,46 @@ def test_nothing_reads_the_mid_without_checking_it_is_there() -> None: assert create_mid_sensors(_coordinator(), flat) == [] assert _grid_forming_device_name(flat) is None assert "mid" not in detect_capabilities(flat) + + +def test_the_mid_card_carries_the_identity_a_producer_publishes() -> None: + """Model, firmware and hardware revision reach the device card when published. + + r202633 documents all three on the MID's `info` node. Until the library carried the + latter two, `mid_device_info` could set a model and a serial and nothing else, so a + user saw a Microgrid Interconnect card with no firmware row beside a battery that + had one — the battery's identical property having been mapped from the start. + + Not gated on schema. Flat publishes no MID at all, so `has_mid` keeps every caller + of this builder off a flat panel; a guard here would be unreachable code implying a + case that cannot arise. + """ + info = mid_device_info( + "sim-40t-001", + _mid(model="SPAN MID", software_version="sim-mid/v0.1.0", hardware_version="rev1"), + "SPAN Panel", + panel_device_id="panel-device-id", + ) + + assert info["model"] == "SPAN MID" + assert info["sw_version"] == "sim-mid/v0.1.0" + assert info["hw_version"] == "rev1" + + +def test_an_unpublished_revision_omits_the_row_rather_than_blanking_it() -> None: + """`None` and `""` are different to a user, so the library's distinction is kept. + + `DeviceInfo` omits a `None` field and renders an empty string as a present-but-blank + row. Defaulting with `or ""` here would invent a firmware row reading empty for a + panel that published nothing, which is worse than no row: it asserts the panel + answered and the answer was nothing. + """ + info = mid_device_info( + "sim-40t-001", + _mid(software_version=None, hardware_version=None), + "SPAN Panel", + panel_device_id="panel-device-id", + ) + + assert info.get("sw_version") is None + assert info.get("hw_version") is None diff --git a/uv.lock b/uv.lock index 8108e62c..61397ab4 100644 --- a/uv.lock +++ b/uv.lock @@ -2580,7 +2580,7 @@ dev = [ { name = "ruff", specifier = ">=0.15.5" }, { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api-p2/packages/schema-0" }, { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api-p2/packages/schema-1" }, - { name = "twine" }, + { name = "twine", specifier = ">=7.0" }, { name = "types-pyyaml", specifier = ">=6.0.12.20250915" }, { name = "vulture", specifier = ">=2.14" }, ] From 19e555fee0e8f45d26d13aeeaa662f66ce9091ec Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:01:26 -0700 Subject: [PATCH 013/345] test: add adapter distributions and vendored schema fixtures --- requirements_test.txt | 3 + tests/adapter_fixtures.py | 74 +++ tests/fixtures/schema_one_tree.json | 225 ++++++++++ .../fixtures/schema_one_tree_batteryless.json | 214 +++++++++ tests/fixtures/schema_one_tree_no_pv.json | 218 +++++++++ tests/fixtures/schema_zero_types.json | 420 ++++++++++++++++++ 6 files changed, 1154 insertions(+) create mode 100644 tests/adapter_fixtures.py create mode 100644 tests/fixtures/schema_one_tree.json create mode 100644 tests/fixtures/schema_one_tree_batteryless.json create mode 100644 tests/fixtures/schema_one_tree_no_pv.json create mode 100644 tests/fixtures/schema_zero_types.json diff --git a/requirements_test.txt b/requirements_test.txt index 250967b2..0ecdb95d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -3,3 +3,6 @@ pytest-asyncio>=1.3.0 setuptools>=65.7.0 pytest-homeassistant-custom-component==0.13.354 homeassistant==2026.8.0 +span-panel-api==3.0.0b4 +span-panel-api-schema-0==1.0.0b4 +span-panel-api-schema-1==0.1.0b4 diff --git a/tests/adapter_fixtures.py b/tests/adapter_fixtures.py new file mode 100644 index 00000000..473161ca --- /dev/null +++ b/tests/adapter_fixtures.py @@ -0,0 +1,74 @@ +"""Build real adapter field metadata from vendored fixtures. + +The library's own harness compares wire-level deltas between schemas; it cannot +know what this integration declares it reads. These helpers give the +integration's tests real adapter output to check declarations against. + +Uses the real `ebus_sdk.DiscoveredDevice` rather than a stand-in. A +description-only stand-in is not sufficient: `_downstream_lugs_metadata` -> +`find_lugs` reads property *values* via `device.get_property(...)`, so a stand-in +either raises or silently loses the five downstream/feedthrough paths. ebus-sdk +arrives transitively with span-panel-api-schema-1, so the import is free. +""" + +from __future__ import annotations + +import json +import pathlib + +from ebus_sdk.homie import DiscoveredDevice +from span_panel_api.models import FieldMetadata + +_FIXTURES = pathlib.Path(__file__).parent / "fixtures" + + +def _devices(name: str) -> list[DiscoveredDevice]: + """Rebuild discovered devices from a retained-topic capture. + + Mirrors the library's own builder (test_schema_one_devices.py:26-37): + `update_description` parses the JSON string, `update_property` stores each + non-`$` topic as a property value. + """ + tree = json.loads((_FIXTURES / name).read_text()) + devices: list[DiscoveredDevice] = [] + for device_id, topics in tree.items(): + device = DiscoveredDevice(device_id, "ebus") + device.update_description(topics["$description"]) + device.update_state(topics.get("$state", "ready")) + for topic, value in topics.items(): + if topic.startswith("$"): + continue + node, _, prop = topic.partition("/") + if prop: + device.update_property(node, prop, value) + devices.append(device) + return devices + + +def schema_zero_metadata() -> dict[str, FieldMetadata]: + """Field metadata as schema_0 builds it from the flat REST schema.""" + from span_panel_api_schema_0.field_metadata import build_field_metadata + + raw = json.loads((_FIXTURES / "schema_zero_types.json").read_text()) + return build_field_metadata(raw["types"]) + + +def schema_one_metadata() -> dict[str, FieldMetadata]: + """Field metadata as schema_1 builds it from a full parent/child tree.""" + from span_panel_api_schema_1.field_metadata import build_field_metadata + + return build_field_metadata(_devices("schema_one_tree.json")) + + +def schema_one_metadata_batteryless() -> dict[str, FieldMetadata]: + """Build the same tree with the BESS removed — no battery hardware present.""" + from span_panel_api_schema_1.field_metadata import build_field_metadata + + return build_field_metadata(_devices("schema_one_tree_batteryless.json")) + + +def schema_one_metadata_no_pv() -> dict[str, FieldMetadata]: + """Build the same tree with the PV device removed, power-flows still present.""" + from span_panel_api_schema_1.field_metadata import build_field_metadata + + return build_field_metadata(_devices("schema_one_tree_no_pv.json")) diff --git a/tests/fixtures/schema_one_tree.json b/tests/fixtures/schema_one_tree.json new file mode 100644 index 00000000..f6c25916 --- /dev/null +++ b/tests/fixtures/schema_one_tree.json @@ -0,0 +1,225 @@ +{ + "0ab966b95f92a6a51ec548485aa85f54": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "15", + "info/name": "Kitchen Lights", + "info/spaces": "1", + "load-shed/priority": "UNKNOWN", + "meter/active-power": "-121.0", + "meter/current": "1.0083333333333333", + "meter/exported-energy": "2.0166666666666666", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "1", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "573066aaddd7b75114c4563ce3af18c4": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Solar Inverter\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "30", + "connection/feeds-device-id": "pv", + "connection/feeds-device-status": "OK", + "connection/feeds-device-type": "energy.ebus.device.pv", + "info/name": "Solar Inverter", + "info/spaces": "36,38", + "load-shed/priority": "NEVER", + "meter/active-power": "8500.0", + "meter/current": "35.416666666666664", + "meter/exported-energy": "0.0", + "meter/imported-energy": "141.66666666666666", + "pcs/managed": "false", + "pcs/priority": "5", + "switch/relay": "CLOSED", + "switch/relay-controllable": "false", + "switch/relay-requester": "NONE" + }, + "62d0e03897b337b57101aae82f1e9ba2": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "50", + "connection/feeds-device-id": "evse", + "connection/feeds-device-status": "OK", + "connection/feeds-device-type": "energy.ebus.device.evse", + "info/name": "SPAN Drive - Garage", + "info/spaces": "32,34", + "load-shed/priority": "OFF_GRID", + "meter/active-power": "-2410.0", + "meter/current": "10.041666666666666", + "meter/exported-energy": "40.166666666666664", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "3", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "bess": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.bess\", \"name\": \"Battery\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"soc\": {\"name\": \"soc\", \"type\": \"energy.ebus.capability.soc\", \"properties\": {\"soc\": {\"name\": \"State of charge\", \"datatype\": \"float\", \"unit\": \"%\"}, \"soe\": {\"name\": \"State of energy\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"communication-state\": {\"name\": \"Communication state\", \"datatype\": \"enum\", \"format\": \"OK,DEGRADED,LOST,UNKNOWN\"}}}}, \"children\": [\"bess-mid\"], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "info/model": "Example BESS", + "info/nameplate-capacity": "13.5", + "info/vendor-name": "Span", + "meter/active-power": "-3500.0", + "soc/soc": "50.410493827160494", + "soc/soe": "6.805416666666667", + "status/communication-state": "OK" + }, + "bess-mid": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.mid\", \"name\": \"Microgrid Interconnect Device\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}}}, \"grid\": {\"name\": \"grid\", \"type\": \"energy.ebus.capability.grid\", \"properties\": {\"islanding-state\": {\"name\": \"Islanding state of the BESS-integrated grid-forming device\", \"datatype\": \"enum\", \"format\": \"ON_GRID,OFF_GRID,UNKNOWN\"}, \"grid-state\": {\"name\": \"Sensed grid condition\", \"datatype\": \"enum\", \"format\": \"UP,DOWN,DEGRADED,UNKNOWN\"}, \"grid-forming-entity\": {\"name\": \"Identity of the currently grid-forming entity\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"bess\", \"extensions\": []}", + "$state": "ready", + "grid/grid-forming-entity": "GRID", + "grid/grid-state": "UP", + "grid/islanding-state": "ON_GRID", + "info/vendor-name": "Span" + }, + "d3724e0d660ba506aa79c1cafe5d1181": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garage Outlet\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "20", + "info/name": "Garage Outlet", + "info/spaces": "2", + "load-shed/priority": "UNKNOWN", + "meter/active-power": "-122.0", + "meter/current": "1.0166666666666666", + "meter/exported-energy": "2.033333333333333", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "2", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "evse": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "config/max-charge-current": "32", + "config/user-max-charge-current": "32", + "info/firmware-version": "example/v0.1.0", + "info/model": "SPAN Drive", + "info/part-number": "SPN-DRV-001", + "info/serial-number": "SIM-EVSE-example-40t-001", + "info/vendor-name": "SPAN", + "meter/advertised-current": "32.0", + "status/status": "CHARGING", + "switch/lock-state": "LOCKED" + }, + "evse-2": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "config/max-charge-current": "32", + "config/user-max-charge-current": "32", + "info/firmware-version": "example/v0.1.0", + "info/model": "SPAN Drive", + "info/part-number": "SPN-DRV-001", + "info/serial-number": "SIM-EVSE-example-40t-001-2", + "info/vendor-name": "SPAN", + "meter/advertised-current": "32.0", + "status/status": "AVAILABLE", + "switch/lock-state": "UNLOCKED" + }, + "example-40t-001": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Example 40-tab Panel\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"enum\", \"format\": \"MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"data-model-version\": {\"name\": \"eBus data-model version (parent/child schema discriminator)\", \"datatype\": \"string\"}}}, \"door\": {\"name\": \"door\", \"type\": \"energy.ebus.capability.door\", \"properties\": {\"state\": {\"name\": \"Door state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"voltage-a\": {\"name\": \"L1 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}, \"voltage-b\": {\"name\": \"L2 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Main breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"enabled\": {\"name\": \"PCS system enabled\", \"datatype\": \"boolean\"}, \"active\": {\"name\": \"PCS system actively controlling one (or more) loads\", \"datatype\": \"boolean\"}, \"import-limit\": {\"name\": \"The power import limit currently being managed to\", \"datatype\": \"float\", \"unit\": \"A\"}, \"binding-constraint\": {\"name\": \"Which constraint class currently sets the import limit\", \"datatype\": \"enum\", \"format\": \"FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN\"}, \"feed-import-limit\": {\"name\": \"Limit of maximum power feeding the distribution enclosure\", \"datatype\": \"float\", \"unit\": \"A\"}, \"feed-import-limit-enablement\": {\"name\": \"Enablement status of the feed-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"feed-import-limit-active\": {\"name\": \"Is feed-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"operator-import-limit\": {\"name\": \"Operator-imposed maximum import limit\", \"datatype\": \"float\", \"unit\": \"A\"}, \"operator-import-limit-enablement\": {\"name\": \"Enablement status of the operator-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"operator-import-limit-active\": {\"name\": \"Is operator-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"off-grid-import-limit\": {\"name\": \"Off-Grid limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"off-grid-import-limit-enablement\": {\"name\": \"Enablement status of the off-grid-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"off-grid-import-limit-active\": {\"name\": \"Is off-grid-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"requested-import-limit\": {\"name\": \"Requested limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"requested-import-limit-enablement\": {\"name\": \"Enablement status of the requested-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"requested-import-limit-active\": {\"name\": \"Is requested-import-limit currently being enforced?\", \"datatype\": \"boolean\"}}}, \"shed-forecast\": {\"name\": \"shed-forecast\", \"type\": \"energy.ebus.capability.shed-forecast\", \"properties\": {\"total-time-remaining\": {\"name\": \"Estimated total time before all sheddable circuits are shed (off-grid runtime)\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"time-to-priority-shed\": {\"name\": \"Estimated time before the next priority tier is shed\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-total-time-remaining\": {\"name\": \"Estimated total time assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-time-to-priority-shed\": {\"name\": \"Estimated time to next priority shed assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"confidence\": {\"name\": \"Confidence of the shed-forecast estimate\", \"datatype\": \"enum\", \"format\": \"LOW,MEDIUM,HIGH\"}}}, \"shed\": {\"name\": \"shed\", \"type\": \"energy.ebus.capability.shed\", \"properties\": {\"asserted-islanding-state\": {\"name\": \"Consumer-asserted islanding-state (grid-state override during MID/BESS comm-loss)\", \"datatype\": \"enum\", \"format\": \"NONE,ON_GRID,OFF_GRID\", \"settable\": true}, \"policy\": {\"name\": \"Shed policy (algorithm and parameters)\", \"datatype\": \"json\", \"format\": \"{\\\"$id\\\":\\\"soc-priority.v1\\\",\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"algorithm\\\",\\\"parameters\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"algorithm\\\":{\\\"const\\\":\\\"soc-priority.v1\\\"},\\\"parameters\\\":{\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"soc-threshold-shed\\\",\\\"soc-threshold-release\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"soc-threshold-shed\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent below which SOC_THRESHOLD circuits shed\\\"},\\\"soc-threshold-release\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent above which shed SOC_THRESHOLD circuits restore\\\"}}}}}\"}}}, \"power-flows\": {\"name\": \"power-flows\", \"type\": \"energy.ebus.capability.power-flows\", \"properties\": {\"pv\": {\"name\": \"PV power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"battery\": {\"name\": \"Battery/BESS power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"grid\": {\"name\": \"Grid power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"site\": {\"name\": \"Site power flow\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"relay\": {\"name\": \"Main relay\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"ethernet\": {\"name\": \"Is Ethernet network interface operational?\", \"datatype\": \"boolean\"}, \"wifi\": {\"name\": \"Is Wi-Fi network interface operational?\", \"datatype\": \"boolean\"}, \"wifi-ssid\": {\"name\": \"SSID to which Wi-Fi network interface is connected\", \"datatype\": \"string\"}, \"cloud-connection\": {\"name\": \"Device connected to vendor cloud?\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,UNCONNECTED,CONNECTED\"}, \"postal-code\": {\"name\": \"Postal (Zip) code\", \"datatype\": \"string\"}, \"time-zone\": {\"name\": \"Time zone\", \"datatype\": \"string\"}}}}, \"children\": [\"bess\", \"0ab966b95f92a6a51ec548485aa85f54\", \"d3724e0d660ba506aa79c1cafe5d1181\", \"62d0e03897b337b57101aae82f1e9ba2\", \"fe8b85c15bc9610c1b8b4ebc6f82488d\", \"573066aaddd7b75114c4563ce3af18c4\", \"evse\", \"evse-2\", \"lugs-upstream\", \"lugs-downstream\", \"pv\"], \"extensions\": []}", + "$state": "ready", + "breaker/rating": "200", + "door/state": "CLOSED", + "info/data-model-version": "1.0", + "info/firmware-version": "example/v0.1.0", + "info/hardware-version": "rev2", + "info/model": "MAIN_40", + "info/serial-number": "example-40t-001", + "info/vendor-name": "Span", + "meter/voltage-a": "120.0", + "meter/voltage-b": "120.0", + "pcs/active": "false", + "pcs/binding-constraint": "NONE", + "pcs/enabled": "false", + "pcs/feed-import-limit": "0.0", + "pcs/feed-import-limit-active": "false", + "pcs/feed-import-limit-enablement": "UNCONFIGURED", + "pcs/import-limit": "0.0", + "pcs/off-grid-import-limit": "0.0", + "pcs/off-grid-import-limit-active": "false", + "pcs/off-grid-import-limit-enablement": "UNCONFIGURED", + "pcs/operator-import-limit": "0.0", + "pcs/operator-import-limit-active": "false", + "pcs/operator-import-limit-enablement": "UNCONFIGURED", + "pcs/requested-import-limit": "0.0", + "pcs/requested-import-limit-active": "false", + "pcs/requested-import-limit-enablement": "UNCONFIGURED", + "power-flows/battery": "-3500.0", + "power-flows/grid": "-2347.0", + "power-flows/pv": "8500.0", + "power-flows/site": "2653.0", + "shed-forecast/confidence": "HIGH", + "shed-forecast/full-charge-time-to-priority-shed": "3038", + "shed-forecast/full-charge-total-time-remaining": "4320", + "shed-forecast/time-to-priority-shed": "3037", + "shed-forecast/total-time-remaining": "4320", + "shed/asserted-islanding-state": "NONE", + "shed/policy": "{\"algorithm\": \"soc-priority.v1\", \"parameters\": {\"soc-threshold-shed\": 20, \"soc-threshold-release\": 30}}", + "status/cloud-connection": "CONNECTED", + "status/ethernet": "true", + "status/postal-code": "94103", + "status/relay": "CLOSED", + "status/time-zone": "America/Los_Angeles", + "status/wifi": "true" + }, + "fe8b85c15bc9610c1b8b4ebc6f82488d": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "50", + "connection/feeds-device-id": "evse-2", + "connection/feeds-device-status": "OK", + "connection/feeds-device-type": "energy.ebus.device.evse", + "info/name": "SPAN Drive - Driveway", + "info/spaces": "35,37", + "load-shed/priority": "OFF_GRID", + "meter/active-power": "0.0", + "meter/current": "0.0", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "4", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "lugs-downstream": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Downstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "info/direction": "DOWNSTREAM", + "meter/active-power": "-5847.0", + "meter/current-a": "46.46666666666666", + "meter/current-b": "46.474999999999994", + "meter/exported-energy": "141.66666666666666", + "meter/imported-energy": "44.21666666666666" + }, + "lugs-upstream": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Upstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "connection/fed-by-device-id": "bess", + "connection/fed-by-device-status": "OK", + "connection/fed-by-device-type": "energy.ebus.device.bess", + "info/direction": "UPSTREAM", + "meter/active-power": "-5847.0", + "meter/current-a": "46.46666666666666", + "meter/current-b": "46.474999999999994", + "meter/exported-energy": "141.66666666666666", + "meter/imported-energy": "44.21666666666666" + }, + "pv": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.pv\", \"name\": \"Solar\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nominal-power\": {\"name\": \"Nominal power\", \"datatype\": \"float\", \"unit\": \"W\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "info/model": "IQ8PLUS-72-2-US", + "info/nominal-power": "10000.0", + "info/vendor-name": "Enphase" + } +} diff --git a/tests/fixtures/schema_one_tree_batteryless.json b/tests/fixtures/schema_one_tree_batteryless.json new file mode 100644 index 00000000..6baeda2a --- /dev/null +++ b/tests/fixtures/schema_one_tree_batteryless.json @@ -0,0 +1,214 @@ +{ + "0ab966b95f92a6a51ec548485aa85f54": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "15", + "info/name": "Kitchen Lights", + "info/spaces": "1", + "load-shed/priority": "UNKNOWN", + "meter/active-power": "-121.0", + "meter/current": "1.0083333333333333", + "meter/exported-energy": "2.0166666666666666", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "1", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "573066aaddd7b75114c4563ce3af18c4": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Solar Inverter\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "30", + "connection/feeds-device-id": "pv", + "connection/feeds-device-status": "OK", + "connection/feeds-device-type": "energy.ebus.device.pv", + "info/name": "Solar Inverter", + "info/spaces": "36,38", + "load-shed/priority": "NEVER", + "meter/active-power": "8500.0", + "meter/current": "35.416666666666664", + "meter/exported-energy": "0.0", + "meter/imported-energy": "141.66666666666666", + "pcs/managed": "false", + "pcs/priority": "5", + "switch/relay": "CLOSED", + "switch/relay-controllable": "false", + "switch/relay-requester": "NONE" + }, + "62d0e03897b337b57101aae82f1e9ba2": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "50", + "connection/feeds-device-id": "evse", + "connection/feeds-device-status": "OK", + "connection/feeds-device-type": "energy.ebus.device.evse", + "info/name": "SPAN Drive - Garage", + "info/spaces": "32,34", + "load-shed/priority": "OFF_GRID", + "meter/active-power": "-2410.0", + "meter/current": "10.041666666666666", + "meter/exported-energy": "40.166666666666664", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "3", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "bess-mid": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.mid\", \"name\": \"Microgrid Interconnect Device\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}}}, \"grid\": {\"name\": \"grid\", \"type\": \"energy.ebus.capability.grid\", \"properties\": {\"islanding-state\": {\"name\": \"Islanding state of the BESS-integrated grid-forming device\", \"datatype\": \"enum\", \"format\": \"ON_GRID,OFF_GRID,UNKNOWN\"}, \"grid-state\": {\"name\": \"Sensed grid condition\", \"datatype\": \"enum\", \"format\": \"UP,DOWN,DEGRADED,UNKNOWN\"}, \"grid-forming-entity\": {\"name\": \"Identity of the currently grid-forming entity\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"bess\", \"extensions\": []}", + "$state": "ready", + "grid/grid-forming-entity": "GRID", + "grid/grid-state": "UP", + "grid/islanding-state": "ON_GRID", + "info/vendor-name": "Span" + }, + "d3724e0d660ba506aa79c1cafe5d1181": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garage Outlet\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "20", + "info/name": "Garage Outlet", + "info/spaces": "2", + "load-shed/priority": "UNKNOWN", + "meter/active-power": "-122.0", + "meter/current": "1.0166666666666666", + "meter/exported-energy": "2.033333333333333", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "2", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "evse": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "config/max-charge-current": "32", + "config/user-max-charge-current": "32", + "info/firmware-version": "example/v0.1.0", + "info/model": "SPAN Drive", + "info/part-number": "SPN-DRV-001", + "info/serial-number": "SIM-EVSE-example-40t-001", + "info/vendor-name": "SPAN", + "meter/advertised-current": "32.0", + "status/status": "CHARGING", + "switch/lock-state": "LOCKED" + }, + "evse-2": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "config/max-charge-current": "32", + "config/user-max-charge-current": "32", + "info/firmware-version": "example/v0.1.0", + "info/model": "SPAN Drive", + "info/part-number": "SPN-DRV-001", + "info/serial-number": "SIM-EVSE-example-40t-001-2", + "info/vendor-name": "SPAN", + "meter/advertised-current": "32.0", + "status/status": "AVAILABLE", + "switch/lock-state": "UNLOCKED" + }, + "example-40t-001": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Example 40-tab Panel\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"enum\", \"format\": \"MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"data-model-version\": {\"name\": \"eBus data-model version (parent/child schema discriminator)\", \"datatype\": \"string\"}}}, \"door\": {\"name\": \"door\", \"type\": \"energy.ebus.capability.door\", \"properties\": {\"state\": {\"name\": \"Door state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"voltage-a\": {\"name\": \"L1 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}, \"voltage-b\": {\"name\": \"L2 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Main breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"enabled\": {\"name\": \"PCS system enabled\", \"datatype\": \"boolean\"}, \"active\": {\"name\": \"PCS system actively controlling one (or more) loads\", \"datatype\": \"boolean\"}, \"import-limit\": {\"name\": \"The power import limit currently being managed to\", \"datatype\": \"float\", \"unit\": \"A\"}, \"binding-constraint\": {\"name\": \"Which constraint class currently sets the import limit\", \"datatype\": \"enum\", \"format\": \"FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN\"}, \"feed-import-limit\": {\"name\": \"Limit of maximum power feeding the distribution enclosure\", \"datatype\": \"float\", \"unit\": \"A\"}, \"feed-import-limit-enablement\": {\"name\": \"Enablement status of the feed-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"feed-import-limit-active\": {\"name\": \"Is feed-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"operator-import-limit\": {\"name\": \"Operator-imposed maximum import limit\", \"datatype\": \"float\", \"unit\": \"A\"}, \"operator-import-limit-enablement\": {\"name\": \"Enablement status of the operator-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"operator-import-limit-active\": {\"name\": \"Is operator-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"off-grid-import-limit\": {\"name\": \"Off-Grid limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"off-grid-import-limit-enablement\": {\"name\": \"Enablement status of the off-grid-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"off-grid-import-limit-active\": {\"name\": \"Is off-grid-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"requested-import-limit\": {\"name\": \"Requested limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"requested-import-limit-enablement\": {\"name\": \"Enablement status of the requested-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"requested-import-limit-active\": {\"name\": \"Is requested-import-limit currently being enforced?\", \"datatype\": \"boolean\"}}}, \"shed-forecast\": {\"name\": \"shed-forecast\", \"type\": \"energy.ebus.capability.shed-forecast\", \"properties\": {\"total-time-remaining\": {\"name\": \"Estimated total time before all sheddable circuits are shed (off-grid runtime)\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"time-to-priority-shed\": {\"name\": \"Estimated time before the next priority tier is shed\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-total-time-remaining\": {\"name\": \"Estimated total time assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-time-to-priority-shed\": {\"name\": \"Estimated time to next priority shed assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"confidence\": {\"name\": \"Confidence of the shed-forecast estimate\", \"datatype\": \"enum\", \"format\": \"LOW,MEDIUM,HIGH\"}}}, \"shed\": {\"name\": \"shed\", \"type\": \"energy.ebus.capability.shed\", \"properties\": {\"asserted-islanding-state\": {\"name\": \"Consumer-asserted islanding-state (grid-state override during MID/BESS comm-loss)\", \"datatype\": \"enum\", \"format\": \"NONE,ON_GRID,OFF_GRID\", \"settable\": true}, \"policy\": {\"name\": \"Shed policy (algorithm and parameters)\", \"datatype\": \"json\", \"format\": \"{\\\"$id\\\":\\\"soc-priority.v1\\\",\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"algorithm\\\",\\\"parameters\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"algorithm\\\":{\\\"const\\\":\\\"soc-priority.v1\\\"},\\\"parameters\\\":{\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"soc-threshold-shed\\\",\\\"soc-threshold-release\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"soc-threshold-shed\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent below which SOC_THRESHOLD circuits shed\\\"},\\\"soc-threshold-release\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent above which shed SOC_THRESHOLD circuits restore\\\"}}}}}\"}}}, \"power-flows\": {\"name\": \"power-flows\", \"type\": \"energy.ebus.capability.power-flows\", \"properties\": {\"pv\": {\"name\": \"PV power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"battery\": {\"name\": \"Battery/BESS power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"grid\": {\"name\": \"Grid power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"site\": {\"name\": \"Site power flow\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"relay\": {\"name\": \"Main relay\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"ethernet\": {\"name\": \"Is Ethernet network interface operational?\", \"datatype\": \"boolean\"}, \"wifi\": {\"name\": \"Is Wi-Fi network interface operational?\", \"datatype\": \"boolean\"}, \"wifi-ssid\": {\"name\": \"SSID to which Wi-Fi network interface is connected\", \"datatype\": \"string\"}, \"cloud-connection\": {\"name\": \"Device connected to vendor cloud?\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,UNCONNECTED,CONNECTED\"}, \"postal-code\": {\"name\": \"Postal (Zip) code\", \"datatype\": \"string\"}, \"time-zone\": {\"name\": \"Time zone\", \"datatype\": \"string\"}}}}, \"children\": [\"bess\", \"0ab966b95f92a6a51ec548485aa85f54\", \"d3724e0d660ba506aa79c1cafe5d1181\", \"62d0e03897b337b57101aae82f1e9ba2\", \"fe8b85c15bc9610c1b8b4ebc6f82488d\", \"573066aaddd7b75114c4563ce3af18c4\", \"evse\", \"evse-2\", \"lugs-upstream\", \"lugs-downstream\", \"pv\"], \"extensions\": []}", + "$state": "ready", + "breaker/rating": "200", + "door/state": "CLOSED", + "info/data-model-version": "1.0", + "info/firmware-version": "example/v0.1.0", + "info/hardware-version": "rev2", + "info/model": "MAIN_40", + "info/serial-number": "example-40t-001", + "info/vendor-name": "Span", + "meter/voltage-a": "120.0", + "meter/voltage-b": "120.0", + "pcs/active": "false", + "pcs/binding-constraint": "NONE", + "pcs/enabled": "false", + "pcs/feed-import-limit": "0.0", + "pcs/feed-import-limit-active": "false", + "pcs/feed-import-limit-enablement": "UNCONFIGURED", + "pcs/import-limit": "0.0", + "pcs/off-grid-import-limit": "0.0", + "pcs/off-grid-import-limit-active": "false", + "pcs/off-grid-import-limit-enablement": "UNCONFIGURED", + "pcs/operator-import-limit": "0.0", + "pcs/operator-import-limit-active": "false", + "pcs/operator-import-limit-enablement": "UNCONFIGURED", + "pcs/requested-import-limit": "0.0", + "pcs/requested-import-limit-active": "false", + "pcs/requested-import-limit-enablement": "UNCONFIGURED", + "power-flows/battery": "-3500.0", + "power-flows/grid": "-2347.0", + "power-flows/pv": "8500.0", + "power-flows/site": "2653.0", + "shed-forecast/confidence": "HIGH", + "shed-forecast/full-charge-time-to-priority-shed": "3038", + "shed-forecast/full-charge-total-time-remaining": "4320", + "shed-forecast/time-to-priority-shed": "3037", + "shed-forecast/total-time-remaining": "4320", + "shed/asserted-islanding-state": "NONE", + "shed/policy": "{\"algorithm\": \"soc-priority.v1\", \"parameters\": {\"soc-threshold-shed\": 20, \"soc-threshold-release\": 30}}", + "status/cloud-connection": "CONNECTED", + "status/ethernet": "true", + "status/postal-code": "94103", + "status/relay": "CLOSED", + "status/time-zone": "America/Los_Angeles", + "status/wifi": "true" + }, + "fe8b85c15bc9610c1b8b4ebc6f82488d": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "50", + "connection/feeds-device-id": "evse-2", + "connection/feeds-device-status": "OK", + "connection/feeds-device-type": "energy.ebus.device.evse", + "info/name": "SPAN Drive - Driveway", + "info/spaces": "35,37", + "load-shed/priority": "OFF_GRID", + "meter/active-power": "0.0", + "meter/current": "0.0", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "4", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "lugs-downstream": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Downstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "info/direction": "DOWNSTREAM", + "meter/active-power": "-5847.0", + "meter/current-a": "46.46666666666666", + "meter/current-b": "46.474999999999994", + "meter/exported-energy": "141.66666666666666", + "meter/imported-energy": "44.21666666666666" + }, + "lugs-upstream": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Upstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "connection/fed-by-device-id": "bess", + "connection/fed-by-device-status": "OK", + "connection/fed-by-device-type": "energy.ebus.device.bess", + "info/direction": "UPSTREAM", + "meter/active-power": "-5847.0", + "meter/current-a": "46.46666666666666", + "meter/current-b": "46.474999999999994", + "meter/exported-energy": "141.66666666666666", + "meter/imported-energy": "44.21666666666666" + }, + "pv": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.pv\", \"name\": \"Solar\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nominal-power\": {\"name\": \"Nominal power\", \"datatype\": \"float\", \"unit\": \"W\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "info/model": "IQ8PLUS-72-2-US", + "info/nominal-power": "10000.0", + "info/vendor-name": "Enphase" + } +} diff --git a/tests/fixtures/schema_one_tree_no_pv.json b/tests/fixtures/schema_one_tree_no_pv.json new file mode 100644 index 00000000..9b0483ab --- /dev/null +++ b/tests/fixtures/schema_one_tree_no_pv.json @@ -0,0 +1,218 @@ +{ + "0ab966b95f92a6a51ec548485aa85f54": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "15", + "info/name": "Kitchen Lights", + "info/spaces": "1", + "load-shed/priority": "UNKNOWN", + "meter/active-power": "-121.0", + "meter/current": "1.0083333333333333", + "meter/exported-energy": "2.0166666666666666", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "1", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "573066aaddd7b75114c4563ce3af18c4": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Solar Inverter\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "30", + "connection/feeds-device-id": "pv", + "connection/feeds-device-status": "OK", + "connection/feeds-device-type": "energy.ebus.device.pv", + "info/name": "Solar Inverter", + "info/spaces": "36,38", + "load-shed/priority": "NEVER", + "meter/active-power": "8500.0", + "meter/current": "35.416666666666664", + "meter/exported-energy": "0.0", + "meter/imported-energy": "141.66666666666666", + "pcs/managed": "false", + "pcs/priority": "5", + "switch/relay": "CLOSED", + "switch/relay-controllable": "false", + "switch/relay-requester": "NONE" + }, + "62d0e03897b337b57101aae82f1e9ba2": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "50", + "connection/feeds-device-id": "evse", + "connection/feeds-device-status": "OK", + "connection/feeds-device-type": "energy.ebus.device.evse", + "info/name": "SPAN Drive - Garage", + "info/spaces": "32,34", + "load-shed/priority": "OFF_GRID", + "meter/active-power": "-2410.0", + "meter/current": "10.041666666666666", + "meter/exported-energy": "40.166666666666664", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "3", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "bess": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.bess\", \"name\": \"Battery\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"soc\": {\"name\": \"soc\", \"type\": \"energy.ebus.capability.soc\", \"properties\": {\"soc\": {\"name\": \"State of charge\", \"datatype\": \"float\", \"unit\": \"%\"}, \"soe\": {\"name\": \"State of energy\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"communication-state\": {\"name\": \"Communication state\", \"datatype\": \"enum\", \"format\": \"OK,DEGRADED,LOST,UNKNOWN\"}}}}, \"children\": [\"bess-mid\"], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "info/model": "Example BESS", + "info/nameplate-capacity": "13.5", + "info/vendor-name": "Span", + "meter/active-power": "-3500.0", + "soc/soc": "50.410493827160494", + "soc/soe": "6.805416666666667", + "status/communication-state": "OK" + }, + "bess-mid": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.mid\", \"name\": \"Microgrid Interconnect Device\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}}}, \"grid\": {\"name\": \"grid\", \"type\": \"energy.ebus.capability.grid\", \"properties\": {\"islanding-state\": {\"name\": \"Islanding state of the BESS-integrated grid-forming device\", \"datatype\": \"enum\", \"format\": \"ON_GRID,OFF_GRID,UNKNOWN\"}, \"grid-state\": {\"name\": \"Sensed grid condition\", \"datatype\": \"enum\", \"format\": \"UP,DOWN,DEGRADED,UNKNOWN\"}, \"grid-forming-entity\": {\"name\": \"Identity of the currently grid-forming entity\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"bess\", \"extensions\": []}", + "$state": "ready", + "grid/grid-forming-entity": "GRID", + "grid/grid-state": "UP", + "grid/islanding-state": "ON_GRID", + "info/vendor-name": "Span" + }, + "d3724e0d660ba506aa79c1cafe5d1181": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garage Outlet\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "20", + "info/name": "Garage Outlet", + "info/spaces": "2", + "load-shed/priority": "UNKNOWN", + "meter/active-power": "-122.0", + "meter/current": "1.0166666666666666", + "meter/exported-energy": "2.033333333333333", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "2", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "evse": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "config/max-charge-current": "32", + "config/user-max-charge-current": "32", + "info/firmware-version": "example/v0.1.0", + "info/model": "SPAN Drive", + "info/part-number": "SPN-DRV-001", + "info/serial-number": "SIM-EVSE-example-40t-001", + "info/vendor-name": "SPAN", + "meter/advertised-current": "32.0", + "status/status": "CHARGING", + "switch/lock-state": "LOCKED" + }, + "evse-2": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "config/max-charge-current": "32", + "config/user-max-charge-current": "32", + "info/firmware-version": "example/v0.1.0", + "info/model": "SPAN Drive", + "info/part-number": "SPN-DRV-001", + "info/serial-number": "SIM-EVSE-example-40t-001-2", + "info/vendor-name": "SPAN", + "meter/advertised-current": "32.0", + "status/status": "AVAILABLE", + "switch/lock-state": "UNLOCKED" + }, + "example-40t-001": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Example 40-tab Panel\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"enum\", \"format\": \"MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"data-model-version\": {\"name\": \"eBus data-model version (parent/child schema discriminator)\", \"datatype\": \"string\"}}}, \"door\": {\"name\": \"door\", \"type\": \"energy.ebus.capability.door\", \"properties\": {\"state\": {\"name\": \"Door state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"voltage-a\": {\"name\": \"L1 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}, \"voltage-b\": {\"name\": \"L2 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Main breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"enabled\": {\"name\": \"PCS system enabled\", \"datatype\": \"boolean\"}, \"active\": {\"name\": \"PCS system actively controlling one (or more) loads\", \"datatype\": \"boolean\"}, \"import-limit\": {\"name\": \"The power import limit currently being managed to\", \"datatype\": \"float\", \"unit\": \"A\"}, \"binding-constraint\": {\"name\": \"Which constraint class currently sets the import limit\", \"datatype\": \"enum\", \"format\": \"FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN\"}, \"feed-import-limit\": {\"name\": \"Limit of maximum power feeding the distribution enclosure\", \"datatype\": \"float\", \"unit\": \"A\"}, \"feed-import-limit-enablement\": {\"name\": \"Enablement status of the feed-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"feed-import-limit-active\": {\"name\": \"Is feed-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"operator-import-limit\": {\"name\": \"Operator-imposed maximum import limit\", \"datatype\": \"float\", \"unit\": \"A\"}, \"operator-import-limit-enablement\": {\"name\": \"Enablement status of the operator-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"operator-import-limit-active\": {\"name\": \"Is operator-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"off-grid-import-limit\": {\"name\": \"Off-Grid limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"off-grid-import-limit-enablement\": {\"name\": \"Enablement status of the off-grid-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"off-grid-import-limit-active\": {\"name\": \"Is off-grid-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"requested-import-limit\": {\"name\": \"Requested limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"requested-import-limit-enablement\": {\"name\": \"Enablement status of the requested-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"requested-import-limit-active\": {\"name\": \"Is requested-import-limit currently being enforced?\", \"datatype\": \"boolean\"}}}, \"shed-forecast\": {\"name\": \"shed-forecast\", \"type\": \"energy.ebus.capability.shed-forecast\", \"properties\": {\"total-time-remaining\": {\"name\": \"Estimated total time before all sheddable circuits are shed (off-grid runtime)\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"time-to-priority-shed\": {\"name\": \"Estimated time before the next priority tier is shed\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-total-time-remaining\": {\"name\": \"Estimated total time assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-time-to-priority-shed\": {\"name\": \"Estimated time to next priority shed assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"confidence\": {\"name\": \"Confidence of the shed-forecast estimate\", \"datatype\": \"enum\", \"format\": \"LOW,MEDIUM,HIGH\"}}}, \"shed\": {\"name\": \"shed\", \"type\": \"energy.ebus.capability.shed\", \"properties\": {\"asserted-islanding-state\": {\"name\": \"Consumer-asserted islanding-state (grid-state override during MID/BESS comm-loss)\", \"datatype\": \"enum\", \"format\": \"NONE,ON_GRID,OFF_GRID\", \"settable\": true}, \"policy\": {\"name\": \"Shed policy (algorithm and parameters)\", \"datatype\": \"json\", \"format\": \"{\\\"$id\\\":\\\"soc-priority.v1\\\",\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"algorithm\\\",\\\"parameters\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"algorithm\\\":{\\\"const\\\":\\\"soc-priority.v1\\\"},\\\"parameters\\\":{\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"soc-threshold-shed\\\",\\\"soc-threshold-release\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"soc-threshold-shed\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent below which SOC_THRESHOLD circuits shed\\\"},\\\"soc-threshold-release\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent above which shed SOC_THRESHOLD circuits restore\\\"}}}}}\"}}}, \"power-flows\": {\"name\": \"power-flows\", \"type\": \"energy.ebus.capability.power-flows\", \"properties\": {\"pv\": {\"name\": \"PV power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"battery\": {\"name\": \"Battery/BESS power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"grid\": {\"name\": \"Grid power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"site\": {\"name\": \"Site power flow\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"relay\": {\"name\": \"Main relay\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"ethernet\": {\"name\": \"Is Ethernet network interface operational?\", \"datatype\": \"boolean\"}, \"wifi\": {\"name\": \"Is Wi-Fi network interface operational?\", \"datatype\": \"boolean\"}, \"wifi-ssid\": {\"name\": \"SSID to which Wi-Fi network interface is connected\", \"datatype\": \"string\"}, \"cloud-connection\": {\"name\": \"Device connected to vendor cloud?\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,UNCONNECTED,CONNECTED\"}, \"postal-code\": {\"name\": \"Postal (Zip) code\", \"datatype\": \"string\"}, \"time-zone\": {\"name\": \"Time zone\", \"datatype\": \"string\"}}}}, \"children\": [\"bess\", \"0ab966b95f92a6a51ec548485aa85f54\", \"d3724e0d660ba506aa79c1cafe5d1181\", \"62d0e03897b337b57101aae82f1e9ba2\", \"fe8b85c15bc9610c1b8b4ebc6f82488d\", \"573066aaddd7b75114c4563ce3af18c4\", \"evse\", \"evse-2\", \"lugs-upstream\", \"lugs-downstream\", \"pv\"], \"extensions\": []}", + "$state": "ready", + "breaker/rating": "200", + "door/state": "CLOSED", + "info/data-model-version": "1.0", + "info/firmware-version": "example/v0.1.0", + "info/hardware-version": "rev2", + "info/model": "MAIN_40", + "info/serial-number": "example-40t-001", + "info/vendor-name": "Span", + "meter/voltage-a": "120.0", + "meter/voltage-b": "120.0", + "pcs/active": "false", + "pcs/binding-constraint": "NONE", + "pcs/enabled": "false", + "pcs/feed-import-limit": "0.0", + "pcs/feed-import-limit-active": "false", + "pcs/feed-import-limit-enablement": "UNCONFIGURED", + "pcs/import-limit": "0.0", + "pcs/off-grid-import-limit": "0.0", + "pcs/off-grid-import-limit-active": "false", + "pcs/off-grid-import-limit-enablement": "UNCONFIGURED", + "pcs/operator-import-limit": "0.0", + "pcs/operator-import-limit-active": "false", + "pcs/operator-import-limit-enablement": "UNCONFIGURED", + "pcs/requested-import-limit": "0.0", + "pcs/requested-import-limit-active": "false", + "pcs/requested-import-limit-enablement": "UNCONFIGURED", + "power-flows/battery": "-3500.0", + "power-flows/grid": "-2347.0", + "power-flows/pv": "8500.0", + "power-flows/site": "2653.0", + "shed-forecast/confidence": "HIGH", + "shed-forecast/full-charge-time-to-priority-shed": "3038", + "shed-forecast/full-charge-total-time-remaining": "4320", + "shed-forecast/time-to-priority-shed": "3037", + "shed-forecast/total-time-remaining": "4320", + "shed/asserted-islanding-state": "NONE", + "shed/policy": "{\"algorithm\": \"soc-priority.v1\", \"parameters\": {\"soc-threshold-shed\": 20, \"soc-threshold-release\": 30}}", + "status/cloud-connection": "CONNECTED", + "status/ethernet": "true", + "status/postal-code": "94103", + "status/relay": "CLOSED", + "status/time-zone": "America/Los_Angeles", + "status/wifi": "true" + }, + "fe8b85c15bc9610c1b8b4ebc6f82488d": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "50", + "connection/feeds-device-id": "evse-2", + "connection/feeds-device-status": "OK", + "connection/feeds-device-type": "energy.ebus.device.evse", + "info/name": "SPAN Drive - Driveway", + "info/spaces": "35,37", + "load-shed/priority": "OFF_GRID", + "meter/active-power": "0.0", + "meter/current": "0.0", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "4", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "lugs-downstream": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Downstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "info/direction": "DOWNSTREAM", + "meter/active-power": "-5847.0", + "meter/current-a": "46.46666666666666", + "meter/current-b": "46.474999999999994", + "meter/exported-energy": "141.66666666666666", + "meter/imported-energy": "44.21666666666666" + }, + "lugs-upstream": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Upstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "connection/fed-by-device-id": "bess", + "connection/fed-by-device-status": "OK", + "connection/fed-by-device-type": "energy.ebus.device.bess", + "info/direction": "UPSTREAM", + "meter/active-power": "-5847.0", + "meter/current-a": "46.46666666666666", + "meter/current-b": "46.474999999999994", + "meter/exported-energy": "141.66666666666666", + "meter/imported-energy": "44.21666666666666" + } +} diff --git a/tests/fixtures/schema_zero_types.json b/tests/fixtures/schema_zero_types.json new file mode 100644 index 00000000..b7a338d0 --- /dev/null +++ b/tests/fixtures/schema_zero_types.json @@ -0,0 +1,420 @@ +{ + "firmwareVersion": "spanos2/r202603/05", + "homieDomain": "ebus", + "homieVersion": 5, + "types": { + "energy.ebus.device.distribution-enclosure.core": { + "vendor-name": { + "name": "Vendor name", + "datatype": "string" + }, + "serial-number": { + "name": "Serial number", + "datatype": "string" + }, + "hardware-version": { + "name": "Hardware version", + "datatype": "string" + }, + "software-version": { + "name": "Software version", + "datatype": "string" + }, + "door": { + "name": "Door state", + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED" + }, + "grid-islandable": { + "name": "Capable of operating with power while disconnected from the grid", + "datatype": "boolean" + }, + "dominant-power-source": { + "name": "Current dominant power source, load-shedding trigger", + "datatype": "enum", + "format": "GRID,BATTERY,PV,GENERATOR,NONE,UNKNOWN", + "settable": true + }, + "relay": { + "name": "Main relay", + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED" + }, + "l1-voltage": { + "name": "L1 voltage", + "datatype": "float", + "unit": "V" + }, + "l2-voltage": { + "name": "L2 voltage", + "datatype": "float", + "unit": "V" + }, + "breaker-rating": { + "name": "Main breaker rating", + "datatype": "integer", + "unit": "A" + }, + "ethernet": { + "name": "Is Ethernet network interface operational?", + "datatype": "boolean" + }, + "wifi": { + "name": "Is Wi-Fi network interface operational?", + "datatype": "boolean" + }, + "wifi-ssid": { + "name": "SSID to which Wi-Fi network interface is connected", + "datatype": "string" + }, + "vendor-cloud": { + "name": "Device connected to vendor cloud?", + "datatype": "enum", + "format": "UNKNOWN,UNCONNECTED,CONNECTED" + }, + "postal-code": { + "name": "Postal (Zip) code", + "datatype": "string" + }, + "time-zone": { + "name": "Time zone", + "datatype": "string" + } + }, + "energy.ebus.device.lugs": { + "direction": { + "name": "Lugs feed direction: upstream or downstream", + "datatype": "enum", + "format": "UPSTREAM,DOWNSTREAM" + }, + "feed": { + "name": "Device the lugs are connected to, if known", + "datatype": "string" + }, + "l1-current": { + "name": "L1 current", + "datatype": "float", + "unit": "A" + }, + "l2-current": { + "name": "L2 current", + "datatype": "float", + "unit": "A" + }, + "active-power": { + "name": "Active power", + "datatype": "float", + "unit": "W" + }, + "imported-energy": { + "name": "Imported energy", + "datatype": "float", + "unit": "Wh" + }, + "exported-energy": { + "name": "Exported energy", + "datatype": "float", + "unit": "Wh" + } + }, + "energy.ebus.device.circuit": { + "name": { + "name": "Circuit name", + "datatype": "string" + }, + "relay": { + "name": "Circuit relay state", + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED", + "settable": true + }, + "relay-requester": { + "name": "Actor requesting the relay state", + "datatype": "enum", + "format": "UNKNOWN,NONE,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT" + }, + "breaker-rating": { + "name": "Circuit breaker rating", + "datatype": "integer", + "unit": "A" + }, + "current": { + "name": "Measured current", + "datatype": "float", + "unit": "A" + }, + "active-power": { + "name": "Measured active power", + "datatype": "float", + "unit": "kW" + }, + "imported-energy": { + "name": "Measured energy imported", + "datatype": "float", + "unit": "Wh" + }, + "exported-energy": { + "name": "Measured energy exported", + "datatype": "float", + "unit": "Wh" + }, + "space": { + "name": "Circuit breaker space number within load center", + "datatype": "integer", + "format": "1:32:1" + }, + "dipole": { + "name": "Does circuit land on a two-pole breaker?", + "datatype": "boolean" + }, + "shed-priority": { + "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", + "datatype": "enum", + "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", + "settable": true + }, + "pcs-managed": { + "name": "Is circuit managed by PCS?", + "datatype": "boolean" + }, + "pcs-priority": { + "name": "Circuit PCS priority ranking", + "datatype": "integer" + }, + "sheddable": { + "name": "Is circuit configured to be sheddable?", + "datatype": "boolean" + }, + "never-backup": { + "name": "Is circuit configured to be never-backup?", + "datatype": "boolean" + }, + "always-on": { + "name": "Is circuit configured to be always on?", + "datatype": "boolean" + } + }, + "energy.ebus.device.bess": { + "vendor-name": { + "name": "Vendor name", + "datatype": "string" + }, + "product-name": { + "name": "Product name", + "datatype": "string" + }, + "model": { + "name": "Model", + "datatype": "string" + }, + "serial-number": { + "name": "Serial number", + "datatype": "string" + }, + "software-version": { + "name": "Software version", + "datatype": "string" + }, + "nameplate-capacity": { + "name": "Nameplate capacity", + "datatype": "float", + "unit": "kWh" + }, + "relative-position": { + "name": "Relative position of the commissioned backup system WRT the distribution enclosure", + "datatype": "enum", + "format": "UPSTREAM,DOWNSTREAM,IN_PANEL" + }, + "feed": { + "name": "Circuit ID upon which the commissioned backup system is landed", + "datatype": "enum" + }, + "soc": { + "name": "State of charge", + "datatype": "float", + "unit": "%" + }, + "soe": { + "name": "State of energy", + "datatype": "float", + "unit": "kWh" + }, + "connected": { + "name": "Connected to backup system?", + "datatype": "boolean" + }, + "grid-state": { + "name": "Grid connection state", + "datatype": "enum", + "format": "UNKNOWN,ON_GRID,OFF_GRID" + } + }, + "energy.ebus.device.pv": { + "vendor-name": { + "name": "Vendor name", + "datatype": "string" + }, + "product-name": { + "name": "Product name", + "datatype": "string" + }, + "serial-number": { + "name": "Serial number", + "datatype": "string" + }, + "software-version": { + "name": "Software version", + "datatype": "string" + }, + "nameplate-capacity": { + "name": "Nameplate capacity", + "datatype": "float", + "unit": "W" + }, + "relative-position": { + "name": "Relative position of the commissioned PV system WRT the distribution enclosure", + "datatype": "enum", + "format": "UPSTREAM,DOWNSTREAM,IN_PANEL" + }, + "feed": { + "name": "Circuit ID upon which the commissioned PV system is landed", + "datatype": "enum" + } + }, + "energy.ebus.device.evse": { + "vendor-name": { + "name": "Vendor name", + "datatype": "string" + }, + "product-name": { + "name": "Product name", + "datatype": "string" + }, + "part-number": { + "name": "Part number", + "datatype": "string" + }, + "serial-number": { + "name": "Serial number", + "datatype": "string" + }, + "software-version": { + "name": "Software version", + "datatype": "string" + }, + "feed": { + "name": "Circuit ID upon which the commissioned EVSE is landed", + "datatype": "enum" + }, + "lock-state": { + "name": "Lock state", + "datatype": "enum", + "format": "UNKNOWN,LOCKED,UNLOCKED" + }, + "status": { + "name": "Status", + "datatype": "enum", + "format": "UNKNOWN,AVAILABLE,PREPARING,CHARGING,SUSPENDED_EV,SUSPENDED_EVSE,FINISHING,RESERVED,FAULTED,UNAVAILABLE" + }, + "advertised-current": { + "name": "Current EVSE is advertising to the EV", + "datatype": "float", + "unit": "A" + } + }, + "energy.ebus.device.pcs": { + "enabled": { + "name": "PCS system enabled", + "datatype": "boolean" + }, + "active": { + "name": "PCS system actively controlling one (or more) loads", + "datatype": "boolean" + }, + "import-limit": { + "name": "The power import limit currently being managed to", + "datatype": "float", + "unit": "A" + }, + "feed-import-limit": { + "name": "Limit of maximum power feeding the distribution enclosure", + "datatype": "float", + "unit": "A" + }, + "feed-import-limit-enablement": { + "name": "Enablement status of the feed-import-limit", + "datatype": "enum", + "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED" + }, + "feed-import-limit-active": { + "name": "Is feed-import-limit currently being enforced?", + "datatype": "boolean" + }, + "grid-import-limit": { + "name": "Grid limit maximum import power", + "datatype": "float", + "unit": "A" + }, + "grid-import-limit-enablement": { + "name": "Enablement status of the grid-import-limit", + "datatype": "enum", + "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED" + }, + "grid-import-limit-active": { + "name": "Is grid-import-limit currently being enforced?", + "datatype": "boolean" + }, + "off-grid-import-limit": { + "name": "Off-Grid limit maximum import power", + "datatype": "float", + "unit": "A" + }, + "off-grid-import-limit-enablement": { + "name": "Enablement status of the off-grid-import-limit", + "datatype": "enum", + "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED" + }, + "off-grid-import-limit-active": { + "name": "Is off-grid-import-limit currently being enforced?", + "datatype": "boolean" + }, + "requested-import-limit": { + "name": "Requested limit maximum import power", + "datatype": "float", + "unit": "A" + }, + "requested-import-limit-enablement": { + "name": "Enablement status of the requested-import-limit", + "datatype": "enum", + "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED" + }, + "requested-import-limit-active": { + "name": "Is requested-import-limit currently being enforced?", + "datatype": "boolean" + } + }, + "energy.ebus.device.power-flows": { + "pv": { + "name": "PV power flow", + "datatype": "float", + "unit": "W" + }, + "battery": { + "name": "Battery/BESS power flow", + "datatype": "float", + "unit": "W" + }, + "grid": { + "name": "Grid power flow", + "datatype": "float", + "unit": "W" + }, + "site": { + "name": "Site power flow", + "datatype": "float", + "unit": "W" + } + } + }, + "typesSchemaHash": "sha256:d347556a07d98f40" +} From 4ff4e3d535f8bef488a6aa1ec46a01c0207fdac8 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:08:09 -0700 Subject: [PATCH 014/345] chore: pin span-panel-api 3.0.0b4 and both schema adapters Sources point at the span-panel-api-p3 worktree so this branch develops against the field-metadata changes without disturbing the baseline pair. --- custom_components/span_panel/manifest.json | 6 ++--- pyproject.toml | 8 +++---- uv.lock | 26 +++++++++++----------- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 267b2db6..4781e14f 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -22,9 +22,9 @@ ], "quality_scale": "gold", "requirements": [ - "span-panel-api==3.0.0b3", - "span-panel-api-schema-0==1.0.0b3", - "span-panel-api-schema-1==0.1.0b3" + "span-panel-api==3.0.0b4", + "span-panel-api-schema-0==1.0.0b4", + "span-panel-api-schema-1==0.1.0b4" ], "version": "2.1.0", "zeroconf": [ diff --git a/pyproject.toml b/pyproject.toml index 4cacfe27..a9c87152 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,9 +47,9 @@ package = false [tool.uv.sources] # This branch develops against the in-progress schema-1 adapter, which lives in a # worktree rather than the primary checkout the symlink points at. -span-panel-api = { path = "../../span/span-panel-api-p2", editable = true } -span-panel-api-schema-0 = { path = "../../span/span-panel-api-p2/packages/schema-0", editable = true } -span-panel-api-schema-1 = { path = "../../span/span-panel-api-p2/packages/schema-1", editable = true } +span-panel-api = { path = "../../span/span-panel-api-p3", editable = true } +span-panel-api-schema-0 = { path = "../../span/span-panel-api-p3/packages/schema-0", editable = true } +span-panel-api-schema-1 = { path = "../../span/span-panel-api-p3/packages/schema-1", editable = true } [tool.jscpd] path = ["custom_components/span_panel", "./*.{html,md}"] @@ -129,7 +129,7 @@ exclude = [ ] extraPaths = [ "./custom_components", - "../../span/span-panel-api-p2/src", + "../../span/span-panel-api-p3/src", "../ha-synthetic-sensors/src" ] pythonPlatform = "Darwin" diff --git a/uv.lock b/uv.lock index 61397ab4..87d23906 100644 --- a/uv.lock +++ b/uv.lock @@ -2523,9 +2523,9 @@ dev = [ [package.metadata] requires-dist = [ { name = "homeassistant", specifier = "==2026.8.0" }, - { name = "span-panel-api", editable = "../../span/span-panel-api-p2" }, - { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api-p2/packages/schema-0" }, - { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api-p2/packages/schema-1" }, + { name = "span-panel-api", editable = "../../span/span-panel-api-p3" }, + { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api-p3/packages/schema-0" }, + { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api-p3/packages/schema-1" }, ] [package.metadata.requires-dev] @@ -2550,8 +2550,8 @@ dev = [ [[package]] name = "span-panel-api" -version = "3.0.0b3" -source = { editable = "../../span/span-panel-api-p2" } +version = "3.0.0b4" +source = { editable = "../../span/span-panel-api-p3" } dependencies = [ { name = "httpx" }, { name = "paho-mqtt" }, @@ -2578,8 +2578,8 @@ dev = [ { name = "pytest-cov" }, { name = "radon" }, { name = "ruff", specifier = ">=0.15.5" }, - { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api-p2/packages/schema-0" }, - { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api-p2/packages/schema-1" }, + { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api-p3/packages/schema-0" }, + { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api-p3/packages/schema-1" }, { name = "twine", specifier = ">=7.0" }, { name = "types-pyyaml", specifier = ">=6.0.12.20250915" }, { name = "vulture", specifier = ">=2.14" }, @@ -2587,19 +2587,19 @@ dev = [ [[package]] name = "span-panel-api-schema-0" -version = "1.0.0b3" -source = { editable = "../../span/span-panel-api-p2/packages/schema-0" } +version = "1.0.0b4" +source = { editable = "../../span/span-panel-api-p3/packages/schema-0" } dependencies = [ { name = "span-panel-api" }, ] [package.metadata] -requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-api-p2" }] +requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-api-p3" }] [[package]] name = "span-panel-api-schema-1" -version = "0.1.0b3" -source = { editable = "../../span/span-panel-api-p2/packages/schema-1" } +version = "0.1.0b4" +source = { editable = "../../span/span-panel-api-p3/packages/schema-1" } dependencies = [ { name = "ebus-sdk" }, { name = "span-panel-api" }, @@ -2608,7 +2608,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "ebus-sdk", specifier = ">=0.19.0,<1.0" }, - { name = "span-panel-api", editable = "../../span/span-panel-api-p2" }, + { name = "span-panel-api", editable = "../../span/span-panel-api-p3" }, ] [[package]] From 56e552c292f0aef3a61fa8ee6659ff16668219e2 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:12:17 -0700 Subject: [PATCH 015/345] chore: pin b4 in project dependencies and document fixture provenance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier pin commit updated manifest.json and the uv sources but left [project] dependencies on b3 — inert locally because the sources override it, operative on a clean resolve. requirements_test.txt is read by nothing; uv sync reads [project] dependencies, so that is the file that had to change. --- pyproject.toml | 6 ++--- tests/fixtures/README.md | 58 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/README.md diff --git a/pyproject.toml b/pyproject.toml index a9c87152..b042b9c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,13 +8,13 @@ readme = "README.md" requires-python = ">=3.14.3,<3.15" dependencies = [ "homeassistant==2026.8.0", - "span-panel-api==3.0.0b3", + "span-panel-api==3.0.0b4", # 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.0.0b3", - "span-panel-api-schema-1==0.1.0b3", + "span-panel-api-schema-0==1.0.0b4", + "span-panel-api-schema-1==0.1.0b4", ] [dependency-groups] diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 00000000..498828d2 --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,58 @@ +# Adapter fixtures + +Real schema-adapter inputs, used by the field-path conformance tests via +`tests/adapter_fixtures.py`. They are **committed rather than generated** so the +test suite has no cross-repo dependency and CI needs no checkout of the library. +The cost of that choice is that they go stale silently — hence this file. + +## Provenance + +Both are byte-identical copies from the `span-panel-api` repository: + +| File here | Source in `span-panel-api` | +|---|---| +| `schema_zero_types.json` | `tests/fixtures/v2/homie_schema.json` | +| `schema_one_tree.json` | `tests/fixtures/parent_child_tree.json` | + +Refresh by copying them again. If a copy changes shape rather than content, the +loader in `tests/adapter_fixtures.py` is what needs updating — note that +`schema_one_tree.json` is a **dict keyed by device id**, whose `$description` +value is a **JSON string**, not a parsed object. + +## Derived variants + +Both are produced from `schema_one_tree.json` by dropping every device whose +parsed `$description["type"]` contains a marker. Regenerate with: + +```bash +uv run python - << 'PY' +import json, pathlib + +tree = json.loads(pathlib.Path("tests/fixtures/schema_one_tree.json").read_text()) + +def drop(marker: str, out_name: str) -> None: + kept = { + device_id: topics + for device_id, topics in tree.items() + if marker not in json.loads(topics.get("$description", "{}")).get("type", "") + } + pathlib.Path(f"tests/fixtures/{out_name}").write_text(json.dumps(kept, indent=2)) + print(f"{out_name}: {len(tree)} -> {len(kept)} devices") + +drop(".bess", "schema_one_tree_batteryless.json") +drop(".pv", "schema_one_tree_no_pv.json") +PY +``` + +Each must remove **exactly one** device (13 -> 12) and must retain the panel and +both lugs devices. A variant that removed more would make the conformance tests +pass for the wrong reason. Note `bess-mid` is typed `energy.ebus.device.mid` and +correctly survives the `.bess` filter. + +## Why these exist + +`schema_one_tree_batteryless.json` proves a panel with no BESS produces **no** +`battery.*` entries — hardware absence, not degradation. +`schema_one_tree_no_pv.json` proves the same for a panel that has power-flows +telemetry but no PV device, which is the case telemetry-based capability +detection gets wrong. From 253694235c8cf91a380d9bc3f3b36587dc8031b8 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:28:05 -0700 Subject: [PATCH 016/345] refactor: declare snapshot field paths on entity descriptions Retires SENSOR_FIELD_MAP. The declaration now lives on the object that reads the field, so the two cannot drift. Also drops the key-based dedup, which silently discarded descriptions sharing a key across device classes. --- custom_components/span_panel/binary_sensor.py | 19 ++- custom_components/span_panel/field_paths.py | 105 ++++++++++++++++ .../span_panel/sensor_circuit.py | 6 + .../span_panel/sensor_definitions.py | 118 +++++++++++++++--- tests/test_field_paths.py | 28 +++++ 5 files changed, 256 insertions(+), 20 deletions(-) create mode 100644 custom_components/span_panel/field_paths.py create mode 100644 tests/test_field_paths.py diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py index 59a125b8..f4f80fb0 100644 --- a/custom_components/span_panel/binary_sensor.py +++ b/custom_components/span_panel/binary_sensor.py @@ -30,6 +30,7 @@ ) from .coordinator import SpanPanelCoordinator from .entity import SpanPanelEntity +from .field_paths import FieldPathDeclarationMixin from .helpers import ( build_binary_sensor_unique_id_for_entry, build_evse_unique_id_for_entry, @@ -48,13 +49,13 @@ @dataclass(frozen=True) -class SpanPanelRequiredKeysMixin: +class SpanPanelRequiredKeysMixin(FieldPathDeclarationMixin): """Required keys mixin for Span Panel binary sensors.""" value_fn: Callable[[SpanPanelSnapshot], bool | None] -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class SpanPanelBinarySensorEntityDescription( BinarySensorEntityDescription, SpanPanelRequiredKeysMixin ): @@ -72,6 +73,7 @@ class SpanPanelBinarySensorEntityDescription( ] = ( SpanPanelBinarySensorEntityDescription( key=SYSTEM_DOOR_STATE, + field_path="panel.door_state", translation_key="door_state", device_class=BinarySensorDeviceClass.TAMPER, entity_category=EntityCategory.DIAGNOSTIC, @@ -83,6 +85,7 @@ class SpanPanelBinarySensorEntityDescription( ), SpanPanelBinarySensorEntityDescription( key=SYSTEM_ETHERNET_LINK, + field_path="panel.eth0_link", translation_key="ethernet_link", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, @@ -90,6 +93,7 @@ class SpanPanelBinarySensorEntityDescription( ), SpanPanelBinarySensorEntityDescription( key=SYSTEM_WIFI_LINK, + field_path="panel.wlan_link", translation_key="wifi_link", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, @@ -97,6 +101,9 @@ class SpanPanelBinarySensorEntityDescription( ), SpanPanelBinarySensorEntityDescription( key=PANEL_STATUS, + # Reports coordinator reachability, not a snapshot field — the value_fn + # is a placeholder the entity class overrides. + derived=True, translation_key="panel_status", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, @@ -134,6 +141,7 @@ def _grid_islandable(snapshot: SpanPanelSnapshot) -> bool | None: GRID_ISLANDABLE_SENSOR = SpanPanelBinarySensorEntityDescription( key="grid_islandable", + derived=True, translation_key="grid_islandable", device_class=BinarySensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, @@ -142,6 +150,7 @@ def _grid_islandable(snapshot: SpanPanelSnapshot) -> bool | None: BESS_CONNECTED_SENSOR = SpanPanelBinarySensorEntityDescription( key="bess_connected", + derived=True, translation_key="bess_connected", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, @@ -278,13 +287,13 @@ def _construct_binary_sensor_unique_id( @dataclass(frozen=True) -class SpanEvseBinarySensorRequiredKeysMixin: +class SpanEvseBinarySensorRequiredKeysMixin(FieldPathDeclarationMixin): """Required keys mixin for EVSE binary sensors.""" value_fn: Callable[[SpanEvseSnapshot], bool | None] -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class SpanEvseBinarySensorEntityDescription( BinarySensorEntityDescription, SpanEvseBinarySensorRequiredKeysMixin ): @@ -301,12 +310,14 @@ class SpanEvseBinarySensorEntityDescription( ] = ( SpanEvseBinarySensorEntityDescription( key="evse_charging", + field_path="evse.status", translation_key="evse_charging", device_class=BinarySensorDeviceClass.BATTERY_CHARGING, value_fn=lambda e: (e.status or "") == "CHARGING", ), SpanEvseBinarySensorEntityDescription( key="evse_ev_connected", + derived=True, translation_key="evse_ev_connected", device_class=BinarySensorDeviceClass.PLUG, value_fn=lambda e: (e.status or "") in _EV_CONNECTED_STATUSES, diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py new file mode 100644 index 00000000..ea415532 --- /dev/null +++ b/custom_components/span_panel/field_paths.py @@ -0,0 +1,105 @@ +"""Snapshot field paths this integration reads. + +Most declarations live on the entity descriptions that read them, so they +cannot drift from the reader. A few readers are in entity code rather than on a +description; those are listed here. + +This module replaces `schema_expectations.SENSOR_FIELD_MAP`, a hand-maintained +parallel dict that had already drifted once (it pointed at +`battery.product_name` and `pv.product_name` after the library renamed those +fields to `battery.model` / `pv.model`). + +Field path convention: ``{snapshot_type}.{field_name}`` — ``panel``, +``circuit``, ``battery``, ``pv`` and ``evse``. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, kw_only=True) +class FieldPathDeclarationMixin: + """Declares which snapshot field an entity description reads. + + Mixed into every required-keys mixin so the declaration and the reader are + the same object. Defined once here rather than repeated on each mixin, so + the two fields cannot themselves drift apart across platforms. + + The fields are keyword-only: an entity description flattens this mixin's + fields ahead of ``EntityDescription.key``, which has no default, so a + positional pair here would make every description unconstructable. + """ + + field_path: str | None = None + """Snapshot field this entity reads, e.g. "circuit.instant_power_w". + + Declared here rather than in a parallel map so the declaration and the + reader are the same object. Verified against `value_fn` by the proxy test + in tests/test_field_path_introspection.py. + """ + + derived: bool = False + """True when the value is computed from several fields, or none. + + Derived entities have no single source field, so they are exempt from the + producible check. + """ + + +RESIDUAL_FIELD_PATHS: frozenset[str] = frozenset( + { + # switch.py reads this in entity code, not via a description value_fn + "circuit.relay_state", + # select.py uses a wrapper class rather than a frozen dataclass + # description, so it cannot carry the field as a dataclass field + "circuit.priority", + # Consumed by entity naming and attributes rather than by any platform + "circuit.name", + "circuit.tabs", + } +) +"""Readers not carried on an entity description. + +Keep this small. A new entry is a hint that the reader belongs on a +description instead. +""" + + +def declared_field_paths() -> dict[str, bool]: + """Every field path the integration reads, mapped to whether it is derived. + + Derived paths are exempt from the producible check because they have no + single source field. + """ + # Deferred: the platform modules import `FieldPathDeclarationMixin` from + # here, and `binary_sensor` reaches the package root for its config-entry + # type. Importing them at module scope would close both loops. + from .binary_sensor import ( # pylint: disable=import-outside-toplevel + BESS_CONNECTED_SENSOR, + BINARY_SENSORS, + EVSE_BINARY_SENSORS, + GRID_ISLANDABLE_SENSOR, + ) + from .sensor_definitions import ( # pylint: disable=import-outside-toplevel + all_sensor_descriptions, + ) + + paths: dict[str, bool] = dict.fromkeys(RESIDUAL_FIELD_PATHS, False) + for description in ( + *all_sensor_descriptions(), + *BINARY_SENSORS, + *EVSE_BINARY_SENSORS, + GRID_ISLANDABLE_SENSOR, + BESS_CONNECTED_SENSOR, + ): + if not isinstance(description, FieldPathDeclarationMixin): + # A description that cannot declare anything would be dropped + # silently, which is the drift this module exists to prevent. + raise TypeError( + f"entity description '{description.key}' carries no field-path declaration" + ) + if description.derived or description.field_path is None: + continue + paths[description.field_path] = False + return paths diff --git a/custom_components/span_panel/sensor_circuit.py b/custom_components/span_panel/sensor_circuit.py index 8e12c30c..af0a0d68 100644 --- a/custom_components/span_panel/sensor_circuit.py +++ b/custom_components/span_panel/sensor_circuit.py @@ -113,6 +113,8 @@ def __init__( suggested_display_precision=description.suggested_display_precision, device_class=description.device_class, value_fn=description.value_fn, + field_path=description.field_path, + derived=description.derived, entity_registry_enabled_default=description.entity_registry_enabled_default, entity_registry_visible_default=description.entity_registry_visible_default, entity_category=description.entity_category, @@ -270,6 +272,8 @@ def __init__( suggested_display_precision=description.suggested_display_precision, device_class=description.device_class, value_fn=description.value_fn, + field_path=description.field_path, + derived=description.derived, entity_registry_enabled_default=description.entity_registry_enabled_default, entity_registry_visible_default=description.entity_registry_visible_default, ) @@ -445,6 +449,8 @@ def __init__( suggested_display_precision=description.suggested_display_precision, device_class=description.device_class, value_fn=description.value_fn, + field_path=description.field_path, + derived=description.derived, entity_registry_enabled_default=True, entity_registry_visible_default=False, ) diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py index 286ed920..4fd70973 100644 --- a/custom_components/span_panel/sensor_definitions.py +++ b/custom_components/span_panel/sensor_definitions.py @@ -35,15 +35,17 @@ SpanPanelSnapshot, ) +from .field_paths import FieldPathDeclarationMixin + @dataclass(frozen=True) -class SpanPanelCircuitsRequiredKeysMixin: +class SpanPanelCircuitsRequiredKeysMixin(FieldPathDeclarationMixin): """Required keys mixin for Span Panel circuit sensors.""" value_fn: Callable[[SpanCircuitSnapshot], float | None] -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class SpanPanelCircuitsSensorEntityDescription( SensorEntityDescription, SpanPanelCircuitsRequiredKeysMixin ): @@ -51,25 +53,25 @@ class SpanPanelCircuitsSensorEntityDescription( @dataclass(frozen=True) -class SpanPanelDataRequiredKeysMixin: +class SpanPanelDataRequiredKeysMixin(FieldPathDeclarationMixin): """Required keys mixin for Span Panel data sensors.""" value_fn: Callable[[SpanPanelSnapshot], float | str | None] -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class SpanPanelDataSensorEntityDescription(SensorEntityDescription, SpanPanelDataRequiredKeysMixin): """Describes a Span Panel data sensor entity.""" @dataclass(frozen=True) -class SpanPanelStatusRequiredKeysMixin: +class SpanPanelStatusRequiredKeysMixin(FieldPathDeclarationMixin): """Required keys mixin for Span Panel status sensors.""" value_fn: Callable[[SpanPanelSnapshot], str] -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class SpanPanelStatusSensorEntityDescription( SensorEntityDescription, SpanPanelStatusRequiredKeysMixin ): @@ -77,13 +79,13 @@ class SpanPanelStatusSensorEntityDescription( @dataclass(frozen=True) -class SpanPanelBatteryRequiredKeysMixin: +class SpanPanelBatteryRequiredKeysMixin(FieldPathDeclarationMixin): """Required keys mixin for Span Panel battery sensors.""" value_fn: Callable[[SpanBatterySnapshot], float | None] -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class SpanPanelBatterySensorEntityDescription( SensorEntityDescription, SpanPanelBatteryRequiredKeysMixin ): @@ -101,6 +103,7 @@ class SpanPanelBatterySensorEntityDescription( ] = ( SpanPanelDataSensorEntityDescription( key="dsm_state", + derived=True, translation_key="dsm_state", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, @@ -110,6 +113,7 @@ class SpanPanelBatterySensorEntityDescription( ), SpanPanelDataSensorEntityDescription( key="dsm_grid_state", + derived=True, translation_key="dsm_grid_state", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, @@ -118,6 +122,7 @@ class SpanPanelBatterySensorEntityDescription( ), SpanPanelDataSensorEntityDescription( key="current_run_config", + derived=True, translation_key="current_run_config", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, @@ -126,6 +131,7 @@ class SpanPanelBatterySensorEntityDescription( ), SpanPanelDataSensorEntityDescription( key="main_relay_state", + field_path="panel.main_relay_state", translation_key="main_relay_state", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, @@ -134,6 +140,7 @@ class SpanPanelBatterySensorEntityDescription( ), SpanPanelDataSensorEntityDescription( key="grid_forming_entity", + derived=True, translation_key="grid_forming_entity", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, @@ -142,6 +149,7 @@ class SpanPanelBatterySensorEntityDescription( ), SpanPanelDataSensorEntityDescription( key="vendor_cloud", + field_path="panel.vendor_cloud", translation_key="vendor_cloud", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, @@ -155,6 +163,7 @@ class SpanPanelBatterySensorEntityDescription( STATUS_SENSORS: tuple[SpanPanelStatusSensorEntityDescription,] = ( SpanPanelStatusSensorEntityDescription( key="software_version", + field_path="panel.firmware_version", translation_key="software_version", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda s: s.firmware_version, @@ -170,6 +179,7 @@ class SpanPanelBatterySensorEntityDescription( ] = ( SpanPanelCircuitsSensorEntityDescription( key="instantPowerW", + field_path="circuit.instant_power_w", name="Power", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, @@ -181,6 +191,7 @@ class SpanPanelBatterySensorEntityDescription( ), SpanPanelCircuitsSensorEntityDescription( key="producedEnergyWh", + field_path="circuit.produced_energy_wh", name="Produced Energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, @@ -192,6 +203,7 @@ class SpanPanelBatterySensorEntityDescription( ), SpanPanelCircuitsSensorEntityDescription( key="consumedEnergyWh", + field_path="circuit.consumed_energy_wh", name="Consumed Energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, @@ -206,6 +218,7 @@ class SpanPanelBatterySensorEntityDescription( # Battery sensor definition (conditionally created when battery data available) BATTERY_SENSOR: SpanPanelBatterySensorEntityDescription = SpanPanelBatterySensorEntityDescription( key="storage_battery_percentage", + field_path="battery.soe_percentage", translation_key="battery_level", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -221,6 +234,7 @@ class SpanPanelBatterySensorEntityDescription( # L1/L2 voltage sensors (v2 only, conditionally created) L1_VOLTAGE_SENSOR: SpanPanelDataSensorEntityDescription = SpanPanelDataSensorEntityDescription( key="l1_voltage", + field_path="panel.l1_voltage", translation_key="l1_voltage", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -233,6 +247,7 @@ class SpanPanelBatterySensorEntityDescription( L2_VOLTAGE_SENSOR: SpanPanelDataSensorEntityDescription = SpanPanelDataSensorEntityDescription( key="l2_voltage", + field_path="panel.l2_voltage", translation_key="l2_voltage", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -247,6 +262,7 @@ class SpanPanelBatterySensorEntityDescription( UPSTREAM_L1_CURRENT_SENSOR: SpanPanelDataSensorEntityDescription = ( SpanPanelDataSensorEntityDescription( key="upstream_l1_current", + field_path="panel.upstream_l1_current_a", translation_key="upstream_l1_current", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -261,6 +277,7 @@ class SpanPanelBatterySensorEntityDescription( UPSTREAM_L2_CURRENT_SENSOR: SpanPanelDataSensorEntityDescription = ( SpanPanelDataSensorEntityDescription( key="upstream_l2_current", + field_path="panel.upstream_l2_current_a", translation_key="upstream_l2_current", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -275,6 +292,7 @@ class SpanPanelBatterySensorEntityDescription( DOWNSTREAM_L1_CURRENT_SENSOR: SpanPanelDataSensorEntityDescription = ( SpanPanelDataSensorEntityDescription( key="downstream_l1_current", + field_path="panel.downstream_l1_current_a", translation_key="downstream_l1_current", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -288,6 +306,7 @@ class SpanPanelBatterySensorEntityDescription( DOWNSTREAM_L2_CURRENT_SENSOR: SpanPanelDataSensorEntityDescription = ( SpanPanelDataSensorEntityDescription( key="downstream_l2_current", + field_path="panel.downstream_l2_current_a", translation_key="downstream_l2_current", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -302,6 +321,7 @@ class SpanPanelBatterySensorEntityDescription( MAIN_BREAKER_RATING_SENSOR: SpanPanelDataSensorEntityDescription = ( SpanPanelDataSensorEntityDescription( key="main_breaker_rating", + field_path="panel.main_breaker_rating_a", translation_key="main_breaker_rating", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, entity_category=EntityCategory.DIAGNOSTIC, @@ -318,6 +338,7 @@ class SpanPanelBatterySensorEntityDescription( CIRCUIT_CURRENT_SENSOR: SpanPanelCircuitsSensorEntityDescription = ( SpanPanelCircuitsSensorEntityDescription( key="circuit_current", + field_path="circuit.current_a", name="Current", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -331,6 +352,7 @@ class SpanPanelBatterySensorEntityDescription( CIRCUIT_BREAKER_RATING_SENSOR: SpanPanelCircuitsSensorEntityDescription = ( SpanPanelCircuitsSensorEntityDescription( key="circuit_breaker_rating", + field_path="circuit.breaker_rating_a", name="Breaker Rating", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, entity_category=EntityCategory.DIAGNOSTIC, @@ -345,13 +367,13 @@ class SpanPanelBatterySensorEntityDescription( @dataclass(frozen=True) -class SpanBessMetadataRequiredKeysMixin: +class SpanBessMetadataRequiredKeysMixin(FieldPathDeclarationMixin): """Required keys mixin for BESS metadata sensors.""" value_fn: Callable[[SpanBatterySnapshot], float | str | None] -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class SpanBessMetadataSensorEntityDescription( SensorEntityDescription, SpanBessMetadataRequiredKeysMixin ): @@ -359,13 +381,13 @@ class SpanBessMetadataSensorEntityDescription( @dataclass(frozen=True) -class SpanMidRequiredKeysMixin: +class SpanMidRequiredKeysMixin(FieldPathDeclarationMixin): """Required keys mixin for MID sensors.""" value_fn: Callable[[SpanMidSnapshot], str | None] -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class SpanMidSensorEntityDescription(SensorEntityDescription, SpanMidRequiredKeysMixin): """Describes a sensor on the Microgrid Interconnect Device.""" @@ -373,6 +395,7 @@ class SpanMidSensorEntityDescription(SensorEntityDescription, SpanMidRequiredKey MID_SENSORS: tuple[SpanMidSensorEntityDescription, ...] = ( SpanMidSensorEntityDescription( key="mid_grid_state", + derived=True, translation_key="mid_grid_state", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, @@ -403,30 +426,35 @@ class SpanMidSensorEntityDescription(SensorEntityDescription, SpanMidRequiredKey ] = ( SpanBessMetadataSensorEntityDescription( key="vendor", + field_path="battery.vendor_name", translation_key="bess_vendor", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda b: b.vendor_name, ), SpanBessMetadataSensorEntityDescription( key="model", + field_path="battery.model", translation_key="bess_model", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda b: b.model, ), SpanBessMetadataSensorEntityDescription( key="serial_number", + field_path="battery.serial_number", translation_key="bess_serial_number", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda b: b.serial_number, ), SpanBessMetadataSensorEntityDescription( key="firmware_version", + field_path="battery.software_version", translation_key="bess_firmware_version", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda b: b.software_version, ), SpanBessMetadataSensorEntityDescription( key="nameplate_capacity", + field_path="battery.nameplate_capacity_kwh", translation_key="bess_nameplate_capacity", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, entity_category=EntityCategory.DIAGNOSTIC, @@ -435,6 +463,7 @@ class SpanMidSensorEntityDescription(SensorEntityDescription, SpanMidRequiredKey ), SpanBessMetadataSensorEntityDescription( key="soe_kwh", + field_path="battery.soe_kwh", translation_key="bess_soe_kwh", device_class=SensorDeviceClass.ENERGY_STORAGE, state_class=SensorStateClass.MEASUREMENT, @@ -451,13 +480,13 @@ class SpanMidSensorEntityDescription(SensorEntityDescription, SpanMidRequiredKey @dataclass(frozen=True) -class SpanPVMetadataRequiredKeysMixin: +class SpanPVMetadataRequiredKeysMixin(FieldPathDeclarationMixin): """Required keys mixin for PV metadata sensors.""" value_fn: Callable[[SpanPanelSnapshot], float | str | None] -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class SpanPVMetadataSensorEntityDescription( SensorEntityDescription, SpanPVMetadataRequiredKeysMixin ): @@ -471,18 +500,21 @@ class SpanPVMetadataSensorEntityDescription( ] = ( SpanPVMetadataSensorEntityDescription( key="pv_vendor", + field_path="pv.vendor_name", translation_key="pv_vendor", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda s: s.pv.vendor_name, ), SpanPVMetadataSensorEntityDescription( key="pv_product", + field_path="pv.model", translation_key="pv_product", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda s: s.pv.model, ), SpanPVMetadataSensorEntityDescription( key="pv_nameplate_capacity", + field_path="pv.nameplate_capacity_w", translation_key="pv_nameplate_capacity", native_unit_of_measurement=UnitOfPower.WATT, entity_category=EntityCategory.DIAGNOSTIC, @@ -499,6 +531,7 @@ class SpanPVMetadataSensorEntityDescription( ] = ( SpanPanelDataSensorEntityDescription( key="instantGridPowerW", + field_path="panel.instant_grid_power_w", translation_key="instant_grid_power", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, @@ -508,6 +541,7 @@ class SpanPVMetadataSensorEntityDescription( ), SpanPanelDataSensorEntityDescription( key="feedthroughPowerW", + field_path="panel.feedthrough_power_w", translation_key="feedthrough_power", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, @@ -520,6 +554,7 @@ class SpanPVMetadataSensorEntityDescription( # Battery power sensor (conditionally created when BESS is commissioned) BATTERY_POWER_SENSOR: SpanPanelDataSensorEntityDescription = SpanPanelDataSensorEntityDescription( key="batteryPowerW", + field_path="panel.power_flow_battery", translation_key="battery_power", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, @@ -531,6 +566,7 @@ class SpanPVMetadataSensorEntityDescription( # PV power sensor (conditionally created when PV is commissioned) PV_POWER_SENSOR: SpanPanelDataSensorEntityDescription = SpanPanelDataSensorEntityDescription( key="pvPowerW", + field_path="panel.power_flow_pv", translation_key="pv_power", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, @@ -542,6 +578,7 @@ class SpanPVMetadataSensorEntityDescription( # Grid power flow sensor (conditionally created when power-flows data is available) GRID_POWER_FLOW_SENSOR: SpanPanelDataSensorEntityDescription = SpanPanelDataSensorEntityDescription( key="gridPowerFlowW", + field_path="panel.power_flow_grid", translation_key="grid_power_flow", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, @@ -553,6 +590,7 @@ class SpanPVMetadataSensorEntityDescription( # Site power sensor (conditionally created when power-flows data is available) SITE_POWER_SENSOR: SpanPanelDataSensorEntityDescription = SpanPanelDataSensorEntityDescription( key="sitePowerW", + field_path="panel.power_flow_site", translation_key="site_power", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, @@ -572,6 +610,7 @@ class SpanPVMetadataSensorEntityDescription( ] = ( SpanPanelDataSensorEntityDescription( key="mainMeterEnergyProducedWh", + field_path="panel.main_meter_energy_produced_wh", translation_key="main_meter_produced_energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, @@ -581,6 +620,7 @@ class SpanPVMetadataSensorEntityDescription( ), SpanPanelDataSensorEntityDescription( key="mainMeterEnergyConsumedWh", + field_path="panel.main_meter_energy_consumed_wh", translation_key="main_meter_consumed_energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, @@ -590,6 +630,7 @@ class SpanPVMetadataSensorEntityDescription( ), SpanPanelDataSensorEntityDescription( key="feedthroughEnergyProducedWh", + field_path="panel.feedthrough_energy_produced_wh", translation_key="feedthrough_produced_energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL, @@ -599,6 +640,7 @@ class SpanPVMetadataSensorEntityDescription( ), SpanPanelDataSensorEntityDescription( key="feedthroughEnergyConsumedWh", + field_path="panel.feedthrough_energy_consumed_wh", translation_key="feedthrough_consumed_energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL, @@ -608,6 +650,7 @@ class SpanPVMetadataSensorEntityDescription( ), SpanPanelDataSensorEntityDescription( key="mainMeterNetEnergyWh", + derived=True, translation_key="main_meter_net_energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL, @@ -620,6 +663,7 @@ class SpanPVMetadataSensorEntityDescription( ), SpanPanelDataSensorEntityDescription( key="feedthroughNetEnergyWh", + derived=True, translation_key="feedthrough_net_energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL, @@ -641,6 +685,7 @@ class SpanPVMetadataSensorEntityDescription( ] = ( SpanPanelCircuitsSensorEntityDescription( key="circuit_power", + field_path="circuit.instant_power_w", name="Power", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, @@ -654,6 +699,7 @@ class SpanPVMetadataSensorEntityDescription( ), SpanPanelCircuitsSensorEntityDescription( key="circuit_energy_produced", + field_path="circuit.produced_energy_wh", name="Produced Energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, @@ -665,6 +711,7 @@ class SpanPVMetadataSensorEntityDescription( ), SpanPanelCircuitsSensorEntityDescription( key="circuit_energy_consumed", + field_path="circuit.consumed_energy_wh", name="Consumed Energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, @@ -676,6 +723,7 @@ class SpanPVMetadataSensorEntityDescription( ), SpanPanelCircuitsSensorEntityDescription( key="circuit_energy_net", + derived=True, name="Net Energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL, @@ -698,13 +746,13 @@ class SpanPVMetadataSensorEntityDescription( @dataclass(frozen=True) -class SpanEvseRequiredKeysMixin: +class SpanEvseRequiredKeysMixin(FieldPathDeclarationMixin): """Required keys mixin for EVSE sensors.""" value_fn: Callable[[SpanEvseSnapshot], float | str | None] -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class SpanEvseSensorEntityDescription(SensorEntityDescription, SpanEvseRequiredKeysMixin): """Describes an EVSE sensor entity.""" @@ -716,6 +764,7 @@ class SpanEvseSensorEntityDescription(SensorEntityDescription, SpanEvseRequiredK ] = ( SpanEvseSensorEntityDescription( key="evse_status", + field_path="evse.status", translation_key="evse_status", device_class=SensorDeviceClass.ENUM, options=[ @@ -734,6 +783,7 @@ class SpanEvseSensorEntityDescription(SensorEntityDescription, SpanEvseRequiredK ), SpanEvseSensorEntityDescription( key="evse_advertised_current", + field_path="evse.advertised_current_a", translation_key="evse_advertised_current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, @@ -743,9 +793,45 @@ class SpanEvseSensorEntityDescription(SensorEntityDescription, SpanEvseRequiredK ), SpanEvseSensorEntityDescription( key="evse_lock_state", + field_path="evse.lock_state", translation_key="evse_lock_state", device_class=SensorDeviceClass.ENUM, options=["locked", "unlocked", "unknown"], value_fn=lambda e: e.lock_state or "unknown", ), ) + + +def all_sensor_descriptions() -> tuple[SensorEntityDescription, ...]: + """Every sensor description, without deduplication. + + Deliberately not keyed by `description.key`: keys such as "model" and + "serial_number" repeat across device classes, so a dict keyed on them + silently drops descriptions. + """ + return ( + *PANEL_DATA_STATUS_SENSORS, + *STATUS_SENSORS, + *UNMAPPED_SENSORS, + *MID_SENSORS, + *BESS_METADATA_SENSORS, + *PV_METADATA_SENSORS, + *PANEL_POWER_SENSORS, + *PANEL_ENERGY_SENSORS, + *CIRCUIT_SENSORS, + *EVSE_SENSORS, + BATTERY_SENSOR, + BATTERY_POWER_SENSOR, + PV_POWER_SENSOR, + GRID_POWER_FLOW_SENSOR, + SITE_POWER_SENSOR, + L1_VOLTAGE_SENSOR, + L2_VOLTAGE_SENSOR, + UPSTREAM_L1_CURRENT_SENSOR, + UPSTREAM_L2_CURRENT_SENSOR, + DOWNSTREAM_L1_CURRENT_SENSOR, + DOWNSTREAM_L2_CURRENT_SENSOR, + MAIN_BREAKER_RATING_SENSOR, + CIRCUIT_CURRENT_SENSOR, + CIRCUIT_BREAKER_RATING_SENSOR, + ) diff --git a/tests/test_field_paths.py b/tests/test_field_paths.py new file mode 100644 index 00000000..2a1fded8 --- /dev/null +++ b/tests/test_field_paths.py @@ -0,0 +1,28 @@ +"""Every entity that reads one snapshot field must say which field.""" + +from __future__ import annotations + +from custom_components.span_panel.field_paths import declared_field_paths +from custom_components.span_panel.sensor_definitions import CIRCUIT_SENSORS + + +def test_circuit_power_declares_its_field_path() -> None: + power = next(d for d in CIRCUIT_SENSORS if d.key == "circuit_power") + assert power.field_path == "circuit.instant_power_w" + assert power.derived is False + + +def test_derived_sensor_declares_no_path() -> None: + """dsm_state is a multi-signal derivation with no single source field.""" + from custom_components.span_panel.sensor_definitions import PANEL_DATA_STATUS_SENSORS + + dsm = next(d for d in PANEL_DATA_STATUS_SENSORS if d.key == "dsm_state") + assert dsm.derived is True + assert dsm.field_path is None + + +def test_declared_field_paths_includes_residuals() -> None: + """Readers that live in entity code rather than on a description still count.""" + paths = declared_field_paths() + assert "circuit.relay_state" in paths + assert "circuit.priority" in paths From 0a15c2a20414d645ff820f7ebfa70e167e3321d2 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:45:34 -0700 Subject: [PATCH 017/345] refactor: return a frozenset of producible field paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit declared_field_paths() returned dict[str, bool] whose value was always False — derived descriptions are skipped before the assignment, so no key could map to True. It is a set, so return one. Also splits the residual readers by empirical producibility against both schema adapters. Readers both adapters produce join RESIDUAL_FIELD_PATHS; $target echoes, topology-derived values and schema-conditional fields go to RESIDUAL_DERIVED_PATHS and stay out of the producible set. Adds the enumeration test that was missing: a description inheriting the declaration mixin but setting neither field passed silently and was dropped from every gate. --- custom_components/span_panel/field_paths.py | 57 ++++++++++++++++++--- tests/test_field_paths.py | 46 ++++++++++++++++- 2 files changed, 93 insertions(+), 10 deletions(-) diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py index ea415532..8570eeae 100644 --- a/custom_components/span_panel/field_paths.py +++ b/custom_components/span_panel/field_paths.py @@ -57,20 +57,61 @@ class FieldPathDeclarationMixin: # Consumed by entity naming and attributes rather than by any platform "circuit.name", "circuit.tabs", + # sensor_circuit.py publishes this as a circuit attribute + "circuit.relay_requester", } ) -"""Readers not carried on an entity description. +"""Readers not carried on an entity description, and producible by both adapters. Keep this small. A new entry is a hint that the reader belongs on a description instead. """ -def declared_field_paths() -> dict[str, bool]: - """Every field path the integration reads, mapped to whether it is derived. +RESIDUAL_DERIVED_PATHS: frozenset[str] = frozenset( + { + # Homie `$target` values — a pending-command echo, not a schema field. + # Neither adapter publishes a metadata row for them. + "circuit.relay_state_target", + "circuit.priority_target", + # Assembled by the library from panel topology rather than read from a + # schema property; no metadata row in either adapter. + "circuit.device_type", + "circuit.relative_position", + # No metadata row in either adapter — the panel reports it outside the + # typed field surface. + "panel.panel_size", + # Neither adapter emits any `mid.*` metadata rows at all; util.py reads + # these off the MID snapshot for device_info only. + "mid.hardware_version", + "mid.software_version", + # Schema-conditional: schema_1 publishes it, schema_0 has no row. + "circuit.is_user_controllable", + # Schema-conditional: schema_0 publishes these, schema_1 has no row. + "circuit.always_on", + "circuit.is_sheddable", + "panel.wifi_ssid", + # Schema-conditional: schema_0 publishes it, schema_1's + # `_PROPERTY_FIELD_MAP` has no `connected` row — the same gap that makes + # the `bess_connected` binary sensor `derived=True`. + "battery.connected", + } +) +"""Residual readers exempt from the producible check. + +Deliberately **not** returned by `declared_field_paths()`. Each entry is either +produced by no adapter at all, or by only one of the two — so requiring it to be +producible would fail against the other schema. Recorded here so the reads are +still enumerated somewhere rather than being invisible. +""" + + +def declared_field_paths() -> frozenset[str]: + """Field paths the integration reads that must be producible by an adapter. - Derived paths are exempt from the producible check because they have no - single source field. + Derived entities are excluded: they have no single source field, so there is + nothing for an adapter to produce. Residual readers that no adapter (or only + one) produces are excluded too, and are listed in `RESIDUAL_DERIVED_PATHS`. """ # Deferred: the platform modules import `FieldPathDeclarationMixin` from # here, and `binary_sensor` reaches the package root for its config-entry @@ -85,7 +126,7 @@ def declared_field_paths() -> dict[str, bool]: all_sensor_descriptions, ) - paths: dict[str, bool] = dict.fromkeys(RESIDUAL_FIELD_PATHS, False) + paths: set[str] = set(RESIDUAL_FIELD_PATHS) for description in ( *all_sensor_descriptions(), *BINARY_SENSORS, @@ -101,5 +142,5 @@ def declared_field_paths() -> dict[str, bool]: ) if description.derived or description.field_path is None: continue - paths[description.field_path] = False - return paths + paths.add(description.field_path) + return frozenset(paths) diff --git a/tests/test_field_paths.py b/tests/test_field_paths.py index 2a1fded8..ddc5fd28 100644 --- a/tests/test_field_paths.py +++ b/tests/test_field_paths.py @@ -2,8 +2,21 @@ from __future__ import annotations -from custom_components.span_panel.field_paths import declared_field_paths -from custom_components.span_panel.sensor_definitions import CIRCUIT_SENSORS +from custom_components.span_panel.binary_sensor import ( + BESS_CONNECTED_SENSOR, + BINARY_SENSORS, + EVSE_BINARY_SENSORS, + GRID_ISLANDABLE_SENSOR, +) +from custom_components.span_panel.field_paths import ( + RESIDUAL_DERIVED_PATHS, + RESIDUAL_FIELD_PATHS, + declared_field_paths, +) +from custom_components.span_panel.sensor_definitions import ( + CIRCUIT_SENSORS, + all_sensor_descriptions, +) def test_circuit_power_declares_its_field_path() -> None: @@ -26,3 +39,32 @@ def test_declared_field_paths_includes_residuals() -> None: paths = declared_field_paths() assert "circuit.relay_state" in paths assert "circuit.priority" in paths + + +def test_every_description_declares_exactly_one() -> None: + """A description inheriting the mixin but setting neither field is invisible. + + The `TypeError` guard in `declared_field_paths` only catches a description + that lacks the mixin entirely. Every new sensor inherits it automatically, + so the likelier mistake is inheriting it and declaring nothing — which + drops the entity from every gate with no signal. This is that signal. + + Must enumerate exactly what `declared_field_paths` iterates. + """ + for description in ( + *all_sensor_descriptions(), + *BINARY_SENSORS, + *EVSE_BINARY_SENSORS, + GRID_ISLANDABLE_SENSOR, + BESS_CONNECTED_SENSOR, + ): + declares_path = description.field_path is not None + assert declares_path != description.derived, ( + f"{description.key} must declare exactly one of field_path / derived=True" + ) + + +def test_residual_buckets_are_disjoint() -> None: + """A residual path is either producible or exempt, never both.""" + assert not (RESIDUAL_FIELD_PATHS & RESIDUAL_DERIVED_PATHS) + assert not (declared_field_paths() & RESIDUAL_DERIVED_PATHS) From f4d0de0152176b8d74bfbb2fde9a9dc583797c75 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:47:29 -0700 Subject: [PATCH 018/345] refactor: rename RESIDUAL_DERIVED_PATHS to RESIDUAL_EXEMPT_PATHS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only some of its entries are derived. Five are schema-conditional — read straight off a snapshot field that one adapter publishes and the other does not — so "derived" misdescribes why they are exempt. The docstring now names both reasons a path lands here. --- custom_components/span_panel/field_paths.py | 23 ++++++++++++++------- tests/test_field_paths.py | 6 +++--- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py index 8570eeae..d1506a61 100644 --- a/custom_components/span_panel/field_paths.py +++ b/custom_components/span_panel/field_paths.py @@ -68,7 +68,7 @@ class FieldPathDeclarationMixin: """ -RESIDUAL_DERIVED_PATHS: frozenset[str] = frozenset( +RESIDUAL_EXEMPT_PATHS: frozenset[str] = frozenset( { # Homie `$target` values — a pending-command echo, not a schema field. # Neither adapter publishes a metadata row for them. @@ -97,12 +97,21 @@ class FieldPathDeclarationMixin: "battery.connected", } ) -"""Residual readers exempt from the producible check. +"""Residual readers exempt from the producible check, for one of two reasons. -Deliberately **not** returned by `declared_field_paths()`. Each entry is either -produced by no adapter at all, or by only one of the two — so requiring it to be -producible would fail against the other schema. Recorded here so the reads are -still enumerated somewhere rather than being invisible. +**Not produced by any adapter** — Homie `$target` echoes, values the library +assembles from panel topology, and every `mid.*` field. There is no metadata row +to check against on either schema. + +**Produced by only one adapter** — schema-conditional fields. The gate requires +a path to be producible by *both* adapters, so a field present on one schema and +absent from the other cannot satisfy it. Exempt is not the same as derived: +these are read straight off a snapshot field, that field just is not on both +schemas. + +Deliberately **not** returned by `declared_field_paths()`. Recorded here so the +reads are still enumerated somewhere rather than being invisible. The per-entry +comments say which of the two reasons applies. """ @@ -111,7 +120,7 @@ def declared_field_paths() -> frozenset[str]: Derived entities are excluded: they have no single source field, so there is nothing for an adapter to produce. Residual readers that no adapter (or only - one) produces are excluded too, and are listed in `RESIDUAL_DERIVED_PATHS`. + one) produces are excluded too, and are listed in `RESIDUAL_EXEMPT_PATHS`. """ # Deferred: the platform modules import `FieldPathDeclarationMixin` from # here, and `binary_sensor` reaches the package root for its config-entry diff --git a/tests/test_field_paths.py b/tests/test_field_paths.py index ddc5fd28..a5b12b3c 100644 --- a/tests/test_field_paths.py +++ b/tests/test_field_paths.py @@ -9,7 +9,7 @@ GRID_ISLANDABLE_SENSOR, ) from custom_components.span_panel.field_paths import ( - RESIDUAL_DERIVED_PATHS, + RESIDUAL_EXEMPT_PATHS, RESIDUAL_FIELD_PATHS, declared_field_paths, ) @@ -66,5 +66,5 @@ def test_every_description_declares_exactly_one() -> None: def test_residual_buckets_are_disjoint() -> None: """A residual path is either producible or exempt, never both.""" - assert not (RESIDUAL_FIELD_PATHS & RESIDUAL_DERIVED_PATHS) - assert not (declared_field_paths() & RESIDUAL_DERIVED_PATHS) + assert not (RESIDUAL_FIELD_PATHS & RESIDUAL_EXEMPT_PATHS) + assert not (declared_field_paths() & RESIDUAL_EXEMPT_PATHS) From 8b70c294a7c1cd3e27da2e8b84cfe50d177ec0e6 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:46:33 -0700 Subject: [PATCH 019/345] refactor: complete the exempt field path enumeration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourteen further snapshot-field reads outside any entity description were in neither bucket, including three in the same DeviceInfo call as bucketed mid.* paths and one in the same availability property as battery.connected. That adjacency is how the next reader concludes the constant is complete when it is not. All fourteen are exempt — none is producible by both adapters — so declared_field_paths() stays at 46 and the gate is unchanged. --- custom_components/span_panel/field_paths.py | 34 +++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py index d1506a61..f3f5edf7 100644 --- a/custom_components/span_panel/field_paths.py +++ b/custom_components/span_panel/field_paths.py @@ -10,7 +10,7 @@ fields to `battery.model` / `pv.model`). Field path convention: ``{snapshot_type}.{field_name}`` — ``panel``, -``circuit``, ``battery``, ``pv`` and ``evse``. +``circuit``, ``battery``, ``pv``, ``evse`` and ``mid``. """ from __future__ import annotations @@ -81,16 +81,46 @@ class FieldPathDeclarationMixin: # No metadata row in either adapter — the panel reports it outside the # typed field surface. "panel.panel_size", + # The panel identity key behind every unique_id and the panel DeviceInfo + # (~30 read sites). Neither adapter publishes a row for it. + "panel.serial_number", + # Gates button availability at button.py:115. No row in either adapter — + # the same reason the `dsm_state` sensor is `derived=True`. + "panel.dsm_state", + # The circuit's own identity key, used for lookups and id construction + # (helpers.py, coordinator.py, entity_resolver.py). No row in either + # adapter. + "circuit.circuit_id", # Neither adapter emits any `mid.*` metadata rows at all; util.py reads - # these off the MID snapshot for device_info only. + # these off the MID snapshot for device_info, and sensor_panel.py reads + # the grid-forming name for an attribute. "mid.hardware_version", "mid.software_version", + "mid.vendor_name", + "mid.model", + "mid.serial_number", + "mid.grid_forming_device_name", + # The EVSE's Homie node id — an addressing handle used to build the + # sub-device identifier, not a published field. No row in either adapter. + "evse.node_id", # Schema-conditional: schema_1 publishes it, schema_0 has no row. "circuit.is_user_controllable", # Schema-conditional: schema_0 publishes these, schema_1 has no row. "circuit.always_on", "circuit.is_sheddable", "panel.wifi_ssid", + # Schema-conditional: schema_0 publishes these, schema_1 has no row. + # util.py builds the EVSE DeviceInfo from them; entity_resolver.py and + # sensor.py resolve the fed circuit through `feed_circuit_id`. + "evse.vendor_name", + "evse.model", + "evse.serial_number", + "evse.software_version", + "evse.feed_circuit_id", + # Schema-conditional: schema_0 publishes it, schema_1 derives islanding + # via `resolve_grid_islandable(inverters)`. Read at binary_sensor.py:408 + # as an entity-creation gate, outside any description. + "panel.grid_islandable", # Schema-conditional: schema_0 publishes it, schema_1's # `_PROPERTY_FIELD_MAP` has no `connected` row — the same gap that makes # the `bess_connected` binary sensor `derived=True`. From 06ef60f0708877994c9da0d841b7fd20aed0d255 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:57:39 -0700 Subject: [PATCH 020/345] test: assert declared field paths are producible by both adapters --- tests/test_field_path_conformance.py | 49 ++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 tests/test_field_path_conformance.py diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py new file mode 100644 index 00000000..dc290705 --- /dev/null +++ b/tests/test_field_path_conformance.py @@ -0,0 +1,49 @@ +"""Every declared field path must be producible by each adapter, or derived. + +This is the test that would have caught the battery.product_name drift. +""" + +from __future__ import annotations + +from collections.abc import Callable + +import pytest +from span_panel_api.models import FieldMetadata + +from custom_components.span_panel.field_paths import declared_field_paths +from tests.adapter_fixtures import schema_one_metadata, schema_zero_metadata + +MetadataFn = Callable[[], dict[str, FieldMetadata]] + +_ADAPTERS: list[tuple[str, MetadataFn]] = [ + ("schema_0", schema_zero_metadata), + ("schema_1", schema_one_metadata), +] + + +@pytest.mark.parametrize(("adapter", "metadata_fn"), _ADAPTERS) +def test_every_declared_path_is_producible(adapter: str, metadata_fn: MetadataFn) -> None: + metadata = metadata_fn() + missing = sorted(path for path in declared_field_paths() if path not in metadata) + assert not missing, ( + f"{adapter} does not produce declared field paths: {missing}. " + "Either the declaration is stale, or the entity should be derived=True." + ) + + +@pytest.mark.parametrize(("adapter", "metadata_fn"), _ADAPTERS) +def test_gate_is_one_directional(adapter: str, metadata_fn: MetadataFn) -> None: + """A produced path nothing reads must NOT fail the build. + + Additions are legal within a major version, so asserting the converse would + turn correct upstream behaviour into a red CI every time SPAN ships a + property. This test exists to stop someone "completing" the gate by adding + that assertion. + """ + metadata = metadata_fn() + unread = sorted(set(metadata) - set(declared_field_paths())) + # The CI inventory the spec asks for: printed so the build log carries it, + # never asserted against. An addition is legal within a major version. + print(f"\n[{adapter}] produced but unread ({len(unread)}):") + for path in unread: + print(f" {path}") From 863a429cece8a07a5ca6d353c781379ef62bf8c4 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:11:16 -0700 Subject: [PATCH 021/345] test: pin field path gate coverage and surface the unread inventory in CI --- tests/test_field_path_conformance.py | 74 +++++++++++++++++++++++++--- 1 file changed, 68 insertions(+), 6 deletions(-) diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py index dc290705..f799a9b6 100644 --- a/tests/test_field_path_conformance.py +++ b/tests/test_field_path_conformance.py @@ -5,11 +5,14 @@ from __future__ import annotations +import ast from collections.abc import Callable +import pathlib import pytest from span_panel_api.models import FieldMetadata +from custom_components.span_panel import field_paths as field_paths_module from custom_components.span_panel.field_paths import declared_field_paths from tests.adapter_fixtures import schema_one_metadata, schema_zero_metadata @@ -20,6 +23,34 @@ ("schema_1", schema_one_metadata), ] +_PACKAGE_ROOT = pathlib.Path(field_paths_module.__file__).parent + + +def _source_declared_paths() -> dict[str, str]: + """Every ``field_path="..."`` literal in the integration source, by module. + + Read from the source text rather than from the platform collections, so the + hand-written tuple `declared_field_paths` iterates is pinned against what + the modules actually declare. A runtime scan of module attributes could not + do this: most descriptions are inline literals inside their collection, so + emptying the collection would hide them from the check as well as from the + gate. + """ + found: dict[str, str] = {} + for source in sorted(_PACKAGE_ROOT.rglob("*.py")): + tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source)) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + for keyword in node.keywords: + if keyword.arg != "field_path": + continue + # Non-literal values are pass-throughs that copy an existing + # description's declaration, not new declarations. + if isinstance(keyword.value, ast.Constant) and isinstance(keyword.value.value, str): + found.setdefault(keyword.value.value, source.name) + return found + @pytest.mark.parametrize(("adapter", "metadata_fn"), _ADAPTERS) def test_every_declared_path_is_producible(adapter: str, metadata_fn: MetadataFn) -> None: @@ -31,8 +62,35 @@ def test_every_declared_path_is_producible(adapter: str, metadata_fn: MetadataFn ) +def test_gate_covers_every_declaration_in_the_source() -> None: + """A declaration the gate stops iterating must fail, not shrink silently. + + `test_every_declared_path_is_producible` is monotone: a smaller declared set + always passes, because any subset of a producible set is producible. So + dropping a platform collection from `declared_field_paths`, or letting a + description fall out of its collection, would silently retire the gate for + every entity involved with no signal at all — the same invisible-omission + failure this whole module exists to prevent, one level up. + + This is that signal. It compares against the source text, so it holds even + when a collection is emptied rather than unreferenced. + """ + declared = declared_field_paths() + uncovered = sorted( + (path, module) for path, module in _source_declared_paths().items() if path not in declared + ) + assert not uncovered, ( + "declared_field_paths() does not cover field paths declared in the source: " + f"{uncovered}. A platform collection likely stopped being iterated, or a " + "description fell out of its collection — the gate is no longer checking " + "those entities." + ) + + @pytest.mark.parametrize(("adapter", "metadata_fn"), _ADAPTERS) -def test_gate_is_one_directional(adapter: str, metadata_fn: MetadataFn) -> None: +def test_gate_is_one_directional( + adapter: str, metadata_fn: MetadataFn, capsys: pytest.CaptureFixture[str] +) -> None: """A produced path nothing reads must NOT fail the build. Additions are legal within a major version, so asserting the converse would @@ -42,8 +100,12 @@ def test_gate_is_one_directional(adapter: str, metadata_fn: MetadataFn) -> None: """ metadata = metadata_fn() unread = sorted(set(metadata) - set(declared_field_paths())) - # The CI inventory the spec asks for: printed so the build log carries it, - # never asserted against. An addition is legal within a major version. - print(f"\n[{adapter}] produced but unread ({len(unread)}):") - for path in unread: - print(f" {path}") + # The CI inventory the spec asks for: never asserted against, an addition is + # legal within a major version. Written with capture suspended so it reaches + # the build log under the plain `pytest` CI runs -- pytest swallows stdout + # from a passing test, which would leave the inventory existing in code and + # nowhere else. + with capsys.disabled(): + print(f"\n[{adapter}] produced but unread ({len(unread)}):") + for path in unread: + print(f" {path}") From b72bd2bedd3d8e3c3df307ff5c7f7e2dccaa990f Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:22:23 -0700 Subject: [PATCH 022/345] test: verify field_path declarations against their value_fns --- tests/test_field_path_introspection.py | 112 +++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 tests/test_field_path_introspection.py diff --git a/tests/test_field_path_introspection.py b/tests/test_field_path_introspection.py new file mode 100644 index 00000000..0b7196f1 --- /dev/null +++ b/tests/test_field_path_introspection.py @@ -0,0 +1,112 @@ +"""Verify each declared field_path against what its value_fn actually reads. + +Runs every value_fn against a proxy that records attribute access. The +declaration stays authoritative — this only stops it drifting from the reader. +""" + +from __future__ import annotations + +from typing import Any + +from custom_components.span_panel.sensor_definitions import all_sensor_descriptions + +# Attributes of the panel snapshot that are themselves sub-snapshots. Their +# fields are addressed as "battery.x", not "panel.battery.x". +_SUB_SNAPSHOTS = {"battery", "pv", "evse", "mid"} + + +class _Recorder: + """Records every attribute path touched, and survives arithmetic. + + value_fns do real work — `or "unknown"`, `a - b`, unary minus for PV sign + flips — so the proxy has to absorb those without raising and without + ending the recording. + """ + + def __init__(self, sink: set[str], prefix: str, root: bool = False) -> None: + object.__setattr__(self, "_sink", sink) + object.__setattr__(self, "_prefix", prefix) + object.__setattr__(self, "_root", root) + + def __getattr__(self, name: str) -> _Recorder: + if name.startswith("_"): + raise AttributeError(name) + sink: set[str] = object.__getattribute__(self, "_sink") + prefix: str = object.__getattribute__(self, "_prefix") + root: bool = object.__getattribute__(self, "_root") + if root and name in _SUB_SNAPSHOTS: + return _Recorder(sink, name) + path = f"{prefix}.{name}" if prefix else name + sink.add(path) + return _Recorder(sink, path) + + # Absorb the operations value_fns perform on the values they read. + def __bool__(self) -> bool: + return True + + def __sub__(self, other: Any) -> _Recorder: + return self + + def __rsub__(self, other: Any) -> _Recorder: + return self + + def __neg__(self) -> _Recorder: + return self + + def __or__(self, other: Any) -> _Recorder: + return self + + def __call__(self, *args: Any, **kwargs: Any) -> _Recorder: + """Absorb method calls, e.g. `(m.grid_state or "unknown").lower()`. + + The extra path this records (`mid.grid_state.lower`) is harmless: the + check is membership of the declared path, not set equality. + """ + return self + + def __eq__(self, other: Any) -> bool: + return False + + def __hash__(self) -> int: + return 0 + + +# Every description class, and the snapshot type its value_fn receives. +# A class missing here is silently skipped by the `prefix is None` guard below — +# which is exactly the hole this test exists to close, so keep it complete. +_ROOT_PREFIX = { + "SpanPanelCircuitsSensorEntityDescription": "circuit", + "SpanPanelDataSensorEntityDescription": "panel", + "SpanPanelStatusSensorEntityDescription": "panel", + "SpanPanelBatterySensorEntityDescription": "battery", + "SpanBessMetadataSensorEntityDescription": "battery", + # PV metadata value_fns take the whole panel snapshot and reach through + # `s.pv.x`, so the root prefix is "panel" and _SUB_SNAPSHOTS rewrites it. + "SpanPVMetadataSensorEntityDescription": "panel", + "SpanEvseSensorEntityDescription": "evse", + "SpanMidSensorEntityDescription": "mid", +} + + +def test_declared_paths_match_what_value_fns_read() -> None: + mismatches: list[str] = [] + + for description in all_sensor_descriptions(): + if description.derived or description.field_path is None: + continue + prefix = _ROOT_PREFIX.get(type(description).__name__) + if prefix is None: + continue + sink: set[str] = set() + proxy = _Recorder(sink, prefix, root=(prefix == "panel")) + try: + description.value_fn(proxy) + except Exception as err: # noqa: BLE001 + mismatches.append(f"{description.key}: value_fn raised {err!r}") + continue + if description.field_path not in sink: + mismatches.append( + f"{description.key}: declares {description.field_path!r} but reads {sorted(sink)}" + ) + + assert not mismatches, "Declarations disagree with readers:\n" + "\n".join(mismatches) From d5b83ca3cd93e615b1748d95edfa5ffb3e02f143 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:24:15 -0700 Subject: [PATCH 023/345] test: introspect binary sensor declarations and fail on unmapped classes The gate in declared_field_paths() walks the binary sensor collections too, so their declarations were producible-checked but never checked against their readers. Cover them, and pin the introspected description set against the gate's own so a new platform collection cannot be gated without being verified. An entity description class absent from the root-prefix map is now reported as a failure rather than skipped -- a silent skip is the drift this test exists to catch. --- tests/test_field_path_introspection.py | 89 ++++++++++++++++++++++++-- 1 file changed, 84 insertions(+), 5 deletions(-) diff --git a/tests/test_field_path_introspection.py b/tests/test_field_path_introspection.py index 0b7196f1..826f028b 100644 --- a/tests/test_field_path_introspection.py +++ b/tests/test_field_path_introspection.py @@ -6,8 +6,13 @@ from __future__ import annotations -from typing import Any +from collections.abc import Callable, Iterator +from typing import Any, Protocol, runtime_checkable +from custom_components.span_panel.field_paths import ( + RESIDUAL_FIELD_PATHS, + declared_field_paths, +) from custom_components.span_panel.sensor_definitions import all_sensor_descriptions # Attributes of the panel snapshot that are themselves sub-snapshots. Their @@ -71,9 +76,61 @@ def __hash__(self) -> int: return 0 +@runtime_checkable +class _DeclaringDescription(Protocol): + """The surface this test needs from an entity description. + + A protocol rather than a concrete type because the eight sensor classes and + the two binary-sensor classes share no base beyond + `FieldPathDeclarationMixin`, which does not carry `value_fn`. + """ + + @property + def key(self) -> str: ... + + @property + def field_path(self) -> str | None: ... + + @property + def derived(self) -> bool: ... + + @property + def value_fn(self) -> Callable[[Any], object]: ... + + +def _declaring_descriptions() -> Iterator[_DeclaringDescription]: + """Every entity description that carries a field-path declaration. + + Mirrors the collections `declared_field_paths()` walks, so the gate and this + verifier cover the same descriptions; + `test_introspection_covers_every_declared_path` pins that they still do. + """ + # Deferred for the same reason `field_paths` defers it: `binary_sensor` + # reaches the package root, and the root imports the platforms. + from custom_components.span_panel.binary_sensor import ( # noqa: PLC0415 + BESS_CONNECTED_SENSOR, + BINARY_SENSORS, + EVSE_BINARY_SENSORS, + GRID_ISLANDABLE_SENSOR, + ) + + for description in ( + *all_sensor_descriptions(), + *BINARY_SENSORS, + *EVSE_BINARY_SENSORS, + GRID_ISLANDABLE_SENSOR, + BESS_CONNECTED_SENSOR, + ): + if not isinstance(description, _DeclaringDescription): + raise TypeError( + f"entity description '{description.key}' carries no field-path declaration" + ) + yield description + + # Every description class, and the snapshot type its value_fn receives. -# A class missing here is silently skipped by the `prefix is None` guard below — -# which is exactly the hole this test exists to close, so keep it complete. +# A class missing here is reported as a mismatch rather than skipped: a silent +# skip is exactly the hole this test exists to close. _ROOT_PREFIX = { "SpanPanelCircuitsSensorEntityDescription": "circuit", "SpanPanelDataSensorEntityDescription": "panel", @@ -85,17 +142,24 @@ def __hash__(self) -> int: "SpanPVMetadataSensorEntityDescription": "panel", "SpanEvseSensorEntityDescription": "evse", "SpanMidSensorEntityDescription": "mid", + "SpanPanelBinarySensorEntityDescription": "panel", + "SpanEvseBinarySensorEntityDescription": "evse", } def test_declared_paths_match_what_value_fns_read() -> None: mismatches: list[str] = [] - for description in all_sensor_descriptions(): + for description in _declaring_descriptions(): if description.derived or description.field_path is None: continue - prefix = _ROOT_PREFIX.get(type(description).__name__) + class_name = type(description).__name__ + prefix = _ROOT_PREFIX.get(class_name) if prefix is None: + mismatches.append( + f"{description.key}: {class_name} is absent from _ROOT_PREFIX, so its " + "declaration would go unverified" + ) continue sink: set[str] = set() proxy = _Recorder(sink, prefix, root=(prefix == "panel")) @@ -110,3 +174,18 @@ def test_declared_paths_match_what_value_fns_read() -> None: ) assert not mismatches, "Declarations disagree with readers:\n" + "\n".join(mismatches) + + +def test_introspection_covers_every_declared_path() -> None: + """Every path the gate accepts must be one this test verified, or residual. + + `_declaring_descriptions` restates the collections `declared_field_paths()` + walks. Without this, a platform collection added to the gate but not here + would be gated for producibility and never checked against its reader. + """ + introspected = { + description.field_path + for description in _declaring_descriptions() + if not description.derived and description.field_path is not None + } + assert declared_field_paths() == frozenset(introspected | set(RESIDUAL_FIELD_PATHS)) From eb325503335b7e30ced839f1035d65b0857b7995 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:34:09 -0700 Subject: [PATCH 024/345] test: assert schema unit strings are recognised HA units --- tests/test_unit_vocabulary.py | 56 +++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 tests/test_unit_vocabulary.py diff --git a/tests/test_unit_vocabulary.py b/tests/test_unit_vocabulary.py new file mode 100644 index 00000000..765ff7a3 --- /dev/null +++ b/tests/test_unit_vocabulary.py @@ -0,0 +1,56 @@ +"""Schema unit strings must be recognisable HA units.""" + +from __future__ import annotations + +from collections.abc import Callable + +from homeassistant.const import ( + PERCENTAGE, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, +) +import pytest +from span_panel_api.models import FieldMetadata + +from tests.adapter_fixtures import schema_one_metadata, schema_zero_metadata + +MetadataFn = Callable[[], dict[str, FieldMetadata]] + +_KNOWN: set[str] = { + *(u.value for u in UnitOfPower), + *(u.value for u in UnitOfEnergy), + *(u.value for u in UnitOfElectricCurrent), + *(u.value for u in UnitOfElectricPotential), + PERCENTAGE, +} + +UNIT_TRANSLATIONS: dict[str, str] = {} +"""Schema unit string -> HA unit, for units the panel spells differently. + +Empty today. An entry here is a deliberate statement that the panel's spelling +differs from HA's, not a licence to paper over a firmware bug. +""" + + +@pytest.mark.parametrize( + ("adapter", "metadata_fn"), + [("schema_0", schema_zero_metadata), ("schema_1", schema_one_metadata)], +) +def test_units_are_recognised(adapter: str, metadata_fn: MetadataFn) -> None: + unknown = sorted( + { + entry.unit + for entry in metadata_fn().values() + if entry.resolved + and entry.unit is not None + and entry.unit not in _KNOWN + and entry.unit not in UNIT_TRANSLATIONS + } + ) + assert not unknown, ( + f"{adapter} declares unit strings HA does not recognise: {unknown}. " + "Add a UNIT_TRANSLATIONS entry, or fix the mapping — do not let this " + "reach the unit check, where it becomes one Repair per sensor." + ) From 608d247e1707144bd8167f870e0c586b3d9fcd94 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:37:05 -0700 Subject: [PATCH 025/345] feat: surface the BESS part number as a diagnostic sensor --- custom_components/span_panel/sensor_definitions.py | 9 +++++++++ custom_components/span_panel/strings.json | 3 +++ custom_components/span_panel/translations/en.json | 3 +++ custom_components/span_panel/translations/es.json | 3 +++ custom_components/span_panel/translations/fr.json | 3 +++ custom_components/span_panel/translations/ja.json | 3 +++ custom_components/span_panel/translations/pt.json | 3 +++ tests/test_dps_and_bess.py | 14 ++++++++++++++ tests/test_promoted_sensors.py | 2 +- tests/test_sensor_platform.py | 3 ++- 10 files changed, 44 insertions(+), 2 deletions(-) diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py index 4fd70973..fa3ebcb6 100644 --- a/custom_components/span_panel/sensor_definitions.py +++ b/custom_components/span_panel/sensor_definitions.py @@ -423,6 +423,7 @@ class SpanMidSensorEntityDescription(SensorEntityDescription, SpanMidRequiredKey SpanBessMetadataSensorEntityDescription, SpanBessMetadataSensorEntityDescription, SpanBessMetadataSensorEntityDescription, + SpanBessMetadataSensorEntityDescription, ] = ( SpanBessMetadataSensorEntityDescription( key="vendor", @@ -438,6 +439,14 @@ class SpanMidSensorEntityDescription(SensorEntityDescription, SpanMidRequiredKey entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda b: b.model, ), + SpanBessMetadataSensorEntityDescription( + key="part_number", + field_path="battery.part_number", + translation_key="bess_part_number", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda b: b.part_number, + ), SpanBessMetadataSensorEntityDescription( key="serial_number", field_path="battery.serial_number", diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index 83e427c2..a8f63db5 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -180,6 +180,9 @@ "bess_nameplate_capacity": { "name": "Nameplate Capacity" }, + "bess_part_number": { + "name": "Part Number" + }, "bess_serial_number": { "name": "Serial Number" }, diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index 83e427c2..a8f63db5 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -180,6 +180,9 @@ "bess_nameplate_capacity": { "name": "Nameplate Capacity" }, + "bess_part_number": { + "name": "Part Number" + }, "bess_serial_number": { "name": "Serial Number" }, diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index ed94a353..fa90ee06 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -180,6 +180,9 @@ "bess_nameplate_capacity": { "name": "Capacidad Nominal" }, + "bess_part_number": { + "name": "Número de Pieza" + }, "bess_serial_number": { "name": "Número de Serie" }, diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index 8061af76..0fd22ede 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -180,6 +180,9 @@ "bess_nameplate_capacity": { "name": "Capacité Nominale" }, + "bess_part_number": { + "name": "Référence" + }, "bess_serial_number": { "name": "Numéro de Série" }, diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index 4e4e6458..bc1563aa 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -180,6 +180,9 @@ "bess_nameplate_capacity": { "name": "定格容量" }, + "bess_part_number": { + "name": "型番" + }, "bess_serial_number": { "name": "シリアル番号" }, diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index 166aa539..03ff093d 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -180,6 +180,9 @@ "bess_nameplate_capacity": { "name": "Capacidade Nominal" }, + "bess_part_number": { + "name": "Número de Peça" + }, "bess_serial_number": { "name": "Número de Série" }, diff --git a/tests/test_dps_and_bess.py b/tests/test_dps_and_bess.py index 637284a5..0078f9ae 100644 --- a/tests/test_dps_and_bess.py +++ b/tests/test_dps_and_bess.py @@ -13,6 +13,7 @@ SpanPanelGFEOverrideButton, ) from custom_components.span_panel.helpers import has_bess +from custom_components.span_panel.sensor_definitions import BESS_METADATA_SENSORS from custom_components.span_panel.sensor_panel import _grid_forming_device_name from .factories import SpanPanelSnapshotFactory @@ -242,3 +243,16 @@ def test_no_name_when_the_grid_itself_is_forming(self) -> None: snapshot = SimpleNamespace(mid=SimpleNamespace(grid_forming_device_name=None)) assert _grid_forming_device_name(snapshot) is None + + +# --------------------------------------------------------------------------- +# BESS metadata sensor declarations +# --------------------------------------------------------------------------- + + +def test_bess_part_number_sensor_is_declared() -> None: + """The BESS SKU is surfaced, and declares the field it reads.""" + part = next(d for d in BESS_METADATA_SENSORS if d.key == "part_number") + + assert part.field_path == "battery.part_number" + assert part.derived is False diff --git a/tests/test_promoted_sensors.py b/tests/test_promoted_sensors.py index ee2ec445..25e1be67 100644 --- a/tests/test_promoted_sensors.py +++ b/tests/test_promoted_sensors.py @@ -250,7 +250,7 @@ class TestBessMetadataSensorDefinitions: """Test BESS metadata sensor definitions.""" def test_sensor_count(self): - assert len(BESS_METADATA_SENSORS) == 6 + assert len(BESS_METADATA_SENSORS) == 7 def test_all_have_translation_keys(self): for desc in BESS_METADATA_SENSORS: diff --git a/tests/test_sensor_platform.py b/tests/test_sensor_platform.py index e397ebbb..05c8ff95 100644 --- a/tests/test_sensor_platform.py +++ b/tests/test_sensor_platform.py @@ -345,10 +345,11 @@ def test_create_battery_sensors_returns_expected_entities_when_bess_present() -> entities = create_battery_sensors(coordinator, snapshot) keys = [entity.entity_description.key for entity in entities] - assert len(entities) == 8 + assert len(entities) == 9 assert "batteryPowerW" in keys assert "storage_battery_percentage" in keys assert "vendor" in keys + assert "part_number" in keys def test_create_power_flow_sensors_gate_pv_and_site_flow() -> None: From fa8a4e2f26599c25da14b57cbea9c79eae585ccd Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:47:43 -0700 Subject: [PATCH 026/345] test: pin the unit sample floor and the part number's disabled default --- tests/test_promoted_sensors.py | 15 +++++++++++++++ tests/test_unit_vocabulary.py | 24 ++++++++++++++++-------- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/tests/test_promoted_sensors.py b/tests/test_promoted_sensors.py index 25e1be67..866d2eeb 100644 --- a/tests/test_promoted_sensors.py +++ b/tests/test_promoted_sensors.py @@ -274,6 +274,20 @@ def test_model_value_function(self): desc = next(d for d in BESS_METADATA_SENSORS if d.key == "model") assert desc.value_fn(battery) == "IQ Battery 10" + def test_part_number_value_function(self): + battery = SpanBatterySnapshotFactory.create(part_number="830-01234-01") + desc = next(d for d in BESS_METADATA_SENSORS if d.key == "part_number") + assert desc.value_fn(battery) == "830-01234-01" + + def test_part_number_is_disabled_by_default(self): + """A new entity on an existing install must not appear uninvited. + + Asserted separately from the value function so a flip of this flag names + itself in the failure rather than hiding inside a value test. + """ + desc = next(d for d in BESS_METADATA_SENSORS if d.key == "part_number") + assert desc.entity_registry_enabled_default is False + def test_serial_number_value_function(self): battery = SpanBatterySnapshotFactory.create(serial_number="BESS-12345") desc = next(d for d in BESS_METADATA_SENSORS if d.key == "serial_number") @@ -316,6 +330,7 @@ def test_build_bess_unique_id_different_keys(self): for key in ( "vendor", "model", + "part_number", "serial_number", "firmware_version", "nameplate_capacity", diff --git a/tests/test_unit_vocabulary.py b/tests/test_unit_vocabulary.py index 765ff7a3..7f76c626 100644 --- a/tests/test_unit_vocabulary.py +++ b/tests/test_unit_vocabulary.py @@ -26,6 +26,9 @@ PERCENTAGE, } +_MIN_SAMPLED_UNITS = 5 +"""Floor on distinct unit strings an adapter must emit for the check to mean anything.""" + UNIT_TRANSLATIONS: dict[str, str] = {} """Schema unit string -> HA unit, for units the panel spells differently. @@ -39,15 +42,20 @@ [("schema_0", schema_zero_metadata), ("schema_1", schema_one_metadata)], ) def test_units_are_recognised(adapter: str, metadata_fn: MetadataFn) -> None: + sampled = {e.unit for e in metadata_fn().values() if e.resolved and e.unit is not None} + + # The check below is monotone: it passes for any subset, including the empty + # one. Pin the sample so a fixture path change, a fixture regenerated without + # units, or an adapter that stops populating `unit` cannot retire it silently. + # schema_0 emits 7 distinct strings and schema_1 emits 6 over 27 entries each, + # so 5 leaves room for one unit to be legitimately retired and still fails on + # a collapse. + assert len(sampled) >= _MIN_SAMPLED_UNITS, ( + f"{adapter} emitted only {sorted(sampled)} — the vocabulary check has nothing to check" + ) + unknown = sorted( - { - entry.unit - for entry in metadata_fn().values() - if entry.resolved - and entry.unit is not None - and entry.unit not in _KNOWN - and entry.unit not in UNIT_TRANSLATIONS - } + unit for unit in sampled if unit not in _KNOWN and unit not in UNIT_TRANSLATIONS ) assert not unknown, ( f"{adapter} declares unit strings HA does not recognise: {unknown}. " From 80d8259760ffbeface9a4a786e7e5b1ec75ee1a6 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:57:13 -0700 Subject: [PATCH 027/345] refactor: classify schema findings from the adapter's resolved signal Removes the telemetry-based capability inference, checks unit-less sensors for resolution, and reads field_metadata through the protocol rather than naming the transport class. --- custom_components/span_panel/coordinator.py | 49 ++- custom_components/span_panel/field_paths.py | 4 +- .../span_panel/schema_expectations.py | 102 ----- .../span_panel/schema_validation.py | 216 +++------- .../span_panel/sensor_definitions.py | 24 ++ tests/test_coordinator.py | 79 ++-- tests/test_schema_validation.py | 378 ++++++------------ 7 files changed, 280 insertions(+), 572 deletions(-) delete mode 100644 custom_components/span_panel/schema_expectations.py diff --git a/custom_components/span_panel/coordinator.py b/custom_components/span_panel/coordinator.py index fb2ec28a..30d1cef8 100644 --- a/custom_components/span_panel/coordinator.py +++ b/custom_components/span_panel/coordinator.py @@ -23,12 +23,13 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from span_panel_api import SpanMqttClient, SpanPanelSnapshot +from span_panel_api import SpanMqttClient, SpanPanelClientProtocol, SpanPanelSnapshot from span_panel_api.exceptions import SpanPanelAuthError, SpanPanelStaleDataError from .const import DOMAIN from .id_builder import build_circuit_unique_id -from .schema_validation import collect_sensor_definitions, validate_field_metadata +from .schema_validation import SchemaFindings, evaluate_field_metadata +from .sensor_definitions import sensor_descriptions_by_field_path class SpanCircuitEnergySensorProtocol(Protocol): @@ -90,6 +91,7 @@ def __init__( # Schema validation — run once after first successful refresh self._schema_validated = False + self._findings: SchemaFindings | None = None # Energy dip compensation — sensors append events here during updates; # drained and surfaced as a persistent notification after each cycle. @@ -363,26 +365,41 @@ async def async_shutdown(self) -> None: # --- Schema validation --- def _run_schema_validation(self) -> None: - """Run schema field metadata validation once at startup. + """Classify the adapter's field metadata once at startup. - Compares the library's schema-derived field metadata against the - integration's sensor definitions to detect unit mismatches. Also - reports fields the integration doesn't map to any sensor. + Reads the metadata through ``SpanPanelClientProtocol`` so this module + never names a transport class, and stores the result for the platforms + and the Repairs reconciler to read. """ - field_metadata: dict[str, dict[str, object]] | None = None - if isinstance(self._client, SpanMqttClient): - raw = self._client.field_metadata - if raw is not None: - field_metadata = { - k: {"unit": v.unit, "datatype": v.datatype} for k, v in raw.items() - } + field_metadata = ( + self._client.field_metadata + if isinstance(self._client, SpanPanelClientProtocol) + else None + ) if field_metadata is None: - _LOGGER.debug("Schema validation skipped — no field metadata available") + # "Unknown", NOT "nothing is wrong". `field_metadata` is None for the + # whole _on_pre_rebuild -> retained-message window, and that fires on + # an ORDINARY reconnect (after MQTT_FULL_REBUILD_AFTER_FAILURES), not + # only on a generation change. Reconciling against empty findings here + # would delete every schema issue — and with it every dismissal the + # user has made. Keep the previous findings and skip this pass. + _LOGGER.debug("Schema validation skipped: metadata not available yet") return - sensor_defs = collect_sensor_definitions() - validate_field_metadata(field_metadata, sensor_defs=sensor_defs) + self._findings = evaluate_field_metadata( + field_metadata, sensor_descriptions_by_field_path() + ) + + @property + def unresolved_paths(self) -> frozenset[str]: + """Field paths the adapter could not resolve. Empty when healthy.""" + return self._findings.unresolved if self._findings is not None else frozenset() + + @property + def schema_findings(self) -> SchemaFindings | None: + """Findings from the last completed validation pass, if any.""" + return self._findings # --- Hardware capability detection --- diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py index f3f5edf7..e25a098a 100644 --- a/custom_components/span_panel/field_paths.py +++ b/custom_components/span_panel/field_paths.py @@ -4,8 +4,8 @@ cannot drift from the reader. A few readers are in entity code rather than on a description; those are listed here. -This module replaces `schema_expectations.SENSOR_FIELD_MAP`, a hand-maintained -parallel dict that had already drifted once (it pointed at +This module replaced `schema_expectations.SENSOR_FIELD_MAP` (since deleted), a +hand-maintained parallel dict that had already drifted once (it pointed at `battery.product_name` and `pv.product_name` after the library renamed those fields to `battery.model` / `pv.model`). diff --git a/custom_components/span_panel/schema_expectations.py b/custom_components/span_panel/schema_expectations.py deleted file mode 100644 index 4ebe1f34..00000000 --- a/custom_components/span_panel/schema_expectations.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Sensor-to-snapshot-field mapping for schema validation. - -Maps integration sensor definition keys to snapshot field paths. This is the -integration's declaration of which snapshot fields it reads, expressed in -transport-agnostic terms. - -The integration does NOT know about Homie, MQTT, node types, or property IDs. -The ``span-panel-api`` library owns that knowledge and exposes field-level -metadata keyed by snapshot field paths. This module bridges from sensor -definitions (HA side) to field paths (library side). - -Field path convention: ``{snapshot_type}.{field_name}`` - - ``panel`` — SpanPanelSnapshot fields - - ``circuit`` — SpanCircuitSnapshot fields - - ``battery`` — SpanBatterySnapshot fields - - ``pv`` — SpanPVSnapshot fields - - ``evse`` — SpanEvseSnapshot fields - -Derived sensors (net energy, dsm_state, current_run_config) that compute -values from multiple fields have no single source field and are excluded. -""" - -from __future__ import annotations - -# --------------------------------------------------------------------------- -# Sensor definition key → snapshot field path -# -# Every sensor the integration creates that reads a single snapshot field -# should appear here. The sensor definition provides the HA unit; the -# library's field metadata provides the schema-declared unit. The validation -# module compares them. -# -# Entries are grouped by snapshot type for readability. -# --------------------------------------------------------------------------- - -SENSOR_FIELD_MAP: dict[str, str] = { - # --- Panel power sensors ------------------------------------------------- - "instantGridPowerW": "panel.instant_grid_power_w", - "feedthroughPowerW": "panel.feedthrough_power_w", - "batteryPowerW": "panel.power_flow_battery", - "pvPowerW": "panel.power_flow_pv", - "gridPowerFlowW": "panel.power_flow_grid", - "sitePowerW": "panel.power_flow_site", - # --- Panel energy sensors ------------------------------------------------ - "mainMeterEnergyProducedWh": "panel.main_meter_energy_produced_wh", - "mainMeterEnergyConsumedWh": "panel.main_meter_energy_consumed_wh", - "feedthroughEnergyProducedWh": "panel.feedthrough_energy_produced_wh", - "feedthroughEnergyConsumedWh": "panel.feedthrough_energy_consumed_wh", - # --- Panel diagnostic sensors -------------------------------------------- - "l1_voltage": "panel.l1_voltage", - "l2_voltage": "panel.l2_voltage", - "upstream_l1_current": "panel.upstream_l1_current_a", - "upstream_l2_current": "panel.upstream_l2_current_a", - "downstream_l1_current": "panel.downstream_l1_current_a", - "downstream_l2_current": "panel.downstream_l2_current_a", - "main_breaker_rating": "panel.main_breaker_rating_a", - # --- Panel status sensors (enum/string — no unit, but tracked) ----------- - "main_relay_state": "panel.main_relay_state", - "grid_forming_entity": "panel.dominant_power_source", - "vendor_cloud": "panel.vendor_cloud", - "software_version": "panel.firmware_version", - # --- Circuit sensors ----------------------------------------------------- - "circuit_power": "circuit.instant_power_w", - "circuit_energy_produced": "circuit.produced_energy_wh", - "circuit_energy_consumed": "circuit.consumed_energy_wh", - "circuit_current": "circuit.current_a", - "circuit_breaker_rating": "circuit.breaker_rating_a", - # --- Unmapped circuit sensors (same fields, different sensor keys) -------- - "instantPowerW": "circuit.instant_power_w", - "producedEnergyWh": "circuit.produced_energy_wh", - "consumedEnergyWh": "circuit.consumed_energy_wh", - # --- Battery sensors ----------------------------------------------------- - "storage_battery_percentage": "battery.soe_percentage", - "nameplate_capacity": "battery.nameplate_capacity_kwh", - "soe_kwh": "battery.soe_kwh", - # --- BESS metadata sensors ----------------------------------------------- - "vendor": "battery.vendor_name", - "model": "battery.model", - "serial_number": "battery.serial_number", - "firmware_version": "battery.software_version", - # --- PV metadata sensors ------------------------------------------------- - "pv_vendor": "pv.vendor_name", - "pv_product": "pv.model", - "pv_nameplate_capacity": "pv.nameplate_capacity_w", - # --- EVSE sensors -------------------------------------------------------- - "evse_status": "evse.status", - "evse_advertised_current": "evse.advertised_current_a", - "evse_lock_state": "evse.lock_state", -} - -# Derived sensors excluded from the map (computed from multiple fields): -# dsm_state — multi-signal heuristic -# dsm_grid_state — deprecated alias for dsm_state -# current_run_config — tri-state derivation -# mainMeterNetEnergyWh — consumed_wh - produced_wh -# feedthroughNetEnergyWh — consumed_wh - produced_wh -# circuit_energy_net — consumed_wh - produced_wh (or inverse for PV) - - -def all_referenced_field_paths() -> frozenset[str]: - """Return the set of all snapshot field paths referenced by any sensor.""" - return frozenset(SENSOR_FIELD_MAP.values()) diff --git a/custom_components/span_panel/schema_validation.py b/custom_components/span_panel/schema_validation.py index 34febe52..25fc82f1 100644 --- a/custom_components/span_panel/schema_validation.py +++ b/custom_components/span_panel/schema_validation.py @@ -1,176 +1,82 @@ -"""Schema validation — cross-check field metadata against sensor definitions. +"""Compare adapter field metadata against what this integration declares it reads. -Compares the ``span-panel-api`` library's field metadata (schema-derived units -and datatypes keyed by snapshot field paths) against the integration's sensor -definitions. All Homie/MQTT knowledge stays in the library; this module only -sees snapshot field paths and HA sensor metadata. +Consumes the library's three-way signal: -Schema drift detection (diffing schema versions between firmware updates) is -the library's responsibility. The integration only consumes the result. +- entry, ``resolved=True`` — produced; the unit is meaningful +- entry, ``resolved=False`` — a device is present but does not declare the + property. Degradation. +- **no entry** — no device of that type. Hardware absent; not a defect. -All output is log-only. No entity creation or sensor behavior changes. - -Phase 1 of the schema-driven changes plan. - -Usage: - Called from the coordinator after the first successful data refresh. - Requires ``span-panel-api`` to expose field metadata via the client protocol. - Until that library change lands, ``validate_field_metadata()`` is a safe no-op. +Because the adapter classifies absence, this module needs no capability table +and never infers hardware presence from telemetry. """ from __future__ import annotations +from dataclasses import dataclass import logging from homeassistant.components.sensor import SensorEntityDescription +from span_panel_api.models import FieldMetadata -from .schema_expectations import SENSOR_FIELD_MAP, all_referenced_field_paths -from .sensor_definitions import ( - BATTERY_POWER_SENSOR, - BATTERY_SENSOR, - BESS_METADATA_SENSORS, - CIRCUIT_BREAKER_RATING_SENSOR, - CIRCUIT_CURRENT_SENSOR, - CIRCUIT_SENSORS, - DOWNSTREAM_L1_CURRENT_SENSOR, - DOWNSTREAM_L2_CURRENT_SENSOR, - EVSE_SENSORS, - GRID_POWER_FLOW_SENSOR, - L1_VOLTAGE_SENSOR, - L2_VOLTAGE_SENSOR, - MAIN_BREAKER_RATING_SENSOR, - PANEL_DATA_STATUS_SENSORS, - PANEL_ENERGY_SENSORS, - PANEL_POWER_SENSORS, - PV_METADATA_SENSORS, - PV_POWER_SENSOR, - SITE_POWER_SENSOR, - STATUS_SENSORS, - UNMAPPED_SENSORS, - UPSTREAM_L1_CURRENT_SENSOR, - UPSTREAM_L2_CURRENT_SENSOR, -) +from .field_paths import declared_field_paths _LOGGER = logging.getLogger(__name__) -def _cross_check_units( - field_metadata: dict[str, dict[str, object]], - sensor_defs: dict[str, SensorEntityDescription], -) -> None: - """Compare library-reported units against sensor definition units. +@dataclass(frozen=True, slots=True) +class UnitMismatch: + """A declared unit that disagrees with the schema's.""" - For each sensor in SENSOR_FIELD_MAP that has a ``native_unit_of_measurement``, - look up the corresponding field path in the library's metadata and compare - the declared unit. - """ - for sensor_key, field_path in SENSOR_FIELD_MAP.items(): - sensor_def = sensor_defs.get(sensor_key) - if sensor_def is None: - continue + field_path: str + ha_unit: str + schema_unit: str - ha_unit = sensor_def.native_unit_of_measurement - if ha_unit is None: - # Sensor has no unit (enum, string) — nothing to cross-check - continue - field_info = field_metadata.get(field_path) - if field_info is None: - _LOGGER.debug( - "Schema cross-check: sensor '%s' reads field '%s' but " - "library reports no metadata for it", - sensor_key, - field_path, - ) - continue +@dataclass(frozen=True, slots=True) +class SchemaFindings: + """Outcome of one validation pass.""" + + unresolved: frozenset[str] + unit_mismatches: tuple[UnitMismatch, ...] + unread: frozenset[str] - schema_unit = field_info.get("unit") - if schema_unit is None: - _LOGGER.debug( - "Schema cross-check: field '%s' (sensor '%s') has no unit " - "in library metadata, integration expects '%s'", - field_path, - sensor_key, - ha_unit, - ) - elif str(schema_unit) != str(ha_unit): - _LOGGER.debug( - "Schema cross-check: field '%s' (sensor '%s') unit is '%s' " - "in library metadata, integration expects '%s'", - field_path, - sensor_key, - schema_unit, - ha_unit, - ) - - -def _report_unmapped_fields( - field_metadata: dict[str, dict[str, object]], -) -> None: - """Log fields in library metadata that no sensor definition references.""" - referenced = all_referenced_field_paths() - for field_path in sorted(set(field_metadata) - referenced): - _LOGGER.debug( - "Schema: field '%s' in library metadata is not mapped to any sensor", - field_path, - ) - - -def validate_field_metadata( - field_metadata: dict[str, dict[str, object]] | None, + +def evaluate_field_metadata( + field_metadata: dict[str, FieldMetadata] | None, sensor_defs: dict[str, SensorEntityDescription] | None = None, -) -> None: - """Run integration-side schema validation checks. - - Args: - field_metadata: The library's field metadata, keyed by snapshot field - path (e.g. ``"panel.instant_grid_power_w"``). Each value is a dict - with at least ``"unit"`` and ``"datatype"`` keys. None if the - library does not yet expose metadata. - sensor_defs: Dict of sensor_key → SensorEntityDescription for unit - cross-checking. None skips the cross-check. - - """ +) -> SchemaFindings: + """Classify one snapshot of adapter metadata against our declarations.""" if field_metadata is None: - _LOGGER.debug("Schema validation skipped — library does not expose field metadata") - return - - if sensor_defs is not None: - _cross_check_units(field_metadata, sensor_defs) - - _report_unmapped_fields(field_metadata) - - -def collect_sensor_definitions() -> dict[str, SensorEntityDescription]: - """Collect all sensor definitions into a dict keyed by sensor key. - - Only includes sensors that appear in SENSOR_FIELD_MAP (i.e. sensors - that read a single snapshot field and are eligible for cross-checking). - """ - all_defs: list[SensorEntityDescription] = [ - *PANEL_DATA_STATUS_SENSORS, - *STATUS_SENSORS, - *UNMAPPED_SENSORS, - BATTERY_SENSOR, - L1_VOLTAGE_SENSOR, - L2_VOLTAGE_SENSOR, - UPSTREAM_L1_CURRENT_SENSOR, - UPSTREAM_L2_CURRENT_SENSOR, - DOWNSTREAM_L1_CURRENT_SENSOR, - DOWNSTREAM_L2_CURRENT_SENSOR, - MAIN_BREAKER_RATING_SENSOR, - CIRCUIT_CURRENT_SENSOR, - CIRCUIT_BREAKER_RATING_SENSOR, - *BESS_METADATA_SENSORS, - *PV_METADATA_SENSORS, - *PANEL_POWER_SENSORS, - BATTERY_POWER_SENSOR, - PV_POWER_SENSOR, - GRID_POWER_FLOW_SENSOR, - SITE_POWER_SENSOR, - *PANEL_ENERGY_SENSORS, - *CIRCUIT_SENSORS, - *EVSE_SENSORS, - ] - mapped_keys = set(SENSOR_FIELD_MAP.keys()) - return {d.key: d for d in all_defs if d.key in mapped_keys} + return SchemaFindings(frozenset(), (), frozenset()) + + declared = declared_field_paths() + sensor_defs = sensor_defs or {} + + unresolved: set[str] = set() + mismatches: list[UnitMismatch] = [] + + for field_path in declared: + entry = field_metadata.get(field_path) + if entry is None: + # Hardware not present. Not a defect, and deliberately silent. + continue + if not entry.resolved: + unresolved.add(field_path) + continue + description = sensor_defs.get(field_path) + if description is None: + continue + ha_unit = description.native_unit_of_measurement + if ha_unit is None or entry.unit is None: + continue + if str(entry.unit) != str(ha_unit): + mismatches.append(UnitMismatch(field_path, str(ha_unit), str(entry.unit))) + + unread = frozenset(set(field_metadata) - set(declared)) + for field_path in sorted(unread): + # An addition is legal within a major version. This is an inventory for + # us, never a user-facing finding. + _LOGGER.debug("Schema: %s is produced but no platform reads it", field_path) + + return SchemaFindings(frozenset(unresolved), tuple(mismatches), unread) diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py index fa3ebcb6..3224d15a 100644 --- a/custom_components/span_panel/sensor_definitions.py +++ b/custom_components/span_panel/sensor_definitions.py @@ -844,3 +844,27 @@ def all_sensor_descriptions() -> tuple[SensorEntityDescription, ...]: CIRCUIT_CURRENT_SENSOR, CIRCUIT_BREAKER_RATING_SENSOR, ) + + +def sensor_descriptions_by_field_path() -> dict[str, SensorEntityDescription]: + """Every non-derived sensor description, keyed by the field path it reads. + + Keyed by field path rather than `description.key` for the reason + `all_sensor_descriptions` returns a tuple: keys such as "model" and + "serial_number" repeat across device classes, so a dict keyed on them + silently drops descriptions. Derived descriptions are excluded — they read + several fields, or none, so no single path identifies them. + + Lives here rather than at the call site so the narrowing to + `FieldPathDeclarationMixin` stays with the descriptions that carry it. + """ + by_field_path: dict[str, SensorEntityDescription] = {} + for description in all_sensor_descriptions(): + if not isinstance(description, FieldPathDeclarationMixin): + # `declared_field_paths` raises on this; here it is simply nothing + # to key on. + continue + if description.derived or description.field_path is None: + continue + by_field_path[description.field_path] = description + return by_field_path diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 48427766..b9711332 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -18,6 +18,8 @@ from custom_components.span_panel.coordinator import SpanPanelCoordinator from homeassistant.core import HomeAssistant from span_panel_api import SpanMqttClient +from span_panel_api.models import FieldMetadata +from span_panel_api.protocol import SpanPanelClientProtocol from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryNotReady, @@ -230,61 +232,58 @@ async def test_on_snapshot_push_updates_state_and_runs_post_tasks( async def test_run_schema_validation_skips_without_metadata( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: - """Schema validation should skip cleanly when no metadata is available.""" - - class FakeSpanMqttClient: - field_metadata = None - - client = FakeSpanMqttClient() + """No metadata means "unknown", so the pass must leave findings untouched. + + `field_metadata` is None for the whole _on_pre_rebuild -> retained-message + window, which an ordinary reconnect opens. Producing empty findings here + would read as "every issue is resolved" to the Repairs reconciler. + """ + client = MagicMock(spec=SpanPanelClientProtocol) + client.field_metadata = None coordinator = _create_coordinator(hass, client=client) caplog.set_level(logging.DEBUG) - - with patch( - "custom_components.span_panel.coordinator.SpanMqttClient", - FakeSpanMqttClient, - ): - coordinator._run_schema_validation() + coordinator._run_schema_validation() assert "Schema validation skipped" in caplog.text + assert coordinator.schema_findings is None + assert coordinator.unresolved_paths == frozenset() -async def test_run_schema_validation_validates_field_metadata( +async def test_run_schema_validation_preserves_prior_findings( hass: HomeAssistant, ) -> None: - """Schema validation should pass field metadata to the validator.""" + """A later pass without metadata must not erase what an earlier one found.""" + client = MagicMock(spec=SpanPanelClientProtocol) + client.field_metadata = { + "circuit.instant_power_w": FieldMetadata(None, "unknown", resolved=False) + } + coordinator = _create_coordinator(hass, client=client) + + coordinator._run_schema_validation() + assert coordinator.unresolved_paths == frozenset({"circuit.instant_power_w"}) - class FakeField: - def __init__(self, unit: str, datatype: str) -> None: - self.unit = unit - self.datatype = datatype + client.field_metadata = None + coordinator._run_schema_validation() - class FakeSpanMqttClient: - field_metadata = {"instantPowerW": FakeField("W", "number")} + assert coordinator.unresolved_paths == frozenset({"circuit.instant_power_w"}) - client = FakeSpanMqttClient() + +async def test_run_schema_validation_reads_metadata_through_the_protocol( + hass: HomeAssistant, +) -> None: + """The unit cross-check must key sensor definitions by field path.""" + client = MagicMock(spec=SpanPanelClientProtocol) + client.field_metadata = {"circuit.instant_power_w": FieldMetadata("kW", "float")} coordinator = _create_coordinator(hass, client=client) - with ( - patch( - "custom_components.span_panel.coordinator.SpanMqttClient", - FakeSpanMqttClient, - ), - patch( - "custom_components.span_panel.coordinator.collect_sensor_definitions", - return_value={"sensor_defs": "ok"}, - ) as mock_collect, - patch( - "custom_components.span_panel.coordinator.validate_field_metadata" - ) as mock_validate, - ): - coordinator._run_schema_validation() + coordinator._run_schema_validation() - mock_collect.assert_called_once() - mock_validate.assert_called_once_with( - {"instantPowerW": {"unit": "W", "datatype": "number"}}, - sensor_defs={"sensor_defs": "ok"}, - ) + findings = coordinator.schema_findings + assert findings is not None + assert [m.field_path for m in findings.unit_mismatches] == ["circuit.instant_power_w"] + assert findings.unit_mismatches[0].schema_unit == "kW" + assert findings.unit_mismatches[0].ha_unit == "W" @pytest.mark.parametrize( diff --git a/tests/test_schema_validation.py b/tests/test_schema_validation.py index 9bd2ade3..4c7d3c5a 100644 --- a/tests/test_schema_validation.py +++ b/tests/test_schema_validation.py @@ -1,269 +1,133 @@ -"""Tests for schema validation and sensor-to-field mapping.""" - from __future__ import annotations -import logging -from unittest.mock import MagicMock - -import pytest -from span_panel_api import ( - SpanBatterySnapshot, - SpanCircuitSnapshot, - SpanEvseSnapshot, - SpanPanelSnapshot, - SpanPVSnapshot, -) +from span_panel_api.models import FieldMetadata -from custom_components.span_panel.schema_expectations import ( - SENSOR_FIELD_MAP, - all_referenced_field_paths, -) +from custom_components.span_panel.field_paths import declared_field_paths from custom_components.span_panel.schema_validation import ( - validate_field_metadata, + SchemaFindings, + evaluate_field_metadata, ) from custom_components.span_panel.sensor_definitions import ( - BATTERY_POWER_SENSOR, - BATTERY_SENSOR, - BESS_METADATA_SENSORS, - CIRCUIT_BREAKER_RATING_SENSOR, - CIRCUIT_CURRENT_SENSOR, - CIRCUIT_SENSORS, - DOWNSTREAM_L1_CURRENT_SENSOR, - DOWNSTREAM_L2_CURRENT_SENSOR, - EVSE_SENSORS, - GRID_POWER_FLOW_SENSOR, - L1_VOLTAGE_SENSOR, - L2_VOLTAGE_SENSOR, - MAIN_BREAKER_RATING_SENSOR, - PANEL_DATA_STATUS_SENSORS, - PANEL_ENERGY_SENSORS, - PANEL_POWER_SENSORS, - PV_METADATA_SENSORS, - PV_POWER_SENSOR, - SITE_POWER_SENSOR, - STATUS_SENSORS, - UNMAPPED_SENSORS, - UPSTREAM_L1_CURRENT_SENSOR, - UPSTREAM_L2_CURRENT_SENSOR, + sensor_descriptions_by_field_path, ) -_LOGGER_NAME = "custom_components.span_panel.schema_validation" - - -# --------------------------------------------------------------------------- -# Sensor field mapping tests -# --------------------------------------------------------------------------- - - -class TestSensorFieldMap: - """Tests for the sensor-to-snapshot-field mapping.""" - - def test_no_empty_keys_or_paths(self) -> None: - """Every entry must have non-empty sensor key and field path.""" - for sensor_key, field_path in SENSOR_FIELD_MAP.items(): - assert sensor_key, "Empty sensor key in SENSOR_FIELD_MAP" - assert field_path, f"Empty field path for sensor key '{sensor_key}'" - - def test_field_paths_follow_convention(self) -> None: - """All field paths must be {snapshot_type}.{field_name}.""" - valid_prefixes = {"panel", "circuit", "battery", "pv", "evse"} - for sensor_key, field_path in SENSOR_FIELD_MAP.items(): - parts = field_path.split(".", 1) - assert len(parts) == 2, ( - f"Field path '{field_path}' for sensor '{sensor_key}' " - f"does not follow 'type.field' convention" - ) - assert parts[0] in valid_prefixes, ( - f"Field path '{field_path}' for sensor '{sensor_key}' " - f"has unknown prefix '{parts[0]}'" - ) - - def test_sensor_keys_exist_in_definitions(self) -> None: - """Every sensor key should match a real sensor definition.""" - all_defs = [ - *PANEL_DATA_STATUS_SENSORS, - *STATUS_SENSORS, - *UNMAPPED_SENSORS, - BATTERY_SENSOR, - L1_VOLTAGE_SENSOR, - L2_VOLTAGE_SENSOR, - UPSTREAM_L1_CURRENT_SENSOR, - UPSTREAM_L2_CURRENT_SENSOR, - DOWNSTREAM_L1_CURRENT_SENSOR, - DOWNSTREAM_L2_CURRENT_SENSOR, - MAIN_BREAKER_RATING_SENSOR, - CIRCUIT_CURRENT_SENSOR, - CIRCUIT_BREAKER_RATING_SENSOR, - *BESS_METADATA_SENSORS, - *PV_METADATA_SENSORS, - *PANEL_POWER_SENSORS, - BATTERY_POWER_SENSOR, - PV_POWER_SENSOR, - GRID_POWER_FLOW_SENSOR, - SITE_POWER_SENSOR, - *PANEL_ENERGY_SENSORS, - *CIRCUIT_SENSORS, - *EVSE_SENSORS, - ] - known_keys = {d.key for d in all_defs} - - for sensor_key in SENSOR_FIELD_MAP: - assert sensor_key in known_keys, ( - f"Sensor key '{sensor_key}' in SENSOR_FIELD_MAP not found in sensor definitions" - ) - - def test_field_paths_match_snapshot_attrs(self) -> None: - """Field names should match actual snapshot dataclass attributes.""" - snapshot_classes = { - "panel": SpanPanelSnapshot, - "circuit": SpanCircuitSnapshot, - "battery": SpanBatterySnapshot, - "pv": SpanPVSnapshot, - "evse": SpanEvseSnapshot, - } - - for sensor_key, field_path in SENSOR_FIELD_MAP.items(): - prefix, field_name = field_path.split(".", 1) - cls = snapshot_classes[prefix] - assert hasattr(cls, field_name) or field_name in { - f.name for f in cls.__dataclass_fields__.values() - }, ( - f"Field '{field_name}' from path '{field_path}' " - f"(sensor '{sensor_key}') not found on {cls.__name__}" - ) - - def test_all_referenced_field_paths(self) -> None: - """all_referenced_field_paths should return all unique values.""" - paths = all_referenced_field_paths() - assert paths == frozenset(SENSOR_FIELD_MAP.values()) - - -# --------------------------------------------------------------------------- -# Unit cross-check tests -# --------------------------------------------------------------------------- - - -def _make_sensor_def(key: str, unit: str | None) -> MagicMock: - """Create a minimal mock SensorEntityDescription with key and unit.""" - mock = MagicMock(spec=["key", "native_unit_of_measurement"]) - mock.key = key - mock.native_unit_of_measurement = unit - return mock - - -class TestUnitCrossCheck: - """Tests for field metadata unit vs sensor definition unit cross-checking.""" - - def test_matching_units_no_cross_check_message( - self, caplog: pytest.LogCaptureFixture - ) -> None: - """Matching units should produce no cross-check log messages.""" - metadata = {"panel.instant_grid_power_w": {"unit": "W", "datatype": "float"}} - sensor_defs = {"instantGridPowerW": _make_sensor_def("instantGridPowerW", "W")} - with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME): - validate_field_metadata(metadata, sensor_defs=sensor_defs) - assert not any("cross-check" in r.lower() for r in caplog.messages) - - def test_mismatched_units_logs_debug( - self, caplog: pytest.LogCaptureFixture - ) -> None: - """Unit mismatch should produce a debug message naming both units.""" - metadata = {"panel.instant_grid_power_w": {"unit": "kW", "datatype": "float"}} - sensor_defs = {"instantGridPowerW": _make_sensor_def("instantGridPowerW", "W")} - with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME): - validate_field_metadata(metadata, sensor_defs=sensor_defs) - cross_msgs = [m for m in caplog.messages if "cross-check" in m.lower()] - assert len(cross_msgs) == 1 - assert "'kW'" in cross_msgs[0] - assert "'W'" in cross_msgs[0] - - def test_missing_metadata_logs_debug( - self, caplog: pytest.LogCaptureFixture - ) -> None: - """Sensor reading a field with no metadata should log debug.""" - metadata: dict[str, dict[str, object]] = {} - sensor_defs = {"l1_voltage": _make_sensor_def("l1_voltage", "V")} - with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME): - validate_field_metadata(metadata, sensor_defs=sensor_defs) - assert any("no metadata" in m for m in caplog.messages) - - def test_missing_schema_unit_logs_debug( - self, caplog: pytest.LogCaptureFixture - ) -> None: - """Field with no unit in metadata but unit in sensor def should log debug.""" - metadata = {"panel.l1_voltage": {"datatype": "float"}} - sensor_defs = {"l1_voltage": _make_sensor_def("l1_voltage", "V")} - with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME): - validate_field_metadata(metadata, sensor_defs=sensor_defs) - assert any("no unit" in m for m in caplog.messages) - - def test_sensor_without_unit_skipped( - self, caplog: pytest.LogCaptureFixture - ) -> None: - """Sensor with no native_unit_of_measurement should be skipped.""" - metadata = {"panel.main_relay_state": {"datatype": "enum"}} - sensor_defs = {"main_relay_state": _make_sensor_def("main_relay_state", None)} - with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME): - validate_field_metadata(metadata, sensor_defs=sensor_defs) - assert not any("cross-check" in m.lower() for m in caplog.messages) - - def test_all_output_is_debug_level(self, caplog: pytest.LogCaptureFixture) -> None: - """All schema validation output should be DEBUG — never visible to users.""" - metadata = { - "panel.instant_grid_power_w": {"unit": "kW", "datatype": "float"}, - "panel.l1_voltage": {"datatype": "float"}, - "panel.new_fancy_field": {"unit": "W", "datatype": "float"}, - } - sensor_defs = { - "instantGridPowerW": _make_sensor_def("instantGridPowerW", "W"), - "l1_voltage": _make_sensor_def("l1_voltage", "V"), - } - with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME): - validate_field_metadata(metadata, sensor_defs=sensor_defs) - above_debug = [r for r in caplog.records if r.levelno > logging.DEBUG] - assert len(above_debug) == 0, ( - f"Expected all DEBUG, got: {[(r.levelname, r.getMessage()) for r in above_debug]}" - ) - - -# --------------------------------------------------------------------------- -# Unmapped field detection tests -# --------------------------------------------------------------------------- - - -class TestUnmappedFields: - """Tests for detecting fields the integration doesn't consume.""" - - def test_unmapped_field_logs_debug(self, caplog: pytest.LogCaptureFixture) -> None: - """Field not in SENSOR_FIELD_MAP values should log at DEBUG.""" - metadata = {"panel.new_fancy_field": {"unit": "W", "datatype": "float"}} - with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME): - validate_field_metadata(metadata) - assert any( - r.levelno == logging.DEBUG and "new_fancy_field" in r.getMessage() - for r in caplog.records - ) - - def test_mapped_field_not_reported(self, caplog: pytest.LogCaptureFixture) -> None: - """Field that IS in SENSOR_FIELD_MAP should not be reported as unmapped.""" - metadata = {"panel.instant_grid_power_w": {"unit": "W", "datatype": "float"}} - with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME): - validate_field_metadata(metadata) - assert not any("not mapped" in m for m in caplog.messages) - -# --------------------------------------------------------------------------- -# No-op when metadata unavailable -# --------------------------------------------------------------------------- +def test_unresolved_entry_is_degradation() -> None: + findings = evaluate_field_metadata( + {"circuit.instant_power_w": FieldMetadata(None, "unknown", resolved=False)}, + sensor_defs={}, + ) + assert "circuit.instant_power_w" in findings.unresolved -class TestNoOp: - """Tests for graceful handling when library doesn't expose metadata.""" +def test_absent_entry_is_not_degradation() -> None: + """No entry means the hardware is not installed — not a defect.""" + findings = evaluate_field_metadata({}, sensor_defs={}) + assert findings.unresolved == frozenset() - def test_none_metadata_is_noop(self, caplog: pytest.LogCaptureFixture) -> None: - """None metadata should produce no output above DEBUG.""" - with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME): - validate_field_metadata(None) - assert any("skipped" in m for m in caplog.messages) - above_debug = [r for r in caplog.records if r.levelno > logging.DEBUG] - assert len(above_debug) == 0 + +def test_unit_mismatch_is_reported() -> None: + from homeassistant.components.sensor import SensorEntityDescription + from homeassistant.const import UnitOfPower + + description = SensorEntityDescription( + key="circuit_power", native_unit_of_measurement=UnitOfPower.WATT + ) + findings = evaluate_field_metadata( + {"circuit.instant_power_w": FieldMetadata("kW", "float")}, + sensor_defs={"circuit.instant_power_w": description}, + ) + assert findings.unit_mismatches[0].field_path == "circuit.instant_power_w" + assert findings.unit_mismatches[0].schema_unit == "kW" + + +def test_unitless_sensor_still_checked_for_resolution() -> None: + """Resolution is checked before the unit is. + + The old code short-circuited on `ha_unit is None` BEFORE the lookup, so enum + and string sensors could go dead with no signal. + """ + from homeassistant.components.sensor import SensorEntityDescription + + description = SensorEntityDescription(key="evse_status") + findings = evaluate_field_metadata( + {"evse.status": FieldMetadata(None, "unknown", resolved=False)}, + sensor_defs={"evse.status": description}, + ) + assert "evse.status" in findings.unresolved + + +def test_unresolved_entry_is_never_a_unit_mismatch() -> None: + """An unresolved entry carries `unit=None` by construction. + + Comparing that against a declared unit would raise a false mismatch on every + affected sensor, so resolution must be branched on first. + """ + from homeassistant.components.sensor import SensorEntityDescription + from homeassistant.const import UnitOfPower + + description = SensorEntityDescription( + key="circuit_power", native_unit_of_measurement=UnitOfPower.WATT + ) + findings = evaluate_field_metadata( + {"circuit.instant_power_w": FieldMetadata(None, "unknown", resolved=False)}, + sensor_defs={"circuit.instant_power_w": description}, + ) + assert findings.unresolved == frozenset({"circuit.instant_power_w"}) + assert findings.unit_mismatches == () + + +def test_matching_unit_is_not_a_mismatch() -> None: + from homeassistant.components.sensor import SensorEntityDescription + from homeassistant.const import UnitOfPower + + description = SensorEntityDescription( + key="circuit_power", native_unit_of_measurement=UnitOfPower.WATT + ) + findings = evaluate_field_metadata( + {"circuit.instant_power_w": FieldMetadata("W", "float")}, + sensor_defs={"circuit.instant_power_w": description}, + ) + assert findings.unit_mismatches == () + assert findings.unresolved == frozenset() + + +def test_produced_but_unread_fields_are_inventoried() -> None: + """An addition is legal within a major version — inventory, not a defect.""" + findings = evaluate_field_metadata( + {"panel.some_future_field": FieldMetadata("W", "float")}, sensor_defs={} + ) + assert findings.unread == frozenset({"panel.some_future_field"}) + assert findings.unresolved == frozenset() + assert findings.unit_mismatches == () + + +def test_declared_and_resolved_fields_are_not_unread() -> None: + findings = evaluate_field_metadata( + {"circuit.instant_power_w": FieldMetadata("W", "float")}, sensor_defs={} + ) + assert findings.unread == frozenset() + + +def test_none_metadata_yields_empty_findings() -> None: + """The module-level fallback. + + Callers that must distinguish "unknown" from "healthy" — the coordinator + does — check for None before calling. + """ + findings = evaluate_field_metadata(None) + assert findings == SchemaFindings(frozenset(), (), frozenset()) + + +def test_every_declared_field_path_keys_a_sensor_or_a_residual_reader() -> None: + """`sensor_descriptions_by_field_path` must not drop descriptions. + + Keys such as "model" and "serial_number" repeat across device classes, so a + dict keyed on `description.key` would silently collapse them. + """ + by_path = sensor_descriptions_by_field_path() + assert by_path.keys() <= declared_field_paths() + assert {"battery.model", "pv.model"} <= by_path.keys() + for field_path, description in by_path.items(): + assert description.field_path == field_path + assert not description.derived From 4bff08fbc8fcf93b5a6faef4faae11ddd57dac73 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:58:29 -0700 Subject: [PATCH 028/345] test: cover the resolved-unitless and undeclared-description branches Brings schema_validation and sensor_definitions to full statement coverage and pins where the missing-declaration check is enforced. --- tests/test_schema_validation.py | 34 +++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/test_schema_validation.py b/tests/test_schema_validation.py index 4c7d3c5a..9da3ef8c 100644 --- a/tests/test_schema_validation.py +++ b/tests/test_schema_validation.py @@ -1,7 +1,9 @@ from __future__ import annotations +import pytest from span_panel_api.models import FieldMetadata +from custom_components.span_panel import sensor_definitions from custom_components.span_panel.field_paths import declared_field_paths from custom_components.span_panel.schema_validation import ( SchemaFindings, @@ -131,3 +133,35 @@ def test_every_declared_field_path_keys_a_sensor_or_a_residual_reader() -> None: for field_path, description in by_path.items(): assert description.field_path == field_path assert not description.derived + + +def test_resolved_unitless_sensor_yields_no_mismatch() -> None: + """A resolved field read by an enum or string sensor has nothing to compare.""" + from homeassistant.components.sensor import SensorEntityDescription + + description = SensorEntityDescription(key="evse_status") + findings = evaluate_field_metadata( + {"evse.status": FieldMetadata(None, "enum")}, + sensor_defs={"evse.status": description}, + ) + assert findings.unresolved == frozenset() + assert findings.unit_mismatches == () + + +def test_descriptions_without_a_declaration_are_not_keyed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """`declared_field_paths` is the enforcement point; here there is no key. + + A description that carries no `FieldPathDeclarationMixin` names no field, so + it cannot appear in a field-path-keyed map. `declared_field_paths` raises on + the same input, which is where the drift is caught. + """ + from homeassistant.components.sensor import SensorEntityDescription + + monkeypatch.setattr( + sensor_definitions, + "all_sensor_descriptions", + lambda: (SensorEntityDescription(key="undeclared"),), + ) + assert sensor_definitions.sensor_descriptions_by_field_path() == {} From a95243c6c3da5b3a4b1affa46d3c67919ea1cbc8 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:22:28 -0700 Subject: [PATCH 029/345] fix: excuse the known-bad schema_0 circuit power unit label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPAN firmware declares the circuit active-power property as kW while publishing watts. The sibling lugs device declares W for the same quantity, the library consumes the value unscaled, and our sensor declares W — so the label is wrong and the reading is right. Left unhandled, every schema_0 panel would raise a permanent unit mismatch its owner cannot act on. KNOWN_BAD_SCHEMA_UNITS excuses only the exact declared unit, so a future firmware declaring anything else for the same field is still reported. Also: exclude RESIDUAL_EXEMPT_PATHS from `unread` (they are read, just not required of both adapters — 10 of schema_0's 17 were mislabelled); drop the `| None` arm of evaluate_field_metadata so the sentinel cannot be answered with "healthy"; and evaluate both real adapter fixtures in the tests rather than only synthetic single-entry dicts. --- custom_components/span_panel/field_paths.py | 54 +++++-- .../span_panel/schema_validation.py | 52 ++++-- .../span_panel/sensor_definitions.py | 40 ++--- tests/test_coordinator.py | 14 +- tests/test_schema_validation.py | 152 +++++++++++++++--- 5 files changed, 246 insertions(+), 66 deletions(-) diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py index e25a098a..7031abdf 100644 --- a/custom_components/span_panel/field_paths.py +++ b/custom_components/span_panel/field_paths.py @@ -15,8 +15,11 @@ from __future__ import annotations +from collections.abc import Iterable, Iterator from dataclasses import dataclass +from homeassistant.helpers.entity import EntityDescription + @dataclass(frozen=True, kw_only=True) class FieldPathDeclarationMixin: @@ -145,6 +148,31 @@ class FieldPathDeclarationMixin: """ +def iter_field_path_declarations[DescriptionT: EntityDescription]( + descriptions: Iterable[DescriptionT], +) -> Iterator[tuple[str, DescriptionT]]: + """Yield ``(field_path, description)`` for each description that declares one. + + The single copy of the traversal rule — which descriptions declare a field, + and which are exempt — so a caller that only wants the paths and a caller + that wants the descriptions cannot drift apart on it. + + Raises `TypeError` for a description that carries no + `FieldPathDeclarationMixin` at all: such a description would be dropped + silently, which is the drift this module exists to prevent. Descriptions + that carry the mixin but declare nothing (`derived`, or `field_path is + None`) are skipped, which is the declared-exempt case rather than drift. + """ + for description in descriptions: + if not isinstance(description, FieldPathDeclarationMixin): + raise TypeError( + f"entity description '{description.key}' carries no field-path declaration" + ) + if description.derived or description.field_path is None: + continue + yield description.field_path, description + + def declared_field_paths() -> frozenset[str]: """Field paths the integration reads that must be producible by an adapter. @@ -166,20 +194,16 @@ def declared_field_paths() -> frozenset[str]: ) paths: set[str] = set(RESIDUAL_FIELD_PATHS) - for description in ( - *all_sensor_descriptions(), - *BINARY_SENSORS, - *EVSE_BINARY_SENSORS, - GRID_ISLANDABLE_SENSOR, - BESS_CONNECTED_SENSOR, - ): - if not isinstance(description, FieldPathDeclarationMixin): - # A description that cannot declare anything would be dropped - # silently, which is the drift this module exists to prevent. - raise TypeError( - f"entity description '{description.key}' carries no field-path declaration" + paths.update( + field_path + for field_path, _ in iter_field_path_declarations( + ( + *all_sensor_descriptions(), + *BINARY_SENSORS, + *EVSE_BINARY_SENSORS, + GRID_ISLANDABLE_SENSOR, + BESS_CONNECTED_SENSOR, ) - if description.derived or description.field_path is None: - continue - paths.add(description.field_path) + ) + ) return frozenset(paths) diff --git a/custom_components/span_panel/schema_validation.py b/custom_components/span_panel/schema_validation.py index 25fc82f1..c8b848df 100644 --- a/custom_components/span_panel/schema_validation.py +++ b/custom_components/span_panel/schema_validation.py @@ -19,11 +19,33 @@ from homeassistant.components.sensor import SensorEntityDescription from span_panel_api.models import FieldMetadata -from .field_paths import declared_field_paths +from .field_paths import RESIDUAL_EXEMPT_PATHS, declared_field_paths _LOGGER = logging.getLogger(__name__) +KNOWN_BAD_SCHEMA_UNITS: dict[str, str] = { + # SPAN firmware declares the circuit `active-power` property as "kW" while + # publishing watts. Three things agree that the label, not the reading, is + # wrong: the sibling `lugs` device declares the same quantity as "W"; the + # library consumes the value unscaled ("active-power is in watts", + # span_panel_api_schema_0/consumer.py:244); and the independent `span-hass` + # integration documents the same defect under "Known SPAN API Issue" and + # hardcodes the same override. Our `UnitOfPower.WATT` declaration is correct. + "circuit.instant_power_w": "kW", +} +"""Schema unit declarations this integration knowingly ignores, by field path. + +Every entry is a firmware defect worked around deliberately — the panel labels a +property with a unit it does not publish — never a sensor whose unit we gave up +on checking. Without this, a panel running the affected firmware raises a +mismatch its owner cannot act on and that reflects no real defect. + +The match is exact. If firmware later declares something OTHER than the value +here for the same field, that is new information and is still reported. +""" + + @dataclass(frozen=True, slots=True) class UnitMismatch: """A declared unit that disagrees with the schema's.""" @@ -43,13 +65,17 @@ class SchemaFindings: def evaluate_field_metadata( - field_metadata: dict[str, FieldMetadata] | None, + field_metadata: dict[str, FieldMetadata], sensor_defs: dict[str, SensorEntityDescription] | None = None, ) -> SchemaFindings: - """Classify one snapshot of adapter metadata against our declarations.""" - if field_metadata is None: - return SchemaFindings(frozenset(), (), frozenset()) - + """Classify one snapshot of adapter metadata against our declarations. + + `field_metadata` is deliberately not optional. The client returns None until + its adapter is ready, and that sentinel means "unknown", not "healthy" — + answering it with empty findings would tell a reconciler every issue is + resolved. Callers interpret the sentinel themselves; see + `SpanPanelCoordinator._run_schema_validation`. + """ declared = declared_field_paths() sensor_defs = sensor_defs or {} @@ -70,10 +96,18 @@ def evaluate_field_metadata( ha_unit = description.native_unit_of_measurement if ha_unit is None or entry.unit is None: continue - if str(entry.unit) != str(ha_unit): - mismatches.append(UnitMismatch(field_path, str(ha_unit), str(entry.unit))) + schema_unit = str(entry.unit) + if schema_unit == str(ha_unit): + continue + if KNOWN_BAD_SCHEMA_UNITS.get(field_path) == schema_unit: + # A firmware mislabel we already work around. See the constant. + continue + mismatches.append(UnitMismatch(field_path, str(ha_unit), schema_unit)) - unread = frozenset(set(field_metadata) - set(declared)) + # `RESIDUAL_EXEMPT_PATHS` are read by the integration; they are exempt from + # the *producible* gate because only one adapter emits them, so they are + # absent from `declared` without being unread. + unread = frozenset(set(field_metadata) - set(declared) - RESIDUAL_EXEMPT_PATHS) for field_path in sorted(unread): # An addition is legal within a major version. This is an inventory for # us, never a user-facing finding. diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py index 3224d15a..5af8eeed 100644 --- a/custom_components/span_panel/sensor_definitions.py +++ b/custom_components/span_panel/sensor_definitions.py @@ -35,7 +35,7 @@ SpanPanelSnapshot, ) -from .field_paths import FieldPathDeclarationMixin +from .field_paths import FieldPathDeclarationMixin, iter_field_path_declarations @dataclass(frozen=True) @@ -814,9 +814,11 @@ class SpanEvseSensorEntityDescription(SensorEntityDescription, SpanEvseRequiredK def all_sensor_descriptions() -> tuple[SensorEntityDescription, ...]: """Every sensor description, without deduplication. - Deliberately not keyed by `description.key`: keys such as "model" and - "serial_number" repeat across device classes, so a dict keyed on them - silently drops descriptions. + A tuple rather than a dict because no one key identifies a description: + `description.key` and `field_path` are different namespaces, and neither is + unique across the whole set — several field paths are read by two + descriptions. Callers key by whichever suits them; see + `sensor_descriptions_by_field_path`. """ return ( *PANEL_DATA_STATUS_SENSORS, @@ -849,22 +851,22 @@ def all_sensor_descriptions() -> tuple[SensorEntityDescription, ...]: def sensor_descriptions_by_field_path() -> dict[str, SensorEntityDescription]: """Every non-derived sensor description, keyed by the field path it reads. - Keyed by field path rather than `description.key` for the reason - `all_sensor_descriptions` returns a tuple: keys such as "model" and - "serial_number" repeat across device classes, so a dict keyed on them - silently drops descriptions. Derived descriptions are excluded — they read - several fields, or none, so no single path identifies them. + Keyed by field path because that is how the adapter keys its metadata; + `description.key` is a different namespace and would not line up. Derived + descriptions are excluded — they read several fields, or none, so no single + path identifies them. - Lives here rather than at the call site so the narrowing to - `FieldPathDeclarationMixin` stays with the descriptions that carry it. + A few field paths are read by two descriptions (an unmapped-circuit raw key + and its named-circuit twin), and only the first is kept. That is safe only + while such readers agree on `native_unit_of_measurement`, which is all this + map is consulted for; `test_readers_of_the_same_field_path_agree_on_unit` + pins that rather than leaving it to chance. + + Lives here rather than at the call site so no consumer has to know how a + description declares its field; `field_paths.iter_field_path_declarations` + holds that rule. """ by_field_path: dict[str, SensorEntityDescription] = {} - for description in all_sensor_descriptions(): - if not isinstance(description, FieldPathDeclarationMixin): - # `declared_field_paths` raises on this; here it is simply nothing - # to key on. - continue - if description.derived or description.field_path is None: - continue - by_field_path[description.field_path] = description + for field_path, description in iter_field_path_declarations(all_sensor_descriptions()): + by_field_path.setdefault(field_path, description) return by_field_path diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index b9711332..cc666ee5 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -272,18 +272,22 @@ async def test_run_schema_validation_preserves_prior_findings( async def test_run_schema_validation_reads_metadata_through_the_protocol( hass: HomeAssistant, ) -> None: - """The unit cross-check must key sensor definitions by field path.""" + """The unit cross-check must key sensor definitions by field path. + + Not `circuit.instant_power_w`/"kW": that pair is in + `KNOWN_BAD_SCHEMA_UNITS`, so it would prove the exception, not the wiring. + """ client = MagicMock(spec=SpanPanelClientProtocol) - client.field_metadata = {"circuit.instant_power_w": FieldMetadata("kW", "float")} + client.field_metadata = {"panel.l1_voltage": FieldMetadata("kV", "float")} coordinator = _create_coordinator(hass, client=client) coordinator._run_schema_validation() findings = coordinator.schema_findings assert findings is not None - assert [m.field_path for m in findings.unit_mismatches] == ["circuit.instant_power_w"] - assert findings.unit_mismatches[0].schema_unit == "kW" - assert findings.unit_mismatches[0].ha_unit == "W" + assert [m.field_path for m in findings.unit_mismatches] == ["panel.l1_voltage"] + assert findings.unit_mismatches[0].schema_unit == "kV" + assert findings.unit_mismatches[0].ha_unit == "V" @pytest.mark.parametrize( diff --git a/tests/test_schema_validation.py b/tests/test_schema_validation.py index 9da3ef8c..b59f3dd9 100644 --- a/tests/test_schema_validation.py +++ b/tests/test_schema_validation.py @@ -1,17 +1,31 @@ from __future__ import annotations +from collections.abc import Callable + import pytest from span_panel_api.models import FieldMetadata from custom_components.span_panel import sensor_definitions -from custom_components.span_panel.field_paths import declared_field_paths +from custom_components.span_panel.field_paths import ( + RESIDUAL_EXEMPT_PATHS, + FieldPathDeclarationMixin, + declared_field_paths, +) from custom_components.span_panel.schema_validation import ( SchemaFindings, evaluate_field_metadata, ) from custom_components.span_panel.sensor_definitions import ( + all_sensor_descriptions, sensor_descriptions_by_field_path, ) +from tests.adapter_fixtures import ( + schema_one_metadata, + schema_one_metadata_batteryless, + schema_zero_metadata, +) + +MetadataFn = Callable[[], dict[str, FieldMetadata]] def test_unresolved_entry_is_degradation() -> None: @@ -29,18 +43,23 @@ def test_absent_entry_is_not_degradation() -> None: def test_unit_mismatch_is_reported() -> None: + """Deliberately not `circuit.instant_power_w`/"kW". + + That pair is the one entry in `KNOWN_BAD_SCHEMA_UNITS`, so it would prove the + exception rather than the check. + """ from homeassistant.components.sensor import SensorEntityDescription - from homeassistant.const import UnitOfPower + from homeassistant.const import UnitOfElectricPotential description = SensorEntityDescription( - key="circuit_power", native_unit_of_measurement=UnitOfPower.WATT + key="l1_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT ) findings = evaluate_field_metadata( - {"circuit.instant_power_w": FieldMetadata("kW", "float")}, - sensor_defs={"circuit.instant_power_w": description}, + {"panel.l1_voltage": FieldMetadata("kV", "float")}, + sensor_defs={"panel.l1_voltage": description}, ) - assert findings.unit_mismatches[0].field_path == "circuit.instant_power_w" - assert findings.unit_mismatches[0].schema_unit == "kW" + assert findings.unit_mismatches[0].field_path == "panel.l1_voltage" + assert findings.unit_mismatches[0].schema_unit == "kV" def test_unitless_sensor_still_checked_for_resolution() -> None: @@ -111,13 +130,14 @@ def test_declared_and_resolved_fields_are_not_unread() -> None: assert findings.unread == frozenset() -def test_none_metadata_yields_empty_findings() -> None: - """The module-level fallback. +def test_empty_metadata_is_healthy_not_unknown() -> None: + """A pass over a healthy panel is expressible and is not the None sentinel. - Callers that must distinguish "unknown" from "healthy" — the coordinator - does — check for None before calling. + Task 7 needs three distinct states; this is the middle one. "Unknown" is the + coordinator's `schema_findings is None`, which `evaluate_field_metadata` can + no longer produce — it no longer accepts the sentinel at all. """ - findings = evaluate_field_metadata(None) + findings = evaluate_field_metadata({}) assert findings == SchemaFindings(frozenset(), (), frozenset()) @@ -148,14 +168,14 @@ def test_resolved_unitless_sensor_yields_no_mismatch() -> None: assert findings.unit_mismatches == () -def test_descriptions_without_a_declaration_are_not_keyed( +def test_descriptions_without_a_declaration_raise( monkeypatch: pytest.MonkeyPatch, ) -> None: - """`declared_field_paths` is the enforcement point; here there is no key. + """Both consumers of the shared traversal reject an undeclared description. - A description that carries no `FieldPathDeclarationMixin` names no field, so - it cannot appear in a field-path-keyed map. `declared_field_paths` raises on - the same input, which is where the drift is caught. + Skipping it would drop the sensor from the unit cross-check silently, which + is the drift `field_paths` exists to prevent. `declared_field_paths` raises + on the same input, and both now do so from one place. """ from homeassistant.components.sensor import SensorEntityDescription @@ -164,4 +184,100 @@ def test_descriptions_without_a_declaration_are_not_keyed( "all_sensor_descriptions", lambda: (SensorEntityDescription(key="undeclared"),), ) - assert sensor_definitions.sensor_descriptions_by_field_path() == {} + with pytest.raises(TypeError, match="carries no field-path declaration"): + sensor_definitions.sensor_descriptions_by_field_path() + + +@pytest.mark.parametrize( + "metadata_fn", + [ + pytest.param(schema_zero_metadata, id="schema_0"), + pytest.param(schema_one_metadata, id="schema_1"), + ], +) +def test_real_adapter_metadata_produces_no_findings(metadata_fn: MetadataFn) -> None: + """A healthy panel of either generation must be finding-free. + + The rest of this file drives the evaluator with synthetic single-entry dicts, + which cannot show what real firmware actually declares. This is the standing + guard against a day-one Repair that no user can act on. + """ + findings = evaluate_field_metadata(metadata_fn(), sensor_descriptions_by_field_path()) + assert findings.unresolved == frozenset() + assert findings.unit_mismatches == () + + +def test_known_bad_schema_unit_exception_is_narrow() -> None: + """Only the exact known-bad unit is excused; anything else is new information.""" + from homeassistant.components.sensor import SensorEntityDescription + from homeassistant.const import UnitOfPower + + description = SensorEntityDescription( + key="circuit_power", native_unit_of_measurement=UnitOfPower.WATT + ) + findings = evaluate_field_metadata( + {"circuit.instant_power_w": FieldMetadata("MW", "float")}, + sensor_defs={"circuit.instant_power_w": description}, + ) + assert [m.schema_unit for m in findings.unit_mismatches] == ["MW"] + + +def test_absent_hardware_on_real_metadata_is_not_degradation() -> None: + """A batteryless panel simply omits the battery rows — nothing is wrong. + + Stronger than the empty-dict case, which passes whether or not the + `entry is None` arm exists: here 8 `battery.*` paths are declared and read, + and every one of them is missing from the adapter's output. + """ + metadata = schema_one_metadata_batteryless() + battery_paths = {p for p in declared_field_paths() if p.startswith("battery.")} + assert battery_paths + assert battery_paths.isdisjoint(metadata) + + findings = evaluate_field_metadata(metadata, sensor_descriptions_by_field_path()) + assert findings.unresolved == frozenset() + assert findings.unit_mismatches == () + + +@pytest.mark.parametrize( + "metadata_fn", + [ + pytest.param(schema_zero_metadata, id="schema_0"), + pytest.param(schema_one_metadata, id="schema_1"), + ], +) +def test_unread_excludes_readers_exempt_from_the_producible_gate( + metadata_fn: MetadataFn, +) -> None: + """`RESIDUAL_EXEMPT_PATHS` are read, just not required of both adapters. + + They are absent from `declared_field_paths()`, so a plain set difference + reports them as produced-but-unread — false for 10 of schema_0's 17. + """ + findings = evaluate_field_metadata(metadata_fn(), sensor_descriptions_by_field_path()) + assert findings.unread.isdisjoint(RESIDUAL_EXEMPT_PATHS) + + +def test_readers_of_the_same_field_path_agree_on_unit() -> None: + """`sensor_descriptions_by_field_path` keeps one reader per path. + + Several field paths are read by two descriptions (an unmapped-circuit raw + key and its named-circuit twin). Dropping one is only safe while they agree + on what the unit check would compare, so pin that here rather than trusting + it. + """ + from collections import defaultdict + + by_path: defaultdict[str, list[object]] = defaultdict(list) + for description in all_sensor_descriptions(): + if not isinstance(description, FieldPathDeclarationMixin): + continue + if description.derived or description.field_path is None: + continue + by_path[description.field_path].append(description) + + colliding = {path: ds for path, ds in by_path.items() if len(ds) > 1} + assert colliding, "expected at least one field path with two readers" + for path, descriptions in colliding.items(): + units = {d.native_unit_of_measurement for d in descriptions} + assert len(units) == 1, f"readers of {path} disagree on unit: {units}" From 997971cd399d4aa10cab5d4aa117f391c289b90a Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:42:40 -0700 Subject: [PATCH 030/345] feat: raise Repairs for unresolved fields and unit mismatches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One issue per (entry, class, field path) so a dismissal cannot swallow a later finding and one panel cannot clear another's, re-raised idempotently so reconciliation never clears a dismissal. Issues are non-persistent: derived from live state and re-asserted at startup from the tombstone that carries the dismissal forward. A produced field nothing reads stays a sanctioned addition — debug log only. Adds async_remove_entry, which core does not provide, so a removed panel does not leave its notices behind. Entity enumeration in a payload is a count plus three examples, matched by the description's user-friendly suffix rather than the snapshot field name. --- custom_components/span_panel/__init__.py | 12 + custom_components/span_panel/const.py | 4 + custom_components/span_panel/coordinator.py | 35 ++- custom_components/span_panel/field_paths.py | 40 ++- .../span_panel/quality_scale.yaml | 4 +- .../span_panel/schema_repairs.py | 137 ++++++++ custom_components/span_panel/strings.json | 8 + .../span_panel/translations/en.json | 8 + .../span_panel/translations/es.json | 8 + .../span_panel/translations/fr.json | 8 + .../span_panel/translations/ja.json | 8 + .../span_panel/translations/pt.json | 8 + tests/test_coordinator.py | 83 +++++ tests/test_schema_repairs.py | 297 ++++++++++++++++++ tests/test_schema_validation.py | 22 ++ 15 files changed, 662 insertions(+), 20 deletions(-) create mode 100644 custom_components/span_panel/schema_repairs.py create mode 100644 tests/test_schema_repairs.py diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py index 4bfcb60f..bad321f6 100644 --- a/custom_components/span_panel/__init__.py +++ b/custom_components/span_panel/__init__.py @@ -52,6 +52,7 @@ from .graph_horizon import GraphHorizonManager from .migrations import CURRENT_CONFIG_VERSION, async_migrate_entry # noqa: F401 from .options import SNAPSHOT_UPDATE_INTERVAL +from .schema_repairs import async_clear_schema_issues from .services import ( # noqa: F401 _async_register_favorites_services, _async_register_graph_horizon_services, @@ -282,6 +283,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) - return True +async def async_remove_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> None: + """Clean up the Repairs this entry raised. + + Core deletes neither issues nor their dismissals when a config entry is + removed, so a panel that is taken out of the system would otherwise leave + its schema notices behind forever. Scoped to this entry: another panel's + issues share the domain and must survive. + """ + async_clear_schema_issues(hass, entry) + + async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: SpanPanelConfigEntry, diff --git a/custom_components/span_panel/const.py b/custom_components/span_panel/const.py index f18e4e09..1fcb803d 100644 --- a/custom_components/span_panel/const.py +++ b/custom_components/span_panel/const.py @@ -80,6 +80,10 @@ ) EVENT_CURRENT_ALERT = "span_panel_current_alert" +# Fired alongside a schema Repair so an automation can react without polling +# the issue registry. Same bus-event shape as the current alert above. +EVENT_SCHEMA_ISSUE = "span_panel_schema_issue" + # Graph time horizon configuration VALID_GRAPH_HORIZONS: Final[tuple[str, ...]] = ("5m", "1h", "1d", "1w", "1M") DEFAULT_GRAPH_HORIZON = "5m" diff --git a/custom_components/span_panel/coordinator.py b/custom_components/span_panel/coordinator.py index 30d1cef8..03ec29cb 100644 --- a/custom_components/span_panel/coordinator.py +++ b/custom_components/span_panel/coordinator.py @@ -27,7 +27,9 @@ from span_panel_api.exceptions import SpanPanelAuthError, SpanPanelStaleDataError from .const import DOMAIN -from .id_builder import build_circuit_unique_id +from .field_paths import iter_all_field_path_declarations +from .id_builder import build_circuit_unique_id, get_user_friendly_suffix +from .schema_repairs import async_sync_schema_issues from .schema_validation import SchemaFindings, evaluate_field_metadata from .sensor_definitions import sensor_descriptions_by_field_path @@ -390,6 +392,37 @@ def _run_schema_validation(self) -> None: self._findings = evaluate_field_metadata( field_metadata, sensor_descriptions_by_field_path() ) + async_sync_schema_issues( + self.hass, + self.config_entry, + self._findings, + self._affected_entity_ids(self._findings.unresolved), + ) + + def _affected_entity_ids(self, field_paths: frozenset[str]) -> dict[str, list[str]]: + """Entity ids this entry owns that read each of `field_paths`. + + Matched through `get_user_friendly_suffix(description.key)`, not the + snapshot field name: a unique_id ends in the suffix ("_power"), never in + the field ("instant_power_w"), so matching on the field would silently + find nothing and report every dead field as affecting zero entities. + """ + entity_registry = er.async_get(self.hass) + entries = er.async_entries_for_config_entry(entity_registry, self.config_entry.entry_id) + + suffixes_by_path: dict[str, set[str]] = {path: set() for path in field_paths} + for field_path, description in iter_all_field_path_declarations(): + if field_path in suffixes_by_path: + suffixes_by_path[field_path].add(get_user_friendly_suffix(description.key)) + + return { + path: [ + entry.entity_id + for entry in entries + if any(entry.unique_id.endswith(suffix) for suffix in suffixes) + ] + for path, suffixes in suffixes_by_path.items() + } @property def unresolved_paths(self) -> frozenset[str]: diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py index 7031abdf..496cef42 100644 --- a/custom_components/span_panel/field_paths.py +++ b/custom_components/span_panel/field_paths.py @@ -173,12 +173,13 @@ def iter_field_path_declarations[DescriptionT: EntityDescription]( yield description.field_path, description -def declared_field_paths() -> frozenset[str]: - """Field paths the integration reads that must be producible by an adapter. +def iter_all_field_path_declarations() -> Iterator[tuple[str, EntityDescription]]: + """Yield every ``(field_path, description)`` pair across all platforms. - Derived entities are excluded: they have no single source field, so there is - nothing for an adapter to produce. Residual readers that no adapter (or only - one) produces are excluded too, and are listed in `RESIDUAL_EXEMPT_PATHS`. + The one place that knows which platforms carry declarations. Both consumers + read it: `declared_field_paths` wants only the paths, and the Repairs call + site wants the descriptions, so it can name the entities a dead field killed. + Keeping the assembly here is what stops those two views from drifting. """ # Deferred: the platform modules import `FieldPathDeclarationMixin` from # here, and `binary_sensor` reaches the package root for its config-entry @@ -193,17 +194,24 @@ def declared_field_paths() -> frozenset[str]: all_sensor_descriptions, ) - paths: set[str] = set(RESIDUAL_FIELD_PATHS) - paths.update( - field_path - for field_path, _ in iter_field_path_declarations( - ( - *all_sensor_descriptions(), - *BINARY_SENSORS, - *EVSE_BINARY_SENSORS, - GRID_ISLANDABLE_SENSOR, - BESS_CONNECTED_SENSOR, - ) + yield from iter_field_path_declarations( + ( + *all_sensor_descriptions(), + *BINARY_SENSORS, + *EVSE_BINARY_SENSORS, + GRID_ISLANDABLE_SENSOR, + BESS_CONNECTED_SENSOR, ) ) + + +def declared_field_paths() -> frozenset[str]: + """Field paths the integration reads that must be producible by an adapter. + + Derived entities are excluded: they have no single source field, so there is + nothing for an adapter to produce. Residual readers that no adapter (or only + one) produces are excluded too, and are listed in `RESIDUAL_EXEMPT_PATHS`. + """ + paths: set[str] = set(RESIDUAL_FIELD_PATHS) + paths.update(field_path for field_path, _ in iter_all_field_path_declarations()) return frozenset(paths) diff --git a/custom_components/span_panel/quality_scale.yaml b/custom_components/span_panel/quality_scale.yaml index d4e56eeb..e00ca898 100644 --- a/custom_components/span_panel/quality_scale.yaml +++ b/custom_components/span_panel/quality_scale.yaml @@ -95,9 +95,7 @@ rules: exception-translations: done icon-translations: done reconfiguration-flow: done - repair-issues: - status: exempt - comment: No actionable repair scenarios identified for this integration. + repair-issues: done stale-devices: done # Platinum diff --git a/custom_components/span_panel/schema_repairs.py b/custom_components/span_panel/schema_repairs.py new file mode 100644 index 00000000..dd780c84 --- /dev/null +++ b/custom_components/span_panel/schema_repairs.py @@ -0,0 +1,137 @@ +"""Surface schema findings as Home Assistant Repairs. + +Two conditions reach the user, both defects: a field the adapter cannot resolve +(a sensor is dead), and a unit that disagrees with the schema (a reading or its +statistics are wrong). Both are reported regardless of install age — a defect is +not a change. + +A third condition, a produced field nothing reads, is a sanctioned addition and +stays in the debug log. +""" + +from __future__ import annotations + +from collections.abc import Mapping + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir + +from .const import DOMAIN, EVENT_SCHEMA_ISSUE +from .schema_validation import SchemaFindings + +_MAX_EXAMPLES = 3 + + +def _unresolved_id(entry_id: str, field_path: str) -> str: + return f"unresolved_{entry_id}_{field_path}" + + +def _unit_id(entry_id: str, field_path: str) -> str: + return f"unit_mismatch_{entry_id}_{field_path}" + + +@callback +def async_sync_schema_issues( + hass: HomeAssistant, + entry: ConfigEntry, + findings: SchemaFindings, + entity_ids_by_path: Mapping[str, list[str]], +) -> None: + """Reconcile Repairs against the current findings. + + One issue per (class, field path). Aggregating would be actively harmful: + `async_get_or_create`'s update branch preserves `dismissed_version`, so a + user who dismissed an aggregate would never be told when another field + joined it. + + Re-raises idempotently and deletes only on genuine resolution. Deleting is + the one thing that clears a dismissal — `dismissed_version` is never + compared against the running HA version, and neither the update branch nor + the store reload touches it — so a delete-then-recreate loop would wipe + every dismissal on every pass and turn an accepted notice into a permanent + nag. + """ + registry = ir.async_get(hass) + wanted: set[str] = set() + + for field_path in sorted(findings.unresolved): + issue_id = _unresolved_id(entry.entry_id, field_path) + wanted.add(issue_id) + # Sorted so an unchanged panel produces an unchanged payload: the update + # branch only rewrites the entry when something actually differs. + affected = sorted(entity_ids_by_path.get(field_path, [])) + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + is_fixable=False, + # Derived from live state, so it is re-asserted at every startup. A + # non-persistent issue reloads as a tombstone carrying only the + # dismissal, which is what lets re-assertion happen without + # resurrecting one the user already accepted. + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key="schema_field_unresolved", + translation_placeholders={ + "field_path": field_path, + # A count plus a few examples, never the full list: one missing + # `circuit.instant_power_w` affects every circuit on the panel. + "count": str(len(affected)), + "examples": ", ".join(affected[:_MAX_EXAMPLES]) or "none", + }, + ) + + for mismatch in findings.unit_mismatches: + issue_id = _unit_id(entry.entry_id, mismatch.field_path) + wanted.add(issue_id) + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key="schema_unit_mismatch", + translation_placeholders={ + "field_path": mismatch.field_path, + "ha_unit": mismatch.ha_unit, + "schema_unit": mismatch.schema_unit, + }, + ) + + for issue_id in _ours(registry, entry.entry_id) - wanted: + ir.async_delete_issue(hass, DOMAIN, issue_id) + + if wanted: + hass.bus.async_fire( + EVENT_SCHEMA_ISSUE, + { + "entry_id": entry.entry_id, + "unresolved": sorted(findings.unresolved), + "unit_mismatches": [m.field_path for m in findings.unit_mismatches], + }, + ) + + +def _ours(registry: ir.IssueRegistry, entry_id: str) -> set[str]: + """Our issue ids for ONE config entry. + + Scoping by entry is not cosmetic: with a shared namespace, a healthy panel's + reconcile pass would delete a degraded panel's issues on every cycle, and + removing one panel would clear every panel's issues. + """ + prefixes = (f"unresolved_{entry_id}_", f"unit_mismatch_{entry_id}_") + return { + issue_id + for (domain, issue_id) in registry.issues + if domain == DOMAIN and issue_id.startswith(prefixes) + } + + +@callback +def async_clear_schema_issues(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove one entry's issues. Core does not do this on entry removal.""" + registry = ir.async_get(hass) + for issue_id in _ours(registry, entry.entry_id): + ir.async_delete_issue(hass, DOMAIN, issue_id) diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index a8f63db5..e3e7a1d0 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -711,6 +711,14 @@ "panel_upgraded_to_ebus_v1": { "title": "Your SPAN Panel now reports islanding from its Microgrid Interconnect Device", "description": "Your SPAN Panel upgraded to the eBus v1.0 data model. Nothing you rely on has gone away, and no automation changes are required — this is a note about what improved.\n\n**DSM Grid State** keeps its entity ID and its history, and is now more trustworthy. On the previous firmware it was *inferred* — from the battery when one was fitted, otherwise from the dominant power source and whether any power was crossing the grid connection. It now reads the islanding state the Microgrid Interconnect Device (MID) actually senses.\n\n**Grid Islandable** also keeps working. v1.0 publishes no panel-level `grid-islandable` property, so it now reflects whether a MID is present, which is how v1.0 says backup capability is detected.\n\n**New:** a Microgrid Interconnect Device appears among your devices, carrying **Grid State** — the health of the utility supply itself, which the previous firmware did not report.\n\nDismiss this once you have had a look." + }, + "schema_field_unresolved": { + "title": "SPAN Panel data field unavailable", + "description": "Your panel's firmware no longer provides `{field_path}`. {count} entity/entities are affected (for example: {examples}). They will show as unavailable until an integration update adds support for the new firmware." + }, + "schema_unit_mismatch": { + "title": "SPAN Panel unit mismatch", + "description": "Your panel reports `{field_path}` in {schema_unit}, but this integration expects {ha_unit}. Readings and long-term statistics for the affected entities may be wrong. The entity has not been changed automatically." } } } diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index a8f63db5..e3e7a1d0 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -711,6 +711,14 @@ "panel_upgraded_to_ebus_v1": { "title": "Your SPAN Panel now reports islanding from its Microgrid Interconnect Device", "description": "Your SPAN Panel upgraded to the eBus v1.0 data model. Nothing you rely on has gone away, and no automation changes are required — this is a note about what improved.\n\n**DSM Grid State** keeps its entity ID and its history, and is now more trustworthy. On the previous firmware it was *inferred* — from the battery when one was fitted, otherwise from the dominant power source and whether any power was crossing the grid connection. It now reads the islanding state the Microgrid Interconnect Device (MID) actually senses.\n\n**Grid Islandable** also keeps working. v1.0 publishes no panel-level `grid-islandable` property, so it now reflects whether a MID is present, which is how v1.0 says backup capability is detected.\n\n**New:** a Microgrid Interconnect Device appears among your devices, carrying **Grid State** — the health of the utility supply itself, which the previous firmware did not report.\n\nDismiss this once you have had a look." + }, + "schema_field_unresolved": { + "title": "SPAN Panel data field unavailable", + "description": "Your panel's firmware no longer provides `{field_path}`. {count} entity/entities are affected (for example: {examples}). They will show as unavailable until an integration update adds support for the new firmware." + }, + "schema_unit_mismatch": { + "title": "SPAN Panel unit mismatch", + "description": "Your panel reports `{field_path}` in {schema_unit}, but this integration expects {ha_unit}. Readings and long-term statistics for the affected entities may be wrong. The entity has not been changed automatically." } } } diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index fa90ee06..45a1b1a4 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -711,6 +711,14 @@ "panel_upgraded_to_ebus_v1": { "title": "Su Panel SPAN ahora informa del aislamiento desde su Dispositivo de Interconexión de Microrred", "description": "Su Panel SPAN se ha actualizado al modelo de datos eBus v1.0. Nada de lo que usted utiliza ha desaparecido y no es necesario modificar ninguna automatización: este aviso solo explica lo que ha mejorado.\n\n**Estado de Red DSM** conserva su ID de entidad y su historial, y ahora es más fiable. Con el firmware anterior se *deducía*: a partir de la batería cuando había una instalada y, en su defecto, de la fuente de energía dominante y de si circulaba potencia por la conexión a la red. Ahora lee el estado de aislamiento que el Dispositivo de Interconexión de Microrred (MID) detecta realmente.\n\n**Aislable de la Red** también sigue funcionando. La v1.0 no publica ninguna propiedad `grid-islandable` a nivel de panel, por lo que ahora refleja si hay un MID presente, que es como la v1.0 determina la capacidad de respaldo.\n\n**Novedad:** aparece un Dispositivo de Interconexión de Microrred entre sus dispositivos, con **Estado de la Red**, es decir, el estado del suministro eléctrico, que el firmware anterior no informaba.\n\nPuede descartar este aviso cuando lo haya revisado." + }, + "schema_field_unresolved": { + "title": "Campo de datos del Panel SPAN no disponible", + "description": "El firmware de su panel ya no proporciona `{field_path}`. Hay {count} entidad(es) afectada(s) (por ejemplo: {examples}). Aparecerán como no disponibles hasta que una actualización de la integración añada compatibilidad con el nuevo firmware." + }, + "schema_unit_mismatch": { + "title": "Discrepancia de unidades del Panel SPAN", + "description": "Su panel informa de `{field_path}` en {schema_unit}, pero esta integración espera {ha_unit}. Las lecturas y las estadísticas a largo plazo de las entidades afectadas pueden ser incorrectas. La entidad no se ha modificado automáticamente." } } } diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index 0fd22ede..775deb0e 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -711,6 +711,14 @@ "panel_upgraded_to_ebus_v1": { "title": "Votre Panneau SPAN signale désormais l'îlotage depuis son Dispositif d'Interconnexion de Micro-réseau", "description": "Votre Panneau SPAN est passé au modèle de données eBus v1.0. Rien de ce que vous utilisez n'a disparu et aucune modification de vos automatisations n'est nécessaire : cet avis explique simplement ce qui s'est amélioré.\n\n**État Réseau DSM** conserve son identifiant d'entité et son historique, et gagne en fiabilité. Avec le micrologiciel précédent, il était *déduit* : à partir de la batterie lorsqu'il y en avait une, sinon à partir de la source d'énergie dominante et de la présence de puissance transitant par le raccordement réseau. Il lit désormais l'état d'îlotage réellement mesuré par le Dispositif d'Interconnexion de Micro-réseau (MID).\n\n**Îlotage Réseau Possible** continue également de fonctionner. La v1.0 ne publie aucune propriété `grid-islandable` au niveau du panneau ; ce capteur indique donc maintenant la présence d'un MID, ce qui est la façon dont v1.0 détermine la capacité de secours.\n\n**Nouveau :** un Dispositif d'Interconnexion de Micro-réseau apparaît parmi vos appareils, avec **État du Réseau** — l'état de l'alimentation du réseau public, que le micrologiciel précédent ne signalait pas.\n\nVous pouvez ignorer cet avis après consultation." + }, + "schema_field_unresolved": { + "title": "Champ de données du Panneau SPAN indisponible", + "description": "Le micrologiciel de votre panneau ne fournit plus `{field_path}`. {count} entité(s) sont concernées (par exemple : {examples}). Elles resteront indisponibles jusqu'à ce qu'une mise à jour de l'intégration prenne en charge le nouveau micrologiciel." + }, + "schema_unit_mismatch": { + "title": "Incohérence d'unité du Panneau SPAN", + "description": "Votre panneau signale `{field_path}` en {schema_unit}, alors que cette intégration attend {ha_unit}. Les relevés et les statistiques à long terme des entités concernées peuvent être erronés. L'entité n'a pas été modifiée automatiquement." } } } diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index bc1563aa..9a07cbf4 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -711,6 +711,14 @@ "panel_upgraded_to_ebus_v1": { "title": "SPAN パネルは自立運転状態をマイクログリッド相互接続装置から取得するようになりました", "description": "SPAN パネルが eBus v1.0 データモデルにアップグレードされました。ご利用中のものが失われることはなく、オートメーションの変更も必要ありません。この通知は改善点をお知らせするものです。\n\n**DSM 系統状態** はエンティティ ID と履歴をそのまま維持し、さらに信頼性が向上しました。以前のファームウェアでは *推定* されていました。バッテリーがある場合はバッテリーから、ない場合は主電源と系統接続を流れる電力の有無から判断していました。現在はマイクログリッド相互接続装置 (MID) が実際に検知した自立運転状態を読み取ります。\n\n**系統自立運転可能** も引き続き動作します。v1.0 ではパネル単位の `grid-islandable` プロパティは公開されないため、現在は MID の有無を示します。これが v1.0 におけるバックアップ能力の判定方法です。\n\n**新規:** デバイス一覧にマイクログリッド相互接続装置が追加され、**系統状態** (電力会社からの供給状態) を報告します。これは以前のファームウェアにはなかった情報です。\n\n内容を確認したら、この通知は閉じて構いません。" + }, + "schema_field_unresolved": { + "title": "SPAN パネルのデータフィールドが利用できません", + "description": "パネルのファームウェアが `{field_path}` を提供しなくなりました。{count} 個のエンティティが影響を受けます (例: {examples})。新しいファームウェアに対応する統合の更新が入るまで、これらは「利用不可」と表示されます。" + }, + "schema_unit_mismatch": { + "title": "SPAN パネルの単位の不一致", + "description": "パネルは `{field_path}` を {schema_unit} で報告していますが、この統合は {ha_unit} を想定しています。影響を受けるエンティティの測定値と長期統計が誤っている可能性があります。エンティティは自動的には変更されていません。" } } } diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index 03ff093d..4b3dab29 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -711,6 +711,14 @@ "panel_upgraded_to_ebus_v1": { "title": "O seu Painel SPAN comunica agora o ilhamento a partir do Dispositivo de Interligação de Microrrede", "description": "O seu Painel SPAN foi atualizado para o modelo de dados eBus v1.0. Nada do que utiliza desapareceu e não é necessário alterar automações — este aviso apenas explica o que melhorou.\n\n**Estado da Rede DSM** mantém o seu ID de entidade e o seu histórico, e passa a ser mais fiável. No firmware anterior era *inferido*: a partir da bateria quando existia uma e, caso contrário, a partir da fonte de energia dominante e da existência de potência a atravessar a ligação à rede. Agora lê o estado de ilhamento que o Dispositivo de Interligação de Microrrede (MID) realmente deteta.\n\n**Isolável da Rede** também continua a funcionar. A v1.0 não publica qualquer propriedade `grid-islandable` ao nível do painel, pelo que agora reflete a presença de um MID, que é como a v1.0 determina a capacidade de reserva.\n\n**Novidade:** aparece um Dispositivo de Interligação de Microrrede entre os seus dispositivos, com **Estado da Rede** — o estado do fornecimento da rede elétrica, que o firmware anterior não comunicava.\n\nPode dispensar este aviso depois de o consultar." + }, + "schema_field_unresolved": { + "title": "Campo de dados do Painel SPAN indisponível", + "description": "O firmware do seu painel já não fornece `{field_path}`. Há {count} entidade(s) afetada(s) (por exemplo: {examples}). Ficarão indisponíveis até que uma atualização da integração adicione suporte ao novo firmware." + }, + "schema_unit_mismatch": { + "title": "Discrepância de unidades do Painel SPAN", + "description": "O seu painel comunica `{field_path}` em {schema_unit}, mas esta integração espera {ha_unit}. As leituras e as estatísticas de longo prazo das entidades afetadas podem estar erradas. A entidade não foi alterada automaticamente." } } } diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index cc666ee5..694a79f0 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -510,3 +510,86 @@ async def test_async_update_data_stale_data_error_marks_offline_and_returns_last "is unavailable" in r.message and "MQTT broker disconnected" in r.message for r in caplog.records ) + + +async def test_schema_validation_raises_a_repair_naming_the_dead_entities( + hass: HomeAssistant, +) -> None: + """The whole point of the feature: a dead field names the sensors it killed. + + Entity ids are matched through `get_user_friendly_suffix(description.key)`. + A unique_id ends in the suffix ("_power"), never in the snapshot field name + ("instant_power_w"), so matching on the field would report every dead field + as affecting zero entities while still looking like it worked. + """ + from homeassistant.helpers import ( + entity_registry as er, + issue_registry as ir, + ) + + entry = MockConfigEntry(domain="span_panel", entry_id="entry-affected") + entry.add_to_hass(hass) + entity_registry = er.async_get(hass) + entity_registry.async_get_or_create( + "sensor", + "span_panel", + "span_sp3-001_0dad2f16cd514812ae1807b0457d473e_power", + suggested_object_id="span_panel_kitchen_power", + config_entry=entry, + ) + entity_registry.async_get_or_create( + "sensor", + "span_panel", + "span_sp3-001_0dad2f16cd514812ae1807b0457d473e_energy_produced", + suggested_object_id="span_panel_kitchen_energy_produced", + config_entry=entry, + ) + + client = MagicMock(spec=SpanPanelClientProtocol) + client.field_metadata = { + "circuit.instant_power_w": FieldMetadata(None, "unknown", resolved=False) + } + coordinator = SpanPanelCoordinator(hass, cast(SpanMqttClient, client), entry) + + coordinator._run_schema_validation() + + issue = ir.async_get(hass).async_get_issue( + "span_panel", "unresolved_entry-affected_circuit.instant_power_w" + ) + assert issue is not None + assert issue.translation_placeholders["count"] == "1" + assert issue.translation_placeholders["examples"] == "sensor.span_panel_kitchen_power" + + +async def test_schema_validation_without_metadata_raises_no_repair( + hass: HomeAssistant, +) -> None: + """The "unknown" pass must not reconcile at all. + + Reconciling against empty findings during the retained-message window would + delete every schema issue, and with it every dismissal the user has made. + """ + from homeassistant.helpers import issue_registry as ir + + entry = MockConfigEntry(domain="span_panel", entry_id="entry-unknown") + entry.add_to_hass(hass) + ir.async_create_issue( + hass, + "span_panel", + "unresolved_entry-unknown_circuit.instant_power_w", + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key="schema_field_unresolved", + translation_placeholders={"field_path": "x", "count": "0", "examples": "none"}, + ) + + client = MagicMock(spec=SpanPanelClientProtocol) + client.field_metadata = None + coordinator = SpanPanelCoordinator(hass, cast(SpanMqttClient, client), entry) + + coordinator._run_schema_validation() + + assert ir.async_get(hass).async_get_issue( + "span_panel", "unresolved_entry-unknown_circuit.instant_power_w" + ) diff --git a/tests/test_schema_repairs.py b/tests/test_schema_repairs.py new file mode 100644 index 00000000..b6f1108e --- /dev/null +++ b/tests/test_schema_repairs.py @@ -0,0 +1,297 @@ +from __future__ import annotations + +from homeassistant.helpers import issue_registry as ir +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.span_panel.const import DOMAIN, EVENT_SCHEMA_ISSUE +from custom_components.span_panel.schema_repairs import ( + async_clear_schema_issues, + async_sync_schema_issues, +) +from custom_components.span_panel.schema_validation import SchemaFindings, UnitMismatch + +_PATH = "circuit.instant_power_w" + + +@pytest.fixture +def entry(hass) -> MockConfigEntry: + """Return a config entry added to hass. No conftest fixture exists for this.""" + mock = MockConfigEntry(domain=DOMAIN, data={}, unique_id="abc123") + mock.add_to_hass(hass) + return mock + + +def _issue_id(entry: MockConfigEntry, path: str = _PATH) -> str: + return f"unresolved_{entry.entry_id}_{path}" + + +def _unit_issue_id(entry: MockConfigEntry, path: str = _PATH) -> str: + return f"unit_mismatch_{entry.entry_id}_{path}" + + +async def test_unresolved_path_raises_one_issue(hass, entry) -> None: + findings = SchemaFindings(frozenset({_PATH}), (), frozenset()) + async_sync_schema_issues(hass, entry, findings, {}) + + registry = ir.async_get(hass) + assert registry.async_get_issue(DOMAIN, _issue_id(entry)) + + +async def test_issue_cleared_when_condition_resolves(hass, entry) -> None: + async_sync_schema_issues(hass, entry, SchemaFindings(frozenset({_PATH}), (), frozenset()), {}) + async_sync_schema_issues(hass, entry, SchemaFindings(frozenset(), (), frozenset()), {}) + + registry = ir.async_get(hass) + assert registry.async_get_issue(DOMAIN, _issue_id(entry)) is None + + +async def test_dismissal_survives_reconciliation(hass, entry) -> None: + """Re-raise idempotently rather than delete-then-recreate. + + Deleting is the one thing that resets a dismissal, which would turn an + accepted notice into a permanent nag. + """ + findings = SchemaFindings(frozenset({_PATH}), (), frozenset()) + async_sync_schema_issues(hass, entry, findings, {}) + + issue_id = _issue_id(entry) + ir.async_ignore_issue(hass, DOMAIN, issue_id, True) + registry = ir.async_get(hass) + dismissed = registry.async_get_issue(DOMAIN, issue_id).dismissed_version + assert dismissed is not None + + for _ in range(3): + async_sync_schema_issues(hass, entry, findings, {}) + + assert registry.async_get_issue(DOMAIN, issue_id).dismissed_version == dismissed + + +async def test_dismissal_survives_a_changing_affected_entity_payload(hass, entry) -> None: + """The update branch replaces the placeholders and keeps the dismissal. + + Stronger than the identical-payload case above: `async_get_or_create` skips + the write entirely when nothing changed, so a delete-then-recreate bug could + hide there. Here the placeholders genuinely differ between passes, forcing + the update branch to run. + """ + findings = SchemaFindings(frozenset({_PATH}), (), frozenset()) + async_sync_schema_issues(hass, entry, findings, {_PATH: ["sensor.a"]}) + + issue_id = _issue_id(entry) + ir.async_ignore_issue(hass, DOMAIN, issue_id, True) + registry = ir.async_get(hass) + dismissed = registry.async_get_issue(DOMAIN, issue_id).dismissed_version + + async_sync_schema_issues(hass, entry, findings, {_PATH: ["sensor.a", "sensor.b"]}) + + issue = registry.async_get_issue(DOMAIN, issue_id) + assert issue.dismissed_version == dismissed + assert issue.translation_placeholders["count"] == "2" + + +async def test_distinct_paths_get_distinct_issues(hass, entry) -> None: + """Dismissing one finding must not swallow a later, different one.""" + async_sync_schema_issues(hass, entry, SchemaFindings(frozenset({"a.one"}), (), frozenset()), {}) + async_sync_schema_issues( + hass, entry, SchemaFindings(frozenset({"a.one", "b.two"}), (), frozenset()), {} + ) + registry = ir.async_get(hass) + assert registry.async_get_issue(DOMAIN, _issue_id(entry, "b.two")) + + +async def test_a_dismissed_finding_does_not_swallow_a_later_one(hass, entry) -> None: + """The reason one issue per (class, path) is not cosmetic. + + Dismissing an aggregate would silence every finding that joined it later, + because the update branch preserves `dismissed_version`. + """ + async_sync_schema_issues(hass, entry, SchemaFindings(frozenset({"a.one"}), (), frozenset()), {}) + ir.async_ignore_issue(hass, DOMAIN, _issue_id(entry, "a.one"), True) + + async_sync_schema_issues( + hass, entry, SchemaFindings(frozenset({"a.one", "b.two"}), (), frozenset()), {} + ) + + registry = ir.async_get(hass) + assert registry.async_get_issue(DOMAIN, _issue_id(entry, "a.one")).dismissed_version + assert registry.async_get_issue(DOMAIN, _issue_id(entry, "b.two")).dismissed_version is None + + +async def test_one_entry_does_not_clear_another(hass) -> None: + """Two panels must not delete each other's issues on every reconcile.""" + sick = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sick") + sick.add_to_hass(hass) + well = MockConfigEntry(domain=DOMAIN, data={}, unique_id="well") + well.add_to_hass(hass) + + async_sync_schema_issues(hass, sick, SchemaFindings(frozenset({_PATH}), (), frozenset()), {}) + async_sync_schema_issues(hass, well, SchemaFindings(frozenset(), (), frozenset()), {}) + + registry = ir.async_get(hass) + assert registry.async_get_issue(DOMAIN, _issue_id(sick)) + + +async def test_circuit_rename_and_commissioning_raise_no_issue(hass, entry) -> None: + """Tier-1 and Tier-2 changes are handled elsewhere and must stay silent.""" + async_sync_schema_issues( + hass, entry, SchemaFindings(frozenset(), (), frozenset({"pv.model"})), {} + ) + registry = ir.async_get(hass) + assert not [k for k in registry.issues if k[0] == DOMAIN] + + +async def test_unit_mismatch_raises_its_own_issue(hass, entry) -> None: + """The second of the two user-facing defects: a reading may be wrong.""" + mismatch = UnitMismatch("panel.l1_voltage", "V", "kV") + async_sync_schema_issues(hass, entry, SchemaFindings(frozenset(), (mismatch,), frozenset()), {}) + + registry = ir.async_get(hass) + issue = registry.async_get_issue(DOMAIN, _unit_issue_id(entry, "panel.l1_voltage")) + assert issue is not None + assert issue.translation_key == "schema_unit_mismatch" + assert issue.translation_placeholders == { + "field_path": "panel.l1_voltage", + "ha_unit": "V", + "schema_unit": "kV", + } + + +async def test_unit_mismatch_issue_is_cleared_on_its_own(hass, entry) -> None: + """Reconciliation must scope both classes, not just the unresolved one.""" + mismatch = UnitMismatch("panel.l1_voltage", "V", "kV") + async_sync_schema_issues(hass, entry, SchemaFindings(frozenset(), (mismatch,), frozenset()), {}) + async_sync_schema_issues(hass, entry, SchemaFindings(frozenset(), (), frozenset()), {}) + + registry = ir.async_get(hass) + assert registry.async_get_issue(DOMAIN, _unit_issue_id(entry, "panel.l1_voltage")) is None + + +async def test_issues_are_not_persistent(hass, entry) -> None: + """Derived from live state, so they must be re-asserted at startup. + + A non-persistent issue reloads as a tombstone carrying only the dismissal, + which is exactly what lets re-assertion happen without resurrecting one. + """ + mismatch = UnitMismatch("panel.l1_voltage", "V", "kV") + async_sync_schema_issues( + hass, entry, SchemaFindings(frozenset({_PATH}), (mismatch,), frozenset()), {} + ) + + registry = ir.async_get(hass) + for issue_id in (_issue_id(entry), _unit_issue_id(entry, "panel.l1_voltage")): + issue = registry.async_get_issue(DOMAIN, issue_id) + assert issue.is_persistent is False + assert issue.is_fixable is False + assert issue.severity is ir.IssueSeverity.WARNING + + +async def test_affected_entities_are_bounded_and_counted(hass, entry) -> None: + """One missing `circuit.instant_power_w` affects every circuit. + + The payload carries the full count but only a few examples, so a 40-circuit + panel does not render a wall of entity ids. + """ + affected = [f"sensor.circuit_{n}_power" for n in range(40)] + async_sync_schema_issues( + hass, + entry, + SchemaFindings(frozenset({_PATH}), (), frozenset()), + {_PATH: affected}, + ) + + registry = ir.async_get(hass) + placeholders = registry.async_get_issue(DOMAIN, _issue_id(entry)).translation_placeholders + assert placeholders["count"] == "40" + assert placeholders["examples"].count(",") < 5 + assert "sensor.circuit_0_power" in placeholders["examples"] + + +async def test_no_affected_entities_still_reads_sensibly(hass, entry) -> None: + """An empty example list must not render as an empty string in the notice.""" + async_sync_schema_issues(hass, entry, SchemaFindings(frozenset({_PATH}), (), frozenset()), {}) + + registry = ir.async_get(hass) + placeholders = registry.async_get_issue(DOMAIN, _issue_id(entry)).translation_placeholders + assert placeholders["count"] == "0" + assert placeholders["examples"] + + +async def test_findings_fire_an_event(hass, entry) -> None: + """Matches the `span_panel_current_alert` pattern so automations can react.""" + events = [] + hass.bus.async_listen(EVENT_SCHEMA_ISSUE, events.append) + + mismatch = UnitMismatch("panel.l1_voltage", "V", "kV") + async_sync_schema_issues( + hass, entry, SchemaFindings(frozenset({_PATH}), (mismatch,), frozenset()), {} + ) + await hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].data == { + "entry_id": entry.entry_id, + "unresolved": [_PATH], + "unit_mismatches": ["panel.l1_voltage"], + } + + +async def test_a_healthy_pass_fires_no_event(hass, entry) -> None: + events = [] + hass.bus.async_listen(EVENT_SCHEMA_ISSUE, events.append) + + async_sync_schema_issues( + hass, entry, SchemaFindings(frozenset(), (), frozenset({"pv.model"})), {} + ) + await hass.async_block_till_done() + + assert events == [] + + +async def test_clearing_removes_only_this_entry(hass) -> None: + """`async_remove_entry` must not take a second panel's issues with it.""" + removed = MockConfigEntry(domain=DOMAIN, data={}, unique_id="removed") + removed.add_to_hass(hass) + kept = MockConfigEntry(domain=DOMAIN, data={}, unique_id="kept") + kept.add_to_hass(hass) + + findings = SchemaFindings(frozenset({_PATH}), (), frozenset()) + async_sync_schema_issues(hass, removed, findings, {}) + async_sync_schema_issues(hass, kept, findings, {}) + + async_clear_schema_issues(hass, removed) + + registry = ir.async_get(hass) + assert registry.async_get_issue(DOMAIN, _issue_id(removed)) is None + assert registry.async_get_issue(DOMAIN, _issue_id(kept)) + + +async def test_reconciliation_leaves_other_domain_issues_alone(hass, entry) -> None: + """The upgrade repair shares our domain and must survive a reconcile pass.""" + ir.async_create_issue( + hass, + DOMAIN, + f"panel_upgraded_to_ebus_v1_{entry.entry_id}", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="panel_upgraded_to_ebus_v1", + ) + + async_sync_schema_issues(hass, entry, SchemaFindings(frozenset(), (), frozenset()), {}) + async_clear_schema_issues(hass, entry) + + registry = ir.async_get(hass) + assert registry.async_get_issue(DOMAIN, f"panel_upgraded_to_ebus_v1_{entry.entry_id}") + + +async def test_remove_entry_clears_this_entry_issues(hass, entry) -> None: + """Core does not delete our issues when the entry is removed.""" + from custom_components.span_panel import async_remove_entry + + async_sync_schema_issues(hass, entry, SchemaFindings(frozenset({_PATH}), (), frozenset()), {}) + registry = ir.async_get(hass) + assert registry.async_get_issue(DOMAIN, _issue_id(entry)) + + await async_remove_entry(hass, entry) + + assert registry.async_get_issue(DOMAIN, _issue_id(entry)) is None diff --git a/tests/test_schema_validation.py b/tests/test_schema_validation.py index b59f3dd9..cba9cd63 100644 --- a/tests/test_schema_validation.py +++ b/tests/test_schema_validation.py @@ -281,3 +281,25 @@ def test_readers_of_the_same_field_path_agree_on_unit() -> None: for path, descriptions in colliding.items(): units = {d.native_unit_of_measurement for d in descriptions} assert len(units) == 1, f"readers of {path} disagree on unit: {units}" + + +def test_known_bad_schema_unit_exception_is_keyed_on_the_field_path() -> None: + """The other half of the pair: only `circuit.instant_power_w` is excused. + + `test_known_bad_schema_unit_exception_is_narrow` pins the unit half — a + different unit on the same path is still reported. Without this, widening + the check to a unit-only membership test ("is kW ever known-bad?") would + pass the whole suite while silently excusing every field that declares kW. + """ + from homeassistant.components.sensor import SensorEntityDescription + from homeassistant.const import UnitOfPower + + description = SensorEntityDescription( + key="grid_power", native_unit_of_measurement=UnitOfPower.WATT + ) + findings = evaluate_field_metadata( + {"panel.instant_grid_power_w": FieldMetadata("kW", "float")}, + sensor_defs={"panel.instant_grid_power_w": description}, + ) + assert [m.field_path for m in findings.unit_mismatches] == ["panel.instant_grid_power_w"] + assert findings.unit_mismatches[0].schema_unit == "kW" From a495c676044d6466110e4cbeca5ad518958e4168 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:04:08 -0700 Subject: [PATCH 031/345] fix: record affected entities from the entities themselves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Repairs named the entities a dead field takes down by re-deriving unique_ids from entity descriptions through get_user_friendly_suffix. Only circuit sensors build ids that way. Panel-data sensors use get_panel_entity_suffix, whose mapping deliberately disagrees for six keys (instantGridPowerW is current_power, not grid_power), and binary sensors use the raw camelCase key, which a lowercasing helper can never match — eight declared fields resolved to nothing, so the notice read "0 entity/entities are affected" while the sensor was dead. endswith("power") also claimed every power entity on the panel. Entities now record field_path -> entity_id on the coordinator as they are added to hass and withdraw it as they leave. Both halves come from the entity, so no unique_id builder is consulted and nothing can drift from them. Reconciliation moves out of the first-refresh path into async_sync_schema_repairs, called after the platforms are forwarded: setup awaits the first refresh well before any entity exists, so reconciling there reported zero affected entities on every fresh install regardless of the suffix bug. --- custom_components/span_panel/__init__.py | 6 + custom_components/span_panel/coordinator.py | 76 ++++--- custom_components/span_panel/entity.py | 38 ++++ custom_components/span_panel/field_paths.py | 40 ++-- tests/test_coordinator.py | 74 ++++--- tests/test_schema_repairs.py | 217 ++++++++++++++++++++ tests/test_setup_entry.py | 50 +++++ 7 files changed, 420 insertions(+), 81 deletions(-) diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py index bad321f6..866e3842 100644 --- a/custom_components/span_panel/__init__.py +++ b/custom_components/span_panel/__init__.py @@ -253,6 +253,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + # After the platforms, not before: schema validation runs on the first + # refresh, which is awaited above, but the Repairs it raises name the + # entities an unresolved field took down — and those entities only + # register themselves once their platform has added them. + coordinator.async_sync_schema_repairs() except Exception: if coordinator is not None: await coordinator.async_shutdown() diff --git a/custom_components/span_panel/coordinator.py b/custom_components/span_panel/coordinator.py index 03ec29cb..71e91374 100644 --- a/custom_components/span_panel/coordinator.py +++ b/custom_components/span_panel/coordinator.py @@ -14,7 +14,7 @@ from .graph_horizon import GraphHorizonManager from homeassistant.components.persistent_notification import async_create -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryNotReady, @@ -27,8 +27,7 @@ from span_panel_api.exceptions import SpanPanelAuthError, SpanPanelStaleDataError from .const import DOMAIN -from .field_paths import iter_all_field_path_declarations -from .id_builder import build_circuit_unique_id, get_user_friendly_suffix +from .id_builder import build_circuit_unique_id from .schema_repairs import async_sync_schema_issues from .schema_validation import SchemaFindings, evaluate_field_metadata from .sensor_definitions import sensor_descriptions_by_field_path @@ -95,6 +94,13 @@ def __init__( self._schema_validated = False self._findings: SchemaFindings | None = None + # Which entities read which snapshot field, recorded by the entities + # themselves as they are added to hass. Authoritative rather than + # reverse-engineered: three platforms build unique_ids three different + # ways, so deriving entity ids from entity descriptions gets most of + # them wrong. See `SpanPanelEntity.async_added_to_hass`. + self._entity_ids_by_field_path: dict[str, set[str]] = {} + # Energy dip compensation — sensors append events here during updates; # drained and surfaced as a persistent notification after each cycle. self._pending_dip_events: list[tuple[str, float, float]] = [] @@ -392,38 +398,54 @@ def _run_schema_validation(self) -> None: self._findings = evaluate_field_metadata( field_metadata, sensor_descriptions_by_field_path() ) - async_sync_schema_issues( - self.hass, - self.config_entry, - self._findings, - self._affected_entity_ids(self._findings.unresolved), - ) - def _affected_entity_ids(self, field_paths: frozenset[str]) -> dict[str, list[str]]: - """Entity ids this entry owns that read each of `field_paths`. + @callback + def async_register_field_path_entity(self, field_path: str, entity_id: str) -> None: + """Record that `entity_id` reads `field_path`. - Matched through `get_user_friendly_suffix(description.key)`, not the - snapshot field name: a unique_id ends in the suffix ("_power"), never in - the field ("instant_power_w"), so matching on the field would silently - find nothing and report every dead field as affecting zero entities. + Called by the entity itself, which is the only thing that knows both + halves for certain. Circuit, panel-data and binary-sensor entities each + build their unique_id from a different suffix rule, so a mapping derived + from entity descriptions would silently miss most of them. """ - entity_registry = er.async_get(self.hass) - entries = er.async_entries_for_config_entry(entity_registry, self.config_entry.entry_id) + self._entity_ids_by_field_path.setdefault(field_path, set()).add(entity_id) - suffixes_by_path: dict[str, set[str]] = {path: set() for path in field_paths} - for field_path, description in iter_all_field_path_declarations(): - if field_path in suffixes_by_path: - suffixes_by_path[field_path].add(get_user_friendly_suffix(description.key)) + @callback + def async_unregister_field_path_entity(self, field_path: str, entity_id: str) -> None: + """Forget an entity that is leaving hass, so it stops inflating counts.""" + entity_ids = self._entity_ids_by_field_path.get(field_path) + if entity_ids is None: + return + entity_ids.discard(entity_id) + if not entity_ids: + del self._entity_ids_by_field_path[field_path] + @property + def entity_ids_by_field_path(self) -> dict[str, list[str]]: + """Entities currently in hass, by the snapshot field each one reads.""" return { - path: [ - entry.entity_id - for entry in entries - if any(entry.unique_id.endswith(suffix) for suffix in suffixes) - ] - for path, suffixes in suffixes_by_path.items() + field_path: sorted(entity_ids) + for field_path, entity_ids in self._entity_ids_by_field_path.items() } + @callback + def async_sync_schema_repairs(self) -> None: + """Reconcile Repairs against the last validation pass. + + Deliberately separate from `_run_schema_validation`: that runs on the + first refresh, which `async_setup_entry` awaits *before* forwarding the + platforms, so no entity exists yet and every Repair would report zero + affected entities. Called once the platforms are up instead. + + Findings of None means "not yet known", never "healthy" — reconciling + against that would delete every issue and every dismissal with it. + """ + if self._findings is None: + return + async_sync_schema_issues( + self.hass, self.config_entry, self._findings, self.entity_ids_by_field_path + ) + @property def unresolved_paths(self) -> frozenset[str]: """Field paths the adapter could not resolve. Empty when healthy.""" diff --git a/custom_components/span_panel/entity.py b/custom_components/span_panel/entity.py index afc2590f..f3d0a2ab 100644 --- a/custom_components/span_panel/entity.py +++ b/custom_components/span_panel/entity.py @@ -9,6 +9,7 @@ from .const import CONF_DEVICE_NAME from .coordinator import SpanPanelCoordinator +from .field_paths import FieldPathDeclarationMixin from .util import snapshot_to_device_info @@ -17,6 +18,43 @@ class SpanPanelEntity(CoordinatorEntity[SpanPanelCoordinator]): _attr_has_entity_name = True + async def async_added_to_hass(self) -> None: + """Tell the coordinator which snapshot field this entity reads. + + The entity is the only place both halves are known for certain: it holds + its own `field_path` declaration and its own `entity_id`. Reconstructing + the pair from entity descriptions instead would mean reimplementing three + different unique_id builders — circuit sensors, panel-data sensors and + binary sensors each use a different suffix rule — and would rot the next + time one of them changed. + + Consumed by the schema Repairs, which name the entities a field the panel + stopped producing has taken down with it. + """ + await super().async_added_to_hass() + field_path = self._declared_field_path() + if field_path is not None: + self.coordinator.async_register_field_path_entity(field_path, self.entity_id) + + async def async_will_remove_from_hass(self) -> None: + """Stop counting this entity against its field.""" + field_path = self._declared_field_path() + if field_path is not None: + self.coordinator.async_unregister_field_path_entity(field_path, self.entity_id) + await super().async_will_remove_from_hass() + + def _declared_field_path(self) -> str | None: + """Return the snapshot field this entity reads, if its description declares one. + + Platforms that carry no entity description (the circuit switch) or whose + description declares nothing (`derived` entities, which read several + fields or none) return None and are simply not tracked. + """ + description: object = getattr(self, "entity_description", None) + if not isinstance(description, FieldPathDeclarationMixin) or description.derived: + return None + return description.field_path + @staticmethod def _build_device_info( coordinator: SpanPanelCoordinator, diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py index 496cef42..7031abdf 100644 --- a/custom_components/span_panel/field_paths.py +++ b/custom_components/span_panel/field_paths.py @@ -173,13 +173,12 @@ def iter_field_path_declarations[DescriptionT: EntityDescription]( yield description.field_path, description -def iter_all_field_path_declarations() -> Iterator[tuple[str, EntityDescription]]: - """Yield every ``(field_path, description)`` pair across all platforms. +def declared_field_paths() -> frozenset[str]: + """Field paths the integration reads that must be producible by an adapter. - The one place that knows which platforms carry declarations. Both consumers - read it: `declared_field_paths` wants only the paths, and the Repairs call - site wants the descriptions, so it can name the entities a dead field killed. - Keeping the assembly here is what stops those two views from drifting. + Derived entities are excluded: they have no single source field, so there is + nothing for an adapter to produce. Residual readers that no adapter (or only + one) produces are excluded too, and are listed in `RESIDUAL_EXEMPT_PATHS`. """ # Deferred: the platform modules import `FieldPathDeclarationMixin` from # here, and `binary_sensor` reaches the package root for its config-entry @@ -194,24 +193,17 @@ def iter_all_field_path_declarations() -> Iterator[tuple[str, EntityDescription] all_sensor_descriptions, ) - yield from iter_field_path_declarations( - ( - *all_sensor_descriptions(), - *BINARY_SENSORS, - *EVSE_BINARY_SENSORS, - GRID_ISLANDABLE_SENSOR, - BESS_CONNECTED_SENSOR, + paths: set[str] = set(RESIDUAL_FIELD_PATHS) + paths.update( + field_path + for field_path, _ in iter_field_path_declarations( + ( + *all_sensor_descriptions(), + *BINARY_SENSORS, + *EVSE_BINARY_SENSORS, + GRID_ISLANDABLE_SENSOR, + BESS_CONNECTED_SENSOR, + ) ) ) - - -def declared_field_paths() -> frozenset[str]: - """Field paths the integration reads that must be producible by an adapter. - - Derived entities are excluded: they have no single source field, so there is - nothing for an adapter to produce. Residual readers that no adapter (or only - one) produces are excluded too, and are listed in `RESIDUAL_EXEMPT_PATHS`. - """ - paths: set[str] = set(RESIDUAL_FIELD_PATHS) - paths.update(field_path for field_path, _ in iter_all_field_path_declarations()) return frozenset(paths) diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 694a79f0..adb0636f 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -512,46 +512,57 @@ async def test_async_update_data_stale_data_error_marks_offline_and_returns_last ) -async def test_schema_validation_raises_a_repair_naming_the_dead_entities( +async def test_entities_register_themselves_against_the_field_they_read( hass: HomeAssistant, ) -> None: - """The whole point of the feature: a dead field names the sensors it killed. + """The affected-entity map is recorded by entities, never derived from them. - Entity ids are matched through `get_user_friendly_suffix(description.key)`. - A unique_id ends in the suffix ("_power"), never in the snapshot field name - ("instant_power_w"), so matching on the field would report every dead field - as affecting zero entities while still looking like it worked. + Three unique_id builders are in play and they disagree, so reconstructing + entity ids from entity descriptions reported "0 affected" for most fields. + tests/test_schema_repairs.py drives real entities through a real platform; + this pins the coordinator side of the contract. """ - from homeassistant.helpers import ( - entity_registry as er, - issue_registry as ir, - ) + coordinator = _create_coordinator(hass) + + coordinator.async_register_field_path_entity("panel.door_state", "binary_sensor.door") + coordinator.async_register_field_path_entity("circuit.instant_power_w", "sensor.b") + coordinator.async_register_field_path_entity("circuit.instant_power_w", "sensor.a") + coordinator.async_register_field_path_entity("circuit.instant_power_w", "sensor.a") + + assert coordinator.entity_ids_by_field_path == { + "panel.door_state": ["binary_sensor.door"], + "circuit.instant_power_w": ["sensor.a", "sensor.b"], + } + + coordinator.async_unregister_field_path_entity("circuit.instant_power_w", "sensor.a") + coordinator.async_unregister_field_path_entity("panel.door_state", "binary_sensor.door") + # Unknown pairs are ignored rather than raising: removal can outlive setup. + coordinator.async_unregister_field_path_entity("panel.door_state", "binary_sensor.gone") + + assert coordinator.entity_ids_by_field_path == { + "circuit.instant_power_w": ["sensor.b"] + } + + +async def test_sync_schema_repairs_raises_a_repair_naming_the_dead_entities( + hass: HomeAssistant, +) -> None: + """The whole point of the feature: a dead field names the sensors it killed.""" + from homeassistant.helpers import issue_registry as ir entry = MockConfigEntry(domain="span_panel", entry_id="entry-affected") entry.add_to_hass(hass) - entity_registry = er.async_get(hass) - entity_registry.async_get_or_create( - "sensor", - "span_panel", - "span_sp3-001_0dad2f16cd514812ae1807b0457d473e_power", - suggested_object_id="span_panel_kitchen_power", - config_entry=entry, - ) - entity_registry.async_get_or_create( - "sensor", - "span_panel", - "span_sp3-001_0dad2f16cd514812ae1807b0457d473e_energy_produced", - suggested_object_id="span_panel_kitchen_energy_produced", - config_entry=entry, - ) - client = MagicMock(spec=SpanPanelClientProtocol) client.field_metadata = { "circuit.instant_power_w": FieldMetadata(None, "unknown", resolved=False) } coordinator = SpanPanelCoordinator(hass, cast(SpanMqttClient, client), entry) + coordinator.async_register_field_path_entity( + "circuit.instant_power_w", "sensor.span_panel_kitchen_power" + ) coordinator._run_schema_validation() + coordinator.async_sync_schema_repairs() issue = ir.async_get(hass).async_get_issue( "span_panel", "unresolved_entry-affected_circuit.instant_power_w" @@ -561,13 +572,14 @@ async def test_schema_validation_raises_a_repair_naming_the_dead_entities( assert issue.translation_placeholders["examples"] == "sensor.span_panel_kitchen_power" -async def test_schema_validation_without_metadata_raises_no_repair( +async def test_sync_schema_repairs_is_a_no_op_while_findings_are_unknown( hass: HomeAssistant, ) -> None: - """The "unknown" pass must not reconcile at all. + """"Unknown" must not reconcile at all. - Reconciling against empty findings during the retained-message window would - delete every schema issue, and with it every dismissal the user has made. + `field_metadata` is None for the whole retained-message window, which an + ordinary reconnect opens. Reconciling against no findings would delete every + schema issue, and with it every dismissal the user has made. """ from homeassistant.helpers import issue_registry as ir @@ -589,7 +601,9 @@ async def test_schema_validation_without_metadata_raises_no_repair( coordinator = SpanPanelCoordinator(hass, cast(SpanMqttClient, client), entry) coordinator._run_schema_validation() + coordinator.async_sync_schema_repairs() + assert coordinator.schema_findings is None assert ir.async_get(hass).async_get_issue( "span_panel", "unresolved_entry-unknown_circuit.instant_power_w" ) diff --git a/tests/test_schema_repairs.py b/tests/test_schema_repairs.py index b6f1108e..0fa915d1 100644 --- a/tests/test_schema_repairs.py +++ b/tests/test_schema_repairs.py @@ -295,3 +295,220 @@ async def test_remove_entry_clears_this_entry_issues(hass, entry) -> None: await async_remove_entry(hass, entry) assert registry.async_get_issue(DOMAIN, _issue_id(entry)) is None + + +# --- The affected-entity map, built from real entities --------------------- +# +# The map is populated by the entities themselves rather than reverse-engineered +# from entity descriptions. Reverse-engineering was wrong three ways at once: +# panel-data sensors build their unique_id from `get_panel_entity_suffix`, whose +# `PANEL_ENTITY_SUFFIX_MAPPING` deliberately disagrees with the general mapping +# (`instantGridPowerW` -> "current_power", not "grid_power"); binary sensors use +# the raw camelCase key ("doorState"), which a lowercasing suffix helper can +# never match; and an `endswith("power")` test claims every power entity on the +# panel. Self-registration cannot drift from the builders because it never +# consults them. + +_STYLE_PATHS = { + # circuit style — `get_user_friendly_suffix`, and the over-match case + "circuit.instant_power_w", + # panel-data style — `get_panel_entity_suffix`, which disagrees + "panel.instant_grid_power_w", + "panel.power_flow_battery", + "panel.power_flow_pv", + "panel.power_flow_site", + # binary-sensor style — the raw camelCase description key + "panel.door_state", +} + + +async def _entities_by_declared_path(hass): + """Build the real entities for a healthy panel, grouped by declared field. + + Real platform setup, real entity classes, real unique_id builders — the + three id styles only differ because the builders differ, so anything less + faithful would not exercise the bug this replaced. + """ + from unittest.mock import AsyncMock, MagicMock + + from custom_components.span_panel import SpanPanelRuntimeData, ensure_device_registered + from custom_components.span_panel.binary_sensor import ( + async_setup_entry as binary_setup, + ) + from custom_components.span_panel.coordinator import SpanPanelCoordinator + from custom_components.span_panel.field_paths import FieldPathDeclarationMixin + from custom_components.span_panel.sensor import async_setup_entry as sensor_setup + + from .factories import ( + SpanBatterySnapshotFactory, + SpanCircuitSnapshotFactory, + SpanPanelSnapshotFactory, + ) + + # Two circuits and the three power flows, so the over-match case has real + # panel power sensors to be wrongly claimed by. + snapshot = SpanPanelSnapshotFactory.create( + circuits={ + "1": SpanCircuitSnapshotFactory.create(circuit_id="1", name="Kitchen"), + "2": SpanCircuitSnapshotFactory.create(circuit_id="2", name="Garage"), + }, + battery=SpanBatterySnapshotFactory.create(soe_percentage=85.0, connected=True), + power_flow_battery=-250.0, + power_flow_pv=1250.0, + power_flow_site=3000.0, + ) + config_entry = MockConfigEntry( + domain=DOMAIN, data={}, title="SPAN Panel", unique_id=snapshot.serial_number + ) + config_entry.add_to_hass(hass) + client = MagicMock() + client.stop_streaming = AsyncMock() + client.close = AsyncMock() + coordinator = SpanPanelCoordinator(hass, client, config_entry) + coordinator.data = snapshot + # A real panel device: the BESS sub-device declares `via_device`, and HA + # refuses to add an entity whose via_device is not a registered device id. + config_entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id=await ensure_device_registered( + hass, config_entry, snapshot, "SPAN Panel" + ), + ) + + grouped: dict[str, dict[str, list[object]]] = {} + for platform_domain, setup in (("sensor", sensor_setup), ("binary_sensor", binary_setup)): + added = MagicMock() + await setup(hass, config_entry, added) + for entity in added.call_args.args[0]: + description = getattr(entity, "entity_description", None) + if not isinstance(description, FieldPathDeclarationMixin): + continue + if description.derived or description.field_path not in _STYLE_PATHS: + continue + grouped.setdefault(description.field_path, {}).setdefault( + platform_domain, [] + ).append(entity) + + return coordinator, config_entry, grouped + + +async def _add_to_platform(hass, config_entry, entities, platform_domain: str) -> None: + """Add real entities to a real entity platform, as HA does at setup.""" + from pytest_homeassistant_custom_component.common import MockEntityPlatform + + platform = MockEntityPlatform(hass, domain=platform_domain, platform_name=DOMAIN) + platform.config_entry = config_entry + await platform.async_add_entities(entities) + await hass.async_block_till_done() + + +async def _stop_scheduling(coordinator) -> None: + """Cancel the coordinator's refresh timer and debouncer. + + `SpanPanelCoordinator.async_shutdown` releases the client but does not chain + to the base implementation; in production the timer is unscheduled when the + last entity listener goes away, which these tests deliberately do not do. + """ + from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + + await DataUpdateCoordinator.async_shutdown(coordinator) + + +async def test_affected_entities_span_all_three_unique_id_styles(hass) -> None: + """A dead field must name the entities that actually died — every style. + + Three unique_id builders are in play and they do not agree, so any scheme + that re-derives entity ids from entity descriptions gets at least two of the + three wrong: it reports "0 affected" for a panel field whose sensor is dead, + and over-claims for a circuit field. + """ + coordinator, config_entry, grouped = await _entities_by_declared_path(hass) + try: + assert _STYLE_PATHS <= grouped.keys(), ( + f"fixture missed {_STYLE_PATHS - grouped.keys()}" + ) + + # The fixture really does cover three different builders: circuit suffix, + # panel entity suffix, raw camelCase key. + def _first(path: str, platform_domain: str): + return grouped[path][platform_domain][0] + + assert _first("circuit.instant_power_w", "sensor").unique_id.endswith("_power") + assert _first("panel.instant_grid_power_w", "sensor").unique_id.endswith( + "_current_power" + ) + assert _first("panel.door_state", "binary_sensor").unique_id.endswith("doorState") + + # One platform per domain, as HA does — several platforms sharing a + # domain and platform name is not a shape the real integration produces. + for platform_domain in ("sensor", "binary_sensor"): + batch = [ + entity + for by_domain in grouped.values() + for entity in by_domain.get(platform_domain, []) + ] + await _add_to_platform(hass, config_entry, batch, platform_domain) + + affected = coordinator.entity_ids_by_field_path + + for path, by_domain in grouped.items(): + expected = sorted( + entity.entity_id for entities in by_domain.values() for entity in entities + ) + assert affected[path] == expected, path + assert all(expected), f"{path} recorded an entity with no entity_id" + + # The over-match case: a dead circuit power field must claim only circuit + # power entities, never the panel's own power sensors. + circuit_power = set(affected["circuit.instant_power_w"]) + assert circuit_power + for other in _STYLE_PATHS - {"circuit.instant_power_w"}: + assert affected[other] + assert set(affected[other]).isdisjoint(circuit_power), ( + f"{other} entities were claimed by circuit.instant_power_w" + ) + finally: + await _stop_scheduling(coordinator) + + +async def test_the_repair_payload_names_the_real_entities(hass) -> None: + """End to end: the notice a user reads carries real, resolvable entity ids.""" + coordinator, config_entry, grouped = await _entities_by_declared_path(hass) + try: + entities = grouped["panel.instant_grid_power_w"]["sensor"] + await _add_to_platform(hass, config_entry, entities, "sensor") + + async_sync_schema_issues( + hass, + config_entry, + SchemaFindings(frozenset({"panel.instant_grid_power_w"}), (), frozenset()), + coordinator.entity_ids_by_field_path, + ) + + issue = ir.async_get(hass).async_get_issue( + DOMAIN, f"unresolved_{config_entry.entry_id}_panel.instant_grid_power_w" + ) + assert issue is not None + placeholders = issue.translation_placeholders + assert placeholders["count"] == str(len(entities)) + assert placeholders["examples"] == entities[0].entity_id + assert hass.states.get(entities[0].entity_id) is not None + finally: + await _stop_scheduling(coordinator) + + +async def test_removing_an_entity_drops_it_from_the_map(hass) -> None: + """A removed entity must stop inflating the count.""" + coordinator, config_entry, grouped = await _entities_by_declared_path(hass) + try: + entities = grouped["panel.instant_grid_power_w"]["sensor"] + await _add_to_platform(hass, config_entry, entities, "sensor") + assert coordinator.entity_ids_by_field_path["panel.instant_grid_power_w"] + + for entity in entities: + await entity.async_remove() + await hass.async_block_till_done() + + assert "panel.instant_grid_power_w" not in coordinator.entity_ids_by_field_path + finally: + await _stop_scheduling(coordinator) diff --git a/tests/test_setup_entry.py b/tests/test_setup_entry.py index af04a161..04842c66 100644 --- a/tests/test_setup_entry.py +++ b/tests/test_setup_entry.py @@ -288,3 +288,53 @@ async def test_async_setup_entry_shutdowns_coordinator_on_forward_failure( await async_setup_entry(hass, entry) coordinator.async_shutdown.assert_awaited_once() + + +async def test_setup_syncs_schema_repairs_after_the_platforms( + hass: HomeAssistant, +) -> None: + """Repairs must be reconciled after the platforms, never before. + + A schema Repair names the entities an unresolved field took down, and those + entities record themselves only once their platform has added them. Schema + validation itself runs on the first refresh, which setup awaits well before + forwarding the platforms — reconciling there would report every dead field + as affecting zero entities. + """ + entry = _create_v2_entry() + entry.add_to_hass(hass) + snapshot = SpanPanelSnapshotFactory.create(serial_number="sp3-setup-001") + client = MagicMock() + client.connect = AsyncMock() + coordinator = MagicMock() + coordinator.async_config_entry_first_refresh = AsyncMock() + coordinator.async_setup_streaming = AsyncMock() + coordinator.data = snapshot + + order: list[str] = [] + coordinator.async_sync_schema_repairs = MagicMock( + side_effect=lambda: order.append("sync") + ) + + async def _forward(*_args, **_kwargs) -> None: + order.append("forward") + + with ( + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + patch( + "custom_components.span_panel.SpanPanelCoordinator", + return_value=coordinator, + ), + patch( + "custom_components.span_panel.ensure_device_registered", + AsyncMock(return_value="panel-device-id"), + ), + patch.object( + hass.config_entries, "async_forward_entry_setups", AsyncMock(side_effect=_forward) + ), + patch.object(hass.config_entries, "async_update_entry"), + ): + assert await async_setup_entry(hass, entry) is True + + assert order == ["forward", "sync"] From 740435242392ae94523578e57ba1f4aef3ad3a3d Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:05:05 -0700 Subject: [PATCH 032/345] test: pin that undeclared and derived entities are not tracked A derived entity reads several fields or none, and the circuit switch carries no entity description at all; attributing either to a field would blame it for a loss that may not have taken it down. --- tests/test_schema_repairs.py | 44 ++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/test_schema_repairs.py b/tests/test_schema_repairs.py index 0fa915d1..4e525377 100644 --- a/tests/test_schema_repairs.py +++ b/tests/test_schema_repairs.py @@ -512,3 +512,47 @@ async def test_removing_an_entity_drops_it_from_the_map(hass) -> None: assert "panel.instant_grid_power_w" not in coordinator.entity_ids_by_field_path finally: await _stop_scheduling(coordinator) + + +async def test_entities_without_a_declaration_are_not_tracked(hass) -> None: + """A derived entity must not be blamed for a field it only partly reads. + + Derived entities compute from several fields or none, and the circuit switch + carries no entity description at all. Tracking either would attribute an + entity to a field whose loss may not have taken it down. + """ + coordinator, config_entry, _ = await _entities_by_declared_path(hass) + try: + from unittest.mock import MagicMock + + from custom_components.span_panel.binary_sensor import ( + BESS_CONNECTED_SENSOR, + async_setup_entry as binary_setup, + ) + from custom_components.span_panel.switch import ( + async_setup_entry as switch_setup, + ) + + assert BESS_CONNECTED_SENSOR.derived, "fixture assumes a derived description" + + added = MagicMock() + await binary_setup(hass, config_entry, added) + derived = [ + entity + for entity in added.call_args.args[0] + if getattr(entity, "entity_description", None) is BESS_CONNECTED_SENSOR + ] + assert derived + + added = MagicMock() + await switch_setup(hass, config_entry, added) + switches = list(added.call_args.args[0]) + assert switches + assert not hasattr(switches[0], "entity_description") + + await _add_to_platform(hass, config_entry, derived, "binary_sensor") + await _add_to_platform(hass, config_entry, switches, "switch") + + assert coordinator.entity_ids_by_field_path == {} + finally: + await _stop_scheduling(coordinator) From 30738a486bdda487c968fa657884970bd497b077 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:28:59 -0700 Subject: [PATCH 033/345] fix: retry schema validation while the answer is unknown, and name residual readers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects, both surfacing as a Repair that tells the user nothing. The once-only guard was set before validation ran, so a first pass landing in the metadata-not-ready window — which an ordinary reconnect opens — spent the guard on a pass that answered nothing and left findings at None for the life of the entry. The guard now sets only once findings exist, and a pass that first succeeds after setup reconciles itself rather than waiting for a reload. Five field paths are read from entity code rather than a description, so nothing registered them and a dead circuit.relay_state reported "0 affected" while every breaker switch was out. The switch, select and circuit sensors now declare those reads; a test pins the declarations against RESIDUAL_FIELD_PATHS in both directions. The unresolved-field notice no longer promises the entities will go unavailable: losing a circuit's name or tabs degrades naming and attributes without taking the entity down. --- custom_components/span_panel/coordinator.py | 42 ++++- custom_components/span_panel/entity.py | 52 ++++-- custom_components/span_panel/select.py | 12 +- .../span_panel/sensor_circuit.py | 16 +- custom_components/span_panel/strings.json | 2 +- custom_components/span_panel/switch.py | 11 +- .../span_panel/translations/en.json | 2 +- .../span_panel/translations/es.json | 2 +- .../span_panel/translations/fr.json | 2 +- .../span_panel/translations/ja.json | 2 +- .../span_panel/translations/pt.json | 2 +- tests/test_coordinator.py | 87 +++++++++- tests/test_schema_repairs.py | 155 ++++++++++++++++-- 13 files changed, 336 insertions(+), 51 deletions(-) diff --git a/custom_components/span_panel/coordinator.py b/custom_components/span_panel/coordinator.py index 71e91374..8542b49a 100644 --- a/custom_components/span_panel/coordinator.py +++ b/custom_components/span_panel/coordinator.py @@ -90,9 +90,14 @@ def __init__( # and trigger a reload so the factory creates the appropriate sensors. self._known_capabilities: frozenset[str] | None = None - # Schema validation — run once after first successful refresh + # Schema validation — runs once SUCCESSFULLY; a pass that finds no + # metadata yet leaves the flag unset so a later one can still answer. self._schema_validated = False self._findings: SchemaFindings | None = None + # True once `async_setup_entry` has forwarded the platforms and asked for + # the first reconcile. Before that there are no entities for a finding to + # name; after it, a late first success must reconcile itself. + self._platforms_ready = False # Which entities read which snapshot field, recorded by the entities # themselves as they are added to hass. Authoritative rather than @@ -398,6 +403,15 @@ def _run_schema_validation(self) -> None: self._findings = evaluate_field_metadata( field_metadata, sensor_descriptions_by_field_path() ) + # Only now: metadata is static within a session, so one success is + # enough and re-reading identical inputs on every pass would be waste. + self._schema_validated = True + + if self._platforms_ready: + # A late first success. Setup already passed its reconcile point, so + # nothing else will raise these — do it here, where the entities that + # the findings name are guaranteed to exist. + self._sync_repairs() @callback def async_register_field_path_entity(self, field_path: str, entity_id: str) -> None: @@ -430,12 +444,21 @@ def entity_ids_by_field_path(self) -> dict[str, list[str]]: @callback def async_sync_schema_repairs(self) -> None: - """Reconcile Repairs against the last validation pass. + """Reconcile Repairs now that the platforms are up. + + Called by `async_setup_entry` after the platforms are forwarded, which is + the earliest point the entities a finding names exist: validation runs on + the first refresh, and setup awaits that *before* forwarding anything. + + Also records that the reconcile point has passed, so a validation pass + that first succeeds later reconciles itself rather than waiting for the + next reload. + """ + self._platforms_ready = True + self._sync_repairs() - Deliberately separate from `_run_schema_validation`: that runs on the - first refresh, which `async_setup_entry` awaits *before* forwarding the - platforms, so no entity exists yet and every Repair would report zero - affected entities. Called once the platforms are up instead. + def _sync_repairs(self) -> None: + """Reconcile Repairs against the findings, if there are any yet. Findings of None means "not yet known", never "healthy" — reconciling against that would delete every issue and every dismissal with it. @@ -612,9 +635,12 @@ async def _run_post_update_tasks(self, snapshot: SpanPanelSnapshot) -> None: streaming the polling path effectively never fires. This shared method ensures reload requests are processed regardless of transport mode. """ - # One-shot schema validation after first successful refresh + # Schema validation: at most once SUCCESSFULLY, retried only while the + # answer is still unknown. The guard is set inside `_run_schema_validation` + # for that reason — setting it here disabled the feature for the life of + # the entry whenever the very first pass landed in the metadata-not-ready + # window, which an ordinary reconnect opens. if not self._schema_validated: - self._schema_validated = True self._run_schema_validation() # Check for pending solar entity migration (v1 solar → v2 PV circuit) diff --git a/custom_components/span_panel/entity.py b/custom_components/span_panel/entity.py index f3d0a2ab..e4fcca26 100644 --- a/custom_components/span_panel/entity.py +++ b/custom_components/span_panel/entity.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import ClassVar + from homeassistant.const import CONF_HOST from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -18,12 +20,26 @@ class SpanPanelEntity(CoordinatorEntity[SpanPanelCoordinator]): _attr_has_entity_name = True + _residual_field_paths: ClassVar[tuple[str, ...]] = () + """Snapshot fields this entity reads from entity code, not from a description. + + A handful of reads cannot be expressed as a description `field_path`: the + switch has no entity description at all, the select wraps one, and a circuit + entity's name, tabs and attributes are read outside any `value_fn`. + `field_paths.RESIDUAL_FIELD_PATHS` already names that exact set for the + producible gate; declaring the same paths here is what lets a Repair say + which entities a dead one takes with it, instead of "0 affected". + + `test_every_residual_field_path_is_claimed_by_an_entity` pins the two lists + to each other in both directions. + """ + async def async_added_to_hass(self) -> None: - """Tell the coordinator which snapshot field this entity reads. + """Tell the coordinator which snapshot fields this entity reads. The entity is the only place both halves are known for certain: it holds - its own `field_path` declaration and its own `entity_id`. Reconstructing - the pair from entity descriptions instead would mean reimplementing three + its own declarations and its own `entity_id`. Reconstructing the pair + from entity descriptions instead would mean reimplementing three different unique_id builders — circuit sensors, panel-data sensors and binary sensors each use a different suffix rule — and would rot the next time one of them changed. @@ -32,28 +48,32 @@ async def async_added_to_hass(self) -> None: stopped producing has taken down with it. """ await super().async_added_to_hass() - field_path = self._declared_field_path() - if field_path is not None: + for field_path in self._declared_field_paths(): self.coordinator.async_register_field_path_entity(field_path, self.entity_id) async def async_will_remove_from_hass(self) -> None: - """Stop counting this entity against its field.""" - field_path = self._declared_field_path() - if field_path is not None: + """Stop counting this entity against its fields.""" + for field_path in self._declared_field_paths(): self.coordinator.async_unregister_field_path_entity(field_path, self.entity_id) await super().async_will_remove_from_hass() - def _declared_field_path(self) -> str | None: - """Return the snapshot field this entity reads, if its description declares one. + def _declared_field_paths(self) -> tuple[str, ...]: + """Return every snapshot field this entity reads. - Platforms that carry no entity description (the circuit switch) or whose - description declares nothing (`derived` entities, which read several - fields or none) return None and are simply not tracked. + The description's `field_path` when it declares one, plus any residual + reads. A description that declares nothing (`derived` entities, which + read several fields or none) contributes nothing, and a platform with no + entity description at all — the circuit switch — contributes only its + residual reads. """ description: object = getattr(self, "entity_description", None) - if not isinstance(description, FieldPathDeclarationMixin) or description.derived: - return None - return description.field_path + if ( + isinstance(description, FieldPathDeclarationMixin) + and not description.derived + and description.field_path is not None + ): + return (description.field_path, *self._residual_field_paths) + return self._residual_field_paths @staticmethod def _build_device_info( diff --git a/custom_components/span_panel/select.py b/custom_components/span_panel/select.py index 30f54042..22993eac 100644 --- a/custom_components/span_panel/select.py +++ b/custom_components/span_panel/select.py @@ -2,7 +2,7 @@ from collections.abc import Callable, Mapping import logging -from typing import Any, Final +from typing import Any, ClassVar, Final from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant @@ -84,6 +84,16 @@ def __init__( class SpanPanelCircuitsSelect(SpanPanelEntity, SelectEntity): """Represent a select entity for Span Panel circuits.""" + # Read in entity code rather than through a description: the wrapper + # class is not a frozen dataclass description, so it cannot carry the + # declaration. `priority` is the selected option (~80); `name` and + # `tabs` build the display name (~125-148). + _residual_field_paths: ClassVar[tuple[str, ...]] = ( + "circuit.priority", + "circuit.name", + "circuit.tabs", + ) + def __init__( self, coordinator: SpanPanelCoordinator, diff --git a/custom_components/span_panel/sensor_circuit.py b/custom_components/span_panel/sensor_circuit.py index af0a0d68..b519aa90 100644 --- a/custom_components/span_panel/sensor_circuit.py +++ b/custom_components/span_panel/sensor_circuit.py @@ -4,7 +4,7 @@ from collections.abc import Mapping import logging -from typing import Any +from typing import Any, ClassVar from homeassistant.helpers.device_registry import DeviceInfo from span_panel_api import SpanCircuitSnapshot, SpanPanelSnapshot @@ -91,6 +91,17 @@ class SpanCircuitPowerSensor( ): """Circuit power/current/breaker-rating sensor with extra state attributes.""" + # Beyond the value the description declares: `name` and `tabs` build the + # entity's identity (~56-83), and `tabs`, `relay_state`, `relay_requester` + # and `priority` are republished as state attributes (~231-243). + _residual_field_paths: ClassVar[tuple[str, ...]] = ( + "circuit.name", + "circuit.tabs", + "circuit.relay_state", + "circuit.relay_requester", + "circuit.priority", + ) + def __init__( self, data_coordinator: SpanPanelCoordinator, @@ -250,6 +261,9 @@ class SpanCircuitEnergySensor( ): """Circuit energy sensor with grace period tracking.""" + # Naming only; this sensor publishes no circuit attributes. + _residual_field_paths: ClassVar[tuple[str, ...]] = ("circuit.name", "circuit.tabs") + def __init__( self, data_coordinator: SpanPanelCoordinator, diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index e3e7a1d0..f3522630 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -714,7 +714,7 @@ }, "schema_field_unresolved": { "title": "SPAN Panel data field unavailable", - "description": "Your panel's firmware no longer provides `{field_path}`. {count} entity/entities are affected (for example: {examples}). They will show as unavailable until an integration update adds support for the new firmware." + "description": "Your panel's firmware no longer provides `{field_path}`. {count} entity/entities are affected (for example: {examples}). They may become unavailable, or report incomplete data, until an integration update adds support for the new firmware." }, "schema_unit_mismatch": { "title": "SPAN Panel unit mismatch", diff --git a/custom_components/span_panel/switch.py b/custom_components/span_panel/switch.py index 88427934..7a368e1b 100644 --- a/custom_components/span_panel/switch.py +++ b/custom_components/span_panel/switch.py @@ -2,7 +2,7 @@ from collections.abc import Mapping import logging -from typing import Any +from typing import Any, ClassVar from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant @@ -43,6 +43,15 @@ def _unnamed_switch_fallback(circuit: SpanCircuitSnapshot, circuit_id: str) -> s class SpanPanelCircuitsSwitch(SpanPanelEntity, SwitchEntity): """Represent a switch entity.""" + # Read in entity code rather than through a description: this platform + # has no entity description at all. `relay_state` is the switch's own + # state (line ~262); `name` and `tabs` build its display name (~77-101). + _residual_field_paths: ClassVar[tuple[str, ...]] = ( + "circuit.relay_state", + "circuit.name", + "circuit.tabs", + ) + def __init__( self, coordinator: SpanPanelCoordinator, diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index e3e7a1d0..f3522630 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -714,7 +714,7 @@ }, "schema_field_unresolved": { "title": "SPAN Panel data field unavailable", - "description": "Your panel's firmware no longer provides `{field_path}`. {count} entity/entities are affected (for example: {examples}). They will show as unavailable until an integration update adds support for the new firmware." + "description": "Your panel's firmware no longer provides `{field_path}`. {count} entity/entities are affected (for example: {examples}). They may become unavailable, or report incomplete data, until an integration update adds support for the new firmware." }, "schema_unit_mismatch": { "title": "SPAN Panel unit mismatch", diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index 45a1b1a4..8d79397d 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -714,7 +714,7 @@ }, "schema_field_unresolved": { "title": "Campo de datos del Panel SPAN no disponible", - "description": "El firmware de su panel ya no proporciona `{field_path}`. Hay {count} entidad(es) afectada(s) (por ejemplo: {examples}). Aparecerán como no disponibles hasta que una actualización de la integración añada compatibilidad con el nuevo firmware." + "description": "El firmware de su panel ya no proporciona `{field_path}`. Hay {count} entidad(es) afectada(s) (por ejemplo: {examples}). Pueden quedar no disponibles, o informar datos incompletos, hasta que una actualización de la integración añada compatibilidad con el nuevo firmware." }, "schema_unit_mismatch": { "title": "Discrepancia de unidades del Panel SPAN", diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index 775deb0e..512fecad 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -714,7 +714,7 @@ }, "schema_field_unresolved": { "title": "Champ de données du Panneau SPAN indisponible", - "description": "Le micrologiciel de votre panneau ne fournit plus `{field_path}`. {count} entité(s) sont concernées (par exemple : {examples}). Elles resteront indisponibles jusqu'à ce qu'une mise à jour de l'intégration prenne en charge le nouveau micrologiciel." + "description": "Le micrologiciel de votre panneau ne fournit plus `{field_path}`. {count} entité(s) sont concernées (par exemple : {examples}). Elles peuvent devenir indisponibles, ou renvoyer des données incomplètes, jusqu'à ce qu'une mise à jour de l'intégration prenne en charge le nouveau micrologiciel." }, "schema_unit_mismatch": { "title": "Incohérence d'unité du Panneau SPAN", diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index 9a07cbf4..d92876aa 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -714,7 +714,7 @@ }, "schema_field_unresolved": { "title": "SPAN パネルのデータフィールドが利用できません", - "description": "パネルのファームウェアが `{field_path}` を提供しなくなりました。{count} 個のエンティティが影響を受けます (例: {examples})。新しいファームウェアに対応する統合の更新が入るまで、これらは「利用不可」と表示されます。" + "description": "パネルのファームウェアが `{field_path}` を提供しなくなりました。{count} 個のエンティティが影響を受けます (例: {examples})。新しいファームウェアに対応する統合の更新が入るまで、これらは利用不可になるか、不完全なデータを報告する可能性があります。" }, "schema_unit_mismatch": { "title": "SPAN パネルの単位の不一致", diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index 4b3dab29..90bacfeb 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -714,7 +714,7 @@ }, "schema_field_unresolved": { "title": "Campo de dados do Painel SPAN indisponível", - "description": "O firmware do seu painel já não fornece `{field_path}`. Há {count} entidade(s) afetada(s) (por exemplo: {examples}). Ficarão indisponíveis até que uma atualização da integração adicione suporte ao novo firmware." + "description": "O firmware do seu painel já não fornece `{field_path}`. Há {count} entidade(s) afetada(s) (por exemplo: {examples}). Podem ficar indisponíveis, ou comunicar dados incompletos, até que uma atualização da integração adicione suporte ao novo firmware." }, "schema_unit_mismatch": { "title": "Discrepância de unidades do Painel SPAN", diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index adb0636f..eda1c34c 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -119,8 +119,17 @@ async def test_run_post_update_tasks_validates_once_and_schedules_reload( snapshot = SpanPanelSnapshotFactory.create() coordinator._reload_requested = True + # The real `_run_schema_validation` sets the guard itself, and only once it + # has actually produced findings; the stand-in has to emulate that or the + # second pass would legitimately retry. `_a_none_first_pass...` below covers + # the retry side. + def _succeed() -> None: + coordinator._schema_validated = True + with ( - patch.object(coordinator, "_run_schema_validation") as mock_validate, + patch.object( + coordinator, "_run_schema_validation", side_effect=_succeed + ) as mock_validate, patch.object(coordinator, "_fire_dip_notification", AsyncMock()) as mock_notify, patch.object(coordinator, "_async_reload_task", AsyncMock()) as mock_reload, patch.object(hass, "async_create_task") as mock_create_task, @@ -607,3 +616,79 @@ async def test_sync_schema_repairs_is_a_no_op_while_findings_are_unknown( assert ir.async_get(hass).async_get_issue( "span_panel", "unresolved_entry-unknown_circuit.instant_power_w" ) + + +async def test_a_none_first_pass_does_not_disable_validation_for_the_session( + hass: HomeAssistant, +) -> None: + """One unlucky first pass must not silence the feature until the next reload. + + `field_metadata` is None for the whole not-ready / pre-rebuild window, which + an ordinary reconnect opens. Setting the once-only guard before validation + ran meant that a first pass landing in that window left findings at None + forever: the guard was set, later passes skipped, and the single reconcile at + setup had nothing to raise. Zero issues, for the life of the entry. + """ + from homeassistant.helpers import issue_registry as ir + + entry = MockConfigEntry(domain="span_panel", entry_id="entry-late") + entry.add_to_hass(hass) + client = MagicMock(spec=SpanPanelClientProtocol) + client.field_metadata = None + coordinator = SpanPanelCoordinator(hass, cast(SpanMqttClient, client), entry) + coordinator.async_register_field_path_entity( + "circuit.instant_power_w", "sensor.kitchen_power" + ) + snapshot = SpanPanelSnapshotFactory.create() + + with patch.object(coordinator, "_fire_dip_notification", AsyncMock()): + # The pass that happens during setup's first refresh — metadata not ready. + await coordinator._run_post_update_tasks(snapshot) + assert coordinator.schema_findings is None + assert coordinator._schema_validated is False + + # Setup finishes and reconciles; there is nothing to raise yet. + coordinator.async_sync_schema_repairs() + assert not [k for k in ir.async_get(hass).issues if k[0] == "span_panel"] + + # Metadata arrives, and the panel turns out to be degraded. + client.field_metadata = { + "circuit.instant_power_w": FieldMetadata(None, "unknown", resolved=False) + } + for _ in range(5): + await coordinator._run_post_update_tasks(snapshot) + + assert coordinator.schema_findings is not None + assert coordinator.unresolved_paths == frozenset({"circuit.instant_power_w"}) + + issue = ir.async_get(hass).async_get_issue( + "span_panel", "unresolved_entry-late_circuit.instant_power_w" + ) + assert issue is not None + assert issue.translation_placeholders["count"] == "1" + assert issue.translation_placeholders["examples"] == "sensor.kitchen_power" + + +async def test_validation_runs_at_most_once_successfully(hass: HomeAssistant) -> None: + """Metadata is static within a session; re-reading it every pass is waste. + + The retry above must not turn into revalidation. Once a pass has produced + findings, later passes leave them alone. + """ + client = MagicMock(spec=SpanPanelClientProtocol) + client.field_metadata = { + "circuit.instant_power_w": FieldMetadata(None, "unknown", resolved=False) + } + coordinator = _create_coordinator(hass, client=client) + snapshot = SpanPanelSnapshotFactory.create() + + with patch.object(coordinator, "_fire_dip_notification", AsyncMock()): + await coordinator._run_post_update_tasks(snapshot) + assert coordinator.unresolved_paths == frozenset({"circuit.instant_power_w"}) + + # A later pass sees different metadata and must not act on it. + client.field_metadata = {} + for _ in range(3): + await coordinator._run_post_update_tasks(snapshot) + + assert coordinator.unresolved_paths == frozenset({"circuit.instant_power_w"}) diff --git a/tests/test_schema_repairs.py b/tests/test_schema_repairs.py index 4e525377..269255c9 100644 --- a/tests/test_schema_repairs.py +++ b/tests/test_schema_repairs.py @@ -514,25 +514,22 @@ async def test_removing_an_entity_drops_it_from_the_map(hass) -> None: await _stop_scheduling(coordinator) -async def test_entities_without_a_declaration_are_not_tracked(hass) -> None: - """A derived entity must not be blamed for a field it only partly reads. +async def test_derived_entities_are_not_tracked(hass) -> None: + """A derived entity must not be blamed for one of the fields it combines. - Derived entities compute from several fields or none, and the circuit switch - carries no entity description at all. Tracking either would attribute an - entity to a field whose loss may not have taken it down. + Derived entities compute from several fields or none, so no single field's + loss can be said to have taken them down. They declare no `field_path` and + no residual reads, and must therefore land in no bucket at all. """ - coordinator, config_entry, _ = await _entities_by_declared_path(hass) - try: - from unittest.mock import MagicMock + from unittest.mock import MagicMock - from custom_components.span_panel.binary_sensor import ( - BESS_CONNECTED_SENSOR, - async_setup_entry as binary_setup, - ) - from custom_components.span_panel.switch import ( - async_setup_entry as switch_setup, - ) + from custom_components.span_panel.binary_sensor import ( + BESS_CONNECTED_SENSOR, + async_setup_entry as binary_setup, + ) + coordinator, config_entry, _ = await _entities_by_declared_path(hass) + try: assert BESS_CONNECTED_SENSOR.derived, "fixture assumes a derived description" added = MagicMock() @@ -544,15 +541,139 @@ async def test_entities_without_a_declaration_are_not_tracked(hass) -> None: ] assert derived + await _add_to_platform(hass, config_entry, derived, "binary_sensor") + + assert coordinator.entity_ids_by_field_path == {} + finally: + await _stop_scheduling(coordinator) + + +async def test_a_platform_with_no_description_still_registers_its_residuals( + hass, +) -> None: + """The circuit switch carries no entity description at all. + + It is tracked purely through `_residual_field_paths`, which is the whole + reason that hook exists. + """ + from unittest.mock import MagicMock + + from custom_components.span_panel.switch import async_setup_entry as switch_setup + + coordinator, config_entry, _ = await _entities_by_declared_path(hass) + try: added = MagicMock() await switch_setup(hass, config_entry, added) switches = list(added.call_args.args[0]) assert switches assert not hasattr(switches[0], "entity_description") - await _add_to_platform(hass, config_entry, derived, "binary_sensor") await _add_to_platform(hass, config_entry, switches, "switch") - assert coordinator.entity_ids_by_field_path == {} + expected = sorted(s.entity_id for s in switches) + assert coordinator.entity_ids_by_field_path == { + "circuit.relay_state": expected, + "circuit.name": expected, + "circuit.tabs": expected, + } finally: await _stop_scheduling(coordinator) + + +# --- Residual reads ------------------------------------------------------- +# +# Five field paths are read from entity code rather than from a description's +# `field_path`: the switch's relay state, the select's priority, and the name, +# tabs and relay requester a circuit entity uses for its identity and its +# attributes. `RESIDUAL_FIELD_PATHS` lists them for the producible gate. Nothing +# declared them on the entities, so a dead `circuit.relay_state` reported "0 +# entity/entities are affected" while every breaker switch on the panel was out. + + +async def test_a_dead_relay_state_names_the_breaker_switches(hass) -> None: + """The residual set must not reproduce the "0 affected" lie.""" + from unittest.mock import MagicMock + + from custom_components.span_panel.switch import async_setup_entry as switch_setup + + coordinator, config_entry, _ = await _entities_by_declared_path(hass) + try: + added = MagicMock() + await switch_setup(hass, config_entry, added) + switches = list(added.call_args.args[0]) + assert len(switches) == 2, "fixture should build one switch per circuit" + + await _add_to_platform(hass, config_entry, switches, "switch") + + affected = coordinator.entity_ids_by_field_path + assert affected["circuit.relay_state"] == sorted(s.entity_id for s in switches) + + async_sync_schema_issues( + hass, + config_entry, + SchemaFindings(frozenset({"circuit.relay_state"}), (), frozenset()), + affected, + ) + issue = ir.async_get(hass).async_get_issue( + DOMAIN, f"unresolved_{config_entry.entry_id}_circuit.relay_state" + ) + assert issue is not None + assert issue.translation_placeholders["count"] == "2" + assert issue.translation_placeholders["examples"] != "none" + finally: + await _stop_scheduling(coordinator) + + +async def test_a_dead_priority_names_the_selects(hass) -> None: + """The select's own state comes from `circuit.priority`.""" + from unittest.mock import MagicMock + + from custom_components.span_panel.select import async_setup_entry as select_setup + + coordinator, config_entry, _ = await _entities_by_declared_path(hass) + try: + added = MagicMock() + await select_setup(hass, config_entry, added) + selects = list(added.call_args.args[0]) + assert selects + + await _add_to_platform(hass, config_entry, selects, "select") + + affected = coordinator.entity_ids_by_field_path + assert affected["circuit.priority"] == sorted(s.entity_id for s in selects) + finally: + await _stop_scheduling(coordinator) + + +def test_every_residual_field_path_is_claimed_by_an_entity() -> None: + """No residual read may be left with nothing to name. + + `RESIDUAL_FIELD_PATHS` exists because these reads live in entity code rather + than on a description. That is exactly why they cannot be discovered — so + each one is declared on the entity that makes it, and this pins the two lists + together. A new residual entry with no declaring entity would otherwise ship + a Repair that says "0 affected" when the answer is "all of them". + """ + from custom_components.span_panel.field_paths import RESIDUAL_FIELD_PATHS + from custom_components.span_panel.select import SpanPanelCircuitsSelect + from custom_components.span_panel.sensor_circuit import ( + SpanCircuitEnergySensor, + SpanCircuitPowerSensor, + ) + from custom_components.span_panel.switch import SpanPanelCircuitsSwitch + + declared: set[str] = set() + for entity_class in ( + SpanPanelCircuitsSwitch, + SpanPanelCircuitsSelect, + SpanCircuitPowerSensor, + SpanCircuitEnergySensor, + ): + declared.update(entity_class._residual_field_paths) + + assert RESIDUAL_FIELD_PATHS <= declared, ( + f"undeclared residual reads: {RESIDUAL_FIELD_PATHS - declared}" + ) + assert declared <= RESIDUAL_FIELD_PATHS, ( + f"declared but not in the residual set: {declared - RESIDUAL_FIELD_PATHS}" + ) From e35bba9c24dddc5f3da8215b0198fa87f822870a Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:36:39 -0700 Subject: [PATCH 034/345] feat: mark entities unavailable when their field is unresolved An entity whose declared snapshot field the adapter cannot resolve now reports unavailable instead of publishing a parsed default. The probe lives on SpanPanelEntity so binary sensors are covered too, and runs ahead of the grace-period branches in SpanSensorBase and SpanPanelBinarySensor, which return True on their own. --- custom_components/span_panel/binary_sensor.py | 10 + custom_components/span_panel/entity.py | 40 +++ custom_components/span_panel/sensor_base.py | 6 + tests/test_schema_availability.py | 265 ++++++++++++++++++ 4 files changed, 321 insertions(+) create mode 100644 tests/test_schema_availability.py diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py index f4f80fb0..6c3f4e7d 100644 --- a/custom_components/span_panel/binary_sensor.py +++ b/custom_components/span_panel/binary_sensor.py @@ -197,7 +197,17 @@ def available(self) -> bool: - Panel status sensor: always available to show online/offline state - Hardware status sensors: remain available when offline to show Unknown state - Other binary sensors (switches): become unavailable when panel is offline + + The unresolved-field probe runs ahead of all of that, for the same + reason it precedes the grace-period branch in `SpanSensorBase`: the + offline branch below returns True, so a probe after it would leave + `door_state`, `eth0_link` and `wlan_link` reporting a field the adapter + cannot resolve. `panel_status` and the derived sensors declare no + `field_path`, so the probe never fires for them. """ + if self._reads_an_unresolved_field: + return False + # Panel status sensor should always be available to show online/offline state if hasattr(self.entity_description, "key") and self.entity_description.key == PANEL_STATUS: return True diff --git a/custom_components/span_panel/entity.py b/custom_components/span_panel/entity.py index e4fcca26..291807c3 100644 --- a/custom_components/span_panel/entity.py +++ b/custom_components/span_panel/entity.py @@ -75,6 +75,46 @@ def _declared_field_paths(self) -> tuple[str, ...]: return (description.field_path, *self._residual_field_paths) return self._residual_field_paths + @property + def _reads_an_unresolved_field(self) -> bool: + """True when this entity's own source field could not be resolved. + + An O(1) membership test against a set that is empty on a healthy panel, + so the normal case costs one hash lookup per availability read. + + Only the description's `field_path` counts -- the entity's value source. + Residual paths are deliberately excluded: `circuit.name` and + `circuit.tabs` feed naming and attributes, and a circuit's power reading + is still true when they are gone. The switch and select read their state + through residual paths and so are not covered here; the Repair still + names them. + """ + description: object = getattr(self, "entity_description", None) + if ( + isinstance(description, FieldPathDeclarationMixin) + and not description.derived + and description.field_path is not None + ): + return description.field_path in self.coordinator.unresolved_paths + return False + + @property + def available(self) -> bool: + """False when this entity's snapshot field could not be resolved. + + Any value we would report for an unresolvable field is a default rather + than a reading, and a default is indistinguishable from a real one at + the dashboard. Reporting unavailable is reporting, not correcting: the + entity keeps its shape and comes back when the field does. + + This covers the resolution-failure case only. A field the adapter + resolves but the device stops publishing still reaches HA as a parsed + default; that needs the snapshot model to admit None. + """ + if self._reads_an_unresolved_field: + return False + return super().available + @staticmethod def _build_device_info( coordinator: SpanPanelCoordinator, diff --git a/custom_components/span_panel/sensor_base.py b/custom_components/span_panel/sensor_base.py index 1d86f1d1..88dbf41f 100644 --- a/custom_components/span_panel/sensor_base.py +++ b/custom_components/span_panel/sensor_base.py @@ -321,7 +321,13 @@ def available(self) -> bool: Keep entities available during a panel_offline condition so sensors can show their grace period state (last_valid_state) or None when grace period expires. + + The unresolved-field probe runs first: the grace-period branch below + returns True unconditionally, so probing after it would let every + offline sensor keep reporting a field the adapter cannot resolve. """ + if self._reads_an_unresolved_field: + return False try: if getattr(self.coordinator, "panel_offline", False): return True diff --git a/tests/test_schema_availability.py b/tests/test_schema_availability.py new file mode 100644 index 00000000..86185678 --- /dev/null +++ b/tests/test_schema_availability.py @@ -0,0 +1,265 @@ +"""An entity whose declared field could not be resolved reports unavailable. + +The Repairs from `schema_repairs` explain and aggregate; this is the same fact +told where a user actually looks. It also mitigates the user-visible symptom of +the "absent property parses to 0.0 rather than None" defect *for the +resolution-failure case*: a field the adapter cannot resolve at all would +otherwise render as a live zero, which for a TOTAL_INCREASING energy sensor +reads as a counter reset. It does nothing for a field that resolves but stops +being published at runtime -- that needs the snapshot model to admit None. +""" + +from __future__ import annotations + +from typing import cast +from unittest.mock import MagicMock + +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from pytest_homeassistant_custom_component.common import MockConfigEntry +from span_panel_api import SpanMqttClient, SpanPanelSnapshot + +from custom_components.span_panel import ( + SpanPanelRuntimeData, + binary_sensor, + button, + select, + sensor_base, + switch, +) +from custom_components.span_panel.binary_sensor import ( + BESS_CONNECTED_SENSOR, + BINARY_SENSORS, + EVSE_BINARY_SENSORS, + SpanEvseBinarySensor, + SpanPanelBinarySensor, +) +from custom_components.span_panel.const import ( + CONF_DEVICE_NAME, + PANEL_STATUS, + SYSTEM_ETHERNET_LINK, +) +from custom_components.span_panel.coordinator import SpanPanelCoordinator +from custom_components.span_panel.entity import SpanPanelEntity +from custom_components.span_panel.schema_validation import SchemaFindings +from custom_components.span_panel.sensor_circuit import SpanCircuitPowerSensor +from custom_components.span_panel.sensor_definitions import CIRCUIT_SENSORS + +from .factories import ( + SpanCircuitSnapshotFactory, + SpanEvseSnapshotFactory, + SpanPanelSnapshotFactory, +) + +_CIRCUIT_POWER_PATH = "circuit.instant_power_w" +_ETHERNET_LINK_PATH = "panel.eth0_link" + + +def _make_coordinator(hass: HomeAssistant) -> SpanPanelCoordinator: + """Return a real coordinator, so `unresolved_paths` is the real property. + + A MagicMock would answer `_findings` with a mock and `unresolved_paths` with + a mock whose `__contains__` is False -- the probe would look correct while + never firing. + """ + snapshot = SpanPanelSnapshotFactory.create( + circuits={"c1": SpanCircuitSnapshotFactory.create(circuit_id="c1", name="Kitchen")} + ) + entry = MockConfigEntry( + domain="span_panel", + data={CONF_HOST: "192.168.1.50", CONF_DEVICE_NAME: "SPAN Panel"}, + options={}, + title="SPAN Panel", + unique_id=snapshot.serial_number, + ) + entry.add_to_hass(hass) + coordinator = SpanPanelCoordinator(hass, cast(SpanMqttClient, MagicMock()), entry) + coordinator.data = snapshot + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, panel_device_id="panel-device-id" + ) + return coordinator + + +def _snapshot(coordinator: SpanPanelCoordinator) -> SpanPanelSnapshot: + return coordinator.data + + +def _circuit_power_entity(coordinator: SpanPanelCoordinator) -> SpanCircuitPowerSensor: + """Build a sensor reading `circuit.instant_power_w` -- the `SpanSensorBase` branch.""" + description = next(desc for desc in CIRCUIT_SENSORS if desc.key == "circuit_power") + assert description.field_path == _CIRCUIT_POWER_PATH + return SpanCircuitPowerSensor(coordinator, description, _snapshot(coordinator), "c1") + + +def _ethernet_link_entity(coordinator: SpanPanelCoordinator) -> SpanPanelBinarySensor: + """Build a binary sensor reading `panel.eth0_link` -- the `SpanPanelEntity` branch. + + Binary sensors do not inherit `SpanSensorBase`, so this is the second base + class the probe has to live on. + """ + description = next(desc for desc in BINARY_SENSORS if desc.key == SYSTEM_ETHERNET_LINK) + assert description.field_path == _ETHERNET_LINK_PATH + return SpanPanelBinarySensor(coordinator, description) + + +async def test_sensor_unavailable_when_its_field_is_unresolved(hass: HomeAssistant) -> None: + """A dead field must not render as a live 0.0.""" + coordinator = _make_coordinator(hass) + coordinator._findings = SchemaFindings(frozenset({_CIRCUIT_POWER_PATH}), (), frozenset()) + + assert _circuit_power_entity(coordinator).available is False + + +async def test_sensor_available_when_findings_are_clean(hass: HomeAssistant) -> None: + coordinator = _make_coordinator(hass) + coordinator._findings = SchemaFindings(frozenset(), (), frozenset()) + + assert _circuit_power_entity(coordinator).available is True + + +async def test_sensor_available_while_findings_are_unknown(hass: HomeAssistant) -> None: + """No validation pass has completed yet; that is not a reason to go dark.""" + coordinator = _make_coordinator(hass) + assert coordinator.schema_findings is None + + assert _circuit_power_entity(coordinator).available is True + + +async def test_sensor_unavailable_when_unresolved_and_panel_offline( + hass: HomeAssistant, +) -> None: + """The probe must precede the grace-period branch. + + `SpanSensorBase.available` returns True while `panel_offline` so the sensor + can show its grace-period state. Probing after that check would let every + offline sensor report a resolved-looking value for a field that is gone. + """ + coordinator = _make_coordinator(hass) + coordinator._findings = SchemaFindings(frozenset({_CIRCUIT_POWER_PATH}), (), frozenset()) + coordinator._panel_offline = True + + assert coordinator.panel_offline is True + assert _circuit_power_entity(coordinator).available is False + + +async def test_sensor_unaffected_by_an_unrelated_unresolved_field( + hass: HomeAssistant, +) -> None: + """Only the entity's own field counts; a neighbour's failure is not its own.""" + coordinator = _make_coordinator(hass) + coordinator._findings = SchemaFindings(frozenset({_ETHERNET_LINK_PATH}), (), frozenset()) + + assert _circuit_power_entity(coordinator).available is True + + +async def test_binary_sensor_unavailable_when_its_field_is_unresolved( + hass: HomeAssistant, +) -> None: + """`SpanPanelBinarySensor` extends `SpanPanelEntity`, not `SpanSensorBase`.""" + coordinator = _make_coordinator(hass) + coordinator._findings = SchemaFindings(frozenset({_ETHERNET_LINK_PATH}), (), frozenset()) + + assert _ethernet_link_entity(coordinator).available is False + + +async def test_binary_sensor_available_when_findings_are_clean(hass: HomeAssistant) -> None: + coordinator = _make_coordinator(hass) + coordinator._findings = SchemaFindings(frozenset(), (), frozenset()) + + assert _ethernet_link_entity(coordinator).available is True + + +async def test_binary_sensor_unavailable_when_unresolved_and_panel_offline( + hass: HomeAssistant, +) -> None: + """`SpanPanelBinarySensor` has its own offline branch that returns True. + + The hardware-status sensors stay available while the panel is offline so + they can show Unknown. That branch bypasses `super().available`, so the + probe has to run ahead of it here too -- the same ordering `SpanSensorBase` + needs. + """ + coordinator = _make_coordinator(hass) + coordinator._findings = SchemaFindings(frozenset({_ETHERNET_LINK_PATH}), (), frozenset()) + coordinator._panel_offline = True + + assert _ethernet_link_entity(coordinator).available is False + + +async def test_panel_status_binary_sensor_is_never_probed(hass: HomeAssistant) -> None: + """It reports coordinator reachability, not a snapshot field.""" + coordinator = _make_coordinator(hass) + coordinator._findings = SchemaFindings(frozenset({_ETHERNET_LINK_PATH}), (), frozenset()) + description = next(desc for desc in BINARY_SENSORS if desc.key == PANEL_STATUS) + assert description.derived is True + + entity = SpanPanelBinarySensor(coordinator, description) + + assert entity.available is True + + +async def test_derived_entity_is_never_probed(hass: HomeAssistant) -> None: + """A derived entity declares no source field, so nothing can unresolve it. + + `bess_connected` reads `battery.connected`, which only one adapter + publishes -- exactly why the description is `derived`. Probing a derived + description would make availability depend on a path it never declared. + """ + coordinator = _make_coordinator(hass) + coordinator._findings = SchemaFindings(frozenset({"battery.connected"}), (), frozenset()) + assert BESS_CONNECTED_SENSOR.derived is True + + entity = SpanPanelBinarySensor(coordinator, BESS_CONNECTED_SENSOR) + + assert entity.available is True + + +async def test_evse_binary_sensor_is_covered_by_the_base_class(hass: HomeAssistant) -> None: + """`SpanEvseBinarySensor` defines no `available`, so it exercises `entity.py`. + + The circuit sensor goes through `SpanSensorBase.available` and the panel + binary sensor through `SpanPanelBinarySensor.available`; neither reaches the + override on `SpanPanelEntity` itself. This one does. + """ + coordinator = _make_coordinator(hass) + coordinator.data = SpanPanelSnapshotFactory.create( + evse={"evse-0": SpanEvseSnapshotFactory.create()} + ) + coordinator._findings = SchemaFindings(frozenset({"evse.status"}), (), frozenset()) + description = next(desc for desc in EVSE_BINARY_SENSORS if desc.key == "evse_charging") + assert description.field_path == "evse.status" + assert "available" not in vars(SpanEvseBinarySensor) + + entity = SpanEvseBinarySensor(coordinator, description, "evse-0") + + assert entity.available is False + + +def test_every_available_override_is_accounted_for() -> None: + """A new `available` override must decide where the probe sits. + + `SpanSensorBase` and `SpanPanelBinarySensor` return True on their own + before delegating, so each carries the probe ahead of that branch. The + switch, select and button return False when the panel is offline and + otherwise delegate, so the override on `SpanPanelEntity` covers them. + Anything new in this list has to answer the same question, and this + assertion is what forces it to be asked. + """ + overriders = { + cls.__name__ + for module in (binary_sensor, button, select, sensor_base, switch) + for cls in vars(module).values() + if isinstance(cls, type) + and issubclass(cls, SpanPanelEntity) + and cls is not SpanPanelEntity # the base itself, imported into each module + and "available" in vars(cls) + } + + assert overriders == { + "SpanSensorBase", + "SpanPanelBinarySensor", + "SpanPanelCircuitsSwitch", + "SpanPanelGFEOverrideButton", + "SpanPanelCircuitsSelect", + } From 47d50e8e9fb42c9cab948485865832150671d151 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:55:39 -0700 Subject: [PATCH 035/345] fix: evse_ev_connected declares evse.status instead of claiming derived It reads exactly one field that both adapters produce, which is a declaration. Classified derived, it was skipped by _declared_field_paths: the Repair for a dead evse.status never named it and the availability probe never fired for it, while its sibling evse_charging got both from the same field. The derived docstring now states the rule as a countable test, and a new introspection test fails any derived description that reads exactly one producible field. --- custom_components/span_panel/binary_sensor.py | 2 +- custom_components/span_panel/field_paths.py | 28 ++++++++++-- tests/test_field_path_introspection.py | 45 +++++++++++++++++++ tests/test_schema_availability.py | 12 ++++- 4 files changed, 80 insertions(+), 7 deletions(-) diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py index 6c3f4e7d..90a35e08 100644 --- a/custom_components/span_panel/binary_sensor.py +++ b/custom_components/span_panel/binary_sensor.py @@ -327,7 +327,7 @@ class SpanEvseBinarySensorEntityDescription( ), SpanEvseBinarySensorEntityDescription( key="evse_ev_connected", - derived=True, + field_path="evse.status", translation_key="evse_ev_connected", device_class=BinarySensorDeviceClass.PLUG, value_fn=lambda e: (e.status or "") in _EV_CONNECTED_STATUSES, diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py index 7031abdf..8a8ac2ea 100644 --- a/custom_components/span_panel/field_paths.py +++ b/custom_components/span_panel/field_paths.py @@ -43,10 +43,30 @@ class FieldPathDeclarationMixin: """ derived: bool = False - """True when the value is computed from several fields, or none. - - Derived entities have no single source field, so they are exempt from the - producible check. + """True only when there is no single source field to declare. + + Exactly one of these three must hold, and the test is mechanical — count the + snapshot fields the `value_fn` reads: + + 1. it reads **no** snapshot field, or + 2. it reads **more than one**, or + 3. it reads exactly one that no adapter, or only one adapter, produces. + + Reading exactly one producible field is **always** a declaration, however + much arithmetic, mapping or membership-testing is applied on top. "Computed + from `status`" is not derivation: `field_path="evse.status"` with a + `value_fn` of `status in {...}` declares its source correctly and still + computes whatever it likes. `evse_ev_connected` was misclassified this way, + which cost it both a Repair mention and its unavailability, while its + sibling `evse_charging` — same field, same shape — got both. + + Case 3 keeps the producible gate satisfiable: it requires a path both + adapters emit, so a schema-conditional field cannot be declared. Those are + listed in `RESIDUAL_EXEMPT_PATHS` when the integration reads them outside a + description. + + `test_no_derived_description_reads_one_producible_field` enforces this rule + against every derived description rather than against any one instance. """ diff --git a/tests/test_field_path_introspection.py b/tests/test_field_path_introspection.py index 826f028b..89bf2740 100644 --- a/tests/test_field_path_introspection.py +++ b/tests/test_field_path_introspection.py @@ -14,6 +14,7 @@ declared_field_paths, ) from custom_components.span_panel.sensor_definitions import all_sensor_descriptions +from tests.adapter_fixtures import schema_one_metadata, schema_zero_metadata # Attributes of the panel snapshot that are themselves sub-snapshots. Their # fields are addressed as "battery.x", not "panel.battery.x". @@ -189,3 +190,47 @@ def test_introspection_covers_every_declared_path() -> None: if not description.derived and description.field_path is not None } assert declared_field_paths() == frozenset(introspected | set(RESIDUAL_FIELD_PATHS)) + + +def test_no_derived_description_reads_one_producible_field() -> None: + """`derived` must mean no field, several fields, or an unproducible one. + + Pins the rule, not the instance that broke it. `evse_ev_connected` read + exactly `evse.status` — one field both adapters produce — while declaring + itself derived, so `_declared_field_paths` skipped it: the Repair for a dead + `evse.status` never named it and the availability probe never fired for it, + though its sibling `evse_charging` got both from the very same field. + + Producibility is what makes this checkable: the recorder also picks up + method names and other noise, and intersecting with what both adapters + actually emit leaves only real fields. + """ + producible = set(schema_zero_metadata()) & set(schema_one_metadata()) + offenders: list[str] = [] + + for description in _declaring_descriptions(): + if not description.derived: + continue + class_name = type(description).__name__ + prefix = _ROOT_PREFIX.get(class_name) + if prefix is None: + offenders.append( + f"{description.key}: {class_name} is absent from _ROOT_PREFIX, so its " + "derived classification would go unverified" + ) + continue + sink: set[str] = set() + proxy = _Recorder(sink, prefix, root=(prefix == "panel")) + try: + description.value_fn(proxy) + except Exception as err: # noqa: BLE001 + offenders.append(f"{description.key}: value_fn raised {err!r}") + continue + read = sorted(sink & producible) + if len(read) == 1: + offenders.append( + f"{description.key}: derived=True but reads exactly one producible field, " + f"{read[0]!r} — that is a declaration, so set field_path={read[0]!r}" + ) + + assert not offenders, "Misclassified derived descriptions:\n" + "\n".join(offenders) diff --git a/tests/test_schema_availability.py b/tests/test_schema_availability.py index 86185678..c2400fe1 100644 --- a/tests/test_schema_availability.py +++ b/tests/test_schema_availability.py @@ -16,6 +16,7 @@ from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant +import pytest from pytest_homeassistant_custom_component.common import MockConfigEntry from span_panel_api import SpanMqttClient, SpanPanelSnapshot @@ -215,19 +216,26 @@ async def test_derived_entity_is_never_probed(hass: HomeAssistant) -> None: assert entity.available is True -async def test_evse_binary_sensor_is_covered_by_the_base_class(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("key", ["evse_charging", "evse_ev_connected"]) +async def test_evse_binary_sensor_is_covered_by_the_base_class( + hass: HomeAssistant, key: str +) -> None: """`SpanEvseBinarySensor` defines no `available`, so it exercises `entity.py`. The circuit sensor goes through `SpanSensorBase.available` and the panel binary sensor through `SpanPanelBinarySensor.available`; neither reaches the override on `SpanPanelEntity` itself. This one does. + + Both EVSE binary sensors read `evse.status`, so both must answer the same + way. `evse_ev_connected` used to be `derived=True` and so stayed available + while its sibling went dark on the very same dead field. """ coordinator = _make_coordinator(hass) coordinator.data = SpanPanelSnapshotFactory.create( evse={"evse-0": SpanEvseSnapshotFactory.create()} ) coordinator._findings = SchemaFindings(frozenset({"evse.status"}), (), frozenset()) - description = next(desc for desc in EVSE_BINARY_SENSORS if desc.key == "evse_charging") + description = next(desc for desc in EVSE_BINARY_SENSORS if desc.key == key) assert description.field_path == "evse.status" assert "available" not in vars(SpanEvseBinarySensor) From cb1b776da8f5a9c80ab6bc89054ce630962a6653 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:51:11 -0700 Subject: [PATCH 036/345] chore: point editable sources at the base library worktree The merged branch developed against a temporary surgery worktree. The base branch must depend on the durable one, which now carries the same merged library work. --- pyproject.toml | 8 ++++---- uv.lock | 20 ++++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b042b9c5..56a165ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,9 +47,9 @@ package = false [tool.uv.sources] # This branch develops against the in-progress schema-1 adapter, which lives in a # worktree rather than the primary checkout the symlink points at. -span-panel-api = { path = "../../span/span-panel-api-p3", editable = true } -span-panel-api-schema-0 = { path = "../../span/span-panel-api-p3/packages/schema-0", editable = true } -span-panel-api-schema-1 = { path = "../../span/span-panel-api-p3/packages/schema-1", editable = true } +span-panel-api = { path = "../../span/span-panel-api-p2", editable = true } +span-panel-api-schema-0 = { path = "../../span/span-panel-api-p2/packages/schema-0", editable = true } +span-panel-api-schema-1 = { path = "../../span/span-panel-api-p2/packages/schema-1", editable = true } [tool.jscpd] path = ["custom_components/span_panel", "./*.{html,md}"] @@ -129,7 +129,7 @@ exclude = [ ] extraPaths = [ "./custom_components", - "../../span/span-panel-api-p3/src", + "../../span/span-panel-api-p2/src", "../ha-synthetic-sensors/src" ] pythonPlatform = "Darwin" diff --git a/uv.lock b/uv.lock index 87d23906..949093d8 100644 --- a/uv.lock +++ b/uv.lock @@ -2523,9 +2523,9 @@ dev = [ [package.metadata] requires-dist = [ { name = "homeassistant", specifier = "==2026.8.0" }, - { name = "span-panel-api", editable = "../../span/span-panel-api-p3" }, - { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api-p3/packages/schema-0" }, - { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api-p3/packages/schema-1" }, + { name = "span-panel-api", editable = "../../span/span-panel-api-p2" }, + { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api-p2/packages/schema-0" }, + { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api-p2/packages/schema-1" }, ] [package.metadata.requires-dev] @@ -2551,7 +2551,7 @@ dev = [ [[package]] name = "span-panel-api" version = "3.0.0b4" -source = { editable = "../../span/span-panel-api-p3" } +source = { editable = "../../span/span-panel-api-p2" } dependencies = [ { name = "httpx" }, { name = "paho-mqtt" }, @@ -2578,8 +2578,8 @@ dev = [ { name = "pytest-cov" }, { name = "radon" }, { name = "ruff", specifier = ">=0.15.5" }, - { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api-p3/packages/schema-0" }, - { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api-p3/packages/schema-1" }, + { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api-p2/packages/schema-0" }, + { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api-p2/packages/schema-1" }, { name = "twine", specifier = ">=7.0" }, { name = "types-pyyaml", specifier = ">=6.0.12.20250915" }, { name = "vulture", specifier = ">=2.14" }, @@ -2588,18 +2588,18 @@ dev = [ [[package]] name = "span-panel-api-schema-0" version = "1.0.0b4" -source = { editable = "../../span/span-panel-api-p3/packages/schema-0" } +source = { editable = "../../span/span-panel-api-p2/packages/schema-0" } dependencies = [ { name = "span-panel-api" }, ] [package.metadata] -requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-api-p3" }] +requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-api-p2" }] [[package]] name = "span-panel-api-schema-1" version = "0.1.0b4" -source = { editable = "../../span/span-panel-api-p3/packages/schema-1" } +source = { editable = "../../span/span-panel-api-p2/packages/schema-1" } dependencies = [ { name = "ebus-sdk" }, { name = "span-panel-api" }, @@ -2608,7 +2608,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "ebus-sdk", specifier = ">=0.19.0,<1.0" }, - { name = "span-panel-api", editable = "../../span/span-panel-api-p3" }, + { name = "span-panel-api", editable = "../../span/span-panel-api-p2" }, ] [[package]] From 6cdbaeb793e9623e05a56aedc39c1f6f7919ce3f Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:17:41 -0700 Subject: [PATCH 037/345] fix(repairs): do not raise a Repair nothing the user owns is affected by Both schema Repairs claim something the user owns is broken: a sensor is dead, or a reading may be wrong. A field path no enabled entity reads supports neither claim. `vendor_cloud` is `entity_registry_enabled_default=False`, so it is registered and never added to hass and never self-registers with the coordinator. A fresh install against the flat simulator therefore raised "`panel.vendor_cloud` ... 0 entity/entities are affected (for example: none)" beside two genuine notices, which is how a category of Repair gets ignored. Both classes now look up the affected entities first and skip the finding when there are none, logging the suppressed path at debug. The unit mismatch class did not look them up at all before. A suppressed path never enters `wanted`, so the existing reconcile sweep deletes an issue whose last affected entity has gone rather than leaving it orphaned. The unit mismatch notice now carries the same count and bounded examples as the unresolved one; every locale already had the fragment translated for the other message. The `span_panel_schema_issue` payload now lists the paths that actually raised, so a partly suppressed pass cannot name a path that a fully suppressed pass omits entirely. --- .../span_panel/schema_repairs.py | 74 +++++- custom_components/span_panel/strings.json | 2 +- .../span_panel/translations/en.json | 2 +- .../span_panel/translations/es.json | 2 +- .../span_panel/translations/fr.json | 2 +- .../span_panel/translations/ja.json | 2 +- .../span_panel/translations/pt.json | 2 +- tests/test_schema_repairs.py | 238 +++++++++++++++--- 8 files changed, 270 insertions(+), 54 deletions(-) diff --git a/custom_components/span_panel/schema_repairs.py b/custom_components/span_panel/schema_repairs.py index dd780c84..9a52d2fe 100644 --- a/custom_components/span_panel/schema_repairs.py +++ b/custom_components/span_panel/schema_repairs.py @@ -7,11 +7,18 @@ A third condition, a produced field nothing reads, is a sanctioned addition and stays in the debug log. + +Every Repair here claims something the user owns is broken, so a finding whose +field path no enabled entity reads is not raised at all. Disabled-by-default +descriptions are registered but never added to hass, so they never self-register +with the coordinator and a finding against one has nothing to name; telling the +user "0 entity/entities are affected" only teaches them to ignore the category. """ from __future__ import annotations from collections.abc import Mapping +import logging from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -20,6 +27,8 @@ from .const import DOMAIN, EVENT_SCHEMA_ISSUE from .schema_validation import SchemaFindings +_LOGGER = logging.getLogger(__name__) + _MAX_EXAMPLES = 3 @@ -31,6 +40,35 @@ def _unit_id(entry_id: str, field_path: str) -> str: return f"unit_mismatch_{entry_id}_{field_path}" +def _affected(entity_ids_by_path: Mapping[str, list[str]], field_path: str) -> list[str]: + """Entities in hass that read this field, sorted. + + Sorted so an unchanged panel produces an unchanged payload: the update branch + only rewrites the entry when something actually differs. + """ + return sorted(entity_ids_by_path.get(field_path, [])) + + +def _log_suppressed(condition: str, field_path: str) -> None: + _LOGGER.debug( + "Suppressed %s Repair for %s: no enabled entity reads that field path, so " + "nothing the user owns is affected", + condition, + field_path, + ) + + +def _placeholders(affected: list[str]) -> dict[str, str]: + """Count plus a bounded sample, never the full list. + + One missing `circuit.instant_power_w` affects every circuit on the panel. + """ + return { + "count": str(len(affected)), + "examples": ", ".join(affected[:_MAX_EXAMPLES]), + } + + @callback def async_sync_schema_issues( hass: HomeAssistant, @@ -45,6 +83,11 @@ def async_sync_schema_issues( user who dismissed an aggregate would never be told when another field joined it. + A finding with no affected entity is suppressed rather than raised, and a + suppressed path leaves `wanted`, so an issue raised while entities existed is + deleted once the last one goes — the same path that clears a genuinely + resolved finding. + Re-raises idempotently and deletes only on genuine resolution. Deleting is the one thing that clears a dismissal — `dismissed_version` is never compared against the running HA version, and neither the update branch nor @@ -54,13 +97,17 @@ def async_sync_schema_issues( """ registry = ir.async_get(hass) wanted: set[str] = set() + raised_unresolved: list[str] = [] + raised_mismatches: list[str] = [] for field_path in sorted(findings.unresolved): + affected = _affected(entity_ids_by_path, field_path) + if not affected: + _log_suppressed("unresolved-field", field_path) + continue issue_id = _unresolved_id(entry.entry_id, field_path) wanted.add(issue_id) - # Sorted so an unchanged panel produces an unchanged payload: the update - # branch only rewrites the entry when something actually differs. - affected = sorted(entity_ids_by_path.get(field_path, [])) + raised_unresolved.append(field_path) ir.async_create_issue( hass, DOMAIN, @@ -73,18 +120,17 @@ def async_sync_schema_issues( is_persistent=False, severity=ir.IssueSeverity.WARNING, translation_key="schema_field_unresolved", - translation_placeholders={ - "field_path": field_path, - # A count plus a few examples, never the full list: one missing - # `circuit.instant_power_w` affects every circuit on the panel. - "count": str(len(affected)), - "examples": ", ".join(affected[:_MAX_EXAMPLES]) or "none", - }, + translation_placeholders={"field_path": field_path, **_placeholders(affected)}, ) for mismatch in findings.unit_mismatches: + affected = _affected(entity_ids_by_path, mismatch.field_path) + if not affected: + _log_suppressed("unit-mismatch", mismatch.field_path) + continue issue_id = _unit_id(entry.entry_id, mismatch.field_path) wanted.add(issue_id) + raised_mismatches.append(mismatch.field_path) ir.async_create_issue( hass, DOMAIN, @@ -97,6 +143,7 @@ def async_sync_schema_issues( "field_path": mismatch.field_path, "ha_unit": mismatch.ha_unit, "schema_unit": mismatch.schema_unit, + **_placeholders(affected), }, ) @@ -104,12 +151,15 @@ def async_sync_schema_issues( ir.async_delete_issue(hass, DOMAIN, issue_id) if wanted: + # The event mirrors what the user was actually told: a suppressed finding + # is deliberately not user-facing, and an automation reacting to one + # would be reacting to a defect that took nothing down. hass.bus.async_fire( EVENT_SCHEMA_ISSUE, { "entry_id": entry.entry_id, - "unresolved": sorted(findings.unresolved), - "unit_mismatches": [m.field_path for m in findings.unit_mismatches], + "unresolved": raised_unresolved, + "unit_mismatches": raised_mismatches, }, ) diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index f3522630..76f25f4b 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -718,7 +718,7 @@ }, "schema_unit_mismatch": { "title": "SPAN Panel unit mismatch", - "description": "Your panel reports `{field_path}` in {schema_unit}, but this integration expects {ha_unit}. Readings and long-term statistics for the affected entities may be wrong. The entity has not been changed automatically." + "description": "Your panel reports `{field_path}` in {schema_unit}, but this integration expects {ha_unit}. {count} entity/entities are affected (for example: {examples}). Their readings and long-term statistics may be wrong. They have not been changed automatically." } } } diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index f3522630..76f25f4b 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -718,7 +718,7 @@ }, "schema_unit_mismatch": { "title": "SPAN Panel unit mismatch", - "description": "Your panel reports `{field_path}` in {schema_unit}, but this integration expects {ha_unit}. Readings and long-term statistics for the affected entities may be wrong. The entity has not been changed automatically." + "description": "Your panel reports `{field_path}` in {schema_unit}, but this integration expects {ha_unit}. {count} entity/entities are affected (for example: {examples}). Their readings and long-term statistics may be wrong. They have not been changed automatically." } } } diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index 8d79397d..fdf1faea 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -718,7 +718,7 @@ }, "schema_unit_mismatch": { "title": "Discrepancia de unidades del Panel SPAN", - "description": "Su panel informa de `{field_path}` en {schema_unit}, pero esta integración espera {ha_unit}. Las lecturas y las estadísticas a largo plazo de las entidades afectadas pueden ser incorrectas. La entidad no se ha modificado automáticamente." + "description": "Su panel informa de `{field_path}` en {schema_unit}, pero esta integración espera {ha_unit}. Hay {count} entidad(es) afectada(s) (por ejemplo: {examples}). Sus lecturas y estadísticas a largo plazo pueden ser incorrectas. No se han modificado automáticamente." } } } diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index 512fecad..3bb0dab4 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -718,7 +718,7 @@ }, "schema_unit_mismatch": { "title": "Incohérence d'unité du Panneau SPAN", - "description": "Votre panneau signale `{field_path}` en {schema_unit}, alors que cette intégration attend {ha_unit}. Les relevés et les statistiques à long terme des entités concernées peuvent être erronés. L'entité n'a pas été modifiée automatiquement." + "description": "Votre panneau signale `{field_path}` en {schema_unit}, alors que cette intégration attend {ha_unit}. {count} entité(s) sont concernées (par exemple : {examples}). Leurs relevés et leurs statistiques à long terme peuvent être erronés. Elles n'ont pas été modifiées automatiquement." } } } diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index d92876aa..8e017e2d 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -718,7 +718,7 @@ }, "schema_unit_mismatch": { "title": "SPAN パネルの単位の不一致", - "description": "パネルは `{field_path}` を {schema_unit} で報告していますが、この統合は {ha_unit} を想定しています。影響を受けるエンティティの測定値と長期統計が誤っている可能性があります。エンティティは自動的には変更されていません。" + "description": "パネルは `{field_path}` を {schema_unit} で報告していますが、この統合は {ha_unit} を想定しています。{count} 個のエンティティが影響を受けます (例: {examples})。これらの測定値と長期統計が誤っている可能性があります。エンティティは自動的には変更されていません。" } } } diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index 90bacfeb..ee392d75 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -718,7 +718,7 @@ }, "schema_unit_mismatch": { "title": "Discrepância de unidades do Painel SPAN", - "description": "O seu painel comunica `{field_path}` em {schema_unit}, mas esta integração espera {ha_unit}. As leituras e as estatísticas de longo prazo das entidades afetadas podem estar erradas. A entidade não foi alterada automaticamente." + "description": "O seu painel comunica `{field_path}` em {schema_unit}, mas esta integração espera {ha_unit}. Há {count} entidade(s) afetada(s) (por exemplo: {examples}). As suas leituras e estatísticas de longo prazo podem estar erradas. Não foram alteradas automaticamente." } } } diff --git a/tests/test_schema_repairs.py b/tests/test_schema_repairs.py index 269255c9..946d8bb4 100644 --- a/tests/test_schema_repairs.py +++ b/tests/test_schema_repairs.py @@ -1,5 +1,7 @@ from __future__ import annotations +import logging + from homeassistant.helpers import issue_registry as ir import pytest from pytest_homeassistant_custom_component.common import MockConfigEntry @@ -12,6 +14,13 @@ from custom_components.span_panel.schema_validation import SchemaFindings, UnitMismatch _PATH = "circuit.instant_power_w" +_UNIT_PATH = "panel.l1_voltage" + +# Both Repairs claim something the user owns is broken, so nothing is raised for a +# field path no enabled entity reads. Every call below that expects an issue has +# to name the entities the finding took down. +_AFFECTED = {_PATH: ["sensor.a"]} +_UNIT_AFFECTED = {_UNIT_PATH: ["sensor.voltage"]} @pytest.fixture @@ -32,15 +41,17 @@ def _unit_issue_id(entry: MockConfigEntry, path: str = _PATH) -> str: async def test_unresolved_path_raises_one_issue(hass, entry) -> None: findings = SchemaFindings(frozenset({_PATH}), (), frozenset()) - async_sync_schema_issues(hass, entry, findings, {}) + async_sync_schema_issues(hass, entry, findings, _AFFECTED) registry = ir.async_get(hass) assert registry.async_get_issue(DOMAIN, _issue_id(entry)) async def test_issue_cleared_when_condition_resolves(hass, entry) -> None: - async_sync_schema_issues(hass, entry, SchemaFindings(frozenset({_PATH}), (), frozenset()), {}) - async_sync_schema_issues(hass, entry, SchemaFindings(frozenset(), (), frozenset()), {}) + async_sync_schema_issues( + hass, entry, SchemaFindings(frozenset({_PATH}), (), frozenset()), _AFFECTED + ) + async_sync_schema_issues(hass, entry, SchemaFindings(frozenset(), (), frozenset()), _AFFECTED) registry = ir.async_get(hass) assert registry.async_get_issue(DOMAIN, _issue_id(entry)) is None @@ -53,7 +64,7 @@ async def test_dismissal_survives_reconciliation(hass, entry) -> None: accepted notice into a permanent nag. """ findings = SchemaFindings(frozenset({_PATH}), (), frozenset()) - async_sync_schema_issues(hass, entry, findings, {}) + async_sync_schema_issues(hass, entry, findings, _AFFECTED) issue_id = _issue_id(entry) ir.async_ignore_issue(hass, DOMAIN, issue_id, True) @@ -62,7 +73,7 @@ async def test_dismissal_survives_reconciliation(hass, entry) -> None: assert dismissed is not None for _ in range(3): - async_sync_schema_issues(hass, entry, findings, {}) + async_sync_schema_issues(hass, entry, findings, _AFFECTED) assert registry.async_get_issue(DOMAIN, issue_id).dismissed_version == dismissed @@ -92,9 +103,12 @@ async def test_dismissal_survives_a_changing_affected_entity_payload(hass, entry async def test_distinct_paths_get_distinct_issues(hass, entry) -> None: """Dismissing one finding must not swallow a later, different one.""" - async_sync_schema_issues(hass, entry, SchemaFindings(frozenset({"a.one"}), (), frozenset()), {}) + affected = {"a.one": ["sensor.one"], "b.two": ["sensor.two"]} async_sync_schema_issues( - hass, entry, SchemaFindings(frozenset({"a.one", "b.two"}), (), frozenset()), {} + hass, entry, SchemaFindings(frozenset({"a.one"}), (), frozenset()), affected + ) + async_sync_schema_issues( + hass, entry, SchemaFindings(frozenset({"a.one", "b.two"}), (), frozenset()), affected ) registry = ir.async_get(hass) assert registry.async_get_issue(DOMAIN, _issue_id(entry, "b.two")) @@ -106,11 +120,14 @@ async def test_a_dismissed_finding_does_not_swallow_a_later_one(hass, entry) -> Dismissing an aggregate would silence every finding that joined it later, because the update branch preserves `dismissed_version`. """ - async_sync_schema_issues(hass, entry, SchemaFindings(frozenset({"a.one"}), (), frozenset()), {}) + affected = {"a.one": ["sensor.one"], "b.two": ["sensor.two"]} + async_sync_schema_issues( + hass, entry, SchemaFindings(frozenset({"a.one"}), (), frozenset()), affected + ) ir.async_ignore_issue(hass, DOMAIN, _issue_id(entry, "a.one"), True) async_sync_schema_issues( - hass, entry, SchemaFindings(frozenset({"a.one", "b.two"}), (), frozenset()), {} + hass, entry, SchemaFindings(frozenset({"a.one", "b.two"}), (), frozenset()), affected ) registry = ir.async_get(hass) @@ -125,17 +142,26 @@ async def test_one_entry_does_not_clear_another(hass) -> None: well = MockConfigEntry(domain=DOMAIN, data={}, unique_id="well") well.add_to_hass(hass) - async_sync_schema_issues(hass, sick, SchemaFindings(frozenset({_PATH}), (), frozenset()), {}) - async_sync_schema_issues(hass, well, SchemaFindings(frozenset(), (), frozenset()), {}) + async_sync_schema_issues( + hass, sick, SchemaFindings(frozenset({_PATH}), (), frozenset()), _AFFECTED + ) + async_sync_schema_issues(hass, well, SchemaFindings(frozenset(), (), frozenset()), _AFFECTED) registry = ir.async_get(hass) assert registry.async_get_issue(DOMAIN, _issue_id(sick)) async def test_circuit_rename_and_commissioning_raise_no_issue(hass, entry) -> None: - """Tier-1 and Tier-2 changes are handled elsewhere and must stay silent.""" + """Tier-1 and Tier-2 changes are handled elsewhere and must stay silent. + + Silent because of what they are, not because nothing reads them: the map + names a live entity for the field, and it still raises nothing. + """ async_sync_schema_issues( - hass, entry, SchemaFindings(frozenset(), (), frozenset({"pv.model"})), {} + hass, + entry, + SchemaFindings(frozenset(), (), frozenset({"pv.model"})), + {"pv.model": ["sensor.pv_model"]}, ) registry = ir.async_get(hass) assert not [k for k in registry.issues if k[0] == DOMAIN] @@ -143,28 +169,36 @@ async def test_circuit_rename_and_commissioning_raise_no_issue(hass, entry) -> N async def test_unit_mismatch_raises_its_own_issue(hass, entry) -> None: """The second of the two user-facing defects: a reading may be wrong.""" - mismatch = UnitMismatch("panel.l1_voltage", "V", "kV") - async_sync_schema_issues(hass, entry, SchemaFindings(frozenset(), (mismatch,), frozenset()), {}) + mismatch = UnitMismatch(_UNIT_PATH, "V", "kV") + async_sync_schema_issues( + hass, entry, SchemaFindings(frozenset(), (mismatch,), frozenset()), _UNIT_AFFECTED + ) registry = ir.async_get(hass) - issue = registry.async_get_issue(DOMAIN, _unit_issue_id(entry, "panel.l1_voltage")) + issue = registry.async_get_issue(DOMAIN, _unit_issue_id(entry, _UNIT_PATH)) assert issue is not None assert issue.translation_key == "schema_unit_mismatch" assert issue.translation_placeholders == { - "field_path": "panel.l1_voltage", + "field_path": _UNIT_PATH, "ha_unit": "V", "schema_unit": "kV", + "count": "1", + "examples": "sensor.voltage", } async def test_unit_mismatch_issue_is_cleared_on_its_own(hass, entry) -> None: """Reconciliation must scope both classes, not just the unresolved one.""" - mismatch = UnitMismatch("panel.l1_voltage", "V", "kV") - async_sync_schema_issues(hass, entry, SchemaFindings(frozenset(), (mismatch,), frozenset()), {}) - async_sync_schema_issues(hass, entry, SchemaFindings(frozenset(), (), frozenset()), {}) + mismatch = UnitMismatch(_UNIT_PATH, "V", "kV") + async_sync_schema_issues( + hass, entry, SchemaFindings(frozenset(), (mismatch,), frozenset()), _UNIT_AFFECTED + ) + async_sync_schema_issues( + hass, entry, SchemaFindings(frozenset(), (), frozenset()), _UNIT_AFFECTED + ) registry = ir.async_get(hass) - assert registry.async_get_issue(DOMAIN, _unit_issue_id(entry, "panel.l1_voltage")) is None + assert registry.async_get_issue(DOMAIN, _unit_issue_id(entry, _UNIT_PATH)) is None async def test_issues_are_not_persistent(hass, entry) -> None: @@ -173,13 +207,16 @@ async def test_issues_are_not_persistent(hass, entry) -> None: A non-persistent issue reloads as a tombstone carrying only the dismissal, which is exactly what lets re-assertion happen without resurrecting one. """ - mismatch = UnitMismatch("panel.l1_voltage", "V", "kV") + mismatch = UnitMismatch(_UNIT_PATH, "V", "kV") async_sync_schema_issues( - hass, entry, SchemaFindings(frozenset({_PATH}), (mismatch,), frozenset()), {} + hass, + entry, + SchemaFindings(frozenset({_PATH}), (mismatch,), frozenset()), + _AFFECTED | _UNIT_AFFECTED, ) registry = ir.async_get(hass) - for issue_id in (_issue_id(entry), _unit_issue_id(entry, "panel.l1_voltage")): + for issue_id in (_issue_id(entry), _unit_issue_id(entry, _UNIT_PATH)): issue = registry.async_get_issue(DOMAIN, issue_id) assert issue.is_persistent is False assert issue.is_fixable is False @@ -207,14 +244,104 @@ async def test_affected_entities_are_bounded_and_counted(hass, entry) -> None: assert "sensor.circuit_0_power" in placeholders["examples"] -async def test_no_affected_entities_still_reads_sensibly(hass, entry) -> None: - """An empty example list must not render as an empty string in the notice.""" - async_sync_schema_issues(hass, entry, SchemaFindings(frozenset({_PATH}), (), frozenset()), {}) +# --- Findings nobody owns ------------------------------------------------- +# +# `vendor_cloud` is `entity_registry_enabled_default=False`, so it is registered +# and never added to hass. A fresh install against the flat simulator raised +# "`panel.vendor_cloud` ... 0 entity/entities are affected (for example: none)" +# beside two genuine notices, which is how a category of Repair gets ignored. + + +async def test_a_finding_no_enabled_entity_reads_raises_no_issue(hass, entry) -> None: + """The disabled-by-default case: nothing the user owns is affected.""" + async_sync_schema_issues( + hass, entry, SchemaFindings(frozenset({"panel.vendor_cloud"}), (), frozenset()), {} + ) registry = ir.async_get(hass) - placeholders = registry.async_get_issue(DOMAIN, _issue_id(entry)).translation_placeholders - assert placeholders["count"] == "0" - assert placeholders["examples"] + assert registry.async_get_issue(DOMAIN, _issue_id(entry, "panel.vendor_cloud")) is None + assert not [k for k in registry.issues if k[0] == DOMAIN] + + +async def test_a_unit_mismatch_no_enabled_entity_reads_raises_no_issue(hass, entry) -> None: + """The same rule for the second class: no reading of the user's is wrong.""" + mismatch = UnitMismatch(_UNIT_PATH, "V", "kV") + async_sync_schema_issues(hass, entry, SchemaFindings(frozenset(), (mismatch,), frozenset()), {}) + + registry = ir.async_get(hass) + assert registry.async_get_issue(DOMAIN, _unit_issue_id(entry, _UNIT_PATH)) is None + assert not [k for k in registry.issues if k[0] == DOMAIN] + + +async def test_suppression_only_silences_the_path_nobody_reads(hass, entry) -> None: + """The real install: two genuine notices, one suppressed, in one pass.""" + async_sync_schema_issues( + hass, + entry, + SchemaFindings(frozenset({_PATH, "panel.vendor_cloud"}), (), frozenset()), + _AFFECTED, + ) + + registry = ir.async_get(hass) + assert registry.async_get_issue(DOMAIN, _issue_id(entry)) + assert registry.async_get_issue(DOMAIN, _issue_id(entry, "panel.vendor_cloud")) is None + + +async def test_a_suppressed_finding_is_logged(hass, entry, caplog) -> None: + """Suppressed is not discarded: the field path stays reachable in the log.""" + with caplog.at_level(logging.DEBUG, logger="custom_components.span_panel.schema_repairs"): + async_sync_schema_issues( + hass, entry, SchemaFindings(frozenset({"panel.vendor_cloud"}), (), frozenset()), {} + ) + + assert "panel.vendor_cloud" in caplog.text + assert "no enabled entity reads" in caplog.text + + +async def test_an_issue_is_deleted_when_its_last_affected_entity_goes(hass, entry) -> None: + """The transition the reconcile pass has to cover. + + A path raised while entities read it, then disabled or removed, must have its + issue deleted rather than left orphaned at "0 affected". + """ + findings = SchemaFindings(frozenset({_PATH}), (), frozenset()) + async_sync_schema_issues(hass, entry, findings, _AFFECTED) + registry = ir.async_get(hass) + assert registry.async_get_issue(DOMAIN, _issue_id(entry)) + + async_sync_schema_issues(hass, entry, findings, {}) + + assert registry.async_get_issue(DOMAIN, _issue_id(entry)) is None + + +async def test_a_unit_mismatch_issue_is_deleted_when_its_entities_go(hass, entry) -> None: + """The same transition for the second class.""" + findings = SchemaFindings(frozenset(), (UnitMismatch(_UNIT_PATH, "V", "kV"),), frozenset()) + async_sync_schema_issues(hass, entry, findings, _UNIT_AFFECTED) + registry = ir.async_get(hass) + assert registry.async_get_issue(DOMAIN, _unit_issue_id(entry, _UNIT_PATH)) + + async_sync_schema_issues(hass, entry, findings, {}) + + assert registry.async_get_issue(DOMAIN, _unit_issue_id(entry, _UNIT_PATH)) is None + + +async def test_a_dismissal_survives_the_entity_leaving_and_returning(hass, entry) -> None: + """Suppression deletes, and a delete is the one thing that clears a dismissal. + + That is the accepted cost of not nagging about a finding nobody owns: the + notice is genuinely new when an entity starts reading the field again. + """ + findings = SchemaFindings(frozenset({_PATH}), (), frozenset()) + async_sync_schema_issues(hass, entry, findings, _AFFECTED) + ir.async_ignore_issue(hass, DOMAIN, _issue_id(entry), True) + + async_sync_schema_issues(hass, entry, findings, {}) + async_sync_schema_issues(hass, entry, findings, _AFFECTED) + + issue = ir.async_get(hass).async_get_issue(DOMAIN, _issue_id(entry)) + assert issue is not None + assert issue.dismissed_version is None async def test_findings_fire_an_event(hass, entry) -> None: @@ -222,9 +349,12 @@ async def test_findings_fire_an_event(hass, entry) -> None: events = [] hass.bus.async_listen(EVENT_SCHEMA_ISSUE, events.append) - mismatch = UnitMismatch("panel.l1_voltage", "V", "kV") + mismatch = UnitMismatch(_UNIT_PATH, "V", "kV") async_sync_schema_issues( - hass, entry, SchemaFindings(frozenset({_PATH}), (mismatch,), frozenset()), {} + hass, + entry, + SchemaFindings(frozenset({_PATH}), (mismatch,), frozenset()), + _AFFECTED | _UNIT_AFFECTED, ) await hass.async_block_till_done() @@ -232,10 +362,44 @@ async def test_findings_fire_an_event(hass, entry) -> None: assert events[0].data == { "entry_id": entry.entry_id, "unresolved": [_PATH], - "unit_mismatches": ["panel.l1_voltage"], + "unit_mismatches": [_UNIT_PATH], } +async def test_the_event_carries_only_what_the_user_was_told(hass, entry) -> None: + """A suppressed finding is not user-facing, so it is not in the event either. + + Otherwise an automation would react to a defect that took nothing down, and + an all-suppressed pass — which fires nothing at all — would disagree with a + partly-suppressed one. + """ + events = [] + hass.bus.async_listen(EVENT_SCHEMA_ISSUE, events.append) + + async_sync_schema_issues( + hass, + entry, + SchemaFindings(frozenset({_PATH, "panel.vendor_cloud"}), (), frozenset()), + _AFFECTED, + ) + await hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].data["unresolved"] == [_PATH] + + +async def test_an_all_suppressed_pass_fires_no_event(hass, entry) -> None: + events = [] + hass.bus.async_listen(EVENT_SCHEMA_ISSUE, events.append) + + async_sync_schema_issues( + hass, entry, SchemaFindings(frozenset({"panel.vendor_cloud"}), (), frozenset()), {} + ) + await hass.async_block_till_done() + + assert events == [] + + async def test_a_healthy_pass_fires_no_event(hass, entry) -> None: events = [] hass.bus.async_listen(EVENT_SCHEMA_ISSUE, events.append) @@ -256,8 +420,8 @@ async def test_clearing_removes_only_this_entry(hass) -> None: kept.add_to_hass(hass) findings = SchemaFindings(frozenset({_PATH}), (), frozenset()) - async_sync_schema_issues(hass, removed, findings, {}) - async_sync_schema_issues(hass, kept, findings, {}) + async_sync_schema_issues(hass, removed, findings, _AFFECTED) + async_sync_schema_issues(hass, kept, findings, _AFFECTED) async_clear_schema_issues(hass, removed) @@ -288,7 +452,9 @@ async def test_remove_entry_clears_this_entry_issues(hass, entry) -> None: """Core does not delete our issues when the entry is removed.""" from custom_components.span_panel import async_remove_entry - async_sync_schema_issues(hass, entry, SchemaFindings(frozenset({_PATH}), (), frozenset()), {}) + async_sync_schema_issues( + hass, entry, SchemaFindings(frozenset({_PATH}), (), frozenset()), _AFFECTED + ) registry = ir.async_get(hass) assert registry.async_get_issue(DOMAIN, _issue_id(entry)) From 9b78cc7aa24ba68f49d4fe542f7ef9d6f208daa1 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:04:17 -0700 Subject: [PATCH 038/345] feat(repairs): notice entities that were added disabled A release that adds a diagnostic with entity_registry_enabled_default=False grows nobody's entity list uninvited, which is the point -- and is also why the addition reaches the user through nothing at all. battery.part_number shipped that way and was only found by opening the device's disabled-entity list on a hunch. Setup now snapshots the entry's registered unique_ids immediately before the platforms are forwarded and, once they are up, raises one Repair naming everything that is both newly registered and disabled_by INTEGRATION. Silent on a first install: with nothing registered beforehand every entity is new, so the notice would name the whole integration. Keyed on the entry AND a digest of the exact set of new unique_ids, for the reason that forced per-field ids on the degradation Repairs -- async_get_or_create's update branch preserves dismissed_version while replacing placeholders, so an entry-wide id would rewrite a notice the user put away instead of raising a new one. An event rather than a condition, which needs three things together: it is persistent, because the diff cannot produce it twice and a tombstone would vanish unread at the first restart after the upgrade; the defect reconcile pass cannot reach its ids, so the startup that raised it does not then delete it; and raising is skipped outright when the id already exists. IssueSeverity has no informational member -- CRITICAL, ERROR, WARNING -- so WARNING is the mildest available, the floor panel_upgraded_to_ebus_v1 already settled on for the same reason. --- custom_components/span_panel/__init__.py | 17 +- .../span_panel/schema_repairs.py | 167 ++++++++- custom_components/span_panel/strings.json | 4 + .../span_panel/translations/en.json | 4 + .../span_panel/translations/es.json | 4 + .../span_panel/translations/fr.json | 4 + .../span_panel/translations/ja.json | 4 + .../span_panel/translations/pt.json | 4 + tests/test_new_entity_notice.py | 336 ++++++++++++++++++ tests/test_setup_entry.py | 69 ++++ 10 files changed, 607 insertions(+), 6 deletions(-) create mode 100644 tests/test_new_entity_notice.py diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py index 866e3842..4d221be2 100644 --- a/custom_components/span_panel/__init__.py +++ b/custom_components/span_panel/__init__.py @@ -52,7 +52,11 @@ from .graph_horizon import GraphHorizonManager from .migrations import CURRENT_CONFIG_VERSION, async_migrate_entry # noqa: F401 from .options import SNAPSHOT_UPDATE_INTERVAL -from .schema_repairs import async_clear_schema_issues +from .schema_repairs import ( + async_clear_schema_issues, + async_notice_new_disabled_entities, + async_registered_unique_ids, +) from .services import ( # noqa: F401 _async_register_favorites_services, _async_register_graph_horizon_services, @@ -252,6 +256,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> ), ) + # Taken before the forward, because forwarding is what registers the + # entities: everything absent here and present afterwards is an entity + # this version of the integration added. + known_unique_ids = async_registered_unique_ids(hass, entry) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # After the platforms, not before: schema validation runs on the first @@ -259,6 +268,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> # entities an unresolved field took down — and those entities only # register themselves once their platform has added them. coordinator.async_sync_schema_repairs() + + # Also after the platforms, for the other half of the same reason: a + # newly added entity is only in the registry once its platform has added + # it. An addition that arrives disabled reaches the user through nothing + # else at all. + async_notice_new_disabled_entities(hass, entry, known_unique_ids) except Exception: if coordinator is not None: await coordinator.async_shutdown() diff --git a/custom_components/span_panel/schema_repairs.py b/custom_components/span_panel/schema_repairs.py index 9a52d2fe..9511fed6 100644 --- a/custom_components/span_panel/schema_repairs.py +++ b/custom_components/span_panel/schema_repairs.py @@ -8,6 +8,14 @@ A third condition, a produced field nothing reads, is a sanctioned addition and stays in the debug log. +One further notice lives here and is not a defect at all: entities that this +setup registered for the first time and disabled by default. Registering a new +diagnostic disabled is what keeps an upgrade from growing everybody's entity +list uninvited, but it also makes the addition invisible — the user only finds +it by opening the device's disabled-entity list. That notice is an event, not a +condition, and is raised on different terms from the two defects; see +`async_notice_new_disabled_entities`. + Every Repair here claims something the user owns is broken, so a finding whose field path no enabled entity reads is not raised at all. Disabled-by-default descriptions are registered but never added to hass, so they never self-register @@ -17,12 +25,13 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Collection, Mapping +import hashlib import logging from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers import entity_registry as er, issue_registry as ir from .const import DOMAIN, EVENT_SCHEMA_ISSUE from .schema_validation import SchemaFindings @@ -31,6 +40,8 @@ _MAX_EXAMPLES = 3 +_NEW_ENTITIES_PREFIX = "new_entities_" + def _unresolved_id(entry_id: str, field_path: str) -> str: return f"unresolved_{entry_id}_{field_path}" @@ -40,6 +51,25 @@ def _unit_id(entry_id: str, field_path: str) -> str: return f"unit_mismatch_{entry_id}_{field_path}" +def _new_entities_id(entry_id: str, unique_ids: Collection[str]) -> str: + """One id per (entry, exact set of new entities). + + Keyed on the set and not on the entry alone for the reason the two defect + notices are keyed per field path: `async_get_or_create`'s update branch + preserves `dismissed_version` while replacing the placeholders, so an entry- + wide id would let a user who dismissed "Part Number appeared" never be told + about the next addition — it would silently rewrite the notice they already + put away. + + Truncated to 12 hex characters. The digest only has to separate one set from + another within a single config entry, and the id ends up in a storage file a + human occasionally reads. + """ + joined = "\n".join(sorted(unique_ids)) + digest = hashlib.sha256(joined.encode()).hexdigest()[:12] + return f"{_NEW_ENTITIES_PREFIX}{entry_id}_{digest}" + + def _affected(entity_ids_by_path: Mapping[str, list[str]], field_path: str) -> list[str]: """Entities in hass that read this field, sorted. @@ -164,12 +194,123 @@ def async_sync_schema_issues( ) +@callback +def async_registered_unique_ids(hass: HomeAssistant, entry: ConfigEntry) -> frozenset[str]: + """Return the unique_ids already registered for this entry. + + Taken by `async_setup_entry` immediately before the platforms are forwarded, + which is the last moment "already registered" still means "registered by an + earlier run". Unique_ids rather than entity_ids: an entity_id is the user's + to rename, a unique_id is the identity the registry itself keys on. + """ + return frozenset( + registry_entry.unique_id + for registry_entry in er.async_entries_for_config_entry(er.async_get(hass), entry.entry_id) + ) + + +def _label(registry_entry: er.RegistryEntry) -> str: + """Return what to call an entity the user has never seen. + + A disabled entity has no state, so there is no friendly name to read off the + state machine — only what the registry recorded when the platform added it. + The entity_id is the last resort rather than the first choice because it is + the name the user will *not* see in the device's disabled-entity list. + """ + return registry_entry.name or registry_entry.original_name or registry_entry.entity_id + + +@callback +def async_notice_new_disabled_entities( + hass: HomeAssistant, + entry: ConfigEntry, + known_unique_ids: Collection[str], +) -> None: + """Tell the user about entities this setup added and disabled. + + A release that adds a diagnostic with `entity_registry_enabled_default=False` + grows nobody's entity list, which is the point — and is also why the addition + reaches the user through nothing at all. This is the notice for that: the + entities exist, they are switched off, and here is what they are called. + + Silent on a first install. With nothing registered beforehand every entity is + new, so the notice would name the entire integration and teach the user to + ignore it. An empty `known_unique_ids` is the probe for that: + `er.async_entries_for_config_entry` answers with nothing for an entry that + has never registered anything. + + An EVENT, not a condition, and that shapes three decisions: + + * `is_persistent=True`, unlike the two defect notices. Those are re-derived + from live state at every startup, so they can afford to reload as + tombstones. This one cannot be re-derived at all: on the next startup the + entity is in `known_unique_ids` and the diff is empty by construction. A + non-persistent issue would therefore vanish unread at the first restart + after the upgrade, which for a user who was away is the same silent add + the notice exists to prevent. + * It is never reconciled away. `async_sync_schema_issues` deletes the defect + ids it does not re-derive, and applying that here would delete this notice + on the very next startup. `_ours` is deliberately scoped to the two defect + prefixes so the reconcile pass cannot reach these ids. + * Raising is skipped outright when the id already exists, so the notice is + asserted once and then left alone. Nothing re-derives it, so an update pass + could only rewrite the placeholders of a notice the user has already read + or dismissed. + + Severity is the mildest Home Assistant offers. `IssueSeverity` has no + informational member — it is CRITICAL, ERROR, WARNING — so WARNING is the + floor, the same floor `panel_upgraded_to_ebus_v1` settled on for the same + reason: nothing is broken and no action is required. + """ + if not known_unique_ids: + _LOGGER.debug( + "Suppressed new-entity notice for %s: nothing was registered before this " + "setup, so this is a first install and every entity is new", + entry.entry_id, + ) + return + + new_disabled = [ + registry_entry + for registry_entry in er.async_entries_for_config_entry(er.async_get(hass), entry.entry_id) + if registry_entry.unique_id not in known_unique_ids + and registry_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + ] + if not new_disabled: + return + + issue_id = _new_entities_id( + entry.entry_id, [registry_entry.unique_id for registry_entry in new_disabled] + ) + if ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is not None: + _LOGGER.debug("New-entity notice %s already raised; leaving it alone", issue_id) + return + + labels = sorted(_label(registry_entry) for registry_entry in new_disabled) + _LOGGER.debug("Raising new-entity notice %s for %s", issue_id, labels) + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + is_fixable=False, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="new_entities_disabled", + translation_placeholders=_placeholders(labels), + ) + + def _ours(registry: ir.IssueRegistry, entry_id: str) -> set[str]: - """Our issue ids for ONE config entry. + """Return the reconcilable issue ids for ONE config entry. Scoping by entry is not cosmetic: with a shared namespace, a healthy panel's reconcile pass would delete a degraded panel's issues on every cycle, and removing one panel would clear every panel's issues. + + Scoping to the two defect prefixes is not cosmetic either. Everything in here + is re-derived on every pass and deleted when it stops being derived; the + new-entity notice is derived exactly once and must survive a pass that cannot + re-derive it. See `_new_entity_notices`. """ prefixes = (f"unresolved_{entry_id}_", f"unit_mismatch_{entry_id}_") return { @@ -179,9 +320,25 @@ def _ours(registry: ir.IssueRegistry, entry_id: str) -> set[str]: } +def _new_entity_notices(registry: ir.IssueRegistry, entry_id: str) -> set[str]: + """Return this entry's new-entity notices. Cleared on removal, never reconciled.""" + prefix = f"{_NEW_ENTITIES_PREFIX}{entry_id}_" + return { + issue_id + for (domain, issue_id) in registry.issues + if domain == DOMAIN and issue_id.startswith(prefix) + } + + @callback def async_clear_schema_issues(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Remove one entry's issues. Core does not do this on entry removal.""" + """Remove one entry's issues. Core does not do this on entry removal. + + Covers the new-entity notices too, and they are the ones that need it most: + they are persistent, so unlike the defect notices they would not even be + demoted to tombstones by a restart — a removed panel would leave them + standing forever. + """ registry = ir.async_get(hass) - for issue_id in _ours(registry, entry.entry_id): + for issue_id in _ours(registry, entry.entry_id) | _new_entity_notices(registry, entry.entry_id): ir.async_delete_issue(hass, DOMAIN, issue_id) diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index 76f25f4b..74760c53 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -708,6 +708,10 @@ } }, "issues": { + "new_entities_disabled": { + "title": "New SPAN Panel entities are available", + "description": "This update added data your panel was already reporting but this integration did not yet expose. {count} new entity/entities were created for it (for example: {examples}).\n\nThey were created **disabled**, so nothing appeared in your dashboards or history uninvited. Nothing is broken and no action is required — this notice exists only so the addition is not silent.\n\nTo use one, open the SPAN Panel device page, show its disabled entities, and enable the ones you want. Dismiss this once you have had a look." + }, "panel_upgraded_to_ebus_v1": { "title": "Your SPAN Panel now reports islanding from its Microgrid Interconnect Device", "description": "Your SPAN Panel upgraded to the eBus v1.0 data model. Nothing you rely on has gone away, and no automation changes are required — this is a note about what improved.\n\n**DSM Grid State** keeps its entity ID and its history, and is now more trustworthy. On the previous firmware it was *inferred* — from the battery when one was fitted, otherwise from the dominant power source and whether any power was crossing the grid connection. It now reads the islanding state the Microgrid Interconnect Device (MID) actually senses.\n\n**Grid Islandable** also keeps working. v1.0 publishes no panel-level `grid-islandable` property, so it now reflects whether a MID is present, which is how v1.0 says backup capability is detected.\n\n**New:** a Microgrid Interconnect Device appears among your devices, carrying **Grid State** — the health of the utility supply itself, which the previous firmware did not report.\n\nDismiss this once you have had a look." diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index 76f25f4b..74760c53 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -708,6 +708,10 @@ } }, "issues": { + "new_entities_disabled": { + "title": "New SPAN Panel entities are available", + "description": "This update added data your panel was already reporting but this integration did not yet expose. {count} new entity/entities were created for it (for example: {examples}).\n\nThey were created **disabled**, so nothing appeared in your dashboards or history uninvited. Nothing is broken and no action is required — this notice exists only so the addition is not silent.\n\nTo use one, open the SPAN Panel device page, show its disabled entities, and enable the ones you want. Dismiss this once you have had a look." + }, "panel_upgraded_to_ebus_v1": { "title": "Your SPAN Panel now reports islanding from its Microgrid Interconnect Device", "description": "Your SPAN Panel upgraded to the eBus v1.0 data model. Nothing you rely on has gone away, and no automation changes are required — this is a note about what improved.\n\n**DSM Grid State** keeps its entity ID and its history, and is now more trustworthy. On the previous firmware it was *inferred* — from the battery when one was fitted, otherwise from the dominant power source and whether any power was crossing the grid connection. It now reads the islanding state the Microgrid Interconnect Device (MID) actually senses.\n\n**Grid Islandable** also keeps working. v1.0 publishes no panel-level `grid-islandable` property, so it now reflects whether a MID is present, which is how v1.0 says backup capability is detected.\n\n**New:** a Microgrid Interconnect Device appears among your devices, carrying **Grid State** — the health of the utility supply itself, which the previous firmware did not report.\n\nDismiss this once you have had a look." diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index fdf1faea..6134baec 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -708,6 +708,10 @@ } }, "issues": { + "new_entities_disabled": { + "title": "Hay nuevas entidades del Panel SPAN disponibles", + "description": "Esta actualización ha añadido datos que su panel ya informaba pero que esta integración aún no exponía. Se han creado {count} entidad(es) nueva(s) para ello (por ejemplo: {examples}).\n\nSe han creado **deshabilitadas**, por lo que no ha aparecido nada en sus paneles de control ni en su historial sin haberlo pedido. No hay nada averiado ni es necesaria ninguna acción: este aviso existe solo para que la novedad no pase desapercibida.\n\nPara usar alguna, abra la página del dispositivo Panel SPAN, muestre sus entidades deshabilitadas y habilite las que desee. Descarte este aviso cuando lo haya revisado." + }, "panel_upgraded_to_ebus_v1": { "title": "Su Panel SPAN ahora informa del aislamiento desde su Dispositivo de Interconexión de Microrred", "description": "Su Panel SPAN se ha actualizado al modelo de datos eBus v1.0. Nada de lo que usted utiliza ha desaparecido y no es necesario modificar ninguna automatización: este aviso solo explica lo que ha mejorado.\n\n**Estado de Red DSM** conserva su ID de entidad y su historial, y ahora es más fiable. Con el firmware anterior se *deducía*: a partir de la batería cuando había una instalada y, en su defecto, de la fuente de energía dominante y de si circulaba potencia por la conexión a la red. Ahora lee el estado de aislamiento que el Dispositivo de Interconexión de Microrred (MID) detecta realmente.\n\n**Aislable de la Red** también sigue funcionando. La v1.0 no publica ninguna propiedad `grid-islandable` a nivel de panel, por lo que ahora refleja si hay un MID presente, que es como la v1.0 determina la capacidad de respaldo.\n\n**Novedad:** aparece un Dispositivo de Interconexión de Microrred entre sus dispositivos, con **Estado de la Red**, es decir, el estado del suministro eléctrico, que el firmware anterior no informaba.\n\nPuede descartar este aviso cuando lo haya revisado." diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index 3bb0dab4..f22b4bab 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -708,6 +708,10 @@ } }, "issues": { + "new_entities_disabled": { + "title": "De nouvelles entités du Panneau SPAN sont disponibles", + "description": "Cette mise à jour a ajouté des données que votre panneau signalait déjà mais que cette intégration n'exposait pas encore. {count} nouvelle(s) entité(s) ont été créées pour cela (par exemple : {examples}).\n\nElles ont été créées **désactivées**, afin que rien n'apparaisse dans vos tableaux de bord ni dans votre historique sans votre accord. Rien n'est cassé et aucune action n'est requise : cet avis existe uniquement pour que cet ajout ne soit pas silencieux.\n\nPour en utiliser une, ouvrez la page de l'appareil Panneau SPAN, affichez ses entités désactivées et activez celles que vous souhaitez. Ignorez cet avis une fois que vous y avez jeté un œil." + }, "panel_upgraded_to_ebus_v1": { "title": "Votre Panneau SPAN signale désormais l'îlotage depuis son Dispositif d'Interconnexion de Micro-réseau", "description": "Votre Panneau SPAN est passé au modèle de données eBus v1.0. Rien de ce que vous utilisez n'a disparu et aucune modification de vos automatisations n'est nécessaire : cet avis explique simplement ce qui s'est amélioré.\n\n**État Réseau DSM** conserve son identifiant d'entité et son historique, et gagne en fiabilité. Avec le micrologiciel précédent, il était *déduit* : à partir de la batterie lorsqu'il y en avait une, sinon à partir de la source d'énergie dominante et de la présence de puissance transitant par le raccordement réseau. Il lit désormais l'état d'îlotage réellement mesuré par le Dispositif d'Interconnexion de Micro-réseau (MID).\n\n**Îlotage Réseau Possible** continue également de fonctionner. La v1.0 ne publie aucune propriété `grid-islandable` au niveau du panneau ; ce capteur indique donc maintenant la présence d'un MID, ce qui est la façon dont v1.0 détermine la capacité de secours.\n\n**Nouveau :** un Dispositif d'Interconnexion de Micro-réseau apparaît parmi vos appareils, avec **État du Réseau** — l'état de l'alimentation du réseau public, que le micrologiciel précédent ne signalait pas.\n\nVous pouvez ignorer cet avis après consultation." diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index 8e017e2d..1350b7d0 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -708,6 +708,10 @@ } }, "issues": { + "new_entities_disabled": { + "title": "SPAN パネルの新しいエンティティが利用可能です", + "description": "この更新により、パネルがすでに報告していたものの、この統合がまだ公開していなかったデータが追加されました。そのために {count} 個の新しいエンティティが作成されました (例: {examples})。\n\nこれらは**無効な状態**で作成されているため、ダッシュボードや履歴に無断で何かが現れることはありません。故障ではなく、対応も不要です。この通知は、追加が知らされないままにならないようにするためだけのものです。\n\n使用するには、SPAN パネルのデバイスページを開き、無効なエンティティを表示して、必要なものを有効にしてください。確認が済んだらこの通知を閉じてください。" + }, "panel_upgraded_to_ebus_v1": { "title": "SPAN パネルは自立運転状態をマイクログリッド相互接続装置から取得するようになりました", "description": "SPAN パネルが eBus v1.0 データモデルにアップグレードされました。ご利用中のものが失われることはなく、オートメーションの変更も必要ありません。この通知は改善点をお知らせするものです。\n\n**DSM 系統状態** はエンティティ ID と履歴をそのまま維持し、さらに信頼性が向上しました。以前のファームウェアでは *推定* されていました。バッテリーがある場合はバッテリーから、ない場合は主電源と系統接続を流れる電力の有無から判断していました。現在はマイクログリッド相互接続装置 (MID) が実際に検知した自立運転状態を読み取ります。\n\n**系統自立運転可能** も引き続き動作します。v1.0 ではパネル単位の `grid-islandable` プロパティは公開されないため、現在は MID の有無を示します。これが v1.0 におけるバックアップ能力の判定方法です。\n\n**新規:** デバイス一覧にマイクログリッド相互接続装置が追加され、**系統状態** (電力会社からの供給状態) を報告します。これは以前のファームウェアにはなかった情報です。\n\n内容を確認したら、この通知は閉じて構いません。" diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index ee392d75..d9dbd5e9 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -708,6 +708,10 @@ } }, "issues": { + "new_entities_disabled": { + "title": "Estão disponíveis novas entidades do Painel SPAN", + "description": "Esta atualização acrescentou dados que o seu painel já comunicava mas que esta integração ainda não expunha. Foram criadas {count} nova(s) entidade(s) para o efeito (por exemplo: {examples}).\n\nForam criadas **desativadas**, pelo que nada apareceu nos seus painéis nem no seu histórico sem ter sido pedido. Não há nada avariado e não é necessária qualquer ação: este aviso existe apenas para que a novidade não passe despercebida.\n\nPara usar alguma, abra a página do dispositivo Painel SPAN, mostre as suas entidades desativadas e ative as que quiser. Dispense este aviso depois de o ter visto." + }, "panel_upgraded_to_ebus_v1": { "title": "O seu Painel SPAN comunica agora o ilhamento a partir do Dispositivo de Interligação de Microrrede", "description": "O seu Painel SPAN foi atualizado para o modelo de dados eBus v1.0. Nada do que utiliza desapareceu e não é necessário alterar automações — este aviso apenas explica o que melhorou.\n\n**Estado da Rede DSM** mantém o seu ID de entidade e o seu histórico, e passa a ser mais fiável. No firmware anterior era *inferido*: a partir da bateria quando existia uma e, caso contrário, a partir da fonte de energia dominante e da existência de potência a atravessar a ligação à rede. Agora lê o estado de ilhamento que o Dispositivo de Interligação de Microrrede (MID) realmente deteta.\n\n**Isolável da Rede** também continua a funcionar. A v1.0 não publica qualquer propriedade `grid-islandable` ao nível do painel, pelo que agora reflete a presença de um MID, que é como a v1.0 determina a capacidade de reserva.\n\n**Novidade:** aparece um Dispositivo de Interligação de Microrrede entre os seus dispositivos, com **Estado da Rede** — o estado do fornecimento da rede elétrica, que o firmware anterior não comunicava.\n\nPode dispensar este aviso depois de o consultar." diff --git a/tests/test_new_entity_notice.py b/tests/test_new_entity_notice.py new file mode 100644 index 00000000..7440b2c5 --- /dev/null +++ b/tests/test_new_entity_notice.py @@ -0,0 +1,336 @@ +"""An entity that arrives disabled must not arrive silently. + +`battery.part_number` shipped with `entity_registry_enabled_default=False` so +that upgrading would not grow anybody's entity list uninvited. It worked, and +the cost was that nothing whatsoever told the user the sensor now existed -- +they found it by opening the device's disabled-entity list on a hunch. + +These cover the notice that closes that gap, and the four ways it could be worse +than nothing: shouting on a first install, being swallowed by an earlier +dismissal, nagging on every restart, or vanishing unread at the first one. +""" + +from __future__ import annotations + +from homeassistant.helpers import entity_registry as er, issue_registry as ir +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.schema_repairs import ( + _new_entities_id, + async_clear_schema_issues, + async_notice_new_disabled_entities, + async_registered_unique_ids, + async_sync_schema_issues, +) +from custom_components.span_panel.schema_validation import SchemaFindings + +_PART_NUMBER = "sp3-001_bess_part_number" + + +@pytest.fixture +def entry(hass) -> MockConfigEntry: + """Return a config entry in hass. No conftest fixture exists for this.""" + mock = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sp3-001") + mock.add_to_hass(hass) + return mock + + +def _register( + hass, + entry: MockConfigEntry, + unique_id: str, + *, + disabled: bool = True, + name: str | None = None, +) -> er.RegistryEntry: + """Register one entity the way a platform would.""" + return er.async_get(hass).async_get_or_create( + "sensor", + DOMAIN, + unique_id, + config_entry=entry, + original_name=name, + disabled_by=er.RegistryEntryDisabler.INTEGRATION if disabled else None, + ) + + +def _notices(hass, entry: MockConfigEntry) -> list[ir.IssueEntry]: + prefix = f"new_entities_{entry.entry_id}_" + return [ + issue + for (domain, issue_id), issue in ir.async_get(hass).issues.items() + if domain == DOMAIN and issue_id.startswith(prefix) + ] + + +# --- The gap this closes -------------------------------------------------- + + +async def test_a_new_disabled_entity_raises_one_notice_naming_it(hass, entry) -> None: + """The `battery.part_number` case, end to end.""" + _register(hass, entry, "sp3-001_bess_serial_number", disabled=False, name="Serial Number") + known = async_registered_unique_ids(hass, entry) + + _register(hass, entry, _PART_NUMBER, name="Part Number") + async_notice_new_disabled_entities(hass, entry, known) + + notices = _notices(hass, entry) + assert len(notices) == 1 + assert notices[0].translation_key == "new_entities_disabled" + assert notices[0].translation_placeholders == {"count": "1", "examples": "Part Number"} + + +async def test_the_notice_is_informational_and_not_fixable(hass, entry) -> None: + """Nothing is broken; data became available. + + `IssueSeverity` has no informational member -- CRITICAL, ERROR, WARNING -- + so WARNING is the mildest Home Assistant offers, and is what the equally + action-free `panel_upgraded_to_ebus_v1` notice settled on. + """ + _register(hass, entry, "sp3-001_bess_serial_number", disabled=False) + known = async_registered_unique_ids(hass, entry) + _register(hass, entry, _PART_NUMBER, name="Part Number") + + async_notice_new_disabled_entities(hass, entry, known) + + notice = _notices(hass, entry)[0] + assert notice.severity is ir.IssueSeverity.WARNING + assert notice.is_fixable is False + + +async def test_the_notice_falls_back_to_the_entity_id_when_unnamed(hass, entry) -> None: + """A disabled entity has no state, so there is no friendly name to read.""" + _register(hass, entry, "sp3-001_bess_serial_number", disabled=False) + known = async_registered_unique_ids(hass, entry) + registered = _register(hass, entry, _PART_NUMBER, name=None) + + async_notice_new_disabled_entities(hass, entry, known) + + assert _notices(hass, entry)[0].translation_placeholders["examples"] == registered.entity_id + + +# --- Silent on a first install -------------------------------------------- + + +async def test_a_first_install_raises_no_notice(hass, entry) -> None: + """Everything is new, so naming it would name the whole integration.""" + known = async_registered_unique_ids(hass, entry) + assert known == frozenset() + + _register(hass, entry, _PART_NUMBER, name="Part Number") + _register(hass, entry, "sp3-001_bess_model", name="Model") + async_notice_new_disabled_entities(hass, entry, known) + + assert _notices(hass, entry) == [] + + +async def test_another_entrys_history_does_not_make_this_one_established(hass) -> None: + """The probe is scoped per entry, so a second panel still installs quietly.""" + established = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sp3-old") + established.add_to_hass(hass) + _register(hass, established, "sp3-old_bess_serial_number", disabled=False) + + fresh = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sp3-new") + fresh.add_to_hass(hass) + known = async_registered_unique_ids(hass, fresh) + _register(hass, fresh, "sp3-new_bess_part_number", name="Part Number") + + async_notice_new_disabled_entities(hass, fresh, known) + + assert _notices(hass, fresh) == [] + + +# --- Only the invisible additions ----------------------------------------- + + +async def test_an_entity_that_is_enabled_by_default_raises_no_notice(hass, entry) -> None: + """It shows up in the user's entity list on its own; a notice is noise.""" + _register(hass, entry, "sp3-001_bess_serial_number", disabled=False) + known = async_registered_unique_ids(hass, entry) + + _register(hass, entry, "sp3-001_bess_model", disabled=False, name="Model") + async_notice_new_disabled_entities(hass, entry, known) + + assert _notices(hass, entry) == [] + + +async def test_a_user_disabled_entity_is_not_a_new_addition(hass, entry) -> None: + """Only `INTEGRATION` means "we shipped it switched off".""" + _register(hass, entry, "sp3-001_bess_serial_number", disabled=False) + known = async_registered_unique_ids(hass, entry) + + er.async_get(hass).async_get_or_create( + "sensor", + DOMAIN, + "sp3-001_bess_model", + config_entry=entry, + disabled_by=er.RegistryEntryDisabler.USER, + ) + async_notice_new_disabled_entities(hass, entry, known) + + assert _notices(hass, entry) == [] + + +async def test_the_named_entities_are_bounded_and_counted(hass, entry) -> None: + """The same rule the defect Repairs follow: a few names plus a count.""" + _register(hass, entry, "sp3-001_bess_serial_number", disabled=False) + known = async_registered_unique_ids(hass, entry) + + for n in range(12): + _register(hass, entry, f"sp3-001_new_{n:02d}", name=f"New Sensor {n:02d}") + async_notice_new_disabled_entities(hass, entry, known) + + placeholders = _notices(hass, entry)[0].translation_placeholders + assert placeholders["count"] == "12" + assert placeholders["examples"].count(",") < 5 + assert "New Sensor 00" in placeholders["examples"] + + +# --- Dismissing one set must not swallow a later, different set ----------- + + +async def test_a_dismissed_notice_does_not_swallow_a_later_addition(hass, entry) -> None: + """The trap that forced per-field ids on the degradation Repairs. + + `async_get_or_create`'s update branch preserves `dismissed_version` while + replacing the placeholders, so an entry-wide id would rewrite the notice the + user put away rather than raising a new one. + """ + _register(hass, entry, "sp3-001_bess_serial_number", disabled=False) + + known = async_registered_unique_ids(hass, entry) + _register(hass, entry, _PART_NUMBER, name="Part Number") + async_notice_new_disabled_entities(hass, entry, known) + + first = _notices(hass, entry)[0] + ir.async_ignore_issue(hass, DOMAIN, first.issue_id, True) + assert ir.async_get(hass).async_get_issue(DOMAIN, first.issue_id).dismissed_version + + known = async_registered_unique_ids(hass, entry) + _register(hass, entry, "sp3-001_bess_cell_temperature", name="Cell Temperature") + async_notice_new_disabled_entities(hass, entry, known) + + by_id = {notice.issue_id: notice for notice in _notices(hass, entry)} + assert len(by_id) == 2 + second = next(issue for issue_id, issue in by_id.items() if issue_id != first.issue_id) + assert second.dismissed_version is None + assert second.translation_placeholders["examples"] == "Cell Temperature" + assert by_id[first.issue_id].dismissed_version is not None + + +async def test_the_id_is_keyed_on_the_exact_set(hass, entry) -> None: + """Two different sets produce two different ids; the same set, one id.""" + assert _new_entities_id("e1", ["a"]) != _new_entities_id("e1", ["a", "b"]) + assert _new_entities_id("e1", ["a"]) != _new_entities_id("e2", ["a"]) + assert _new_entities_id("e1", ["b", "a"]) == _new_entities_id("e1", ["a", "b"]) + + +# --- An event, not a condition -------------------------------------------- + + +async def test_the_notice_is_persistent(hass, entry) -> None: + """It cannot be re-derived, so it has to survive the restart itself. + + On the next startup the entity is in the known set and the diff is empty by + construction. A non-persistent issue reloads as a tombstone, so this notice + would disappear unread at the first restart after the upgrade -- the same + silent add it exists to prevent, one step later. + """ + _register(hass, entry, "sp3-001_bess_serial_number", disabled=False) + known = async_registered_unique_ids(hass, entry) + _register(hass, entry, _PART_NUMBER, name="Part Number") + + async_notice_new_disabled_entities(hass, entry, known) + + assert _notices(hass, entry)[0].is_persistent is True + + +async def test_a_restart_with_no_new_entities_neither_duplicates_nor_re_raises(hass, entry) -> None: + """The event property, exercised the way the field will exercise it.""" + _register(hass, entry, "sp3-001_bess_serial_number", disabled=False) + known = async_registered_unique_ids(hass, entry) + _register(hass, entry, _PART_NUMBER, name="Part Number") + async_notice_new_disabled_entities(hass, entry, known) + + raised = _notices(hass, entry)[0] + created = raised.created + + for _ in range(3): + # Each pass is a setup: probe the registry, forward nothing new, notice. + async_notice_new_disabled_entities(hass, entry, async_registered_unique_ids(hass, entry)) + + notices = _notices(hass, entry) + assert len(notices) == 1 + assert notices[0].issue_id == raised.issue_id + assert notices[0].created == created + + +async def test_a_dismissed_notice_is_not_resurrected_by_a_restart(hass, entry) -> None: + """Dismissing it must end it, not defer it to the next startup.""" + _register(hass, entry, "sp3-001_bess_serial_number", disabled=False) + known = async_registered_unique_ids(hass, entry) + _register(hass, entry, _PART_NUMBER, name="Part Number") + async_notice_new_disabled_entities(hass, entry, known) + + issue_id = _notices(hass, entry)[0].issue_id + ir.async_ignore_issue(hass, DOMAIN, issue_id, True) + dismissed = ir.async_get(hass).async_get_issue(DOMAIN, issue_id).dismissed_version + + async_notice_new_disabled_entities(hass, entry, async_registered_unique_ids(hass, entry)) + + assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id).dismissed_version == dismissed + + +async def test_the_defect_reconcile_pass_does_not_delete_the_notice(hass, entry) -> None: + """`_ours` is scoped to the two defect prefixes on purpose. + + The reconcile pass deletes every id it did not re-derive. This notice is + derived exactly once, so a shared scope would delete it on the same startup + that raised it. + """ + _register(hass, entry, "sp3-001_bess_serial_number", disabled=False) + known = async_registered_unique_ids(hass, entry) + _register(hass, entry, _PART_NUMBER, name="Part Number") + async_notice_new_disabled_entities(hass, entry, known) + issue_id = _notices(hass, entry)[0].issue_id + + async_sync_schema_issues(hass, entry, SchemaFindings(frozenset(), (), frozenset()), {}) + + assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is not None + + +# --- Removal --------------------------------------------------------------- + + +async def test_the_notice_is_cleared_when_the_entry_is_removed(hass, entry) -> None: + """Persistent, so a restart would not even demote it to a tombstone.""" + _register(hass, entry, "sp3-001_bess_serial_number", disabled=False) + known = async_registered_unique_ids(hass, entry) + _register(hass, entry, _PART_NUMBER, name="Part Number") + async_notice_new_disabled_entities(hass, entry, known) + assert _notices(hass, entry) + + async_clear_schema_issues(hass, entry) + + assert _notices(hass, entry) == [] + + +async def test_removing_one_entry_leaves_another_entrys_notice(hass) -> None: + """Two panels share the domain; one leaving must not silence the other.""" + kept = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sp3-kept") + kept.add_to_hass(hass) + removed = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sp3-removed") + removed.add_to_hass(hass) + + for panel in (kept, removed): + _register(hass, panel, f"{panel.unique_id}_bess_serial_number", disabled=False) + known = async_registered_unique_ids(hass, panel) + _register(hass, panel, f"{panel.unique_id}_bess_part_number", name="Part Number") + async_notice_new_disabled_entities(hass, panel, known) + + async_clear_schema_issues(hass, removed) + + assert _notices(hass, kept) + assert _notices(hass, removed) == [] diff --git a/tests/test_setup_entry.py b/tests/test_setup_entry.py index 04842c66..18f7e15a 100644 --- a/tests/test_setup_entry.py +++ b/tests/test_setup_entry.py @@ -24,6 +24,7 @@ ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .factories import SpanPanelSnapshotFactory @@ -338,3 +339,71 @@ async def _forward(*_args, **_kwargs) -> None: assert await async_setup_entry(hass, entry) is True assert order == ["forward", "sync"] + + +async def test_setup_probes_the_registry_before_the_platforms_and_notices_after( + hass: HomeAssistant, +) -> None: + """The new-entity notice brackets the forward, and both halves matter. + + The probe has to run before forwarding, because forwarding is what registers + the entities -- probing afterwards would find every entity already known and + the notice could never fire. The notice has to run after, because a newly + added entity is only in the registry once its platform has added it. + """ + entry = _create_v2_entry() + entry.add_to_hass(hass) + snapshot = SpanPanelSnapshotFactory.create(serial_number="sp3-setup-001") + client = MagicMock() + client.connect = AsyncMock() + coordinator = MagicMock() + coordinator.async_config_entry_first_refresh = AsyncMock() + coordinator.async_setup_streaming = AsyncMock() + coordinator.data = snapshot + + registry = er.async_get(hass) + registry.async_get_or_create("sensor", DOMAIN, "already-there", config_entry=entry) + + order: list[str] = [] + coordinator.async_sync_schema_repairs = MagicMock(side_effect=lambda: order.append("sync")) + + async def _forward(*_args, **_kwargs) -> None: + order.append("forward") + registry.async_get_or_create( + "sensor", + DOMAIN, + "added-by-the-forward", + config_entry=entry, + disabled_by=er.RegistryEntryDisabler.INTEGRATION, + ) + + seen: list[frozenset[str]] = [] + + def _notice(_hass, _entry, known_unique_ids) -> None: + order.append("notice") + seen.append(frozenset(known_unique_ids)) + + with ( + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + patch( + "custom_components.span_panel.SpanPanelCoordinator", + return_value=coordinator, + ), + patch( + "custom_components.span_panel.ensure_device_registered", + AsyncMock(return_value="panel-device-id"), + ), + patch.object( + hass.config_entries, "async_forward_entry_setups", AsyncMock(side_effect=_forward) + ), + patch.object(hass.config_entries, "async_update_entry"), + patch( + "custom_components.span_panel.async_notice_new_disabled_entities", + side_effect=_notice, + ), + ): + assert await async_setup_entry(hass, entry) is True + + assert order == ["forward", "sync", "notice"] + assert seen == [frozenset({"already-there"})] From 488b2fe03cddb7c9ccca66b47e07637e450ee819 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:23:05 -0700 Subject: [PATCH 039/345] fix(repairs): state the observation, not a cause the trigger cannot establish The new-entity notice fires on any entity newly registered for the entry with disabled_by INTEGRATION, but the text asserted one specific cause: "This update added data your panel was already reporting but this integration did not yet expose." Three ordinary paths fire it with no update involved. A breaker added to an established panel brings a per-circuit CIRCUIT_BREAKER_RATING_SENSOR, which is disabled by default. Enabling solar in the options flow is the user's own deliberate action, reported back to them as something an update did. Attaching a BESS brings BESS_METADATA_SENSORS with it. A feature whose purpose is telling the user the truth about an addition must not tell some of them something false. The text now states what was observed and offers the causes as possibilities. Rewritten in strings.json and all five locales in the same pass -- a partial edit would leave some users with the false claim. Also from review, both cheap: The already-raised guard was unobservable and its comment oversold it. It does not prevent duplication -- the shared id does that. It prevents a repeat of the same set from rewriting the text of a notice the user has already read, via async_get_or_create's update branch. A test now pins that through the one path where the diff can repeat: an entity removed from the registry and later re-registered under a different name. _ours and _new_entity_notices were the same function twice, now one _scoped_issue_ids taking the prefixes it may see. The reconcile pass passes the defect prefixes only, which is the reason the scope is a parameter at all; removal passes every prefix. --- .../span_panel/schema_repairs.py | 55 ++++++++++--------- custom_components/span_panel/strings.json | 2 +- .../span_panel/translations/en.json | 2 +- .../span_panel/translations/es.json | 2 +- .../span_panel/translations/fr.json | 2 +- .../span_panel/translations/ja.json | 2 +- .../span_panel/translations/pt.json | 2 +- tests/test_new_entity_notice.py | 28 ++++++++++ 8 files changed, 63 insertions(+), 32 deletions(-) diff --git a/custom_components/span_panel/schema_repairs.py b/custom_components/span_panel/schema_repairs.py index 9511fed6..b45b7106 100644 --- a/custom_components/span_panel/schema_repairs.py +++ b/custom_components/span_panel/schema_repairs.py @@ -40,6 +40,9 @@ _MAX_EXAMPLES = 3 +# Issue-id prefixes. `_DEFECT_PREFIXES` is what the reconcile pass owns; the +# new-entity notice is deliberately not among them. See `_scoped_issue_ids`. +_DEFECT_PREFIXES = ("unresolved_", "unit_mismatch_") _NEW_ENTITIES_PREFIX = "new_entities_" @@ -177,7 +180,9 @@ def async_sync_schema_issues( }, ) - for issue_id in _ours(registry, entry.entry_id) - wanted: + # Defect prefixes only: everything in this scope is re-derived on every pass, + # and the new-entity notice is not re-derivable at all. + for issue_id in _scoped_issue_ids(registry, entry.entry_id, _DEFECT_PREFIXES) - wanted: ir.async_delete_issue(hass, DOMAIN, issue_id) if wanted: @@ -250,12 +255,15 @@ def async_notice_new_disabled_entities( the notice exists to prevent. * It is never reconciled away. `async_sync_schema_issues` deletes the defect ids it does not re-derive, and applying that here would delete this notice - on the very next startup. `_ours` is deliberately scoped to the two defect - prefixes so the reconcile pass cannot reach these ids. - * Raising is skipped outright when the id already exists, so the notice is - asserted once and then left alone. Nothing re-derives it, so an update pass - could only rewrite the placeholders of a notice the user has already read - or dismissed. + on the very next startup. Its reconcile scope is `_DEFECT_PREFIXES`, which + deliberately does not include this one. + * Raising is skipped outright when the id already exists. What that buys is + narrow and worth stating exactly: a repeat of the same set cannot rewrite + the text of a notice the user has already read. Without it the repeat would + take `async_get_or_create`'s update branch, which replaces the placeholders + — so a set that came back with a renamed entity would silently restate + itself. It buys nothing against duplication, which the shared id already + rules out. Severity is the mildest Home Assistant offers. `IssueSeverity` has no informational member — it is CRITICAL, ERROR, WARNING — so WARNING is the @@ -300,33 +308,27 @@ def async_notice_new_disabled_entities( ) -def _ours(registry: ir.IssueRegistry, entry_id: str) -> set[str]: - """Return the reconcilable issue ids for ONE config entry. +def _scoped_issue_ids( + registry: ir.IssueRegistry, entry_id: str, prefixes: tuple[str, ...] +) -> set[str]: + """Return this entry's issue ids under the given id prefixes. Scoping by entry is not cosmetic: with a shared namespace, a healthy panel's reconcile pass would delete a degraded panel's issues on every cycle, and removing one panel would clear every panel's issues. - Scoping to the two defect prefixes is not cosmetic either. Everything in here - is re-derived on every pass and deleted when it stops being derived; the - new-entity notice is derived exactly once and must survive a pass that cannot - re-derive it. See `_new_entity_notices`. + Which prefixes is not cosmetic either, and is why this takes them rather than + answering for the whole domain. The reconcile pass deletes every id it did + not re-derive, so it may only ever see `_DEFECT_PREFIXES`; the new-entity + notice is derived exactly once and would not survive being reconciled against + a pass that cannot re-derive it. Removal, which deletes unconditionally, is + the one caller that passes every prefix. """ - prefixes = (f"unresolved_{entry_id}_", f"unit_mismatch_{entry_id}_") + scoped = tuple(f"{prefix}{entry_id}_" for prefix in prefixes) return { issue_id for (domain, issue_id) in registry.issues - if domain == DOMAIN and issue_id.startswith(prefixes) - } - - -def _new_entity_notices(registry: ir.IssueRegistry, entry_id: str) -> set[str]: - """Return this entry's new-entity notices. Cleared on removal, never reconciled.""" - prefix = f"{_NEW_ENTITIES_PREFIX}{entry_id}_" - return { - issue_id - for (domain, issue_id) in registry.issues - if domain == DOMAIN and issue_id.startswith(prefix) + if domain == DOMAIN and issue_id.startswith(scoped) } @@ -340,5 +342,6 @@ def async_clear_schema_issues(hass: HomeAssistant, entry: ConfigEntry) -> None: standing forever. """ registry = ir.async_get(hass) - for issue_id in _ours(registry, entry.entry_id) | _new_entity_notices(registry, entry.entry_id): + every_prefix = (*_DEFECT_PREFIXES, _NEW_ENTITIES_PREFIX) + for issue_id in _scoped_issue_ids(registry, entry.entry_id, every_prefix): ir.async_delete_issue(hass, DOMAIN, issue_id) diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index 74760c53..c839aad1 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -710,7 +710,7 @@ "issues": { "new_entities_disabled": { "title": "New SPAN Panel entities are available", - "description": "This update added data your panel was already reporting but this integration did not yet expose. {count} new entity/entities were created for it (for example: {examples}).\n\nThey were created **disabled**, so nothing appeared in your dashboards or history uninvited. Nothing is broken and no action is required — this notice exists only so the addition is not silent.\n\nTo use one, open the SPAN Panel device page, show its disabled entities, and enable the ones you want. Dismiss this once you have had a look." + "description": "{count} new entity/entities have appeared for your SPAN Panel (for example: {examples}). They were created **disabled**, so nothing appeared in your dashboards or history uninvited.\n\nEntities can appear for several reasons: an integration update that exposes more of what your panel reports, a circuit added to the panel, or hardware such as a battery or solar coming online. Nothing is broken and no action is required — this notice exists only so the addition is not silent.\n\nTo use one, open the SPAN Panel device page, show its disabled entities, and enable the ones you want. Dismiss this once you have had a look." }, "panel_upgraded_to_ebus_v1": { "title": "Your SPAN Panel now reports islanding from its Microgrid Interconnect Device", diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index 74760c53..c839aad1 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -710,7 +710,7 @@ "issues": { "new_entities_disabled": { "title": "New SPAN Panel entities are available", - "description": "This update added data your panel was already reporting but this integration did not yet expose. {count} new entity/entities were created for it (for example: {examples}).\n\nThey were created **disabled**, so nothing appeared in your dashboards or history uninvited. Nothing is broken and no action is required — this notice exists only so the addition is not silent.\n\nTo use one, open the SPAN Panel device page, show its disabled entities, and enable the ones you want. Dismiss this once you have had a look." + "description": "{count} new entity/entities have appeared for your SPAN Panel (for example: {examples}). They were created **disabled**, so nothing appeared in your dashboards or history uninvited.\n\nEntities can appear for several reasons: an integration update that exposes more of what your panel reports, a circuit added to the panel, or hardware such as a battery or solar coming online. Nothing is broken and no action is required — this notice exists only so the addition is not silent.\n\nTo use one, open the SPAN Panel device page, show its disabled entities, and enable the ones you want. Dismiss this once you have had a look." }, "panel_upgraded_to_ebus_v1": { "title": "Your SPAN Panel now reports islanding from its Microgrid Interconnect Device", diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index 6134baec..795b897f 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -710,7 +710,7 @@ "issues": { "new_entities_disabled": { "title": "Hay nuevas entidades del Panel SPAN disponibles", - "description": "Esta actualización ha añadido datos que su panel ya informaba pero que esta integración aún no exponía. Se han creado {count} entidad(es) nueva(s) para ello (por ejemplo: {examples}).\n\nSe han creado **deshabilitadas**, por lo que no ha aparecido nada en sus paneles de control ni en su historial sin haberlo pedido. No hay nada averiado ni es necesaria ninguna acción: este aviso existe solo para que la novedad no pase desapercibida.\n\nPara usar alguna, abra la página del dispositivo Panel SPAN, muestre sus entidades deshabilitadas y habilite las que desee. Descarte este aviso cuando lo haya revisado." + "description": "Han aparecido {count} entidad(es) nueva(s) en su Panel SPAN (por ejemplo: {examples}). Se han creado **deshabilitadas**, por lo que no ha aparecido nada en sus paneles de control ni en su historial sin haberlo pedido.\n\nPueden aparecer entidades nuevas por varios motivos: una actualización de la integración que expone más de lo que informa su panel, un circuito añadido al panel, o hardware como una batería o energía solar que se pone en marcha. No hay nada averiado ni es necesaria ninguna acción: este aviso existe solo para que la novedad no pase desapercibida.\n\nPara usar alguna, abra la página del dispositivo Panel SPAN, muestre sus entidades deshabilitadas y habilite las que desee. Descarte este aviso cuando lo haya revisado." }, "panel_upgraded_to_ebus_v1": { "title": "Su Panel SPAN ahora informa del aislamiento desde su Dispositivo de Interconexión de Microrred", diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index f22b4bab..44b0fa65 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -710,7 +710,7 @@ "issues": { "new_entities_disabled": { "title": "De nouvelles entités du Panneau SPAN sont disponibles", - "description": "Cette mise à jour a ajouté des données que votre panneau signalait déjà mais que cette intégration n'exposait pas encore. {count} nouvelle(s) entité(s) ont été créées pour cela (par exemple : {examples}).\n\nElles ont été créées **désactivées**, afin que rien n'apparaisse dans vos tableaux de bord ni dans votre historique sans votre accord. Rien n'est cassé et aucune action n'est requise : cet avis existe uniquement pour que cet ajout ne soit pas silencieux.\n\nPour en utiliser une, ouvrez la page de l'appareil Panneau SPAN, affichez ses entités désactivées et activez celles que vous souhaitez. Ignorez cet avis une fois que vous y avez jeté un œil." + "description": "{count} nouvelle(s) entité(s) sont apparues pour votre Panneau SPAN (par exemple : {examples}). Elles ont été créées **désactivées**, afin que rien n'apparaisse dans vos tableaux de bord ni dans votre historique sans votre accord.\n\nDe nouvelles entités peuvent apparaître pour plusieurs raisons : une mise à jour de l'intégration qui expose davantage de ce que votre panneau signale, un circuit ajouté au panneau, ou du matériel tel qu'une batterie ou du solaire qui entre en service. Rien n'est cassé et aucune action n'est requise : cet avis existe uniquement pour que cet ajout ne soit pas silencieux.\n\nPour en utiliser une, ouvrez la page de l'appareil Panneau SPAN, affichez ses entités désactivées et activez celles que vous souhaitez. Ignorez cet avis une fois que vous y avez jeté un œil." }, "panel_upgraded_to_ebus_v1": { "title": "Votre Panneau SPAN signale désormais l'îlotage depuis son Dispositif d'Interconnexion de Micro-réseau", diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index 1350b7d0..7c2bf0c2 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -710,7 +710,7 @@ "issues": { "new_entities_disabled": { "title": "SPAN パネルの新しいエンティティが利用可能です", - "description": "この更新により、パネルがすでに報告していたものの、この統合がまだ公開していなかったデータが追加されました。そのために {count} 個の新しいエンティティが作成されました (例: {examples})。\n\nこれらは**無効な状態**で作成されているため、ダッシュボードや履歴に無断で何かが現れることはありません。故障ではなく、対応も不要です。この通知は、追加が知らされないままにならないようにするためだけのものです。\n\n使用するには、SPAN パネルのデバイスページを開き、無効なエンティティを表示して、必要なものを有効にしてください。確認が済んだらこの通知を閉じてください。" + "description": "SPAN パネルに {count} 個の新しいエンティティが現れました (例: {examples})。これらは**無効な状態**で作成されているため、ダッシュボードや履歴に無断で何かが現れることはありません。\n\n新しいエンティティが現れる理由はいくつかあります。パネルが報告する内容をより多く公開する統合の更新、パネルに追加された回路、あるいはバッテリーや太陽光などのハードウェアの稼働開始などです。故障ではなく、対応も不要です。この通知は、追加が知らされないままにならないようにするためだけのものです。\n\n使用するには、SPAN パネルのデバイスページを開き、無効なエンティティを表示して、必要なものを有効にしてください。確認が済んだらこの通知を閉じてください。" }, "panel_upgraded_to_ebus_v1": { "title": "SPAN パネルは自立運転状態をマイクログリッド相互接続装置から取得するようになりました", diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index d9dbd5e9..f2db3d6c 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -710,7 +710,7 @@ "issues": { "new_entities_disabled": { "title": "Estão disponíveis novas entidades do Painel SPAN", - "description": "Esta atualização acrescentou dados que o seu painel já comunicava mas que esta integração ainda não expunha. Foram criadas {count} nova(s) entidade(s) para o efeito (por exemplo: {examples}).\n\nForam criadas **desativadas**, pelo que nada apareceu nos seus painéis nem no seu histórico sem ter sido pedido. Não há nada avariado e não é necessária qualquer ação: este aviso existe apenas para que a novidade não passe despercebida.\n\nPara usar alguma, abra a página do dispositivo Painel SPAN, mostre as suas entidades desativadas e ative as que quiser. Dispense este aviso depois de o ter visto." + "description": "Apareceram {count} nova(s) entidade(s) no seu Painel SPAN (por exemplo: {examples}). Foram criadas **desativadas**, pelo que nada apareceu nos seus painéis nem no seu histórico sem ter sido pedido.\n\nPodem aparecer novas entidades por vários motivos: uma atualização da integração que expõe mais do que o seu painel comunica, um circuito acrescentado ao painel, ou hardware como uma bateria ou energia solar que entra em funcionamento. Não há nada avariado e não é necessária qualquer ação: este aviso existe apenas para que a novidade não passe despercebida.\n\nPara usar alguma, abra a página do dispositivo Painel SPAN, mostre as suas entidades desativadas e ative as que quiser. Dispense este aviso depois de o ter visto." }, "panel_upgraded_to_ebus_v1": { "title": "O seu Painel SPAN comunica agora o ilhamento a partir do Dispositivo de Interligação de Microrrede", diff --git a/tests/test_new_entity_notice.py b/tests/test_new_entity_notice.py index 7440b2c5..78da5ef8 100644 --- a/tests/test_new_entity_notice.py +++ b/tests/test_new_entity_notice.py @@ -267,6 +267,34 @@ async def test_a_restart_with_no_new_entities_neither_duplicates_nor_re_raises(h assert notices[0].created == created +async def test_a_repeat_of_the_same_set_cannot_rewrite_the_notice(hass, entry) -> None: + """What the already-raised guard actually buys. + + It buys nothing against duplication -- the shared id rules that out. It buys + exactly this: a set that comes back cannot silently restate a notice the user + has already read. Without the guard the repeat takes `async_get_or_create`'s + update branch, which replaces the placeholders in place. + + The set comes back when an entity is removed from the registry and later + re-registered -- a BESS taken off the panel and put back -- because the + unique_ids, and therefore the digest, are unchanged. + """ + _register(hass, entry, "sp3-001_bess_serial_number", disabled=False) + known = async_registered_unique_ids(hass, entry) + added = _register(hass, entry, _PART_NUMBER, name="Part Number") + async_notice_new_disabled_entities(hass, entry, known) + issue_id = _notices(hass, entry)[0].issue_id + + er.async_get(hass).async_remove(added.entity_id) + _register(hass, entry, _PART_NUMBER, name="BESS Part Number") + async_notice_new_disabled_entities(hass, entry, known) + + notices = _notices(hass, entry) + assert len(notices) == 1 + assert notices[0].issue_id == issue_id + assert notices[0].translation_placeholders["examples"] == "Part Number" + + async def test_a_dismissed_notice_is_not_resurrected_by_a_restart(hass, entry) -> None: """Dismissing it must end it, not defer it to the next startup.""" _register(hass, entry, "sp3-001_bess_serial_number", disabled=False) From 86e477ccdb02faa45d8e416ccc19ff0e62525f3f Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:13:03 -0700 Subject: [PATCH 040/345] test(field-paths): assert every exemption's reason against both adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `RESIDUAL_EXEMPT_PATHS` was 26 entries whose reason for being exempt lived in a prose comment that nothing executed. An entry could be mislabelled from the day it was written, go stale when the library changed what it publishes, or become producible by both adapters — at which point it should be promoted to a declaration — and no test would notice any of it. Make the reason part of the data: `Producibility` (NEITHER / SCHEMA_0_ONLY / SCHEMA_1_ONLY, with no BOTH member because such a path is not exemptible), and `RESIDUAL_EXEMPT_PATHS` as a Mapping from path to that annotation. Four new conformance tests build both adapters' metadata from the vendored fixtures and check each annotation against it, demand promotion for anything both adapters produce, pin the inventory so an entry cannot quietly disappear, and check each exempt path still has a reader in the package source. Bookkeeping only: `declared_field_paths()` returns the same 47 paths, verified by diffing the dumped set against the previous commit's. The sole runtime consumer needs the key view (schema_validation.py:110). --- custom_components/span_panel/field_paths.py | 142 ++++++++++-------- .../span_panel/schema_validation.py | 8 +- tests/test_field_path_conformance.py | 126 +++++++++++++++- tests/test_field_paths.py | 4 +- 4 files changed, 212 insertions(+), 68 deletions(-) diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py index 8a8ac2ea..25b5cb0b 100644 --- a/custom_components/span_panel/field_paths.py +++ b/custom_components/span_panel/field_paths.py @@ -15,8 +15,10 @@ from __future__ import annotations -from collections.abc import Iterable, Iterator +from collections.abc import Iterable, Iterator, Mapping from dataclasses import dataclass +from enum import Enum +from types import MappingProxyType from homeassistant.helpers.entity import EntityDescription @@ -91,80 +93,96 @@ class FieldPathDeclarationMixin: """ -RESIDUAL_EXEMPT_PATHS: frozenset[str] = frozenset( +class Producibility(Enum): + """Which adapters publish a metadata row for an exempt residual path. + + There is deliberately no `BOTH` member. A path both adapters produce + satisfies the producible gate, so it belongs in `declared_field_paths()` + rather than in an exemption; `test_no_exempt_path_is_producible_by_both` + turns that missing member into a failure naming the path to promote. + """ + + NEITHER = "neither" + """No metadata row on either adapter.""" + + SCHEMA_0_ONLY = "schema_0_only" + """Produced by the schema_0 adapter, absent from schema_1.""" + + SCHEMA_1_ONLY = "schema_1_only" + """Produced by the schema_1 adapter, absent from schema_0.""" + + +RESIDUAL_EXEMPT_PATHS: Mapping[str, Producibility] = MappingProxyType( { # Homie `$target` values — a pending-command echo, not a schema field. - # Neither adapter publishes a metadata row for them. - "circuit.relay_state_target", - "circuit.priority_target", + "circuit.relay_state_target": Producibility.NEITHER, + "circuit.priority_target": Producibility.NEITHER, # Assembled by the library from panel topology rather than read from a - # schema property; no metadata row in either adapter. - "circuit.device_type", - "circuit.relative_position", - # No metadata row in either adapter — the panel reports it outside the - # typed field surface. - "panel.panel_size", + # schema property. + "circuit.device_type": Producibility.NEITHER, + "circuit.relative_position": Producibility.NEITHER, + # The panel reports it outside the typed field surface. + "panel.panel_size": Producibility.NEITHER, # The panel identity key behind every unique_id and the panel DeviceInfo - # (~30 read sites). Neither adapter publishes a row for it. - "panel.serial_number", - # Gates button availability at button.py:115. No row in either adapter — - # the same reason the `dsm_state` sensor is `derived=True`. - "panel.dsm_state", + # (~30 read sites). + "panel.serial_number": Producibility.NEITHER, + # Gates button availability at button.py:115 — the same reason the + # `dsm_state` sensor is `derived=True`. + "panel.dsm_state": Producibility.NEITHER, # The circuit's own identity key, used for lookups and id construction - # (helpers.py, coordinator.py, entity_resolver.py). No row in either - # adapter. - "circuit.circuit_id", - # Neither adapter emits any `mid.*` metadata rows at all; util.py reads - # these off the MID snapshot for device_info, and sensor_panel.py reads - # the grid-forming name for an attribute. - "mid.hardware_version", - "mid.software_version", - "mid.vendor_name", - "mid.model", - "mid.serial_number", - "mid.grid_forming_device_name", + # (helpers.py, coordinator.py, entity_resolver.py). + "circuit.circuit_id": Producibility.NEITHER, + # util.py reads these off the MID snapshot for device_info, and + # sensor_panel.py reads the grid-forming name for an attribute. + "mid.hardware_version": Producibility.NEITHER, + "mid.software_version": Producibility.NEITHER, + "mid.vendor_name": Producibility.NEITHER, + "mid.model": Producibility.NEITHER, + "mid.serial_number": Producibility.NEITHER, + "mid.grid_forming_device_name": Producibility.NEITHER, # The EVSE's Homie node id — an addressing handle used to build the - # sub-device identifier, not a published field. No row in either adapter. - "evse.node_id", - # Schema-conditional: schema_1 publishes it, schema_0 has no row. - "circuit.is_user_controllable", - # Schema-conditional: schema_0 publishes these, schema_1 has no row. - "circuit.always_on", - "circuit.is_sheddable", - "panel.wifi_ssid", - # Schema-conditional: schema_0 publishes these, schema_1 has no row. - # util.py builds the EVSE DeviceInfo from them; entity_resolver.py and + # sub-device identifier, not a published field. + "evse.node_id": Producibility.NEITHER, + "circuit.is_user_controllable": Producibility.SCHEMA_1_ONLY, + "circuit.always_on": Producibility.SCHEMA_0_ONLY, + "circuit.is_sheddable": Producibility.SCHEMA_0_ONLY, + "panel.wifi_ssid": Producibility.SCHEMA_0_ONLY, + # util.py builds the EVSE DeviceInfo from these; entity_resolver.py and # sensor.py resolve the fed circuit through `feed_circuit_id`. - "evse.vendor_name", - "evse.model", - "evse.serial_number", - "evse.software_version", - "evse.feed_circuit_id", - # Schema-conditional: schema_0 publishes it, schema_1 derives islanding - # via `resolve_grid_islandable(inverters)`. Read at binary_sensor.py:408 - # as an entity-creation gate, outside any description. - "panel.grid_islandable", - # Schema-conditional: schema_0 publishes it, schema_1's - # `_PROPERTY_FIELD_MAP` has no `connected` row — the same gap that makes - # the `bess_connected` binary sensor `derived=True`. - "battery.connected", + "evse.vendor_name": Producibility.SCHEMA_0_ONLY, + "evse.model": Producibility.SCHEMA_0_ONLY, + "evse.serial_number": Producibility.SCHEMA_0_ONLY, + "evse.software_version": Producibility.SCHEMA_0_ONLY, + "evse.feed_circuit_id": Producibility.SCHEMA_0_ONLY, + # schema_1 derives islanding via `resolve_grid_islandable(inverters)` + # instead. Read at binary_sensor.py:408 as an entity-creation gate, + # outside any description. + "panel.grid_islandable": Producibility.SCHEMA_0_ONLY, + # schema_1's `_PROPERTY_FIELD_MAP` has no `connected` row — the same gap + # that makes the `bess_connected` binary sensor `derived=True`. + "battery.connected": Producibility.SCHEMA_0_ONLY, } ) -"""Residual readers exempt from the producible check, for one of two reasons. +"""Residual readers exempt from the producible check, and why each is exempt. + +The gate requires a path to be producible by *both* adapters, so a read is +exempt for one of two reasons, and the annotation says which: +`Producibility.NEITHER` for values no adapter publishes a metadata row for — +Homie `$target` echoes, values the library assembles from panel topology, every +`mid.*` field; `SCHEMA_0_ONLY` / `SCHEMA_1_ONLY` for schema-conditional fields +present on one schema and absent from the other. -**Not produced by any adapter** — Homie `$target` echoes, values the library -assembles from panel topology, and every `mid.*` field. There is no metadata row -to check against on either schema. +Exempt is not the same as derived: these are read straight off a snapshot field, +that field just is not on both schemas. -**Produced by only one adapter** — schema-conditional fields. The gate requires -a path to be producible by *both* adapters, so a field present on one schema and -absent from the other cannot satisfy it. Exempt is not the same as derived: -these are read straight off a snapshot field, that field just is not on both -schemas. +The annotations are not documentation. `tests/test_field_path_conformance.py` +builds both adapters' metadata from the vendored fixtures and asserts every +entry's annotation against what those adapters actually produce, so a stale +reason fails the build instead of misleading a reader. A path that becomes +producible by both fails there too, demanding promotion to a declaration. Deliberately **not** returned by `declared_field_paths()`. Recorded here so the -reads are still enumerated somewhere rather than being invisible. The per-entry -comments say which of the two reasons applies. +reads are still enumerated somewhere rather than being invisible. """ diff --git a/custom_components/span_panel/schema_validation.py b/custom_components/span_panel/schema_validation.py index c8b848df..d7fc945c 100644 --- a/custom_components/span_panel/schema_validation.py +++ b/custom_components/span_panel/schema_validation.py @@ -105,9 +105,11 @@ def evaluate_field_metadata( mismatches.append(UnitMismatch(field_path, str(ha_unit), schema_unit)) # `RESIDUAL_EXEMPT_PATHS` are read by the integration; they are exempt from - # the *producible* gate because only one adapter emits them, so they are - # absent from `declared` without being unread. - unread = frozenset(set(field_metadata) - set(declared) - RESIDUAL_EXEMPT_PATHS) + # the *producible* gate because only one adapter emits them, or neither + # does, so they are absent from `declared` without being unread. Only the + # paths matter here; each entry's `Producibility` annotation is what the + # conformance tests verify. + unread = frozenset(set(field_metadata) - set(declared) - RESIDUAL_EXEMPT_PATHS.keys()) for field_path in sorted(unread): # An addition is legal within a major version. This is an inventory for # us, never a user-facing finding. diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py index f799a9b6..27277ac8 100644 --- a/tests/test_field_path_conformance.py +++ b/tests/test_field_path_conformance.py @@ -1,11 +1,17 @@ """Every declared field path must be producible by each adapter, or derived. This is the test that would have caught the battery.product_name drift. + +A read that no adapter, or only one, produces cannot satisfy that gate, so it is +exempted in `RESIDUAL_EXEMPT_PATHS`. The second half of this module holds those +exemptions to the same standard: each one states which adapters produce it, and +that statement is checked against the adapters rather than left as prose. """ from __future__ import annotations import ast +from collections import Counter from collections.abc import Callable import pathlib @@ -13,7 +19,11 @@ from span_panel_api.models import FieldMetadata from custom_components.span_panel import field_paths as field_paths_module -from custom_components.span_panel.field_paths import declared_field_paths +from custom_components.span_panel.field_paths import ( + RESIDUAL_EXEMPT_PATHS, + Producibility, + declared_field_paths, +) from tests.adapter_fixtures import schema_one_metadata, schema_zero_metadata MetadataFn = Callable[[], dict[str, FieldMetadata]] @@ -109,3 +119,117 @@ def test_gate_is_one_directional( print(f"\n[{adapter}] produced but unread ({len(unread)}):") for path in unread: print(f" {path}") + + +def _adapter_paths() -> tuple[frozenset[str], frozenset[str]]: + """Return the paths each adapter actually produces, from the vendored fixtures.""" + return frozenset(schema_zero_metadata()), frozenset(schema_one_metadata()) + + +def _observed_producibility( + path: str, s0: frozenset[str], s1: frozenset[str] +) -> Producibility | None: + """Classify a path by which adapters produce it. + + `None` is the fourth, illegal case — produced by both — which has no + `Producibility` member precisely because such a path is not exemptible. + """ + match (path in s0, path in s1): + case (True, True): + return None + case (True, False): + return Producibility.SCHEMA_0_ONLY + case (False, True): + return Producibility.SCHEMA_1_ONLY + case _: + return Producibility.NEITHER + + +def test_every_exempt_path_matches_its_annotation() -> None: + """Each exemption's stated reason is checked against the adapters that run. + + Before these annotations the 26 exemptions were checked against nothing: + the neither/one-adapter distinction lived in prose, so an entry could be + mislabelled from the day it was written, or go stale when the library + changed what it publishes, with no signal anywhere. + """ + s0, s1 = _adapter_paths() + wrong = [ + (path, annotated.name, observed.name if observed else "BOTH") + for path, annotated in RESIDUAL_EXEMPT_PATHS.items() + if (observed := _observed_producibility(path, s0, s1)) is not annotated + ] + assert not wrong, ( + "RESIDUAL_EXEMPT_PATHS annotations disagree with the adapters " + f"(path, annotated, actual): {wrong}. Either the annotation is stale or " + "the library changed what it produces." + ) + + +def test_no_exempt_path_is_producible_by_both() -> None: + """A path both adapters produce is a declaration, not an exemption. + + The exemption exists only because the gate demands producibility by both. + Once both produce it, the reason has evaporated and the path should be + declared — silently leaving it exempt would retire the gate for that read. + """ + s0, s1 = _adapter_paths() + promotable = sorted(RESIDUAL_EXEMPT_PATHS.keys() & s0 & s1) + assert not promotable, ( + f"exempt paths are now producible by both adapters: {promotable}. " + "Promote each to a declaration — a description's `field_path=`, or " + "`RESIDUAL_FIELD_PATHS` for a reader in entity code — so the producible " + "gate covers it again." + ) + + +_EXPECTED_EXEMPT_COUNTS: dict[Producibility, int] = { + Producibility.NEITHER: 15, + Producibility.SCHEMA_0_ONLY: 10, + Producibility.SCHEMA_1_ONLY: 1, +} +"""The exemption inventory, by reason. See `test_exempt_inventory_is_complete`.""" + + +def test_exempt_inventory_is_complete() -> None: + """An exemption disappearing must be a deliberate edit, not an accident. + + Every other check here is consistency-only: deleting an entry leaves the + survivors perfectly annotated, so the set could quietly shrink and no test + would notice — the read would simply stop being enumerated anywhere. The + completeness of the set cannot be derived (a `Producibility.NEITHER` path + has, by definition, no adapter row to discover it from, and matching reads + by leaf attribute name collides across snapshot types), so it is pinned by + size per reason instead. Changing a count is fine; doing it in the same + commit as the entry, with a reason, is the point. + """ + counts = Counter(RESIDUAL_EXEMPT_PATHS.values()) + assert counts == _EXPECTED_EXEMPT_COUNTS, ( + f"exemption inventory changed: {counts} != {_EXPECTED_EXEMPT_COUNTS}. An " + "entry was added or removed; update the expected counts in the same " + "commit if that was intended." + ) + + +def test_every_exempt_path_still_has_a_reader() -> None: + """An exemption outlives its reader silently; the list only ever grows. + + Heuristic by necessity — an exempt path is read outside any description, so + there is no declaration to match against, only source text. Matching the + leaf attribute anywhere in the package is deliberately generous: it cannot + accuse a live read, and it still catches the last reader of a field being + deleted while its exemption stays behind. + """ + read_attributes = { + node.attr + for source in sorted(_PACKAGE_ROOT.rglob("*.py")) + for node in ast.walk(ast.parse(source.read_text(encoding="utf-8"), filename=str(source))) + if isinstance(node, ast.Attribute) + } + unread = sorted( + path for path in RESIDUAL_EXEMPT_PATHS if path.split(".", 1)[1] not in read_attributes + ) + assert not unread, ( + f"exempt paths no longer read anywhere in the package: {unread}. The " + "reader was removed; drop the exemption with it." + ) diff --git a/tests/test_field_paths.py b/tests/test_field_paths.py index a5b12b3c..c8b4f72b 100644 --- a/tests/test_field_paths.py +++ b/tests/test_field_paths.py @@ -66,5 +66,5 @@ def test_every_description_declares_exactly_one() -> None: def test_residual_buckets_are_disjoint() -> None: """A residual path is either producible or exempt, never both.""" - assert not (RESIDUAL_FIELD_PATHS & RESIDUAL_EXEMPT_PATHS) - assert not (declared_field_paths() & RESIDUAL_EXEMPT_PATHS) + assert not (RESIDUAL_FIELD_PATHS & RESIDUAL_EXEMPT_PATHS.keys()) + assert not (declared_field_paths() & RESIDUAL_EXEMPT_PATHS.keys()) From 242bebc38c0b520b34be570bf229be7f525fd748 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:21:45 -0700 Subject: [PATCH 041/345] refactor(field-paths): derive residual paths from the entities that read them `RESIDUAL_FIELD_PATHS` restated, as a module constant, exactly what the four entity classes already declare in `_residual_field_paths` -- the same fact in two places, kept coherent by a bidirectional test that was itself a third copy of the class list. The entity's copy is the one that has to be right: it is what `async_added_to_hass` registers, so it is what a Repair names when a field dies. So the constant goes and `residual_field_paths()` collects the paths off a recursive `SpanPanelEntity.__subclasses__()` walk instead, with the platform imports deferred for the same cycle-avoidance reason `declared_field_paths()` already defers its own. A walk sees only what has been imported, which is a hole where a residual could go missing silently -- the derived set would just be smaller, and everything downstream of it is monotone in the direction that hides that. Closed the same way the conformance gate closes it for `field_path=`: an AST scan for `_residual_field_paths` tuple literals over the package source, asserted against the walk in both directions. `declared_field_paths()` returns the same 47 paths. --- custom_components/span_panel/entity.py | 13 ++-- custom_components/span_panel/field_paths.py | 71 +++++++++++++------ tests/test_field_path_conformance.py | 75 ++++++++++++++++++++- tests/test_field_path_introspection.py | 4 +- tests/test_field_paths.py | 4 +- tests/test_schema_repairs.py | 60 +++-------------- 6 files changed, 144 insertions(+), 83 deletions(-) diff --git a/custom_components/span_panel/entity.py b/custom_components/span_panel/entity.py index 291807c3..125bd113 100644 --- a/custom_components/span_panel/entity.py +++ b/custom_components/span_panel/entity.py @@ -26,12 +26,15 @@ class SpanPanelEntity(CoordinatorEntity[SpanPanelCoordinator]): A handful of reads cannot be expressed as a description `field_path`: the switch has no entity description at all, the select wraps one, and a circuit entity's name, tabs and attributes are read outside any `value_fn`. - `field_paths.RESIDUAL_FIELD_PATHS` already names that exact set for the - producible gate; declaring the same paths here is what lets a Repair say - which entities a dead one takes with it, instead of "0 affected". + Declaring them here is what lets a Repair say which entities a dead field + takes with it, instead of "0 affected". - `test_every_residual_field_path_is_claimed_by_an_entity` pins the two lists - to each other in both directions. + This is the only place these paths are written down: + `field_paths.residual_field_paths()` collects them from every subclass, so + the producible gate covers exactly what the entities claim. + + Keep the list short. A new entry is a hint that the reader belongs on a + description instead, where the declaration and the reader are one object. """ async def async_added_to_hass(self) -> None: diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py index 25b5cb0b..dfe3da84 100644 --- a/custom_components/span_panel/field_paths.py +++ b/custom_components/span_panel/field_paths.py @@ -72,27 +72,6 @@ class FieldPathDeclarationMixin: """ -RESIDUAL_FIELD_PATHS: frozenset[str] = frozenset( - { - # switch.py reads this in entity code, not via a description value_fn - "circuit.relay_state", - # select.py uses a wrapper class rather than a frozen dataclass - # description, so it cannot carry the field as a dataclass field - "circuit.priority", - # Consumed by entity naming and attributes rather than by any platform - "circuit.name", - "circuit.tabs", - # sensor_circuit.py publishes this as a circuit attribute - "circuit.relay_requester", - } -) -"""Readers not carried on an entity description, and producible by both adapters. - -Keep this small. A new entry is a hint that the reader belongs on a -description instead. -""" - - class Producibility(Enum): """Which adapters publish a metadata row for an exempt residual path. @@ -211,6 +190,54 @@ def iter_field_path_declarations[DescriptionT: EntityDescription]( yield description.field_path, description +def _walk_subclasses[EntityT](root: type[EntityT]) -> Iterator[type[EntityT]]: + """Yield every subclass of `root`, transitively. + + `__subclasses__()` is one level deep; platform entities sit two or three + levels below the base (`SpanSensorBase` -> `SpanCircuitPowerSensor`), so a + single level would miss exactly the classes that carry residual reads. + """ + for subclass in root.__subclasses__(): + yield subclass + yield from _walk_subclasses(subclass) + + +def residual_field_paths() -> frozenset[str]: + """Field paths read from entity code rather than from a description. + + A handful of reads cannot be expressed as a description `field_path`: the + switch has no entity description at all, the select wraps one rather than + being a frozen dataclass, and a circuit entity's name, tabs and attributes + are read outside any `value_fn`. Each such read is declared on the entity + that makes it -- `SpanPanelEntity._residual_field_paths` -- because the + entity is what a Repair has to name when the field dies. + + Collected from those declarations rather than restated as a constant here: + a second copy would need a test to hold it against the first, and the copy + that the Repair actually consumes is the entity's. + + The walk sees only classes Python has imported, so the platform modules + that declare residuals are imported here explicitly. A residual declared in + a module this function does not reach would go missing silently, so + `test_source_residuals_match_the_subclass_walk` scans the package source + for `_residual_field_paths` assignments and fails on any the walk missed. + """ + # Deferred for the same cycle-avoidance reason as `declared_field_paths()` + # below: every platform module imports this one for the declaration mixin. + from . import ( # noqa: F401 pylint: disable=import-outside-toplevel,unused-import + select, + sensor_circuit, + switch, + ) + from .entity import SpanPanelEntity # pylint: disable=import-outside-toplevel + + return frozenset( + path + for entity_class in _walk_subclasses(SpanPanelEntity) + for path in entity_class._residual_field_paths # pylint: disable=protected-access + ) + + def declared_field_paths() -> frozenset[str]: """Field paths the integration reads that must be producible by an adapter. @@ -231,7 +258,7 @@ def declared_field_paths() -> frozenset[str]: all_sensor_descriptions, ) - paths: set[str] = set(RESIDUAL_FIELD_PATHS) + paths: set[str] = set(residual_field_paths()) paths.update( field_path for field_path, _ in iter_field_path_declarations( diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py index 27277ac8..25cb48ec 100644 --- a/tests/test_field_path_conformance.py +++ b/tests/test_field_path_conformance.py @@ -23,6 +23,7 @@ RESIDUAL_EXEMPT_PATHS, Producibility, declared_field_paths, + residual_field_paths, ) from tests.adapter_fixtures import schema_one_metadata, schema_zero_metadata @@ -97,6 +98,74 @@ def test_gate_covers_every_declaration_in_the_source() -> None: ) +def _source_residual_paths() -> dict[str, str]: + """Every `_residual_field_paths` tuple literal in the integration source, by module. + + The runtime counterpart, `residual_field_paths()`, unions the class + attribute over a `SpanPanelEntity.__subclasses__()` walk, and a walk sees + only what has been imported. This reads the same declarations out of the + source text, where importedness is not a factor. + """ + found: dict[str, str] = {} + for source in sorted(_PACKAGE_ROOT.rglob("*.py")): + tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source)) + for node in ast.walk(tree): + targets: list[ast.expr] + if isinstance(node, ast.AnnAssign): + targets = [node.target] + elif isinstance(node, ast.Assign): + targets = list(node.targets) + else: + continue + if not any( + isinstance(target, ast.Name) and target.id == "_residual_field_paths" + for target in targets + ): + continue + if not isinstance(node.value, ast.Tuple): + continue + for element in node.value.elts: + if isinstance(element, ast.Constant) and isinstance(element.value, str): + found.setdefault(element.value, source.name) + return found + + +def test_source_residuals_match_the_subclass_walk() -> None: + """A residual the walk cannot see must fail, not vanish. + + `residual_field_paths()` imports the platform modules that declare + residuals so their classes exist to be walked. A class declaring one in a + module it does not import — a new platform, or an existing one that stops + being imported — would drop out of the producible gate and out of the + Repair's affected-entity count with no signal at all: the derived set would + simply be smaller, and every check downstream of it is monotone in exactly + the direction that hides the loss. + + This is that signal, read from the source text where import order does not + apply. The converse direction matters too: a residual assembled at runtime + rather than written as a tuple literal is invisible to this scan, so the + scan would silently stop pinning it. + """ + from_source = _source_residual_paths() + from_walk = residual_field_paths() + + unwalked = sorted( + (path, module) for path, module in from_source.items() if path not in from_walk + ) + assert not unwalked, ( + f"residual paths declared in the source but not reached by the subclass walk: " + f"{unwalked}. `residual_field_paths()` does not import the declaring module, so " + "the producible gate and the Repair's affected-entity count both miss these reads." + ) + + unscanned = sorted(from_walk - from_source.keys()) + assert not unscanned, ( + f"residual paths the source scan cannot see: {unscanned}. They are not written " + "as string literals in a `_residual_field_paths` tuple, so this pin no longer " + "covers them — declare them literally." + ) + + @pytest.mark.parametrize(("adapter", "metadata_fn"), _ADAPTERS) def test_gate_is_one_directional( adapter: str, metadata_fn: MetadataFn, capsys: pytest.CaptureFixture[str] @@ -177,9 +246,9 @@ def test_no_exempt_path_is_producible_by_both() -> None: promotable = sorted(RESIDUAL_EXEMPT_PATHS.keys() & s0 & s1) assert not promotable, ( f"exempt paths are now producible by both adapters: {promotable}. " - "Promote each to a declaration — a description's `field_path=`, or " - "`RESIDUAL_FIELD_PATHS` for a reader in entity code — so the producible " - "gate covers it again." + "Promote each to a declaration — a description's `field_path=`, or an " + "entity's `_residual_field_paths` for a reader in entity code — so the " + "producible gate covers it again." ) diff --git a/tests/test_field_path_introspection.py b/tests/test_field_path_introspection.py index 89bf2740..3ae46805 100644 --- a/tests/test_field_path_introspection.py +++ b/tests/test_field_path_introspection.py @@ -10,8 +10,8 @@ from typing import Any, Protocol, runtime_checkable from custom_components.span_panel.field_paths import ( - RESIDUAL_FIELD_PATHS, declared_field_paths, + residual_field_paths, ) from custom_components.span_panel.sensor_definitions import all_sensor_descriptions from tests.adapter_fixtures import schema_one_metadata, schema_zero_metadata @@ -189,7 +189,7 @@ def test_introspection_covers_every_declared_path() -> None: for description in _declaring_descriptions() if not description.derived and description.field_path is not None } - assert declared_field_paths() == frozenset(introspected | set(RESIDUAL_FIELD_PATHS)) + assert declared_field_paths() == frozenset(introspected | residual_field_paths()) def test_no_derived_description_reads_one_producible_field() -> None: diff --git a/tests/test_field_paths.py b/tests/test_field_paths.py index c8b4f72b..5c0d95ed 100644 --- a/tests/test_field_paths.py +++ b/tests/test_field_paths.py @@ -10,8 +10,8 @@ ) from custom_components.span_panel.field_paths import ( RESIDUAL_EXEMPT_PATHS, - RESIDUAL_FIELD_PATHS, declared_field_paths, + residual_field_paths, ) from custom_components.span_panel.sensor_definitions import ( CIRCUIT_SENSORS, @@ -66,5 +66,5 @@ def test_every_description_declares_exactly_one() -> None: def test_residual_buckets_are_disjoint() -> None: """A residual path is either producible or exempt, never both.""" - assert not (RESIDUAL_FIELD_PATHS & RESIDUAL_EXEMPT_PATHS.keys()) + assert not (residual_field_paths() & RESIDUAL_EXEMPT_PATHS.keys()) assert not (declared_field_paths() & RESIDUAL_EXEMPT_PATHS.keys()) diff --git a/tests/test_schema_repairs.py b/tests/test_schema_repairs.py index 946d8bb4..6db31053 100644 --- a/tests/test_schema_repairs.py +++ b/tests/test_schema_repairs.py @@ -536,9 +536,7 @@ async def _entities_by_declared_path(hass): # refuses to add an entity whose via_device is not a registered device id. config_entry.runtime_data = SpanPanelRuntimeData( coordinator=coordinator, - panel_device_id=await ensure_device_registered( - hass, config_entry, snapshot, "SPAN Panel" - ), + panel_device_id=await ensure_device_registered(hass, config_entry, snapshot, "SPAN Panel"), ) grouped: dict[str, dict[str, list[object]]] = {} @@ -551,9 +549,9 @@ async def _entities_by_declared_path(hass): continue if description.derived or description.field_path not in _STYLE_PATHS: continue - grouped.setdefault(description.field_path, {}).setdefault( - platform_domain, [] - ).append(entity) + grouped.setdefault(description.field_path, {}).setdefault(platform_domain, []).append( + entity + ) return coordinator, config_entry, grouped @@ -590,9 +588,7 @@ async def test_affected_entities_span_all_three_unique_id_styles(hass) -> None: """ coordinator, config_entry, grouped = await _entities_by_declared_path(hass) try: - assert _STYLE_PATHS <= grouped.keys(), ( - f"fixture missed {_STYLE_PATHS - grouped.keys()}" - ) + assert _STYLE_PATHS <= grouped.keys(), f"fixture missed {_STYLE_PATHS - grouped.keys()}" # The fixture really does cover three different builders: circuit suffix, # panel entity suffix, raw camelCase key. @@ -600,9 +596,7 @@ def _first(path: str, platform_domain: str): return grouped[path][platform_domain][0] assert _first("circuit.instant_power_w", "sensor").unique_id.endswith("_power") - assert _first("panel.instant_grid_power_w", "sensor").unique_id.endswith( - "_current_power" - ) + assert _first("panel.instant_grid_power_w", "sensor").unique_id.endswith("_current_power") assert _first("panel.door_state", "binary_sensor").unique_id.endswith("doorState") # One platform per domain, as HA does — several platforms sharing a @@ -751,9 +745,11 @@ async def test_a_platform_with_no_description_still_registers_its_residuals( # Five field paths are read from entity code rather than from a description's # `field_path`: the switch's relay state, the select's priority, and the name, # tabs and relay requester a circuit entity uses for its identity and its -# attributes. `RESIDUAL_FIELD_PATHS` lists them for the producible gate. Nothing -# declared them on the entities, so a dead `circuit.relay_state` reported "0 -# entity/entities are affected" while every breaker switch on the panel was out. +# attributes. Each is declared on the entity that reads it, which is where +# `field_paths.residual_field_paths()` collects them from for the producible +# gate. Nothing declared them on the entities, so a dead `circuit.relay_state` +# reported "0 entity/entities are affected" while every breaker switch on the +# panel was out. async def test_a_dead_relay_state_names_the_breaker_switches(hass) -> None: @@ -809,37 +805,3 @@ async def test_a_dead_priority_names_the_selects(hass) -> None: assert affected["circuit.priority"] == sorted(s.entity_id for s in selects) finally: await _stop_scheduling(coordinator) - - -def test_every_residual_field_path_is_claimed_by_an_entity() -> None: - """No residual read may be left with nothing to name. - - `RESIDUAL_FIELD_PATHS` exists because these reads live in entity code rather - than on a description. That is exactly why they cannot be discovered — so - each one is declared on the entity that makes it, and this pins the two lists - together. A new residual entry with no declaring entity would otherwise ship - a Repair that says "0 affected" when the answer is "all of them". - """ - from custom_components.span_panel.field_paths import RESIDUAL_FIELD_PATHS - from custom_components.span_panel.select import SpanPanelCircuitsSelect - from custom_components.span_panel.sensor_circuit import ( - SpanCircuitEnergySensor, - SpanCircuitPowerSensor, - ) - from custom_components.span_panel.switch import SpanPanelCircuitsSwitch - - declared: set[str] = set() - for entity_class in ( - SpanPanelCircuitsSwitch, - SpanPanelCircuitsSelect, - SpanCircuitPowerSensor, - SpanCircuitEnergySensor, - ): - declared.update(entity_class._residual_field_paths) - - assert RESIDUAL_FIELD_PATHS <= declared, ( - f"undeclared residual reads: {RESIDUAL_FIELD_PATHS - declared}" - ) - assert declared <= RESIDUAL_FIELD_PATHS, ( - f"declared but not in the residual set: {declared - RESIDUAL_FIELD_PATHS}" - ) From e3e64c21d3dfde886c03ca0485ae27d96e5f216a Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:29:13 -0700 Subject: [PATCH 042/345] test(field-paths): derive the recorder prefix from the value_fn annotation The class-name map had to be edited for every new description class -- ten entries and growing -- and a class missing from it was the original silent skip, patched into a recorded mismatch but not removed. Resolve the snapshot type from the value_fn annotation instead (get_type_hints, so the stringified annotations resolve) and map the five snapshot types to their prefixes. A new description class over an existing snapshot type now needs no map edit, and its prefix cannot be wrong: it comes from the annotation mypy already checks the value_fn body against. An unresolvable annotation or an unmapped snapshot type raises, and both callers record it as a mismatch naming the description -- never a skip. --- tests/test_field_path_introspection.py | 131 ++++++++++++++++++------- 1 file changed, 93 insertions(+), 38 deletions(-) diff --git a/tests/test_field_path_introspection.py b/tests/test_field_path_introspection.py index 3ae46805..35bbe9be 100644 --- a/tests/test_field_path_introspection.py +++ b/tests/test_field_path_introspection.py @@ -6,8 +6,16 @@ from __future__ import annotations -from collections.abc import Callable, Iterator -from typing import Any, Protocol, runtime_checkable +from collections.abc import Callable, Iterator, Mapping +from typing import Any, Protocol, get_args, get_type_hints, runtime_checkable + +from span_panel_api import ( + SpanBatterySnapshot, + SpanCircuitSnapshot, + SpanEvseSnapshot, + SpanMidSnapshot, + SpanPanelSnapshot, +) from custom_components.span_panel.field_paths import ( declared_field_paths, @@ -129,43 +137,95 @@ def _declaring_descriptions() -> Iterator[_DeclaringDescription]: yield description -# Every description class, and the snapshot type its value_fn receives. -# A class missing here is reported as a mismatch rather than skipped: a silent -# skip is exactly the hole this test exists to close. -_ROOT_PREFIX = { - "SpanPanelCircuitsSensorEntityDescription": "circuit", - "SpanPanelDataSensorEntityDescription": "panel", - "SpanPanelStatusSensorEntityDescription": "panel", - "SpanPanelBatterySensorEntityDescription": "battery", - "SpanBessMetadataSensorEntityDescription": "battery", - # PV metadata value_fns take the whole panel snapshot and reach through - # `s.pv.x`, so the root prefix is "panel" and _SUB_SNAPSHOTS rewrites it. - "SpanPVMetadataSensorEntityDescription": "panel", - "SpanEvseSensorEntityDescription": "evse", - "SpanMidSensorEntityDescription": "mid", - "SpanPanelBinarySensorEntityDescription": "panel", - "SpanEvseBinarySensorEntityDescription": "evse", +# Every snapshot type a value_fn can take, and the prefix its fields are +# addressed by. Keyed by the snapshot type rather than by description class +# name: the class name map had to be edited for every new description class — +# the recurring event — while this one grows only when the library grows a new +# snapshot type. A new description class over an existing snapshot type needs no +# edit here, and its prefix cannot be wrong, because it comes from the same +# annotation mypy checks the value_fn bodies against. +# +# PV has no entry: PV metadata value_fns take the whole panel snapshot and reach +# through `s.pv.x`, so their prefix is "panel" and `_SUB_SNAPSHOTS` rewrites it. +_SNAPSHOT_PREFIX: Mapping[type, str] = { + SpanCircuitSnapshot: "circuit", + SpanPanelSnapshot: "panel", + SpanBatterySnapshot: "battery", + SpanEvseSnapshot: "evse", + SpanMidSnapshot: "mid", } +class _UndeterminedPrefix(Exception): + """A description's snapshot prefix could not be determined. + + Raised, never swallowed: a description whose prefix is unknown must be + reported by its caller as a mismatch. Skipping it is what let a description + class absent from the old class-name map drop out of verification entirely. + """ + + +def _snapshot_type(description: _DeclaringDescription) -> type: + """Return the snapshot type this description's `value_fn` is annotated to take. + + `from __future__ import annotations` stringifies the annotation, so this + resolves it with `get_type_hints`, which evaluates it in the defining + module's namespace — every mixin's module imports the snapshot types it + names, so resolution succeeds. + """ + cls = type(description) + try: + hints = get_type_hints(cls) + except Exception as err: # noqa: BLE001 + raise _UndeterminedPrefix( + f"{cls.__name__}: value_fn annotation does not resolve ({err!r})" + ) from err + annotation = hints.get("value_fn") + if annotation is None: + raise _UndeterminedPrefix(f"{cls.__name__} carries no value_fn annotation") + args = get_args(annotation) + if len(args) != 2 or not isinstance(args[0], list) or not args[0]: + raise _UndeterminedPrefix( + f"{cls.__name__}: value_fn annotated {annotation!r} names no parameter type" + ) + parameter = args[0][0] + if not isinstance(parameter, type): + raise _UndeterminedPrefix( + f"{cls.__name__}: value_fn takes {parameter!r}, which is not a snapshot class" + ) + return parameter + + +def _record_reads(description: _DeclaringDescription) -> set[str]: + """Run a description's `value_fn` against the recorder, return what it read. + + Raises `_UndeterminedPrefix` when the snapshot type cannot be resolved or is + absent from `_SNAPSHOT_PREFIX`; anything the `value_fn` itself raises + propagates unchanged. + """ + snapshot_type = _snapshot_type(description) + prefix = _SNAPSHOT_PREFIX.get(snapshot_type) + if prefix is None: + raise _UndeterminedPrefix( + f"{type(description).__name__}: value_fn takes {snapshot_type.__name__}, " + "which is absent from _SNAPSHOT_PREFIX" + ) + sink: set[str] = set() + description.value_fn(_Recorder(sink, prefix, root=snapshot_type is SpanPanelSnapshot)) + return sink + + def test_declared_paths_match_what_value_fns_read() -> None: mismatches: list[str] = [] for description in _declaring_descriptions(): if description.derived or description.field_path is None: continue - class_name = type(description).__name__ - prefix = _ROOT_PREFIX.get(class_name) - if prefix is None: - mismatches.append( - f"{description.key}: {class_name} is absent from _ROOT_PREFIX, so its " - "declaration would go unverified" - ) - continue - sink: set[str] = set() - proxy = _Recorder(sink, prefix, root=(prefix == "panel")) try: - description.value_fn(proxy) + sink = _record_reads(description) + except _UndeterminedPrefix as err: + mismatches.append(f"{description.key}: {err}, so its declaration would go unverified") + continue except Exception as err: # noqa: BLE001 mismatches.append(f"{description.key}: value_fn raised {err!r}") continue @@ -211,18 +271,13 @@ def test_no_derived_description_reads_one_producible_field() -> None: for description in _declaring_descriptions(): if not description.derived: continue - class_name = type(description).__name__ - prefix = _ROOT_PREFIX.get(class_name) - if prefix is None: + try: + sink = _record_reads(description) + except _UndeterminedPrefix as err: offenders.append( - f"{description.key}: {class_name} is absent from _ROOT_PREFIX, so its " - "derived classification would go unverified" + f"{description.key}: {err}, so its derived classification would go unverified" ) continue - sink: set[str] = set() - proxy = _Recorder(sink, prefix, root=(prefix == "panel")) - try: - description.value_fn(proxy) except Exception as err: # noqa: BLE001 offenders.append(f"{description.key}: value_fn raised {err!r}") continue From d7ce6f980c83067691ce0b6608a6e717dc4e81e7 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:34:42 -0700 Subject: [PATCH 043/345] feat(field-paths): say why a description is derived, and verify the reason `derived: bool` conflated four situations -- reads nothing, reads several fields, reads one field only one adapter produces, and the mistake of marking a single producible field derived. That last one is how evse_ev_connected went unnoticed: it declared no path, so the Repair never named it and the availability probe never fired for it. Replace it with `derived: DerivedReason | None`, three variants, each a claim the recorder can check: NO_SOURCE_FIELD reads nothing either adapter publishes, MULTIPLE_FIELDS reads two or more, SCHEMA_CONDITIONAL_FIELD reads exactly one that only one adapter produces. There is deliberately no variant for "reads a field no adapter produces" -- the recorder cannot tell that from reading no field at all, and an unverifiable variant is what this replaces. test_derived_reasons_match_what_value_fns_read asserts every one of the 11 declarations against what its value_fn actually reads, alongside the existing single-producible-field check. An enum member is truthy and None is falsy, so every consumer -- field_paths, entity.py's declaration and probe gates -- is unchanged. The declares-exactly-one pin needed the comparison made explicit: bool != enum was true for every description, declaring and derived alike. --- custom_components/span_panel/binary_sensor.py | 8 +- custom_components/span_panel/entity.py | 8 +- custom_components/span_panel/field_paths.py | 115 ++++++++++++------ .../span_panel/sensor_definitions.py | 22 ++-- tests/test_dps_and_bess.py | 2 +- tests/test_field_path_conformance.py | 2 +- tests/test_field_path_introspection.py | 67 +++++++++- tests/test_field_paths.py | 9 +- tests/test_schema_availability.py | 5 +- 9 files changed, 174 insertions(+), 64 deletions(-) diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py index 90a35e08..3c2ef7ec 100644 --- a/custom_components/span_panel/binary_sensor.py +++ b/custom_components/span_panel/binary_sensor.py @@ -30,7 +30,7 @@ ) from .coordinator import SpanPanelCoordinator from .entity import SpanPanelEntity -from .field_paths import FieldPathDeclarationMixin +from .field_paths import DerivedReason, FieldPathDeclarationMixin from .helpers import ( build_binary_sensor_unique_id_for_entry, build_evse_unique_id_for_entry, @@ -103,7 +103,7 @@ class SpanPanelBinarySensorEntityDescription( key=PANEL_STATUS, # Reports coordinator reachability, not a snapshot field — the value_fn # is a placeholder the entity class overrides. - derived=True, + derived=DerivedReason.NO_SOURCE_FIELD, translation_key="panel_status", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, @@ -141,7 +141,7 @@ def _grid_islandable(snapshot: SpanPanelSnapshot) -> bool | None: GRID_ISLANDABLE_SENSOR = SpanPanelBinarySensorEntityDescription( key="grid_islandable", - derived=True, + derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD, translation_key="grid_islandable", device_class=BinarySensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, @@ -150,7 +150,7 @@ def _grid_islandable(snapshot: SpanPanelSnapshot) -> bool | None: BESS_CONNECTED_SENSOR = SpanPanelBinarySensorEntityDescription( key="bess_connected", - derived=True, + derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD, translation_key="bess_connected", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, diff --git a/custom_components/span_panel/entity.py b/custom_components/span_panel/entity.py index 125bd113..f4ca67b5 100644 --- a/custom_components/span_panel/entity.py +++ b/custom_components/span_panel/entity.py @@ -64,10 +64,10 @@ def _declared_field_paths(self) -> tuple[str, ...]: """Return every snapshot field this entity reads. The description's `field_path` when it declares one, plus any residual - reads. A description that declares nothing (`derived` entities, which - read several fields or none) contributes nothing, and a platform with no - entity description at all — the circuit switch — contributes only its - residual reads. + reads. A description that declares nothing (`derived` entities, whose + `DerivedReason` says why no single field is theirs) contributes nothing, + and a platform with no entity description at all — the circuit switch — + contributes only its residual reads. """ description: object = getattr(self, "entity_description", None) if ( diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py index dfe3da84..6e50ddc6 100644 --- a/custom_components/span_panel/field_paths.py +++ b/custom_components/span_panel/field_paths.py @@ -23,58 +23,64 @@ from homeassistant.helpers.entity import EntityDescription -@dataclass(frozen=True, kw_only=True) -class FieldPathDeclarationMixin: - """Declares which snapshot field an entity description reads. +class DerivedReason(Enum): + """Why an entity description declares no single source field. - Mixed into every required-keys mixin so the declaration and the reader are - the same object. Defined once here rather than repeated on each mixin, so - the two fields cannot themselves drift apart across platforms. + A description is derived for exactly one of these reasons, and which one is + mechanical — count the snapshot fields its `value_fn` reads. + `test_derived_reasons_match_what_value_fns_read` runs every derived + description against the recorder and asserts the reason it claims, so a + wrong reason fails the build rather than misinforming a reader. - The fields are keyword-only: an entity description flattens this mixin's - fields ahead of ``EntityDescription.key``, which has no default, so a - positional pair here would make every description unconstructable. + Reading exactly one producible field is **always** a declaration, however + much arithmetic, mapping or membership-testing is applied on top. "Computed + from `status`" is not derivation: `field_path="evse.status"` with a + `value_fn` of `status in {...}` declares its source correctly and still + computes whatever it likes. `evse_ev_connected` was misclassified as derived + this way, which cost it both a Repair mention and its unavailability, while + its sibling `evse_charging` — same field, same shape — got both. That is the + conflation this enum exists to break: as a bare `bool`, all three reasons + below and that mistake looked identical. """ - field_path: str | None = None - """Snapshot field this entity reads, e.g. "circuit.instant_power_w". + NO_SOURCE_FIELD = "no_source_field" + """Reads no field either adapter publishes a metadata row for. - Declared here rather than in a parallel map so the declaration and the - reader are the same object. Verified against `value_fn` by the proxy test - in tests/test_field_path_introspection.py. + Either it reads nothing off the snapshot at all (`panel_status` reports + coordinator reachability) or the field it reads is one no adapter produces + (`dsm_state`, every `mid.*` read). Deliberately one member rather than two: + the recorder cannot tell those apart — both leave an empty intersection with + what the adapters emit — and a variant nothing can verify is exactly what + this enum replaces. """ - derived: bool = False - """True only when there is no single source field to declare. - - Exactly one of these three must hold, and the test is mechanical — count the - snapshot fields the `value_fn` reads: - - 1. it reads **no** snapshot field, or - 2. it reads **more than one**, or - 3. it reads exactly one that no adapter, or only one adapter, produces. + MULTIPLE_FIELDS = "multiple_fields" + """Combines two or more producible fields, so no one of them is the source. - Reading exactly one producible field is **always** a declaration, however - much arithmetic, mapping or membership-testing is applied on top. "Computed - from `status`" is not derivation: `field_path="evse.status"` with a - `value_fn` of `status in {...}` declares its source correctly and still - computes whatever it likes. `evse_ev_connected` was misclassified this way, - which cost it both a Repair mention and its unavailability, while its - sibling `evse_charging` — same field, same shape — got both. + The net-energy sensors subtract produced from consumed; blaming either field + alone for the entity would be wrong. + """ - Case 3 keeps the producible gate satisfiable: it requires a path both - adapters emit, so a schema-conditional field cannot be declared. Those are - listed in `RESIDUAL_EXEMPT_PATHS` when the integration reads them outside a - description. + SCHEMA_CONDITIONAL_FIELD = "schema_conditional_field" + """Reads exactly one field, which only one adapter produces. - `test_no_derived_description_reads_one_producible_field` enforces this rule - against every derived description rather than against any one instance. + Keeps the producible gate satisfiable: the gate requires a path both + adapters emit, so a schema-conditional field cannot be declared. If the + other adapter ever grows the field, this stops being true and the + verification fails, demanding promotion to a `field_path` declaration. """ class Producibility(Enum): """Which adapters publish a metadata row for an exempt residual path. + A *path's* producibility, not a *description's* classification: kept beside + `DerivedReason` because the two are verified from the same pair of adapter + metadata sets, and deliberately separate because they describe different + subjects. `bess_connected` is `SCHEMA_CONDITIONAL_FIELD` while the + `battery.connected` path it reads is `SCHEMA_0_ONLY` — two facts about two + things. + There is deliberately no `BOTH` member. A path both adapters produce satisfies the producible gate, so it belongs in `declared_field_paths()` rather than in an exemption; `test_no_exempt_path_is_producible_by_both` @@ -91,6 +97,43 @@ class Producibility(Enum): """Produced by the schema_1 adapter, absent from schema_0.""" +@dataclass(frozen=True, kw_only=True) +class FieldPathDeclarationMixin: + """Declares which snapshot field an entity description reads. + + Mixed into every required-keys mixin so the declaration and the reader are + the same object. Defined once here rather than repeated on each mixin, so + the two fields cannot themselves drift apart across platforms. + + The fields are keyword-only: an entity description flattens this mixin's + fields ahead of ``EntityDescription.key``, which has no default, so a + positional pair here would make every description unconstructable. + """ + + field_path: str | None = None + """Snapshot field this entity reads, e.g. "circuit.instant_power_w". + + Declared here rather than in a parallel map so the declaration and the + reader are the same object. Verified against `value_fn` by the proxy test + in tests/test_field_path_introspection.py. + """ + + derived: DerivedReason | None = None + """Why this entity has no single source field to declare, or `None`. + + Set only when `field_path` is not: the two are alternatives, pinned by + `test_every_description_declares_exactly_one`. Which reason applies is not + a matter of opinion — see `DerivedReason`, whose members are each asserted + against what the `value_fn` actually reads. + + A reason rather than a flag because `bool` conflated four situations, and + that conflation is how `evse_ev_connected` — one producible field, marked + derived — stayed invisible to both the Repair count and the availability + probe. Every consumer tests this by truthiness, which an enum member and + `None` answer exactly as `True` and `False` did. + """ + + RESIDUAL_EXEMPT_PATHS: Mapping[str, Producibility] = MappingProxyType( { # Homie `$target` values — a pending-command echo, not a schema field. diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py index 5af8eeed..1ea54b87 100644 --- a/custom_components/span_panel/sensor_definitions.py +++ b/custom_components/span_panel/sensor_definitions.py @@ -35,7 +35,11 @@ SpanPanelSnapshot, ) -from .field_paths import FieldPathDeclarationMixin, iter_field_path_declarations +from .field_paths import ( + DerivedReason, + FieldPathDeclarationMixin, + iter_field_path_declarations, +) @dataclass(frozen=True) @@ -103,7 +107,7 @@ class SpanPanelBatterySensorEntityDescription( ] = ( SpanPanelDataSensorEntityDescription( key="dsm_state", - derived=True, + derived=DerivedReason.NO_SOURCE_FIELD, translation_key="dsm_state", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, @@ -113,7 +117,7 @@ class SpanPanelBatterySensorEntityDescription( ), SpanPanelDataSensorEntityDescription( key="dsm_grid_state", - derived=True, + derived=DerivedReason.NO_SOURCE_FIELD, translation_key="dsm_grid_state", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, @@ -122,7 +126,7 @@ class SpanPanelBatterySensorEntityDescription( ), SpanPanelDataSensorEntityDescription( key="current_run_config", - derived=True, + derived=DerivedReason.NO_SOURCE_FIELD, translation_key="current_run_config", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, @@ -140,7 +144,7 @@ class SpanPanelBatterySensorEntityDescription( ), SpanPanelDataSensorEntityDescription( key="grid_forming_entity", - derived=True, + derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD, translation_key="grid_forming_entity", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, @@ -395,7 +399,7 @@ class SpanMidSensorEntityDescription(SensorEntityDescription, SpanMidRequiredKey MID_SENSORS: tuple[SpanMidSensorEntityDescription, ...] = ( SpanMidSensorEntityDescription( key="mid_grid_state", - derived=True, + derived=DerivedReason.NO_SOURCE_FIELD, translation_key="mid_grid_state", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, @@ -659,7 +663,7 @@ class SpanPVMetadataSensorEntityDescription( ), SpanPanelDataSensorEntityDescription( key="mainMeterNetEnergyWh", - derived=True, + derived=DerivedReason.MULTIPLE_FIELDS, translation_key="main_meter_net_energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL, @@ -672,7 +676,7 @@ class SpanPVMetadataSensorEntityDescription( ), SpanPanelDataSensorEntityDescription( key="feedthroughNetEnergyWh", - derived=True, + derived=DerivedReason.MULTIPLE_FIELDS, translation_key="feedthrough_net_energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL, @@ -732,7 +736,7 @@ class SpanPVMetadataSensorEntityDescription( ), SpanPanelCircuitsSensorEntityDescription( key="circuit_energy_net", - derived=True, + derived=DerivedReason.MULTIPLE_FIELDS, name="Net Energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL, diff --git a/tests/test_dps_and_bess.py b/tests/test_dps_and_bess.py index 0078f9ae..d8a64bfa 100644 --- a/tests/test_dps_and_bess.py +++ b/tests/test_dps_and_bess.py @@ -255,4 +255,4 @@ def test_bess_part_number_sensor_is_declared() -> None: part = next(d for d in BESS_METADATA_SENSORS if d.key == "part_number") assert part.field_path == "battery.part_number" - assert part.derived is False + assert part.derived is None diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py index 25cb48ec..070b0f04 100644 --- a/tests/test_field_path_conformance.py +++ b/tests/test_field_path_conformance.py @@ -69,7 +69,7 @@ def test_every_declared_path_is_producible(adapter: str, metadata_fn: MetadataFn missing = sorted(path for path in declared_field_paths() if path not in metadata) assert not missing, ( f"{adapter} does not produce declared field paths: {missing}. " - "Either the declaration is stale, or the entity should be derived=True." + "Either the declaration is stale, or the entity should declare a DerivedReason." ) diff --git a/tests/test_field_path_introspection.py b/tests/test_field_path_introspection.py index 35bbe9be..56ac4a1f 100644 --- a/tests/test_field_path_introspection.py +++ b/tests/test_field_path_introspection.py @@ -18,6 +18,7 @@ ) from custom_components.span_panel.field_paths import ( + DerivedReason, declared_field_paths, residual_field_paths, ) @@ -101,7 +102,7 @@ def key(self) -> str: ... def field_path(self) -> str | None: ... @property - def derived(self) -> bool: ... + def derived(self) -> DerivedReason | None: ... @property def value_fn(self) -> Callable[[Any], object]: ... @@ -284,8 +285,68 @@ def test_no_derived_description_reads_one_producible_field() -> None: read = sorted(sink & producible) if len(read) == 1: offenders.append( - f"{description.key}: derived=True but reads exactly one producible field, " - f"{read[0]!r} — that is a declaration, so set field_path={read[0]!r}" + f"{description.key}: derived={description.derived} but reads exactly one " + f"producible field, {read[0]!r} — that is a declaration, so set " + f"field_path={read[0]!r}" ) assert not offenders, "Misclassified derived descriptions:\n" + "\n".join(offenders) + + +def test_derived_reasons_match_what_value_fns_read() -> None: + """Each derived description's stated reason must be the one its reads imply. + + `derived` used to be a `bool` covering four different situations, and it was + that conflation which hid `evse_ev_connected`: a single producible field + marked derived looked exactly like a genuine multi-field derivation. The + reason is only worth its syntax if it is checked, so each variant is a claim + about the recorder's output and is asserted as one: + + * `NO_SOURCE_FIELD` — reads nothing either adapter publishes, + * `MULTIPLE_FIELDS` — reads two or more fields an adapter publishes, + * `SCHEMA_CONDITIONAL_FIELD` — reads exactly one, produced by one adapter + only. When the other adapter grows it, this fails and demands promotion to + a `field_path` declaration. + + Intersecting with what the adapters emit is what makes the count meaningful: + the recorder also picks up method names and other noise. + """ + schema_0 = set(schema_zero_metadata()) + schema_1 = set(schema_one_metadata()) + produced = schema_0 | schema_1 + offenders: list[str] = [] + + for description in _declaring_descriptions(): + reason = description.derived + if reason is None: + continue + try: + sink = _record_reads(description) + except _UndeterminedPrefix as err: + offenders.append(f"{description.key}: {err}, so its reason would go unverified") + continue + except Exception as err: # noqa: BLE001 + offenders.append(f"{description.key}: value_fn raised {err!r}") + continue + read = sorted(sink & produced) + if reason is DerivedReason.NO_SOURCE_FIELD and read: + offenders.append( + f"{description.key}: claims NO_SOURCE_FIELD but reads {read} — " + "the reason is MULTIPLE_FIELDS, SCHEMA_CONDITIONAL_FIELD, or it is a " + "declaration" + ) + elif reason is DerivedReason.MULTIPLE_FIELDS and len(read) < 2: + offenders.append( + f"{description.key}: claims MULTIPLE_FIELDS but reads {read} — " + "one field or none is a different reason" + ) + elif reason is DerivedReason.SCHEMA_CONDITIONAL_FIELD and ( + len(read) != 1 or read[0] in schema_0 & schema_1 + ): + offenders.append( + f"{description.key}: claims SCHEMA_CONDITIONAL_FIELD but reads {read}, " + f"of which {sorted(set(read) & schema_0 & schema_1)} are produced by both " + "adapters" + ) + + assert not offenders, "Derived reasons disagree with readers:\n" + "\n".join(offenders) diff --git a/tests/test_field_paths.py b/tests/test_field_paths.py index 5c0d95ed..19830353 100644 --- a/tests/test_field_paths.py +++ b/tests/test_field_paths.py @@ -10,6 +10,7 @@ ) from custom_components.span_panel.field_paths import ( RESIDUAL_EXEMPT_PATHS, + DerivedReason, declared_field_paths, residual_field_paths, ) @@ -22,7 +23,7 @@ def test_circuit_power_declares_its_field_path() -> None: power = next(d for d in CIRCUIT_SENSORS if d.key == "circuit_power") assert power.field_path == "circuit.instant_power_w" - assert power.derived is False + assert power.derived is None def test_derived_sensor_declares_no_path() -> None: @@ -30,7 +31,7 @@ def test_derived_sensor_declares_no_path() -> None: from custom_components.span_panel.sensor_definitions import PANEL_DATA_STATUS_SENSORS dsm = next(d for d in PANEL_DATA_STATUS_SENSORS if d.key == "dsm_state") - assert dsm.derived is True + assert dsm.derived is DerivedReason.NO_SOURCE_FIELD assert dsm.field_path is None @@ -59,8 +60,8 @@ def test_every_description_declares_exactly_one() -> None: BESS_CONNECTED_SENSOR, ): declares_path = description.field_path is not None - assert declares_path != description.derived, ( - f"{description.key} must declare exactly one of field_path / derived=True" + assert declares_path != (description.derived is not None), ( + f"{description.key} must declare exactly one of field_path / a DerivedReason" ) diff --git a/tests/test_schema_availability.py b/tests/test_schema_availability.py index c2400fe1..2d62aaf8 100644 --- a/tests/test_schema_availability.py +++ b/tests/test_schema_availability.py @@ -42,6 +42,7 @@ ) from custom_components.span_panel.coordinator import SpanPanelCoordinator from custom_components.span_panel.entity import SpanPanelEntity +from custom_components.span_panel.field_paths import DerivedReason from custom_components.span_panel.schema_validation import SchemaFindings from custom_components.span_panel.sensor_circuit import SpanCircuitPowerSensor from custom_components.span_panel.sensor_definitions import CIRCUIT_SENSORS @@ -193,7 +194,7 @@ async def test_panel_status_binary_sensor_is_never_probed(hass: HomeAssistant) - coordinator = _make_coordinator(hass) coordinator._findings = SchemaFindings(frozenset({_ETHERNET_LINK_PATH}), (), frozenset()) description = next(desc for desc in BINARY_SENSORS if desc.key == PANEL_STATUS) - assert description.derived is True + assert description.derived is DerivedReason.NO_SOURCE_FIELD entity = SpanPanelBinarySensor(coordinator, description) @@ -209,7 +210,7 @@ async def test_derived_entity_is_never_probed(hass: HomeAssistant) -> None: """ coordinator = _make_coordinator(hass) coordinator._findings = SchemaFindings(frozenset({"battery.connected"}), (), frozenset()) - assert BESS_CONNECTED_SENSOR.derived is True + assert BESS_CONNECTED_SENSOR.derived is DerivedReason.SCHEMA_CONDITIONAL_FIELD entity = SpanPanelBinarySensor(coordinator, BESS_CONNECTED_SENSOR) From 18971d02babd92a12f7956f0db306ff4371af90d Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:37:18 -0700 Subject: [PATCH 044/345] test(field-paths): pin the residual read no other entity class claims circuit.relay_requester is declared by one class alone. If that class stopped declaring it the path would leave residual_field_paths() entirely and the producible gate would quietly stop covering a read that is still happening. Asserted through the Repair's own output, so it fails on the observable consequence rather than on one list disagreeing with another. --- tests/test_schema_repairs.py | 44 ++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/test_schema_repairs.py b/tests/test_schema_repairs.py index 6db31053..c78e18dd 100644 --- a/tests/test_schema_repairs.py +++ b/tests/test_schema_repairs.py @@ -786,6 +786,50 @@ async def test_a_dead_relay_state_names_the_breaker_switches(hass) -> None: await _stop_scheduling(coordinator) +async def test_a_dead_circuit_attribute_names_the_power_sensors(hass) -> None: + """The circuit power sensor's residual reads must name it, one by one. + + Every other residual is claimed by more than one entity class, so dropping + it from any single class leaves the read enumerated somewhere and the panel + still describable. `circuit.relay_requester` is claimed by this class alone: + it is republished as a state attribute here and nowhere else, so if this + entity stopped declaring it, the path would leave + `field_paths.residual_field_paths()` entirely and the producible gate would + quietly stop covering a read that is still happening. + + Stated as the Repair's own output rather than as a second copy of the + declaration, so it fails on the observable consequence -- a dead attribute + naming no entity -- instead of on a list disagreeing with a list. + """ + from custom_components.span_panel.sensor_circuit import SpanCircuitPowerSensor + + coordinator, config_entry, grouped = await _entities_by_declared_path(hass) + try: + power_sensors = [ + entity + for entity in grouped["circuit.instant_power_w"]["sensor"] + if isinstance(entity, SpanCircuitPowerSensor) + ] + assert len(power_sensors) == 2, "fixture should build one power sensor per circuit" + + await _add_to_platform(hass, config_entry, power_sensors, "sensor") + + expected = sorted(sensor.entity_id for sensor in power_sensors) + assert coordinator.entity_ids_by_field_path == { + # The description's own declaration. + "circuit.instant_power_w": expected, + # Identity, read outside any value_fn. + "circuit.name": expected, + "circuit.tabs": expected, + # Republished as state attributes. + "circuit.relay_state": expected, + "circuit.relay_requester": expected, + "circuit.priority": expected, + } + finally: + await _stop_scheduling(coordinator) + + async def test_a_dead_priority_names_the_selects(hass) -> None: """The select's own state comes from `circuit.priority`.""" from unittest.mock import MagicMock From 0caf2922aa8f1bd3627b974678133a153e3b339c Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:04:32 -0700 Subject: [PATCH 045/345] chore: pin the library and schema-1 at b5 The reference payloads moved into the packages: the flat schema to span_panel_api, the parent/child tree to span-panel-api-schema-1, since a retained-topic tree needs the vocabulary that interprets it. --- custom_components/span_panel/manifest.json | 4 ++-- pyproject.toml | 4 ++-- uv.lock | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 4781e14f..400717d8 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -22,9 +22,9 @@ ], "quality_scale": "gold", "requirements": [ - "span-panel-api==3.0.0b4", + "span-panel-api==3.0.0b5", "span-panel-api-schema-0==1.0.0b4", - "span-panel-api-schema-1==0.1.0b4" + "span-panel-api-schema-1==0.1.0b5" ], "version": "2.1.0", "zeroconf": [ diff --git a/pyproject.toml b/pyproject.toml index 56a165ac..49c7c78e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,13 +8,13 @@ readme = "README.md" requires-python = ">=3.14.3,<3.15" dependencies = [ "homeassistant==2026.8.0", - "span-panel-api==3.0.0b4", + "span-panel-api==3.0.0b5", # 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.0.0b4", - "span-panel-api-schema-1==0.1.0b4", + "span-panel-api-schema-1==0.1.0b5", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 949093d8..b94ffd87 100644 --- a/uv.lock +++ b/uv.lock @@ -2550,7 +2550,7 @@ dev = [ [[package]] name = "span-panel-api" -version = "3.0.0b4" +version = "3.0.0b5" source = { editable = "../../span/span-panel-api-p2" } dependencies = [ { name = "httpx" }, @@ -2598,7 +2598,7 @@ requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-ap [[package]] name = "span-panel-api-schema-1" -version = "0.1.0b4" +version = "0.1.0b5" source = { editable = "../../span/span-panel-api-p2/packages/schema-1" } dependencies = [ { name = "ebus-sdk" }, From d86af335afb888ce0e4fb13adf29766dac8cd128 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:44:08 -0700 Subject: [PATCH 046/345] feat(sensor): surface the shed forecast as two duration sensors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel has published `energy.ebus.capability.shed-forecast` since r202633 and nothing read it. Its two live estimates are the highest-value unread properties on a v1.0 panel — how long before the panel starts shedding circuits, and how long before the battery is spent — so they land as sensors that are enabled by default and not filed under diagnostics. Surfacing them behind a disabled default would be surfacing them in name only. The two hypothetical-full-charge figures and the confidence enum ride as attributes on the sensor each one refines. They answer "what would this installation give me from a full battery", which moves when the hardware does rather than as the battery drains; separate entities would put a near-constant on a graph beside the countdown it qualifies. Which twin belongs to which sensor is carried on the description, not compared against `description.key`, so a rename cannot silently swap two plausible-looking durations. Creation is gated twice, because absence has two shapes. `has_shed_forecast` answers whether the panel publishes the capability at all — false on every flat panel — and joins `detect_capabilities`, so a panel that gains it on a firmware upgrade picks the sensors up on the reload the coordinator already performs. The per-description gate then asks whether *this* estimate is among what the node publishes: the catalog marks all four times SHOULD rather than MUST, so a partial node is legal firmware and the half it omits must produce no entity rather than one permanently unknown. The presence test is the description's own `value_fn`, so the gate cannot drift away from the read. Both descriptions are `SCHEMA_CONDITIONAL_FIELD` rather than declaring a `field_path`: the producible gate demands a path both adapters emit and no flat panel publishes this capability. The two live estimates join `RESIDUAL_EXEMPT_PATHS` as `SCHEMA_1_ONLY` — schema_1 maps them, which buys unit validation against the panel's own `$description` — and the three refinements as `NEITHER`, matching the `mid.*` attribute reads. Tests build a real snapshot through the real schema_1 adapter over the vendored capture, and read every expected value out of that capture rather than writing it as a literal. Each reading is then proved by republishing it, deleting it, or dropping the node. Hardcoding the captured 3037 and crossing the two full-charge twins fails seven of them. --- CHANGELOG.md | 15 + README.md | 19 + custom_components/span_panel/field_paths.py | 19 + custom_components/span_panel/helpers.py | 40 +- custom_components/span_panel/icons.json | 6 + custom_components/span_panel/sensor.py | 39 ++ .../span_panel/sensor_definitions.py | 93 ++++ custom_components/span_panel/sensor_panel.py | 93 ++++ custom_components/span_panel/strings.json | 24 +- .../span_panel/translations/en.json | 24 +- .../span_panel/translations/es.json | 6 + .../span_panel/translations/fr.json | 6 + .../span_panel/translations/ja.json | 6 + .../span_panel/translations/pt.json | 6 + tests/adapter_fixtures.py | 74 ++- tests/test_field_path_conformance.py | 9 +- tests/test_shed_forecast.py | 475 ++++++++++++++++++ tests/test_unit_vocabulary.py | 5 + 18 files changed, 921 insertions(+), 38 deletions(-) create mode 100644 tests/test_shed_forecast.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 42b75d54..17e1ab7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ All notable changes to this project will be documented in this file. +## [Unreleased] + +### Added + +- **Backup planning, in minutes: two new sensors on panels running the v1.0 data model.** **Time to Priority Shed** is how long before the panel starts shedding + circuits, and **Backup Time Remaining** is how long before the battery is spent. Your panel has been publishing both since firmware r202633 and nothing read + them; they are the numbers you would actually set an alarm on, so they are enabled by default and sit beside the power and energy sensors rather than under + diagnostics. +- **Each forecast sensor carries the refinements that qualify it** as attributes: `full_charge_time_to_priority_shed` / `full_charge_total_time_remaining` — + what the same estimate would be from a full battery — and `forecast_confidence`, the panel's own `LOW` / `MEDIUM` / `HIGH` assessment of the estimate. They + refine a number already on screen rather than adding two near-constant entities to your entity list. +- Both sensors are created only where the panel publishes the estimate behind them. A panel on the older data model, or one whose firmware publishes only part + of the forecast, gets no entity for what it cannot report rather than one permanently unknown — and a panel that gains the capability on a firmware upgrade + picks the sensors up on the reload the integration already performs. + ## [2.1.0] - 8/2026 Support for the eBus v1.0 (parent/child) data model your panel moves to on firmware r202633 and later, and a clean transition when it does. diff --git a/README.md b/README.md index de316de8..85689dcb 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,25 @@ The following terms appear throughout this document and in the integration's sen | Downstream L2 Current | Current | A | Downstream lugs L2 current | | Main Breaker Rating | Current | A | Main breaker amperage | +### Shed Forecast Sensors (v1.0 data model only) + +Created only when your panel publishes the `shed-forecast` capability, and only for the estimates it actually publishes. + +| Sensor | Device Class | Unit | Notes | +| --------------------- | ------------ | ---- | ---------------------------------------------------------------- | +| Time to Priority Shed | Duration | min | Estimated time before the next priority tier of circuits is shed | +| Backup Time Remaining | Duration | min | Estimated time before every sheddable circuit is shed (off-grid) | + +#### Shed Forecast Sensor Attributes + +Present only when the panel publishes them. + +| Attribute | Type | On | Notes | +| ----------------------------------- | ------ | --------------------- | ------------------------------------------------------ | +| `full_charge_time_to_priority_shed` | int | Time to Priority Shed | The same estimate assuming the battery starts full | +| `full_charge_total_time_remaining` | int | Backup Time Remaining | The same estimate assuming the battery starts full | +| `forecast_confidence` | string | both | The panel's own assessment: `LOW`, `MEDIUM`, or `HIGH` | + ### Power Flow Sensors (v2 only) | Sensor | Device Class | Unit | Notes | diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py index 6e50ddc6..c060a7f0 100644 --- a/custom_components/span_panel/field_paths.py +++ b/custom_components/span_panel/field_paths.py @@ -165,7 +165,26 @@ class FieldPathDeclarationMixin: # The EVSE's Homie node id — an addressing handle used to build the # sub-device identifier, not a published field. "evse.node_id": Producibility.NEITHER, + # The shed-forecast refinements, read for attributes on the two forecast + # sensors (`SpanShedForecastSensor.extra_state_attributes`). schema_1 + # reads all three into the snapshot but carries a `_PROPERTY_FIELD_MAP` + # row for neither: they qualify the two live estimates rather than being + # readings of their own, so there is no unit surface for a row to + # describe. Same shape as the `mid.*` attribute reads above. + "panel.shed_full_charge_time_to_priority_shed_min": Producibility.NEITHER, + "panel.shed_full_charge_total_time_remaining_min": Producibility.NEITHER, + "panel.shed_forecast_confidence": Producibility.NEITHER, "circuit.is_user_controllable": Producibility.SCHEMA_1_ONLY, + # The two backup-planning estimates behind `time_to_priority_shed` and + # `shed_total_time_remaining`, whose descriptions are + # `SCHEMA_CONDITIONAL_FIELD` for exactly this reason: no flat panel + # publishes `energy.ebus.capability.shed-forecast` at all, so the + # producible gate — which demands both adapters — cannot be satisfied. + # schema_1 does carry a metadata row for each, which is what makes the + # annotation SCHEMA_1_ONLY rather than NEITHER and buys the pair unit + # and datatype validation against the panel's own `$description`. + "panel.shed_time_to_priority_shed_min": Producibility.SCHEMA_1_ONLY, + "panel.shed_total_time_remaining_min": Producibility.SCHEMA_1_ONLY, "circuit.always_on": Producibility.SCHEMA_0_ONLY, "circuit.is_sheddable": Producibility.SCHEMA_0_ONLY, "panel.wifi_ssid": Producibility.SCHEMA_0_ONLY, diff --git a/custom_components/span_panel/helpers.py b/custom_components/span_panel/helpers.py index 2bcc2e20..f4d1d240 100644 --- a/custom_components/span_panel/helpers.py +++ b/custom_components/span_panel/helpers.py @@ -102,6 +102,7 @@ "has_mid", "has_power_flows", "has_pv", + "has_shed_forecast", "is_panel_level_sensor_key", "resolve_evse_display_suffix", ] @@ -274,6 +275,36 @@ def has_mid(snapshot: SpanPanelSnapshot) -> bool: return snapshot.mid is not None +def has_shed_forecast(snapshot: SpanPanelSnapshot) -> bool: + """Detect whether the panel publishes an `energy.ebus.capability.shed-forecast` node. + + Presence of the *capability*, from presence of any of the five fields it + fills. The library models each one as `None` when unpublished, and a panel + with no such node fills none of them, so any non-`None` field is the node — + there is no telemetry value that could be mistaken for it. That is why this + reads all five rather than only the two that back sensors: a firmware + publishing the node with a partial property set still has the capability, + and the per-sensor gate in `create_shed_forecast_sensors` is what decides + which entities that firmware can actually support. + + Always false on flat firmware, which publishes no such node at all. + + DUAL-SCHEMA: gated on what the snapshot carries rather than on a schema + version, so a panel that hot-loads parent/child mid-life gains the + capability, reloads, and the sensors appear. + """ + return any( + value is not None + for value in ( + snapshot.shed_time_to_priority_shed_min, + snapshot.shed_total_time_remaining_min, + snapshot.shed_full_charge_time_to_priority_shed_min, + snapshot.shed_full_charge_total_time_remaining_min, + snapshot.shed_forecast_confidence, + ) + ) + + def has_evse(snapshot: SpanPanelSnapshot) -> bool: """Detect whether an EVSE (EV charger) is commissioned.""" return len(snapshot.evse) > 0 @@ -282,8 +313,11 @@ def has_evse(snapshot: SpanPanelSnapshot) -> bool: def detect_capabilities(snapshot: SpanPanelSnapshot) -> frozenset[str]: """Derive the set of optional capabilities present in the snapshot. - Used by the coordinator to detect when new hardware (BESS, PV, EVSE, MID) appears - and trigger a reload so new sensors are created. + Used by the coordinator to detect when new hardware (BESS, PV, EVSE, MID) or a + new published capability (shed-forecast) appears, and trigger a reload so new + sensors are created. A capability is not hardware, but it reaches this the same + way — the panel starts publishing a node it did not publish before — and the + consequence is identical: entities that could not be created at setup now can. """ caps: set[str] = set() if has_bess(snapshot): @@ -296,4 +330,6 @@ def detect_capabilities(snapshot: SpanPanelSnapshot) -> frozenset[str]: caps.add("evse") if has_mid(snapshot): caps.add("mid") + if has_shed_forecast(snapshot): + caps.add("shed_forecast") return frozenset(caps) diff --git a/custom_components/span_panel/icons.json b/custom_components/span_panel/icons.json index 4a7e9445..43ca46f4 100644 --- a/custom_components/span_panel/icons.json +++ b/custom_components/span_panel/icons.json @@ -180,12 +180,18 @@ "pv_vendor": { "default": "mdi:domain" }, + "shed_total_time_remaining": { + "default": "mdi:battery-clock-outline" + }, "site_power": { "default": "mdi:home-lightning-bolt" }, "software_version": { "default": "mdi:information" }, + "time_to_priority_shed": { + "default": "mdi:timer-alert-outline" + }, "upstream_l1_current": { "default": "mdi:current-ac" }, diff --git a/custom_components/span_panel/sensor.py b/custom_components/span_panel/sensor.py index 66acba12..cc327b49 100644 --- a/custom_components/span_panel/sensor.py +++ b/custom_components/span_panel/sensor.py @@ -25,6 +25,7 @@ has_mid, has_power_flows, has_pv, + has_shed_forecast, resolve_evse_display_suffix, ) from .sensor_base import SpanEnergySensorBase, SpanSensorBase @@ -53,6 +54,7 @@ PANEL_POWER_SENSORS, PV_METADATA_SENSORS, PV_POWER_SENSOR, + SHED_FORECAST_SENSORS, SITE_POWER_SENSOR, STATUS_SENSORS, UNMAPPED_SENSORS, @@ -69,6 +71,7 @@ SpanPanelPowerSensor, SpanPanelStatus, SpanPVMetadataSensor, + SpanShedForecastSensor, ) from .util import bess_device_info, evse_device_info, mid_device_info @@ -86,6 +89,7 @@ "SpanPanelPowerSensor", "SpanPanelStatus", "SpanSensorBase", + "SpanShedForecastSensor", "SpanUnmappedCircuitSensor", ] @@ -360,6 +364,38 @@ def create_mid_sensors( return [SpanMidSensor(coordinator, desc, snapshot, mid_info) for desc in MID_SENSORS] +def create_shed_forecast_sensors( + coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot +) -> list[SpanShedForecastSensor]: + """Create the backup-planning forecast sensors the panel can actually fill. + + Two gates, not one, because absence has two shapes here. `has_shed_forecast` + answers whether the panel publishes the capability at all — false on every + flat panel and on any v1.0 firmware that omits the node, and the reason a + reload creates these when a panel gains it mid-life. The per-description + check then answers whether *this* estimate is among what the node publishes: + the catalog marks all four times SHOULD rather than MUST, so a partial + node is legal and the half it omits must produce no entity rather than one + permanently unknown. + + The presence test is the description's own `value_fn`. The field a sensor + reads is exactly the field whose absence should suppress it, so asking the + reader is what keeps the gate from drifting away from the read. + + DUAL-SCHEMA: gated on what the snapshot carries, never on a version or a + config flag. When the flat path retires, the first gate goes and the second + stays — a v1.0 panel may still publish a partial node. + """ + if not has_shed_forecast(snapshot): + return [] + + return [ + SpanShedForecastSensor(coordinator, description, snapshot) + for description in SHED_FORECAST_SENSORS + if description.value_fn(snapshot) is not None + ] + + def create_battery_sensors( coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot ) -> list[SpanPanelBattery | SpanPanelPowerSensor | SpanBessMetadataSensor]: @@ -446,6 +482,7 @@ def create_native_sensors( | SpanPVMetadataSensor | SpanEvseSensor | SpanMidSensor + | SpanShedForecastSensor ]: """Create all native sensors for the platform.""" entities: list[ @@ -461,6 +498,7 @@ def create_native_sensors( | SpanPVMetadataSensor | SpanEvseSensor | SpanMidSensor + | SpanShedForecastSensor ] = [] # Create different sensor types @@ -470,6 +508,7 @@ def create_native_sensors( entities.extend(create_unmapped_circuit_sensors(coordinator, snapshot)) entities.extend(create_battery_sensors(coordinator, snapshot)) entities.extend(create_mid_sensors(coordinator, snapshot)) + entities.extend(create_shed_forecast_sensors(coordinator, snapshot)) entities.extend(create_power_flow_sensors(coordinator, snapshot)) entities.extend(create_evse_sensors(coordinator, snapshot)) diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py index 1ea54b87..38e14688 100644 --- a/custom_components/span_panel/sensor_definitions.py +++ b/custom_components/span_panel/sensor_definitions.py @@ -25,6 +25,7 @@ UnitOfElectricPotential, UnitOfEnergy, UnitOfPower, + UnitOfTime, ) from homeassistant.helpers.entity import EntityCategory from span_panel_api import ( @@ -334,6 +335,97 @@ class SpanPanelBatterySensorEntityDescription( ) ) +# --------------------------------------------------------------------------- +# Shed forecast (v1.0 `shed-forecast`, conditionally created) +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class SpanShedForecastRequiredKeysMixin(FieldPathDeclarationMixin): + """Required keys mixin for the backup-planning forecast sensors. + + Carries a second reader beside `value_fn`, which the other panel mixins do + not need. The capability publishes each live estimate with a + hypothetical-full-charge twin, and the twin refines the number on screen + rather than standing on its own — so it belongs to the sensor as an + attribute, and which twin belongs to which sensor is a fact about the + pairing rather than about the entity class. + + Declared here so that pairing is data. Reading it off `description.key` + inside the entity would put a string comparison between the two halves of + something the catalog states outright, which is how a rename silently + swaps two plausible-looking durations. + """ + + value_fn: Callable[[SpanPanelSnapshot], int | None] + + full_charge_attribute: str + """Attribute name the hypothetical twin is published under.""" + + full_charge_fn: Callable[[SpanPanelSnapshot], int | None] + """Reads the twin. `None` when the panel does not publish it.""" + + +@dataclass(frozen=True, kw_only=True) +class SpanShedForecastSensorEntityDescription( + SensorEntityDescription, SpanShedForecastRequiredKeysMixin +): + """Describes one of the two backup-planning forecast sensors.""" + + +SHED_FORECAST_SENSORS: tuple[ + SpanShedForecastSensorEntityDescription, + SpanShedForecastSensorEntityDescription, +] = ( + SpanShedForecastSensorEntityDescription( + key="time_to_priority_shed", + derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD, + translation_key="time_to_priority_shed", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.MINUTES, + suggested_display_precision=0, + value_fn=lambda s: s.shed_time_to_priority_shed_min, + full_charge_attribute="full_charge_time_to_priority_shed", + full_charge_fn=lambda s: s.shed_full_charge_time_to_priority_shed_min, + ), + SpanShedForecastSensorEntityDescription( + key="shed_total_time_remaining", + derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD, + translation_key="shed_total_time_remaining", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.MINUTES, + suggested_display_precision=0, + value_fn=lambda s: s.shed_total_time_remaining_min, + full_charge_attribute="full_charge_total_time_remaining", + full_charge_fn=lambda s: s.shed_full_charge_total_time_remaining_min, + ), +) +"""The two live estimates from `energy.ebus.capability.shed-forecast` 0.1. + +**Enabled by default, and not diagnostic.** These are the numbers a user plans a +backup around — how long before the panel starts shedding circuits, how long +before the battery is spent — so they belong beside the power and energy +sensors rather than under the diagnostics fold. That is the whole argument for +surfacing them ahead of the rest of the unread v1.0 surface. + +**`derived` rather than a `field_path` declaration, by the producible rule.** +The gate requires a path *both* adapters produce, and no flat panel publishes +this capability at all; `SCHEMA_CONDITIONAL_FIELD` is what that situation is +called. The paths are enumerated in `RESIDUAL_EXEMPT_PATHS` instead, annotated +`SCHEMA_1_ONLY`, and the conformance suite checks that annotation against what +the adapters actually emit — so if flat firmware ever grew the capability, the +build would fail and demand promotion rather than leaving the read ungated. + +The two hypothetical-full-charge figures ride as attributes rather than as +sensors of their own. They answer "what would this installation give me from a +full battery", which moves when the hardware does and not as the battery +drains; a separate entity would put a near-constant on a graph beside the +countdown it qualifies. +""" + + # --------------------------------------------------------------------------- # Circuit diagnostic sensors (promoted from attributes) # --------------------------------------------------------------------------- @@ -835,6 +927,7 @@ def all_sensor_descriptions() -> tuple[SensorEntityDescription, ...]: *PANEL_ENERGY_SENSORS, *CIRCUIT_SENSORS, *EVSE_SENSORS, + *SHED_FORECAST_SENSORS, BATTERY_SENSOR, BATTERY_POWER_SENSOR, PV_POWER_SENSOR, diff --git a/custom_components/span_panel/sensor_panel.py b/custom_components/span_panel/sensor_panel.py index e49539ed..9f1bf38d 100644 --- a/custom_components/span_panel/sensor_panel.py +++ b/custom_components/span_panel/sensor_panel.py @@ -25,6 +25,7 @@ SpanPanelDataSensorEntityDescription, SpanPanelStatusSensorEntityDescription, SpanPVMetadataSensorEntityDescription, + SpanShedForecastSensorEntityDescription, ) _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -91,6 +92,98 @@ def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanPanelSnapshot: return snapshot +class SpanShedForecastSensor( + SpanSensorBase[SpanShedForecastSensorEntityDescription, SpanPanelSnapshot] +): + """One of the two backup-planning estimates, with its refinements attached. + + Created only where the panel publishes the estimate this sensor reads, so a + panel with no `shed-forecast` node — every flat panel, and any v1.0 panel + whose firmware omits the capability — gets no entity rather than one stuck + at unknown. See `create_shed_forecast_sensors`. + """ + + # `_residual_field_paths` stays empty on purpose. The attribute reads below + # are not declarable: neither adapter carries a metadata row for those three + # fields, so declaring them here would put them in `declared_field_paths()` + # where the producible gate rejects anything one adapter cannot emit. They + # are enumerated in `RESIDUAL_EXEMPT_PATHS` as `Producibility.NEITHER` + # instead, which is where the `mid.*` attribute reads live for the same + # reason. + + def __init__( + self, + data_coordinator: SpanPanelCoordinator, + description: SpanShedForecastSensorEntityDescription, + snapshot: SpanPanelSnapshot, + ) -> None: + """Initialize the shed-forecast sensor, keeping a typed handle on its description. + + `SensorEntity.entity_description` is annotated as the base + `SensorEntityDescription`, so reading the two extra members off it would + need either a narrowing override — which mypy rejects on a mutable + attribute — or a `getattr`, which is the same thing with the check + removed. Keeping the description under a name of our own is what makes + `full_charge_fn` and `full_charge_attribute` statically checked; the same + move `SpanPanelPowerSensor` makes for `_description_key`. + """ + super().__init__(data_coordinator, description, snapshot) + self._forecast = description + + def _generate_unique_id( + self, + snapshot: SpanPanelSnapshot, + description: SpanShedForecastSensorEntityDescription, + ) -> str: + """Generate unique ID for a shed-forecast sensor.""" + return construct_panel_unique_id_for_entry( + self.coordinator, snapshot, description.key, self._device_name + ) + + def _generate_friendly_name( + self, + snapshot: SpanPanelSnapshot, + description: SpanShedForecastSensorEntityDescription, + ) -> str: + """Generate friendly name for a shed-forecast sensor.""" + if description.name is not None and description.name is not UNDEFINED: + return str(description.name) + return "Shed Forecast" + + def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanPanelSnapshot: + """Get the data source for the shed-forecast sensor.""" + return snapshot + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """The hypothetical-full-charge twin, and the estimate's confidence. + + Both are omitted when the panel does not publish them, rather than + appearing as `None`. An attribute that is present and empty reads as a + reading the panel failed to produce; an absent one reads as a firmware + that does not carry it, which is what this is. + + Which twin belongs to this sensor comes from the description, not from a + comparison against `key` — the pairing is stated once, where the two + readers sit beside each other. + """ + snapshot = self.coordinator.data + if snapshot is None: + return None + + attributes: dict[str, Any] = {} + + full_charge = self._forecast.full_charge_fn(snapshot) + if full_charge is not None: + attributes[self._forecast.full_charge_attribute] = full_charge + + confidence = snapshot.shed_forecast_confidence + if confidence is not None: + attributes["forecast_confidence"] = confidence + + return attributes or None + + class SpanPanelStatus(SpanSensorBase[SpanPanelStatusSensorEntityDescription, SpanPanelSnapshot]): """Span Panel hardware status sensor entity.""" diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index c839aad1..11908f34 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -192,15 +192,6 @@ "bess_vendor": { "name": "Vendor" }, - "mid_grid_state": { - "name": "Grid State", - "state": { - "up": "Up", - "down": "Down", - "degraded": "Degraded", - "unknown": "Unknown" - } - }, "current_run_config": { "name": "Current Run Config", "state": { @@ -313,6 +304,15 @@ "unknown": "Unknown" } }, + "mid_grid_state": { + "name": "Grid State", + "state": { + "up": "Up", + "down": "Down", + "degraded": "Degraded", + "unknown": "Unknown" + } + }, "pv_nameplate_capacity": { "name": "PV Nameplate Capacity" }, @@ -325,12 +325,18 @@ "pv_vendor": { "name": "PV Vendor" }, + "shed_total_time_remaining": { + "name": "Backup Time Remaining" + }, "site_power": { "name": "Site Power" }, "software_version": { "name": "Software Version" }, + "time_to_priority_shed": { + "name": "Time to Priority Shed" + }, "upstream_l1_current": { "name": "Upstream L1 Current" }, diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index c839aad1..11908f34 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -192,15 +192,6 @@ "bess_vendor": { "name": "Vendor" }, - "mid_grid_state": { - "name": "Grid State", - "state": { - "up": "Up", - "down": "Down", - "degraded": "Degraded", - "unknown": "Unknown" - } - }, "current_run_config": { "name": "Current Run Config", "state": { @@ -313,6 +304,15 @@ "unknown": "Unknown" } }, + "mid_grid_state": { + "name": "Grid State", + "state": { + "up": "Up", + "down": "Down", + "degraded": "Degraded", + "unknown": "Unknown" + } + }, "pv_nameplate_capacity": { "name": "PV Nameplate Capacity" }, @@ -325,12 +325,18 @@ "pv_vendor": { "name": "PV Vendor" }, + "shed_total_time_remaining": { + "name": "Backup Time Remaining" + }, "site_power": { "name": "Site Power" }, "software_version": { "name": "Software Version" }, + "time_to_priority_shed": { + "name": "Time to Priority Shed" + }, "upstream_l1_current": { "name": "Upstream L1 Current" }, diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index 795b897f..603c749b 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -325,12 +325,18 @@ "pv_vendor": { "name": "Fabricante PV" }, + "shed_total_time_remaining": { + "name": "Tiempo de Respaldo Restante" + }, "site_power": { "name": "Potencia del Sitio" }, "software_version": { "name": "Versión de Software" }, + "time_to_priority_shed": { + "name": "Tiempo hasta el Corte por Prioridad" + }, "upstream_l1_current": { "name": "Corriente Ascendente L1" }, diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index 44b0fa65..096f209d 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -325,12 +325,18 @@ "pv_vendor": { "name": "Fournisseur PV" }, + "shed_total_time_remaining": { + "name": "Autonomie de Secours Restante" + }, "site_power": { "name": "Puissance du Site" }, "software_version": { "name": "Version du Logiciel" }, + "time_to_priority_shed": { + "name": "Temps avant Délestage Prioritaire" + }, "upstream_l1_current": { "name": "Courant Amont L1" }, diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index 7c2bf0c2..6b1e7530 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -325,12 +325,18 @@ "pv_vendor": { "name": "PVベンダー" }, + "shed_total_time_remaining": { + "name": "バックアップ残り時間" + }, "site_power": { "name": "サイト電力" }, "software_version": { "name": "ソフトウェアバージョン" }, + "time_to_priority_shed": { + "name": "優先遮断までの時間" + }, "upstream_l1_current": { "name": "上流側L1電流" }, diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index f2db3d6c..b793f4e7 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -325,12 +325,18 @@ "pv_vendor": { "name": "Fornecedor PV" }, + "shed_total_time_remaining": { + "name": "Tempo de Reserva Restante" + }, "site_power": { "name": "Potência do Local" }, "software_version": { "name": "Versão do Software" }, + "time_to_priority_shed": { + "name": "Tempo até o Corte por Prioridade" + }, "upstream_l1_current": { "name": "Corrente Upstream L1" }, diff --git a/tests/adapter_fixtures.py b/tests/adapter_fixtures.py index 473161ca..c71743ca 100644 --- a/tests/adapter_fixtures.py +++ b/tests/adapter_fixtures.py @@ -17,32 +17,74 @@ import pathlib from ebus_sdk.homie import DiscoveredDevice -from span_panel_api.models import FieldMetadata +from span_panel_api.models import FieldMetadata, SpanPanelSnapshot _FIXTURES = pathlib.Path(__file__).parent / "fixtures" +SCHEMA_ONE_PANEL = "example-40t-001" +"""Device id of the enclosure in `schema_one_tree.json`. -def _devices(name: str) -> list[DiscoveredDevice]: - """Rebuild discovered devices from a retained-topic capture. +Named rather than inlined because a test that mutates one of the panel's +published topics has to address the panel, and every such test would otherwise +carry its own copy of the simulator's device naming. +""" + + +def _device(device_id: str, topics: dict[str, str]) -> DiscoveredDevice: + """Rebuild one discovered device from its retained topics. Mirrors the library's own builder (test_schema_one_devices.py:26-37): `update_description` parses the JSON string, `update_property` stores each non-`$` topic as a property value. """ + device = DiscoveredDevice(device_id, "ebus") + device.update_description(topics["$description"]) + device.update_state(topics.get("$state", "ready")) + for topic, value in topics.items(): + if topic.startswith("$"): + continue + node, _, prop = topic.partition("/") + if prop: + device.update_property(node, prop, value) + return device + + +def _devices(name: str) -> list[DiscoveredDevice]: + """Rebuild discovered devices from a named retained-topic capture.""" tree = json.loads((_FIXTURES / name).read_text()) - devices: list[DiscoveredDevice] = [] - for device_id, topics in tree.items(): - device = DiscoveredDevice(device_id, "ebus") - device.update_description(topics["$description"]) - device.update_state(topics.get("$state", "ready")) - for topic, value in topics.items(): - if topic.startswith("$"): - continue - node, _, prop = topic.partition("/") - if prop: - device.update_property(node, prop, value) - devices.append(device) - return devices + return [_device(device_id, topics) for device_id, topics in tree.items()] + + +def schema_one_tree() -> dict[str, dict[str, str]]: + """A mutable copy of the parent/child capture, ready to be rewritten. + + Copied per call, and one level deep, which is as deep as a topic goes: a + test proves a reading came off the wire by republishing it and asserting the + entity followed, and that is impossible against a shared immutable capture. + """ + tree = json.loads((_FIXTURES / "schema_one_tree.json").read_text()) + return {device_id: dict(topics) for device_id, topics in tree.items()} + + +def schema_one_snapshot(tree: dict[str, dict[str, str]] | None = None) -> SpanPanelSnapshot: + """Build a real snapshot from the capture, through the real schema_1 mapper. + + The point of going the long way round rather than through + `SpanPanelSnapshotFactory`: a factory takes the value a test hands it, so an + assertion against it proves only that the test and the entity agree. Driving + the actual adapter over the actual capture makes the published topic the + source of truth, so republishing one is a mutation the entity has to follow. + """ + from span_panel_api_schema_1.snapshot import build_snapshot + + tree = schema_one_tree() if tree is None else tree + panel = _device(SCHEMA_ONE_PANEL, tree[SCHEMA_ONE_PANEL]) + children = [ + _device(device_id, topics) + for device_id, topics in tree.items() + if device_id != SCHEMA_ONE_PANEL + ] + return build_snapshot(panel, children) def schema_zero_metadata() -> dict[str, FieldMetadata]: diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py index 070b0f04..e3598eb7 100644 --- a/tests/test_field_path_conformance.py +++ b/tests/test_field_path_conformance.py @@ -253,9 +253,14 @@ def test_no_exempt_path_is_producible_by_both() -> None: _EXPECTED_EXEMPT_COUNTS: dict[Producibility, int] = { - Producibility.NEITHER: 15, + # +3 with the shed forecast: the two full-charge refinements and the + # confidence enum, read as attributes on the two forecast sensors and + # carried by no adapter's metadata map. + Producibility.NEITHER: 18, Producibility.SCHEMA_0_ONLY: 10, - Producibility.SCHEMA_1_ONLY: 1, + # +2 with the shed forecast: the two live estimates, which schema_1 maps and + # flat firmware does not publish at all. + Producibility.SCHEMA_1_ONLY: 3, } """The exemption inventory, by reason. See `test_exempt_inventory_is_complete`.""" diff --git a/tests/test_shed_forecast.py b/tests/test_shed_forecast.py new file mode 100644 index 00000000..a6bfb083 --- /dev/null +++ b/tests/test_shed_forecast.py @@ -0,0 +1,475 @@ +"""The backup-planning forecast, surfaced as two sensors and three attributes. + +Every assertion here runs against a real snapshot built by the real schema_1 +adapter over the vendored capture, and every expected value is read out of that +capture rather than written as a literal. That is deliberate and it is the whole +design of this module: a test that pins the same constant the code pins passes +whether or not the wire is ever read, so each reading is proved by republishing +it — a different value, or none — and asserting the entity moved. +""" + +from __future__ import annotations + +import json +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from span_panel_api import SpanPanelSnapshot + +from custom_components.span_panel import SpanPanelRuntimeData +from custom_components.span_panel.field_paths import ( + RESIDUAL_EXEMPT_PATHS, + Producibility, +) +from custom_components.span_panel.helpers import detect_capabilities, has_shed_forecast +from custom_components.span_panel.sensor import create_shed_forecast_sensors +from custom_components.span_panel.sensor_definitions import SHED_FORECAST_SENSORS +from custom_components.span_panel.sensor_panel import SpanShedForecastSensor +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import CONF_HOST, UnitOfTime +from homeassistant.helpers.entity import EntityCategory + +from .adapter_fixtures import SCHEMA_ONE_PANEL, schema_one_snapshot, schema_one_tree +from .factories import SpanPanelSnapshotFactory + +from pytest_homeassistant_custom_component.common import MockConfigEntry + +NODE = "shed-forecast" + +TIME_TO_PRIORITY_SHED = "time-to-priority-shed" +TOTAL_TIME_REMAINING = "total-time-remaining" +FULL_CHARGE_TIME_TO_PRIORITY_SHED = "full-charge-time-to-priority-shed" +FULL_CHARGE_TOTAL_TIME_REMAINING = "full-charge-total-time-remaining" +CONFIDENCE = "confidence" + +TIME_TO_PRIORITY_SHED_KEY = "time_to_priority_shed" +TOTAL_TIME_REMAINING_KEY = "shed_total_time_remaining" + + +@pytest.fixture(autouse=True) +def _mock_entity_registry() -> Any: + """Patch entity registry lookups used during sensor construction.""" + registry = MagicMock() + registry.async_get_entity_id.return_value = None + with patch( + "custom_components.span_panel.sensor_base.er.async_get", + return_value=registry, + ): + yield registry + + +def _coordinator(snapshot: SpanPanelSnapshot) -> MagicMock: + """A coordinator-like mock carrying one snapshot.""" + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.hass = MagicMock() + coordinator.panel_offline = False + coordinator.unresolved_paths = frozenset() + coordinator.config_entry = MockConfigEntry( + domain="span_panel", + data={CONF_HOST: "192.168.1.50"}, + options={}, + title="SPAN Panel", + unique_id=snapshot.serial_number, + ) + coordinator.config_entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, panel_device_id="panel-device-id" + ) + return coordinator + + +def _published(property_id: str) -> str: + """What the capture publishes for one forecast property.""" + return schema_one_tree()[SCHEMA_ONE_PANEL][f"{NODE}/{property_id}"] + + +def _republishing(**topics: str) -> SpanPanelSnapshot: + """A snapshot from the capture with some forecast topics rewritten.""" + tree = schema_one_tree() + for property_id, value in topics.items(): + tree[SCHEMA_ONE_PANEL][f"{NODE}/{property_id.replace('_', '-')}"] = value + return schema_one_snapshot(tree) + + +def _without(*property_ids: str) -> SpanPanelSnapshot: + """A snapshot from a capture that stopped publishing (and declaring) properties.""" + tree = schema_one_tree() + description = json.loads(tree[SCHEMA_ONE_PANEL]["$description"]) + for property_id in property_ids: + del tree[SCHEMA_ONE_PANEL][f"{NODE}/{property_id}"] + del description["nodes"][NODE]["properties"][property_id] + tree[SCHEMA_ONE_PANEL]["$description"] = json.dumps(description) + return schema_one_snapshot(tree) + + +def _without_node() -> SpanPanelSnapshot: + """A snapshot from a capture with no `shed-forecast` node at all.""" + tree = schema_one_tree() + for topic in [t for t in tree[SCHEMA_ONE_PANEL] if t.startswith(f"{NODE}/")]: + del tree[SCHEMA_ONE_PANEL][topic] + description = json.loads(tree[SCHEMA_ONE_PANEL]["$description"]) + del description["nodes"][NODE] + tree[SCHEMA_ONE_PANEL]["$description"] = json.dumps(description) + return schema_one_snapshot(tree) + + +def _sensors(snapshot: SpanPanelSnapshot) -> dict[str, SpanShedForecastSensor]: + """Whatever the platform creates for this snapshot, keyed by description key.""" + created = create_shed_forecast_sensors(_coordinator(snapshot), snapshot) + return {sensor.entity_description.key: sensor for sensor in created} + + +def _state(snapshot: SpanPanelSnapshot, key: str) -> float | int | str | None: + """The state one forecast sensor reports for a snapshot.""" + sensor = _sensors(snapshot)[key] + sensor._update_native_value() + return sensor.native_value + + +def _attributes(snapshot: SpanPanelSnapshot, key: str) -> dict[str, Any]: + return _sensors(snapshot)[key].extra_state_attributes or {} + + +# --------------------------------------------------------------------------- +# The premise: the capture publishes the capability +# --------------------------------------------------------------------------- + + +def test_the_capture_publishes_the_whole_capability() -> None: + """Guard the premise for every test below, all of which read the capture for + their expected value: a capture that stopped publishing the node would make + them vacuously true rather than failing.""" + panel = schema_one_tree()[SCHEMA_ONE_PANEL] + + for property_id in ( + TIME_TO_PRIORITY_SHED, + TOTAL_TIME_REMAINING, + FULL_CHARGE_TIME_TO_PRIORITY_SHED, + FULL_CHARGE_TOTAL_TIME_REMAINING, + CONFIDENCE, + ): + assert f"{NODE}/{property_id}" in panel + + +# --------------------------------------------------------------------------- +# States follow the wire +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("key", "property_id"), + [ + (TIME_TO_PRIORITY_SHED_KEY, TIME_TO_PRIORITY_SHED), + (TOTAL_TIME_REMAINING_KEY, TOTAL_TIME_REMAINING), + ], +) +def test_each_sensor_reports_the_value_the_panel_published(key: str, property_id: str) -> None: + assert _state(schema_one_snapshot(), key) == float(_published(property_id)) + + +def test_the_two_sensors_do_not_report_the_same_reading() -> None: + """The capture publishes different values for the two estimates, so a wiring + that crossed them would fail here rather than looking plausible.""" + snapshot = schema_one_snapshot() + + assert _state(snapshot, TIME_TO_PRIORITY_SHED_KEY) != _state( + snapshot, TOTAL_TIME_REMAINING_KEY + ) + + +@pytest.mark.parametrize( + ("key", "property_id", "republished"), + [ + (TIME_TO_PRIORITY_SHED_KEY, TIME_TO_PRIORITY_SHED, "17"), + (TOTAL_TIME_REMAINING_KEY, TOTAL_TIME_REMAINING, "1440"), + ], +) +def test_republishing_an_estimate_moves_its_sensor( + key: str, property_id: str, republished: str +) -> None: + """The mutation proof. Each republished value differs from every value the + capture carries, so a sensor pinned to a constant — or wired to the wrong + property — cannot report it.""" + snapshot = _republishing(**{property_id.replace("-", "_"): republished}) + + assert _state(snapshot, key) == float(republished) + assert _state(snapshot, key) != float(_published(property_id)) + + +@pytest.mark.parametrize( + ("key", "property_id"), + [ + (TIME_TO_PRIORITY_SHED_KEY, TIME_TO_PRIORITY_SHED), + (TOTAL_TIME_REMAINING_KEY, TOTAL_TIME_REMAINING), + ], +) +def test_zero_minutes_is_a_state_and_not_an_absence(key: str, property_id: str) -> None: + """Shedding starts now is a reading, and the most important one the capability + reports. A gate that treated zero as absence would delete the entity exactly + when a user needs it.""" + snapshot = _republishing(**{property_id.replace("-", "_"): "0"}) + + assert key in _sensors(snapshot) + assert _state(snapshot, key) == 0.0 + + +# --------------------------------------------------------------------------- +# Attributes follow the wire +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("key", "attribute", "property_id"), + [ + ( + TIME_TO_PRIORITY_SHED_KEY, + "full_charge_time_to_priority_shed", + FULL_CHARGE_TIME_TO_PRIORITY_SHED, + ), + ( + TOTAL_TIME_REMAINING_KEY, + "full_charge_total_time_remaining", + FULL_CHARGE_TOTAL_TIME_REMAINING, + ), + ], +) +def test_each_sensor_carries_its_own_full_charge_twin( + key: str, attribute: str, property_id: str +) -> None: + """The pairing, not just the presence: each estimate carries the + full-charge figure that refines *it*.""" + attributes = _attributes(schema_one_snapshot(), key) + + assert attributes[attribute] == int(_published(property_id)) + + +def test_no_sensor_carries_the_other_sensors_twin() -> None: + """The capture publishes 3038 and 4320, and the second happens to equal the + live total — so only the pairing check above plus this one can tell a correct + wiring from a crossed one.""" + assert "full_charge_total_time_remaining" not in _attributes( + schema_one_snapshot(), TIME_TO_PRIORITY_SHED_KEY + ) + assert "full_charge_time_to_priority_shed" not in _attributes( + schema_one_snapshot(), TOTAL_TIME_REMAINING_KEY + ) + + +@pytest.mark.parametrize( + ("key", "attribute", "property_id"), + [ + ( + TIME_TO_PRIORITY_SHED_KEY, + "full_charge_time_to_priority_shed", + FULL_CHARGE_TIME_TO_PRIORITY_SHED, + ), + ( + TOTAL_TIME_REMAINING_KEY, + "full_charge_total_time_remaining", + FULL_CHARGE_TOTAL_TIME_REMAINING, + ), + ], +) +def test_republishing_a_full_charge_figure_moves_its_attribute( + key: str, attribute: str, property_id: str +) -> None: + snapshot = _republishing(**{property_id.replace("-", "_"): "999"}) + + assert _attributes(snapshot, key)[attribute] == 999 + + +@pytest.mark.parametrize( + ("key", "attribute", "property_id"), + [ + ( + TIME_TO_PRIORITY_SHED_KEY, + "full_charge_time_to_priority_shed", + FULL_CHARGE_TIME_TO_PRIORITY_SHED, + ), + ( + TOTAL_TIME_REMAINING_KEY, + "full_charge_total_time_remaining", + FULL_CHARGE_TOTAL_TIME_REMAINING, + ), + ], +) +def test_a_full_charge_figure_the_panel_drops_takes_its_attribute_with_it( + key: str, attribute: str, property_id: str +) -> None: + """Absent, not `None`. An attribute that is present and empty reads as a + reading the panel failed to produce; this is firmware that does not carry the + property at all.""" + snapshot = _without(property_id) + + assert attribute not in _attributes(snapshot, key) + # The sensor itself is unaffected: the estimate it reads is still published. + assert _state(snapshot, key) == float( + _published(TIME_TO_PRIORITY_SHED if key == TIME_TO_PRIORITY_SHED_KEY else TOTAL_TIME_REMAINING) + ) + + +@pytest.mark.parametrize("key", [TIME_TO_PRIORITY_SHED_KEY, TOTAL_TIME_REMAINING_KEY]) +def test_confidence_rides_on_both_sensors(key: str) -> None: + """It qualifies both estimates, so it appears on both.""" + assert _attributes(schema_one_snapshot(), key)["forecast_confidence"] == _published( + CONFIDENCE + ) + + +@pytest.mark.parametrize("key", [TIME_TO_PRIORITY_SHED_KEY, TOTAL_TIME_REMAINING_KEY]) +def test_republishing_confidence_moves_the_attribute(key: str) -> None: + snapshot = _republishing(confidence="LOW") + + assert _attributes(snapshot, key)["forecast_confidence"] == "LOW" + assert _attributes(snapshot, key)["forecast_confidence"] != _published(CONFIDENCE) + + +@pytest.mark.parametrize("key", [TIME_TO_PRIORITY_SHED_KEY, TOTAL_TIME_REMAINING_KEY]) +def test_confidence_the_panel_drops_takes_its_attribute_with_it(key: str) -> None: + assert "forecast_confidence" not in _attributes(_without(CONFIDENCE), key) + + +def test_a_sensor_with_neither_refinement_publishes_no_attributes_at_all() -> None: + """`None` rather than an empty dict, which is what the entity contract asks + for and what stops an empty attribute block rendering.""" + snapshot = _without(FULL_CHARGE_TIME_TO_PRIORITY_SHED, CONFIDENCE) + + assert _sensors(snapshot)[TIME_TO_PRIORITY_SHED_KEY].extra_state_attributes is None + + +# --------------------------------------------------------------------------- +# Creation is gated on what the panel publishes +# --------------------------------------------------------------------------- + + +def test_the_capture_creates_both_sensors() -> None: + assert set(_sensors(schema_one_snapshot())) == { + TIME_TO_PRIORITY_SHED_KEY, + TOTAL_TIME_REMAINING_KEY, + } + + +def test_a_panel_with_no_forecast_node_gets_no_sensors() -> None: + """The absence test. A dead entity stuck at unknown is worse than no entity: + it occupies the entity list, breaks a dashboard card, and cannot be told + apart from a panel whose forecast has failed.""" + snapshot = _without_node() + + assert has_shed_forecast(snapshot) is False + assert create_shed_forecast_sensors(_coordinator(snapshot), snapshot) == [] + + +def test_a_flat_panel_gets_no_sensors() -> None: + """The same absence by the other route: flat firmware publishes no such + capability, so the factory's default snapshot carries none of the fields.""" + snapshot = SpanPanelSnapshotFactory.create() + + assert has_shed_forecast(snapshot) is False + assert create_shed_forecast_sensors(_coordinator(snapshot), snapshot) == [] + + +@pytest.mark.parametrize( + ("dropped", "surviving"), + [ + (TIME_TO_PRIORITY_SHED, TOTAL_TIME_REMAINING_KEY), + (TOTAL_TIME_REMAINING, TIME_TO_PRIORITY_SHED_KEY), + ], +) +def test_a_partial_node_creates_only_the_sensor_it_can_fill( + dropped: str, surviving: str +) -> None: + """The catalog marks all four times SHOULD, not MUST, so a partial node is + legal firmware rather than a defect — and the half it omits must produce no + entity rather than one permanently unknown.""" + snapshot = _without(dropped) + + assert has_shed_forecast(snapshot) is True + assert set(_sensors(snapshot)) == {surviving} + + +def test_the_forecast_appearing_is_a_capability_change() -> None: + """Which is how a panel that gains the node mid-life gets the sensors: the + coordinator reloads on a new capability.""" + assert "shed_forecast" not in detect_capabilities(SpanPanelSnapshotFactory.create()) + assert "shed_forecast" in detect_capabilities(schema_one_snapshot()) + assert "shed_forecast" not in detect_capabilities(_without_node()) + + +# --------------------------------------------------------------------------- +# Shape of the entities +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("description", SHED_FORECAST_SENSORS, ids=lambda d: d.key) +def test_both_are_duration_sensors_in_the_unit_the_capability_declares( + description: Any, +) -> None: + assert description.device_class is SensorDeviceClass.DURATION + assert description.state_class is SensorStateClass.MEASUREMENT + assert description.native_unit_of_measurement == UnitOfTime.MINUTES + + +@pytest.mark.parametrize("description", SHED_FORECAST_SENSORS, ids=lambda d: d.key) +def test_both_are_enabled_by_default_and_not_filed_under_diagnostics( + description: Any, +) -> None: + """These are the numbers a user plans a backup around, which is the whole + argument for surfacing them ahead of the rest of the unread v1.0 surface. A + disabled or diagnostic sensor would surface them in name only.""" + assert description.entity_registry_enabled_default is True + assert description.entity_category is not EntityCategory.DIAGNOSTIC + + +def test_the_declared_unit_matches_what_the_panel_declares() -> None: + """HA's unit against the tree's, for the two paths schema_1 carries metadata + for. A disagreement here is what the integration's unit-mismatch Repair + reports at runtime; catching it in the suite is cheaper.""" + from .adapter_fixtures import schema_one_metadata + + metadata = schema_one_metadata() + for description, field_path in ( + (SHED_FORECAST_SENSORS[0], "panel.shed_time_to_priority_shed_min"), + (SHED_FORECAST_SENSORS[1], "panel.shed_total_time_remaining_min"), + ): + assert metadata[field_path].unit == description.native_unit_of_measurement + + +def test_the_two_sensors_get_distinct_unique_ids() -> None: + """They live on the same device and differ only by description key.""" + sensors = _sensors(schema_one_snapshot()) + unique_ids = {sensor.unique_id for sensor in sensors.values()} + + assert len(unique_ids) == len(sensors) + for unique_id in unique_ids: + assert schema_one_snapshot().serial_number.lower() in unique_id + + +# --------------------------------------------------------------------------- +# Conformance annotations +# --------------------------------------------------------------------------- + + +def test_the_two_live_estimates_are_exempt_as_schema_1_only() -> None: + """Pinned here as well as in the conformance suite, because the reason is + specific to this capability: no flat panel publishes it, so the producible + gate cannot be satisfied and the descriptions must stay derived.""" + assert ( + RESIDUAL_EXEMPT_PATHS["panel.shed_time_to_priority_shed_min"] + is Producibility.SCHEMA_1_ONLY + ) + assert ( + RESIDUAL_EXEMPT_PATHS["panel.shed_total_time_remaining_min"] + is Producibility.SCHEMA_1_ONLY + ) + + +def test_the_three_refinements_are_exempt_as_neither() -> None: + """No adapter maps them, by design — they qualify the two estimates rather + than being readings of their own.""" + for path in ( + "panel.shed_full_charge_time_to_priority_shed_min", + "panel.shed_full_charge_total_time_remaining_min", + "panel.shed_forecast_confidence", + ): + assert RESIDUAL_EXEMPT_PATHS[path] is Producibility.NEITHER diff --git a/tests/test_unit_vocabulary.py b/tests/test_unit_vocabulary.py index 7f76c626..a9f90e02 100644 --- a/tests/test_unit_vocabulary.py +++ b/tests/test_unit_vocabulary.py @@ -10,6 +10,7 @@ UnitOfElectricPotential, UnitOfEnergy, UnitOfPower, + UnitOfTime, ) import pytest from span_panel_api.models import FieldMetadata @@ -23,6 +24,10 @@ *(u.value for u in UnitOfEnergy), *(u.value for u in UnitOfElectricCurrent), *(u.value for u in UnitOfElectricPotential), + # `shed-forecast` declares its four estimates in `min`, which is + # `UnitOfTime.MINUTES` — the first non-electrical quantity a SPAN panel + # publishes, and the reason a time vocabulary belongs here at all. + *(u.value for u in UnitOfTime), PERCENTAGE, } From eb8f9a655c41c1eda92d7b2ecec87099ad81e030 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:58:16 -0700 Subject: [PATCH 047/345] feat(field-paths): degrade a schema-conditional entity like any other A `SCHEMA_CONDITIONAL_FIELD` description named no field, so the two halves of the degradation apparatus both skipped it: `_reads_an_unresolved_field` required `not description.derived`, and `evaluate_field_metadata` only ever asked the adapter about `declared_field_paths()`. Such an entity therefore kept publishing a parsed default when its field went unresolved, and the Repair for that field could name nobody -- which the suppression rule turns into no Repair at all. That is the `evse_ev_connected` failure reached by a second route, and Task 1 had just shipped two entities down it. The exemption is from the *producible* gate, which is a claim about the other adapter. The adapter that is running still publishes a metadata row for the field and still reports whether the panel resolved it. So the description now names its source field alongside `derived`: `field_path` answers "what is this entity's value", `derived` answers "why is that path outside the both-adapters gate", and only the first is what the Repair and the availability probe need. The deliberate exclusion is untouched and now has a test of its own: `circuit.name` and `circuit.tabs` are residual reads that decorate a reading rather than being it, and a circuit's power is still true when they are gone. `evaluate_field_metadata` widens to the descriptions' source fields, not to every non-NEITHER exemption, for exactly that reason -- device_info fields and circuit attributes stay out. `panel.dominant_power_source` joins RESIDUAL_EXEMPT_PATHS. The `grid_forming_entity` sensor read it while it was enumerated nowhere, so the evaluator logged it as produced-but-unread with an entity reading it. `test_schema_conditional_descriptions_name_an_exempt_field` is what makes that omission impossible to repeat, and the introspection proxy now checks the named path against what the value_fn reads. --- custom_components/span_panel/binary_sensor.py | 2 + custom_components/span_panel/entity.py | 55 +++--- custom_components/span_panel/field_paths.py | 163 ++++++++++++++---- .../span_panel/schema_validation.py | 12 +- .../span_panel/sensor_definitions.py | 23 ++- tests/test_field_path_conformance.py | 15 +- tests/test_field_path_introspection.py | 31 +++- tests/test_field_paths.py | 87 +++++++--- tests/test_schema_availability.py | 48 +++++- tests/test_schema_repairs.py | 56 ++++-- tests/test_schema_validation.py | 55 +++++- 11 files changed, 434 insertions(+), 113 deletions(-) diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py index 3c2ef7ec..7c2d9f1c 100644 --- a/custom_components/span_panel/binary_sensor.py +++ b/custom_components/span_panel/binary_sensor.py @@ -141,6 +141,7 @@ def _grid_islandable(snapshot: SpanPanelSnapshot) -> bool | None: GRID_ISLANDABLE_SENSOR = SpanPanelBinarySensorEntityDescription( key="grid_islandable", + field_path="panel.grid_islandable", derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD, translation_key="grid_islandable", device_class=BinarySensorDeviceClass.POWER, @@ -150,6 +151,7 @@ def _grid_islandable(snapshot: SpanPanelSnapshot) -> bool | None: BESS_CONNECTED_SENSOR = SpanPanelBinarySensorEntityDescription( key="bess_connected", + field_path="battery.connected", derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD, translation_key="bess_connected", device_class=BinarySensorDeviceClass.CONNECTIVITY, diff --git a/custom_components/span_panel/entity.py b/custom_components/span_panel/entity.py index f4ca67b5..eae3dc85 100644 --- a/custom_components/span_panel/entity.py +++ b/custom_components/span_panel/entity.py @@ -60,22 +60,36 @@ async def async_will_remove_from_hass(self) -> None: self.coordinator.async_unregister_field_path_entity(field_path, self.entity_id) await super().async_will_remove_from_hass() + def _source_field_path(self) -> str | None: + """Return the snapshot field this entity's value comes from, if any. + + The description's `field_path`, regardless of `derived`. A + `SCHEMA_CONDITIONAL_FIELD` description is exempt from the *producible* + gate, which is a statement about the other adapter, not about this + entity: the adapter that does produce the field publishes a metadata + row for it, that row can come back unresolved, and when it does this + entity's reading is a default rather than a measurement — exactly as + for a plain declaration. + + `None` for `MULTIPLE_FIELDS` and `NO_SOURCE_FIELD` descriptions, which + have no single field to blame, and for the circuit switch, which has no + entity description at all. + """ + description: object = getattr(self, "entity_description", None) + if isinstance(description, FieldPathDeclarationMixin): + return description.field_path + return None + def _declared_field_paths(self) -> tuple[str, ...]: """Return every snapshot field this entity reads. - The description's `field_path` when it declares one, plus any residual - reads. A description that declares nothing (`derived` entities, whose - `DerivedReason` says why no single field is theirs) contributes nothing, - and a platform with no entity description at all — the circuit switch — - contributes only its residual reads. + Its source field when it has one, plus any residual reads. A platform + with no entity description at all — the circuit switch — contributes + only its residual reads. """ - description: object = getattr(self, "entity_description", None) - if ( - isinstance(description, FieldPathDeclarationMixin) - and not description.derived - and description.field_path is not None - ): - return (description.field_path, *self._residual_field_paths) + source = self._source_field_path() + if source is not None: + return (source, *self._residual_field_paths) return self._residual_field_paths @property @@ -91,15 +105,16 @@ def _reads_an_unresolved_field(self) -> bool: is still true when they are gone. The switch and select read their state through residual paths and so are not covered here; the Repair still names them. + + `derived` is deliberately not consulted. It says why a path is outside + the both-adapters producible gate, which is a fact about the *other* + adapter; the adapter running here still resolves the field or fails to. + Excluding schema-conditional entities from this probe left them with a + default they present as a reading -- the `evse_ev_connected` failure + mode, reached by a different route. """ - description: object = getattr(self, "entity_description", None) - if ( - isinstance(description, FieldPathDeclarationMixin) - and not description.derived - and description.field_path is not None - ): - return description.field_path in self.coordinator.unresolved_paths - return False + source = self._source_field_path() + return source is not None and source in self.coordinator.unresolved_paths @property def available(self) -> bool: diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py index c060a7f0..57d1cda9 100644 --- a/custom_components/span_panel/field_paths.py +++ b/custom_components/span_panel/field_paths.py @@ -65,9 +65,20 @@ class DerivedReason(Enum): """Reads exactly one field, which only one adapter produces. Keeps the producible gate satisfiable: the gate requires a path both - adapters emit, so a schema-conditional field cannot be declared. If the + adapters emit, so a schema-conditional field cannot satisfy it. If the other adapter ever grows the field, this stops being true and the - verification fails, demanding promotion to a `field_path` declaration. + verification fails, demanding promotion to a plain `field_path` declaration. + + Alone among the reasons, this one still names its source: the description + sets `field_path` **as well as** `derived`, and + `test_schema_conditional_descriptions_name_their_field` holds it to that. + The two attributes answer different questions -- "what does this entity's + value come from" and "why is that path outside the both-adapters gate" -- + and only the first is what a Repair and the availability probe need. Leaving + it unset excused schema-conditional entities from both, which is the + `evse_ev_connected` failure mode one level along: an entity whose field the + panel stopped resolving would keep publishing a default, and the Repair + naming that field would say "0 entities affected". """ @@ -111,20 +122,30 @@ class FieldPathDeclarationMixin: """ field_path: str | None = None - """Snapshot field this entity reads, e.g. "circuit.instant_power_w". + """Snapshot field this entity's value comes from, e.g. "circuit.instant_power_w". Declared here rather than in a parallel map so the declaration and the reader are the same object. Verified against `value_fn` by the proxy test in tests/test_field_path_introspection.py. + + Set whenever the entity *has* a single source field — including when that + field is schema-conditional and so cannot enter the producible gate. What + keeps a schema-conditional path out of the gate is `derived`, not the + absence of this. Consumers that ask "which field is this entity's value" + (`SpanPanelEntity._declared_field_paths`, `_reads_an_unresolved_field`) + therefore read this alone; the gate additionally consults `derived`. """ derived: DerivedReason | None = None - """Why this entity has no single source field to declare, or `None`. + """Why this entity's source field is outside the producible gate, or `None`. + + Which reason applies is not a matter of opinion — see `DerivedReason`, whose + members are each asserted against what the `value_fn` actually reads. - Set only when `field_path` is not: the two are alternatives, pinned by - `test_every_description_declares_exactly_one`. Which reason applies is not - a matter of opinion — see `DerivedReason`, whose members are each asserted - against what the `value_fn` actually reads. + Its relationship to `field_path` is per reason, pinned by + `test_every_description_declares_exactly_one`: `NO_SOURCE_FIELD` and + `MULTIPLE_FIELDS` have no single field to name, so `field_path` stays + `None`; `SCHEMA_CONDITIONAL_FIELD` has exactly one and must name it. A reason rather than a flag because `bool` conflated four situations, and that conflation is how `evse_ev_connected` — one producible field, marked @@ -188,6 +209,11 @@ class FieldPathDeclarationMixin: "circuit.always_on": Producibility.SCHEMA_0_ONLY, "circuit.is_sheddable": Producibility.SCHEMA_0_ONLY, "panel.wifi_ssid": Producibility.SCHEMA_0_ONLY, + # The `grid_forming_entity` sensor's source field. schema_1 answers the + # same question through `resolve_dominant_power_source` over the MID's + # `grid/grid-forming-entity` instead of publishing a row of its own, + # which is what makes the sensor `SCHEMA_CONDITIONAL_FIELD`. + "panel.dominant_power_source": Producibility.SCHEMA_0_ONLY, # util.py builds the EVSE DeviceInfo from these; entity_resolver.py and # sensor.py resolve the fed circuit through `feed_circuit_id`. "evse.vendor_name": Producibility.SCHEMA_0_ONLY, @@ -227,29 +253,62 @@ class FieldPathDeclarationMixin: """ -def iter_field_path_declarations[DescriptionT: EntityDescription]( +def _iter_declared[DescriptionT: EntityDescription]( descriptions: Iterable[DescriptionT], -) -> Iterator[tuple[str, DescriptionT]]: - """Yield ``(field_path, description)`` for each description that declares one. +) -> Iterator[tuple[str, FieldPathDeclarationMixin, DescriptionT]]: + """Yield ``(field_path, declaration, description)`` for each named source field. - The single copy of the traversal rule — which descriptions declare a field, - and which are exempt — so a caller that only wants the paths and a caller - that wants the descriptions cannot drift apart on it. + The single copy of the traversal rule — which descriptions carry a + declaration at all — so no caller can drift from another on it. Raises `TypeError` for a description that carries no - `FieldPathDeclarationMixin` at all: such a description would be dropped - silently, which is the drift this module exists to prevent. Descriptions - that carry the mixin but declare nothing (`derived`, or `field_path is - None`) are skipped, which is the declared-exempt case rather than drift. + `FieldPathDeclarationMixin`: such a description would be dropped silently, + which is the drift this module exists to prevent. A description that carries + the mixin and names no field is skipped, which is the declared-derived case + rather than drift. + + `declaration` and `description` are the same object; they are yielded twice + because a type variable bounded on `EntityDescription` cannot also be known + to carry the mixin, and the alternative is a `cast` at every call site. """ for description in descriptions: if not isinstance(description, FieldPathDeclarationMixin): raise TypeError( f"entity description '{description.key}' carries no field-path declaration" ) - if description.derived or description.field_path is None: + if description.field_path is None: continue - yield description.field_path, description + yield description.field_path, description, description + + +def iter_source_field_declarations[DescriptionT: EntityDescription]( + descriptions: Iterable[DescriptionT], +) -> Iterator[tuple[str, DescriptionT]]: + """Yield ``(field_path, description)`` for each description that names one. + + Every entity that has a single source field, whatever `derived` says about + which adapters produce it. + """ + for field_path, _, description in _iter_declared(descriptions): + yield field_path, description + + +def iter_field_path_declarations[DescriptionT: EntityDescription]( + descriptions: Iterable[DescriptionT], +) -> Iterator[tuple[str, DescriptionT]]: + """Yield only the declarations the producible gate covers. + + `derived` descriptions are skipped whether or not they name a field. That + is the whole content of the exemption: a `SCHEMA_CONDITIONAL_FIELD` + description does name its source field, and naming it is what gives the + entity its Repair mention and its unavailability — but the path is still + one adapter short of the both-adapters gate this function feeds, and + `RESIDUAL_EXEMPT_PATHS` is where it is enumerated instead. + """ + for field_path, declaration, description in _iter_declared(descriptions): + if declaration.derived: + continue + yield field_path, description def _walk_subclasses[EntityT](root: type[EntityT]) -> Iterator[type[EntityT]]: @@ -300,12 +359,13 @@ def residual_field_paths() -> frozenset[str]: ) -def declared_field_paths() -> frozenset[str]: - """Field paths the integration reads that must be producible by an adapter. +def platform_descriptions() -> tuple[EntityDescription, ...]: + """Every entity description this integration builds entities from. - Derived entities are excluded: they have no single source field, so there is - nothing for an adapter to produce. Residual readers that no adapter (or only - one) produces are excluded too, and are listed in `RESIDUAL_EXEMPT_PATHS`. + One copy of the collection list. `declared_field_paths` and + `conditional_field_paths` ask different questions of the same descriptions, + and a second copy of the list is how a platform ends up answering one and + not the other. """ # Deferred: the platform modules import `FieldPathDeclarationMixin` from # here, and `binary_sensor` reaches the package root for its config-entry @@ -320,17 +380,50 @@ def declared_field_paths() -> frozenset[str]: all_sensor_descriptions, ) + return ( + *all_sensor_descriptions(), + *BINARY_SENSORS, + *EVSE_BINARY_SENSORS, + GRID_ISLANDABLE_SENSOR, + BESS_CONNECTED_SENSOR, + ) + + +def declared_field_paths() -> frozenset[str]: + """Field paths the integration reads that must be producible by an adapter. + + Derived entities are excluded: they have no single source field, or the one + they have is not on both schemas, so there is nothing for both adapters to + produce. Residual readers that no adapter (or only one) produces are + excluded too, and are listed in `RESIDUAL_EXEMPT_PATHS`. + """ paths: set[str] = set(residual_field_paths()) paths.update( - field_path - for field_path, _ in iter_field_path_declarations( - ( - *all_sensor_descriptions(), - *BINARY_SENSORS, - *EVSE_BINARY_SENSORS, - GRID_ISLANDABLE_SENSOR, - BESS_CONNECTED_SENSOR, - ) - ) + field_path for field_path, _ in iter_field_path_declarations(platform_descriptions()) ) return frozenset(paths) + + +def conditional_field_paths() -> frozenset[str]: + """Source fields of entities only one adapter produces the field for. + + The producible gate cannot cover these -- that is what + `DerivedReason.SCHEMA_CONDITIONAL_FIELD` says -- but the adapter that *does* + produce the field still publishes a metadata row for it, and that row can + come back `resolved=False` when the panel drops the property. So the + degradation half of the apparatus applies to them exactly as it does to a + plain declaration, and `schema_validation.evaluate_field_metadata` asks + about these paths alongside `declared_field_paths()`. + + Read off the descriptions rather than filtered out of + `RESIDUAL_EXEMPT_PATHS`: most of that map is decoration -- device_info + fields, circuit attributes, entity-creation gates -- whose loss does not + make an entity's *reading* wrong, and which is deliberately excluded from + the availability probe. What belongs here is the narrower thing the + description states: the field this entity's value comes from. + """ + return frozenset( + field_path + for field_path, declaration, _ in _iter_declared(platform_descriptions()) + if declaration.derived is DerivedReason.SCHEMA_CONDITIONAL_FIELD + ) diff --git a/custom_components/span_panel/schema_validation.py b/custom_components/span_panel/schema_validation.py index d7fc945c..0ae2955a 100644 --- a/custom_components/span_panel/schema_validation.py +++ b/custom_components/span_panel/schema_validation.py @@ -19,7 +19,7 @@ from homeassistant.components.sensor import SensorEntityDescription from span_panel_api.models import FieldMetadata -from .field_paths import RESIDUAL_EXEMPT_PATHS, declared_field_paths +from .field_paths import RESIDUAL_EXEMPT_PATHS, conditional_field_paths, declared_field_paths _LOGGER = logging.getLogger(__name__) @@ -77,12 +77,20 @@ def evaluate_field_metadata( `SpanPanelCoordinator._run_schema_validation`. """ declared = declared_field_paths() + # Schema-conditional entities read a real field off a real metadata row; + # what they cannot do is satisfy a gate that demands *both* adapters + # produce it. Resolution is a property of the adapter that is running, so + # asking about these paths alongside the declared ones is what gives such + # an entity its unavailability and its Repair — the apparatus every other + # entity already has. Leaving them out is how `panel.wifi_ssid` stayed + # invisible: exempt from the gate read as exempt from everything. + resolvable = declared | conditional_field_paths() sensor_defs = sensor_defs or {} unresolved: set[str] = set() mismatches: list[UnitMismatch] = [] - for field_path in declared: + for field_path in resolvable: entry = field_metadata.get(field_path) if entry is None: # Hardware not present. Not a defect, and deliberately silent. diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py index 38e14688..a1329eb2 100644 --- a/custom_components/span_panel/sensor_definitions.py +++ b/custom_components/span_panel/sensor_definitions.py @@ -39,7 +39,7 @@ from .field_paths import ( DerivedReason, FieldPathDeclarationMixin, - iter_field_path_declarations, + iter_source_field_declarations, ) @@ -145,6 +145,7 @@ class SpanPanelBatterySensorEntityDescription( ), SpanPanelDataSensorEntityDescription( key="grid_forming_entity", + field_path="panel.dominant_power_source", derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD, translation_key="grid_forming_entity", device_class=SensorDeviceClass.ENUM, @@ -379,6 +380,7 @@ class SpanShedForecastSensorEntityDescription( ] = ( SpanShedForecastSensorEntityDescription( key="time_to_priority_shed", + field_path="panel.shed_time_to_priority_shed_min", derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD, translation_key="time_to_priority_shed", device_class=SensorDeviceClass.DURATION, @@ -391,6 +393,7 @@ class SpanShedForecastSensorEntityDescription( ), SpanShedForecastSensorEntityDescription( key="shed_total_time_remaining", + field_path="panel.shed_total_time_remaining_min", derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD, translation_key="shed_total_time_remaining", device_class=SensorDeviceClass.DURATION, @@ -946,12 +949,18 @@ def all_sensor_descriptions() -> tuple[SensorEntityDescription, ...]: def sensor_descriptions_by_field_path() -> dict[str, SensorEntityDescription]: - """Every non-derived sensor description, keyed by the field path it reads. + """Every sensor description with a source field, keyed by that field path. Keyed by field path because that is how the adapter keys its metadata; - `description.key` is a different namespace and would not line up. Derived - descriptions are excluded — they read several fields, or none, so no single - path identifies them. + `description.key` is a different namespace and would not line up. + Descriptions that name no field are excluded — they read several fields, or + none, so no single path identifies them. + + A `SCHEMA_CONDITIONAL_FIELD` description does name one and is included. Its + exemption is from the *producible* gate, and the unit its schema declares + for the field is checkable exactly as any other's: the adapter that + produces the row publishes a unit, and this integration's sensor declares + one, and they can disagree. A few field paths are read by two descriptions (an unmapped-circuit raw key and its named-circuit twin), and only the first is kept. That is safe only @@ -960,10 +969,10 @@ def sensor_descriptions_by_field_path() -> dict[str, SensorEntityDescription]: pins that rather than leaving it to chance. Lives here rather than at the call site so no consumer has to know how a - description declares its field; `field_paths.iter_field_path_declarations` + description declares its field; `field_paths.iter_source_field_declarations` holds that rule. """ by_field_path: dict[str, SensorEntityDescription] = {} - for field_path, description in iter_field_path_declarations(all_sensor_descriptions()): + for field_path, description in iter_source_field_declarations(all_sensor_descriptions()): by_field_path.setdefault(field_path, description) return by_field_path diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py index e3598eb7..aeed159d 100644 --- a/tests/test_field_path_conformance.py +++ b/tests/test_field_path_conformance.py @@ -87,8 +87,15 @@ def test_gate_covers_every_declaration_in_the_source() -> None: when a collection is emptied rather than unreferenced. """ declared = declared_field_paths() + # A `SCHEMA_CONDITIONAL_FIELD` description names its source field too, and + # that path is by definition one adapter short of this gate. It is covered + # instead by `RESIDUAL_EXEMPT_PATHS`, whose annotation is checked against + # both adapters below — so being enumerated there is the alternative to + # being in `declared`, not an escape from being checked. uncovered = sorted( - (path, module) for path, module in _source_declared_paths().items() if path not in declared + (path, module) + for path, module in _source_declared_paths().items() + if path not in declared and path not in RESIDUAL_EXEMPT_PATHS ) assert not uncovered, ( "declared_field_paths() does not cover field paths declared in the source: " @@ -257,7 +264,11 @@ def test_no_exempt_path_is_producible_by_both() -> None: # confidence enum, read as attributes on the two forecast sensors and # carried by no adapter's metadata map. Producibility.NEITHER: 18, - Producibility.SCHEMA_0_ONLY: 10, + # +1 for `panel.dominant_power_source`, the `grid_forming_entity` sensor's + # source field. It was read by a `SCHEMA_CONDITIONAL_FIELD` description and + # enumerated nowhere, so `evaluate_field_metadata` counted it as produced- + # but-unread while an entity was reading it. + Producibility.SCHEMA_0_ONLY: 11, # +2 with the shed forecast: the two live estimates, which schema_1 maps and # flat firmware does not publish at all. Producibility.SCHEMA_1_ONLY: 3, diff --git a/tests/test_field_path_introspection.py b/tests/test_field_path_introspection.py index 56ac4a1f..993a31b4 100644 --- a/tests/test_field_path_introspection.py +++ b/tests/test_field_path_introspection.py @@ -217,10 +217,16 @@ def _record_reads(description: _DeclaringDescription) -> set[str]: def test_declared_paths_match_what_value_fns_read() -> None: + """Every named source field must be one the `value_fn` actually reads. + + `derived` is not consulted: a `SCHEMA_CONDITIONAL_FIELD` description names + its source field too, and that name is what the Repair and the availability + probe act on. An unverified one would send both at the wrong path. + """ mismatches: list[str] = [] for description in _declaring_descriptions(): - if description.derived or description.field_path is None: + if description.field_path is None: continue try: sink = _record_reads(description) @@ -340,13 +346,20 @@ def test_derived_reasons_match_what_value_fns_read() -> None: f"{description.key}: claims MULTIPLE_FIELDS but reads {read} — " "one field or none is a different reason" ) - elif reason is DerivedReason.SCHEMA_CONDITIONAL_FIELD and ( - len(read) != 1 or read[0] in schema_0 & schema_1 - ): - offenders.append( - f"{description.key}: claims SCHEMA_CONDITIONAL_FIELD but reads {read}, " - f"of which {sorted(set(read) & schema_0 & schema_1)} are produced by both " - "adapters" - ) + elif reason is DerivedReason.SCHEMA_CONDITIONAL_FIELD: + if len(read) != 1 or read[0] in schema_0 & schema_1: + offenders.append( + f"{description.key}: claims SCHEMA_CONDITIONAL_FIELD but reads {read}, " + f"of which {sorted(set(read) & schema_0 & schema_1)} are produced by both " + "adapters" + ) + elif description.field_path != read[0]: + # The one reason that still names a field names the right one. + # That name is what the Repair and the availability probe act + # on, so a stale one degrades the wrong entity or none. + offenders.append( + f"{description.key}: declares field_path={description.field_path!r} " + f"but reads {read[0]!r}" + ) assert not offenders, "Derived reasons disagree with readers:\n" + "\n".join(offenders) diff --git a/tests/test_field_paths.py b/tests/test_field_paths.py index 19830353..1656583e 100644 --- a/tests/test_field_paths.py +++ b/tests/test_field_paths.py @@ -2,22 +2,17 @@ from __future__ import annotations -from custom_components.span_panel.binary_sensor import ( - BESS_CONNECTED_SENSOR, - BINARY_SENSORS, - EVSE_BINARY_SENSORS, - GRID_ISLANDABLE_SENSOR, -) from custom_components.span_panel.field_paths import ( RESIDUAL_EXEMPT_PATHS, DerivedReason, + FieldPathDeclarationMixin, + Producibility, + conditional_field_paths, declared_field_paths, + platform_descriptions, residual_field_paths, ) -from custom_components.span_panel.sensor_definitions import ( - CIRCUIT_SENSORS, - all_sensor_descriptions, -) +from custom_components.span_panel.sensor_definitions import CIRCUIT_SENSORS def test_circuit_power_declares_its_field_path() -> None: @@ -50,21 +45,73 @@ def test_every_description_declares_exactly_one() -> None: so the likelier mistake is inheriting it and declaring nothing — which drops the entity from every gate with no signal. This is that signal. - Must enumerate exactly what `declared_field_paths` iterates. + "Exactly one" is per `DerivedReason`, because the reasons differ on whether + a single source field exists at all. `NO_SOURCE_FIELD` and + `MULTIPLE_FIELDS` have none to name. `SCHEMA_CONDITIONAL_FIELD` has exactly + one and must name it: `derived` excuses the path from the *producible* gate, + which is a claim about the other adapter, and saying nothing about the field + would additionally excuse the entity from the Repair count and the + availability probe — the invisibility this whole module exists to prevent. """ - for description in ( - *all_sensor_descriptions(), - *BINARY_SENSORS, - *EVSE_BINARY_SENSORS, - GRID_ISLANDABLE_SENSOR, - BESS_CONNECTED_SENSOR, - ): - declares_path = description.field_path is not None - assert declares_path != (description.derived is not None), ( + for description in platform_descriptions(): + assert isinstance(description, FieldPathDeclarationMixin), description + names_field = description.field_path is not None + if description.derived is DerivedReason.SCHEMA_CONDITIONAL_FIELD: + assert names_field, ( + f"{description.key} is SCHEMA_CONDITIONAL_FIELD, which reads exactly one " + "field, and must declare it as field_path= so the Repair and the " + "availability probe can see it" + ) + continue + assert names_field != (description.derived is not None), ( f"{description.key} must declare exactly one of field_path / a DerivedReason" ) +def test_schema_conditional_descriptions_name_an_exempt_field() -> None: + """The field a schema-conditional description names must be enumerated. + + Its source path cannot enter `declared_field_paths()` — one adapter does not + produce it — so `RESIDUAL_EXEMPT_PATHS` is the only place it is written down + and the only place its producibility is checked against the adapters. A + schema-conditional description naming a path absent from there would be read + by an entity, gated by nothing, and reported by `evaluate_field_metadata` as + produced-but-unread. `panel.dominant_power_source` was exactly that. + + `NEITHER` is excluded on purpose: a path no adapter publishes a metadata row + for has nothing to resolve, so a description reading one is + `NO_SOURCE_FIELD`, not schema-conditional. + """ + for description in platform_descriptions(): + assert isinstance(description, FieldPathDeclarationMixin), description + if description.derived is not DerivedReason.SCHEMA_CONDITIONAL_FIELD: + continue + path = description.field_path + assert path in RESIDUAL_EXEMPT_PATHS, ( + f"{description.key} reads {path!r}, which no adapter pair produces and " + "RESIDUAL_EXEMPT_PATHS does not enumerate" + ) + assert RESIDUAL_EXEMPT_PATHS[path] is not Producibility.NEITHER, ( + f"{description.key} claims SCHEMA_CONDITIONAL_FIELD but {path!r} is " + "annotated NEITHER — no adapter produces a row for it, so there is " + "nothing schema-conditional about it" + ) + + +def test_conditional_paths_are_exactly_the_unresolvable_reads() -> None: + """`conditional_field_paths()` must be disjoint from the declared set. + + The two together are what `evaluate_field_metadata` asks the adapter about. + An overlap would mean a path was both gated and exempted, which is the + contradiction `test_residual_buckets_are_disjoint` rules out one level down; + asserting it here keeps the union honest as the two functions change. + """ + conditional = conditional_field_paths() + assert conditional + assert not (conditional & declared_field_paths()) + assert conditional <= RESIDUAL_EXEMPT_PATHS.keys() + + def test_residual_buckets_are_disjoint() -> None: """A residual path is either producible or exempt, never both.""" assert not (residual_field_paths() & RESIDUAL_EXEMPT_PATHS.keys()) diff --git a/tests/test_schema_availability.py b/tests/test_schema_availability.py index 2d62aaf8..9a69c347 100644 --- a/tests/test_schema_availability.py +++ b/tests/test_schema_availability.py @@ -201,19 +201,55 @@ async def test_panel_status_binary_sensor_is_never_probed(hass: HomeAssistant) - assert entity.available is True -async def test_derived_entity_is_never_probed(hass: HomeAssistant) -> None: - """A derived entity declares no source field, so nothing can unresolve it. - - `bess_connected` reads `battery.connected`, which only one adapter - publishes -- exactly why the description is `derived`. Probing a derived - description would make availability depend on a path it never declared. +async def test_schema_conditional_entity_goes_unavailable(hass: HomeAssistant) -> None: + """A schema-conditional entity is probed like any other. + + `bess_connected` reads exactly one field, `battery.connected`, which only + the schema_0 adapter publishes -- that, and only that, is what `derived` + says here. The adapter that does publish it still reports whether the panel + resolved it, and an unresolved field reaches this entity as a default it + would present as a reading. + + Excluding these from the probe was the `evse_ev_connected` failure reached + by a second route: an entity exempt from the producible gate silently + exempt from degradation too. """ coordinator = _make_coordinator(hass) coordinator._findings = SchemaFindings(frozenset({"battery.connected"}), (), frozenset()) assert BESS_CONNECTED_SENSOR.derived is DerivedReason.SCHEMA_CONDITIONAL_FIELD + assert BESS_CONNECTED_SENSOR.field_path == "battery.connected" entity = SpanPanelBinarySensor(coordinator, BESS_CONNECTED_SENSOR) + assert entity.available is False + + +async def test_schema_conditional_entity_stays_available_when_resolved( + hass: HomeAssistant, +) -> None: + """The probe must key on the entity's own field, not on being conditional.""" + coordinator = _make_coordinator(hass) + coordinator._findings = SchemaFindings(frozenset({_ETHERNET_LINK_PATH}), (), frozenset()) + + entity = SpanPanelBinarySensor(coordinator, BESS_CONNECTED_SENSOR) + + assert entity.available is True + + +async def test_residual_reads_still_do_not_probe(hass: HomeAssistant) -> None: + """`circuit.name` and `circuit.tabs` decorate a reading; they are not it. + + The deliberate exclusion the schema-conditional change must not regress: a + circuit power sensor reads its name and tabs for naming and attributes, and + its power reading is still true when those are gone. + """ + coordinator = _make_coordinator(hass) + coordinator._findings = SchemaFindings(frozenset({"circuit.name"}), (), frozenset()) + + entity = _circuit_power_entity(coordinator) + + assert "circuit.name" in type(entity)._residual_field_paths + assert "circuit.name" in entity._declared_field_paths() assert entity.available is True diff --git a/tests/test_schema_repairs.py b/tests/test_schema_repairs.py index c78e18dd..354a5744 100644 --- a/tests/test_schema_repairs.py +++ b/tests/test_schema_repairs.py @@ -674,12 +674,48 @@ async def test_removing_an_entity_drops_it_from_the_map(hass) -> None: await _stop_scheduling(coordinator) -async def test_derived_entities_are_not_tracked(hass) -> None: - """A derived entity must not be blamed for one of the fields it combines. +async def test_entities_with_no_source_field_are_not_tracked(hass) -> None: + """An entity must not be blamed for one of the fields it combines. - Derived entities compute from several fields or none, so no single field's - loss can be said to have taken them down. They declare no `field_path` and - no residual reads, and must therefore land in no bucket at all. + A `MULTIPLE_FIELDS` or `NO_SOURCE_FIELD` description computes from several + fields or none, so no single field's loss can be said to have taken it + down. It names no `field_path` and carries no residual reads, and must + therefore land in no bucket at all. + """ + from unittest.mock import MagicMock + + from custom_components.span_panel.binary_sensor import async_setup_entry as binary_setup + from custom_components.span_panel.const import PANEL_STATUS + from custom_components.span_panel.field_paths import DerivedReason + + coordinator, config_entry, _ = await _entities_by_declared_path(hass) + try: + added = MagicMock() + await binary_setup(hass, config_entry, added) + sourceless = [ + entity + for entity in added.call_args.args[0] + if getattr(getattr(entity, "entity_description", None), "key", None) == PANEL_STATUS + ] + assert sourceless + assert sourceless[0].entity_description.derived is DerivedReason.NO_SOURCE_FIELD + assert sourceless[0].entity_description.field_path is None + + await _add_to_platform(hass, config_entry, sourceless, "binary_sensor") + + assert coordinator.entity_ids_by_field_path == {} + finally: + await _stop_scheduling(coordinator) + + +async def test_schema_conditional_entities_are_tracked(hass) -> None: + """A schema-conditional entity must be nameable by its field's Repair. + + `bess_connected` reads exactly one field. That the *other* adapter does not + publish it is why the description is `derived`, and is no reason for the + Repair against a dead `battery.connected` to report "0 entities affected" -- + the suppression rule then throws the finding away entirely, so the user is + told nothing at all about a sensor that has gone to a default. """ from unittest.mock import MagicMock @@ -690,20 +726,20 @@ async def test_derived_entities_are_not_tracked(hass) -> None: coordinator, config_entry, _ = await _entities_by_declared_path(hass) try: - assert BESS_CONNECTED_SENSOR.derived, "fixture assumes a derived description" + assert BESS_CONNECTED_SENSOR.field_path == "battery.connected" added = MagicMock() await binary_setup(hass, config_entry, added) - derived = [ + conditional = [ entity for entity in added.call_args.args[0] if getattr(entity, "entity_description", None) is BESS_CONNECTED_SENSOR ] - assert derived + assert conditional - await _add_to_platform(hass, config_entry, derived, "binary_sensor") + await _add_to_platform(hass, config_entry, conditional, "binary_sensor") - assert coordinator.entity_ids_by_field_path == {} + assert coordinator.entity_ids_by_field_path["battery.connected"] finally: await _stop_scheduling(coordinator) diff --git a/tests/test_schema_validation.py b/tests/test_schema_validation.py index cba9cd63..1bfab80b 100644 --- a/tests/test_schema_validation.py +++ b/tests/test_schema_validation.py @@ -8,7 +8,10 @@ from custom_components.span_panel import sensor_definitions from custom_components.span_panel.field_paths import ( RESIDUAL_EXEMPT_PATHS, + DerivedReason, FieldPathDeclarationMixin, + Producibility, + conditional_field_paths, declared_field_paths, ) from custom_components.span_panel.schema_validation import ( @@ -148,11 +151,14 @@ def test_every_declared_field_path_keys_a_sensor_or_a_residual_reader() -> None: dict keyed on `description.key` would silently collapse them. """ by_path = sensor_descriptions_by_field_path() - assert by_path.keys() <= declared_field_paths() + assert by_path.keys() <= declared_field_paths() | conditional_field_paths() assert {"battery.model", "pv.model"} <= by_path.keys() for field_path, description in by_path.items(): assert description.field_path == field_path - assert not description.derived + # A schema-conditional description is included: its unit is as + # checkable as any other's. What it must never be is a description + # that names no field at all. + assert description.derived in (None, DerivedReason.SCHEMA_CONDITIONAL_FIELD) def test_resolved_unitless_sensor_yields_no_mismatch() -> None: @@ -207,6 +213,51 @@ def test_real_adapter_metadata_produces_no_findings(metadata_fn: MetadataFn) -> assert findings.unit_mismatches == () +def test_a_schema_conditional_field_can_be_unresolved() -> None: + """The evaluator must ask about the paths the producible gate cannot. + + A `SCHEMA_CONDITIONAL_FIELD` entity reads a field that only one adapter + publishes a metadata row for. That row still carries the adapter's + three-way answer, so it can come back `resolved=False` -- the panel + declares the node and omits the property. Iterating `declared_field_paths()` + alone never asked, so such an entity got no Repair and no unavailability, + however dead its field was. + """ + path = "panel.shed_time_to_priority_shed_min" + assert path in conditional_field_paths() + assert path not in declared_field_paths() + + findings = evaluate_field_metadata({path: FieldMetadata(None, "unknown", resolved=False)}) + + assert findings.unresolved == frozenset({path}) + + +def test_a_schema_conditional_unit_is_still_compared() -> None: + """Being one adapter short of the gate does not excuse a wrong unit.""" + findings = evaluate_field_metadata( + {"panel.shed_total_time_remaining_min": FieldMetadata("h", "integer")}, + sensor_descriptions_by_field_path(), + ) + assert [m.field_path for m in findings.unit_mismatches] == [ + "panel.shed_total_time_remaining_min" + ] + + +def test_a_decorating_exempt_path_is_not_probed() -> None: + """Exempt is not the same as read-as-a-value. + + `evse.vendor_name` builds a DeviceInfo and `circuit.always_on` is an + attribute; neither is any entity's reading, so neither belongs in the + unresolved set. Widening the evaluator to every non-`NEITHER` exemption + would have swept both in. + """ + for path in ("evse.vendor_name", "circuit.always_on"): + assert RESIDUAL_EXEMPT_PATHS[path] is not Producibility.NEITHER + assert path not in conditional_field_paths() + findings = evaluate_field_metadata({path: FieldMetadata(None, "unknown", resolved=False)}) + assert findings.unresolved == frozenset() + + def test_known_bad_schema_unit_exception_is_narrow() -> None: """Only the exact known-bad unit is excused; anything else is new information.""" from homeassistant.components.sensor import SensorEntityDescription From a14d0999e8f7c69389daa915a846ca31cf6764a0 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:07:19 -0700 Subject: [PATCH 048/345] test(declarations): fail when the panel declares what nothing reads The producible gate asks whether an adapter produces what we declare we read. It cannot see the converse: a property nobody reads is absent from every list the gate consults, so it is invisible by construction. `panel.wifi_ssid` is what that costs -- flat surfaces it, v1.0 declares it, schema_1 maps nothing to it, and its exemption said SCHEMA_0_ONLY, which was true. Every check agreed and a user upgrading lost an attribute. Consumption is decided by experiment rather than by a list, because a list is what made a new property invisible to begin with: republish one declared property with a legal different value, rebuild the snapshot through the real schema_1 mapper, and see which snapshot fields moved. Nothing here restates `_PROPERTY_FIELD_MAP`, the lugs direction tables, the topology readers or the device_info builders -- the probe sees all of them, and sees them the way a user does. Stricter than "mapped by `_PROPERTY_FIELD_MAP`" on purpose. A property the library reads into a snapshot field no entity, attribute or device card touches has reached nobody, so `circuit.is_240v`, `evse.part_number` and `pv.software_version` are baselined rather than counted as read. Two enumerations, both exact sets so neither can absorb a new property silently. `_INTERNAL_ROUTES` has three entries for what the snapshot cannot express -- adapter dispatch, a fallback tier this fixture shadows, a topology branch no producer reaches -- and fails the moment one of them becomes observable after all. The baseline has 39 lines, one reason each, and fails in both directions: an untriaged declaration and a resolved one are equally loud. 39 is 44 from the gap inventory minus the five shed-forecast properties the forecast sensors and their attributes just took. Each catch-up task now deletes the lines it surfaces, so the burn-down is a diff rather than a claim. `mid.grid_state` joins RESIDUAL_EXEMPT_PATHS: the probe found the `mid_grid_state` sensor reading a field enumerated nowhere, the same hole `panel.dominant_power_source` was in. --- custom_components/span_panel/field_paths.py | 4 + .../unread_declarations_baseline.json | 41 ++ tests/test_declared_but_unread.py | 380 ++++++++++++++++++ tests/test_field_path_conformance.py | 6 +- 4 files changed, 430 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/unread_declarations_baseline.json create mode 100644 tests/test_declared_but_unread.py diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py index 57d1cda9..1d8b7e7b 100644 --- a/custom_components/span_panel/field_paths.py +++ b/custom_components/span_panel/field_paths.py @@ -183,6 +183,10 @@ class FieldPathDeclarationMixin: "mid.model": Producibility.NEITHER, "mid.serial_number": Producibility.NEITHER, "mid.grid_forming_device_name": Producibility.NEITHER, + # The `mid_grid_state` sensor's source field — utility-supply health, + # the one non-metadata entity the MID brings. Neither adapter maps the + # MID at all, which is why the description is `NO_SOURCE_FIELD`. + "mid.grid_state": Producibility.NEITHER, # The EVSE's Homie node id — an addressing handle used to build the # sub-device identifier, not a published field. "evse.node_id": Producibility.NEITHER, diff --git a/tests/fixtures/unread_declarations_baseline.json b/tests/fixtures/unread_declarations_baseline.json new file mode 100644 index 00000000..b452133d --- /dev/null +++ b/tests/fixtures/unread_declarations_baseline.json @@ -0,0 +1,41 @@ +{ + "bess/meter/active-power": "the battery's own charge/discharge meter; SpanBatterySnapshot carries no power field, and the panel's power_flow_battery is a different property (Task 5)", + "bess/status/communication-state": "the publisher's self-report, deliberately distinct from the enclosure's bess_connected view; no snapshot field carries it (Task 5)", + "circuit/breaker/poles": "reaches circuit.is_240v and stops there; the voltage attribute is derived from the tab count instead, so no entity or attribute reads the field", + "circuit/connection/count": "microinverter/pack count; no producer publishes it, so there is no fixture value an entity could be tested against", + "circuit/connection/feeds-device-status": "the enclosure's link-health view of a circuit-fed DER; only the lugs fed-by-* route is read today, so PV and EVSE get no connected sensor (Task 3)", + "circuit/connection/feeds-device-type": "redundant: the library resolves a DER's class by dereferencing feeds-device-id against the tree's declared $type", + "circuit/pcs/managed": "per-circuit PCS participation; an attribute on the circuit power sensor rather than a 32x entity, and unbuilt (Task 4)", + "circuit/pcs/priority": "per-circuit PCS ranking; same attribute treatment as pcs/managed, and unbuilt (Task 4)", + "distribution-enclosure/info/hardware-version": "no panel.hardware_version snapshot field, and the panel DeviceInfo declares no hw_version (Task 5)", + "distribution-enclosure/info/vendor-name": "DeviceInfo.manufacturer is hardcoded to \"Span\"; the declared value is never consulted, even as a fallback (Task 5)", + "distribution-enclosure/pcs/active": "\"a limit is being enforced right now\" - the automation trigger of the PCS family, and the one binary sensor it wants (Task 4)", + "distribution-enclosure/pcs/binding-constraint": "names which limit is winning the min() arbitration; the enum sensor that explains pcs/import-limit (Task 4)", + "distribution-enclosure/pcs/enabled": "whether the panel enforces UL 3141 import limiting at all; subsumed by pcs/active for entity purposes (Task 4)", + "distribution-enclosure/pcs/feed-import-limit": "the standing FSR floor; an attribute on the effective-limit sensor rather than an entity (Task 4)", + "distribution-enclosure/pcs/feed-import-limit-active": "one of the four enablement/active pairs; binding-constraint already names the winner (Task 4)", + "distribution-enclosure/pcs/feed-import-limit-enablement": "one of the four enablement/active pairs; binding-constraint already names the winner (Task 4)", + "distribution-enclosure/pcs/import-limit": "the effective enforced import limit - the single number that summarizes the PCS family, and the headline entity of Task 4", + "distribution-enclosure/pcs/off-grid-import-limit": "an arbitration input; an attribute on the effective-limit sensor rather than an entity (Task 4)", + "distribution-enclosure/pcs/off-grid-import-limit-active": "one of the four enablement/active pairs; binding-constraint already names the winner (Task 4)", + "distribution-enclosure/pcs/off-grid-import-limit-enablement": "one of the four enablement/active pairs; binding-constraint already names the winner (Task 4)", + "distribution-enclosure/pcs/operator-import-limit": "an arbitration input; an attribute on the effective-limit sensor rather than an entity (Task 4)", + "distribution-enclosure/pcs/operator-import-limit-active": "one of the four enablement/active pairs; binding-constraint already names the winner (Task 4)", + "distribution-enclosure/pcs/operator-import-limit-enablement": "one of the four enablement/active pairs; binding-constraint already names the winner (Task 4)", + "distribution-enclosure/pcs/requested-import-limit": "an arbitration input; an attribute on the effective-limit sensor rather than an entity (Task 4)", + "distribution-enclosure/pcs/requested-import-limit-active": "one of the four enablement/active pairs; binding-constraint already names the winner (Task 4)", + "distribution-enclosure/pcs/requested-import-limit-enablement": "one of the four enablement/active pairs; binding-constraint already names the winner (Task 4)", + "distribution-enclosure/shed/policy": "a JSON policy document (soc-priority.v1 plus the two SOC thresholds); nothing parses it, and the thresholds belong as attributes beside the forecast (Task 4)", + "distribution-enclosure/status/postal-code": "deliberate skip: location data with no Home Assistant value, and surfacing it would copy the user's location into recorder history", + "distribution-enclosure/status/time-zone": "deliberate skip: Home Assistant owns the time zone, so a mismatch is a Repair rather than an entity", + "distribution-enclosure/status/wifi-ssid": "declared and never published; PanelFields.wifi_ssid is hardcoded None on schema_1, so a flat panel's wifi_ssid attribute is lost on upgrade (Task 2)", + "evse/config/max-charge-current": "the installer ceiling; it becomes native_max_value of the charge-current number entity rather than an entity of its own, and is deferred with it (Task 6)", + "evse/config/user-max-charge-current": "the only settable unread property; the eBus catalog spells this capability charge-limit with different property names, so the write topic waits on a real-firmware capture (Task 6)", + "evse/info/part-number": "reaches evse.part_number and stops there; only battery.part_number has a sensor, so no EVSE device card shows it (Task 5)", + "lugs/connection/count": "the lugs counterpart of circuit/connection/count, with no fed-device story upstream and no producer publishing it", + "lugs/connection/fed-by-device-type": "redundant: the id dereferences to the device's declared $type, which is how the library already resolves classes", + "lugs/connection/feeds-device-status": "feedthrough link-health; the feeds-* half of the downstream lugs record is topology to consume, and no producer publishes it", + "lugs/connection/feeds-device-type": "redundant: the id dereferences to the device's declared $type, which is how the library already resolves classes", + "pv/info/firmware-version": "reaches pv.software_version and stops there; PV has no sub-device registry entry, so there is no DeviceInfo to carry a version (Task 5)", + "pv/info/serial-number": "not read at all: _der_identifier prefers a serial over instance_id, so valuing it would move the PV device id from -pv-1 to - - a device replacement, not an upgrade (Task 7)" +} diff --git a/tests/test_declared_but_unread.py b/tests/test_declared_but_unread.py new file mode 100644 index 00000000..d17cef66 --- /dev/null +++ b/tests/test_declared_but_unread.py @@ -0,0 +1,380 @@ +"""Every property the panel declares must reach a user, or say why it does not. + +The consumer-side mirror of panelbench's `test_declared_but_unvalued`, which +asks the producer whether it ever publishes what it declares. This asks whether +anything ever reads what arrives. + +**Why this and not the producible gate.** `test_field_path_conformance` starts +from what the integration declares it reads and checks an adapter produces it. +That direction cannot see a property nobody reads: an unread declaration is +absent from every list the gate consults, so it is invisible by construction. +`panel.wifi_ssid` is the worked example — flat surfaces it, v1.0 declares it, +schema_1 maps nothing to it, and its `RESIDUAL_EXEMPT_PATHS` annotation said +`SCHEMA_0_ONLY`, which was *true* and still sanctioned a silent regression. +Every check in the codebase agreed, and a user upgrading lost an attribute. + +**How consumption is decided, and why not from a list.** A hand-kept map of +"properties we read" is what made a new property invisible in the first place, +so this derives the answer by experiment instead: republish one declared +property with a different value, rebuild the snapshot through the real schema_1 +mapper, and see which snapshot fields moved. A property that moves a field the +integration reads is surfaced; one that moves nothing reaches nobody. Nothing +here restates `_PROPERTY_FIELD_MAP`, the lugs direction tables, the topology +readers or the device_info builders — the experiment sees all of them, and sees +them the way a user does, through what the panel actually renders. + +That is deliberately stricter than "mapped by `_PROPERTY_FIELD_MAP`". A property +the library reads into a snapshot field no entity, attribute or device card ever +touches has not reached anybody: `circuit.is_240v`, `evse.part_number` and +`pv.software_version` are each one library line and no user-visible effect. They +are baselined here rather than counted as read. + +**The two enumerations, and why neither can absorb a new property.** +`_INTERNAL_ROUTES` holds what the snapshot cannot express — adapter dispatch, a +shadowed fallback tier, a topology branch no producer reaches. The baseline file +holds what is genuinely unread, one reason per line. Both are compared as exact +sets, so an entry that stops being true fails just as loudly as a declaration +that arrives untriaged. A new property can only ever land in either by somebody +writing the line. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +import dataclasses +import json +import pathlib +from typing import NamedTuple + +from span_panel_api import ( + SpanBatterySnapshot, + SpanCircuitSnapshot, + SpanEvseSnapshot, + SpanMidSnapshot, + SpanPanelSnapshot, + SpanPVSnapshot, +) + +from custom_components.span_panel.field_paths import ( + RESIDUAL_EXEMPT_PATHS, + declared_field_paths, +) +from tests.adapter_fixtures import schema_one_snapshot, schema_one_tree + +BASELINE = pathlib.Path(__file__).parent / "fixtures" / "unread_declarations_baseline.json" + + +class Declaration(NamedTuple): + """One ``(device type, node, property)`` the fixture's `$description` declares. + + Keyed by device *type* rather than device id, matching the granularity of + the capability catalogs and of the gap inventory: five circuits declare the + same properties, and three of them going unread is one gap, not three. + """ + + device_type: str + node: str + property_id: str + + def __str__(self) -> str: + """Render as the baseline file's key: ``device-type/node/property``.""" + return f"{self.device_type}/{self.node}/{self.property_id}" + + +_INTERNAL_ROUTES: Mapping[Declaration, str] = { + Declaration("distribution-enclosure", "info", "data-model-version"): ( + "tier-1 adapter dispatch (span_panel_api/dispatch.py) — it chooses which " + "adapter parses the tree, so it is consumed before any snapshot exists" + ), + Declaration("distribution-enclosure", "shed", "asserted-islanding-state"): ( + "tier 2 of resolve_islanding_state (schema_1 panel.py), shadowed in this " + "fixture by the MID's tier-1 answer, and the write target of the existing " + "dominant-power-source control (schema_1 adapter.py)" + ), + Declaration("lugs", "connection", "feeds-device-id"): ( + "the downstream-lugs feedthrough branch of resolve_relative_position " + "(schema_1 devices.py), which no producer currently reaches" + ), +} +"""Declarations consumed by a route no snapshot field can show. + +Three, and each names the code that reads it. This is the category the +experiment cannot measure, so it is the category most at risk of becoming the +allowlist that swallowed the problem — hence +`test_no_internal_route_is_observable_after_all`, which fails the moment an +entry stops being needed. +""" + + +def _mapping(value: object) -> Mapping[str, object]: + """Return `value` as a string-keyed mapping, or an empty one. + + `json.loads` answers `object`, and every level of a Homie `$description` is + optional. Narrowing here rather than at each call site keeps the walk below + readable and keeps `Any` out of the module. + """ + if not isinstance(value, Mapping): + return {} + return {str(key): item for key, item in value.items()} + + +def _text(value: object) -> str: + return value if isinstance(value, str) else "" + + +class _Property(NamedTuple): + """One declared property, and the device that declares it.""" + + device_id: str + topic: str + datatype: str + format_spec: str + + +def _declared(tree: Mapping[str, Mapping[str, str]]) -> dict[Declaration, list[_Property]]: + """Every property declared anywhere in the tree, by declaration. + + The authoritative property set is the `$description`, per the enclosure data + model: what a device publishes is a subset of what it declares, and the gap + between the two is precisely what this module is about. + """ + found: dict[Declaration, list[_Property]] = {} + for device_id, topics in tree.items(): + description = _mapping(json.loads(topics["$description"])) + device_type = _text(description.get("type")).rsplit(".", 1)[-1] + for node_id, node in _mapping(description.get("nodes")).items(): + for property_id, definition in _mapping(_mapping(node).get("properties")).items(): + body = _mapping(definition) + found.setdefault(Declaration(device_type, node_id, property_id), []).append( + _Property( + device_id=device_id, + topic=f"{node_id}/{property_id}", + datatype=_text(body.get("datatype")), + format_spec=_text(body.get("format")), + ) + ) + return found + + +def _perturbed(declared: _Property, current: str | None) -> str: + """Return a legal value for this property that differs from `current`. + + Legal matters: a parser that rejects the probe value would leave the field + unchanged and the property would read as unconsumed. So the replacement is + built from the declared `datatype` and `format`, which is the same + information the adapter parses against. + + `current` is `None` for a property the fixture declares and never publishes + — 19 of the 203 instances. Publishing one is the right probe for exactly + those: it asks whether a value arriving would change anything, which is the + question `status/wifi-ssid` needed answering. + """ + if declared.datatype in {"float", "integer"}: + try: + number = float(current or "") + except ValueError: + return "7" if declared.datatype == "integer" else "7.5" + return str(int(number) + 7) if declared.datatype == "integer" else str(number + 7.5) + if declared.datatype == "boolean": + return "false" if (current or "").lower() == "true" else "true" + if declared.datatype == "enum": + for option in declared.format_spec.split(","): + if option and option != current: + return option + return "probe-value" if current != "probe-value" else "probe-value-2" + + +_SubSnapshot = ( + SpanCircuitSnapshot | SpanEvseSnapshot | SpanBatterySnapshot | SpanPVSnapshot | SpanMidSnapshot +) + +_COLLECTIONS = frozenset({"circuits", "evse", "battery", "pv", "mid"}) +"""Panel-snapshot attributes that hold sub-snapshots rather than a reading. + +Their fields are addressed by their own prefix — `circuit.x`, not +`panel.circuits.x` — matching the field-path convention `field_paths` documents +and `RESIDUAL_EXEMPT_PATHS` is written in. +""" + + +def _record(fields: dict[str, str], prefix: str, obj: _SubSnapshot, suffix: str = "") -> None: + for field in dataclasses.fields(obj): + fields[f"{prefix}.{field.name}{suffix}"] = repr(getattr(obj, field.name)) + + +def _snapshot_fields(snapshot: SpanPanelSnapshot) -> dict[str, str]: + """Flatten a snapshot to ``{field path: value}``. + + The per-instance collections are keyed by circuit and EVSE id so two + circuits cannot mask each other's change; `_bare` strips the key again for + the reader lookup, which is per field and not per instance. + + Values are held as `repr` rather than compared by equality so the diff is a + plain set operation over strings, whatever a field happens to hold. + """ + fields: dict[str, str] = {} + for field in dataclasses.fields(snapshot): + if field.name not in _COLLECTIONS: + fields[f"panel.{field.name}"] = repr(getattr(snapshot, field.name)) + for circuit_id, circuit in snapshot.circuits.items(): + _record(fields, "circuit", circuit, f"@{circuit_id}") + for evse_key, evse in snapshot.evse.items(): + _record(fields, "evse", evse, f"@{evse_key}") + _record(fields, "battery", snapshot.battery) + _record(fields, "pv", snapshot.pv) + if snapshot.mid is not None: + _record(fields, "mid", snapshot.mid) + return fields + + +def _bare(field_path: str) -> str: + return field_path.split("@", 1)[0] + + +def _moved_fields() -> dict[Declaration, frozenset[str]]: + """Republish each declared property once; return the snapshot fields it moved. + + One rebuild per declaring device, so the two lugs devices and the five + circuits are probed separately and their results unioned: only the upstream + lugs' `fed-by-*` properties are read, and a single probe against whichever + came first would answer for both. + """ + tree = schema_one_tree() + baseline = _snapshot_fields(schema_one_snapshot(tree)) + moved: dict[Declaration, frozenset[str]] = {} + + for declaration, instances in _declared(tree).items(): + changed: set[str] = set() + for instance in instances: + current = tree[instance.device_id].get(instance.topic) + replacement = _perturbed(instance, current) + assert replacement != current, ( + f"{declaration} on {instance.device_id}: the probe value equals the " + f"published one ({current!r}), so this property is not being tested" + ) + mutated = {device_id: dict(topics) for device_id, topics in tree.items()} + mutated[instance.device_id][instance.topic] = replacement + after = _snapshot_fields(schema_one_snapshot(mutated)) + changed.update(path for path, value in after.items() if baseline.get(path) != value) + moved[declaration] = frozenset(changed) + return moved + + +def _read_field_paths() -> frozenset[str]: + """Every snapshot field the integration reads, by any route. + + `declared_field_paths()` is descriptions plus residual entity-code reads; + `RESIDUAL_EXEMPT_PATHS` is the rest — device_info fields, circuit + attributes, entity-creation gates. Together they are the integration's + complete enumeration of its own reads, which is the claim + `test_every_exempt_path_still_has_a_reader` and the conformance suite hold + it to. A field in neither is one nothing renders. + """ + return declared_field_paths() | frozenset(RESIDUAL_EXEMPT_PATHS) + + +def _classified() -> tuple[dict[Declaration, frozenset[str]], dict[Declaration, frozenset[str]]]: + """Split every declaration into (surfaced, unread).""" + read = _read_field_paths() + surfaced: dict[Declaration, frozenset[str]] = {} + unread: dict[Declaration, frozenset[str]] = {} + for declaration, moved in _moved_fields().items(): + if any(_bare(path) in read for path in moved): + surfaced[declaration] = moved + else: + unread[declaration] = moved + return surfaced, unread + + +def _baseline() -> dict[Declaration, str]: + loaded: dict[str, str] = json.loads(BASELINE.read_text(encoding="utf-8")) + entries: dict[Declaration, str] = {} + for key, reason in loaded.items(): + device_type, node, property_id = key.split("/", 2) + entries[Declaration(device_type, node, property_id)] = reason + return entries + + +def _lines(declarations: Iterable[Declaration]) -> str: + return "\n".join(f" {declaration}" for declaration in sorted(declarations)) or " (none)" + + +def test_the_unread_declarations_match_the_recorded_baseline() -> None: + """Fails in both directions, so neither a gap nor a fix can land unnoticed. + + A declaration nothing reads must be triaged: surfaced by a catch-up task, or + written into the baseline with the reason it stays unread. A declaration that + starts being read must lose its baseline line in the same commit — otherwise + the file drifts into a description of an older codebase and the count it + reports stops meaning anything. + """ + _, unread = _classified() + expected = _baseline() + + appeared = sorted(set(unread) - set(expected) - set(_INTERNAL_ROUTES)) + resolved = sorted(set(expected) - set(unread)) + + assert set(unread) - set(_INTERNAL_ROUTES) == set(expected), ( + "the set of declarations nothing reads moved.\n" + f" newly unread (nothing renders these):\n{_lines(appeared)}\n" + f" now read (delete their lines from {BASELINE.name}):\n{_lines(resolved)}\n\n" + "A newly unread property is a declaration that reaches no entity, attribute " + "or device card. Surface it, or record why it stays unread." + ) + + +def test_every_baseline_entry_carries_a_reason() -> None: + """A line with no reason is an allowlist entry wearing a baseline's clothes.""" + empty = sorted(str(key) for key, reason in _baseline().items() if len(reason.split()) < 4) + assert not empty, ( + f"baseline entries with no usable reason: {empty}. Each line says why the " + "property is not surfaced, so a reader can tell a deliberate skip from a backlog item." + ) + + +def test_every_baseline_entry_is_still_declared() -> None: + """A baseline outlives its declaration silently; the file only ever grows.""" + declared = set(_declared(schema_one_tree())) + stale = sorted(str(key) for key in _baseline() if key not in declared) + assert not stale, ( + f"baseline entries the fixture no longer declares: {stale}. The property " + "went away; drop its line with it." + ) + + +def test_no_internal_route_is_observable_after_all() -> None: + """An internal-route entry must be the only thing keeping its property out. + + This is the entry that could quietly become an allowlist: unlike the + baseline it claims the property *is* consumed, and a claim the experiment + could check is one it should. So the moment a route's property does move a + field the integration reads, the entry has to go — otherwise the next + property added beside it inherits an exemption nobody re-examined. + """ + surfaced, _ = _classified() + redundant = sorted(str(key) for key in _INTERNAL_ROUTES if key in surfaced) + assert not redundant, ( + f"internal-route entries whose property now reaches a reader: {redundant}. " + "The route is no longer the only thing consuming it; delete the entry." + ) + + +def test_every_internal_route_is_still_declared() -> None: + declared = set(_declared(schema_one_tree())) + stale = sorted(str(key) for key in _INTERNAL_ROUTES if key not in declared) + assert not stale, f"internal-route entries the fixture no longer declares: {stale}" + + +def test_the_probe_moves_something_for_a_known_reading() -> None: + """The experiment must be able to observe a change at all. + + Every assertion above is satisfied by a probe that changes nothing, ever: + the unread set would simply be every declaration, matched by a baseline + somebody had grown to fit. This is the floor under that — a property whose + reading is unarguably rendered has to come back surfaced, and has to name + the field it moved. + """ + surfaced, _ = _classified() + power = Declaration("circuit", "meter", "active-power") + assert power in surfaced + assert any(_bare(path) == "circuit.instant_power_w" for path in surfaced[power]) diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py index aeed159d..0be0126e 100644 --- a/tests/test_field_path_conformance.py +++ b/tests/test_field_path_conformance.py @@ -263,7 +263,11 @@ def test_no_exempt_path_is_producible_by_both() -> None: # +3 with the shed forecast: the two full-charge refinements and the # confidence enum, read as attributes on the two forecast sensors and # carried by no adapter's metadata map. - Producibility.NEITHER: 18, + # +1 for `mid.grid_state`, the `mid_grid_state` sensor's source field. Like + # `panel.dominant_power_source` below it was read by a description and + # enumerated nowhere, so nothing held it against the adapters and + # `evaluate_field_metadata` had no way to tell it from an unread field. + Producibility.NEITHER: 19, # +1 for `panel.dominant_power_source`, the `grid_forming_entity` sensor's # source field. It was read by a `SCHEMA_CONDITIONAL_FIELD` description and # enumerated nowhere, so `evaluate_field_metadata` counted it as produced- From 1178243149130876ed4ba819e15c180d5184dc4b Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:37:24 -0700 Subject: [PATCH 049/345] feat(sensor): surface what the BESS reports about itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `meter/active-power` and `status/communication-state` have been declared and published on the BESS device since r202633 and nothing read either. The battery's own charge/discharge figure lands as a sensor enabled by default; its own link health lands as a diagnostic, off by default, because a fault signal is interesting when something is wrong and noise on a device card the rest of the time. **The sign was the whole problem, and it is now normalised in one direction.** The two properties arrive in opposite conventions: the enclosure's `power-flows/battery` is discharge-positive per the capability catalog, while its meter of the BESS is charge-positive-negated — it meters the battery the way it meters a circuit it feeds. `BATTERY_POWER_SENSOR` has always negated the first; the library now negates the second into the snapshot's charge-positive frame, and `bess_meter_power` passes that through. Both sensors sit on the same device card and both read **positive when charging**. A sensor whose sign contradicted the one beside it would be worse than no sensor, so the agreement is asserted on the two states rather than inferred from the two definitions, at two operating points. The charging premise itself is derived, not assumed: the four power flows balance (`pv + battery + grid == site`, grid positive when importing), and solving that identity says which way the battery is going without appealing to any convention this codebase chose. Reading the direction off the sign would be circular, since the sign is the claim under test. **The README said the opposite** — "Battery Power ... (+discharge, -charge)" — and had since before the v1.0 work. The shipped behaviour is charge-positive; only the documentation was wrong, so the documentation is what changed. `bess_meter_power` deliberately does not replace `battery_power`. One is the enclosure's arbitrated figure and one is the battery's own meter; on a healthy panel they agree, and where they disagree that is a fact worth seeing. `bess_communication_state` likewise does not replace `bess_connected`: the binary sensor is the panel's view of the link and the new sensor is the battery's, and a BESS can report its own link lost while the enclosure still claims it. `BESS_TELEMETRY_SENSORS` is a separate tuple from `BESS_METADATA_SENSORS` because the creation rule differs. Every metadata sensor exists on any commissioned BESS, filled or empty; these two come from capability nodes a BESS may not have, so absence has to mean no entity rather than a permanently unknown one. Gated twice like the shed forecast — `has_bess_telemetry` for the capability (a new `detect_capabilities` member, so a firmware upgrade that adds the nodes reaches the reload), then the description's own `value_fn` for the individual reading. Both descriptions are `SCHEMA_CONDITIONAL_FIELD` *and* name their `field_path`, per the rule Task 1b established: flat's BESS device class declares neither property, so the both-adapters gate cannot be satisfied, but the entity still needs its Repair mention and its unavailability. The paths join `RESIDUAL_EXEMPT_PATHS` as `SCHEMA_1_ONLY`, which schema_1's metadata rows earn them and which buys unit validation against the BESS's own `$description`. Their two lines leave the unread baseline, which is what the burn-down looks like: 39 to 37. Tests read every expected value out of the capture and prove it by mutation. Flipping the new sensor's sign fails five; removing the per-description creation gate fails four; dropping either exemption fails seven across four modules. --- CHANGELOG.md | 17 + README.md | 34 +- custom_components/span_panel/field_paths.py | 15 + custom_components/span_panel/helpers.py | 23 + custom_components/span_panel/icons.json | 6 + custom_components/span_panel/sensor.py | 19 + .../span_panel/sensor_definitions.py | 65 ++ custom_components/span_panel/strings.json | 12 + .../span_panel/translations/en.json | 12 + .../span_panel/translations/es.json | 12 + .../span_panel/translations/fr.json | 12 + .../span_panel/translations/ja.json | 12 + .../span_panel/translations/pt.json | 12 + .../unread_declarations_baseline.json | 2 - tests/test_bess_telemetry.py | 568 ++++++++++++++++++ tests/test_enum_sensor_options.py | 2 + tests/test_field_path_conformance.py | 7 +- 17 files changed, 811 insertions(+), 19 deletions(-) create mode 100644 tests/test_bess_telemetry.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 17e1ab7b..d78e7535 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ All notable changes to this project will be documented in this file. ### Added +- **Your battery's own meter and its own link health, on panels running the v1.0 data model.** **Meter Power** is what the BESS itself reports it is charging or + discharging at, as distinct from the panel's **Battery Power**, which is the enclosure's arbitrated figure. Both have been on the wire since firmware r202633 + and nothing read either. Meter Power is enabled by default; **Communication State** — the BESS's own `OK` / `DEGRADED` / `LOST` / `UNKNOWN` report on its link + — is a diagnostic and is off by default, since it is only interesting when something is wrong. +- **Both battery power sensors read positive when the battery is charging.** They come off the wire in opposite sign conventions and are normalised to one, so + the two sitting side by side on the BESS device can never point opposite ways. +- **Communication State is not the same thing as BESS Connected.** The binary sensor is the _panel's_ view of the link, from the enclosure's connection record; + the new sensor is the _battery's_ view of it. A BESS can report its own link lost while the panel still claims it, and now you can see that. +- Both sensors are created only where the BESS publishes the reading behind them — a battery on the older data model, or one whose firmware publishes only one + of the two, gets no entity for what it cannot report rather than one permanently unknown, and a BESS that gains the capability on a firmware upgrade picks the + sensors up on the reload the integration already performs. + +### Fixed + +- **The README described Battery Power's sign backwards** (`+discharge, -charge`). The sensor has always reported charging as positive; only the documentation + was wrong. No entity changed. + - **Backup planning, in minutes: two new sensors on panels running the v1.0 data model.** **Time to Priority Shed** is how long before the panel starts shedding circuits, and **Backup Time Remaining** is how long before the battery is spent. Your panel has been publishing both since firmware r202633 and nothing read them; they are the numbers you would actually set an alarm on, so they are enabled by default and sit beside the power and energy sensors rather than under diff --git a/README.md b/README.md index 85689dcb..8b751fb3 100644 --- a/README.md +++ b/README.md @@ -170,12 +170,12 @@ Present only when the panel publishes them. ### Power Flow Sensors (v2 only) -| Sensor | Device Class | Unit | Notes | -| ------------- | ------------ | ---- | --------------------------------------------------------------------------- | -| Grid Power | Power | W | Grid power flow | -| Site Power | Power | W | Total site power (grid + PV + battery) | -| Battery Power | Power | W | Battery charge/discharge (+discharge, -charge). Only when BESS commissioned | -| PV Power | Power | W | PV generation (+producing). Only when PV commissioned | +| Sensor | Device Class | Unit | Notes | +| ------------- | ------------ | ---- | ------------------------------------------------------------------------------- | +| Grid Power | Power | W | Grid power flow | +| Site Power | Power | W | Total site power (grid + PV + battery) | +| Battery Power | Power | W | Battery charge/discharge (+charging, -discharging). Only when BESS commissioned | +| PV Power | Power | W | PV generation (+producing). Only when PV commissioned | ### PV Metadata Sensors (v2 only, on main panel device) @@ -258,16 +258,18 @@ device uses manufacturer, model, serial number, and software version from batter #### BESS Sensors -| Sensor | Device Class | Unit | Notes | -| ------------------ | -------------- | ---- | ----------------------------------------------------------------- | -| Battery Level | Battery | % | State of energy as percentage | -| Battery Power | Power | W | Same entity as Power Flow Battery Power, shown on BESS sub-device | -| BESS Vendor | — | — | Battery system vendor (diagnostic) | -| BESS Model | — | — | Battery system model (diagnostic) | -| BESS Serial Number | — | — | Battery system serial number (diagnostic) | -| BESS Firmware | — | — | Battery system firmware (diagnostic) | -| Nameplate Capacity | Energy Storage | kWh | Rated battery capacity (diagnostic) | -| Stored Energy | Energy Storage | kWh | Current stored energy (diagnostic) | +| Sensor | Device Class | Unit | Notes | +| ------------------- | -------------- | ---- | ------------------------------------------------------------------------------------------------ | +| Battery Level | Battery | % | State of energy as percentage | +| Battery Power | Power | W | Same entity as Power Flow Battery Power, shown on BESS sub-device | +| Meter Power | Power | W | The BESS's own meter (+charging, -discharging). v1.0 data model only | +| Communication State | — | — | The BESS's report of its own link health (diagnostic, disabled by default). v1.0 data model only | +| BESS Vendor | — | — | Battery system vendor (diagnostic) | +| BESS Model | — | — | Battery system model (diagnostic) | +| BESS Serial Number | — | — | Battery system serial number (diagnostic) | +| BESS Firmware | — | — | Battery system firmware (diagnostic) | +| Nameplate Capacity | Energy Storage | kWh | Rated battery capacity (diagnostic) | +| Stored Energy | Energy Storage | kWh | Current stored energy (diagnostic) | #### BESS Binary Sensors diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py index 1d8b7e7b..eb55441c 100644 --- a/custom_components/span_panel/field_paths.py +++ b/custom_components/span_panel/field_paths.py @@ -210,6 +210,21 @@ class FieldPathDeclarationMixin: # and datatype validation against the panel's own `$description`. "panel.shed_time_to_priority_shed_min": Producibility.SCHEMA_1_ONLY, "panel.shed_total_time_remaining_min": Producibility.SCHEMA_1_ONLY, + # The BESS's own meter and its own link health, behind `bess_meter_power` + # and `bess_communication_state`, whose descriptions are + # `SCHEMA_CONDITIONAL_FIELD` for the usual reason: flat's BESS device + # class declares neither property, so the both-adapters gate cannot be + # satisfied. schema_1 carries a `_PROPERTY_FIELD_MAP` row for each, which + # is what makes these SCHEMA_1_ONLY rather than NEITHER and buys them unit + # and datatype validation against the BESS's own `$description`. + # + # `battery.power_w` is charge-positive in the snapshot -- the library + # negates the enclosure's meter frame -- which is the same direction + # `battery_power` shows after negating `panel.power_flow_battery`. The two + # sensors sit on one device card and must not disagree about which way is + # charging. + "battery.power_w": Producibility.SCHEMA_1_ONLY, + "battery.communication_state": Producibility.SCHEMA_1_ONLY, "circuit.always_on": Producibility.SCHEMA_0_ONLY, "circuit.is_sheddable": Producibility.SCHEMA_0_ONLY, "panel.wifi_ssid": Producibility.SCHEMA_0_ONLY, diff --git a/custom_components/span_panel/helpers.py b/custom_components/span_panel/helpers.py index f4d1d240..482e415f 100644 --- a/custom_components/span_panel/helpers.py +++ b/custom_components/span_panel/helpers.py @@ -305,6 +305,27 @@ def has_shed_forecast(snapshot: SpanPanelSnapshot) -> bool: ) +def has_bess_telemetry(snapshot: SpanPanelSnapshot) -> bool: + """Detect whether the BESS publishes anything about itself beyond its state of charge. + + Presence of the BESS's own `meter` and `status` capability nodes, from + presence of the fields they fill. A BESS may be commissioned and publish + neither: `has_bess` reads `soc/soc`, which is a different node, and every flat + panel's BESS has no such properties at all. + + Separate from `has_bess` rather than folded into it, because the two answer + different questions and the wrong one is silently wrong. `has_bess` decides + whether the sub-device exists; this decides whether two of its sensors can be + created. Merging them would either delete the metadata sensors from a BESS + with no meter node or invent two permanently-unknown ones on it. + + DUAL-SCHEMA: gated on what the snapshot carries rather than on a schema + version, so a BESS that gains these nodes on a firmware upgrade reaches + `detect_capabilities`, the coordinator reloads, and the sensors appear. + """ + return snapshot.battery.power_w is not None or snapshot.battery.communication_state is not None + + def has_evse(snapshot: SpanPanelSnapshot) -> bool: """Detect whether an EVSE (EV charger) is commissioned.""" return len(snapshot.evse) > 0 @@ -332,4 +353,6 @@ def detect_capabilities(snapshot: SpanPanelSnapshot) -> frozenset[str]: caps.add("mid") if has_shed_forecast(snapshot): caps.add("shed_forecast") + if has_bess_telemetry(snapshot): + caps.add("bess_telemetry") return frozenset(caps) diff --git a/custom_components/span_panel/icons.json b/custom_components/span_panel/icons.json index 43ca46f4..c3f0f3a6 100644 --- a/custom_components/span_panel/icons.json +++ b/custom_components/span_panel/icons.json @@ -57,6 +57,12 @@ "bess_model": { "default": "mdi:battery-outline" }, + "bess_communication_state": { + "default": "mdi:lan-connect" + }, + "bess_meter_power": { + "default": "mdi:battery-charging-outline" + }, "bess_nameplate_capacity": { "default": "mdi:battery-high" }, diff --git a/custom_components/span_panel/sensor.py b/custom_components/span_panel/sensor.py index cc327b49..52ef105e 100644 --- a/custom_components/span_panel/sensor.py +++ b/custom_components/span_panel/sensor.py @@ -21,6 +21,7 @@ from .coordinator import SpanPanelCoordinator from .helpers import ( has_bess, + has_bess_telemetry, has_evse, has_mid, has_power_flows, @@ -38,6 +39,7 @@ BATTERY_POWER_SENSOR, BATTERY_SENSOR, BESS_METADATA_SENSORS, + BESS_TELEMETRY_SENSORS, CIRCUIT_BREAKER_RATING_SENSOR, CIRCUIT_CURRENT_SENSOR, CIRCUIT_SENSORS, @@ -422,6 +424,23 @@ def create_battery_sensors( for desc in BESS_METADATA_SENSORS ) + # What the BESS reports about itself, gated per description because it comes + # from capability nodes a BESS may not have. `has_bess_telemetry` answers + # whether it publishes either node at all -- false on every flat panel, and + # the reason a reload creates these when a BESS gains them mid-life. The + # per-description check then asks whether *this* reading is among what it + # publishes: a BESS with a `meter` node and no `status` node is legal, and the + # half it omits must produce no entity rather than one permanently unknown. + # + # The presence test is the description's own `value_fn`, so the gate cannot + # drift away from the read it is gating. + if has_bess_telemetry(snapshot): + entities.extend( + SpanBessMetadataSensor(coordinator, desc, snapshot, bess_info) + for desc in BESS_TELEMETRY_SENSORS + if desc.value_fn(snapshot.battery) is not None + ) + return entities diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py index a1329eb2..391fc9c6 100644 --- a/custom_components/span_panel/sensor_definitions.py +++ b/custom_components/span_panel/sensor_definitions.py @@ -582,6 +582,70 @@ class SpanMidSensorEntityDescription(SensorEntityDescription, SpanMidRequiredKey ), ) +BESS_TELEMETRY_SENSORS: tuple[ + SpanBessMetadataSensorEntityDescription, + SpanBessMetadataSensorEntityDescription, +] = ( + SpanBessMetadataSensorEntityDescription( + key="meter_power", + field_path="battery.power_w", + derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD, + translation_key="bess_meter_power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=0, + value_fn=lambda b: b.power_w, + ), + SpanBessMetadataSensorEntityDescription( + key="communication_state", + field_path="battery.communication_state", + derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD, + translation_key="bess_communication_state", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + options=["ok", "degraded", "lost", "unknown"], + value_fn=lambda b: None if b.communication_state is None else b.communication_state.lower(), + ), +) +"""What the BESS reports about *itself*, as opposed to what the panel reports about it. + +Separate from `BESS_METADATA_SENSORS` because these are created conditionally and +those are not. Every metadata sensor exists on any commissioned BESS, filled or +empty — `bess_part_number` sits at `None` on this fixture and has since it +shipped. These two come from capability nodes a BESS may simply not have, so +absence has to mean no entity rather than a permanently unknown one, and mixing +the two rules into one tuple would mean deciding per description which applied. + +**Power is enabled by default and not diagnostic; communication state is +neither.** The battery's own charge/discharge figure is a reading a user graphs +and automates on. Its link health is a fault signal — interesting when something +is wrong, noise on a device card the rest of the time — so it lands the way the +other diagnostics do, off by default and available to anyone who wants it. + +**`bess_meter_power` is not `battery_power`, and the names say so.** The existing +`battery_power` sensor reads `panel.power_flow_battery`, the enclosure's own +arbitrated flow figure; this one reads the BESS's `meter/active-power`, the +battery's own meter. On a healthy panel they agree, and where they disagree that +is a fact worth being able to see rather than one to hide behind a single entity. + +**Both read charge-positive, which is what makes them agree.** The library +negates the BESS meter into the snapshot's frame (`SpanBatterySnapshot.power_w` +is documented charge-positive), and `BATTERY_POWER_SENSOR` negates +`power_flow_battery`, which the capability catalog defines as +discharge-positive. Two negations for two opposite wire conventions, landing on +one convention in the UI: **positive means the battery is charging**. A sensor +whose sign contradicted the one beside it would be worse than no sensor. + +**`derived` as well as `field_path`, by the producible rule.** The gate wants a +path both adapters produce, and flat's BESS device class declares neither +property — so `SCHEMA_CONDITIONAL_FIELD`, with the paths enumerated in +`RESIDUAL_EXEMPT_PATHS` as `SCHEMA_1_ONLY`. `field_path` still names the source, +which is what gives each sensor its Repair mention and its unavailability when +the panel stops resolving the property. +""" + # --------------------------------------------------------------------------- # PV metadata sensors (on main panel device) # --------------------------------------------------------------------------- @@ -925,6 +989,7 @@ def all_sensor_descriptions() -> tuple[SensorEntityDescription, ...]: *UNMAPPED_SENSORS, *MID_SENSORS, *BESS_METADATA_SENSORS, + *BESS_TELEMETRY_SENSORS, *PV_METADATA_SENSORS, *PANEL_POWER_SENSORS, *PANEL_ENERGY_SENSORS, diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index 11908f34..14d6d1ab 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -177,6 +177,18 @@ "bess_model": { "name": "Model" }, + "bess_communication_state": { + "name": "Communication State", + "state": { + "ok": "OK", + "degraded": "Degraded", + "lost": "Lost", + "unknown": "Unknown" + } + }, + "bess_meter_power": { + "name": "Meter Power" + }, "bess_nameplate_capacity": { "name": "Nameplate Capacity" }, diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index 11908f34..14d6d1ab 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -177,6 +177,18 @@ "bess_model": { "name": "Model" }, + "bess_communication_state": { + "name": "Communication State", + "state": { + "ok": "OK", + "degraded": "Degraded", + "lost": "Lost", + "unknown": "Unknown" + } + }, + "bess_meter_power": { + "name": "Meter Power" + }, "bess_nameplate_capacity": { "name": "Nameplate Capacity" }, diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index 603c749b..5cbfa878 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -177,6 +177,18 @@ "bess_model": { "name": "Modelo" }, + "bess_communication_state": { + "name": "Estado de Comunicación", + "state": { + "ok": "Correcto", + "degraded": "Degradado", + "lost": "Perdido", + "unknown": "Desconocido" + } + }, + "bess_meter_power": { + "name": "Potencia del Medidor" + }, "bess_nameplate_capacity": { "name": "Capacidad Nominal" }, diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index 096f209d..55fc0cf7 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -177,6 +177,18 @@ "bess_model": { "name": "Modèle" }, + "bess_communication_state": { + "name": "État de Communication", + "state": { + "ok": "Correct", + "degraded": "Dégradé", + "lost": "Perdu", + "unknown": "Inconnu" + } + }, + "bess_meter_power": { + "name": "Puissance du Compteur" + }, "bess_nameplate_capacity": { "name": "Capacité Nominale" }, diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index 6b1e7530..234bcc9a 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -177,6 +177,18 @@ "bess_model": { "name": "モデル" }, + "bess_communication_state": { + "name": "通信状態", + "state": { + "ok": "正常", + "degraded": "劣化", + "lost": "喪失", + "unknown": "不明" + } + }, + "bess_meter_power": { + "name": "メーター電力" + }, "bess_nameplate_capacity": { "name": "定格容量" }, diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index b793f4e7..ff7c298a 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -177,6 +177,18 @@ "bess_model": { "name": "Modelo" }, + "bess_communication_state": { + "name": "Estado de Comunicação", + "state": { + "ok": "Correto", + "degraded": "Degradado", + "lost": "Perdido", + "unknown": "Desconhecido" + } + }, + "bess_meter_power": { + "name": "Potência do Medidor" + }, "bess_nameplate_capacity": { "name": "Capacidade Nominal" }, diff --git a/tests/fixtures/unread_declarations_baseline.json b/tests/fixtures/unread_declarations_baseline.json index b452133d..b731ff8b 100644 --- a/tests/fixtures/unread_declarations_baseline.json +++ b/tests/fixtures/unread_declarations_baseline.json @@ -1,6 +1,4 @@ { - "bess/meter/active-power": "the battery's own charge/discharge meter; SpanBatterySnapshot carries no power field, and the panel's power_flow_battery is a different property (Task 5)", - "bess/status/communication-state": "the publisher's self-report, deliberately distinct from the enclosure's bess_connected view; no snapshot field carries it (Task 5)", "circuit/breaker/poles": "reaches circuit.is_240v and stops there; the voltage attribute is derived from the tab count instead, so no entity or attribute reads the field", "circuit/connection/count": "microinverter/pack count; no producer publishes it, so there is no fixture value an entity could be tested against", "circuit/connection/feeds-device-status": "the enclosure's link-health view of a circuit-fed DER; only the lugs fed-by-* route is read today, so PV and EVSE get no connected sensor (Task 3)", diff --git a/tests/test_bess_telemetry.py b/tests/test_bess_telemetry.py new file mode 100644 index 00000000..7d1a206a --- /dev/null +++ b/tests/test_bess_telemetry.py @@ -0,0 +1,568 @@ +"""What the BESS reports about itself, surfaced as two sensors on its own device. + +`meter/active-power` and `status/communication-state` were declared, published +and read by nobody. They are the battery's own view of its power and its own view +of its link, as opposed to the enclosure's view of both. + +Every assertion runs against a real snapshot built by the real schema_1 adapter +over the vendored capture, and every expected value is read out of that capture +rather than written as a literal — a test that pins the same constant the code +pins passes whether or not the wire is ever read. Each reading is proved by +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 negative `meter/active-power` +because the enclosure meters the BESS the way it meters a circuit it feeds. The +snapshot negates that into its charge-positive frame, and `battery_power` — the +sensor already on this device, reading the enclosure's discharge-positive +`power-flows/battery` — negates too. Two negations of two opposite wire +conventions, landing on one convention in the UI. 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. +""" + +from __future__ import annotations + +from datetime import date, datetime +from decimal import Decimal +import json +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from span_panel_api import SpanPanelSnapshot + +from custom_components.span_panel import SpanPanelRuntimeData +from custom_components.span_panel.field_paths import ( + RESIDUAL_EXEMPT_PATHS, + DerivedReason, + Producibility, +) +from custom_components.span_panel.helpers import detect_capabilities, has_bess_telemetry +from custom_components.span_panel.sensor import create_battery_sensors +from custom_components.span_panel.sensor_definitions import ( + BATTERY_POWER_SENSOR, + BESS_TELEMETRY_SENSORS, +) +from custom_components.span_panel.sensor_panel import ( + SpanBessMetadataSensor, + SpanPanelBattery, + SpanPanelPowerSensor, +) +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import CONF_HOST, UnitOfPower +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.typing import StateType + +from .adapter_fixtures import SCHEMA_ONE_PANEL, schema_one_snapshot, schema_one_tree +from .factories import SpanPanelSnapshotFactory + +from pytest_homeassistant_custom_component.common import MockConfigEntry + +BESS = "bess" + +POWER_TOPIC = "meter/active-power" +COMMS_TOPIC = "status/communication-state" +ENCLOSURE_FLOW_TOPIC = "power-flows/battery" + +POWER_KEY = "meter_power" +COMMS_KEY = "communication_state" +ENCLOSURE_FLOW_KEY = BATTERY_POWER_SENSOR.key + + +@pytest.fixture(autouse=True) +def _mock_entity_registry() -> Any: + """Patch entity registry lookups used during sensor construction.""" + registry = MagicMock() + registry.async_get_entity_id.return_value = None + with patch( + "custom_components.span_panel.sensor_base.er.async_get", + return_value=registry, + ): + yield registry + + +def _coordinator(snapshot: SpanPanelSnapshot) -> MagicMock: + """A coordinator-like mock carrying one snapshot.""" + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.hass = MagicMock() + coordinator.panel_offline = False + coordinator.unresolved_paths = frozenset() + coordinator.config_entry = MockConfigEntry( + domain="span_panel", + data={CONF_HOST: "192.168.1.50"}, + options={}, + title="SPAN Panel", + unique_id=snapshot.serial_number, + ) + coordinator.config_entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, panel_device_id="panel-device-id" + ) + return coordinator + + +def _published(device_id: str, topic: str) -> str: + """What the capture publishes on one topic, or fail saying it does not.""" + value = schema_one_tree()[device_id].get(topic) + assert value is not None, f"{device_id} publishes no {topic} in the capture" + return value + + +def _republishing(**topics: str) -> SpanPanelSnapshot: + """A snapshot from the capture with some BESS topics rewritten.""" + tree = schema_one_tree() + for name, value in topics.items(): + tree[BESS][name.replace("__", "/").replace("_", "-")] = value + return schema_one_snapshot(tree) + + +def _without(topic: str) -> SpanPanelSnapshot: + """A snapshot from a BESS that stopped publishing (and declaring) a property.""" + tree = schema_one_tree() + node, _, property_id = topic.partition("/") + description = json.loads(tree[BESS]["$description"]) + del tree[BESS][topic] + del description["nodes"][node]["properties"][property_id] + tree[BESS]["$description"] = json.dumps(description) + return schema_one_snapshot(tree) + + +def _without_node(node: str) -> SpanPanelSnapshot: + """A snapshot from a BESS with no such capability node at all.""" + tree = schema_one_tree() + for topic in [t for t in tree[BESS] if t.startswith(f"{node}/")]: + del tree[BESS][topic] + description = json.loads(tree[BESS]["$description"]) + del description["nodes"][node] + tree[BESS]["$description"] = json.dumps(description) + return schema_one_snapshot(tree) + + +def _without_bess() -> SpanPanelSnapshot: + """A snapshot from a capture with no BESS device in the tree at all.""" + tree = {device: topics for device, topics in schema_one_tree().items() if device != BESS} + return schema_one_snapshot(tree) + + +BessSensor = SpanPanelBattery | SpanPanelPowerSensor | SpanBessMetadataSensor +"""What `create_battery_sensors` returns: everything on the BESS sub-device.""" + + +def _sensors(snapshot: SpanPanelSnapshot) -> dict[str, BessSensor]: + """Whatever the platform creates for this snapshot, keyed by description key.""" + created = create_battery_sensors(_coordinator(snapshot), snapshot) + return {sensor.entity_description.key: sensor for sensor in created} + + +def _state(snapshot: SpanPanelSnapshot, key: str) -> StateType | date | datetime | Decimal: + """The state one BESS sensor reports for a snapshot. + + Typed as `SensorEntity.native_value` is, rather than narrowed to what these + two sensors happen to report: narrowing here would be the test asserting its + own expectation twice, once in the annotation and once in the body. + """ + sensor = _sensors(snapshot)[key] + sensor._update_native_value() + return sensor.native_value + + +# --------------------------------------------------------------------------- +# The premise: the capture publishes both properties, on a charging battery +# --------------------------------------------------------------------------- + + +def test_the_capture_publishes_both_properties() -> None: + """Guard the premise for every test below, all of which read the capture for + their expected value: a capture that stopped publishing one would make them + vacuously true rather than failing.""" + assert _published(BESS, POWER_TOPIC) + assert _published(BESS, COMMS_TOPIC) + + +def test_the_capture_is_a_charging_battery() -> None: + """The premise of every sign assertion below, derived rather than assumed. + + A sign convention can only be tested against a known physical state, and + "negative means charging" is the very claim under test, so reading the state + off the sign would be circular. The enclosure's four power flows balance + instead — `pv + battery + grid == site`, with `grid` positive when importing — + and solving that identity says which way the battery is going without + appealing to any convention this codebase chose. + + The capture: 8500 W of PV meets 2653 W of site load and exports 2347 W, and + the 3500 W left over is going into the battery. So this battery is charging, + and the enclosure publishes that as a *negative* number. + + Were the capture ever retaken with the battery discharging, this fails first + and says so, rather than the sign tests failing and reading as a wiring bug. + """ + flows = { + name: float(_published(SCHEMA_ONE_PANEL, f"power-flows/{name}")) + for name in ("pv", "battery", "grid", "site") + } + + assert flows["pv"] + flows["battery"] + flows["grid"] == pytest.approx(flows["site"]) + # PV alone exceeds the site load, so the surplus has nowhere to go but the + # battery and the grid — and the grid term is an export. + assert flows["pv"] > flows["site"] + assert flows["grid"] < 0 + 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. + + `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. Pinned here rather than assumed, because "negate exactly one + of them" would be the wrong rule if a firmware ever published them opposed. + """ + bess_meter = float(_published(BESS, POWER_TOPIC)) + enclosure_flow = float(_published(SCHEMA_ONE_PANEL, ENCLOSURE_FLOW_TOPIC)) + + assert (bess_meter < 0) == (enclosure_flow < 0) + + +# --------------------------------------------------------------------------- +# The sign, and the agreement with the sensor beside it +# --------------------------------------------------------------------------- + + +def test_charging_reads_positive() -> None: + """The convention, asserted on the state a user sees rather than on the field. + + The wire is charge-negative and the snapshot is charge-positive, so the + sensor's state is the negation of the published value. Magnitude and sign are + asserted separately on purpose: losing the negation keeps the magnitude, so + only the sign check catches it. + """ + published = float(_published(BESS, POWER_TOPIC)) + state = _state(schema_one_snapshot(), POWER_KEY) + + assert state == -published + assert isinstance(state, float) and state > 0 + + +def test_it_agrees_with_the_battery_power_sensor_beside_it() -> None: + """The two battery-power sensors on this device must not contradict each other. + + `battery_power` reads the enclosure's arbitrated `power-flows/battery`, which + the capability catalog defines as discharge-positive; this one reads the + BESS's own meter, which the enclosure publishes charge-negative. Two opposite + wire conventions, and the UI shows one — so the check is on the states, not + on either definition. A flip on either side fails here even if the side that + flipped still looks self-consistent. + """ + snapshot = schema_one_snapshot() + + own_meter = _state(snapshot, POWER_KEY) + enclosure_flow = _state(snapshot, ENCLOSURE_FLOW_KEY) + + assert isinstance(own_meter, float) and isinstance(enclosure_flow, float) + assert (own_meter > 0) == (enclosure_flow > 0) + + +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 negative together. + """ + snapshot = _republishing_both( + power=-float(_published(BESS, POWER_TOPIC)), + enclosure_flow=-float(_published(SCHEMA_ONE_PANEL, ENCLOSURE_FLOW_TOPIC)), + ) + + own_meter = _state(snapshot, POWER_KEY) + enclosure_flow = _state(snapshot, ENCLOSURE_FLOW_KEY) + + assert isinstance(own_meter, float) and own_meter < 0 + assert isinstance(enclosure_flow, float) and enclosure_flow < 0 + + +def _republishing_both(*, power: float, enclosure_flow: float) -> SpanPanelSnapshot: + """A snapshot with the BESS meter and the enclosure's flow both rewritten.""" + tree = schema_one_tree() + tree[BESS][POWER_TOPIC] = str(power) + tree[SCHEMA_ONE_PANEL][ENCLOSURE_FLOW_TOPIC] = str(enclosure_flow) + return schema_one_snapshot(tree) + + +# --------------------------------------------------------------------------- +# States follow the wire +# --------------------------------------------------------------------------- + + +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.""" + published = float(_published(BESS, POWER_TOPIC)) + discharging = -published / 2 + + snapshot = _republishing(meter__active_power=str(discharging)) + + 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: + """`-0.0` compares equal to `0.0` and renders as "-0.0" beside it, so a + negation added without a guard produces a reading that looks broken exactly + when nothing is happening.""" + snapshot = _republishing(meter__active_power="0.0") + + assert _state(snapshot, POWER_KEY) == 0.0 + assert str(_state(snapshot, POWER_KEY)) == "0.0" + + +def test_zero_watts_is_a_state_and_not_an_absence() -> None: + """An idle battery is a reading. A gate that treated zero as absence would + delete the entity whenever the battery stopped moving power.""" + snapshot = _republishing(meter__active_power="0.0") + + assert POWER_KEY in _sensors(snapshot) + + +def test_the_communication_state_is_the_published_enum_lowercased() -> None: + """Lowercase because HA looks the state up as a translation key, which its own + contract restricts to `[a-z0-9-_]+`.""" + published = _published(BESS, COMMS_TOPIC) + + assert _state(schema_one_snapshot(), COMMS_KEY) == published.lower() + + +@pytest.mark.parametrize("republished", ["DEGRADED", "LOST", "UNKNOWN"]) +def test_republishing_the_communication_state_moves_the_sensor(republished: str) -> None: + """Every other member of the enum the BESS's own `$description` declares, so + a sensor pinned to the captured OK cannot report any of them.""" + snapshot = _republishing(status__communication_state=republished) + + assert _state(snapshot, COMMS_KEY) == republished.lower() + assert _state(snapshot, COMMS_KEY) != _published(BESS, COMMS_TOPIC).lower() + + +def test_the_declared_options_are_the_enum_the_bess_declares() -> None: + """The sensor's "Possible states" against the wire's `format`, so a firmware + that widens the enum is caught here rather than by the runtime append.""" + description = json.loads(schema_one_tree()[BESS]["$description"]) + declared = description["nodes"]["status"]["properties"]["communication-state"]["format"] + + options = next(d for d in BESS_TELEMETRY_SENSORS if d.key == COMMS_KEY).options + + assert options is not None + assert set(options) == {value.lower() for value in declared.split(",")} + + +def test_communication_state_is_not_the_connected_binary_sensor() -> None: + """The two link facts this task deliberately keeps apart. + + `bess_connected` is the enclosure's `connection/fed-by-device-status` view; + this sensor is the BESS's report about itself. A BESS can report its own link + LOST while the enclosure still claims it as OK, and a mapping that conflated + them could not express that. + """ + snapshot = _republishing(status__communication_state="LOST") + + assert _state(snapshot, COMMS_KEY) == "lost" + assert snapshot.battery.connected is True + + +# --------------------------------------------------------------------------- +# Absence: deleted property, dropped node, no BESS, flat panel +# --------------------------------------------------------------------------- + + +def test_the_capture_creates_both_sensors() -> None: + created = _sensors(schema_one_snapshot()) + + assert POWER_KEY in created + assert COMMS_KEY in created + + +def test_a_bess_with_no_meter_node_gets_no_power_sensor() -> None: + """A dead entity stuck at unknown is worse than no entity: it occupies the + entity list, breaks a dashboard card, and cannot be told apart from a battery + whose meter has failed.""" + snapshot = _without_node("meter") + + assert POWER_KEY not in _sensors(snapshot) + # The other half of the pair is unaffected — a partial BESS is legal firmware. + assert COMMS_KEY in _sensors(snapshot) + + +def test_a_bess_with_no_status_node_gets_no_communication_sensor() -> None: + snapshot = _without_node("status") + + assert COMMS_KEY not in _sensors(snapshot) + assert POWER_KEY in _sensors(snapshot) + + +@pytest.mark.parametrize( + ("key", "topic", "unknown"), + [(POWER_KEY, POWER_TOPIC, None), (COMMS_KEY, COMMS_TOPIC, "unknown")], +) +def test_a_reading_that_stops_arriving_goes_unknown_rather_than_stale( + key: str, topic: str, unknown: str | None +) -> None: + """Absence after setup is a different event from absence at setup. + + Creation is decided once, from what the panel was publishing when the entry + loaded; a property that stops arriving afterwards cannot delete an entity a + user already has on a dashboard, so it has to degrade instead. The last value + persisting would be the worse outcome — a battery reading 3500 W forever is + indistinguishable from one that is actually charging. + + Driven through the coordinator rather than by rebuilding the entity, because + that is the path a live update takes. + """ + sensor = _sensors(schema_one_snapshot())[key] + + sensor.coordinator.data = _without(topic) + sensor._update_native_value() + + assert sensor.native_value == unknown + + +@pytest.mark.parametrize(("key", "topic"), [(POWER_KEY, POWER_TOPIC), (COMMS_KEY, COMMS_TOPIC)]) +def test_a_property_declared_and_never_published_creates_no_entity(key: str, topic: str) -> None: + """The gate is the value, not the declaration. + + A BESS may declare a property in its `$description` and publish nothing on it + — 19 instances in this capture do. An entity created from a declaration alone + would be permanently unknown, which is the outcome the per-description gate + exists to prevent, so this is the same answer as a missing node reached by a + different route. + """ + tree = schema_one_tree() + del tree[BESS][topic] + + created = _sensors(schema_one_snapshot(tree)) + + assert key not in created + + +def test_a_bess_publishing_neither_gets_neither_sensor() -> None: + tree = schema_one_tree() + description = json.loads(tree[BESS]["$description"]) + for node in ("meter", "status"): + for topic in [t for t in tree[BESS] if t.startswith(f"{node}/")]: + del tree[BESS][topic] + del description["nodes"][node] + tree[BESS]["$description"] = json.dumps(description) + snapshot = schema_one_snapshot(tree) + + assert has_bess_telemetry(snapshot) is False + created = _sensors(snapshot) + assert POWER_KEY not in created + assert COMMS_KEY not in created + # The BESS itself is still commissioned, so its metadata sensors survive. + assert created + + +def test_no_bess_device_creates_no_battery_sensors_at_all() -> None: + snapshot = _without_bess() + + assert create_battery_sensors(_coordinator(snapshot), snapshot) == [] + + +def test_a_flat_panel_gets_neither_sensor() -> None: + """The same absence by the other route: flat's BESS device class declares + neither property, so the factory's default snapshot carries neither field.""" + snapshot = SpanPanelSnapshotFactory.create() + + assert has_bess_telemetry(snapshot) is False + created = _sensors(snapshot) + assert POWER_KEY not in created + assert COMMS_KEY not in created + + +def test_the_telemetry_appearing_is_a_capability_change() -> None: + """Which is how a BESS that gains these nodes mid-life gets the sensors: the + coordinator reloads on a new capability.""" + assert "bess_telemetry" not in detect_capabilities(SpanPanelSnapshotFactory.create()) + assert "bess_telemetry" in detect_capabilities(schema_one_snapshot()) + assert "bess_telemetry" in detect_capabilities(_without_node("meter")) + assert "bess_telemetry" not in detect_capabilities(_without_bess()) + + +# --------------------------------------------------------------------------- +# Shape of the entities +# --------------------------------------------------------------------------- + + +def test_the_power_sensor_is_a_watt_measurement_enabled_by_default() -> None: + """The battery's own charge/discharge figure is a reading a user graphs and + automates on, so it belongs beside the other power sensors rather than under + the diagnostics fold.""" + description = next(d for d in BESS_TELEMETRY_SENSORS if d.key == POWER_KEY) + + assert description.device_class is SensorDeviceClass.POWER + assert description.state_class is SensorStateClass.MEASUREMENT + assert description.native_unit_of_measurement == UnitOfPower.WATT + assert description.entity_registry_enabled_default is True + assert description.entity_category is not EntityCategory.DIAGNOSTIC + + +def test_the_communication_sensor_is_a_diagnostic_off_by_default() -> None: + """A fault signal: interesting when something is wrong, noise on a device card + the rest of the time.""" + description = next(d for d in BESS_TELEMETRY_SENSORS if d.key == COMMS_KEY) + + assert description.device_class is SensorDeviceClass.ENUM + assert description.entity_category is EntityCategory.DIAGNOSTIC + assert description.entity_registry_enabled_default is False + + +def test_the_declared_unit_matches_what_the_bess_declares() -> None: + """HA's unit against the tree's, for the path schema_1 carries metadata for. A + disagreement here is what the unit-mismatch Repair reports at runtime.""" + from .adapter_fixtures import schema_one_metadata + + description = next(d for d in BESS_TELEMETRY_SENSORS if d.key == POWER_KEY) + + assert schema_one_metadata()["battery.power_w"].unit == (description.native_unit_of_measurement) + + +def test_both_sensors_live_on_the_bess_sub_device() -> None: + """Beside the metadata sensors and the battery level, not on the panel.""" + created = _sensors(schema_one_snapshot()) + bess_device = created["vendor"].device_info + + assert created[POWER_KEY].device_info == bess_device + assert created[COMMS_KEY].device_info == bess_device + + +def test_every_bess_sensor_gets_a_distinct_unique_id() -> None: + """They live on one device and differ only by description key, so a key reused + from the metadata group would silently collide.""" + created = _sensors(schema_one_snapshot()) + unique_ids = {sensor.unique_id for sensor in created.values()} + + assert len(unique_ids) == len(created) + + +# --------------------------------------------------------------------------- +# Conformance annotations +# --------------------------------------------------------------------------- + + +def test_both_paths_are_exempt_as_schema_1_only() -> None: + """Pinned here as well as in the conformance suite, because the reason is + specific to these properties: flat's BESS device class declares neither, so + the producible gate cannot be satisfied and the descriptions must stay + derived. schema_1 does map both, which is what makes the annotation + SCHEMA_1_ONLY rather than NEITHER.""" + assert RESIDUAL_EXEMPT_PATHS["battery.power_w"] is Producibility.SCHEMA_1_ONLY + assert RESIDUAL_EXEMPT_PATHS["battery.communication_state"] is Producibility.SCHEMA_1_ONLY + + +@pytest.mark.parametrize("description", BESS_TELEMETRY_SENSORS, ids=lambda d: d.key) +def test_each_description_names_its_field_as_well_as_its_reason(description: Any) -> None: + """`field_path` says what the entity's value is and `derived` says why that + path is outside the both-adapters gate. Leaving the first unset excuses the + entity from its Repair mention and from going unavailable when the panel stops + resolving the property.""" + assert description.derived is DerivedReason.SCHEMA_CONDITIONAL_FIELD + assert description.field_path in RESIDUAL_EXEMPT_PATHS diff --git a/tests/test_enum_sensor_options.py b/tests/test_enum_sensor_options.py index 82941ab7..9546466c 100644 --- a/tests/test_enum_sensor_options.py +++ b/tests/test_enum_sensor_options.py @@ -28,6 +28,7 @@ from custom_components.span_panel.sensor_definitions import ( BESS_METADATA_SENSORS, + BESS_TELEMETRY_SENSORS, CIRCUIT_SENSORS, EVSE_SENSORS, MID_SENSORS, @@ -56,6 +57,7 @@ def _enum_descriptions() -> list[Any]: UNMAPPED_SENSORS, MID_SENSORS, BESS_METADATA_SENSORS, + BESS_TELEMETRY_SENSORS, PV_METADATA_SENSORS, CIRCUIT_SENSORS, EVSE_SENSORS, diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py index 0be0126e..beb3c220 100644 --- a/tests/test_field_path_conformance.py +++ b/tests/test_field_path_conformance.py @@ -275,7 +275,12 @@ def test_no_exempt_path_is_producible_by_both() -> None: Producibility.SCHEMA_0_ONLY: 11, # +2 with the shed forecast: the two live estimates, which schema_1 maps and # flat firmware does not publish at all. - Producibility.SCHEMA_1_ONLY: 3, + # +2 for `battery.power_w` and `battery.communication_state`, the BESS's own + # meter and link health behind `bess_meter_power` and + # `bess_communication_state`. schema_1 maps both; flat's BESS device class + # declares neither property, so neither can ever satisfy the both-adapters + # gate. + Producibility.SCHEMA_1_ONLY: 5, } """The exemption inventory, by reason. See `test_exempt_inventory_is_complete`.""" From 0cbcb4af498a9d64b8e8e0d8dc9491d4f4613493 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:07:52 -0700 Subject: [PATCH 050/345] feat(sensor): surface the panel's power control system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `energy.ebus.capability.pcs` 0.3 is the largest capability the enclosure publishes — sixteen properties on the panel and two on every circuit — and nothing read a byte of it. It is UL 3141 import limiting: the panel's Firm Service Rating, plus the arbitration that reconciles every active import constraint to one enforced current limit. **The capability decides the entity/attribute split, not taste.** It says outright that `pcs` does not re-publish the other regimes' constraints and that "what `pcs` publishes is the **result**: the effective `import-limit` and the `binding-constraint`". So the result is the entities: `pcs_import_limit` in amps, enabled by default because a panel throttling the user's supply is a fact about their electricity rather than about the integration's health, and `pcs_binding_constraint` beside it, which is what makes the number interpretable — "limited to 40 A" versus "limited to 40 A *because the utility sent an envelope*". `pcs_active` is the third, a binary sensor, because it is the only property here that changes on its own and therefore the one an automation triggers on. The twelve amps-native inputs and `pcs_enabled` ride as attributes on the limit they explain. They are working, not readings: eleven of the thirteen move only when somebody reconfigures the panel, and a dashboard with twelve near-constant amperages on it is not a dashboard. Circuit-side `pcs/managed` and `pcs/priority` land the same way, as two attributes on the circuit's power sensor — a 40-space panel would otherwise gain eighty entities carrying two facts. `pcs_priority` is deliberately not the existing `shed_priority`: two policies on one relay, and the catalog keeps them apart because a circuit may take part in one, both, or neither. **Creation is gated once, on the node, and that is the one place this capability differs from the shed forecast and the BESS telemetry.** Both of those gate a second time on the reading, because a declared-but-unpublished property would become a permanently-unknown entity. That reasoning does not transfer: every `pcs` property is legally `0.0`, `false` or `UNCONFIGURED`, and the reference capture is exactly that — a PCS which exists and is switched off. A per-reading gate would create the entities on a configured panel and delete them the moment somebody turned the PCS off, which is the state most panels are in and the state a user most wants reported. The library carries the distinction as `SpanPanelSnapshot.pcs is None`, from the capability's own rule that absence of the node means the device runs no PCS, and `has_pcs` joins `detect_capabilities` so a panel that gains the node reaches the reload. All three descriptions are `SCHEMA_CONDITIONAL_FIELD` *and* name their `field_path`: no flat panel declares the capability, so the both-adapters gate cannot be satisfied, while the entities still need their Repair mention and their unavailability. The three result paths join `RESIDUAL_EXEMPT_PATHS` as `SCHEMA_1_ONLY`, which schema_1's metadata rows earn them and which buys the limit unit validation against the panel's own `$description`; the fifteen attribute reads join as `NEITHER`, matching the shed-forecast refinements. Eighteen lines leave the unread baseline, 37 to 19 — the whole PCS block, panel and circuit. **The capture cannot prove a reading on its own, so no test asks it to.** All sixteen panel values are zeros, `false` and `UNCONFIGURED`, which means an entity wired to a neighbouring property reports the identical value and an entity hardcoding a zero agrees with the wire by accident. Presence is asserted against the capture; every reading is proved against a republished PCS where each value differs from the captured one and from every sibling's, with the attribute dictionary compared whole so a wrong wiring shows as a value under the wrong name. On top of that, each of the sixteen is republished individually and exactly one observable may move. Crossing two attribute families fails four tests; making the presence gate value-based fails thirty; deleting the circuit attribute block fails four; dropping one exemption fails three; pointing `pcs_active` at `enabled` fails eight. --- CHANGELOG.md | 24 +- README.md | 59 +- custom_components/span_panel/binary_sensor.py | 42 + custom_components/span_panel/field_paths.py | 45 +- custom_components/span_panel/helpers.py | 27 + custom_components/span_panel/icons.json | 9 + custom_components/span_panel/sensor.py | 37 + .../span_panel/sensor_circuit.py | 21 + .../span_panel/sensor_definitions.py | 218 +++++ custom_components/span_panel/sensor_panel.py | 94 +- custom_components/span_panel/strings.json | 19 + .../span_panel/translations/en.json | 19 + .../span_panel/translations/es.json | 19 + .../span_panel/translations/fr.json | 19 + .../span_panel/translations/ja.json | 19 + .../span_panel/translations/pt.json | 19 + .../unread_declarations_baseline.json | 18 - tests/test_declared_but_unread.py | 12 +- tests/test_enum_sensor_options.py | 2 + tests/test_field_path_conformance.py | 13 +- tests/test_field_path_introspection.py | 6 +- tests/test_pcs.py | 816 ++++++++++++++++++ 22 files changed, 1510 insertions(+), 47 deletions(-) create mode 100644 tests/test_pcs.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d78e7535..61076d69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,12 +17,6 @@ All notable changes to this project will be documented in this file. - Both sensors are created only where the BESS publishes the reading behind them — a battery on the older data model, or one whose firmware publishes only one of the two, gets no entity for what it cannot report rather than one permanently unknown, and a BESS that gains the capability on a firmware upgrade picks the sensors up on the reload the integration already performs. - -### Fixed - -- **The README described Battery Power's sign backwards** (`+discharge, -charge`). The sensor has always reported charging as positive; only the documentation - was wrong. No entity changed. - - **Backup planning, in minutes: two new sensors on panels running the v1.0 data model.** **Time to Priority Shed** is how long before the panel starts shedding circuits, and **Backup Time Remaining** is how long before the battery is spent. Your panel has been publishing both since firmware r202633 and nothing read them; they are the numbers you would actually set an alarm on, so they are enabled by default and sit beside the power and energy sensors rather than under @@ -33,6 +27,24 @@ All notable changes to this project will be documented in this file. - Both sensors are created only where the panel publishes the estimate behind them. A panel on the older data model, or one whose firmware publishes only part of the forecast, gets no entity for what it cannot report rather than one permanently unknown — and a panel that gains the capability on a firmware upgrade picks the sensors up on the reload the integration already performs. +- **What is limiting your import, in amps: three new entities on panels running the v1.0 data model.** **Import Limit** is the current limit your panel is + actually enforcing, **Binding Constraint** names which rule set it — your service rating, a utility envelope, an operator cap, a limit you asked for — and + **PCS Active** says whether anything is being throttled right now. Your panel has published all of this since firmware r202633 and nothing read it. +- **Import Limit carries the whole arbitration as attributes**: the four constraint limits the panel reconciled (`feed_import_limit`, `operator_import_limit`, + `off_grid_import_limit`, `requested_import_limit`), each one's `_enablement` and `_active` flag, and `pcs_enabled`. They explain the enforced number rather + than being numbers to watch, and most of them change only when somebody reconfigures the panel — so they refine an entity you already have instead of adding + twelve to your entity list. +- **Every circuit's power sensor gains `pcs_managed` and `pcs_priority`** where the circuit reports them: whether the Power Control System manages that circuit, + and where it sits in the shed order when an import limit binds. `pcs_priority` is a different thing from the existing `shed_priority`, which is the backup + tier — a circuit may take part in one policy, both, or neither. +- All three entities are created wherever the panel publishes the capability, **including when the PCS is switched off**. A panel reporting a 0 A limit with + everything unconfigured is reporting a state, and that is the state most panels are in; entities that vanished until somebody configured a limit would be + entities nobody could build a dashboard on. + +### Fixed + +- **The README described Battery Power's sign backwards** (`+discharge, -charge`). The sensor has always reported charging as positive; only the documentation + was wrong. No entity changed. ## [2.1.0] - 8/2026 diff --git a/README.md b/README.md index 8b751fb3..93951535 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,30 @@ Present only when the panel publishes them. | `full_charge_total_time_remaining` | int | Backup Time Remaining | The same estimate assuming the battery starts full | | `forecast_confidence` | string | both | The panel's own assessment: `LOW`, `MEDIUM`, or `HIGH` | +### Power Control System Sensors (v1.0 data model only) + +Created only when your panel publishes the `pcs` capability, and created whether or not the PCS is switched on — a PCS reporting a limit of 0 A is reporting a +state, not an absence. + +| Sensor | Device Class | Unit | Notes | +| ------------------ | ------------ | ---- | -------------------------------------------------------------------------------------------------------------------------- | +| Import Limit | Current | A | The limit actually being enforced: the most restrictive of every active constraint | +| Binding Constraint | Enum | — | Which constraint sets that limit: Firm Service Rating, Grid Envelope, Voltage Support, Off-Grid, Requested, Operator, None | + +#### Power Control System Sensor Attributes + +On **Import Limit**, and present only when the panel publishes them. These are the inputs the panel reconciled to produce the enforced limit above. + +| Attribute | Type | Notes | +| ------------------------ | ------ | --------------------------------------------------------------- | +| `pcs_enabled` | bool | Whether the panel's PCS is enabled at all | +| `feed_import_limit` | float | The Firm Service Rating: the commissioned, always-on floor (A) | +| `operator_import_limit` | float | A cap imposed by a fleet or aggregator operator (A) | +| `off_grid_import_limit` | float | The import cap while islanded (A) | +| `requested_import_limit` | float | A voluntary limit requested by the owner or installer (A) | +| `_enablement` | string | Per limit: `UNSPECIFIED`, `UNCONFIGURED`, `DISABLED`, `ENABLED` | +| `_active` | bool | Per limit: whether that constraint is currently enforcing | + ### Power Flow Sensors (v2 only) | Sensor | Device Class | Unit | Notes | @@ -298,15 +322,17 @@ Applies to Main Meter and Feed Through energy sensors. ### Circuit Power Sensor Attributes -| Attribute | Type | Notes | -| ----------------- | ------ | ----------------------------------------------------- | -| `tabs` | string | Breaker slot position(s) | -| `voltage` | string | 120 or 240 (derived from tab count) | -| `always_on` | bool | Whether circuit is always-on | -| `relay_state` | string | OPEN / CLOSED / UNKNOWN | -| `relay_requester` | string | Who requested relay state | -| `shed_priority` | string | API value: NEVER / SOC_THRESHOLD / OFF_GRID / UNKNOWN | -| `is_sheddable` | bool | Whether circuit can be shed | +| Attribute | Type | Notes | +| ----------------- | ------ | ----------------------------------------------------------------------------------------------------------------------- | +| `tabs` | string | Breaker slot position(s) | +| `voltage` | string | 120 or 240 (derived from tab count) | +| `always_on` | bool | Whether circuit is always-on | +| `relay_state` | string | OPEN / CLOSED / UNKNOWN | +| `relay_requester` | string | Who requested relay state | +| `shed_priority` | string | API value: NEVER / SOC_THRESHOLD / OFF_GRID / UNKNOWN | +| `is_sheddable` | bool | Whether circuit can be shed | +| `pcs_managed` | bool | (v1.0) Whether the panel's Power Control System manages this circuit. Present only when the circuit reports it | +| `pcs_priority` | int | (v1.0) This circuit's shed order under an active import limit — distinct from `shed_priority`, which is the backup tier | ### Circuit Energy Sensor Attributes @@ -317,13 +343,14 @@ Applies to Main Meter and Feed Through energy sensors. ### Binary Sensors -| Sensor | Device Class | Notes | -| --------------- | ------------ | ------------------------------------------------------------------- | -| Door State | Tamper | Panel door open/closed | -| Ethernet Link | Connectivity | Wired network status | -| Wi-Fi Link | Connectivity | Wireless network status | -| Panel Status | Connectivity | Overall panel online/offline | -| Grid Islandable | — | (v2) Whether the panel can island from the grid. Only when reported | +| Sensor | Device Class | Notes | +| --------------- | ------------ | -------------------------------------------------------------------------------------------------- | +| Door State | Tamper | Panel door open/closed | +| Ethernet Link | Connectivity | Wired network status | +| Wi-Fi Link | Connectivity | Wireless network status | +| Panel Status | Connectivity | Overall panel online/offline | +| Grid Islandable | — | (v2) Whether the panel can island from the grid. Only when reported | +| PCS Active | Running | (v1.0) Whether the Power Control System is limiting import right now. Only when the panel runs one | **Removed from binary sensors:** diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py index 7c2d9f1c..65b72147 100644 --- a/custom_components/span_panel/binary_sensor.py +++ b/custom_components/span_panel/binary_sensor.py @@ -36,6 +36,7 @@ build_evse_unique_id_for_entry, has_bess, has_mid, + has_pcs, resolve_evse_display_suffix, ) from .util import bess_device_info, evse_device_info @@ -160,6 +161,40 @@ def _grid_islandable(snapshot: SpanPanelSnapshot) -> bool | None: ) +PCS_ACTIVE_SENSOR = SpanPanelBinarySensorEntityDescription( + key="pcs_active", + field_path="pcs.active", + derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD, + translation_key="pcs_active", + device_class=BinarySensorDeviceClass.RUNNING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda s: None if s.pcs is None else s.pcs.active, +) +"""Is the Power Control System limiting import right now? + +The one property of this capability that changes on its own, and therefore the +one an automation triggers on: `pcs/enabled` is a commissioning fact and the four +constraint limits move only on reconfiguration, but `active` flips when the panel +starts throttling. A binary sensor rather than a third enum, because the question +is binary and `pcs_binding_constraint` already answers "which limit" for anyone +who needs it. + +Diagnostic, and enabled by default. It reports the panel constraining the user's +supply, which is worth seeing, but it belongs beside the other panel-state +sensors rather than among the power readings. + +`None` when the panel runs no PCS, which is what a flat panel and any v1.0 +firmware without the node report — but the entity is not created there at all, so +the branch is reached only if the node disappears mid-session, where unknown is +the right answer. + +`SCHEMA_CONDITIONAL_FIELD` *and* `field_path`, by the producible rule: flat +declares no `pcs` capability, so the both-adapters gate cannot be satisfied, +while the entity still needs its Repair mention and its unavailability when the +panel stops resolving the property. +""" + + class SpanPanelBinarySensor[T: SpanPanelBinarySensorEntityDescription]( SpanPanelEntity, BinarySensorEntity ): @@ -439,6 +474,13 @@ async def async_setup_entry( ) ) + # Add the PCS activity sensor where the panel runs a Power Control System. + # Gated on the node, not on a value: every property this capability publishes + # is legally zero or false, so a value gate would delete the entity of every + # panel whose PCS is merely switched off — see `has_pcs`. + if has_pcs(snapshot): + entities.append(SpanPanelBinarySensor(coordinator, PCS_ACTIVE_SENSOR)) + # Add EVSE binary sensors for each commissioned charger if snapshot.evse: for evse_id in snapshot.evse: diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py index eb55441c..211298bf 100644 --- a/custom_components/span_panel/field_paths.py +++ b/custom_components/span_panel/field_paths.py @@ -10,7 +10,7 @@ fields to `battery.model` / `pv.model`). Field path convention: ``{snapshot_type}.{field_name}`` — ``panel``, -``circuit``, ``battery``, ``pv``, ``evse`` and ``mid``. +``circuit``, ``battery``, ``pv``, ``evse``, ``mid`` and ``pcs``. """ from __future__ import annotations @@ -199,6 +199,36 @@ class FieldPathDeclarationMixin: "panel.shed_full_charge_time_to_priority_shed_min": Producibility.NEITHER, "panel.shed_full_charge_total_time_remaining_min": Producibility.NEITHER, "panel.shed_forecast_confidence": Producibility.NEITHER, + # The PCS arbitration's *inputs*, read for the twelve attributes on + # `pcs_import_limit` plus its `pcs_enabled` (`pcs_arbitration_attributes` + # in sensor_definitions). schema_1 reads all thirteen into the snapshot + # and carries a `_PROPERTY_FIELD_MAP` row for none of them, deliberately: + # the capability calls `import-limit` and `binding-constraint` "the + # result", and these explain that result rather than being readings of + # their own, so there is no unit surface for a row to describe. Same + # shape as the shed-forecast refinements above. + "pcs.enabled": Producibility.NEITHER, + "pcs.feed_import_limit_a": Producibility.NEITHER, + "pcs.feed_import_limit_enablement": Producibility.NEITHER, + "pcs.feed_import_limit_active": Producibility.NEITHER, + "pcs.operator_import_limit_a": Producibility.NEITHER, + "pcs.operator_import_limit_enablement": Producibility.NEITHER, + "pcs.operator_import_limit_active": Producibility.NEITHER, + "pcs.off_grid_import_limit_a": Producibility.NEITHER, + "pcs.off_grid_import_limit_enablement": Producibility.NEITHER, + "pcs.off_grid_import_limit_active": Producibility.NEITHER, + "pcs.requested_import_limit_a": Producibility.NEITHER, + "pcs.requested_import_limit_enablement": Producibility.NEITHER, + "pcs.requested_import_limit_active": Producibility.NEITHER, + # A circuit's *participation* in that PCS — `managed` and `priority`, + # read as attributes on its power sensor (`sensor_circuit.py`). Not + # `_residual_field_paths` on the entity: that feeds + # `declared_field_paths()`, and no flat circuit declares a `pcs` node at + # all, so the producible gate would reject both. schema_1 maps neither + # for the reason above — they qualify a circuit's reading rather than + # being one. + "circuit.pcs_managed": Producibility.NEITHER, + "circuit.pcs_priority": Producibility.NEITHER, "circuit.is_user_controllable": Producibility.SCHEMA_1_ONLY, # The two backup-planning estimates behind `time_to_priority_shed` and # `shed_total_time_remaining`, whose descriptions are @@ -225,6 +255,17 @@ class FieldPathDeclarationMixin: # charging. "battery.power_w": Producibility.SCHEMA_1_ONLY, "battery.communication_state": Producibility.SCHEMA_1_ONLY, + # The Power Control System's result, behind `pcs_import_limit`, + # `pcs_binding_constraint` and the `pcs_active` binary sensor. Their + # descriptions are `SCHEMA_CONDITIONAL_FIELD` for the usual reason: no + # flat panel declares `energy.ebus.capability.pcs`, so the both-adapters + # gate cannot be satisfied. schema_1 carries a `_PROPERTY_FIELD_MAP` row + # for each of these three, which is what makes them SCHEMA_1_ONLY rather + # than NEITHER and buys `pcs.import_limit_a` unit validation against the + # panel's own `$description`. + "pcs.import_limit_a": Producibility.SCHEMA_1_ONLY, + "pcs.binding_constraint": Producibility.SCHEMA_1_ONLY, + "pcs.active": Producibility.SCHEMA_1_ONLY, "circuit.always_on": Producibility.SCHEMA_0_ONLY, "circuit.is_sheddable": Producibility.SCHEMA_0_ONLY, "panel.wifi_ssid": Producibility.SCHEMA_0_ONLY, @@ -394,6 +435,7 @@ def platform_descriptions() -> tuple[EntityDescription, ...]: BINARY_SENSORS, EVSE_BINARY_SENSORS, GRID_ISLANDABLE_SENSOR, + PCS_ACTIVE_SENSOR, ) from .sensor_definitions import ( # pylint: disable=import-outside-toplevel all_sensor_descriptions, @@ -405,6 +447,7 @@ def platform_descriptions() -> tuple[EntityDescription, ...]: *EVSE_BINARY_SENSORS, GRID_ISLANDABLE_SENSOR, BESS_CONNECTED_SENSOR, + PCS_ACTIVE_SENSOR, ) diff --git a/custom_components/span_panel/helpers.py b/custom_components/span_panel/helpers.py index 482e415f..cdc51967 100644 --- a/custom_components/span_panel/helpers.py +++ b/custom_components/span_panel/helpers.py @@ -326,6 +326,31 @@ def has_bess_telemetry(snapshot: SpanPanelSnapshot) -> bool: return snapshot.battery.power_w is not None or snapshot.battery.communication_state is not None +def has_pcs(snapshot: SpanPanelSnapshot) -> bool: + """Detect whether the panel runs a Power Control System. + + The one capability gate here that cannot be a value test, and the library is + where that is enforced: `SpanPanelSnapshot.pcs` is `None` exactly when the + enclosure declares no `pcs` node, per the capability's own rule that + "absence of the `pcs` node means the device does not run (or participate in) + a Power Control System". + + A value test would be wrong rather than merely awkward. Every property this + capability publishes is legally zero — the reference capture is a PCS that + exists and is switched off, reporting `0.0` on every limit — so reading the + values would delete the entities of every panel whose PCS is unconfigured, + which is the state most panels are in and the state a user most wants to + see. + + Always false on flat firmware, which publishes no such node at all. + + DUAL-SCHEMA: gated on what the snapshot carries rather than on a schema + version, so a panel that gains the node reaches `detect_capabilities`, the + coordinator reloads, and the entities appear. + """ + return snapshot.pcs is not None + + def has_evse(snapshot: SpanPanelSnapshot) -> bool: """Detect whether an EVSE (EV charger) is commissioned.""" return len(snapshot.evse) > 0 @@ -355,4 +380,6 @@ def detect_capabilities(snapshot: SpanPanelSnapshot) -> frozenset[str]: caps.add("shed_forecast") if has_bess_telemetry(snapshot): caps.add("bess_telemetry") + if has_pcs(snapshot): + caps.add("pcs") return frozenset(caps) diff --git a/custom_components/span_panel/icons.json b/custom_components/span_panel/icons.json index c3f0f3a6..a6a5f1c4 100644 --- a/custom_components/span_panel/icons.json +++ b/custom_components/span_panel/icons.json @@ -32,6 +32,9 @@ }, "wifi_link": { "default": "mdi:wifi" + }, + "pcs_active": { + "default": "mdi:speedometer" } }, "button": { @@ -210,6 +213,12 @@ "connected": "mdi:cloud-check", "unconnected": "mdi:cloud-off-outline" } + }, + "pcs_import_limit": { + "default": "mdi:transmission-tower-import" + }, + "pcs_binding_constraint": { + "default": "mdi:scale-balance" } } }, diff --git a/custom_components/span_panel/sensor.py b/custom_components/span_panel/sensor.py index 52ef105e..cfbc5c77 100644 --- a/custom_components/span_panel/sensor.py +++ b/custom_components/span_panel/sensor.py @@ -24,6 +24,7 @@ has_bess_telemetry, has_evse, has_mid, + has_pcs, has_power_flows, has_pv, has_shed_forecast, @@ -54,6 +55,7 @@ PANEL_DATA_STATUS_SENSORS, PANEL_ENERGY_SENSORS, PANEL_POWER_SENSORS, + PCS_SENSORS, PV_METADATA_SENSORS, PV_POWER_SENSOR, SHED_FORECAST_SENSORS, @@ -72,6 +74,7 @@ SpanPanelPanelStatus, SpanPanelPowerSensor, SpanPanelStatus, + SpanPcsSensor, SpanPVMetadataSensor, SpanShedForecastSensor, ) @@ -90,6 +93,7 @@ "SpanPanelPanelStatus", "SpanPanelPowerSensor", "SpanPanelStatus", + "SpanPcsSensor", "SpanSensorBase", "SpanShedForecastSensor", "SpanUnmappedCircuitSensor", @@ -398,6 +402,36 @@ def create_shed_forecast_sensors( ] +def create_pcs_sensors( + coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot +) -> list[SpanPcsSensor]: + """Create the Power Control System sensors, where the panel runs one. + + **One gate, not two, and that is the difference from every other capability + here.** The shed forecast and the BESS telemetry gate a second time on each + description's own `value_fn`, because a property that arrives unpublished + would otherwise become a permanently-unknown entity. That reasoning does not + transfer: `pcs` publishes properties that are legally `0.0`, `false` and + `UNCONFIGURED`, and the reference capture is exactly that — a PCS which + exists and is switched off. A per-reading gate would create the entities on a + configured panel and delete them the moment somebody turned the PCS off, + which is the state a user most wants to see reported. + + So presence is the node, which `has_pcs` reads off the library's `None` + contract, and a property the node omits degrades to unknown on an entity that + stays. Both results the capability marks `SHOULD`, so a panel publishing the + node without them is unusual firmware rather than an expected shape. + + DUAL-SCHEMA: gated on what the snapshot carries, never on a version. No flat + panel publishes the capability, and a v1.0 panel that gains it reaches + `detect_capabilities` and picks the entities up on the reload. + """ + if not has_pcs(snapshot): + return [] + + return [SpanPcsSensor(coordinator, description, snapshot) for description in PCS_SENSORS] + + def create_battery_sensors( coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot ) -> list[SpanPanelBattery | SpanPanelPowerSensor | SpanBessMetadataSensor]: @@ -502,6 +536,7 @@ def create_native_sensors( | SpanEvseSensor | SpanMidSensor | SpanShedForecastSensor + | SpanPcsSensor ]: """Create all native sensors for the platform.""" entities: list[ @@ -518,6 +553,7 @@ def create_native_sensors( | SpanEvseSensor | SpanMidSensor | SpanShedForecastSensor + | SpanPcsSensor ] = [] # Create different sensor types @@ -528,6 +564,7 @@ def create_native_sensors( entities.extend(create_battery_sensors(coordinator, snapshot)) entities.extend(create_mid_sensors(coordinator, snapshot)) entities.extend(create_shed_forecast_sensors(coordinator, snapshot)) + entities.extend(create_pcs_sensors(coordinator, snapshot)) entities.extend(create_power_flow_sensors(coordinator, snapshot)) entities.extend(create_evse_sensors(coordinator, snapshot)) diff --git a/custom_components/span_panel/sensor_circuit.py b/custom_components/span_panel/sensor_circuit.py index b519aa90..0e06c6c1 100644 --- a/custom_components/span_panel/sensor_circuit.py +++ b/custom_components/span_panel/sensor_circuit.py @@ -253,6 +253,27 @@ def extra_state_attributes(self) -> dict[str, Any] | None: attributes["shed_priority"] = circuit.priority attributes["is_sheddable"] = circuit.is_sheddable + # This circuit's participation in the enclosure's Power Control System, + # beside its load-shed participation above. Two policies on the same + # relay, and the catalog keeps them apart because they answer different + # questions — limit site import, versus preserve backup runtime — so the + # attribute names do too: `pcs_priority` is an integer shed ordering + # under an import limit, `shed_priority` the backup tier. + # + # Attributes rather than entities: a 40-space panel would otherwise gain + # eighty entities carrying two facts that change only when somebody + # reconfigures the panel. + # + # Omitted when the circuit publishes neither, which is every flat + # circuit and any v1.0 circuit outside a PCS. Both properties are `MAY`, + # so absence is conformant firmware; an attribute present and empty would + # read as a reading that failed, and `False` / `0` would each be a claim + # the panel never made. + if circuit.pcs_managed is not None: + attributes["pcs_managed"] = circuit.pcs_managed + if circuit.pcs_priority is not None: + attributes["pcs_priority"] = circuit.pcs_priority + return attributes diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py index 391fc9c6..b254b899 100644 --- a/custom_components/span_panel/sensor_definitions.py +++ b/custom_components/span_panel/sensor_definitions.py @@ -13,6 +13,7 @@ from collections.abc import Callable from dataclasses import dataclass +from typing import NamedTuple from homeassistant.components.sensor import ( SensorDeviceClass, @@ -34,6 +35,7 @@ SpanEvseSnapshot, SpanMidSnapshot, SpanPanelSnapshot, + SpanPcsSnapshot, ) from .field_paths import ( @@ -429,6 +431,221 @@ class SpanShedForecastSensorEntityDescription( """ +# --------------------------------------------------------------------------- +# Power Control System (v1.0 `pcs`, conditionally created) +# --------------------------------------------------------------------------- + + +class PcsConstraintFamily(NamedTuple): + """One amps-native constraint class, and how to read its three properties. + + `energy.ebus.capability.pcs` 0.3 publishes each arbitration input as an + identical `{-import-limit, -enablement, -active}` triplet, and says + so as a rule: a vendor "MAY publish further amps-native limits using the + same triplet". So the four families are one shape repeated, and the + attribute builder is written once over this table rather than four times + over twelve field names — where a copied line would put an enablement in an + active flag and still read plausibly. + + `attribute` is the name the limit is published under, and the enablement and + active flags extend it. The names mirror the wire property ids so a user + reading the catalog and a user reading the attribute list see the same + words. + """ + + attribute: str + limit_fn: Callable[[SpanPcsSnapshot], float | None] + enablement_fn: Callable[[SpanPcsSnapshot], str | None] + active_fn: Callable[[SpanPcsSnapshot], bool | None] + + +PCS_CONSTRAINT_FAMILIES: tuple[ + PcsConstraintFamily, + PcsConstraintFamily, + PcsConstraintFamily, + PcsConstraintFamily, +] = ( + PcsConstraintFamily( + attribute="feed_import_limit", + limit_fn=lambda p: p.feed_import_limit_a, + enablement_fn=lambda p: p.feed_import_limit_enablement, + active_fn=lambda p: p.feed_import_limit_active, + ), + PcsConstraintFamily( + attribute="operator_import_limit", + limit_fn=lambda p: p.operator_import_limit_a, + enablement_fn=lambda p: p.operator_import_limit_enablement, + active_fn=lambda p: p.operator_import_limit_active, + ), + PcsConstraintFamily( + attribute="off_grid_import_limit", + limit_fn=lambda p: p.off_grid_import_limit_a, + enablement_fn=lambda p: p.off_grid_import_limit_enablement, + active_fn=lambda p: p.off_grid_import_limit_active, + ), + PcsConstraintFamily( + attribute="requested_import_limit", + limit_fn=lambda p: p.requested_import_limit_a, + enablement_fn=lambda p: p.requested_import_limit_enablement, + active_fn=lambda p: p.requested_import_limit_active, + ), +) +"""The four constraint classes the catalog names, in the order it names them. + +The FSR first because it is the only standing one: `feed_import_limit` is the +commissioned floor that "cannot be lost", and the other three are conditional — +an operator cap set over a fleet API, an islanded cap, and a limit the owner +asked for. A reader scanning the attributes meets the permanent one first. +""" + + +def _no_pcs_attributes(pcs: SpanPcsSnapshot) -> dict[str, float | str | bool]: + """Return nothing — the default for a PCS sensor with no attributes of its own.""" + return {} + + +def pcs_arbitration_attributes(pcs: SpanPcsSnapshot) -> dict[str, float | str | bool]: + """Return the arbitration inputs behind the effective import limit. + + They belong to the sensor that shows the limit itself. + + `capabilities/pcs.md` is explicit that what a PCS publishes is "the + **result**: the effective `import-limit` and the `binding-constraint`". That + is the entity; these are the working. Twelve of them, which is why they are + attributes: a dashboard with twelve near-constant amperages on it is not a + dashboard, and eleven of these move only when somebody reconfigures the + panel. + + Each is omitted when the panel does not publish it, rather than appearing as + `None`. Three of the four classes are `MAY`, so an absent family is + conformant firmware; an attribute present and empty would read as a reading + the panel failed to produce. + + `pcs_enabled` rides here rather than as its own entity because it is + subsumed: the `pcs_active` binary sensor is the fact an automation triggers + on, and a PCS that is not enabled cannot be active. + """ + attributes: dict[str, float | str | bool] = {} + + if pcs.enabled is not None: + attributes["pcs_enabled"] = pcs.enabled + + for family in PCS_CONSTRAINT_FAMILIES: + limit = family.limit_fn(pcs) + if limit is not None: + attributes[family.attribute] = limit + enablement = family.enablement_fn(pcs) + if enablement is not None: + attributes[f"{family.attribute}_enablement"] = enablement + active = family.active_fn(pcs) + if active is not None: + attributes[f"{family.attribute}_active"] = active + + return attributes + + +@dataclass(frozen=True, kw_only=True) +class SpanPcsRequiredKeysMixin(FieldPathDeclarationMixin): + """Required keys mixin for the Power Control System sensors. + + Keyword-only for the reason `FieldPathDeclarationMixin` is: a mixin's fields + flatten ahead of `EntityDescription.key`, which has no default, so a + defaulted positional field here would make every description + unconstructable. + + `attributes_fn` is carried on the description rather than decided inside the + entity, for the reason the shed-forecast pairing is: the alternative is a + comparison against `description.key` in `extra_state_attributes`, which puts + a string match between an entity and the data it publishes and silently + stops matching after a rename. + """ + + value_fn: Callable[[SpanPcsSnapshot], float | str | None] + + attributes_fn: Callable[[SpanPcsSnapshot], dict[str, float | str | bool]] = _no_pcs_attributes + """What this sensor publishes beside its state. Empty for most.""" + + +@dataclass(frozen=True, kw_only=True) +class SpanPcsSensorEntityDescription(SensorEntityDescription, SpanPcsRequiredKeysMixin): + """Describes a Power Control System sensor entity.""" + + +PCS_BINDING_CONSTRAINT_OPTIONS: tuple[str, ...] = ( + "fsr", + "doe", + "voltage", + "off_grid", + "requested", + "operator", + "none", + "unknown", +) +"""`binding-constraint`'s enum, lowercased for Home Assistant's state keys. + +The catalog's eight members, in its order. Publishers MAY extend the enum +through the property's Homie `$format`, so this is the interoperable core rather +than a closed set — a vendor value arrives as a state Home Assistant does not +recognise, which is a visible gap rather than a silent re-labelling. +""" + + +PCS_SENSORS: tuple[SpanPcsSensorEntityDescription, SpanPcsSensorEntityDescription] = ( + SpanPcsSensorEntityDescription( + key="pcs_import_limit", + field_path="pcs.import_limit_a", + derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD, + translation_key="pcs_import_limit", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=1, + value_fn=lambda p: p.import_limit_a, + attributes_fn=pcs_arbitration_attributes, + ), + SpanPcsSensorEntityDescription( + key="pcs_binding_constraint", + field_path="pcs.binding_constraint", + derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD, + translation_key="pcs_binding_constraint", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=list(PCS_BINDING_CONSTRAINT_OPTIONS), + value_fn=lambda p: None if p.binding_constraint is None else p.binding_constraint.lower(), + ), +) +"""What the enclosure's Power Control System publishes as a result. + +**The effective limit is the entity, and the arbitration is its attributes.** +The capability says the PCS reconciles every active import constraint — some of +them in watts on `doe`, some in volts on `voltage-response` — to one enforced +current limit, and that "what `pcs` publishes is the **result**". So the result +is what gets an entity a user can graph and alarm on. The twelve amps-native +inputs behind it are on that entity as attributes: they explain a number rather +than being numbers anyone watches, and eleven of them move only on +reconfiguration. + +`pcs_binding_constraint` is the second half of that result and the reason the +first is interpretable — it names which constraint class won the `min()`, which +is the difference between "the panel is limiting me to 40 A" and "the panel is +limiting me to 40 A *because the utility sent an envelope*". Diagnostic and +enabled by default: it is short, it changes rarely, and it is useless filed +where nobody finds it. + +**Enabled by default and not diagnostic, for the limit.** A PCS actively +throttling import is a fact about the user's electricity supply, not about the +integration's health. + +**`derived` as well as `field_path`, by the producible rule.** No flat panel +publishes `energy.ebus.capability.pcs` at all, so the both-adapters gate cannot +be satisfied; the paths are enumerated in `RESIDUAL_EXEMPT_PATHS` as +`SCHEMA_1_ONLY`, which schema_1's metadata rows earn them and which buys +`pcs_import_limit` unit validation against the panel's own `$description`. +`field_path` still names the source, which is what gives each sensor its Repair +mention and its unavailability when the panel stops resolving the property. +""" + + # --------------------------------------------------------------------------- # Circuit diagnostic sensors (promoted from attributes) # --------------------------------------------------------------------------- @@ -990,6 +1207,7 @@ def all_sensor_descriptions() -> tuple[SensorEntityDescription, ...]: *MID_SENSORS, *BESS_METADATA_SENSORS, *BESS_TELEMETRY_SENSORS, + *PCS_SENSORS, *PV_METADATA_SENSORS, *PANEL_POWER_SENSORS, *PANEL_ENERGY_SENSORS, diff --git a/custom_components/span_panel/sensor_panel.py b/custom_components/span_panel/sensor_panel.py index 9f1bf38d..5cce73e8 100644 --- a/custom_components/span_panel/sensor_panel.py +++ b/custom_components/span_panel/sensor_panel.py @@ -7,7 +7,12 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.typing import UNDEFINED -from span_panel_api import SpanBatterySnapshot, SpanMidSnapshot, SpanPanelSnapshot +from span_panel_api import ( + SpanBatterySnapshot, + SpanMidSnapshot, + SpanPanelSnapshot, + SpanPcsSnapshot, +) from .coordinator import SpanPanelCoordinator from .helpers import ( @@ -24,6 +29,7 @@ SpanPanelBatterySensorEntityDescription, SpanPanelDataSensorEntityDescription, SpanPanelStatusSensorEntityDescription, + SpanPcsSensorEntityDescription, SpanPVMetadataSensorEntityDescription, SpanShedForecastSensorEntityDescription, ) @@ -184,6 +190,92 @@ def extra_state_attributes(self) -> dict[str, Any] | None: return attributes or None +class SpanPcsSensor(SpanSensorBase[SpanPcsSensorEntityDescription, SpanPcsSnapshot]): + """A reading from the enclosure's Power Control System. + + Created only where the panel declares a `pcs` node, so a panel that runs no + PCS — every flat panel, and any v1.0 firmware without the capability — gets + no entity rather than one stuck at unknown. See `create_pcs_sensors`. + """ + + # `_residual_field_paths` stays empty on purpose. The thirteen fields + # `pcs_arbitration_attributes` reads are not declarable here: no adapter + # carries a metadata row for them, so declaring them would put them in + # `declared_field_paths()` where the producible gate rejects anything one + # adapter cannot emit. They are enumerated in `RESIDUAL_EXEMPT_PATHS` as + # `Producibility.NEITHER` instead, beside the shed-forecast refinements and + # the `mid.*` attribute reads, which are outside the gate for the same + # reason. + + def __init__( + self, + data_coordinator: SpanPanelCoordinator, + description: SpanPcsSensorEntityDescription, + snapshot: SpanPanelSnapshot, + ) -> None: + """Initialize a PCS sensor, keeping a typed handle on its description. + + `SensorEntity.entity_description` is annotated as the base + `SensorEntityDescription`, so reading `attributes_fn` off it would need a + narrowing override mypy rejects, or a `getattr` that removes the check. + The same move `SpanShedForecastSensor` makes for its twin readers. + """ + super().__init__(data_coordinator, description, snapshot) + self._pcs = description + + def _generate_unique_id( + self, + snapshot: SpanPanelSnapshot, + description: SpanPcsSensorEntityDescription, + ) -> str: + """Generate unique ID for a PCS sensor.""" + return construct_panel_unique_id_for_entry( + self.coordinator, snapshot, description.key, self._device_name + ) + + def _generate_friendly_name( + self, + snapshot: SpanPanelSnapshot, + description: SpanPcsSensorEntityDescription, + ) -> str: + """Generate friendly name for a PCS sensor.""" + if description.name is not None and description.name is not UNDEFINED: + return str(description.name) + return "Power Control System" + + def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanPcsSnapshot: + """Get the data source for the PCS sensor. + + The PCS is optional, so a snapshot without one has no data source. + Entities are created only when `has_pcs` is true, and a panel that stops + publishing the node makes them unknown rather than reaching this — the + same contract `SpanMidSensor` has. + """ + pcs = snapshot.pcs + if pcs is None: + raise ValueError("PCS sensor asked for a data source on a snapshot with no PCS") + return pcs + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """The arbitration inputs behind this sensor's reading, where it has any. + + Which attributes belong to which sensor comes from the description, not + from a comparison against `key`: `pcs_binding_constraint` publishes none + and `pcs_import_limit` publishes twelve, and stating that as data is what + keeps a rename from silently moving them. + + Individually omitted when the panel does not publish them — three of the + four constraint classes are `MAY`, so an absent family is conformant + firmware rather than a reading that failed. + """ + snapshot = self.coordinator.data + if snapshot is None or snapshot.pcs is None: + return None + + return self._pcs.attributes_fn(snapshot.pcs) or None + + class SpanPanelStatus(SpanSensorBase[SpanPanelStatusSensorEntityDescription, SpanPanelSnapshot]): """Span Panel hardware status sensor entity.""" diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index 14d6d1ab..311876e9 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -147,6 +147,9 @@ }, "wifi_link": { "name": "Wi-Fi Link" + }, + "pcs_active": { + "name": "PCS Active" } }, "button": { @@ -362,6 +365,22 @@ "unconnected": "Disconnected", "unknown": "Unknown" } + }, + "pcs_import_limit": { + "name": "Import Limit" + }, + "pcs_binding_constraint": { + "name": "Binding Constraint", + "state": { + "fsr": "Firm Service Rating", + "doe": "Grid Envelope", + "voltage": "Voltage Support", + "off_grid": "Off-Grid", + "requested": "Requested", + "operator": "Operator", + "none": "None", + "unknown": "Unknown" + } } } }, diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index 14d6d1ab..311876e9 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -147,6 +147,9 @@ }, "wifi_link": { "name": "Wi-Fi Link" + }, + "pcs_active": { + "name": "PCS Active" } }, "button": { @@ -362,6 +365,22 @@ "unconnected": "Disconnected", "unknown": "Unknown" } + }, + "pcs_import_limit": { + "name": "Import Limit" + }, + "pcs_binding_constraint": { + "name": "Binding Constraint", + "state": { + "fsr": "Firm Service Rating", + "doe": "Grid Envelope", + "voltage": "Voltage Support", + "off_grid": "Off-Grid", + "requested": "Requested", + "operator": "Operator", + "none": "None", + "unknown": "Unknown" + } } } }, diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index 5cbfa878..4180ef0b 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -147,6 +147,9 @@ }, "wifi_link": { "name": "Enlace Wi-Fi" + }, + "pcs_active": { + "name": "PCS Activo" } }, "button": { @@ -362,6 +365,22 @@ "unconnected": "Desconectado", "unknown": "Desconocido" } + }, + "pcs_import_limit": { + "name": "Límite de Importación" + }, + "pcs_binding_constraint": { + "name": "Restricción Vinculante", + "state": { + "fsr": "Capacidad Firme del Servicio", + "doe": "Envolvente de Red", + "voltage": "Soporte de Tensión", + "off_grid": "Fuera de Red", + "requested": "Solicitado", + "operator": "Operador", + "none": "Ninguno", + "unknown": "Desconocido" + } } } }, diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index 55fc0cf7..65e7c50b 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -147,6 +147,9 @@ }, "wifi_link": { "name": "Liaison Wi-Fi" + }, + "pcs_active": { + "name": "PCS Actif" } }, "button": { @@ -362,6 +365,22 @@ "unconnected": "Déconnecté", "unknown": "Inconnu" } + }, + "pcs_import_limit": { + "name": "Limite d'Importation" + }, + "pcs_binding_constraint": { + "name": "Contrainte Contraignante", + "state": { + "fsr": "Capacité Ferme du Service", + "doe": "Enveloppe Réseau", + "voltage": "Support de Tension", + "off_grid": "Hors Réseau", + "requested": "Demandé", + "operator": "Opérateur", + "none": "Aucun", + "unknown": "Inconnu" + } } } }, diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index 234bcc9a..babc29ba 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -147,6 +147,9 @@ }, "wifi_link": { "name": "Wi-Fiリンク" + }, + "pcs_active": { + "name": "PCS 動作中" } }, "button": { @@ -362,6 +365,22 @@ "unconnected": "未接続", "unknown": "不明" } + }, + "pcs_import_limit": { + "name": "インポート制限" + }, + "pcs_binding_constraint": { + "name": "拘束制約", + "state": { + "fsr": "確定サービス容量", + "doe": "系統エンベロープ", + "voltage": "電圧サポート", + "off_grid": "オフグリッド", + "requested": "要求", + "operator": "オペレーター", + "none": "なし", + "unknown": "不明" + } } } }, diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index ff7c298a..aedf0e58 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -147,6 +147,9 @@ }, "wifi_link": { "name": "Ligação Wi-Fi" + }, + "pcs_active": { + "name": "PCS Ativo" } }, "button": { @@ -362,6 +365,22 @@ "unconnected": "Desconectado", "unknown": "Desconhecido" } + }, + "pcs_import_limit": { + "name": "Limite de Importação" + }, + "pcs_binding_constraint": { + "name": "Restrição Vinculativa", + "state": { + "fsr": "Capacidade Firme do Serviço", + "doe": "Envelope da Rede", + "voltage": "Suporte de Tensão", + "off_grid": "Fora da Rede", + "requested": "Solicitado", + "operator": "Operador", + "none": "Nenhum", + "unknown": "Desconhecido" + } } } }, diff --git a/tests/fixtures/unread_declarations_baseline.json b/tests/fixtures/unread_declarations_baseline.json index b731ff8b..69668cda 100644 --- a/tests/fixtures/unread_declarations_baseline.json +++ b/tests/fixtures/unread_declarations_baseline.json @@ -3,26 +3,8 @@ "circuit/connection/count": "microinverter/pack count; no producer publishes it, so there is no fixture value an entity could be tested against", "circuit/connection/feeds-device-status": "the enclosure's link-health view of a circuit-fed DER; only the lugs fed-by-* route is read today, so PV and EVSE get no connected sensor (Task 3)", "circuit/connection/feeds-device-type": "redundant: the library resolves a DER's class by dereferencing feeds-device-id against the tree's declared $type", - "circuit/pcs/managed": "per-circuit PCS participation; an attribute on the circuit power sensor rather than a 32x entity, and unbuilt (Task 4)", - "circuit/pcs/priority": "per-circuit PCS ranking; same attribute treatment as pcs/managed, and unbuilt (Task 4)", "distribution-enclosure/info/hardware-version": "no panel.hardware_version snapshot field, and the panel DeviceInfo declares no hw_version (Task 5)", "distribution-enclosure/info/vendor-name": "DeviceInfo.manufacturer is hardcoded to \"Span\"; the declared value is never consulted, even as a fallback (Task 5)", - "distribution-enclosure/pcs/active": "\"a limit is being enforced right now\" - the automation trigger of the PCS family, and the one binary sensor it wants (Task 4)", - "distribution-enclosure/pcs/binding-constraint": "names which limit is winning the min() arbitration; the enum sensor that explains pcs/import-limit (Task 4)", - "distribution-enclosure/pcs/enabled": "whether the panel enforces UL 3141 import limiting at all; subsumed by pcs/active for entity purposes (Task 4)", - "distribution-enclosure/pcs/feed-import-limit": "the standing FSR floor; an attribute on the effective-limit sensor rather than an entity (Task 4)", - "distribution-enclosure/pcs/feed-import-limit-active": "one of the four enablement/active pairs; binding-constraint already names the winner (Task 4)", - "distribution-enclosure/pcs/feed-import-limit-enablement": "one of the four enablement/active pairs; binding-constraint already names the winner (Task 4)", - "distribution-enclosure/pcs/import-limit": "the effective enforced import limit - the single number that summarizes the PCS family, and the headline entity of Task 4", - "distribution-enclosure/pcs/off-grid-import-limit": "an arbitration input; an attribute on the effective-limit sensor rather than an entity (Task 4)", - "distribution-enclosure/pcs/off-grid-import-limit-active": "one of the four enablement/active pairs; binding-constraint already names the winner (Task 4)", - "distribution-enclosure/pcs/off-grid-import-limit-enablement": "one of the four enablement/active pairs; binding-constraint already names the winner (Task 4)", - "distribution-enclosure/pcs/operator-import-limit": "an arbitration input; an attribute on the effective-limit sensor rather than an entity (Task 4)", - "distribution-enclosure/pcs/operator-import-limit-active": "one of the four enablement/active pairs; binding-constraint already names the winner (Task 4)", - "distribution-enclosure/pcs/operator-import-limit-enablement": "one of the four enablement/active pairs; binding-constraint already names the winner (Task 4)", - "distribution-enclosure/pcs/requested-import-limit": "an arbitration input; an attribute on the effective-limit sensor rather than an entity (Task 4)", - "distribution-enclosure/pcs/requested-import-limit-active": "one of the four enablement/active pairs; binding-constraint already names the winner (Task 4)", - "distribution-enclosure/pcs/requested-import-limit-enablement": "one of the four enablement/active pairs; binding-constraint already names the winner (Task 4)", "distribution-enclosure/shed/policy": "a JSON policy document (soc-priority.v1 plus the two SOC thresholds); nothing parses it, and the thresholds belong as attributes beside the forecast (Task 4)", "distribution-enclosure/status/postal-code": "deliberate skip: location data with no Home Assistant value, and surfacing it would copy the user's location into recorder history", "distribution-enclosure/status/time-zone": "deliberate skip: Home Assistant owns the time zone, so a mismatch is a Repair rather than an entity", diff --git a/tests/test_declared_but_unread.py b/tests/test_declared_but_unread.py index d17cef66..891bd213 100644 --- a/tests/test_declared_but_unread.py +++ b/tests/test_declared_but_unread.py @@ -52,6 +52,7 @@ SpanEvseSnapshot, SpanMidSnapshot, SpanPanelSnapshot, + SpanPcsSnapshot, SpanPVSnapshot, ) @@ -185,10 +186,15 @@ def _perturbed(declared: _Property, current: str | None) -> str: _SubSnapshot = ( - SpanCircuitSnapshot | SpanEvseSnapshot | SpanBatterySnapshot | SpanPVSnapshot | SpanMidSnapshot + SpanCircuitSnapshot + | SpanEvseSnapshot + | SpanBatterySnapshot + | SpanPVSnapshot + | SpanMidSnapshot + | SpanPcsSnapshot ) -_COLLECTIONS = frozenset({"circuits", "evse", "battery", "pv", "mid"}) +_COLLECTIONS = frozenset({"circuits", "evse", "battery", "pv", "mid", "pcs"}) """Panel-snapshot attributes that hold sub-snapshots rather than a reading. Their fields are addressed by their own prefix — `circuit.x`, not @@ -224,6 +230,8 @@ def _snapshot_fields(snapshot: SpanPanelSnapshot) -> dict[str, str]: _record(fields, "pv", snapshot.pv) if snapshot.mid is not None: _record(fields, "mid", snapshot.mid) + if snapshot.pcs is not None: + _record(fields, "pcs", snapshot.pcs) return fields diff --git a/tests/test_enum_sensor_options.py b/tests/test_enum_sensor_options.py index 9546466c..b9c80338 100644 --- a/tests/test_enum_sensor_options.py +++ b/tests/test_enum_sensor_options.py @@ -33,6 +33,7 @@ EVSE_SENSORS, MID_SENSORS, PANEL_DATA_STATUS_SENSORS, + PCS_SENSORS, PV_METADATA_SENSORS, STATUS_SENSORS, UNMAPPED_SENSORS, @@ -58,6 +59,7 @@ def _enum_descriptions() -> list[Any]: MID_SENSORS, BESS_METADATA_SENSORS, BESS_TELEMETRY_SENSORS, + PCS_SENSORS, PV_METADATA_SENSORS, CIRCUIT_SENSORS, EVSE_SENSORS, diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py index beb3c220..754d9d61 100644 --- a/tests/test_field_path_conformance.py +++ b/tests/test_field_path_conformance.py @@ -267,7 +267,12 @@ def test_no_exempt_path_is_producible_by_both() -> None: # `panel.dominant_power_source` below it was read by a description and # enumerated nowhere, so nothing held it against the adapters and # `evaluate_field_metadata` had no way to tell it from an unread field. - Producibility.NEITHER: 19, + # +15 with the PCS: the twelve arbitration inputs and `pcs.enabled` behind + # `pcs_import_limit`'s attributes, plus the two circuit participation fields + # read as attributes on the circuit power sensor. schema_1 reads all fifteen + # and maps none of them, deliberately — they explain the effective limit + # rather than being readings of their own. + Producibility.NEITHER: 34, # +1 for `panel.dominant_power_source`, the `grid_forming_entity` sensor's # source field. It was read by a `SCHEMA_CONDITIONAL_FIELD` description and # enumerated nowhere, so `evaluate_field_metadata` counted it as produced- @@ -280,7 +285,11 @@ def test_no_exempt_path_is_producible_by_both() -> None: # `bess_communication_state`. schema_1 maps both; flat's BESS device class # declares neither property, so neither can ever satisfy the both-adapters # gate. - Producibility.SCHEMA_1_ONLY: 5, + # +3 for the PCS's result: `pcs.import_limit_a`, `pcs.binding_constraint` + # and `pcs.active`, behind the two PCS sensors and the `pcs_active` binary + # sensor. schema_1 maps all three; no flat panel declares the capability at + # all, so none can ever satisfy the both-adapters gate. + Producibility.SCHEMA_1_ONLY: 8, } """The exemption inventory, by reason. See `test_exempt_inventory_is_complete`.""" diff --git a/tests/test_field_path_introspection.py b/tests/test_field_path_introspection.py index 993a31b4..b1182761 100644 --- a/tests/test_field_path_introspection.py +++ b/tests/test_field_path_introspection.py @@ -15,6 +15,7 @@ SpanEvseSnapshot, SpanMidSnapshot, SpanPanelSnapshot, + SpanPcsSnapshot, ) from custom_components.span_panel.field_paths import ( @@ -27,7 +28,7 @@ # Attributes of the panel snapshot that are themselves sub-snapshots. Their # fields are addressed as "battery.x", not "panel.battery.x". -_SUB_SNAPSHOTS = {"battery", "pv", "evse", "mid"} +_SUB_SNAPSHOTS = {"battery", "pv", "evse", "mid", "pcs"} class _Recorder: @@ -122,6 +123,7 @@ def _declaring_descriptions() -> Iterator[_DeclaringDescription]: BINARY_SENSORS, EVSE_BINARY_SENSORS, GRID_ISLANDABLE_SENSOR, + PCS_ACTIVE_SENSOR, ) for description in ( @@ -130,6 +132,7 @@ def _declaring_descriptions() -> Iterator[_DeclaringDescription]: *EVSE_BINARY_SENSORS, GRID_ISLANDABLE_SENSOR, BESS_CONNECTED_SENSOR, + PCS_ACTIVE_SENSOR, ): if not isinstance(description, _DeclaringDescription): raise TypeError( @@ -154,6 +157,7 @@ def _declaring_descriptions() -> Iterator[_DeclaringDescription]: SpanBatterySnapshot: "battery", SpanEvseSnapshot: "evse", SpanMidSnapshot: "mid", + SpanPcsSnapshot: "pcs", } diff --git a/tests/test_pcs.py b/tests/test_pcs.py new file mode 100644 index 00000000..06608033 --- /dev/null +++ b/tests/test_pcs.py @@ -0,0 +1,816 @@ +"""The Power Control System, surfaced as three entities and fifteen attributes. + +Thirteen of the attributes are on the effective-limit sensor and two are on +every circuit's power sensor. + +`energy.ebus.capability.pcs` 0.3 is the largest capability the enclosure +publishes — sixteen properties on the panel, two on every circuit — and nothing +read a byte of it. The capability itself says what to surface: it reconciles +every active import constraint to one enforced current limit, and "what `pcs` +publishes is the **result**: the effective `import-limit` and the +`binding-constraint`". So the result is the entities, and the arbitration behind +it is their attributes. + +**The capture is a PCS that is switched off, and every test here is written +around that.** Every limit is `0.0`, every enablement `UNCONFIGURED`, every +boolean `false`. Uniform data makes an assertion cheap to satisfy for the wrong +reason — an entity wired to the neighbouring property reports the identical +value, and an entity hardcoding a zero agrees with the wire by accident. So no +state or attribute here is asserted against the captured values. Presence is +checked against the capture; every *reading* is proved by republishing a value +that differs from the captured one and from every sibling's, and the sensor's +whole attribute dictionary is compared at once, so a wrong wiring shows up as a +value under the wrong name rather than as a value that happens to match. +""" + +from __future__ import annotations + +from datetime import date, datetime +from decimal import Decimal +import json +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from span_panel_api import SpanPanelSnapshot + +from custom_components.span_panel import SpanPanelRuntimeData +from custom_components.span_panel.binary_sensor import ( + PCS_ACTIVE_SENSOR, + SpanPanelBinarySensor, + SpanPanelBinarySensorEntityDescription, +) +from custom_components.span_panel.field_paths import ( + RESIDUAL_EXEMPT_PATHS, + DerivedReason, + Producibility, +) +from custom_components.span_panel.helpers import detect_capabilities, has_pcs +from custom_components.span_panel.sensor import create_circuit_sensors, create_pcs_sensors +from custom_components.span_panel.sensor_circuit import SpanCircuitPowerSensor +from custom_components.span_panel.sensor_definitions import ( + PCS_BINDING_CONSTRAINT_OPTIONS, + PCS_CONSTRAINT_FAMILIES, + PCS_SENSORS, +) +from custom_components.span_panel.sensor_panel import SpanPcsSensor +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import CONF_HOST, UnitOfElectricCurrent +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.typing import StateType + +from .adapter_fixtures import SCHEMA_ONE_PANEL, schema_one_snapshot, schema_one_tree +from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory + +from pytest_homeassistant_custom_component.common import MockConfigEntry + +NODE = "pcs" + +IMPORT_LIMIT_KEY = "pcs_import_limit" +BINDING_CONSTRAINT_KEY = "pcs_binding_constraint" + +# A circuit the capture reports as PCS-managed, and one it reports as not. +_SOURCES = ("feed", "operator", "off-grid", "requested") +"""The catalog's four amps-native constraint classes, in the order it names them.""" + +MANAGED_CIRCUIT = "0ab966b95f92a6a51ec548485aa85f54" +UNMANAGED_CIRCUIT = "573066aaddd7b75114c4563ce3af18c4" + +# One republished value per panel property, every one different from the +# captured value *and* from every sibling's. That distinctness is the whole +# apparatus of this module: against a capture of zeros, `false` and +# `UNCONFIGURED`, an attribute asserted to equal what was published is satisfied +# by eleven wrong wirings as easily as by the right one. +# +# Two of the sixteen cannot be made unique, and saying why matters more than +# hiding it. The enablement enum has four members and one of them is what the +# capture already publishes, so four families can differ from the capture and +# from each other in at most three ways. Every `-active` flag is worse: all four +# are `false` in the capture, so all four must be republished `true` to be +# testing anything at all. +# +# Telling those apart is what `test_republishing_any_property_moves_only_what_reads_it` +# is for. It flips one property at a time on top of this state and requires +# exactly one observable to move, which catches a cross-wiring the dictionary +# comparison below cannot see. +_CONFIGURED: dict[str, str] = { + "enabled": "true", + "active": "true", + "import-limit": "37.5", + "binding-constraint": "DOE", + "feed-import-limit": "100.0", + "feed-import-limit-enablement": "ENABLED", + "feed-import-limit-active": "true", + "operator-import-limit": "62.5", + "operator-import-limit-enablement": "DISABLED", + "operator-import-limit-active": "true", + "off-grid-import-limit": "25.0", + "off-grid-import-limit-enablement": "UNSPECIFIED", + "off-grid-import-limit-active": "true", + "requested-import-limit": "80.0", + "requested-import-limit-enablement": "ENABLED", + "requested-import-limit-active": "true", +} + +# What `pcs_import_limit` must publish beside its state for `_CONFIGURED`. The +# effective limit and the binding constraint are absent: those are the two +# entities, and repeating an entity's state as its own attribute would be a +# second copy to keep in step. +_CONFIGURED_ATTRIBUTES: dict[str, float | str | bool] = { + "pcs_enabled": True, + "feed_import_limit": 100.0, + "feed_import_limit_enablement": "ENABLED", + "feed_import_limit_active": True, + "operator_import_limit": 62.5, + "operator_import_limit_enablement": "DISABLED", + "operator_import_limit_active": True, + "off_grid_import_limit": 25.0, + "off_grid_import_limit_enablement": "UNSPECIFIED", + "off_grid_import_limit_active": True, + "requested_import_limit": 80.0, + "requested_import_limit_enablement": "ENABLED", + "requested_import_limit_active": True, +} + + +@pytest.fixture(autouse=True) +def _mock_entity_registry() -> Any: + """Patch entity registry lookups used during sensor construction.""" + registry = MagicMock() + registry.async_get_entity_id.return_value = None + with patch( + "custom_components.span_panel.sensor_base.er.async_get", + return_value=registry, + ): + yield registry + + +def _coordinator(snapshot: SpanPanelSnapshot) -> MagicMock: + """A coordinator-like mock carrying one snapshot.""" + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.hass = MagicMock() + coordinator.panel_offline = False + coordinator.unresolved_paths = frozenset() + coordinator.config_entry = MockConfigEntry( + domain="span_panel", + data={CONF_HOST: "192.168.1.50"}, + options={}, + title="SPAN Panel", + unique_id=snapshot.serial_number, + ) + coordinator.config_entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, panel_device_id="panel-device-id" + ) + return coordinator + + +def _published(property_id: str, device_id: str = SCHEMA_ONE_PANEL) -> str: + """What the capture publishes on one PCS topic, or fail saying it does not.""" + value = schema_one_tree()[device_id].get(f"{NODE}/{property_id}") + assert value is not None, f"{device_id} publishes no {NODE}/{property_id} in the capture" + return value + + +def _republishing(device_id: str = SCHEMA_ONE_PANEL, **properties: str) -> SpanPanelSnapshot: + """A snapshot from the capture with some PCS topics rewritten.""" + tree = schema_one_tree() + for property_id, value in properties.items(): + tree[device_id][f"{NODE}/{property_id.replace('_', '-')}"] = value + return schema_one_snapshot(tree) + + +def _configured() -> SpanPanelSnapshot: + """A snapshot of a PCS that is switched on, every property distinct. + + The capture cannot be used for a reading test — see the module docstring — + so this is the state the reading tests run against. + """ + tree = schema_one_tree() + for property_id, value in _CONFIGURED.items(): + tree[SCHEMA_ONE_PANEL][f"{NODE}/{property_id}"] = value + return schema_one_snapshot(tree) + + +def _without(*property_ids: str, device_id: str = SCHEMA_ONE_PANEL) -> SpanPanelSnapshot: + """A snapshot from a panel that stopped publishing (and declaring) properties.""" + tree = schema_one_tree() + description = json.loads(tree[device_id]["$description"]) + for property_id in property_ids: + del tree[device_id][f"{NODE}/{property_id}"] + del description["nodes"][NODE]["properties"][property_id] + tree[device_id]["$description"] = json.dumps(description) + return schema_one_snapshot(tree) + + +def _without_node(device_id: str = SCHEMA_ONE_PANEL) -> SpanPanelSnapshot: + """A snapshot from a capture with no `pcs` node on one device at all.""" + tree = schema_one_tree() + for topic in [t for t in tree[device_id] if t.startswith(f"{NODE}/")]: + del tree[device_id][topic] + description = json.loads(tree[device_id]["$description"]) + del description["nodes"][NODE] + tree[device_id]["$description"] = json.dumps(description) + return schema_one_snapshot(tree) + + +def _sensors(snapshot: SpanPanelSnapshot) -> dict[str, SpanPcsSensor]: + """Whatever the platform creates for this snapshot, keyed by description key.""" + created = create_pcs_sensors(_coordinator(snapshot), snapshot) + return {sensor.entity_description.key: sensor for sensor in created} + + +def _state(snapshot: SpanPanelSnapshot, key: str) -> StateType | date | datetime | Decimal: + """The state one PCS sensor reports for a snapshot. + + Typed as `SensorEntity.native_value` is rather than narrowed to what these + two sensors happen to report: narrowing here would be the test asserting its + own expectation twice, once in the annotation and once in the body. + """ + sensor = _sensors(snapshot)[key] + sensor._update_native_value() + return sensor.native_value + + +def _attributes(snapshot: SpanPanelSnapshot, key: str) -> dict[str, Any]: + return _sensors(snapshot)[key].extra_state_attributes or {} + + +def _binary( + snapshot: SpanPanelSnapshot, +) -> SpanPanelBinarySensor[SpanPanelBinarySensorEntityDescription]: + """The `pcs_active` binary sensor, updated from the snapshot.""" + sensor: SpanPanelBinarySensor[SpanPanelBinarySensorEntityDescription] = SpanPanelBinarySensor( + _coordinator(snapshot), PCS_ACTIVE_SENSOR + ) + sensor.async_write_ha_state = MagicMock() + sensor._handle_coordinator_update() + return sensor + + +def _circuit_power_sensor(snapshot: SpanPanelSnapshot, circuit_id: str) -> SpanCircuitPowerSensor: + """The power sensor for one circuit, which is where PCS participation lands.""" + coordinator = _coordinator(snapshot) + created = create_circuit_sensors(coordinator, snapshot, coordinator.config_entry) + for sensor in created: + if isinstance(sensor, SpanCircuitPowerSensor) and sensor.circuit_id == circuit_id: + return sensor + raise AssertionError(f"no power sensor created for circuit {circuit_id}") + + +def _circuit_attributes(snapshot: SpanPanelSnapshot, circuit_id: str) -> dict[str, Any]: + return _circuit_power_sensor(snapshot, circuit_id).extra_state_attributes or {} + + +# --------------------------------------------------------------------------- +# The premise: what the capture carries, and why it cannot prove a reading +# --------------------------------------------------------------------------- + + +def test_the_capture_publishes_the_whole_system_surface() -> None: + """Guard the premise for every test below. Sixteen properties, each declared + and published; a capture that dropped one would make its absence test + vacuous rather than failing.""" + declared = json.loads(schema_one_tree()[SCHEMA_ONE_PANEL]["$description"])["nodes"][NODE] + + assert set(declared["properties"]) == set(_CONFIGURED) + for property_id in _CONFIGURED: + assert _published(property_id) + + +def test_the_capture_is_a_pcs_that_is_switched_off() -> None: + """The fact this module is written around, asserted rather than assumed. + + Every limit zero, every enablement `UNCONFIGURED`, every boolean false. That + is why no reading below is proved by comparing an entity against the + capture: fifteen wrong wirings report the same value as the right one. Were + the capture ever retaken with a configured PCS, this fails first and says + so, rather than the reading tests silently becoming redundant. + """ + assert _published("enabled") == "false" + assert _published("active") == "false" + assert _published("binding-constraint") == "NONE" + assert {float(_published(f"{source}-import-limit")) for source in _SOURCES} == {0.0} + assert {_published(f"{source}-import-limit-enablement") for source in _SOURCES} == { + "UNCONFIGURED" + } + assert float(_published("import-limit")) == 0.0 + + +def test_the_republished_values_are_all_different_from_each_other() -> None: + """Guard the apparatus itself. + + Every reading test below rests on `_CONFIGURED` giving each property a value + no sibling shares — that is what turns "the attribute equals what was + published" into a statement about which property it came from. Two entries + accidentally made equal would silently weaken every one of them. + """ + numeric = [value for value in _CONFIGURED.values() if value.replace(".", "").isdigit()] + assert len(set(numeric)) == len(numeric) + + # As distinct as the enum allows: four families, four members, one of which + # is the value the capture already publishes. + declared = json.loads(schema_one_tree()[SCHEMA_ONE_PANEL]["$description"])["nodes"][NODE][ + "properties" + ]["feed-import-limit-enablement"]["format"] + enablements = {_CONFIGURED[f"{source}-import-limit-enablement"] for source in _SOURCES} + assert len(enablements) == len(declared.split(",")) - 1 + + for property_id, value in _CONFIGURED.items(): + assert value != _published(property_id), f"{property_id} is not being changed" + + +# --------------------------------------------------------------------------- +# The effective limit, and the arbitration it carries as attributes +# --------------------------------------------------------------------------- + + +def test_the_import_limit_sensor_reports_the_effective_limit() -> None: + """The headline reading, on a PCS that is switched on.""" + assert _state(_configured(), IMPORT_LIMIT_KEY) == float(_CONFIGURED["import-limit"]) + + +def test_the_effective_limit_is_not_any_of_its_inputs() -> None: + """The capability calls `import-limit` the arbitration *result*. A sensor + wired to the FSR would be plausible and wrong, so the four inputs are + republished to one shared value the result does not share.""" + snapshot = _republishing( + **{ + "import_limit": "12.5", + "feed_import_limit": "99.0", + "operator_import_limit": "99.0", + "off_grid_import_limit": "99.0", + "requested_import_limit": "99.0", + } + ) + + assert _state(snapshot, IMPORT_LIMIT_KEY) == 12.5 + + +def test_republishing_the_effective_limit_moves_the_sensor() -> None: + """The mutation proof, twice over: two values, neither the captured zero.""" + assert _state(_republishing(import_limit="15.0"), IMPORT_LIMIT_KEY) == 15.0 + assert _state(_republishing(import_limit="16.5"), IMPORT_LIMIT_KEY) == 16.5 + + +def test_zero_amps_is_a_reading_and_not_an_absence() -> None: + """The captured state, and a real one: the PCS is permitting no import at + all. An entity that treated it as missing would go blank exactly when the + panel is most restrictive.""" + assert _state(schema_one_snapshot(), IMPORT_LIMIT_KEY) == 0.0 + + +def test_the_arbitration_rides_as_attributes_on_the_limit() -> None: + """The whole attribute dictionary at once, against a PCS where every value + is distinct. + + Compared as a dictionary rather than key by key, deliberately. Twelve of + these are the same shape and the capture makes them identical, so the + failure worth catching is a value landing under the wrong name — which a + per-key assertion on a matching value cannot see and this does. + """ + assert _attributes(_configured(), IMPORT_LIMIT_KEY) == _CONFIGURED_ATTRIBUTES + + +def test_the_attributes_are_the_inputs_and_not_the_result() -> None: + """`import-limit` and `binding-constraint` are the two entities. Repeating + either as an attribute of the other would be a second copy to keep in + step.""" + attributes = _attributes(_configured(), IMPORT_LIMIT_KEY) + + assert "import_limit" not in attributes + assert "binding_constraint" not in attributes + + +@pytest.mark.parametrize("family", PCS_CONSTRAINT_FAMILIES, ids=lambda f: f.attribute) +def test_each_constraint_family_publishes_its_own_three_attributes(family: Any) -> None: + """Every family contributes a limit, an enablement and an active flag, and + the names extend the limit's. Asserted per family so a copied line that left + one family reading another's fields fails naming the family.""" + snapshot = _configured() + pcs = snapshot.pcs + assert pcs is not None + attributes = _attributes(snapshot, IMPORT_LIMIT_KEY) + + assert attributes[family.attribute] == family.limit_fn(pcs) + assert attributes[f"{family.attribute}_enablement"] == family.enablement_fn(pcs) + assert attributes[f"{family.attribute}_active"] == family.active_fn(pcs) + + +@pytest.mark.parametrize("property_id", sorted(_CONFIGURED)) +def test_republishing_any_property_moves_only_what_reads_it(property_id: str) -> None: + """The strongest statement this module makes, and the one the uniform + capture demands. + + One property is republished on top of the fully-configured PCS, and the + entity states plus the whole attribute dictionary are compared against the + unmodified configured baseline. Exactly one thing may move. An attribute + wired to a neighbouring property moves when it should not, which no + assertion against the captured zeros could ever detect — every sibling + already holds the value a wrong wiring would report. + """ + tree = schema_one_tree() + for name, value in _CONFIGURED.items(): + tree[SCHEMA_ONE_PANEL][f"{NODE}/{name}"] = value + + def observe(snapshot: SpanPanelSnapshot) -> dict[str, object]: + observed: dict[str, object] = { + f"state:{IMPORT_LIMIT_KEY}": _state(snapshot, IMPORT_LIMIT_KEY), + f"state:{BINDING_CONSTRAINT_KEY}": _state(snapshot, BINDING_CONSTRAINT_KEY), + "state:pcs_active": _binary(snapshot).is_on, + } + observed.update(_attributes(snapshot, IMPORT_LIMIT_KEY)) + return observed + + baseline = observe(schema_one_snapshot(tree)) + + # A second value for this property, again unlike anything else published. + republished = "false" if _CONFIGURED[property_id] == "true" else "true" + if property_id == "binding-constraint": + republished = "OPERATOR" + elif property_id.endswith("-enablement"): + republished = "DISABLED" if _CONFIGURED[property_id] != "DISABLED" else "ENABLED" + elif property_id.endswith("import-limit"): + republished = "7.25" + + tree[SCHEMA_ONE_PANEL][f"{NODE}/{property_id}"] = republished + after = observe(schema_one_snapshot(tree)) + + moved = {name for name, value in after.items() if baseline[name] != value} + assert len(moved) == 1, f"republishing {property_id} moved {sorted(moved)}" + + +@pytest.mark.parametrize("property_id", sorted(_CONFIGURED)) +def test_an_unpublished_property_is_omitted_rather_than_shown_empty(property_id: str) -> None: + """Three of the four constraint classes are `MAY`, so an absent family is + conformant firmware. An attribute present and holding `None` would read as a + reading the panel failed to produce, which is a different claim.""" + attributes = _attributes(_without(property_id), IMPORT_LIMIT_KEY) + + assert None not in attributes.values() + + +def test_a_panel_publishing_no_constraint_families_still_shows_its_limit() -> None: + """The minimum conformant PCS: the two results, and none of the working.""" + snapshot = _without( + *[ + f"{source}-import-limit{suffix}" + for source in _SOURCES + for suffix in ("", "-enablement", "-active") + ], + "enabled", + ) + + assert _state(snapshot, IMPORT_LIMIT_KEY) == 0.0 + assert _attributes(snapshot, IMPORT_LIMIT_KEY) == {} + + +# --------------------------------------------------------------------------- +# The binding constraint +# --------------------------------------------------------------------------- + + +def test_the_binding_constraint_is_the_published_enum_lowercased() -> None: + """Lowercase because Home Assistant looks the state up as a translation key, + which its own contract restricts to `[a-z0-9-_]+`.""" + assert ( + _state(_configured(), BINDING_CONSTRAINT_KEY) == _CONFIGURED["binding-constraint"].lower() + ) + + +@pytest.mark.parametrize("republished", ["FSR", "DOE", "VOLTAGE", "OFF_GRID", "OPERATOR"]) +def test_republishing_the_binding_constraint_moves_the_sensor(republished: str) -> None: + """Members of the enum the panel's own `$description` declares, none of them + the captured `NONE`, so a sensor pinned to the capture cannot report any.""" + assert _state(_republishing(binding_constraint=republished), BINDING_CONSTRAINT_KEY) == ( + republished.lower() + ) + + +def test_the_declared_options_are_the_enum_the_panel_declares() -> None: + """The sensor's "Possible states" against the wire's `format`, so a firmware + that widens the enum is caught here rather than by a runtime append. + + This is the assertion against the catalog: the fixture's `$format` is a + verbatim copy of the eight members `capabilities/pcs.md` lists, so checking + the options against it checks them against the catalog without a third + hand-written copy in this file. + """ + declared = json.loads(schema_one_tree()[SCHEMA_ONE_PANEL]["$description"])["nodes"][NODE][ + "properties" + ]["binding-constraint"]["format"] + + assert set(PCS_BINDING_CONSTRAINT_OPTIONS) == {value.lower() for value in declared.split(",")} + + +def test_none_is_a_binding_constraint_and_not_an_absence() -> None: + """The captured value. `NONE` means nothing is constraining import, which is + a state the catalog defines; reporting it as unknown would lose that.""" + assert _state(schema_one_snapshot(), BINDING_CONSTRAINT_KEY) == "none" + + +def test_a_binding_constraint_that_stops_arriving_goes_unknown() -> None: + """Absence after setup is a different event from absence at setup: an entity + a user already has cannot be deleted, so it degrades instead.""" + sensor = _sensors(schema_one_snapshot())[BINDING_CONSTRAINT_KEY] + + sensor.coordinator.data = _without("binding-constraint") + sensor._update_native_value() + + assert sensor.native_value == "unknown" + + +# --------------------------------------------------------------------------- +# The activity binary sensor +# --------------------------------------------------------------------------- + + +def test_the_activity_sensor_follows_the_published_flag() -> None: + """Both directions, because the capture only shows one of them.""" + assert _binary(_republishing(active="true")).is_on is True + assert _binary(_republishing(active="false")).is_on is False + + +def test_activity_is_not_enablement() -> None: + """A configured PCS spends most of its life enabled and inactive, which is + exactly the state a sensor reading the wrong flag would misreport. Both are + `false` in the capture, so crossing them there is invisible.""" + snapshot = _republishing(enabled="true", active="false") + + assert _binary(snapshot).is_on is False + assert _attributes(snapshot, IMPORT_LIMIT_KEY)["pcs_enabled"] is True + + +def test_the_activity_sensor_goes_unknown_when_the_flag_stops_arriving() -> None: + """`None` reaches Home Assistant as unknown, not as unavailable: the panel + is reachable and the entity is fine, the fact simply is not being stated.""" + assert _binary(_without("active")).is_on is None + + +def test_the_activity_sensor_is_a_running_diagnostic() -> None: + """A panel throttling the user's supply is a state worth seeing, but it + belongs beside the other panel-state sensors rather than the power + readings.""" + assert PCS_ACTIVE_SENSOR.device_class is BinarySensorDeviceClass.RUNNING + assert PCS_ACTIVE_SENSOR.entity_category is EntityCategory.DIAGNOSTIC + assert PCS_ACTIVE_SENSOR.entity_registry_enabled_default is True + + +# --------------------------------------------------------------------------- +# Absence: no node, no PCS, a flat panel +# --------------------------------------------------------------------------- + + +def test_the_capture_creates_all_three_entities() -> None: + created = _sensors(schema_one_snapshot()) + + assert set(created) == {IMPORT_LIMIT_KEY, BINDING_CONSTRAINT_KEY} + assert has_pcs(schema_one_snapshot()) is True + + +def test_a_panel_with_no_pcs_node_gets_no_entities() -> None: + """The presence gate, from the tree end.""" + snapshot = _without_node() + + assert has_pcs(snapshot) is False + assert _sensors(snapshot) == {} + + +def test_a_flat_panel_gets_no_entities() -> None: + """The same absence by the other route: no flat panel declares the + capability, so the factory's default snapshot carries no PCS at all.""" + snapshot = SpanPanelSnapshotFactory.create() + + assert has_pcs(snapshot) is False + assert _sensors(snapshot) == {} + + +def test_a_switched_off_pcs_still_gets_its_entities() -> None: + """The reason the gate is the node and not a value, stated as a test. + + The capture publishes `0.0` on every limit and `false` on every flag. A + creation rule that read those as absence would delete the entities of every + panel whose PCS is merely unconfigured — which is the state most panels are + in, and the state a user most wants reported. + """ + snapshot = schema_one_snapshot() + + assert snapshot.pcs is not None + assert snapshot.pcs.import_limit_a == 0.0 + assert set(_sensors(snapshot)) == {IMPORT_LIMIT_KEY, BINDING_CONSTRAINT_KEY} + assert _binary(snapshot).is_on is False + + +def test_a_declared_node_that_publishes_nothing_still_gets_its_entities() -> None: + """Mid-discovery, and the same rule: the panel has announced the capability + and not yet retained its topics, so the entities exist and read unknown.""" + tree = schema_one_tree() + for property_id in _CONFIGURED: + del tree[SCHEMA_ONE_PANEL][f"{NODE}/{property_id}"] + snapshot = schema_one_snapshot(tree) + + assert set(_sensors(snapshot)) == {IMPORT_LIMIT_KEY, BINDING_CONSTRAINT_KEY} + assert _state(snapshot, IMPORT_LIMIT_KEY) is None + assert _attributes(snapshot, IMPORT_LIMIT_KEY) == {} + + +def test_a_reading_that_stops_arriving_goes_unknown_rather_than_stale() -> None: + """The last value persisting would be worse than unknown: a panel reporting + a 40 A limit forever is indistinguishable from one still enforcing it.""" + sensor = _sensors(_configured())[IMPORT_LIMIT_KEY] + + sensor.coordinator.data = _without("import-limit") + sensor._update_native_value() + + assert sensor.native_value is None + + +def test_the_pcs_appearing_is_a_capability_change() -> None: + """Which is how a panel that gains the node mid-life gets the entities: the + coordinator reloads on a new capability.""" + assert "pcs" not in detect_capabilities(SpanPanelSnapshotFactory.create()) + assert "pcs" in detect_capabilities(schema_one_snapshot()) + assert "pcs" not in detect_capabilities(_without_node()) + + +# --------------------------------------------------------------------------- +# Circuit participation +# --------------------------------------------------------------------------- + + +def test_the_capture_publishes_participation_on_two_disagreeing_circuits() -> None: + """Guard the premise for the circuit tests: an attribute wired to a constant + would satisfy either circuit alone.""" + assert _published("managed", MANAGED_CIRCUIT) == "true" + assert _published("managed", UNMANAGED_CIRCUIT) == "false" + assert _published("priority", MANAGED_CIRCUIT) != _published("priority", UNMANAGED_CIRCUIT) + + +def test_a_circuit_sensor_carries_its_pcs_participation() -> None: + """Read against the capture rather than against literals, on both circuits.""" + snapshot = schema_one_snapshot() + + managed = _circuit_attributes(snapshot, MANAGED_CIRCUIT) + unmanaged = _circuit_attributes(snapshot, UNMANAGED_CIRCUIT) + + assert managed["pcs_managed"] is True + assert unmanaged["pcs_managed"] is False + assert managed["pcs_priority"] == int(_published("priority", MANAGED_CIRCUIT)) + assert unmanaged["pcs_priority"] == int(_published("priority", UNMANAGED_CIRCUIT)) + + +def test_republishing_participation_moves_the_circuit_attributes() -> None: + """The attribute-mutation proof. The republished priority is outside the + range any circuit uses in the capture, so an attribute wired to another + circuit — or to the load-shed priority beside it — cannot report it.""" + snapshot = _republishing(device_id=MANAGED_CIRCUIT, managed="false", priority="42") + + attributes = _circuit_attributes(snapshot, MANAGED_CIRCUIT) + + assert attributes["pcs_managed"] is False + assert attributes["pcs_priority"] == 42 + # The other circuit is untouched, so a shared read would show here. + assert _circuit_attributes(snapshot, UNMANAGED_CIRCUIT)["pcs_priority"] == int( + _published("priority", UNMANAGED_CIRCUIT) + ) + + +def test_pcs_priority_is_not_the_shed_priority_beside_it() -> None: + """Two policies on one relay, and two attributes on one sensor. The catalog + keeps them apart because they answer different questions, and they do not + even share a value space.""" + attributes = _circuit_attributes(schema_one_snapshot(), MANAGED_CIRCUIT) + + assert isinstance(attributes["pcs_priority"], int) + assert isinstance(attributes["shed_priority"], str) + + +@pytest.mark.parametrize("property_id", ["managed", "priority"]) +def test_a_circuit_that_does_not_publish_participation_omits_the_attribute( + property_id: str, +) -> None: + """Both properties are `MAY`. `False` and `0` would each be a claim the + panel never made, and an attribute holding `None` reads as a failed + reading.""" + snapshot = _without(property_id, device_id=MANAGED_CIRCUIT) + + assert f"pcs_{property_id}" not in _circuit_attributes(snapshot, MANAGED_CIRCUIT) + + +def test_a_circuit_outside_any_pcs_shows_neither_attribute() -> None: + snapshot = _without_node(MANAGED_CIRCUIT) + attributes = _circuit_attributes(snapshot, MANAGED_CIRCUIT) + + assert "pcs_managed" not in attributes + assert "pcs_priority" not in attributes + # The circuit's own readings are unaffected. + assert "shed_priority" in attributes + + +def test_a_flat_circuit_shows_neither_attribute() -> None: + """No flat circuit declares a `pcs` node, so the attributes simply do not + appear rather than appearing empty.""" + circuit = SpanCircuitSnapshotFactory.create(circuit_id="1", name="Kitchen") + snapshot = SpanPanelSnapshotFactory.create(circuits={circuit.circuit_id: circuit}) + + assert circuit.pcs_managed is None + attributes = _circuit_attributes(snapshot, circuit.circuit_id) + + assert "pcs_managed" not in attributes + assert "pcs_priority" not in attributes + + +# --------------------------------------------------------------------------- +# Shape of the entities, and the conformance annotations +# --------------------------------------------------------------------------- + + +def test_the_import_limit_is_an_ampere_measurement_enabled_by_default() -> None: + """A PCS throttling import is a fact about the user's electricity supply, + not about the integration's health, so it is not filed as a diagnostic.""" + description = next(d for d in PCS_SENSORS if d.key == IMPORT_LIMIT_KEY) + + assert description.device_class is SensorDeviceClass.CURRENT + assert description.state_class is SensorStateClass.MEASUREMENT + assert description.native_unit_of_measurement == UnitOfElectricCurrent.AMPERE + assert description.entity_registry_enabled_default is True + assert description.entity_category is not EntityCategory.DIAGNOSTIC + + +def test_the_binding_constraint_is_an_enum_diagnostic_enabled_by_default() -> None: + """It explains a number already on screen: short, rarely changing, and + useless filed where nobody finds it.""" + description = next(d for d in PCS_SENSORS if d.key == BINDING_CONSTRAINT_KEY) + + assert description.device_class is SensorDeviceClass.ENUM + assert description.entity_category is EntityCategory.DIAGNOSTIC + assert description.entity_registry_enabled_default is True + + +def test_the_declared_unit_matches_what_the_panel_declares() -> None: + """Home Assistant's unit against the tree's, for the one PCS path schema_1 + carries metadata for. A disagreement here is what the unit-mismatch Repair + reports at runtime.""" + from .adapter_fixtures import schema_one_metadata + + description = next(d for d in PCS_SENSORS if d.key == IMPORT_LIMIT_KEY) + + assert schema_one_metadata()["pcs.import_limit_a"].unit == ( + description.native_unit_of_measurement + ) + + +def test_the_entities_live_on_the_main_panel_device() -> None: + """The PCS is the enclosure's own capability, not a sub-device.""" + created = list(_sensors(schema_one_snapshot()).values()) + panel_device = _binary(schema_one_snapshot()).device_info + + assert panel_device is not None + for sensor in created: + assert sensor.device_info == panel_device + + +def test_every_pcs_entity_gets_a_distinct_unique_id() -> None: + """They live on one device and differ only by description key, so a key + reused from another panel sensor would silently collide.""" + created = _sensors(schema_one_snapshot()) + unique_ids = {sensor.unique_id for sensor in created.values()} | { + _binary(schema_one_snapshot()).unique_id + } + + assert len(unique_ids) == len(created) + 1 + + +def test_the_three_result_paths_are_exempt_as_schema_1_only() -> None: + """Pinned here as well as in the conformance suite because the reason is + specific to this capability: no flat panel declares `pcs` at all, so the + producible gate cannot be satisfied and the descriptions must stay derived. + schema_1 maps all three, which is what makes the annotation SCHEMA_1_ONLY + rather than NEITHER.""" + for path in ("pcs.import_limit_a", "pcs.binding_constraint", "pcs.active"): + assert RESIDUAL_EXEMPT_PATHS[path] is Producibility.SCHEMA_1_ONLY + + +def test_every_attribute_read_is_enumerated_as_neither() -> None: + """The fifteen fields nothing renders as a reading are still reads, and an + unenumerated read is invisible to the Repair machinery — which is the exact + hole `panel.wifi_ssid` sat in. Derived from the families rather than listed + again, so a fifth constraint class cannot be added without one.""" + expected = {"pcs.enabled", "circuit.pcs_managed", "circuit.pcs_priority"} + for family in PCS_CONSTRAINT_FAMILIES: + field = f"pcs.{family.attribute}" + expected |= {f"{field}_a", f"{field}_enablement", f"{field}_active"} + + for path in expected: + assert RESIDUAL_EXEMPT_PATHS[path] is Producibility.NEITHER + + +@pytest.mark.parametrize("description", [*PCS_SENSORS, PCS_ACTIVE_SENSOR], ids=lambda d: str(d.key)) +def test_each_description_names_its_field_as_well_as_its_reason(description: Any) -> None: + """`field_path` says what the entity's value is and `derived` says why that + path is outside the both-adapters gate. Leaving the first unset excuses the + entity from its Repair mention and from going unavailable when the panel + stops resolving the property.""" + assert description.derived is DerivedReason.SCHEMA_CONDITIONAL_FIELD + assert description.field_path in RESIDUAL_EXEMPT_PATHS From d42a18e8c50aac03dfd50a9106dd53f2a96a1bee Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:08:38 -0700 Subject: [PATCH 051/345] test(pcs): pin that only one PCS sensor carries the arbitration The attribute set is carried on the description rather than decided from `description.key` inside the entity, and nothing asserted the half of that claim that matters: `pcs_binding_constraint` must publish no attributes at all while `pcs_import_limit` publishes thirteen. Without it the default reader was the one line of this feature no test reached. --- tests/test_pcs.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_pcs.py b/tests/test_pcs.py index 06608033..53755d61 100644 --- a/tests/test_pcs.py +++ b/tests/test_pcs.py @@ -504,6 +504,13 @@ def test_the_declared_options_are_the_enum_the_panel_declares() -> None: assert set(PCS_BINDING_CONSTRAINT_OPTIONS) == {value.lower() for value in declared.split(",")} +def test_the_binding_constraint_publishes_no_attributes_of_its_own() -> None: + """The other half of putting the pairing on the description: one PCS sensor + carries thirteen attributes and the other carries none, and which is which is + data rather than a comparison against `key` inside the entity.""" + assert _sensors(_configured())[BINDING_CONSTRAINT_KEY].extra_state_attributes is None + + def test_none_is_a_binding_constraint_and_not_an_absence() -> None: """The captured value. `NONE` means nothing is constraining import, which is a state the catalog defines; reporting it as unknown would lose that.""" From 78989f13367b251b7ede9e23a2dd6006349e4074 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:36:45 -0700 Subject: [PATCH 052/345] feat(binary_sensor): surface the panel's link to each DER it feeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bess_connected` has shown the enclosure's view of the link to the battery since v1.0 landed. The identical fact for a solar inverter or a charger reached nothing, and the reason is where the panel publishes it: `connection` 0.1 states the enclosure/DER relationship on the **circuit**, so the battery's record arrives on the upstream lugs — already read — while the inverter's and each charger's arrive on the circuits that feed them, and that half of the capability had no reader. One of three device classes had a link sensor because of where its record happened to sit. `pv_panel_link` and `evse_panel_link` close it. Connectivity, diagnostic, on the device the link describes: the charger's on its own sub-device, the inverter's on the panel beside `pv_vendor` and `pv_product` until the PV becomes a sub-device of its own. **The names are the point, not a detail.** `evse_ev_connected` sits two definitions above the new charger sensor and reads `evse.status`: a vehicle is plugged in. `evse_panel_link` reads the feeding circuit's connection record: the enclosure can reach the charger. Those diverge exactly when it matters — a charger mid-session behind a lost link reports a plugged-in vehicle and a dead link at once, because the last session state the panel heard is still the last session state the panel heard — and a test produces that state rather than asserting the distinction in prose. `PLUG` against `CONNECTIVITY`, primary against diagnostic, and neither key is a prefix of the other. **Creation is gated on the record existing, never on the circuit.** The spec is unambiguous: `connection.md` makes an unpublished property the "unknown" signal, `distribution-enclosure.md` makes a mixed-load circuit publishing no `feeds-*` normal, and the enum is `OK,LOST,DEGRADED` with no UNKNOWN member — so absence is the only way a panel can say it does not know, and reporting it as a broken link would invent the one reading a user would act on. Two of the capture's five circuits publish nothing, and they create nothing. The gate is per DER, not per panel: two chargers can be fed by two circuits of which only one publishes a record, and only one sensor may exist then. `has_der_link_health` joins `detect_capabilities` so a panel that starts publishing reaches the reload. Both descriptions are `SCHEMA_CONDITIONAL_FIELD` *and* name their `field_path`: flat firmware publishes `connected` on the BESS and on no other device class, so the both-adapters gate cannot be satisfied, while the entities still need their Repair mention and their unavailability. `pv.connected` and `evse.connected` join `RESIDUAL_EXEMPT_PATHS` as `SCHEMA_1_ONLY`, which schema_1's metadata rows earn them — `battery.connected` sits beside them as `SCHEMA_0_ONLY`, which is the whole shape of the gap in one place. One line leaves the unread baseline, 19 to 18: `circuit/connection/feeds-device-status`. `feeds-device-type` stays, and its recorded reason is still true — the library resolves a DER's class by dereferencing `feeds-device-id` against the tree's declared `$type`, so the echo is read by nothing and would be redundant if it were. **The capture publishes `OK` on all three records, so no test rests on it.** That value is satisfied by an implementation returning a constant, one reading the wrong circuit, and one handing every DER the first record it finds. Every expectation is read out of the capture, the enum's members come from the circuit's own `$format`, and each reading is proved by republishing values that differ per DER and then swapping them — two chargers are in the capture precisely so cross-wiring is falsifiable. Conflating the charger's link with the EV plug fails nine tests; removing the record gate fails four; making it a value gate rather than a record gate fails five; landing both chargers' records on one charger fails four; dropping one exemption fails three; restoring the baseline line fails one. --- CHANGELOG.md | 11 + README.md | 14 +- custom_components/span_panel/binary_sensor.py | 68 ++- custom_components/span_panel/field_paths.py | 14 + custom_components/span_panel/helpers.py | 33 ++ custom_components/span_panel/icons.json | 6 + custom_components/span_panel/strings.json | 6 + .../span_panel/translations/en.json | 6 + .../span_panel/translations/es.json | 6 + .../span_panel/translations/fr.json | 6 + .../span_panel/translations/ja.json | 6 + .../span_panel/translations/pt.json | 6 + .../unread_declarations_baseline.json | 1 - tests/test_der_link_health.py | 519 ++++++++++++++++++ tests/test_field_path_conformance.py | 8 +- tests/test_field_path_introspection.py | 4 + 16 files changed, 707 insertions(+), 7 deletions(-) create mode 100644 tests/test_der_link_health.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 61076d69..83f58d74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ All notable changes to this project will be documented in this file. ### Added +- **Whether your panel can reach your solar inverter and each of your chargers, on panels running the v1.0 data model.** **PV Panel Link** and **EVSE Panel + Link** are the same fact **BESS Connected** has always shown for the battery: the panel's own report of the link to a device it feeds. The battery's version + worked because the panel publishes it on the main lugs; the inverter's and each charger's are published by the circuit that feeds them, and nothing read that + half — so one of your three device classes had a link sensor and the others did not. +- **EVSE Panel Link is not EV Connected.** EV Connected is the charger reporting that a vehicle is plugged in. EVSE Panel Link is the panel reporting that it + can reach the charger at all. A charger part-way through a session behind a link the panel has lost shows a plugged-in vehicle and a dead link at the same + time, which is exactly the case where you want to know which of the two you are looking at. The new sensors are diagnostics; EV Connected is unchanged. +- Each sensor is created only where a circuit publishes the link record for that device, and per charger rather than per panel — two chargers whose circuits + report differently get two sensors that say different things. A circuit that feeds ordinary loads publishes no such record, which is normal and is not + reported as a fault, and a panel that starts publishing one picks the sensors up on the reload the integration already performs. + - **Your battery's own meter and its own link health, on panels running the v1.0 data model.** **Meter Power** is what the BESS itself reports it is charging or discharging at, as distinct from the panel's **Battery Power**, which is the enclosure's arbitrated figure. Both have been on the wire since firmware r202633 and nothing read either. Meter Power is enabled by default; **Communication State** — the BESS's own `OK` / `DEGRADED` / `LOST` / `UNKNOWN` report on its link diff --git a/README.md b/README.md index 93951535..6bf2aa28 100644 --- a/README.md +++ b/README.md @@ -261,10 +261,15 @@ feature. A display suffix differentiates multiple chargers on the same panel: #### EVSE Binary Sensors (per charger) -| Sensor | Device Class | Notes | -| ------------ | ---------------- | ------------------------------------------------------------------ | -| Charging | Battery Charging | ON when status is CHARGING | -| EV Connected | Plug | ON when status is PREPARING, CHARGING, SUSPENDED\_\*, or FINISHING | +| Sensor | Device Class | Notes | +| --------------- | ---------------- | ------------------------------------------------------------------------------------------------------- | +| Charging | Battery Charging | ON when status is CHARGING | +| EV Connected | Plug | ON when status is PREPARING, CHARGING, SUSPENDED\_\*, or FINISHING — a vehicle is plugged in | +| EVSE Panel Link | Connectivity | (v1.0) Whether the panel can reach the charger. A different fact from EV Connected, and it can disagree | + +**EVSE Panel Link is not EV Connected.** EV Connected is what the charger says about the cable in front of it; EVSE Panel Link is what the panel says about +whether it can reach the charger at all. A charger part-way through a session behind a lost link reports a plugged-in vehicle and a dead link at the same time. +EVSE Panel Link is a diagnostic and appears only where the circuit feeding that charger publishes the link record. #### EVSE Device Info Attributes @@ -351,6 +356,7 @@ Applies to Main Meter and Feed Through energy sensors. | Panel Status | Connectivity | Overall panel online/offline | | Grid Islandable | — | (v2) Whether the panel can island from the grid. Only when reported | | PCS Active | Running | (v1.0) Whether the Power Control System is limiting import right now. Only when the panel runs one | +| PV Panel Link | Connectivity | (v1.0) Whether the panel can reach the solar inverter. Only when the feeding circuit reports it | **Removed from binary sensors:** diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py index 65b72147..0b2e6bda 100644 --- a/custom_components/span_panel/binary_sensor.py +++ b/custom_components/span_panel/binary_sensor.py @@ -160,6 +160,33 @@ def _grid_islandable(snapshot: SpanPanelSnapshot) -> bool | None: value_fn=lambda s: s.battery.connected, ) +PV_PANEL_LINK_SENSOR = SpanPanelBinarySensorEntityDescription( + key="pv_panel_link", + field_path="pv.connected", + derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD, + translation_key="pv_panel_link", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda s: s.pv.connected, +) +"""Can the enclosure talk to the solar inverter? + +`bess_connected`'s counterpart for the other DER the panel feeds through a +circuit. The BESS got one because the upstream lugs' `connection/fed-by-*` +record was already read; the PV's and the charger's live on the *circuit* that +feeds them, as `connection` 0.1 specifies, and nothing read that half — so the +one device class whose link the panel happened to report through the lugs was +the only one a user could see. + +On the panel device, beside `pv_vendor` and `pv_product`, because the PV is not +yet a sub-device of its own. It moves with them when it becomes one. + +`SCHEMA_CONDITIONAL_FIELD` *and* `field_path`: flat firmware publishes +`connected` on the BESS and on nothing else, so the both-adapters gate cannot be +satisfied, while the entity still needs its Repair mention and its +unavailability when the panel stops resolving the property. +""" + PCS_ACTIVE_SENSOR = SpanPanelBinarySensorEntityDescription( key="pcs_active", @@ -371,6 +398,32 @@ class SpanEvseBinarySensorEntityDescription( ), ) +EVSE_PANEL_LINK_SENSOR = SpanEvseBinarySensorEntityDescription( + key="evse_panel_link", + field_path="evse.connected", + derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD, + translation_key="evse_panel_link", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda e: e.connected, +) +"""Can the enclosure talk to this charger? + +**Not `evse_ev_connected`, which sits two definitions above it.** That one reads +the charger's own `status/status` and answers "is a vehicle plugged in" — a +`PLUG` device class, enabled by default, the fact a user builds a charging +automation on. This reads the *feeding circuit's* `connection/feeds-device-status` +and answers "can the panel reach the charger at all" — a `CONNECTIVITY` device +class, diagnostic. The two disagree exactly when it matters: a charger +mid-session over a lost link reports a plugged-in vehicle and a dead link at the +same time, because the last session state the panel heard is still the last +session state the panel heard. + +Deliberately outside `EVSE_BINARY_SENSORS`, which is the unconditional pair. +This one is created per charger and only where the record exists, following +`bess_connected` and `pcs_active` rather than its two neighbours. +""" + # Fallback EVSE snapshot used when the EVSE disappears mid-session _EMPTY_EVSE = SpanEvseSnapshot(node_id="", feed_circuit_id="") @@ -481,12 +534,25 @@ async def async_setup_entry( if has_pcs(snapshot): entities.append(SpanPanelBinarySensor(coordinator, PCS_ACTIVE_SENSOR)) + # The enclosure's view of the link to the solar inverter, where a circuit + # publishes one. Gated on the record existing and never on what kind of + # circuit publishes it — `distribution-enclosure.md` makes a mixed-load + # circuit publishing no `feeds-*` the normal case, so absence is the panel + # saying it does not know rather than a fault, and the enum it does publish + # has no UNKNOWN member for it to say that with. See `PV_PANEL_LINK_SENSOR`. + if snapshot.pv.connected is not None: + entities.append(SpanPanelBinarySensor(coordinator, PV_PANEL_LINK_SENSOR)) + # Add EVSE binary sensors for each commissioned charger if snapshot.evse: - for evse_id in snapshot.evse: + for evse_id, evse in snapshot.evse.items(): entities.extend( SpanEvseBinarySensor(coordinator, evse_desc, evse_id) for evse_desc in EVSE_BINARY_SENSORS ) + # Per charger, not per panel: two chargers can be fed by two + # circuits of which only one publishes the record. + if evse.connected is not None: + entities.append(SpanEvseBinarySensor(coordinator, EVSE_PANEL_LINK_SENSOR, evse_id)) async_add_entities(entities) diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py index 211298bf..f20fa5fd 100644 --- a/custom_components/span_panel/field_paths.py +++ b/custom_components/span_panel/field_paths.py @@ -266,6 +266,20 @@ class FieldPathDeclarationMixin: "pcs.import_limit_a": Producibility.SCHEMA_1_ONLY, "pcs.binding_constraint": Producibility.SCHEMA_1_ONLY, "pcs.active": Producibility.SCHEMA_1_ONLY, + # The enclosure's view of the link to each circuit-fed DER, behind the + # `pv_panel_link` and `evse_panel_link` binary sensors. Both descriptions + # are `SCHEMA_CONDITIONAL_FIELD` for the usual reason: flat firmware + # publishes `connected` on the BESS and on no other device class, so the + # both-adapters gate cannot be satisfied. schema_1 maps both — from one + # property, the feeding circuit's `connection/feeds-device-status` — + # which is what makes these SCHEMA_1_ONLY rather than NEITHER. + # + # `battery.connected` is the same fact for the third DER and sits below + # as SCHEMA_0_ONLY, which is the whole shape of this gap: the link the + # enclosure reported through the lugs was read, and the one it reports + # through a circuit was not. + "pv.connected": Producibility.SCHEMA_1_ONLY, + "evse.connected": Producibility.SCHEMA_1_ONLY, "circuit.always_on": Producibility.SCHEMA_0_ONLY, "circuit.is_sheddable": Producibility.SCHEMA_0_ONLY, "panel.wifi_ssid": Producibility.SCHEMA_0_ONLY, diff --git a/custom_components/span_panel/helpers.py b/custom_components/span_panel/helpers.py index cdc51967..aeb57d53 100644 --- a/custom_components/span_panel/helpers.py +++ b/custom_components/span_panel/helpers.py @@ -356,6 +356,37 @@ def has_evse(snapshot: SpanPanelSnapshot) -> bool: return len(snapshot.evse) > 0 +def has_der_link_health(snapshot: SpanPanelSnapshot) -> bool: + """Detect whether the panel reports the link to any circuit-fed DER. + + Presence of the *record*, from presence of the field it fills. The library + models `connected` as `None` for a DER no circuit claims, and the enum a + circuit does publish is `OK,LOST,DEGRADED` with no UNKNOWN member — so an + absent property is the only way the panel can say it does not know, and + `None` is the only reading that can mean it. + + A value gate would be wrong here in a way it is not for the PCS: the + question is not what the link is doing but whether the panel says anything + about it, and `distribution-enclosure.md` makes silence the normal state for + a circuit that feeds an ordinary load rather than a DER. + + Coarse on purpose. This decides whether a *reload* is worth requesting, not + which entities exist — the per-DER gate in `binary_sensor.async_setup_entry` + does that, because two chargers can be fed by two circuits of which only one + publishes the record. + + Always false on flat firmware, which publishes this only for the BESS, and + reaches `battery.connected` rather than either field here. + + DUAL-SCHEMA: gated on what the snapshot carries rather than on a schema + version, so a panel that starts publishing the record reaches + `detect_capabilities`, the coordinator reloads, and the sensors appear. + """ + return snapshot.pv.connected is not None or any( + evse.connected is not None for evse in snapshot.evse.values() + ) + + def detect_capabilities(snapshot: SpanPanelSnapshot) -> frozenset[str]: """Derive the set of optional capabilities present in the snapshot. @@ -382,4 +413,6 @@ def detect_capabilities(snapshot: SpanPanelSnapshot) -> frozenset[str]: caps.add("bess_telemetry") if has_pcs(snapshot): caps.add("pcs") + if has_der_link_health(snapshot): + caps.add("der_link_health") return frozenset(caps) diff --git a/custom_components/span_panel/icons.json b/custom_components/span_panel/icons.json index a6a5f1c4..0b790506 100644 --- a/custom_components/span_panel/icons.json +++ b/custom_components/span_panel/icons.json @@ -35,6 +35,12 @@ }, "pcs_active": { "default": "mdi:speedometer" + }, + "pv_panel_link": { + "default": "mdi:solar-power-variant" + }, + "evse_panel_link": { + "default": "mdi:lan-connect" } }, "button": { diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index 311876e9..76667302 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -150,6 +150,12 @@ }, "pcs_active": { "name": "PCS Active" + }, + "pv_panel_link": { + "name": "PV Panel Link" + }, + "evse_panel_link": { + "name": "EVSE Panel Link" } }, "button": { diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index 311876e9..76667302 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -150,6 +150,12 @@ }, "pcs_active": { "name": "PCS Active" + }, + "pv_panel_link": { + "name": "PV Panel Link" + }, + "evse_panel_link": { + "name": "EVSE Panel Link" } }, "button": { diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index 4180ef0b..aef041fb 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -150,6 +150,12 @@ }, "pcs_active": { "name": "PCS Activo" + }, + "pv_panel_link": { + "name": "Enlace del Panel FV" + }, + "evse_panel_link": { + "name": "Enlace del Panel EVSE" } }, "button": { diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index 65e7c50b..80a4ab40 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -150,6 +150,12 @@ }, "pcs_active": { "name": "PCS Actif" + }, + "pv_panel_link": { + "name": "Liaison du panneau PV" + }, + "evse_panel_link": { + "name": "Liaison du panneau EVSE" } }, "button": { diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index babc29ba..d0dc23f9 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -150,6 +150,12 @@ }, "pcs_active": { "name": "PCS 動作中" + }, + "pv_panel_link": { + "name": "PVパネルリンク" + }, + "evse_panel_link": { + "name": "EVSEパネルリンク" } }, "button": { diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index aedf0e58..4b1d6112 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -150,6 +150,12 @@ }, "pcs_active": { "name": "PCS Ativo" + }, + "pv_panel_link": { + "name": "Ligação do Painel FV" + }, + "evse_panel_link": { + "name": "Ligação do Painel EVSE" } }, "button": { diff --git a/tests/fixtures/unread_declarations_baseline.json b/tests/fixtures/unread_declarations_baseline.json index 69668cda..ea1e6914 100644 --- a/tests/fixtures/unread_declarations_baseline.json +++ b/tests/fixtures/unread_declarations_baseline.json @@ -1,7 +1,6 @@ { "circuit/breaker/poles": "reaches circuit.is_240v and stops there; the voltage attribute is derived from the tab count instead, so no entity or attribute reads the field", "circuit/connection/count": "microinverter/pack count; no producer publishes it, so there is no fixture value an entity could be tested against", - "circuit/connection/feeds-device-status": "the enclosure's link-health view of a circuit-fed DER; only the lugs fed-by-* route is read today, so PV and EVSE get no connected sensor (Task 3)", "circuit/connection/feeds-device-type": "redundant: the library resolves a DER's class by dereferencing feeds-device-id against the tree's declared $type", "distribution-enclosure/info/hardware-version": "no panel.hardware_version snapshot field, and the panel DeviceInfo declares no hw_version (Task 5)", "distribution-enclosure/info/vendor-name": "DeviceInfo.manufacturer is hardcoded to \"Span\"; the declared value is never consulted, even as a fallback (Task 5)", diff --git a/tests/test_der_link_health.py b/tests/test_der_link_health.py new file mode 100644 index 00000000..8d6f49f8 --- /dev/null +++ b/tests/test_der_link_health.py @@ -0,0 +1,519 @@ +"""The enclosure's link to each circuit-fed DER, as two diagnostic binary sensors. + +`bess_connected` has shown the panel's view of the link to the battery since v1.0 +landed, because the upstream lugs' `connection/fed-by-device-status` was already +read. The identical fact for a PV or a charger is published by the **circuit that +feeds it** — `connection/feeds-device-status`, the other half of the same +capability — and reached nothing, so one DER class had a link sensor and the +other two did not. + +`pv_panel_link` and `evse_panel_link` close that. + +**The naming is load-bearing.** `evse_ev_connected` already exists on the same +device and reads the charger's own `status/status`: *a vehicle is plugged in*. +The new one reads the feeding circuit's connection record: *the enclosure can +reach the charger*. Those are different questions with different answers, and +`test_the_charger_link_is_not_the_ev_plug` produces the state where they +disagree rather than asserting the distinction in prose. + +**Every expectation is read out of the capture**, including the enum's members, +which come from the circuit's own `$description` `format` rather than from a list +written here. And the capture publishes `OK` on all three records, so no reading +is proved by the baseline alone: each is proved by republishing values that +differ per DER, and by swapping them, because two chargers fed by two circuits is +what makes cross-wiring falsifiable. + +**Absence is a reading too.** Two of the capture's five circuits publish no +connection record at all — the enclosure data model calls that normal for a +mixed-load circuit, and the enum has no UNKNOWN member, so an unpublished +property is the only way a panel can say it does not know. The entity is gated on +the record existing, never on the circuit's type, and never treats silence as a +fault. +""" + +from __future__ import annotations + +from collections.abc import Sequence +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest +from span_panel_api import SpanPanelSnapshot + +from custom_components.span_panel import SpanPanelRuntimeData +from custom_components.span_panel.binary_sensor import ( + EVSE_BINARY_SENSORS, + EVSE_PANEL_LINK_SENSOR, + PV_PANEL_LINK_SENSOR, + SpanEvseBinarySensor, + SpanPanelBinarySensor, + SpanPanelBinarySensorEntityDescription, + async_setup_entry, +) +from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.field_paths import ( + RESIDUAL_EXEMPT_PATHS, + DerivedReason, + Producibility, +) +from custom_components.span_panel.helpers import detect_capabilities, has_der_link_health +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory + +from .adapter_fixtures import schema_one_snapshot, schema_one_tree +from .factories import SpanPanelSnapshotFactory + +from pytest_homeassistant_custom_component.common import MockConfigEntry + +CONNECTION_NODE = "connection" +FEEDS_ID_TOPIC = f"{CONNECTION_NODE}/feeds-device-id" +FEEDS_STATUS_TOPIC = f"{CONNECTION_NODE}/feeds-device-status" + +# The DER the capture commissions: one inverter and two chargers, each fed by its +# own circuit. Two chargers is what makes the wiring falsifiable, so a capture +# that lost one has to fail rather than quietly halve the evidence. +PV = "pv" +EVSE = "evse" +EVSE_2 = "evse-2" + +STATUS_OK = "OK" + +PanelBinarySensor = SpanPanelBinarySensor[SpanPanelBinarySensorEntityDescription] +LinkEntity = PanelBinarySensor | SpanEvseBinarySensor +"""Everything `binary_sensor.async_setup_entry` can add, and nothing wider. + +Narrowed at the boundary rather than carried as `object`, so every lookup below +reads a real attribute instead of one the type checker had to be told to ignore. +""" + + +# --------------------------------------------------------------------------- +# Reading the capture +# --------------------------------------------------------------------------- + + +def _feeding_circuit(tree: dict[str, dict[str, str]], device_id: str) -> str: + """The circuit the capture says feeds `device_id`, or fail saying none does.""" + feeders = [ + circuit for circuit, topics in tree.items() if topics.get(FEEDS_ID_TOPIC) == device_id + ] + assert len(feeders) == 1, f"{len(feeders)} circuits feed {device_id} in the capture, expected 1" + return feeders[0] + + +def _status_options() -> list[str]: + """The enum as the feeding circuit's own `$description` declares it. + + Read off the wire because the legal values are the panel's claim, not this + module's — and because the absence of an `UNKNOWN` member is the premise + that makes `None` the only way to report an unknown link. + """ + tree = schema_one_tree() + description = json.loads(tree[_feeding_circuit(tree, PV)]["$description"]) + declared = description["nodes"][CONNECTION_NODE]["properties"]["feeds-device-status"] + assert declared["datatype"] == "enum" + return str(declared["format"]).split(",") + + +def _not_ok() -> list[str]: + return [option for option in _status_options() if option != STATUS_OK] + + +def _republishing(**statuses: str) -> SpanPanelSnapshot: + """A snapshot from the capture with each named DER's link status rewritten. + + Keyed by DER rather than by circuit so a test says what it means — "the + charger's link is down" — while the indirection through `_feeding_circuit` + keeps it reading the capture's own topology rather than a circuit id copied + into the test. + """ + tree = schema_one_tree() + for der, status in statuses.items(): + tree[_feeding_circuit(tree, der.replace("_", "-"))][FEEDS_STATUS_TOPIC] = status + return schema_one_snapshot(tree) + + +def _without_status(der: str) -> SpanPanelSnapshot: + """A snapshot whose feeding circuit stopped publishing the status half.""" + tree = schema_one_tree() + del tree[_feeding_circuit(tree, der)][FEEDS_STATUS_TOPIC] + return schema_one_snapshot(tree) + + +def _without_record(der: str) -> SpanPanelSnapshot: + """A snapshot whose feeding circuit publishes no connection record at all. + + What a circuit feeding an ordinary load looks like, applied to a circuit + that used to feed a DER. + """ + tree = schema_one_tree() + circuit = _feeding_circuit(tree, der) + del tree[circuit][FEEDS_ID_TOPIC] + del tree[circuit][FEEDS_STATUS_TOPIC] + return schema_one_snapshot(tree) + + +# --------------------------------------------------------------------------- +# Building the entities +# --------------------------------------------------------------------------- + + +def _coordinator(snapshot: SpanPanelSnapshot) -> MagicMock: + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.panel_offline = False + coordinator.last_update_success = True + coordinator.unresolved_paths = frozenset() + coordinator.config_entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={}, + title="SPAN Panel", + unique_id=snapshot.serial_number, + ) + coordinator.config_entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, panel_device_id="panel-device-id" + ) + coordinator.async_request_refresh = AsyncMock() + return coordinator + + +async def _created(hass: HomeAssistant, snapshot: SpanPanelSnapshot) -> list[LinkEntity]: + """Everything `binary_sensor.async_setup_entry` creates for one snapshot. + + Driven through the platform rather than by constructing descriptions, + because the gate under test is a creation gate: an entity that must not + exist cannot be observed by asking an entity for its state. + """ + coordinator = _coordinator(snapshot) + config_entry = coordinator.config_entry + async_add_entities = MagicMock() + + await async_setup_entry(hass, config_entry, async_add_entities) + + added: Sequence[LinkEntity] = async_add_entities.call_args.args[0] + for entity in added: + assert isinstance(entity, SpanPanelBinarySensor | SpanEvseBinarySensor) + return list(added) + + +def _keys(created: Sequence[LinkEntity]) -> list[str]: + return [entity.entity_description.key for entity in created] + + +def _state(entity: LinkEntity) -> bool | None: + entity.async_write_ha_state = MagicMock() + entity._handle_coordinator_update() + return entity.is_on + + +def _pv_link(created: Sequence[LinkEntity]) -> PanelBinarySensor: + matches = [ + entity + for entity in created + if isinstance(entity, SpanPanelBinarySensor) + and entity.entity_description.key == PV_PANEL_LINK_SENSOR.key + ] + assert len(matches) == 1, f"{len(matches)} PV link sensors created, expected 1" + return matches[0] + + +def _evse_links_by_feed( + created: Sequence[LinkEntity], snapshot: SpanPanelSnapshot +) -> dict[str, SpanEvseBinarySensor]: + """The charger link sensors, keyed by the circuit that feeds each charger. + + Keyed by feed rather than by the snapshot's EVSE key, which is a harmonised + serial: a test that named one would still pass if every record landed on the + same charger, which is the failure this module exists to rule out. + """ + links: dict[str, SpanEvseBinarySensor] = {} + for entity in created: + if ( + isinstance(entity, SpanEvseBinarySensor) + and entity.entity_description.key == EVSE_PANEL_LINK_SENSOR.key + ): + links[snapshot.evse[entity._evse_id].feed_circuit_id] = entity + return links + + +# --------------------------------------------------------------------------- +# The premise +# --------------------------------------------------------------------------- + + +def test_the_capture_publishes_a_link_record_for_three_ders_and_two_bare_circuits() -> None: + """Guard every expectation below, and the negative case with them. + + Three DER-feeding circuits publish the record; the remaining circuits + declare the node and publish neither half of it. That second group is the + absence case, and it is in the capture rather than manufactured here. + """ + tree = schema_one_tree() + circuits = { + device_id + for device_id, topics in tree.items() + if json.loads(topics["$description"])["type"].endswith(".circuit") + } + feeding = {_feeding_circuit(tree, der) for der in (PV, EVSE, EVSE_2)} + + assert feeding <= circuits + for circuit in feeding: + assert tree[circuit][FEEDS_STATUS_TOPIC] in _status_options() + + bare = circuits - feeding + assert len(bare) == 2, f"expected two circuits feeding no DER, found {len(bare)}" + for circuit in bare: + declared = json.loads(tree[circuit]["$description"])["nodes"] + assert CONNECTION_NODE in declared, ( + f"{circuit} does not declare the node, so its silence proves nothing about " + "a circuit that declares the record and publishes none of it" + ) + assert not [topic for topic in tree[circuit] if topic.startswith(f"{CONNECTION_NODE}/")] + + +def test_the_status_enum_has_no_unknown_member() -> None: + """Why absence has to mean unknown: the enum cannot say it.""" + options = _status_options() + + assert STATUS_OK in options + assert "UNKNOWN" not in options + assert _not_ok(), "the enum offers no bad status, so nothing below can observe a broken link" + + +# --------------------------------------------------------------------------- +# The readings +# --------------------------------------------------------------------------- + + +async def test_each_der_reports_the_link_its_own_circuit_publishes(hass: HomeAssistant) -> None: + """Baseline, with every expectation computed from the capture.""" + tree = schema_one_tree() + snapshot = schema_one_snapshot(tree) + created = await _created(hass, snapshot) + + assert _state(_pv_link(created)) is ( + tree[_feeding_circuit(tree, PV)][FEEDS_STATUS_TOPIC] == STATUS_OK + ) + + links = _evse_links_by_feed(created, snapshot) + assert len(links) == 2, "the capture commissions two chargers; both should carry a link sensor" + for der in (EVSE, EVSE_2): + circuit = _feeding_circuit(tree, der) + assert _state(links[circuit]) is (tree[circuit][FEEDS_STATUS_TOPIC] == STATUS_OK) + + +@pytest.mark.parametrize("status", _not_ok()) +async def test_a_bad_status_flips_the_sensor(hass: HomeAssistant, status: str) -> None: + """Both non-OK members, so a check written as `!= "LOST"` fails on DEGRADED.""" + snapshot = _republishing(pv=status, evse=status) + created = await _created(hass, snapshot) + + assert _state(_pv_link(created)) is False + links = _evse_links_by_feed(created, snapshot) + assert _state(links[_feeding_circuit(schema_one_tree(), EVSE)]) is False + + +async def test_two_chargers_do_not_share_one_link(hass: HomeAssistant) -> None: + """The cross-wiring case, and the reason the capture carries two chargers. + + Both read `OK` as captured, so the baseline test above is satisfied by an + implementation that hands every charger the first record it finds. Here the + two are republished differing and then swapped: getting one arrangement + right by luck is possible, both is not. + """ + down, degraded = _not_ok()[0], _not_ok()[-1] + tree = schema_one_tree() + first_circuit, second_circuit = _feeding_circuit(tree, EVSE), _feeding_circuit(tree, EVSE_2) + + for first, second in ((down, STATUS_OK), (STATUS_OK, down), (degraded, STATUS_OK)): + snapshot = _republishing(evse=first, **{"evse_2": second}) + links = _evse_links_by_feed(await _created(hass, snapshot), snapshot) + + assert _state(links[first_circuit]) is (first == STATUS_OK) + assert _state(links[second_circuit]) is (second == STATUS_OK) + + +async def test_the_inverters_link_is_not_a_chargers(hass: HomeAssistant) -> None: + """The third DER, held apart from the two chargers the same way.""" + snapshot = _republishing(pv=_not_ok()[0]) + created = await _created(hass, snapshot) + + assert _state(_pv_link(created)) is False + for link in _evse_links_by_feed(created, snapshot).values(): + assert _state(link) is True + + +# --------------------------------------------------------------------------- +# Absence is not a fault +# --------------------------------------------------------------------------- + + +async def test_a_der_whose_circuit_publishes_no_status_gets_no_entity( + hass: HomeAssistant, +) -> None: + """The gate is the record, and a retained topic can simply go away. + + Not "unavailable" and not `False`: the panel has said nothing, the enum has + no way to say it, and an entity reporting a broken link on that basis would + be inventing the one reading a user would act on. + """ + snapshot = _without_status(PV) + created = await _created(hass, snapshot) + + assert PV_PANEL_LINK_SENSOR.key not in _keys(created) + assert len(_evse_links_by_feed(created, snapshot)) == 2, ( + "removing the inverter's status removed a charger's sensor too" + ) + + +async def test_a_charger_whose_circuit_publishes_no_record_gets_no_entity( + hass: HomeAssistant, +) -> None: + """Per charger, not per panel. + + One of two chargers losing its record must remove one of two sensors. A gate + that asked "does any DER have a record" would keep both, and the surviving + one would report a link nothing publishes. + """ + snapshot = _without_record(EVSE) + created = await _created(hass, snapshot) + + links = _evse_links_by_feed(created, snapshot) + assert len(links) == 1 + assert _feeding_circuit(schema_one_tree(), EVSE_2) in links + assert PV_PANEL_LINK_SENSOR.key in _keys(created), "the inverter's sensor went with it" + + +async def test_the_circuits_that_feed_no_der_create_nothing(hass: HomeAssistant) -> None: + """The capture's two bare circuits, asserted as producing no entity. + + The `feeds-*` triple is absent from a mixed-load circuit by design, so the + count of link sensors must equal the count of DER the capture claims — three + — and not the count of circuits, five. + """ + snapshot = schema_one_snapshot() + created = await _created(hass, snapshot) + + link_keys = [key for key in _keys(created) if key.endswith("_panel_link")] + assert len(link_keys) == 3 + + +async def test_a_panel_with_no_connection_records_creates_neither_sensor( + hass: HomeAssistant, +) -> None: + """A flat panel, and any v1.0 panel whose circuits publish no record. + + The factory snapshot leaves both fields `None`, which is what every flat + panel produces: flat publishes `connected` on the BESS and on no other + device class. + """ + snapshot = SpanPanelSnapshotFactory.create() + created = await _created(hass, snapshot) + + assert [key for key in _keys(created) if key.endswith("_panel_link")] == [] + + +# --------------------------------------------------------------------------- +# The fact this must not be confused with +# --------------------------------------------------------------------------- + + +async def test_the_charger_link_is_not_the_ev_plug(hass: HomeAssistant) -> None: + """`evse_panel_link` and `evse_ev_connected` disagree, and both are right. + + The state that separates them: a charger mid-session behind a link the + enclosure has lost. `status/status` still says a vehicle is plugged in — + that is the last thing the panel heard — while the feeding circuit reports + the link as down. One entity for both facts would have to pick, and would be + wrong about one of them every time they diverge. + + The two sensors are also told apart at a glance: `CONNECTIVITY` against + `PLUG`, diagnostic against primary, and neither key is a prefix of the + other, so no automation can select one meaning to get the other. + """ + tree = schema_one_tree() + circuit = _feeding_circuit(tree, EVSE) + plugged_in = tree[EVSE]["status/status"] + snapshot = _republishing(evse=_not_ok()[0]) + created = await _created(hass, snapshot) + + link = _evse_links_by_feed(created, snapshot)[circuit] + plug = next( + entity + for entity in created + if isinstance(entity, SpanEvseBinarySensor) + and entity.entity_description.key == "evse_ev_connected" + and snapshot.evse[entity._evse_id].feed_circuit_id == circuit + ) + + assert snapshot.evse[link._evse_id].status == plugged_in + assert _state(link) is False + assert _state(plug) is True + + ev_connected = next(desc for desc in EVSE_BINARY_SENSORS if desc.key == "evse_ev_connected") + assert EVSE_PANEL_LINK_SENSOR.device_class is BinarySensorDeviceClass.CONNECTIVITY + assert ev_connected.device_class is BinarySensorDeviceClass.PLUG + assert EVSE_PANEL_LINK_SENSOR.entity_category is EntityCategory.DIAGNOSTIC + assert ev_connected.entity_category is None + assert EVSE_PANEL_LINK_SENSOR.field_path != ev_connected.field_path + assert not EVSE_PANEL_LINK_SENSOR.key.startswith(ev_connected.key) + assert not ev_connected.key.startswith(EVSE_PANEL_LINK_SENSOR.key) + + +async def test_the_inverter_link_does_not_displace_the_batterys(hass: HomeAssistant) -> None: + """`bess_connected` reads the lugs; these read a circuit. Both survive. + + Breaking every circuit-side record must leave the battery's sensor reporting + what the upstream lugs say, or the new route has quietly taken over a field + that was already right. + """ + down = _not_ok()[0] + snapshot = _republishing(pv=down, evse=down, **{"evse_2": down}) + created = await _created(hass, snapshot) + + bess = next( + entity + for entity in created + if isinstance(entity, SpanPanelBinarySensor) + and entity.entity_description.key == "bess_connected" + ) + assert _state(bess) is True + + +# --------------------------------------------------------------------------- +# Declarations and gating +# --------------------------------------------------------------------------- + + +def test_both_descriptions_name_their_field_and_say_why_it_is_exempt() -> None: + """`SCHEMA_CONDITIONAL_FIELD` *and* `field_path`, per the established rule. + + Flat firmware publishes `connected` on the BESS alone, so neither path can + satisfy the both-adapters gate — while both entities still need their Repair + mention and their unavailability when the panel stops resolving the property. + """ + for description in (PV_PANEL_LINK_SENSOR, EVSE_PANEL_LINK_SENSOR): + assert description.derived is DerivedReason.SCHEMA_CONDITIONAL_FIELD + assert description.field_path is not None + assert RESIDUAL_EXEMPT_PATHS[description.field_path] is Producibility.SCHEMA_1_ONLY + + assert PV_PANEL_LINK_SENSOR.field_path == "pv.connected" + assert EVSE_PANEL_LINK_SENSOR.field_path == "evse.connected" + + +def test_the_capability_gate_follows_the_record_and_reaches_the_reload() -> None: + """A panel that starts publishing the record must be able to gain the entities. + + Entities are created at setup, so a capability that appears later reaches a + user only through `detect_capabilities` and the reload it triggers. + """ + assert has_der_link_health(schema_one_snapshot()) is True + assert has_der_link_health(SpanPanelSnapshotFactory.create()) is False + assert has_der_link_health(_without_record(PV)) is True, ( + "the chargers still publish records; the gate must not be all-or-nothing" + ) + + assert "der_link_health" in detect_capabilities(schema_one_snapshot()) + assert "der_link_health" not in detect_capabilities(SpanPanelSnapshotFactory.create()) diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py index 754d9d61..35c6810b 100644 --- a/tests/test_field_path_conformance.py +++ b/tests/test_field_path_conformance.py @@ -289,7 +289,13 @@ def test_no_exempt_path_is_producible_by_both() -> None: # and `pcs.active`, behind the two PCS sensors and the `pcs_active` binary # sensor. schema_1 maps all three; no flat panel declares the capability at # all, so none can ever satisfy the both-adapters gate. - Producibility.SCHEMA_1_ONLY: 8, + # +2 for `pv.connected` and `evse.connected`, the enclosure's view of the + # link to each circuit-fed DER, behind `pv_panel_link` and + # `evse_panel_link`. schema_1 maps both from one property — the feeding + # circuit's `connection/feeds-device-status` — while flat publishes + # `connected` on the BESS and on no other device class, so neither can + # satisfy the both-adapters gate. + Producibility.SCHEMA_1_ONLY: 10, } """The exemption inventory, by reason. See `test_exempt_inventory_is_complete`.""" diff --git a/tests/test_field_path_introspection.py b/tests/test_field_path_introspection.py index b1182761..53b56160 100644 --- a/tests/test_field_path_introspection.py +++ b/tests/test_field_path_introspection.py @@ -122,8 +122,10 @@ def _declaring_descriptions() -> Iterator[_DeclaringDescription]: BESS_CONNECTED_SENSOR, BINARY_SENSORS, EVSE_BINARY_SENSORS, + EVSE_PANEL_LINK_SENSOR, GRID_ISLANDABLE_SENSOR, PCS_ACTIVE_SENSOR, + PV_PANEL_LINK_SENSOR, ) for description in ( @@ -133,6 +135,8 @@ def _declaring_descriptions() -> Iterator[_DeclaringDescription]: GRID_ISLANDABLE_SENSOR, BESS_CONNECTED_SENSOR, PCS_ACTIVE_SENSOR, + PV_PANEL_LINK_SENSOR, + EVSE_PANEL_LINK_SENSOR, ): if not isinstance(description, _DeclaringDescription): raise TypeError( From e1552a9fc91ac5c8ddb5519085f929fbb3fa486d Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:41:57 -0700 Subject: [PATCH 053/345] fix(coordinator): derive capabilities once, so an upgrade actually reloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_detect_capabilities` was a second, hand-rolled copy of the derivation the platforms gate on. It knew four capabilities — bess, pv, power_flows, evse — and `helpers.detect_capabilities` knows nine. `mid` had been missing since it was added; this catch-up took the count to five, since `shed_forecast`, `bess_telemetry`, `pcs` and `der_link_health` all joined the helper and none joined the copy. The copy is what `_check_capability_change` reads to decide whether to request a reload. So a panel that gained any of the five on a firmware upgrade published the properties, matched a creation gate that was never re-run, and asked for no reload — the entities appeared only when the user next restarted Home Assistant. Two of the commits in this branch claim the opposite in their own messages, because the helper is where the capability was registered and the copy is not visible from there. Delegating removes the class of bug rather than the instance. `has_evse` gains the circuit-typed-evse signal the copy carried and the helper lacked, so converging loses nothing: creation still iterates `snapshot.evse`, so a commissioned-but-not-yet-published charger adds no entities and only brings the reload forward to the moment the panel first admits it exists. The test asserts the two sets are equal on a capture that exercises the five missing capabilities, rather than asserting the two names are the same object — the latter would pass again the moment someone re-inlined the logic. --- custom_components/span_panel/coordinator.py | 28 +++++++++------------ custom_components/span_panel/helpers.py | 13 ++++++++-- tests/test_coordinator.py | 28 +++++++++++++++++++++ 3 files changed, 51 insertions(+), 18 deletions(-) diff --git a/custom_components/span_panel/coordinator.py b/custom_components/span_panel/coordinator.py index 8542b49a..cc7824dc 100644 --- a/custom_components/span_panel/coordinator.py +++ b/custom_components/span_panel/coordinator.py @@ -27,6 +27,7 @@ from span_panel_api.exceptions import SpanPanelAuthError, SpanPanelStaleDataError from .const import DOMAIN +from .helpers import detect_capabilities from .id_builder import build_circuit_unique_id from .schema_repairs import async_sync_schema_issues from .schema_validation import SchemaFindings, evaluate_field_metadata @@ -483,22 +484,17 @@ def schema_findings(self) -> SchemaFindings | None: @staticmethod def _detect_capabilities(snapshot: SpanPanelSnapshot) -> frozenset[str]: - """Derive optional hardware capabilities present in the snapshot.""" - caps: set[str] = set() - if snapshot.battery.soe_percentage is not None: - caps.add("bess") - if snapshot.power_flow_pv is not None or any( - c.device_type == "pv" for c in snapshot.circuits.values() - ): - caps.add("pv") - if snapshot.power_flow_site is not None: - caps.add("power_flows") - if ( - any(c.device_type == "evse" for c in snapshot.circuits.values()) - or len(snapshot.evse) > 0 - ): - caps.add("evse") - return frozenset(caps) + """Derive optional hardware capabilities present in the snapshot. + + Delegates to `helpers.detect_capabilities` rather than deriving its + own set. This was a second, hand-rolled copy that never learned about + `mid`, `shed_forecast`, `bess_telemetry`, `pcs` or `der_link_health` -- + so a panel that gained any of them on a firmware upgrade published the + properties, grew no entities, and requested no reload. The platforms + gate creation on the helper; the reload trigger has to read the same + set or the two silently disagree about what the panel can do. + """ + return detect_capabilities(snapshot) def _check_capability_change(self, snapshot: SpanPanelSnapshot) -> None: """Check if hardware capabilities changed and request reload if expanded.""" diff --git a/custom_components/span_panel/helpers.py b/custom_components/span_panel/helpers.py index aeb57d53..8609a208 100644 --- a/custom_components/span_panel/helpers.py +++ b/custom_components/span_panel/helpers.py @@ -352,8 +352,17 @@ def has_pcs(snapshot: SpanPanelSnapshot) -> bool: def has_evse(snapshot: SpanPanelSnapshot) -> bool: - """Detect whether an EVSE (EV charger) is commissioned.""" - return len(snapshot.evse) > 0 + """Detect whether an EVSE (EV charger) is commissioned. + + A circuit typed `evse` counts even before the charger appears in + `snapshot.evse`: the panel has commissioned it and the device usually + arrives on a later snapshot. Creation still iterates `snapshot.evse`, so + the wider signal adds no entities -- it makes the coordinator ask for a + reload at the moment the panel first admits the charger exists. + """ + return len(snapshot.evse) > 0 or any( + circuit.device_type == "evse" for circuit in snapshot.circuits.values() + ) def has_der_link_health(snapshot: SpanPanelSnapshot) -> bool: diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index eda1c34c..b449bb95 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -26,6 +26,7 @@ HomeAssistantError, ) +from .adapter_fixtures import schema_one_snapshot from .factories import ( SpanBatterySnapshotFactory, SpanEvseSnapshotFactory, @@ -692,3 +693,30 @@ async def test_validation_runs_at_most_once_successfully(hass: HomeAssistant) -> await coordinator._run_post_update_tasks(snapshot) assert coordinator.unresolved_paths == frozenset({"circuit.instant_power_w"}) + + +def test_the_reload_trigger_sees_every_capability_the_platforms_gate_on() -> None: + """The coordinator must not derive its own, narrower capability set. + + It used to. `_detect_capabilities` was a hand-rolled copy that knew only + bess/pv/power_flows/evse, so a panel that gained `mid`, `shed_forecast`, + `bess_telemetry`, `pcs` or `der_link_health` on a firmware upgrade + published the properties, created no entities, and asked for no reload -- + the user saw nothing until they restarted Home Assistant. + + Asserting equality on a snapshot that exercises the capabilities is what + catches a re-divergence; asserting the two names are the same object would + pass the moment someone re-inlined the logic. + """ + snapshot = schema_one_snapshot() + + from custom_components.span_panel.helpers import detect_capabilities + + assert SpanPanelCoordinator._detect_capabilities(snapshot) == detect_capabilities( + snapshot + ) + # The capabilities the old copy was blind to are present in this capture, + # so the assertion above is not comparing two empty sets. + assert {"shed_forecast", "pcs", "bess_telemetry", "der_link_health"} <= ( + detect_capabilities(snapshot) + ) From 3d18abb940d9e0fb3dac0a5683581d3ba76ab95c Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:12:51 -0700 Subject: [PATCH 054/345] feat(sensor): give the panel its own identity, and give the SSID back The metadata sweep: no new entity classes, four surfaces, and one of them is a regression rather than a feature. `wifi_ssid` is the regression. Flat published it and this integration has shown it as an attribute since; on v1.0 the library read nothing into the field, so an upgrading user's attribute quietly emptied. With the library reading it, both adapters produce `panel.wifi_ssid` and the producible gate refuses to let it stay exempt -- `test_no_exempt_path_is_producible_by_both` named it and demanded the promotion, which is the ratchet working as designed. It is declared on `SpanPanelStatus`, the entity that reads it, so a Repair can name what a dead field takes down. The panel's device card now shows the manufacturer, model and hardware revision the enclosure publishes. The fallbacks are the compatibility guarantee, not a courtesy: flat declares none of the three, so every existing installation lands on "Span" / "SPAN Panel" exactly as before, and `hw_version` is absent rather than blank where no revision is published. `shed/policy` reaches `dsm_state` as `shed_algorithm` plus the two SoC thresholds -- the numbers that make the panel's shed behaviour predictable. An algorithm this integration does not recognise keeps its name and carries the raw document beside it, because the policy schema is versioned in its own `$id` and a user can read a document a parser could not. `evse.part_number` becomes a diagnostic sensor shaped like `bess_part_number`, promoted out of the unread baseline by the schema_1 metadata row the library gained. Baseline: 18 -> 13. `pv/info/firmware-version` stays, reworded to name Task 7 -- the library reads it, but PV has no sub-device for a `sw_version` to land on until that device exists. MID firmware needed nothing: `mid_device_info` has carried `sw_version` and `hw_version` since the MID landed. Fixtures resynced from the library, whose reference tree caught up with the producer: eight identity properties it had never published are published now, including the SSID this all turns on. --- custom_components/span_panel/field_paths.py | 21 +- .../span_panel/sensor_definitions.py | 17 +- custom_components/span_panel/sensor_panel.py | 74 ++- custom_components/span_panel/strings.json | 3 + .../span_panel/translations/en.json | 3 + .../span_panel/translations/es.json | 3 + .../span_panel/translations/fr.json | 3 + .../span_panel/translations/ja.json | 3 + .../span_panel/translations/pt.json | 3 + custom_components/span_panel/util.py | 23 +- tests/fixtures/README.md | 11 +- tests/fixtures/schema_one_tree.json | 11 +- .../fixtures/schema_one_tree_batteryless.json | 8 +- tests/fixtures/schema_one_tree_no_pv.json | 10 +- .../unread_declarations_baseline.json | 7 +- tests/test_evse_entities.py | 2 +- tests/test_field_path_conformance.py | 20 +- tests/test_metadata_sweep.py | 472 ++++++++++++++++++ tests/test_promoted_sensors.py | 2 +- tests/test_sensor_platform.py | 3 +- 20 files changed, 675 insertions(+), 24 deletions(-) create mode 100644 tests/test_metadata_sweep.py diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py index f20fa5fd..6fa11d11 100644 --- a/custom_components/span_panel/field_paths.py +++ b/custom_components/span_panel/field_paths.py @@ -166,6 +166,25 @@ class FieldPathDeclarationMixin: "circuit.relative_position": Producibility.NEITHER, # The panel reports it outside the typed field surface. "panel.panel_size": Producibility.NEITHER, + # The enclosure's build identity, read by `snapshot_to_device_info` for + # the panel's device card. No row on either adapter, and deliberately: + # flat declares none of the three, and schema_1 rows exist to carry a + # unit and a datatype for a *reading*, which an identity string is not. + # Same shape as the `mid.*` device-card reads below. + "panel.vendor_name": Producibility.NEITHER, + "panel.model": Producibility.NEITHER, + "panel.hardware_version": Producibility.NEITHER, + # `shed/policy` parsed, read for the attributes on `dsm_state` + # (`SpanPanelPanelStatus.extra_state_attributes`). The raw document is + # kept beside the parsed members because the policy schema is versioned + # in its own `$id`: an algorithm this library does not recognise still + # reaches a user as the string the panel published. No row on either + # adapter -- flat has no `shed` node, and a JSON document has no unit + # surface for a schema_1 row to describe. + "panel.shed_policy": Producibility.NEITHER, + "panel.shed_policy_algorithm": Producibility.NEITHER, + "panel.shed_soc_threshold_shed_percent": Producibility.NEITHER, + "panel.shed_soc_threshold_release_percent": Producibility.NEITHER, # The panel identity key behind every unique_id and the panel DeviceInfo # (~30 read sites). "panel.serial_number": Producibility.NEITHER, @@ -282,7 +301,6 @@ class FieldPathDeclarationMixin: "evse.connected": Producibility.SCHEMA_1_ONLY, "circuit.always_on": Producibility.SCHEMA_0_ONLY, "circuit.is_sheddable": Producibility.SCHEMA_0_ONLY, - "panel.wifi_ssid": Producibility.SCHEMA_0_ONLY, # The `grid_forming_entity` sensor's source field. schema_1 answers the # same question through `resolve_dominant_power_source` over the MID's # `grid/grid-forming-entity` instead of publishing a row of its own, @@ -422,6 +440,7 @@ def residual_field_paths() -> frozenset[str]: from . import ( # noqa: F401 pylint: disable=import-outside-toplevel,unused-import select, sensor_circuit, + sensor_panel, switch, ) from .entity import SpanPanelEntity # pylint: disable=import-outside-toplevel diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py index b254b899..e1a406f5 100644 --- a/custom_components/span_panel/sensor_definitions.py +++ b/custom_components/span_panel/sensor_definitions.py @@ -830,8 +830,7 @@ class SpanMidSensorEntityDescription(SensorEntityDescription, SpanMidRequiredKey Separate from `BESS_METADATA_SENSORS` because these are created conditionally and those are not. Every metadata sensor exists on any commissioned BESS, filled or -empty — `bess_part_number` sits at `None` on this fixture and has since it -shipped. These two come from capability nodes a BESS may simply not have, so +empty. These two come from capability nodes a BESS may simply not have, so absence has to mean no entity rather than a permanently unknown one, and mixing the two rules into one tuple would mean deciding per description which applied. @@ -1150,6 +1149,7 @@ class SpanEvseSensorEntityDescription(SensorEntityDescription, SpanEvseRequiredK SpanEvseSensorEntityDescription, SpanEvseSensorEntityDescription, SpanEvseSensorEntityDescription, + SpanEvseSensorEntityDescription, ] = ( SpanEvseSensorEntityDescription( key="evse_status", @@ -1188,6 +1188,19 @@ class SpanEvseSensorEntityDescription(SensorEntityDescription, SpanEvseRequiredK options=["locked", "unlocked", "unknown"], value_fn=lambda e: e.lock_state or "unknown", ), + # The charger's SKU, shaped like `bess_part_number`: build metadata, so + # diagnostic and off by default, and a plain `field_path` because both + # adapters map the property (`evse/part-number` on flat, `info/part-number` + # on v1.0). It was the one unread declaration whose promotion the producible + # gate could demand, and adding the schema_1 metadata row is what demanded it. + SpanEvseSensorEntityDescription( + key="evse_part_number", + field_path="evse.part_number", + translation_key="evse_part_number", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda e: e.part_number, + ), ) diff --git a/custom_components/span_panel/sensor_panel.py b/custom_components/span_panel/sensor_panel.py index 5cce73e8..161fcc47 100644 --- a/custom_components/span_panel/sensor_panel.py +++ b/custom_components/span_panel/sensor_panel.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import Any, ClassVar from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.typing import UNDEFINED @@ -61,9 +61,53 @@ def _grid_forming_device_name(snapshot: SpanPanelSnapshot) -> str | None: return mid.grid_forming_device_name +def _shed_policy_attributes(snapshot: SpanPanelSnapshot) -> dict[str, Any]: + """Render the shed policy for a person rather than as a JSON blob. + + `shed/policy` is one `json` property carrying an algorithm name and its + parameters, and the two SoC thresholds inside it are the numbers that make + the panel's shedding behaviour predictable -- what state of charge sheds the + SOC_THRESHOLD circuits, and what state of charge brings them back. + + **The raw document survives whenever the parse did not fully succeed.** The + property's `$format` schema is versioned in its own `$id`, which is the + publisher saying a different algorithm may arrive; when one does, the + library reports its name and no thresholds, and showing the document beside + the name is strictly more than showing nothing. A user can read it; an + exception would have taken the sensor down instead. + + Absent members are omitted rather than rendered as `None`, matching the + forecast sensors: an empty attribute reads as a value the panel failed to + produce, a missing one as firmware that does not carry it. + """ + attributes: dict[str, Any] = {} + if snapshot.shed_policy_algorithm is not None: + attributes["shed_algorithm"] = snapshot.shed_policy_algorithm + if snapshot.shed_soc_threshold_shed_percent is not None: + attributes["soc_threshold_shed"] = snapshot.shed_soc_threshold_shed_percent + if snapshot.shed_soc_threshold_release_percent is not None: + attributes["soc_threshold_release"] = snapshot.shed_soc_threshold_release_percent + thresholds_complete = ( + snapshot.shed_soc_threshold_shed_percent is not None + and snapshot.shed_soc_threshold_release_percent is not None + ) + if not thresholds_complete and snapshot.shed_policy is not None: + attributes["shed_policy"] = snapshot.shed_policy + return attributes + + class SpanPanelPanelStatus(SpanSensorBase[SpanPanelDataSensorEntityDescription, SpanPanelSnapshot]): """Span Panel data status sensor entity.""" + # `_residual_field_paths` stays empty on purpose. The four `panel.shed_*` + # policy fields read for `dsm_state`'s attributes are not declarable here: + # everything declared on an entity flows into `declared_field_paths()`, + # where the producible gate demands both adapters emit it, and no adapter + # carries a row for any of them -- flat has no `shed` node at all, and a + # JSON policy document has no unit surface for a schema_1 row to describe. + # They are enumerated in `RESIDUAL_EXEMPT_PATHS` as `Producibility.NEITHER` + # instead, beside the shed-forecast refinements and the PCS attributes. + def __init__( self, data_coordinator: SpanPanelCoordinator, @@ -97,6 +141,23 @@ def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanPanelSnapshot: """Get the data source for the panel data status sensor.""" return snapshot + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """The shed policy, on the sensor that says whether shedding is in force. + + `dsm_state` is the entity a user already looks at to know whether the + panel is on grid or off it, and the policy is what says what happens + next. Attached to that one description rather than to every sensor this + class renders, the same way `SpanPanelStatus` attaches the grid-forming + device name to `grid_forming_entity` alone. + """ + if self.entity_description.key != "dsm_state": + return None + snapshot = self.coordinator.data + if snapshot is None: + return None + return _shed_policy_attributes(snapshot) or None + class SpanShedForecastSensor( SpanSensorBase[SpanShedForecastSensorEntityDescription, SpanPanelSnapshot] @@ -279,6 +340,17 @@ def extra_state_attributes(self) -> dict[str, Any] | None: class SpanPanelStatus(SpanSensorBase[SpanPanelStatusSensorEntityDescription, SpanPanelSnapshot]): """Span Panel hardware status sensor entity.""" + _residual_field_paths: ClassVar[tuple[str, ...]] = ("panel.wifi_ssid",) + """The SSID, read for an attribute rather than by a `value_fn`. + + A plain residual and not an exemption: both adapters map the property + (`core/wifi-ssid` on flat, `status/wifi-ssid` on v1.0), so the producible + gate covers it, and `test_no_exempt_path_is_producible_by_both` is what + demanded the move the moment schema_1 grew its row. Until then the path sat + in `RESIDUAL_EXEMPT_PATHS` annotated `SCHEMA_0_ONLY` -- true, and the reason + a v1.0 panel silently stopped filling an attribute a flat panel filled. + """ + def __init__( self, data_coordinator: SpanPanelCoordinator, diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index 76667302..4d79c150 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -255,6 +255,9 @@ "unlocked": "Unlocked" } }, + "evse_part_number": { + "name": "Part Number" + }, "evse_status": { "name": "Charger Status", "state": { diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index 76667302..4d79c150 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -255,6 +255,9 @@ "unlocked": "Unlocked" } }, + "evse_part_number": { + "name": "Part Number" + }, "evse_status": { "name": "Charger Status", "state": { diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index aef041fb..8e895a6f 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -255,6 +255,9 @@ "unlocked": "Desbloqueado" } }, + "evse_part_number": { + "name": "Número de Pieza" + }, "evse_status": { "name": "Estado del Cargador", "state": { diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index 80a4ab40..ad1d7f3b 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -255,6 +255,9 @@ "unlocked": "Déverrouillé" } }, + "evse_part_number": { + "name": "Référence" + }, "evse_status": { "name": "État du Chargeur", "state": { diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index d0dc23f9..36a228cc 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -255,6 +255,9 @@ "unlocked": "アンロック" } }, + "evse_part_number": { + "name": "型番" + }, "evse_status": { "name": "充電器ステータス", "state": { diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index 4b1d6112..7a508f23 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -255,6 +255,9 @@ "unlocked": "Desbloqueado" } }, + "evse_part_number": { + "name": "Número de Peça" + }, "evse_status": { "name": "Estado do Carregador", "state": { diff --git a/custom_components/span_panel/util.py b/custom_components/span_panel/util.py index 9462180e..85024db4 100644 --- a/custom_components/span_panel/util.py +++ b/custom_components/span_panel/util.py @@ -60,15 +60,32 @@ def snapshot_to_device_info( device_name: str | None = None, host: str | None = None, ) -> DeviceInfo: - """Convert a SpanPanelSnapshot to a Home Assistant device info object.""" + """Convert a SpanPanelSnapshot to a Home Assistant device info object. + + Manufacturer, model and hardware revision come from the enclosure's own + `info` node where it publishes them, and fall back to the strings this + integration has always shown where it does not. + + **The fallbacks are the point, not a courtesy.** Flat firmware declares none + of the three, so every existing installation lands on them; a panel that + omits one must keep the card it has rather than losing a row. `hw_version` + has no such string to fall back to and so is simply absent on flat -- + `DeviceInfo` omits a `None` field, which is the difference between "this + panel does not report a revision" and "this panel reports a blank one". + """ configuration_url = f"http://{host}" if host else None return DeviceInfo( identifiers={(DOMAIN, snapshot.serial_number)}, - manufacturer="Span", - model="SPAN Panel", + manufacturer=snapshot.vendor_name or "Span", + # The published designation is the panel's own model code (`MAIN_40`), + # which is also what `panel_size` is derived from. Showing it beats + # "SPAN Panel" on a card whose whole job is saying which hardware this + # is -- and the generic string remains for anything that publishes none. + model=snapshot.model or "SPAN Panel", name=device_name or "Span Panel", sw_version=snapshot.firmware_version, + hw_version=snapshot.hardware_version, configuration_url=configuration_url, ) diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index 498828d2..dea51ed7 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -12,9 +12,14 @@ Both are byte-identical copies from the `span-panel-api` repository: | File here | Source in `span-panel-api` | |---|---| | `schema_zero_types.json` | `tests/fixtures/v2/homie_schema.json` | -| `schema_one_tree.json` | `tests/fixtures/parent_child_tree.json` | +| `schema_one_tree.json` | `packages/schema-1/src/.../reference_payloads/parent_child_tree.json` | -Refresh by copying them again. If a copy changes shape rather than content, the +Refresh by copying them again, and keep `schema_one_tree.json` byte-identical to +its source: the library pins what that capture leaves unvalued against +panelbench's own baseline (`tests/test_reference_tree_values.py` there), so a +copy that has drifted puts these tests on a wire no producer sends. + +If a copy changes shape rather than content, the loader in `tests/adapter_fixtures.py` is what needs updating — note that `schema_one_tree.json` is a **dict keyed by device id**, whose `$description` value is a **JSON string**, not a parsed object. @@ -36,7 +41,7 @@ def drop(marker: str, out_name: str) -> None: for device_id, topics in tree.items() if marker not in json.loads(topics.get("$description", "{}")).get("type", "") } - pathlib.Path(f"tests/fixtures/{out_name}").write_text(json.dumps(kept, indent=2)) + pathlib.Path(f"tests/fixtures/{out_name}").write_text(json.dumps(kept, indent=2) + "\n") print(f"{out_name}: {len(tree)} -> {len(kept)} devices") drop(".bess", "schema_one_tree_batteryless.json") diff --git a/tests/fixtures/schema_one_tree.json b/tests/fixtures/schema_one_tree.json index f6c25916..0aee1b83 100644 --- a/tests/fixtures/schema_one_tree.json +++ b/tests/fixtures/schema_one_tree.json @@ -62,8 +62,11 @@ "bess": { "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.bess\", \"name\": \"Battery\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"soc\": {\"name\": \"soc\", \"type\": \"energy.ebus.capability.soc\", \"properties\": {\"soc\": {\"name\": \"State of charge\", \"datatype\": \"float\", \"unit\": \"%\"}, \"soe\": {\"name\": \"State of energy\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"communication-state\": {\"name\": \"Communication state\", \"datatype\": \"enum\", \"format\": \"OK,DEGRADED,LOST,UNKNOWN\"}}}}, \"children\": [\"bess-mid\"], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", "$state": "ready", + "info/firmware-version": "example-bess/v0.1.0", "info/model": "Example BESS", "info/nameplate-capacity": "13.5", + "info/part-number": "SPN-BESS-001", + "info/serial-number": "EXAMPLE-BESS-40T-001", "info/vendor-name": "Span", "meter/active-power": "-3500.0", "soc/soc": "50.410493827160494", @@ -76,6 +79,10 @@ "grid/grid-forming-entity": "GRID", "grid/grid-state": "UP", "grid/islanding-state": "ON_GRID", + "info/firmware-version": "example-mid/v0.1.0", + "info/hardware-version": "rev1", + "info/model": "SPAN MID", + "info/serial-number": "EXAMPLE-BESS-40T-001-mid", "info/vendor-name": "Span" }, "d3724e0d660ba506aa79c1cafe5d1181": { @@ -169,7 +176,8 @@ "status/postal-code": "94103", "status/relay": "CLOSED", "status/time-zone": "America/Los_Angeles", - "status/wifi": "true" + "status/wifi": "true", + "status/wifi-ssid": "example-wifi" }, "fe8b85c15bc9610c1b8b4ebc6f82488d": { "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", @@ -218,6 +226,7 @@ "pv": { "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.pv\", \"name\": \"Solar\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nominal-power\": {\"name\": \"Nominal power\", \"datatype\": \"float\", \"unit\": \"W\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", "$state": "ready", + "info/firmware-version": "example-pv/v0.1.0", "info/model": "IQ8PLUS-72-2-US", "info/nominal-power": "10000.0", "info/vendor-name": "Enphase" diff --git a/tests/fixtures/schema_one_tree_batteryless.json b/tests/fixtures/schema_one_tree_batteryless.json index 6baeda2a..5e67af74 100644 --- a/tests/fixtures/schema_one_tree_batteryless.json +++ b/tests/fixtures/schema_one_tree_batteryless.json @@ -65,6 +65,10 @@ "grid/grid-forming-entity": "GRID", "grid/grid-state": "UP", "grid/islanding-state": "ON_GRID", + "info/firmware-version": "example-mid/v0.1.0", + "info/hardware-version": "rev1", + "info/model": "SPAN MID", + "info/serial-number": "EXAMPLE-BESS-40T-001-mid", "info/vendor-name": "Span" }, "d3724e0d660ba506aa79c1cafe5d1181": { @@ -158,7 +162,8 @@ "status/postal-code": "94103", "status/relay": "CLOSED", "status/time-zone": "America/Los_Angeles", - "status/wifi": "true" + "status/wifi": "true", + "status/wifi-ssid": "example-wifi" }, "fe8b85c15bc9610c1b8b4ebc6f82488d": { "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", @@ -207,6 +212,7 @@ "pv": { "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.pv\", \"name\": \"Solar\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nominal-power\": {\"name\": \"Nominal power\", \"datatype\": \"float\", \"unit\": \"W\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", "$state": "ready", + "info/firmware-version": "example-pv/v0.1.0", "info/model": "IQ8PLUS-72-2-US", "info/nominal-power": "10000.0", "info/vendor-name": "Enphase" diff --git a/tests/fixtures/schema_one_tree_no_pv.json b/tests/fixtures/schema_one_tree_no_pv.json index 9b0483ab..ac92a366 100644 --- a/tests/fixtures/schema_one_tree_no_pv.json +++ b/tests/fixtures/schema_one_tree_no_pv.json @@ -62,8 +62,11 @@ "bess": { "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.bess\", \"name\": \"Battery\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"soc\": {\"name\": \"soc\", \"type\": \"energy.ebus.capability.soc\", \"properties\": {\"soc\": {\"name\": \"State of charge\", \"datatype\": \"float\", \"unit\": \"%\"}, \"soe\": {\"name\": \"State of energy\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"communication-state\": {\"name\": \"Communication state\", \"datatype\": \"enum\", \"format\": \"OK,DEGRADED,LOST,UNKNOWN\"}}}}, \"children\": [\"bess-mid\"], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", "$state": "ready", + "info/firmware-version": "example-bess/v0.1.0", "info/model": "Example BESS", "info/nameplate-capacity": "13.5", + "info/part-number": "SPN-BESS-001", + "info/serial-number": "EXAMPLE-BESS-40T-001", "info/vendor-name": "Span", "meter/active-power": "-3500.0", "soc/soc": "50.410493827160494", @@ -76,6 +79,10 @@ "grid/grid-forming-entity": "GRID", "grid/grid-state": "UP", "grid/islanding-state": "ON_GRID", + "info/firmware-version": "example-mid/v0.1.0", + "info/hardware-version": "rev1", + "info/model": "SPAN MID", + "info/serial-number": "EXAMPLE-BESS-40T-001-mid", "info/vendor-name": "Span" }, "d3724e0d660ba506aa79c1cafe5d1181": { @@ -169,7 +176,8 @@ "status/postal-code": "94103", "status/relay": "CLOSED", "status/time-zone": "America/Los_Angeles", - "status/wifi": "true" + "status/wifi": "true", + "status/wifi-ssid": "example-wifi" }, "fe8b85c15bc9610c1b8b4ebc6f82488d": { "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", diff --git a/tests/fixtures/unread_declarations_baseline.json b/tests/fixtures/unread_declarations_baseline.json index ea1e6914..1ca96e9d 100644 --- a/tests/fixtures/unread_declarations_baseline.json +++ b/tests/fixtures/unread_declarations_baseline.json @@ -2,19 +2,14 @@ "circuit/breaker/poles": "reaches circuit.is_240v and stops there; the voltage attribute is derived from the tab count instead, so no entity or attribute reads the field", "circuit/connection/count": "microinverter/pack count; no producer publishes it, so there is no fixture value an entity could be tested against", "circuit/connection/feeds-device-type": "redundant: the library resolves a DER's class by dereferencing feeds-device-id against the tree's declared $type", - "distribution-enclosure/info/hardware-version": "no panel.hardware_version snapshot field, and the panel DeviceInfo declares no hw_version (Task 5)", - "distribution-enclosure/info/vendor-name": "DeviceInfo.manufacturer is hardcoded to \"Span\"; the declared value is never consulted, even as a fallback (Task 5)", - "distribution-enclosure/shed/policy": "a JSON policy document (soc-priority.v1 plus the two SOC thresholds); nothing parses it, and the thresholds belong as attributes beside the forecast (Task 4)", "distribution-enclosure/status/postal-code": "deliberate skip: location data with no Home Assistant value, and surfacing it would copy the user's location into recorder history", "distribution-enclosure/status/time-zone": "deliberate skip: Home Assistant owns the time zone, so a mismatch is a Repair rather than an entity", - "distribution-enclosure/status/wifi-ssid": "declared and never published; PanelFields.wifi_ssid is hardcoded None on schema_1, so a flat panel's wifi_ssid attribute is lost on upgrade (Task 2)", "evse/config/max-charge-current": "the installer ceiling; it becomes native_max_value of the charge-current number entity rather than an entity of its own, and is deferred with it (Task 6)", "evse/config/user-max-charge-current": "the only settable unread property; the eBus catalog spells this capability charge-limit with different property names, so the write topic waits on a real-firmware capture (Task 6)", - "evse/info/part-number": "reaches evse.part_number and stops there; only battery.part_number has a sensor, so no EVSE device card shows it (Task 5)", "lugs/connection/count": "the lugs counterpart of circuit/connection/count, with no fed-device story upstream and no producer publishing it", "lugs/connection/fed-by-device-type": "redundant: the id dereferences to the device's declared $type, which is how the library already resolves classes", "lugs/connection/feeds-device-status": "feedthrough link-health; the feeds-* half of the downstream lugs record is topology to consume, and no producer publishes it", "lugs/connection/feeds-device-type": "redundant: the id dereferences to the device's declared $type, which is how the library already resolves classes", - "pv/info/firmware-version": "reaches pv.software_version and stops there; PV has no sub-device registry entry, so there is no DeviceInfo to carry a version (Task 5)", + "pv/info/firmware-version": "reaches pv.software_version and stops there; PV has no sub-device registry entry, so there is no DeviceInfo to carry a version - it lands with the PV sub-device (Task 7)", "pv/info/serial-number": "not read at all: _der_identifier prefers a serial over instance_id, so valuing it would move the PV device id from -pv-1 to - - a device replacement, not an upgrade (Task 7)" } diff --git a/tests/test_evse_entities.py b/tests/test_evse_entities.py index 87de36b2..06c9e36a 100644 --- a/tests/test_evse_entities.py +++ b/tests/test_evse_entities.py @@ -77,7 +77,7 @@ class TestEvseSensorDefinitions: """Test EVSE sensor definition structure.""" def test_evse_sensors_count(self): - assert len(EVSE_SENSORS) == 3 + assert len(EVSE_SENSORS) == 4 def test_evse_status_sensor_is_enum(self): status_desc = next(d for d in EVSE_SENSORS if d.key == "evse_status") diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py index 35c6810b..ee480d5f 100644 --- a/tests/test_field_path_conformance.py +++ b/tests/test_field_path_conformance.py @@ -272,12 +272,28 @@ def test_no_exempt_path_is_producible_by_both() -> None: # read as attributes on the circuit power sensor. schema_1 reads all fifteen # and maps none of them, deliberately — they explain the effective limit # rather than being readings of their own. - Producibility.NEITHER: 34, + # +3 for the enclosure's own build identity -- `panel.vendor_name`, + # `panel.model`, `panel.hardware_version` -- read by `snapshot_to_device_info` + # for the panel's device card. Flat declares none of the three, and a + # schema_1 row exists to carry a unit and a datatype for a reading, which an + # identity string is not; the `mid.*` device-card reads sit here for the + # same reason. + # +4 for the shed policy -- the raw `shed/policy` document plus the + # algorithm and the two SoC thresholds parsed out of it -- read as + # attributes on `dsm_state`. Flat has no `shed` node, and a JSON document + # has no unit surface for a schema_1 row to describe. + Producibility.NEITHER: 41, # +1 for `panel.dominant_power_source`, the `grid_forming_entity` sensor's # source field. It was read by a `SCHEMA_CONDITIONAL_FIELD` description and # enumerated nowhere, so `evaluate_field_metadata` counted it as produced- # but-unread while an entity was reading it. - Producibility.SCHEMA_0_ONLY: 11, + # -1 for `panel.wifi_ssid`, which left this map entirely: schema_1 grew the + # `status/wifi-ssid` row, both adapters produce the path, and + # `test_no_exempt_path_is_producible_by_both` demanded it become a + # declaration -- `SpanPanelStatus._residual_field_paths`. Its time here as a + # true `SCHEMA_0_ONLY` annotation is what sanctioned a flat -> v1.0 + # regression: the attribute a flat panel filled, a v1.0 panel did not. + Producibility.SCHEMA_0_ONLY: 10, # +2 with the shed forecast: the two live estimates, which schema_1 maps and # flat firmware does not publish at all. # +2 for `battery.power_w` and `battery.communication_state`, the BESS's own diff --git a/tests/test_metadata_sweep.py b/tests/test_metadata_sweep.py new file mode 100644 index 00000000..2fb4ecf7 --- /dev/null +++ b/tests/test_metadata_sweep.py @@ -0,0 +1,472 @@ +"""Identity that reaches a device card, an attribute or a diagnostic sensor. + +Four surfaces, no new entity classes, and one of them is a regression rather +than a feature. Grouped because they share a proof obligation: each is a value +the panel has published all along that nothing rendered, so a test asserting a +constant the code also holds would pass whether or not the wire is ever read. + +Every expectation below is therefore read out of the vendored capture, and every +reading is proved by republishing it, unpublishing it, or both. The device-card +assertions go through the real device registry after a real registration rather +than through the `DeviceInfo` dict, because the dict is what the code returns and +the registry is what a user sees. + +**`panel.wifi_ssid` is the regression.** Flat published `core/wifi-ssid` and the +integration has surfaced it as an attribute since; v1.0 declares +`status/wifi-ssid`, schema_1 mapped nothing to it, and the path's exemption +annotation said `SCHEMA_0_ONLY` -- which was true, and sanctioned a user losing +an attribute on upgrade. With the library reading it, both adapters produce the +path, so it is a declaration now and the producible gate covers it. +""" + +from __future__ import annotations + +import json +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from span_panel_api import SpanPanelSnapshot + +from custom_components.span_panel import SpanPanelRuntimeData, ensure_device_registered +from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.field_paths import ( + RESIDUAL_EXEMPT_PATHS, + declared_field_paths, +) +from custom_components.span_panel.sensor import create_evse_sensors, create_panel_sensors +from custom_components.span_panel.sensor_definitions import EVSE_SENSORS +from custom_components.span_panel.sensor_panel import SpanPanelPanelStatus, SpanPanelStatus +from homeassistant.const import CONF_HOST, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .adapter_fixtures import SCHEMA_ONE_PANEL, schema_one_snapshot, schema_one_tree + +from pytest_homeassistant_custom_component.common import MockConfigEntry + +EVSE_PART_NUMBER_KEY = "evse_part_number" +DSM_STATE_KEY = "dsm_state" +SOFTWARE_VERSION_KEY = "software_version" + +WIFI_SSID_TOPIC = "status/wifi-ssid" +POLICY_TOPIC = "shed/policy" +VENDOR_TOPIC = "info/vendor-name" +MODEL_TOPIC = "info/model" +HARDWARE_TOPIC = "info/hardware-version" +FIRMWARE_TOPIC = "info/firmware-version" +PART_NUMBER_TOPIC = "info/part-number" + +EVSE = "evse" + +# What the panel's device card showed before any of this was readable, and what +# a panel publishing nothing must go on showing. +FALLBACK_MANUFACTURER = "Span" +FALLBACK_MODEL = "SPAN Panel" + + +@pytest.fixture(autouse=True) +def _mock_entity_registry() -> Any: + """Patch the entity-registry lookup sensor construction performs.""" + registry = MagicMock() + registry.async_get_entity_id.return_value = None + with patch( + "custom_components.span_panel.sensor_base.er.async_get", + return_value=registry, + ): + yield registry + + +def _published(device_id: str, topic: str) -> str: + """What the capture publishes on one topic, or fail saying it does not.""" + value = schema_one_tree()[device_id].get(topic) + assert value is not None, f"{device_id} publishes no {topic} in the capture" + return value + + +def _snapshot(**rewrites: str | None) -> SpanPanelSnapshot: + """A snapshot from the capture with panel topics rewritten or unpublished. + + Keyword spelling is `node__property_name`. `None` removes the topic, which is + what a panel whose firmware omits a property looks like -- a different event + from publishing an empty string, and the one the fallbacks exist for. + """ + tree = schema_one_tree() + for path, value in rewrites.items(): + node, _, prop = path.partition("__") + topic = f"{node.replace('_', '-')}/{prop.replace('_', '-')}" + if value is None: + tree[SCHEMA_ONE_PANEL].pop(topic, None) + else: + tree[SCHEMA_ONE_PANEL][topic] = value + return schema_one_snapshot(tree) + + +def _evse_snapshot(**rewrites: str | None) -> SpanPanelSnapshot: + """The same, against the first EVSE in the capture.""" + tree = schema_one_tree() + for path, value in rewrites.items(): + node, _, prop = path.partition("__") + topic = f"{node.replace('_', '-')}/{prop.replace('_', '-')}" + if value is None: + tree[EVSE].pop(topic, None) + else: + tree[EVSE][topic] = value + return schema_one_snapshot(tree) + + +def _coordinator(snapshot: SpanPanelSnapshot) -> MagicMock: + """A coordinator-like mock carrying one snapshot.""" + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.hass = MagicMock() + coordinator.panel_offline = False + coordinator.unresolved_paths = frozenset() + coordinator.config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.50"}, + options={}, + title="SPAN Panel", + unique_id=snapshot.serial_number, + ) + coordinator.config_entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, panel_device_id="panel-device-id" + ) + return coordinator + + +def _panel_sensors(snapshot: SpanPanelSnapshot) -> dict[str, Any]: + """Every panel-level sensor the platform creates, keyed by description key.""" + coordinator = _coordinator(snapshot) + created = create_panel_sensors(coordinator, snapshot, coordinator.config_entry) + return {sensor.entity_description.key: sensor for sensor in created} + + +def _attributes(snapshot: SpanPanelSnapshot, key: str) -> dict[str, Any]: + """The attributes one panel sensor reports, or an empty dict for none.""" + sensor = _panel_sensors(snapshot)[key] + return sensor.extra_state_attributes or {} + + +async def _registered_panel( + hass: HomeAssistant, snapshot: SpanPanelSnapshot, entry_id: str +) -> dr.DeviceEntry: + """Register the panel the way setup does, and read its card back. + + Through the registry rather than through `snapshot_to_device_info`'s return + value: the dict is this integration's claim, the registry entry is the device + a user opens. A field the registry drops or overwrites is invisible to an + assertion on the dict. + """ + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.30"}, + entry_id=entry_id, + unique_id=snapshot.serial_number, + ) + entry.add_to_hass(hass) + await ensure_device_registered(hass, entry, snapshot, "SPAN Panel") + device = dr.async_get(hass).async_get_device_by_identifier( + (DOMAIN, snapshot.serial_number), entry.entry_id + ) + assert device is not None + return device + + +# --------------------------------------------------------------------------- +# The premise +# --------------------------------------------------------------------------- + + +def test_the_capture_publishes_everything_this_module_reads() -> None: + """Guard the premise, since every expectation below is read from the capture. + + The capture was eight identity properties behind the producer until it was + refreshed, and the shape of that gap is exactly what makes this guard worth + having: a test whose expected value comes from an unpublished topic does not + fail, it stops asserting anything. + """ + assert _published(SCHEMA_ONE_PANEL, WIFI_SSID_TOPIC) + assert _published(SCHEMA_ONE_PANEL, POLICY_TOPIC) + assert _published(SCHEMA_ONE_PANEL, VENDOR_TOPIC) + assert _published(SCHEMA_ONE_PANEL, MODEL_TOPIC) + assert _published(SCHEMA_ONE_PANEL, HARDWARE_TOPIC) + assert _published(EVSE, PART_NUMBER_TOPIC) + + +# --------------------------------------------------------------------------- +# The panel's device card +# --------------------------------------------------------------------------- + + +async def test_the_panel_card_shows_the_identity_the_panel_publishes( + hass: HomeAssistant, +) -> None: + """Manufacturer, model and hardware revision come off the wire, not a constant. + + Read back from the registry, and compared against the capture rather than + against literals: the model in particular is `MAIN_40`, which is also the + string `panel_size` is derived from, so an assertion written as a literal + would agree with the size lookup rather than with the panel. + """ + device = await _registered_panel(hass, _snapshot(), "entry-card-published") + + assert device.manufacturer == _published(SCHEMA_ONE_PANEL, VENDOR_TOPIC) + assert device.model == _published(SCHEMA_ONE_PANEL, MODEL_TOPIC) + assert device.hw_version == _published(SCHEMA_ONE_PANEL, HARDWARE_TOPIC) + assert device.sw_version == _published(SCHEMA_ONE_PANEL, FIRMWARE_TOPIC) + + +async def test_the_panel_card_follows_a_republished_identity(hass: HomeAssistant) -> None: + """The card tracks the wire, so nothing here is passing on a coincidence. + + `MAIN_32` is deliberately another real model: it keeps `panel_size` resolvable, + so the only thing the rewrite changes is the string on the card. + """ + rewritten = _snapshot( + info__vendor_name="Another Vendor", + info__model="MAIN_32", + info__hardware_version="rev9", + ) + + device = await _registered_panel(hass, rewritten, "entry-card-rewritten") + + assert device.manufacturer == "Another Vendor" + assert device.model == "MAIN_32" + assert device.hw_version == "rev9" + + +async def test_a_panel_publishing_no_identity_keeps_the_card_it_has_always_had( + hass: HomeAssistant, +) -> None: + """The fallbacks are the compatibility guarantee, not a courtesy. + + Flat firmware declares none of these three, so every existing installation + lands here. A panel that omits one must keep the row it has rather than + losing it -- and `hw_version`, which never had a string to fall back to, must + be absent rather than blank: `DeviceInfo` omits a `None` and renders an empty + string as a present-but-empty row. + """ + bare = _snapshot(info__vendor_name=None, info__model=None, info__hardware_version=None) + + device = await _registered_panel(hass, bare, "entry-card-bare") + + assert device.manufacturer == FALLBACK_MANUFACTURER + assert device.model == FALLBACK_MODEL + assert device.hw_version is None + + +def test_the_panel_identity_paths_are_enumerated_as_device_card_reads() -> None: + """`snapshot_to_device_info` is not an entity, so its reads are exempt residuals. + + Annotated `NEITHER` beside the `mid.*` device-card reads: flat declares none + of the three, and a schema_1 metadata row exists to carry a unit and a + datatype for a reading, which an identity string is not. Asserted here so the + three cannot quietly leave the inventory that is the only record of them. + """ + for path in ("panel.vendor_name", "panel.model", "panel.hardware_version"): + assert path in RESIDUAL_EXEMPT_PATHS, path + assert path not in declared_field_paths(), path + + +# --------------------------------------------------------------------------- +# `panel.wifi_ssid` -- the flat -> v1.0 regression +# --------------------------------------------------------------------------- + + +def test_the_ssid_attribute_carries_what_the_panel_publishes() -> None: + """The attribute a flat panel filled, filled again on v1.0. + + Asserted on a sensor built by the platform over a snapshot the real schema_1 + adapter produced from the capture, so what is under test is the whole route: + published topic, mapper, snapshot field, attribute. + """ + attributes = _attributes(_snapshot(), SOFTWARE_VERSION_KEY) + + assert attributes["wifi_ssid"] == _published(SCHEMA_ONE_PANEL, WIFI_SSID_TOPIC) + + +def test_the_ssid_attribute_follows_a_republished_ssid() -> None: + """A panel that joins another network says so, which a hardcoded `None` never could.""" + attributes = _attributes(_snapshot(status__wifi_ssid="another-network"), SOFTWARE_VERSION_KEY) + + assert attributes["wifi_ssid"] == "another-network" + + +def test_an_unpublished_ssid_leaves_the_attribute_off_entirely() -> None: + """Absent, not empty. An attribute present and blank reads as a failed reading.""" + attributes = _attributes(_snapshot(status__wifi_ssid=None), SOFTWARE_VERSION_KEY) + + assert "wifi_ssid" not in attributes + # The sensor still reports its other attributes, so this is the SSID going + # missing rather than the attribute block collapsing. + assert "panel_size" in attributes + + +def test_the_ssid_is_a_declaration_now_rather_than_an_exemption() -> None: + """The gate's own ratchet, asserted where a reader will find it. + + Both adapters map `wifi_ssid`, so the path satisfies the producible gate and + `test_no_exempt_path_is_producible_by_both` refuses to let it stay exempt. + It is declared on the entity that reads it, which is what lets a Repair name + the entities a dead field takes with it. + """ + assert "panel.wifi_ssid" not in RESIDUAL_EXEMPT_PATHS + assert "panel.wifi_ssid" in declared_field_paths() + assert "panel.wifi_ssid" in SpanPanelStatus._residual_field_paths + + +# --------------------------------------------------------------------------- +# `shed/policy` -- attributes on `dsm_state` +# --------------------------------------------------------------------------- + + +def test_the_shed_policy_reaches_dsm_state_as_its_two_thresholds() -> None: + """The numbers that make the panel's shed behaviour predictable. + + Compared against the document the capture publishes rather than against + literals, so the parse is checked against the producer's own encoding of it. + """ + document = json.loads(_published(SCHEMA_ONE_PANEL, POLICY_TOPIC)) + attributes = _attributes(_snapshot(), DSM_STATE_KEY) + + assert attributes["shed_algorithm"] == document["algorithm"] + assert attributes["soc_threshold_shed"] == document["parameters"]["soc-threshold-shed"] + assert attributes["soc_threshold_release"] == document["parameters"]["soc-threshold-release"] + # Fully parsed, so the raw document adds nothing a user could act on. + assert "shed_policy" not in attributes + + +def test_the_thresholds_follow_a_republished_policy() -> None: + """A panel reconfigured to shed later says so.""" + rewritten = json.dumps( + { + "algorithm": "soc-priority.v1", + "parameters": {"soc-threshold-shed": 5, "soc-threshold-release": 15}, + } + ) + + attributes = _attributes(_snapshot(shed__policy=rewritten), DSM_STATE_KEY) + + assert attributes["soc_threshold_shed"] == 5 + assert attributes["soc_threshold_release"] == 15 + + +def test_an_unknown_algorithm_degrades_to_the_raw_document() -> None: + """The policy schema is versioned in its own `$id`, so another algorithm may arrive. + + Reporting `soc-priority.v1`'s thresholds for a document that never had them + would be worse than reporting nothing, and raising would take the sensor + down. Naming the algorithm and showing the document is what a user can act on. + """ + other = json.dumps({"algorithm": "runtime-priority.v2", "parameters": {"minutes-shed": 30}}) + + attributes = _attributes(_snapshot(shed__policy=other), DSM_STATE_KEY) + + assert attributes["shed_algorithm"] == "runtime-priority.v2" + assert attributes["shed_policy"] == other + assert "soc_threshold_shed" not in attributes + assert "soc_threshold_release" not in attributes + + +def test_an_unparseable_policy_still_leaves_the_sensor_standing() -> None: + """A panel is a publisher this integration does not control. + + One malformed string must not take `dsm_state` -- the sensor a button reads + to decide whether the panel is already on grid -- down with it. + """ + sensor = _panel_sensors(_snapshot(shed__policy="{not json"))[DSM_STATE_KEY] + sensor._update_native_value() + + assert sensor.native_value is not None + assert (sensor.extra_state_attributes or {}) == {"shed_policy": "{not json"} + + +def test_a_panel_publishing_no_policy_reports_no_policy_attributes() -> None: + """`dsm_state` keeps its state and simply carries nothing extra.""" + sensor = _panel_sensors(_snapshot(shed__policy=None))[DSM_STATE_KEY] + sensor._update_native_value() + + assert sensor.native_value is not None + assert sensor.extra_state_attributes is None + + +def test_the_policy_attributes_hang_off_dsm_state_and_nothing_else() -> None: + """One sensor's attributes, not every sensor this class renders. + + `SpanPanelPanelStatus` renders the relay state and the run config too, and a + shed policy repeated on each of them is noise on three cards. + """ + sensors = _panel_sensors(_snapshot()) + carrying = { + key + for key, sensor in sensors.items() + if isinstance(sensor, SpanPanelPanelStatus) + and "shed_algorithm" in (sensor.extra_state_attributes or {}) + } + + assert carrying == {DSM_STATE_KEY} + + +# --------------------------------------------------------------------------- +# `evse.part_number` -- the promotion the producible gate demanded +# --------------------------------------------------------------------------- + + +def _evse_states(snapshot: SpanPanelSnapshot, key: str) -> set[Any]: + """What every charger's sensor of this key reports.""" + states: set[Any] = set() + for sensor in create_evse_sensors(_coordinator(snapshot), snapshot): + if sensor.entity_description.key != key: + continue + sensor._update_native_value() + states.add(sensor.native_value) + return states + + +def test_the_charger_reports_the_part_number_the_panel_publishes() -> None: + """The BESS has shown its SKU since it shipped; the charger beside it had none.""" + assert _evse_states(_snapshot(), EVSE_PART_NUMBER_KEY) == {_published(EVSE, PART_NUMBER_TOPIC)} + + +def test_the_part_number_follows_a_republished_value() -> None: + """Two chargers, one rewritten, so the sensor cannot be reading a constant. + + The capture publishes the same SKU on both, which is what makes this the + mutation worth running: a rewrite of one has to show up as two distinct + states rather than as one. + """ + states = _evse_states(_evse_snapshot(info__part_number="SPN-DRV-999"), EVSE_PART_NUMBER_KEY) + + assert states == {"SPN-DRV-999", _published(EVSE, PART_NUMBER_TOPIC)} + + +def test_a_charger_publishing_no_part_number_reports_unknown_rather_than_a_default() -> None: + """An unpublished SKU is unknown, and no charger invents one for another. + + `STATE_UNKNOWN` rather than `None` because the platform renders a + non-numeric sensor's absent value that way, which is how `bess_part_number` + has always behaved on a BESS that publishes none. The other charger keeps + its value in the same breath, so this is one charger going quiet rather than + the sensor failing. + """ + states = _evse_states(_evse_snapshot(info__part_number=None), EVSE_PART_NUMBER_KEY) + + assert states == {STATE_UNKNOWN, _published(EVSE, PART_NUMBER_TOPIC)} + + +def test_the_part_number_is_a_plain_declaration_on_both_adapters() -> None: + """The promotion this task's schema_1 metadata row demanded. + + Flat has mapped `evse/part-number` all along; adding the v1.0 row made the + path producible by both, and a both-producible path is a declaration rather + than an exemption. Diagnostic and off by default, matching `bess_part_number` + -- build metadata is not something a user wants on a card by default. + """ + (description,) = [d for d in EVSE_SENSORS if d.key == EVSE_PART_NUMBER_KEY] + + assert description.field_path == "evse.part_number" + assert description.derived is None + assert description.entity_registry_enabled_default is False + assert "evse.part_number" in declared_field_paths() + assert "evse.part_number" not in RESIDUAL_EXEMPT_PATHS diff --git a/tests/test_promoted_sensors.py b/tests/test_promoted_sensors.py index 866d2eeb..b7ec0b6d 100644 --- a/tests/test_promoted_sensors.py +++ b/tests/test_promoted_sensors.py @@ -401,7 +401,7 @@ class TestEvseSensorDefinitions: """Test EVSE sensor definitions.""" def test_sensor_count(self): - assert len(EVSE_SENSORS) == 3 + assert len(EVSE_SENSORS) == 4 def test_all_have_translation_keys(self): for desc in EVSE_SENSORS: diff --git a/tests/test_sensor_platform.py b/tests/test_sensor_platform.py index 05c8ff95..4370eb20 100644 --- a/tests/test_sensor_platform.py +++ b/tests/test_sensor_platform.py @@ -26,6 +26,7 @@ create_power_flow_sensors, create_unmapped_circuit_sensors, ) +from custom_components.span_panel.sensor_definitions import EVSE_SENSORS from homeassistant.core import HomeAssistant from .factories import ( @@ -399,5 +400,5 @@ def test_create_evse_sensors_creates_all_descriptions_for_each_charger() -> None entities = create_evse_sensors(coordinator, snapshot) - assert len(entities) == 6 + assert len(entities) == len(EVSE_SENSORS) * 2 assert {entity._evse_id for entity in entities} == {"evse-0", "evse-1"} From bc3e19aee0d441dbbd1dcf046d5ffe0e333713e6 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:53:13 -0700 Subject: [PATCH 055/345] feat(number): give each charger the charge-current limit its panel declares The catch-up's only settable property, and the integration's first `number` platform. One entity per commissioned EVSE, on the charger's own device card beside the sensors it already has. Every part of the control is described by the panel rather than by this integration. The value and the maximum come from whichever node the charger's `$description` declares -- `config/{max,user-max}-charge-current` in the reference tree, `charge-limit/{installer-max,owner-limit}` in the eBus catalog, resolved in the library so nothing here names a wire property. A charger publishing the catalogued spelling produces an identical entity, asserted field by field against one publishing SPAN's. `native_max_value` is the installer-commissioned ceiling, read off the wire. That is the whole reason this is not a hardcoded slider: the maximum is a derating from the breaker and J1772, it moves when a charger is recommissioned, and Home Assistant checks a service call against it before this platform sees one. The library checks again against what the panel is publishing *now* and refuses rather than clamping -- a clamp would report a limit the charger is not enforcing. Two gates, both from the declaration and neither from a value: - `$settable` creates the entity. A charger publishing a perfectly readable limit it does not declare writable gets no control, the same distinction `never-backup` already draws for a circuit's priority. Asserted through `async_setup_entry`, because an entity that must not exist cannot be observed by asking an entity for its state. - An unpublished ceiling makes the entity unavailable. A number must report some maximum and Home Assistant's default is 100, so the alternative is putting a plausible-looking amperage in front of a user that no installer commissioned. A write the panel has acknowledged and not yet applied shows as a `charge_current_limit_target` attribute while the state stays the limit the charger is still enforcing -- the rendering the priority select already gives `circuit.priority_target`, rather than a second pending-command mechanism. Field paths: the limit and the ceiling are `SCHEMA_1_ONLY` -- schema_1 resolves a metadata row for each from the charger's own `$description`, and flat's `evse` type declares no settable ceiling at all -- and the description is `SCHEMA_CONDITIONAL_FIELD` naming the limit, so the entity keeps its Repair mention and its unavailability. `$settable` and the `$target` echo are `NEITHER`: facts about a command rather than readings. The two `evse/config/*` lines leave the unread baseline, 13 -> 11. Proof, beyond the assertions: nine deliberate breakages of this platform and its gates -- the creation gate ignoring `$settable`, a hardcoded maximum, the value reading the ceiling, the write addressing the wrong charger, the refusal swallowed, the platform unforwarded -- each failed the tests that name it, and dropping either read from `RESIDUAL_EXEMPT_PATHS` put its property straight back into the unread baseline. --- CHANGELOG.md | 11 + README.md | 11 + custom_components/span_panel/__init__.py | 5 + custom_components/span_panel/field_paths.py | 28 + custom_components/span_panel/icons.json | 5 + custom_components/span_panel/number.py | 295 ++++++++ custom_components/span_panel/strings.json | 11 + .../span_panel/translations/en.json | 11 + .../span_panel/translations/es.json | 11 + .../span_panel/translations/fr.json | 11 + .../span_panel/translations/ja.json | 11 + .../span_panel/translations/pt.json | 11 + .../unread_declarations_baseline.json | 2 - tests/test_evse_charge_limit.py | 640 ++++++++++++++++++ tests/test_field_path_conformance.py | 16 +- 15 files changed, 1075 insertions(+), 4 deletions(-) create mode 100644 custom_components/span_panel/number.py create mode 100644 tests/test_evse_charge_limit.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 83f58d74..43b78e1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ All notable changes to this project will be documented in this file. ### Added +- **A charge-current limit you can set, on panels running the v1.0 data model.** Each commissioned SPAN Drive gets an **EVSE Charge Current Limit** number on + its own device — the ceiling the charger offers your vehicle, which you can lower to charge more slowly and raise back. It is the first control this + integration has that changes something on a charger rather than on the panel. +- The maximum you can ask for is the one your installer commissioned, read from the panel rather than assumed: the box will not accept a value above the + charger's rated current, and neither will anything else — a value beyond it is refused before it is sent, not quietly rounded down to something you did not + ask for. If the panel has not yet published what the charger is rated for, the control reports unavailable instead of offering an invented range. +- The control appears only where the panel says the limit can be changed. A charger that publishes its limit as read-only gets no control, which is the same + distinction **Circuit Priority** already makes for a circuit commissioned never-backup. +- While the panel is acknowledging a change it has not yet applied, the requested value shows as a `charge_current_limit_target` attribute and the state stays + the limit the charger is still enforcing — the same way Circuit Priority reports a priority change in flight. + - **Whether your panel can reach your solar inverter and each of your chargers, on panels running the v1.0 data model.** **PV Panel Link** and **EVSE Panel Link** are the same fact **BESS Connected** has always shown for the battery: the panel's own report of the link to a device it feeds. The battery's version worked because the panel publishes it on the main lugs; the inverter's and each charger's are published by the circuit that feeds them, and nothing read that diff --git a/README.md b/README.md index 6bf2aa28..7bf49a5a 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,17 @@ feature. A display suffix differentiates multiple chargers on the same panel: whether it can reach the charger at all. A charger part-way through a session behind a lost link reports a plugged-in vehicle and a dead link at the same time. EVSE Panel Link is a diagnostic and appears only where the circuit feeding that charger publishes the link record. +#### EVSE Controls (per charger, v1.0) + +| Control | Platform | Unit | Notes | +| ------------------------- | -------- | ---- | --------------------------------------------------------------------------------------- | +| EVSE Charge Current Limit | Number | A | The charge-current ceiling you can lower. Bounded by the installer-commissioned maximum | + +The maximum is read from the panel, never assumed: it is the current the charger was commissioned for, and a value above it is refused rather than clamped. The +control is created only where the panel declares the limit settable, and reports unavailable while the panel has not published the commissioned maximum that +bounds it. A change the panel has acknowledged but not yet applied appears as a `charge_current_limit_target` attribute while the state stays the limit the +charger is still enforcing. + #### EVSE Device Info Attributes | Attribute | Source | diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py index 4d221be2..741e2d71 100644 --- a/custom_components/span_panel/__init__.py +++ b/custom_components/span_panel/__init__.py @@ -92,6 +92,11 @@ class SpanPanelRuntimeData: PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.BUTTON, + # Added with the EVSE charge-current control -- the first number this + # integration has ever had, and forwarded unconditionally like every other + # platform: `number.async_setup_entry` creates nothing on a panel with no + # charger that declares a settable limit. + Platform.NUMBER, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py index 6fa11d11..ba874ffd 100644 --- a/custom_components/span_panel/field_paths.py +++ b/custom_components/span_panel/field_paths.py @@ -209,6 +209,16 @@ class FieldPathDeclarationMixin: # The EVSE's Homie node id — an addressing handle used to build the # sub-device identifier, not a published field. "evse.node_id": Producibility.NEITHER, + # The charge-current control's two non-readings, read by + # `SpanEvseNumber`: `$settable` on the limit's declaration, which is the + # entity-creation gate, and the Homie `$target` echo of a write the + # panel has accepted but not yet applied, rendered as an attribute. Both + # are facts about a command rather than readings, so no adapter carries + # a metadata row for either — the same shape as the `circuit.*_target` + # pair at the top of this map, and the same reason `panel.grid_islandable` + # sits here as a creation gate. + "evse.charge_current_limit_settable": Producibility.NEITHER, + "evse.charge_current_limit_target_a": Producibility.NEITHER, # The shed-forecast refinements, read for attributes on the two forecast # sensors (`SpanShedForecastSensor.extra_state_attributes`). schema_1 # reads all three into the snapshot but carries a `_PROPERTY_FIELD_MAP` @@ -299,6 +309,22 @@ class FieldPathDeclarationMixin: # through a circuit was not. "pv.connected": Producibility.SCHEMA_1_ONLY, "evse.connected": Producibility.SCHEMA_1_ONLY, + # The EVSE charge-current pair behind the `evse_charge_current_limit` + # number: the settable limit the entity's value comes from — its + # description is `SCHEMA_CONDITIONAL_FIELD` and names it — and the + # commissioned ceiling the entity reads for `native_max_value`. Flat + # firmware's `evse` device type publishes `advertised-current` and no + # settable ceiling at all, so neither can ever satisfy the both-adapters + # gate. schema_1 carries a metadata row for each, resolved from the + # charger's own `$description` rather than from a table, which is what + # makes these SCHEMA_1_ONLY rather than NEITHER and buys the entity unit + # validation against the property the panel actually declares. + # + # The ceiling is deliberately not on `SpanEvseNumber._residual_field_paths`: + # that feeds `declared_field_paths()`, which is the both-adapters gate, + # and flat produces neither path. + "evse.charge_current_limit_a": Producibility.SCHEMA_1_ONLY, + "evse.charge_current_ceiling_a": Producibility.SCHEMA_1_ONLY, "circuit.always_on": Producibility.SCHEMA_0_ONLY, "circuit.is_sheddable": Producibility.SCHEMA_0_ONLY, # The `grid_forming_entity` sensor's source field. schema_1 answers the @@ -470,6 +496,7 @@ def platform_descriptions() -> tuple[EntityDescription, ...]: GRID_ISLANDABLE_SENSOR, PCS_ACTIVE_SENSOR, ) + from .number import EVSE_NUMBERS # pylint: disable=import-outside-toplevel from .sensor_definitions import ( # pylint: disable=import-outside-toplevel all_sensor_descriptions, ) @@ -478,6 +505,7 @@ def platform_descriptions() -> tuple[EntityDescription, ...]: *all_sensor_descriptions(), *BINARY_SENSORS, *EVSE_BINARY_SENSORS, + *EVSE_NUMBERS, GRID_ISLANDABLE_SENSOR, BESS_CONNECTED_SENSOR, PCS_ACTIVE_SENSOR, diff --git a/custom_components/span_panel/icons.json b/custom_components/span_panel/icons.json index 0b790506..d96c4534 100644 --- a/custom_components/span_panel/icons.json +++ b/custom_components/span_panel/icons.json @@ -48,6 +48,11 @@ "default": "mdi:transmission-tower" } }, + "number": { + "evse_charge_current_limit": { + "default": "mdi:ev-station" + } + }, "select": { "circuit_priority": { "default": "mdi:priority-high" diff --git a/custom_components/span_panel/number.py b/custom_components/span_panel/number.py new file mode 100644 index 00000000..ab62620d --- /dev/null +++ b/custom_components/span_panel/number.py @@ -0,0 +1,295 @@ +"""Number entities for the Span Panel — today, one per commissioned EV charger. + +The charge-current ceiling is the only settable property v1.0 puts outside the +panel and its circuits, and the only control this integration has ever offered +that carries a physical bound: the installer commissions a maximum from the +breaker rating and J1772 derating, and nothing a user does may exceed it. + +So every number this platform builds is described by the panel rather than by +this module. The value, the maximum, and the fact that a control exists at all +come from the charger's own `$description`, resolved in the library +(`span_panel_api_schema_1.charge_limit`) because the node carrying the limit has +two spellings in circulation and the `$description` is the specification's +authority on which one a charger publishes. Nothing here names a wire property. + +**A control is offered only where the panel declares one.** `$settable` on the +limit is what creates the entity; the commissioned ceiling is what bounds it. +A charger that declares neither gets no entity, which is the honest rendering of +`charge-limit.md`'s absence semantics — "the EVSE has no adjustable +charge-current ceiling (it charges at a fixed rate)". + +**A pending write shows as an attribute, not as a state.** The panel echoes an +accepted command on the Homie `$target` topic and republishes the property when +it takes effect, and the priority select already renders that pair exactly this +way (`circuit.priority_target`). Reporting the requested value as the state +instead would show a limit the charger may never have accepted. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass +import logging +from typing import Any, Final + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.const import UnitOfElectricCurrent +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from span_panel_api import EvseControlProtocol, SpanEvseSnapshot, SpanPanelSnapshot +from span_panel_api.exceptions import SpanPanelServerError + +from . import SpanPanelConfigEntry +from .const import CONF_DEVICE_NAME, DOMAIN, USE_CIRCUIT_NUMBERS +from .coordinator import SpanPanelCoordinator +from .entity import SpanPanelEntity +from .field_paths import DerivedReason, FieldPathDeclarationMixin +from .helpers import build_evse_unique_id_for_entry, resolve_evse_display_suffix +from .util import evse_device_info + +_LOGGER: logging.Logger = logging.getLogger(__name__) + +PARALLEL_UPDATES = 1 + +_EMPTY_EVSE = SpanEvseSnapshot(node_id="", feed_circuit_id="") +"""Fallback for a charger that disappears mid-session, as the other EVSE platforms use.""" + + +@dataclass(frozen=True) +class SpanEvseNumberRequiredKeysMixin(FieldPathDeclarationMixin): + """Required keys mixin for EVSE number entities.""" + + value_fn: Callable[[SpanEvseSnapshot], int | None] + maximum_fn: Callable[[SpanEvseSnapshot], int | None] + target_fn: Callable[[SpanEvseSnapshot], int | None] + settable_fn: Callable[[SpanEvseSnapshot], bool] + set_fn: Callable[[EvseControlProtocol, str, int], Awaitable[None]] + + +@dataclass(frozen=True, kw_only=True) +class SpanEvseNumberEntityDescription(NumberEntityDescription, SpanEvseNumberRequiredKeysMixin): + """Describes an EVSE number entity.""" + + +EVSE_CHARGE_CURRENT_LIMIT: Final = SpanEvseNumberEntityDescription( + key="evse_charge_current_limit", + field_path="evse.charge_current_limit_a", + # One field, produced by schema_1 alone: flat firmware's `evse` device type + # carries `advertised-current` — what the charger is offering the vehicle, + # read-only — and no settable ceiling anywhere, so the both-adapters gate + # cannot be satisfied. schema_1 carries a metadata row for the field, which + # is what makes the path SCHEMA_1_ONLY rather than NEITHER and buys the + # entity unit validation against the charger's own `$description`. + derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD, + 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, + # 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 + # `test_evse_charge_limit.py` rather than assumed here. + native_step=1, + mode=NumberMode.BOX, + entity_category=EntityCategory.CONFIG, + value_fn=lambda evse: evse.charge_current_limit_a, + maximum_fn=lambda evse: evse.charge_current_ceiling_a, + 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), +) +"""The owner's charge-current ceiling. + +`mode=BOX` rather than a slider: the useful values are a handful of amperages an +installer or an owner knows by name (16, 24, 32, 40), and a slider over an +installer-set range invites dragging past the value someone meant. The range is +still enforced — Home Assistant checks the service call against `min_value` and +`max_value`, and the library refuses anything above the commissioned ceiling +before it reaches the wire. + +Deliberately `EntityCategory.CONFIG`, beside the circuit priority select: this +changes how the panel behaves rather than reporting how it is behaving. +""" + +EVSE_NUMBERS: tuple[SpanEvseNumberEntityDescription, ...] = (EVSE_CHARGE_CURRENT_LIMIT,) + + +class SpanEvseNumber(SpanPanelEntity, NumberEntity): + """One settable amperage on one commissioned EV charger.""" + + def __init__( + self, + data_coordinator: SpanPanelCoordinator, + description: SpanEvseNumberEntityDescription, + evse_id: str, + ) -> None: + """Initialize the EVSE number.""" + super().__init__(data_coordinator, context=description) + snapshot: SpanPanelSnapshot = data_coordinator.data + self._evse_id = evse_id + # The same object under two names. `entity_description` is what Home + # Assistant and `SpanPanelEntity._source_field_path` read, and its + # declared type is `NumberEntityDescription`; `_description` is the same + # instance at the type this platform actually declared, so the readers + # below stay on the description rather than being copied off it onto the + # entity. Copying is what splits a declaration from its reader, which is + # the drift `field_paths` exists to prevent. + self.entity_description = description + self._description = description + + panel_name = ( + data_coordinator.config_entry.data.get( + CONF_DEVICE_NAME, data_coordinator.config_entry.title + ) + or "Span Panel" + ) + evse = snapshot.evse.get(evse_id, _EMPTY_EVSE) + use_circuit_numbers = data_coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) + self._attr_device_info = evse_device_info( + snapshot.serial_number, + evse, + panel_name, + resolve_evse_display_suffix(evse, snapshot, use_circuit_numbers), + panel_device_id=data_coordinator.config_entry.runtime_data.panel_device_id, + ) + self._attr_unique_id = build_evse_unique_id_for_entry( + data_coordinator, + snapshot, + evse_id, + description.key, + data_coordinator.config_entry.data.get( + CONF_DEVICE_NAME, data_coordinator.config_entry.title + ), + ) + self._apply(evse) + + def _evse(self) -> SpanEvseSnapshot: + snapshot: SpanPanelSnapshot | None = self.coordinator.data + if snapshot is None: + return _EMPTY_EVSE + return snapshot.evse.get(self._evse_id, _EMPTY_EVSE) + + def _apply(self, evse: SpanEvseSnapshot) -> None: + """Take the reading and the bound the panel currently publishes. + + 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. + """ + 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 + + @property + def available(self) -> bool: + """False while the panel is offline or the bound is unknown. + + Offline follows the priority select: a control that cannot reach the + panel is not a control. + + The bound is the addition, and it is this entity's own hazard. A number + must report *some* maximum, so an unpublished ceiling would otherwise be + rendered as Home Assistant's default of 100 — a plausible-looking + amperage that no installer commissioned. Reporting unavailable says the + panel has not told us what the charger is rated for, which is the true + statement; the entity comes back when the ceiling does. + """ + if self.coordinator.panel_offline: + return False + if self._description.maximum_fn(self._evse()) is None: + return False + return super().available + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """The pending write, while the panel is echoing one. + + The same rendering the priority select gives `priority_target`, and for + the same reason: `$target` is a command in flight, and the state stays + the value the charger is actually enforcing until it republishes. + """ + target = self._description.target_fn(self._evse()) + if target is None: + return None + return {"charge_current_limit_target": target} + + async def async_set_native_value(self, value: float) -> None: + """Ask the panel to lower (or restore) this charger's ceiling. + + Home Assistant has already rejected anything outside `min_value` / + `max_value` by the time this runs, and the library refuses anything above + the commissioned ceiling again before publishing. That is not redundancy: + the first check is against the range this entity last reported, the second + against what the panel is publishing now, and a recommissioning between + the two is exactly when they differ. + + A fractional request truncates rather than rounds. The property is + declared `integer`, so some whole number has to be chosen, and for a + ceiling the safe direction is down: asking for 16.7 A and getting 16 is + a slower charge, asking for it and getting 17 is a current the user did + not request. + """ + client = self.coordinator.client + if not isinstance(client, EvseControlProtocol): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="evse_charge_limit_unsupported", + translation_placeholders={"charger": self._evse_id}, + ) + try: + await self._description.set_fn(client, self._evse_id, int(value)) + except SpanPanelServerError as err: + _LOGGER.warning("SPAN panel refused a charge-current limit: %s", err) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="evse_charge_limit_failed", + translation_placeholders={"charger": self._evse_id, "reason": str(err)}, + ) from err + await self.coordinator.async_request_refresh() + + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._apply(self._evse()) + super()._handle_coordinator_update() + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: SpanPanelConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up number entities for Span Panel.""" + _LOGGER.debug("ASYNC SETUP ENTRY NUMBER") + + coordinator = config_entry.runtime_data.coordinator + snapshot: SpanPanelSnapshot = coordinator.data + + async_add_entities( + [ + SpanEvseNumber(coordinator, description, evse_id) + for evse_id, evse in snapshot.evse.items() + for description in EVSE_NUMBERS + # The declaration is the gate, never the value: a charger that + # declares the property settable and has not published one yet still + # has the control, and a charger that publishes a value it does not + # declare settable does not. + if description.settable_fn(evse) + ] + ) diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index 4d79c150..9ecc5d28 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -163,6 +163,11 @@ "name": "GFE Override: Grid Connected" } }, + "number": { + "evse_charge_current_limit": { + "name": "EVSE Charge Current Limit" + } + }, "select": { "circuit_priority": { "name": "Circuit Priority", @@ -400,6 +405,12 @@ "circuit_relay_failed": { "message": "Failed to set circuit relay state for {circuit}." }, + "evse_charge_limit_failed": { + "message": "Failed to set the charge-current limit for charger {charger}: {reason}" + }, + "evse_charge_limit_unsupported": { + "message": "This SPAN panel offers no charge-current control for charger {charger}." + }, "export_manifest_no_entries": { "message": "No SPAN panel configuration entries are loaded. Add and configure a SPAN panel before calling this service." }, diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index 4d79c150..9ecc5d28 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -163,6 +163,11 @@ "name": "GFE Override: Grid Connected" } }, + "number": { + "evse_charge_current_limit": { + "name": "EVSE Charge Current Limit" + } + }, "select": { "circuit_priority": { "name": "Circuit Priority", @@ -400,6 +405,12 @@ "circuit_relay_failed": { "message": "Failed to set circuit relay state for {circuit}." }, + "evse_charge_limit_failed": { + "message": "Failed to set the charge-current limit for charger {charger}: {reason}" + }, + "evse_charge_limit_unsupported": { + "message": "This SPAN panel offers no charge-current control for charger {charger}." + }, "export_manifest_no_entries": { "message": "No SPAN panel configuration entries are loaded. Add and configure a SPAN panel before calling this service." }, diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index 8e895a6f..34bb52a7 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -163,6 +163,11 @@ "name": "Anulación GFE: Conectado a Red" } }, + "number": { + "evse_charge_current_limit": { + "name": "Límite de Corriente de Carga EVSE" + } + }, "select": { "circuit_priority": { "name": "Prioridad del Circuito", @@ -400,6 +405,12 @@ "circuit_relay_failed": { "message": "Error al establecer el estado del relé del circuito para {circuit}." }, + "evse_charge_limit_failed": { + "message": "No se pudo establecer el límite de corriente de carga para el cargador {charger}: {reason}" + }, + "evse_charge_limit_unsupported": { + "message": "Este panel SPAN no ofrece control de corriente de carga para el cargador {charger}." + }, "export_manifest_no_entries": { "message": "No hay entradas de configuración de SPAN Panel cargadas. Agregue y configure un SPAN Panel antes de llamar a este servicio." }, diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index ad1d7f3b..dea10a79 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -163,6 +163,11 @@ "name": "Forçage GFE : Connecté au Réseau" } }, + "number": { + "evse_charge_current_limit": { + "name": "Limite de Courant de Charge EVSE" + } + }, "select": { "circuit_priority": { "name": "Priorité du Circuit", @@ -400,6 +405,12 @@ "circuit_relay_failed": { "message": "Échec de la définition de l'état du relais du circuit pour {circuit}." }, + "evse_charge_limit_failed": { + "message": "Échec du réglage de la limite de courant de charge pour le chargeur {charger} : {reason}" + }, + "evse_charge_limit_unsupported": { + "message": "Ce panneau SPAN n'offre aucun contrôle du courant de charge pour le chargeur {charger}." + }, "export_manifest_no_entries": { "message": "Aucune entrée de configuration SPAN Panel n'est chargée. Ajoutez et configurez un panneau SPAN avant d'appeler ce service." }, diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index 36a228cc..5309c41d 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -163,6 +163,11 @@ "name": "GFEオーバーライド:系統接続" } }, + "number": { + "evse_charge_current_limit": { + "name": "EVSE充電電流上限" + } + }, "select": { "circuit_priority": { "name": "回路優先度", @@ -400,6 +405,12 @@ "circuit_relay_failed": { "message": "{circuit}の回路リレー状態の設定に失敗しました。" }, + "evse_charge_limit_failed": { + "message": "充電器 {charger} の充電電流上限を設定できませんでした: {reason}" + }, + "evse_charge_limit_unsupported": { + "message": "このSPANパネルは充電器 {charger} の充電電流制御に対応していません。" + }, "export_manifest_no_entries": { "message": "SPANパネルの設定エントリが読み込まれていません。このサービスを呼び出す前に、SPANパネルを追加して設定してください。" }, diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index 7a508f23..e67c4436 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -163,6 +163,11 @@ "name": "Substituição GFE: Conectado à Rede" } }, + "number": { + "evse_charge_current_limit": { + "name": "Limite de Corrente de Carga EVSE" + } + }, "select": { "circuit_priority": { "name": "Prioridade do Circuito", @@ -400,6 +405,12 @@ "circuit_relay_failed": { "message": "Falha ao definir o estado do relé do circuito para {circuit}." }, + "evse_charge_limit_failed": { + "message": "Falha ao definir o limite de corrente de carga do carregador {charger}: {reason}" + }, + "evse_charge_limit_unsupported": { + "message": "Este painel SPAN não oferece controlo da corrente de carga para o carregador {charger}." + }, "export_manifest_no_entries": { "message": "Nenhuma entrada de configuração do painel SPAN está carregada. Adicione e configure um painel SPAN antes de chamar este serviço." }, diff --git a/tests/fixtures/unread_declarations_baseline.json b/tests/fixtures/unread_declarations_baseline.json index 1ca96e9d..b9767d52 100644 --- a/tests/fixtures/unread_declarations_baseline.json +++ b/tests/fixtures/unread_declarations_baseline.json @@ -4,8 +4,6 @@ "circuit/connection/feeds-device-type": "redundant: the library resolves a DER's class by dereferencing feeds-device-id against the tree's declared $type", "distribution-enclosure/status/postal-code": "deliberate skip: location data with no Home Assistant value, and surfacing it would copy the user's location into recorder history", "distribution-enclosure/status/time-zone": "deliberate skip: Home Assistant owns the time zone, so a mismatch is a Repair rather than an entity", - "evse/config/max-charge-current": "the installer ceiling; it becomes native_max_value of the charge-current number entity rather than an entity of its own, and is deferred with it (Task 6)", - "evse/config/user-max-charge-current": "the only settable unread property; the eBus catalog spells this capability charge-limit with different property names, so the write topic waits on a real-firmware capture (Task 6)", "lugs/connection/count": "the lugs counterpart of circuit/connection/count, with no fed-device story upstream and no producer publishing it", "lugs/connection/fed-by-device-type": "redundant: the id dereferences to the device's declared $type, which is how the library already resolves classes", "lugs/connection/feeds-device-status": "feedthrough link-health; the feeds-* half of the downstream lugs record is topology to consume, and no producer publishes it", diff --git a/tests/test_evse_charge_limit.py b/tests/test_evse_charge_limit.py new file mode 100644 index 00000000..186d2542 --- /dev/null +++ b/tests/test_evse_charge_limit.py @@ -0,0 +1,640 @@ +"""The EVSE charge-current limit, as the integration's first number entity. + +The only settable property the v1.0 catch-up surfaces, so this is the one place +in the catch-up where being wrong reaches the panel rather than the dashboard. +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 +assertion satisfied by reading one charger twice proves nothing about two. + +**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 +broker — including that the topic is addressed by the charger's *device id* +while the entity holds its serial-harmonised snapshot key, which are different +strings and would both look plausible in a log. + +**The control exists only where the panel declares it.** `$settable` is the +gate, so a charger whose limit is not declared settable gets no entity at all — +asserted through `async_setup_entry`, because an entity that must not exist +cannot be observed by asking an entity for its state. +""" + +from __future__ import annotations + +from collections.abc import Sequence +import json +from unittest.mock import AsyncMock, MagicMock + +from homeassistant.components.number import NumberDeviceClass, NumberMode +from homeassistant.const import Platform, UnitOfElectricCurrent +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import EntityCategory +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry +from span_panel_api import SpanPanelSnapshot + +from custom_components.span_panel import PLATFORMS, SpanPanelRuntimeData +from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.field_paths import ( + RESIDUAL_EXEMPT_PATHS, + DerivedReason, + Producibility, + platform_descriptions, +) +from custom_components.span_panel.number import ( + EVSE_CHARGE_CURRENT_LIMIT, + SpanEvseNumber, + async_setup_entry, +) + +from .adapter_fixtures import ( + SCHEMA_ONE_PANEL, + schema_one_metadata, + schema_one_snapshot, + schema_one_tree, +) + +EVSE = "evse" +EVSE_2 = "evse-2" +"""The two chargers the capture commissions. Two is what makes cross-wiring falsifiable.""" + +CONFIG_NODE = "config" +CEILING_TOPIC = f"{CONFIG_NODE}/max-charge-current" +LIMIT_TOPIC = f"{CONFIG_NODE}/user-max-charge-current" + +FIELD_LIMIT = "evse.charge_current_limit_a" +FIELD_CEILING = "evse.charge_current_ceiling_a" +FIELD_TARGET = "evse.charge_current_limit_target_a" +FIELD_SETTABLE = "evse.charge_current_limit_settable" + + +# --------------------------------------------------------------------------- +# Reading the capture +# --------------------------------------------------------------------------- + + +def _published(tree: dict[str, dict[str, str]], device_id: str, topic: str) -> int: + """What the capture publishes on this topic, or fail saying it does not.""" + value = tree[device_id].get(topic) + assert value is not None, f"{device_id} publishes no {topic} in the capture" + return int(value) + + +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"]) + declared = description["nodes"][CONFIG_NODE]["properties"][property_id] + assert isinstance(declared, dict) + return declared + + +def _snapshot(**overrides: dict[str, int | None]) -> SpanPanelSnapshot: + """A snapshot from the capture with each charger's topics rewritten or removed.""" + tree = schema_one_tree() + for device_id, topics in overrides.items(): + for topic, value in topics.items(): + if value is None: + tree[device_id].pop(topic, None) + else: + tree[device_id][topic] = str(value) + return schema_one_snapshot(tree) + + +def _mutated_description( + tree: dict[str, dict[str, str]], device_id: str, mutate: object +) -> dict[str, dict[str, str]]: + """Rewrite one charger's `$description` through `mutate`, in place on `tree`.""" + description = json.loads(tree[device_id]["$description"]) + assert callable(mutate) + mutate(description) + tree[device_id]["$description"] = json.dumps(description) + return tree + + +def _not_settable(device_id: str) -> SpanPanelSnapshot: + """A charger that publishes a limit and does not declare it writable.""" + + def drop(description: dict[str, dict[str, dict[str, dict[str, dict[str, object]]]]]) -> None: + description["nodes"][CONFIG_NODE]["properties"]["user-max-charge-current"].pop("settable") + + 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. + + 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. + """ + tree = schema_one_tree() + description = json.loads(tree[device_id]["$description"]) + properties = description["nodes"].pop(CONFIG_NODE)["properties"] + description["nodes"]["charge-limit"] = { + "name": "charge-limit", + "type": "energy.ebus.capability.charge-limit", + "properties": { + "installer-max": properties["max-charge-current"], + "owner-limit": properties["user-max-charge-current"], + }, + } + 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) + return tree + + +def _fed_adapter(*extra: tuple[str, str]) -> object: + """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 + + 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) + for topic, value in extra: + adapter.handle_message(topic, value) + return adapter + + +# --------------------------------------------------------------------------- +# Building the entities +# --------------------------------------------------------------------------- + + +def _coordinator(snapshot: SpanPanelSnapshot, client: object | None = None) -> MagicMock: + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.panel_offline = False + coordinator.last_update_success = True + coordinator.unresolved_paths = frozenset() + coordinator.client = MagicMock() if client is None else client + coordinator.config_entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={}, + title="SPAN Panel", + unique_id=snapshot.serial_number, + ) + coordinator.config_entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, panel_device_id="panel-device-id" + ) + coordinator.async_request_refresh = AsyncMock() + return coordinator + + +async def _created( + hass: HomeAssistant, snapshot: SpanPanelSnapshot, client: object | None = None +) -> list[SpanEvseNumber]: + """Everything `number.async_setup_entry` creates for one snapshot.""" + coordinator = _coordinator(snapshot, client) + async_add_entities = MagicMock() + + await async_setup_entry(hass, coordinator.config_entry, async_add_entities) + + added: Sequence[SpanEvseNumber] = async_add_entities.call_args.args[0] + for entity in added: + assert isinstance(entity, SpanEvseNumber) + return list(added) + + +def _serial(tree: dict[str, dict[str, str]], device_id: str) -> str: + serial = tree[device_id].get("info/serial-number") + assert serial, f"{device_id} publishes no serial in the capture" + return serial + + +def _for(created: Sequence[SpanEvseNumber], tree: dict[str, dict[str, str]], device_id: str) -> SpanEvseNumber: + """The number belonging to one charger, found by the serial the snapshot keys it on.""" + serial = _serial(tree, device_id) + matches = [entity for entity in created if entity._evse_id == serial] + assert len(matches) == 1, f"{len(matches)} numbers created for {device_id}, expected 1" + return matches[0] + + +def _refreshed(entity: SpanEvseNumber, snapshot: SpanPanelSnapshot | None) -> SpanEvseNumber: + """Push a new snapshot through the coordinator update the way HA does.""" + entity.coordinator.data = snapshot + entity.async_write_ha_state = MagicMock() + entity._handle_coordinator_update() + return entity + + +# --------------------------------------------------------------------------- +# The platform exists +# --------------------------------------------------------------------------- + + +def test_the_number_platform_is_forwarded() -> None: + """A platform module nothing forwards creates nothing, silently.""" + assert Platform.NUMBER in PLATFORMS + + +async def test_one_number_per_charger_that_declares_a_settable_limit(hass: HomeAssistant) -> None: + tree = schema_one_tree() + created = await _created(hass, schema_one_snapshot(tree)) + + assert [entity._evse_id for entity in created] == [_serial(tree, EVSE), _serial(tree, EVSE_2)] + assert {entity.entity_description.key for entity in created} == {EVSE_CHARGE_CURRENT_LIMIT.key} + assert len({entity.unique_id for entity in created}) == 2 + + +async def test_no_number_where_the_limit_is_not_declared_settable(hass: HomeAssistant) -> None: + """The refusal, at the point where it costs a user nothing. + + A charger publishes a perfectly readable limit and does not declare it + writable. Offering the control anyway would put a write on the wire the + panel never offered, and the user would find out by it not working. + """ + tree = schema_one_tree() + created = await _created(hass, _not_settable(EVSE)) + + assert [entity._evse_id for entity in created] == [_serial(tree, EVSE_2)] + + +async def test_no_number_where_the_charger_declares_no_limit_at_all(hass: HomeAssistant) -> None: + """`charge-limit.md`: absence means the charger charges at a fixed rate.""" + tree = schema_one_tree() + + 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] + + created = await _created(hass, schema_one_snapshot(tree)) + + assert [entity._evse_id for entity in created] == [_serial(tree, EVSE_2)] + + +# --------------------------------------------------------------------------- +# Reading — per charger, from the wire +# --------------------------------------------------------------------------- + + +async def test_each_number_reads_its_own_charger(hass: HomeAssistant) -> None: + """Read each charger's own limit, from values 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. + """ + tree = schema_one_tree() + first = _published(tree, EVSE, LIMIT_TOPIC) - 8 + second = _published(tree, EVSE_2, LIMIT_TOPIC) - 16 + assert first != second + + created = await _created(hass, _snapshot(evse={LIMIT_TOPIC: first}, **{"evse-2": {LIMIT_TOPIC: second}})) + + assert _for(created, tree, EVSE).native_value == first + assert _for(created, tree, EVSE_2).native_value == second + + +async def test_each_number_is_bounded_by_its_own_ceiling(hass: HomeAssistant) -> None: + """`native_max_value` is the commissioned ceiling, and per charger.""" + tree = schema_one_tree() + first = _published(tree, EVSE, CEILING_TOPIC) - 8 + second = _published(tree, EVSE_2, CEILING_TOPIC) - 16 + assert first != second + + created = await _created(hass, _snapshot(evse={CEILING_TOPIC: first}, **{"evse-2": {CEILING_TOPIC: second}})) + + assert _for(created, tree, EVSE).native_max_value == first + assert _for(created, tree, EVSE_2).native_max_value == second + + +async def test_republishing_moves_the_state_and_the_bound(hass: HomeAssistant) -> None: + tree = schema_one_tree() + created = await _created(hass, schema_one_snapshot(tree)) + entity = _for(created, tree, EVSE) + assert entity.native_value == _published(tree, EVSE, LIMIT_TOPIC) + + lowered = _published(tree, EVSE, LIMIT_TOPIC) - 16 + recommissioned = _published(tree, EVSE, CEILING_TOPIC) - 8 + _refreshed(entity, _snapshot(evse={LIMIT_TOPIC: lowered, CEILING_TOPIC: recommissioned})) + + assert entity.native_value == lowered + assert entity.native_max_value == recommissioned + + +async def test_an_unpublished_limit_is_unknown_rather_than_zero(hass: HomeAssistant) -> None: + """The control is still offered — the property is declared, the value is late.""" + tree = schema_one_tree() + created = await _created(hass, _snapshot(evse={LIMIT_TOPIC: None})) + entity = _for(created, tree, EVSE) + + assert entity.native_value is None + assert entity.available is True + + +async def test_an_unpublished_ceiling_makes_the_control_unavailable(hass: HomeAssistant) -> None: + """A number must report some maximum, and Home Assistant's default is 100. + + Rendering an uncommissioned charger as a 0-100 A control would put a + plausible-looking amperage in front of a user that no installer ever set. + Unavailable says the panel has not told us what the charger is rated for. + """ + tree = schema_one_tree() + created = await _created(hass, _snapshot(evse={CEILING_TOPIC: None})) + + assert _for(created, tree, EVSE).available is False + assert _for(created, tree, EVSE_2).available is True + + +async def test_a_coordinator_with_no_snapshot_yet_takes_the_control_down(hass: HomeAssistant) -> None: + """A failed first refresh leaves `coordinator.data` unset. + + Every other read here goes through the snapshot, so the entity has to answer + without one rather than raising inside a property Home Assistant polls. + """ + tree = schema_one_tree() + created = await _created(hass, schema_one_snapshot(tree)) + entity = _for(created, tree, EVSE) + + _refreshed(entity, None) + + assert entity.available is False + assert entity.native_value is None + assert entity.extra_state_attributes is None + + +async def test_the_control_is_unavailable_while_the_panel_is_offline(hass: HomeAssistant) -> None: + tree = schema_one_tree() + coordinator = _coordinator(schema_one_snapshot(tree)) + async_add_entities = MagicMock() + await async_setup_entry(hass, coordinator.config_entry, async_add_entities) + entity = _for(async_add_entities.call_args.args[0], tree, EVSE) + assert entity.available is True + + coordinator.panel_offline = True + + assert entity.available is False + + +# --------------------------------------------------------------------------- +# The pending write +# --------------------------------------------------------------------------- + + +async def test_a_pending_write_is_an_attribute_and_not_the_state(hass: HomeAssistant) -> 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. + """ + tree = schema_one_tree() + pending = _published(tree, EVSE, LIMIT_TOPIC) - 8 + adapter = _fed_adapter((f"ebus/5/{EVSE}/{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 _for(created, tree, EVSE_2).extra_state_attributes is None + + +# --------------------------------------------------------------------------- +# The other spelling +# --------------------------------------------------------------------------- + + +async def test_the_catalogued_spelling_produces_the_same_control(hass: HomeAssistant) -> 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. + """ + tree = schema_one_tree() + 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) + + assert catalogued.unique_id == published.unique_id + assert catalogued.native_value == published.native_value + assert catalogued.native_max_value == published.native_max_value + assert catalogued.available == published.available + + +# --------------------------------------------------------------------------- +# The control's shape, taken from the declaration +# --------------------------------------------------------------------------- + + +def test_the_unit_is_the_one_the_charger_declares() -> None: + """The entity's unit against the panel's, through the adapter's metadata row. + + The same check `evaluate_field_metadata` makes for every sensor, made here + because a number carries a unit and this platform is not a sensor platform. + """ + declared = schema_one_metadata()[FIELD_LIMIT] + + assert declared.resolved is True + assert declared.unit == EVSE_CHARGE_CURRENT_LIMIT.native_unit_of_measurement + assert EVSE_CHARGE_CURRENT_LIMIT.native_unit_of_measurement == UnitOfElectricCurrent.AMPERE + + +def test_the_step_is_the_granularity_the_charger_declares() -> None: + """A step of 1 is a claim about the datatype, so it is checked against it.""" + tree = schema_one_tree() + + assert _declaration(tree, EVSE, "user-max-charge-current")["datatype"] == "integer" + assert schema_one_metadata()[FIELD_LIMIT].datatype == "integer" + assert EVSE_CHARGE_CURRENT_LIMIT.native_step == 1 + + +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 + assert EVSE_CHARGE_CURRENT_LIMIT.native_min_value == 0 + + +# --------------------------------------------------------------------------- +# Writing +# --------------------------------------------------------------------------- + + +def _live_client() -> tuple[object, MagicMock]: + """A real transport over a real schema_1 adapter fed the capture. + + Not a mock: the point of the write tests is the topic and the payload, and a + mocked client asserts only that the integration called the method it was + written to call. + """ + from span_panel_api.mqtt.client import MqttClientConfig, SpanMqttClient + + client = SpanMqttClient( + host="192.168.1.1", + serial_number=SCHEMA_ONE_PANEL, + broker_config=MqttClientConfig(broker_host="h", username="u", password="p"), + ) + client._adapter = _fed_adapter() + bridge = MagicMock() + client._bridge = bridge + return client, bridge + + +async def test_the_write_reaches_the_wire_as_one_publish(hass: HomeAssistant) -> None: + """Entity to broker, with the exact topic and payload asserted. + + The topic is addressed by the charger's **device id** while the entity holds + its serial-harmonised snapshot key — two different strings, both plausible + in a log, and only one of which any panel subscribes to. + """ + tree = schema_one_tree() + client, bridge = _live_client() + created = await _created(hass, schema_one_snapshot(tree), client) + entity = _for(created, tree, EVSE) + asked = _published(tree, EVSE, CEILING_TOPIC) - 8 + + await entity.async_set_native_value(float(asked)) + + assert entity._evse_id != EVSE + bridge.publish.assert_called_once_with( + f"ebus/5/{EVSE}/config/user-max-charge-current/set", str(asked), qos=1 + ) + entity.coordinator.async_request_refresh.assert_awaited_once() + + +async def test_the_write_goes_to_the_charger_the_entity_belongs_to(hass: HomeAssistant) -> None: + tree = schema_one_tree() + client, bridge = _live_client() + created = await _created(hass, schema_one_snapshot(tree), client) + asked = _published(tree, EVSE_2, CEILING_TOPIC) - 8 + + await _for(created, tree, EVSE_2).async_set_native_value(float(asked)) + + bridge.publish.assert_called_once_with( + f"ebus/5/{EVSE_2}/config/user-max-charge-current/set", str(asked), qos=1 + ) + + +async def test_a_fractional_request_truncates_downward(hass: HomeAssistant) -> None: + """The property is declared `integer`, so some whole number has to be chosen. + + Down, because this is a ceiling: asking for a fraction and getting the lower + whole number is a slower charge, getting the higher one is a current the + user did not request. + """ + tree = schema_one_tree() + client, bridge = _live_client() + created = await _created(hass, schema_one_snapshot(tree), client) + whole = _published(tree, EVSE, CEILING_TOPIC) - 8 + + await _for(created, tree, EVSE).async_set_native_value(whole + 0.7) + + bridge.publish.assert_called_once_with( + f"ebus/5/{EVSE}/config/user-max-charge-current/set", str(whole), qos=1 + ) + + +async def test_a_value_above_the_ceiling_is_refused_before_it_reaches_the_wire( + hass: HomeAssistant, +) -> None: + """The second of the two range checks, and the one that matters. + + Home Assistant rejects a service call outside the range this entity last + reported. This is the library rejecting it against what the panel is + publishing now — which is the same check only until an installer + recommissions the charger between the two. + """ + tree = schema_one_tree() + client, bridge = _live_client() + created = await _created(hass, schema_one_snapshot(tree), client) + over = _published(tree, EVSE, CEILING_TOPIC) + 1 + + with pytest.raises(HomeAssistantError) as raised: + await _for(created, tree, EVSE).async_set_native_value(float(over)) + + assert raised.value.translation_key == "evse_charge_limit_failed" + bridge.publish.assert_not_called() + + +async def test_the_range_home_assistant_checks_is_the_commissioned_one(hass: HomeAssistant) -> None: + """The first of the two checks, asserted through the range the entity reports. + + `number.async_set_value` refuses a service call outside `min_value` / + `max_value` before it ever reaches this platform, and for a non-temperature + device class those are `native_min_value` / `native_max_value` unconverted. + So the range the entity reports has to be the panel's, which is what reading + `native_max_value` off the wire buys — the state-side properties are not + read here because they raise until the entity is attached to a platform. + """ + tree = schema_one_tree() + created = await _created(hass, schema_one_snapshot(tree)) + entity = _for(created, tree, EVSE) + + assert entity.native_max_value == _published(tree, EVSE, CEILING_TOPIC) + assert entity.native_min_value == 0 + + +async def test_a_client_with_no_evse_control_is_reported_rather_than_ignored( + hass: HomeAssistant, +) -> None: + """A transport that does not implement the control at all — a flat panel's.""" + + class _NoEvseControl: + """Everything but `set_evse_charge_limit`.""" + + tree = schema_one_tree() + created = await _created(hass, schema_one_snapshot(tree), _NoEvseControl()) + + with pytest.raises(HomeAssistantError) as raised: + await _for(created, tree, EVSE).async_set_native_value(16.0) + + assert raised.value.translation_key == "evse_charge_limit_unsupported" + + +# --------------------------------------------------------------------------- +# The declarations this entity makes about itself +# --------------------------------------------------------------------------- + + +def test_the_description_names_its_source_field_and_why_it_is_exempt() -> None: + """Both `field_path` and `derived`, as a schema-conditional description must. + + The only settable entity to carry the pair, and the Repair naming a dead + field has to be able to name this entity too. + """ + assert EVSE_CHARGE_CURRENT_LIMIT.field_path == FIELD_LIMIT + assert EVSE_CHARGE_CURRENT_LIMIT.derived is DerivedReason.SCHEMA_CONDITIONAL_FIELD + assert EVSE_CHARGE_CURRENT_LIMIT in platform_descriptions() + + +def test_every_field_this_control_reads_is_enumerated() -> None: + """Four reads, four annotations, each checked against the adapters elsewhere. + + The pair the panel publishes as readings carries a schema_1 metadata row; the + pair that describes a command carries none on either adapter. + """ + assert RESIDUAL_EXEMPT_PATHS[FIELD_LIMIT] is Producibility.SCHEMA_1_ONLY + assert RESIDUAL_EXEMPT_PATHS[FIELD_CEILING] is Producibility.SCHEMA_1_ONLY + assert RESIDUAL_EXEMPT_PATHS[FIELD_TARGET] is Producibility.NEITHER + assert RESIDUAL_EXEMPT_PATHS[FIELD_SETTABLE] is Producibility.NEITHER diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py index ee480d5f..37b272dc 100644 --- a/tests/test_field_path_conformance.py +++ b/tests/test_field_path_conformance.py @@ -282,7 +282,13 @@ def test_no_exempt_path_is_producible_by_both() -> None: # algorithm and the two SoC thresholds parsed out of it -- read as # attributes on `dsm_state`. Flat has no `shed` node, and a JSON document # has no unit surface for a schema_1 row to describe. - Producibility.NEITHER: 41, + # +2 for the EVSE charge-current control's two non-readings: + # `charge_current_limit_settable`, the `$settable` flag `number.py` gates + # entity creation on, and `charge_current_limit_target_a`, the Homie + # `$target` echo it renders as an attribute. Facts about a command rather + # than readings, so no adapter carries a row for either -- the same shape as + # the `circuit.*_target` pair. + Producibility.NEITHER: 43, # +1 for `panel.dominant_power_source`, the `grid_forming_entity` sensor's # source field. It was read by a `SCHEMA_CONDITIONAL_FIELD` description and # enumerated nowhere, so `evaluate_field_metadata` counted it as produced- @@ -311,7 +317,13 @@ def test_no_exempt_path_is_producible_by_both() -> None: # circuit's `connection/feeds-device-status` — while flat publishes # `connected` on the BESS and on no other device class, so neither can # satisfy the both-adapters gate. - Producibility.SCHEMA_1_ONLY: 10, + # +2 for the EVSE charge-current pair behind the + # `evse_charge_current_limit` number: the settable limit its description + # names, and the commissioned ceiling the entity reads for + # `native_max_value`. schema_1 resolves both from the charger's own + # `$description`; flat's `evse` type declares no settable ceiling at all, so + # neither can satisfy the both-adapters gate. + Producibility.SCHEMA_1_ONLY: 12, } """The exemption inventory, by reason. See `test_exempt_inventory_is_complete`.""" From 85ca61997ba16e4257547426902a28f05edd59b1 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:22:17 -0700 Subject: [PATCH 056/345] feat(device): give the solar inverter a card of its own, and move its entities to it PV was the last DER on a v1.0 panel with no device. Its vendor, model and nameplate capacity were rendered as three diagnostic sensors on the *panel's* card -- beside the panel's own manufacturer and model, so the card whose job is saying which enclosure this is read as if the enclosure were an Enphase inverter. The firmware version the library has read all along reached nothing, because a version has nowhere to go but a card; `pv/info/firmware-version` was baselined saying exactly that, and that line goes. `SUB_DEVICE_PV` and `pv_device_info` follow the BESS and the MID, and the four kinds now sit under one classifier: `classify_sub_device_identifier` tests the EVSE *infix* before the three suffixes, because a suffix rule cannot tell a charger whose node id ends in `_pv` from an inverter. The identifier is `{panel serial}_pv` and deliberately does not mention the inverter's serial. Every PV description declares `info/serial-number` and no producer publishes one; an identifier preferring it would change the day firmware started publishing, and a device identifier is what a consumer keys its registry on -- so that day would read as the inverter being replaced. The serial stays baselined, with the reason reworded to name this second thing that would have moved. Five entities change device: `pv_power`, `pv_vendor`, `pv_product`, `pv_nameplate_capacity` and `pv_panel_link`. Home Assistant re-homes an entity itself when it re-registers with new `device_info`, so there is no bespoke migration and deliberately none written. What it does not do is re-derive an `entity_id`, and it derives a new one from the *device* name -- so a fresh installation would have landed on `sensor.span_panel_solar_pv_vendor` where every existing one has `sensor.span_panel_pv_vendor`, two shapes for the same entity decided by install date. `construct_panel_scoped_entity_id` pins the panel-scoped id on both, which is what `construct_single_circuit_entity_id` already does for the circuit sensors that live on an EVSE. `tests/test_pv_device.py` seeds a registry in the released shape, sets both platforms up through real entity platforms, and reads the registry back: every entity keeps its `entity_id` and its `unique_id`, lands on the inverter, and leaves no duplicate or orphan behind. A fresh install is compared against a migrated one on a second Home Assistant instance and must match entity for entity. A user-renamed `entity_id` still wins over the pin. --- custom_components/span_panel/binary_sensor.py | 43 +- .../span_panel/entity_resolver.py | 34 + custom_components/span_panel/field_paths.py | 13 + custom_components/span_panel/helpers.py | 2 + custom_components/span_panel/sensor.py | 53 +- custom_components/span_panel/sensor_panel.py | 28 +- custom_components/span_panel/services.py | 8 +- custom_components/span_panel/util.py | 60 +- custom_components/span_panel/websocket.py | 11 +- .../unread_declarations_baseline.json | 3 +- tests/test_device_links.py | 34 +- tests/test_field_path_conformance.py | 6 +- tests/test_pv_device.py | 739 ++++++++++++++++++ tests/test_sensor_entities.py | 16 +- tests/test_websocket.py | 10 +- 15 files changed, 1024 insertions(+), 36 deletions(-) create mode 100644 tests/test_pv_device.py diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py index 0b2e6bda..f914dee7 100644 --- a/custom_components/span_panel/binary_sensor.py +++ b/custom_components/span_panel/binary_sensor.py @@ -34,12 +34,13 @@ from .helpers import ( build_binary_sensor_unique_id_for_entry, build_evse_unique_id_for_entry, + construct_panel_scoped_entity_id, has_bess, has_mid, has_pcs, resolve_evse_display_suffix, ) -from .util import bess_device_info, evse_device_info +from .util import bess_device_info, evse_device_info, pv_device_info # pylint: disable=invalid-overridden-method @@ -178,8 +179,9 @@ def _grid_islandable(snapshot: SpanPanelSnapshot) -> bool | None: one device class whose link the panel happened to report through the lugs was the only one a user could see. -On the panel device, beside `pv_vendor` and `pv_product`, because the PV is not -yet a sub-device of its own. It moves with them when it becomes one. +On the inverter's own sub-device, beside `pv_vendor` and `pv_product`, which is +where it moved when the PV got a device of its own -- the same place +`bess_connected` sits relative to the battery. `SCHEMA_CONDITIONAL_FIELD` *and* `field_path`: flat firmware publishes `connected` on the BESS and on nothing else, so the both-adapters gate cannot be @@ -232,8 +234,16 @@ def __init__( data_coordinator: SpanPanelCoordinator, description: T, device_info_override: DeviceInfo | None = None, + entity_id_override: str | None = None, ) -> None: - """Initialize Span Panel Circuit entity.""" + """Initialize Span Panel Circuit entity. + + `entity_id_override` is how a sensor that used to sit on the panel's own + card keeps the object id that card gave it after moving to a sub-device -- + see `construct_panel_scoped_entity_id`. Home Assistant treats it as a + suggestion and ignores it for an entity already in the registry, so it + only ever decides what a *new* installation gets. + """ super().__init__(data_coordinator, context=description) snapshot: SpanPanelSnapshot = data_coordinator.data @@ -254,6 +264,9 @@ def __init__( data_coordinator, snapshot, description.key ) + if entity_id_override is not None: + self.entity_id = entity_id_override + @property def available(self) -> bool: """Return entity availability. @@ -541,7 +554,27 @@ async def async_setup_entry( # saying it does not know rather than a fault, and the enum it does publish # has no UNKNOWN member for it to say that with. See `PV_PANEL_LINK_SENSOR`. if snapshot.pv.connected is not None: - entities.append(SpanPanelBinarySensor(coordinator, PV_PANEL_LINK_SENSOR)) + configured_name = coordinator.config_entry.data.get( + CONF_DEVICE_NAME, coordinator.config_entry.title + ) + entities.append( + SpanPanelBinarySensor( + coordinator, + PV_PANEL_LINK_SENSOR, + device_info_override=pv_device_info( + snapshot.serial_number, + snapshot.pv, + configured_name or "Span Panel", + panel_device_id=config_entry.runtime_data.panel_device_id, + ), + entity_id_override=construct_panel_scoped_entity_id( + snapshot, + "binary_sensor", + PV_PANEL_LINK_SENSOR.translation_key or "", + configured_name, + ), + ) + ) # Add EVSE binary sensors for each commissioned charger if snapshot.evse: diff --git a/custom_components/span_panel/entity_resolver.py b/custom_components/span_panel/entity_resolver.py index 88458876..7ea29929 100644 --- a/custom_components/span_panel/entity_resolver.py +++ b/custom_components/span_panel/entity_resolver.py @@ -459,3 +459,37 @@ def construct_unmapped_friendly_name( """Construct friendly name for unmapped circuit sensors.""" # Format: "Unmapped Tab 32 Consumed Energy" return f"Unmapped Tab {circuit_number} {sensor_description_name}" + + +def construct_panel_scoped_entity_id( + snapshot: SpanPanelSnapshot, + platform: str, + translation_key: str, + device_name: str | None = None, +) -> str: + """Return the entity_id a sub-device entity keeps from the panel's card. + + Home Assistant builds an `has_entity_name` entity's object id from the name of + the device it belongs to, so moving an entity to a sub-device would give a + *new* installation `sensor.span_panel_solar_pv_vendor` where every existing + one has `sensor.span_panel_pv_vendor` -- the registry never renames an entity + that already exists. Two shapes for the same entity, decided by install date, + is the long-tail bug this pins shut: an entity that was born on the panel card + keeps the panel-scoped id on both. + + The same thing `construct_single_circuit_entity_id` already does for the + circuit sensors that live on an EVSE sub-device, and for the same reason. + + Suggestion only. Home Assistant honours an `entity_id` an entity sets for + itself when it registers, and ignores it for one that is already registered -- + which is exactly the behaviour wanted here, since an id a user has renamed is + theirs. + + `translation_key` rather than a name, because the name comes from the + translations and is not loaded when an entity is constructed. + `test_the_pinned_ids_are_what_the_panel_card_would_have_generated` holds the + two to each other. + """ + device_info = snapshot_to_device_info(snapshot, device_name) + panel_name = device_info.get("name") or "Span Panel" + return f"{platform}.{slugify(panel_name)}_{translation_key}" diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py index ba874ffd..e58dce2b 100644 --- a/custom_components/span_panel/field_paths.py +++ b/custom_components/span_panel/field_paths.py @@ -202,6 +202,19 @@ class FieldPathDeclarationMixin: "mid.model": Producibility.NEITHER, "mid.serial_number": Producibility.NEITHER, "mid.grid_forming_device_name": Producibility.NEITHER, + # `pv_device_info` reads the inverter's firmware version for its device + # card. `pv.vendor_name` and `pv.model` are not here beside it because + # they are `field_path` declarations on the three PV metadata sensors + # already, and the card reads the same two fields those sensors do. + # + # `NEITHER` for the same reason as the `mid.*` and `panel.*` card reads + # above: flat's `pv` device class declares no firmware version at all, + # and a schema_1 metadata row carries a unit and a datatype for a + # *reading*, which a version string is not. + # + # `pv.serial_number` is deliberately absent -- from this table, from the + # card and from the snapshot. See `pv_device_info`. + "pv.software_version": Producibility.NEITHER, # The `mid_grid_state` sensor's source field — utility-supply health, # the one non-metadata entity the MID brings. Neither adapter maps the # MID at all, which is why the description is `NO_SOURCE_FIELD`. diff --git a/custom_components/span_panel/helpers.py b/custom_components/span_panel/helpers.py index 8609a208..3733020b 100644 --- a/custom_components/span_panel/helpers.py +++ b/custom_components/span_panel/helpers.py @@ -20,6 +20,7 @@ build_switch_unique_id_for_entry, construct_circuit_unique_id_for_entry, construct_multi_circuit_entity_id, + construct_panel_scoped_entity_id, construct_panel_unique_id_for_entry, construct_single_circuit_entity_id, construct_synthetic_unique_id_for_entry, @@ -79,6 +80,7 @@ "construct_circuit_unique_id", "construct_circuit_unique_id_for_entry", "construct_multi_circuit_entity_id", + "construct_panel_scoped_entity_id", "construct_panel_unique_id", "construct_panel_unique_id_for_entry", "construct_select_unique_id", diff --git a/custom_components/span_panel/sensor.py b/custom_components/span_panel/sensor.py index cfbc5c77..cdcdd6b9 100644 --- a/custom_components/span_panel/sensor.py +++ b/custom_components/span_panel/sensor.py @@ -20,6 +20,7 @@ ) from .coordinator import SpanPanelCoordinator from .helpers import ( + construct_panel_scoped_entity_id, has_bess, has_bess_telemetry, has_evse, @@ -78,7 +79,7 @@ SpanPVMetadataSensor, SpanShedForecastSensor, ) -from .util import bess_device_info, evse_device_info, mid_device_info +from .util import bess_device_info, evse_device_info, mid_device_info, pv_device_info # Export the sensor classes for backward compatibility with tests __all__ = [ @@ -478,6 +479,22 @@ def create_battery_sensors( return entities +def _build_pv_device_info( + coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot +) -> DeviceInfo: + """DeviceInfo for the solar inverter sub-device.""" + panel_name = ( + coordinator.config_entry.data.get(CONF_DEVICE_NAME, coordinator.config_entry.title) + or "Span Panel" + ) + return pv_device_info( + snapshot.serial_number, + snapshot.pv, + panel_name, + panel_device_id=coordinator.config_entry.runtime_data.panel_device_id, + ) + + def create_power_flow_sensors( coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot ) -> list[SpanPanelPowerSensor | SpanPVMetadataSensor]: @@ -486,15 +503,43 @@ def create_power_flow_sensors( PV Power — only when PV is commissioned. Site Power — only when the power-flows node is publishing. PV metadata sensors — only when PV is commissioned. + + The PV sensors land on the inverter's own sub-device, matching what the BESS + has done since v1.0: `battery_power` is the enclosure's reading of the + battery and it sits on the battery's card, so `pv_power` -- the enclosure's + reading of the inverter -- belongs on the inverter's. Each keeps the + panel-scoped entity_id it already has; see `construct_panel_scoped_entity_id`. """ entities: list[SpanPanelPowerSensor | SpanPVMetadataSensor] = [] if has_pv(snapshot): - entities.append(SpanPanelPowerSensor(coordinator, PV_POWER_SENSOR, snapshot)) + pv_info = _build_pv_device_info(coordinator, snapshot) + panel_name = coordinator.config_entry.data.get( + CONF_DEVICE_NAME, coordinator.config_entry.title + ) + entities.append( + SpanPanelPowerSensor( + coordinator, + PV_POWER_SENSOR, + snapshot, + device_info_override=pv_info, + entity_id_override=construct_panel_scoped_entity_id( + snapshot, "sensor", PV_POWER_SENSOR.translation_key or "", panel_name + ), + ) + ) - # PV metadata sensors on the main panel device entities.extend( - SpanPVMetadataSensor(coordinator, desc, snapshot) for desc in PV_METADATA_SENSORS + SpanPVMetadataSensor( + coordinator, + desc, + snapshot, + pv_info, + construct_panel_scoped_entity_id( + snapshot, "sensor", desc.translation_key or "", panel_name + ), + ) + for desc in PV_METADATA_SENSORS ) if has_power_flows(snapshot): diff --git a/custom_components/span_panel/sensor_panel.py b/custom_components/span_panel/sensor_panel.py index 161fcc47..dba89837 100644 --- a/custom_components/span_panel/sensor_panel.py +++ b/custom_components/span_panel/sensor_panel.py @@ -457,13 +457,23 @@ def __init__( description: SpanPanelDataSensorEntityDescription, snapshot: SpanPanelSnapshot, device_info_override: DeviceInfo | None = None, + entity_id_override: str | None = None, ) -> None: - """Initialize the enhanced panel power sensor.""" + """Initialize the enhanced panel power sensor. + + `entity_id_override` is how a power sensor that used to sit on the panel's + own card keeps the object id that card gave it after moving to a + sub-device -- see `construct_panel_scoped_entity_id`. Home Assistant + treats it as a suggestion and ignores it for an entity already in the + registry, so it only ever decides what a *new* installation gets. + """ self._description_key = description.key super().__init__(data_coordinator, description, snapshot) if device_info_override is not None: self._attr_device_info = device_info_override + if entity_id_override is not None: + self.entity_id = entity_id_override def _generate_unique_id( self, @@ -657,16 +667,30 @@ def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanMidSnapshot: class SpanPVMetadataSensor( SpanSensorBase[SpanPVMetadataSensorEntityDescription, SpanPanelSnapshot] ): - """PV metadata sensor entity on the main panel device.""" + """PV metadata sensor entity on the PV sub-device. + + On the panel's own card until the inverter got one of its own, which put the + inverter's vendor and model beside the *panel's* vendor and model on the card + whose job is saying which enclosure this is. + + The unique_id stays the panel-scoped one `construct_panel_unique_id_for_entry` + has always built, because a unique_id is an identity and these are the same + three entities they were. Only the device they hang off changes, which is a + registry update Home Assistant performs itself when the entity re-registers. + """ def __init__( self, data_coordinator: SpanPanelCoordinator, description: SpanPVMetadataSensorEntityDescription, snapshot: SpanPanelSnapshot, + device_info_override: DeviceInfo, + entity_id_override: str, ) -> None: """Initialize the PV metadata sensor.""" super().__init__(data_coordinator, description, snapshot) + self._attr_device_info = device_info_override + self.entity_id = entity_id_override def _generate_unique_id( self, diff --git a/custom_components/span_panel/services.py b/custom_components/span_panel/services.py index 0500424b..24581d91 100644 --- a/custom_components/span_panel/services.py +++ b/custom_components/span_panel/services.py @@ -513,8 +513,10 @@ def _resolve_entity_to_favorite_target(entity_id: str) -> tuple[str, FavoriteKin ``kind`` is ``"circuits"`` or ``"sub_devices"``. For circuits, ``target_id`` is the panel-local circuit uuid (extracted from the - entity's unique_id). For sub-devices, ``target_id`` is the HA - device id of the BESS/EVSE; the panel id walks up via ``via_device_id``. + entity's unique_id). For sub-devices, ``target_id`` is the HA device id + of the sub-device; the panel id walks up via ``via_device_id``. Nothing + here enumerates the kinds, so a new one -- the PV inverter most recently + -- is favouritable the day its device exists. Failure paths use distinct translation keys so users see the actual reason their pick was rejected. @@ -551,7 +553,7 @@ def _resolve_entity_to_favorite_target(entity_id: str) -> tuple[str, FavoriteKin # Resolve the panel device id. Sub-devices register with # via_device_id; main panels never do, so via_device_id presence is a - # reliable discriminator (BESS / EVSE today) and we walk up to the + # reliable discriminator whatever kinds exist, and we walk up to the # parent SPAN Panel here. if device_entry.via_device_id is not None: parent = device_registry.async_get(device_entry.via_device_id) diff --git a/custom_components/span_panel/util.py b/custom_components/span_panel/util.py index 85024db4..a821d391 100644 --- a/custom_components/span_panel/util.py +++ b/custom_components/span_panel/util.py @@ -9,6 +9,7 @@ SpanEvseSnapshot, SpanMidSnapshot, SpanPanelSnapshot, + SpanPVSnapshot, ) from .const import DOMAIN @@ -35,6 +36,7 @@ SUB_DEVICE_BESS: Final = "bess" SUB_DEVICE_MID: Final = "mid" SUB_DEVICE_EVSE: Final = "evse" +SUB_DEVICE_PV: Final = "pv" def classify_sub_device_identifier(identifier: str) -> str | None: @@ -43,15 +45,23 @@ def classify_sub_device_identifier(identifier: str) -> str | None: None rather than an "unknown" string: the caller knows whether it is looking at something that must be a sub-device, and a sentinel that reads like a kind is what let an unclassified device render as a device with no type. + + **Most specific first.** EVSE is the one kind whose token is an infix rather + than a suffix, and a suffix test cannot tell `..._evse_inverter_pv` from a PV + identifier. Testing the infix first makes the charger's node id opaque to the + suffix rules below, which is the only ordering that stays right whatever a + panel names its nodes. """ - if identifier.endswith(f"_{SUB_DEVICE_BESS}"): - return SUB_DEVICE_BESS - if identifier.endswith(f"_{SUB_DEVICE_MID}"): - return SUB_DEVICE_MID # Infix, not suffix: the node id follows, and it is what distinguishes one # charger from another on the same panel. if f"_{SUB_DEVICE_EVSE}_" in identifier: return SUB_DEVICE_EVSE + if identifier.endswith(f"_{SUB_DEVICE_BESS}"): + return SUB_DEVICE_BESS + if identifier.endswith(f"_{SUB_DEVICE_MID}"): + return SUB_DEVICE_MID + if identifier.endswith(f"_{SUB_DEVICE_PV}"): + return SUB_DEVICE_PV return None @@ -181,3 +191,45 @@ def evse_device_info( sw_version=evse.software_version, via_device_id=panel_device_id, ) + + +def pv_device_info( + panel_identifier: str, + pv: SpanPVSnapshot, + panel_name: str, + *, + panel_device_id: str, +) -> DeviceInfo: + """Create DeviceInfo for the solar inverter, linked to the parent panel. + + The last DER to get a card of its own. Its vendor, model and nameplate + capacity have been readable all along and were shown as three diagnostic + sensors on the *panel's* card, beside the panel's own manufacturer and model, + which reads as if the enclosure were an Enphase inverter. The firmware + version the library also reads reached nothing at all, because a version has + no home but a device card. + + **The identifier deliberately does not mention the inverter's serial.** + `info/serial-number` is declared by every PV `$description` and published by + no producer today, so an identifier preferring it would be `_pv` on + every panel now and `_` on the first panel whose firmware + starts publishing one -- and a device identifier is what a consumer keys its + registry on, so that day would read as the inverter being replaced rather + than as a value arriving. `{panel serial}_pv` answers the only question an + identifier has to answer, "which panel's inverter", and a panel has exactly + one `pv` node, so nothing distinguishes two of them. The serial is not on the + card either, for the same reason it is not in the identifier: nothing in this + integration should start depending on it before a producer publishes one. + """ + return DeviceInfo( + identifiers={(DOMAIN, f"{panel_identifier}_{SUB_DEVICE_PV}")}, + name=f"{panel_name} Solar", + manufacturer=pv.vendor_name or "Unknown", + model=pv.model or "Solar Inverter", + # Passed through unguarded, as on the BESS and the MID: `DeviceInfo` + # omits a `None` field and renders an empty string as a present-but-blank + # row, so `or ""` would invent a version row for an inverter that + # published none. + sw_version=pv.software_version, + via_device_id=panel_device_id, + ) diff --git a/custom_components/span_panel/websocket.py b/custom_components/span_panel/websocket.py index f2969a60..b969bf8b 100644 --- a/custom_components/span_panel/websocket.py +++ b/custom_components/span_panel/websocket.py @@ -75,8 +75,9 @@ async def handle_panel_topology( """Return the full panel topology with entity mappings. Admin users must pass the HA device registry ID for the **main SPAN panel** - device only (not BESS/EVSE sub-devices). Returns panel metadata, circuits - with tabs/entity mappings, and sub-devices (BESS, EVSE). + device only (not a sub-device). Returns panel metadata, circuits with + tabs/entity mappings, and sub-devices -- every kind + `classify_sub_device_identifier` names, which is BESS, MID, EVSE and PV. """ device_id = msg["device_id"] @@ -92,12 +93,12 @@ async def handle_panel_topology( connection.send_error(msg["id"], "not_span_panel", "Device is not a SPAN Panel device") return - # Sub-devices (BESS, EVSE) register with via_device_id pointing at the panel. + # Every sub-device registers with via_device_id pointing at the panel. if device_entry.via_device_id is not None: connection.send_error( msg["id"], "not_panel_device", - "Use the SPAN panel device registry ID, not a BESS or EVSE sub-device.", + "Use the SPAN panel device registry ID, not a sub-device.", ) return @@ -225,7 +226,7 @@ def _find_config_entry_id(device_entry: dr.DeviceEntry) -> str | None: def _classify_sub_device(device_entry: dr.DeviceEntry) -> str: - """Classify a sub-device by its identifiers: 'bess', 'mid', 'evse' or 'unknown'. + """Classify a sub-device by its identifiers, or 'unknown'. The grammar lives with the builders that write it, in `util`, rather than being restated here. Restating it is how the MID went out as 'unknown' for a diff --git a/tests/fixtures/unread_declarations_baseline.json b/tests/fixtures/unread_declarations_baseline.json index b9767d52..8cfe362e 100644 --- a/tests/fixtures/unread_declarations_baseline.json +++ b/tests/fixtures/unread_declarations_baseline.json @@ -8,6 +8,5 @@ "lugs/connection/fed-by-device-type": "redundant: the id dereferences to the device's declared $type, which is how the library already resolves classes", "lugs/connection/feeds-device-status": "feedthrough link-health; the feeds-* half of the downstream lugs record is topology to consume, and no producer publishes it", "lugs/connection/feeds-device-type": "redundant: the id dereferences to the device's declared $type, which is how the library already resolves classes", - "pv/info/firmware-version": "reaches pv.software_version and stops there; PV has no sub-device registry entry, so there is no DeviceInfo to carry a version - it lands with the PV sub-device (Task 7)", - "pv/info/serial-number": "not read at all: _der_identifier prefers a serial over instance_id, so valuing it would move the PV device id from -pv-1 to - - a device replacement, not an upgrade (Task 7)" + "pv/info/serial-number": "held, and the PV device identifier deliberately does not use it: _der_identifier prefers a serial over instance_id, so valuing it would move the PV device id from -pv-1 to - - a device replacement, not an upgrade. The Home Assistant sub-device is keyed _pv for the same reason, so a serial arriving on the wire moves nothing" } diff --git a/tests/test_device_links.py b/tests/test_device_links.py index de91e06c..6ea08af2 100644 --- a/tests/test_device_links.py +++ b/tests/test_device_links.py @@ -21,7 +21,12 @@ from typing import Any +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry +from span_panel_api import SpanMidSnapshot, SpanPVSnapshot from custom_components.span_panel import ensure_device_registered from custom_components.span_panel.const import DOMAIN @@ -30,11 +35,8 @@ classify_sub_device_identifier, evse_device_info, mid_device_info, + pv_device_info, ) -from homeassistant.const import CONF_HOST -from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr -from span_panel_api import SpanMidSnapshot from .factories import ( SpanBatterySnapshotFactory, @@ -42,8 +44,6 @@ SpanPanelSnapshotFactory, ) -from pytest_homeassistant_custom_component.common import MockConfigEntry - _PANEL_ID = "a-registry-id" @@ -77,6 +77,15 @@ def _builders() -> list[tuple[str, Any]]: "sp3-link-001", _mid(), "Panel", panel_device_id=_PANEL_ID ), ), + ( + "pv", + lambda: pv_device_info( + "sp3-link-001", + SpanPVSnapshot(vendor_name="Enphase", model="IQ8", software_version="v1"), + "Panel", + panel_device_id=_PANEL_ID, + ), + ), ( "evse", lambda: evse_device_info( @@ -118,6 +127,19 @@ def test_the_panel_is_not_mistaken_for_a_sub_device() -> None: assert classify_sub_device_identifier("sim-40t-001") is None +def test_a_charger_node_ending_in_a_kind_is_still_a_charger() -> None: + """The infix wins over the suffixes, which is why it is tested first. + + A charger's identifier carries its Homie node id, and nothing stops a node id + from ending in `_pv` or `_bess`. Testing the suffix rules first would classify + such a charger as whatever its node id happened to end with -- a device + rendering as the wrong type on a dashboard, which is the failure the MID + already shipped once. + """ + assert classify_sub_device_identifier("sp3-link-001_evse_inverter_pv") == "evse" + assert classify_sub_device_identifier("sp3-link-001_evse_garage_bess") == "evse" + + @pytest.mark.parametrize(("label", "build"), _builders(), ids=lambda v: v if isinstance(v, str) else "") def test_sub_devices_link_by_registry_id(label: str, build: Any) -> None: """Both halves asserted: the new key is set and the old one is gone. diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py index 37b272dc..2e4e0bf2 100644 --- a/tests/test_field_path_conformance.py +++ b/tests/test_field_path_conformance.py @@ -288,7 +288,11 @@ def test_no_exempt_path_is_producible_by_both() -> None: # `$target` echo it renders as an attribute. Facts about a command rather # than readings, so no adapter carries a row for either -- the same shape as # the `circuit.*_target` pair. - Producibility.NEITHER: 43, + # +1 for `pv.software_version`, the firmware row on the solar inverter's own + # device card. Flat's `pv` device class declares no firmware version, and a + # version string is identity rather than a reading -- the same argument as + # the `mid.*` and `panel.*` card reads above. + Producibility.NEITHER: 44, # +1 for `panel.dominant_power_source`, the `grid_forming_entity` sensor's # source field. It was read by a `SCHEMA_CONDITIONAL_FIELD` description and # enumerated nowhere, so `evaluate_field_metadata` counted it as produced- diff --git a/tests/test_pv_device.py b/tests/test_pv_device.py new file mode 100644 index 00000000..aa72b9df --- /dev/null +++ b/tests/test_pv_device.py @@ -0,0 +1,739 @@ +"""The solar inverter gets a device of its own, and takes its entities with it. + +PV was the last DER on a v1.0 panel with no card. Its vendor, model and +nameplate capacity were rendered as three diagnostic sensors on the *panel's* +device — beside the panel's own manufacturer and model, so a panel card read as +if the enclosure were an Enphase inverter — and the firmware version the library +has always read reached nothing at all, because a version has nowhere to go but +a card. `pv/info/firmware-version` was baselined naming exactly that. + +**The identifier is the decision worth writing down.** `{panel serial}_pv`, and +deliberately not the inverter's serial. Every PV `$description` declares +`info/serial-number` and no producer publishes one, so an identifier that +preferred a serial would be `_pv` on every panel today and +`_` on the first panel whose firmware starts publishing one — and +a device identifier is what a consumer keys its registry on, so that day would +read as the inverter being replaced. `test_a_serial_arriving_on_the_wire_does_not_move_the_device` +produces that day and asserts nothing moves. + +**The migration is the risk.** Existing installations have these entities on the +panel device with panel-scoped `unique_id`s and panel-scoped `entity_id`s. Moving +an entity to another device must change *only* the device: a changed `entity_id` +breaks a dashboard, a changed `unique_id` orphans the entity and mints a +duplicate. Home Assistant re-homes an entity by itself when it re-registers with +new `device_info`, which is the mechanism used here; what it does *not* do is +re-derive an `entity_id`, so a fresh installation would otherwise get +`sensor.span_panel_solar_pv_vendor` where every existing one has +`sensor.span_panel_pv_vendor`. `construct_panel_scoped_entity_id` pins that shut +and `test_a_fresh_install_and_a_migrated_one_are_the_same_shape` is the proof. + +Every expectation about what the card shows is read out of the vendored capture +and proved by republishing or unpublishing the property, never by a literal. The +registry-shape expectations *are* literals, on purpose: they record what a +released installation carries, and deriving them from the code under test would +make the migration assertions vacuous. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable +from contextlib import asynccontextmanager +import json +import logging +import pathlib +from typing import Final +from unittest.mock import AsyncMock, MagicMock + +from span_panel_api import SpanPanelSnapshot + +from custom_components.span_panel import SpanPanelRuntimeData, ensure_device_registered +from custom_components.span_panel.binary_sensor import ( + PV_PANEL_LINK_SENSOR, + async_setup_entry as binary_sensor_setup_entry, +) +from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.field_paths import ( + RESIDUAL_EXEMPT_PATHS, + Producibility, + declared_field_paths, +) +from custom_components.span_panel.sensor import async_setup_entry as sensor_setup_entry +from custom_components.span_panel.sensor_definitions import PV_METADATA_SENSORS, PV_POWER_SENSOR +from custom_components.span_panel.util import SUB_DEVICE_PV, classify_sub_device_identifier +from custom_components.span_panel.websocket import _classify_sub_device +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util import slugify + +from .adapter_fixtures import schema_one_snapshot, schema_one_tree + +from pytest_homeassistant_custom_component.common import ( + MockConfigEntry, + MockEntityPlatform, + async_test_home_assistant, +) + +PV_DEVICE: Final = "pv" +"""The inverter's Homie device id in the capture.""" + +VENDOR_TOPIC: Final = "info/vendor-name" +MODEL_TOPIC: Final = "info/model" +FIRMWARE_TOPIC: Final = "info/firmware-version" +SERIAL_TOPIC: Final = "info/serial-number" + +PANEL_NAME: Final = "SPAN Panel" + +FALLBACK_MANUFACTURER: Final = "Unknown" +FALLBACK_MODEL: Final = "Solar Inverter" + +BASELINE: Final = pathlib.Path(__file__).parent / "fixtures" / "unread_declarations_baseline.json" + +_LEGACY_PV_ENTITIES: Final[tuple[tuple[str, str, str], ...]] = ( + ("sensor", "pv_power", "sensor.span_panel_pv_power"), + ("sensor", "pv_vendor", "sensor.span_panel_pv_vendor"), + ("sensor", "pv_product", "sensor.span_panel_pv_product"), + ("sensor", "pv_nameplate_capacity", "sensor.span_panel_pv_nameplate_capacity"), + ("binary_sensor", "pv_panel_link", "binary_sensor.span_panel_pv_panel_link"), +) +"""``(platform, unique_id suffix, entity_id)`` as a released installation holds them. + +Literals, deliberately. This is the registry a user upgrading already has, which +is a historical fact rather than something the current code gets to decide — +deriving it from the builders under test would make every assertion below agree +with itself. `test_a_fresh_install_still_builds_the_unique_ids_users_already_have` +is what holds the literals to the code. +""" + + +# --------------------------------------------------------------------------- +# Reading the capture +# --------------------------------------------------------------------------- + + +def _published(topic: str) -> str: + """What the capture publishes on one PV topic, or fail saying it does not.""" + value = schema_one_tree()[PV_DEVICE].get(topic) + assert value is not None, f"the capture publishes no {topic} on the inverter" + return value + + +def _declared(topic: str) -> bool: + """Whether the inverter's `$description` declares one `node/property`.""" + description = json.loads(schema_one_tree()[PV_DEVICE]["$description"]) + node, _, prop = topic.partition("/") + return prop in description["nodes"].get(node, {}).get("properties", {}) + + +def _pv_snapshot(**rewrites: str | None) -> SpanPanelSnapshot: + """A snapshot from the capture with the inverter's topics rewritten or removed. + + Keyword spelling is `node__property_name`. `None` removes the topic, which is + what firmware omitting a property looks like — a different event from + publishing an empty string, and the one the card's fallbacks exist for. + """ + tree = schema_one_tree() + for path, value in rewrites.items(): + node, _, prop = path.partition("__") + topic = f"{node.replace('_', '-')}/{prop.replace('_', '-')}" + if value is None: + tree[PV_DEVICE].pop(topic, None) + else: + tree[PV_DEVICE][topic] = value + return schema_one_snapshot(tree) + + +# --------------------------------------------------------------------------- +# Installing for real +# --------------------------------------------------------------------------- + + +def _entry(hass: HomeAssistant, entry_id: str, serial: str) -> MockConfigEntry: + """A config entry keyed on the panel's serial, as the config flow makes one.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.40", "device_name": PANEL_NAME}, + options={}, + title=PANEL_NAME, + entry_id=entry_id, + unique_id=serial, + ) + entry.add_to_hass(hass) + return entry + + +def _coordinator( + hass: HomeAssistant, entry: MockConfigEntry, snapshot: SpanPanelSnapshot +) -> MagicMock: + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.hass = hass + coordinator.panel_offline = False + coordinator.last_update_success = True + coordinator.unresolved_paths = frozenset() + coordinator.config_entry = entry + coordinator.async_request_refresh = AsyncMock() + return coordinator + + +async def _register( + hass: HomeAssistant, + entry: MockConfigEntry, + platform: str, + setup: Callable[..., object], +) -> None: + """Run one platform's `async_setup_entry` through a real `EntityPlatform`. + + Through the platform rather than by inspecting the entities the setup + function returns, because everything under test here happens *in* the + registry: which device an entity is filed under, and which `entity_id` it + keeps. Neither is observable on an entity object. + """ + added: list[object] = [] + await setup(hass, entry, lambda entities, **_: added.extend(entities)) + + entity_platform = MockEntityPlatform( + hass, domain=platform, platform_name=DOMAIN, logger=logging.getLogger(__name__) + ) + entity_platform.config_entry = entry + # The translations decide the object id Home Assistant derives, so a harness + # that skipped them would generate `sensor.span_panel_2` and prove nothing + # about the ids a user sees. + await entity_platform.platform_data.async_load_translations() + await entity_platform.async_add_entities(added) + + +async def _install( + hass: HomeAssistant, + snapshot: SpanPanelSnapshot, + entry_id: str, + *, + seed: Callable[[HomeAssistant, MockConfigEntry, str], None] | None = None, +) -> MockConfigEntry: + """Set up both platforms the way the integration does, optionally over a seeded registry.""" + entry = _entry(hass, entry_id, snapshot.serial_number) + panel_device_id = await ensure_device_registered(hass, entry, snapshot, PANEL_NAME) + if seed is not None: + seed(hass, entry, panel_device_id) + + coordinator = _coordinator(hass, entry, snapshot) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, panel_device_id=panel_device_id + ) + await _register(hass, entry, "sensor", sensor_setup_entry) + await _register(hass, entry, "binary_sensor", binary_sensor_setup_entry) + return entry + + +@asynccontextmanager +async def _a_second_home_assistant() -> AsyncIterator[HomeAssistant]: + """A second, empty Home Assistant, for comparing two installations. + + Both halves of the fresh-versus-migrated comparison have to be *the* install + on their instance. Running them into one registry makes the second collide + with the first and land on `..._pv_vendor_2`, which is an artefact of the + harness and would mask or invent a divergence either way. + """ + async with async_test_home_assistant() as second: + try: + yield second + finally: + await second.async_stop(force=True) + + +def _seed_the_old_shape(hass: HomeAssistant, entry: MockConfigEntry, panel_device_id: str) -> None: + """Write the PV entities onto the panel device, as a released install holds them.""" + registry = er.async_get(hass) + for platform, suffix, entity_id in _LEGACY_PV_ENTITIES: + created = registry.async_get_or_create( + platform, + DOMAIN, + f"span_{entry.unique_id}_{suffix}", + config_entry=entry, + device_id=panel_device_id, + suggested_object_id=entity_id.split(".", 1)[1], + ) + assert created.entity_id == entity_id, ( + f"the seed could not reproduce {entity_id}; it landed on {created.entity_id}" + ) + + +def _pv_device(hass: HomeAssistant, entry: MockConfigEntry) -> dr.DeviceEntry | None: + return dr.async_get(hass).async_get_device_by_identifier( + (DOMAIN, f"{entry.unique_id}_{SUB_DEVICE_PV}"), entry.entry_id + ) + + +def _registry_shape(hass: HomeAssistant, entry: MockConfigEntry) -> dict[str, tuple[str, ...]]: + """``{entity_id: (unique_id, *device identifiers)}`` for every entity of one entry. + + Devices by identifier rather than by registry id, because two installations + mint different registry ids for the same device and the identifier is the + stable name for "which card is this on". + """ + devices = dr.async_get(hass) + shape: dict[str, tuple[str, ...]] = {} + for entity in er.async_entries_for_config_entry(er.async_get(hass), entry.entry_id): + device = devices.async_get(entity.device_id) if entity.device_id else None + identifiers = sorted(name for _domain, name in device.identifiers) if device else [] + shape[entity.entity_id] = (entity.unique_id, *identifiers) + return shape + + +# --------------------------------------------------------------------------- +# The premise +# --------------------------------------------------------------------------- + + +def test_the_capture_publishes_the_identity_the_card_shows() -> None: + """Guard the premise: every card expectation below is read from these topics. + + A test whose expected value comes from an unpublished topic does not fail, it + stops asserting anything — which is how the fixture drifted eight identity + properties behind the producer without a single red test. + """ + assert _published(VENDOR_TOPIC) + assert _published(MODEL_TOPIC) + assert _published(FIRMWARE_TOPIC) + + +def test_the_capture_declares_a_serial_and_publishes_none() -> None: + """The premise of the identifier decision, held to the capture. + + If a producer ever values this, the choice of identifier stops being + hypothetical and `test_a_serial_arriving_on_the_wire_does_not_move_the_device` + stops being a simulation. Either way the identifier must not move, which is + what the two together assert. + """ + assert _declared(SERIAL_TOPIC), "the inverter no longer declares a serial number" + assert schema_one_tree()[PV_DEVICE].get(SERIAL_TOPIC) is None, ( + "the capture now values PV info/serial-number; decision 3 says it stays unvalued " + "until the flat side's PV device id is confirmed" + ) + + +# --------------------------------------------------------------------------- +# The card +# --------------------------------------------------------------------------- + + +async def test_the_inverter_gets_a_card_showing_what_it_publishes( + hass: HomeAssistant, +) -> None: + """Manufacturer, model and firmware read off the wire, not out of a constant.""" + entry = await _install(hass, _pv_snapshot(), "entry-pv-card") + + device = _pv_device(hass, entry) + assert device is not None + assert device.manufacturer == _published(VENDOR_TOPIC) + assert device.model == _published(MODEL_TOPIC) + assert device.sw_version == _published(FIRMWARE_TOPIC) + + +async def test_the_card_follows_a_republished_identity(hass: HomeAssistant) -> None: + """The card tracks the wire, so nothing above is passing on a coincidence.""" + rewritten = _pv_snapshot( + info__vendor_name="Another Vendor", + info__model="ANOTHER-MODEL-1", + info__firmware_version="example-pv/v9.9.9", + ) + + entry = await _install(hass, rewritten, "entry-pv-card-rewritten") + + device = _pv_device(hass, entry) + assert device is not None + assert device.manufacturer == "Another Vendor" + assert device.model == "ANOTHER-MODEL-1" + assert device.sw_version == "example-pv/v9.9.9" + + +async def test_an_inverter_publishing_no_identity_gets_a_card_with_no_blank_rows( + hass: HomeAssistant, +) -> None: + """The fallbacks, and the difference between an absent row and a blank one. + + Vendor and model fall back to strings because a card with no name at all is + worse than a generic one. The firmware version has no string to fall back to + and must be *absent* rather than empty: `DeviceInfo` omits a `None` field and + renders `""` as a present-but-blank row, which reads as an inverter reporting + a blank version rather than one reporting none. + """ + bare = _pv_snapshot( + info__vendor_name=None, + info__model=None, + info__firmware_version=None, + ) + + entry = await _install(hass, bare, "entry-pv-card-bare") + + device = _pv_device(hass, entry) + assert device is not None + assert device.manufacturer == FALLBACK_MANUFACTURER + assert device.model == FALLBACK_MODEL + assert device.sw_version is None + + +async def test_the_card_hangs_off_the_panel_like_every_other_sub_device( + hass: HomeAssistant, +) -> None: + """By registry id, which is the link Home Assistant stops dropping in 2027.8.""" + snapshot = _pv_snapshot() + entry = await _install(hass, snapshot, "entry-pv-link") + + device = _pv_device(hass, entry) + panel = dr.async_get(hass).async_get_device_by_identifier( + (DOMAIN, snapshot.serial_number), entry.entry_id + ) + assert device is not None + assert panel is not None + assert device.via_device_id == panel.id + + +async def test_a_panel_with_no_inverter_gets_no_card(hass: HomeAssistant) -> None: + """Absence is a reading. No PV node, no device, and no entities to re-home.""" + tree = schema_one_tree() + del tree[PV_DEVICE] + for topics in tree.values(): + topics.pop("power-flows/pv", None) + for circuit, topics in tree.items(): + if topics.get("connection/feeds-device-id") == PV_DEVICE: + topics.pop("connection/feeds-device-id", None) + topics.pop("connection/feeds-device-status", None) + topics.pop("connection/feeds-device-type", None) + + entry = await _install(hass, schema_one_snapshot(tree), "entry-pv-absent") + + assert _pv_device(hass, entry) is None + shape = _registry_shape(hass, entry) + assert not [entity_id for entity_id in shape if "_pv_" in entity_id] + + +# --------------------------------------------------------------------------- +# The identifier +# --------------------------------------------------------------------------- + + +async def test_a_serial_arriving_on_the_wire_does_not_move_the_device( + hass: HomeAssistant, +) -> None: + """The whole reason the identifier does not mention the inverter's serial. + + `_der_identifier` on the producer side prefers a serial over an instance id, + so the day firmware starts publishing `info/serial-number` the inverter's + *Homie* device id changes. A Home Assistant identifier derived from it would + change with it, and a changed identifier is a new device: the card empties, + the entities orphan, and an upgrade rehearsal becomes a device-replacement + rehearsal. Keyed on the panel's serial and the kind instead, so this test + publishes a serial and watches nothing move. + """ + before = await _install(hass, _pv_snapshot(), "entry-pv-noserial") + identifier_before = _pv_device(hass, before) + shape_before = _registry_shape(hass, before) + + async with _a_second_home_assistant() as second: + after = await _install( + second, + _pv_snapshot(info__serial_number="INVERTER-SERIAL-0001"), + "entry-pv-serial", + ) + identifier_after = _pv_device(second, after) + shape_after = _registry_shape(second, after) + + assert identifier_before is not None + assert identifier_after is not None + assert identifier_before.identifiers == identifier_after.identifiers + assert shape_before == shape_after + + +async def test_the_topology_reader_calls_the_new_card_a_pv(hass: HomeAssistant) -> None: + """The writing end and the reading end of the identifier grammar agree. + + The MID shipped classifying as `unknown` because a kind was added to the + builders and not to the reader, and a card rendered a device with a name and + no type. Asserted against the device as registered rather than against the + builder's dict, because the reader is handed a `DeviceEntry`. + """ + entry = await _install(hass, _pv_snapshot(), "entry-pv-classify") + + device = _pv_device(hass, entry) + assert device is not None + assert _classify_sub_device(device) == SUB_DEVICE_PV + assert classify_sub_device_identifier(f"{entry.unique_id}_{SUB_DEVICE_PV}") == SUB_DEVICE_PV + + +# --------------------------------------------------------------------------- +# Where the entities land, and what they keep +# --------------------------------------------------------------------------- + + +def _pv_entities(hass: HomeAssistant, entry: MockConfigEntry) -> dict[str, er.RegistryEntry]: + """The five PV entities, by `entity_id`, or fail naming the ones missing.""" + registry = er.async_get(hass) + found: dict[str, er.RegistryEntry] = {} + missing: list[str] = [] + for platform, suffix, _entity_id in _LEGACY_PV_ENTITIES: + entity_id = registry.async_get_entity_id( + platform, DOMAIN, f"span_{entry.unique_id}_{suffix}" + ) + if entity_id is None: + missing.append(suffix) + continue + entity = registry.async_get(entity_id) + assert entity is not None + found[entity_id] = entity + assert not missing, f"the platform created no entity for {missing}" + return found + + +async def test_a_fresh_install_still_builds_the_unique_ids_users_already_have( + hass: HomeAssistant, +) -> None: + """The literals above are the current code's output, so the migration tests bite. + + A `unique_id` is an identity: changing one does not rename an entity, it + orphans the old one and mints a second. This holds the recorded shape to the + builders, so a change to either fails here rather than in an upgrade. + """ + entry = await _install(hass, _pv_snapshot(), "entry-pv-uids") + + assert set(_pv_entities(hass, entry)) == { + entity_id for _platform, _suffix, entity_id in _LEGACY_PV_ENTITIES + } + + +async def test_every_pv_entity_lands_on_the_inverters_card(hass: HomeAssistant) -> None: + """All five, and none of them left behind on the panel.""" + entry = await _install(hass, _pv_snapshot(), "entry-pv-home") + + device = _pv_device(hass, entry) + assert device is not None + for entity_id, entity in _pv_entities(hass, entry).items(): + assert entity.device_id == device.id, f"{entity_id} is not on the inverter's card" + + +def test_the_pinned_ids_are_what_the_panel_card_would_have_generated() -> None: + """The pin reproduces Home Assistant's own derivation, rather than guessing it. + + `construct_panel_scoped_entity_id` builds `{panel slug}_{translation key}`, + while Home Assistant builds `{device name} {entity name}` and slugifies it. + The two agree only because every PV entity's translated name slugifies to its + translation key — true today, and silently untrue the day somebody renames + one, which would make a *new* installation's entity_id differ from every + existing one. Read out of `strings.json`, which is the file that would change. + """ + strings = json.loads( + ( + pathlib.Path(__file__).parent.parent + / "custom_components" + / "span_panel" + / "strings.json" + ).read_text(encoding="utf-8") + ) + for platform, suffix, entity_id in _LEGACY_PV_ENTITIES: + name = strings["entity"][platform][suffix]["name"] + assert slugify(name) == suffix, ( + f"{platform}.{suffix} is named {name!r}, which slugifies to " + f"{slugify(name)!r}; the pinned entity_id would stop matching what " + "Home Assistant derives" + ) + assert entity_id == f"{platform}.{slugify(PANEL_NAME)}_{slugify(name)}" + + +def test_the_descriptions_carry_the_translation_keys_the_pin_reads() -> None: + """The pin reads `translation_key`, so a description without one would pin to ``. + + An empty key would build `sensor.span_panel_` for every PV entity and Home + Assistant would resolve the collision by appending numbers — a silent, + permanent scrambling of five entity ids on new installations only. + """ + keys = {PV_POWER_SENSOR.translation_key} | { + description.translation_key for description in PV_METADATA_SENSORS + } + keys.add(PV_PANEL_LINK_SENSOR.translation_key) + assert keys == {suffix for _platform, suffix, _entity_id in _LEGACY_PV_ENTITIES} + + +# --------------------------------------------------------------------------- +# The migration +# --------------------------------------------------------------------------- + + +async def test_the_migration_moves_the_device_and_nothing_else( + hass: HomeAssistant, +) -> None: + """Seed the released shape, set up, and read the registry back. + + Home Assistant re-homes an entity when it re-registers with different + `device_info` — `async_get_or_create` updates `device_id` on an existing + entry — so there is no bespoke migration to write and deliberately none + written. What that mechanism does *not* touch is the `entity_id` or the + `unique_id`, and those are what a user's dashboards and automations name, so + they are asserted one by one rather than in aggregate. + """ + entry = await _install(hass, _pv_snapshot(), "entry-pv-migrate", seed=_seed_the_old_shape) + + device = _pv_device(hass, entry) + assert device is not None + registry = er.async_get(hass) + for platform, suffix, entity_id in _LEGACY_PV_ENTITIES: + unique_id = f"span_{entry.unique_id}_{suffix}" + entity = registry.async_get(entity_id) + assert entity is not None, f"{entity_id} no longer exists after setup" + assert entity.unique_id == unique_id, f"{entity_id} changed unique_id" + assert entity.platform == DOMAIN + assert entity.domain == platform + assert entity.device_id == device.id, f"{entity_id} did not move to the inverter" + + +async def test_the_migration_leaves_no_duplicate_and_no_orphan( + hass: HomeAssistant, +) -> None: + """The two failure modes a re-home has, made observable. + + A duplicate: the entity re-registers under a new `unique_id`, so the old + registry entry survives beside a new one and a user sees each reading twice. + An orphan: the panel device keeps an entity nothing writes to any more. Both + are counted rather than spot-checked, because either would otherwise hide + among forty circuit entities. + """ + entry = await _install(hass, _pv_snapshot(), "entry-pv-migrate-clean", seed=_seed_the_old_shape) + + device = _pv_device(hass, entry) + panel = dr.async_get(hass).async_get_device_by_identifier( + (DOMAIN, entry.unique_id or ""), entry.entry_id + ) + assert device is not None + assert panel is not None + + entities = er.async_entries_for_config_entry(er.async_get(hass), entry.entry_id) + unique_ids = [entity.unique_id for entity in entities] + assert len(unique_ids) == len(set(unique_ids)), "an entity re-registered under a second id" + + pv_unique_ids = {f"span_{entry.unique_id}_{suffix}" for _p, suffix, _e in _LEGACY_PV_ENTITIES} + left_behind = [ + entity.entity_id + for entity in entities + if entity.unique_id in pv_unique_ids and entity.device_id != device.id + ] + assert not left_behind, f"still on the panel card: {left_behind}" + + pv_devices = [ + candidate + for candidate in dr.async_entries_for_config_entry(dr.async_get(hass), entry.entry_id) + if any( + classify_sub_device_identifier(name) == SUB_DEVICE_PV + for _domain, name in candidate.identifiers + ) + ] + assert len(pv_devices) == 1, f"{len(pv_devices)} PV devices registered, expected 1" + + +async def test_a_fresh_install_and_a_migrated_one_are_the_same_shape( + hass: HomeAssistant, +) -> None: + """Two installations of the same panel, one upgraded and one new, must agree. + + Divergence here is the long-tail bug: a documentation example, a blueprint or + a support answer that is right for exactly half of the installed base. The + comparison is over `{entity_id: (unique_id, device identifiers)}` — every fact + a user or a dashboard can name — rather than over counts, so a single entity + filed under a different card fails with the entity_id in the message. + """ + snapshot = _pv_snapshot() + migrated = await _install(hass, snapshot, "entry-pv-shape-old", seed=_seed_the_old_shape) + migrated_shape = _registry_shape(hass, migrated) + + async with _a_second_home_assistant() as second: + fresh = await _install(second, snapshot, "entry-pv-shape-new") + assert migrated_shape == _registry_shape(second, fresh) + + +async def test_a_user_renamed_entity_id_survives_the_move(hass: HomeAssistant) -> None: + """The pin is a suggestion, and must lose to a choice the user already made. + + Home Assistant only honours a suggested `entity_id` for an entity it is + registering for the first time. Asserted rather than assumed, because the pin + is the one new thing in this change that could plausibly overwrite something + of the user's. + """ + + def seed(hass_: HomeAssistant, entry_: MockConfigEntry, panel_device_id: str) -> None: + _seed_the_old_shape(hass_, entry_, panel_device_id) + er.async_get(hass_).async_update_entity( + "sensor.span_panel_pv_vendor", new_entity_id="sensor.my_solar_brand" + ) + + entry = await _install(hass, _pv_snapshot(), "entry-pv-renamed", seed=seed) + + registry = er.async_get(hass) + assert registry.async_get("sensor.span_panel_pv_vendor") is None + renamed = registry.async_get("sensor.my_solar_brand") + assert renamed is not None + device = _pv_device(hass, entry) + assert device is not None + assert renamed.device_id == device.id + + +# --------------------------------------------------------------------------- +# The inventories +# --------------------------------------------------------------------------- + + +def test_the_firmware_version_is_no_longer_an_unread_declaration() -> None: + """The line this task exists to delete, and the line that stays. + + `pv/info/firmware-version` was baselined saying the inverter had no card to + carry a version; it has one now, so the line goes. `pv/info/serial-number` + stays, and its reason has to name the identifier decision rather than only + the producer-side one, because the Home Assistant identifier is now a second + thing that would have moved. + """ + baseline: dict[str, str] = json.loads(BASELINE.read_text(encoding="utf-8")) + + assert "pv/info/firmware-version" not in baseline + reason = baseline.get("pv/info/serial-number") + assert reason is not None, "the serial must stay baselined; see decision 3" + assert "identifier" in reason + + +def test_the_cards_firmware_read_is_enumerated_as_a_residual() -> None: + """`pv_device_info` is not an entity, so its read is an exempt residual. + + `NEITHER` beside the `mid.*` and `panel.*` card reads: flat's `pv` device + class declares no firmware version, and a schema_1 metadata row carries a + unit and a datatype for a reading, which a version string is not. + """ + assert RESIDUAL_EXEMPT_PATHS["pv.software_version"] is Producibility.NEITHER + assert "pv.software_version" not in declared_field_paths() + + +def test_nothing_reads_a_pv_serial_anywhere() -> None: + """The negative half of the identifier decision, asserted rather than assumed. + + Adding the field to the snapshot would be harmless; reading it here would + not, because every read is a place a future change could route into the + identifier. There is no such field and no such path, and this fails the day + one arrives without the decision being revisited. + """ + assert "pv.serial_number" not in RESIDUAL_EXEMPT_PATHS + assert "pv.serial_number" not in declared_field_paths() + snapshot = _pv_snapshot(info__serial_number="INVERTER-SERIAL-0001") + assert not hasattr(snapshot.pv, "serial_number") + + +def test_the_kind_vocabulary_is_closed() -> None: + """Every kind the classifier answers with, in one place. + + Not a restatement of the classifier: `test_device_links` parametrises over + the *builders*, so a fifth sub-device whose identifier nothing classifies + fails there. This is the other direction — the reader answering a kind the + writers never mint — and it is why the list is spelled out rather than + derived from the constants. + """ + assert [ + classify_sub_device_identifier("panel-serial_bess"), + classify_sub_device_identifier("panel-serial_mid"), + classify_sub_device_identifier("panel-serial_evse_node"), + classify_sub_device_identifier("panel-serial_pv"), + classify_sub_device_identifier("panel-serial"), + ] == ["bess", "mid", "evse", SUB_DEVICE_PV, None] diff --git a/tests/test_sensor_entities.py b/tests/test_sensor_entities.py index acc3e599..93348c14 100644 --- a/tests/test_sensor_entities.py +++ b/tests/test_sensor_entities.py @@ -154,7 +154,13 @@ def test_panel_sensor_default_friendly_names_cover_fallback_branches() -> None: snapshot, {"identifiers": {("span_panel", "bess")}}, ) - pv_sensor = SpanPVMetadataSensor(coordinator, PV_METADATA_SENSORS[0], snapshot) + pv_sensor = SpanPVMetadataSensor( + coordinator, + PV_METADATA_SENSORS[0], + snapshot, + {"identifiers": {("span_panel", "pv")}}, + "sensor.span_panel_pv_vendor", + ) assert panel_data_sensor._generate_friendly_name(snapshot, panel_data_desc) == "Sensor" assert status_sensor._generate_friendly_name(snapshot, status_desc) == "Status" @@ -178,7 +184,13 @@ def test_panel_metadata_sensors_return_expected_data_sources() -> None: snapshot, {"identifiers": {("span_panel", "bess")}}, ) - pv_sensor = SpanPVMetadataSensor(coordinator, PV_METADATA_SENSORS[0], snapshot) + pv_sensor = SpanPVMetadataSensor( + coordinator, + PV_METADATA_SENSORS[0], + snapshot, + {"identifiers": {("span_panel", "pv")}}, + "sensor.span_panel_pv_vendor", + ) assert bess_sensor.get_data_source(snapshot) is battery assert pv_sensor.get_data_source(snapshot) is snapshot diff --git a/tests/test_websocket.py b/tests/test_websocket.py index 188de7cf..d11647f4 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -184,6 +184,12 @@ def test_evse(self): device.identifiers = {(DOMAIN, "sp3-242424-001_evse_0")} assert _classify_sub_device(device) == "evse" + def test_pv(self): + """Classify the solar inverter sub-device from its identifier.""" + device = MagicMock() + device.identifiers = {(DOMAIN, "sp3-242424-001_pv")} + assert _classify_sub_device(device) == "pv" + def test_unknown(self): """Treat the panel device itself as an unknown sub-device type.""" device = MagicMock() @@ -382,7 +388,7 @@ async def test_non_span_device(self, hass: HomeAssistant): @pytest.mark.asyncio async def test_sub_device_id_rejected(self, hass: HomeAssistant): - """Error when device_id is a BESS/EVSE sub-device, not the panel.""" + """Error when device_id is a sub-device, not the panel.""" entry = MockConfigEntry( domain=DOMAIN, data={}, @@ -419,7 +425,7 @@ async def test_sub_device_id_rejected(self, hass: HomeAssistant): connection.send_error.assert_called_once_with( 1, "not_panel_device", - "Use the SPAN panel device registry ID, not a BESS or EVSE sub-device.", + "Use the SPAN panel device registry ID, not a sub-device.", ) @pytest.mark.asyncio From 18937d0a6a882d79236c35f7ce1b4f1cdd8d26be Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:26:26 -0700 Subject: [PATCH 057/345] docs(changelog): record the two catch-up tasks that shipped without an entry The metadata sweep and the PV sub-device landed with commit messages and no user-facing changelog, and the capability-reload fix landed with neither. The changelog is what a user reads to decide whether an upgrade affects them, so a restored attribute and a reload that did not happen belong in it more than most of what is already there. Two of the four are fixes rather than additions, and are written as such: the SSID emptied silently when a panel upgraded, and a panel that gained a capability while Home Assistant was running created no entities and asked for no reload. Both are the kind of thing a user cannot report usefully, because nothing appears in a log and the symptom is an absence. --- CHANGELOG.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43b78e1b..6629310e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ All notable changes to this project will be documented in this file. ### Added +- **Your solar inverter gets a device of its own, on panels running the v1.0 data model.** Its vendor, model and nameplate capacity used to render as diagnostic + sensors on the _panel's_ card, beside the panel's own manufacturer and model — so the card whose job is telling you which enclosure this is read as though the + enclosure were an Enphase inverter. It now has a card like the battery and the chargers already do, carrying the firmware version the panel has been + publishing all along. +- **Nothing about the move changes an entity.** The five sensors that relocate keep the entity ids and unique ids they already have, so dashboards, automations + and history follow them across. A freshly installed system and an upgraded one end up with identical entity ids — they would otherwise have diverged, because + Home Assistant derives a new entity's id from the name of the device it sits on. + +- **Your panel's own card now shows what the panel says it is** — manufacturer, model and hardware revision, read from the enclosure rather than assumed. A + panel on the older data model publishes none of the three and keeps exactly the card it has today; the hardware revision row is left off rather than shown + blank where no revision is published. +- **Every SPAN Drive gets a Part Number** diagnostic sensor, matching the one the battery already has. Off by default. +- **Circuit Priority's shed policy is readable.** The `dsm_state` sensor gains `shed_algorithm` and the two state-of-charge thresholds that decide when circuits + shed and when they come back — the numbers that make the panel's shed behaviour predictable rather than surprising. A policy this integration does not + recognise keeps its name and carries the panel's raw document beside it, so you can read what a parser could not. + - **A charge-current limit you can set, on panels running the v1.0 data model.** Each commissioned SPAN Drive gets an **EVSE Charge Current Limit** number on its own device — the ceiling the charger offers your vehicle, which you can lower to charge more slowly and raise back. It is the first control this integration has that changes something on a charger rather than on the panel. @@ -65,6 +81,14 @@ All notable changes to this project will be documented in this file. ### Fixed +- **The Wi-Fi network name came back.** Panels on the older data model report the SSID they are joined to, and this integration has shown it as an attribute on + the panel status sensor for as long as it has existed. On the v1.0 data model nothing read it, so the attribute quietly emptied when your panel upgraded — a + value you had, silently gone, with no error and nothing in the log. It is read again. +- **A firmware upgrade that adds a capability now actually reloads.** The check that decides whether new hardware warrants a reload knew about four capabilities + where the rest of the integration knew about nine. A panel that gained the shed forecast, the power control system, battery telemetry or DER link health while + Home Assistant was running published the data, matched every rule for creating the entities, and asked for no reload — so the new entities appeared only the + next time you restarted. This affected the Microgrid Interconnect Device before this release too. + - **The README described Battery Power's sign backwards** (`+discharge, -charge`). The sensor has always reported charging as positive; only the documentation was wrong. No entity changed. From c418ff10426cadf86ee4110989296b7f77413925 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:42:40 -0700 Subject: [PATCH 058/345] Revert "feat(device): give the solar inverter a card of its own, and move its entities to it" This reverts 85ca619. The change moved four PV entities off the panel device onto a new sub-device, and the brief that asked for it was wrong about who has those entities. `create_power_flow_sensors` gates PV on `has_pv()`, which is schema-agnostic, and schema_0 populates PV vendor, model and nameplate capacity. So every existing installation with solar has `pv_vendor`, `pv_product`, `pv_nameplate_capacity` and `pv_power` on its panel device *today*, on current firmware. The move was scoped as a v1.0 gap; it would have reached the whole installed base on an integration update, with no firmware change involved. Entity ids, unique ids and history survived a re-home -- that much was built and proved. What does not survive is everything keyed on the device: an entity with no explicit area inherits its device's area, so PV entities in a panel assigned to an area would have fallen out of it and stopped matching area-based automations and filters, and any dashboard card populated from the panel device would have lost its PV rows. Adding sensors cannot break an installation. Moving existing entities between devices can, and this bought nothing but consistency with the BESS and the charger. `pv/info/firmware-version` returns to the unread baseline, which is the honest state: the library reads it and no card carries it. --- CHANGELOG.md | 8 - custom_components/span_panel/binary_sensor.py | 43 +- .../span_panel/entity_resolver.py | 34 - custom_components/span_panel/field_paths.py | 13 - custom_components/span_panel/helpers.py | 2 - custom_components/span_panel/sensor.py | 53 +- custom_components/span_panel/sensor_panel.py | 28 +- custom_components/span_panel/services.py | 8 +- custom_components/span_panel/util.py | 60 +- custom_components/span_panel/websocket.py | 11 +- .../unread_declarations_baseline.json | 3 +- tests/test_device_links.py | 34 +- tests/test_field_path_conformance.py | 6 +- tests/test_pv_device.py | 739 ------------------ tests/test_sensor_entities.py | 16 +- tests/test_websocket.py | 10 +- 16 files changed, 36 insertions(+), 1032 deletions(-) delete mode 100644 tests/test_pv_device.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6629310e..ecce0eab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,14 +6,6 @@ All notable changes to this project will be documented in this file. ### Added -- **Your solar inverter gets a device of its own, on panels running the v1.0 data model.** Its vendor, model and nameplate capacity used to render as diagnostic - sensors on the _panel's_ card, beside the panel's own manufacturer and model — so the card whose job is telling you which enclosure this is read as though the - enclosure were an Enphase inverter. It now has a card like the battery and the chargers already do, carrying the firmware version the panel has been - publishing all along. -- **Nothing about the move changes an entity.** The five sensors that relocate keep the entity ids and unique ids they already have, so dashboards, automations - and history follow them across. A freshly installed system and an upgraded one end up with identical entity ids — they would otherwise have diverged, because - Home Assistant derives a new entity's id from the name of the device it sits on. - - **Your panel's own card now shows what the panel says it is** — manufacturer, model and hardware revision, read from the enclosure rather than assumed. A panel on the older data model publishes none of the three and keeps exactly the card it has today; the hardware revision row is left off rather than shown blank where no revision is published. diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py index f914dee7..0b2e6bda 100644 --- a/custom_components/span_panel/binary_sensor.py +++ b/custom_components/span_panel/binary_sensor.py @@ -34,13 +34,12 @@ from .helpers import ( build_binary_sensor_unique_id_for_entry, build_evse_unique_id_for_entry, - construct_panel_scoped_entity_id, has_bess, has_mid, has_pcs, resolve_evse_display_suffix, ) -from .util import bess_device_info, evse_device_info, pv_device_info +from .util import bess_device_info, evse_device_info # pylint: disable=invalid-overridden-method @@ -179,9 +178,8 @@ def _grid_islandable(snapshot: SpanPanelSnapshot) -> bool | None: one device class whose link the panel happened to report through the lugs was the only one a user could see. -On the inverter's own sub-device, beside `pv_vendor` and `pv_product`, which is -where it moved when the PV got a device of its own -- the same place -`bess_connected` sits relative to the battery. +On the panel device, beside `pv_vendor` and `pv_product`, because the PV is not +yet a sub-device of its own. It moves with them when it becomes one. `SCHEMA_CONDITIONAL_FIELD` *and* `field_path`: flat firmware publishes `connected` on the BESS and on nothing else, so the both-adapters gate cannot be @@ -234,16 +232,8 @@ def __init__( data_coordinator: SpanPanelCoordinator, description: T, device_info_override: DeviceInfo | None = None, - entity_id_override: str | None = None, ) -> None: - """Initialize Span Panel Circuit entity. - - `entity_id_override` is how a sensor that used to sit on the panel's own - card keeps the object id that card gave it after moving to a sub-device -- - see `construct_panel_scoped_entity_id`. Home Assistant treats it as a - suggestion and ignores it for an entity already in the registry, so it - only ever decides what a *new* installation gets. - """ + """Initialize Span Panel Circuit entity.""" super().__init__(data_coordinator, context=description) snapshot: SpanPanelSnapshot = data_coordinator.data @@ -264,9 +254,6 @@ def __init__( data_coordinator, snapshot, description.key ) - if entity_id_override is not None: - self.entity_id = entity_id_override - @property def available(self) -> bool: """Return entity availability. @@ -554,27 +541,7 @@ async def async_setup_entry( # saying it does not know rather than a fault, and the enum it does publish # has no UNKNOWN member for it to say that with. See `PV_PANEL_LINK_SENSOR`. if snapshot.pv.connected is not None: - configured_name = coordinator.config_entry.data.get( - CONF_DEVICE_NAME, coordinator.config_entry.title - ) - entities.append( - SpanPanelBinarySensor( - coordinator, - PV_PANEL_LINK_SENSOR, - device_info_override=pv_device_info( - snapshot.serial_number, - snapshot.pv, - configured_name or "Span Panel", - panel_device_id=config_entry.runtime_data.panel_device_id, - ), - entity_id_override=construct_panel_scoped_entity_id( - snapshot, - "binary_sensor", - PV_PANEL_LINK_SENSOR.translation_key or "", - configured_name, - ), - ) - ) + entities.append(SpanPanelBinarySensor(coordinator, PV_PANEL_LINK_SENSOR)) # Add EVSE binary sensors for each commissioned charger if snapshot.evse: diff --git a/custom_components/span_panel/entity_resolver.py b/custom_components/span_panel/entity_resolver.py index 7ea29929..88458876 100644 --- a/custom_components/span_panel/entity_resolver.py +++ b/custom_components/span_panel/entity_resolver.py @@ -459,37 +459,3 @@ def construct_unmapped_friendly_name( """Construct friendly name for unmapped circuit sensors.""" # Format: "Unmapped Tab 32 Consumed Energy" return f"Unmapped Tab {circuit_number} {sensor_description_name}" - - -def construct_panel_scoped_entity_id( - snapshot: SpanPanelSnapshot, - platform: str, - translation_key: str, - device_name: str | None = None, -) -> str: - """Return the entity_id a sub-device entity keeps from the panel's card. - - Home Assistant builds an `has_entity_name` entity's object id from the name of - the device it belongs to, so moving an entity to a sub-device would give a - *new* installation `sensor.span_panel_solar_pv_vendor` where every existing - one has `sensor.span_panel_pv_vendor` -- the registry never renames an entity - that already exists. Two shapes for the same entity, decided by install date, - is the long-tail bug this pins shut: an entity that was born on the panel card - keeps the panel-scoped id on both. - - The same thing `construct_single_circuit_entity_id` already does for the - circuit sensors that live on an EVSE sub-device, and for the same reason. - - Suggestion only. Home Assistant honours an `entity_id` an entity sets for - itself when it registers, and ignores it for one that is already registered -- - which is exactly the behaviour wanted here, since an id a user has renamed is - theirs. - - `translation_key` rather than a name, because the name comes from the - translations and is not loaded when an entity is constructed. - `test_the_pinned_ids_are_what_the_panel_card_would_have_generated` holds the - two to each other. - """ - device_info = snapshot_to_device_info(snapshot, device_name) - panel_name = device_info.get("name") or "Span Panel" - return f"{platform}.{slugify(panel_name)}_{translation_key}" diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py index e58dce2b..ba874ffd 100644 --- a/custom_components/span_panel/field_paths.py +++ b/custom_components/span_panel/field_paths.py @@ -202,19 +202,6 @@ class FieldPathDeclarationMixin: "mid.model": Producibility.NEITHER, "mid.serial_number": Producibility.NEITHER, "mid.grid_forming_device_name": Producibility.NEITHER, - # `pv_device_info` reads the inverter's firmware version for its device - # card. `pv.vendor_name` and `pv.model` are not here beside it because - # they are `field_path` declarations on the three PV metadata sensors - # already, and the card reads the same two fields those sensors do. - # - # `NEITHER` for the same reason as the `mid.*` and `panel.*` card reads - # above: flat's `pv` device class declares no firmware version at all, - # and a schema_1 metadata row carries a unit and a datatype for a - # *reading*, which a version string is not. - # - # `pv.serial_number` is deliberately absent -- from this table, from the - # card and from the snapshot. See `pv_device_info`. - "pv.software_version": Producibility.NEITHER, # The `mid_grid_state` sensor's source field — utility-supply health, # the one non-metadata entity the MID brings. Neither adapter maps the # MID at all, which is why the description is `NO_SOURCE_FIELD`. diff --git a/custom_components/span_panel/helpers.py b/custom_components/span_panel/helpers.py index 3733020b..8609a208 100644 --- a/custom_components/span_panel/helpers.py +++ b/custom_components/span_panel/helpers.py @@ -20,7 +20,6 @@ build_switch_unique_id_for_entry, construct_circuit_unique_id_for_entry, construct_multi_circuit_entity_id, - construct_panel_scoped_entity_id, construct_panel_unique_id_for_entry, construct_single_circuit_entity_id, construct_synthetic_unique_id_for_entry, @@ -80,7 +79,6 @@ "construct_circuit_unique_id", "construct_circuit_unique_id_for_entry", "construct_multi_circuit_entity_id", - "construct_panel_scoped_entity_id", "construct_panel_unique_id", "construct_panel_unique_id_for_entry", "construct_select_unique_id", diff --git a/custom_components/span_panel/sensor.py b/custom_components/span_panel/sensor.py index cdcdd6b9..cfbc5c77 100644 --- a/custom_components/span_panel/sensor.py +++ b/custom_components/span_panel/sensor.py @@ -20,7 +20,6 @@ ) from .coordinator import SpanPanelCoordinator from .helpers import ( - construct_panel_scoped_entity_id, has_bess, has_bess_telemetry, has_evse, @@ -79,7 +78,7 @@ SpanPVMetadataSensor, SpanShedForecastSensor, ) -from .util import bess_device_info, evse_device_info, mid_device_info, pv_device_info +from .util import bess_device_info, evse_device_info, mid_device_info # Export the sensor classes for backward compatibility with tests __all__ = [ @@ -479,22 +478,6 @@ def create_battery_sensors( return entities -def _build_pv_device_info( - coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot -) -> DeviceInfo: - """DeviceInfo for the solar inverter sub-device.""" - panel_name = ( - coordinator.config_entry.data.get(CONF_DEVICE_NAME, coordinator.config_entry.title) - or "Span Panel" - ) - return pv_device_info( - snapshot.serial_number, - snapshot.pv, - panel_name, - panel_device_id=coordinator.config_entry.runtime_data.panel_device_id, - ) - - def create_power_flow_sensors( coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot ) -> list[SpanPanelPowerSensor | SpanPVMetadataSensor]: @@ -503,43 +486,15 @@ def create_power_flow_sensors( PV Power — only when PV is commissioned. Site Power — only when the power-flows node is publishing. PV metadata sensors — only when PV is commissioned. - - The PV sensors land on the inverter's own sub-device, matching what the BESS - has done since v1.0: `battery_power` is the enclosure's reading of the - battery and it sits on the battery's card, so `pv_power` -- the enclosure's - reading of the inverter -- belongs on the inverter's. Each keeps the - panel-scoped entity_id it already has; see `construct_panel_scoped_entity_id`. """ entities: list[SpanPanelPowerSensor | SpanPVMetadataSensor] = [] if has_pv(snapshot): - pv_info = _build_pv_device_info(coordinator, snapshot) - panel_name = coordinator.config_entry.data.get( - CONF_DEVICE_NAME, coordinator.config_entry.title - ) - entities.append( - SpanPanelPowerSensor( - coordinator, - PV_POWER_SENSOR, - snapshot, - device_info_override=pv_info, - entity_id_override=construct_panel_scoped_entity_id( - snapshot, "sensor", PV_POWER_SENSOR.translation_key or "", panel_name - ), - ) - ) + entities.append(SpanPanelPowerSensor(coordinator, PV_POWER_SENSOR, snapshot)) + # PV metadata sensors on the main panel device entities.extend( - SpanPVMetadataSensor( - coordinator, - desc, - snapshot, - pv_info, - construct_panel_scoped_entity_id( - snapshot, "sensor", desc.translation_key or "", panel_name - ), - ) - for desc in PV_METADATA_SENSORS + SpanPVMetadataSensor(coordinator, desc, snapshot) for desc in PV_METADATA_SENSORS ) if has_power_flows(snapshot): diff --git a/custom_components/span_panel/sensor_panel.py b/custom_components/span_panel/sensor_panel.py index dba89837..161fcc47 100644 --- a/custom_components/span_panel/sensor_panel.py +++ b/custom_components/span_panel/sensor_panel.py @@ -457,23 +457,13 @@ def __init__( description: SpanPanelDataSensorEntityDescription, snapshot: SpanPanelSnapshot, device_info_override: DeviceInfo | None = None, - entity_id_override: str | None = None, ) -> None: - """Initialize the enhanced panel power sensor. - - `entity_id_override` is how a power sensor that used to sit on the panel's - own card keeps the object id that card gave it after moving to a - sub-device -- see `construct_panel_scoped_entity_id`. Home Assistant - treats it as a suggestion and ignores it for an entity already in the - registry, so it only ever decides what a *new* installation gets. - """ + """Initialize the enhanced panel power sensor.""" self._description_key = description.key super().__init__(data_coordinator, description, snapshot) if device_info_override is not None: self._attr_device_info = device_info_override - if entity_id_override is not None: - self.entity_id = entity_id_override def _generate_unique_id( self, @@ -667,30 +657,16 @@ def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanMidSnapshot: class SpanPVMetadataSensor( SpanSensorBase[SpanPVMetadataSensorEntityDescription, SpanPanelSnapshot] ): - """PV metadata sensor entity on the PV sub-device. - - On the panel's own card until the inverter got one of its own, which put the - inverter's vendor and model beside the *panel's* vendor and model on the card - whose job is saying which enclosure this is. - - The unique_id stays the panel-scoped one `construct_panel_unique_id_for_entry` - has always built, because a unique_id is an identity and these are the same - three entities they were. Only the device they hang off changes, which is a - registry update Home Assistant performs itself when the entity re-registers. - """ + """PV metadata sensor entity on the main panel device.""" def __init__( self, data_coordinator: SpanPanelCoordinator, description: SpanPVMetadataSensorEntityDescription, snapshot: SpanPanelSnapshot, - device_info_override: DeviceInfo, - entity_id_override: str, ) -> None: """Initialize the PV metadata sensor.""" super().__init__(data_coordinator, description, snapshot) - self._attr_device_info = device_info_override - self.entity_id = entity_id_override def _generate_unique_id( self, diff --git a/custom_components/span_panel/services.py b/custom_components/span_panel/services.py index 24581d91..0500424b 100644 --- a/custom_components/span_panel/services.py +++ b/custom_components/span_panel/services.py @@ -513,10 +513,8 @@ def _resolve_entity_to_favorite_target(entity_id: str) -> tuple[str, FavoriteKin ``kind`` is ``"circuits"`` or ``"sub_devices"``. For circuits, ``target_id`` is the panel-local circuit uuid (extracted from the - entity's unique_id). For sub-devices, ``target_id`` is the HA device id - of the sub-device; the panel id walks up via ``via_device_id``. Nothing - here enumerates the kinds, so a new one -- the PV inverter most recently - -- is favouritable the day its device exists. + entity's unique_id). For sub-devices, ``target_id`` is the HA + device id of the BESS/EVSE; the panel id walks up via ``via_device_id``. Failure paths use distinct translation keys so users see the actual reason their pick was rejected. @@ -553,7 +551,7 @@ def _resolve_entity_to_favorite_target(entity_id: str) -> tuple[str, FavoriteKin # Resolve the panel device id. Sub-devices register with # via_device_id; main panels never do, so via_device_id presence is a - # reliable discriminator whatever kinds exist, and we walk up to the + # reliable discriminator (BESS / EVSE today) and we walk up to the # parent SPAN Panel here. if device_entry.via_device_id is not None: parent = device_registry.async_get(device_entry.via_device_id) diff --git a/custom_components/span_panel/util.py b/custom_components/span_panel/util.py index a821d391..85024db4 100644 --- a/custom_components/span_panel/util.py +++ b/custom_components/span_panel/util.py @@ -9,7 +9,6 @@ SpanEvseSnapshot, SpanMidSnapshot, SpanPanelSnapshot, - SpanPVSnapshot, ) from .const import DOMAIN @@ -36,7 +35,6 @@ SUB_DEVICE_BESS: Final = "bess" SUB_DEVICE_MID: Final = "mid" SUB_DEVICE_EVSE: Final = "evse" -SUB_DEVICE_PV: Final = "pv" def classify_sub_device_identifier(identifier: str) -> str | None: @@ -45,23 +43,15 @@ def classify_sub_device_identifier(identifier: str) -> str | None: None rather than an "unknown" string: the caller knows whether it is looking at something that must be a sub-device, and a sentinel that reads like a kind is what let an unclassified device render as a device with no type. - - **Most specific first.** EVSE is the one kind whose token is an infix rather - than a suffix, and a suffix test cannot tell `..._evse_inverter_pv` from a PV - identifier. Testing the infix first makes the charger's node id opaque to the - suffix rules below, which is the only ordering that stays right whatever a - panel names its nodes. """ - # Infix, not suffix: the node id follows, and it is what distinguishes one - # charger from another on the same panel. - if f"_{SUB_DEVICE_EVSE}_" in identifier: - return SUB_DEVICE_EVSE if identifier.endswith(f"_{SUB_DEVICE_BESS}"): return SUB_DEVICE_BESS if identifier.endswith(f"_{SUB_DEVICE_MID}"): return SUB_DEVICE_MID - if identifier.endswith(f"_{SUB_DEVICE_PV}"): - return SUB_DEVICE_PV + # Infix, not suffix: the node id follows, and it is what distinguishes one + # charger from another on the same panel. + if f"_{SUB_DEVICE_EVSE}_" in identifier: + return SUB_DEVICE_EVSE return None @@ -191,45 +181,3 @@ def evse_device_info( sw_version=evse.software_version, via_device_id=panel_device_id, ) - - -def pv_device_info( - panel_identifier: str, - pv: SpanPVSnapshot, - panel_name: str, - *, - panel_device_id: str, -) -> DeviceInfo: - """Create DeviceInfo for the solar inverter, linked to the parent panel. - - The last DER to get a card of its own. Its vendor, model and nameplate - capacity have been readable all along and were shown as three diagnostic - sensors on the *panel's* card, beside the panel's own manufacturer and model, - which reads as if the enclosure were an Enphase inverter. The firmware - version the library also reads reached nothing at all, because a version has - no home but a device card. - - **The identifier deliberately does not mention the inverter's serial.** - `info/serial-number` is declared by every PV `$description` and published by - no producer today, so an identifier preferring it would be `_pv` on - every panel now and `_` on the first panel whose firmware - starts publishing one -- and a device identifier is what a consumer keys its - registry on, so that day would read as the inverter being replaced rather - than as a value arriving. `{panel serial}_pv` answers the only question an - identifier has to answer, "which panel's inverter", and a panel has exactly - one `pv` node, so nothing distinguishes two of them. The serial is not on the - card either, for the same reason it is not in the identifier: nothing in this - integration should start depending on it before a producer publishes one. - """ - return DeviceInfo( - identifiers={(DOMAIN, f"{panel_identifier}_{SUB_DEVICE_PV}")}, - name=f"{panel_name} Solar", - manufacturer=pv.vendor_name or "Unknown", - model=pv.model or "Solar Inverter", - # Passed through unguarded, as on the BESS and the MID: `DeviceInfo` - # omits a `None` field and renders an empty string as a present-but-blank - # row, so `or ""` would invent a version row for an inverter that - # published none. - sw_version=pv.software_version, - via_device_id=panel_device_id, - ) diff --git a/custom_components/span_panel/websocket.py b/custom_components/span_panel/websocket.py index b969bf8b..f2969a60 100644 --- a/custom_components/span_panel/websocket.py +++ b/custom_components/span_panel/websocket.py @@ -75,9 +75,8 @@ async def handle_panel_topology( """Return the full panel topology with entity mappings. Admin users must pass the HA device registry ID for the **main SPAN panel** - device only (not a sub-device). Returns panel metadata, circuits with - tabs/entity mappings, and sub-devices -- every kind - `classify_sub_device_identifier` names, which is BESS, MID, EVSE and PV. + device only (not BESS/EVSE sub-devices). Returns panel metadata, circuits + with tabs/entity mappings, and sub-devices (BESS, EVSE). """ device_id = msg["device_id"] @@ -93,12 +92,12 @@ async def handle_panel_topology( connection.send_error(msg["id"], "not_span_panel", "Device is not a SPAN Panel device") return - # Every sub-device registers with via_device_id pointing at the panel. + # Sub-devices (BESS, EVSE) register with via_device_id pointing at the panel. if device_entry.via_device_id is not None: connection.send_error( msg["id"], "not_panel_device", - "Use the SPAN panel device registry ID, not a sub-device.", + "Use the SPAN panel device registry ID, not a BESS or EVSE sub-device.", ) return @@ -226,7 +225,7 @@ def _find_config_entry_id(device_entry: dr.DeviceEntry) -> str | None: def _classify_sub_device(device_entry: dr.DeviceEntry) -> str: - """Classify a sub-device by its identifiers, or 'unknown'. + """Classify a sub-device by its identifiers: 'bess', 'mid', 'evse' or 'unknown'. The grammar lives with the builders that write it, in `util`, rather than being restated here. Restating it is how the MID went out as 'unknown' for a diff --git a/tests/fixtures/unread_declarations_baseline.json b/tests/fixtures/unread_declarations_baseline.json index 8cfe362e..b9767d52 100644 --- a/tests/fixtures/unread_declarations_baseline.json +++ b/tests/fixtures/unread_declarations_baseline.json @@ -8,5 +8,6 @@ "lugs/connection/fed-by-device-type": "redundant: the id dereferences to the device's declared $type, which is how the library already resolves classes", "lugs/connection/feeds-device-status": "feedthrough link-health; the feeds-* half of the downstream lugs record is topology to consume, and no producer publishes it", "lugs/connection/feeds-device-type": "redundant: the id dereferences to the device's declared $type, which is how the library already resolves classes", - "pv/info/serial-number": "held, and the PV device identifier deliberately does not use it: _der_identifier prefers a serial over instance_id, so valuing it would move the PV device id from -pv-1 to - - a device replacement, not an upgrade. The Home Assistant sub-device is keyed _pv for the same reason, so a serial arriving on the wire moves nothing" + "pv/info/firmware-version": "reaches pv.software_version and stops there; PV has no sub-device registry entry, so there is no DeviceInfo to carry a version - it lands with the PV sub-device (Task 7)", + "pv/info/serial-number": "not read at all: _der_identifier prefers a serial over instance_id, so valuing it would move the PV device id from -pv-1 to - - a device replacement, not an upgrade (Task 7)" } diff --git a/tests/test_device_links.py b/tests/test_device_links.py index 6ea08af2..de91e06c 100644 --- a/tests/test_device_links.py +++ b/tests/test_device_links.py @@ -21,12 +21,7 @@ from typing import Any -from homeassistant.const import CONF_HOST -from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr import pytest -from pytest_homeassistant_custom_component.common import MockConfigEntry -from span_panel_api import SpanMidSnapshot, SpanPVSnapshot from custom_components.span_panel import ensure_device_registered from custom_components.span_panel.const import DOMAIN @@ -35,8 +30,11 @@ classify_sub_device_identifier, evse_device_info, mid_device_info, - pv_device_info, ) +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from span_panel_api import SpanMidSnapshot from .factories import ( SpanBatterySnapshotFactory, @@ -44,6 +42,8 @@ SpanPanelSnapshotFactory, ) +from pytest_homeassistant_custom_component.common import MockConfigEntry + _PANEL_ID = "a-registry-id" @@ -77,15 +77,6 @@ def _builders() -> list[tuple[str, Any]]: "sp3-link-001", _mid(), "Panel", panel_device_id=_PANEL_ID ), ), - ( - "pv", - lambda: pv_device_info( - "sp3-link-001", - SpanPVSnapshot(vendor_name="Enphase", model="IQ8", software_version="v1"), - "Panel", - panel_device_id=_PANEL_ID, - ), - ), ( "evse", lambda: evse_device_info( @@ -127,19 +118,6 @@ def test_the_panel_is_not_mistaken_for_a_sub_device() -> None: assert classify_sub_device_identifier("sim-40t-001") is None -def test_a_charger_node_ending_in_a_kind_is_still_a_charger() -> None: - """The infix wins over the suffixes, which is why it is tested first. - - A charger's identifier carries its Homie node id, and nothing stops a node id - from ending in `_pv` or `_bess`. Testing the suffix rules first would classify - such a charger as whatever its node id happened to end with -- a device - rendering as the wrong type on a dashboard, which is the failure the MID - already shipped once. - """ - assert classify_sub_device_identifier("sp3-link-001_evse_inverter_pv") == "evse" - assert classify_sub_device_identifier("sp3-link-001_evse_garage_bess") == "evse" - - @pytest.mark.parametrize(("label", "build"), _builders(), ids=lambda v: v if isinstance(v, str) else "") def test_sub_devices_link_by_registry_id(label: str, build: Any) -> None: """Both halves asserted: the new key is set and the old one is gone. diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py index 2e4e0bf2..37b272dc 100644 --- a/tests/test_field_path_conformance.py +++ b/tests/test_field_path_conformance.py @@ -288,11 +288,7 @@ def test_no_exempt_path_is_producible_by_both() -> None: # `$target` echo it renders as an attribute. Facts about a command rather # than readings, so no adapter carries a row for either -- the same shape as # the `circuit.*_target` pair. - # +1 for `pv.software_version`, the firmware row on the solar inverter's own - # device card. Flat's `pv` device class declares no firmware version, and a - # version string is identity rather than a reading -- the same argument as - # the `mid.*` and `panel.*` card reads above. - Producibility.NEITHER: 44, + Producibility.NEITHER: 43, # +1 for `panel.dominant_power_source`, the `grid_forming_entity` sensor's # source field. It was read by a `SCHEMA_CONDITIONAL_FIELD` description and # enumerated nowhere, so `evaluate_field_metadata` counted it as produced- diff --git a/tests/test_pv_device.py b/tests/test_pv_device.py deleted file mode 100644 index aa72b9df..00000000 --- a/tests/test_pv_device.py +++ /dev/null @@ -1,739 +0,0 @@ -"""The solar inverter gets a device of its own, and takes its entities with it. - -PV was the last DER on a v1.0 panel with no card. Its vendor, model and -nameplate capacity were rendered as three diagnostic sensors on the *panel's* -device — beside the panel's own manufacturer and model, so a panel card read as -if the enclosure were an Enphase inverter — and the firmware version the library -has always read reached nothing at all, because a version has nowhere to go but -a card. `pv/info/firmware-version` was baselined naming exactly that. - -**The identifier is the decision worth writing down.** `{panel serial}_pv`, and -deliberately not the inverter's serial. Every PV `$description` declares -`info/serial-number` and no producer publishes one, so an identifier that -preferred a serial would be `_pv` on every panel today and -`_` on the first panel whose firmware starts publishing one — and -a device identifier is what a consumer keys its registry on, so that day would -read as the inverter being replaced. `test_a_serial_arriving_on_the_wire_does_not_move_the_device` -produces that day and asserts nothing moves. - -**The migration is the risk.** Existing installations have these entities on the -panel device with panel-scoped `unique_id`s and panel-scoped `entity_id`s. Moving -an entity to another device must change *only* the device: a changed `entity_id` -breaks a dashboard, a changed `unique_id` orphans the entity and mints a -duplicate. Home Assistant re-homes an entity by itself when it re-registers with -new `device_info`, which is the mechanism used here; what it does *not* do is -re-derive an `entity_id`, so a fresh installation would otherwise get -`sensor.span_panel_solar_pv_vendor` where every existing one has -`sensor.span_panel_pv_vendor`. `construct_panel_scoped_entity_id` pins that shut -and `test_a_fresh_install_and_a_migrated_one_are_the_same_shape` is the proof. - -Every expectation about what the card shows is read out of the vendored capture -and proved by republishing or unpublishing the property, never by a literal. The -registry-shape expectations *are* literals, on purpose: they record what a -released installation carries, and deriving them from the code under test would -make the migration assertions vacuous. -""" - -from __future__ import annotations - -from collections.abc import AsyncIterator, Callable -from contextlib import asynccontextmanager -import json -import logging -import pathlib -from typing import Final -from unittest.mock import AsyncMock, MagicMock - -from span_panel_api import SpanPanelSnapshot - -from custom_components.span_panel import SpanPanelRuntimeData, ensure_device_registered -from custom_components.span_panel.binary_sensor import ( - PV_PANEL_LINK_SENSOR, - async_setup_entry as binary_sensor_setup_entry, -) -from custom_components.span_panel.const import DOMAIN -from custom_components.span_panel.field_paths import ( - RESIDUAL_EXEMPT_PATHS, - Producibility, - declared_field_paths, -) -from custom_components.span_panel.sensor import async_setup_entry as sensor_setup_entry -from custom_components.span_panel.sensor_definitions import PV_METADATA_SENSORS, PV_POWER_SENSOR -from custom_components.span_panel.util import SUB_DEVICE_PV, classify_sub_device_identifier -from custom_components.span_panel.websocket import _classify_sub_device -from homeassistant.const import CONF_HOST -from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.util import slugify - -from .adapter_fixtures import schema_one_snapshot, schema_one_tree - -from pytest_homeassistant_custom_component.common import ( - MockConfigEntry, - MockEntityPlatform, - async_test_home_assistant, -) - -PV_DEVICE: Final = "pv" -"""The inverter's Homie device id in the capture.""" - -VENDOR_TOPIC: Final = "info/vendor-name" -MODEL_TOPIC: Final = "info/model" -FIRMWARE_TOPIC: Final = "info/firmware-version" -SERIAL_TOPIC: Final = "info/serial-number" - -PANEL_NAME: Final = "SPAN Panel" - -FALLBACK_MANUFACTURER: Final = "Unknown" -FALLBACK_MODEL: Final = "Solar Inverter" - -BASELINE: Final = pathlib.Path(__file__).parent / "fixtures" / "unread_declarations_baseline.json" - -_LEGACY_PV_ENTITIES: Final[tuple[tuple[str, str, str], ...]] = ( - ("sensor", "pv_power", "sensor.span_panel_pv_power"), - ("sensor", "pv_vendor", "sensor.span_panel_pv_vendor"), - ("sensor", "pv_product", "sensor.span_panel_pv_product"), - ("sensor", "pv_nameplate_capacity", "sensor.span_panel_pv_nameplate_capacity"), - ("binary_sensor", "pv_panel_link", "binary_sensor.span_panel_pv_panel_link"), -) -"""``(platform, unique_id suffix, entity_id)`` as a released installation holds them. - -Literals, deliberately. This is the registry a user upgrading already has, which -is a historical fact rather than something the current code gets to decide — -deriving it from the builders under test would make every assertion below agree -with itself. `test_a_fresh_install_still_builds_the_unique_ids_users_already_have` -is what holds the literals to the code. -""" - - -# --------------------------------------------------------------------------- -# Reading the capture -# --------------------------------------------------------------------------- - - -def _published(topic: str) -> str: - """What the capture publishes on one PV topic, or fail saying it does not.""" - value = schema_one_tree()[PV_DEVICE].get(topic) - assert value is not None, f"the capture publishes no {topic} on the inverter" - return value - - -def _declared(topic: str) -> bool: - """Whether the inverter's `$description` declares one `node/property`.""" - description = json.loads(schema_one_tree()[PV_DEVICE]["$description"]) - node, _, prop = topic.partition("/") - return prop in description["nodes"].get(node, {}).get("properties", {}) - - -def _pv_snapshot(**rewrites: str | None) -> SpanPanelSnapshot: - """A snapshot from the capture with the inverter's topics rewritten or removed. - - Keyword spelling is `node__property_name`. `None` removes the topic, which is - what firmware omitting a property looks like — a different event from - publishing an empty string, and the one the card's fallbacks exist for. - """ - tree = schema_one_tree() - for path, value in rewrites.items(): - node, _, prop = path.partition("__") - topic = f"{node.replace('_', '-')}/{prop.replace('_', '-')}" - if value is None: - tree[PV_DEVICE].pop(topic, None) - else: - tree[PV_DEVICE][topic] = value - return schema_one_snapshot(tree) - - -# --------------------------------------------------------------------------- -# Installing for real -# --------------------------------------------------------------------------- - - -def _entry(hass: HomeAssistant, entry_id: str, serial: str) -> MockConfigEntry: - """A config entry keyed on the panel's serial, as the config flow makes one.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_HOST: "192.168.1.40", "device_name": PANEL_NAME}, - options={}, - title=PANEL_NAME, - entry_id=entry_id, - unique_id=serial, - ) - entry.add_to_hass(hass) - return entry - - -def _coordinator( - hass: HomeAssistant, entry: MockConfigEntry, snapshot: SpanPanelSnapshot -) -> MagicMock: - coordinator = MagicMock() - coordinator.data = snapshot - coordinator.hass = hass - coordinator.panel_offline = False - coordinator.last_update_success = True - coordinator.unresolved_paths = frozenset() - coordinator.config_entry = entry - coordinator.async_request_refresh = AsyncMock() - return coordinator - - -async def _register( - hass: HomeAssistant, - entry: MockConfigEntry, - platform: str, - setup: Callable[..., object], -) -> None: - """Run one platform's `async_setup_entry` through a real `EntityPlatform`. - - Through the platform rather than by inspecting the entities the setup - function returns, because everything under test here happens *in* the - registry: which device an entity is filed under, and which `entity_id` it - keeps. Neither is observable on an entity object. - """ - added: list[object] = [] - await setup(hass, entry, lambda entities, **_: added.extend(entities)) - - entity_platform = MockEntityPlatform( - hass, domain=platform, platform_name=DOMAIN, logger=logging.getLogger(__name__) - ) - entity_platform.config_entry = entry - # The translations decide the object id Home Assistant derives, so a harness - # that skipped them would generate `sensor.span_panel_2` and prove nothing - # about the ids a user sees. - await entity_platform.platform_data.async_load_translations() - await entity_platform.async_add_entities(added) - - -async def _install( - hass: HomeAssistant, - snapshot: SpanPanelSnapshot, - entry_id: str, - *, - seed: Callable[[HomeAssistant, MockConfigEntry, str], None] | None = None, -) -> MockConfigEntry: - """Set up both platforms the way the integration does, optionally over a seeded registry.""" - entry = _entry(hass, entry_id, snapshot.serial_number) - panel_device_id = await ensure_device_registered(hass, entry, snapshot, PANEL_NAME) - if seed is not None: - seed(hass, entry, panel_device_id) - - coordinator = _coordinator(hass, entry, snapshot) - entry.runtime_data = SpanPanelRuntimeData( - coordinator=coordinator, panel_device_id=panel_device_id - ) - await _register(hass, entry, "sensor", sensor_setup_entry) - await _register(hass, entry, "binary_sensor", binary_sensor_setup_entry) - return entry - - -@asynccontextmanager -async def _a_second_home_assistant() -> AsyncIterator[HomeAssistant]: - """A second, empty Home Assistant, for comparing two installations. - - Both halves of the fresh-versus-migrated comparison have to be *the* install - on their instance. Running them into one registry makes the second collide - with the first and land on `..._pv_vendor_2`, which is an artefact of the - harness and would mask or invent a divergence either way. - """ - async with async_test_home_assistant() as second: - try: - yield second - finally: - await second.async_stop(force=True) - - -def _seed_the_old_shape(hass: HomeAssistant, entry: MockConfigEntry, panel_device_id: str) -> None: - """Write the PV entities onto the panel device, as a released install holds them.""" - registry = er.async_get(hass) - for platform, suffix, entity_id in _LEGACY_PV_ENTITIES: - created = registry.async_get_or_create( - platform, - DOMAIN, - f"span_{entry.unique_id}_{suffix}", - config_entry=entry, - device_id=panel_device_id, - suggested_object_id=entity_id.split(".", 1)[1], - ) - assert created.entity_id == entity_id, ( - f"the seed could not reproduce {entity_id}; it landed on {created.entity_id}" - ) - - -def _pv_device(hass: HomeAssistant, entry: MockConfigEntry) -> dr.DeviceEntry | None: - return dr.async_get(hass).async_get_device_by_identifier( - (DOMAIN, f"{entry.unique_id}_{SUB_DEVICE_PV}"), entry.entry_id - ) - - -def _registry_shape(hass: HomeAssistant, entry: MockConfigEntry) -> dict[str, tuple[str, ...]]: - """``{entity_id: (unique_id, *device identifiers)}`` for every entity of one entry. - - Devices by identifier rather than by registry id, because two installations - mint different registry ids for the same device and the identifier is the - stable name for "which card is this on". - """ - devices = dr.async_get(hass) - shape: dict[str, tuple[str, ...]] = {} - for entity in er.async_entries_for_config_entry(er.async_get(hass), entry.entry_id): - device = devices.async_get(entity.device_id) if entity.device_id else None - identifiers = sorted(name for _domain, name in device.identifiers) if device else [] - shape[entity.entity_id] = (entity.unique_id, *identifiers) - return shape - - -# --------------------------------------------------------------------------- -# The premise -# --------------------------------------------------------------------------- - - -def test_the_capture_publishes_the_identity_the_card_shows() -> None: - """Guard the premise: every card expectation below is read from these topics. - - A test whose expected value comes from an unpublished topic does not fail, it - stops asserting anything — which is how the fixture drifted eight identity - properties behind the producer without a single red test. - """ - assert _published(VENDOR_TOPIC) - assert _published(MODEL_TOPIC) - assert _published(FIRMWARE_TOPIC) - - -def test_the_capture_declares_a_serial_and_publishes_none() -> None: - """The premise of the identifier decision, held to the capture. - - If a producer ever values this, the choice of identifier stops being - hypothetical and `test_a_serial_arriving_on_the_wire_does_not_move_the_device` - stops being a simulation. Either way the identifier must not move, which is - what the two together assert. - """ - assert _declared(SERIAL_TOPIC), "the inverter no longer declares a serial number" - assert schema_one_tree()[PV_DEVICE].get(SERIAL_TOPIC) is None, ( - "the capture now values PV info/serial-number; decision 3 says it stays unvalued " - "until the flat side's PV device id is confirmed" - ) - - -# --------------------------------------------------------------------------- -# The card -# --------------------------------------------------------------------------- - - -async def test_the_inverter_gets_a_card_showing_what_it_publishes( - hass: HomeAssistant, -) -> None: - """Manufacturer, model and firmware read off the wire, not out of a constant.""" - entry = await _install(hass, _pv_snapshot(), "entry-pv-card") - - device = _pv_device(hass, entry) - assert device is not None - assert device.manufacturer == _published(VENDOR_TOPIC) - assert device.model == _published(MODEL_TOPIC) - assert device.sw_version == _published(FIRMWARE_TOPIC) - - -async def test_the_card_follows_a_republished_identity(hass: HomeAssistant) -> None: - """The card tracks the wire, so nothing above is passing on a coincidence.""" - rewritten = _pv_snapshot( - info__vendor_name="Another Vendor", - info__model="ANOTHER-MODEL-1", - info__firmware_version="example-pv/v9.9.9", - ) - - entry = await _install(hass, rewritten, "entry-pv-card-rewritten") - - device = _pv_device(hass, entry) - assert device is not None - assert device.manufacturer == "Another Vendor" - assert device.model == "ANOTHER-MODEL-1" - assert device.sw_version == "example-pv/v9.9.9" - - -async def test_an_inverter_publishing_no_identity_gets_a_card_with_no_blank_rows( - hass: HomeAssistant, -) -> None: - """The fallbacks, and the difference between an absent row and a blank one. - - Vendor and model fall back to strings because a card with no name at all is - worse than a generic one. The firmware version has no string to fall back to - and must be *absent* rather than empty: `DeviceInfo` omits a `None` field and - renders `""` as a present-but-blank row, which reads as an inverter reporting - a blank version rather than one reporting none. - """ - bare = _pv_snapshot( - info__vendor_name=None, - info__model=None, - info__firmware_version=None, - ) - - entry = await _install(hass, bare, "entry-pv-card-bare") - - device = _pv_device(hass, entry) - assert device is not None - assert device.manufacturer == FALLBACK_MANUFACTURER - assert device.model == FALLBACK_MODEL - assert device.sw_version is None - - -async def test_the_card_hangs_off_the_panel_like_every_other_sub_device( - hass: HomeAssistant, -) -> None: - """By registry id, which is the link Home Assistant stops dropping in 2027.8.""" - snapshot = _pv_snapshot() - entry = await _install(hass, snapshot, "entry-pv-link") - - device = _pv_device(hass, entry) - panel = dr.async_get(hass).async_get_device_by_identifier( - (DOMAIN, snapshot.serial_number), entry.entry_id - ) - assert device is not None - assert panel is not None - assert device.via_device_id == panel.id - - -async def test_a_panel_with_no_inverter_gets_no_card(hass: HomeAssistant) -> None: - """Absence is a reading. No PV node, no device, and no entities to re-home.""" - tree = schema_one_tree() - del tree[PV_DEVICE] - for topics in tree.values(): - topics.pop("power-flows/pv", None) - for circuit, topics in tree.items(): - if topics.get("connection/feeds-device-id") == PV_DEVICE: - topics.pop("connection/feeds-device-id", None) - topics.pop("connection/feeds-device-status", None) - topics.pop("connection/feeds-device-type", None) - - entry = await _install(hass, schema_one_snapshot(tree), "entry-pv-absent") - - assert _pv_device(hass, entry) is None - shape = _registry_shape(hass, entry) - assert not [entity_id for entity_id in shape if "_pv_" in entity_id] - - -# --------------------------------------------------------------------------- -# The identifier -# --------------------------------------------------------------------------- - - -async def test_a_serial_arriving_on_the_wire_does_not_move_the_device( - hass: HomeAssistant, -) -> None: - """The whole reason the identifier does not mention the inverter's serial. - - `_der_identifier` on the producer side prefers a serial over an instance id, - so the day firmware starts publishing `info/serial-number` the inverter's - *Homie* device id changes. A Home Assistant identifier derived from it would - change with it, and a changed identifier is a new device: the card empties, - the entities orphan, and an upgrade rehearsal becomes a device-replacement - rehearsal. Keyed on the panel's serial and the kind instead, so this test - publishes a serial and watches nothing move. - """ - before = await _install(hass, _pv_snapshot(), "entry-pv-noserial") - identifier_before = _pv_device(hass, before) - shape_before = _registry_shape(hass, before) - - async with _a_second_home_assistant() as second: - after = await _install( - second, - _pv_snapshot(info__serial_number="INVERTER-SERIAL-0001"), - "entry-pv-serial", - ) - identifier_after = _pv_device(second, after) - shape_after = _registry_shape(second, after) - - assert identifier_before is not None - assert identifier_after is not None - assert identifier_before.identifiers == identifier_after.identifiers - assert shape_before == shape_after - - -async def test_the_topology_reader_calls_the_new_card_a_pv(hass: HomeAssistant) -> None: - """The writing end and the reading end of the identifier grammar agree. - - The MID shipped classifying as `unknown` because a kind was added to the - builders and not to the reader, and a card rendered a device with a name and - no type. Asserted against the device as registered rather than against the - builder's dict, because the reader is handed a `DeviceEntry`. - """ - entry = await _install(hass, _pv_snapshot(), "entry-pv-classify") - - device = _pv_device(hass, entry) - assert device is not None - assert _classify_sub_device(device) == SUB_DEVICE_PV - assert classify_sub_device_identifier(f"{entry.unique_id}_{SUB_DEVICE_PV}") == SUB_DEVICE_PV - - -# --------------------------------------------------------------------------- -# Where the entities land, and what they keep -# --------------------------------------------------------------------------- - - -def _pv_entities(hass: HomeAssistant, entry: MockConfigEntry) -> dict[str, er.RegistryEntry]: - """The five PV entities, by `entity_id`, or fail naming the ones missing.""" - registry = er.async_get(hass) - found: dict[str, er.RegistryEntry] = {} - missing: list[str] = [] - for platform, suffix, _entity_id in _LEGACY_PV_ENTITIES: - entity_id = registry.async_get_entity_id( - platform, DOMAIN, f"span_{entry.unique_id}_{suffix}" - ) - if entity_id is None: - missing.append(suffix) - continue - entity = registry.async_get(entity_id) - assert entity is not None - found[entity_id] = entity - assert not missing, f"the platform created no entity for {missing}" - return found - - -async def test_a_fresh_install_still_builds_the_unique_ids_users_already_have( - hass: HomeAssistant, -) -> None: - """The literals above are the current code's output, so the migration tests bite. - - A `unique_id` is an identity: changing one does not rename an entity, it - orphans the old one and mints a second. This holds the recorded shape to the - builders, so a change to either fails here rather than in an upgrade. - """ - entry = await _install(hass, _pv_snapshot(), "entry-pv-uids") - - assert set(_pv_entities(hass, entry)) == { - entity_id for _platform, _suffix, entity_id in _LEGACY_PV_ENTITIES - } - - -async def test_every_pv_entity_lands_on_the_inverters_card(hass: HomeAssistant) -> None: - """All five, and none of them left behind on the panel.""" - entry = await _install(hass, _pv_snapshot(), "entry-pv-home") - - device = _pv_device(hass, entry) - assert device is not None - for entity_id, entity in _pv_entities(hass, entry).items(): - assert entity.device_id == device.id, f"{entity_id} is not on the inverter's card" - - -def test_the_pinned_ids_are_what_the_panel_card_would_have_generated() -> None: - """The pin reproduces Home Assistant's own derivation, rather than guessing it. - - `construct_panel_scoped_entity_id` builds `{panel slug}_{translation key}`, - while Home Assistant builds `{device name} {entity name}` and slugifies it. - The two agree only because every PV entity's translated name slugifies to its - translation key — true today, and silently untrue the day somebody renames - one, which would make a *new* installation's entity_id differ from every - existing one. Read out of `strings.json`, which is the file that would change. - """ - strings = json.loads( - ( - pathlib.Path(__file__).parent.parent - / "custom_components" - / "span_panel" - / "strings.json" - ).read_text(encoding="utf-8") - ) - for platform, suffix, entity_id in _LEGACY_PV_ENTITIES: - name = strings["entity"][platform][suffix]["name"] - assert slugify(name) == suffix, ( - f"{platform}.{suffix} is named {name!r}, which slugifies to " - f"{slugify(name)!r}; the pinned entity_id would stop matching what " - "Home Assistant derives" - ) - assert entity_id == f"{platform}.{slugify(PANEL_NAME)}_{slugify(name)}" - - -def test_the_descriptions_carry_the_translation_keys_the_pin_reads() -> None: - """The pin reads `translation_key`, so a description without one would pin to ``. - - An empty key would build `sensor.span_panel_` for every PV entity and Home - Assistant would resolve the collision by appending numbers — a silent, - permanent scrambling of five entity ids on new installations only. - """ - keys = {PV_POWER_SENSOR.translation_key} | { - description.translation_key for description in PV_METADATA_SENSORS - } - keys.add(PV_PANEL_LINK_SENSOR.translation_key) - assert keys == {suffix for _platform, suffix, _entity_id in _LEGACY_PV_ENTITIES} - - -# --------------------------------------------------------------------------- -# The migration -# --------------------------------------------------------------------------- - - -async def test_the_migration_moves_the_device_and_nothing_else( - hass: HomeAssistant, -) -> None: - """Seed the released shape, set up, and read the registry back. - - Home Assistant re-homes an entity when it re-registers with different - `device_info` — `async_get_or_create` updates `device_id` on an existing - entry — so there is no bespoke migration to write and deliberately none - written. What that mechanism does *not* touch is the `entity_id` or the - `unique_id`, and those are what a user's dashboards and automations name, so - they are asserted one by one rather than in aggregate. - """ - entry = await _install(hass, _pv_snapshot(), "entry-pv-migrate", seed=_seed_the_old_shape) - - device = _pv_device(hass, entry) - assert device is not None - registry = er.async_get(hass) - for platform, suffix, entity_id in _LEGACY_PV_ENTITIES: - unique_id = f"span_{entry.unique_id}_{suffix}" - entity = registry.async_get(entity_id) - assert entity is not None, f"{entity_id} no longer exists after setup" - assert entity.unique_id == unique_id, f"{entity_id} changed unique_id" - assert entity.platform == DOMAIN - assert entity.domain == platform - assert entity.device_id == device.id, f"{entity_id} did not move to the inverter" - - -async def test_the_migration_leaves_no_duplicate_and_no_orphan( - hass: HomeAssistant, -) -> None: - """The two failure modes a re-home has, made observable. - - A duplicate: the entity re-registers under a new `unique_id`, so the old - registry entry survives beside a new one and a user sees each reading twice. - An orphan: the panel device keeps an entity nothing writes to any more. Both - are counted rather than spot-checked, because either would otherwise hide - among forty circuit entities. - """ - entry = await _install(hass, _pv_snapshot(), "entry-pv-migrate-clean", seed=_seed_the_old_shape) - - device = _pv_device(hass, entry) - panel = dr.async_get(hass).async_get_device_by_identifier( - (DOMAIN, entry.unique_id or ""), entry.entry_id - ) - assert device is not None - assert panel is not None - - entities = er.async_entries_for_config_entry(er.async_get(hass), entry.entry_id) - unique_ids = [entity.unique_id for entity in entities] - assert len(unique_ids) == len(set(unique_ids)), "an entity re-registered under a second id" - - pv_unique_ids = {f"span_{entry.unique_id}_{suffix}" for _p, suffix, _e in _LEGACY_PV_ENTITIES} - left_behind = [ - entity.entity_id - for entity in entities - if entity.unique_id in pv_unique_ids and entity.device_id != device.id - ] - assert not left_behind, f"still on the panel card: {left_behind}" - - pv_devices = [ - candidate - for candidate in dr.async_entries_for_config_entry(dr.async_get(hass), entry.entry_id) - if any( - classify_sub_device_identifier(name) == SUB_DEVICE_PV - for _domain, name in candidate.identifiers - ) - ] - assert len(pv_devices) == 1, f"{len(pv_devices)} PV devices registered, expected 1" - - -async def test_a_fresh_install_and_a_migrated_one_are_the_same_shape( - hass: HomeAssistant, -) -> None: - """Two installations of the same panel, one upgraded and one new, must agree. - - Divergence here is the long-tail bug: a documentation example, a blueprint or - a support answer that is right for exactly half of the installed base. The - comparison is over `{entity_id: (unique_id, device identifiers)}` — every fact - a user or a dashboard can name — rather than over counts, so a single entity - filed under a different card fails with the entity_id in the message. - """ - snapshot = _pv_snapshot() - migrated = await _install(hass, snapshot, "entry-pv-shape-old", seed=_seed_the_old_shape) - migrated_shape = _registry_shape(hass, migrated) - - async with _a_second_home_assistant() as second: - fresh = await _install(second, snapshot, "entry-pv-shape-new") - assert migrated_shape == _registry_shape(second, fresh) - - -async def test_a_user_renamed_entity_id_survives_the_move(hass: HomeAssistant) -> None: - """The pin is a suggestion, and must lose to a choice the user already made. - - Home Assistant only honours a suggested `entity_id` for an entity it is - registering for the first time. Asserted rather than assumed, because the pin - is the one new thing in this change that could plausibly overwrite something - of the user's. - """ - - def seed(hass_: HomeAssistant, entry_: MockConfigEntry, panel_device_id: str) -> None: - _seed_the_old_shape(hass_, entry_, panel_device_id) - er.async_get(hass_).async_update_entity( - "sensor.span_panel_pv_vendor", new_entity_id="sensor.my_solar_brand" - ) - - entry = await _install(hass, _pv_snapshot(), "entry-pv-renamed", seed=seed) - - registry = er.async_get(hass) - assert registry.async_get("sensor.span_panel_pv_vendor") is None - renamed = registry.async_get("sensor.my_solar_brand") - assert renamed is not None - device = _pv_device(hass, entry) - assert device is not None - assert renamed.device_id == device.id - - -# --------------------------------------------------------------------------- -# The inventories -# --------------------------------------------------------------------------- - - -def test_the_firmware_version_is_no_longer_an_unread_declaration() -> None: - """The line this task exists to delete, and the line that stays. - - `pv/info/firmware-version` was baselined saying the inverter had no card to - carry a version; it has one now, so the line goes. `pv/info/serial-number` - stays, and its reason has to name the identifier decision rather than only - the producer-side one, because the Home Assistant identifier is now a second - thing that would have moved. - """ - baseline: dict[str, str] = json.loads(BASELINE.read_text(encoding="utf-8")) - - assert "pv/info/firmware-version" not in baseline - reason = baseline.get("pv/info/serial-number") - assert reason is not None, "the serial must stay baselined; see decision 3" - assert "identifier" in reason - - -def test_the_cards_firmware_read_is_enumerated_as_a_residual() -> None: - """`pv_device_info` is not an entity, so its read is an exempt residual. - - `NEITHER` beside the `mid.*` and `panel.*` card reads: flat's `pv` device - class declares no firmware version, and a schema_1 metadata row carries a - unit and a datatype for a reading, which a version string is not. - """ - assert RESIDUAL_EXEMPT_PATHS["pv.software_version"] is Producibility.NEITHER - assert "pv.software_version" not in declared_field_paths() - - -def test_nothing_reads_a_pv_serial_anywhere() -> None: - """The negative half of the identifier decision, asserted rather than assumed. - - Adding the field to the snapshot would be harmless; reading it here would - not, because every read is a place a future change could route into the - identifier. There is no such field and no such path, and this fails the day - one arrives without the decision being revisited. - """ - assert "pv.serial_number" not in RESIDUAL_EXEMPT_PATHS - assert "pv.serial_number" not in declared_field_paths() - snapshot = _pv_snapshot(info__serial_number="INVERTER-SERIAL-0001") - assert not hasattr(snapshot.pv, "serial_number") - - -def test_the_kind_vocabulary_is_closed() -> None: - """Every kind the classifier answers with, in one place. - - Not a restatement of the classifier: `test_device_links` parametrises over - the *builders*, so a fifth sub-device whose identifier nothing classifies - fails there. This is the other direction — the reader answering a kind the - writers never mint — and it is why the list is spelled out rather than - derived from the constants. - """ - assert [ - classify_sub_device_identifier("panel-serial_bess"), - classify_sub_device_identifier("panel-serial_mid"), - classify_sub_device_identifier("panel-serial_evse_node"), - classify_sub_device_identifier("panel-serial_pv"), - classify_sub_device_identifier("panel-serial"), - ] == ["bess", "mid", "evse", SUB_DEVICE_PV, None] diff --git a/tests/test_sensor_entities.py b/tests/test_sensor_entities.py index 93348c14..acc3e599 100644 --- a/tests/test_sensor_entities.py +++ b/tests/test_sensor_entities.py @@ -154,13 +154,7 @@ def test_panel_sensor_default_friendly_names_cover_fallback_branches() -> None: snapshot, {"identifiers": {("span_panel", "bess")}}, ) - pv_sensor = SpanPVMetadataSensor( - coordinator, - PV_METADATA_SENSORS[0], - snapshot, - {"identifiers": {("span_panel", "pv")}}, - "sensor.span_panel_pv_vendor", - ) + pv_sensor = SpanPVMetadataSensor(coordinator, PV_METADATA_SENSORS[0], snapshot) assert panel_data_sensor._generate_friendly_name(snapshot, panel_data_desc) == "Sensor" assert status_sensor._generate_friendly_name(snapshot, status_desc) == "Status" @@ -184,13 +178,7 @@ def test_panel_metadata_sensors_return_expected_data_sources() -> None: snapshot, {"identifiers": {("span_panel", "bess")}}, ) - pv_sensor = SpanPVMetadataSensor( - coordinator, - PV_METADATA_SENSORS[0], - snapshot, - {"identifiers": {("span_panel", "pv")}}, - "sensor.span_panel_pv_vendor", - ) + pv_sensor = SpanPVMetadataSensor(coordinator, PV_METADATA_SENSORS[0], snapshot) assert bess_sensor.get_data_source(snapshot) is battery assert pv_sensor.get_data_source(snapshot) is snapshot diff --git a/tests/test_websocket.py b/tests/test_websocket.py index d11647f4..188de7cf 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -184,12 +184,6 @@ def test_evse(self): device.identifiers = {(DOMAIN, "sp3-242424-001_evse_0")} assert _classify_sub_device(device) == "evse" - def test_pv(self): - """Classify the solar inverter sub-device from its identifier.""" - device = MagicMock() - device.identifiers = {(DOMAIN, "sp3-242424-001_pv")} - assert _classify_sub_device(device) == "pv" - def test_unknown(self): """Treat the panel device itself as an unknown sub-device type.""" device = MagicMock() @@ -388,7 +382,7 @@ async def test_non_span_device(self, hass: HomeAssistant): @pytest.mark.asyncio async def test_sub_device_id_rejected(self, hass: HomeAssistant): - """Error when device_id is a sub-device, not the panel.""" + """Error when device_id is a BESS/EVSE sub-device, not the panel.""" entry = MockConfigEntry( domain=DOMAIN, data={}, @@ -425,7 +419,7 @@ async def test_sub_device_id_rejected(self, hass: HomeAssistant): connection.send_error.assert_called_once_with( 1, "not_panel_device", - "Use the SPAN panel device registry ID, not a sub-device.", + "Use the SPAN panel device registry ID, not a BESS or EVSE sub-device.", ) @pytest.mark.asyncio From 2d7b5f9d7face27f2bc08ebfe8163319cd4c398c Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:27:17 -0700 Subject: [PATCH 059/345] feat(device): give the solar inverter a card of its own Reinstates 85ca619 without the part of it that was wrong. PV was the last DER on a v1.0 panel with no device. eBus models it as a device beside the BESS, the MID and the EVSE, and it was the only one of the four with nothing to render on. Its vendor, model and nameplate capacity were three diagnostic sensors on the *panel's* card, beside the panel's own manufacturer and model, so the card whose job is saying which enclosure this is read as if the enclosure were an Enphase inverter. The firmware version the library has read all along reached nothing, because a version has nowhere to go but a card; `pv/info/firmware-version` was baselined saying exactly that, and that line goes. `pv/info/serial-number` stays, with its reason intact. `SUB_DEVICE_PV` and `pv_device_info` follow the BESS and the MID, carrying manufacturer, model and sw_version off the wire. The identifier is `{panel serial}_pv` and deliberately does not mention the inverter's serial: every PV description declares `info/serial-number` and no producer publishes one, so an identifier preferring it would change the day firmware started publishing, and a device identifier is what a consumer keys its registry on -- that day would read as the inverter being replaced. The four kinds now sit under one classifier, which tests the EVSE *infix* before the three suffixes because a suffix rule cannot tell a charger whose node id ends in `_pv` from an inverter. Five entities change device: `pv_power`, `pv_vendor`, `pv_product`, `pv_nameplate_capacity` and `pv_panel_link`. Circuit entities do not move, the PV circuit's included -- those belong to the circuit model. **What is different from 85ca619.** That revision pinned all five to their panel-scoped `entity_id` on every installation, so a new install matched an upgraded one exactly. The pin is gone. `async_get_or_create` honours a suggested object id only at first registration -- for a `unique_id` it already knows it returns the existing entry, updates `device_id` and leaves `entity_id` alone -- so the pin never protected the installed base at all. All it did was give every future installation an entity id derived from the panel rather than from the device the entity sits on, permanently, to match a history that installation does not have. So an existing installation keeps `sensor.span_panel_pv_vendor` and a new one gets `sensor.span_panel_solar_pv_vendor`, both correct, and `construct_panel_scoped_entity_id` and the `entity_id_override` parameters that existed only to serve the pin are removed. `construct_single_circuit_entity_id` is untouched -- it serves the circuit sensors on an EVSE sub-device and is not this. No area is seeded on the new device: `suggested_area` is deprecated with `breaks_in_ha_version="2026.9"` against a 2026.8 pin, and an explicit registry write cannot tell an area a user cleared from one never assigned, so it would re-assign the card on every reload. The reasoning is recorded on `pv_device_info` rather than left to be rediscovered. `tests/test_pv_device.py` seeds a registry in the released shape with literal legacy ids, sets both platforms up through real entity platforms, and reads the registry back: every entity keeps its `entity_id` and its `unique_id`, lands on the inverter, and leaves no duplicate or orphan behind. A user-renamed `entity_id` still wins. `test_a_fresh_install_and_a_migrated_one_differ_only_in_entity_id` replaces the test that asserted the two were identical: it pins both sets of literals, asserts the five have the same unique_ids and the same device across the fork, and asserts every *other* entity is identical between the two -- and its docstring says why the asymmetry exists, so it is not read later as a bug. --- CHANGELOG.md | 13 + custom_components/span_panel/binary_sensor.py | 23 +- custom_components/span_panel/field_paths.py | 13 + custom_components/span_panel/sensor.py | 44 +- custom_components/span_panel/sensor_panel.py | 20 +- custom_components/span_panel/services.py | 8 +- custom_components/span_panel/util.py | 71 +- custom_components/span_panel/websocket.py | 11 +- .../unread_declarations_baseline.json | 3 +- tests/test_device_links.py | 34 +- tests/test_field_path_conformance.py | 6 +- tests/test_pv_device.py | 821 ++++++++++++++++++ tests/test_sensor_entities.py | 14 +- tests/test_websocket.py | 10 +- 14 files changed, 1057 insertions(+), 34 deletions(-) create mode 100644 tests/test_pv_device.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ecce0eab..6cefce3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ All notable changes to this project will be documented in this file. ### Added +- **Your solar inverter gets a device of its own, on panels running the v1.0 data model.** Its vendor, model and nameplate capacity used to render as diagnostic + sensors on the _panel's_ card, beside the panel's own manufacturer and model — so the card whose job is telling you which enclosure this is read as though the + enclosure were an Enphase inverter. It now has a card like the battery and the chargers already do, carrying the firmware version the panel has been + publishing all along. +- **If you already have these sensors, nothing about them changes.** The five entities that move to the new card — PV Power, PV Vendor, PV Product, PV Nameplate + Capacity and PV Panel Link — keep the entity ids and unique ids they have today, so dashboards, automations and history follow them across untouched. Only the + card they appear on changes. +- **New installations get different entity ids for these five, and that is intended.** Home Assistant derives a new entity's id from the name of the device it + sits on, so a system installed from now on gets `sensor.span_panel_solar_pv_vendor` where a system installed before this release keeps + `sensor.span_panel_pv_vendor`. Both are correct and neither will change again: an existing system must never have an id renamed under it, and a new one gets + the id Home Assistant would give it. If you are comparing two SPAN systems and their PV entity ids differ, install date is why. The unique ids are the same on + both, and both sets of entities sit on the same new card. + - **Your panel's own card now shows what the panel says it is** — manufacturer, model and hardware revision, read from the enclosure rather than assumed. A panel on the older data model publishes none of the three and keeps exactly the card it has today; the hardware revision row is left off rather than shown blank where no revision is published. diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py index 0b2e6bda..769f0cb2 100644 --- a/custom_components/span_panel/binary_sensor.py +++ b/custom_components/span_panel/binary_sensor.py @@ -39,7 +39,7 @@ has_pcs, resolve_evse_display_suffix, ) -from .util import bess_device_info, evse_device_info +from .util import bess_device_info, evse_device_info, pv_device_info # pylint: disable=invalid-overridden-method @@ -178,8 +178,9 @@ def _grid_islandable(snapshot: SpanPanelSnapshot) -> bool | None: one device class whose link the panel happened to report through the lugs was the only one a user could see. -On the panel device, beside `pv_vendor` and `pv_product`, because the PV is not -yet a sub-device of its own. It moves with them when it becomes one. +On the inverter's own sub-device, beside `pv_vendor` and `pv_product`, which is +where it moved when the PV got a device of its own -- the same place +`bess_connected` sits relative to the battery. `SCHEMA_CONDITIONAL_FIELD` *and* `field_path`: flat firmware publishes `connected` on the BESS and on nothing else, so the both-adapters gate cannot be @@ -541,7 +542,21 @@ async def async_setup_entry( # saying it does not know rather than a fault, and the enum it does publish # has no UNKNOWN member for it to say that with. See `PV_PANEL_LINK_SENSOR`. if snapshot.pv.connected is not None: - entities.append(SpanPanelBinarySensor(coordinator, PV_PANEL_LINK_SENSOR)) + configured_name = coordinator.config_entry.data.get( + CONF_DEVICE_NAME, coordinator.config_entry.title + ) + entities.append( + SpanPanelBinarySensor( + coordinator, + PV_PANEL_LINK_SENSOR, + device_info_override=pv_device_info( + snapshot.serial_number, + snapshot.pv, + configured_name or "Span Panel", + panel_device_id=config_entry.runtime_data.panel_device_id, + ), + ) + ) # Add EVSE binary sensors for each commissioned charger if snapshot.evse: diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py index ba874ffd..e58dce2b 100644 --- a/custom_components/span_panel/field_paths.py +++ b/custom_components/span_panel/field_paths.py @@ -202,6 +202,19 @@ class FieldPathDeclarationMixin: "mid.model": Producibility.NEITHER, "mid.serial_number": Producibility.NEITHER, "mid.grid_forming_device_name": Producibility.NEITHER, + # `pv_device_info` reads the inverter's firmware version for its device + # card. `pv.vendor_name` and `pv.model` are not here beside it because + # they are `field_path` declarations on the three PV metadata sensors + # already, and the card reads the same two fields those sensors do. + # + # `NEITHER` for the same reason as the `mid.*` and `panel.*` card reads + # above: flat's `pv` device class declares no firmware version at all, + # and a schema_1 metadata row carries a unit and a datatype for a + # *reading*, which a version string is not. + # + # `pv.serial_number` is deliberately absent -- from this table, from the + # card and from the snapshot. See `pv_device_info`. + "pv.software_version": Producibility.NEITHER, # The `mid_grid_state` sensor's source field — utility-supply health, # the one non-metadata entity the MID brings. Neither adapter maps the # MID at all, which is why the description is `NO_SOURCE_FIELD`. diff --git a/custom_components/span_panel/sensor.py b/custom_components/span_panel/sensor.py index cfbc5c77..7661b1cd 100644 --- a/custom_components/span_panel/sensor.py +++ b/custom_components/span_panel/sensor.py @@ -78,7 +78,7 @@ SpanPVMetadataSensor, SpanShedForecastSensor, ) -from .util import bess_device_info, evse_device_info, mid_device_info +from .util import bess_device_info, evse_device_info, mid_device_info, pv_device_info # Export the sensor classes for backward compatibility with tests __all__ = [ @@ -478,6 +478,22 @@ def create_battery_sensors( return entities +def _build_pv_device_info( + coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot +) -> DeviceInfo: + """DeviceInfo for the solar inverter sub-device.""" + panel_name = ( + coordinator.config_entry.data.get(CONF_DEVICE_NAME, coordinator.config_entry.title) + or "Span Panel" + ) + return pv_device_info( + snapshot.serial_number, + snapshot.pv, + panel_name, + panel_device_id=coordinator.config_entry.runtime_data.panel_device_id, + ) + + def create_power_flow_sensors( coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot ) -> list[SpanPanelPowerSensor | SpanPVMetadataSensor]: @@ -486,15 +502,35 @@ def create_power_flow_sensors( PV Power — only when PV is commissioned. Site Power — only when the power-flows node is publishing. PV metadata sensors — only when PV is commissioned. + + The PV sensors land on the inverter's own sub-device, matching what the BESS + has done since v1.0: `battery_power` is the enclosure's reading of the + battery and it sits on the battery's card, so `pv_power` -- the enclosure's + reading of the inverter -- belongs on the inverter's. + + Nothing pins an entity_id. An installation that already has these five keeps + the ids it has, because the registry never renames an entity it already + knows; a new one gets whatever Home Assistant derives from the inverter's + device name, which is the standard assignment and what every other + sub-device entity gets. The two shapes differ by install date and that is + deliberate -- see `test_pv_device.py`. """ entities: list[SpanPanelPowerSensor | SpanPVMetadataSensor] = [] if has_pv(snapshot): - entities.append(SpanPanelPowerSensor(coordinator, PV_POWER_SENSOR, snapshot)) + pv_info = _build_pv_device_info(coordinator, snapshot) + entities.append( + SpanPanelPowerSensor( + coordinator, + PV_POWER_SENSOR, + snapshot, + device_info_override=pv_info, + ) + ) - # PV metadata sensors on the main panel device entities.extend( - SpanPVMetadataSensor(coordinator, desc, snapshot) for desc in PV_METADATA_SENSORS + SpanPVMetadataSensor(coordinator, desc, snapshot, pv_info) + for desc in PV_METADATA_SENSORS ) if has_power_flows(snapshot): diff --git a/custom_components/span_panel/sensor_panel.py b/custom_components/span_panel/sensor_panel.py index 161fcc47..30d85560 100644 --- a/custom_components/span_panel/sensor_panel.py +++ b/custom_components/span_panel/sensor_panel.py @@ -657,16 +657,34 @@ def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanMidSnapshot: class SpanPVMetadataSensor( SpanSensorBase[SpanPVMetadataSensorEntityDescription, SpanPanelSnapshot] ): - """PV metadata sensor entity on the main panel device.""" + """PV metadata sensor entity on the PV sub-device. + + On the panel's own card until the inverter got one of its own, which put the + inverter's vendor and model beside the *panel's* vendor and model on the card + whose job is saying which enclosure this is. + + The unique_id stays the panel-scoped one `construct_panel_unique_id_for_entry` + has always built, because a unique_id is an identity and these are the same + three entities they were. Only the device they hang off changes, which is a + registry update Home Assistant performs itself when the entity re-registers. + + The `entity_id` is not touched either way. An installation that already has + these three keeps the panel-scoped ids it has, because the registry never + renames an entity it already knows; a new one gets the id Home Assistant + derives from the inverter's device name. That asymmetry is intended -- see + `test_pv_device.py`. + """ def __init__( self, data_coordinator: SpanPanelCoordinator, description: SpanPVMetadataSensorEntityDescription, snapshot: SpanPanelSnapshot, + device_info_override: DeviceInfo, ) -> None: """Initialize the PV metadata sensor.""" super().__init__(data_coordinator, description, snapshot) + self._attr_device_info = device_info_override def _generate_unique_id( self, diff --git a/custom_components/span_panel/services.py b/custom_components/span_panel/services.py index 0500424b..24581d91 100644 --- a/custom_components/span_panel/services.py +++ b/custom_components/span_panel/services.py @@ -513,8 +513,10 @@ def _resolve_entity_to_favorite_target(entity_id: str) -> tuple[str, FavoriteKin ``kind`` is ``"circuits"`` or ``"sub_devices"``. For circuits, ``target_id`` is the panel-local circuit uuid (extracted from the - entity's unique_id). For sub-devices, ``target_id`` is the HA - device id of the BESS/EVSE; the panel id walks up via ``via_device_id``. + entity's unique_id). For sub-devices, ``target_id`` is the HA device id + of the sub-device; the panel id walks up via ``via_device_id``. Nothing + here enumerates the kinds, so a new one -- the PV inverter most recently + -- is favouritable the day its device exists. Failure paths use distinct translation keys so users see the actual reason their pick was rejected. @@ -551,7 +553,7 @@ def _resolve_entity_to_favorite_target(entity_id: str) -> tuple[str, FavoriteKin # Resolve the panel device id. Sub-devices register with # via_device_id; main panels never do, so via_device_id presence is a - # reliable discriminator (BESS / EVSE today) and we walk up to the + # reliable discriminator whatever kinds exist, and we walk up to the # parent SPAN Panel here. if device_entry.via_device_id is not None: parent = device_registry.async_get(device_entry.via_device_id) diff --git a/custom_components/span_panel/util.py b/custom_components/span_panel/util.py index 85024db4..93f6f0cb 100644 --- a/custom_components/span_panel/util.py +++ b/custom_components/span_panel/util.py @@ -9,6 +9,7 @@ SpanEvseSnapshot, SpanMidSnapshot, SpanPanelSnapshot, + SpanPVSnapshot, ) from .const import DOMAIN @@ -35,6 +36,7 @@ SUB_DEVICE_BESS: Final = "bess" SUB_DEVICE_MID: Final = "mid" SUB_DEVICE_EVSE: Final = "evse" +SUB_DEVICE_PV: Final = "pv" def classify_sub_device_identifier(identifier: str) -> str | None: @@ -43,15 +45,23 @@ def classify_sub_device_identifier(identifier: str) -> str | None: None rather than an "unknown" string: the caller knows whether it is looking at something that must be a sub-device, and a sentinel that reads like a kind is what let an unclassified device render as a device with no type. + + **Most specific first.** EVSE is the one kind whose token is an infix rather + than a suffix, and a suffix test cannot tell `..._evse_inverter_pv` from a PV + identifier. Testing the infix first makes the charger's node id opaque to the + suffix rules below, which is the only ordering that stays right whatever a + panel names its nodes. """ - if identifier.endswith(f"_{SUB_DEVICE_BESS}"): - return SUB_DEVICE_BESS - if identifier.endswith(f"_{SUB_DEVICE_MID}"): - return SUB_DEVICE_MID # Infix, not suffix: the node id follows, and it is what distinguishes one # charger from another on the same panel. if f"_{SUB_DEVICE_EVSE}_" in identifier: return SUB_DEVICE_EVSE + if identifier.endswith(f"_{SUB_DEVICE_BESS}"): + return SUB_DEVICE_BESS + if identifier.endswith(f"_{SUB_DEVICE_MID}"): + return SUB_DEVICE_MID + if identifier.endswith(f"_{SUB_DEVICE_PV}"): + return SUB_DEVICE_PV return None @@ -181,3 +191,56 @@ def evse_device_info( sw_version=evse.software_version, via_device_id=panel_device_id, ) + + +def pv_device_info( + panel_identifier: str, + pv: SpanPVSnapshot, + panel_name: str, + *, + panel_device_id: str, +) -> DeviceInfo: + """Create DeviceInfo for the solar inverter, linked to the parent panel. + + The last DER to get a card of its own. Its vendor, model and nameplate + capacity have been readable all along and were shown as three diagnostic + sensors on the *panel's* card, beside the panel's own manufacturer and model, + which reads as if the enclosure were an Enphase inverter. The firmware + version the library also reads reached nothing at all, because a version has + no home but a device card. + + **The identifier deliberately does not mention the inverter's serial.** + `info/serial-number` is declared by every PV `$description` and published by + no producer today, so an identifier preferring it would be `_pv` on + every panel now and `_` on the first panel whose firmware + starts publishing one -- and a device identifier is what a consumer keys its + registry on, so that day would read as the inverter being replaced rather + than as a value arriving. `{panel serial}_pv` answers the only question an + identifier has to answer, "which panel's inverter", and a panel has exactly + one `pv` node, so nothing distinguishes two of them. The serial is not on the + card either, for the same reason it is not in the identifier: nothing in this + integration should start depending on it before a producer publishes one. + + **No area is seeded**, so an upgraded installation has to assign this card to + an area the way it assigned the battery's and the chargers'. Both routes were + considered and neither is clean. `DeviceInfo`'s `suggested_area` is deprecated + with `breaks_in_ha_version="2026.9"`, one release past the version pinned + here, so adopting it would be adopting a removal. An explicit + `device_registry.async_update_device(area_id=...)` after setup cannot tell an + area a user deliberately cleared from one never assigned -- the registry + records `None` for both -- so it would silently re-assign the card on every + reload. Doing nothing also keeps every sub-device the same: the BESS, the MID + and each charger seed no area either. + """ + return DeviceInfo( + identifiers={(DOMAIN, f"{panel_identifier}_{SUB_DEVICE_PV}")}, + name=f"{panel_name} Solar", + manufacturer=pv.vendor_name or "Unknown", + model=pv.model or "Solar Inverter", + # Passed through unguarded, as on the BESS and the MID: `DeviceInfo` + # omits a `None` field and renders an empty string as a present-but-blank + # row, so `or ""` would invent a version row for an inverter that + # published none. + sw_version=pv.software_version, + via_device_id=panel_device_id, + ) diff --git a/custom_components/span_panel/websocket.py b/custom_components/span_panel/websocket.py index f2969a60..b969bf8b 100644 --- a/custom_components/span_panel/websocket.py +++ b/custom_components/span_panel/websocket.py @@ -75,8 +75,9 @@ async def handle_panel_topology( """Return the full panel topology with entity mappings. Admin users must pass the HA device registry ID for the **main SPAN panel** - device only (not BESS/EVSE sub-devices). Returns panel metadata, circuits - with tabs/entity mappings, and sub-devices (BESS, EVSE). + device only (not a sub-device). Returns panel metadata, circuits with + tabs/entity mappings, and sub-devices -- every kind + `classify_sub_device_identifier` names, which is BESS, MID, EVSE and PV. """ device_id = msg["device_id"] @@ -92,12 +93,12 @@ async def handle_panel_topology( connection.send_error(msg["id"], "not_span_panel", "Device is not a SPAN Panel device") return - # Sub-devices (BESS, EVSE) register with via_device_id pointing at the panel. + # Every sub-device registers with via_device_id pointing at the panel. if device_entry.via_device_id is not None: connection.send_error( msg["id"], "not_panel_device", - "Use the SPAN panel device registry ID, not a BESS or EVSE sub-device.", + "Use the SPAN panel device registry ID, not a sub-device.", ) return @@ -225,7 +226,7 @@ def _find_config_entry_id(device_entry: dr.DeviceEntry) -> str | None: def _classify_sub_device(device_entry: dr.DeviceEntry) -> str: - """Classify a sub-device by its identifiers: 'bess', 'mid', 'evse' or 'unknown'. + """Classify a sub-device by its identifiers, or 'unknown'. The grammar lives with the builders that write it, in `util`, rather than being restated here. Restating it is how the MID went out as 'unknown' for a diff --git a/tests/fixtures/unread_declarations_baseline.json b/tests/fixtures/unread_declarations_baseline.json index b9767d52..8cfe362e 100644 --- a/tests/fixtures/unread_declarations_baseline.json +++ b/tests/fixtures/unread_declarations_baseline.json @@ -8,6 +8,5 @@ "lugs/connection/fed-by-device-type": "redundant: the id dereferences to the device's declared $type, which is how the library already resolves classes", "lugs/connection/feeds-device-status": "feedthrough link-health; the feeds-* half of the downstream lugs record is topology to consume, and no producer publishes it", "lugs/connection/feeds-device-type": "redundant: the id dereferences to the device's declared $type, which is how the library already resolves classes", - "pv/info/firmware-version": "reaches pv.software_version and stops there; PV has no sub-device registry entry, so there is no DeviceInfo to carry a version - it lands with the PV sub-device (Task 7)", - "pv/info/serial-number": "not read at all: _der_identifier prefers a serial over instance_id, so valuing it would move the PV device id from -pv-1 to - - a device replacement, not an upgrade (Task 7)" + "pv/info/serial-number": "held, and the PV device identifier deliberately does not use it: _der_identifier prefers a serial over instance_id, so valuing it would move the PV device id from -pv-1 to - - a device replacement, not an upgrade. The Home Assistant sub-device is keyed _pv for the same reason, so a serial arriving on the wire moves nothing" } diff --git a/tests/test_device_links.py b/tests/test_device_links.py index de91e06c..6ea08af2 100644 --- a/tests/test_device_links.py +++ b/tests/test_device_links.py @@ -21,7 +21,12 @@ from typing import Any +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry +from span_panel_api import SpanMidSnapshot, SpanPVSnapshot from custom_components.span_panel import ensure_device_registered from custom_components.span_panel.const import DOMAIN @@ -30,11 +35,8 @@ classify_sub_device_identifier, evse_device_info, mid_device_info, + pv_device_info, ) -from homeassistant.const import CONF_HOST -from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr -from span_panel_api import SpanMidSnapshot from .factories import ( SpanBatterySnapshotFactory, @@ -42,8 +44,6 @@ SpanPanelSnapshotFactory, ) -from pytest_homeassistant_custom_component.common import MockConfigEntry - _PANEL_ID = "a-registry-id" @@ -77,6 +77,15 @@ def _builders() -> list[tuple[str, Any]]: "sp3-link-001", _mid(), "Panel", panel_device_id=_PANEL_ID ), ), + ( + "pv", + lambda: pv_device_info( + "sp3-link-001", + SpanPVSnapshot(vendor_name="Enphase", model="IQ8", software_version="v1"), + "Panel", + panel_device_id=_PANEL_ID, + ), + ), ( "evse", lambda: evse_device_info( @@ -118,6 +127,19 @@ def test_the_panel_is_not_mistaken_for_a_sub_device() -> None: assert classify_sub_device_identifier("sim-40t-001") is None +def test_a_charger_node_ending_in_a_kind_is_still_a_charger() -> None: + """The infix wins over the suffixes, which is why it is tested first. + + A charger's identifier carries its Homie node id, and nothing stops a node id + from ending in `_pv` or `_bess`. Testing the suffix rules first would classify + such a charger as whatever its node id happened to end with -- a device + rendering as the wrong type on a dashboard, which is the failure the MID + already shipped once. + """ + assert classify_sub_device_identifier("sp3-link-001_evse_inverter_pv") == "evse" + assert classify_sub_device_identifier("sp3-link-001_evse_garage_bess") == "evse" + + @pytest.mark.parametrize(("label", "build"), _builders(), ids=lambda v: v if isinstance(v, str) else "") def test_sub_devices_link_by_registry_id(label: str, build: Any) -> None: """Both halves asserted: the new key is set and the old one is gone. diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py index 37b272dc..2e4e0bf2 100644 --- a/tests/test_field_path_conformance.py +++ b/tests/test_field_path_conformance.py @@ -288,7 +288,11 @@ def test_no_exempt_path_is_producible_by_both() -> None: # `$target` echo it renders as an attribute. Facts about a command rather # than readings, so no adapter carries a row for either -- the same shape as # the `circuit.*_target` pair. - Producibility.NEITHER: 43, + # +1 for `pv.software_version`, the firmware row on the solar inverter's own + # device card. Flat's `pv` device class declares no firmware version, and a + # version string is identity rather than a reading -- the same argument as + # the `mid.*` and `panel.*` card reads above. + Producibility.NEITHER: 44, # +1 for `panel.dominant_power_source`, the `grid_forming_entity` sensor's # source field. It was read by a `SCHEMA_CONDITIONAL_FIELD` description and # enumerated nowhere, so `evaluate_field_metadata` counted it as produced- diff --git a/tests/test_pv_device.py b/tests/test_pv_device.py new file mode 100644 index 00000000..db353cba --- /dev/null +++ b/tests/test_pv_device.py @@ -0,0 +1,821 @@ +"""The solar inverter gets a device of its own, and takes its entities with it. + +PV was the last DER on a v1.0 panel with no card. Its vendor, model and +nameplate capacity were rendered as three diagnostic sensors on the *panel's* +device — beside the panel's own manufacturer and model, so a panel card read as +if the enclosure were an Enphase inverter — and the firmware version the library +has always read reached nothing at all, because a version has nowhere to go but +a card. `pv/info/firmware-version` was baselined naming exactly that. + +**The identifier is the decision worth writing down.** `{panel serial}_pv`, and +deliberately not the inverter's serial. Every PV `$description` declares +`info/serial-number` and no producer publishes one, so an identifier that +preferred a serial would be `_pv` on every panel today and +`_` on the first panel whose firmware starts publishing one — and +a device identifier is what a consumer keys its registry on, so that day would +read as the inverter being replaced. `test_a_serial_arriving_on_the_wire_does_not_move_the_device` +produces that day and asserts nothing moves. + +**The migration is the risk.** Existing installations have these entities on the +panel device with panel-scoped `unique_id`s and panel-scoped `entity_id`s. Moving +an entity to another device must change *only* the device: a changed `entity_id` +breaks a dashboard, a changed `unique_id` orphans the entity and mints a +duplicate. Home Assistant re-homes an entity by itself when it re-registers with +new `device_info` — `async_get_or_create` returns the entry it already holds for +a `unique_id`, updates its `device_id` and leaves its `entity_id` untouched — so +there is no bespoke migration here and deliberately none written. + +**The fork in entity ids is deliberate; do not close it.** Nothing pins an +`entity_id`. Home Assistant derives a new entity's object id from the name of the +device it belongs to, so a fresh installation gets +`sensor.span_panel_solar_pv_vendor` where every existing one keeps +`sensor.span_panel_pv_vendor`. Both are correct: an existing installation must +never have an id change under it, and a new one gets the standard Home Assistant +assignment rather than a legacy shape invented to match history it does not have. +An earlier revision pinned the panel-scoped id on both to keep the two identical; +that was reversed, because the pin bought uniformity by giving every future +installation an entity id derived from the wrong device. +`test_a_fresh_install_and_a_migrated_one_differ_only_in_entity_id` is the record +of the decision. + +Every expectation about what the card shows is read out of the vendored capture +and proved by republishing or unpublishing the property, never by a literal. The +registry-shape expectations *are* literals, on purpose: they record what a +released installation carries, and deriving them from the code under test would +make the migration assertions vacuous. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable +from contextlib import asynccontextmanager +import json +import logging +import pathlib +from typing import Final +from unittest.mock import AsyncMock, MagicMock + +from span_panel_api import SpanPanelSnapshot + +from custom_components.span_panel import SpanPanelRuntimeData, ensure_device_registered +from custom_components.span_panel.binary_sensor import ( + PV_PANEL_LINK_SENSOR, + async_setup_entry as binary_sensor_setup_entry, +) +from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.field_paths import ( + RESIDUAL_EXEMPT_PATHS, + Producibility, + declared_field_paths, +) +from custom_components.span_panel.sensor import async_setup_entry as sensor_setup_entry +from custom_components.span_panel.sensor_definitions import PV_METADATA_SENSORS, PV_POWER_SENSOR +from custom_components.span_panel.util import SUB_DEVICE_PV, classify_sub_device_identifier +from custom_components.span_panel.websocket import _classify_sub_device +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util import slugify + +from .adapter_fixtures import schema_one_snapshot, schema_one_tree + +from pytest_homeassistant_custom_component.common import ( + MockConfigEntry, + MockEntityPlatform, + async_test_home_assistant, +) + +PV_DEVICE: Final = "pv" +"""The inverter's Homie device id in the capture.""" + +VENDOR_TOPIC: Final = "info/vendor-name" +MODEL_TOPIC: Final = "info/model" +FIRMWARE_TOPIC: Final = "info/firmware-version" +SERIAL_TOPIC: Final = "info/serial-number" + +PANEL_NAME: Final = "SPAN Panel" + +FALLBACK_MANUFACTURER: Final = "Unknown" +FALLBACK_MODEL: Final = "Solar Inverter" + +BASELINE: Final = pathlib.Path(__file__).parent / "fixtures" / "unread_declarations_baseline.json" + +_LEGACY_PV_ENTITIES: Final[tuple[tuple[str, str, str], ...]] = ( + ("sensor", "pv_power", "sensor.span_panel_pv_power"), + ("sensor", "pv_vendor", "sensor.span_panel_pv_vendor"), + ("sensor", "pv_product", "sensor.span_panel_pv_product"), + ("sensor", "pv_nameplate_capacity", "sensor.span_panel_pv_nameplate_capacity"), + ("binary_sensor", "pv_panel_link", "binary_sensor.span_panel_pv_panel_link"), +) +"""``(platform, unique_id suffix, entity_id)`` as a released installation holds them. + +Literals, deliberately. This is the registry a user upgrading already has, which +is a historical fact rather than something the current code gets to decide — +deriving it from the builders under test would make every assertion below agree +with itself. `test_a_fresh_install_still_builds_the_unique_ids_users_already_have` +is what holds the literals to the code. +""" + +_FRESH_PV_ENTITIES: Final[tuple[tuple[str, str, str], ...]] = ( + ("sensor", "pv_power", "sensor.span_panel_solar_pv_power"), + ("sensor", "pv_vendor", "sensor.span_panel_solar_pv_vendor"), + ("sensor", "pv_product", "sensor.span_panel_solar_pv_product"), + ("sensor", "pv_nameplate_capacity", "sensor.span_panel_solar_pv_nameplate_capacity"), + ("binary_sensor", "pv_panel_link", "binary_sensor.span_panel_solar_pv_panel_link"), +) +"""The same five as a *new* installation gets them, on the inverter's own card. + +`{device name} {entity name}`, slugified — Home Assistant's own derivation, with +the device being `SPAN Panel Solar` rather than `SPAN Panel`. Literals for the +same reason the legacy tuple is: these are what a user, a dashboard and a +support answer will name, and deriving them from the code under test would make +the assertions agree with themselves. +`test_the_fresh_ids_are_what_the_inverters_card_derives` holds them to +`strings.json`. +""" + + +# --------------------------------------------------------------------------- +# Reading the capture +# --------------------------------------------------------------------------- + + +def _published(topic: str) -> str: + """What the capture publishes on one PV topic, or fail saying it does not.""" + value = schema_one_tree()[PV_DEVICE].get(topic) + assert value is not None, f"the capture publishes no {topic} on the inverter" + return value + + +def _declared(topic: str) -> bool: + """Whether the inverter's `$description` declares one `node/property`.""" + description = json.loads(schema_one_tree()[PV_DEVICE]["$description"]) + node, _, prop = topic.partition("/") + return prop in description["nodes"].get(node, {}).get("properties", {}) + + +def _pv_snapshot(**rewrites: str | None) -> SpanPanelSnapshot: + """A snapshot from the capture with the inverter's topics rewritten or removed. + + Keyword spelling is `node__property_name`. `None` removes the topic, which is + what firmware omitting a property looks like — a different event from + publishing an empty string, and the one the card's fallbacks exist for. + """ + tree = schema_one_tree() + for path, value in rewrites.items(): + node, _, prop = path.partition("__") + topic = f"{node.replace('_', '-')}/{prop.replace('_', '-')}" + if value is None: + tree[PV_DEVICE].pop(topic, None) + else: + tree[PV_DEVICE][topic] = value + return schema_one_snapshot(tree) + + +# --------------------------------------------------------------------------- +# Installing for real +# --------------------------------------------------------------------------- + + +def _entry(hass: HomeAssistant, entry_id: str, serial: str) -> MockConfigEntry: + """A config entry keyed on the panel's serial, as the config flow makes one.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.40", "device_name": PANEL_NAME}, + options={}, + title=PANEL_NAME, + entry_id=entry_id, + unique_id=serial, + ) + entry.add_to_hass(hass) + return entry + + +def _coordinator( + hass: HomeAssistant, entry: MockConfigEntry, snapshot: SpanPanelSnapshot +) -> MagicMock: + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.hass = hass + coordinator.panel_offline = False + coordinator.last_update_success = True + coordinator.unresolved_paths = frozenset() + coordinator.config_entry = entry + coordinator.async_request_refresh = AsyncMock() + return coordinator + + +async def _register( + hass: HomeAssistant, + entry: MockConfigEntry, + platform: str, + setup: Callable[..., object], +) -> None: + """Run one platform's `async_setup_entry` through a real `EntityPlatform`. + + Through the platform rather than by inspecting the entities the setup + function returns, because everything under test here happens *in* the + registry: which device an entity is filed under, and which `entity_id` it + keeps. Neither is observable on an entity object. + """ + added: list[object] = [] + await setup(hass, entry, lambda entities, **_: added.extend(entities)) + + entity_platform = MockEntityPlatform( + hass, domain=platform, platform_name=DOMAIN, logger=logging.getLogger(__name__) + ) + entity_platform.config_entry = entry + # The translations decide the object id Home Assistant derives, so a harness + # that skipped them would generate `sensor.span_panel_2` and prove nothing + # about the ids a user sees. + await entity_platform.platform_data.async_load_translations() + await entity_platform.async_add_entities(added) + + +async def _install( + hass: HomeAssistant, + snapshot: SpanPanelSnapshot, + entry_id: str, + *, + seed: Callable[[HomeAssistant, MockConfigEntry, str], None] | None = None, +) -> MockConfigEntry: + """Set up both platforms the way the integration does, optionally over a seeded registry.""" + entry = _entry(hass, entry_id, snapshot.serial_number) + panel_device_id = await ensure_device_registered(hass, entry, snapshot, PANEL_NAME) + if seed is not None: + seed(hass, entry, panel_device_id) + + coordinator = _coordinator(hass, entry, snapshot) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, panel_device_id=panel_device_id + ) + await _register(hass, entry, "sensor", sensor_setup_entry) + await _register(hass, entry, "binary_sensor", binary_sensor_setup_entry) + return entry + + +@asynccontextmanager +async def _a_second_home_assistant() -> AsyncIterator[HomeAssistant]: + """A second, empty Home Assistant, for comparing two installations. + + Both halves of the fresh-versus-migrated comparison have to be *the* install + on their instance. Running them into one registry makes the second collide + with the first and land on `..._pv_vendor_2`, which is an artefact of the + harness and would mask or invent a divergence either way. + """ + async with async_test_home_assistant() as second: + try: + yield second + finally: + await second.async_stop(force=True) + + +def _seed_the_old_shape(hass: HomeAssistant, entry: MockConfigEntry, panel_device_id: str) -> None: + """Write the PV entities onto the panel device, as a released install holds them.""" + registry = er.async_get(hass) + for platform, suffix, entity_id in _LEGACY_PV_ENTITIES: + created = registry.async_get_or_create( + platform, + DOMAIN, + f"span_{entry.unique_id}_{suffix}", + config_entry=entry, + device_id=panel_device_id, + suggested_object_id=entity_id.split(".", 1)[1], + ) + assert created.entity_id == entity_id, ( + f"the seed could not reproduce {entity_id}; it landed on {created.entity_id}" + ) + + +def _pv_device(hass: HomeAssistant, entry: MockConfigEntry) -> dr.DeviceEntry | None: + return dr.async_get(hass).async_get_device_by_identifier( + (DOMAIN, f"{entry.unique_id}_{SUB_DEVICE_PV}"), entry.entry_id + ) + + +def _registry_shape(hass: HomeAssistant, entry: MockConfigEntry) -> dict[str, tuple[str, ...]]: + """``{entity_id: (unique_id, *device identifiers)}`` for every entity of one entry. + + Devices by identifier rather than by registry id, because two installations + mint different registry ids for the same device and the identifier is the + stable name for "which card is this on". + """ + devices = dr.async_get(hass) + shape: dict[str, tuple[str, ...]] = {} + for entity in er.async_entries_for_config_entry(er.async_get(hass), entry.entry_id): + device = devices.async_get(entity.device_id) if entity.device_id else None + identifiers = sorted(name for _domain, name in device.identifiers) if device else [] + shape[entity.entity_id] = (entity.unique_id, *identifiers) + return shape + + +# --------------------------------------------------------------------------- +# The premise +# --------------------------------------------------------------------------- + + +def test_the_capture_publishes_the_identity_the_card_shows() -> None: + """Guard the premise: every card expectation below is read from these topics. + + A test whose expected value comes from an unpublished topic does not fail, it + stops asserting anything — which is how the fixture drifted eight identity + properties behind the producer without a single red test. + """ + assert _published(VENDOR_TOPIC) + assert _published(MODEL_TOPIC) + assert _published(FIRMWARE_TOPIC) + + +def test_the_capture_declares_a_serial_and_publishes_none() -> None: + """The premise of the identifier decision, held to the capture. + + If a producer ever values this, the choice of identifier stops being + hypothetical and `test_a_serial_arriving_on_the_wire_does_not_move_the_device` + stops being a simulation. Either way the identifier must not move, which is + what the two together assert. + """ + assert _declared(SERIAL_TOPIC), "the inverter no longer declares a serial number" + assert schema_one_tree()[PV_DEVICE].get(SERIAL_TOPIC) is None, ( + "the capture now values PV info/serial-number; decision 3 says it stays unvalued " + "until the flat side's PV device id is confirmed" + ) + + +# --------------------------------------------------------------------------- +# The card +# --------------------------------------------------------------------------- + + +async def test_the_inverter_gets_a_card_showing_what_it_publishes( + hass: HomeAssistant, +) -> None: + """Manufacturer, model and firmware read off the wire, not out of a constant.""" + entry = await _install(hass, _pv_snapshot(), "entry-pv-card") + + device = _pv_device(hass, entry) + assert device is not None + assert device.manufacturer == _published(VENDOR_TOPIC) + assert device.model == _published(MODEL_TOPIC) + assert device.sw_version == _published(FIRMWARE_TOPIC) + + +async def test_the_card_follows_a_republished_identity(hass: HomeAssistant) -> None: + """The card tracks the wire, so nothing above is passing on a coincidence.""" + rewritten = _pv_snapshot( + info__vendor_name="Another Vendor", + info__model="ANOTHER-MODEL-1", + info__firmware_version="example-pv/v9.9.9", + ) + + entry = await _install(hass, rewritten, "entry-pv-card-rewritten") + + device = _pv_device(hass, entry) + assert device is not None + assert device.manufacturer == "Another Vendor" + assert device.model == "ANOTHER-MODEL-1" + assert device.sw_version == "example-pv/v9.9.9" + + +async def test_an_inverter_publishing_no_identity_gets_a_card_with_no_blank_rows( + hass: HomeAssistant, +) -> None: + """The fallbacks, and the difference between an absent row and a blank one. + + Vendor and model fall back to strings because a card with no name at all is + worse than a generic one. The firmware version has no string to fall back to + and must be *absent* rather than empty: `DeviceInfo` omits a `None` field and + renders `""` as a present-but-blank row, which reads as an inverter reporting + a blank version rather than one reporting none. + """ + bare = _pv_snapshot( + info__vendor_name=None, + info__model=None, + info__firmware_version=None, + ) + + entry = await _install(hass, bare, "entry-pv-card-bare") + + device = _pv_device(hass, entry) + assert device is not None + assert device.manufacturer == FALLBACK_MANUFACTURER + assert device.model == FALLBACK_MODEL + assert device.sw_version is None + + +async def test_the_card_hangs_off_the_panel_like_every_other_sub_device( + hass: HomeAssistant, +) -> None: + """By registry id, which is the link Home Assistant stops dropping in 2027.8.""" + snapshot = _pv_snapshot() + entry = await _install(hass, snapshot, "entry-pv-link") + + device = _pv_device(hass, entry) + panel = dr.async_get(hass).async_get_device_by_identifier( + (DOMAIN, snapshot.serial_number), entry.entry_id + ) + assert device is not None + assert panel is not None + assert device.via_device_id == panel.id + + +async def test_a_panel_with_no_inverter_gets_no_card(hass: HomeAssistant) -> None: + """Absence is a reading. No PV node, no device, and no entities to re-home.""" + tree = schema_one_tree() + del tree[PV_DEVICE] + for topics in tree.values(): + topics.pop("power-flows/pv", None) + for circuit, topics in tree.items(): + if topics.get("connection/feeds-device-id") == PV_DEVICE: + topics.pop("connection/feeds-device-id", None) + topics.pop("connection/feeds-device-status", None) + topics.pop("connection/feeds-device-type", None) + + entry = await _install(hass, schema_one_snapshot(tree), "entry-pv-absent") + + assert _pv_device(hass, entry) is None + shape = _registry_shape(hass, entry) + assert not [entity_id for entity_id in shape if "_pv_" in entity_id] + + +# --------------------------------------------------------------------------- +# The identifier +# --------------------------------------------------------------------------- + + +async def test_a_serial_arriving_on_the_wire_does_not_move_the_device( + hass: HomeAssistant, +) -> None: + """The whole reason the identifier does not mention the inverter's serial. + + `_der_identifier` on the producer side prefers a serial over an instance id, + so the day firmware starts publishing `info/serial-number` the inverter's + *Homie* device id changes. A Home Assistant identifier derived from it would + change with it, and a changed identifier is a new device: the card empties, + the entities orphan, and an upgrade rehearsal becomes a device-replacement + rehearsal. Keyed on the panel's serial and the kind instead, so this test + publishes a serial and watches nothing move. + """ + before = await _install(hass, _pv_snapshot(), "entry-pv-noserial") + identifier_before = _pv_device(hass, before) + shape_before = _registry_shape(hass, before) + + async with _a_second_home_assistant() as second: + after = await _install( + second, + _pv_snapshot(info__serial_number="INVERTER-SERIAL-0001"), + "entry-pv-serial", + ) + identifier_after = _pv_device(second, after) + shape_after = _registry_shape(second, after) + + assert identifier_before is not None + assert identifier_after is not None + assert identifier_before.identifiers == identifier_after.identifiers + assert shape_before == shape_after + + +async def test_the_topology_reader_calls_the_new_card_a_pv(hass: HomeAssistant) -> None: + """The writing end and the reading end of the identifier grammar agree. + + The MID shipped classifying as `unknown` because a kind was added to the + builders and not to the reader, and a card rendered a device with a name and + no type. Asserted against the device as registered rather than against the + builder's dict, because the reader is handed a `DeviceEntry`. + """ + entry = await _install(hass, _pv_snapshot(), "entry-pv-classify") + + device = _pv_device(hass, entry) + assert device is not None + assert _classify_sub_device(device) == SUB_DEVICE_PV + assert classify_sub_device_identifier(f"{entry.unique_id}_{SUB_DEVICE_PV}") == SUB_DEVICE_PV + + +# --------------------------------------------------------------------------- +# Where the entities land, and what they keep +# --------------------------------------------------------------------------- + + +def _pv_entities(hass: HomeAssistant, entry: MockConfigEntry) -> dict[str, er.RegistryEntry]: + """The five PV entities, by `entity_id`, or fail naming the ones missing.""" + registry = er.async_get(hass) + found: dict[str, er.RegistryEntry] = {} + missing: list[str] = [] + for platform, suffix, _entity_id in _LEGACY_PV_ENTITIES: + entity_id = registry.async_get_entity_id( + platform, DOMAIN, f"span_{entry.unique_id}_{suffix}" + ) + if entity_id is None: + missing.append(suffix) + continue + entity = registry.async_get(entity_id) + assert entity is not None + found[entity_id] = entity + assert not missing, f"the platform created no entity for {missing}" + return found + + +async def test_a_fresh_install_still_builds_the_unique_ids_users_already_have( + hass: HomeAssistant, +) -> None: + """The `unique_id` literals above are the current code's output, so the migration bites. + + A `unique_id` is an identity: changing one does not rename an entity, it + orphans the old one and mints a second. The migration tests seed a registry + from `_LEGACY_PV_ENTITIES` and then expect setup to find those same ids, so + if the builders ever stopped producing them the seed would simply never be + matched and every migration assertion would pass over a registry it had + quietly rebuilt. This holds the recorded ids to the builders. + + Only the `unique_id`s. The `entity_id`s a fresh installation gets are the + ones on `_FRESH_PV_ENTITIES`, which is the intended fork. + """ + entry = await _install(hass, _pv_snapshot(), "entry-pv-uids") + + found = _pv_entities(hass, entry) + assert {entity.unique_id for entity in found.values()} == { + f"span_{entry.unique_id}_{suffix}" for _platform, suffix, _entity_id in _LEGACY_PV_ENTITIES + } + assert set(found) == {entity_id for _platform, _suffix, entity_id in _FRESH_PV_ENTITIES} + + +async def test_every_pv_entity_lands_on_the_inverters_card(hass: HomeAssistant) -> None: + """All five, and none of them left behind on the panel.""" + entry = await _install(hass, _pv_snapshot(), "entry-pv-home") + + device = _pv_device(hass, entry) + assert device is not None + for entity_id, entity in _pv_entities(hass, entry).items(): + assert entity.device_id == device.id, f"{entity_id} is not on the inverter's card" + + +def test_the_fresh_ids_are_what_the_inverters_card_derives() -> None: + """`_FRESH_PV_ENTITIES` is Home Assistant's derivation, not a guess at it. + + Home Assistant slugifies `{device name} {entity name}` for an + `has_entity_name` entity. The device is `pv_device_info`'s + `f"{panel_name} Solar"` and the name comes from `strings.json`, so both + halves are read from the sources that would change rather than restated. + Renaming one of these in `strings.json` changes the entity_id a *new* + installation gets and nothing else, which is a user-visible change worth + failing a test over. + + Also asserts each fresh id differs from the legacy one, so the fork this file + documents is a fact the test suite carries rather than a claim in a comment. + """ + strings = json.loads( + ( + pathlib.Path(__file__).parent.parent + / "custom_components" + / "span_panel" + / "strings.json" + ).read_text(encoding="utf-8") + ) + legacy = {suffix: entity_id for _platform, suffix, entity_id in _LEGACY_PV_ENTITIES} + for platform, suffix, entity_id in _FRESH_PV_ENTITIES: + name = strings["entity"][platform][suffix]["name"] + assert entity_id == f"{platform}.{slugify(f'{PANEL_NAME} Solar')}_{slugify(name)}", ( + f"{platform}.{suffix} is named {name!r}; a new installation's entity_id " + "is derived from that name and the inverter's device name" + ) + assert entity_id != legacy[suffix] + + +def test_the_descriptions_carry_the_translation_keys_the_names_are_read_from() -> None: + """Without a `translation_key` an entity has no name, and no derived object id. + + Home Assistant falls back to the device name alone for an `has_entity_name` + entity with no name, so five PV entities would all want + `sensor.span_panel_solar` and the registry would resolve the collision by + appending numbers — a silent, permanent scrambling of five entity ids on new + installations only. The keys are also the `unique_id` suffixes, which is why + they are compared against the recorded shape. + """ + keys = {PV_POWER_SENSOR.translation_key} | { + description.translation_key for description in PV_METADATA_SENSORS + } + keys.add(PV_PANEL_LINK_SENSOR.translation_key) + assert keys == {suffix for _platform, suffix, _entity_id in _LEGACY_PV_ENTITIES} + + +# --------------------------------------------------------------------------- +# The migration +# --------------------------------------------------------------------------- + + +async def test_the_migration_moves_the_device_and_nothing_else( + hass: HomeAssistant, +) -> None: + """Seed the released shape, set up, and read the registry back. + + Home Assistant re-homes an entity when it re-registers with different + `device_info` — `async_get_or_create` updates `device_id` on an existing + entry — so there is no bespoke migration to write and deliberately none + written. What that mechanism does *not* touch is the `entity_id` or the + `unique_id`, and those are what a user's dashboards and automations name, so + they are asserted one by one rather than in aggregate. + """ + entry = await _install(hass, _pv_snapshot(), "entry-pv-migrate", seed=_seed_the_old_shape) + + device = _pv_device(hass, entry) + assert device is not None + registry = er.async_get(hass) + for platform, suffix, entity_id in _LEGACY_PV_ENTITIES: + unique_id = f"span_{entry.unique_id}_{suffix}" + entity = registry.async_get(entity_id) + assert entity is not None, f"{entity_id} no longer exists after setup" + assert entity.unique_id == unique_id, f"{entity_id} changed unique_id" + assert entity.platform == DOMAIN + assert entity.domain == platform + assert entity.device_id == device.id, f"{entity_id} did not move to the inverter" + + +async def test_the_migration_leaves_no_duplicate_and_no_orphan( + hass: HomeAssistant, +) -> None: + """The two failure modes a re-home has, made observable. + + A duplicate: the entity re-registers under a new `unique_id`, so the old + registry entry survives beside a new one and a user sees each reading twice. + An orphan: the panel device keeps an entity nothing writes to any more. Both + are counted rather than spot-checked, because either would otherwise hide + among forty circuit entities. + """ + entry = await _install(hass, _pv_snapshot(), "entry-pv-migrate-clean", seed=_seed_the_old_shape) + + device = _pv_device(hass, entry) + panel = dr.async_get(hass).async_get_device_by_identifier( + (DOMAIN, entry.unique_id or ""), entry.entry_id + ) + assert device is not None + assert panel is not None + + entities = er.async_entries_for_config_entry(er.async_get(hass), entry.entry_id) + unique_ids = [entity.unique_id for entity in entities] + assert len(unique_ids) == len(set(unique_ids)), "an entity re-registered under a second id" + + pv_unique_ids = {f"span_{entry.unique_id}_{suffix}" for _p, suffix, _e in _LEGACY_PV_ENTITIES} + left_behind = [ + entity.entity_id + for entity in entities + if entity.unique_id in pv_unique_ids and entity.device_id != device.id + ] + assert not left_behind, f"still on the panel card: {left_behind}" + + pv_devices = [ + candidate + for candidate in dr.async_entries_for_config_entry(dr.async_get(hass), entry.entry_id) + if any( + classify_sub_device_identifier(name) == SUB_DEVICE_PV + for _domain, name in candidate.identifiers + ) + ] + assert len(pv_devices) == 1, f"{len(pv_devices)} PV devices registered, expected 1" + + +async def test_a_fresh_install_and_a_migrated_one_differ_only_in_entity_id( + hass: HomeAssistant, +) -> None: + """The two installations fork on `entity_id`, and on nothing else. On purpose. + + **This asymmetry is a decision, not a bug.** An earlier revision pinned the + panel-scoped `entity_id` on both installations so they matched exactly. That + was reversed. The governing constraint is that an *existing* installation's + `entity_id` and `unique_id` must never change — dashboards, automations and + history all name them — and that constraint is satisfied by Home Assistant's + own behaviour, without a pin: `async_get_or_create` honours a suggested + object id only at first registration. The pin therefore did nothing for the + installed base; all it did was give every *future* installation an entity id + derived from the panel rather than from the device the entity actually sits + on, permanently, to match a history that installation does not have. + + So: same `unique_id`s, same device, different `entity_id`s, decided by install + date. If you are reading this because the divergence looks wrong, it is + intended — the fix is not to pin the legacy shape back on. + + Compared over `{entity_id: (unique_id, device identifiers)}` — every fact a + user or a dashboard can name — so an entity filed under the wrong card fails + with its entity_id in the message. + """ + snapshot = _pv_snapshot() + migrated = await _install(hass, snapshot, "entry-pv-shape-old", seed=_seed_the_old_shape) + migrated_shape = _registry_shape(hass, migrated) + + async with _a_second_home_assistant() as second: + fresh = await _install(second, snapshot, "entry-pv-shape-new") + fresh_shape = _registry_shape(second, fresh) + + legacy_ids = {entity_id for _platform, _suffix, entity_id in _LEGACY_PV_ENTITIES} + fresh_ids = {entity_id for _platform, _suffix, entity_id in _FRESH_PV_ENTITIES} + + # The literals, both ways round: the upgraded installation kept every id it + # had, and the new one took the id its device derives. + assert legacy_ids <= set(migrated_shape) + assert not (fresh_ids & set(migrated_shape)), "an upgraded installation grew a second shape" + assert fresh_ids <= set(fresh_shape) + assert not (legacy_ids & set(fresh_shape)), "a new installation was given the legacy shape" + + # Everything that is not one of the five is identical, so the fork is exactly + # as wide as it is meant to be and no other entity moved or was renamed. + assert { + entity_id: value + for entity_id, value in migrated_shape.items() + if entity_id not in legacy_ids + } == { + entity_id: value for entity_id, value in fresh_shape.items() if entity_id not in fresh_ids + } + + # And across the fork the five are the same five: same identity, same card. + assert {migrated_shape[entity_id] for entity_id in legacy_ids} == { + fresh_shape[entity_id] for entity_id in fresh_ids + } + + +async def test_a_user_renamed_entity_id_survives_the_move(hass: HomeAssistant) -> None: + """A `entity_id` the user chose is theirs, and the re-home must not touch it. + + The same registry behaviour the whole migration rests on, asserted at its + sharpest point: an id that matches neither the legacy shape nor the shape the + inverter's card derives still survives, because Home Assistant re-derives an + object id for a `unique_id` it has never seen and for no other. + """ + + def seed(hass_: HomeAssistant, entry_: MockConfigEntry, panel_device_id: str) -> None: + _seed_the_old_shape(hass_, entry_, panel_device_id) + er.async_get(hass_).async_update_entity( + "sensor.span_panel_pv_vendor", new_entity_id="sensor.my_solar_brand" + ) + + entry = await _install(hass, _pv_snapshot(), "entry-pv-renamed", seed=seed) + + registry = er.async_get(hass) + assert registry.async_get("sensor.span_panel_pv_vendor") is None + renamed = registry.async_get("sensor.my_solar_brand") + assert renamed is not None + device = _pv_device(hass, entry) + assert device is not None + assert renamed.device_id == device.id + + +# --------------------------------------------------------------------------- +# The inventories +# --------------------------------------------------------------------------- + + +def test_the_firmware_version_is_no_longer_an_unread_declaration() -> None: + """The line this task exists to delete, and the line that stays. + + `pv/info/firmware-version` was baselined saying the inverter had no card to + carry a version; it has one now, so the line goes. `pv/info/serial-number` + stays, and its reason has to name the identifier decision rather than only + the producer-side one, because the Home Assistant identifier is now a second + thing that would have moved. + """ + baseline: dict[str, str] = json.loads(BASELINE.read_text(encoding="utf-8")) + + assert "pv/info/firmware-version" not in baseline + reason = baseline.get("pv/info/serial-number") + assert reason is not None, "the serial must stay baselined; see decision 3" + assert "identifier" in reason + + +def test_the_cards_firmware_read_is_enumerated_as_a_residual() -> None: + """`pv_device_info` is not an entity, so its read is an exempt residual. + + `NEITHER` beside the `mid.*` and `panel.*` card reads: flat's `pv` device + class declares no firmware version, and a schema_1 metadata row carries a + unit and a datatype for a reading, which a version string is not. + """ + assert RESIDUAL_EXEMPT_PATHS["pv.software_version"] is Producibility.NEITHER + assert "pv.software_version" not in declared_field_paths() + + +def test_nothing_reads_a_pv_serial_anywhere() -> None: + """The negative half of the identifier decision, asserted rather than assumed. + + Adding the field to the snapshot would be harmless; reading it here would + not, because every read is a place a future change could route into the + identifier. There is no such field and no such path, and this fails the day + one arrives without the decision being revisited. + """ + assert "pv.serial_number" not in RESIDUAL_EXEMPT_PATHS + assert "pv.serial_number" not in declared_field_paths() + snapshot = _pv_snapshot(info__serial_number="INVERTER-SERIAL-0001") + assert not hasattr(snapshot.pv, "serial_number") + + +def test_the_kind_vocabulary_is_closed() -> None: + """Every kind the classifier answers with, in one place. + + Not a restatement of the classifier: `test_device_links` parametrises over + the *builders*, so a fifth sub-device whose identifier nothing classifies + fails there. This is the other direction — the reader answering a kind the + writers never mint — and it is why the list is spelled out rather than + derived from the constants. + """ + assert [ + classify_sub_device_identifier("panel-serial_bess"), + classify_sub_device_identifier("panel-serial_mid"), + classify_sub_device_identifier("panel-serial_evse_node"), + classify_sub_device_identifier("panel-serial_pv"), + classify_sub_device_identifier("panel-serial"), + ] == ["bess", "mid", "evse", SUB_DEVICE_PV, None] diff --git a/tests/test_sensor_entities.py b/tests/test_sensor_entities.py index acc3e599..ff2f5a9f 100644 --- a/tests/test_sensor_entities.py +++ b/tests/test_sensor_entities.py @@ -154,7 +154,12 @@ def test_panel_sensor_default_friendly_names_cover_fallback_branches() -> None: snapshot, {"identifiers": {("span_panel", "bess")}}, ) - pv_sensor = SpanPVMetadataSensor(coordinator, PV_METADATA_SENSORS[0], snapshot) + pv_sensor = SpanPVMetadataSensor( + coordinator, + PV_METADATA_SENSORS[0], + snapshot, + {"identifiers": {("span_panel", "pv")}}, + ) assert panel_data_sensor._generate_friendly_name(snapshot, panel_data_desc) == "Sensor" assert status_sensor._generate_friendly_name(snapshot, status_desc) == "Status" @@ -178,7 +183,12 @@ def test_panel_metadata_sensors_return_expected_data_sources() -> None: snapshot, {"identifiers": {("span_panel", "bess")}}, ) - pv_sensor = SpanPVMetadataSensor(coordinator, PV_METADATA_SENSORS[0], snapshot) + pv_sensor = SpanPVMetadataSensor( + coordinator, + PV_METADATA_SENSORS[0], + snapshot, + {"identifiers": {("span_panel", "pv")}}, + ) assert bess_sensor.get_data_source(snapshot) is battery assert pv_sensor.get_data_source(snapshot) is snapshot diff --git a/tests/test_websocket.py b/tests/test_websocket.py index 188de7cf..d11647f4 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -184,6 +184,12 @@ def test_evse(self): device.identifiers = {(DOMAIN, "sp3-242424-001_evse_0")} assert _classify_sub_device(device) == "evse" + def test_pv(self): + """Classify the solar inverter sub-device from its identifier.""" + device = MagicMock() + device.identifiers = {(DOMAIN, "sp3-242424-001_pv")} + assert _classify_sub_device(device) == "pv" + def test_unknown(self): """Treat the panel device itself as an unknown sub-device type.""" device = MagicMock() @@ -382,7 +388,7 @@ async def test_non_span_device(self, hass: HomeAssistant): @pytest.mark.asyncio async def test_sub_device_id_rejected(self, hass: HomeAssistant): - """Error when device_id is a BESS/EVSE sub-device, not the panel.""" + """Error when device_id is a sub-device, not the panel.""" entry = MockConfigEntry( domain=DOMAIN, data={}, @@ -419,7 +425,7 @@ async def test_sub_device_id_rejected(self, hass: HomeAssistant): connection.send_error.assert_called_once_with( 1, "not_panel_device", - "Use the SPAN panel device registry ID, not a BESS or EVSE sub-device.", + "Use the SPAN panel device registry ID, not a sub-device.", ) @pytest.mark.asyncio From 2508b44488ef413468a5eb2dfd4eca20d8ab41ef Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:12:18 -0700 Subject: [PATCH 060/345] fix(strings): say what the new-entity count actually counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The notice reads "1 new entity/entities have appeared for your SPAN Panel", which scans as a total. It is not: the notice only ever reports entities created with `entity_registry_enabled_default=False`, because those are the ones that reach the user through nothing at all. Everything added enabled -- the shed-forecast sensors, the PCS entities, the battery meter -- announces itself by appearing, and is deliberately outside the count. A user who reads "1" after an upgrade that added a dozen entities concludes the upgrade did almost nothing. The count now follows a label that names its scope, and a second sentence says what it excludes. `entity/entities` goes at the same time, along with `entidad(es)`, `entité(s)` and `entidade(s)` in the three Latin locales. The parenthetical is there because the string has to read for one or many, and Home Assistant's issue translations have no plural forms. Putting the number after a label -- "Entities affected: 3" -- removes the agreement problem instead of papering over it, and removes it in every language rather than only in English. Japanese never had the problem, since it counts with 個 and does not inflect; it gains only the scope sentence. --- custom_components/span_panel/strings.json | 6 +++--- custom_components/span_panel/translations/en.json | 6 +++--- custom_components/span_panel/translations/es.json | 6 +++--- custom_components/span_panel/translations/fr.json | 6 +++--- custom_components/span_panel/translations/ja.json | 2 +- custom_components/span_panel/translations/pt.json | 6 +++--- 6 files changed, 16 insertions(+), 16 deletions(-) diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index 9ecc5d28..47968d20 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -767,7 +767,7 @@ "issues": { "new_entities_disabled": { "title": "New SPAN Panel entities are available", - "description": "{count} new entity/entities have appeared for your SPAN Panel (for example: {examples}). They were created **disabled**, so nothing appeared in your dashboards or history uninvited.\n\nEntities can appear for several reasons: an integration update that exposes more of what your panel reports, a circuit added to the panel, or hardware such as a battery or solar coming online. Nothing is broken and no action is required — this notice exists only so the addition is not silent.\n\nTo use one, open the SPAN Panel device page, show its disabled entities, and enable the ones you want. Dismiss this once you have had a look." + "description": "New entities added and left **disabled**: {count} (for example: {examples}). Nothing appeared in your dashboards or history uninvited.\n\nThis count covers only entities that were switched off. Anything added enabled is already visible in your entity list and history.\n\nEntities can appear for several reasons: an integration update that exposes more of what your panel reports, a circuit added to the panel, or hardware such as a battery or solar coming online. Nothing is broken and no action is required — this notice exists only so the addition is not silent.\n\nTo use one, open the SPAN Panel device page, show its disabled entities, and enable the ones you want. Dismiss this once you have had a look." }, "panel_upgraded_to_ebus_v1": { "title": "Your SPAN Panel now reports islanding from its Microgrid Interconnect Device", @@ -775,11 +775,11 @@ }, "schema_field_unresolved": { "title": "SPAN Panel data field unavailable", - "description": "Your panel's firmware no longer provides `{field_path}`. {count} entity/entities are affected (for example: {examples}). They may become unavailable, or report incomplete data, until an integration update adds support for the new firmware." + "description": "Your panel's firmware no longer provides `{field_path}`. Entities affected: {count} (for example: {examples}). They may become unavailable, or report incomplete data, until an integration update adds support for the new firmware." }, "schema_unit_mismatch": { "title": "SPAN Panel unit mismatch", - "description": "Your panel reports `{field_path}` in {schema_unit}, but this integration expects {ha_unit}. {count} entity/entities are affected (for example: {examples}). Their readings and long-term statistics may be wrong. They have not been changed automatically." + "description": "Your panel reports `{field_path}` in {schema_unit}, but this integration expects {ha_unit}. Entities affected: {count} (for example: {examples}). Their readings and long-term statistics may be wrong. They have not been changed automatically." } } } diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index 9ecc5d28..47968d20 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -767,7 +767,7 @@ "issues": { "new_entities_disabled": { "title": "New SPAN Panel entities are available", - "description": "{count} new entity/entities have appeared for your SPAN Panel (for example: {examples}). They were created **disabled**, so nothing appeared in your dashboards or history uninvited.\n\nEntities can appear for several reasons: an integration update that exposes more of what your panel reports, a circuit added to the panel, or hardware such as a battery or solar coming online. Nothing is broken and no action is required — this notice exists only so the addition is not silent.\n\nTo use one, open the SPAN Panel device page, show its disabled entities, and enable the ones you want. Dismiss this once you have had a look." + "description": "New entities added and left **disabled**: {count} (for example: {examples}). Nothing appeared in your dashboards or history uninvited.\n\nThis count covers only entities that were switched off. Anything added enabled is already visible in your entity list and history.\n\nEntities can appear for several reasons: an integration update that exposes more of what your panel reports, a circuit added to the panel, or hardware such as a battery or solar coming online. Nothing is broken and no action is required — this notice exists only so the addition is not silent.\n\nTo use one, open the SPAN Panel device page, show its disabled entities, and enable the ones you want. Dismiss this once you have had a look." }, "panel_upgraded_to_ebus_v1": { "title": "Your SPAN Panel now reports islanding from its Microgrid Interconnect Device", @@ -775,11 +775,11 @@ }, "schema_field_unresolved": { "title": "SPAN Panel data field unavailable", - "description": "Your panel's firmware no longer provides `{field_path}`. {count} entity/entities are affected (for example: {examples}). They may become unavailable, or report incomplete data, until an integration update adds support for the new firmware." + "description": "Your panel's firmware no longer provides `{field_path}`. Entities affected: {count} (for example: {examples}). They may become unavailable, or report incomplete data, until an integration update adds support for the new firmware." }, "schema_unit_mismatch": { "title": "SPAN Panel unit mismatch", - "description": "Your panel reports `{field_path}` in {schema_unit}, but this integration expects {ha_unit}. {count} entity/entities are affected (for example: {examples}). Their readings and long-term statistics may be wrong. They have not been changed automatically." + "description": "Your panel reports `{field_path}` in {schema_unit}, but this integration expects {ha_unit}. Entities affected: {count} (for example: {examples}). Their readings and long-term statistics may be wrong. They have not been changed automatically." } } } diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index 34bb52a7..b572d0a1 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -767,7 +767,7 @@ "issues": { "new_entities_disabled": { "title": "Hay nuevas entidades del Panel SPAN disponibles", - "description": "Han aparecido {count} entidad(es) nueva(s) en su Panel SPAN (por ejemplo: {examples}). Se han creado **deshabilitadas**, por lo que no ha aparecido nada en sus paneles de control ni en su historial sin haberlo pedido.\n\nPueden aparecer entidades nuevas por varios motivos: una actualización de la integración que expone más de lo que informa su panel, un circuito añadido al panel, o hardware como una batería o energía solar que se pone en marcha. No hay nada averiado ni es necesaria ninguna acción: este aviso existe solo para que la novedad no pase desapercibida.\n\nPara usar alguna, abra la página del dispositivo Panel SPAN, muestre sus entidades deshabilitadas y habilite las que desee. Descarte este aviso cuando lo haya revisado." + "description": "Entidades nuevas creadas **deshabilitadas**: {count} (por ejemplo: {examples}). No ha aparecido nada en sus paneles de control ni en su historial sin haberlo pedido.\n\nEste recuento incluye solo las entidades que se crearon desactivadas. Todo lo que se añade habilitado ya es visible en su lista de entidades y en su historial.\n\nPueden aparecer entidades nuevas por varios motivos: una actualización de la integración que expone más de lo que informa su panel, un circuito añadido al panel, o hardware como una batería o energía solar que se pone en marcha. No hay nada averiado ni es necesaria ninguna acción: este aviso existe solo para que la novedad no pase desapercibida.\n\nPara usar alguna, abra la página del dispositivo Panel SPAN, muestre sus entidades deshabilitadas y habilite las que desee. Descarte este aviso cuando lo haya revisado." }, "panel_upgraded_to_ebus_v1": { "title": "Su Panel SPAN ahora informa del aislamiento desde su Dispositivo de Interconexión de Microrred", @@ -775,11 +775,11 @@ }, "schema_field_unresolved": { "title": "Campo de datos del Panel SPAN no disponible", - "description": "El firmware de su panel ya no proporciona `{field_path}`. Hay {count} entidad(es) afectada(s) (por ejemplo: {examples}). Pueden quedar no disponibles, o informar datos incompletos, hasta que una actualización de la integración añada compatibilidad con el nuevo firmware." + "description": "El firmware de su panel ya no proporciona `{field_path}`. Entidades afectadas: {count} (por ejemplo: {examples}). Pueden quedar no disponibles, o informar datos incompletos, hasta que una actualización de la integración añada compatibilidad con el nuevo firmware." }, "schema_unit_mismatch": { "title": "Discrepancia de unidades del Panel SPAN", - "description": "Su panel informa de `{field_path}` en {schema_unit}, pero esta integración espera {ha_unit}. Hay {count} entidad(es) afectada(s) (por ejemplo: {examples}). Sus lecturas y estadísticas a largo plazo pueden ser incorrectas. No se han modificado automáticamente." + "description": "Su panel informa de `{field_path}` en {schema_unit}, pero esta integración espera {ha_unit}. Entidades afectadas: {count} (por ejemplo: {examples}). Sus lecturas y estadísticas a largo plazo pueden ser incorrectas. No se han modificado automáticamente." } } } diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index dea10a79..3fce2ae9 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -767,7 +767,7 @@ "issues": { "new_entities_disabled": { "title": "De nouvelles entités du Panneau SPAN sont disponibles", - "description": "{count} nouvelle(s) entité(s) sont apparues pour votre Panneau SPAN (par exemple : {examples}). Elles ont été créées **désactivées**, afin que rien n'apparaisse dans vos tableaux de bord ni dans votre historique sans votre accord.\n\nDe nouvelles entités peuvent apparaître pour plusieurs raisons : une mise à jour de l'intégration qui expose davantage de ce que votre panneau signale, un circuit ajouté au panneau, ou du matériel tel qu'une batterie ou du solaire qui entre en service. Rien n'est cassé et aucune action n'est requise : cet avis existe uniquement pour que cet ajout ne soit pas silencieux.\n\nPour en utiliser une, ouvrez la page de l'appareil Panneau SPAN, affichez ses entités désactivées et activez celles que vous souhaitez. Ignorez cet avis une fois que vous y avez jeté un œil." + "description": "Nouvelles entités créées **désactivées** : {count} (par exemple : {examples}). Rien n'est apparu dans vos tableaux de bord ni dans votre historique sans votre accord.\n\nCe décompte ne couvre que les entités créées désactivées. Tout ce qui est ajouté activé est déjà visible dans votre liste d'entités et votre historique.\n\nDe nouvelles entités peuvent apparaître pour plusieurs raisons : une mise à jour de l'intégration qui expose davantage de ce que votre panneau signale, un circuit ajouté au panneau, ou du matériel tel qu'une batterie ou du solaire qui entre en service. Rien n'est cassé et aucune action n'est requise : cet avis existe uniquement pour que cet ajout ne soit pas silencieux.\n\nPour en utiliser une, ouvrez la page de l'appareil Panneau SPAN, affichez ses entités désactivées et activez celles que vous souhaitez. Ignorez cet avis une fois que vous y avez jeté un œil." }, "panel_upgraded_to_ebus_v1": { "title": "Votre Panneau SPAN signale désormais l'îlotage depuis son Dispositif d'Interconnexion de Micro-réseau", @@ -775,11 +775,11 @@ }, "schema_field_unresolved": { "title": "Champ de données du Panneau SPAN indisponible", - "description": "Le micrologiciel de votre panneau ne fournit plus `{field_path}`. {count} entité(s) sont concernées (par exemple : {examples}). Elles peuvent devenir indisponibles, ou renvoyer des données incomplètes, jusqu'à ce qu'une mise à jour de l'intégration prenne en charge le nouveau micrologiciel." + "description": "Le micrologiciel de votre panneau ne fournit plus `{field_path}`. Entités concernées : {count} (par exemple : {examples}). Elles peuvent devenir indisponibles, ou renvoyer des données incomplètes, jusqu'à ce qu'une mise à jour de l'intégration prenne en charge le nouveau micrologiciel." }, "schema_unit_mismatch": { "title": "Incohérence d'unité du Panneau SPAN", - "description": "Votre panneau signale `{field_path}` en {schema_unit}, alors que cette intégration attend {ha_unit}. {count} entité(s) sont concernées (par exemple : {examples}). Leurs relevés et leurs statistiques à long terme peuvent être erronés. Elles n'ont pas été modifiées automatiquement." + "description": "Votre panneau signale `{field_path}` en {schema_unit}, alors que cette intégration attend {ha_unit}. Entités concernées : {count} (par exemple : {examples}). Leurs relevés et leurs statistiques à long terme peuvent être erronés. Elles n'ont pas été modifiées automatiquement." } } } diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index 5309c41d..54ef81d8 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -767,7 +767,7 @@ "issues": { "new_entities_disabled": { "title": "SPAN パネルの新しいエンティティが利用可能です", - "description": "SPAN パネルに {count} 個の新しいエンティティが現れました (例: {examples})。これらは**無効な状態**で作成されているため、ダッシュボードや履歴に無断で何かが現れることはありません。\n\n新しいエンティティが現れる理由はいくつかあります。パネルが報告する内容をより多く公開する統合の更新、パネルに追加された回路、あるいはバッテリーや太陽光などのハードウェアの稼働開始などです。故障ではなく、対応も不要です。この通知は、追加が知らされないままにならないようにするためだけのものです。\n\n使用するには、SPAN パネルのデバイスページを開き、無効なエンティティを表示して、必要なものを有効にしてください。確認が済んだらこの通知を閉じてください。" + "description": "SPAN パネルに {count} 個の新しいエンティティが現れました (例: {examples})。これらは**無効な状態**で作成されているため、ダッシュボードや履歴に無断で何かが現れることはありません。\n\nこの件数には、無効な状態で作成されたエンティティのみが含まれます。有効な状態で追加されたものは、すでにエンティティ一覧や履歴に表示されています。\n\n新しいエンティティが現れる理由はいくつかあります。パネルが報告する内容をより多く公開する統合の更新、パネルに追加された回路、あるいはバッテリーや太陽光などのハードウェアの稼働開始などです。故障ではなく、対応も不要です。この通知は、追加が知らされないままにならないようにするためだけのものです。\n\n使用するには、SPAN パネルのデバイスページを開き、無効なエンティティを表示して、必要なものを有効にしてください。確認が済んだらこの通知を閉じてください。" }, "panel_upgraded_to_ebus_v1": { "title": "SPAN パネルは自立運転状態をマイクログリッド相互接続装置から取得するようになりました", diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index e67c4436..fc8208f0 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -767,7 +767,7 @@ "issues": { "new_entities_disabled": { "title": "Estão disponíveis novas entidades do Painel SPAN", - "description": "Apareceram {count} nova(s) entidade(s) no seu Painel SPAN (por exemplo: {examples}). Foram criadas **desativadas**, pelo que nada apareceu nos seus painéis nem no seu histórico sem ter sido pedido.\n\nPodem aparecer novas entidades por vários motivos: uma atualização da integração que expõe mais do que o seu painel comunica, um circuito acrescentado ao painel, ou hardware como uma bateria ou energia solar que entra em funcionamento. Não há nada avariado e não é necessária qualquer ação: este aviso existe apenas para que a novidade não passe despercebida.\n\nPara usar alguma, abra a página do dispositivo Painel SPAN, mostre as suas entidades desativadas e ative as que quiser. Dispense este aviso depois de o ter visto." + "description": "Entidades novas criadas **desativadas**: {count} (por exemplo: {examples}). Nada apareceu nos seus painéis nem no seu histórico sem ter sido pedido.\n\nEsta contagem inclui apenas as entidades criadas desativadas. Tudo o que é acrescentado ativado já está visível na sua lista de entidades e no seu histórico.\n\nPodem aparecer novas entidades por vários motivos: uma atualização da integração que expõe mais do que o seu painel comunica, um circuito acrescentado ao painel, ou hardware como uma bateria ou energia solar que entra em funcionamento. Não há nada avariado e não é necessária qualquer ação: este aviso existe apenas para que a novidade não passe despercebida.\n\nPara usar alguma, abra a página do dispositivo Painel SPAN, mostre as suas entidades desativadas e ative as que quiser. Dispense este aviso depois de o ter visto." }, "panel_upgraded_to_ebus_v1": { "title": "O seu Painel SPAN comunica agora o ilhamento a partir do Dispositivo de Interligação de Microrrede", @@ -775,11 +775,11 @@ }, "schema_field_unresolved": { "title": "Campo de dados do Painel SPAN indisponível", - "description": "O firmware do seu painel já não fornece `{field_path}`. Há {count} entidade(s) afetada(s) (por exemplo: {examples}). Podem ficar indisponíveis, ou comunicar dados incompletos, até que uma atualização da integração adicione suporte ao novo firmware." + "description": "O firmware do seu painel já não fornece `{field_path}`. Entidades afetadas: {count} (por exemplo: {examples}). Podem ficar indisponíveis, ou comunicar dados incompletos, até que uma atualização da integração adicione suporte ao novo firmware." }, "schema_unit_mismatch": { "title": "Discrepância de unidades do Painel SPAN", - "description": "O seu painel comunica `{field_path}` em {schema_unit}, mas esta integração espera {ha_unit}. Há {count} entidade(s) afetada(s) (por exemplo: {examples}). As suas leituras e estatísticas de longo prazo podem estar erradas. Não foram alteradas automaticamente." + "description": "O seu painel comunica `{field_path}` em {schema_unit}, mas esta integração espera {ha_unit}. Entidades afetadas: {count} (por exemplo: {examples}). As suas leituras e estatísticas de longo prazo podem estar erradas. Não foram alteradas automaticamente." } } } From 67f00ebd627b82ee94e2268215188db1ef24eda3 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:38:21 -0700 Subject: [PATCH 061/345] feat(binary_sensor): put the Wi-Fi network name on the Wi-Fi link `wifi_ssid` has been an attribute of the Software Version sensor since the integration existed, for the historical reason that `panel_size` was already squatting in that attribute block. A network name on a firmware-version sensor is incoherent. The coherent host is the Wi-Fi Link binary sensor: both values come off the same node on the wire -- `status/wifi` and `status/wifi-ssid` on v1.0, `core/wifi` and `core/wifi-ssid` on flat -- and the entity that reports whether Wi-Fi is up is the one that should report which network it is up on. It stays on the Software Version sensor as well. That copy is what a user's `state_attr('sensor.span_panel_software_version', 'wifi_ssid')` template reads, and a template reading a missing attribute returns None rather than erroring, so moving it would break those silently. One duplicated attribute string is the price; the old location comes out at a future major version. The Wi-Fi Link gets an entity class of its own rather than a branch on `SpanPanelBinarySensor`. `_residual_field_paths` is a `ClassVar` and that base class serves every panel binary sensor -- door, both links, panel status, PCS activity, PV link -- so declaring `panel.wifi_ssid` there would claim all six read the SSID. That is not cosmetic: the declaration is what a Repair consults to name the entities a dead field took down with it, so an unresolved SSID would name the door sensor. Which class serves which description is a named map keyed by description key, so the setup comprehension goes on saying only "one entity per description". Two classes now declare one residual path, which the apparatus was built for and is now verified: `residual_field_paths()` unions a frozenset and the coordinator maps a path to a set of entity ids, so the Repair names both readers. `residual_field_paths()` also imports `binary_sensor` now -- the subclass walk sees only imported modules, and the path being declared twice is exactly what would have hidden the omission. No exemption counts move: `panel.wifi_ssid` was already a declared residual rather than an entry in `RESIDUAL_EXEMPT_PATHS`. --- CHANGELOG.md | 4 + README.md | 14 ++- custom_components/span_panel/binary_sensor.py | 80 +++++++++++++- custom_components/span_panel/field_paths.py | 1 + custom_components/span_panel/sensor_panel.py | 19 +++- tests/test_metadata_sweep.py | 104 +++++++++++++++++- tests/test_schema_repairs.py | 55 +++++++++ 7 files changed, 268 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cefce3d..84822ed8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,10 @@ All notable changes to this project will be documented in this file. everything unconfigured is reporting a state, and that is the state most panels are in; entities that vanished until somebody configured a limit would be entities nobody could build a dashboard on. +- **The Wi-Fi network name is now on the Wi-Fi Link sensor**, which is where you would look for it: the entity that tells you whether Wi-Fi is up now also tells + you which network it is up on, as a `wifi_ssid` attribute. It is absent rather than blank on a panel that publishes no SSID. The same attribute stays on the + Software Version sensor, where it has always been, so existing templates reading it there keep working unchanged. + ### Fixed - **The Wi-Fi network name came back.** Panels on the older data model report the SSID they are joined to, and this integration has shown it as an attribute on diff --git a/README.md b/README.md index 7bf49a5a..20fac9b0 100644 --- a/README.md +++ b/README.md @@ -226,10 +226,16 @@ Applies to Current Power, Feed Through Power, Battery Power, PV Power, Grid Powe ### Software Version Sensor Attributes -| Attribute | Type | Notes | -| ------------ | ------ | ----------------------------------- | -| `panel_size` | int | Total breaker spaces (e.g., 32, 40) | -| `wifi_ssid` | string | Current Wi-Fi network | +| Attribute | Type | Notes | +| ------------ | ------ | ---------------------------------------------------------------------------------------------------------------------------------- | +| `panel_size` | int | Total breaker spaces (e.g., 32, 40) | +| `wifi_ssid` | string | Current Wi-Fi network. Also on the Wi-Fi Link binary sensor, which is where it belongs; this copy stays for template compatibility | + +### Wi-Fi Link Binary Sensor Attributes + +| Attribute | Type | Notes | +| ----------- | ------ | ---------------------------------------------------------------- | +| `wifi_ssid` | string | Network this link is to. Absent when the panel publishes no SSID | ### EVSE (EV Charger) Entities diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py index 769f0cb2..b3aa5a4b 100644 --- a/custom_components/span_panel/binary_sensor.py +++ b/custom_components/span_panel/binary_sensor.py @@ -2,9 +2,11 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping from dataclasses import dataclass import logging +from types import MappingProxyType +from typing import Any, ClassVar from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -356,6 +358,77 @@ def _construct_binary_sensor_unique_id( ) +class SpanPanelWifiLinkBinarySensor(SpanPanelBinarySensor[SpanPanelBinarySensorEntityDescription]): + """The Wi-Fi link, which also reports the network the link is to. + + Its own class, not a branch on `SpanPanelBinarySensor`, because + `_residual_field_paths` is a `ClassVar` and that base class serves every + panel binary sensor — the door, the two links, the panel status, the PCS + activity, the PV link. Declaring the SSID there would claim all of them read + it, and the declaration is not decoration: it is what a Repair consults to + name the entities a dead field took down with it, so an unresolved + `panel.wifi_ssid` would name the door sensor. `SpanPanelStatus` is its own + class in `sensor_panel` for exactly this reason. + """ + + _residual_field_paths: ClassVar[tuple[str, ...]] = ("panel.wifi_ssid",) + """The SSID, read for an attribute rather than by the `value_fn`. + + A plain residual and not an exemption: both adapters map the property + (`core/wifi-ssid` on flat, `status/wifi-ssid` on v1.0), so the producible + gate covers it. + + `SpanPanelStatus` declares the same path, and that is deliberate — see + `extra_state_attributes` below. `residual_field_paths()` unions a set, and + the coordinator's `field_path -> entity_id` map holds a set of entity ids + per path, so two readers of one path is the case both were built for: the + Repair names both entities, which is what a user needs to see. + """ + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """The network this link is up on, when the panel names it. + + The coherent host for the SSID: the entity that reports whether Wi-Fi is + up is the one that should report which network it is up on. Both values + come from the same node on the wire — `status/wifi` and + `status/wifi-ssid` on v1.0, `core/wifi` and `core/wifi-ssid` on flat. + + The same attribute is still published by the Software Version sensor, + which is where it has always lived. That duplication is deliberate and + is documented at `SpanPanelStatus.extra_state_attributes`; do not remove + either half without reading it. + + Omitted rather than reported as `None` when the panel publishes no SSID: + an attribute present and empty reads as a reading that failed, which is + a different claim from the panel never having made one. + """ + snapshot = self.coordinator.data + if snapshot is None or snapshot.wifi_ssid is None: + return None + return {"wifi_ssid": snapshot.wifi_ssid} + + +_PANEL_BINARY_SENSOR_CLASSES: Mapping[ + str, type[SpanPanelBinarySensor[SpanPanelBinarySensorEntityDescription]] +] = MappingProxyType({SYSTEM_WIFI_LINK: SpanPanelWifiLinkBinarySensor}) +"""Panel binary sensors needing a class of their own, by description key. + +Everything absent from this map is a plain `SpanPanelBinarySensor`. A named map +rather than a conditional inside the setup comprehension: the comprehension says +"build one entity per description" and should keep saying only that, and the +next description that needs its own class is then a one-line addition here +rather than a second branch to read past. +""" + + +def _panel_binary_sensor_class( + description: SpanPanelBinarySensorEntityDescription, +) -> type[SpanPanelBinarySensor[SpanPanelBinarySensorEntityDescription]]: + """Return the entity class that serves one panel binary sensor description.""" + return _PANEL_BINARY_SENSOR_CLASSES.get(description.key, SpanPanelBinarySensor) + + # --------------------------------------------------------------------------- # EVSE (EV Charger) binary sensors # --------------------------------------------------------------------------- @@ -499,7 +572,10 @@ async def async_setup_entry( entities: list[ SpanPanelBinarySensor[SpanPanelBinarySensorEntityDescription] | SpanEvseBinarySensor - ] = [SpanPanelBinarySensor(coordinator, description) for description in BINARY_SENSORS] + ] = [ + _panel_binary_sensor_class(description)(coordinator, description) + for description in BINARY_SENSORS + ] # Add grid islandable binary sensor when v2 data is available snapshot: SpanPanelSnapshot = coordinator.data diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py index e58dce2b..fbc30901 100644 --- a/custom_components/span_panel/field_paths.py +++ b/custom_components/span_panel/field_paths.py @@ -477,6 +477,7 @@ def residual_field_paths() -> frozenset[str]: # Deferred for the same cycle-avoidance reason as `declared_field_paths()` # below: every platform module imports this one for the declaration mixin. from . import ( # noqa: F401 pylint: disable=import-outside-toplevel,unused-import + binary_sensor, select, sensor_circuit, sensor_panel, diff --git a/custom_components/span_panel/sensor_panel.py b/custom_components/span_panel/sensor_panel.py index 30d85560..38583d50 100644 --- a/custom_components/span_panel/sensor_panel.py +++ b/custom_components/span_panel/sensor_panel.py @@ -349,6 +349,11 @@ class SpanPanelStatus(SpanSensorBase[SpanPanelStatusSensorEntityDescription, Spa demanded the move the moment schema_1 grew its row. Until then the path sat in `RESIDUAL_EXEMPT_PATHS` annotated `SCHEMA_0_ONLY` -- true, and the reason a v1.0 panel silently stopped filling an attribute a flat panel filled. + + `SpanPanelWifiLinkBinarySensor` declares the same path, which is the + duplication `extra_state_attributes` explains. Nothing breaks: the collector + unions a frozenset, and the coordinator maps a path to a *set* of entity + ids, so the Repair names both readers. """ def __init__( @@ -386,7 +391,19 @@ def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanPanelSnapshot: @property def extra_state_attributes(self) -> dict[str, Any] | None: - """Return additional state attributes for the software version sensor.""" + """Return additional state attributes for the software version sensor. + + **`wifi_ssid` is published here and on the Wi-Fi Link binary sensor, on + purpose. Do not "tidy" it away from either place.** The coherent host is + the binary sensor — a network name on a firmware-version sensor is + incoherent, and it only sits here because `panel_size` was already + occupying this attribute block. But it has been here for as long as the + integration has existed, so a user's + `state_attr('sensor.span_panel_software_version', 'wifi_ssid')` template + keeps working. One duplicated attribute string is the price of not + breaking those; this copy comes out at a future major version, not + before. `test_the_ssid_stays_on_the_software_version_sensor_too` pins it. + """ if not self.coordinator.data: return None diff --git a/tests/test_metadata_sweep.py b/tests/test_metadata_sweep.py index 2fb4ecf7..629419c7 100644 --- a/tests/test_metadata_sweep.py +++ b/tests/test_metadata_sweep.py @@ -1,6 +1,6 @@ """Identity that reaches a device card, an attribute or a diagnostic sensor. -Four surfaces, no new entity classes, and one of them is a regression rather +Four surfaces, one new entity class, and one of them is a regression rather than a feature. Grouped because they share a proof obligation: each is a value the panel has published all along that nothing rendered, so a test asserting a constant the code also holds would pass whether or not the wire is ever read. @@ -17,6 +17,12 @@ annotation said `SCHEMA_0_ONLY` -- which was true, and sanctioned a user losing an attribute on upgrade. With the library reading it, both adapters produce the path, so it is a declaration now and the producible gate covers it. + +It is published on the **Wi-Fi Link binary sensor** as well, which is the +coherent host: the entity that reports whether Wi-Fi is up is the one that +should say which network it is up on, and both values come off the same node on +the wire. The Software Version copy stays, deliberately, so existing templates +keep working -- pinned below rather than left to a reader's judgement. """ from __future__ import annotations @@ -29,7 +35,10 @@ from span_panel_api import SpanPanelSnapshot from custom_components.span_panel import SpanPanelRuntimeData, ensure_device_registered -from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.binary_sensor import ( + async_setup_entry as binary_sensor_async_setup_entry, +) +from custom_components.span_panel.const import DOMAIN, SYSTEM_DOOR_STATE, SYSTEM_WIFI_LINK from custom_components.span_panel.field_paths import ( RESIDUAL_EXEMPT_PATHS, declared_field_paths, @@ -148,6 +157,20 @@ def _attributes(snapshot: SpanPanelSnapshot, key: str) -> dict[str, Any]: return sensor.extra_state_attributes or {} +async def _binary_sensors(hass: HomeAssistant, snapshot: SpanPanelSnapshot) -> dict[str, Any]: + """Every binary sensor the platform creates, keyed by description key. + + Through `async_setup_entry` rather than by constructing an entity directly: + which entity class serves which description is exactly what is under test + here, and a direct construction would only assert the class the test itself + picked. + """ + coordinator = _coordinator(snapshot) + added = MagicMock() + await binary_sensor_async_setup_entry(hass, coordinator.config_entry, added) + return {entity.entity_description.key: entity for entity in added.call_args.args[0]} + + async def _registered_panel( hass: HomeAssistant, snapshot: SpanPanelSnapshot, entry_id: str ) -> dr.DeviceEntry: @@ -316,6 +339,83 @@ def test_the_ssid_is_a_declaration_now_rather_than_an_exemption() -> None: assert "panel.wifi_ssid" in SpanPanelStatus._residual_field_paths +async def test_the_wifi_link_sensor_carries_the_network_it_is_linked_to( + hass: HomeAssistant, +) -> None: + """The coherent host: the link sensor says which network the link is to. + + Read out of the capture rather than compared against a literal, so what is + under test is the whole route -- published topic, mapper, snapshot field, + attribute -- and not a constant the code also holds. + """ + sensors = await _binary_sensors(hass, _snapshot()) + + attributes = sensors[SYSTEM_WIFI_LINK].extra_state_attributes + + assert attributes == {"wifi_ssid": _published(SCHEMA_ONE_PANEL, WIFI_SSID_TOPIC)} + + +async def test_the_wifi_link_attribute_follows_a_republished_ssid( + hass: HomeAssistant, +) -> None: + """A panel that joins another network says so, which a hardcoded value never could.""" + sensors = await _binary_sensors(hass, _snapshot(status__wifi_ssid="another-network")) + + assert sensors[SYSTEM_WIFI_LINK].extra_state_attributes == {"wifi_ssid": "another-network"} + + +async def test_an_unpublished_ssid_leaves_the_wifi_link_attribute_off_entirely( + hass: HomeAssistant, +) -> None: + """Absent, not `None`. A present-but-empty attribute reads as a failed reading.""" + sensors = await _binary_sensors(hass, _snapshot(status__wifi_ssid=None)) + + assert sensors[SYSTEM_WIFI_LINK].extra_state_attributes is None + + +async def test_the_ssid_stays_on_the_software_version_sensor_too( + hass: HomeAssistant, +) -> None: + """The duplication is the compatibility guarantee, not an oversight. + + `wifi_ssid` has been an attribute of the Software Version sensor for as long + as the integration has existed, so a user's + `state_attr('sensor.span_panel_software_version', 'wifi_ssid')` template + depends on it. Moving it to its coherent host would break those templates + silently -- a template that reads a missing attribute returns `None` rather + than erroring -- so both entities publish it and the old copy comes out at a + future major version. This test is what stops it being tidied away sooner. + """ + snapshot = _snapshot() + published = _published(SCHEMA_ONE_PANEL, WIFI_SSID_TOPIC) + sensors = await _binary_sensors(hass, snapshot) + + assert _attributes(snapshot, SOFTWARE_VERSION_KEY)["wifi_ssid"] == published + assert sensors[SYSTEM_WIFI_LINK].extra_state_attributes == {"wifi_ssid": published} + + +async def test_only_the_wifi_link_sensor_declares_the_ssid_it_reads( + hass: HomeAssistant, +) -> None: + """The reason the Wi-Fi link gets an entity class of its own. + + `_residual_field_paths` is a `ClassVar` and one class serves every panel + binary sensor, so declaring the SSID on that base class would claim the door + sensor reads it. That is not cosmetic: the declaration is what a Repair + consults to name the entities a dead field took down with it, so an + unresolved `panel.wifi_ssid` would name the door. + """ + sensors = await _binary_sensors(hass, _snapshot()) + wifi_link = sensors[SYSTEM_WIFI_LINK] + door = sensors[SYSTEM_DOOR_STATE] + + assert "panel.wifi_ssid" in type(wifi_link)._residual_field_paths + assert "panel.wifi_ssid" in wifi_link._declared_field_paths() + + assert "panel.wifi_ssid" not in type(door)._residual_field_paths + assert "panel.wifi_ssid" not in door._declared_field_paths() + + # --------------------------------------------------------------------------- # `shed/policy` -- attributes on `dsm_state` # --------------------------------------------------------------------------- diff --git a/tests/test_schema_repairs.py b/tests/test_schema_repairs.py index 354a5744..524cfd18 100644 --- a/tests/test_schema_repairs.py +++ b/tests/test_schema_repairs.py @@ -885,3 +885,58 @@ async def test_a_dead_priority_names_the_selects(hass) -> None: assert affected["circuit.priority"] == sorted(s.entity_id for s in selects) finally: await _stop_scheduling(coordinator) + + +async def test_two_entities_reading_one_residual_path_are_both_named(hass) -> None: + """`panel.wifi_ssid` has two readers, and the Repair has to name both. + + The Wi-Fi Link binary sensor is the coherent host for the SSID; the Software + Version sensor keeps publishing it so existing templates go on working. Two + entities therefore declare one residual path, which the apparatus is built + for -- `residual_field_paths()` unions a frozenset and the coordinator maps a + path to a *set* of entity ids -- but "built for" is not "verified", and a map + that dropped one of the two would report half the damage without failing. + """ + import dataclasses + from unittest.mock import MagicMock + + from custom_components.span_panel.binary_sensor import ( + async_setup_entry as binary_setup, + ) + from custom_components.span_panel.const import SYSTEM_WIFI_LINK + from custom_components.span_panel.sensor import async_setup_entry as sensor_setup + + coordinator, config_entry, _ = await _entities_by_declared_path(hass) + try: + coordinator.data = dataclasses.replace(coordinator.data, wifi_ssid="synthetic-network") + + added_sensors = MagicMock() + await sensor_setup(hass, config_entry, added_sensors) + software_version = [ + entity + for entity in added_sensors.call_args.args[0] + if getattr(entity.entity_description, "key", None) == "software_version" + ] + assert len(software_version) == 1 + + added_binary = MagicMock() + await binary_setup(hass, config_entry, added_binary) + wifi_link = [ + entity + for entity in added_binary.call_args.args[0] + if getattr(entity.entity_description, "key", None) == SYSTEM_WIFI_LINK + ] + assert len(wifi_link) == 1 + + await _add_to_platform(hass, config_entry, software_version, "sensor") + await _add_to_platform(hass, config_entry, wifi_link, "binary_sensor") + + assert coordinator.entity_ids_by_field_path["panel.wifi_ssid"] == sorted( + entity.entity_id for entity in (*software_version, *wifi_link) + ) + # Both really do publish it, so the pair the Repair names is the pair a + # user would see go blank. + assert software_version[0].extra_state_attributes["wifi_ssid"] == "synthetic-network" + assert wifi_link[0].extra_state_attributes == {"wifi_ssid": "synthetic-network"} + finally: + await _stop_scheduling(coordinator) From f2a49980a842734d5360d55eb3e168d5db86cf90 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:46:55 -0700 Subject: [PATCH 062/345] refactor(sensor): move the Wi-Fi network name rather than duplicate it The previous commit left `wifi_ssid` on the Software Version sensor as well, on the argument that a `state_attr('sensor.span_panel_software_version', 'wifi_ssid')` template must keep working. That argument does not survive checking: at v2.0.8 `STATUS_SENSORS` held four descriptions -- `dsm_state`, `dsm_grid_state`, `current_run_config`, `software_version` -- so the attribute appeared on all four. The other three have since moved to `SpanPanelDataSensorEntityDescription` / `SpanPanelPanelStatus`, which narrowed it to one sensor without anyone recording that it had happened. This finishes that narrowing and writes it down instead of restoring half of it. So the SSID is on the Wi-Fi Link binary sensor and nowhere else. `panel_size` is untouched. The residual declaration moved with the read: it is what a Repair consults to name the entity a dead field took down with it, and a copy left on a sensor that no longer reads the field would name the wrong entity. That leaves `panel.wifi_ssid` declared only in `binary_sensor`, which made the subclass walk's import list load-bearing for the first time -- and nothing held that list to anything. `test_source_residuals_match_the_subclass_walk` cannot: under pytest every platform module is imported many times over for other reasons, so an omission still walks and the test passes. Dropping `binary_sensor` from the list was a green mutation. It is now pinned in both directions against the modules the source scan finds declarations in, which also caught `sensor_panel` becoming a stale entry the moment its declaration left. Tests that asserted the attribute on Software Version now assert its absence, against a snapshot that does publish an SSID so a restored read fails rather than passing on a panel that carries none. --- CHANGELOG.md | 11 ++- README.md | 9 ++- custom_components/span_panel/binary_sensor.py | 21 +++--- custom_components/span_panel/field_paths.py | 12 +-- custom_components/span_panel/sensor_panel.py | 51 ++++++------- tests/test_field_path_conformance.py | 75 +++++++++++++++++-- tests/test_metadata_sweep.py | 74 ++++++------------ tests/test_panel_sensors.py | 21 ++++-- tests/test_schema_repairs.py | 50 ++++++------- 9 files changed, 188 insertions(+), 136 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84822ed8..52731a7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,15 +84,18 @@ All notable changes to this project will be documented in this file. everything unconfigured is reporting a state, and that is the state most panels are in; entities that vanished until somebody configured a limit would be entities nobody could build a dashboard on. -- **The Wi-Fi network name is now on the Wi-Fi Link sensor**, which is where you would look for it: the entity that tells you whether Wi-Fi is up now also tells - you which network it is up on, as a `wifi_ssid` attribute. It is absent rather than blank on a panel that publishes no SSID. The same attribute stays on the - Software Version sensor, where it has always been, so existing templates reading it there keep working unchanged. +- **The Wi-Fi network name moved to the Wi-Fi Link sensor**, which is where you would look for it: the entity that tells you whether Wi-Fi is up now also tells + you which network it is up on, as a `wifi_ssid` attribute. It is absent rather than blank on a panel that publishes no SSID. +- **It is no longer an attribute of the Software Version sensor.** A network name on a firmware-version sensor never made sense — it sat there because + `panel_size` was already in that attribute block. If you have a template reading `state_attr('sensor.span_panel_software_version', 'wifi_ssid')`, point it at + the Wi-Fi Link binary sensor instead. `panel_size` is unaffected and stays where it is. ### Fixed - **The Wi-Fi network name came back.** Panels on the older data model report the SSID they are joined to, and this integration has shown it as an attribute on the panel status sensor for as long as it has existed. On the v1.0 data model nothing read it, so the attribute quietly emptied when your panel upgraded — a - value you had, silently gone, with no error and nothing in the log. It is read again. + value you had, silently gone, with no error and nothing in the log. It is read again, and it is now published on the Wi-Fi Link binary sensor rather than on + the Software Version sensor — see above. - **A firmware upgrade that adds a capability now actually reloads.** The check that decides whether new hardware warrants a reload knew about four capabilities where the rest of the integration knew about nine. A panel that gained the shed forecast, the power control system, battery telemetry or DER link health while Home Assistant was running published the data, matched every rule for creating the entities, and asked for no reload — so the new entities appeared only the diff --git a/README.md b/README.md index 20fac9b0..0029b23a 100644 --- a/README.md +++ b/README.md @@ -226,10 +226,11 @@ Applies to Current Power, Feed Through Power, Battery Power, PV Power, Grid Powe ### Software Version Sensor Attributes -| Attribute | Type | Notes | -| ------------ | ------ | ---------------------------------------------------------------------------------------------------------------------------------- | -| `panel_size` | int | Total breaker spaces (e.g., 32, 40) | -| `wifi_ssid` | string | Current Wi-Fi network. Also on the Wi-Fi Link binary sensor, which is where it belongs; this copy stays for template compatibility | +| Attribute | Type | Notes | +| ------------ | ---- | ----------------------------------- | +| `panel_size` | int | Total breaker spaces (e.g., 32, 40) | + +`wifi_ssid` used to appear here. It moved to the Wi-Fi Link binary sensor below; a template reading it from this sensor should be pointed there. ### Wi-Fi Link Binary Sensor Attributes diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py index b3aa5a4b..3de76fe5 100644 --- a/custom_components/span_panel/binary_sensor.py +++ b/custom_components/span_panel/binary_sensor.py @@ -378,11 +378,14 @@ class in `sensor_panel` for exactly this reason. (`core/wifi-ssid` on flat, `status/wifi-ssid` on v1.0), so the producible gate covers it. - `SpanPanelStatus` declares the same path, and that is deliberate — see - `extra_state_attributes` below. `residual_field_paths()` unions a set, and - the coordinator's `field_path -> entity_id` map holds a set of entity ids - per path, so two readers of one path is the case both were built for: the - Repair names both entities, which is what a user needs to see. + The only declaration of this path in the integration. It was on + `SpanPanelStatus` while that sensor rendered the SSID, and it moved with the + read rather than being left behind: the declaration is what a Repair + consults to name the entity a dead field took down with it, so a stale copy + would name a sensor that no longer reads the field. Because this is now the + only declaration, `residual_field_paths()` has to import `binary_sensor` for + the subclass walk to see it -- the walk sees only imported modules, and this + path would otherwise leave the producible gate silently. """ @property @@ -394,10 +397,10 @@ def extra_state_attributes(self) -> dict[str, Any] | None: come from the same node on the wire — `status/wifi` and `status/wifi-ssid` on v1.0, `core/wifi` and `core/wifi-ssid` on flat. - The same attribute is still published by the Software Version sensor, - which is where it has always lived. That duplication is deliberate and - is documented at `SpanPanelStatus.extra_state_attributes`; do not remove - either half without reading it. + The Software Version sensor used to publish it, for the historical + reason that `panel_size` was already occupying its attribute block. That + copy is gone -- see `SpanPanelStatus.extra_state_attributes` for why the + compatibility argument for keeping one did not hold up. Omitted rather than reported as `None` when the panel publishes no SSID: an attribute present and empty reads as a reading that failed, which is diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py index fbc30901..86c94495 100644 --- a/custom_components/span_panel/field_paths.py +++ b/custom_components/span_panel/field_paths.py @@ -469,10 +469,13 @@ def residual_field_paths() -> frozenset[str]: that the Repair actually consumes is the entity's. The walk sees only classes Python has imported, so the platform modules - that declare residuals are imported here explicitly. A residual declared in - a module this function does not reach would go missing silently, so - `test_source_residuals_match_the_subclass_walk` scans the package source - for `_residual_field_paths` assignments and fails on any the walk missed. + that declare residuals are imported here explicitly -- exactly those, no + more: a module listed here that declares nothing is a stale import, and one + that declares something and is missing would go missing silently. Both + directions are pinned by + `test_the_residual_walk_imports_exactly_the_modules_that_declare_one`, which + reads this list out of the source because under pytest the platform modules + are already imported for other reasons and an omission here would still walk. """ # Deferred for the same cycle-avoidance reason as `declared_field_paths()` # below: every platform module imports this one for the declaration mixin. @@ -480,7 +483,6 @@ def residual_field_paths() -> frozenset[str]: binary_sensor, select, sensor_circuit, - sensor_panel, switch, ) from .entity import SpanPanelEntity # pylint: disable=import-outside-toplevel diff --git a/custom_components/span_panel/sensor_panel.py b/custom_components/span_panel/sensor_panel.py index 38583d50..efc64516 100644 --- a/custom_components/span_panel/sensor_panel.py +++ b/custom_components/span_panel/sensor_panel.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any, ClassVar +from typing import Any from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.typing import UNDEFINED @@ -340,21 +340,12 @@ def extra_state_attributes(self) -> dict[str, Any] | None: class SpanPanelStatus(SpanSensorBase[SpanPanelStatusSensorEntityDescription, SpanPanelSnapshot]): """Span Panel hardware status sensor entity.""" - _residual_field_paths: ClassVar[tuple[str, ...]] = ("panel.wifi_ssid",) - """The SSID, read for an attribute rather than by a `value_fn`. - - A plain residual and not an exemption: both adapters map the property - (`core/wifi-ssid` on flat, `status/wifi-ssid` on v1.0), so the producible - gate covers it, and `test_no_exempt_path_is_producible_by_both` is what - demanded the move the moment schema_1 grew its row. Until then the path sat - in `RESIDUAL_EXEMPT_PATHS` annotated `SCHEMA_0_ONLY` -- true, and the reason - a v1.0 panel silently stopped filling an attribute a flat panel filled. - - `SpanPanelWifiLinkBinarySensor` declares the same path, which is the - duplication `extra_state_attributes` explains. Nothing breaks: the collector - unions a frozenset, and the coordinator maps a path to a *set* of entity - ids, so the Repair names both readers. - """ + # `_residual_field_paths` stays empty on purpose. `panel.wifi_ssid` was + # declared here while this sensor rendered the SSID; the read moved to + # `SpanPanelWifiLinkBinarySensor` and the declaration went with it, because + # the declaration exists to let a Repair name the entity that made the read. + # `panel.panel_size` is not declared for the older reason: no adapter + # produces it, so it is an entry in `RESIDUAL_EXEMPT_PATHS` instead. def __init__( self, @@ -393,16 +384,22 @@ def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanPanelSnapshot: def extra_state_attributes(self) -> dict[str, Any] | None: """Return additional state attributes for the software version sensor. - **`wifi_ssid` is published here and on the Wi-Fi Link binary sensor, on - purpose. Do not "tidy" it away from either place.** The coherent host is - the binary sensor — a network name on a firmware-version sensor is - incoherent, and it only sits here because `panel_size` was already - occupying this attribute block. But it has been here for as long as the - integration has existed, so a user's - `state_attr('sensor.span_panel_software_version', 'wifi_ssid')` template - keeps working. One duplicated attribute string is the price of not - breaking those; this copy comes out at a future major version, not - before. `test_the_ssid_stays_on_the_software_version_sensor_too` pins it. + **No `wifi_ssid` here. It moved to the Wi-Fi Link binary sensor and is + not coming back; do not restore it "for compatibility".** A network name + on a firmware-version sensor was incoherent — it only ever sat here + because `panel_size` was already occupying this attribute block — and the + entity that reports whether Wi-Fi is up is the one that should report + which network it is up on. + + The compatibility argument for keeping a copy does not hold up. At v2.0.8 + `STATUS_SENSORS` held four descriptions, so the attribute appeared on + four sensors; the other three have since moved to + `SpanPanelPanelStatus`, which narrowed it to this one sensor without + anybody recording that it had happened. This is that narrowing finished + and written down rather than half-done and undocumented. + `test_the_ssid_moved_off_the_software_version_sensor` pins the absence. + + `panel_size` is untouched and stays here. """ if not self.coordinator.data: return None @@ -411,8 +408,6 @@ def extra_state_attributes(self) -> dict[str, Any] | None: attributes: dict[str, Any] = {} attributes["panel_size"] = snapshot.panel_size - if snapshot.wifi_ssid is not None: - attributes["wifi_ssid"] = snapshot.wifi_ssid if self.entity_description.key == "grid_forming_entity": forming = _grid_forming_device_name(snapshot) diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py index 2e4e0bf2..4cdf6489 100644 --- a/tests/test_field_path_conformance.py +++ b/tests/test_field_path_conformance.py @@ -12,7 +12,7 @@ import ast from collections import Counter -from collections.abc import Callable +from collections.abc import Callable, Iterator import pathlib import pytest @@ -105,15 +105,18 @@ def test_gate_covers_every_declaration_in_the_source() -> None: ) -def _source_residual_paths() -> dict[str, str]: - """Every `_residual_field_paths` tuple literal in the integration source, by module. +def _iter_source_residuals() -> Iterator[tuple[str, str]]: + """Yield ``(field_path, module_filename)`` for every residual literal in the source. The runtime counterpart, `residual_field_paths()`, unions the class attribute over a `SpanPanelEntity.__subclasses__()` walk, and a walk sees only what has been imported. This reads the same declarations out of the source text, where importedness is not a factor. + + A path declared by two modules yields twice, deliberately: which *paths* + exist and which *modules* declare one are different questions, and collapsing + to the first module that mentions a path answers the second wrongly. """ - found: dict[str, str] = {} for source in sorted(_PACKAGE_ROOT.rglob("*.py")): tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source)) for node in ast.walk(tree): @@ -133,10 +136,22 @@ def _source_residual_paths() -> dict[str, str]: continue for element in node.value.elts: if isinstance(element, ast.Constant) and isinstance(element.value, str): - found.setdefault(element.value, source.name) + yield element.value, source.name + + +def _source_residual_paths() -> dict[str, str]: + """Every residual path in the integration source, against a module declaring it.""" + found: dict[str, str] = {} + for path, module in _iter_source_residuals(): + found.setdefault(path, module) return found +def _source_residual_modules() -> set[str]: + """Every integration module that declares at least one residual path.""" + return {module.removesuffix(".py") for _, module in _iter_source_residuals()} + + def test_source_residuals_match_the_subclass_walk() -> None: """A residual the walk cannot see must fail, not vanish. @@ -173,6 +188,56 @@ def test_source_residuals_match_the_subclass_walk() -> None: ) +def _walk_imported_modules() -> set[str]: + """Return the sibling modules `residual_field_paths()` imports for its walk. + + Read out of the source rather than by calling the function, because what is + under test is the import list itself: a module missing from it still gets + walked in-process whenever some *other* importer has already pulled it in, + which is true of every module in the test suite and is exactly why the walk + test below cannot see the omission. + """ + tree = ast.parse((_PACKAGE_ROOT / "field_paths.py").read_text(encoding="utf-8")) + function = next( + node + for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) and node.name == "residual_field_paths" + ) + return { + alias.name + for node in ast.walk(function) + if isinstance(node, ast.ImportFrom) and node.level == 1 and node.module is None + for alias in node.names + } + + +def test_the_residual_walk_imports_exactly_the_modules_that_declare_one() -> None: + """The walk's import list, pinned against the declarations it exists to reach. + + `residual_field_paths()` walks `SpanPanelEntity.__subclasses__()`, which sees + only classes Python has already imported, so it imports the declaring + platform modules itself. Nothing held that list to the declarations: a module + dropped from it goes on being walked under pytest, where the platform modules + are imported many times over for other reasons, and fails only in production + where `field_paths` may be reached first. A path that leaves the walk leaves + the producible gate and the Repair's affected-entity count with it, silently. + + Both directions. An unlisted module is the failure above; a listed module + that declares nothing is a stale import, which is how the list stops meaning + what its docstring says and starts being copied forward unread. + """ + declaring = _source_residual_modules() + imported = _walk_imported_modules() + + assert declaring, "the source scan found no residual declarations at all" + assert imported == declaring, ( + f"`residual_field_paths()` imports {sorted(imported)} for its subclass walk but " + f"residuals are declared in {sorted(declaring)}. Unlisted modules drop out of the " + "producible gate in any process that reaches `field_paths` first; listed modules " + "that declare nothing are stale." + ) + + @pytest.mark.parametrize(("adapter", "metadata_fn"), _ADAPTERS) def test_gate_is_one_directional( adapter: str, metadata_fn: MetadataFn, capsys: pytest.CaptureFixture[str] diff --git a/tests/test_metadata_sweep.py b/tests/test_metadata_sweep.py index 629419c7..4e540481 100644 --- a/tests/test_metadata_sweep.py +++ b/tests/test_metadata_sweep.py @@ -18,11 +18,13 @@ an attribute on upgrade. With the library reading it, both adapters produce the path, so it is a declaration now and the producible gate covers it. -It is published on the **Wi-Fi Link binary sensor** as well, which is the -coherent host: the entity that reports whether Wi-Fi is up is the one that -should say which network it is up on, and both values come off the same node on -the wire. The Software Version copy stays, deliberately, so existing templates -keep working -- pinned below rather than left to a reader's judgement. +It is published on the **Wi-Fi Link binary sensor**, which is the coherent host: +the entity that reports whether Wi-Fi is up is the one that should say which +network it is up on, and both values come off the same node on the wire. The +Software Version sensor no longer carries it. That narrowing had already begun +undocumented -- at v2.0.8 four `STATUS_SENSORS` descriptions rendered the +attribute and three have since moved elsewhere, leaving one -- and this finishes +it and writes it down. """ from __future__ import annotations @@ -36,6 +38,7 @@ from custom_components.span_panel import SpanPanelRuntimeData, ensure_device_registered from custom_components.span_panel.binary_sensor import ( + SpanPanelWifiLinkBinarySensor, async_setup_entry as binary_sensor_async_setup_entry, ) from custom_components.span_panel.const import DOMAIN, SYSTEM_DOOR_STATE, SYSTEM_WIFI_LINK @@ -297,32 +300,22 @@ def test_the_panel_identity_paths_are_enumerated_as_device_card_reads() -> None: # --------------------------------------------------------------------------- -def test_the_ssid_attribute_carries_what_the_panel_publishes() -> None: - """The attribute a flat panel filled, filled again on v1.0. +def test_the_ssid_moved_off_the_software_version_sensor() -> None: + """The old site, asserted absent — deliberately, and not coming back. - Asserted on a sensor built by the platform over a snapshot the real schema_1 - adapter produced from the capture, so what is under test is the whole route: - published topic, mapper, snapshot field, attribute. + A network name on a firmware-version sensor was incoherent; it only ever sat + there because `panel_size` was already occupying the attribute block. The + value is not lost, it moved: `test_the_wifi_link_sensor_carries_the_network_it_is_linked_to` + reads it back out of this same capture on the Wi-Fi Link binary sensor. + + Asserted against a snapshot that *does* publish an SSID, so restoring the + read fails here rather than passing on a panel that happens to carry none. """ attributes = _attributes(_snapshot(), SOFTWARE_VERSION_KEY) - assert attributes["wifi_ssid"] == _published(SCHEMA_ONE_PANEL, WIFI_SSID_TOPIC) - - -def test_the_ssid_attribute_follows_a_republished_ssid() -> None: - """A panel that joins another network says so, which a hardcoded `None` never could.""" - attributes = _attributes(_snapshot(status__wifi_ssid="another-network"), SOFTWARE_VERSION_KEY) - - assert attributes["wifi_ssid"] == "another-network" - - -def test_an_unpublished_ssid_leaves_the_attribute_off_entirely() -> None: - """Absent, not empty. An attribute present and blank reads as a failed reading.""" - attributes = _attributes(_snapshot(status__wifi_ssid=None), SOFTWARE_VERSION_KEY) - + assert _published(SCHEMA_ONE_PANEL, WIFI_SSID_TOPIC) assert "wifi_ssid" not in attributes - # The sensor still reports its other attributes, so this is the SSID going - # missing rather than the attribute block collapsing. + # The attribute block did not collapse; only the SSID left it. assert "panel_size" in attributes @@ -331,12 +324,14 @@ def test_the_ssid_is_a_declaration_now_rather_than_an_exemption() -> None: Both adapters map `wifi_ssid`, so the path satisfies the producible gate and `test_no_exempt_path_is_producible_by_both` refuses to let it stay exempt. - It is declared on the entity that reads it, which is what lets a Repair name - the entities a dead field takes with it. + It is declared on the entity that reads it -- one entity, now that the read + has moved -- which is what lets a Repair name the entity a dead field takes + with it. """ assert "panel.wifi_ssid" not in RESIDUAL_EXEMPT_PATHS assert "panel.wifi_ssid" in declared_field_paths() - assert "panel.wifi_ssid" in SpanPanelStatus._residual_field_paths + assert "panel.wifi_ssid" in SpanPanelWifiLinkBinarySensor._residual_field_paths + assert "panel.wifi_ssid" not in SpanPanelStatus._residual_field_paths async def test_the_wifi_link_sensor_carries_the_network_it_is_linked_to( @@ -373,27 +368,6 @@ async def test_an_unpublished_ssid_leaves_the_wifi_link_attribute_off_entirely( assert sensors[SYSTEM_WIFI_LINK].extra_state_attributes is None -async def test_the_ssid_stays_on_the_software_version_sensor_too( - hass: HomeAssistant, -) -> None: - """The duplication is the compatibility guarantee, not an oversight. - - `wifi_ssid` has been an attribute of the Software Version sensor for as long - as the integration has existed, so a user's - `state_attr('sensor.span_panel_software_version', 'wifi_ssid')` template - depends on it. Moving it to its coherent host would break those templates - silently -- a template that reads a missing attribute returns `None` rather - than erroring -- so both entities publish it and the old copy comes out at a - future major version. This test is what stops it being tidied away sooner. - """ - snapshot = _snapshot() - published = _published(SCHEMA_ONE_PANEL, WIFI_SSID_TOPIC) - sensors = await _binary_sensors(hass, snapshot) - - assert _attributes(snapshot, SOFTWARE_VERSION_KEY)["wifi_ssid"] == published - assert sensors[SYSTEM_WIFI_LINK].extra_state_attributes == {"wifi_ssid": published} - - async def test_only_the_wifi_link_sensor_declares_the_ssid_it_reads( hass: HomeAssistant, ) -> None: diff --git a/tests/test_panel_sensors.py b/tests/test_panel_sensors.py index 746ed1c9..60e933c6 100644 --- a/tests/test_panel_sensors.py +++ b/tests/test_panel_sensors.py @@ -202,24 +202,33 @@ def test_software_version_extra_state_attributes_panel_size( assert attrs is not None assert attrs["panel_size"] == 32 - def test_software_version_extra_state_attributes_wifi_ssid( + def test_software_version_no_longer_carries_the_wifi_ssid( self, mock_coordinator: MagicMock ) -> None: - """Test that wifi_ssid appears in extra_state_attributes when present.""" + """The SSID moved to the Wi-Fi Link binary sensor, deliberately. + + A network name on a firmware-version sensor was incoherent; it only sat + here because `panel_size` was already occupying the attribute block. The + value is not lost -- `SpanPanelWifiLinkBinarySensor` publishes it, and + `tests/test_metadata_sweep.py` reads it back out of the capture there. + Asserted even with an SSID published, so a restored read fails here + rather than passing on a snapshot that happens to carry none. + """ description = next(d for d in STATUS_SENSORS if d.key == "software_version") - snapshot = SpanPanelSnapshotFactory.create(panel_size=24, wifi_ssid="MyNetwork") + snapshot = SpanPanelSnapshotFactory.create(panel_size=24, wifi_ssid="synthetic-network") mock_coordinator.data = snapshot sensor = SpanPanelStatus(mock_coordinator, description, snapshot) attrs = sensor.extra_state_attributes assert attrs is not None + assert "wifi_ssid" not in attrs + # The attribute block did not collapse; only the SSID left it. assert attrs["panel_size"] == 24 - assert attrs["wifi_ssid"] == "MyNetwork" - def test_software_version_extra_state_attributes_no_wifi( + def test_software_version_keeps_panel_size_without_a_wifi_ssid( self, mock_coordinator: MagicMock ) -> None: - """Test that wifi_ssid is omitted when None.""" + """A panel publishing no SSID is indistinguishable here, which is the point.""" description = next(d for d in STATUS_SENSORS if d.key == "software_version") snapshot = SpanPanelSnapshotFactory.create(panel_size=16, wifi_ssid=None) diff --git a/tests/test_schema_repairs.py b/tests/test_schema_repairs.py index 524cfd18..b1a21798 100644 --- a/tests/test_schema_repairs.py +++ b/tests/test_schema_repairs.py @@ -887,15 +887,14 @@ async def test_a_dead_priority_names_the_selects(hass) -> None: await _stop_scheduling(coordinator) -async def test_two_entities_reading_one_residual_path_are_both_named(hass) -> None: - """`panel.wifi_ssid` has two readers, and the Repair has to name both. - - The Wi-Fi Link binary sensor is the coherent host for the SSID; the Software - Version sensor keeps publishing it so existing templates go on working. Two - entities therefore declare one residual path, which the apparatus is built - for -- `residual_field_paths()` unions a frozenset and the coordinator maps a - path to a *set* of entity ids -- but "built for" is not "verified", and a map - that dropped one of the two would report half the damage without failing. +async def test_the_wifi_link_sensor_registers_the_ssid_it_reads(hass) -> None: + """The Wi-Fi Link is the only entity a dead `panel.wifi_ssid` should name. + + The read moved off the Software Version sensor, and the declaration moved + with it. That leaves the SSID as this integration's one residual declared on + a *binary sensor* -- every other residual test here covers a switch, a select + or a circuit sensor -- so nothing but this holds the binary-sensor route into + the Repair's affected-entity map. """ import dataclasses from unittest.mock import MagicMock @@ -910,15 +909,6 @@ async def test_two_entities_reading_one_residual_path_are_both_named(hass) -> No try: coordinator.data = dataclasses.replace(coordinator.data, wifi_ssid="synthetic-network") - added_sensors = MagicMock() - await sensor_setup(hass, config_entry, added_sensors) - software_version = [ - entity - for entity in added_sensors.call_args.args[0] - if getattr(entity.entity_description, "key", None) == "software_version" - ] - assert len(software_version) == 1 - added_binary = MagicMock() await binary_setup(hass, config_entry, added_binary) wifi_link = [ @@ -928,15 +918,25 @@ async def test_two_entities_reading_one_residual_path_are_both_named(hass) -> No ] assert len(wifi_link) == 1 - await _add_to_platform(hass, config_entry, software_version, "sensor") + added_sensors = MagicMock() + await sensor_setup(hass, config_entry, added_sensors) + software_version = [ + entity + for entity in added_sensors.call_args.args[0] + if getattr(entity.entity_description, "key", None) == "software_version" + ] + assert len(software_version) == 1 + await _add_to_platform(hass, config_entry, wifi_link, "binary_sensor") + await _add_to_platform(hass, config_entry, software_version, "sensor") - assert coordinator.entity_ids_by_field_path["panel.wifi_ssid"] == sorted( - entity.entity_id for entity in (*software_version, *wifi_link) - ) - # Both really do publish it, so the pair the Repair names is the pair a - # user would see go blank. - assert software_version[0].extra_state_attributes["wifi_ssid"] == "synthetic-network" + named = coordinator.entity_ids_by_field_path["panel.wifi_ssid"] + + assert named == [wifi_link[0].entity_id] + # The sensor that used to read it must not still be claiming it: a stale + # declaration would name an entity a dead SSID no longer affects. + assert software_version[0].entity_id not in named assert wifi_link[0].extra_state_attributes == {"wifi_ssid": "synthetic-network"} + assert "wifi_ssid" not in (software_version[0].extra_state_attributes or {}) finally: await _stop_scheduling(coordinator) From 4e717b72dbcb806cb02c11b17605eab7ff7aea6b Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:06:59 -0700 Subject: [PATCH 063/345] docs(developer): explain the declared-but-unread gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate is new and its mechanism is not obvious from reading it: it decides "read" by experiment rather than by inspecting a map, republishing each declared property with a legal different value and checking whether anything a consumer reads moved. That is why it sees consumption `_PROPERTY_FIELD_MAP` cannot express, and it is worth writing down before someone reads the map and concludes the gate is redundant. Also states the two limits, because both are easy to mistake for coverage. The gate reads the vendored fixture, so it cannot see a property a real panel starts publishing in the field. And it asks whether anything moved, not whether everything did — a property read on the first PV child and discarded on the second still counts as read. --- developer.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/developer.md b/developer.md index 46b96dcc..8ccc0d9d 100644 --- a/developer.md +++ b/developer.md @@ -162,6 +162,44 @@ python -m pytest tests/test_current_monitor.py -q python -m pytest tests/ --cov=custom_components/span_panel --cov-report=term-missing ``` +## Knowing what the panel publishes that nothing reads + +The panel declares more than this integration surfaces, and the gap is tracked mechanically rather than by memory. + +### The gate + +`tests/test_declared_but_unread.py` asserts that **every property declared in a device's `$description`** is one of three things: mapped to a snapshot field by +an adapter, consumed by a known internal route (topology, dispatch, device_info, role resolution), or listed in +`tests/fixtures/unread_declarations_baseline.json` with a one-line reason. + +It decides "read" **by experiment**, not by inspecting a map. For each declared property it republishes a legal different value derived from the property's own +`datatype`/`format`, rebuilds the snapshot through the real adapter, and checks whether any field a consumer reads actually moved. That is why it sees +consumption the `_PROPERTY_FIELD_MAP` cannot express, and why it caught a case where a metadata row existed while nothing read the value. + +It fails in **both** directions: + +- A newly declared property that reaches nothing fails the build until somebody triages it. +- A property that becomes read fails until its baseline line is deleted. + +### Working with the baseline + +When you surface a property, delete its baseline line in the same change. The test will tell you if you deleted one you did not surface, or surfaced one whose +line you left. + +When you decide a property should _stay_ unread, add a line with an honest reason. The reasons are load-bearing — they are what stops the file becoming a list +of things nobody remembers deciding. The current entries are all permanent: deliberate skips (`status/postal-code` copies location into recorder history; Home +Assistant owns `status/time-zone`), values held for identity reasons (`pv/info/serial-number`), redundant echoes (`connection/*-device-type` dereferences to a +declared `$type`), and properties no producer publishes (`connection/count`). + +### What it does not cover + +The gate reads the **vendored fixture**, so it answers "what does our capture declare that we do not read". It cannot see a property a real panel starts +publishing in the field. That is the purpose of the runtime discovery work on `feat/discovery-and-catalog-validation`: the same question asked of a live panel +and surfaced in diagnostics, so a maintainer triaging an issue can see what that fleet declares that this integration ignores. + +Note also that a property can be _read on one device and not another_ and the gate will not see it — it asks whether anything moved, not whether everything did. +`snapshot.pv` keeps the first `energy.ebus.device.pv` child and discards the rest, so a second inverter is invisible while the property still counts as read. + ## Linting and Type Checking ```bash From 8e33fce2a0ee4ec8ff5e8ec8ae34c24932b1bb6d Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:10:16 -0700 Subject: [PATCH 064/345] feat(diagnostics): carry what the panel declares and this integration ignores `test_declared_but_unread` answers "what does our capture declare that we do not read" by experiment, and it is fixture-bound: a real panel that starts publishing a property fails nothing until somebody recaptures. schema_1 now asks the same question of the live tree and returns the answer as namespaced rows in its metadata map. This is the consumer half. **The partition comes first, because a discovery feature that breaks the catch-up's gate is a net loss.** `schema_validation.partition` splits an adapter's map on the `discovered.` namespace before any other question is asked of it, and every inventory downstream -- the producible gate, `unread`, the exemption annotations, the unit vocabulary -- sees the curated half only. All of them read "in an adapter's map" as "this integration could read this", which a discovered path is not: one landing in `unread` would bury ten deliberate entries under whatever a firmware release added, and would make the count depend on the panel in front of the user. `test_the_unread_inventory_is_deaf_to_ discovery` proves it by mutation -- a synthetic discovered row moves `unread`, `unresolved` and the unit mismatches not at all. The test fixtures follow the same rule, so no other module can be perturbed by accident. **Diagnostics ships paths, datatypes, units and retention -- never values.** `TO_REDACT` is key-based over the config entry: it knows nothing about wire property names and could not protect a value added here, so the block is safe by construction rather than by redaction. The row shape is a `TypedDict` so a fifth key is a type error rather than a review comment, and the privacy claim is asserted against the capture's own published values. Nothing user-facing: no entity, no Repair, no notification. That line is the one between this and automatic adoption, whose costs -- notice aggregation, an exclusion denylist, the accumulator register -- are unsettled, and `test_the_discovery_block_creates_nothing_a_user_sees` holds it. **Pointed at the library worktree this branch belongs with.** `[tool.uv.sources]` named `-p2`, which has neither half of this change, so the branch was being tested against a library four commits behind it. Repointing at `-p3` surfaced a stale annotation the conformance gate caught immediately and correctly: `pv.software_version` is `SCHEMA_0_ONLY`, not `NEITHER`. It was annotated on the claim that flat's `pv` device class declares no firmware version; flat declares `software-version` on it, and the library grew the mapping row once a producer valued the v1.0 half. The exemption stands -- schema_1 still carries no row, because a version string is identity rather than a reading -- but the reason it stands has changed. Against the vendored tree the report is nine properties, which is the ten-entry unread baseline less `circuit/breaker/poles`, whose value the library does read. --- custom_components/span_panel/diagnostics.py | 71 +++- custom_components/span_panel/field_paths.py | 14 +- .../span_panel/schema_validation.py | 116 +++++- developer.md | 49 ++- pyproject.toml | 13 +- tests/adapter_fixtures.py | 64 +++- tests/test_diagnostics.py | 5 + tests/test_field_path_conformance.py | 16 +- tests/test_pv_device.py | 11 +- tests/test_schema_discovery.py | 355 ++++++++++++++++++ uv.lock | 20 +- 11 files changed, 691 insertions(+), 43 deletions(-) create mode 100644 tests/test_schema_discovery.py diff --git a/custom_components/span_panel/diagnostics.py b/custom_components/span_panel/diagnostics.py index 0c46cc6e..9020bb94 100644 --- a/custom_components/span_panel/diagnostics.py +++ b/custom_components/span_panel/diagnostics.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any +from typing import Any, TypedDict from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_ACCESS_TOKEN @@ -14,6 +14,7 @@ CONF_EBUS_BROKER_USERNAME, CONF_HOP_PASSPHRASE, ) +from .schema_validation import SchemaFindings TO_REDACT = { CONF_ACCESS_TOKEN, @@ -23,6 +24,73 @@ "password", "username", } +"""Config-entry keys whose values are replaced before the payload leaves. + +Key-based, and only over `entry.as_dict()`. It knows nothing about wire +property names and cannot be taught them cheaply, so anything added to this +payload from the panel has to be safe by construction rather than by redaction. +That is the constraint `_discovery` is built to — see its docstring. +""" + + +class DiscoveredRow(TypedDict): + """One declared-but-unread property, as it appears in the payload.""" + + path: str + datatype: str + unit: str | None + retained: bool | None + + +class DiscoveryBlock(TypedDict): + """The `schema_discovery` section. Typed so the shape is checked, not described. + + Four keys per row and no more: a fifth would be the seam a wire value slips + through, and this is what makes adding one a type error rather than a review + comment. + """ + + available: bool + count: int + properties: list[DiscoveredRow] + + +def _discovery(findings: SchemaFindings | None) -> DiscoveryBlock: + """Report what the panel declares that this integration reads nothing from. + + Maintainer-facing, and the reason it is here rather than anywhere a user + looks: a diagnostics attachment on an issue is where the question "what does + that fleet publish that we ignore" actually gets asked, and aggregating a + few attachments is how the rate gets measured. Nothing is created from it — + no entity, no Repair, no notification. + + **Declarations, never values.** Each row is the property's path, its declared + datatype and unit, and whether the panel has published a value for it. A + diagnostics payload leaves the house into issues and forum posts, and + `TO_REDACT` above is key-based over the config entry — it could not protect a + wire value put here, so no wire value is put here. `test_diagnostics` asserts + that against the vendored capture rather than leaving it to review. + + `available` is False while the adapter has not reported metadata yet, which + is a real state on a reconnect. It is not the same as an empty report: an + empty `properties` list means the panel declares nothing this integration + ignores, which is a finding. + """ + if findings is None: + return {"available": False, "count": 0, "properties": []} + return { + "available": True, + "count": len(findings.discovered), + "properties": [ + { + "path": entry.path, + "datatype": entry.datatype, + "unit": entry.unit, + "retained": entry.retained, + } + for entry in findings.discovered + ], + } async def async_get_config_entry_diagnostics( @@ -93,4 +161,5 @@ async def async_get_config_entry_diagnostics( "panel_offline": coordinator.panel_offline, "last_update_success": coordinator.last_update_success, }, + "schema_discovery": _discovery(coordinator.schema_findings), } diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py index 86c94495..791f019f 100644 --- a/custom_components/span_panel/field_paths.py +++ b/custom_components/span_panel/field_paths.py @@ -207,14 +207,18 @@ class FieldPathDeclarationMixin: # they are `field_path` declarations on the three PV metadata sensors # already, and the card reads the same two fields those sensors do. # - # `NEITHER` for the same reason as the `mid.*` and `panel.*` card reads - # above: flat's `pv` device class declares no firmware version at all, - # and a schema_1 metadata row carries a unit and a datatype for a - # *reading*, which a version string is not. + # `SCHEMA_0_ONLY`, and it says so because the annotation is checked + # rather than asserted: this read `NEITHER` on the claim that "flat's + # `pv` device class declares no firmware version at all", which was + # wrong. Flat declares `software-version` on `energy.ebus.device.pv`, + # and the library grew the mapping row for it once a producer valued the + # v1.0 half. schema_1 still carries no row, for the reason the `mid.*` + # and `panel.*` card reads above carry none: a row states a *reading's* + # unit and datatype, and a version string is not a reading. # # `pv.serial_number` is deliberately absent -- from this table, from the # card and from the snapshot. See `pv_device_info`. - "pv.software_version": Producibility.NEITHER, + "pv.software_version": Producibility.SCHEMA_0_ONLY, # The `mid_grid_state` sensor's source field — utility-supply health, # the one non-metadata entity the MID brings. Neither adapter maps the # MID at all, which is why the description is `NO_SOURCE_FIELD`. diff --git a/custom_components/span_panel/schema_validation.py b/custom_components/span_panel/schema_validation.py index 0ae2955a..a4c77573 100644 --- a/custom_components/span_panel/schema_validation.py +++ b/custom_components/span_panel/schema_validation.py @@ -9,6 +9,19 @@ Because the adapter classifies absence, this module needs no capability table and never infers hardware presence from telemetry. + +**Two inventories, not one.** An adapter's metadata map carries curated rows — +snapshot field paths this integration knows about — and, under the library's +discovery namespace, rows for properties the panel declares and the adapter +reads nothing from. They answer opposite questions and must never mix: +`unread` below is "we produce this and render nothing from it", which is an +inventory of *our* backlog, while a discovered row is "the panel has something +we never modelled". Letting the second into the first would bury ten known +entries under whatever a firmware release happened to add, and would make the +conformance gate's producible set depend on the panel in front of the user. + +So the metadata is partitioned by namespace before any other question is asked +of it — see `partition`. """ from __future__ import annotations @@ -17,7 +30,7 @@ import logging from homeassistant.components.sensor import SensorEntityDescription -from span_panel_api.models import FieldMetadata +from span_panel_api.models import DiscoveredMetadata, FieldMetadata, is_discovery_path from .field_paths import RESIDUAL_EXEMPT_PATHS, conditional_field_paths, declared_field_paths @@ -55,6 +68,46 @@ class UnitMismatch: schema_unit: str +@dataclass(frozen=True, slots=True) +class DiscoveredProperty: + """A property the panel declares that the running adapter reads nothing from. + + The runtime half of `tests/test_declared_but_unread`, which asks the same + question of a vendored capture and therefore cannot see a panel that starts + publishing something in the field. + + Maintainer-facing only. Nothing creates an entity, a Repair or a + notification from one of these — it is carried in diagnostics so that + triaging an issue shows what that panel declares and this integration + ignores, and so the rate can be measured across a few attachments. + + **Declarations only, deliberately.** Diagnostics leave the house into GitHub + issues and forum posts, and `diagnostics.TO_REDACT` is key-based: it knows + the config entry's keys and nothing at all about wire property names, so it + could not protect a value put here. `retained` is the only thing this says + about a value, and it says whether one exists rather than what it is. + """ + + path: str + """The library's namespaced path, ``discovered.{device type}/{node}/{property}``. + + Carried verbatim rather than trimmed to the wire path: it is the key the + adapter emitted, so a maintainer cross-referencing a capability catalog or + the unread baseline is looking at the same string the library is. + """ + + datatype: str + unit: str | None + retained: bool | None + """Whether the panel has published a value, or None if the adapter did not say. + + None is the forward-compatible case: the namespace is the contract and the + enriched row type is not, so an adapter that namespaces a row without + carrying `DiscoveredMetadata` still reports its path, datatype and unit + rather than being dropped. + """ + + @dataclass(frozen=True, slots=True) class SchemaFindings: """Outcome of one validation pass.""" @@ -62,6 +115,41 @@ class SchemaFindings: unresolved: frozenset[str] unit_mismatches: tuple[UnitMismatch, ...] unread: frozenset[str] + discovered: tuple[DiscoveredProperty, ...] = () + """Properties the panel declares and the adapter reads nothing from. + + Defaulted because it is additive and because every other member is a + finding about *our* declarations, which this is not: an adapter that emits + no discovered rows — the flat one does not — leaves this empty, and that is + a fact about the adapter rather than a clean bill of health. + """ + + +def partition( + field_metadata: dict[str, FieldMetadata], +) -> tuple[dict[str, FieldMetadata], tuple[DiscoveredProperty, ...]]: + """Split one adapter metadata map into the curated rows and the discovered ones. + + The single place the namespace is tested, so a caller cannot half-apply it. + Everything downstream — the producible gate, the unread inventory, the unit + check, the Repairs reconciler — takes the curated half and can therefore not + be perturbed by what a panel happens to declare. + """ + curated: dict[str, FieldMetadata] = {} + discovered: list[DiscoveredProperty] = [] + for path, entry in field_metadata.items(): + if not is_discovery_path(path): + curated[path] = entry + continue + discovered.append( + DiscoveredProperty( + path=path, + datatype=entry.datatype, + unit=entry.unit, + retained=entry.retained if isinstance(entry, DiscoveredMetadata) else None, + ) + ) + return curated, tuple(sorted(discovered, key=lambda item: item.path)) def evaluate_field_metadata( @@ -76,6 +164,10 @@ def evaluate_field_metadata( resolved. Callers interpret the sentinel themselves; see `SpanPanelCoordinator._run_schema_validation`. """ + # First, before anything reads the map: the discovered rows are a report + # about the panel, not an inventory of what we produce, and every question + # below is the second kind. + curated, discovered = partition(field_metadata) declared = declared_field_paths() # Schema-conditional entities read a real field off a real metadata row; # what they cannot do is satisfy a gate that demands *both* adapters @@ -91,7 +183,7 @@ def evaluate_field_metadata( mismatches: list[UnitMismatch] = [] for field_path in resolvable: - entry = field_metadata.get(field_path) + entry = curated.get(field_path) if entry is None: # Hardware not present. Not a defect, and deliberately silent. continue @@ -117,10 +209,26 @@ def evaluate_field_metadata( # does, so they are absent from `declared` without being unread. Only the # paths matter here; each entry's `Producibility` annotation is what the # conformance tests verify. - unread = frozenset(set(field_metadata) - set(declared) - RESIDUAL_EXEMPT_PATHS.keys()) + # + # `curated` and never `field_metadata`, which still holds both halves. A + # discovered path reaching this set would read as a produced field nothing + # renders -- a defect's shape -- and would bury ten deliberate entries under + # whatever the panel's firmware happens to declare. + unread = frozenset(set(curated) - set(declared) - RESIDUAL_EXEMPT_PATHS.keys()) for field_path in sorted(unread): # An addition is legal within a major version. This is an inventory for # us, never a user-facing finding. _LOGGER.debug("Schema: %s is produced but no platform reads it", field_path) - return SchemaFindings(frozenset(unresolved), tuple(mismatches), unread) + for declaration in discovered: + # The panel's side of the same question, and equally not user-facing: + # the user-facing half of this would be adoption, which is not built. + _LOGGER.debug( + "Schema: %s is declared by the panel and read by nothing here (%s%s, retained=%s)", + declaration.path, + declaration.datatype, + f" in {declaration.unit}" if declaration.unit else "", + declaration.retained, + ) + + return SchemaFindings(frozenset(unresolved), tuple(mismatches), unread, discovered) diff --git a/developer.md b/developer.md index 8ccc0d9d..3afec603 100644 --- a/developer.md +++ b/developer.md @@ -194,12 +194,57 @@ declared `$type`), and properties no producer publishes (`connection/count`). ### What it does not cover The gate reads the **vendored fixture**, so it answers "what does our capture declare that we do not read". It cannot see a property a real panel starts -publishing in the field. That is the purpose of the runtime discovery work on `feat/discovery-and-catalog-validation`: the same question asked of a live panel -and surfaced in diagnostics, so a maintainer triaging an issue can see what that fleet declares that this integration ignores. +publishing in the field. The runtime half below is what answers that. Note also that a property can be _read on one device and not another_ and the gate will not see it — it asks whether anything moved, not whether everything did. `snapshot.pv` keeps the first `energy.ebus.device.pv` child and discards the rest, so a second inverter is invisible while the property still counts as read. +### The runtime half: what the panel in front of the user declares + +The schema_1 adapter asks the same question of the live tree and puts the answer in **diagnostics**, under `schema_discovery`: + +```json +"schema_discovery": { + "available": true, + "count": 9, + "properties": [ + { "path": "discovered.circuit/connection/count", "datatype": "integer", "unit": null, "retained": false }, + { "path": "discovered.distribution-enclosure/status/postal-code", "datatype": "string", "unit": null, "retained": true } + ] +} +``` + +`available: false` means the adapter has not reported metadata yet — a real state on a reconnect, and not the same as an empty report. `retained` says whether +the panel has published a value for the property, which is the declared-but-never-valued signal; it never says what the value is. + +**Paths, datatypes, units and retention only — never values.** Diagnostics leave the house into issues and forum posts, and `TO_REDACT` in `diagnostics.py` is +key-based over the config entry: it knows nothing about wire property names and could not protect a value added here. `test_schema_discovery` asserts that +against the capture's own published values rather than leaving it to review. + +This is **maintainer-facing only**. Nothing creates an entity, a Repair or a notification from it. Automatic adoption is a separate, unbuilt step whose costs — +notice aggregation, an exclusion denylist, the accumulator register — are not settled. + +### Why discovered rows cannot reach the curated inventories + +The adapter returns both kinds of row in one map, keyed by the library's `discovered.` namespace. `schema_validation.partition` splits them **before any other +question is asked**, and everything downstream — the producible gate, the `unread` inventory, the exemption annotations, the unit vocabulary — sees the curated +half only. + +That partition is load-bearing rather than tidy. Every one of those inventories reads "in an adapter's map" as "this integration could read this", which a +discovered path is not: a discovered row in `unread` would bury ten deliberate entries under whatever a firmware release added, and would make the count depend +on the panel in front of the user. `test_the_unread_inventory_is_deaf_to_discovery` proves it by mutation — a synthetic discovered row changes `unread`, +`unresolved` and the unit mismatches not at all. + +The test fixtures follow the same rule: `adapter_fixtures.schema_one_metadata()` hands out the curated half, and a test that wants the other half asks for +`schema_one_discovery()` by name. + +### Keeping the library's answer honest + +The adapter decides "read" from four enumerations of what it addresses — the metadata map, the lugs direction tables, the charge-limit resolution, and +`_CONSUMED_WITHOUT_A_ROW` for the properties it reads into the snapshot without a unit surface. A stale entry there fails _silently_, by keeping a property out +of the report. `tests/test_schema_one_discovery.py` in the library runs the same republish-and-diff experiment this gate uses and holds every entry to it in +both directions, so the report means "nothing reads this" rather than "nobody wrote it down". + ## Linting and Type Checking ```bash diff --git a/pyproject.toml b/pyproject.toml index 49c7c78e..e79e0a13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,10 +46,13 @@ package = false [tool.uv.sources] # This branch develops against the in-progress schema-1 adapter, which lives in a -# worktree rather than the primary checkout the symlink points at. -span-panel-api = { path = "../../span/span-panel-api-p2", editable = true } -span-panel-api-schema-0 = { path = "../../span/span-panel-api-p2/packages/schema-0", editable = true } -span-panel-api-schema-1 = { path = "../../span/span-panel-api-p2/packages/schema-1", editable = true } +# worktree rather than the primary checkout the symlink points at. `-p3` carries +# the library half of `feat/discovery-and-catalog-validation`; the two halves are +# one change and pointing at the wrong worktree means the branch is not being +# tested against the library it needs. +span-panel-api = { path = "../../span/span-panel-api-p3", editable = true } +span-panel-api-schema-0 = { path = "../../span/span-panel-api-p3/packages/schema-0", editable = true } +span-panel-api-schema-1 = { path = "../../span/span-panel-api-p3/packages/schema-1", editable = true } [tool.jscpd] path = ["custom_components/span_panel", "./*.{html,md}"] @@ -129,7 +132,7 @@ exclude = [ ] extraPaths = [ "./custom_components", - "../../span/span-panel-api-p2/src", + "../../span/span-panel-api-p3/src", "../ha-synthetic-sensors/src" ] pythonPlatform = "Darwin" diff --git a/tests/adapter_fixtures.py b/tests/adapter_fixtures.py index c71743ca..869c786b 100644 --- a/tests/adapter_fixtures.py +++ b/tests/adapter_fixtures.py @@ -19,6 +19,8 @@ from ebus_sdk.homie import DiscoveredDevice from span_panel_api.models import FieldMetadata, SpanPanelSnapshot +from custom_components.span_panel.schema_validation import DiscoveredProperty + _FIXTURES = pathlib.Path(__file__).parent / "fixtures" SCHEMA_ONE_PANEL = "example-40t-001" @@ -88,29 +90,79 @@ def schema_one_snapshot(tree: dict[str, dict[str, str]] | None = None) -> SpanPa def schema_zero_metadata() -> dict[str, FieldMetadata]: - """Field metadata as schema_0 builds it from the flat REST schema.""" + """Curated field metadata as schema_0 builds it from the flat REST schema. + + Partitioned like its schema_1 counterpart even though the flat adapter emits + no discovered rows: the fixtures state the rule, not the current contents of + one adapter. + """ from span_panel_api_schema_0.field_metadata import build_field_metadata raw = json.loads((_FIXTURES / "schema_zero_types.json").read_text()) - return build_field_metadata(raw["types"]) + return _curated(build_field_metadata(raw["types"])) + + +def _curated(metadata: dict[str, FieldMetadata]) -> dict[str, FieldMetadata]: + """The half of an adapter's map that names snapshot fields we curate. + + Every fixture below hands out the curated half, through the same + `schema_validation.partition` the coordinator uses, so no test can be + perturbed by what a panel declares and nobody reads. That is not tidiness: + the producible gate, the exemption annotations, the derived-reason checks + and the unit vocabulary all treat "in an adapter's map" as "this integration + could read it", and a discovered path satisfies neither half of that. + + `schema_one_discovery` is how a test asks for the other half, and + `test_schema_discovery` is where the partition itself is checked against the + unpartitioned map. + """ + from custom_components.span_panel.schema_validation import partition + + return partition(metadata)[0] def schema_one_metadata() -> dict[str, FieldMetadata]: - """Field metadata as schema_1 builds it from a full parent/child tree.""" + """Curated field metadata as schema_1 builds it from a full parent/child tree.""" + from span_panel_api_schema_1.field_metadata import build_field_metadata + + return _curated(build_field_metadata(_devices("schema_one_tree.json"))) + + +def schema_one_metadata_raw(name: str = "schema_one_tree.json") -> dict[str, FieldMetadata]: + """The adapter's map exactly as it returns it, both halves together. + + The one fixture that does *not* partition, because the partition is the + thing under test in `test_schema_discovery`. Everywhere else, ask for a + partitioned half by name. + """ from span_panel_api_schema_1.field_metadata import build_field_metadata - return build_field_metadata(_devices("schema_one_tree.json")) + return build_field_metadata(_devices(name)) + + +def schema_one_discovery() -> tuple[DiscoveredProperty, ...]: + """What schema_1 declares in the vendored tree that it reads nothing from. + + The other half of the same map. Held apart from `schema_one_metadata` so a + test has to ask for it by name — a discovered path arriving unannounced in a + curated inventory is the failure mode the namespace exists to prevent. + """ + from span_panel_api_schema_1.field_metadata import build_field_metadata + + from custom_components.span_panel.schema_validation import partition + + return partition(build_field_metadata(_devices("schema_one_tree.json")))[1] def schema_one_metadata_batteryless() -> dict[str, FieldMetadata]: """Build the same tree with the BESS removed — no battery hardware present.""" from span_panel_api_schema_1.field_metadata import build_field_metadata - return build_field_metadata(_devices("schema_one_tree_batteryless.json")) + return _curated(build_field_metadata(_devices("schema_one_tree_batteryless.json"))) def schema_one_metadata_no_pv() -> dict[str, FieldMetadata]: """Build the same tree with the PV device removed, power-flows still present.""" from span_panel_api_schema_1.field_metadata import build_field_metadata - return build_field_metadata(_devices("schema_one_tree_no_pv.json")) + return _curated(build_field_metadata(_devices("schema_one_tree_no_pv.json"))) diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index f257c2e8..d1748fa7 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -64,6 +64,10 @@ async def test_config_entry_diagnostics_includes_redacted_runtime_data( coordinator.data = snapshot coordinator.panel_offline = False coordinator.last_update_success = True + # Explicit: a MagicMock answers `len()` and iteration happily, so leaving + # 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 entry = MockConfigEntry( domain=DOMAIN, @@ -155,6 +159,7 @@ async def test_config_entry_diagnostics_omits_optional_sections_when_unavailable coordinator.data = snapshot coordinator.panel_offline = True coordinator.last_update_success = False + coordinator.schema_findings = None entry = MockConfigEntry(domain=DOMAIN, data={}, title="SPAN Panel") entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator, panel_device_id="panel-device-id") diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py index 4cdf6489..f018dcaa 100644 --- a/tests/test_field_path_conformance.py +++ b/tests/test_field_path_conformance.py @@ -353,11 +353,7 @@ def test_no_exempt_path_is_producible_by_both() -> None: # `$target` echo it renders as an attribute. Facts about a command rather # than readings, so no adapter carries a row for either -- the same shape as # the `circuit.*_target` pair. - # +1 for `pv.software_version`, the firmware row on the solar inverter's own - # device card. Flat's `pv` device class declares no firmware version, and a - # version string is identity rather than a reading -- the same argument as - # the `mid.*` and `panel.*` card reads above. - Producibility.NEITHER: 44, + Producibility.NEITHER: 43, # +1 for `panel.dominant_power_source`, the `grid_forming_entity` sensor's # source field. It was read by a `SCHEMA_CONDITIONAL_FIELD` description and # enumerated nowhere, so `evaluate_field_metadata` counted it as produced- @@ -368,7 +364,15 @@ def test_no_exempt_path_is_producible_by_both() -> None: # declaration -- `SpanPanelStatus._residual_field_paths`. Its time here as a # true `SCHEMA_0_ONLY` annotation is what sanctioned a flat -> v1.0 # regression: the attribute a flat panel filled, a v1.0 panel did not. - Producibility.SCHEMA_0_ONLY: 10, + # +1 for `pv.software_version`, the firmware row on the solar inverter's own + # device card, which moved here from `NEITHER`. It was annotated on the + # claim that flat's `pv` device class declares no firmware version; flat + # declares `software-version` on it, and the library grew the mapping row + # once a producer valued the v1.0 half. schema_1 still carries no row -- a + # version string is identity rather than a reading, the same argument as the + # `mid.*` and `panel.*` card reads above -- so this stays an exemption + # rather than becoming a declaration. + Producibility.SCHEMA_0_ONLY: 11, # +2 with the shed forecast: the two live estimates, which schema_1 maps and # flat firmware does not publish at all. # +2 for `battery.power_w` and `battery.communication_state`, the BESS's own diff --git a/tests/test_pv_device.py b/tests/test_pv_device.py index db353cba..49e19fba 100644 --- a/tests/test_pv_device.py +++ b/tests/test_pv_device.py @@ -781,11 +781,14 @@ def test_the_firmware_version_is_no_longer_an_unread_declaration() -> None: def test_the_cards_firmware_read_is_enumerated_as_a_residual() -> None: """`pv_device_info` is not an entity, so its read is an exempt residual. - `NEITHER` beside the `mid.*` and `panel.*` card reads: flat's `pv` device - class declares no firmware version, and a schema_1 metadata row carries a - unit and a datatype for a reading, which a version string is not. + `SCHEMA_0_ONLY`: flat declares `software-version` on its `pv` device class + and the library maps it, while schema_1 carries no row -- a row states a + reading's unit and datatype, and a version string is identity, the same + argument as the `mid.*` and `panel.*` card reads. One adapter short of the + both-adapters gate either way, so it stays an exemption rather than becoming + a declaration. """ - assert RESIDUAL_EXEMPT_PATHS["pv.software_version"] is Producibility.NEITHER + assert RESIDUAL_EXEMPT_PATHS["pv.software_version"] is Producibility.SCHEMA_0_ONLY assert "pv.software_version" not in declared_field_paths() diff --git a/tests/test_schema_discovery.py b/tests/test_schema_discovery.py new file mode 100644 index 00000000..0124f09d --- /dev/null +++ b/tests/test_schema_discovery.py @@ -0,0 +1,355 @@ +"""What the panel declares that nothing here reads, surfaced to a maintainer. + +`test_declared_but_unread` asks this question of the vendored capture and +answers it by experiment. It is the right check and it is fixture-bound: a real +panel that starts publishing a property fails nothing until somebody recaptures. +The adapter answers the same question at runtime, for the panel in front of the +user, and this module is the consumer half — the partition that keeps those rows +out of every curated inventory, and the diagnostics block that carries them to +whoever is triaging the issue. + +Two properties are load-bearing and both are asserted here rather than reviewed: + +**The partition.** Discovered rows arrive in the same map as curated ones. Every +inventory downstream — the producible gate, the unread set, the exemption +annotations, the unit vocabulary — reads "in an adapter's map" as "this +integration could read this", which a discovered path is not. One namespace test +applied once is what keeps that true, and `test_the_unread_inventory_is_deaf_to +_discovery` is the mutation proof that it is applied. + +**No value leaves.** A diagnostics payload goes into GitHub issues and forum +posts. `TO_REDACT` is key-based over the config entry and knows nothing about +wire property names, so nothing downstream can protect a value put in this +block. It carries declarations only, and that is checked against the capture's +own published values. +""" + +from __future__ import annotations + +import json +import pathlib +from unittest.mock import MagicMock + +from homeassistant.core import HomeAssistant +from pytest_homeassistant_custom_component.common import MockConfigEntry +from span_panel_api.models import ( + DiscoveredMetadata, + FieldMetadata, + SpanPanelSnapshot, + is_discovery_path, +) + +from custom_components.span_panel import SpanPanelRuntimeData +from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.diagnostics import async_get_config_entry_diagnostics +from custom_components.span_panel.field_paths import ( + RESIDUAL_EXEMPT_PATHS, + declared_field_paths, +) +from custom_components.span_panel.schema_validation import ( + SchemaFindings, + evaluate_field_metadata, + partition, +) + +from .adapter_fixtures import ( + SCHEMA_ONE_PANEL, + schema_one_discovery, + schema_one_metadata, + schema_one_metadata_raw, + schema_one_tree, +) + +BASELINE = pathlib.Path(__file__).parent / "fixtures" / "unread_declarations_baseline.json" + +_SYNTHETIC = DiscoveredMetadata(unit="°C", datatype="float", retained=True) +"""A row for a property no firmware in the fixtures declares. + +Used to prove the partition by mutation rather than by inspection: a curated +inventory that is genuinely deaf to discovery is unchanged by this, and one that +merely happens to contain nothing surprising is not. +""" +_SYNTHETIC_PATH = "discovered.distribution-enclosure/status/enclosure-temperature" + + +# --- the partition --------------------------------------------------------- + + +def test_the_adapter_emits_discovered_rows_at_all() -> None: + """The floor. Every assertion below passes trivially against an empty report.""" + raw = schema_one_metadata_raw() + namespaced = {path for path in raw if is_discovery_path(path)} + assert namespaced, ( + "schema_1 emitted no discovered rows for the vendored tree, so nothing " + "below is being tested. If that is real, the adapter stopped emitting them." + ) + assert all(isinstance(raw[path], DiscoveredMetadata) for path in namespaced) + + +def test_partition_splits_the_map_and_loses_nothing() -> None: + raw = schema_one_metadata_raw() + curated, discovered = partition(raw) + + assert len(curated) + len(discovered) == len(raw) + assert not [path for path in curated if is_discovery_path(path)] + assert {entry.path for entry in discovered} == {path for path in raw if is_discovery_path(path)} + assert [entry.path for entry in discovered] == sorted(entry.path for entry in discovered) + + +def test_a_discovered_row_carries_the_declaration_and_the_retention() -> None: + by_path = {entry.path: entry for entry in schema_one_discovery()} + raw = schema_one_metadata_raw() + for path, entry in by_path.items(): + row = raw[path] + assert entry.datatype == row.datatype + assert entry.unit == row.unit + assert by_path["discovered.distribution-enclosure/status/time-zone"].retained is True + assert by_path["discovered.circuit/connection/count"].retained is False + + +def test_a_namespaced_row_without_the_enriched_type_still_reports() -> None: + """The namespace is the contract; the row type is the enrichment. + + An adapter distribution built against a later library could namespace a row + and carry a plain `FieldMetadata`. Dropping it would be the worst of both — + absent from the curated inventory *and* absent from the report. + """ + _curated, discovered = partition({_SYNTHETIC_PATH: FieldMetadata(unit="°C", datatype="float")}) + assert len(discovered) == 1 + assert discovered[0].retained is None + assert discovered[0].unit == "°C" + + +# --- discovered paths reach no curated inventory --------------------------- + + +def test_no_curated_inventory_names_a_discovered_path() -> None: + """The three enumerations the conformance gate and the unread gate consult.""" + for path in declared_field_paths(): + assert not is_discovery_path(path) + for path in RESIDUAL_EXEMPT_PATHS: + assert not is_discovery_path(path) + for key in json.loads(BASELINE.read_text(encoding="utf-8")): + assert not is_discovery_path(key), ( + f"{key} is a discovered path in the unread baseline. The baseline is the " + "consumer's own backlog, decided per line; discovery is a report about " + "the panel and nothing may be written into the baseline from it." + ) + + +def test_the_curated_fixture_hands_out_no_discovered_row() -> None: + """Every other test module reads this fixture, so the guarantee lives here.""" + assert not [path for path in schema_one_metadata() if is_discovery_path(path)] + + +def test_the_unread_inventory_is_deaf_to_discovery() -> None: + """The mutation proof, and the one that matters for cost #4. + + `unread` is "we produce this and render nothing from it" — ten known entries + with reasons. A discovered row landing there would bury them under whatever a + firmware release added, and would make the count depend on the panel in front + of the user rather than on this integration's backlog. + """ + raw = schema_one_metadata_raw() + before = evaluate_field_metadata(raw) + after = evaluate_field_metadata({**raw, _SYNTHETIC_PATH: _SYNTHETIC}) + + assert after.unread == before.unread + assert after.unresolved == before.unresolved + assert after.unit_mismatches == before.unit_mismatches + assert _SYNTHETIC_PATH in {entry.path for entry in after.discovered} + assert len(after.discovered) == len(before.discovered) + 1 + + +def test_a_discovered_row_raises_no_unit_mismatch() -> None: + """A wire unit is not a claim about any sensor we declare. + + `°C` matches no sensor's declared unit, so an unpartitioned map would report + it as a mismatch and, downstream, as a Repair the user cannot act on. + """ + findings = evaluate_field_metadata({**schema_one_metadata_raw(), _SYNTHETIC_PATH: _SYNTHETIC}) + assert not [m for m in findings.unit_mismatches if is_discovery_path(m.field_path)] + + +# --- the diagnostics block ------------------------------------------------- + + +def _entry(findings: SchemaFindings | None) -> MockConfigEntry: + coordinator = MagicMock() + coordinator.data = _snapshot() + coordinator.panel_offline = False + coordinator.last_update_success = True + coordinator.schema_findings = findings + entry = MockConfigEntry(domain=DOMAIN, title="SPAN Panel", unique_id="example-40t-001") + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, panel_device_id="panel-device-id" + ) + return entry + + +def _snapshot() -> SpanPanelSnapshot: + from .adapter_fixtures import schema_one_snapshot + + return schema_one_snapshot() + + +async def test_diagnostics_carries_the_discovery_report(hass: HomeAssistant) -> None: + """The block a maintainer reads off an issue attachment.""" + findings = evaluate_field_metadata(schema_one_metadata_raw()) + result = await async_get_config_entry_diagnostics(hass, _entry(findings)) + + block = result["schema_discovery"] + assert block["available"] is True + assert block["count"] == len(findings.discovered) + assert block["count"] > 0 + assert block["properties"][0] == { + "path": "discovered.circuit/connection/count", + "datatype": "integer", + "unit": None, + "retained": False, + } + assert {key for row in block["properties"] for key in row} == { + "path", + "datatype", + "unit", + "retained", + } + + +async def test_diagnostics_says_unavailable_rather_than_empty(hass: HomeAssistant) -> None: + """No metadata yet is a real state on a reconnect, and it is not "nothing to report".""" + block = (await async_get_config_entry_diagnostics(hass, _entry(None)))["schema_discovery"] + assert block == {"available": False, "count": 0, "properties": []} + + +async def test_no_published_value_reaches_the_discovery_block(hass: HomeAssistant) -> None: + """The privacy constraint, checked against the capture's own values. + + Diagnostics leave the house. `TO_REDACT` is key-based over the config entry + and knows nothing about wire property names, so a value added here would be + published verbatim — including the postal code and the time zone, which the + reference panel does publish and this integration deliberately does not + surface. + """ + tree = schema_one_tree() + published = { + value + for topics in tree.values() + for topic, value in topics.items() + if not topic.startswith("$") and value + } + # The declaration vocabulary a row is allowed to be built from: device type + # tails, node ids and property ids all appear inside a `$description`. A + # value that is also one of those cannot be told apart from its declaration, + # and is excluded rather than scanned for. + vocabulary: set[str] = set() + for topics in tree.values(): + for token in json.dumps(json.loads(topics["$description"])).replace('"', " ").split(): + vocabulary.update(token.split(".")) + vocabulary.update(token.split(",")) + vocabulary.add(token) + + findings = evaluate_field_metadata(schema_one_metadata_raw()) + block = (await async_get_config_entry_diagnostics(hass, _entry(findings)))["schema_discovery"] + + # `retained` is scanned by type rather than by content: it is a bool, so it + # has no room for a value, and its JSON rendering collides with the literal + # "false" a boolean property publishes. The three string fields are where a + # value could actually appear, and they are what the scan reads. + strings = [ + text + for row in block["properties"] + for text in (row["path"], row["datatype"], row["unit"]) + if isinstance(text, str) + ] + leaked = sorted( + value for value in published - vocabulary if any(value in text for text in strings) + ) + assert not leaked, f"published values reached the diagnostics discovery block: {leaked}" + for row in block["properties"]: + assert isinstance(row["retained"], bool) + assert row["path"].startswith("discovered.") + + +async def test_the_discovery_block_creates_nothing_a_user_sees(hass: HomeAssistant) -> None: + """Maintainer-facing only: no path here is one an entity reads. + + The line between step 1 and step 3. A discovered path becoming an entity's + source would mean adoption shipped by accident, and adoption is deliberately + not built — its costs (notice aggregation, a denylist, the accumulator + register) are unsettled. + """ + readable = declared_field_paths() | frozenset(RESIDUAL_EXEMPT_PATHS) + for entry in schema_one_discovery(): + assert entry.path not in readable + + +# --- it bites in both directions ------------------------------------------- + + +def _tree_declaring( + node: str, property_id: str, definition: dict[str, str], value: str +) -> dict[str, dict[str, str]]: + tree = schema_one_tree() + description: dict[str, dict[str, dict[str, dict[str, object]]]] = json.loads( + tree[SCHEMA_ONE_PANEL]["$description"] + ) + description["nodes"][node]["properties"][property_id] = dict(definition) + tree[SCHEMA_ONE_PANEL]["$description"] = json.dumps(description) + tree[SCHEMA_ONE_PANEL][f"{node}/{property_id}"] = value + return tree + + +def _discovery_for(tree: dict[str, dict[str, str]]) -> dict[str, dict[str, object]]: + from ebus_sdk.homie import DiscoveredDevice + from span_panel_api_schema_1.field_metadata import build_field_metadata + + devices: list[DiscoveredDevice] = [] + for device_id, topics in tree.items(): + device = DiscoveredDevice(device_id, "ebus") + device.update_description(topics["$description"]) + device.update_state(topics.get("$state", "ready")) + for topic, value in topics.items(): + if topic.startswith("$"): + continue + node, _, prop = topic.partition("/") + if prop: + device.update_property(node, prop, value) + devices.append(device) + findings = evaluate_field_metadata(build_field_metadata(devices)) + return { + entry.path: {"datatype": entry.datatype, "unit": entry.unit, "retained": entry.retained} + for entry in findings.discovered + } + + +def test_a_property_the_panel_adds_shows_up_with_its_declaration() -> None: + """A firmware release that starts declaring something reaches the report. + + This is the whole of what step 1 buys over the fixture-bound gate: no + recapture, no release, and the maintainer sees it on the next attachment. + """ + tree = _tree_declaring( + "status", + "enclosure-temperature", + {"name": "Enclosure temperature", "datatype": "float", "unit": "°C"}, + "41.5", + ) + reported = _discovery_for(tree) + assert reported[_SYNTHETIC_PATH] == {"datatype": "float", "unit": "°C", "retained": True} + assert "41.5" not in json.dumps(reported) + + +def test_a_property_that_becomes_read_leaves_the_report() -> None: + """The acceptance criterion for acting on a discovered row. + + Adding a `_PROPERTY_FIELD_MAP` row in the library is what a maintainer does + next, and the row leaving this report is how they know it landed. Proved by + the property that already has one: `status/cloud-connection` is declared, + mapped, and absent from the report, while `status/postal-code` beside it on + the same node is declared, unmapped, and present. + """ + reported = _discovery_for(schema_one_tree()) + assert "discovered.distribution-enclosure/status/postal-code" in reported + assert "discovered.distribution-enclosure/status/cloud-connection" not in reported + assert "panel.vendor_cloud" in schema_one_metadata() diff --git a/uv.lock b/uv.lock index b94ffd87..f765a5cb 100644 --- a/uv.lock +++ b/uv.lock @@ -2523,9 +2523,9 @@ dev = [ [package.metadata] requires-dist = [ { name = "homeassistant", specifier = "==2026.8.0" }, - { name = "span-panel-api", editable = "../../span/span-panel-api-p2" }, - { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api-p2/packages/schema-0" }, - { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api-p2/packages/schema-1" }, + { name = "span-panel-api", editable = "../../span/span-panel-api-p3" }, + { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api-p3/packages/schema-0" }, + { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api-p3/packages/schema-1" }, ] [package.metadata.requires-dev] @@ -2551,7 +2551,7 @@ dev = [ [[package]] name = "span-panel-api" version = "3.0.0b5" -source = { editable = "../../span/span-panel-api-p2" } +source = { editable = "../../span/span-panel-api-p3" } dependencies = [ { name = "httpx" }, { name = "paho-mqtt" }, @@ -2578,8 +2578,8 @@ dev = [ { name = "pytest-cov" }, { name = "radon" }, { name = "ruff", specifier = ">=0.15.5" }, - { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api-p2/packages/schema-0" }, - { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api-p2/packages/schema-1" }, + { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api-p3/packages/schema-0" }, + { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api-p3/packages/schema-1" }, { name = "twine", specifier = ">=7.0" }, { name = "types-pyyaml", specifier = ">=6.0.12.20250915" }, { name = "vulture", specifier = ">=2.14" }, @@ -2588,18 +2588,18 @@ dev = [ [[package]] name = "span-panel-api-schema-0" version = "1.0.0b4" -source = { editable = "../../span/span-panel-api-p2/packages/schema-0" } +source = { editable = "../../span/span-panel-api-p3/packages/schema-0" } dependencies = [ { name = "span-panel-api" }, ] [package.metadata] -requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-api-p2" }] +requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-api-p3" }] [[package]] name = "span-panel-api-schema-1" version = "0.1.0b5" -source = { editable = "../../span/span-panel-api-p2/packages/schema-1" } +source = { editable = "../../span/span-panel-api-p3/packages/schema-1" } dependencies = [ { name = "ebus-sdk" }, { name = "span-panel-api" }, @@ -2608,7 +2608,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "ebus-sdk", specifier = ">=0.19.0,<1.0" }, - { name = "span-panel-api", editable = "../../span/span-panel-api-p2" }, + { name = "span-panel-api", editable = "../../span/span-panel-api-p3" }, ] [[package]] From 8c63d2b95ae142a95f789e5b7c501af94f86e040 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:42:55 -0700 Subject: [PATCH 065/345] feat(adoption): surface devices this integration models nothing for The panel is a hub for whatever plugs into it and the eBus schema is vendor-extensible, so a device type nobody modelled is an expected arrival. It used to produce no device, no entity and no sign it was there. Adoption is scoped to a device. A new property on a modelled device stays a curation task, because that is where the judgement lives about entity versus attribute versus device-card field, and adopting it would spend an entity_id permanently on a machine-derived shape before anyone made that call. No adopted entity carries a state_class. It is not derivable from the wire, a wrong one corrupts long-term statistics irreversibly, and enrolling properties nobody asked for is a permanent write to every recorder database. A user who wants statistics wraps the entity themselves. info resolves to the device card and connection to the device link, keyed on the node rather than on property names because the catalogs carry no marker for a device reference. An adopted device freezes its identity anchor at first sighting, with the registry as the memory: a late serial, or a wire id that moves when a serial appears, would otherwise read as a device replacement. The notice counts an adopted device rather than listing its entities, and diagnostics carry declarations and never values. Controls are classified and not yet built: a generic property write would put a new member on SchemaAdapter, required of every adapter package. Diagnostics count what is waiting on it. --- CHANGELOG.md | 28 ++ custom_components/span_panel/adoption.py | 400 ++++++++++++++++++ custom_components/span_panel/binary_sensor.py | 17 +- custom_components/span_panel/diagnostics.py | 80 ++++ .../span_panel/schema_repairs.py | 52 ++- custom_components/span_panel/sensor.py | 15 +- custom_components/span_panel/util.py | 18 + tests/fixtures/README.md | 43 +- tests/test_adoption.py | 350 +++++++++++++++ tests/test_diagnostics.py | 1 + websocket-api.md | 16 +- 11 files changed, 981 insertions(+), 39 deletions(-) create mode 100644 custom_components/span_panel/adoption.py create mode 100644 tests/test_adoption.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 52731a7e..e6eddae3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,34 @@ All notable changes to this project will be documented in this file. ### Added +- **A device the panel publishes that this integration has never modelled now appears, instead of appearing nowhere.** SPAN positions the panel as the hub for + whatever plugs into it and the eBus schema is explicitly vendor-extensible, so a device type nobody modelled is an expected arrival — and until now it + produced no device, no entity and no sign it was there. Such a device now gets a card of its own hanging off the panel, carrying whatever `info` it publishes, + with its readings as entities beneath it. Everything adopted arrives **disabled and diagnostic**: nothing reaches a dashboard uninvited, and the new-entity + notice names the device so you can find it. +- **Devices this integration does model are left alone, deliberately.** A new property on a circuit, the battery, a charger or the panel is not adopted — it is + curated in a release, because that is where the judgement lives about whether it should be an entity, an attribute, or a line on a device card. Automatic + adoption would spend an `entity_id` permanently on a machine-derived shape before anyone made that call. The sixteen Power Control System properties that + curation collapsed into one entity and thirteen attributes are the worked example of what a rule cannot produce. +- **Nothing adopted enters long-term statistics, and that is a decision rather than an omission.** No adopted entity carries a `state_class`. It is not declared + on the wire and cannot be derived from one — this integration ships `feedthroughEnergyProducedWh` as `TOTAL` beside `mainMeterEnergyProducedWh` as + `TOTAL_INCREASING`, same unit and same device class — and a wrong one writes corrupt statistics that fixing the panel afterwards does not repair. Enrolling a + property nobody asked for into long-term statistics is also a permanent write to your recorder database. If you want statistics from an adopted reading, wrap + it in a template sensor, a Riemann-sum integration or a utility meter: that is your call, made on an entity you chose to enable. +- **`info` becomes the device card and `connection` becomes the device link — neither becomes entities.** A panel publishing its own build metadata and its own + wiring topology should not arrive as a handful of sensors holding version strings and opaque device ids. The split is keyed on the Homie node rather than on + property names, because the capability catalogs carry no marker for "this value is a device reference" and a hard-coded name list goes stale silently. +- **An adopted device keeps the identity it was first seen under.** A serial number arriving after the device was adopted is recorded on its card and moves + nothing, and a device adopted under its serial survives its wire id changing. Either move would read to Home Assistant as a device _replacement_ and would + take the device's entities and their history with it. +- **The new-entity notice counts an adopted device rather than listing its entities.** A vendor device declaring a dozen properties would otherwise spend the + whole notice on itself and teach you that the category is noise — which would cost you the curated additions too. +- **Diagnostics report which device types and properties were adopted, and never their values.** The same rule the declared-but-unread report follows: a + diagnostics attachment leaves the house, and the redaction that protects your config entry is key-based and knows nothing about wire property names. +- **Controls are classified but not yet built.** A property the panel accepts writes to is recognised as a switch, a select or a number, and surfaces as a + reading until the write path exists — every write this integration performs today goes through a curated, adapter-named topic, and a generic one is a change + to the adapter contract with its own version bump. Diagnostics count how many properties are waiting on it, so the decision is made on a measurement. + - **Your solar inverter gets a device of its own, on panels running the v1.0 data model.** Its vendor, model and nameplate capacity used to render as diagnostic sensors on the _panel's_ card, beside the panel's own manufacturer and model — so the card whose job is telling you which enclosure this is read as though the enclosure were an Enphase inverter. It now has a card like the battery and the chargers already do, carrying the firmware version the panel has been diff --git a/custom_components/span_panel/adoption.py b/custom_components/span_panel/adoption.py new file mode 100644 index 00000000..332e7203 --- /dev/null +++ b/custom_components/span_panel/adoption.py @@ -0,0 +1,400 @@ +"""Entities for devices the panel publishes and this integration models nothing for. + +The panel is a hub for whatever plugs into it, and the eBus schema is explicitly +vendor-extensible. A device type this integration has never modelled therefore +arrives as an expected event rather than a hypothetical one -- and until now it +arrived as nothing at all: no device, no entity, no sign it was there. + +**The unit of adoption is a device, never a property.** A new property on a device +this integration already models is a curation task with a short turnaround, and +minting an entity for it automatically would spend an `entity_id` permanently on +a shape a human would likely have chosen differently. That cost only bites where +curation is coming. On a device type nobody has modelled, no better identity is +coming, so a disabled diagnostic entity is strictly better than the silence. + +**Nothing adopted enters long-term statistics.** No adopted entity carries a +`state_class`, ever. Three reasons, and the third is the one that shapes the +module: `state_class` is not declared on the wire and is not derivable from one +(`feedthroughEnergyProducedWh` is `TOTAL` beside `mainMeterEnergyProducedWh` as +`TOTAL_INCREASING` -- same unit, same device class); a wrong one writes corrupt +statistics that fixing the producer does not repair; and enrolling a property +nobody asked for into long-term statistics is a permanent write to every +install's recorder database. A user who wants statistics from an adopted reading +can wrap it in a template sensor, a Riemann sum or a utility meter, which is +their call to make on an entity they chose to enable. + +**These entities declare no field paths.** `snapshot.adopted_devices` is outside +the curated field-path vocabulary by construction: it carries no metadata row, so +the producible gate has nothing to check it against and `residual_field_paths()` +must not collect it. That is why this module is absent from that walk's import +list, and why the classes below declare no `_residual_field_paths`. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Final + +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorEntityDescription +from homeassistant.const import EntityCategory, Platform +from homeassistant.helpers.device_registry import DeviceInfo +from span_panel_api import AdoptedDevice, AdoptedProperty, SpanPanelSnapshot + +from .const import DOMAIN +from .entity import SpanPanelEntity +from .util import ADOPTED_IDENTIFIER_TOKEN + +if TYPE_CHECKING: + from homeassistant.helpers.device_registry import DeviceRegistry + + from .coordinator import SpanPanelCoordinator + +_LOGGER = logging.getLogger(__name__) + +BOOLEAN_DATATYPE: Final = "boolean" +ENUM_DATATYPE: Final = "enum" +NUMERIC_DATATYPES: Final = frozenset({"float", "integer"}) + +DEVICE_CLASS_BY_UNIT: dict[str, SensorDeviceClass] = { + "W": SensorDeviceClass.POWER, + "kW": SensorDeviceClass.POWER, + "Wh": SensorDeviceClass.ENERGY, + "kWh": SensorDeviceClass.ENERGY, + "V": SensorDeviceClass.VOLTAGE, + "A": SensorDeviceClass.CURRENT, + "Hz": SensorDeviceClass.FREQUENCY, + "VA": SensorDeviceClass.APPARENT_POWER, + "var": SensorDeviceClass.REACTIVE_POWER, + "°C": SensorDeviceClass.TEMPERATURE, + "°F": SensorDeviceClass.TEMPERATURE, + "s": SensorDeviceClass.DURATION, +} +"""Units this integration is willing to claim a device class for, enumerated. + +Enumerated rather than inferred, and the omissions are the point. `%` is absent +because its uses in this vocabulary are not one class -- a state of charge, a +confidence, a duty cycle -- and guessing `BATTERY` for all of them mislabels the +rest. A unit outside this map yields **no** device class rather than a guess: an +unlabelled reading is honest, a mislabelled one is not. + +Note what is *not* here: a `state_class`. A device class is a display decision +and a wrong one is fixed by a line in the next release. A state class writes +long-term statistics, and a wrong one is not repaired by fixing it afterwards. +""" + + +def classify(declaration: AdoptedProperty) -> Platform: + """Return the platform a declared property surfaces on. + + Driven by the declaration, in the order the declaration constrains it: + + | Declaration | Platform | + | --- | --- | + | `boolean`, settable | `SWITCH` | + | `boolean` | `BINARY_SENSOR` | + | `enum`, settable, with a `format` | `SELECT` | + | numeric, settable, with a `format` | `NUMBER` | + | anything else | `SENSOR` | + + **A settable property with no usable value domain falls back to a reading, + and that is not caution.** A select with no option list and a number with no + bounds are not safer controls; they are broken ones. `format` is where Homie + carries the domain, so its absence is the absence of the thing a control + needs. + + Disabled-by-default is what gates a control, not read-only. Enabling an + entity is a deliberate act and commanding it is a second one, the panel + authorises the write regardless of what is created here, and this + integration already ships switches that open and close breakers. + """ + settable_with_domain = declaration.settable and bool(declaration.format) + if declaration.datatype == BOOLEAN_DATATYPE: + return Platform.SWITCH if declaration.settable else Platform.BINARY_SENSOR + if declaration.datatype == ENUM_DATATYPE and settable_with_domain: + return Platform.SELECT + if declaration.datatype in NUMERIC_DATATYPES and settable_with_domain: + return Platform.NUMBER + return Platform.SENSOR + + +CONTROL_PLATFORMS: Final = frozenset({Platform.SWITCH, Platform.SELECT, Platform.NUMBER}) +"""Platforms `classify` names that this integration cannot yet construct. + +Not a reversal of the rule -- `classify` is the rule and it is complete. A +control needs a write, and every write this integration performs goes through a +curated, adapter-named topic: `set_circuit_relay`, `set_circuit_priority`, +`set_evse_charge_limit`. There is no generic property write, and adding one would +put a new member on `SchemaAdapter`, whose required set is derived from the +protocol itself -- so it would be required of every adapter package and would +invalidate built adapter wheels. + +That is a contract change and belongs in its own one, with its own version bump. +Until then a property that classifies as a control surfaces as a reading, and +`adopted_control_count` is what makes the gap countable rather than invisible. +""" + + +def adopted_anchor(device: AdoptedDevice) -> str: + """Return the identity this device would be adopted under, before any freezing. + + The serial when the device publishes one, because the specification is + explicit that consumers correlate representations of a physical device by + `info/serial-number` and never by device id -- ids are opaque, and a proxied + id is `{proxier-id}-{proxied-id}`, so the same hardware carries different + ids under different enclosures by design. + + The wire id otherwise, as this panel's local handle. + + This is only the *candidate*. What an install actually uses is frozen at + first sighting; see `resolve_identifier`. + """ + return device.serial_number or device.device_id + + +def adopted_identifier(panel_serial: str, anchor: str) -> str: + """Return a registry identifier for one adopted device.""" + return f"{panel_serial}_{ADOPTED_IDENTIFIER_TOKEN}_{anchor}" + + +def resolve_identifier(registry: DeviceRegistry, panel_serial: str, device: AdoptedDevice) -> str: + """Return the identifier this install already uses for this device, or a new one. + + **An adopted device freezes its identity anchor at first sighting.** Both + candidate spellings are looked up before either is minted, because both + drift in practice and each covers the other's case: + + - a serial arriving *after* adoption would move the device from its wire id + onto the serial, and + - a producer that derives its wire id from a serial moves the id itself when + the serial appears -- which is why this repository holds PV's + `info/serial-number` unvalued, since publishing it moves the PV device + from `-pv-1` to `-`. + + Either move is a device *replacement* to the registry, taking the device's + entities and their history with it. Whatever was seen first is what is kept; + a better anchor arriving later is recorded on the card and changes nothing. + + The registry is the memory, so this needs no new persistence: a device that + exists was adopted before, and one that does not is being adopted now. + """ + for candidate in (device.device_id, device.serial_number): + if candidate is None: + continue + identifier = adopted_identifier(panel_serial, candidate) + if registry.async_get_device(identifiers={(DOMAIN, identifier)}) is not None: + return identifier + return adopted_identifier(panel_serial, adopted_anchor(device)) + + +def adopted_device_info( + identifier: str, + device: AdoptedDevice, + *, + panel_device_id: str, +) -> DeviceInfo: + """Device card for an adopted device, from its `info` node. + + The same reading `bess_device_info` has done since v1.0, applied to a device + nobody modelled. `info` describes the thing rather than reporting a reading, + so it lands here and never as entities -- a panel publishing its own build + metadata should not arrive as a handful of string sensors. + + `name` falls back to the wire vocabulary because there is nothing better: an + adopted device has no translation key until somebody curates it, which is + the main thing curating it is for. + """ + return DeviceInfo( + identifiers={(DOMAIN, identifier)}, + name=device.name or _humanised(device.device_type.rsplit(".", 1)[-1]), + manufacturer=device.vendor_name or "Unknown", + model=device.model or _humanised(device.device_type.rsplit(".", 1)[-1]), + serial_number=device.serial_number, + sw_version=device.software_version, + hw_version=device.hardware_version, + via_device_id=panel_device_id, + ) + + +def _humanised(wire_token: str) -> str: + """`backup-generator` -> `Backup Generator`, for a name with no translation. + + Deliberately plain. An adopted entity renders from wire vocabulary until it + is promoted, and dressing that up would disguise which entities are curated + and which are waiting to be. + """ + return wire_token.replace("-", " ").replace("_", " ").title() + + +class AdoptedEntity(SpanPanelEntity): + """Base for an entity built from a declaration rather than from a description. + + Disabled and diagnostic without exception. Adoption's job is to make a device + reachable, not to put it on somebody's dashboard: the user decides what is + worth enabling, having seen the device exists. + """ + + _attr_entity_registry_enabled_default = False + _attr_entity_category: EntityCategory | None = EntityCategory.DIAGNOSTIC + + def __init__( + self, + coordinator: SpanPanelCoordinator, + identifier: str, + device: AdoptedDevice, + declaration: AdoptedProperty, + *, + panel_device_id: str, + ) -> None: + """Bind this entity to one property of one adopted device.""" + super().__init__(coordinator) + self._device_wire_id = device.device_id + self._declaration_path = declaration.path + self._attr_unique_id = ( + f"span_{identifier}_{declaration.node_id}_{declaration.property_id}".replace("-", "_") + ) + self._attr_name = _humanised(declaration.property_id) + self._attr_device_info = adopted_device_info( + identifier, device, panel_device_id=panel_device_id + ) + + def _published(self) -> str | None: + """Return this property's current value, or None when the panel publishes none. + + Read back out of the snapshot each time rather than captured at + construction: the device is matched by its wire id, so a device that + leaves the tree and returns keeps reporting through the same entity. + """ + snapshot: SpanPanelSnapshot = self.coordinator.data + for device in snapshot.adopted_devices: + if device.device_id != self._device_wire_id: + continue + for declaration in device.properties: + if declaration.path == self._declaration_path: + return declaration.value + return None + + +class AdoptedSensor(AdoptedEntity, SensorEntity): + """A reading from an adopted device, with no `state_class`.""" + + def __init__( + self, + coordinator: SpanPanelCoordinator, + identifier: str, + device: AdoptedDevice, + declaration: AdoptedProperty, + *, + panel_device_id: str, + ) -> None: + """Take the unit and device class from what the panel declared.""" + super().__init__( + coordinator, identifier, device, declaration, panel_device_id=panel_device_id + ) + self.entity_description = SensorEntityDescription( + key=declaration.path, + device_class=DEVICE_CLASS_BY_UNIT.get(declaration.unit or ""), + native_unit_of_measurement=declaration.unit, + ) + self._attr_name = _humanised(declaration.property_id) + + @property + def native_value(self) -> str | float | None: + """Return the published value, parsed to a number only where one is declared. + + A declared numeric that arrives unparseable is reported as `None` rather + than as its raw text: the entity has a unit and a device class, and + putting a string behind those would be a worse lie than reporting + nothing. + """ + raw = self._published() + if raw is None: + return None + if self.entity_description.native_unit_of_measurement is None: + return raw + try: + return float(raw) + except ValueError: + _LOGGER.debug( + "Adopted %s published %r, which is not a number", self._declaration_path, raw + ) + return None + + +class AdoptedBinarySensor(AdoptedEntity, BinarySensorEntity): + """A declared `boolean` from an adopted device that the panel does not accept writes to.""" + + @property + def is_on(self) -> bool | None: + """Homie spells a boolean `true`/`false`; anything else is not an answer.""" + raw = self._published() + if raw is None: + return None + lowered = raw.strip().lower() + if lowered in ("true", "false"): + return lowered == "true" + return None + + +def adopted_control_count(snapshot: SpanPanelSnapshot) -> int: + """How many declared properties would be controls if this could build them. + + Zero on every panel that publishes no settable property on an unmodelled + device, which is every panel seen so far. Non-zero is the signal that the + generic write path `CONTROL_PLATFORMS` describes is worth its contract bump, + measured rather than assumed. + """ + return sum( + 1 + for device in snapshot.adopted_devices + for declaration in device.properties + if classify(declaration) in CONTROL_PLATFORMS + ) + + +def create_adopted_sensors( + coordinator: SpanPanelCoordinator, + snapshot: SpanPanelSnapshot, + registry: DeviceRegistry, + *, + panel_device_id: str, +) -> list[AdoptedSensor]: + """Every adopted property that is not a declared boolean. + + Properties that classify as a control land here too, as readings, until the + write path exists -- see `CONTROL_PLATFORMS`. + """ + return [ + AdoptedSensor(coordinator, identifier, device, declaration, panel_device_id=panel_device_id) + for device, identifier in _adopted(snapshot, registry) + for declaration in device.properties + if classify(declaration) != Platform.BINARY_SENSOR + ] + + +def create_adopted_binary_sensors( + coordinator: SpanPanelCoordinator, + snapshot: SpanPanelSnapshot, + registry: DeviceRegistry, + *, + panel_device_id: str, +) -> list[AdoptedBinarySensor]: + """Every adopted property declared `boolean` and not settable.""" + return [ + AdoptedBinarySensor( + coordinator, identifier, device, declaration, panel_device_id=panel_device_id + ) + for device, identifier in _adopted(snapshot, registry) + for declaration in device.properties + if classify(declaration) == Platform.BINARY_SENSOR + ] + + +def _adopted( + snapshot: SpanPanelSnapshot, registry: DeviceRegistry +) -> list[tuple[AdoptedDevice, str]]: + """Each adopted device paired with the identifier this install uses for it.""" + return [ + (device, resolve_identifier(registry, snapshot.serial_number, device)) + for device in snapshot.adopted_devices + ] diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py index 3de76fe5..f25ce6db 100644 --- a/custom_components/span_panel/binary_sensor.py +++ b/custom_components/span_panel/binary_sensor.py @@ -14,12 +14,14 @@ BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from span_panel_api import SpanEvseSnapshot, SpanPanelSnapshot from . import SpanPanelConfigEntry +from .adoption import create_adopted_binary_sensors from .const import ( CONF_DEVICE_NAME, PANEL_STATUS, @@ -649,4 +651,17 @@ async def async_setup_entry( if evse.connected is not None: entities.append(SpanEvseBinarySensor(coordinator, EVSE_PANEL_LINK_SENSOR, evse_id)) - async_add_entities(entities) + # Declared booleans on devices this integration models nothing for. Disabled + # and diagnostic, so a panel that gains a vendor device gains no dashboard + # clutter -- only something the user can find and enable. + async_add_entities( + [ + *entities, + *create_adopted_binary_sensors( + coordinator, + snapshot, + dr.async_get(hass), + panel_device_id=config_entry.runtime_data.panel_device_id, + ), + ] + ) diff --git a/custom_components/span_panel/diagnostics.py b/custom_components/span_panel/diagnostics.py index 9020bb94..811e94e2 100644 --- a/custom_components/span_panel/diagnostics.py +++ b/custom_components/span_panel/diagnostics.py @@ -7,8 +7,10 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant +from span_panel_api import SpanPanelSnapshot from . import SpanPanelConfigEntry +from .adoption import adopted_control_count, classify from .const import ( CONF_EBUS_BROKER_PASSWORD, CONF_EBUS_BROKER_USERNAME, @@ -93,6 +95,83 @@ def _discovery(findings: SchemaFindings | None) -> DiscoveryBlock: } +class AdoptedDeviceRow(TypedDict): + """One adopted device, as it appears in the payload. + + No `name`, no `serial_number`. Both are on `AdoptedDevice` and both reach the + device card, but neither answers the question this block exists to ask -- and + a vendor-set device name is free text a household chose. The type, the shape + and the counts are what a maintainer needs. + """ + + device_type: str + model: str | None + property_count: int + properties: list[AdoptedPropertyRow] + + +class AdoptedPropertyRow(TypedDict): + """One adopted property's declaration, with no value. + + The same rule `DiscoveredRow` follows and for the same reason: `TO_REDACT` is + key-based over the config entry and cannot protect a wire value put here. + `platform` is included because it is derived from the other three and is the + decision a maintainer would otherwise recompute by hand. + """ + + path: str + datatype: str + unit: str | None + settable: bool + platform: str + + +class AdoptionBlock(TypedDict): + """The `adopted_devices` section. + + `pending_controls` is the measurement that decides whether a generic write + path earns its contract bump -- how many declared properties would be + controls if one existed. Zero on every panel seen so far, and the number + stays invisible unless it is reported. + """ + + count: int + pending_controls: int + devices: list[AdoptedDeviceRow] + + +def _adoption(snapshot: SpanPanelSnapshot) -> AdoptionBlock: + """Report the devices this integration models nothing for. + + Declarations, never values -- see `AdoptedPropertyRow`. Empty on every panel + that publishes only device types this integration reads, which is every panel + seen so far, and a non-empty block is the first evidence that the schema's + vendor extensibility is being used in the field. + """ + return { + "count": len(snapshot.adopted_devices), + "pending_controls": adopted_control_count(snapshot), + "devices": [ + { + "device_type": device.device_type, + "model": device.model, + "property_count": len(device.properties), + "properties": [ + { + "path": declaration.path, + "datatype": declaration.datatype, + "unit": declaration.unit, + "settable": declaration.settable, + "platform": classify(declaration).value, + } + for declaration in device.properties + ], + } + for device in snapshot.adopted_devices + ], + } + + async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: SpanPanelConfigEntry, @@ -162,4 +241,5 @@ async def async_get_config_entry_diagnostics( "last_update_success": coordinator.last_update_success, }, "schema_discovery": _discovery(coordinator.schema_findings), + "adopted_devices": _adoption(snapshot), } diff --git a/custom_components/span_panel/schema_repairs.py b/custom_components/span_panel/schema_repairs.py index b45b7106..2b2d1c1f 100644 --- a/custom_components/span_panel/schema_repairs.py +++ b/custom_components/span_panel/schema_repairs.py @@ -31,10 +31,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers import device_registry as dr, entity_registry as er, issue_registry as ir from .const import DOMAIN, EVENT_SCHEMA_ISSUE from .schema_validation import SchemaFindings +from .util import ADOPTED_IDENTIFIER_TOKEN _LOGGER = logging.getLogger(__name__) @@ -225,6 +226,53 @@ def _label(registry_entry: er.RegistryEntry) -> str: return registry_entry.name or registry_entry.original_name or registry_entry.entity_id +def _labels(hass: HomeAssistant, new_disabled: list[er.RegistryEntry]) -> list[str]: + """Return what to call the new entities, with adopted ones counted rather than listed. + + A curated addition is a handful of entities a maintainer chose, so naming each + one is the notice doing its job. An adopted device is the opposite shape: + every property a vendor device declares becomes an entity at once, so listing + them would spend the whole notice on one device and teach the user that this + category is noise -- which would cost them the curated additions too. + + So an adopted device contributes exactly one line carrying its own count, and + the notice reads "Backup Generator (6 entities)" beside whatever else the + release added. + """ + devices = dr.async_get(hass) + adopted: dict[str, int] = {} + labels: list[str] = [] + for registry_entry in new_disabled: + device_name = _adopted_device_name(devices, registry_entry) + if device_name is None: + labels.append(_label(registry_entry)) + else: + adopted[device_name] = adopted.get(device_name, 0) + 1 + labels.extend(f"{name} ({count} entities)" for name, count in adopted.items()) + return sorted(labels) + + +def _adopted_device_name( + devices: dr.DeviceRegistry, registry_entry: er.RegistryEntry +) -> str | None: + """Return the adopted device this entity belongs to, or None when it belongs to none. + + Read off the device identifier rather than off the entity, because the entity + carries nothing that distinguishes an adopted reading from a curated one -- + by design, since an adopted entity is meant to be indistinguishable once it + is enabled. + """ + if registry_entry.device_id is None: + return None + device = devices.async_get(registry_entry.device_id) + if device is None: + return None + token = f"_{ADOPTED_IDENTIFIER_TOKEN}_" + if not any(token in identifier for _domain, identifier in device.identifiers): + return None + return device.name_by_user or device.name or registry_entry.entity_id + + @callback def async_notice_new_disabled_entities( hass: HomeAssistant, @@ -294,7 +342,7 @@ def async_notice_new_disabled_entities( _LOGGER.debug("New-entity notice %s already raised; leaving it alone", issue_id) return - labels = sorted(_label(registry_entry) for registry_entry in new_disabled) + labels = _labels(hass, new_disabled) _LOGGER.debug("Raising new-entity notice %s for %s", issue_id, labels) ir.async_create_issue( hass, diff --git a/custom_components/span_panel/sensor.py b/custom_components/span_panel/sensor.py index 7661b1cd..2d1d111e 100644 --- a/custom_components/span_panel/sensor.py +++ b/custom_components/span_panel/sensor.py @@ -6,11 +6,13 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from span_panel_api import SpanPanelSnapshot from . import SpanPanelConfigEntry +from .adoption import create_adopted_sensors from .const import ( CONF_DEVICE_NAME, ENABLE_CIRCUIT_NET_ENERGY_SENSORS, @@ -117,8 +119,19 @@ async def async_setup_entry( # Create all native sensors (panel, circuit, and battery sensors) entities = create_native_sensors(coordinator, snapshot, config_entry) + # Readings from devices this integration models nothing for. Appended + # rather than merged into `create_native_sensors`: those are curated + # descriptions and these are declarations, and the two inventories answer + # different questions -- see `adoption`. + adopted = create_adopted_sensors( + coordinator, + snapshot, + dr.async_get(hass), + panel_device_id=config_entry.runtime_data.panel_device_id, + ) + # Add all native sensor entities - async_add_entities(entities) + async_add_entities([*entities, *adopted]) # Force immediate coordinator refresh to ensure all sensors update right away await coordinator.async_request_refresh() diff --git a/custom_components/span_panel/util.py b/custom_components/span_panel/util.py index 93f6f0cb..1e0230ad 100644 --- a/custom_components/span_panel/util.py +++ b/custom_components/span_panel/util.py @@ -38,6 +38,17 @@ SUB_DEVICE_EVSE: Final = "evse" SUB_DEVICE_PV: Final = "pv" +ADOPTED_IDENTIFIER_TOKEN: Final = "adopted" +"""The infix marking a sub-device identifier as adopted rather than curated. + +An adopted device is `{panel serial}_adopted_{anchor}`, where the anchor is +whatever the device was first seen under. Kept here beside the curated kinds +because the two namespaces have to be readable apart, and the reading end below +is what would otherwise mistake one for the other: a vendor device whose id +happens to end in `pv` would classify as the solar sub-device under a suffix +rule that had never heard of adoption. +""" + def classify_sub_device_identifier(identifier: str) -> str | None: """Return the kind of sub-device an identifier names, or None if it names none. @@ -52,6 +63,13 @@ def classify_sub_device_identifier(identifier: str) -> str | None: suffix rules below, which is the only ordering that stays right whatever a panel names its nodes. """ + # Adopted devices are not a curated kind and must not be read as one. Tested + # before every suffix rule below, because the anchor that follows the token + # is vendor vocabulary: a device id ending in `pv` would otherwise classify + # as the panel's solar sub-device. + if f"_{ADOPTED_IDENTIFIER_TOKEN}_" in identifier: + return None + # Infix, not suffix: the node id follows, and it is what distinguishes one # charger from another on the same panel. if f"_{SUB_DEVICE_EVSE}_" in identifier: diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index dea51ed7..9c458c60 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -1,33 +1,26 @@ # Adapter fixtures -Real schema-adapter inputs, used by the field-path conformance tests via -`tests/adapter_fixtures.py`. They are **committed rather than generated** so the -test suite has no cross-repo dependency and CI needs no checkout of the library. -The cost of that choice is that they go stale silently — hence this file. +Real schema-adapter inputs, used by the field-path conformance tests via `tests/adapter_fixtures.py`. They are **committed rather than generated** so the test +suite has no cross-repo dependency and CI needs no checkout of the library. The cost of that choice is that they go stale silently — hence this file. ## Provenance Both are byte-identical copies from the `span-panel-api` repository: -| File here | Source in `span-panel-api` | -|---|---| -| `schema_zero_types.json` | `tests/fixtures/v2/homie_schema.json` | -| `schema_one_tree.json` | `packages/schema-1/src/.../reference_payloads/parent_child_tree.json` | +| File here | Source in `span-panel-api` | +| ------------------------ | --------------------------------------------------------------------- | +| `schema_zero_types.json` | `tests/fixtures/v2/homie_schema.json` | +| `schema_one_tree.json` | `packages/schema-1/src/.../reference_payloads/parent_child_tree.json` | -Refresh by copying them again, and keep `schema_one_tree.json` byte-identical to -its source: the library pins what that capture leaves unvalued against -panelbench's own baseline (`tests/test_reference_tree_values.py` there), so a -copy that has drifted puts these tests on a wire no producer sends. +Refresh by copying them again, and keep `schema_one_tree.json` byte-identical to its source: the library pins what that capture leaves unvalued against +panelbench's own baseline (`tests/test_reference_tree_values.py` there), so a copy that has drifted puts these tests on a wire no producer sends. -If a copy changes shape rather than content, the -loader in `tests/adapter_fixtures.py` is what needs updating — note that -`schema_one_tree.json` is a **dict keyed by device id**, whose `$description` -value is a **JSON string**, not a parsed object. +If a copy changes shape rather than content, the loader in `tests/adapter_fixtures.py` is what needs updating — note that `schema_one_tree.json` is a **dict +keyed by device id**, whose `$description` value is a **JSON string**, not a parsed object. ## Derived variants -Both are produced from `schema_one_tree.json` by dropping every device whose -parsed `$description["type"]` contains a marker. Regenerate with: +Both are produced from `schema_one_tree.json` by dropping every device whose parsed `$description["type"]` contains a marker. Regenerate with: ```bash uv run python - << 'PY' @@ -49,15 +42,11 @@ drop(".pv", "schema_one_tree_no_pv.json") PY ``` -Each must remove **exactly one** device (13 -> 12) and must retain the panel and -both lugs devices. A variant that removed more would make the conformance tests -pass for the wrong reason. Note `bess-mid` is typed `energy.ebus.device.mid` and -correctly survives the `.bess` filter. +Each must remove **exactly one** device (13 -> 12) and must retain the panel and both lugs devices. A variant that removed more would make the conformance tests +pass for the wrong reason. Note `bess-mid` is typed `energy.ebus.device.mid` and correctly survives the `.bess` filter. ## Why these exist -`schema_one_tree_batteryless.json` proves a panel with no BESS produces **no** -`battery.*` entries — hardware absence, not degradation. -`schema_one_tree_no_pv.json` proves the same for a panel that has power-flows -telemetry but no PV device, which is the case telemetry-based capability -detection gets wrong. +`schema_one_tree_batteryless.json` proves a panel with no BESS produces **no** `battery.*` entries — hardware absence, not degradation. +`schema_one_tree_no_pv.json` proves the same for a panel that has power-flows telemetry but no PV device, which is the case telemetry-based capability detection +gets wrong. diff --git a/tests/test_adoption.py b/tests/test_adoption.py new file mode 100644 index 00000000..6a3924f8 --- /dev/null +++ b/tests/test_adoption.py @@ -0,0 +1,350 @@ +"""Devices this integration models nothing for surface without disturbing curated ones. + +Three properties carry the whole design and each has a test here that fails if it +stops holding: nothing adopted enters long-term statistics, an adopted device +keeps the identity it was first seen under, and the notice counts adopted devices +rather than listing their entities. +""" + +from __future__ import annotations + +import ast +from dataclasses import replace +from pathlib import Path +from typing import TYPE_CHECKING +from unittest.mock import MagicMock + +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import EntityCategory, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +import pytest +from span_panel_api import AdoptedDevice, AdoptedProperty + +from custom_components.span_panel.adoption import ( + CONTROL_PLATFORMS, + DEVICE_CLASS_BY_UNIT, + adopted_control_count, + adopted_identifier, + classify, + create_adopted_binary_sensors, + create_adopted_sensors, + resolve_identifier, +) +from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.util import ( + ADOPTED_IDENTIFIER_TOKEN, + classify_sub_device_identifier, +) + +from .factories import SpanPanelSnapshotFactory + +if TYPE_CHECKING: + from span_panel_api import SpanPanelSnapshot + +PANEL_SERIAL = "sp3-242424-001" + + +def _property( + node_id: str = "meter", + property_id: str = "active-power", + datatype: str = "float", + unit: str | None = "W", + fmt: str | None = None, + settable: bool = False, + value: str | None = "2400", +) -> AdoptedProperty: + return AdoptedProperty( + node_id=node_id, + property_id=property_id, + datatype=datatype, + unit=unit, + format=fmt, + settable=settable, + value=value, + ) + + +def _device( + device_id: str = "generator-1", + *, + serial_number: str | None = None, + properties: tuple[AdoptedProperty, ...] = (), +) -> AdoptedDevice: + return AdoptedDevice( + device_id=device_id, + device_type="energy.ebus.device.generator", + name="Backup Generator", + model="GEN-9000", + serial_number=serial_number, + properties=properties, + ) + + +def _snapshot(*devices: AdoptedDevice) -> SpanPanelSnapshot: + """Return a complete snapshot carrying the given adopted devices. + + Built through `replace` rather than by teaching the factory a keyword, so the + factory keeps describing a curated panel and adoption stays visibly additive. + """ + return replace(SpanPanelSnapshotFactory.create_complete(serial_number=PANEL_SERIAL), adopted_devices=devices) + + +# -- The platform table ------------------------------------------------------ + + +@pytest.mark.parametrize( + ("datatype", "settable", "fmt", "expected"), + [ + ("boolean", True, None, Platform.SWITCH), + ("boolean", False, None, Platform.BINARY_SENSOR), + ("enum", True, "AUTO,MANUAL,OFF", Platform.SELECT), + ("float", True, "0:100:1", Platform.NUMBER), + ("integer", True, "0:100:1", Platform.NUMBER), + ("float", False, None, Platform.SENSOR), + ("enum", False, "AUTO,MANUAL", Platform.SENSOR), + ("string", True, None, Platform.SENSOR), + ], +) +def test_the_declaration_decides_the_platform( + datatype: str, settable: bool, fmt: str | None, expected: Platform +) -> None: + """The rule in one table, including its two fallbacks.""" + assert classify(_property(datatype=datatype, fmt=fmt, settable=settable, unit=None)) is expected + + +@pytest.mark.parametrize("datatype", ["enum", "float", "integer"]) +def test_a_settable_property_with_no_value_domain_falls_back_to_a_reading(datatype: str) -> None: + """Not caution -- the absence of the thing a control is made of. + + `format` is where Homie carries the domain. A select with no option list and + a number with no bounds are not safer controls, they are broken ones, so the + property surfaces as the reading it can still be. + """ + assert classify(_property(datatype=datatype, settable=True, fmt=None, unit=None)) is Platform.SENSOR + + +def test_a_settable_boolean_needs_no_format_because_its_domain_is_the_datatype() -> None: + """The one control whose value domain the datatype already states in full.""" + assert classify(_property(datatype="boolean", settable=True, fmt=None, unit=None)) is Platform.SWITCH + + +# -- Nothing adopted enters long-term statistics ----------------------------- + + +def test_no_adopted_sensor_carries_a_state_class(hass: HomeAssistant) -> None: + """The single most important assertion in this module. + + `state_class` is not declared on the wire and is not derivable from one: this + integration ships `feedthroughEnergyProducedWh` as TOTAL beside + `mainMeterEnergyProducedWh` as TOTAL_INCREASING, same unit and same device + class. A wrong one writes corrupt long-term statistics that fixing the + producer afterwards does not repair, so adoption classifies none of them -- + and a user who wants statistics from an adopted reading wraps it themselves. + """ + declarations = tuple( + _property(property_id=name, unit=unit, datatype="float") + for name, unit in (("active-power", "W"), ("imported-energy", "Wh"), ("exported-energy", "kWh")) + ) + entities = create_adopted_sensors( + MagicMock(data=_snapshot(_device(properties=declarations))), + _snapshot(_device(properties=declarations)), + dr.async_get(hass), + panel_device_id="panel-device-id", + ) + + assert len(entities) == 3 + assert all(entity.state_class is None for entity in entities) + + +def test_no_state_class_is_set_anywhere_in_the_module() -> None: + """The rule stated once more, against the syntax rather than an instance. + + An instance test only covers the paths a test constructs, so a future branch + that set a state class on some platform nobody instantiated here would pass + every test above. Read as syntax rather than as text because the module's own + prose has to be free to explain why the rule exists. + """ + from custom_components.span_panel import adoption + + tree = ast.parse(Path(adoption.__file__).read_text(encoding="utf-8")) + keywords = [node.arg for node in ast.walk(tree) if isinstance(node, ast.keyword)] + targets = [node.attr for node in ast.walk(tree) if isinstance(node, ast.Attribute)] + assert "state_class" not in keywords + assert "_attr_state_class" not in targets + assert not [node.id for node in ast.walk(tree) if isinstance(node, ast.Name) and "StateClass" in node.id] + + +# -- Device class is enumerated, never inferred ------------------------------ + + +def test_a_declared_unit_this_integration_knows_gets_a_device_class(hass: HomeAssistant) -> None: + declarations = (_property(unit="W"),) + (entity,) = create_adopted_sensors( + MagicMock(data=_snapshot(_device(properties=declarations))), + _snapshot(_device(properties=declarations)), + dr.async_get(hass), + panel_device_id="panel-device-id", + ) + assert entity.device_class is SensorDeviceClass.POWER + + +@pytest.mark.parametrize("unit", ["%", "ppm", "kg"]) +def test_a_unit_outside_the_map_gets_no_device_class(hass: HomeAssistant, unit: str) -> None: + """An unlabelled reading is honest; a mislabelled one is not. + + `%` is the case that makes the rule earn its keep. Its uses in this + vocabulary are not one class -- a state of charge, a confidence, a duty cycle + -- so a rule that guessed BATTERY for all of them would mislabel most. + """ + declarations = (_property(unit=unit),) + (entity,) = create_adopted_sensors( + MagicMock(data=_snapshot(_device(properties=declarations))), + _snapshot(_device(properties=declarations)), + dr.async_get(hass), + panel_device_id="panel-device-id", + ) + assert entity.device_class is None + assert unit not in DEVICE_CLASS_BY_UNIT + + +# -- Everything adopted is disabled and diagnostic --------------------------- + + +def test_every_adopted_entity_is_disabled_and_diagnostic(hass: HomeAssistant) -> None: + """Adoption makes a device reachable; it does not put one on a dashboard.""" + declarations = (_property(), _property(node_id="relay", property_id="closed", datatype="boolean", unit=None)) + snapshot = _snapshot(_device(properties=declarations)) + coordinator = MagicMock(data=snapshot) + registry = dr.async_get(hass) + + entities = [ + *create_adopted_sensors(coordinator, snapshot, registry, panel_device_id="panel-device-id"), + *create_adopted_binary_sensors(coordinator, snapshot, registry, panel_device_id="panel-device-id"), + ] + + assert len(entities) == 2 + assert all(entity.entity_registry_enabled_default is False for entity in entities) + assert all(entity.entity_category is EntityCategory.DIAGNOSTIC for entity in entities) + + +def test_a_declared_boolean_becomes_a_binary_sensor_and_not_a_sensor(hass: HomeAssistant) -> None: + """The two creators partition the properties rather than both claiming one.""" + declarations = (_property(node_id="relay", property_id="closed", datatype="boolean", unit=None, value="true"),) + snapshot = _snapshot(_device(properties=declarations)) + coordinator = MagicMock(data=snapshot) + registry = dr.async_get(hass) + + assert create_adopted_sensors(coordinator, snapshot, registry, panel_device_id="p") == [] + (entity,) = create_adopted_binary_sensors(coordinator, snapshot, registry, panel_device_id="p") + assert entity.is_on is True + + +# -- Identity freezes at first sighting -------------------------------------- + + +def test_a_serial_arriving_after_adoption_does_not_move_the_device(hass: HomeAssistant) -> None: + """The failure this rule exists to prevent, in its most likely form. + + A device adopted under its wire id that later publishes `info/serial-number` + would, without freezing, be re-derived onto the serial -- which the registry + reads as a device replacement and which takes the entities and their history + with it. + """ + registry = dr.async_get(hass) + first_seen = adopted_identifier(PANEL_SERIAL, "generator-1") + entry = MagicMock() + registry.async_get_or_create = MagicMock() + registry.async_get_device = lambda identifiers: entry if (DOMAIN, first_seen) in identifiers else None + + later = _device("generator-1", serial_number="EX-0000-0001") + assert resolve_identifier(registry, PANEL_SERIAL, later) == first_seen + + +def test_a_wire_id_that_moves_keeps_a_device_adopted_under_its_serial(hass: HomeAssistant) -> None: + """The other direction, which this repository has already been bitten by. + + Producers derive a DER's id preferring a serial over a default slug, so the + wire id itself moves when a serial appears -- which is why PV's + `info/serial-number` is held unvalued. A device adopted under its serial has + to survive that. + """ + registry = dr.async_get(hass) + first_seen = adopted_identifier(PANEL_SERIAL, "EX-0000-0001") + entry = MagicMock() + registry.async_get_device = lambda identifiers: entry if (DOMAIN, first_seen) in identifiers else None + + moved = _device("panel-EX-0000-0001", serial_number="EX-0000-0001") + assert resolve_identifier(registry, PANEL_SERIAL, moved) == first_seen + + +def test_a_device_never_seen_before_is_adopted_under_its_serial_when_it_has_one(hass: HomeAssistant) -> None: + """The specification's own correlator, used where nothing is frozen yet. + + Device ids are opaque and a proxied id is `{proxier-id}-{proxied-id}`, so the + same hardware carries different ids under different enclosures by design. The + serial is what the specification says to correlate on. + """ + registry = dr.async_get(hass) + registry.async_get_device = lambda identifiers: None + + fresh = _device("generator-1", serial_number="EX-0000-0001") + assert resolve_identifier(registry, PANEL_SERIAL, fresh) == adopted_identifier(PANEL_SERIAL, "EX-0000-0001") + + +def test_a_device_with_no_serial_is_adopted_under_its_wire_id(hass: HomeAssistant) -> None: + registry = dr.async_get(hass) + registry.async_get_device = lambda identifiers: None + + assert resolve_identifier(registry, PANEL_SERIAL, _device("generator-1")) == adopted_identifier( + PANEL_SERIAL, "generator-1" + ) + + +# -- The two identifier namespaces stay readable apart ----------------------- + + +@pytest.mark.parametrize("anchor", ["generator-1", "some-pv", "an_evse_thing", "ends-in-mid", "bess"]) +def test_an_adopted_identifier_never_classifies_as_a_curated_sub_device(anchor: str) -> None: + """The anchor is vendor vocabulary and can spell anything. + + `classify_sub_device_identifier` reads the curated grammar with suffix rules, + so a vendor device id ending in `pv` would classify as the panel's solar + sub-device -- which is how a device nobody modelled would end up rendered as + one that was. + """ + identifier = adopted_identifier(PANEL_SERIAL, anchor) + assert ADOPTED_IDENTIFIER_TOKEN in identifier + assert classify_sub_device_identifier(identifier) is None + + +def test_a_curated_identifier_still_classifies(hass: HomeAssistant) -> None: + """The other direction: adding the adoption test must not blind the reader.""" + assert classify_sub_device_identifier(f"{PANEL_SERIAL}_bess") == "bess" + assert classify_sub_device_identifier(f"{PANEL_SERIAL}_pv") == "pv" + + +# -- The pending write path is counted rather than hidden -------------------- + + +def test_a_settable_property_is_counted_as_a_pending_control() -> None: + """`classify` is the complete rule; the write path is what does not exist yet. + + Every write this integration performs goes through a curated, adapter-named + topic, and a generic one would put a new member on `SchemaAdapter` -- required + of every adapter package, invalidating built wheels. That is its own change. + Counting the properties waiting on it is what decides whether it is worth one. + """ + declarations = ( + _property(datatype="boolean", settable=True, unit=None), + _property(node_id="generator", property_id="mode", datatype="enum", fmt="AUTO,OFF", settable=True, unit=None), + _property(), + ) + snapshot = _snapshot(_device(properties=declarations)) + + assert adopted_control_count(snapshot) == 2 + assert classify(declarations[0]) in CONTROL_PLATFORMS + + +def test_a_panel_with_no_adopted_device_counts_no_pending_controls() -> None: + assert adopted_control_count(_snapshot()) == 0 diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index d1748fa7..6d010a63 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -154,6 +154,7 @@ async def test_config_entry_diagnostics_omits_optional_sections_when_unavailable }, evse={}, battery=None, + adopted_devices=(), ) coordinator = MagicMock() coordinator.data = snapshot diff --git a/websocket-api.md b/websocket-api.md index 4e74b8f4..2eeb6f8f 100644 --- a/websocket-api.md +++ b/websocket-api.md @@ -109,14 +109,14 @@ relationships explicitly, keyed by circuit UUID, so the card reads a single stru #### Top Level -| Field | Type | Description | -| ------------- | ----------- | ----------------------------------------------- | -| `serial` | string | Panel serial number | -| `firmware` | string | Panel firmware version | -| `panel_size` | int or null | Total breaker spaces (e.g., 32, 40) | -| `device_id` | string | HA device registry ID (echoed from request) | -| `device_name` | string | HA device display name | -| `circuits` | object | Circuit UUID keyed map (see below) | +| Field | Type | Description | +| ------------- | ----------- | --------------------------------------------------- | +| `serial` | string | Panel serial number | +| `firmware` | string | Panel firmware version | +| `panel_size` | int or null | Total breaker spaces (e.g., 32, 40) | +| `device_id` | string | HA device registry ID (echoed from request) | +| `device_name` | string | HA device display name | +| `circuits` | object | Circuit UUID keyed map (see below) | | `sub_devices` | object | HA device ID keyed map of BESS/MID/EVSE (see below) | #### Circuit Object From 3eacbd3c538e2eaec2cd456347043ac79fa4d043 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:46:18 -0700 Subject: [PATCH 066/345] docs(developer): explain device-scoped adoption and the rules that bound it --- developer.md | 109 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 107 insertions(+), 2 deletions(-) diff --git a/developer.md b/developer.md index 3afec603..d473c1cf 100644 --- a/developer.md +++ b/developer.md @@ -221,8 +221,8 @@ the panel has published a value for the property, which is the declared-but-neve key-based over the config entry: it knows nothing about wire property names and could not protect a value added here. `test_schema_discovery` asserts that against the capture's own published values rather than leaving it to review. -This is **maintainer-facing only**. Nothing creates an entity, a Repair or a notification from it. Automatic adoption is a separate, unbuilt step whose costs — -notice aggregation, an exclusion denylist, the accumulator register — are not settled. +This is **maintainer-facing only**. Nothing creates an entity, a Repair or a notification from it — including for a device that _is_ adopted, whose properties +are reported here as declarations exactly like any other. Adoption itself is the next section. ### Why discovered rows cannot reach the curated inventories @@ -245,6 +245,111 @@ The adapter decides "read" from four enumerations of what it addresses — the m of the report. `tests/test_schema_one_discovery.py` in the library runs the same republish-and-diff experiment this gate uses and holds every entry to it in both directions, so the report means "nothing reads this" rather than "nobody wrote it down". +## Adopting a device this integration models nothing for + +The section above is about properties on devices we already read. This one is about a device type nobody modelled at all — a vendor's generator, heat pump or +second inverter, which the eBus schema explicitly permits. Such a device used to produce nothing: no device, no entity, no sign it was there. + +### The rule + +**The unit of adoption is a device, never a property.** + +| What arrives | What happens | +| ------------------------------------------------ | -------------------------------------------------------------------------------- | +| A device type `MODELLED_TYPES` does not name | **Adopt.** One sub-device, its properties surfaced beneath it. | +| A new node or property on a device we _do_ model | **Do not adopt.** It lands in `schema_discovery`; curate it in the next release. | +| A new property on a device already adopted | Adopt, with its siblings. | + +The asymmetry is where the cost calculus actually points. An adopted entity's id is machine-derived and permanent once it registers, which is only a loss where +curation is coming. On a type nobody modelled, no better id is coming and the alternative is silence. + +Extra instances of a modelled type are **not** adopted. A second BESS is a multiplicity limitation of the snapshot model, not an unmodelled device, and adopting +it would stand a machine-named card beside the curated Battery describing the same hardware. The gap stays visible as a gap. + +### Inside an adopted device, the node decides the destination + +Keyed on the Homie node — what the eBus vocabulary defines — rather than on property names: + +- **`info/*` → device-card fields.** `model`, `serial-number`, `firmware-version`, `hardware-version`, `vendor-name`. The whole node, not just the five the card + reads: dropping only the recognised ones would surface `info/nominal-power` as a string sensor the moment a vendor declared one. +- **`connection/*` → the device link.** Topology, which is `via_device`. +- **Everything else → entities**, `EntityCategory.DIAGNOSTIC` and disabled by default. + +Why by node: the capability catalogs carry **no marker** for "this value is a device reference", so the only alternative is a hard-coded property-name list — +and such a list goes stale silently. `ebus-sdk`'s own `topology.py` covers `feeds-device-id` and `fed-by-device-id` and omits `grid-forming-entity`, which lives +on the `grid` capability. A node cannot go stale that way. + +### Nothing adopted enters long-term statistics + +No adopted entity carries a `state_class`. `test_no_state_class_is_set_anywhere_in_the_module` reads `adoption.py` as syntax and fails if one ever appears. + +Three reasons, and they are independent: + +1. It is not declared on the wire and is not derivable from one. This integration ships `feedthroughEnergyProducedWh` as `TOTAL` beside + `mainMeterEnergyProducedWh` as `TOTAL_INCREASING` — same unit, same device class, opposite classification. +2. A wrong one writes corrupt long-term statistics, and fixing the producer afterwards does not repair them. +3. Enrolling a property nobody asked for into long-term statistics is a permanent write to every install's recorder database. + +A user who wants statistics from an adopted reading wraps it in a template sensor, a Riemann-sum integration or a utility meter. That is their call, made on an +entity they chose to enable. + +`device_class` is enumerated in `DEVICE_CLASS_BY_UNIT` rather than inferred. A unit outside the map gets **no** device class — `%` is deliberately absent, +because its uses here are a state of charge, a confidence and a duty cycle, and no single class is right for all of them. + +### Identity freezes at first sighting + +`resolve_identifier` looks up **both** candidate spellings — `{panel serial}_adopted_{wire id}` and `{panel serial}_adopted_{serial}` — before minting either, +and keeps whichever already exists. Both drift in practice: + +- a serial arriving _after_ adoption would move the device off its wire id, and +- a producer that derives its wire id from a serial moves the id itself when the serial appears, which is why this repository holds PV's `info/serial-number` + unvalued. + +Either move reads to Home Assistant as a device **replacement**, taking the entities and their history. The device registry is the memory, so this needs no new +persistence. + +`classify_sub_device_identifier` returns `None` for any identifier carrying the `adopted` token, tested before its suffix rules — the anchor is vendor +vocabulary, and a device id ending in `pv` would otherwise classify as the panel's solar sub-device. + +### Controls are classified but not built + +`classify` implements the full rule, including the three control platforms: + +| Declaration | Platform | +| ---------------------------------- | --------------- | +| `boolean`, settable | `SWITCH` | +| `boolean` | `BINARY_SENSOR` | +| `enum`, settable, with a `format` | `SELECT` | +| numeric, settable, with a `format` | `NUMBER` | +| anything else | `SENSOR` | + +A settable property with no `format` falls back to a reading because `format` is the value domain: a select with no option list and a number with no bounds are +broken controls, not safe ones. + +The three control platforms are in `CONTROL_PLATFORMS` and are **not constructed yet**. Every write this integration performs goes through a curated, +adapter-named topic, and a generic property write would put a new member on `SchemaAdapter` — whose required set is derived from the protocol itself, so it +would be required of every adapter package and would invalidate built adapter wheels. That is a contract change with its own version bump. + +`adopted_control_count` reports how many declared properties are waiting on it, in diagnostics under `adopted_devices.pending_controls`, so the decision is made +on a measurement rather than a guess. + +### The notice counts devices, not entities + +Adopted entities are disabled, so they reach the user only through `async_notice_new_disabled_entities`. That notice lists curated additions individually and +collapses each adopted device to one line with a count — `Backup Generator (6 entities)`. A vendor device declaring a dozen properties would otherwise spend the +whole notice on itself and teach the user that the category is noise, which would cost them the curated additions too. + +### Diagnostics + +`adopted_devices` in the diagnostics payload carries the device type, model, property paths, datatypes, units, `settable` flags and the platform each would +take. **No values, no device name, no serial.** Same rule as `schema_discovery` and for the same reason: `TO_REDACT` is key-based over the config entry and +cannot protect a wire value put there. + +### Adopted entities declare no field paths + +`snapshot.adopted_devices` is outside the curated field-path vocabulary by construction — it carries no metadata row, so the producible gate has nothing to +check it against. `adoption.py` is therefore absent from `residual_field_paths()`'s import list, and its entity classes declare no `_residual_field_paths`. + ## Linting and Type Checking ```bash From a3f8a4d8ea616cf3ade4115dfb50c5c6f0dea731 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:31:29 -0700 Subject: [PATCH 067/345] feat(adoption): build the controls, authorised by the snapshot not by arguments A settable property on an adopted device now becomes a switch, a select or a number rather than surfacing as a reading. Disabled and diagnostic like every other adopted entity, so a control exists only where a user enables one. The write goes through set_adopted_property, which resolves the property against the current snapshot's adopted devices and publishes to the set topic that property carries. No topic is accepted from the caller. The alternative considered and rejected was a set_property_topic member on SchemaAdapter. It would have put every curated control one argument away, and two of them do real work on the way out: the islanding assertion translates its value, and the charge ceiling refuses one above the commissioned limit. It would also have been required of every adapter package, so an install with an older adapter wheel would fail at discovery rather than losing one feature. All five creators share one partition function so classify stays the only place a platform is decided, asserted as a partition rather than per platform. Nothing is translated or clamped on the way out. The declaration is all this integration knows about an adopted property, and a NUMBER on an integer property publishes 45 rather than 45.0. --- CHANGELOG.md | 14 +- custom_components/span_panel/adoption.py | 319 +++++++++++++++++--- custom_components/span_panel/diagnostics.py | 11 +- custom_components/span_panel/manifest.json | 6 +- custom_components/span_panel/number.py | 36 ++- custom_components/span_panel/select.py | 17 +- custom_components/span_panel/switch.py | 17 +- developer.md | 39 ++- pyproject.toml | 6 +- tests/test_adoption.py | 138 ++++++++- uv.lock | 6 +- 11 files changed, 522 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6eddae3..cc3d1b9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,9 +30,17 @@ All notable changes to this project will be documented in this file. whole notice on itself and teach you that the category is noise — which would cost you the curated additions too. - **Diagnostics report which device types and properties were adopted, and never their values.** The same rule the declared-but-unread report follows: a diagnostics attachment leaves the house, and the redaction that protects your config entry is key-based and knows nothing about wire property names. -- **Controls are classified but not yet built.** A property the panel accepts writes to is recognised as a switch, a select or a number, and surfaces as a - reading until the write path exists — every write this integration performs today goes through a curated, adapter-named topic, and a generic one is a change - to the adapter contract with its own version bump. Diagnostics count how many properties are waiting on it, so the decision is made on a measurement. +- **A property an adopted device accepts writes to becomes a control, not just a reading.** A declared `boolean` becomes a switch, an `enum` with its option + list becomes a select, and a number with its `min:max:step` becomes a number entity — all disabled and diagnostic like every other adopted entity, so a + control appears only if you go and enable it. A settable property that declares no value domain stays a reading: a select with no options and a number with no + bounds are broken controls, not safe ones. +- **The write cannot reach a device this integration does model, by construction.** It is authorised by looking the property up in the current snapshot rather + than by its arguments, and a modelled device produces no adopted record to find. That matters because the curated controls do real work on the way out — the + islanding assertion translates its value, and the EV charge limit refuses one above what your charger was commissioned for — and a generic write would route + around both. +- **Your panel stays the authority on the value.** Nothing is translated or clamped on the way out: this integration knows an adopted property's declaration and + nothing else, so inventing a bound would be inventing a fact about your hardware. The control constrains you to what the device declared, and the panel + accepts or refuses. - **Your solar inverter gets a device of its own, on panels running the v1.0 data model.** Its vendor, model and nameplate capacity used to render as diagnostic sensors on the _panel's_ card, beside the panel's own manufacturer and model — so the card whose job is telling you which enclosure this is read as though the diff --git a/custom_components/span_panel/adoption.py b/custom_components/span_panel/adoption.py index 332e7203..b0a1a0c8 100644 --- a/custom_components/span_panel/adoption.py +++ b/custom_components/span_panel/adoption.py @@ -36,7 +36,10 @@ from typing import TYPE_CHECKING, Final from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.number import NumberEntity +from homeassistant.components.select import SelectEntity from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorEntityDescription +from homeassistant.components.switch import SwitchEntity from homeassistant.const import EntityCategory, Platform from homeassistant.helpers.device_registry import DeviceInfo from span_panel_api import AdoptedDevice, AdoptedProperty, SpanPanelSnapshot @@ -119,19 +122,19 @@ def classify(declaration: AdoptedProperty) -> Platform: CONTROL_PLATFORMS: Final = frozenset({Platform.SWITCH, Platform.SELECT, Platform.NUMBER}) -"""Platforms `classify` names that this integration cannot yet construct. - -Not a reversal of the rule -- `classify` is the rule and it is complete. A -control needs a write, and every write this integration performs goes through a -curated, adapter-named topic: `set_circuit_relay`, `set_circuit_priority`, -`set_evse_charge_limit`. There is no generic property write, and adding one would -put a new member on `SchemaAdapter`, whose required set is derived from the -protocol itself -- so it would be required of every adapter package and would -invalidate built adapter wheels. - -That is a contract change and belongs in its own one, with its own version bump. -Until then a property that classifies as a control surfaces as a reading, and -`adopted_control_count` is what makes the gap countable rather than invisible. +"""The platforms `classify` names that write back to the panel. + +Built, since 2026-08-20. The write goes through `set_adopted_property`, whose +authorisation is a snapshot lookup rather than its arguments: it resolves the +property against the current `adopted_devices` and publishes to the topic that +property carries. A device the adapter models produces no adopted record, so it +cannot be addressed that way however the arguments are spelled -- which is what +keeps this from becoming a generic write around `set_circuit_relay`, +`set_circuit_priority` and `set_evse_charge_limit`. + +That mattered: two of those do real work on the way out. The islanding assertion +translates its value, and the charge ceiling refuses one above what the charger +was commissioned for. """ @@ -327,22 +330,187 @@ class AdoptedBinarySensor(AdoptedEntity, BinarySensorEntity): @property def is_on(self) -> bool | None: """Homie spells a boolean `true`/`false`; anything else is not an answer.""" - raw = self._published() - if raw is None: + return _boolean(self._published()) + + +class AdoptedControl(AdoptedEntity): + """Base for an adopted entity that writes back to the panel. + + The write is refused by the library unless the property is still there and + still settable, so nothing here re-checks it: a control for a device that has + left the tree raises rather than publishing into a topic nothing subscribes + to, and that is the correct outcome to surface. + """ + + def __init__( + self, + coordinator: SpanPanelCoordinator, + identifier: str, + device: AdoptedDevice, + declaration: AdoptedProperty, + *, + panel_device_id: str, + ) -> None: + """Remember the wire address this control publishes to.""" + super().__init__( + coordinator, identifier, device, declaration, panel_device_id=panel_device_id + ) + self._node_id = declaration.node_id + self._property_id = declaration.property_id + + async def _publish(self, value: str) -> None: + """Write one value and refresh, or raise what the library raised. + + No `hasattr` guard, unlike the curated controls. Those ask because a + transport may not implement an optional protocol at all; this entity only + exists because a v1.0 tree reported an adopted device, and that is the + same transport that carries the write. + """ + await self.coordinator.client.set_adopted_property( + self._device_wire_id, self._node_id, self._property_id, value + ) + await self.coordinator.async_request_refresh() + + +class AdoptedSwitch(AdoptedControl, SwitchEntity): + """A declared `boolean` the panel accepts writes to.""" + + @property + def is_on(self) -> bool | None: + """Homie spells a boolean `true`/`false`; anything else is not an answer.""" + return _boolean(self._published()) + + async def async_turn_on(self, **kwargs: object) -> None: + """Publish the vocabulary Homie defines for a boolean, not HA's.""" + await self._publish("true") + + async def async_turn_off(self, **kwargs: object) -> None: + """Publish the vocabulary Homie defines for a boolean, not HA's.""" + await self._publish("false") + + +class AdoptedSelect(AdoptedControl, SelectEntity): + """A declared `enum` the panel accepts writes to, with its declared options.""" + + def __init__( + self, + coordinator: SpanPanelCoordinator, + identifier: str, + device: AdoptedDevice, + declaration: AdoptedProperty, + *, + panel_device_id: str, + ) -> None: + """Take the option list from the declaration, which is the whole domain.""" + super().__init__( + coordinator, identifier, device, declaration, panel_device_id=panel_device_id + ) + self._attr_options = parse_enum_format(declaration.format) + + @property + def current_option(self) -> str | None: + """The published value, but only when it is one of the declared options. + + A value outside the declared set is reported as unknown rather than as a + selection. Home Assistant rejects a `current_option` outside `options`, + and quietly widening the list to admit whatever arrived would hide a + panel disagreeing with its own declaration. + """ + published = self._published() + return published if published in self._attr_options else None + + async def async_select_option(self, option: str) -> None: + """Publish the option verbatim -- it came from the panel's own list.""" + await self._publish(option) + + +class AdoptedNumber(AdoptedControl, NumberEntity): + """A declared numeric the panel accepts writes to, with its declared bounds.""" + + def __init__( + self, + coordinator: SpanPanelCoordinator, + identifier: str, + device: AdoptedDevice, + declaration: AdoptedProperty, + *, + panel_device_id: str, + ) -> None: + """Take the bounds from the declaration, which is what makes this a number.""" + bounds = parse_number_format(declaration.format) + super().__init__( + coordinator, identifier, device, declaration, panel_device_id=panel_device_id + ) + self._attr_native_min_value, self._attr_native_max_value, self._attr_native_step = bounds + self._attr_native_unit_of_measurement = declaration.unit + self._integral = declaration.datatype == "integer" + + @property + def native_value(self) -> float | None: + """The published value as a number, or None when it is not one.""" + published = self._published() + if published is None: + return None + try: + return float(published) + except ValueError: + _LOGGER.debug( + "Adopted %s published %r, which is not a number", self._declaration_path, published + ) return None - lowered = raw.strip().lower() - if lowered in ("true", "false"): - return lowered == "true" + + async def async_set_native_value(self, value: float) -> None: + """Publish the value in the datatype the property declares. + + An `integer` property gets an integer literal. Publishing `5.0` where the + declaration says `integer` is a payload outside the declared datatype, + and this library has no business sending one. + """ + await self._publish(str(int(value)) if self._integral else str(value)) + + +def _boolean(published: str | None) -> bool | None: + """Return Homie's `true`/`false`, with anything else meaning no answer.""" + if published is None: return None + lowered = published.strip().lower() + if lowered in ("true", "false"): + return lowered == "true" + return None + + +def parse_enum_format(declared: str | None) -> list[str]: + """Return the options a Homie `enum` `$format` lists. + + Comma-separated, per Homie 5. An empty result means the declaration carried + no usable domain, which `classify` has already used to route the property to + a sensor -- so this never returns empty for a property that reached a select. + """ + if not declared: + return [] + return [option.strip() for option in declared.split(",") if option.strip()] + + +def parse_number_format(declared: str | None) -> tuple[float, float, float]: + """Return the `min:max:step` a Homie numeric `$format` states. + + Step defaults to 1 when the declaration gives only a range, which Homie + permits. `classify` has already required a format, so the two bounds are + present by the time this is called. + """ + parts = (declared or "").split(":") + minimum = float(parts[0]) if len(parts) > 0 and parts[0] else 0.0 + maximum = float(parts[1]) if len(parts) > 1 and parts[1] else 100.0 + step = float(parts[2]) if len(parts) > 2 and parts[2] else 1.0 + return minimum, maximum, step def adopted_control_count(snapshot: SpanPanelSnapshot) -> int: - """How many declared properties would be controls if this could build them. + """How many adopted properties this panel exposes as controls rather than readings. - Zero on every panel that publishes no settable property on an unmodelled - device, which is every panel seen so far. Non-zero is the signal that the - generic write path `CONTROL_PLATFORMS` describes is worth its contract bump, - measured rather than assumed. + Reported in diagnostics beside the device list. A control on a device nobody + modelled is the highest-consequence thing adoption creates, so the count is + worth having in the one artefact that reaches a maintainer. """ return sum( 1 @@ -361,15 +529,17 @@ def create_adopted_sensors( ) -> list[AdoptedSensor]: """Every adopted property that is not a declared boolean. - Properties that classify as a control land here too, as readings, until the - write path exists -- see `CONTROL_PLATFORMS`. + Everything `classify` routes to `SENSOR`: every property that is not a + declared boolean and not a settable one with a usable value domain. """ - return [ - AdoptedSensor(coordinator, identifier, device, declaration, panel_device_id=panel_device_id) - for device, identifier in _adopted(snapshot, registry) - for declaration in device.properties - if classify(declaration) != Platform.BINARY_SENSOR - ] + return _create( + AdoptedSensor, + coordinator, + snapshot, + registry, + Platform.SENSOR, + panel_device_id=panel_device_id, + ) def create_adopted_binary_sensors( @@ -379,14 +549,91 @@ def create_adopted_binary_sensors( *, panel_device_id: str, ) -> list[AdoptedBinarySensor]: - """Every adopted property declared `boolean` and not settable.""" + """Every adopted property declared `boolean` that the panel accepts no write to.""" + return _create( + AdoptedBinarySensor, + coordinator, + snapshot, + registry, + Platform.BINARY_SENSOR, + panel_device_id=panel_device_id, + ) + + +def create_adopted_switches( + coordinator: SpanPanelCoordinator, + snapshot: SpanPanelSnapshot, + registry: DeviceRegistry, + *, + panel_device_id: str, +) -> list[AdoptedSwitch]: + """Every adopted property declared `boolean` and settable.""" + return _create( + AdoptedSwitch, + coordinator, + snapshot, + registry, + Platform.SWITCH, + panel_device_id=panel_device_id, + ) + + +def create_adopted_selects( + coordinator: SpanPanelCoordinator, + snapshot: SpanPanelSnapshot, + registry: DeviceRegistry, + *, + panel_device_id: str, +) -> list[AdoptedSelect]: + """Every adopted `enum` that is settable and declares its option list.""" + return _create( + AdoptedSelect, + coordinator, + snapshot, + registry, + Platform.SELECT, + panel_device_id=panel_device_id, + ) + + +def create_adopted_numbers( + coordinator: SpanPanelCoordinator, + snapshot: SpanPanelSnapshot, + registry: DeviceRegistry, + *, + panel_device_id: str, +) -> list[AdoptedNumber]: + """Every adopted numeric that is settable and declares its bounds.""" + return _create( + AdoptedNumber, + coordinator, + snapshot, + registry, + Platform.NUMBER, + panel_device_id=panel_device_id, + ) + + +def _create[AdoptedT: AdoptedEntity]( + entity_class: type[AdoptedT], + coordinator: SpanPanelCoordinator, + snapshot: SpanPanelSnapshot, + registry: DeviceRegistry, + platform: Platform, + *, + panel_device_id: str, +) -> list[AdoptedT]: + """Build one platform's share of the adopted properties. + + One partition function rather than five bodies, so `classify` stays the only + place a property's platform is decided. Five bodies would each restate the + predicate, and a property could then reach two platforms or none. + """ return [ - AdoptedBinarySensor( - coordinator, identifier, device, declaration, panel_device_id=panel_device_id - ) + entity_class(coordinator, identifier, device, declaration, panel_device_id=panel_device_id) for device, identifier in _adopted(snapshot, registry) for declaration in device.properties - if classify(declaration) == Platform.BINARY_SENSOR + if classify(declaration) is platform ] diff --git a/custom_components/span_panel/diagnostics.py b/custom_components/span_panel/diagnostics.py index 811e94e2..cc95bc25 100644 --- a/custom_components/span_panel/diagnostics.py +++ b/custom_components/span_panel/diagnostics.py @@ -129,14 +129,13 @@ class AdoptedPropertyRow(TypedDict): class AdoptionBlock(TypedDict): """The `adopted_devices` section. - `pending_controls` is the measurement that decides whether a generic write - path earns its contract bump -- how many declared properties would be - controls if one existed. Zero on every panel seen so far, and the number - stays invisible unless it is reported. + `controls` counts the adopted properties that write back to the panel rather + than only reporting. It is the highest-consequence thing adoption creates, so + it is worth a number in the one artefact that reaches a maintainer. """ count: int - pending_controls: int + controls: int devices: list[AdoptedDeviceRow] @@ -150,7 +149,7 @@ def _adoption(snapshot: SpanPanelSnapshot) -> AdoptionBlock: """ return { "count": len(snapshot.adopted_devices), - "pending_controls": adopted_control_count(snapshot), + "controls": adopted_control_count(snapshot), "devices": [ { "device_type": device.device_type, diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 400717d8..9a7ca8d3 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -22,9 +22,9 @@ ], "quality_scale": "gold", "requirements": [ - "span-panel-api==3.0.0b5", - "span-panel-api-schema-0==1.0.0b4", - "span-panel-api-schema-1==0.1.0b5" + "span-panel-api==3.0.0b6", + "span-panel-api-schema-0==1.0.0b5", + "span-panel-api-schema-1==0.1.0b6" ], "version": "2.1.0", "zeroconf": [ diff --git a/custom_components/span_panel/number.py b/custom_components/span_panel/number.py index ab62620d..1bd7f9bb 100644 --- a/custom_components/span_panel/number.py +++ b/custom_components/span_panel/number.py @@ -41,12 +41,14 @@ from homeassistant.const import UnitOfElectricCurrent from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from span_panel_api import EvseControlProtocol, SpanEvseSnapshot, SpanPanelSnapshot from span_panel_api.exceptions import SpanPanelServerError from . import SpanPanelConfigEntry +from .adoption import AdoptedNumber, create_adopted_numbers from .const import CONF_DEVICE_NAME, DOMAIN, USE_CIRCUIT_NUMBERS from .coordinator import SpanPanelCoordinator from .entity import SpanPanelEntity @@ -281,15 +283,27 @@ async def async_setup_entry( coordinator = config_entry.runtime_data.coordinator snapshot: SpanPanelSnapshot = coordinator.data - async_add_entities( - [ - SpanEvseNumber(coordinator, description, evse_id) - for evse_id, evse in snapshot.evse.items() - for description in EVSE_NUMBERS - # The declaration is the gate, never the value: a charger that - # declares the property settable and has not published one yet still - # has the control, and a charger that publishes a value it does not - # declare settable does not. - if description.settable_fn(evse) - ] + entities: list[SpanEvseNumber | AdoptedNumber] = [ + SpanEvseNumber(coordinator, description, evse_id) + for evse_id, evse in snapshot.evse.items() + for description in EVSE_NUMBERS + # The declaration is the gate, never the value: a charger that + # declares the property settable and has not published one yet still + # has the control, and a charger that publishes a value it does not + # declare settable does not. + if description.settable_fn(evse) + ] + + # Settable numerics on devices this integration models nothing for, whose + # bounds come from the declaration -- which is what made them numbers rather + # than readings in the first place. + entities.extend( + create_adopted_numbers( + coordinator, + snapshot, + dr.async_get(hass), + panel_device_id=config_entry.runtime_data.panel_device_id, + ) ) + + async_add_entities(entities) diff --git a/custom_components/span_panel/select.py b/custom_components/span_panel/select.py index 22993eac..42f76e2d 100644 --- a/custom_components/span_panel/select.py +++ b/custom_components/span_panel/select.py @@ -7,13 +7,14 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceNotFound -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from span_panel_api import SpanCircuitSnapshot, SpanPanelSnapshot from span_panel_api.exceptions import SpanPanelServerError from . import SpanPanelConfigEntry +from .adoption import AdoptedSelect, create_adopted_selects from .const import DOMAIN, USE_CIRCUIT_NUMBERS, CircuitPriority from .coordinator import SpanPanelCoordinator from .entity import SpanPanelEntity @@ -389,7 +390,7 @@ async def async_setup_entry( # Get device name from config entry data device_name = config_entry.data.get("device_name", config_entry.title) - entities: list[SpanPanelCircuitsSelect] = [] + entities: list[SpanPanelCircuitsSelect | AdoptedSelect] = [] for circuit_id, circuit_data in snapshot.circuits.items(): if not circuit_data.is_user_controllable: @@ -411,4 +412,16 @@ async def async_setup_entry( ) ) + # Settable properties on devices this integration models nothing for. + # Disabled and diagnostic like every adopted entity: the panel authorises the + # write, and the user decides whether the control is one they want. + entities.extend( + create_adopted_selects( + coordinator, + coordinator.data, + dr.async_get(hass), + panel_device_id=config_entry.runtime_data.panel_device_id, + ) + ) + async_add_entities(entities) diff --git a/custom_components/span_panel/switch.py b/custom_components/span_panel/switch.py index 7a368e1b..a8ea3e73 100644 --- a/custom_components/span_panel/switch.py +++ b/custom_components/span_panel/switch.py @@ -6,11 +6,12 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from span_panel_api import SpanCircuitSnapshot, SpanPanelSnapshot from . import SpanPanelConfigEntry +from .adoption import AdoptedSwitch, create_adopted_switches from .const import DOMAIN, USE_CIRCUIT_NUMBERS, CircuitRelayState from .coordinator import SpanPanelCoordinator from .entity import SpanPanelEntity @@ -335,7 +336,7 @@ async def async_setup_entry( # Get device name from config entry data _device_name = config_entry.data.get("device_name", config_entry.title) - entities: list[SpanPanelCircuitsSwitch] = [] + entities: list[SpanPanelCircuitsSwitch | AdoptedSwitch] = [] for circuit_id, circuit_data in snapshot.circuits.items(): if not circuit_data.is_user_controllable: @@ -351,4 +352,16 @@ async def async_setup_entry( SpanPanelCircuitsSwitch(coordinator, circuit_id, circuit_data.name, _device_name) ) + # Settable properties on devices this integration models nothing for. + # Disabled and diagnostic like every adopted entity: the panel authorises the + # write, and the user decides whether the control is one they want. + entities.extend( + create_adopted_switches( + coordinator, + coordinator.data, + dr.async_get(hass), + panel_device_id=config_entry.runtime_data.panel_device_id, + ) + ) + async_add_entities(entities) diff --git a/developer.md b/developer.md index d473c1cf..9eef3d9d 100644 --- a/developer.md +++ b/developer.md @@ -311,9 +311,9 @@ persistence. `classify_sub_device_identifier` returns `None` for any identifier carrying the `adopted` token, tested before its suffix rules — the anchor is vendor vocabulary, and a device id ending in `pv` would otherwise classify as the panel's solar sub-device. -### Controls are classified but not built +### Controls -`classify` implements the full rule, including the three control platforms: +`classify` routes a declaration to a platform: | Declaration | Platform | | ---------------------------------- | --------------- | @@ -324,14 +324,37 @@ vocabulary, and a device id ending in `pv` would otherwise classify as the panel | anything else | `SENSOR` | A settable property with no `format` falls back to a reading because `format` is the value domain: a select with no option list and a number with no bounds are -broken controls, not safe ones. +broken controls, not safe ones. A settable `boolean` needs none — its datatype states the domain in full. -The three control platforms are in `CONTROL_PLATFORMS` and are **not constructed yet**. Every write this integration performs goes through a curated, -adapter-named topic, and a generic property write would put a new member on `SchemaAdapter` — whose required set is derived from the protocol itself, so it -would be required of every adapter package and would invalidate built adapter wheels. That is a contract change with its own version bump. +All five creators share `_create`, so `classify` is the only place a property's platform is decided. `test_every_property_reaches_exactly_one_platform` asserts +that as a partition, which is what five separate predicates could not guarantee. -`adopted_control_count` reports how many declared properties are waiting on it, in diagnostics under `adopted_devices.pending_controls`, so the decision is made -on a measurement rather than a guess. +Controls are disabled and diagnostic like every other adopted entity. There is deliberately no second, weaker gate — no read-only mode for settable properties. +Enabling an entity is a deliberate act, commanding it is a second one, the panel authorises the write regardless of what we create, and this integration already +ships switches that open and close breakers. + +### The write, and why it is not a generic one + +`SpanMqttClient.set_adopted_property(device_id, node_id, property_id, value)` publishes the write. **The lookup is the authorisation**: it resolves the property +against the current snapshot's `adopted_devices` and publishes to the `set_topic` that property carries. No topic is accepted from the caller. + +That matters because the obvious alternative — a `set_property_topic(device, node, property)` member on `SchemaAdapter` — would put every curated control one +argument away, and two of them do real work on the way out: + +- `set_dominant_power_source` translates `GRID` into the `ON_GRID` the v1.0 islanding assertion accepts. +- `set_evse_charge_limit` **refuses** a value above the commissioned ceiling, because publishing past it is the one write with a physical consequence. + +A generic write reachable at modelled devices routes around both. Because `set_topic` is populated only for settable properties on devices `is_modelled` +rejected, a modelled device produces no `AdoptedDevice` and cannot be addressed this way however the arguments are spelled. + +It also kept the change additive. A new `SchemaAdapter` member is required of every adapter package, so an install carrying an older adapter wheel would fail at +**discovery** — the whole integration, not one feature. + +No payload translation and no bounds check on the way out, deliberately: the library knows nothing about an adopted property beyond its declaration, and +inventing a bound would be inventing a fact about somebody's hardware. The entity constrains the value to the declared domain and the panel stays the authority +on whether to accept it. A `NUMBER` on an `integer` property publishes `45`, never `45.0`. + +Diagnostics report `adopted_devices.controls` — how many adopted properties write back rather than only reporting. ### The notice counts devices, not entities diff --git a/pyproject.toml b/pyproject.toml index e79e0a13..9c496798 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,13 +8,13 @@ readme = "README.md" requires-python = ">=3.14.3,<3.15" dependencies = [ "homeassistant==2026.8.0", - "span-panel-api==3.0.0b5", + "span-panel-api==3.0.0b6", # 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.0.0b4", - "span-panel-api-schema-1==0.1.0b5", + "span-panel-api-schema-0==1.0.0b5", + "span-panel-api-schema-1==0.1.0b6", ] [dependency-groups] diff --git a/tests/test_adoption.py b/tests/test_adoption.py index 6a3924f8..5ef7e20f 100644 --- a/tests/test_adoption.py +++ b/tests/test_adoption.py @@ -12,7 +12,7 @@ from dataclasses import replace from pathlib import Path from typing import TYPE_CHECKING -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import EntityCategory, Platform @@ -28,7 +28,10 @@ adopted_identifier, classify, create_adopted_binary_sensors, + create_adopted_numbers, + create_adopted_selects, create_adopted_sensors, + create_adopted_switches, resolve_identifier, ) from custom_components.span_panel.const import DOMAIN @@ -327,14 +330,8 @@ def test_a_curated_identifier_still_classifies(hass: HomeAssistant) -> None: # -- The pending write path is counted rather than hidden -------------------- -def test_a_settable_property_is_counted_as_a_pending_control() -> None: - """`classify` is the complete rule; the write path is what does not exist yet. - - Every write this integration performs goes through a curated, adapter-named - topic, and a generic one would put a new member on `SchemaAdapter` -- required - of every adapter package, invalidating built wheels. That is its own change. - Counting the properties waiting on it is what decides whether it is worth one. - """ +def test_controls_are_counted_for_diagnostics() -> None: + """A control on a device nobody modelled is the highest-consequence thing here.""" declarations = ( _property(datatype="boolean", settable=True, unit=None), _property(node_id="generator", property_id="mode", datatype="enum", fmt="AUTO,OFF", settable=True, unit=None), @@ -346,5 +343,126 @@ def test_a_settable_property_is_counted_as_a_pending_control() -> None: assert classify(declarations[0]) in CONTROL_PLATFORMS -def test_a_panel_with_no_adopted_device_counts_no_pending_controls() -> None: +def test_a_panel_with_no_adopted_device_counts_no_controls() -> None: assert adopted_control_count(_snapshot()) == 0 + + +# -- Controls are built, and their domain comes from the declaration --------- + + +def _built(hass: HomeAssistant, *declarations: AdoptedProperty) -> dict[Platform, list[object]]: + """Every platform's share of one adopted device, keyed by platform.""" + snapshot = _snapshot(_device(properties=declarations)) + coordinator = MagicMock(data=snapshot) + registry = dr.async_get(hass) + kwargs = {"panel_device_id": "panel-device-id"} + return { + Platform.SENSOR: list(create_adopted_sensors(coordinator, snapshot, registry, **kwargs)), + Platform.BINARY_SENSOR: list(create_adopted_binary_sensors(coordinator, snapshot, registry, **kwargs)), + Platform.SWITCH: list(create_adopted_switches(coordinator, snapshot, registry, **kwargs)), + Platform.SELECT: list(create_adopted_selects(coordinator, snapshot, registry, **kwargs)), + Platform.NUMBER: list(create_adopted_numbers(coordinator, snapshot, registry, **kwargs)), + } + + +def test_every_property_reaches_exactly_one_platform(hass: HomeAssistant) -> None: + """The partition `classify` defines, asserted as a partition. + + Five creators sharing one predicate is what makes this hold. Five bodies each + restating it would let a property reach two platforms or none, and neither + failure shows up in a test that only counts one platform at a time. + """ + declarations = ( + _property(), + _property(node_id="relay", property_id="closed", datatype="boolean", unit=None), + _property(node_id="relay", property_id="enabled", datatype="boolean", unit=None, settable=True), + _property(node_id="generator", property_id="mode", datatype="enum", fmt="AUTO,OFF", settable=True, unit=None), + _property(node_id="generator", property_id="setpoint", datatype="integer", fmt="0:100:5", settable=True), + ) + built = _built(hass, *declarations) + + assert [len(entities) for entities in built.values()] == [1, 1, 1, 1, 1] + assert sum(len(entities) for entities in built.values()) == len(declarations) + + +def test_a_select_takes_its_options_from_the_declaration(hass: HomeAssistant) -> None: + declaration = _property( + node_id="generator", property_id="mode", datatype="enum", fmt="AUTO, MANUAL ,OFF", settable=True, unit=None + ) + (entity,) = _built(hass, declaration)[Platform.SELECT] + assert entity.options == ["AUTO", "MANUAL", "OFF"] + + +def test_a_select_reports_no_option_when_the_panel_publishes_one_it_never_declared(hass: HomeAssistant) -> None: + """Widening the list to admit whatever arrived would hide the disagreement. + + Home Assistant rejects a `current_option` outside `options`, so the choice is + between reporting unknown and quietly rewriting the panel's own declaration. + """ + declaration = _property( + node_id="generator", property_id="mode", datatype="enum", fmt="AUTO,OFF", settable=True, unit=None, value="ECO" + ) + (entity,) = _built(hass, declaration)[Platform.SELECT] + assert entity.current_option is None + assert entity.options == ["AUTO", "OFF"] + + +def test_a_number_takes_its_bounds_from_the_declaration(hass: HomeAssistant) -> None: + """The bounds are what made this a number rather than a reading.""" + declaration = _property( + node_id="generator", property_id="setpoint", datatype="integer", fmt="10:80:5", settable=True, unit="%" + ) + (entity,) = _built(hass, declaration)[Platform.NUMBER] + assert (entity.native_min_value, entity.native_max_value, entity.native_step) == (10.0, 80.0, 5.0) + + +async def test_a_switch_publishes_the_vocabulary_homie_defines(hass: HomeAssistant) -> None: + """`true` and `false`, not Home Assistant's `on` and `off`.""" + declaration = _property(node_id="relay", property_id="enabled", datatype="boolean", unit=None, settable=True) + snapshot = _snapshot(_device(properties=(declaration,))) + coordinator = MagicMock(data=snapshot) + coordinator.client.set_adopted_property = AsyncMock() + coordinator.async_request_refresh = AsyncMock() + + (entity,) = create_adopted_switches( + coordinator, snapshot, dr.async_get(hass), panel_device_id="panel-device-id" + ) + await entity.async_turn_on() + + coordinator.client.set_adopted_property.assert_awaited_once_with("generator-1", "relay", "enabled", "true") + + +async def test_a_number_publishes_an_integer_where_the_declaration_says_integer(hass: HomeAssistant) -> None: + """`5`, never `5.0`: a float literal is outside the declared datatype.""" + declaration = _property( + node_id="generator", property_id="setpoint", datatype="integer", fmt="0:100:5", settable=True, unit=None + ) + snapshot = _snapshot(_device(properties=(declaration,))) + coordinator = MagicMock(data=snapshot) + coordinator.client.set_adopted_property = AsyncMock() + coordinator.async_request_refresh = AsyncMock() + + (entity,) = create_adopted_numbers(coordinator, snapshot, dr.async_get(hass), panel_device_id="panel-device-id") + await entity.async_set_native_value(45.0) + + coordinator.client.set_adopted_property.assert_awaited_once_with("generator-1", "generator", "setpoint", "45") + + +def test_a_control_is_disabled_and_diagnostic_like_every_other_adopted_entity(hass: HomeAssistant) -> None: + """Disabled-by-default is the gate, and it is the same gate for a reading. + + A second, weaker gate for controls -- surfacing them read-only -- would be + inconsistent and would not add safety: enabling is a deliberate act, + commanding is a second one, and the panel authorises the write regardless. + """ + declarations = ( + _property(node_id="relay", property_id="enabled", datatype="boolean", unit=None, settable=True), + _property(node_id="generator", property_id="mode", datatype="enum", fmt="AUTO,OFF", settable=True, unit=None), + _property(node_id="generator", property_id="setpoint", datatype="integer", fmt="0:9:1", settable=True), + ) + built = _built(hass, *declarations) + controls = built[Platform.SWITCH] + built[Platform.SELECT] + built[Platform.NUMBER] + + assert len(controls) == 3 + assert all(entity.entity_registry_enabled_default is False for entity in controls) + assert all(entity.entity_category is EntityCategory.DIAGNOSTIC for entity in controls) diff --git a/uv.lock b/uv.lock index f765a5cb..04f4fe2d 100644 --- a/uv.lock +++ b/uv.lock @@ -2550,7 +2550,7 @@ dev = [ [[package]] name = "span-panel-api" -version = "3.0.0b5" +version = "3.0.0b6" source = { editable = "../../span/span-panel-api-p3" } dependencies = [ { name = "httpx" }, @@ -2587,7 +2587,7 @@ dev = [ [[package]] name = "span-panel-api-schema-0" -version = "1.0.0b4" +version = "1.0.0b5" source = { editable = "../../span/span-panel-api-p3/packages/schema-0" } dependencies = [ { name = "span-panel-api" }, @@ -2598,7 +2598,7 @@ requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-ap [[package]] name = "span-panel-api-schema-1" -version = "0.1.0b5" +version = "0.1.0b6" source = { editable = "../../span/span-panel-api-p3/packages/schema-1" } dependencies = [ { name = "ebus-sdk" }, From 90aa8c86faabdefe1a22b3b6fbe8a868fe877186 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:20:18 -0700 Subject: [PATCH 068/345] feat(additions): announce added entities in a notification that names them Two problems with the previous new-entity notice. It covered only entities added disabled, on the reasoning that an enabled one is already visible in the entity list and its history. Nobody watches their entity count, so that made every enabled addition invisible. And it derived the diff from setup timing -- the registry before the platforms against the registry after -- which answers correctly exactly once. On the next startup the entity is already registered beforehand and the diff is empty by construction, so anything not announced in that one window never was. What is announced is now recorded in a Store, so the question is whether the user has been told rather than whether it happened seconds ago. It is a notification rather than a Repair because an addition is not a repair: nothing is broken and nothing needs fixing. The retired issue is deleted at setup, since it was raised is_persistent and would otherwise stand forever with nothing left to re-derive it. Translations are read from this component's own translations directory. The Home Assistant helper filters to the categories it defines and a persistent notification is not one of them -- a custom category loads as nothing, which was verified rather than assumed. Separately: adopted devices are now registered explicitly, before the platforms. A vendor device publishing only an info node resolves entirely to its device card, creates no entity, and so had nothing to create it -- it produced no device, no entity and no notification at all. --- CHANGELOG.md | 14 + custom_components/span_panel/__init__.py | 39 +- custom_components/span_panel/additions.py | 254 ++++++++++++ custom_components/span_panel/adoption.py | 38 ++ .../span_panel/schema_repairs.py | 202 ++-------- custom_components/span_panel/strings.json | 15 +- .../span_panel/translations/en.json | 15 +- .../span_panel/translations/es.json | 15 +- .../span_panel/translations/fr.json | 15 +- .../span_panel/translations/ja.json | 15 +- .../span_panel/translations/pt.json | 15 +- developer.md | 39 +- tests/test_adoption.py | 91 +++++ tests/test_new_entity_announcement.py | 268 +++++++++++++ tests/test_new_entity_notice.py | 364 ------------------ tests/test_setup_entry.py | 46 +-- 16 files changed, 834 insertions(+), 611 deletions(-) create mode 100644 custom_components/span_panel/additions.py create mode 100644 tests/test_new_entity_announcement.py delete mode 100644 tests/test_new_entity_notice.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cc3d1b9a..c08598b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ All notable changes to this project will be documented in this file. ### Added +- **New entities are now announced in a notification that names them — whether or not they arrived switched on.** Previously only entities added _disabled_ were + mentioned, on the reasoning that an enabled one is already visible in your entity list and its history. That is only true if you are watching your entity + list, which nobody is: an addition that breaks nothing was indistinguishable from no addition at all. The notification names every entity that was added, + splits them by whether they are ready to use or still switched off, and tells you where to turn the switched-off ones on. +- **It is a notification rather than a Repair, because an addition is not a repair.** Nothing is broken and nothing needs fixing. Any new-entity item still + sitting in your Repairs list from a previous version is removed on upgrade. +- **And it can no longer be missed by a restart landing in the wrong place.** The old notice compared the entity registry before and after startup, which + answers correctly exactly once — on the next startup the entity is already registered and the comparison is empty. What has been announced is now recorded, so + the question is whether you have been told rather than whether it happened in the last few seconds. It stays quiet on a first install, and quiet once more on + the first startup after this change, so upgrading does not announce a release's worth of history. +- **An adopted device now appears even when it publishes no readings at all.** A device that advertises what it is — its vendor, model and firmware — before + publishing any measurement resolves entirely to its device card, so it created no entities, and because devices were created as a side effect of entity + creation it produced nothing whatsoever: no device, no entity, no notification. It is now registered in its own right. + - **A device the panel publishes that this integration has never modelled now appears, instead of appearing nowhere.** SPAN positions the panel as the hub for whatever plugs into it and the eBus schema is explicitly vendor-extensible, so a device type nobody modelled is an expected arrival — and until now it produced no device, no entity and no sign it was there. Such a device now gets a card of its own hanging off the panel, carrying whatever `info` it publishes, diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py index 741e2d71..aa5c298d 100644 --- a/custom_components/span_panel/__init__.py +++ b/custom_components/span_panel/__init__.py @@ -28,6 +28,8 @@ # Import config flow to ensure it's registered from . import config_flow # noqa: F401 +from .additions import async_announce_new_entities, async_forget_announcements +from .adoption import async_register_adopted_devices from .const import ( CONF_API_VERSION, CONF_EBUS_BROKER_HOST, @@ -52,11 +54,7 @@ from .graph_horizon import GraphHorizonManager from .migrations import CURRENT_CONFIG_VERSION, async_migrate_entry # noqa: F401 from .options import SNAPSHOT_UPDATE_INTERVAL -from .schema_repairs import ( - async_clear_schema_issues, - async_notice_new_disabled_entities, - async_registered_unique_ids, -) +from .schema_repairs import async_clear_retired_new_entity_notices, async_clear_schema_issues from .services import ( # noqa: F401 _async_register_favorites_services, _async_register_graph_horizon_services, @@ -261,10 +259,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> ), ) - # Taken before the forward, because forwarding is what registers the - # entities: everything absent here and present afterwards is an entity - # this version of the integration added. - known_unique_ids = async_registered_unique_ids(hass, entry) + # Before the forward, because a sub-device's `via_device_id` has to name a + # device that already exists -- and because an adopted device whose whole + # declaration is an `info` node has no entity to be created by. + async_register_adopted_devices( + hass, entry.entry_id, snapshot, panel_device_id=entry.runtime_data.panel_device_id + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -276,9 +276,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> # Also after the platforms, for the other half of the same reason: a # newly added entity is only in the registry once its platform has added - # it. An addition that arrives disabled reaches the user through nothing - # else at all. - async_notice_new_disabled_entities(hass, entry, known_unique_ids) + # it. Nobody watches their entity count, so an addition that breaks + # nothing reaches the user through this and nothing else -- whether or + # not it arrived switched on. + async_clear_retired_new_entity_notices(hass, entry) + await async_announce_new_entities(hass, entry) except Exception: if coordinator is not None: await coordinator.async_shutdown() @@ -310,14 +312,19 @@ async def async_unload_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) - async def async_remove_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> None: - """Clean up the Repairs this entry raised. + """Clean up what this entry left outside its own runtime data. Core deletes neither issues nor their dismissals when a config entry is - removed, so a panel that is taken out of the system would otherwise leave - its schema notices behind forever. Scoped to this entry: another panel's - issues share the domain and must survive. + removed, so a panel taken out of the system would otherwise leave its schema + notices behind forever. Scoped to this entry: another panel's issues share the + domain and must survive. + + The announcement record goes with them, and for a sharper reason: it outliving + the entry would mean re-adding the same panel announces none of the entities + it recreates, because every one of them is already recorded as announced. """ async_clear_schema_issues(hass, entry) + await async_forget_announcements(hass, entry) async def async_remove_config_entry_device( diff --git a/custom_components/span_panel/additions.py b/custom_components/span_panel/additions.py new file mode 100644 index 00000000..83a87681 --- /dev/null +++ b/custom_components/span_panel/additions.py @@ -0,0 +1,254 @@ +"""Telling the user what this integration just added. + +Split out of `schema_repairs` deliberately. An addition is not a repair: nothing +is broken, nothing needs fixing, and filing it in the Repairs list puts it in a +category whose whole meaning is "something wants your attention because it went +wrong". It is a notification, and it says what was added rather than only how +many. + +Two things this fixes about the previous behaviour. + +**Enabled additions were announced to nobody.** The old notice covered only +entities added `disabled_by=INTEGRATION`, on the reasoning that an enabled entity +is already visible in the entity list and its history. That reasoning does not +survive contact with how anyone actually uses Home Assistant: nobody watches +their entity count, so an addition that breaks nothing is indistinguishable from +no addition at all. + +**The diff was derived from setup timing, so it was unrepeatable.** It compared +the registry before the platforms against the registry after, which answers +correctly exactly once: on the next startup the entity is already registered +beforehand and the diff is empty by construction. Anything not announced in that +one window was never announced. What is announced is now recorded, so the +question becomes "has this been announced" rather than "was this registered in +the last few seconds", and nothing is lost to a restart landing in the wrong +place. +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import TYPE_CHECKING, Any, Final + +from homeassistant.components.persistent_notification import async_create +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.storage import Store + +from .const import DOMAIN +from .util import ADOPTED_IDENTIFIER_TOKEN + +if TYPE_CHECKING: + from homeassistant.config_entries import ConfigEntry + from homeassistant.core import HomeAssistant + +_LOGGER = logging.getLogger(__name__) + +_STORE_VERSION: Final = 1 +_ANNOUNCED: Final = "announced_unique_ids" + +_FALLBACK: Final[dict[str, str]] = { + "title": "SPAN Panel added new entities", + "intro_one": "This update added 1 new entity to your SPAN Panel.", + "intro_many": "This update added {count} new entities to your SPAN Panel.", + "enabled_heading": "Added and ready to use", + "disabled_heading": "Added but switched off", + "how_to_enable": ( + "Switched-off entities record nothing until you turn them on. Open the SPAN Panel " + "device page, show its disabled entities, and enable the ones you want." + ), + "nothing_broken": "Nothing is broken and no action is required.", +} +"""English text, used when a translation file cannot be read. + +A fallback rather than the source of truth: `strings.json` and `translations/` +carry the same keys in five languages, and `_text` prefers those. Kept in code so +a notification is never *lost* to an unreadable file -- the addition still gets +announced, in English. +""" + + +def _store(hass: HomeAssistant, entry: ConfigEntry) -> Store[dict[str, Any]]: + """Return the record of what has already been announced for this entry. + + Per entry rather than per domain: two panels add entities independently, and + a shared record would let one panel's announcement suppress the other's. + """ + return Store(hass, _STORE_VERSION, f"{DOMAIN}.announced.{entry.entry_id}") + + +def _read_translations(language: str) -> dict[str, str]: + """Our own notification strings for one language, or an empty mapping. + + Read from this component's `translations/` directory rather than through + `homeassistant.helpers.translation`, because that helper filters to the + categories Home Assistant defines and a persistent notification is not one of + them -- a custom category loads as nothing at all. These are this + integration's own package files, so reading them is not reaching into + somebody else's layout. + + Blocking file I/O. Callers run it in an executor. + """ + directory = Path(__file__).parent / "translations" + for candidate in (f"{language}.json", f"{language.split('-')[0]}.json", "en.json"): + path = directory / candidate + if not path.is_file(): + continue + try: + loaded = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + _LOGGER.debug("Could not read notification strings from %s", path, exc_info=True) + continue + section = loaded.get("notifications", {}).get("new_entities", {}) + if section: + return {str(key): str(value) for key, value in section.items()} + return {} + + +async def async_announce_new_entities(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Tell the user which entities this setup added, and which need enabling. + + Silent on a first install. With nothing recorded and nothing registered, every + entity is new, so the notification would name the entire integration and teach + the user that this category is noise -- which would cost them the real + additions later. + + Silent, once, on the first run after this mechanism ships. An install that + predates the record has entities that were never announced but are not new + either, so the first pass adopts them as already-known rather than announcing + a release's worth of history. + """ + store = _store(hass, entry) + stored = await store.async_load() + registered = _registered(hass, entry) + + if stored is None: + await store.async_save({_ANNOUNCED: sorted(registered)}) + _LOGGER.debug( + "Seeded the announcement record for %s with %d entities; nothing announced", + entry.entry_id, + len(registered), + ) + return + + announced = frozenset(stored.get(_ANNOUNCED, ())) + added = [ + registry_entry + for registry_entry in _entries(hass, entry) + if registry_entry.unique_id not in announced + ] + if not added: + return + + text = await hass.async_add_executor_job(_read_translations, hass.config.language) + async_create( + hass, + message=_message(hass, added, text), + title=_text(text, "title"), + notification_id=f"{DOMAIN}_new_entities_{entry.entry_id}", + ) + await store.async_save({_ANNOUNCED: sorted(announced | registered)}) + _LOGGER.debug("Announced %d new entities for %s", len(added), entry.entry_id) + + +def _entries(hass: HomeAssistant, entry: ConfigEntry) -> list[er.RegistryEntry]: + return er.async_entries_for_config_entry(er.async_get(hass), entry.entry_id) + + +def _registered(hass: HomeAssistant, entry: ConfigEntry) -> frozenset[str]: + return frozenset(registry_entry.unique_id for registry_entry in _entries(hass, entry)) + + +def _text(text: dict[str, str], key: str, **placeholders: object) -> str: + """One string, translated where a file supplied it and English where not.""" + template = text.get(key) or _FALLBACK[key] + return template.format(**placeholders) if placeholders else template + + +def _message(hass: HomeAssistant, added: list[er.RegistryEntry], text: dict[str, str]) -> str: + """Build the notification body: what was added, split by whether it is switched on. + + The split is the actionable part. An enabled entity is already recording and + needs nothing; a disabled one records nothing at all until the user turns it + on, and saying so is the difference between a notice they can act on and one + they can only acknowledge. + """ + devices = dr.async_get(hass) + enabled: list[str] = [] + disabled: list[str] = [] + adopted: dict[str, int] = {} + + for registry_entry in added: + device_name = _adopted_device_name(devices, registry_entry) + if device_name is not None: + adopted[device_name] = adopted.get(device_name, 0) + 1 + continue + target = ( + disabled + if registry_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + else enabled + ) + target.append(_label(registry_entry)) + + # Two keys rather than one with a plural placeholder: a plural rule the caller + # picks a *word* for is an English rule, and the five languages here do not + # share it. + intro = ( + _text(text, "intro_one") if len(added) == 1 else _text(text, "intro_many", count=len(added)) + ) + lines = [intro, ""] + if enabled: + lines += [f"**{_text(text, 'enabled_heading')}**", ""] + lines += [f"- {label}" for label in sorted(enabled)] + lines.append("") + if adopted: + lines += [f"- {name} ({count} entities)" for name, count in sorted(adopted.items())] + lines.append("") + if disabled: + lines += [f"**{_text(text, 'disabled_heading')}**", ""] + lines += [f"- {label}" for label in sorted(disabled)] + lines += ["", _text(text, "how_to_enable"), ""] + lines.append(_text(text, "nothing_broken")) + return "\n".join(lines) + + +def _adopted_device_name( + devices: dr.DeviceRegistry, registry_entry: er.RegistryEntry +) -> str | None: + """Return the adopted device this entity belongs to, or None when it belongs to none. + + An adopted device's entities are collapsed to one line with a count. A vendor + device declaring a dozen properties would otherwise spend the whole + notification on itself and teach the user to skip it -- which would cost them + the curated additions in the same message. + """ + if registry_entry.device_id is None: + return None + device = devices.async_get(registry_entry.device_id) + if device is None: + return None + token = f"_{ADOPTED_IDENTIFIER_TOKEN}_" + if not any(token in identifier for _domain, identifier in device.identifiers): + return None + return device.name_by_user or device.name or registry_entry.entity_id + + +def _label(registry_entry: er.RegistryEntry) -> str: + """Return what to call an entity the user has never seen. + + A disabled entity has no state, so there is no friendly name on the state + machine -- only what the registry recorded when the platform added it. The + entity_id is the last resort rather than the first choice, because it is the + name the user will *not* see in the device's entity list. + """ + return registry_entry.name or registry_entry.original_name or registry_entry.entity_id + + +async def async_forget_announcements(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Drop the announcement record when the entry is removed. + + Without this the record outlives the entry it describes, and re-adding the + same panel would suppress the announcement of every entity it recreates. + """ + await _store(hass, entry).async_remove() diff --git a/custom_components/span_panel/adoption.py b/custom_components/span_panel/adoption.py index b0a1a0c8..2c4bee52 100644 --- a/custom_components/span_panel/adoption.py +++ b/custom_components/span_panel/adoption.py @@ -41,6 +41,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorEntityDescription from homeassistant.components.switch import SwitchEntity from homeassistant.const import EntityCategory, Platform +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from span_panel_api import AdoptedDevice, AdoptedProperty, SpanPanelSnapshot @@ -49,6 +50,7 @@ from .util import ADOPTED_IDENTIFIER_TOKEN if TYPE_CHECKING: + from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceRegistry from .coordinator import SpanPanelCoordinator @@ -229,6 +231,42 @@ def _humanised(wire_token: str) -> str: return wire_token.replace("-", " ").replace("_", " ").title() +def async_register_adopted_devices( + hass: HomeAssistant, + entry_id: str, + snapshot: SpanPanelSnapshot, + *, + panel_device_id: str, +) -> None: + """Create a device-registry entry for every adopted device, before any entity. + + Registered explicitly rather than left to fall out of entity creation, and the + reason is a device that has no entities to fall out of. A vendor device that + publishes only an `info` node -- advertising what it is before it publishes + any reading -- resolves entirely to the device card by the node rule, so it + creates no entity, so nothing ever calls `async_get_or_create` for it. It used + to produce *nothing at all*: no device, no entity, and no notice. Which is the + silence adoption exists to end, reached by a different route. + + It also fixes the identity freeze in one place. `resolve_identifier` reads the + registry to decide which spelling this install already uses, so running it + here -- once, before the platforms -- means every entity created afterwards + resolves against a device that already exists and cannot disagree. + + Called before the platforms are forwarded, for the same reason the panel's own + registration is: a sub-device's `via_device_id` has to name a device that + exists. + """ + registry = dr.async_get(hass) + for device in snapshot.adopted_devices: + identifier = resolve_identifier(registry, snapshot.serial_number, device) + registry.async_get_or_create( + config_entry_id=entry_id, + **adopted_device_info(identifier, device, panel_device_id=panel_device_id), + ) + _LOGGER.debug("Registered adopted device %s as %s", device.device_type, identifier) + + class AdoptedEntity(SpanPanelEntity): """Base for an entity built from a declaration rather than from a description. diff --git a/custom_components/span_panel/schema_repairs.py b/custom_components/span_panel/schema_repairs.py index 2b2d1c1f..e7184da5 100644 --- a/custom_components/span_panel/schema_repairs.py +++ b/custom_components/span_panel/schema_repairs.py @@ -25,17 +25,15 @@ from __future__ import annotations -from collections.abc import Collection, Mapping -import hashlib +from collections.abc import Mapping import logging from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry as dr, entity_registry as er, issue_registry as ir +from homeassistant.helpers import issue_registry as ir from .const import DOMAIN, EVENT_SCHEMA_ISSUE from .schema_validation import SchemaFindings -from .util import ADOPTED_IDENTIFIER_TOKEN _LOGGER = logging.getLogger(__name__) @@ -45,6 +43,13 @@ # new-entity notice is deliberately not among them. See `_scoped_issue_ids`. _DEFECT_PREFIXES = ("unresolved_", "unit_mismatch_") _NEW_ENTITIES_PREFIX = "new_entities_" +"""Id prefix of the retired new-entity Repair. + +Nothing raises one any more -- an addition is not a repair, and it is announced +as a notification by `additions` instead. The prefix survives so the ones already +standing on upgraded installs get cleared: they were raised `is_persistent`, so +without this they would outlive the mechanism that made them. +""" def _unresolved_id(entry_id: str, field_path: str) -> str: @@ -55,25 +60,6 @@ def _unit_id(entry_id: str, field_path: str) -> str: return f"unit_mismatch_{entry_id}_{field_path}" -def _new_entities_id(entry_id: str, unique_ids: Collection[str]) -> str: - """One id per (entry, exact set of new entities). - - Keyed on the set and not on the entry alone for the reason the two defect - notices are keyed per field path: `async_get_or_create`'s update branch - preserves `dismissed_version` while replacing the placeholders, so an entry- - wide id would let a user who dismissed "Part Number appeared" never be told - about the next addition — it would silently rewrite the notice they already - put away. - - Truncated to 12 hex characters. The digest only has to separate one set from - another within a single config entry, and the id ends up in a storage file a - human occasionally reads. - """ - joined = "\n".join(sorted(unique_ids)) - digest = hashlib.sha256(joined.encode()).hexdigest()[:12] - return f"{_NEW_ENTITIES_PREFIX}{entry_id}_{digest}" - - def _affected(entity_ids_by_path: Mapping[str, list[str]], field_path: str) -> list[str]: """Entities in hass that read this field, sorted. @@ -201,161 +187,6 @@ def async_sync_schema_issues( @callback -def async_registered_unique_ids(hass: HomeAssistant, entry: ConfigEntry) -> frozenset[str]: - """Return the unique_ids already registered for this entry. - - Taken by `async_setup_entry` immediately before the platforms are forwarded, - which is the last moment "already registered" still means "registered by an - earlier run". Unique_ids rather than entity_ids: an entity_id is the user's - to rename, a unique_id is the identity the registry itself keys on. - """ - return frozenset( - registry_entry.unique_id - for registry_entry in er.async_entries_for_config_entry(er.async_get(hass), entry.entry_id) - ) - - -def _label(registry_entry: er.RegistryEntry) -> str: - """Return what to call an entity the user has never seen. - - A disabled entity has no state, so there is no friendly name to read off the - state machine — only what the registry recorded when the platform added it. - The entity_id is the last resort rather than the first choice because it is - the name the user will *not* see in the device's disabled-entity list. - """ - return registry_entry.name or registry_entry.original_name or registry_entry.entity_id - - -def _labels(hass: HomeAssistant, new_disabled: list[er.RegistryEntry]) -> list[str]: - """Return what to call the new entities, with adopted ones counted rather than listed. - - A curated addition is a handful of entities a maintainer chose, so naming each - one is the notice doing its job. An adopted device is the opposite shape: - every property a vendor device declares becomes an entity at once, so listing - them would spend the whole notice on one device and teach the user that this - category is noise -- which would cost them the curated additions too. - - So an adopted device contributes exactly one line carrying its own count, and - the notice reads "Backup Generator (6 entities)" beside whatever else the - release added. - """ - devices = dr.async_get(hass) - adopted: dict[str, int] = {} - labels: list[str] = [] - for registry_entry in new_disabled: - device_name = _adopted_device_name(devices, registry_entry) - if device_name is None: - labels.append(_label(registry_entry)) - else: - adopted[device_name] = adopted.get(device_name, 0) + 1 - labels.extend(f"{name} ({count} entities)" for name, count in adopted.items()) - return sorted(labels) - - -def _adopted_device_name( - devices: dr.DeviceRegistry, registry_entry: er.RegistryEntry -) -> str | None: - """Return the adopted device this entity belongs to, or None when it belongs to none. - - Read off the device identifier rather than off the entity, because the entity - carries nothing that distinguishes an adopted reading from a curated one -- - by design, since an adopted entity is meant to be indistinguishable once it - is enabled. - """ - if registry_entry.device_id is None: - return None - device = devices.async_get(registry_entry.device_id) - if device is None: - return None - token = f"_{ADOPTED_IDENTIFIER_TOKEN}_" - if not any(token in identifier for _domain, identifier in device.identifiers): - return None - return device.name_by_user or device.name or registry_entry.entity_id - - -@callback -def async_notice_new_disabled_entities( - hass: HomeAssistant, - entry: ConfigEntry, - known_unique_ids: Collection[str], -) -> None: - """Tell the user about entities this setup added and disabled. - - A release that adds a diagnostic with `entity_registry_enabled_default=False` - grows nobody's entity list, which is the point — and is also why the addition - reaches the user through nothing at all. This is the notice for that: the - entities exist, they are switched off, and here is what they are called. - - Silent on a first install. With nothing registered beforehand every entity is - new, so the notice would name the entire integration and teach the user to - ignore it. An empty `known_unique_ids` is the probe for that: - `er.async_entries_for_config_entry` answers with nothing for an entry that - has never registered anything. - - An EVENT, not a condition, and that shapes three decisions: - - * `is_persistent=True`, unlike the two defect notices. Those are re-derived - from live state at every startup, so they can afford to reload as - tombstones. This one cannot be re-derived at all: on the next startup the - entity is in `known_unique_ids` and the diff is empty by construction. A - non-persistent issue would therefore vanish unread at the first restart - after the upgrade, which for a user who was away is the same silent add - the notice exists to prevent. - * It is never reconciled away. `async_sync_schema_issues` deletes the defect - ids it does not re-derive, and applying that here would delete this notice - on the very next startup. Its reconcile scope is `_DEFECT_PREFIXES`, which - deliberately does not include this one. - * Raising is skipped outright when the id already exists. What that buys is - narrow and worth stating exactly: a repeat of the same set cannot rewrite - the text of a notice the user has already read. Without it the repeat would - take `async_get_or_create`'s update branch, which replaces the placeholders - — so a set that came back with a renamed entity would silently restate - itself. It buys nothing against duplication, which the shared id already - rules out. - - Severity is the mildest Home Assistant offers. `IssueSeverity` has no - informational member — it is CRITICAL, ERROR, WARNING — so WARNING is the - floor, the same floor `panel_upgraded_to_ebus_v1` settled on for the same - reason: nothing is broken and no action is required. - """ - if not known_unique_ids: - _LOGGER.debug( - "Suppressed new-entity notice for %s: nothing was registered before this " - "setup, so this is a first install and every entity is new", - entry.entry_id, - ) - return - - new_disabled = [ - registry_entry - for registry_entry in er.async_entries_for_config_entry(er.async_get(hass), entry.entry_id) - if registry_entry.unique_id not in known_unique_ids - and registry_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - ] - if not new_disabled: - return - - issue_id = _new_entities_id( - entry.entry_id, [registry_entry.unique_id for registry_entry in new_disabled] - ) - if ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is not None: - _LOGGER.debug("New-entity notice %s already raised; leaving it alone", issue_id) - return - - labels = _labels(hass, new_disabled) - _LOGGER.debug("Raising new-entity notice %s for %s", issue_id, labels) - ir.async_create_issue( - hass, - DOMAIN, - issue_id, - is_fixable=False, - is_persistent=True, - severity=ir.IssueSeverity.WARNING, - translation_key="new_entities_disabled", - translation_placeholders=_placeholders(labels), - ) - - def _scoped_issue_ids( registry: ir.IssueRegistry, entry_id: str, prefixes: tuple[str, ...] ) -> set[str]: @@ -380,6 +211,21 @@ def _scoped_issue_ids( } +@callback +def async_clear_retired_new_entity_notices(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Delete new-entity Repairs raised before additions became notifications. + + They were raised `is_persistent=True` precisely so a restart could not sweep + them away, which now means an upgraded install keeps one standing in its + Repairs list forever with nothing left to re-derive it. Cleared at setup + rather than at removal, because the user is looking at it now. + """ + registry = ir.async_get(hass) + for issue_id in _scoped_issue_ids(registry, entry.entry_id, (_NEW_ENTITIES_PREFIX,)): + _LOGGER.debug("Clearing retired new-entity notice %s", issue_id) + ir.async_delete_issue(hass, DOMAIN, issue_id) + + @callback def async_clear_schema_issues(hass: HomeAssistant, entry: ConfigEntry) -> None: """Remove one entry's issues. Core does not do this on entry removal. diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index 47968d20..10936f33 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -765,10 +765,6 @@ } }, "issues": { - "new_entities_disabled": { - "title": "New SPAN Panel entities are available", - "description": "New entities added and left **disabled**: {count} (for example: {examples}). Nothing appeared in your dashboards or history uninvited.\n\nThis count covers only entities that were switched off. Anything added enabled is already visible in your entity list and history.\n\nEntities can appear for several reasons: an integration update that exposes more of what your panel reports, a circuit added to the panel, or hardware such as a battery or solar coming online. Nothing is broken and no action is required — this notice exists only so the addition is not silent.\n\nTo use one, open the SPAN Panel device page, show its disabled entities, and enable the ones you want. Dismiss this once you have had a look." - }, "panel_upgraded_to_ebus_v1": { "title": "Your SPAN Panel now reports islanding from its Microgrid Interconnect Device", "description": "Your SPAN Panel upgraded to the eBus v1.0 data model. Nothing you rely on has gone away, and no automation changes are required — this is a note about what improved.\n\n**DSM Grid State** keeps its entity ID and its history, and is now more trustworthy. On the previous firmware it was *inferred* — from the battery when one was fitted, otherwise from the dominant power source and whether any power was crossing the grid connection. It now reads the islanding state the Microgrid Interconnect Device (MID) actually senses.\n\n**Grid Islandable** also keeps working. v1.0 publishes no panel-level `grid-islandable` property, so it now reflects whether a MID is present, which is how v1.0 says backup capability is detected.\n\n**New:** a Microgrid Interconnect Device appears among your devices, carrying **Grid State** — the health of the utility supply itself, which the previous firmware did not report.\n\nDismiss this once you have had a look." @@ -781,5 +777,16 @@ "title": "SPAN Panel unit mismatch", "description": "Your panel reports `{field_path}` in {schema_unit}, but this integration expects {ha_unit}. Entities affected: {count} (for example: {examples}). Their readings and long-term statistics may be wrong. They have not been changed automatically." } + }, + "notifications": { + "new_entities": { + "title": "SPAN Panel added new entities", + "intro_one": "This update added 1 new entity to your SPAN Panel.", + "intro_many": "This update added {count} new entities to your SPAN Panel.", + "enabled_heading": "Added and ready to use", + "disabled_heading": "Added but switched off", + "how_to_enable": "Switched-off entities record nothing until you turn them on. Open the SPAN Panel device page, show its disabled entities, and enable the ones you want.", + "nothing_broken": "Nothing is broken and no action is required. Entities appear for several reasons: an update that surfaces more of what your panel reports, a circuit added to the panel, or hardware such as a battery, solar or a charger coming online." + } } } diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index 47968d20..10936f33 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -765,10 +765,6 @@ } }, "issues": { - "new_entities_disabled": { - "title": "New SPAN Panel entities are available", - "description": "New entities added and left **disabled**: {count} (for example: {examples}). Nothing appeared in your dashboards or history uninvited.\n\nThis count covers only entities that were switched off. Anything added enabled is already visible in your entity list and history.\n\nEntities can appear for several reasons: an integration update that exposes more of what your panel reports, a circuit added to the panel, or hardware such as a battery or solar coming online. Nothing is broken and no action is required — this notice exists only so the addition is not silent.\n\nTo use one, open the SPAN Panel device page, show its disabled entities, and enable the ones you want. Dismiss this once you have had a look." - }, "panel_upgraded_to_ebus_v1": { "title": "Your SPAN Panel now reports islanding from its Microgrid Interconnect Device", "description": "Your SPAN Panel upgraded to the eBus v1.0 data model. Nothing you rely on has gone away, and no automation changes are required — this is a note about what improved.\n\n**DSM Grid State** keeps its entity ID and its history, and is now more trustworthy. On the previous firmware it was *inferred* — from the battery when one was fitted, otherwise from the dominant power source and whether any power was crossing the grid connection. It now reads the islanding state the Microgrid Interconnect Device (MID) actually senses.\n\n**Grid Islandable** also keeps working. v1.0 publishes no panel-level `grid-islandable` property, so it now reflects whether a MID is present, which is how v1.0 says backup capability is detected.\n\n**New:** a Microgrid Interconnect Device appears among your devices, carrying **Grid State** — the health of the utility supply itself, which the previous firmware did not report.\n\nDismiss this once you have had a look." @@ -781,5 +777,16 @@ "title": "SPAN Panel unit mismatch", "description": "Your panel reports `{field_path}` in {schema_unit}, but this integration expects {ha_unit}. Entities affected: {count} (for example: {examples}). Their readings and long-term statistics may be wrong. They have not been changed automatically." } + }, + "notifications": { + "new_entities": { + "title": "SPAN Panel added new entities", + "intro_one": "This update added 1 new entity to your SPAN Panel.", + "intro_many": "This update added {count} new entities to your SPAN Panel.", + "enabled_heading": "Added and ready to use", + "disabled_heading": "Added but switched off", + "how_to_enable": "Switched-off entities record nothing until you turn them on. Open the SPAN Panel device page, show its disabled entities, and enable the ones you want.", + "nothing_broken": "Nothing is broken and no action is required. Entities appear for several reasons: an update that surfaces more of what your panel reports, a circuit added to the panel, or hardware such as a battery, solar or a charger coming online." + } } } diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index b572d0a1..492ba61a 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -765,10 +765,6 @@ } }, "issues": { - "new_entities_disabled": { - "title": "Hay nuevas entidades del Panel SPAN disponibles", - "description": "Entidades nuevas creadas **deshabilitadas**: {count} (por ejemplo: {examples}). No ha aparecido nada en sus paneles de control ni en su historial sin haberlo pedido.\n\nEste recuento incluye solo las entidades que se crearon desactivadas. Todo lo que se añade habilitado ya es visible en su lista de entidades y en su historial.\n\nPueden aparecer entidades nuevas por varios motivos: una actualización de la integración que expone más de lo que informa su panel, un circuito añadido al panel, o hardware como una batería o energía solar que se pone en marcha. No hay nada averiado ni es necesaria ninguna acción: este aviso existe solo para que la novedad no pase desapercibida.\n\nPara usar alguna, abra la página del dispositivo Panel SPAN, muestre sus entidades deshabilitadas y habilite las que desee. Descarte este aviso cuando lo haya revisado." - }, "panel_upgraded_to_ebus_v1": { "title": "Su Panel SPAN ahora informa del aislamiento desde su Dispositivo de Interconexión de Microrred", "description": "Su Panel SPAN se ha actualizado al modelo de datos eBus v1.0. Nada de lo que usted utiliza ha desaparecido y no es necesario modificar ninguna automatización: este aviso solo explica lo que ha mejorado.\n\n**Estado de Red DSM** conserva su ID de entidad y su historial, y ahora es más fiable. Con el firmware anterior se *deducía*: a partir de la batería cuando había una instalada y, en su defecto, de la fuente de energía dominante y de si circulaba potencia por la conexión a la red. Ahora lee el estado de aislamiento que el Dispositivo de Interconexión de Microrred (MID) detecta realmente.\n\n**Aislable de la Red** también sigue funcionando. La v1.0 no publica ninguna propiedad `grid-islandable` a nivel de panel, por lo que ahora refleja si hay un MID presente, que es como la v1.0 determina la capacidad de respaldo.\n\n**Novedad:** aparece un Dispositivo de Interconexión de Microrred entre sus dispositivos, con **Estado de la Red**, es decir, el estado del suministro eléctrico, que el firmware anterior no informaba.\n\nPuede descartar este aviso cuando lo haya revisado." @@ -781,5 +777,16 @@ "title": "Discrepancia de unidades del Panel SPAN", "description": "Su panel informa de `{field_path}` en {schema_unit}, pero esta integración espera {ha_unit}. Entidades afectadas: {count} (por ejemplo: {examples}). Sus lecturas y estadísticas a largo plazo pueden ser incorrectas. No se han modificado automáticamente." } + }, + "notifications": { + "new_entities": { + "title": "El Panel SPAN ha añadido nuevas entidades", + "intro_one": "Esta actualización añadió 1 entidad nueva a tu Panel SPAN.", + "intro_many": "Esta actualización añadió {count} entidades nuevas a tu Panel SPAN.", + "enabled_heading": "Añadidas y listas para usar", + "disabled_heading": "Añadidas pero desactivadas", + "how_to_enable": "Las entidades desactivadas no registran nada hasta que las actives. Abre la página del dispositivo Panel SPAN, muestra sus entidades desactivadas y activa las que quieras.", + "nothing_broken": "No hay ningún problema y no se requiere ninguna acción. Las entidades aparecen por varios motivos: una actualización que expone más de lo que informa tu panel, un circuito añadido al panel, o hardware como una batería, paneles solares o un cargador que se pone en línea." + } } } diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index 3fce2ae9..438e943c 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -765,10 +765,6 @@ } }, "issues": { - "new_entities_disabled": { - "title": "De nouvelles entités du Panneau SPAN sont disponibles", - "description": "Nouvelles entités créées **désactivées** : {count} (par exemple : {examples}). Rien n'est apparu dans vos tableaux de bord ni dans votre historique sans votre accord.\n\nCe décompte ne couvre que les entités créées désactivées. Tout ce qui est ajouté activé est déjà visible dans votre liste d'entités et votre historique.\n\nDe nouvelles entités peuvent apparaître pour plusieurs raisons : une mise à jour de l'intégration qui expose davantage de ce que votre panneau signale, un circuit ajouté au panneau, ou du matériel tel qu'une batterie ou du solaire qui entre en service. Rien n'est cassé et aucune action n'est requise : cet avis existe uniquement pour que cet ajout ne soit pas silencieux.\n\nPour en utiliser une, ouvrez la page de l'appareil Panneau SPAN, affichez ses entités désactivées et activez celles que vous souhaitez. Ignorez cet avis une fois que vous y avez jeté un œil." - }, "panel_upgraded_to_ebus_v1": { "title": "Votre Panneau SPAN signale désormais l'îlotage depuis son Dispositif d'Interconnexion de Micro-réseau", "description": "Votre Panneau SPAN est passé au modèle de données eBus v1.0. Rien de ce que vous utilisez n'a disparu et aucune modification de vos automatisations n'est nécessaire : cet avis explique simplement ce qui s'est amélioré.\n\n**État Réseau DSM** conserve son identifiant d'entité et son historique, et gagne en fiabilité. Avec le micrologiciel précédent, il était *déduit* : à partir de la batterie lorsqu'il y en avait une, sinon à partir de la source d'énergie dominante et de la présence de puissance transitant par le raccordement réseau. Il lit désormais l'état d'îlotage réellement mesuré par le Dispositif d'Interconnexion de Micro-réseau (MID).\n\n**Îlotage Réseau Possible** continue également de fonctionner. La v1.0 ne publie aucune propriété `grid-islandable` au niveau du panneau ; ce capteur indique donc maintenant la présence d'un MID, ce qui est la façon dont v1.0 détermine la capacité de secours.\n\n**Nouveau :** un Dispositif d'Interconnexion de Micro-réseau apparaît parmi vos appareils, avec **État du Réseau** — l'état de l'alimentation du réseau public, que le micrologiciel précédent ne signalait pas.\n\nVous pouvez ignorer cet avis après consultation." @@ -781,5 +777,16 @@ "title": "Incohérence d'unité du Panneau SPAN", "description": "Votre panneau signale `{field_path}` en {schema_unit}, alors que cette intégration attend {ha_unit}. Entités concernées : {count} (par exemple : {examples}). Leurs relevés et leurs statistiques à long terme peuvent être erronés. Elles n'ont pas été modifiées automatiquement." } + }, + "notifications": { + "new_entities": { + "title": "Le Panneau SPAN a ajouté de nouvelles entités", + "intro_one": "Cette mise à jour a ajouté 1 nouvelle entité à votre Panneau SPAN.", + "intro_many": "Cette mise à jour a ajouté {count} nouvelles entités à votre Panneau SPAN.", + "enabled_heading": "Ajoutées et prêtes à l'emploi", + "disabled_heading": "Ajoutées mais désactivées", + "how_to_enable": "Les entités désactivées n'enregistrent rien tant que vous ne les activez pas. Ouvrez la page de l'appareil Panneau SPAN, affichez ses entités désactivées et activez celles que vous souhaitez.", + "nothing_broken": "Rien n'est cassé et aucune action n'est requise. Des entités apparaissent pour plusieurs raisons : une mise à jour qui expose davantage de ce que votre panneau rapporte, un circuit ajouté au panneau, ou du matériel tel qu'une batterie, du solaire ou un chargeur qui se met en ligne." + } } } diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index 54ef81d8..bf45da3d 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -765,10 +765,6 @@ } }, "issues": { - "new_entities_disabled": { - "title": "SPAN パネルの新しいエンティティが利用可能です", - "description": "SPAN パネルに {count} 個の新しいエンティティが現れました (例: {examples})。これらは**無効な状態**で作成されているため、ダッシュボードや履歴に無断で何かが現れることはありません。\n\nこの件数には、無効な状態で作成されたエンティティのみが含まれます。有効な状態で追加されたものは、すでにエンティティ一覧や履歴に表示されています。\n\n新しいエンティティが現れる理由はいくつかあります。パネルが報告する内容をより多く公開する統合の更新、パネルに追加された回路、あるいはバッテリーや太陽光などのハードウェアの稼働開始などです。故障ではなく、対応も不要です。この通知は、追加が知らされないままにならないようにするためだけのものです。\n\n使用するには、SPAN パネルのデバイスページを開き、無効なエンティティを表示して、必要なものを有効にしてください。確認が済んだらこの通知を閉じてください。" - }, "panel_upgraded_to_ebus_v1": { "title": "SPAN パネルは自立運転状態をマイクログリッド相互接続装置から取得するようになりました", "description": "SPAN パネルが eBus v1.0 データモデルにアップグレードされました。ご利用中のものが失われることはなく、オートメーションの変更も必要ありません。この通知は改善点をお知らせするものです。\n\n**DSM 系統状態** はエンティティ ID と履歴をそのまま維持し、さらに信頼性が向上しました。以前のファームウェアでは *推定* されていました。バッテリーがある場合はバッテリーから、ない場合は主電源と系統接続を流れる電力の有無から判断していました。現在はマイクログリッド相互接続装置 (MID) が実際に検知した自立運転状態を読み取ります。\n\n**系統自立運転可能** も引き続き動作します。v1.0 ではパネル単位の `grid-islandable` プロパティは公開されないため、現在は MID の有無を示します。これが v1.0 におけるバックアップ能力の判定方法です。\n\n**新規:** デバイス一覧にマイクログリッド相互接続装置が追加され、**系統状態** (電力会社からの供給状態) を報告します。これは以前のファームウェアにはなかった情報です。\n\n内容を確認したら、この通知は閉じて構いません。" @@ -781,5 +777,16 @@ "title": "SPAN パネルの単位の不一致", "description": "パネルは `{field_path}` を {schema_unit} で報告していますが、この統合は {ha_unit} を想定しています。{count} 個のエンティティが影響を受けます (例: {examples})。これらの測定値と長期統計が誤っている可能性があります。エンティティは自動的には変更されていません。" } + }, + "notifications": { + "new_entities": { + "title": "SPAN パネルに新しいエンティティが追加されました", + "intro_one": "このアップデートで SPAN パネルに新しいエンティティが 1 個追加されました。", + "intro_many": "このアップデートで SPAN パネルに新しいエンティティが {count} 個追加されました。", + "enabled_heading": "追加され、すぐに使用できます", + "disabled_heading": "追加されましたが無効です", + "how_to_enable": "無効なエンティティは、有効にするまで何も記録しません。SPAN パネルのデバイスページを開き、無効なエンティティを表示して、必要なものを有効にしてください。", + "nothing_broken": "問題はなく、対応は不要です。エンティティが追加される理由はいくつかあります: パネルが報告する内容をより多く公開するアップデート、パネルに追加された回路、またはバッテリー、太陽光、充電器などのハードウェアがオンラインになった場合です。" + } } } diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index fc8208f0..fcd4649b 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -765,10 +765,6 @@ } }, "issues": { - "new_entities_disabled": { - "title": "Estão disponíveis novas entidades do Painel SPAN", - "description": "Entidades novas criadas **desativadas**: {count} (por exemplo: {examples}). Nada apareceu nos seus painéis nem no seu histórico sem ter sido pedido.\n\nEsta contagem inclui apenas as entidades criadas desativadas. Tudo o que é acrescentado ativado já está visível na sua lista de entidades e no seu histórico.\n\nPodem aparecer novas entidades por vários motivos: uma atualização da integração que expõe mais do que o seu painel comunica, um circuito acrescentado ao painel, ou hardware como uma bateria ou energia solar que entra em funcionamento. Não há nada avariado e não é necessária qualquer ação: este aviso existe apenas para que a novidade não passe despercebida.\n\nPara usar alguma, abra a página do dispositivo Painel SPAN, mostre as suas entidades desativadas e ative as que quiser. Dispense este aviso depois de o ter visto." - }, "panel_upgraded_to_ebus_v1": { "title": "O seu Painel SPAN comunica agora o ilhamento a partir do Dispositivo de Interligação de Microrrede", "description": "O seu Painel SPAN foi atualizado para o modelo de dados eBus v1.0. Nada do que utiliza desapareceu e não é necessário alterar automações — este aviso apenas explica o que melhorou.\n\n**Estado da Rede DSM** mantém o seu ID de entidade e o seu histórico, e passa a ser mais fiável. No firmware anterior era *inferido*: a partir da bateria quando existia uma e, caso contrário, a partir da fonte de energia dominante e da existência de potência a atravessar a ligação à rede. Agora lê o estado de ilhamento que o Dispositivo de Interligação de Microrrede (MID) realmente deteta.\n\n**Isolável da Rede** também continua a funcionar. A v1.0 não publica qualquer propriedade `grid-islandable` ao nível do painel, pelo que agora reflete a presença de um MID, que é como a v1.0 determina a capacidade de reserva.\n\n**Novidade:** aparece um Dispositivo de Interligação de Microrrede entre os seus dispositivos, com **Estado da Rede** — o estado do fornecimento da rede elétrica, que o firmware anterior não comunicava.\n\nPode dispensar este aviso depois de o consultar." @@ -781,5 +777,16 @@ "title": "Discrepância de unidades do Painel SPAN", "description": "O seu painel comunica `{field_path}` em {schema_unit}, mas esta integração espera {ha_unit}. Entidades afetadas: {count} (por exemplo: {examples}). As suas leituras e estatísticas de longo prazo podem estar erradas. Não foram alteradas automaticamente." } + }, + "notifications": { + "new_entities": { + "title": "O Painel SPAN adicionou novas entidades", + "intro_one": "Esta atualização adicionou 1 nova entidade ao seu Painel SPAN.", + "intro_many": "Esta atualização adicionou {count} novas entidades ao seu Painel SPAN.", + "enabled_heading": "Adicionadas e prontas a usar", + "disabled_heading": "Adicionadas mas desativadas", + "how_to_enable": "As entidades desativadas não registam nada até as ativar. Abra a página do dispositivo Painel SPAN, mostre as suas entidades desativadas e ative as que quiser.", + "nothing_broken": "Nada está avariado e não é necessária qualquer ação. As entidades aparecem por vários motivos: uma atualização que expõe mais do que o seu painel reporta, um circuito adicionado ao painel, ou hardware como uma bateria, solar ou um carregador a ficar online." + } } } diff --git a/developer.md b/developer.md index 9eef3d9d..dc91db48 100644 --- a/developer.md +++ b/developer.md @@ -296,6 +296,15 @@ entity they chose to enable. `device_class` is enumerated in `DEVICE_CLASS_BY_UNIT` rather than inferred. A unit outside the map gets **no** device class — `%` is deliberately absent, because its uses here are a state of charge, a confidence and a duty cycle, and no single class is right for all of them. +### The device exists even with no entities + +`async_register_adopted_devices` registers each adopted device explicitly, before the platforms are forwarded, rather than letting it fall out of entity +creation. The reason is a device that has no entities to fall out of: a vendor device publishing only an `info` node resolves entirely to the device card by the +node rule, creates no entity, and so had nothing to call `async_get_or_create` for it. It produced _nothing at all_ — no device, no entity, no notification. + +Running it before the platforms also makes the identity freeze single-valued: `resolve_identifier` runs once, at registration, so every entity created +afterwards resolves against a device that already exists and cannot disagree. + ### Identity freezes at first sighting `resolve_identifier` looks up **both** candidate spellings — `{panel serial}_adopted_{wire id}` and `{panel serial}_adopted_{serial}` — before minting either, @@ -356,11 +365,33 @@ on whether to accept it. A `NUMBER` on an `integer` property publishes `45`, nev Diagnostics report `adopted_devices.controls` — how many adopted properties write back rather than only reporting. -### The notice counts devices, not entities +### Telling the user what was added + +Additions are announced by `additions.async_announce_new_entities` as a **persistent notification**, not a Repair. An addition is not a repair: nothing is +broken and nothing needs fixing, and filing it under Repairs puts it in a category whose whole meaning is "something went wrong". The retired +`new_entities_disabled` issue is deleted at setup by `async_clear_retired_new_entity_notices`, because it was raised `is_persistent` and would otherwise stand +forever on an upgraded install with nothing left to re-derive it. + +Three things it does that the Repair did not: + +- **Enabled additions are announced too.** The old notice covered only `disabled_by=INTEGRATION`, reasoning that an enabled entity is already visible in the + entity list and its history. Nobody watches their entity count, so that reasoning made every enabled addition invisible. +- **It names every entity**, rather than a count plus three examples. "What exactly was added" means all of it. +- **The record is durable.** The old diff compared the registry before the platforms against the registry after, which answers correctly exactly once — on the + next startup the entity is already registered beforehand and the diff is empty by construction. `additions` records what it announced in a `Store`, so the + question is "has this been announced" rather than "was this registered in the last few seconds". + +It stays silent on a first install, and silent once more on the first run after this mechanism ships: an install predating the record has entities that were +never announced but are not new either, so the first pass adopts them as known. + +Adopted devices are collapsed to one line with a count — `Backup Generator (6 entities)` — for the reason the whole design is device-scoped: a vendor device +declaring a dozen properties would otherwise spend the entire notification on itself and teach the user to skip it, costing them the curated additions in the +same message. -Adopted entities are disabled, so they reach the user only through `async_notice_new_disabled_entities`. That notice lists curated additions individually and -collapses each adopted device to one line with a count — `Backup Generator (6 entities)`. A vendor device declaring a dozen properties would otherwise spend the -whole notice on itself and teach the user that the category is noise, which would cost them the curated additions too. +**Translations are read from this component's own `translations/` directory**, not through `homeassistant.helpers.translation`. That helper filters to the +categories Home Assistant defines, and a persistent notification is not one of them — a custom category loads as nothing at all, which was verified rather than +assumed. The strings live under a `notifications` key in `strings.json` and all five locales, with English constants in `additions._FALLBACK` so an unreadable +file costs the translation and not the notification. ### Diagnostics diff --git a/tests/test_adoption.py b/tests/test_adoption.py index 5ef7e20f..7784103e 100644 --- a/tests/test_adoption.py +++ b/tests/test_adoption.py @@ -19,6 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry from span_panel_api import AdoptedDevice, AdoptedProperty from custom_components.span_panel.adoption import ( @@ -26,6 +27,7 @@ DEVICE_CLASS_BY_UNIT, adopted_control_count, adopted_identifier, + async_register_adopted_devices, classify, create_adopted_binary_sensors, create_adopted_numbers, @@ -48,6 +50,26 @@ PANEL_SERIAL = "sp3-242424-001" +@pytest.fixture +def registered_panel(hass: HomeAssistant) -> tuple[str, str]: + """Return a config entry and a registered panel device, as setup would leave them. + + Both are required rather than convenient: the device registry refuses to link + a device to an unknown config entry, and refuses a `via_device_id` naming a + device that does not exist. An adopted device is a sub-device of the panel, so + the panel has to be there first -- which is exactly why registration runs + after `ensure_device_registered` and before the platforms. + """ + mock = MockConfigEntry(domain=DOMAIN, data={}, unique_id=PANEL_SERIAL) + mock.add_to_hass(hass) + panel = dr.async_get(hass).async_get_or_create( + config_entry_id=mock.entry_id, + identifiers={(DOMAIN, PANEL_SERIAL)}, + name="Span Panel", + ) + return str(mock.entry_id), panel.id + + def _property( node_id: str = "meter", property_id: str = "active-power", @@ -466,3 +488,72 @@ def test_a_control_is_disabled_and_diagnostic_like_every_other_adopted_entity(ha assert len(controls) == 3 assert all(entity.entity_registry_enabled_default is False for entity in controls) assert all(entity.entity_category is EntityCategory.DIAGNOSTIC for entity in controls) + + +# -- An adopted device exists even when it has no readings ------------------- + + +def test_a_device_whose_whole_declaration_is_info_still_gets_a_card(hass: HomeAssistant, registered_panel: tuple[str, str]) -> None: + """The gap explicit registration closes. + + `info` resolves entirely to the device card by the node rule, so a vendor + device that advertises what it is before publishing any reading creates no + entity -- and devices are otherwise created as a side effect of entity + creation, so it used to produce *nothing at all*: no device, no entity, no + notification. Which is the silence adoption exists to end, reached by a + different route. + """ + entry_id, panel_device_id = registered_panel + device = AdoptedDevice( + device_id="generator-1", + device_type="energy.ebus.device.generator", + name="Backup Generator", + model="GEN-9000", + vendor_name="Example Power", + software_version="3.2.1", + properties=(), + ) + snapshot = _snapshot(device) + + async_register_adopted_devices(hass, entry_id, snapshot, panel_device_id=panel_device_id) + + registered = dr.async_get(hass).async_get_device( + identifiers={(DOMAIN, adopted_identifier(PANEL_SERIAL, "generator-1"))} + ) + assert registered is not None + assert registered.name == "Backup Generator" + assert registered.model == "GEN-9000" + assert registered.manufacturer == "Example Power" + assert registered.sw_version == "3.2.1" + + +def test_registration_freezes_the_anchor_before_any_entity_resolves_it(hass: HomeAssistant, registered_panel: tuple[str, str]) -> None: + """Registering first is what makes the freeze single-valued. + + `resolve_identifier` reads the registry to decide which spelling this install + uses. Running it once at registration means every entity created afterwards + resolves against a device that already exists and cannot disagree -- including + on the run where a serial first arrives. + """ + entry_id, panel_device_id = registered_panel + without_serial = _device("generator-1") + async_register_adopted_devices(hass, entry_id, _snapshot(without_serial), panel_device_id=panel_device_id) + + with_serial = _device("generator-1", serial_number="EX-0000-0001") + async_register_adopted_devices(hass, entry_id, _snapshot(with_serial), panel_device_id=panel_device_id) + + registry = dr.async_get(hass) + assert registry.async_get_device(identifiers={(DOMAIN, adopted_identifier(PANEL_SERIAL, "generator-1"))}) + assert registry.async_get_device(identifiers={(DOMAIN, adopted_identifier(PANEL_SERIAL, "EX-0000-0001"))}) is None + + +def test_a_panel_with_no_adopted_device_registers_nothing(hass: HomeAssistant, registered_panel: tuple[str, str]) -> None: + entry_id, panel_device_id = registered_panel + async_register_adopted_devices(hass, entry_id, _snapshot(), panel_device_id=panel_device_id) + + adopted = [ + device + for device in dr.async_get(hass).devices.values() + if any(ADOPTED_IDENTIFIER_TOKEN in identifier for _domain, identifier in device.identifiers) + ] + assert adopted == [] diff --git a/tests/test_new_entity_announcement.py b/tests/test_new_entity_announcement.py new file mode 100644 index 00000000..b395c24b --- /dev/null +++ b/tests/test_new_entity_announcement.py @@ -0,0 +1,268 @@ +"""An entity this integration adds must not arrive silently, switched on or off. + +`battery.part_number` shipped with `entity_registry_enabled_default=False` so +upgrading would not grow anybody's entity list uninvited. It worked, and the cost +was that nothing told the user the sensor existed -- they found it by opening the +device's disabled-entity list on a hunch. + +The first fix covered only *disabled* additions, on the reasoning that an enabled +one is already visible in the entity list and its history. That reasoning does +not survive contact with how anyone uses Home Assistant: nobody watches their +entity count, so an addition that breaks nothing is indistinguishable from no +addition at all. These cover both, and the four ways the announcement could be +worse than nothing -- shouting on a first install, shouting a release's worth of +history on upgrade, nagging on every restart, or naming a hundred entities at +once. +""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.span_panel.additions import ( + _read_translations, + async_announce_new_entities, + async_forget_announcements, +) +from custom_components.span_panel.const import DOMAIN + +_PART_NUMBER = "sp3-001_bess_part_number" + + +@pytest.fixture +def entry(hass: HomeAssistant) -> MockConfigEntry: + """Return a config entry in hass. No conftest fixture exists for this.""" + mock = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sp3-001") + mock.add_to_hass(hass) + return mock + + +def _register( + hass: HomeAssistant, + entry: MockConfigEntry, + unique_id: str, + *, + disabled: bool = True, + name: str | None = None, + device_id: str | None = None, +) -> er.RegistryEntry: + """Register one entity the way a platform would.""" + return er.async_get(hass).async_get_or_create( + "sensor", + DOMAIN, + unique_id, + config_entry=entry, + original_name=name, + device_id=device_id, + disabled_by=er.RegistryEntryDisabler.INTEGRATION if disabled else None, + ) + + +def _notifications(hass: HomeAssistant) -> dict[str, Any]: + """Every persistent notification currently standing, by id.""" + return dict(hass.data.get("persistent_notification", {})) + + +def _announcement(hass: HomeAssistant, entry: MockConfigEntry) -> Any | None: + return _notifications(hass).get(f"{DOMAIN}_new_entities_{entry.entry_id}") + + +def _text(notification: Any) -> str: + message = notification.message if hasattr(notification, "message") else notification["message"] + return str(message) + + +# -- Silence where silence is right ------------------------------------------ + + +async def test_a_first_install_announces_nothing(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Every entity is new on a first install, so the notice would name them all. + + Which would teach the user that this category is noise, and cost them the + real additions later. + """ + _register(hass, entry, _PART_NUMBER, name="Part Number") + + await async_announce_new_entities(hass, entry) + + assert _announcement(hass, entry) is None + + +async def test_an_install_that_predates_the_record_announces_nothing_once( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Entities that were never announced are not therefore new. + + The first pass adopts what is already registered as known. Without it, the + release that ships this mechanism would announce every entity the integration + has ever created. + """ + for index in range(5): + _register(hass, entry, f"sp3-001_existing_{index}", name=f"Existing {index}") + + await async_announce_new_entities(hass, entry) + + assert _announcement(hass, entry) is None + + +async def test_a_restart_that_adds_nothing_announces_nothing(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """The failure that would make the whole thing worse than useless.""" + _register(hass, entry, _PART_NUMBER, name="Part Number") + await async_announce_new_entities(hass, entry) + + for _ in range(3): + await async_announce_new_entities(hass, entry) + + assert _announcement(hass, entry) is None + + +# -- What it says ------------------------------------------------------------ + + +async def test_a_disabled_addition_is_announced_and_says_it_needs_enabling( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + _register(hass, entry, "sp3-001_existing", name="Existing") + await async_announce_new_entities(hass, entry) + + _register(hass, entry, _PART_NUMBER, name="Part Number") + await async_announce_new_entities(hass, entry) + + message = _text(_announcement(hass, entry)) + assert "Part Number" in message + assert "Added but switched off" in message + assert "enable the ones you want" in message + + +async def test_an_enabled_addition_is_announced_too(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """The gap this replaced the Repair to close. + + An enabled entity is visible in the entity list and starts recording, which is + only an announcement to somebody already looking at the entity list. + """ + _register(hass, entry, "sp3-001_existing", name="Existing") + await async_announce_new_entities(hass, entry) + + _register(hass, entry, "sp3-001_grid_state", name="Grid State", disabled=False) + await async_announce_new_entities(hass, entry) + + message = _text(_announcement(hass, entry)) + assert "Grid State" in message + assert "Added and ready to use" in message + + +async def test_both_kinds_are_split_rather_than_pooled(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """The split is the actionable part: one kind needs an action, the other does not.""" + _register(hass, entry, "sp3-001_existing", name="Existing") + await async_announce_new_entities(hass, entry) + + _register(hass, entry, "sp3-001_on", name="Switched On", disabled=False) + _register(hass, entry, "sp3-001_off", name="Switched Off") + await async_announce_new_entities(hass, entry) + + message = _text(_announcement(hass, entry)) + assert message.index("Added and ready to use") < message.index("Switched On") + assert message.index("Added but switched off") < message.index("Switched Off") + + +async def test_every_added_entity_is_named_rather_than_sampled(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """"What exactly was added" means all of it. + + The Repair this replaced showed a count plus three examples, which tells a + user that something happened and not what. + """ + _register(hass, entry, "sp3-001_existing", name="Existing") + await async_announce_new_entities(hass, entry) + + names = [f"Reading {index}" for index in range(8)] + for index, name in enumerate(names): + _register(hass, entry, f"sp3-001_new_{index}", name=name) + await async_announce_new_entities(hass, entry) + + message = _text(_announcement(hass, entry)) + assert all(name in message for name in names) + + +# -- Adopted devices are counted, not listed --------------------------------- + + +async def test_an_adopted_device_contributes_one_line_with_a_count( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """A vendor device declaring a dozen properties must not consume the message. + + Listing them would spend the whole notification on one device and teach the + user to skip it -- costing them the curated additions in the same message. + """ + _register(hass, entry, "sp3-001_existing", name="Existing") + await async_announce_new_entities(hass, entry) + + device = dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, "sp3-001_adopted_generator-1")}, + name="Backup Generator", + ) + for index in range(6): + _register(hass, entry, f"sp3-001_adopted_{index}", name=f"Adopted {index}", device_id=device.id) + _register(hass, entry, "sp3-001_curated", name="Curated Addition") + await async_announce_new_entities(hass, entry) + + message = _text(_announcement(hass, entry)) + assert "Backup Generator (6 entities)" in message + assert "Adopted 0" not in message + assert "Curated Addition" in message + + +# -- Translations ------------------------------------------------------------ + + +@pytest.mark.parametrize("language", ["en", "es", "fr", "ja", "pt"]) +def test_every_shipped_locale_carries_the_notification_strings(language: str) -> None: + """The notification is assembled here, so nothing else checks these keys. + + Home Assistant's translation helper filters to the categories it defines and a + persistent notification is not one of them, so these are read from this + component's own files. That is precisely why a missing key would fail silently + into English rather than being caught by the platform. + """ + text = _read_translations(language) + assert set(text) >= { + "title", + "intro_one", + "intro_many", + "enabled_heading", + "disabled_heading", + "how_to_enable", + "nothing_broken", + } + + +def test_an_unknown_language_falls_back_to_english_rather_than_to_nothing() -> None: + assert _read_translations("xx")["title"] == _read_translations("en")["title"] + + +def test_a_regional_language_resolves_to_its_base(hass: HomeAssistant) -> None: + """`pt-BR` is not shipped; `pt` is, and is a better answer than English.""" + assert _read_translations("pt-BR") == _read_translations("pt") + + +# -- Removal ----------------------------------------------------------------- + + +async def test_removing_the_entry_forgets_what_was_announced( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Otherwise re-adding the same panel announces none of the entities it recreates.""" + _register(hass, entry, _PART_NUMBER, name="Part Number") + await async_announce_new_entities(hass, entry) + + await async_forget_announcements(hass, entry) + _register(hass, entry, "sp3-001_new", name="New One") + await async_announce_new_entities(hass, entry) + + assert _announcement(hass, entry) is None diff --git a/tests/test_new_entity_notice.py b/tests/test_new_entity_notice.py deleted file mode 100644 index 78da5ef8..00000000 --- a/tests/test_new_entity_notice.py +++ /dev/null @@ -1,364 +0,0 @@ -"""An entity that arrives disabled must not arrive silently. - -`battery.part_number` shipped with `entity_registry_enabled_default=False` so -that upgrading would not grow anybody's entity list uninvited. It worked, and -the cost was that nothing whatsoever told the user the sensor now existed -- -they found it by opening the device's disabled-entity list on a hunch. - -These cover the notice that closes that gap, and the four ways it could be worse -than nothing: shouting on a first install, being swallowed by an earlier -dismissal, nagging on every restart, or vanishing unread at the first one. -""" - -from __future__ import annotations - -from homeassistant.helpers import entity_registry as er, issue_registry as ir -import pytest -from pytest_homeassistant_custom_component.common import MockConfigEntry - -from custom_components.span_panel.const import DOMAIN -from custom_components.span_panel.schema_repairs import ( - _new_entities_id, - async_clear_schema_issues, - async_notice_new_disabled_entities, - async_registered_unique_ids, - async_sync_schema_issues, -) -from custom_components.span_panel.schema_validation import SchemaFindings - -_PART_NUMBER = "sp3-001_bess_part_number" - - -@pytest.fixture -def entry(hass) -> MockConfigEntry: - """Return a config entry in hass. No conftest fixture exists for this.""" - mock = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sp3-001") - mock.add_to_hass(hass) - return mock - - -def _register( - hass, - entry: MockConfigEntry, - unique_id: str, - *, - disabled: bool = True, - name: str | None = None, -) -> er.RegistryEntry: - """Register one entity the way a platform would.""" - return er.async_get(hass).async_get_or_create( - "sensor", - DOMAIN, - unique_id, - config_entry=entry, - original_name=name, - disabled_by=er.RegistryEntryDisabler.INTEGRATION if disabled else None, - ) - - -def _notices(hass, entry: MockConfigEntry) -> list[ir.IssueEntry]: - prefix = f"new_entities_{entry.entry_id}_" - return [ - issue - for (domain, issue_id), issue in ir.async_get(hass).issues.items() - if domain == DOMAIN and issue_id.startswith(prefix) - ] - - -# --- The gap this closes -------------------------------------------------- - - -async def test_a_new_disabled_entity_raises_one_notice_naming_it(hass, entry) -> None: - """The `battery.part_number` case, end to end.""" - _register(hass, entry, "sp3-001_bess_serial_number", disabled=False, name="Serial Number") - known = async_registered_unique_ids(hass, entry) - - _register(hass, entry, _PART_NUMBER, name="Part Number") - async_notice_new_disabled_entities(hass, entry, known) - - notices = _notices(hass, entry) - assert len(notices) == 1 - assert notices[0].translation_key == "new_entities_disabled" - assert notices[0].translation_placeholders == {"count": "1", "examples": "Part Number"} - - -async def test_the_notice_is_informational_and_not_fixable(hass, entry) -> None: - """Nothing is broken; data became available. - - `IssueSeverity` has no informational member -- CRITICAL, ERROR, WARNING -- - so WARNING is the mildest Home Assistant offers, and is what the equally - action-free `panel_upgraded_to_ebus_v1` notice settled on. - """ - _register(hass, entry, "sp3-001_bess_serial_number", disabled=False) - known = async_registered_unique_ids(hass, entry) - _register(hass, entry, _PART_NUMBER, name="Part Number") - - async_notice_new_disabled_entities(hass, entry, known) - - notice = _notices(hass, entry)[0] - assert notice.severity is ir.IssueSeverity.WARNING - assert notice.is_fixable is False - - -async def test_the_notice_falls_back_to_the_entity_id_when_unnamed(hass, entry) -> None: - """A disabled entity has no state, so there is no friendly name to read.""" - _register(hass, entry, "sp3-001_bess_serial_number", disabled=False) - known = async_registered_unique_ids(hass, entry) - registered = _register(hass, entry, _PART_NUMBER, name=None) - - async_notice_new_disabled_entities(hass, entry, known) - - assert _notices(hass, entry)[0].translation_placeholders["examples"] == registered.entity_id - - -# --- Silent on a first install -------------------------------------------- - - -async def test_a_first_install_raises_no_notice(hass, entry) -> None: - """Everything is new, so naming it would name the whole integration.""" - known = async_registered_unique_ids(hass, entry) - assert known == frozenset() - - _register(hass, entry, _PART_NUMBER, name="Part Number") - _register(hass, entry, "sp3-001_bess_model", name="Model") - async_notice_new_disabled_entities(hass, entry, known) - - assert _notices(hass, entry) == [] - - -async def test_another_entrys_history_does_not_make_this_one_established(hass) -> None: - """The probe is scoped per entry, so a second panel still installs quietly.""" - established = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sp3-old") - established.add_to_hass(hass) - _register(hass, established, "sp3-old_bess_serial_number", disabled=False) - - fresh = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sp3-new") - fresh.add_to_hass(hass) - known = async_registered_unique_ids(hass, fresh) - _register(hass, fresh, "sp3-new_bess_part_number", name="Part Number") - - async_notice_new_disabled_entities(hass, fresh, known) - - assert _notices(hass, fresh) == [] - - -# --- Only the invisible additions ----------------------------------------- - - -async def test_an_entity_that_is_enabled_by_default_raises_no_notice(hass, entry) -> None: - """It shows up in the user's entity list on its own; a notice is noise.""" - _register(hass, entry, "sp3-001_bess_serial_number", disabled=False) - known = async_registered_unique_ids(hass, entry) - - _register(hass, entry, "sp3-001_bess_model", disabled=False, name="Model") - async_notice_new_disabled_entities(hass, entry, known) - - assert _notices(hass, entry) == [] - - -async def test_a_user_disabled_entity_is_not_a_new_addition(hass, entry) -> None: - """Only `INTEGRATION` means "we shipped it switched off".""" - _register(hass, entry, "sp3-001_bess_serial_number", disabled=False) - known = async_registered_unique_ids(hass, entry) - - er.async_get(hass).async_get_or_create( - "sensor", - DOMAIN, - "sp3-001_bess_model", - config_entry=entry, - disabled_by=er.RegistryEntryDisabler.USER, - ) - async_notice_new_disabled_entities(hass, entry, known) - - assert _notices(hass, entry) == [] - - -async def test_the_named_entities_are_bounded_and_counted(hass, entry) -> None: - """The same rule the defect Repairs follow: a few names plus a count.""" - _register(hass, entry, "sp3-001_bess_serial_number", disabled=False) - known = async_registered_unique_ids(hass, entry) - - for n in range(12): - _register(hass, entry, f"sp3-001_new_{n:02d}", name=f"New Sensor {n:02d}") - async_notice_new_disabled_entities(hass, entry, known) - - placeholders = _notices(hass, entry)[0].translation_placeholders - assert placeholders["count"] == "12" - assert placeholders["examples"].count(",") < 5 - assert "New Sensor 00" in placeholders["examples"] - - -# --- Dismissing one set must not swallow a later, different set ----------- - - -async def test_a_dismissed_notice_does_not_swallow_a_later_addition(hass, entry) -> None: - """The trap that forced per-field ids on the degradation Repairs. - - `async_get_or_create`'s update branch preserves `dismissed_version` while - replacing the placeholders, so an entry-wide id would rewrite the notice the - user put away rather than raising a new one. - """ - _register(hass, entry, "sp3-001_bess_serial_number", disabled=False) - - known = async_registered_unique_ids(hass, entry) - _register(hass, entry, _PART_NUMBER, name="Part Number") - async_notice_new_disabled_entities(hass, entry, known) - - first = _notices(hass, entry)[0] - ir.async_ignore_issue(hass, DOMAIN, first.issue_id, True) - assert ir.async_get(hass).async_get_issue(DOMAIN, first.issue_id).dismissed_version - - known = async_registered_unique_ids(hass, entry) - _register(hass, entry, "sp3-001_bess_cell_temperature", name="Cell Temperature") - async_notice_new_disabled_entities(hass, entry, known) - - by_id = {notice.issue_id: notice for notice in _notices(hass, entry)} - assert len(by_id) == 2 - second = next(issue for issue_id, issue in by_id.items() if issue_id != first.issue_id) - assert second.dismissed_version is None - assert second.translation_placeholders["examples"] == "Cell Temperature" - assert by_id[first.issue_id].dismissed_version is not None - - -async def test_the_id_is_keyed_on_the_exact_set(hass, entry) -> None: - """Two different sets produce two different ids; the same set, one id.""" - assert _new_entities_id("e1", ["a"]) != _new_entities_id("e1", ["a", "b"]) - assert _new_entities_id("e1", ["a"]) != _new_entities_id("e2", ["a"]) - assert _new_entities_id("e1", ["b", "a"]) == _new_entities_id("e1", ["a", "b"]) - - -# --- An event, not a condition -------------------------------------------- - - -async def test_the_notice_is_persistent(hass, entry) -> None: - """It cannot be re-derived, so it has to survive the restart itself. - - On the next startup the entity is in the known set and the diff is empty by - construction. A non-persistent issue reloads as a tombstone, so this notice - would disappear unread at the first restart after the upgrade -- the same - silent add it exists to prevent, one step later. - """ - _register(hass, entry, "sp3-001_bess_serial_number", disabled=False) - known = async_registered_unique_ids(hass, entry) - _register(hass, entry, _PART_NUMBER, name="Part Number") - - async_notice_new_disabled_entities(hass, entry, known) - - assert _notices(hass, entry)[0].is_persistent is True - - -async def test_a_restart_with_no_new_entities_neither_duplicates_nor_re_raises(hass, entry) -> None: - """The event property, exercised the way the field will exercise it.""" - _register(hass, entry, "sp3-001_bess_serial_number", disabled=False) - known = async_registered_unique_ids(hass, entry) - _register(hass, entry, _PART_NUMBER, name="Part Number") - async_notice_new_disabled_entities(hass, entry, known) - - raised = _notices(hass, entry)[0] - created = raised.created - - for _ in range(3): - # Each pass is a setup: probe the registry, forward nothing new, notice. - async_notice_new_disabled_entities(hass, entry, async_registered_unique_ids(hass, entry)) - - notices = _notices(hass, entry) - assert len(notices) == 1 - assert notices[0].issue_id == raised.issue_id - assert notices[0].created == created - - -async def test_a_repeat_of_the_same_set_cannot_rewrite_the_notice(hass, entry) -> None: - """What the already-raised guard actually buys. - - It buys nothing against duplication -- the shared id rules that out. It buys - exactly this: a set that comes back cannot silently restate a notice the user - has already read. Without the guard the repeat takes `async_get_or_create`'s - update branch, which replaces the placeholders in place. - - The set comes back when an entity is removed from the registry and later - re-registered -- a BESS taken off the panel and put back -- because the - unique_ids, and therefore the digest, are unchanged. - """ - _register(hass, entry, "sp3-001_bess_serial_number", disabled=False) - known = async_registered_unique_ids(hass, entry) - added = _register(hass, entry, _PART_NUMBER, name="Part Number") - async_notice_new_disabled_entities(hass, entry, known) - issue_id = _notices(hass, entry)[0].issue_id - - er.async_get(hass).async_remove(added.entity_id) - _register(hass, entry, _PART_NUMBER, name="BESS Part Number") - async_notice_new_disabled_entities(hass, entry, known) - - notices = _notices(hass, entry) - assert len(notices) == 1 - assert notices[0].issue_id == issue_id - assert notices[0].translation_placeholders["examples"] == "Part Number" - - -async def test_a_dismissed_notice_is_not_resurrected_by_a_restart(hass, entry) -> None: - """Dismissing it must end it, not defer it to the next startup.""" - _register(hass, entry, "sp3-001_bess_serial_number", disabled=False) - known = async_registered_unique_ids(hass, entry) - _register(hass, entry, _PART_NUMBER, name="Part Number") - async_notice_new_disabled_entities(hass, entry, known) - - issue_id = _notices(hass, entry)[0].issue_id - ir.async_ignore_issue(hass, DOMAIN, issue_id, True) - dismissed = ir.async_get(hass).async_get_issue(DOMAIN, issue_id).dismissed_version - - async_notice_new_disabled_entities(hass, entry, async_registered_unique_ids(hass, entry)) - - assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id).dismissed_version == dismissed - - -async def test_the_defect_reconcile_pass_does_not_delete_the_notice(hass, entry) -> None: - """`_ours` is scoped to the two defect prefixes on purpose. - - The reconcile pass deletes every id it did not re-derive. This notice is - derived exactly once, so a shared scope would delete it on the same startup - that raised it. - """ - _register(hass, entry, "sp3-001_bess_serial_number", disabled=False) - known = async_registered_unique_ids(hass, entry) - _register(hass, entry, _PART_NUMBER, name="Part Number") - async_notice_new_disabled_entities(hass, entry, known) - issue_id = _notices(hass, entry)[0].issue_id - - async_sync_schema_issues(hass, entry, SchemaFindings(frozenset(), (), frozenset()), {}) - - assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is not None - - -# --- Removal --------------------------------------------------------------- - - -async def test_the_notice_is_cleared_when_the_entry_is_removed(hass, entry) -> None: - """Persistent, so a restart would not even demote it to a tombstone.""" - _register(hass, entry, "sp3-001_bess_serial_number", disabled=False) - known = async_registered_unique_ids(hass, entry) - _register(hass, entry, _PART_NUMBER, name="Part Number") - async_notice_new_disabled_entities(hass, entry, known) - assert _notices(hass, entry) - - async_clear_schema_issues(hass, entry) - - assert _notices(hass, entry) == [] - - -async def test_removing_one_entry_leaves_another_entrys_notice(hass) -> None: - """Two panels share the domain; one leaving must not silence the other.""" - kept = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sp3-kept") - kept.add_to_hass(hass) - removed = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sp3-removed") - removed.add_to_hass(hass) - - for panel in (kept, removed): - _register(hass, panel, f"{panel.unique_id}_bess_serial_number", disabled=False) - known = async_registered_unique_ids(hass, panel) - _register(hass, panel, f"{panel.unique_id}_bess_part_number", name="Part Number") - async_notice_new_disabled_entities(hass, panel, known) - - async_clear_schema_issues(hass, removed) - - assert _notices(hass, kept) - assert _notices(hass, removed) == [] diff --git a/tests/test_setup_entry.py b/tests/test_setup_entry.py index 18f7e15a..be66441d 100644 --- a/tests/test_setup_entry.py +++ b/tests/test_setup_entry.py @@ -4,7 +4,16 @@ from unittest.mock import AsyncMock, MagicMock, patch +from homeassistant.config_entries import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry from span_panel_api.exceptions import SpanPanelAuthError from custom_components.span_panel import SpanPanelRuntimeData, async_setup_entry @@ -17,19 +26,9 @@ CONF_HTTP_PORT, DOMAIN, ) -from homeassistant.config_entries import ( - ConfigEntryAuthFailed, - ConfigEntryError, - ConfigEntryNotReady, -) -from homeassistant.const import CONF_HOST -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er from .factories import SpanPanelSnapshotFactory -from pytest_homeassistant_custom_component.common import MockConfigEntry - def _create_v2_entry(**data_overrides) -> MockConfigEntry: """Create a standard v2 config entry for setup-entry tests.""" @@ -341,15 +340,16 @@ async def _forward(*_args, **_kwargs) -> None: assert order == ["forward", "sync"] -async def test_setup_probes_the_registry_before_the_platforms_and_notices_after( +async def test_setup_announces_additions_after_the_platforms( hass: HomeAssistant, ) -> None: - """The new-entity notice brackets the forward, and both halves matter. + """The announcement has to run after the forward, and that is the whole ordering. - The probe has to run before forwarding, because forwarding is what registers - the entities -- probing afterwards would find every entity already known and - the notice could never fire. The notice has to run after, because a newly - added entity is only in the registry once its platform has added it. + A newly added entity is only in the registry once its platform has added it, + so announcing before the forward would announce nothing, every time. The old + mechanism also needed a *probe* before the forward, because it diffed the + registry across it; the announcement record replaced that, which is what makes + the answer survive a restart landing between the two. """ entry = _create_v2_entry() entry.add_to_hass(hass) @@ -377,11 +377,8 @@ async def _forward(*_args, **_kwargs) -> None: disabled_by=er.RegistryEntryDisabler.INTEGRATION, ) - seen: list[frozenset[str]] = [] - - def _notice(_hass, _entry, known_unique_ids) -> None: - order.append("notice") - seen.append(frozenset(known_unique_ids)) + async def _announce(_hass, _entry) -> None: + order.append("announce") with ( patch("custom_components.span_panel.async_register_commands"), @@ -399,11 +396,10 @@ def _notice(_hass, _entry, known_unique_ids) -> None: ), patch.object(hass.config_entries, "async_update_entry"), patch( - "custom_components.span_panel.async_notice_new_disabled_entities", - side_effect=_notice, + "custom_components.span_panel.async_announce_new_entities", + side_effect=_announce, ), ): assert await async_setup_entry(hass, entry) is True - assert order == ["forward", "sync", "notice"] - assert seen == [frozenset({"already-there"})] + assert order == ["forward", "sync", "announce"] From 1c4f207a875e88c8d543e9977aa52342bc1d9ed1 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:38:25 -0700 Subject: [PATCH 069/345] fix(adoption): build adopted unique ids in the integration's own grammar The adopted id lower-cased and de-hyphenated the whole string, which mangled the panel serial: span_sp3_242424_001_... where every curated id in the integration says span_sp3-242424-001_.... A reader that parses an id by position, as extract_circuit_uuid_from_unique_id does, would have met a second grammar. Now built through id_builder's own suffix helper, so an adopted id is span_{serial}_adopted_{anchor}_{suffix} -- the same span_{serial}_{scope}_ {suffix} shape as every other. Only the node and property are de-hyphenated, because that segment has to read like a curated suffix; the serial and the anchor keep their hyphens, as a curated id keeps the serial's. Uniform grammar does not remove the migration promotion needs, and a test says so: curation changes the scope and the suffix, which is the change itself rather than a formatting difference. --- custom_components/span_panel/adoption.py | 31 +++++++++++-- tests/test_adoption.py | 56 ++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/custom_components/span_panel/adoption.py b/custom_components/span_panel/adoption.py index 2c4bee52..55c0307f 100644 --- a/custom_components/span_panel/adoption.py +++ b/custom_components/span_panel/adoption.py @@ -47,6 +47,7 @@ from .const import DOMAIN from .entity import SpanPanelEntity +from .id_builder import get_user_friendly_suffix from .util import ADOPTED_IDENTIFIER_TOKEN if TYPE_CHECKING: @@ -192,6 +193,32 @@ def resolve_identifier(registry: DeviceRegistry, panel_serial: str, device: Adop return adopted_identifier(panel_serial, adopted_anchor(device)) +def adopted_unique_id(identifier: str, declaration: AdoptedProperty) -> str: + """Return the unique id for one adopted property, in this integration's grammar. + + `span_{serial}_adopted_{anchor}_{suffix}` -- the same + `span_{serial}_{scope}_{suffix}` shape every curated id has, built through the + same suffix helper. Uniform on purpose: a reader that parses an id by + position, as `extract_circuit_uuid_from_unique_id` does, must not meet a + second grammar. An earlier version lower-cased and de-hyphenated the whole + string, which mangled the panel serial itself -- `span_sp3_242424_001_...` + where every other id in the integration says `span_sp3-242424-001_...`. + + Only the node and property are de-hyphenated, because they are the segment + that has to end up snake_case like a curated suffix. The serial and the + anchor keep their hyphens, exactly as a curated id keeps the serial's. + + **Uniform grammar, and still not the id curation will mint.** Two of the three + segments change when a device type stops being unmodelled: the scope becomes + that type's sub-device kind rather than `adopted_{anchor}`, and the suffix + becomes a human-chosen description key rather than a wire address. Those are + the change itself, not a formatting difference, which is why promotion needs + to take over the existing id rather than expecting to reproduce it. + """ + wire_path = f"{declaration.node_id}.{declaration.property_id}".replace("-", "_") + return f"span_{identifier.lower()}_{get_user_friendly_suffix(wire_path)}" + + def adopted_device_info( identifier: str, device: AdoptedDevice, @@ -291,9 +318,7 @@ def __init__( super().__init__(coordinator) self._device_wire_id = device.device_id self._declaration_path = declaration.path - self._attr_unique_id = ( - f"span_{identifier}_{declaration.node_id}_{declaration.property_id}".replace("-", "_") - ) + self._attr_unique_id = adopted_unique_id(identifier, declaration) self._attr_name = _humanised(declaration.property_id) self._attr_device_info = adopted_device_info( identifier, device, panel_device_id=panel_device_id diff --git a/tests/test_adoption.py b/tests/test_adoption.py index 7784103e..16b54c64 100644 --- a/tests/test_adoption.py +++ b/tests/test_adoption.py @@ -27,6 +27,7 @@ DEVICE_CLASS_BY_UNIT, adopted_control_count, adopted_identifier, + adopted_unique_id, async_register_adopted_devices, classify, create_adopted_binary_sensors, @@ -37,6 +38,7 @@ resolve_identifier, ) from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.id_builder import build_panel_unique_id from custom_components.span_panel.util import ( ADOPTED_IDENTIFIER_TOKEN, classify_sub_device_identifier, @@ -557,3 +559,57 @@ def test_a_panel_with_no_adopted_device_registers_nothing(hass: HomeAssistant, r if any(ADOPTED_IDENTIFIER_TOKEN in identifier for _domain, identifier in device.identifiers) ] assert adopted == [] + + +# -- The id grammar is one grammar ------------------------------------------- + + +def test_an_adopted_id_follows_the_same_grammar_as_a_curated_one() -> None: + """`span_{serial}_{scope}_{suffix}`, with the serial spelled the same way. + + An earlier version lower-cased and de-hyphenated the whole string, which + mangled the serial into `span_sp3_242424_001_...` where every other id in the + integration says `span_sp3-242424-001_...`. A reader that parses an id by + position -- `extract_circuit_uuid_from_unique_id` does -- must not meet a + second grammar. + """ + declaration = _property(node_id="meter", property_id="active-power") + identifier = adopted_identifier(PANEL_SERIAL, "generator-1") + + adopted = adopted_unique_id(identifier, declaration) + curated = build_panel_unique_id(PANEL_SERIAL, "panel.instant_grid_power_w") + + prefix = f"span_{PANEL_SERIAL}_" + assert adopted.startswith(prefix) + assert curated.startswith(prefix) + assert adopted == f"{prefix}{ADOPTED_IDENTIFIER_TOKEN}_generator-1_meter_active_power" + + +def test_the_wire_address_is_snake_cased_and_the_anchor_is_not() -> None: + """The suffix has to read like a curated suffix; the anchor is an identity. + + A serial keeps its hyphens in every curated id, and an adopted anchor is the + same kind of thing -- a name the device is known by, not a description key. + """ + declaration = _property(node_id="charge-limit", property_id="owner-limit") + identifier = adopted_identifier(PANEL_SERIAL, "EX-0000-0001") + + assert adopted_unique_id(identifier, declaration).endswith("_EX-0000-0001_charge_limit_owner_limit".lower()) + + +def test_curation_changes_two_of_the_three_segments(hass: HomeAssistant) -> None: + """Why uniform grammar does not remove the migration promotion needs. + + The serial is the same either way. The scope becomes a curated sub-device kind + rather than `adopted_{anchor}`, and the suffix becomes a human-chosen + description key rather than a wire address. Both are the change itself, not a + formatting difference -- so a curated description cannot reproduce the adopted + id and has to take it over instead. + """ + declaration = _property(node_id="meter", property_id="active-power") + adopted = adopted_unique_id(adopted_identifier(PANEL_SERIAL, "generator-1"), declaration) + curated_if_modelled = build_panel_unique_id(PANEL_SERIAL, "generator.active_power") + + assert adopted != curated_if_modelled + assert ADOPTED_IDENTIFIER_TOKEN in adopted + assert ADOPTED_IDENTIFIER_TOKEN not in curated_if_modelled From 52ae9e06fef616e68d32648cce762cdd90716f5a Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:19:16 -0700 Subject: [PATCH 070/345] test(id_builder): close the suffix mappings against silent id moves The three suffix dictionaries translate legacy camelCase description keys into the suffixes their entities have carried since before 2.0.8. That suffix is shared by the unique_id and the entity_id, so an edit moves both on every installed panel: the unique_id costs the long-term statistics and the entity_id breaks the user's templates. Adding an entry was the live hazard, because it silently reroutes a key that previously resolved verbatim. The dictionaries are now held to their exact contents, so an added key, a removed key and a changed value all fail -- verified by mutating each rather than by inspection. The rule for anything new is verbatim, which the sub-device builders already follow: their keys were written snake_case and never needed translating. This also settles going verbatim across the board. A unique_id-only migration would not cost statistics, since those key on the entity_id it preserves -- but because the suffix is shared, verbatim everywhere would force either verbatim entity_ids, breaking every user reference, or decoupling the two and losing the consistency the helper exists to provide. --- custom_components/span_panel/id_builder.py | 54 ++++++++ developer.md | 18 +++ tests/test_suffix_mappings_are_closed.py | 145 +++++++++++++++++++++ 3 files changed, 217 insertions(+) create mode 100644 tests/test_suffix_mappings_are_closed.py diff --git a/custom_components/span_panel/id_builder.py b/custom_components/span_panel/id_builder.py index b474a733..5d02bb8d 100644 --- a/custom_components/span_panel/id_builder.py +++ b/custom_components/span_panel/id_builder.py @@ -31,6 +31,24 @@ "current": "current", "breaker_rating": "breaker_rating", } +"""**Closed.** A compatibility shim for the keys that predate snake_case, not a house style. + +Every entry here translates a legacy camelCase description key into the suffix +its entities have carried since before 2.0.8 -- and that suffix is shared by the +`unique_id` *and* the `entity_id`, so a changed entry moves both on every +installed panel. A moved `unique_id` costs the statistics; a moved `entity_id` +breaks the templates and automations a user wrote. + +So the rule for anything new is **verbatim**: a description key added from here on +resolves to itself, exactly as the sub-device builders (`build_bess_unique_id`, +`build_mid_unique_id`, `build_evse_unique_id`) have always done. Their keys were +written snake_case and never needed translating. + +`tests/test_suffix_mappings_are_closed.py` holds these dictionaries to their +exact contents. It fails on an added key, a removed key and a changed value, +because all three move a live id. +""" + # Panel sensor API field mappings (used by get_user_friendly_suffix) # Includes main meter/feedthrough produced, consumed, and net energy @@ -49,6 +67,24 @@ "feedthroughNetEnergyWh": "feed_through_energy_net", # Consistent naming "batteryPercentage": "battery_percentage", } +"""**Closed.** A compatibility shim for the keys that predate snake_case, not a house style. + +Every entry here translates a legacy camelCase description key into the suffix +its entities have carried since before 2.0.8 -- and that suffix is shared by the +`unique_id` *and* the `entity_id`, so a changed entry moves both on every +installed panel. A moved `unique_id` costs the statistics; a moved `entity_id` +breaks the templates and automations a user wrote. + +So the rule for anything new is **verbatim**: a description key added from here on +resolves to itself, exactly as the sub-device builders (`build_bess_unique_id`, +`build_mid_unique_id`, `build_evse_unique_id`) have always done. Their keys were +written snake_case and never needed translating. + +`tests/test_suffix_mappings_are_closed.py` holds these dictionaries to their +exact contents. It fails on an added key, a removed key and a changed value, +because all three move a live id. +""" + # Panel entity suffix mappings (used by get_panel_entity_suffix) # These are the actual entity_id/unique_id suffixes used for panel sensors @@ -68,6 +104,24 @@ "feedthroughNetEnergyWh": "feed_through_net_energy", "batteryPercentage": "battery_level", } +"""**Closed.** A compatibility shim for the keys that predate snake_case, not a house style. + +Every entry here translates a legacy camelCase description key into the suffix +its entities have carried since before 2.0.8 -- and that suffix is shared by the +`unique_id` *and* the `entity_id`, so a changed entry moves both on every +installed panel. A moved `unique_id` costs the statistics; a moved `entity_id` +breaks the templates and automations a user wrote. + +So the rule for anything new is **verbatim**: a description key added from here on +resolves to itself, exactly as the sub-device builders (`build_bess_unique_id`, +`build_mid_unique_id`, `build_evse_unique_id`) have always done. Their keys were +written snake_case and never needed translating. + +`tests/test_suffix_mappings_are_closed.py` holds these dictionaries to their +exact contents. It fails on an added key, a removed key and a changed value, +because all three move a live id. +""" + # Combined mapping for general suffix lookup ALL_SUFFIX_MAPPINGS = {**CIRCUIT_SUFFIX_MAPPING, **PANEL_SUFFIX_MAPPING} diff --git a/developer.md b/developer.md index dc91db48..2d959b26 100644 --- a/developer.md +++ b/developer.md @@ -245,6 +245,24 @@ The adapter decides "read" from four enumerations of what it addresses — the m of the report. `tests/test_schema_one_discovery.py` in the library runs the same republish-and-diff experiment this gate uses and holds every entry to it in both directions, so the report means "nothing reads this" rather than "nobody wrote it down". +## The suffix mappings are closed + +`get_user_friendly_suffix` and `get_panel_entity_suffix` translate legacy camelCase description keys (`instantPowerW`, `instantGridPowerW`, `doorState`) into +the suffixes their entities have carried since before 2.0.8. **Do not add entries.** A new description key needs none: it resolves to itself, which is what the +sub-device builders (`build_bess_unique_id`, `build_mid_unique_id`, `build_evse_unique_id`) have always done, since their keys were written snake_case. + +The reason is that the suffix is not only in the `unique_id` — it is the segment shared with the `entity_id` (`sensor_circuit.py:213`, and +`get_panel_entity_suffix`'s own docstring says so). So an edit here moves both on every installed panel: the `unique_id` costs the long-term statistics, and the +`entity_id` breaks whatever templates and automations a user wrote against it. + +`tests/test_suffix_mappings_are_closed.py` holds all three dictionaries to their exact contents and fails on an added key, a removed key or a changed value — +verified by mutation, not by inspection. + +This closes the question of whether to go verbatim everywhere. The migration mechanism exists and would not cost statistics, since those key on `statistic_id` +(the entity_id), which a `unique_id`-only migration preserves. But because the suffix is shared, verbatim-across-the-board would force either verbatim +`entity_id`s — `sensor.span_panel_kitchen_instantPowerW`, breaking every user reference — or a decoupling of the two, which throws away the consistency the +helper exists to provide. Closing the mapping gets the whole benefit for none of that. + ## Adopting a device this integration models nothing for The section above is about properties on devices we already read. This one is about a device type nobody modelled at all — a vendor's generator, heat pump or diff --git a/tests/test_suffix_mappings_are_closed.py b/tests/test_suffix_mappings_are_closed.py new file mode 100644 index 00000000..56c24e2d --- /dev/null +++ b/tests/test_suffix_mappings_are_closed.py @@ -0,0 +1,145 @@ +"""The suffix mappings are a closed compatibility shim, and this is what closes them. + +`get_user_friendly_suffix` and `get_panel_entity_suffix` translate legacy camelCase +description keys into the suffixes their entities have carried since before 2.0.8. +That suffix is shared by the `unique_id` **and** the `entity_id`, so a changed entry +moves both on every installed panel: the `unique_id` costs the long-term statistics, +and the `entity_id` breaks whatever templates and automations a user wrote against it. + +Three edits move a live id and all three fail here: + +- **adding** a key, which silently reroutes a description that previously resolved + verbatim -- the failure mode that prompted this file +- **removing** a key, which sends a legacy description back to its raw camelCase +- **changing** a value, which is the most direct version of the same thing + +The rule for anything new is verbatim, which is what the sub-device builders have +always done. Nothing here needs extending to add a sensor. +""" + +from __future__ import annotations + +from custom_components.span_panel.id_builder import ( + ALL_SUFFIX_MAPPINGS, + CIRCUIT_SUFFIX_MAPPING, + PANEL_ENTITY_SUFFIX_MAPPING, + PANEL_SUFFIX_MAPPING, + build_bess_unique_id, + build_circuit_unique_id, + build_evse_unique_id, + build_mid_unique_id, + build_panel_unique_id, + get_panel_entity_suffix, + get_user_friendly_suffix, +) + +SERIAL = "sp3-001" +CIRCUIT = "0dad2f16cd514812ae1807b0457d473e" + +_CIRCUIT_SUFFIXES = { + "instantPowerW": "power", + "producedEnergyWh": "energy_produced", + "consumedEnergyWh": "energy_consumed", + "netEnergyWh": "energy_net", + "importedEnergyWh": "energy_imported", + "exportedEnergyWh": "energy_exported", + "circuit_priority": "priority", + "current": "current", + "breaker_rating": "breaker_rating", +} + +_PANEL_SUFFIXES = { + "instantGridPowerW": "grid_power", + "feedthroughPowerW": "feed_through_power", + "batteryPowerW": "battery_power", + "pvPowerW": "pv_power", + "gridPowerFlowW": "grid_power_flow", + "sitePowerW": "site_power", + "mainMeterEnergyProducedWh": "main_meter_energy_produced", + "mainMeterEnergyConsumedWh": "main_meter_energy_consumed", + "mainMeterNetEnergyWh": "main_meter_energy_net", + "feedthroughEnergyProducedWh": "feed_through_energy_produced", + "feedthroughEnergyConsumedWh": "feed_through_energy_consumed", + "feedthroughNetEnergyWh": "feed_through_energy_net", + "batteryPercentage": "battery_percentage", +} + +_PANEL_ENTITY_SUFFIXES = { + "instantGridPowerW": "current_power", + "feedthroughPowerW": "feed_through_power", + "batteryPowerW": "battery_power", + "pvPowerW": "pv_power", + "gridPowerFlowW": "grid_power_flow", + "sitePowerW": "site_power", + "mainMeterEnergyProducedWh": "main_meter_produced_energy", + "mainMeterEnergyConsumedWh": "main_meter_consumed_energy", + "mainMeterNetEnergyWh": "main_meter_net_energy", + "feedthroughEnergyProducedWh": "feed_through_produced_energy", + "feedthroughEnergyConsumedWh": "feed_through_consumed_energy", + "feedthroughNetEnergyWh": "feed_through_net_energy", + "batteryPercentage": "battery_level", +} + +_CLOSED = ( + "This mapping is closed. Adding, removing or changing an entry moves a live " + "unique_id and entity_id on every installed panel -- statistics and the user's " + "templates both. A new description key needs no entry: it resolves verbatim." +) + + +def test_the_circuit_suffix_mapping_is_frozen() -> None: + assert CIRCUIT_SUFFIX_MAPPING == _CIRCUIT_SUFFIXES, _CLOSED + + +def test_the_panel_suffix_mapping_is_frozen() -> None: + assert PANEL_SUFFIX_MAPPING == _PANEL_SUFFIXES, _CLOSED + + +def test_the_panel_entity_suffix_mapping_is_frozen() -> None: + assert PANEL_ENTITY_SUFFIX_MAPPING == _PANEL_ENTITY_SUFFIXES, _CLOSED + + +def test_the_combined_mapping_is_exactly_its_two_halves() -> None: + """`ALL_SUFFIX_MAPPINGS` is derived, so it cannot gain an entry of its own. + + Pinned because it is the one a caller reaches for, and a hand-added entry here + would route a key without appearing in either half above. + """ + assert ALL_SUFFIX_MAPPINGS == {**_CIRCUIT_SUFFIXES, **_PANEL_SUFFIXES} + + +def test_a_key_the_shim_does_not_carry_resolves_to_itself() -> None: + """The rule for everything added from here on. + + Sampled from keys real sub-device sensors use, so this fails if the fallback + is ever changed to normalise, prefix or otherwise reshape an unmapped key. + """ + for key in ("soe_kwh", "meter_power", "mid_grid_state", "evse_status", "grid_islandable"): + assert get_user_friendly_suffix(key) == key + assert get_panel_entity_suffix(key) == key + + +def test_the_sub_device_builders_never_consult_the_shim() -> None: + """Verbatim by construction, which is why they are safe from an edit above. + + A BESS, MID or EVSE id is its description key. Even if somebody added a + mapping entry for one of these keys, these builders would not read it -- and + the frozen dictionaries above are what stops the attempt reaching review. + """ + assert build_bess_unique_id(SERIAL, "soe_kwh") == f"span_{SERIAL}_bess_soe_kwh" + assert build_mid_unique_id(SERIAL, "mid_grid_state") == f"span_{SERIAL}_mid_mid_grid_state" + assert build_evse_unique_id(SERIAL, "evse-1", "evse_status") == f"span_{SERIAL}_evse_evse-1_evse_status" + + +def test_the_legacy_ids_the_shim_exists_to_preserve() -> None: + """What the frozen entries actually buy, spelled out as ids rather than suffixes. + + These are the strings on installed panels. If one of them changes, an upgrade + orphans the entity, Home Assistant registers a replacement with a `_2` suffix, + and the history stays on the entity nobody is looking at any more. + """ + assert build_circuit_unique_id(SERIAL, CIRCUIT, "instantPowerW") == f"span_{SERIAL}_{CIRCUIT}_power" + assert build_circuit_unique_id(SERIAL, CIRCUIT, "producedEnergyWh") == f"span_{SERIAL}_{CIRCUIT}_energy_produced" + assert build_circuit_unique_id(SERIAL, "unmapped_tab_32", "instantPowerW") == f"span_{SERIAL}_unmapped_tab_32_power" + assert build_panel_unique_id(SERIAL, "instantGridPowerW") == f"span_{SERIAL}_current_power" + assert build_panel_unique_id(SERIAL, "doorState") == f"span_{SERIAL}_doorstate" From 0784d059c0a976ec7ea988306a701b5081f326de Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:41:11 -0700 Subject: [PATCH 071/345] feat(adoption): report whether an adopted device is proxied by a peer The library now carries the proxy link an adopted device declares. Diagnostics report it as a boolean and never as the parent's id: a device id can embed a serial, since producers derive a DER's id preferring a serial over a default slug, so reporting the parent verbatim would leak the serial the block deliberately withholds. No topology changes. Every adopted device is still registered under the panel, as every curated sub-device is. The nesting waits on python-sdk#57, where the graph builder is still to be reconciled against the new DeviceTreeBuilder. --- CHANGELOG.md | 3 +++ custom_components/span_panel/diagnostics.py | 10 +++++++++ developer.md | 24 +++++++++++++++++++++ tests/test_adoption.py | 22 +++++++++++++++++++ 4 files changed, 59 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c08598b5..9cb974e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ All notable changes to this project will be documented in this file. ### Added +- **Diagnostics now say whether an adopted device is proxied by another device rather than by the panel.** Reported as a yes/no, never as the parent's + identifier: a device identifier can contain a serial number, and diagnostics deliberately carry no serials. + - **New entities are now announced in a notification that names them — whether or not they arrived switched on.** Previously only entities added _disabled_ were mentioned, on the reasoning that an enabled one is already visible in your entity list and its history. That is only true if you are watching your entity list, which nobody is: an addition that breaks nothing was indistinguishable from no addition at all. The notification names every entity that was added, diff --git a/custom_components/span_panel/diagnostics.py b/custom_components/span_panel/diagnostics.py index cc95bc25..d7b050f5 100644 --- a/custom_components/span_panel/diagnostics.py +++ b/custom_components/span_panel/diagnostics.py @@ -102,10 +102,19 @@ class AdoptedDeviceRow(TypedDict): device card, but neither answers the question this block exists to ask -- and a vendor-set device name is free text a household chose. The type, the shape and the counts are what a maintainer needs. + + **`proxied` rather than `parent`.** The parent is a device id, and a device id + can embed a serial: producers derive a DER's id preferring a serial over a + default slug, which is why this repository holds PV's `info/serial-number` + unvalued. Reporting the id verbatim would leak the serial this block + deliberately withholds. The boolean answers the question a maintainer + actually has -- has a *proxied* unmodelled device appeared -- and carries no + identity. """ device_type: str model: str | None + proxied: bool property_count: int properties: list[AdoptedPropertyRow] @@ -154,6 +163,7 @@ def _adoption(snapshot: SpanPanelSnapshot) -> AdoptionBlock: { "device_type": device.device_type, "model": device.model, + "proxied": device.proxied, "property_count": len(device.properties), "properties": [ { diff --git a/developer.md b/developer.md index 2d959b26..e76bddf9 100644 --- a/developer.md +++ b/developer.md @@ -323,6 +323,30 @@ node rule, creates no entity, and so had nothing to call `async_get_or_create` f Running it before the platforms also makes the identity freeze single-valued: `resolve_identifier` runs once, at registration, so every entity created afterwards resolves against a device that already exists and cannot disagree. +### The proxy link is recorded, and the nesting is not built + +`AdoptedDevice` carries `parent` (the device id it declares as its parent) and `proxied` (whether that parent is a peer rather than the tree root). Adoption +does not act on either: every adopted device is registered under the panel with `via_device_id`, exactly as every curated sub-device is. + +They are carried because a _proxied_ unmodelled device is a real shape we would otherwise flatten away without leaving evidence. The library's own reference +tree contains one — `bess-mid` declares `parent: bess`, which is the `{proxier-id}-{proxied-id}` naming of the specification's `devices/proxy.md`. A vendor +gateway proxying its own sub-devices arrives the same way, and the parent link is the only structural information about how they relate. + +**Diagnostics report `proxied`, never `parent`.** A device id can embed a serial — producers derive a DER's id preferring a serial over a default slug, which is +why the library holds PV's `info/serial-number` unvalued — so reporting the parent verbatim would leak the serial the block deliberately withholds. The boolean +answers a maintainer's actual question, which is whether a proxied unmodelled device has appeared at all. + +**Why the nesting waits.** [python-sdk#49](https://github.com/electrification-bus/python-sdk/issues/49#issuecomment-5359203067) settled that proxied ids differ +by design — several enclosures on a shared broker each proxying the same physical device produce different ids on purpose — and that consumers correlate by +`info/serial-number`, never by device id. It also records that `ebus-sdk` 0.21.0 shipped `DeviceSpec` and `DeviceTreeBuilder` +([python-sdk#57](https://github.com/electrification-bus/python-sdk/issues/57)), with the existing graph builder still to be reconciled against it. The tree +model is under active reshaping upstream, so the fields capture the evidence and the topology waits. + +That comment also strengthens two things already here. Its deferral mechanism — a `device_id` callable returning `None` defers the device until +`resolve_deferred()` — is the producer-side form of settling identity _before_ a device exists, which is what registering adopted devices ahead of the platforms +does from this end. And "there is deliberately no existence predicate … expressing it by not calling `add()` is right" is the rule the capability gates already +follow: presence in the tree is the signal, and there is no flag to consult. + ### Identity freezes at first sighting `resolve_identifier` looks up **both** candidate spellings — `{panel serial}_adopted_{wire id}` and `{panel serial}_adopted_{serial}` — before minting either, diff --git a/tests/test_adoption.py b/tests/test_adoption.py index 16b54c64..b5be5d00 100644 --- a/tests/test_adoption.py +++ b/tests/test_adoption.py @@ -10,6 +10,7 @@ import ast from dataclasses import replace +import json from pathlib import Path from typing import TYPE_CHECKING from unittest.mock import AsyncMock, MagicMock @@ -38,6 +39,7 @@ resolve_identifier, ) from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.diagnostics import _adoption from custom_components.span_panel.id_builder import build_panel_unique_id from custom_components.span_panel.util import ( ADOPTED_IDENTIFIER_TOKEN, @@ -613,3 +615,23 @@ def test_curation_changes_two_of_the_three_segments(hass: HomeAssistant) -> None assert adopted != curated_if_modelled assert ADOPTED_IDENTIFIER_TOKEN in adopted assert ADOPTED_IDENTIFIER_TOKEN not in curated_if_modelled + + +# -- Diagnostics report the proxy relationship, never the parent's id --------- + + +def test_diagnostics_report_whether_a_device_is_proxied_and_not_by_whom() -> None: + """A device id can embed a serial, so the id must not go in the payload. + + Producers derive a DER's id preferring a serial over a default slug -- which + is why this repository holds PV's `info/serial-number` unvalued -- so + reporting `parent` verbatim would leak the serial the block deliberately + withholds. The boolean answers the maintainer's actual question. + """ + device = replace(_device("gateway-1-sensor"), parent="gateway-1", proxied=True) + block = _adoption(_snapshot(device)) + + (row,) = block["devices"] + assert row["proxied"] is True + assert "parent" not in row + assert "gateway-1" not in json.dumps(block) From 530e2b83871a77d19f50eea04a4c3d103beb6fed Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:22:45 -0700 Subject: [PATCH 072/345] fix(sensors): disable the five unreliable panel readings, withdraw the sixth The eBus maintainer's r202633 conformance note names six properties this integration surfaces. Five shipped in 2.0.8; one never shipped. That decides the remedy for each. Disabled by default: feedthrough produced/consumed energy (the difference of two unrelated counters, non-monotonic, ~whole-panel figures where the truth is zero), feedthrough power (inverted relative to every other enclosure terminal), and both downstream currents (they report the upstream service conductors). Disabled rather than removed because an existing install has them with history and entities must not vanish. This helps nobody who already has them, and the comments say so: entity_registry_enabled_default applies at first registration only, so an existing install keeps them enabled. It stops new installs adopting them. Withdrawn: bess_meter_power. It was added in the v1.0 work and never released, so no install carries it and dropping it is free -- it would not be after a release. Shipping it disabled would mean shipping a new entity known to be inverted on real firmware, whose wrongness a user cannot detect: both signs are plausible and the two topics are numerically identical. The reading it provided is not lost, since battery_power reads power-flows/battery, which the note confirms is correct as published. Restore it when the firmware publishes the specified frame. The condition is on BESS_TELEMETRY_SENSORS, the assumption is named in the library's devices.py, and the wire property stays in the unread baseline with that reason. --- CHANGELOG.md | 15 ++ custom_components/span_panel/field_paths.py | 11 +- custom_components/span_panel/icons.json | 3 - .../span_panel/sensor_definitions.py | 160 +++++++----- custom_components/span_panel/strings.json | 3 - .../span_panel/translations/en.json | 3 - .../span_panel/translations/es.json | 3 - .../span_panel/translations/fr.json | 3 - .../span_panel/translations/ja.json | 3 - .../span_panel/translations/pt.json | 3 - tests/test_bess_telemetry.py | 233 ++++-------------- 11 files changed, 164 insertions(+), 276 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cb974e2..580d714d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ All notable changes to this project will be documented in this file. ### Added +### Changed + +- **Five panel sensors are switched off for new installations, because the eBus specification's own maintainer has documented that their values cannot be relied + on.** A conformance note for SPAN firmware r202633 identifies three defects in what the panel publishes, all of which predate that release: the feedthrough + (downstream lugs) energy registers are computed from two unrelated counters and can decrease or go negative — on a panel with no feedthrough load they report + roughly whole-panel figures where the truth is zero — the feedthrough power reading is inverted relative to every other terminal, and the feedthrough currents + report the _upstream_ service conductors rather than a downstream measurement. The affected entities are **Feedthrough Produced Energy**, **Feedthrough + Consumed Energy**, **Feedthrough Power**, and the two **Downstream** current sensors. +- **If you already have those five, nothing changes and they stay exactly where they are.** Home Assistant consults the enabled-by-default setting only when an + entity is first created, so an existing installation keeps them, keeps its history and keeps its entity IDs. This stops new installations picking them up; it + cannot reach back. If you use any of the five on a dashboard or in an automation, they are worth removing — but that is your decision to make, not something + an upgrade should do to you. +- **Your other panel readings are unaffected.** The upstream lugs, the main panel meter and every circuit are in the correct frame. So is the power-flow group, + which the specification has now been corrected to describe the way the panel has always published it. + - **Diagnostics now say whether an adopted device is proxied by another device rather than by the panel.** Reported as a yes/no, never as the parent's identifier: a device identifier can contain a serial number, and diagnostics deliberately carry no serials. diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py index 791f019f..c48fbf1b 100644 --- a/custom_components/span_panel/field_paths.py +++ b/custom_components/span_panel/field_paths.py @@ -294,11 +294,12 @@ class FieldPathDeclarationMixin: # is what makes these SCHEMA_1_ONLY rather than NEITHER and buys them unit # and datatype validation against the BESS's own `$description`. # - # `battery.power_w` is charge-positive in the snapshot -- the library - # negates the enclosure's meter frame -- which is the same direction - # `battery_power` shows after negating `panel.power_flow_battery`. The two - # sensors sit on one device card and must not disagree about which way is - # charging. + # `battery.power_w` has no entity of its own since `bess_meter_power` was + # withdrawn -- see `BESS_TELEMETRY_SENSORS` for why and for the condition + # to restore it. The path stays exempt and annotated because `has_bess` + # still reads the field to decide whether a battery is commissioned, and + # the annotation is a fact about which adapter produces it rather than + # about who consumes it. "battery.power_w": Producibility.SCHEMA_1_ONLY, "battery.communication_state": Producibility.SCHEMA_1_ONLY, # The Power Control System's result, behind `pcs_import_limit`, diff --git a/custom_components/span_panel/icons.json b/custom_components/span_panel/icons.json index d96c4534..edf980c4 100644 --- a/custom_components/span_panel/icons.json +++ b/custom_components/span_panel/icons.json @@ -74,9 +74,6 @@ "bess_communication_state": { "default": "mdi:lan-connect" }, - "bess_meter_power": { - "default": "mdi:battery-charging-outline" - }, "bess_nameplate_capacity": { "default": "mdi:battery-high" }, diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py index e1a406f5..4c3adf43 100644 --- a/custom_components/span_panel/sensor_definitions.py +++ b/custom_components/span_panel/sensor_definitions.py @@ -297,32 +297,42 @@ class SpanPanelBatterySensorEntityDescription( ) ) -DOWNSTREAM_L1_CURRENT_SENSOR: SpanPanelDataSensorEntityDescription = ( - SpanPanelDataSensorEntityDescription( - key="downstream_l1_current", - field_path="panel.downstream_l1_current_a", - translation_key="downstream_l1_current", - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - entity_category=EntityCategory.DIAGNOSTIC, - suggested_display_precision=2, - value_fn=lambda s: s.downstream_l1_current_a, - ) +DOWNSTREAM_L1_CURRENT_SENSOR: SpanPanelDataSensorEntityDescription = SpanPanelDataSensorEntityDescription( + key="downstream_l1_current", + field_path="panel.downstream_l1_current_a", + # Disabled by default from 2.1.0: the eBus maintainer's r202633 conformance + # note says this cannot be relied on -- reports the UPSTREAM service conductors, not a downstream measurement. + # Disabled rather than removed because it shipped in 2.0.8, so an existing + # install has it with history and entities must not vanish. This changes + # nothing for those installs -- the default applies at first registration + # only -- it stops NEW installs adopting it. See the delta document. + entity_registry_enabled_default=False, + translation_key="downstream_l1_current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=2, + value_fn=lambda s: s.downstream_l1_current_a, ) -DOWNSTREAM_L2_CURRENT_SENSOR: SpanPanelDataSensorEntityDescription = ( - SpanPanelDataSensorEntityDescription( - key="downstream_l2_current", - field_path="panel.downstream_l2_current_a", - translation_key="downstream_l2_current", - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - entity_category=EntityCategory.DIAGNOSTIC, - suggested_display_precision=2, - value_fn=lambda s: s.downstream_l2_current_a, - ) +DOWNSTREAM_L2_CURRENT_SENSOR: SpanPanelDataSensorEntityDescription = SpanPanelDataSensorEntityDescription( + key="downstream_l2_current", + field_path="panel.downstream_l2_current_a", + # Disabled by default from 2.1.0: the eBus maintainer's r202633 conformance + # note says this cannot be relied on -- reports the UPSTREAM service conductors, not a downstream measurement. + # Disabled rather than removed because it shipped in 2.0.8, so an existing + # install has it with history and entities must not vanish. This changes + # nothing for those installs -- the default applies at first registration + # only -- it stops NEW installs adopting it. See the delta document. + entity_registry_enabled_default=False, + translation_key="downstream_l2_current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=2, + value_fn=lambda s: s.downstream_l2_current_a, ) # Main breaker rating sensor (v2 only, conditionally created) @@ -799,21 +809,7 @@ class SpanMidSensorEntityDescription(SensorEntityDescription, SpanMidRequiredKey ), ) -BESS_TELEMETRY_SENSORS: tuple[ - SpanBessMetadataSensorEntityDescription, - SpanBessMetadataSensorEntityDescription, -] = ( - SpanBessMetadataSensorEntityDescription( - key="meter_power", - field_path="battery.power_w", - derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD, - translation_key="bess_meter_power", - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfPower.WATT, - suggested_display_precision=0, - value_fn=lambda b: b.power_w, - ), +BESS_TELEMETRY_SENSORS: tuple[SpanBessMetadataSensorEntityDescription,] = ( SpanBessMetadataSensorEntityDescription( key="communication_state", field_path="battery.communication_state", @@ -830,35 +826,55 @@ class SpanMidSensorEntityDescription(SensorEntityDescription, SpanMidRequiredKey Separate from `BESS_METADATA_SENSORS` because these are created conditionally and those are not. Every metadata sensor exists on any commissioned BESS, filled or -empty. These two come from capability nodes a BESS may simply not have, so +empty. This one comes from a capability node a BESS may simply not have, so absence has to mean no entity rather than a permanently unknown one, and mixing the two rules into one tuple would mean deciding per description which applied. -**Power is enabled by default and not diagnostic; communication state is -neither.** The battery's own charge/discharge figure is a reading a user graphs -and automates on. Its link health is a fault signal — interesting when something -is wrong, noise on a device card the rest of the time — so it lands the way the -other diagnostics do, off by default and available to anyone who wants it. - -**`bess_meter_power` is not `battery_power`, and the names say so.** The existing -`battery_power` sensor reads `panel.power_flow_battery`, the enclosure's own -arbitrated flow figure; this one reads the BESS's `meter/active-power`, the -battery's own meter. On a healthy panel they agree, and where they disagree that -is a fact worth being able to see rather than one to hide behind a single entity. - -**Both read charge-positive, which is what makes them agree.** The library -negates the BESS meter into the snapshot's frame (`SpanBatterySnapshot.power_w` -is documented charge-positive), and `BATTERY_POWER_SENSOR` negates -`power_flow_battery`, which the capability catalog defines as -discharge-positive. Two negations for two opposite wire conventions, landing on -one convention in the UI: **positive means the battery is charging**. A sensor -whose sign contradicted the one beside it would be worse than no sensor. +Communication state is diagnostic and off by default: link health is a fault +signal, interesting when something is wrong and noise on a device card the rest +of the time. + +**`bess_meter_power` was here and was withdrawn before it ever shipped.** + +It read `battery.power_w`, the BESS child's own `meter/active-power`, alongside +`battery_power` reading the enclosure's `power-flows/battery` — two views of one +quantity, so that a disagreement between them would be visible rather than +hidden behind a single entity. + +The eBus maintainer's r202633 conformance note (2026-08-20) established that the +two will disagree *systematically* on a real panel, for a firmware reason rather +than a panel-health one. The BESS child publishes `meter/active-power` +charge-positive where the specification requires discharge-positive, and +`build_battery`'s `_charge_positive()` negates on the assumption that the +specification holds — so `battery.power_w` inverts on that firmware. The note +calls this the one divergence a consumer cannot detect on its own: both signs +are physically plausible and the two topics are numerically identical, so +nothing in the tree contradicts itself observably. + +Nothing here can catch it either. `ebus-panel-sim` 0.6.0 fixed the same +inversion in the simulator, so on this one property the simulator is now correct +where the panel is not, and every test in this repository runs against the +simulator. + +Withdrawn rather than compensated, and rather than shipped documented, for the +reason the timing allows: this description had never been released, so no +install carries the entity and no history is at stake. Dropping it now is free. +Once a release creates it, the rule that an existing install's entities cannot +simply disappear makes the same decision expensive. + +**Restore it when the firmware publishes the specified frame.** The correct +reading is already shipped and unaffected — `battery_power` reads +`power-flows/battery`, which the note confirms is correct as published — so +nothing is lost meanwhile except the ability to compare the two. The wire +property stays in `tests/fixtures/unread_declarations_baseline.json` with that +reason, which is what will fail when somebody surfaces it again without +revisiting this. **`derived` as well as `field_path`, by the producible rule.** The gate wants a path both adapters produce, and flat's BESS device class declares neither property — so `SCHEMA_CONDITIONAL_FIELD`, with the paths enumerated in `RESIDUAL_EXEMPT_PATHS` as `SCHEMA_1_ONLY`. `field_path` still names the source, -which is what gives each sensor its Repair mention and its unavailability when +which is what gives the sensor its Repair mention and its unavailability when the panel stops resolving the property. """ @@ -930,6 +946,14 @@ class SpanPVMetadataSensorEntityDescription( SpanPanelDataSensorEntityDescription( key="feedthroughPowerW", field_path="panel.feedthrough_power_w", + # Disabled by default from 2.1.0: the eBus maintainer's r202633 conformance + # note says this cannot be relied on -- inverted: positive when power leaves the enclosure, where every other + # enclosure terminal is positive inbound. Negate to compensate. + # Disabled rather than removed because it shipped in 2.0.8, so an existing + # install has it with history and entities must not vanish. This changes + # nothing for those installs -- the default applies at first registration + # only -- it stops NEW installs adopting it. See the delta document. + entity_registry_enabled_default=False, translation_key="feedthrough_power", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, @@ -1019,6 +1043,14 @@ class SpanPVMetadataSensorEntityDescription( SpanPanelDataSensorEntityDescription( key="feedthroughEnergyProducedWh", field_path="panel.feedthrough_energy_produced_wh", + # Disabled by default from 2.1.0: the eBus maintainer's r202633 conformance + # note says this cannot be relied on -- the difference of two unrelated counters, non-monotonic, and ~whole-panel + # figures where the truth is zero. No transformation recovers a real value. + # Disabled rather than removed because it shipped in 2.0.8, so an existing + # install has it with history and entities must not vanish. This changes + # nothing for those installs -- the default applies at first registration + # only -- it stops NEW installs adopting it. See the delta document. + entity_registry_enabled_default=False, translation_key="feedthrough_produced_energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL, @@ -1029,6 +1061,14 @@ class SpanPVMetadataSensorEntityDescription( SpanPanelDataSensorEntityDescription( key="feedthroughEnergyConsumedWh", field_path="panel.feedthrough_energy_consumed_wh", + # Disabled by default from 2.1.0: the eBus maintainer's r202633 conformance + # note says this cannot be relied on -- the difference of two unrelated counters, non-monotonic, and ~whole-panel + # figures where the truth is zero. No transformation recovers a real value. + # Disabled rather than removed because it shipped in 2.0.8, so an existing + # install has it with history and entities must not vanish. This changes + # nothing for those installs -- the default applies at first registration + # only -- it stops NEW installs adopting it. See the delta document. + entity_registry_enabled_default=False, translation_key="feedthrough_consumed_energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL, diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index 10936f33..4f568a29 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -200,9 +200,6 @@ "unknown": "Unknown" } }, - "bess_meter_power": { - "name": "Meter Power" - }, "bess_nameplate_capacity": { "name": "Nameplate Capacity" }, diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index 10936f33..4f568a29 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -200,9 +200,6 @@ "unknown": "Unknown" } }, - "bess_meter_power": { - "name": "Meter Power" - }, "bess_nameplate_capacity": { "name": "Nameplate Capacity" }, diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index 492ba61a..1b027532 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -200,9 +200,6 @@ "unknown": "Desconocido" } }, - "bess_meter_power": { - "name": "Potencia del Medidor" - }, "bess_nameplate_capacity": { "name": "Capacidad Nominal" }, diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index 438e943c..e60a9181 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -200,9 +200,6 @@ "unknown": "Inconnu" } }, - "bess_meter_power": { - "name": "Puissance du Compteur" - }, "bess_nameplate_capacity": { "name": "Capacité Nominale" }, diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index bf45da3d..eb363fb3 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -200,9 +200,6 @@ "unknown": "不明" } }, - "bess_meter_power": { - "name": "メーター電力" - }, "bess_nameplate_capacity": { "name": "定格容量" }, diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index fcd4649b..71b0e6b4 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -200,9 +200,6 @@ "unknown": "Desconhecido" } }, - "bess_meter_power": { - "name": "Potência do Medidor" - }, "bess_nameplate_capacity": { "name": "Capacidade Nominal" }, diff --git a/tests/test_bess_telemetry.py b/tests/test_bess_telemetry.py index 7d1a206a..f544ea7c 100644 --- a/tests/test_bess_telemetry.py +++ b/tests/test_bess_telemetry.py @@ -1,8 +1,8 @@ -"""What the BESS reports about itself, surfaced as two sensors on its own device. +"""What the BESS reports about itself, surfaced on its own device. -`meter/active-power` and `status/communication-state` were declared, published -and read by nobody. They are the battery's own view of its power and its own view -of its link, as opposed to the enclosure's view of both. +`status/communication-state` is the battery's own view of its link, as opposed +to the enclosure's view of it. It was declared, published and read by nobody +until v1.0. Every assertion runs against a real snapshot built by the real schema_1 adapter over the vendored capture, and every expected value is read out of that capture @@ -10,15 +10,21 @@ pins passes whether or not the wire is ever read. Each reading is proved by 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 negative `meter/active-power` -because the enclosure meters the BESS the way it meters a circuit it feeds. The -snapshot negates that into its charge-positive frame, and `battery_power` — the -sensor already on this device, reading the enclosure's discharge-positive -`power-flows/battery` — negates too. Two negations of two opposite wire -conventions, landing on one convention in the UI. 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. +**This module used to be mostly about a sign, and that sensor is gone.** +`bess_meter_power` read the BESS child's own `meter/active-power` beside +`battery_power`'s enclosure view, and fourteen tests here pinned their agreement. +It was withdrawn before release: the eBus maintainer's r202633 conformance note +established that the panel publishes that property charge-positive where the +specification requires discharge-positive, so `_charge_positive()` inverts it on +real firmware. Those tests could never have caught it — `ebus-panel-sim` 0.6.0 +fixed the same inversion in the simulator, so on that one property the simulator +is correct where the panel is not, and everything here runs against the +simulator. The agreement they asserted was real and was an agreement between two +things that were both wrong in the same way. + +See `BESS_TELEMETRY_SENSORS` for the withdrawal and the condition for restoring +it. `POWER_TOPIC` and `POWER_KEY` are kept below because the capture still +publishes the property and the fixtures still name it. """ from __future__ import annotations @@ -29,7 +35,12 @@ from typing import Any from unittest.mock import MagicMock, patch +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import CONF_HOST +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.typing import StateType import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry from span_panel_api import SpanPanelSnapshot from custom_components.span_panel import SpanPanelRuntimeData @@ -49,16 +60,10 @@ SpanPanelBattery, SpanPanelPowerSensor, ) -from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass -from homeassistant.const import CONF_HOST, UnitOfPower -from homeassistant.helpers.entity import EntityCategory -from homeassistant.helpers.typing import StateType from .adapter_fixtures import SCHEMA_ONE_PANEL, schema_one_snapshot, schema_one_tree from .factories import SpanPanelSnapshotFactory -from pytest_homeassistant_custom_component.common import MockConfigEntry - BESS = "bess" POWER_TOPIC = "meter/active-power" @@ -172,56 +177,10 @@ def _state(snapshot: SpanPanelSnapshot, key: str) -> StateType | date | datetime # --------------------------------------------------------------------------- -def test_the_capture_publishes_both_properties() -> None: - """Guard the premise for every test below, all of which read the capture for - their expected value: a capture that stopped publishing one would make them - vacuously true rather than failing.""" - assert _published(BESS, POWER_TOPIC) - assert _published(BESS, COMMS_TOPIC) - - -def test_the_capture_is_a_charging_battery() -> None: - """The premise of every sign assertion below, derived rather than assumed. - A sign convention can only be tested against a known physical state, and - "negative means charging" is the very claim under test, so reading the state - off the sign would be circular. The enclosure's four power flows balance - instead — `pv + battery + grid == site`, with `grid` positive when importing — - and solving that identity says which way the battery is going without - appealing to any convention this codebase chose. - The capture: 8500 W of PV meets 2653 W of site load and exports 2347 W, and - the 3500 W left over is going into the battery. So this battery is charging, - and the enclosure publishes that as a *negative* number. - Were the capture ever retaken with the battery discharging, this fails first - and says so, rather than the sign tests failing and reading as a wiring bug. - """ - flows = { - name: float(_published(SCHEMA_ONE_PANEL, f"power-flows/{name}")) - for name in ("pv", "battery", "grid", "site") - } - - assert flows["pv"] + flows["battery"] + flows["grid"] == pytest.approx(flows["site"]) - # PV alone exceeds the site load, so the surplus has nowhere to go but the - # battery and the grid — and the grid term is an export. - assert flows["pv"] > flows["site"] - assert flows["grid"] < 0 - 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. - - `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. Pinned here rather than assumed, because "negate exactly one - of them" would be the wrong rule if a firmware ever published them opposed. - """ - bess_meter = float(_published(BESS, POWER_TOPIC)) - enclosure_flow = float(_published(SCHEMA_ONE_PANEL, ENCLOSURE_FLOW_TOPIC)) - assert (bess_meter < 0) == (enclosure_flow < 0) # --------------------------------------------------------------------------- @@ -229,56 +188,10 @@ def test_the_bess_meter_agrees_with_the_enclosure_about_direction() -> None: # --------------------------------------------------------------------------- -def test_charging_reads_positive() -> None: - """The convention, asserted on the state a user sees rather than on the field. - The wire is charge-negative and the snapshot is charge-positive, so the - sensor's state is the negation of the published value. Magnitude and sign are - asserted separately on purpose: losing the negation keeps the magnitude, so - only the sign check catches it. - """ - published = float(_published(BESS, POWER_TOPIC)) - state = _state(schema_one_snapshot(), POWER_KEY) - assert state == -published - assert isinstance(state, float) and state > 0 - - -def test_it_agrees_with_the_battery_power_sensor_beside_it() -> None: - """The two battery-power sensors on this device must not contradict each other. - - `battery_power` reads the enclosure's arbitrated `power-flows/battery`, which - the capability catalog defines as discharge-positive; this one reads the - BESS's own meter, which the enclosure publishes charge-negative. Two opposite - wire conventions, and the UI shows one — so the check is on the states, not - on either definition. A flip on either side fails here even if the side that - flipped still looks self-consistent. - """ - snapshot = schema_one_snapshot() - - own_meter = _state(snapshot, POWER_KEY) - enclosure_flow = _state(snapshot, ENCLOSURE_FLOW_KEY) - - assert isinstance(own_meter, float) and isinstance(enclosure_flow, float) - assert (own_meter > 0) == (enclosure_flow > 0) - - -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 negative together. - """ - snapshot = _republishing_both( - power=-float(_published(BESS, POWER_TOPIC)), - enclosure_flow=-float(_published(SCHEMA_ONE_PANEL, ENCLOSURE_FLOW_TOPIC)), - ) - own_meter = _state(snapshot, POWER_KEY) - enclosure_flow = _state(snapshot, ENCLOSURE_FLOW_KEY) - assert isinstance(own_meter, float) and own_meter < 0 - assert isinstance(enclosure_flow, float) and enclosure_flow < 0 def _republishing_both(*, power: float, enclosure_flow: float) -> SpanPanelSnapshot: @@ -294,40 +207,16 @@ def _republishing_both(*, power: float, enclosure_flow: float) -> SpanPanelSnaps # --------------------------------------------------------------------------- -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.""" - published = float(_published(BESS, POWER_TOPIC)) - discharging = -published / 2 - snapshot = _republishing(meter__active_power=str(discharging)) - 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: - """`-0.0` compares equal to `0.0` and renders as "-0.0" beside it, so a - negation added without a guard produces a reading that looks broken exactly - when nothing is happening.""" - snapshot = _republishing(meter__active_power="0.0") - - assert _state(snapshot, POWER_KEY) == 0.0 - assert str(_state(snapshot, POWER_KEY)) == "0.0" - - -def test_zero_watts_is_a_state_and_not_an_absence() -> None: - """An idle battery is a reading. A gate that treated zero as absence would - delete the entity whenever the battery stopped moving power.""" - snapshot = _republishing(meter__active_power="0.0") - - assert POWER_KEY in _sensors(snapshot) def test_the_communication_state_is_the_published_enum_lowercased() -> None: """Lowercase because HA looks the state up as a translation key, which its own - contract restricts to `[a-z0-9-_]+`.""" + contract restricts to `[a-z0-9-_]+`. + """ published = _published(BESS, COMMS_TOPIC) assert _state(schema_one_snapshot(), COMMS_KEY) == published.lower() @@ -336,7 +225,8 @@ def test_the_communication_state_is_the_published_enum_lowercased() -> None: @pytest.mark.parametrize("republished", ["DEGRADED", "LOST", "UNKNOWN"]) def test_republishing_the_communication_state_moves_the_sensor(republished: str) -> None: """Every other member of the enum the BESS's own `$description` declares, so - a sensor pinned to the captured OK cannot report any of them.""" + a sensor pinned to the captured OK cannot report any of them. + """ snapshot = _republishing(status__communication_state=republished) assert _state(snapshot, COMMS_KEY) == republished.lower() @@ -345,7 +235,8 @@ def test_republishing_the_communication_state_moves_the_sensor(republished: str) def test_the_declared_options_are_the_enum_the_bess_declares() -> None: """The sensor's "Possible states" against the wire's `format`, so a firmware - that widens the enum is caught here rather than by the runtime append.""" + that widens the enum is caught here rather than by the runtime append. + """ description = json.loads(schema_one_tree()[BESS]["$description"]) declared = description["nodes"]["status"]["properties"]["communication-state"]["format"] @@ -374,35 +265,17 @@ def test_communication_state_is_not_the_connected_binary_sensor() -> None: # --------------------------------------------------------------------------- -def test_the_capture_creates_both_sensors() -> None: - created = _sensors(schema_one_snapshot()) - - assert POWER_KEY in created - assert COMMS_KEY in created -def test_a_bess_with_no_meter_node_gets_no_power_sensor() -> None: - """A dead entity stuck at unknown is worse than no entity: it occupies the - entity list, breaks a dashboard card, and cannot be told apart from a battery - whose meter has failed.""" - snapshot = _without_node("meter") - - assert POWER_KEY not in _sensors(snapshot) - # The other half of the pair is unaffected — a partial BESS is legal firmware. - assert COMMS_KEY in _sensors(snapshot) def test_a_bess_with_no_status_node_gets_no_communication_sensor() -> None: snapshot = _without_node("status") assert COMMS_KEY not in _sensors(snapshot) - assert POWER_KEY in _sensors(snapshot) -@pytest.mark.parametrize( - ("key", "topic", "unknown"), - [(POWER_KEY, POWER_TOPIC, None), (COMMS_KEY, COMMS_TOPIC, "unknown")], -) +@pytest.mark.parametrize(("key", "topic", "unknown"), [(COMMS_KEY, COMMS_TOPIC, "unknown")]) def test_a_reading_that_stops_arriving_goes_unknown_rather_than_stale( key: str, topic: str, unknown: str | None ) -> None: @@ -469,7 +342,8 @@ def test_no_bess_device_creates_no_battery_sensors_at_all() -> None: def test_a_flat_panel_gets_neither_sensor() -> None: """The same absence by the other route: flat's BESS device class declares - neither property, so the factory's default snapshot carries neither field.""" + neither property, so the factory's default snapshot carries neither field. + """ snapshot = SpanPanelSnapshotFactory.create() assert has_bess_telemetry(snapshot) is False @@ -480,7 +354,8 @@ def test_a_flat_panel_gets_neither_sensor() -> None: def test_the_telemetry_appearing_is_a_capability_change() -> None: """Which is how a BESS that gains these nodes mid-life gets the sensors: the - coordinator reloads on a new capability.""" + coordinator reloads on a new capability. + """ assert "bess_telemetry" not in detect_capabilities(SpanPanelSnapshotFactory.create()) assert "bess_telemetry" in detect_capabilities(schema_one_snapshot()) assert "bess_telemetry" in detect_capabilities(_without_node("meter")) @@ -492,22 +367,12 @@ def test_the_telemetry_appearing_is_a_capability_change() -> None: # --------------------------------------------------------------------------- -def test_the_power_sensor_is_a_watt_measurement_enabled_by_default() -> None: - """The battery's own charge/discharge figure is a reading a user graphs and - automates on, so it belongs beside the other power sensors rather than under - the diagnostics fold.""" - description = next(d for d in BESS_TELEMETRY_SENSORS if d.key == POWER_KEY) - - assert description.device_class is SensorDeviceClass.POWER - assert description.state_class is SensorStateClass.MEASUREMENT - assert description.native_unit_of_measurement == UnitOfPower.WATT - assert description.entity_registry_enabled_default is True - assert description.entity_category is not EntityCategory.DIAGNOSTIC def test_the_communication_sensor_is_a_diagnostic_off_by_default() -> None: """A fault signal: interesting when something is wrong, noise on a device card - the rest of the time.""" + the rest of the time. + """ description = next(d for d in BESS_TELEMETRY_SENSORS if d.key == COMMS_KEY) assert description.device_class is SensorDeviceClass.ENUM @@ -515,28 +380,14 @@ def test_the_communication_sensor_is_a_diagnostic_off_by_default() -> None: assert description.entity_registry_enabled_default is False -def test_the_declared_unit_matches_what_the_bess_declares() -> None: - """HA's unit against the tree's, for the path schema_1 carries metadata for. A - disagreement here is what the unit-mismatch Repair reports at runtime.""" - from .adapter_fixtures import schema_one_metadata - - description = next(d for d in BESS_TELEMETRY_SENSORS if d.key == POWER_KEY) - assert schema_one_metadata()["battery.power_w"].unit == (description.native_unit_of_measurement) -def test_both_sensors_live_on_the_bess_sub_device() -> None: - """Beside the metadata sensors and the battery level, not on the panel.""" - created = _sensors(schema_one_snapshot()) - bess_device = created["vendor"].device_info - - assert created[POWER_KEY].device_info == bess_device - assert created[COMMS_KEY].device_info == bess_device - def test_every_bess_sensor_gets_a_distinct_unique_id() -> None: """They live on one device and differ only by description key, so a key reused - from the metadata group would silently collide.""" + from the metadata group would silently collide. + """ created = _sensors(schema_one_snapshot()) unique_ids = {sensor.unique_id for sensor in created.values()} @@ -553,7 +404,8 @@ def test_both_paths_are_exempt_as_schema_1_only() -> None: specific to these properties: flat's BESS device class declares neither, so the producible gate cannot be satisfied and the descriptions must stay derived. schema_1 does map both, which is what makes the annotation - SCHEMA_1_ONLY rather than NEITHER.""" + SCHEMA_1_ONLY rather than NEITHER. + """ assert RESIDUAL_EXEMPT_PATHS["battery.power_w"] is Producibility.SCHEMA_1_ONLY assert RESIDUAL_EXEMPT_PATHS["battery.communication_state"] is Producibility.SCHEMA_1_ONLY @@ -563,6 +415,7 @@ def test_each_description_names_its_field_as_well_as_its_reason(description: Any """`field_path` says what the entity's value is and `derived` says why that path is outside the both-adapters gate. Leaving the first unset excuses the entity from its Repair mention and from going unavailable when the panel stops - resolving the property.""" + resolving the property. + """ assert description.derived is DerivedReason.SCHEMA_CONDITIONAL_FIELD assert description.field_path in RESIDUAL_EXEMPT_PATHS From 4e4da048865e42e75c9bebd31d3929c487a12380 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:01:53 -0700 Subject: [PATCH 073/345] fix(sensors): restore bess_meter_power, withdrawn on a premise the capture disproves It was pulled on the conformance note's claim that ebus-panel-sim 0.6.0 fixed the simulator's BESS meter/active-power, "so on that one property the simulator is now correct where the panel is not" -- which would have made the defect untestable here and the sensor knowingly wrong. The capture that arrived with the note says otherwise, and so does the maintainer's own comparison report beside it. Both emitter variants publish power-flows/battery and bess meter/active-power at -3500.0: aligned, not opposed, which is the panel's behaviour. distribution-enclosure-simulator#39 aligned them deliberately, because shipping firmware publishes the two that way. So the two wire properties carry the same sign as each other on both producers, each path negates once, and bess_meter_power cannot disagree with battery_power. The reason for withdrawing it does not survive contact with the data. Also settled: nothing regressed across the flat -> parent/child migration. Both adapters map the same five downstream-lugs properties to the same fields with no transform, and BATTERY_POWER_SENSOR is behaviourally identical to the one released in 2.0.8. The five disabled sensors were wrong before the migration and are wrong after it; they are not an upgrade regression. The direction the shared battery convention runs is left open rather than guessed. The docstring's charge-positive claim is withdrawn, not replaced: the vendored fixture predates #39 and carries the pre-fix frame, and the only live capture has the battery idle at 100 percent SoC with both properties zero. --- CHANGELOG.md | 5 + custom_components/span_panel/field_paths.py | 12 +- custom_components/span_panel/icons.json | 3 + .../span_panel/sensor_definitions.py | 103 +++++--- custom_components/span_panel/strings.json | 3 + .../span_panel/translations/en.json | 3 + .../span_panel/translations/es.json | 3 + .../span_panel/translations/fr.json | 3 + .../span_panel/translations/ja.json | 3 + .../span_panel/translations/pt.json | 3 + tests/test_bess_telemetry.py | 248 +++++++++++++++--- 11 files changed, 297 insertions(+), 92 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 580d714d..5a0f127c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ All notable changes to this project will be documented in this file. entity is first created, so an existing installation keeps them, keeps its history and keeps its entity IDs. This stops new installations picking them up; it cannot reach back. If you use any of the five on a dashboard or in an automation, they are worth removing — but that is your decision to make, not something an upgrade should do to you. +- **A new BESS Meter Power sensor, reading the battery's own meter alongside the existing Battery Power.** The two answer slightly different questions — one is + the battery's own view, the other the panel's arbitrated figure — and they agree by construction, so where they ever differ that is worth being able to see. +- **Your battery power readings are unchanged, and that is now checked against a real panel rather than assumed.** A capture from a live upgraded panel arrived + alongside the conformance note. The panel's four power-flow values sum to zero exactly, in the frame the specification now describes, and the battery power + sensor's definition is byte-for-byte what 2.0.8 shipped — same source, same conversion. Nothing about what you see has moved. - **Your other panel readings are unaffected.** The upstream lugs, the main panel meter and every circuit are in the correct frame. So is the power-flow group, which the specification has now been corrected to describe the way the panel has always published it. diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py index c48fbf1b..0c8135d5 100644 --- a/custom_components/span_panel/field_paths.py +++ b/custom_components/span_panel/field_paths.py @@ -294,12 +294,12 @@ class FieldPathDeclarationMixin: # is what makes these SCHEMA_1_ONLY rather than NEITHER and buys them unit # and datatype validation against the BESS's own `$description`. # - # `battery.power_w` has no entity of its own since `bess_meter_power` was - # withdrawn -- see `BESS_TELEMETRY_SENSORS` for why and for the condition - # to restore it. The path stays exempt and annotated because `has_bess` - # still reads the field to decide whether a battery is commissioned, and - # the annotation is a fact about which adapter produces it rather than - # about who consumes it. + # `battery.power_w` carries `bess_meter_power`, and `has_bess` reads it to + # decide whether a battery is commissioned. It agrees with `battery_power` + # by construction: the two wire properties behind them carry the same sign + # as each other on both the panel and the emitter, and each path negates + # once. See `BESS_TELEMETRY_SENSORS` for which direction that shared + # convention runs, which a live capture has not settled. "battery.power_w": Producibility.SCHEMA_1_ONLY, "battery.communication_state": Producibility.SCHEMA_1_ONLY, # The Power Control System's result, behind `pcs_import_limit`, diff --git a/custom_components/span_panel/icons.json b/custom_components/span_panel/icons.json index edf980c4..1272e4a6 100644 --- a/custom_components/span_panel/icons.json +++ b/custom_components/span_panel/icons.json @@ -227,6 +227,9 @@ }, "pcs_binding_constraint": { "default": "mdi:scale-balance" + }, + "bess_meter_power": { + "default": "mdi:battery-charging-outline" } } }, diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py index 4c3adf43..bd3f0b1f 100644 --- a/custom_components/span_panel/sensor_definitions.py +++ b/custom_components/span_panel/sensor_definitions.py @@ -809,7 +809,21 @@ class SpanMidSensorEntityDescription(SensorEntityDescription, SpanMidRequiredKey ), ) -BESS_TELEMETRY_SENSORS: tuple[SpanBessMetadataSensorEntityDescription,] = ( +BESS_TELEMETRY_SENSORS: tuple[ + SpanBessMetadataSensorEntityDescription, + SpanBessMetadataSensorEntityDescription, +] = ( + SpanBessMetadataSensorEntityDescription( + key="meter_power", + field_path="battery.power_w", + derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD, + translation_key="bess_meter_power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=0, + value_fn=lambda b: b.power_w, + ), SpanBessMetadataSensorEntityDescription( key="communication_state", field_path="battery.communication_state", @@ -826,55 +840,58 @@ class SpanMidSensorEntityDescription(SensorEntityDescription, SpanMidRequiredKey Separate from `BESS_METADATA_SENSORS` because these are created conditionally and those are not. Every metadata sensor exists on any commissioned BESS, filled or -empty. This one comes from a capability node a BESS may simply not have, so +empty. These two come from capability nodes a BESS may simply not have, so absence has to mean no entity rather than a permanently unknown one, and mixing the two rules into one tuple would mean deciding per description which applied. -Communication state is diagnostic and off by default: link health is a fault -signal, interesting when something is wrong and noise on a device card the rest -of the time. - -**`bess_meter_power` was here and was withdrawn before it ever shipped.** - -It read `battery.power_w`, the BESS child's own `meter/active-power`, alongside -`battery_power` reading the enclosure's `power-flows/battery` — two views of one -quantity, so that a disagreement between them would be visible rather than -hidden behind a single entity. - -The eBus maintainer's r202633 conformance note (2026-08-20) established that the -two will disagree *systematically* on a real panel, for a firmware reason rather -than a panel-health one. The BESS child publishes `meter/active-power` -charge-positive where the specification requires discharge-positive, and -`build_battery`'s `_charge_positive()` negates on the assumption that the -specification holds — so `battery.power_w` inverts on that firmware. The note -calls this the one divergence a consumer cannot detect on its own: both signs -are physically plausible and the two topics are numerically identical, so -nothing in the tree contradicts itself observably. - -Nothing here can catch it either. `ebus-panel-sim` 0.6.0 fixed the same -inversion in the simulator, so on this one property the simulator is now correct -where the panel is not, and every test in this repository runs against the -simulator. - -Withdrawn rather than compensated, and rather than shipped documented, for the -reason the timing allows: this description had never been released, so no -install carries the entity and no history is at stake. Dropping it now is free. -Once a release creates it, the rule that an existing install's entities cannot -simply disappear makes the same decision expensive. - -**Restore it when the firmware publishes the specified frame.** The correct -reading is already shipped and unaffected — `battery_power` reads -`power-flows/battery`, which the note confirms is correct as published — so -nothing is lost meanwhile except the ability to compare the two. The wire -property stays in `tests/fixtures/unread_declarations_baseline.json` with that -reason, which is what will fail when somebody surfaces it again without -revisiting this. +**Power is enabled by default and not diagnostic; communication state is +neither.** The battery's own charge/discharge figure is a reading a user graphs +and automates on. Its link health is a fault signal, so it lands the way the +other diagnostics do, off by default. + +**`bess_meter_power` is not `battery_power`, and the names say so.** The existing +`battery_power` sensor reads `panel.power_flow_battery`, the enclosure's own +arbitrated flow figure; this one reads the BESS's `meter/active-power`, the +battery's own meter. + +**They agree, and this is why.** Both wire properties carry the *same* sign as +each other -- a live panel capture and `ebus-panel-sim` 0.6.0 both publish +`-3500.0` for the pair -- and each path applies exactly one negation, so the two +entities land on one convention whatever that convention is. A sensor whose sign +contradicted the one beside it would be worse than no sensor, and the agreement +is structural rather than lucky. + +**Which direction that shared convention runs is not settled, and this docstring +used to claim it was.** It said charge-positive. Working it through against +`ebus-panel-sim` 0.6.0 -- where the four power-flow terms sum to zero and the +identity forces the battery to be *discharging* at 3500 W -- both sensors read +`+3500`, which is discharge-positive. The claim is withdrawn rather than +replaced, because the sample that would settle it does not exist yet: the only +live capture has the battery idle at 100 percent SoC with both properties +exactly zero. + +**What is settled is that nothing here regressed.** `BATTERY_POWER_SENSOR` is +behaviourally identical to the one released in 2.0.8 -- same source, same single +negation, same device and state class -- and both adapters pass +`power_flow_battery` through untouched. Whatever a live panel showed then, it +shows now. `bess_meter_power` was briefly withdrawn on the belief that it would +disagree with its neighbour on real firmware; the capture showed the two wire +properties aligned on the panel *and* on the emitter, so it cannot, and it is +restored. + +**The one thing still worth building** is a discriminator, because the alignment +above is the eBus specification's *violation* rather than its rule: the spec +defines `power-flows/battery` as the negation of the BESS meter. Comparing the +two properties therefore tells a consumer which firmware it is talking to -- +identical means today's, opposed means a future conformant one -- which is what +would let `battery.power_w` stay correct across that change without a release. +Undecidable while the battery is idle and both read zero. **`derived` as well as `field_path`, by the producible rule.** The gate wants a path both adapters produce, and flat's BESS device class declares neither -property — so `SCHEMA_CONDITIONAL_FIELD`, with the paths enumerated in +property -- so `SCHEMA_CONDITIONAL_FIELD`, with the paths enumerated in `RESIDUAL_EXEMPT_PATHS` as `SCHEMA_1_ONLY`. `field_path` still names the source, -which is what gives the sensor its Repair mention and its unavailability when +which is what gives each sensor its Repair mention and its unavailability when the panel stops resolving the property. """ diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index 4f568a29..4ae594ae 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -392,6 +392,9 @@ "none": "None", "unknown": "Unknown" } + }, + "bess_meter_power": { + "name": "Meter Power" } } }, diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index 4f568a29..4ae594ae 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -392,6 +392,9 @@ "none": "None", "unknown": "Unknown" } + }, + "bess_meter_power": { + "name": "Meter Power" } } }, diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index 1b027532..8a938910 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -392,6 +392,9 @@ "none": "Ninguno", "unknown": "Desconocido" } + }, + "bess_meter_power": { + "name": "Potencia del Medidor" } } }, diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index e60a9181..36ff2e1f 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -392,6 +392,9 @@ "none": "Aucun", "unknown": "Inconnu" } + }, + "bess_meter_power": { + "name": "Puissance du Compteur" } } }, diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index eb363fb3..5b7f7201 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -392,6 +392,9 @@ "none": "なし", "unknown": "不明" } + }, + "bess_meter_power": { + "name": "メーター電力" } } }, diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index 71b0e6b4..5d76ee6e 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -392,6 +392,9 @@ "none": "Nenhum", "unknown": "Desconhecido" } + }, + "bess_meter_power": { + "name": "Potência do Medidor" } } }, diff --git a/tests/test_bess_telemetry.py b/tests/test_bess_telemetry.py index f544ea7c..72e917c9 100644 --- a/tests/test_bess_telemetry.py +++ b/tests/test_bess_telemetry.py @@ -1,8 +1,8 @@ -"""What the BESS reports about itself, surfaced on its own device. +"""What the BESS reports about itself, surfaced as two sensors on its own device. -`status/communication-state` is the battery's own view of its link, as opposed -to the enclosure's view of it. It was declared, published and read by nobody -until v1.0. +`meter/active-power` and `status/communication-state` were declared, published +and read by nobody. They are the battery's own view of its power and its own view +of its link, as opposed to the enclosure's view of both. Every assertion runs against a real snapshot built by the real schema_1 adapter over the vendored capture, and every expected value is read out of that capture @@ -10,21 +10,30 @@ pins passes whether or not the wire is ever read. Each reading is proved by republishing it, deleting it, or dropping the node that carries it. -**This module used to be mostly about a sign, and that sensor is gone.** -`bess_meter_power` read the BESS child's own `meter/active-power` beside -`battery_power`'s enclosure view, and fourteen tests here pinned their agreement. -It was withdrawn before release: the eBus maintainer's r202633 conformance note -established that the panel publishes that property charge-positive where the -specification requires discharge-positive, so `_charge_positive()` inverts it on -real firmware. Those tests could never have caught it — `ebus-panel-sim` 0.6.0 -fixed the same inversion in the simulator, so on that one property the simulator -is correct where the panel is not, and everything here runs against the -simulator. The agreement they asserted was real and was an agreement between two -things that were both wrong in the same way. - -See `BESS_TELEMETRY_SENSORS` for the withdrawal and the condition for restoring -it. `POWER_TOPIC` and `POWER_KEY` are kept below because the capture still -publishes the property and the fixtures still name 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 negative `meter/active-power` +because the enclosure meters the BESS the way it meters a circuit it feeds. The +snapshot negates that into its charge-positive frame, and `battery_power` — the +sensor already on this device, reading the enclosure's discharge-positive +`power-flows/battery` — negates too. Two negations of two opposite wire +conventions, landing on one convention in the UI. 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.** +Both wire properties carry the *same* sign as each other -- a live panel capture +and `ebus-panel-sim` 0.6.0 both publish `-3500.0` for the pair -- and each path +applies exactly one negation, so the two entities cannot disagree whatever the +convention. That is what the agreement assertions below pin, and it holds +independently of which way is charging. + +Which way *is* charging is read off the vendored capture, and that capture +predates `distribution-enclosure-simulator#39`: its four power-flow terms satisfy +`grid + pv + battery == site` rather than summing to zero, so it carries the +pre-fix frame. The only live capture available has the battery idle at 100 +percent SoC with both properties exactly zero, so nothing has settled the +absolute direction. Refresh the fixture from a 0.6.0-era emitter and revisit +these assertions together; do not change one without the other. """ from __future__ import annotations @@ -35,12 +44,7 @@ from typing import Any from unittest.mock import MagicMock, patch -from homeassistant.components.sensor import SensorDeviceClass -from homeassistant.const import CONF_HOST -from homeassistant.helpers.entity import EntityCategory -from homeassistant.helpers.typing import StateType import pytest -from pytest_homeassistant_custom_component.common import MockConfigEntry from span_panel_api import SpanPanelSnapshot from custom_components.span_panel import SpanPanelRuntimeData @@ -60,10 +64,16 @@ SpanPanelBattery, SpanPanelPowerSensor, ) +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import CONF_HOST, UnitOfPower +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.typing import StateType from .adapter_fixtures import SCHEMA_ONE_PANEL, schema_one_snapshot, schema_one_tree from .factories import SpanPanelSnapshotFactory +from pytest_homeassistant_custom_component.common import MockConfigEntry + BESS = "bess" POWER_TOPIC = "meter/active-power" @@ -177,10 +187,56 @@ def _state(snapshot: SpanPanelSnapshot, key: str) -> StateType | date | datetime # --------------------------------------------------------------------------- +def test_the_capture_publishes_both_properties() -> None: + """Guard the premise for every test below, all of which read the capture for + their expected value: a capture that stopped publishing one would make them + vacuously true rather than failing.""" + assert _published(BESS, POWER_TOPIC) + assert _published(BESS, COMMS_TOPIC) + + +def test_the_capture_is_a_charging_battery() -> None: + """The premise of every sign assertion below, derived rather than assumed. + A sign convention can only be tested against a known physical state, and + "negative means charging" is the very claim under test, so reading the state + off the sign would be circular. The enclosure's four power flows balance + instead — `pv + battery + grid == site`, with `grid` positive when importing — + and solving that identity says which way the battery is going without + appealing to any convention this codebase chose. + The capture: 8500 W of PV meets 2653 W of site load and exports 2347 W, and + the 3500 W left over is going into the battery. So this battery is charging, + and the enclosure publishes that as a *negative* number. + Were the capture ever retaken with the battery discharging, this fails first + and says so, rather than the sign tests failing and reading as a wiring bug. + """ + flows = { + name: float(_published(SCHEMA_ONE_PANEL, f"power-flows/{name}")) + for name in ("pv", "battery", "grid", "site") + } + + assert flows["pv"] + flows["battery"] + flows["grid"] == pytest.approx(flows["site"]) + # PV alone exceeds the site load, so the surplus has nowhere to go but the + # battery and the grid — and the grid term is an export. + assert flows["pv"] > flows["site"] + assert flows["grid"] < 0 + 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. + + `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. Pinned here rather than assumed, because "negate exactly one + of them" would be the wrong rule if a firmware ever published them opposed. + """ + bess_meter = float(_published(BESS, POWER_TOPIC)) + enclosure_flow = float(_published(SCHEMA_ONE_PANEL, ENCLOSURE_FLOW_TOPIC)) + assert (bess_meter < 0) == (enclosure_flow < 0) # --------------------------------------------------------------------------- @@ -188,10 +244,56 @@ def _state(snapshot: SpanPanelSnapshot, key: str) -> StateType | date | datetime # --------------------------------------------------------------------------- +def test_charging_reads_positive() -> None: + """The convention, asserted on the state a user sees rather than on the field. + The wire is charge-negative and the snapshot is charge-positive, so the + sensor's state is the negation of the published value. Magnitude and sign are + asserted separately on purpose: losing the negation keeps the magnitude, so + only the sign check catches it. + """ + published = float(_published(BESS, POWER_TOPIC)) + state = _state(schema_one_snapshot(), POWER_KEY) + assert state == -published + assert isinstance(state, float) and state > 0 + + +def test_it_agrees_with_the_battery_power_sensor_beside_it() -> None: + """The two battery-power sensors on this device must not contradict each other. + + `battery_power` reads the enclosure's arbitrated `power-flows/battery`, which + the capability catalog defines as discharge-positive; this one reads the + BESS's own meter, which the enclosure publishes charge-negative. Two opposite + wire conventions, and the UI shows one — so the check is on the states, not + on either definition. A flip on either side fails here even if the side that + flipped still looks self-consistent. + """ + snapshot = schema_one_snapshot() + + own_meter = _state(snapshot, POWER_KEY) + enclosure_flow = _state(snapshot, ENCLOSURE_FLOW_KEY) + + assert isinstance(own_meter, float) and isinstance(enclosure_flow, float) + assert (own_meter > 0) == (enclosure_flow > 0) + + +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 negative together. + """ + snapshot = _republishing_both( + power=-float(_published(BESS, POWER_TOPIC)), + enclosure_flow=-float(_published(SCHEMA_ONE_PANEL, ENCLOSURE_FLOW_TOPIC)), + ) + own_meter = _state(snapshot, POWER_KEY) + enclosure_flow = _state(snapshot, ENCLOSURE_FLOW_KEY) + assert isinstance(own_meter, float) and own_meter < 0 + assert isinstance(enclosure_flow, float) and enclosure_flow < 0 def _republishing_both(*, power: float, enclosure_flow: float) -> SpanPanelSnapshot: @@ -207,16 +309,40 @@ def _republishing_both(*, power: float, enclosure_flow: float) -> SpanPanelSnaps # --------------------------------------------------------------------------- +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.""" + published = float(_published(BESS, POWER_TOPIC)) + discharging = -published / 2 + snapshot = _republishing(meter__active_power=str(discharging)) + 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: + """`-0.0` compares equal to `0.0` and renders as "-0.0" beside it, so a + negation added without a guard produces a reading that looks broken exactly + when nothing is happening.""" + snapshot = _republishing(meter__active_power="0.0") + + assert _state(snapshot, POWER_KEY) == 0.0 + assert str(_state(snapshot, POWER_KEY)) == "0.0" + + +def test_zero_watts_is_a_state_and_not_an_absence() -> None: + """An idle battery is a reading. A gate that treated zero as absence would + delete the entity whenever the battery stopped moving power.""" + snapshot = _republishing(meter__active_power="0.0") + + assert POWER_KEY in _sensors(snapshot) def test_the_communication_state_is_the_published_enum_lowercased() -> None: """Lowercase because HA looks the state up as a translation key, which its own - contract restricts to `[a-z0-9-_]+`. - """ + contract restricts to `[a-z0-9-_]+`.""" published = _published(BESS, COMMS_TOPIC) assert _state(schema_one_snapshot(), COMMS_KEY) == published.lower() @@ -225,8 +351,7 @@ def test_the_communication_state_is_the_published_enum_lowercased() -> None: @pytest.mark.parametrize("republished", ["DEGRADED", "LOST", "UNKNOWN"]) def test_republishing_the_communication_state_moves_the_sensor(republished: str) -> None: """Every other member of the enum the BESS's own `$description` declares, so - a sensor pinned to the captured OK cannot report any of them. - """ + a sensor pinned to the captured OK cannot report any of them.""" snapshot = _republishing(status__communication_state=republished) assert _state(snapshot, COMMS_KEY) == republished.lower() @@ -235,8 +360,7 @@ def test_republishing_the_communication_state_moves_the_sensor(republished: str) def test_the_declared_options_are_the_enum_the_bess_declares() -> None: """The sensor's "Possible states" against the wire's `format`, so a firmware - that widens the enum is caught here rather than by the runtime append. - """ + that widens the enum is caught here rather than by the runtime append.""" description = json.loads(schema_one_tree()[BESS]["$description"]) declared = description["nodes"]["status"]["properties"]["communication-state"]["format"] @@ -265,17 +389,35 @@ def test_communication_state_is_not_the_connected_binary_sensor() -> None: # --------------------------------------------------------------------------- +def test_the_capture_creates_both_sensors() -> None: + created = _sensors(schema_one_snapshot()) + + assert POWER_KEY in created + assert COMMS_KEY in created +def test_a_bess_with_no_meter_node_gets_no_power_sensor() -> None: + """A dead entity stuck at unknown is worse than no entity: it occupies the + entity list, breaks a dashboard card, and cannot be told apart from a battery + whose meter has failed.""" + snapshot = _without_node("meter") + + assert POWER_KEY not in _sensors(snapshot) + # The other half of the pair is unaffected — a partial BESS is legal firmware. + assert COMMS_KEY in _sensors(snapshot) def test_a_bess_with_no_status_node_gets_no_communication_sensor() -> None: snapshot = _without_node("status") assert COMMS_KEY not in _sensors(snapshot) + assert POWER_KEY in _sensors(snapshot) -@pytest.mark.parametrize(("key", "topic", "unknown"), [(COMMS_KEY, COMMS_TOPIC, "unknown")]) +@pytest.mark.parametrize( + ("key", "topic", "unknown"), + [(POWER_KEY, POWER_TOPIC, None), (COMMS_KEY, COMMS_TOPIC, "unknown")], +) def test_a_reading_that_stops_arriving_goes_unknown_rather_than_stale( key: str, topic: str, unknown: str | None ) -> None: @@ -342,8 +484,7 @@ def test_no_bess_device_creates_no_battery_sensors_at_all() -> None: def test_a_flat_panel_gets_neither_sensor() -> None: """The same absence by the other route: flat's BESS device class declares - neither property, so the factory's default snapshot carries neither field. - """ + neither property, so the factory's default snapshot carries neither field.""" snapshot = SpanPanelSnapshotFactory.create() assert has_bess_telemetry(snapshot) is False @@ -354,8 +495,7 @@ def test_a_flat_panel_gets_neither_sensor() -> None: def test_the_telemetry_appearing_is_a_capability_change() -> None: """Which is how a BESS that gains these nodes mid-life gets the sensors: the - coordinator reloads on a new capability. - """ + coordinator reloads on a new capability.""" assert "bess_telemetry" not in detect_capabilities(SpanPanelSnapshotFactory.create()) assert "bess_telemetry" in detect_capabilities(schema_one_snapshot()) assert "bess_telemetry" in detect_capabilities(_without_node("meter")) @@ -367,12 +507,22 @@ def test_the_telemetry_appearing_is_a_capability_change() -> None: # --------------------------------------------------------------------------- +def test_the_power_sensor_is_a_watt_measurement_enabled_by_default() -> None: + """The battery's own charge/discharge figure is a reading a user graphs and + automates on, so it belongs beside the other power sensors rather than under + the diagnostics fold.""" + description = next(d for d in BESS_TELEMETRY_SENSORS if d.key == POWER_KEY) + + assert description.device_class is SensorDeviceClass.POWER + assert description.state_class is SensorStateClass.MEASUREMENT + assert description.native_unit_of_measurement == UnitOfPower.WATT + assert description.entity_registry_enabled_default is True + assert description.entity_category is not EntityCategory.DIAGNOSTIC def test_the_communication_sensor_is_a_diagnostic_off_by_default() -> None: """A fault signal: interesting when something is wrong, noise on a device card - the rest of the time. - """ + the rest of the time.""" description = next(d for d in BESS_TELEMETRY_SENSORS if d.key == COMMS_KEY) assert description.device_class is SensorDeviceClass.ENUM @@ -380,14 +530,28 @@ def test_the_communication_sensor_is_a_diagnostic_off_by_default() -> None: assert description.entity_registry_enabled_default is False +def test_the_declared_unit_matches_what_the_bess_declares() -> None: + """HA's unit against the tree's, for the path schema_1 carries metadata for. A + disagreement here is what the unit-mismatch Repair reports at runtime.""" + from .adapter_fixtures import schema_one_metadata + + description = next(d for d in BESS_TELEMETRY_SENSORS if d.key == POWER_KEY) + assert schema_one_metadata()["battery.power_w"].unit == (description.native_unit_of_measurement) +def test_both_sensors_live_on_the_bess_sub_device() -> None: + """Beside the metadata sensors and the battery level, not on the panel.""" + created = _sensors(schema_one_snapshot()) + bess_device = created["vendor"].device_info + + assert created[POWER_KEY].device_info == bess_device + assert created[COMMS_KEY].device_info == bess_device + def test_every_bess_sensor_gets_a_distinct_unique_id() -> None: """They live on one device and differ only by description key, so a key reused - from the metadata group would silently collide. - """ + from the metadata group would silently collide.""" created = _sensors(schema_one_snapshot()) unique_ids = {sensor.unique_id for sensor in created.values()} @@ -404,8 +568,7 @@ def test_both_paths_are_exempt_as_schema_1_only() -> None: specific to these properties: flat's BESS device class declares neither, so the producible gate cannot be satisfied and the descriptions must stay derived. schema_1 does map both, which is what makes the annotation - SCHEMA_1_ONLY rather than NEITHER. - """ + SCHEMA_1_ONLY rather than NEITHER.""" assert RESIDUAL_EXEMPT_PATHS["battery.power_w"] is Producibility.SCHEMA_1_ONLY assert RESIDUAL_EXEMPT_PATHS["battery.communication_state"] is Producibility.SCHEMA_1_ONLY @@ -415,7 +578,6 @@ def test_each_description_names_its_field_as_well_as_its_reason(description: Any """`field_path` says what the entity's value is and `derived` says why that path is outside the both-adapters gate. Leaving the first unset excuses the entity from its Repair mention and from going unavailable when the panel stops - resolving the property. - """ + resolving the property.""" assert description.derived is DerivedReason.SCHEMA_CONDITIONAL_FIELD assert description.field_path in RESIDUAL_EXEMPT_PATHS From 4bdadd3bc238bffb8e51db8cca53bb336a2d5f8c Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:10:32 -0700 Subject: [PATCH 074/345] test(fixtures): read the adapter's published capture instead of vendoring it tests/fixtures/schema_one_tree.json was byte-identical to the parent/child capture the adapter already ships as package data, and nothing checked the two still agreed. The adapter's own README says that file exists to be read -- "never by path", and a consumer pinning a version gets the bytes that version's parser was written against -- so the copy was one more artifact to go stale for no benefit. Reading the published one means the capture moves when the pinned adapter moves, and the library's peer-conformance check against the producer covers it transitively rather than this repository needing its own. The two variants go with it. schema_one_tree_no_pv.json and schema_one_tree_batteryless.json differed from the base by exactly one missing device each, so they are now derived with schema_one_tree(without=...) and cannot drift from it by construction. Verified by mutation rather than by inspection: injecting a device into the library's package data makes the integration's fixture see it. --- tests/adapter_fixtures.py | 49 ++-- tests/fixtures/schema_one_tree.json | 234 ------------------ .../fixtures/schema_one_tree_batteryless.json | 220 ---------------- tests/fixtures/schema_one_tree_no_pv.json | 226 ----------------- 4 files changed, 33 insertions(+), 696 deletions(-) delete mode 100644 tests/fixtures/schema_one_tree.json delete mode 100644 tests/fixtures/schema_one_tree_batteryless.json delete mode 100644 tests/fixtures/schema_one_tree_no_pv.json diff --git a/tests/adapter_fixtures.py b/tests/adapter_fixtures.py index 869c786b..d2fd0b20 100644 --- a/tests/adapter_fixtures.py +++ b/tests/adapter_fixtures.py @@ -18,13 +18,14 @@ from ebus_sdk.homie import DiscoveredDevice from span_panel_api.models import FieldMetadata, SpanPanelSnapshot +from span_panel_api_schema_1.reference_payloads import parent_child_tree from custom_components.span_panel.schema_validation import DiscoveredProperty _FIXTURES = pathlib.Path(__file__).parent / "fixtures" SCHEMA_ONE_PANEL = "example-40t-001" -"""Device id of the enclosure in `schema_one_tree.json`. +"""Device id of the enclosure in the adapter's published capture. Named rather than inlined because a test that mutates one of the panel's published topics has to address the panel, and every such test would otherwise @@ -51,21 +52,37 @@ def _device(device_id: str, topics: dict[str, str]) -> DiscoveredDevice: return device -def _devices(name: str) -> list[DiscoveredDevice]: - """Rebuild discovered devices from a named retained-topic capture.""" - tree = json.loads((_FIXTURES / name).read_text()) +def _devices_from(tree: dict[str, dict[str, str]]) -> list[DiscoveredDevice]: + """Rebuild discovered devices from a retained-topic capture.""" return [_device(device_id, topics) for device_id, topics in tree.items()] -def schema_one_tree() -> dict[str, dict[str, str]]: +def schema_one_tree(without: str | None = None) -> dict[str, dict[str, str]]: """A mutable copy of the parent/child capture, ready to be rewritten. - Copied per call, and one level deep, which is as deep as a topic goes: a - test proves a reading came off the wire by republishing it and asserting the - entity followed, and that is impossible against a shared immutable capture. + **Read from the library's package data, not vendored here.** The adapter ships + `parent_child_tree.json` precisely so a consumer can read it -- its own README + says "never by path", and that a consumer pinning a version gets the bytes + that version's parser was written against. This repository used to keep a + byte-identical copy under `tests/fixtures/`, which is one more artifact to go + stale and nothing checked the two still agreed. Reading the published one + means the capture moves when the pinned adapter moves, and the library's own + peer-conformance check against the producer covers it transitively. + + Copied per call, and one level deep, which is as deep as a topic goes: a test + proves a reading came off the wire by republishing it and asserting the entity + followed, and that is impossible against a shared immutable capture. + + `without` drops one device, which is how the batteryless and PV-less variants + are made. They were separate files and are now derived, so they cannot drift + from the base by construction -- the only difference each ever had was the one + missing device. """ - tree = json.loads((_FIXTURES / "schema_one_tree.json").read_text()) - return {device_id: dict(topics) for device_id, topics in tree.items()} + tree = {device_id: dict(topics) for device_id, topics in parent_child_tree().items()} + if without is not None: + assert without in tree, f"{without!r} is not in the capture; nothing to drop" + del tree[without] + return tree def schema_one_snapshot(tree: dict[str, dict[str, str]] | None = None) -> SpanPanelSnapshot: @@ -125,10 +142,10 @@ def schema_one_metadata() -> dict[str, FieldMetadata]: """Curated field metadata as schema_1 builds it from a full parent/child tree.""" from span_panel_api_schema_1.field_metadata import build_field_metadata - return _curated(build_field_metadata(_devices("schema_one_tree.json"))) + return _curated(build_field_metadata(_devices_from(schema_one_tree()))) -def schema_one_metadata_raw(name: str = "schema_one_tree.json") -> dict[str, FieldMetadata]: +def schema_one_metadata_raw() -> dict[str, FieldMetadata]: """The adapter's map exactly as it returns it, both halves together. The one fixture that does *not* partition, because the partition is the @@ -137,7 +154,7 @@ def schema_one_metadata_raw(name: str = "schema_one_tree.json") -> dict[str, Fie """ from span_panel_api_schema_1.field_metadata import build_field_metadata - return build_field_metadata(_devices(name)) + return build_field_metadata(_devices_from(schema_one_tree())) def schema_one_discovery() -> tuple[DiscoveredProperty, ...]: @@ -151,18 +168,18 @@ def schema_one_discovery() -> tuple[DiscoveredProperty, ...]: from custom_components.span_panel.schema_validation import partition - return partition(build_field_metadata(_devices("schema_one_tree.json")))[1] + return partition(build_field_metadata(_devices_from(schema_one_tree())))[1] def schema_one_metadata_batteryless() -> dict[str, FieldMetadata]: """Build the same tree with the BESS removed — no battery hardware present.""" from span_panel_api_schema_1.field_metadata import build_field_metadata - return _curated(build_field_metadata(_devices("schema_one_tree_batteryless.json"))) + return _curated(build_field_metadata(_devices_from(schema_one_tree(without="bess")))) def schema_one_metadata_no_pv() -> dict[str, FieldMetadata]: """Build the same tree with the PV device removed, power-flows still present.""" from span_panel_api_schema_1.field_metadata import build_field_metadata - return _curated(build_field_metadata(_devices("schema_one_tree_no_pv.json"))) + return _curated(build_field_metadata(_devices_from(schema_one_tree(without="pv")))) diff --git a/tests/fixtures/schema_one_tree.json b/tests/fixtures/schema_one_tree.json deleted file mode 100644 index 0aee1b83..00000000 --- a/tests/fixtures/schema_one_tree.json +++ /dev/null @@ -1,234 +0,0 @@ -{ - "0ab966b95f92a6a51ec548485aa85f54": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "breaker/poles": "1", - "breaker/rating": "15", - "info/name": "Kitchen Lights", - "info/spaces": "1", - "load-shed/priority": "UNKNOWN", - "meter/active-power": "-121.0", - "meter/current": "1.0083333333333333", - "meter/exported-energy": "2.0166666666666666", - "meter/imported-energy": "0.0", - "pcs/managed": "true", - "pcs/priority": "1", - "switch/relay": "CLOSED", - "switch/relay-controllable": "true", - "switch/relay-requester": "NONE" - }, - "573066aaddd7b75114c4563ce3af18c4": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Solar Inverter\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "breaker/poles": "2", - "breaker/rating": "30", - "connection/feeds-device-id": "pv", - "connection/feeds-device-status": "OK", - "connection/feeds-device-type": "energy.ebus.device.pv", - "info/name": "Solar Inverter", - "info/spaces": "36,38", - "load-shed/priority": "NEVER", - "meter/active-power": "8500.0", - "meter/current": "35.416666666666664", - "meter/exported-energy": "0.0", - "meter/imported-energy": "141.66666666666666", - "pcs/managed": "false", - "pcs/priority": "5", - "switch/relay": "CLOSED", - "switch/relay-controllable": "false", - "switch/relay-requester": "NONE" - }, - "62d0e03897b337b57101aae82f1e9ba2": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "breaker/poles": "2", - "breaker/rating": "50", - "connection/feeds-device-id": "evse", - "connection/feeds-device-status": "OK", - "connection/feeds-device-type": "energy.ebus.device.evse", - "info/name": "SPAN Drive - Garage", - "info/spaces": "32,34", - "load-shed/priority": "OFF_GRID", - "meter/active-power": "-2410.0", - "meter/current": "10.041666666666666", - "meter/exported-energy": "40.166666666666664", - "meter/imported-energy": "0.0", - "pcs/managed": "true", - "pcs/priority": "3", - "switch/relay": "CLOSED", - "switch/relay-controllable": "true", - "switch/relay-requester": "NONE" - }, - "bess": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.bess\", \"name\": \"Battery\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"soc\": {\"name\": \"soc\", \"type\": \"energy.ebus.capability.soc\", \"properties\": {\"soc\": {\"name\": \"State of charge\", \"datatype\": \"float\", \"unit\": \"%\"}, \"soe\": {\"name\": \"State of energy\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"communication-state\": {\"name\": \"Communication state\", \"datatype\": \"enum\", \"format\": \"OK,DEGRADED,LOST,UNKNOWN\"}}}}, \"children\": [\"bess-mid\"], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "info/firmware-version": "example-bess/v0.1.0", - "info/model": "Example BESS", - "info/nameplate-capacity": "13.5", - "info/part-number": "SPN-BESS-001", - "info/serial-number": "EXAMPLE-BESS-40T-001", - "info/vendor-name": "Span", - "meter/active-power": "-3500.0", - "soc/soc": "50.410493827160494", - "soc/soe": "6.805416666666667", - "status/communication-state": "OK" - }, - "bess-mid": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.mid\", \"name\": \"Microgrid Interconnect Device\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}}}, \"grid\": {\"name\": \"grid\", \"type\": \"energy.ebus.capability.grid\", \"properties\": {\"islanding-state\": {\"name\": \"Islanding state of the BESS-integrated grid-forming device\", \"datatype\": \"enum\", \"format\": \"ON_GRID,OFF_GRID,UNKNOWN\"}, \"grid-state\": {\"name\": \"Sensed grid condition\", \"datatype\": \"enum\", \"format\": \"UP,DOWN,DEGRADED,UNKNOWN\"}, \"grid-forming-entity\": {\"name\": \"Identity of the currently grid-forming entity\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"bess\", \"extensions\": []}", - "$state": "ready", - "grid/grid-forming-entity": "GRID", - "grid/grid-state": "UP", - "grid/islanding-state": "ON_GRID", - "info/firmware-version": "example-mid/v0.1.0", - "info/hardware-version": "rev1", - "info/model": "SPAN MID", - "info/serial-number": "EXAMPLE-BESS-40T-001-mid", - "info/vendor-name": "Span" - }, - "d3724e0d660ba506aa79c1cafe5d1181": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garage Outlet\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "breaker/poles": "1", - "breaker/rating": "20", - "info/name": "Garage Outlet", - "info/spaces": "2", - "load-shed/priority": "UNKNOWN", - "meter/active-power": "-122.0", - "meter/current": "1.0166666666666666", - "meter/exported-energy": "2.033333333333333", - "meter/imported-energy": "0.0", - "pcs/managed": "true", - "pcs/priority": "2", - "switch/relay": "CLOSED", - "switch/relay-controllable": "true", - "switch/relay-requester": "NONE" - }, - "evse": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "config/max-charge-current": "32", - "config/user-max-charge-current": "32", - "info/firmware-version": "example/v0.1.0", - "info/model": "SPAN Drive", - "info/part-number": "SPN-DRV-001", - "info/serial-number": "SIM-EVSE-example-40t-001", - "info/vendor-name": "SPAN", - "meter/advertised-current": "32.0", - "status/status": "CHARGING", - "switch/lock-state": "LOCKED" - }, - "evse-2": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "config/max-charge-current": "32", - "config/user-max-charge-current": "32", - "info/firmware-version": "example/v0.1.0", - "info/model": "SPAN Drive", - "info/part-number": "SPN-DRV-001", - "info/serial-number": "SIM-EVSE-example-40t-001-2", - "info/vendor-name": "SPAN", - "meter/advertised-current": "32.0", - "status/status": "AVAILABLE", - "switch/lock-state": "UNLOCKED" - }, - "example-40t-001": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Example 40-tab Panel\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"enum\", \"format\": \"MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"data-model-version\": {\"name\": \"eBus data-model version (parent/child schema discriminator)\", \"datatype\": \"string\"}}}, \"door\": {\"name\": \"door\", \"type\": \"energy.ebus.capability.door\", \"properties\": {\"state\": {\"name\": \"Door state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"voltage-a\": {\"name\": \"L1 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}, \"voltage-b\": {\"name\": \"L2 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Main breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"enabled\": {\"name\": \"PCS system enabled\", \"datatype\": \"boolean\"}, \"active\": {\"name\": \"PCS system actively controlling one (or more) loads\", \"datatype\": \"boolean\"}, \"import-limit\": {\"name\": \"The power import limit currently being managed to\", \"datatype\": \"float\", \"unit\": \"A\"}, \"binding-constraint\": {\"name\": \"Which constraint class currently sets the import limit\", \"datatype\": \"enum\", \"format\": \"FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN\"}, \"feed-import-limit\": {\"name\": \"Limit of maximum power feeding the distribution enclosure\", \"datatype\": \"float\", \"unit\": \"A\"}, \"feed-import-limit-enablement\": {\"name\": \"Enablement status of the feed-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"feed-import-limit-active\": {\"name\": \"Is feed-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"operator-import-limit\": {\"name\": \"Operator-imposed maximum import limit\", \"datatype\": \"float\", \"unit\": \"A\"}, \"operator-import-limit-enablement\": {\"name\": \"Enablement status of the operator-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"operator-import-limit-active\": {\"name\": \"Is operator-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"off-grid-import-limit\": {\"name\": \"Off-Grid limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"off-grid-import-limit-enablement\": {\"name\": \"Enablement status of the off-grid-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"off-grid-import-limit-active\": {\"name\": \"Is off-grid-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"requested-import-limit\": {\"name\": \"Requested limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"requested-import-limit-enablement\": {\"name\": \"Enablement status of the requested-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"requested-import-limit-active\": {\"name\": \"Is requested-import-limit currently being enforced?\", \"datatype\": \"boolean\"}}}, \"shed-forecast\": {\"name\": \"shed-forecast\", \"type\": \"energy.ebus.capability.shed-forecast\", \"properties\": {\"total-time-remaining\": {\"name\": \"Estimated total time before all sheddable circuits are shed (off-grid runtime)\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"time-to-priority-shed\": {\"name\": \"Estimated time before the next priority tier is shed\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-total-time-remaining\": {\"name\": \"Estimated total time assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-time-to-priority-shed\": {\"name\": \"Estimated time to next priority shed assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"confidence\": {\"name\": \"Confidence of the shed-forecast estimate\", \"datatype\": \"enum\", \"format\": \"LOW,MEDIUM,HIGH\"}}}, \"shed\": {\"name\": \"shed\", \"type\": \"energy.ebus.capability.shed\", \"properties\": {\"asserted-islanding-state\": {\"name\": \"Consumer-asserted islanding-state (grid-state override during MID/BESS comm-loss)\", \"datatype\": \"enum\", \"format\": \"NONE,ON_GRID,OFF_GRID\", \"settable\": true}, \"policy\": {\"name\": \"Shed policy (algorithm and parameters)\", \"datatype\": \"json\", \"format\": \"{\\\"$id\\\":\\\"soc-priority.v1\\\",\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"algorithm\\\",\\\"parameters\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"algorithm\\\":{\\\"const\\\":\\\"soc-priority.v1\\\"},\\\"parameters\\\":{\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"soc-threshold-shed\\\",\\\"soc-threshold-release\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"soc-threshold-shed\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent below which SOC_THRESHOLD circuits shed\\\"},\\\"soc-threshold-release\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent above which shed SOC_THRESHOLD circuits restore\\\"}}}}}\"}}}, \"power-flows\": {\"name\": \"power-flows\", \"type\": \"energy.ebus.capability.power-flows\", \"properties\": {\"pv\": {\"name\": \"PV power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"battery\": {\"name\": \"Battery/BESS power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"grid\": {\"name\": \"Grid power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"site\": {\"name\": \"Site power flow\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"relay\": {\"name\": \"Main relay\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"ethernet\": {\"name\": \"Is Ethernet network interface operational?\", \"datatype\": \"boolean\"}, \"wifi\": {\"name\": \"Is Wi-Fi network interface operational?\", \"datatype\": \"boolean\"}, \"wifi-ssid\": {\"name\": \"SSID to which Wi-Fi network interface is connected\", \"datatype\": \"string\"}, \"cloud-connection\": {\"name\": \"Device connected to vendor cloud?\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,UNCONNECTED,CONNECTED\"}, \"postal-code\": {\"name\": \"Postal (Zip) code\", \"datatype\": \"string\"}, \"time-zone\": {\"name\": \"Time zone\", \"datatype\": \"string\"}}}}, \"children\": [\"bess\", \"0ab966b95f92a6a51ec548485aa85f54\", \"d3724e0d660ba506aa79c1cafe5d1181\", \"62d0e03897b337b57101aae82f1e9ba2\", \"fe8b85c15bc9610c1b8b4ebc6f82488d\", \"573066aaddd7b75114c4563ce3af18c4\", \"evse\", \"evse-2\", \"lugs-upstream\", \"lugs-downstream\", \"pv\"], \"extensions\": []}", - "$state": "ready", - "breaker/rating": "200", - "door/state": "CLOSED", - "info/data-model-version": "1.0", - "info/firmware-version": "example/v0.1.0", - "info/hardware-version": "rev2", - "info/model": "MAIN_40", - "info/serial-number": "example-40t-001", - "info/vendor-name": "Span", - "meter/voltage-a": "120.0", - "meter/voltage-b": "120.0", - "pcs/active": "false", - "pcs/binding-constraint": "NONE", - "pcs/enabled": "false", - "pcs/feed-import-limit": "0.0", - "pcs/feed-import-limit-active": "false", - "pcs/feed-import-limit-enablement": "UNCONFIGURED", - "pcs/import-limit": "0.0", - "pcs/off-grid-import-limit": "0.0", - "pcs/off-grid-import-limit-active": "false", - "pcs/off-grid-import-limit-enablement": "UNCONFIGURED", - "pcs/operator-import-limit": "0.0", - "pcs/operator-import-limit-active": "false", - "pcs/operator-import-limit-enablement": "UNCONFIGURED", - "pcs/requested-import-limit": "0.0", - "pcs/requested-import-limit-active": "false", - "pcs/requested-import-limit-enablement": "UNCONFIGURED", - "power-flows/battery": "-3500.0", - "power-flows/grid": "-2347.0", - "power-flows/pv": "8500.0", - "power-flows/site": "2653.0", - "shed-forecast/confidence": "HIGH", - "shed-forecast/full-charge-time-to-priority-shed": "3038", - "shed-forecast/full-charge-total-time-remaining": "4320", - "shed-forecast/time-to-priority-shed": "3037", - "shed-forecast/total-time-remaining": "4320", - "shed/asserted-islanding-state": "NONE", - "shed/policy": "{\"algorithm\": \"soc-priority.v1\", \"parameters\": {\"soc-threshold-shed\": 20, \"soc-threshold-release\": 30}}", - "status/cloud-connection": "CONNECTED", - "status/ethernet": "true", - "status/postal-code": "94103", - "status/relay": "CLOSED", - "status/time-zone": "America/Los_Angeles", - "status/wifi": "true", - "status/wifi-ssid": "example-wifi" - }, - "fe8b85c15bc9610c1b8b4ebc6f82488d": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "breaker/poles": "2", - "breaker/rating": "50", - "connection/feeds-device-id": "evse-2", - "connection/feeds-device-status": "OK", - "connection/feeds-device-type": "energy.ebus.device.evse", - "info/name": "SPAN Drive - Driveway", - "info/spaces": "35,37", - "load-shed/priority": "OFF_GRID", - "meter/active-power": "0.0", - "meter/current": "0.0", - "meter/exported-energy": "0.0", - "meter/imported-energy": "0.0", - "pcs/managed": "true", - "pcs/priority": "4", - "switch/relay": "CLOSED", - "switch/relay-controllable": "true", - "switch/relay-requester": "NONE" - }, - "lugs-downstream": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Downstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "info/direction": "DOWNSTREAM", - "meter/active-power": "-5847.0", - "meter/current-a": "46.46666666666666", - "meter/current-b": "46.474999999999994", - "meter/exported-energy": "141.66666666666666", - "meter/imported-energy": "44.21666666666666" - }, - "lugs-upstream": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Upstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "connection/fed-by-device-id": "bess", - "connection/fed-by-device-status": "OK", - "connection/fed-by-device-type": "energy.ebus.device.bess", - "info/direction": "UPSTREAM", - "meter/active-power": "-5847.0", - "meter/current-a": "46.46666666666666", - "meter/current-b": "46.474999999999994", - "meter/exported-energy": "141.66666666666666", - "meter/imported-energy": "44.21666666666666" - }, - "pv": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.pv\", \"name\": \"Solar\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nominal-power\": {\"name\": \"Nominal power\", \"datatype\": \"float\", \"unit\": \"W\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "info/firmware-version": "example-pv/v0.1.0", - "info/model": "IQ8PLUS-72-2-US", - "info/nominal-power": "10000.0", - "info/vendor-name": "Enphase" - } -} diff --git a/tests/fixtures/schema_one_tree_batteryless.json b/tests/fixtures/schema_one_tree_batteryless.json deleted file mode 100644 index 5e67af74..00000000 --- a/tests/fixtures/schema_one_tree_batteryless.json +++ /dev/null @@ -1,220 +0,0 @@ -{ - "0ab966b95f92a6a51ec548485aa85f54": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "breaker/poles": "1", - "breaker/rating": "15", - "info/name": "Kitchen Lights", - "info/spaces": "1", - "load-shed/priority": "UNKNOWN", - "meter/active-power": "-121.0", - "meter/current": "1.0083333333333333", - "meter/exported-energy": "2.0166666666666666", - "meter/imported-energy": "0.0", - "pcs/managed": "true", - "pcs/priority": "1", - "switch/relay": "CLOSED", - "switch/relay-controllable": "true", - "switch/relay-requester": "NONE" - }, - "573066aaddd7b75114c4563ce3af18c4": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Solar Inverter\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "breaker/poles": "2", - "breaker/rating": "30", - "connection/feeds-device-id": "pv", - "connection/feeds-device-status": "OK", - "connection/feeds-device-type": "energy.ebus.device.pv", - "info/name": "Solar Inverter", - "info/spaces": "36,38", - "load-shed/priority": "NEVER", - "meter/active-power": "8500.0", - "meter/current": "35.416666666666664", - "meter/exported-energy": "0.0", - "meter/imported-energy": "141.66666666666666", - "pcs/managed": "false", - "pcs/priority": "5", - "switch/relay": "CLOSED", - "switch/relay-controllable": "false", - "switch/relay-requester": "NONE" - }, - "62d0e03897b337b57101aae82f1e9ba2": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "breaker/poles": "2", - "breaker/rating": "50", - "connection/feeds-device-id": "evse", - "connection/feeds-device-status": "OK", - "connection/feeds-device-type": "energy.ebus.device.evse", - "info/name": "SPAN Drive - Garage", - "info/spaces": "32,34", - "load-shed/priority": "OFF_GRID", - "meter/active-power": "-2410.0", - "meter/current": "10.041666666666666", - "meter/exported-energy": "40.166666666666664", - "meter/imported-energy": "0.0", - "pcs/managed": "true", - "pcs/priority": "3", - "switch/relay": "CLOSED", - "switch/relay-controllable": "true", - "switch/relay-requester": "NONE" - }, - "bess-mid": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.mid\", \"name\": \"Microgrid Interconnect Device\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}}}, \"grid\": {\"name\": \"grid\", \"type\": \"energy.ebus.capability.grid\", \"properties\": {\"islanding-state\": {\"name\": \"Islanding state of the BESS-integrated grid-forming device\", \"datatype\": \"enum\", \"format\": \"ON_GRID,OFF_GRID,UNKNOWN\"}, \"grid-state\": {\"name\": \"Sensed grid condition\", \"datatype\": \"enum\", \"format\": \"UP,DOWN,DEGRADED,UNKNOWN\"}, \"grid-forming-entity\": {\"name\": \"Identity of the currently grid-forming entity\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"bess\", \"extensions\": []}", - "$state": "ready", - "grid/grid-forming-entity": "GRID", - "grid/grid-state": "UP", - "grid/islanding-state": "ON_GRID", - "info/firmware-version": "example-mid/v0.1.0", - "info/hardware-version": "rev1", - "info/model": "SPAN MID", - "info/serial-number": "EXAMPLE-BESS-40T-001-mid", - "info/vendor-name": "Span" - }, - "d3724e0d660ba506aa79c1cafe5d1181": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garage Outlet\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "breaker/poles": "1", - "breaker/rating": "20", - "info/name": "Garage Outlet", - "info/spaces": "2", - "load-shed/priority": "UNKNOWN", - "meter/active-power": "-122.0", - "meter/current": "1.0166666666666666", - "meter/exported-energy": "2.033333333333333", - "meter/imported-energy": "0.0", - "pcs/managed": "true", - "pcs/priority": "2", - "switch/relay": "CLOSED", - "switch/relay-controllable": "true", - "switch/relay-requester": "NONE" - }, - "evse": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "config/max-charge-current": "32", - "config/user-max-charge-current": "32", - "info/firmware-version": "example/v0.1.0", - "info/model": "SPAN Drive", - "info/part-number": "SPN-DRV-001", - "info/serial-number": "SIM-EVSE-example-40t-001", - "info/vendor-name": "SPAN", - "meter/advertised-current": "32.0", - "status/status": "CHARGING", - "switch/lock-state": "LOCKED" - }, - "evse-2": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "config/max-charge-current": "32", - "config/user-max-charge-current": "32", - "info/firmware-version": "example/v0.1.0", - "info/model": "SPAN Drive", - "info/part-number": "SPN-DRV-001", - "info/serial-number": "SIM-EVSE-example-40t-001-2", - "info/vendor-name": "SPAN", - "meter/advertised-current": "32.0", - "status/status": "AVAILABLE", - "switch/lock-state": "UNLOCKED" - }, - "example-40t-001": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Example 40-tab Panel\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"enum\", \"format\": \"MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"data-model-version\": {\"name\": \"eBus data-model version (parent/child schema discriminator)\", \"datatype\": \"string\"}}}, \"door\": {\"name\": \"door\", \"type\": \"energy.ebus.capability.door\", \"properties\": {\"state\": {\"name\": \"Door state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"voltage-a\": {\"name\": \"L1 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}, \"voltage-b\": {\"name\": \"L2 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Main breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"enabled\": {\"name\": \"PCS system enabled\", \"datatype\": \"boolean\"}, \"active\": {\"name\": \"PCS system actively controlling one (or more) loads\", \"datatype\": \"boolean\"}, \"import-limit\": {\"name\": \"The power import limit currently being managed to\", \"datatype\": \"float\", \"unit\": \"A\"}, \"binding-constraint\": {\"name\": \"Which constraint class currently sets the import limit\", \"datatype\": \"enum\", \"format\": \"FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN\"}, \"feed-import-limit\": {\"name\": \"Limit of maximum power feeding the distribution enclosure\", \"datatype\": \"float\", \"unit\": \"A\"}, \"feed-import-limit-enablement\": {\"name\": \"Enablement status of the feed-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"feed-import-limit-active\": {\"name\": \"Is feed-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"operator-import-limit\": {\"name\": \"Operator-imposed maximum import limit\", \"datatype\": \"float\", \"unit\": \"A\"}, \"operator-import-limit-enablement\": {\"name\": \"Enablement status of the operator-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"operator-import-limit-active\": {\"name\": \"Is operator-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"off-grid-import-limit\": {\"name\": \"Off-Grid limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"off-grid-import-limit-enablement\": {\"name\": \"Enablement status of the off-grid-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"off-grid-import-limit-active\": {\"name\": \"Is off-grid-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"requested-import-limit\": {\"name\": \"Requested limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"requested-import-limit-enablement\": {\"name\": \"Enablement status of the requested-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"requested-import-limit-active\": {\"name\": \"Is requested-import-limit currently being enforced?\", \"datatype\": \"boolean\"}}}, \"shed-forecast\": {\"name\": \"shed-forecast\", \"type\": \"energy.ebus.capability.shed-forecast\", \"properties\": {\"total-time-remaining\": {\"name\": \"Estimated total time before all sheddable circuits are shed (off-grid runtime)\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"time-to-priority-shed\": {\"name\": \"Estimated time before the next priority tier is shed\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-total-time-remaining\": {\"name\": \"Estimated total time assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-time-to-priority-shed\": {\"name\": \"Estimated time to next priority shed assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"confidence\": {\"name\": \"Confidence of the shed-forecast estimate\", \"datatype\": \"enum\", \"format\": \"LOW,MEDIUM,HIGH\"}}}, \"shed\": {\"name\": \"shed\", \"type\": \"energy.ebus.capability.shed\", \"properties\": {\"asserted-islanding-state\": {\"name\": \"Consumer-asserted islanding-state (grid-state override during MID/BESS comm-loss)\", \"datatype\": \"enum\", \"format\": \"NONE,ON_GRID,OFF_GRID\", \"settable\": true}, \"policy\": {\"name\": \"Shed policy (algorithm and parameters)\", \"datatype\": \"json\", \"format\": \"{\\\"$id\\\":\\\"soc-priority.v1\\\",\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"algorithm\\\",\\\"parameters\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"algorithm\\\":{\\\"const\\\":\\\"soc-priority.v1\\\"},\\\"parameters\\\":{\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"soc-threshold-shed\\\",\\\"soc-threshold-release\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"soc-threshold-shed\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent below which SOC_THRESHOLD circuits shed\\\"},\\\"soc-threshold-release\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent above which shed SOC_THRESHOLD circuits restore\\\"}}}}}\"}}}, \"power-flows\": {\"name\": \"power-flows\", \"type\": \"energy.ebus.capability.power-flows\", \"properties\": {\"pv\": {\"name\": \"PV power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"battery\": {\"name\": \"Battery/BESS power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"grid\": {\"name\": \"Grid power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"site\": {\"name\": \"Site power flow\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"relay\": {\"name\": \"Main relay\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"ethernet\": {\"name\": \"Is Ethernet network interface operational?\", \"datatype\": \"boolean\"}, \"wifi\": {\"name\": \"Is Wi-Fi network interface operational?\", \"datatype\": \"boolean\"}, \"wifi-ssid\": {\"name\": \"SSID to which Wi-Fi network interface is connected\", \"datatype\": \"string\"}, \"cloud-connection\": {\"name\": \"Device connected to vendor cloud?\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,UNCONNECTED,CONNECTED\"}, \"postal-code\": {\"name\": \"Postal (Zip) code\", \"datatype\": \"string\"}, \"time-zone\": {\"name\": \"Time zone\", \"datatype\": \"string\"}}}}, \"children\": [\"bess\", \"0ab966b95f92a6a51ec548485aa85f54\", \"d3724e0d660ba506aa79c1cafe5d1181\", \"62d0e03897b337b57101aae82f1e9ba2\", \"fe8b85c15bc9610c1b8b4ebc6f82488d\", \"573066aaddd7b75114c4563ce3af18c4\", \"evse\", \"evse-2\", \"lugs-upstream\", \"lugs-downstream\", \"pv\"], \"extensions\": []}", - "$state": "ready", - "breaker/rating": "200", - "door/state": "CLOSED", - "info/data-model-version": "1.0", - "info/firmware-version": "example/v0.1.0", - "info/hardware-version": "rev2", - "info/model": "MAIN_40", - "info/serial-number": "example-40t-001", - "info/vendor-name": "Span", - "meter/voltage-a": "120.0", - "meter/voltage-b": "120.0", - "pcs/active": "false", - "pcs/binding-constraint": "NONE", - "pcs/enabled": "false", - "pcs/feed-import-limit": "0.0", - "pcs/feed-import-limit-active": "false", - "pcs/feed-import-limit-enablement": "UNCONFIGURED", - "pcs/import-limit": "0.0", - "pcs/off-grid-import-limit": "0.0", - "pcs/off-grid-import-limit-active": "false", - "pcs/off-grid-import-limit-enablement": "UNCONFIGURED", - "pcs/operator-import-limit": "0.0", - "pcs/operator-import-limit-active": "false", - "pcs/operator-import-limit-enablement": "UNCONFIGURED", - "pcs/requested-import-limit": "0.0", - "pcs/requested-import-limit-active": "false", - "pcs/requested-import-limit-enablement": "UNCONFIGURED", - "power-flows/battery": "-3500.0", - "power-flows/grid": "-2347.0", - "power-flows/pv": "8500.0", - "power-flows/site": "2653.0", - "shed-forecast/confidence": "HIGH", - "shed-forecast/full-charge-time-to-priority-shed": "3038", - "shed-forecast/full-charge-total-time-remaining": "4320", - "shed-forecast/time-to-priority-shed": "3037", - "shed-forecast/total-time-remaining": "4320", - "shed/asserted-islanding-state": "NONE", - "shed/policy": "{\"algorithm\": \"soc-priority.v1\", \"parameters\": {\"soc-threshold-shed\": 20, \"soc-threshold-release\": 30}}", - "status/cloud-connection": "CONNECTED", - "status/ethernet": "true", - "status/postal-code": "94103", - "status/relay": "CLOSED", - "status/time-zone": "America/Los_Angeles", - "status/wifi": "true", - "status/wifi-ssid": "example-wifi" - }, - "fe8b85c15bc9610c1b8b4ebc6f82488d": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "breaker/poles": "2", - "breaker/rating": "50", - "connection/feeds-device-id": "evse-2", - "connection/feeds-device-status": "OK", - "connection/feeds-device-type": "energy.ebus.device.evse", - "info/name": "SPAN Drive - Driveway", - "info/spaces": "35,37", - "load-shed/priority": "OFF_GRID", - "meter/active-power": "0.0", - "meter/current": "0.0", - "meter/exported-energy": "0.0", - "meter/imported-energy": "0.0", - "pcs/managed": "true", - "pcs/priority": "4", - "switch/relay": "CLOSED", - "switch/relay-controllable": "true", - "switch/relay-requester": "NONE" - }, - "lugs-downstream": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Downstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "info/direction": "DOWNSTREAM", - "meter/active-power": "-5847.0", - "meter/current-a": "46.46666666666666", - "meter/current-b": "46.474999999999994", - "meter/exported-energy": "141.66666666666666", - "meter/imported-energy": "44.21666666666666" - }, - "lugs-upstream": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Upstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "connection/fed-by-device-id": "bess", - "connection/fed-by-device-status": "OK", - "connection/fed-by-device-type": "energy.ebus.device.bess", - "info/direction": "UPSTREAM", - "meter/active-power": "-5847.0", - "meter/current-a": "46.46666666666666", - "meter/current-b": "46.474999999999994", - "meter/exported-energy": "141.66666666666666", - "meter/imported-energy": "44.21666666666666" - }, - "pv": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.pv\", \"name\": \"Solar\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nominal-power\": {\"name\": \"Nominal power\", \"datatype\": \"float\", \"unit\": \"W\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "info/firmware-version": "example-pv/v0.1.0", - "info/model": "IQ8PLUS-72-2-US", - "info/nominal-power": "10000.0", - "info/vendor-name": "Enphase" - } -} diff --git a/tests/fixtures/schema_one_tree_no_pv.json b/tests/fixtures/schema_one_tree_no_pv.json deleted file mode 100644 index ac92a366..00000000 --- a/tests/fixtures/schema_one_tree_no_pv.json +++ /dev/null @@ -1,226 +0,0 @@ -{ - "0ab966b95f92a6a51ec548485aa85f54": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "breaker/poles": "1", - "breaker/rating": "15", - "info/name": "Kitchen Lights", - "info/spaces": "1", - "load-shed/priority": "UNKNOWN", - "meter/active-power": "-121.0", - "meter/current": "1.0083333333333333", - "meter/exported-energy": "2.0166666666666666", - "meter/imported-energy": "0.0", - "pcs/managed": "true", - "pcs/priority": "1", - "switch/relay": "CLOSED", - "switch/relay-controllable": "true", - "switch/relay-requester": "NONE" - }, - "573066aaddd7b75114c4563ce3af18c4": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Solar Inverter\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "breaker/poles": "2", - "breaker/rating": "30", - "connection/feeds-device-id": "pv", - "connection/feeds-device-status": "OK", - "connection/feeds-device-type": "energy.ebus.device.pv", - "info/name": "Solar Inverter", - "info/spaces": "36,38", - "load-shed/priority": "NEVER", - "meter/active-power": "8500.0", - "meter/current": "35.416666666666664", - "meter/exported-energy": "0.0", - "meter/imported-energy": "141.66666666666666", - "pcs/managed": "false", - "pcs/priority": "5", - "switch/relay": "CLOSED", - "switch/relay-controllable": "false", - "switch/relay-requester": "NONE" - }, - "62d0e03897b337b57101aae82f1e9ba2": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "breaker/poles": "2", - "breaker/rating": "50", - "connection/feeds-device-id": "evse", - "connection/feeds-device-status": "OK", - "connection/feeds-device-type": "energy.ebus.device.evse", - "info/name": "SPAN Drive - Garage", - "info/spaces": "32,34", - "load-shed/priority": "OFF_GRID", - "meter/active-power": "-2410.0", - "meter/current": "10.041666666666666", - "meter/exported-energy": "40.166666666666664", - "meter/imported-energy": "0.0", - "pcs/managed": "true", - "pcs/priority": "3", - "switch/relay": "CLOSED", - "switch/relay-controllable": "true", - "switch/relay-requester": "NONE" - }, - "bess": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.bess\", \"name\": \"Battery\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"soc\": {\"name\": \"soc\", \"type\": \"energy.ebus.capability.soc\", \"properties\": {\"soc\": {\"name\": \"State of charge\", \"datatype\": \"float\", \"unit\": \"%\"}, \"soe\": {\"name\": \"State of energy\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"communication-state\": {\"name\": \"Communication state\", \"datatype\": \"enum\", \"format\": \"OK,DEGRADED,LOST,UNKNOWN\"}}}}, \"children\": [\"bess-mid\"], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "info/firmware-version": "example-bess/v0.1.0", - "info/model": "Example BESS", - "info/nameplate-capacity": "13.5", - "info/part-number": "SPN-BESS-001", - "info/serial-number": "EXAMPLE-BESS-40T-001", - "info/vendor-name": "Span", - "meter/active-power": "-3500.0", - "soc/soc": "50.410493827160494", - "soc/soe": "6.805416666666667", - "status/communication-state": "OK" - }, - "bess-mid": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.mid\", \"name\": \"Microgrid Interconnect Device\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}}}, \"grid\": {\"name\": \"grid\", \"type\": \"energy.ebus.capability.grid\", \"properties\": {\"islanding-state\": {\"name\": \"Islanding state of the BESS-integrated grid-forming device\", \"datatype\": \"enum\", \"format\": \"ON_GRID,OFF_GRID,UNKNOWN\"}, \"grid-state\": {\"name\": \"Sensed grid condition\", \"datatype\": \"enum\", \"format\": \"UP,DOWN,DEGRADED,UNKNOWN\"}, \"grid-forming-entity\": {\"name\": \"Identity of the currently grid-forming entity\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"bess\", \"extensions\": []}", - "$state": "ready", - "grid/grid-forming-entity": "GRID", - "grid/grid-state": "UP", - "grid/islanding-state": "ON_GRID", - "info/firmware-version": "example-mid/v0.1.0", - "info/hardware-version": "rev1", - "info/model": "SPAN MID", - "info/serial-number": "EXAMPLE-BESS-40T-001-mid", - "info/vendor-name": "Span" - }, - "d3724e0d660ba506aa79c1cafe5d1181": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garage Outlet\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "breaker/poles": "1", - "breaker/rating": "20", - "info/name": "Garage Outlet", - "info/spaces": "2", - "load-shed/priority": "UNKNOWN", - "meter/active-power": "-122.0", - "meter/current": "1.0166666666666666", - "meter/exported-energy": "2.033333333333333", - "meter/imported-energy": "0.0", - "pcs/managed": "true", - "pcs/priority": "2", - "switch/relay": "CLOSED", - "switch/relay-controllable": "true", - "switch/relay-requester": "NONE" - }, - "evse": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "config/max-charge-current": "32", - "config/user-max-charge-current": "32", - "info/firmware-version": "example/v0.1.0", - "info/model": "SPAN Drive", - "info/part-number": "SPN-DRV-001", - "info/serial-number": "SIM-EVSE-example-40t-001", - "info/vendor-name": "SPAN", - "meter/advertised-current": "32.0", - "status/status": "CHARGING", - "switch/lock-state": "LOCKED" - }, - "evse-2": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "config/max-charge-current": "32", - "config/user-max-charge-current": "32", - "info/firmware-version": "example/v0.1.0", - "info/model": "SPAN Drive", - "info/part-number": "SPN-DRV-001", - "info/serial-number": "SIM-EVSE-example-40t-001-2", - "info/vendor-name": "SPAN", - "meter/advertised-current": "32.0", - "status/status": "AVAILABLE", - "switch/lock-state": "UNLOCKED" - }, - "example-40t-001": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Example 40-tab Panel\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"enum\", \"format\": \"MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"data-model-version\": {\"name\": \"eBus data-model version (parent/child schema discriminator)\", \"datatype\": \"string\"}}}, \"door\": {\"name\": \"door\", \"type\": \"energy.ebus.capability.door\", \"properties\": {\"state\": {\"name\": \"Door state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"voltage-a\": {\"name\": \"L1 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}, \"voltage-b\": {\"name\": \"L2 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Main breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"enabled\": {\"name\": \"PCS system enabled\", \"datatype\": \"boolean\"}, \"active\": {\"name\": \"PCS system actively controlling one (or more) loads\", \"datatype\": \"boolean\"}, \"import-limit\": {\"name\": \"The power import limit currently being managed to\", \"datatype\": \"float\", \"unit\": \"A\"}, \"binding-constraint\": {\"name\": \"Which constraint class currently sets the import limit\", \"datatype\": \"enum\", \"format\": \"FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN\"}, \"feed-import-limit\": {\"name\": \"Limit of maximum power feeding the distribution enclosure\", \"datatype\": \"float\", \"unit\": \"A\"}, \"feed-import-limit-enablement\": {\"name\": \"Enablement status of the feed-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"feed-import-limit-active\": {\"name\": \"Is feed-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"operator-import-limit\": {\"name\": \"Operator-imposed maximum import limit\", \"datatype\": \"float\", \"unit\": \"A\"}, \"operator-import-limit-enablement\": {\"name\": \"Enablement status of the operator-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"operator-import-limit-active\": {\"name\": \"Is operator-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"off-grid-import-limit\": {\"name\": \"Off-Grid limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"off-grid-import-limit-enablement\": {\"name\": \"Enablement status of the off-grid-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"off-grid-import-limit-active\": {\"name\": \"Is off-grid-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"requested-import-limit\": {\"name\": \"Requested limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"requested-import-limit-enablement\": {\"name\": \"Enablement status of the requested-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"requested-import-limit-active\": {\"name\": \"Is requested-import-limit currently being enforced?\", \"datatype\": \"boolean\"}}}, \"shed-forecast\": {\"name\": \"shed-forecast\", \"type\": \"energy.ebus.capability.shed-forecast\", \"properties\": {\"total-time-remaining\": {\"name\": \"Estimated total time before all sheddable circuits are shed (off-grid runtime)\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"time-to-priority-shed\": {\"name\": \"Estimated time before the next priority tier is shed\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-total-time-remaining\": {\"name\": \"Estimated total time assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-time-to-priority-shed\": {\"name\": \"Estimated time to next priority shed assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"confidence\": {\"name\": \"Confidence of the shed-forecast estimate\", \"datatype\": \"enum\", \"format\": \"LOW,MEDIUM,HIGH\"}}}, \"shed\": {\"name\": \"shed\", \"type\": \"energy.ebus.capability.shed\", \"properties\": {\"asserted-islanding-state\": {\"name\": \"Consumer-asserted islanding-state (grid-state override during MID/BESS comm-loss)\", \"datatype\": \"enum\", \"format\": \"NONE,ON_GRID,OFF_GRID\", \"settable\": true}, \"policy\": {\"name\": \"Shed policy (algorithm and parameters)\", \"datatype\": \"json\", \"format\": \"{\\\"$id\\\":\\\"soc-priority.v1\\\",\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"algorithm\\\",\\\"parameters\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"algorithm\\\":{\\\"const\\\":\\\"soc-priority.v1\\\"},\\\"parameters\\\":{\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"soc-threshold-shed\\\",\\\"soc-threshold-release\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"soc-threshold-shed\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent below which SOC_THRESHOLD circuits shed\\\"},\\\"soc-threshold-release\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent above which shed SOC_THRESHOLD circuits restore\\\"}}}}}\"}}}, \"power-flows\": {\"name\": \"power-flows\", \"type\": \"energy.ebus.capability.power-flows\", \"properties\": {\"pv\": {\"name\": \"PV power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"battery\": {\"name\": \"Battery/BESS power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"grid\": {\"name\": \"Grid power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"site\": {\"name\": \"Site power flow\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"relay\": {\"name\": \"Main relay\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"ethernet\": {\"name\": \"Is Ethernet network interface operational?\", \"datatype\": \"boolean\"}, \"wifi\": {\"name\": \"Is Wi-Fi network interface operational?\", \"datatype\": \"boolean\"}, \"wifi-ssid\": {\"name\": \"SSID to which Wi-Fi network interface is connected\", \"datatype\": \"string\"}, \"cloud-connection\": {\"name\": \"Device connected to vendor cloud?\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,UNCONNECTED,CONNECTED\"}, \"postal-code\": {\"name\": \"Postal (Zip) code\", \"datatype\": \"string\"}, \"time-zone\": {\"name\": \"Time zone\", \"datatype\": \"string\"}}}}, \"children\": [\"bess\", \"0ab966b95f92a6a51ec548485aa85f54\", \"d3724e0d660ba506aa79c1cafe5d1181\", \"62d0e03897b337b57101aae82f1e9ba2\", \"fe8b85c15bc9610c1b8b4ebc6f82488d\", \"573066aaddd7b75114c4563ce3af18c4\", \"evse\", \"evse-2\", \"lugs-upstream\", \"lugs-downstream\", \"pv\"], \"extensions\": []}", - "$state": "ready", - "breaker/rating": "200", - "door/state": "CLOSED", - "info/data-model-version": "1.0", - "info/firmware-version": "example/v0.1.0", - "info/hardware-version": "rev2", - "info/model": "MAIN_40", - "info/serial-number": "example-40t-001", - "info/vendor-name": "Span", - "meter/voltage-a": "120.0", - "meter/voltage-b": "120.0", - "pcs/active": "false", - "pcs/binding-constraint": "NONE", - "pcs/enabled": "false", - "pcs/feed-import-limit": "0.0", - "pcs/feed-import-limit-active": "false", - "pcs/feed-import-limit-enablement": "UNCONFIGURED", - "pcs/import-limit": "0.0", - "pcs/off-grid-import-limit": "0.0", - "pcs/off-grid-import-limit-active": "false", - "pcs/off-grid-import-limit-enablement": "UNCONFIGURED", - "pcs/operator-import-limit": "0.0", - "pcs/operator-import-limit-active": "false", - "pcs/operator-import-limit-enablement": "UNCONFIGURED", - "pcs/requested-import-limit": "0.0", - "pcs/requested-import-limit-active": "false", - "pcs/requested-import-limit-enablement": "UNCONFIGURED", - "power-flows/battery": "-3500.0", - "power-flows/grid": "-2347.0", - "power-flows/pv": "8500.0", - "power-flows/site": "2653.0", - "shed-forecast/confidence": "HIGH", - "shed-forecast/full-charge-time-to-priority-shed": "3038", - "shed-forecast/full-charge-total-time-remaining": "4320", - "shed-forecast/time-to-priority-shed": "3037", - "shed-forecast/total-time-remaining": "4320", - "shed/asserted-islanding-state": "NONE", - "shed/policy": "{\"algorithm\": \"soc-priority.v1\", \"parameters\": {\"soc-threshold-shed\": 20, \"soc-threshold-release\": 30}}", - "status/cloud-connection": "CONNECTED", - "status/ethernet": "true", - "status/postal-code": "94103", - "status/relay": "CLOSED", - "status/time-zone": "America/Los_Angeles", - "status/wifi": "true", - "status/wifi-ssid": "example-wifi" - }, - "fe8b85c15bc9610c1b8b4ebc6f82488d": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "breaker/poles": "2", - "breaker/rating": "50", - "connection/feeds-device-id": "evse-2", - "connection/feeds-device-status": "OK", - "connection/feeds-device-type": "energy.ebus.device.evse", - "info/name": "SPAN Drive - Driveway", - "info/spaces": "35,37", - "load-shed/priority": "OFF_GRID", - "meter/active-power": "0.0", - "meter/current": "0.0", - "meter/exported-energy": "0.0", - "meter/imported-energy": "0.0", - "pcs/managed": "true", - "pcs/priority": "4", - "switch/relay": "CLOSED", - "switch/relay-controllable": "true", - "switch/relay-requester": "NONE" - }, - "lugs-downstream": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Downstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "info/direction": "DOWNSTREAM", - "meter/active-power": "-5847.0", - "meter/current-a": "46.46666666666666", - "meter/current-b": "46.474999999999994", - "meter/exported-energy": "141.66666666666666", - "meter/imported-energy": "44.21666666666666" - }, - "lugs-upstream": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Upstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "connection/fed-by-device-id": "bess", - "connection/fed-by-device-status": "OK", - "connection/fed-by-device-type": "energy.ebus.device.bess", - "info/direction": "UPSTREAM", - "meter/active-power": "-5847.0", - "meter/current-a": "46.46666666666666", - "meter/current-b": "46.474999999999994", - "meter/exported-energy": "141.66666666666666", - "meter/imported-energy": "44.21666666666666" - } -} From 0a8741dc4816cbd51ab21f7b325c88f9f7d97e3f Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:02:23 -0700 Subject: [PATCH 075/345] docs(sensors): the shared battery convention is discharge-positive, measured The docstring withdrew the charge-positive claim rather than replacing it, because nothing had settled the direction. A producer driven into self-consumption settles it: with the grid at zero, PV 4181 W plus battery 1917 W meeting a 6099 W load, the battery is discharging and both sensors read +1917.49. Positive means discharging, and that is the same convention the other three power-flow sensors follow -- pv_power positive while producing, grid_power_flow positive while importing. Every one is "positive means power flowing toward the house", each reached by one negation of a wire value in the opposite frame. Prose only. No value, no id and no state class moves; the library's rename of _charge_positive to _discharge_positive is the same kind of change. --- .../span_panel/sensor_definitions.py | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py index bd3f0b1f..56e1c8f0 100644 --- a/custom_components/span_panel/sensor_definitions.py +++ b/custom_components/span_panel/sensor_definitions.py @@ -861,14 +861,19 @@ class SpanMidSensorEntityDescription(SensorEntityDescription, SpanMidRequiredKey contradicted the one beside it would be worse than no sensor, and the agreement is structural rather than lucky. -**Which direction that shared convention runs is not settled, and this docstring -used to claim it was.** It said charge-positive. Working it through against -`ebus-panel-sim` 0.6.0 -- where the four power-flow terms sum to zero and the -identity forces the battery to be *discharging* at 3500 W -- both sensors read -`+3500`, which is discharge-positive. The claim is withdrawn rather than -replaced, because the sample that would settle it does not exist yet: the only -live capture has the battery idle at 100 percent SoC with both properties -exactly zero. +**The shared convention is discharge-positive, and that was measured.** This +docstring used to claim charge-positive. Driving the producer into +self-consumption with the grid at zero forces the direction -- PV 4181 W plus +battery 1917 W meeting a 6099 W load, so the battery is discharging -- and both +sensors read `+1917.49`. Positive means the battery is *discharging*. + +That is the same convention the other three power-flow sensors follow, which is +why it is right rather than merely consistent: `pv_power` is positive while +producing, `grid_power_flow` positive while importing, `battery_power` positive +while discharging. Every one is "positive means power flowing toward the house", +and each is reached by the same single negation of a wire value in the opposite +frame. The library's helper was renamed `_charge_positive` -> `_discharge_positive` +for the same reason. **What is settled is that nothing here regressed.** `BATTERY_POWER_SENSOR` is behaviourally identical to the one released in 2.0.8 -- same source, same single From 3ef4b26eeabf4ad5a2e6e6ac202aeb803c1eda12 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:06:39 -0700 Subject: [PATCH 076/345] fix(additions): tell sub-device entities apart in the notification Two commissioned chargers each gained a charge-current limit and a panel link, and the notification listed "EVSE Charge Current Limit" twice with nothing to distinguish them. The battery's meter read as a bare "Meter Power". Every platform sets _attr_has_entity_name, so the registry stores only the entity half and Home Assistant prepends the device in its own UI. A flat list does not, so entities that differ only by which sub-device they sit on collapsed to the same string. Two identical rows is what teaches somebody to skip the category, which costs them the additions that matter. Sub-device entities now carry their device's name; the panel's own do not, because a notification that already says which panel it is about does not need the panel name on every line. The prefix breaks collisions, and the panel is the one device with no sibling to collide with. Verified by mutation: reverting to the shipped behaviour fails the collision test and nothing else. --- custom_components/span_panel/additions.py | 38 +++++++++++-- tests/test_new_entity_announcement.py | 66 +++++++++++++++++++++++ 2 files changed, 101 insertions(+), 3 deletions(-) diff --git a/custom_components/span_panel/additions.py b/custom_components/span_panel/additions.py index 83a87681..2abd67ad 100644 --- a/custom_components/span_panel/additions.py +++ b/custom_components/span_panel/additions.py @@ -189,7 +189,7 @@ def _message(hass: HomeAssistant, added: list[er.RegistryEntry], text: dict[str, if registry_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION else enabled ) - target.append(_label(registry_entry)) + target.append(_label(devices, registry_entry)) # Two keys rather than one with a plural placeholder: a plural rule the caller # picks a *word* for is an English rule, and the five languages here do not @@ -234,15 +234,47 @@ def _adopted_device_name( return device.name_by_user or device.name or registry_entry.entity_id -def _label(registry_entry: er.RegistryEntry) -> str: +def _label(devices: dr.DeviceRegistry, registry_entry: er.RegistryEntry) -> str: """Return what to call an entity the user has never seen. A disabled entity has no state, so there is no friendly name on the state machine -- only what the registry recorded when the platform added it. The entity_id is the last resort rather than the first choice, because it is the name the user will *not* see in the device's entity list. + + **Sub-device entities carry their device's name.** Every platform here sets + `_attr_has_entity_name`, so the registry stores only the entity half and Home + Assistant prepends the device in its own UI. A flat list does not, and the + result was two chargers contributing "EVSE Charge Current Limit" twice with + nothing to tell them apart, and the battery's meter reading as a bare "Meter + Power". Two identical rows is what teaches somebody to skip the category. + + The panel's own entities are left bare. Prefixing them would put the panel + name on every line of a notification that already says which panel it is + about, which is noise rather than disambiguation -- so the prefix is added + only where the device is a sub-device, which is exactly where the collision + happens. + """ + name = registry_entry.name or registry_entry.original_name + if name is None: + return registry_entry.entity_id + device_name = _sub_device_name(devices, registry_entry) + return f"{device_name} {name}" if device_name else name + + +def _sub_device_name(devices: dr.DeviceRegistry, registry_entry: er.RegistryEntry) -> str | None: + """Return the sub-device this entity sits on, or None for the panel itself. + + A sub-device is one that hangs off another with `via_device_id` -- the + battery, the MID, each charger, the solar inverter. The panel is the one + device with no parent, and its entities need no prefix. """ - return registry_entry.name or registry_entry.original_name or registry_entry.entity_id + if registry_entry.device_id is None: + return None + device = devices.async_get(registry_entry.device_id) + if device is None or device.via_device_id is None: + return None + return device.name_by_user or device.name async def async_forget_announcements(hass: HomeAssistant, entry: ConfigEntry) -> None: diff --git a/tests/test_new_entity_announcement.py b/tests/test_new_entity_announcement.py index b395c24b..36ce604e 100644 --- a/tests/test_new_entity_announcement.py +++ b/tests/test_new_entity_announcement.py @@ -266,3 +266,69 @@ async def test_removing_the_entry_forgets_what_was_announced( await async_announce_new_entities(hass, entry) assert _announcement(hass, entry) is None + + +# -- Sub-device entities are told apart -------------------------------------- + + +def _sub_device(hass: HomeAssistant, entry: MockConfigEntry, name: str, ident: str) -> str: + """A device hanging off the panel, the way every SPAN sub-device does.""" + devices = dr.async_get(hass) + panel = devices.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={(DOMAIN, "sp3-001")}, name="SPAN Panel" + ) + child = devices.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, ident)}, + name=name, + via_device_id=panel.id, + ) + return str(child.id) + + +async def test_two_sub_devices_with_one_entity_name_are_told_apart( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """The defect this fixes, in the shape it actually shipped in. + + Two commissioned chargers each gain a charge-current limit. Every platform + sets `_attr_has_entity_name`, so the registry stores only "Charge Current + Limit" for both and Home Assistant prepends the device in its own UI. A flat + list does not, so the notification showed the same row twice with nothing to + tell them apart -- which is what teaches somebody to skip the category. + """ + _register(hass, entry, "sp3-001_existing", name="Existing") + await async_announce_new_entities(hass, entry) + + first = _sub_device(hass, entry, "SPAN Drive 1", "sp3-001_evse_1") + second = _sub_device(hass, entry, "SPAN Drive 2", "sp3-001_evse_2") + _register(hass, entry, "sp3-001_evse_1_limit", name="Charge Current Limit", device_id=first) + _register(hass, entry, "sp3-001_evse_2_limit", name="Charge Current Limit", device_id=second) + await async_announce_new_entities(hass, entry) + + message = _text(_announcement(hass, entry)) + assert "SPAN Drive 1 Charge Current Limit" in message + assert "SPAN Drive 2 Charge Current Limit" in message + assert message.count("Charge Current Limit") == 2 + + +async def test_a_panel_entity_is_not_prefixed(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """The panel needs no prefix, and adding one would be noise. + + A notification that already says which panel it is about does not need the + panel's name on every line. The prefix exists to break collisions, and the + panel is the one device that cannot collide with a sibling. + """ + _register(hass, entry, "sp3-001_existing", name="Existing") + await async_announce_new_entities(hass, entry) + + devices = dr.async_get(hass) + panel = devices.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={(DOMAIN, "sp3-001")}, name="SPAN Panel" + ) + _register(hass, entry, "sp3-001_dsm", name="DSM State", device_id=panel.id) + await async_announce_new_entities(hass, entry) + + message = _text(_announcement(hass, entry)) + assert "DSM State" in message + assert "SPAN Panel DSM State" not in message From 4e8a7640008acc7690f827c8e374b0cb2c162212 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:25:19 -0700 Subject: [PATCH 077/345] refactor(notices): the firmware upgrade is a notification, not a repair A panel that upgraded its data model announced itself twice: a hardcoded English persistent notification about the reload, then a translated Repair about what the reload changed. The Repair is what the user saw first, stamped Warning and offering to Ignore, telling them something about their panel was broken. Nothing was. It said so in its own second sentence. The Repair was chosen for durability, and that reason was sound: a persistent notification lives in memory, so one raised while the owner was away died at the next restart with nobody having read it. But durability and severity are separate properties and only one of them was wanted. `notices` supplies durability directly. A notice is recorded per config entry, re-raised at every setup, and dropped only when the user dismisses it -- observed through the registration callback rather than assumed from a restart count or an age. That is the Repair's one real advantage with none of its meaning. The two messages become one, and it is translated in all five shipped languages rather than only the half that was. Standing Repairs are cleared at setup rather than left to expire with the next restart, because the user is looking at one now. New-entity announcements move onto the same primitive; they had the same hole, and worse, because their own store already recorded the entities as announced -- a restart in the wrong second meant nobody would ever be told. The reload race is the sharp edge and is covered directly. A schema upgrade raises a notice and schedules a reload in the same breath, so the setup that follows can arrive before the delayed write has landed. Re-reading the store there would drop the notice while it sat on screen; the setup keeps the live view instead, and the test asserts on that view because the store hands a pending write back to its own reader and would hide the bug until the next restart. Twenty new tests, none of which existed: the schema-change handler had no coverage at all, which is how a notification and a Repair for one event survived. Three mutations verified -- re-reading the store on reload, dropping the entry filter on dismissal, and dropping the REMOVED guard -- each killed by the tests that claim them. --- custom_components/span_panel/__init__.py | 15 +- custom_components/span_panel/additions.py | 46 +-- custom_components/span_panel/coordinator.py | 94 ++++-- custom_components/span_panel/notices.py | 280 ++++++++++++++++++ .../span_panel/schema_repairs.py | 30 ++ custom_components/span_panel/strings.json | 8 +- .../span_panel/translations/en.json | 8 +- .../span_panel/translations/es.json | 8 +- .../span_panel/translations/fr.json | 8 +- .../span_panel/translations/ja.json | 8 +- .../span_panel/translations/pt.json | 8 +- tests/test_new_entity_announcement.py | 12 +- tests/test_notices.py | 259 ++++++++++++++++ tests/test_schema_repairs.py | 17 +- tests/test_schema_upgrade_notice.py | 169 +++++++++++ 15 files changed, 869 insertions(+), 101 deletions(-) create mode 100644 custom_components/span_panel/notices.py create mode 100644 tests/test_notices.py create mode 100644 tests/test_schema_upgrade_notice.py diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py index aa5c298d..aba6954b 100644 --- a/custom_components/span_panel/__init__.py +++ b/custom_components/span_panel/__init__.py @@ -53,8 +53,13 @@ ) from .graph_horizon import GraphHorizonManager from .migrations import CURRENT_CONFIG_VERSION, async_migrate_entry # noqa: F401 +from .notices import async_forget, async_restore from .options import SNAPSHOT_UPDATE_INTERVAL -from .schema_repairs import async_clear_retired_new_entity_notices, async_clear_schema_issues +from .schema_repairs import ( + async_clear_retired_new_entity_notices, + async_clear_retired_upgrade_notice, + async_clear_schema_issues, +) from .services import ( # noqa: F401 _async_register_favorites_services, _async_register_graph_horizon_services, @@ -135,6 +140,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> "Please reauthenticate with your panel passphrase or proximity." ) + # Before the coordinator, because the coordinator can raise a notice as soon + # as it starts streaming -- a panel that upgraded its firmware while Home + # Assistant was down announces itself on the first snapshot. Restoring after + # that would overwrite the record with a view that never saw it. + await async_restore(hass, entry) + coordinator: SpanPanelCoordinator | None = None try: @@ -280,6 +291,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> # nothing reaches the user through this and nothing else -- whether or # not it arrived switched on. async_clear_retired_new_entity_notices(hass, entry) + async_clear_retired_upgrade_notice(hass, entry) await async_announce_new_entities(hass, entry) except Exception: if coordinator is not None: @@ -325,6 +337,7 @@ async def async_remove_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) - """ async_clear_schema_issues(hass, entry) await async_forget_announcements(hass, entry) + await async_forget(hass, entry) async def async_remove_config_entry_device( diff --git a/custom_components/span_panel/additions.py b/custom_components/span_panel/additions.py index 2abd67ad..724c8e68 100644 --- a/custom_components/span_panel/additions.py +++ b/custom_components/span_panel/additions.py @@ -27,16 +27,14 @@ from __future__ import annotations -import json import logging -from pathlib import Path from typing import TYPE_CHECKING, Any, Final -from homeassistant.components.persistent_notification import async_create from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.storage import Store from .const import DOMAIN +from .notices import async_raise, read_translations from .util import ADOPTED_IDENTIFIER_TOKEN if TYPE_CHECKING: @@ -48,6 +46,11 @@ _STORE_VERSION: Final = 1 _ANNOUNCED: Final = "announced_unique_ids" +_SECTION: Final = "new_entities" +"""Names both the translation section and the notice id. They describe the same +thing, and keeping them one symbol means a rename cannot leave a standing notice +orphaned from the strings that render it.""" + _FALLBACK: Final[dict[str, str]] = { "title": "SPAN Panel added new entities", "intro_one": "This update added 1 new entity to your SPAN Panel.", @@ -78,34 +81,6 @@ def _store(hass: HomeAssistant, entry: ConfigEntry) -> Store[dict[str, Any]]: return Store(hass, _STORE_VERSION, f"{DOMAIN}.announced.{entry.entry_id}") -def _read_translations(language: str) -> dict[str, str]: - """Our own notification strings for one language, or an empty mapping. - - Read from this component's `translations/` directory rather than through - `homeassistant.helpers.translation`, because that helper filters to the - categories Home Assistant defines and a persistent notification is not one of - them -- a custom category loads as nothing at all. These are this - integration's own package files, so reading them is not reaching into - somebody else's layout. - - Blocking file I/O. Callers run it in an executor. - """ - directory = Path(__file__).parent / "translations" - for candidate in (f"{language}.json", f"{language.split('-')[0]}.json", "en.json"): - path = directory / candidate - if not path.is_file(): - continue - try: - loaded = json.loads(path.read_text(encoding="utf-8")) - except (OSError, ValueError): - _LOGGER.debug("Could not read notification strings from %s", path, exc_info=True) - continue - section = loaded.get("notifications", {}).get("new_entities", {}) - if section: - return {str(key): str(value) for key, value in section.items()} - return {} - - async def async_announce_new_entities(hass: HomeAssistant, entry: ConfigEntry) -> None: """Tell the user which entities this setup added, and which need enabling. @@ -141,12 +116,13 @@ async def async_announce_new_entities(hass: HomeAssistant, entry: ConfigEntry) - if not added: return - text = await hass.async_add_executor_job(_read_translations, hass.config.language) - async_create( + text = await hass.async_add_executor_job(read_translations, hass.config.language, _SECTION) + async_raise( hass, - message=_message(hass, added, text), + entry, + _SECTION, title=_text(text, "title"), - notification_id=f"{DOMAIN}_new_entities_{entry.entry_id}", + message=_message(hass, added, text), ) await store.async_save({_ANNOUNCED: sorted(announced | registered)}) _LOGGER.debug("Announced %d new entities for %s", len(added), entry.entry_id) diff --git a/custom_components/span_panel/coordinator.py b/custom_components/span_panel/coordinator.py index cc7824dc..a312cd1d 100644 --- a/custom_components/span_panel/coordinator.py +++ b/custom_components/span_panel/coordinator.py @@ -6,7 +6,7 @@ from datetime import timedelta import logging from time import time as _epoch_time -from typing import TYPE_CHECKING, Protocol +from typing import TYPE_CHECKING, Final, Protocol if TYPE_CHECKING: from . import SpanPanelConfigEntry @@ -21,7 +21,6 @@ HomeAssistantError, ) from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from span_panel_api import SpanMqttClient, SpanPanelClientProtocol, SpanPanelSnapshot from span_panel_api.exceptions import SpanPanelAuthError, SpanPanelStaleDataError @@ -29,6 +28,7 @@ from .const import DOMAIN from .helpers import detect_capabilities from .id_builder import build_circuit_unique_id +from .notices import async_raise, read_translations from .schema_repairs import async_sync_schema_issues from .schema_validation import SchemaFindings, evaluate_field_metadata from .sensor_definitions import sensor_descriptions_by_field_path @@ -45,6 +45,35 @@ def energy_offset(self) -> float: _LOGGER = logging.getLogger(__name__) +_UPGRADE_NOTICE: Final = "panel_upgraded" +"""Names both the notice id and its translation section. + +One symbol for both because they are the same notice: a rename that moved only +one of them would leave a standing notice pointing at strings that no longer +exist, and the notice cannot be re-derived once the upgrade is over. +""" + +_UPGRADE_FALLBACK: Final[dict[str, str]] = { + "title": "SPAN Panel firmware upgraded", + "body": ( + "Your SPAN Panel reported a new eBus data model (**{previous} \u2192 {current}**), " + "which happens after a firmware upgrade. The integration reloaded so its devices " + "and entities match what the panel now publishes.\n\n" + "Nothing you rely on has gone away, and no automation changes are required.\n\n" + "**DSM Grid State** keeps its entity ID and its history, and now reads the " + "islanding state the Microgrid Interconnect Device (MID) senses rather than " + "inferring it. **Grid Islandable** now reflects whether a MID is present.\n\n" + "Entities that were renamed or replaced by the upgrade may need to be removed " + "manually if they remain unavailable." + ), +} +"""English text, used when no translation file can be read. + +Shorter than the translated body on purpose: this is the copy nobody proofreads, +and the paragraphs it drops are elaboration rather than the facts a user needs. +""" + + # Suppress the noisy "Manually updated span_panel data" DEBUG message that # HA's DataUpdateCoordinator emits on every async_set_updated_data() call. # In push/streaming mode this fires every ~1s and drowns out useful debug logs. @@ -266,26 +295,13 @@ def _on_schema_generation_change(self, previous: str | None, current: str | None previous or "absent (flat)", current or "absent (flat)", ) - async_create( - self.hass, - ( - f"Your SPAN Panel reported a new eBus data model " - f"(**{previous or 'flat'} → {current or 'flat'}**), which happens after a " - "firmware upgrade.\n\n" - "The integration is reloading so its devices and entities match what the " - "panel now publishes. New devices — such as the Microgrid Interconnect " - "Device — appear once that finishes.\n\n" - "Entities that were renamed or replaced by the upgrade may need to be " - "removed manually if they remain unavailable." - ), - title="SPAN Panel firmware upgraded", - notification_id=f"span_schema_upgrade_{self.config_entry.entry_id}", + self.hass.async_create_task( + self._explain_the_upgrade(previous, current), "span_panel_upgrade_notice" ) - self._explain_the_upgrade(current) self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id) - def _explain_the_upgrade(self, current: str | None) -> None: - """Raise a repair describing what the new schema changed for the user. + async def _explain_the_upgrade(self, previous: str | None, current: str | None) -> None: + """Tell the user what the new schema changed for them, once and durably. Nothing they depend on goes away, which is worth saying plainly because a firmware upgrade invites the opposite assumption. @@ -306,10 +322,24 @@ def _explain_the_upgrade(self, current: str | None) -> None: What is genuinely new is the MID device itself and its `grid-state`, the health of the utility supply, which flat did not report at all. - A repair rather than only a notification because notifications are dismissed - and gone: a user who was away when the panel upgraded should still find out - that a device appeared and why a sensor changed provenance. It asks for no - action, which is why it is the mildest severity available. + **One notice, not two.** This used to be a hardcoded English notification + about the reload followed immediately by a translated Repair about the + consequences -- two rows in two different places for one event, which is + the same duplication that makes people stop reading either. They are now + one message, and it is translated. + + **A notification, not a Repair.** The Repairs list is where defects go: it + stamped this with a severity and offered to ignore it, so an upgrade that + took nothing away arrived looking like a fault. The reason it was a Repair + was durability -- a plain notification dies with the process, and somebody + away when their panel upgraded would never have learned a device appeared. + `notices` supplies that durability directly, so the classification no + longer has to be paid for with a lie about severity. + + Scheduled as a task because reading the translations is blocking file I/O + and the caller is the client's own synchronous callback fan-out. It races + the reload scheduled alongside it and is written to lose safely either + way; see `notices.async_restore`. """ # Absent means flat, present means parent/child -- the migration guide's own # detection rule. @@ -317,17 +347,21 @@ def _explain_the_upgrade(self, current: str | None) -> None: # Guarding on the direction even though panel firmware does not roll back: # once a panel is on v1.0 it stays there, so in the field this only ever fires # one way. The reverse happens solely in the upgrade rehearsal, where the two - # simulators are swapped under a live client, and raising a retirement repair + # simulators are swapped under a live client, and announcing a retirement # there would be noise about a transition no user experiences. if current is None: return - async_create_issue( + text = await self.hass.async_add_executor_job( + read_translations, self.hass.config.language, _UPGRADE_NOTICE + ) + async_raise( self.hass, - DOMAIN, - f"panel_upgraded_to_ebus_v1_{self.config_entry.entry_id}", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="panel_upgraded_to_ebus_v1", + self.config_entry, + _UPGRADE_NOTICE, + title=text.get("title") or _UPGRADE_FALLBACK["title"], + message=(text.get("body") or _UPGRADE_FALLBACK["body"]).format( + previous=previous or "flat", current=current + ), ) def _on_connection_change(self, connected: bool) -> None: diff --git a/custom_components/span_panel/notices.py b/custom_components/span_panel/notices.py new file mode 100644 index 00000000..2b9480ab --- /dev/null +++ b/custom_components/span_panel/notices.py @@ -0,0 +1,280 @@ +"""Notifications that outlive a restart. + +A persistent notification lives in memory. Raise one, restart Home Assistant, +and it is gone whether or not anybody read it. That is why the things worth +saying exactly once have historically been filed as Repairs instead: a Repair is +stored, so it stands until the user dismisses it. + +That trade is a bad one. The Repairs list means "something wants your attention +because it went wrong" -- it is stamped with a severity, it offers to fix or +ignore, and everything else filed there is a defect. Putting good news in it +tells the user their panel is broken. A firmware upgrade that took nothing away +arrived looking like a warning, which is the opposite of what it was for. + +This module keeps the notification and buys back the one property the Repair had +that it lacked. A notice raised here is recorded per config entry and re-raised +at every setup until it is dismissed, and the dismissal is *observed* rather than +assumed: Home Assistant reports removals to a registered callback, so "the user +has seen it" is a fact rather than a hope. A notice therefore survives a restart, +a reload, and an owner who was on holiday when their panel upgraded. + +Which is durable and which is not is a real distinction, not a default. A notice +belongs here when it reports something that happened once and cannot be +re-derived -- a firmware upgrade, an entity that appeared. Anything re-derived +from live state on every refresh does not belong here; it belongs in +`schema_repairs`, which reconciles instead, and where being a defect is the +point. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from functools import partial +import json +import logging +from pathlib import Path +from typing import TYPE_CHECKING, Final, TypedDict + +from homeassistant.components.persistent_notification import ( + Notification, + UpdateType, + async_create, + async_dismiss, + async_register_callback, +) +from homeassistant.core import callback +from homeassistant.helpers.storage import Store +from homeassistant.util.hass_dict import HassKey + +from .const import DOMAIN + +if TYPE_CHECKING: + from homeassistant.config_entries import ConfigEntry + from homeassistant.core import HomeAssistant + +_LOGGER = logging.getLogger(__name__) + +_STORE_VERSION: Final = 1 + +_SAVE_DELAY: Final = 1.0 +"""Seconds to coalesce writes. + +Raising and dismissing both write, and a reload raises every standing notice in +a burst. Delayed saves collapse that into one file write, and Home Assistant +flushes anything outstanding at shutdown. +""" + + +class StandingNotice(TypedDict): + """A notice the user has been shown and has not dismissed. + + The rendered text is stored, not the arguments that produced it. A notice can + outlive the state that raised it -- the schema upgrade that raised one is over + by the time it is re-raised -- so re-deriving the wording is not possible, and + a notice that changed its story between restarts would be worse than one that + did not survive at all. + """ + + title: str + message: str + + +class StoredNotices(TypedDict): + """One config entry's undismissed notices, by notice id.""" + + standing: dict[str, StandingNotice] + + +@dataclass(slots=True) +class _Notices: + """One entry's live view of its notices. + + The store is held rather than rebuilt per call because `async_delay_save` + schedules on the instance: two `Store` objects over the same key would each + hold a pending write and race to be last. + """ + + store: Store[StoredNotices] + standing: dict[str, StandingNotice] + + +_DATA: HassKey[dict[str, _Notices]] = HassKey(f"{DOMAIN}_standing_notices") + + +def _notification_id(entry: ConfigEntry, notice_id: str) -> str: + """Namespace a notice id by domain and entry. + + Per entry, not per domain: two panels upgrading are two notices, and a shared + id would let the second overwrite the first's text and the first's dismissal + silence the second. + """ + return f"{DOMAIN}_{notice_id}_{entry.entry_id}" + + +async def async_restore(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Re-raise this entry's undismissed notices and watch for dismissals. + + Call before anything that can raise a notice. Raising needs the live view + this builds, and building it afterwards would overwrite what was on disk with + only what this run happened to raise. + + The watch is registered through `entry.async_on_unload`, so a reload replaces + it rather than accumulating one per setup -- the dispatcher fans every + notification change out to every registered callback, so a leaked + registration would be a leaked handler running against a dead entry. + + A reload keeps the live view it already had rather than re-reading the file, + because memory is the fresher of the two and the gap between them is exactly + where the interesting case lives: a schema upgrade raises a notice and + schedules a reload in the same breath, so the setup that follows can easily + arrive before the delayed write has landed. Re-reading there would drop the + notice from the standing set while it sat on the user's screen, and it would + not come back after the next restart. + """ + known = hass.data.setdefault(_DATA, {}) + notices = known.get(entry.entry_id) + if notices is None: + store: Store[StoredNotices] = Store( + hass, _STORE_VERSION, f"{DOMAIN}.notices.{entry.entry_id}" + ) + stored = await store.async_load() + standing = dict(stored.get("standing", {})) if stored else {} + notices = _Notices(store=store, standing=standing) + known[entry.entry_id] = notices + + for notice_id, notice in notices.standing.items(): + _LOGGER.debug("Re-raising undismissed notice %s for %s", notice_id, entry.entry_id) + async_create( + hass, + notice["message"], + title=notice["title"], + notification_id=_notification_id(entry, notice_id), + ) + + entry.async_on_unload(async_register_callback(hass, partial(_on_change, hass, entry))) + + +@callback +def async_raise( + hass: HomeAssistant, entry: ConfigEntry, notice_id: str, *, title: str, message: str +) -> None: + """Show a notice, and keep showing it until the user dismisses it. + + A callback rather than a coroutine because the callers are: a schema-change + notice is raised from the MQTT client's own callback fan-out, which is not a + place that can await. + + Re-raising the same notice id replaces the text in place, which is what makes + the restore above a no-op when it lands on a notice already on screen. + """ + async_create(hass, message, title=title, notification_id=_notification_id(entry, notice_id)) + notices = hass.data.get(_DATA, {}).get(entry.entry_id) + if notices is None: + # The entry was removed while this notice was in flight. Showing it is + # still right -- it describes something that happened -- but there is + # nothing left to record it against, and recreating the record here would + # resurrect the file `async_forget` just deleted. + _LOGGER.debug( + "Raised %s for untracked entry %s; it will not survive a restart", + notice_id, + entry.entry_id, + ) + return + notices.standing[notice_id] = StandingNotice(title=title, message=message) + _persist(notices) + + +@callback +def _on_change( + hass: HomeAssistant, + entry: ConfigEntry, + change: UpdateType, + notifications: dict[str, Notification], +) -> None: + """Forget a notice once it is dismissed. + + Dismissal is the whole acknowledgement mechanism. There is no "read" signal + for a notification, and inventing one -- a restart count, an age -- would + either nag somebody who read it on day one or drop it before somebody on + holiday got back. Dismissing is the act of a user who has seen it. + + Filtered to this entry's own ids because the dispatcher delivers every + notification change in the system, most of which belong to other + integrations. + """ + if change is not UpdateType.REMOVED: + return + notices = hass.data.get(_DATA, {}).get(entry.entry_id) + if notices is None: + return + dismissed = [ + notice_id + for notice_id in notices.standing + if _notification_id(entry, notice_id) in notifications + ] + if not dismissed: + return + for notice_id in dismissed: + _LOGGER.debug("Notice %s dismissed for %s", notice_id, entry.entry_id) + del notices.standing[notice_id] + _persist(notices) + + +@callback +def _persist(notices: _Notices) -> None: + """Queue a write of the current standing set. + + The snapshot is taken now rather than in the callback: `async_delay_save` + calls its argument when the write fires, and by then the live dict may have + moved on -- so the deferred write would record a state nobody was ever shown. + """ + written = StoredNotices(standing=dict(notices.standing)) + notices.store.async_delay_save(lambda: written, _SAVE_DELAY) + + +async def async_forget(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Drop this entry's notices when the entry is removed. + + Both halves matter. The record has to go, or re-adding the same panel would + restore notices about an upgrade the new entry never saw. The notifications + themselves have to go too: nothing else clears them, so a removed panel would + leave the user reading about a device that is no longer in their system. + """ + notices = hass.data.get(_DATA, {}).pop(entry.entry_id, None) + if notices is None: + return + for notice_id in notices.standing: + async_dismiss(hass, _notification_id(entry, notice_id)) + await notices.store.async_remove() + + +def read_translations(language: str, section: str) -> dict[str, str]: + """One notification's strings for one language, or an empty mapping. + + Read from this component's `translations/` directory rather than through + `homeassistant.helpers.translation`, because that helper filters to the + categories Home Assistant defines and a persistent notification is not one of + them -- a custom category loads as nothing at all. These are this + integration's own package files, so reading them is not reaching into + somebody else's layout. + + Falls back along the language chain -- `pt-BR`, then `pt`, then `en` -- so a + regional variant with no file of its own still gets its language rather than + English. + + Blocking file I/O. Callers run it in an executor. + """ + directory = Path(__file__).parent / "translations" + for candidate in (f"{language}.json", f"{language.split('-')[0]}.json", "en.json"): + path = directory / candidate + if not path.is_file(): + continue + try: + loaded = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + _LOGGER.debug("Could not read notification strings from %s", path, exc_info=True) + continue + strings = loaded.get("notifications", {}).get(section, {}) + if strings: + return {str(key): str(value) for key, value in strings.items()} + return {} diff --git a/custom_components/span_panel/schema_repairs.py b/custom_components/span_panel/schema_repairs.py index e7184da5..dade2f0c 100644 --- a/custom_components/span_panel/schema_repairs.py +++ b/custom_components/span_panel/schema_repairs.py @@ -52,6 +52,19 @@ """ +_RETIRED_UPGRADE_ID = "panel_upgraded_to_ebus_v1" +"""Id stem of the retired firmware-upgrade Repair. + +Nothing raises one any more. An upgrade that took nothing away is not a defect, +and the Repairs list stamped it with a severity and offered to ignore it, which +told the user their panel was broken; `notices` carries it as a notification that +survives a restart, which is the only property being a Repair was buying. + +Unlike the other ids here this one is a whole id rather than a prefix -- it was +raised as `{stem}_{entry_id}` with nothing after -- so it is cleared by name. +""" + + def _unresolved_id(entry_id: str, field_path: str) -> str: return f"unresolved_{entry_id}_{field_path}" @@ -226,6 +239,20 @@ def async_clear_retired_new_entity_notices(hass: HomeAssistant, entry: ConfigEnt ir.async_delete_issue(hass, DOMAIN, issue_id) +@callback +def async_clear_retired_upgrade_notice(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Delete the firmware-upgrade Repair raised before it became a notification. + + Cleared at setup rather than left to expire: it was raised non-persistent, so + a restart would sweep it away on its own, but the user is looking at it now + and has no reason to restart. The dismissal tombstone goes with it, which + costs nothing -- there is no longer an issue for it to suppress. + """ + if ir.async_get(hass).async_get_issue(DOMAIN, f"{_RETIRED_UPGRADE_ID}_{entry.entry_id}"): + _LOGGER.debug("Clearing retired upgrade notice for %s", entry.entry_id) + ir.async_delete_issue(hass, DOMAIN, f"{_RETIRED_UPGRADE_ID}_{entry.entry_id}") + + @callback def async_clear_schema_issues(hass: HomeAssistant, entry: ConfigEntry) -> None: """Remove one entry's issues. Core does not do this on entry removal. @@ -239,3 +266,6 @@ def async_clear_schema_issues(hass: HomeAssistant, entry: ConfigEntry) -> None: every_prefix = (*_DEFECT_PREFIXES, _NEW_ENTITIES_PREFIX) for issue_id in _scoped_issue_ids(registry, entry.entry_id, every_prefix): ir.async_delete_issue(hass, DOMAIN, issue_id) + # Setup normally clears this one, but an entry removed before it ever set up + # successfully never got there. + async_clear_retired_upgrade_notice(hass, entry) diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index 4ae594ae..71a4b769 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -765,10 +765,6 @@ } }, "issues": { - "panel_upgraded_to_ebus_v1": { - "title": "Your SPAN Panel now reports islanding from its Microgrid Interconnect Device", - "description": "Your SPAN Panel upgraded to the eBus v1.0 data model. Nothing you rely on has gone away, and no automation changes are required — this is a note about what improved.\n\n**DSM Grid State** keeps its entity ID and its history, and is now more trustworthy. On the previous firmware it was *inferred* — from the battery when one was fitted, otherwise from the dominant power source and whether any power was crossing the grid connection. It now reads the islanding state the Microgrid Interconnect Device (MID) actually senses.\n\n**Grid Islandable** also keeps working. v1.0 publishes no panel-level `grid-islandable` property, so it now reflects whether a MID is present, which is how v1.0 says backup capability is detected.\n\n**New:** a Microgrid Interconnect Device appears among your devices, carrying **Grid State** — the health of the utility supply itself, which the previous firmware did not report.\n\nDismiss this once you have had a look." - }, "schema_field_unresolved": { "title": "SPAN Panel data field unavailable", "description": "Your panel's firmware no longer provides `{field_path}`. Entities affected: {count} (for example: {examples}). They may become unavailable, or report incomplete data, until an integration update adds support for the new firmware." @@ -779,6 +775,10 @@ } }, "notifications": { + "panel_upgraded": { + "title": "SPAN Panel firmware upgraded", + "body": "Your SPAN Panel reported a new eBus data model (**{previous} → {current}**), which happens after a firmware upgrade. The integration reloaded so its devices and entities match what the panel now publishes.\n\nNothing you rely on has gone away, and no automation changes are required — this is a note about what changed.\n\n**DSM Grid State** keeps its entity ID and its history, and is now more trustworthy. On the previous firmware it was *inferred* — from the battery when one was fitted, otherwise from the dominant power source and whether any power was crossing the grid connection. It now reads the islanding state the Microgrid Interconnect Device (MID) actually senses.\n\n**Grid Islandable** also keeps working. The new data model publishes no panel-level `grid-islandable` property, so it now reflects whether a MID is present, which is how backup capability is detected.\n\n**New:** a Microgrid Interconnect Device appears among your devices, carrying **Grid State** — the health of the utility supply itself, which the previous firmware did not report. Any entities the upgrade added are listed in their own notification.\n\nEntities that were renamed or replaced by the upgrade may need to be removed manually if they remain unavailable.\n\nDismiss this once you have had a look." + }, "new_entities": { "title": "SPAN Panel added new entities", "intro_one": "This update added 1 new entity to your SPAN Panel.", diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index 4ae594ae..71a4b769 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -765,10 +765,6 @@ } }, "issues": { - "panel_upgraded_to_ebus_v1": { - "title": "Your SPAN Panel now reports islanding from its Microgrid Interconnect Device", - "description": "Your SPAN Panel upgraded to the eBus v1.0 data model. Nothing you rely on has gone away, and no automation changes are required — this is a note about what improved.\n\n**DSM Grid State** keeps its entity ID and its history, and is now more trustworthy. On the previous firmware it was *inferred* — from the battery when one was fitted, otherwise from the dominant power source and whether any power was crossing the grid connection. It now reads the islanding state the Microgrid Interconnect Device (MID) actually senses.\n\n**Grid Islandable** also keeps working. v1.0 publishes no panel-level `grid-islandable` property, so it now reflects whether a MID is present, which is how v1.0 says backup capability is detected.\n\n**New:** a Microgrid Interconnect Device appears among your devices, carrying **Grid State** — the health of the utility supply itself, which the previous firmware did not report.\n\nDismiss this once you have had a look." - }, "schema_field_unresolved": { "title": "SPAN Panel data field unavailable", "description": "Your panel's firmware no longer provides `{field_path}`. Entities affected: {count} (for example: {examples}). They may become unavailable, or report incomplete data, until an integration update adds support for the new firmware." @@ -779,6 +775,10 @@ } }, "notifications": { + "panel_upgraded": { + "title": "SPAN Panel firmware upgraded", + "body": "Your SPAN Panel reported a new eBus data model (**{previous} → {current}**), which happens after a firmware upgrade. The integration reloaded so its devices and entities match what the panel now publishes.\n\nNothing you rely on has gone away, and no automation changes are required — this is a note about what changed.\n\n**DSM Grid State** keeps its entity ID and its history, and is now more trustworthy. On the previous firmware it was *inferred* — from the battery when one was fitted, otherwise from the dominant power source and whether any power was crossing the grid connection. It now reads the islanding state the Microgrid Interconnect Device (MID) actually senses.\n\n**Grid Islandable** also keeps working. The new data model publishes no panel-level `grid-islandable` property, so it now reflects whether a MID is present, which is how backup capability is detected.\n\n**New:** a Microgrid Interconnect Device appears among your devices, carrying **Grid State** — the health of the utility supply itself, which the previous firmware did not report. Any entities the upgrade added are listed in their own notification.\n\nEntities that were renamed or replaced by the upgrade may need to be removed manually if they remain unavailable.\n\nDismiss this once you have had a look." + }, "new_entities": { "title": "SPAN Panel added new entities", "intro_one": "This update added 1 new entity to your SPAN Panel.", diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index 8a938910..8d2ed679 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -765,10 +765,6 @@ } }, "issues": { - "panel_upgraded_to_ebus_v1": { - "title": "Su Panel SPAN ahora informa del aislamiento desde su Dispositivo de Interconexión de Microrred", - "description": "Su Panel SPAN se ha actualizado al modelo de datos eBus v1.0. Nada de lo que usted utiliza ha desaparecido y no es necesario modificar ninguna automatización: este aviso solo explica lo que ha mejorado.\n\n**Estado de Red DSM** conserva su ID de entidad y su historial, y ahora es más fiable. Con el firmware anterior se *deducía*: a partir de la batería cuando había una instalada y, en su defecto, de la fuente de energía dominante y de si circulaba potencia por la conexión a la red. Ahora lee el estado de aislamiento que el Dispositivo de Interconexión de Microrred (MID) detecta realmente.\n\n**Aislable de la Red** también sigue funcionando. La v1.0 no publica ninguna propiedad `grid-islandable` a nivel de panel, por lo que ahora refleja si hay un MID presente, que es como la v1.0 determina la capacidad de respaldo.\n\n**Novedad:** aparece un Dispositivo de Interconexión de Microrred entre sus dispositivos, con **Estado de la Red**, es decir, el estado del suministro eléctrico, que el firmware anterior no informaba.\n\nPuede descartar este aviso cuando lo haya revisado." - }, "schema_field_unresolved": { "title": "Campo de datos del Panel SPAN no disponible", "description": "El firmware de su panel ya no proporciona `{field_path}`. Entidades afectadas: {count} (por ejemplo: {examples}). Pueden quedar no disponibles, o informar datos incompletos, hasta que una actualización de la integración añada compatibilidad con el nuevo firmware." @@ -779,6 +775,10 @@ } }, "notifications": { + "panel_upgraded": { + "title": "Firmware del Panel SPAN actualizado", + "body": "Su Panel SPAN ha comunicado un nuevo modelo de datos eBus (**{previous} → {current}**), lo que ocurre tras una actualización de firmware. La integración se ha recargado para que sus dispositivos y entidades coincidan con lo que el panel publica ahora.\n\nNada de lo que usted utiliza ha desaparecido y no es necesario modificar ninguna automatización: este aviso solo explica lo que ha cambiado.\n\n**Estado de Red DSM** conserva su ID de entidad y su historial, y ahora es más fiable. Con el firmware anterior se *deducía*: a partir de la batería cuando había una instalada y, en su defecto, de la fuente de energía dominante y de si circulaba potencia por la conexión a la red. Ahora lee el estado de aislamiento que el Dispositivo de Interconexión de Microrred (MID) detecta realmente.\n\n**Aislable de la Red** también sigue funcionando. El nuevo modelo de datos no publica ninguna propiedad `grid-islandable` a nivel de panel, por lo que ahora refleja si hay un MID presente, que es como se determina la capacidad de respaldo.\n\n**Novedad:** aparece un Dispositivo de Interconexión de Microrred entre sus dispositivos, con **Estado de la Red**, es decir, el estado del suministro eléctrico, que el firmware anterior no informaba. Las entidades que la actualización haya añadido se enumeran en su propio aviso.\n\nLas entidades renombradas o sustituidas por la actualización pueden tener que eliminarse manualmente si siguen sin estar disponibles.\n\nPuede descartar este aviso cuando lo haya revisado." + }, "new_entities": { "title": "El Panel SPAN ha añadido nuevas entidades", "intro_one": "Esta actualización añadió 1 entidad nueva a tu Panel SPAN.", diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index 36ff2e1f..ab154286 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -765,10 +765,6 @@ } }, "issues": { - "panel_upgraded_to_ebus_v1": { - "title": "Votre Panneau SPAN signale désormais l'îlotage depuis son Dispositif d'Interconnexion de Micro-réseau", - "description": "Votre Panneau SPAN est passé au modèle de données eBus v1.0. Rien de ce que vous utilisez n'a disparu et aucune modification de vos automatisations n'est nécessaire : cet avis explique simplement ce qui s'est amélioré.\n\n**État Réseau DSM** conserve son identifiant d'entité et son historique, et gagne en fiabilité. Avec le micrologiciel précédent, il était *déduit* : à partir de la batterie lorsqu'il y en avait une, sinon à partir de la source d'énergie dominante et de la présence de puissance transitant par le raccordement réseau. Il lit désormais l'état d'îlotage réellement mesuré par le Dispositif d'Interconnexion de Micro-réseau (MID).\n\n**Îlotage Réseau Possible** continue également de fonctionner. La v1.0 ne publie aucune propriété `grid-islandable` au niveau du panneau ; ce capteur indique donc maintenant la présence d'un MID, ce qui est la façon dont v1.0 détermine la capacité de secours.\n\n**Nouveau :** un Dispositif d'Interconnexion de Micro-réseau apparaît parmi vos appareils, avec **État du Réseau** — l'état de l'alimentation du réseau public, que le micrologiciel précédent ne signalait pas.\n\nVous pouvez ignorer cet avis après consultation." - }, "schema_field_unresolved": { "title": "Champ de données du Panneau SPAN indisponible", "description": "Le micrologiciel de votre panneau ne fournit plus `{field_path}`. Entités concernées : {count} (par exemple : {examples}). Elles peuvent devenir indisponibles, ou renvoyer des données incomplètes, jusqu'à ce qu'une mise à jour de l'intégration prenne en charge le nouveau micrologiciel." @@ -779,6 +775,10 @@ } }, "notifications": { + "panel_upgraded": { + "title": "Micrologiciel du Panneau SPAN mis à jour", + "body": "Votre Panneau SPAN a signalé un nouveau modèle de données eBus (**{previous} → {current}**), ce qui se produit après une mise à jour du micrologiciel. L'intégration a été rechargée afin que ses appareils et ses entités correspondent à ce que le panneau publie désormais.\n\nRien de ce que vous utilisez n'a disparu et aucune modification de vos automatisations n'est nécessaire : cet avis explique simplement ce qui a changé.\n\n**État Réseau DSM** conserve son identifiant d'entité et son historique, et gagne en fiabilité. Avec le micrologiciel précédent, il était *déduit* : à partir de la batterie lorsqu'il y en avait une, sinon à partir de la source d'énergie dominante et de la présence de puissance transitant par le raccordement réseau. Il lit désormais l'état d'îlotage réellement mesuré par le Dispositif d'Interconnexion de Micro-réseau (MID).\n\n**Îlotage Réseau Possible** continue également de fonctionner. Le nouveau modèle de données ne publie aucune propriété `grid-islandable` au niveau du panneau ; ce capteur indique donc maintenant la présence d'un MID, ce qui est la façon dont la capacité de secours est déterminée.\n\n**Nouveau :** un Dispositif d'Interconnexion de Micro-réseau apparaît parmi vos appareils, avec **État du Réseau** — l'état de l'alimentation du réseau public, que le micrologiciel précédent ne signalait pas. Les entités ajoutées par la mise à jour sont répertoriées dans un avis distinct.\n\nLes entités renommées ou remplacées par la mise à jour peuvent devoir être supprimées manuellement si elles restent indisponibles.\n\nVous pouvez ignorer cet avis après consultation." + }, "new_entities": { "title": "Le Panneau SPAN a ajouté de nouvelles entités", "intro_one": "Cette mise à jour a ajouté 1 nouvelle entité à votre Panneau SPAN.", diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index 5b7f7201..2e174645 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -765,10 +765,6 @@ } }, "issues": { - "panel_upgraded_to_ebus_v1": { - "title": "SPAN パネルは自立運転状態をマイクログリッド相互接続装置から取得するようになりました", - "description": "SPAN パネルが eBus v1.0 データモデルにアップグレードされました。ご利用中のものが失われることはなく、オートメーションの変更も必要ありません。この通知は改善点をお知らせするものです。\n\n**DSM 系統状態** はエンティティ ID と履歴をそのまま維持し、さらに信頼性が向上しました。以前のファームウェアでは *推定* されていました。バッテリーがある場合はバッテリーから、ない場合は主電源と系統接続を流れる電力の有無から判断していました。現在はマイクログリッド相互接続装置 (MID) が実際に検知した自立運転状態を読み取ります。\n\n**系統自立運転可能** も引き続き動作します。v1.0 ではパネル単位の `grid-islandable` プロパティは公開されないため、現在は MID の有無を示します。これが v1.0 におけるバックアップ能力の判定方法です。\n\n**新規:** デバイス一覧にマイクログリッド相互接続装置が追加され、**系統状態** (電力会社からの供給状態) を報告します。これは以前のファームウェアにはなかった情報です。\n\n内容を確認したら、この通知は閉じて構いません。" - }, "schema_field_unresolved": { "title": "SPAN パネルのデータフィールドが利用できません", "description": "パネルのファームウェアが `{field_path}` を提供しなくなりました。{count} 個のエンティティが影響を受けます (例: {examples})。新しいファームウェアに対応する統合の更新が入るまで、これらは利用不可になるか、不完全なデータを報告する可能性があります。" @@ -779,6 +775,10 @@ } }, "notifications": { + "panel_upgraded": { + "title": "SPAN パネルのファームウェアが更新されました", + "body": "SPAN パネルが新しい eBus データモデル (**{previous} → {current}**) を報告しました。これはファームウェアの更新後に発生します。パネルが現在公開している内容にデバイスとエンティティを合わせるため、統合を再読み込みしました。\n\nご利用中のものが失われることはなく、オートメーションの変更も必要ありません。この通知は変更点をお知らせするものです。\n\n**DSM 系統状態** はエンティティ ID と履歴をそのまま維持し、さらに信頼性が向上しました。以前のファームウェアでは *推定* されていました。バッテリーがある場合はバッテリーから、ない場合は主電源と系統接続を流れる電力の有無から判断していました。現在はマイクログリッド相互接続装置 (MID) が実際に検知した自立運転状態を読み取ります。\n\n**系統自立運転可能** も引き続き動作します。新しいデータモデルではパネル単位の `grid-islandable` プロパティは公開されないため、現在は MID の有無を示します。これがバックアップ能力の判定方法です。\n\n**新規:** デバイス一覧にマイクログリッド相互接続装置が追加され、**系統状態** (電力会社からの供給状態) を報告します。これは以前のファームウェアにはなかった情報です。今回の更新で追加されたエンティティは、別の通知に一覧表示されます。\n\n更新によって名称が変更された、または置き換えられたエンティティが利用不可のままの場合は、手動で削除する必要があることがあります。\n\n内容を確認したら、この通知は閉じて構いません。" + }, "new_entities": { "title": "SPAN パネルに新しいエンティティが追加されました", "intro_one": "このアップデートで SPAN パネルに新しいエンティティが 1 個追加されました。", diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index 5d76ee6e..501a80ef 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -765,10 +765,6 @@ } }, "issues": { - "panel_upgraded_to_ebus_v1": { - "title": "O seu Painel SPAN comunica agora o ilhamento a partir do Dispositivo de Interligação de Microrrede", - "description": "O seu Painel SPAN foi atualizado para o modelo de dados eBus v1.0. Nada do que utiliza desapareceu e não é necessário alterar automações — este aviso apenas explica o que melhorou.\n\n**Estado da Rede DSM** mantém o seu ID de entidade e o seu histórico, e passa a ser mais fiável. No firmware anterior era *inferido*: a partir da bateria quando existia uma e, caso contrário, a partir da fonte de energia dominante e da existência de potência a atravessar a ligação à rede. Agora lê o estado de ilhamento que o Dispositivo de Interligação de Microrrede (MID) realmente deteta.\n\n**Isolável da Rede** também continua a funcionar. A v1.0 não publica qualquer propriedade `grid-islandable` ao nível do painel, pelo que agora reflete a presença de um MID, que é como a v1.0 determina a capacidade de reserva.\n\n**Novidade:** aparece um Dispositivo de Interligação de Microrrede entre os seus dispositivos, com **Estado da Rede** — o estado do fornecimento da rede elétrica, que o firmware anterior não comunicava.\n\nPode dispensar este aviso depois de o consultar." - }, "schema_field_unresolved": { "title": "Campo de dados do Painel SPAN indisponível", "description": "O firmware do seu painel já não fornece `{field_path}`. Entidades afetadas: {count} (por exemplo: {examples}). Podem ficar indisponíveis, ou comunicar dados incompletos, até que uma atualização da integração adicione suporte ao novo firmware." @@ -779,6 +775,10 @@ } }, "notifications": { + "panel_upgraded": { + "title": "Firmware do Painel SPAN atualizado", + "body": "O seu Painel SPAN comunicou um novo modelo de dados eBus (**{previous} → {current}**), o que acontece após uma atualização de firmware. A integração foi recarregada para que os seus dispositivos e entidades correspondam ao que o painel publica agora.\n\nNada do que utiliza desapareceu e não é necessário alterar automações — este aviso apenas explica o que mudou.\n\n**Estado da Rede DSM** mantém o seu ID de entidade e o seu histórico, e passa a ser mais fiável. No firmware anterior era *inferido*: a partir da bateria quando existia uma e, caso contrário, a partir da fonte de energia dominante e da existência de potência a atravessar a ligação à rede. Agora lê o estado de ilhamento que o Dispositivo de Interligação de Microrrede (MID) realmente deteta.\n\n**Isolável da Rede** também continua a funcionar. O novo modelo de dados não publica qualquer propriedade `grid-islandable` ao nível do painel, pelo que agora reflete a presença de um MID, que é como a capacidade de reserva é determinada.\n\n**Novidade:** aparece um Dispositivo de Interligação de Microrrede entre os seus dispositivos, com **Estado da Rede** — o estado do fornecimento da rede elétrica, que o firmware anterior não comunicava. As entidades que a atualização tenha adicionado são listadas num aviso próprio.\n\nAs entidades renomeadas ou substituídas pela atualização podem ter de ser removidas manualmente se continuarem indisponíveis.\n\nPode dispensar este aviso depois de o consultar." + }, "new_entities": { "title": "O Painel SPAN adicionou novas entidades", "intro_one": "Esta atualização adicionou 1 nova entidade ao seu Painel SPAN.", diff --git a/tests/test_new_entity_announcement.py b/tests/test_new_entity_announcement.py index 36ce604e..97064f0c 100644 --- a/tests/test_new_entity_announcement.py +++ b/tests/test_new_entity_announcement.py @@ -25,11 +25,12 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.span_panel.additions import ( - _read_translations, + _SECTION, async_announce_new_entities, async_forget_announcements, ) from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.notices import read_translations _PART_NUMBER = "sp3-001_bess_part_number" @@ -230,7 +231,7 @@ def test_every_shipped_locale_carries_the_notification_strings(language: str) -> component's own files. That is precisely why a missing key would fail silently into English rather than being caught by the platform. """ - text = _read_translations(language) + text = read_translations(language, _SECTION) assert set(text) >= { "title", "intro_one", @@ -243,12 +244,15 @@ def test_every_shipped_locale_carries_the_notification_strings(language: str) -> def test_an_unknown_language_falls_back_to_english_rather_than_to_nothing() -> None: - assert _read_translations("xx")["title"] == _read_translations("en")["title"] + assert ( + read_translations("xx", _SECTION)["title"] + == read_translations("en", _SECTION)["title"] + ) def test_a_regional_language_resolves_to_its_base(hass: HomeAssistant) -> None: """`pt-BR` is not shipped; `pt` is, and is a better answer than English.""" - assert _read_translations("pt-BR") == _read_translations("pt") + assert read_translations("pt-BR", _SECTION) == read_translations("pt", _SECTION) # -- Removal ----------------------------------------------------------------- diff --git a/tests/test_notices.py b/tests/test_notices.py new file mode 100644 index 00000000..b0172329 --- /dev/null +++ b/tests/test_notices.py @@ -0,0 +1,259 @@ +"""Notices survive a restart without pretending to be defects. + +The property under test is the one that used to cost a Repair: a notice raised +while nobody was looking is still there when they look. Every test here either +restarts (drop the in-memory view, restore from the store) or dismisses, because +those are the only two things that decide whether a notice comes back. +""" + +from __future__ import annotations + +from datetime import timedelta +from typing import Any + +from homeassistant.components.persistent_notification import async_dismiss +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry, async_fire_time_changed + +from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.notices import ( + _DATA, + async_forget, + async_raise, + async_restore, +) + +_NOTICE = "panel_upgraded" + + +@pytest.fixture +def entry(hass: HomeAssistant) -> MockConfigEntry: + """Return a config entry in hass.""" + mock = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sp3-001") + mock.add_to_hass(hass) + return mock + + +@pytest.fixture +def other(hass: HomeAssistant) -> MockConfigEntry: + """Return a second panel. Notices are per entry, and this is what proves it.""" + mock = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sp3-002") + mock.add_to_hass(hass) + return mock + + +def _standing(hass: HomeAssistant) -> dict[str, Any]: + """Every persistent notification currently on screen, by id.""" + return dict(hass.data.get("persistent_notification", {})) + + +def _id(entry: MockConfigEntry, notice_id: str = _NOTICE) -> str: + return f"{DOMAIN}_{notice_id}_{entry.entry_id}" + + +async def _flush(hass: HomeAssistant) -> None: + """Let the delayed store writes fire. Home Assistant does this at shutdown.""" + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + + +async def _restart(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Restart Home Assistant, as far as this module can tell. + + The store is on disk and survives; the live view and the notifications are in + memory and do not. Dropping exactly those two is what a restart does. + + Cleared rather than dismissed, which is the distinction the whole module turns + on: a restart destroys a notification without anybody having read it, and + dismissing here would quietly test the acknowledged path instead and report + that notices vanish when they should not. + """ + await _flush(hass) + hass.data.get(_DATA, {}).pop(entry.entry_id, None) + hass.data.get("persistent_notification", {}).clear() + await async_restore(hass, entry) + + +# -- Raising ----------------------------------------------------------------- + + +async def test_a_raised_notice_is_shown(hass: HomeAssistant, entry: MockConfigEntry) -> None: + await async_restore(hass, entry) + async_raise(hass, entry, _NOTICE, title="Upgraded", message="Body") + + notification = _standing(hass)[_id(entry)] + assert notification["title"] == "Upgraded" + assert notification["message"] == "Body" + + +async def test_a_notice_raised_before_a_restart_is_shown_after_it( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """The whole point. A panel that upgrades while the owner is away still tells them.""" + await async_restore(hass, entry) + async_raise(hass, entry, _NOTICE, title="Upgraded", message="Body") + + await _restart(hass, entry) + + assert _standing(hass)[_id(entry)]["message"] == "Body" + + +async def test_a_notice_survives_more_than_one_restart( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Restoring must re-record, not consume. Undismissed is undismissed.""" + await async_restore(hass, entry) + async_raise(hass, entry, _NOTICE, title="Upgraded", message="Body") + + await _restart(hass, entry) + await _restart(hass, entry) + + assert _id(entry) in _standing(hass) + + +async def test_raising_the_same_notice_again_replaces_its_text( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Restoring a notice already on screen must not stack a second copy.""" + await async_restore(hass, entry) + async_raise(hass, entry, _NOTICE, title="Upgraded", message="First") + async_raise(hass, entry, _NOTICE, title="Upgraded", message="Second") + + assert [key for key in _standing(hass) if key.startswith(DOMAIN)] == [_id(entry)] + assert _standing(hass)[_id(entry)]["message"] == "Second" + + +# -- Dismissal --------------------------------------------------------------- + + +async def test_a_dismissed_notice_does_not_come_back( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Dismissal is the acknowledgement. Ignoring it would turn a notice into a nag.""" + await async_restore(hass, entry) + async_raise(hass, entry, _NOTICE, title="Upgraded", message="Body") + + async_dismiss(hass, _id(entry)) + await hass.async_block_till_done() + hass.data.get(_DATA, {}).pop(entry.entry_id, None) + await async_restore(hass, entry) + + assert _id(entry) not in _standing(hass) + + +async def test_dismissing_someone_elses_notification_leaves_ours_standing( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """The dispatcher delivers every notification change in the system, not just ours.""" + await async_restore(hass, entry) + async_raise(hass, entry, _NOTICE, title="Upgraded", message="Body") + + async_dismiss(hass, "some_other_integration_thing") + await _restart(hass, entry) + + assert _id(entry) in _standing(hass) + + +async def test_dismissing_one_panels_notice_leaves_the_other_panels_alone( + hass: HomeAssistant, entry: MockConfigEntry, other: MockConfigEntry +) -> None: + """Two panels upgrading are two notices; one owner reading is not both.""" + await async_restore(hass, entry) + await async_restore(hass, other) + async_raise(hass, entry, _NOTICE, title="Upgraded", message="First panel") + async_raise(hass, other, _NOTICE, title="Upgraded", message="Second panel") + + async_dismiss(hass, _id(entry)) + await _restart(hass, entry) + await _restart(hass, other) + + assert _id(entry) not in _standing(hass) + assert _standing(hass)[_id(other)]["message"] == "Second panel" + + +# -- The reload race --------------------------------------------------------- + + +async def test_a_notice_raised_moments_before_a_reload_is_not_lost( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """A schema upgrade raises a notice and schedules a reload in the same breath. + + Writes are delayed to coalesce them, so the setup that follows can arrive + before the file has been written. Re-reading the store there would drop the + notice from the standing set while it sat on the user's screen -- it would + look fine until the next restart, and then be gone. + """ + await async_restore(hass, entry) + async_raise(hass, entry, _NOTICE, title="Upgraded", message="Body") + + await async_restore(hass, entry) # the reload, before the delayed write lands + + # Asserted on the live view rather than on what comes back after a restart, + # because the store is what makes this hard to see: it hands a pending write + # back to its own reader, so a reload that re-read it would look correct here + # and lose the notice only on the restart after the process ended. + assert _NOTICE in hass.data[_DATA][entry.entry_id].standing + + await _restart(hass, entry) + assert _standing(hass)[_id(entry)]["message"] == "Body" + + +# -- Removal ----------------------------------------------------------------- + + +async def test_removing_the_entry_takes_its_notices_off_the_screen( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Nothing else clears them, so the user would keep reading about a panel they removed.""" + await async_restore(hass, entry) + async_raise(hass, entry, _NOTICE, title="Upgraded", message="Body") + + await async_forget(hass, entry) + + assert _id(entry) not in _standing(hass) + + +async def test_re_adding_a_removed_panel_does_not_restore_its_old_notices( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """The record has to go with the entry, or a new panel inherits an old upgrade.""" + await async_restore(hass, entry) + async_raise(hass, entry, _NOTICE, title="Upgraded", message="Body") + await async_forget(hass, entry) + + await async_restore(hass, entry) + + assert _id(entry) not in _standing(hass) + + +async def test_removing_one_panel_leaves_the_others_notices_alone( + hass: HomeAssistant, entry: MockConfigEntry, other: MockConfigEntry +) -> None: + await async_restore(hass, entry) + await async_restore(hass, other) + async_raise(hass, entry, _NOTICE, title="Upgraded", message="First panel") + async_raise(hass, other, _NOTICE, title="Upgraded", message="Second panel") + + await async_forget(hass, entry) + + assert _id(entry) not in _standing(hass) + assert _standing(hass)[_id(other)]["message"] == "Second panel" + + +async def test_raising_against_an_untracked_entry_still_reaches_the_user( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """A notice in flight when the entry is removed describes something that happened. + + Showing it is right; recreating the record `async_forget` just deleted is not. + """ + await async_restore(hass, entry) + await async_forget(hass, entry) + + async_raise(hass, entry, _NOTICE, title="Upgraded", message="Body") + + assert _standing(hass)[_id(entry)]["message"] == "Body" + assert entry.entry_id not in hass.data.get(_DATA, {}) diff --git a/tests/test_schema_repairs.py b/tests/test_schema_repairs.py index b1a21798..82e6d229 100644 --- a/tests/test_schema_repairs.py +++ b/tests/test_schema_repairs.py @@ -430,22 +430,25 @@ async def test_clearing_removes_only_this_entry(hass) -> None: assert registry.async_get_issue(DOMAIN, _issue_id(kept)) -async def test_reconciliation_leaves_other_domain_issues_alone(hass, entry) -> None: - """The upgrade repair shares our domain and must survive a reconcile pass.""" +async def test_reconciliation_leaves_our_other_issues_alone(hass, entry) -> None: + """The reconcile pass deletes every id it did not re-derive, within its own scope. + + Anything else this integration raises under the same domain -- a one-shot + notice, a condition derived somewhere other than this pass -- has to survive + it, or the pass silently owns issues it has no way to recreate. + """ ir.async_create_issue( hass, DOMAIN, - f"panel_upgraded_to_ebus_v1_{entry.entry_id}", + f"something_else_{entry.entry_id}", is_fixable=False, severity=ir.IssueSeverity.WARNING, - translation_key="panel_upgraded_to_ebus_v1", + translation_key="something_else", ) async_sync_schema_issues(hass, entry, SchemaFindings(frozenset(), (), frozenset()), {}) - async_clear_schema_issues(hass, entry) - registry = ir.async_get(hass) - assert registry.async_get_issue(DOMAIN, f"panel_upgraded_to_ebus_v1_{entry.entry_id}") + assert ir.async_get(hass).async_get_issue(DOMAIN, f"something_else_{entry.entry_id}") async def test_remove_entry_clears_this_entry_issues(hass, entry) -> None: diff --git a/tests/test_schema_upgrade_notice.py b/tests/test_schema_upgrade_notice.py new file mode 100644 index 00000000..30785cb2 --- /dev/null +++ b/tests/test_schema_upgrade_notice.py @@ -0,0 +1,169 @@ +"""What the user is told when their panel's firmware changes data model. + +This used to be two things at once: a hardcoded English notification about the +reload, and a translated Repair about the consequences. Neither was covered, so +the pairing survived until a screenshot showed it -- an upgrade that took nothing +away, filed under Warning, offering to be ignored. + +The tests below pin the three properties that fixes it: one message, it is a +notification, and it outlives the restart that was the Repair's only advantage. +""" + +from __future__ import annotations + +from datetime import timedelta +from typing import Any, cast +from unittest.mock import MagicMock + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.util import dt as dt_util +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry, async_fire_time_changed +from span_panel_api import SpanMqttClient + +from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.coordinator import SpanPanelCoordinator +from custom_components.span_panel.notices import _DATA, async_restore +from custom_components.span_panel.schema_repairs import async_clear_retired_upgrade_notice + +_RETIRED_ISSUE = "panel_upgraded_to_ebus_v1" + + +@pytest.fixture +def entry(hass: HomeAssistant) -> MockConfigEntry: + """Return a config entry in hass.""" + mock = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sp3-001", title="SPAN Panel") + mock.add_to_hass(hass) + return mock + + +@pytest.fixture +async def coordinator(hass: HomeAssistant, entry: MockConfigEntry) -> SpanPanelCoordinator: + """Return a coordinator whose notices are tracked, as setup would leave it.""" + await async_restore(hass, entry) + return SpanPanelCoordinator(hass, cast(SpanMqttClient, MagicMock()), entry) + + +def _notice(hass: HomeAssistant, entry: MockConfigEntry) -> dict[str, Any] | None: + standing = hass.data.get("persistent_notification", {}) + return standing.get(f"{DOMAIN}_panel_upgraded_{entry.entry_id}") + + +async def test_the_upgrade_is_a_notification( + hass: HomeAssistant, entry: MockConfigEntry, coordinator: SpanPanelCoordinator +) -> None: + await coordinator._explain_the_upgrade(None, "1.0") + + notice = _notice(hass, entry) + assert notice is not None + assert "flat" in notice["message"] and "1.0" in notice["message"] + + +async def test_the_upgrade_is_not_a_repair( + hass: HomeAssistant, entry: MockConfigEntry, coordinator: SpanPanelCoordinator +) -> None: + """Nothing is broken, so nothing belongs in the list of things that are.""" + await coordinator._explain_the_upgrade(None, "1.0") + + assert not [issue_id for (domain, issue_id) in ir.async_get(hass).issues if domain == DOMAIN] + + +async def test_the_upgrade_is_told_in_one_message_not_two( + hass: HomeAssistant, entry: MockConfigEntry, coordinator: SpanPanelCoordinator +) -> None: + """The reload and its consequences are one event and read as one. + + Two rows for one event is what teaches somebody to skim past both. + """ + await coordinator._explain_the_upgrade(None, "1.0") + + ours = [key for key in hass.data.get("persistent_notification", {}) if DOMAIN in key] + assert len(ours) == 1 + + +async def test_the_message_says_what_changed_and_what_did_not( + hass: HomeAssistant, entry: MockConfigEntry, coordinator: SpanPanelCoordinator +) -> None: + """A firmware upgrade invites the assumption that something was lost.""" + await coordinator._explain_the_upgrade(None, "1.0") + + message = _notice(hass, entry)["message"] # type: ignore[index] + assert "DSM Grid State" in message + assert "Grid Islandable" in message + assert "Microgrid Interconnect Device" in message + assert "has gone away" in message + + +async def test_the_notice_survives_a_restart( + hass: HomeAssistant, entry: MockConfigEntry, coordinator: SpanPanelCoordinator +) -> None: + """Durability was the Repair's one real advantage, and it has to be kept. + + A panel upgrades on its own schedule. Somebody away for the weekend must + still find out that a device appeared and why a sensor changed provenance. + """ + await coordinator._explain_the_upgrade(None, "1.0") + + # Writes are delayed so a burst of them collapses into one; Home Assistant + # flushes at shutdown, and a restart that skipped it would test nothing. + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + hass.data[_DATA].pop(entry.entry_id) + hass.data["persistent_notification"].clear() + await async_restore(hass, entry) + + assert _notice(hass, entry) is not None + + +async def test_a_downgrade_says_nothing( + hass: HomeAssistant, entry: MockConfigEntry, coordinator: SpanPanelCoordinator +) -> None: + """Panel firmware does not roll back; the upgrade rehearsal swaps simulators. + + Announcing a retirement there would be noise about a transition no user has. + """ + await coordinator._explain_the_upgrade("1.0", None) + + assert _notice(hass, entry) is None + + +async def test_the_retired_repair_is_cleared_from_an_upgraded_install( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """It is on screen now, and its owner has no reason to restart to be rid of it.""" + ir.async_create_issue( + hass, + DOMAIN, + f"{_RETIRED_ISSUE}_{entry.entry_id}", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key=_RETIRED_ISSUE, + ) + + async_clear_retired_upgrade_notice(hass, entry) + + assert ir.async_get(hass).async_get_issue(DOMAIN, f"{_RETIRED_ISSUE}_{entry.entry_id}") is None + + +async def test_clearing_the_retired_repair_leaves_another_panels_alone( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Two panels share the domain, and one upgrading is not both.""" + other = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sp3-002") + other.add_to_hass(hass) + for target in (entry, other): + ir.async_create_issue( + hass, + DOMAIN, + f"{_RETIRED_ISSUE}_{target.entry_id}", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key=_RETIRED_ISSUE, + ) + + async_clear_retired_upgrade_notice(hass, entry) + + registry = ir.async_get(hass) + assert registry.async_get_issue(DOMAIN, f"{_RETIRED_ISSUE}_{entry.entry_id}") is None + assert registry.async_get_issue(DOMAIN, f"{_RETIRED_ISSUE}_{other.entry_id}") From d12f867286567a22fbe6003923f4da066aea1c2a Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:58:08 -0700 Subject: [PATCH 078/345] fix(additions,notices): adopted entities are switched off, and a bad store file is not fatal Two defects found by review, both in code from this release set. **Adopted entities were announced as ready to use.** `AdoptedEntity` registers every one of them disabled, so the notification said the opposite of the truth. They were counted apart from the disabled ones and emitted between the two headings, which meant a release whose only additions were adopted -- a vendor device appearing, the case adoption exists for -- rendered no heading at all and suppressed the how-to-enable text, the one string in the message with an action attached. They now sit under the switched-off heading, which needs no new translation key because it is where they always belonged. **A malformed notice store took the integration down permanently.** The TypedDicts are compile-time only and on-disk data violates them freely: a list where the mapping should be, a notice with no message, a string at the top level. Each raised straight out of setup, and out of the part above the try, so the entry went to SETUP_ERROR with no retry and stayed dead until somebody found and deleted the file. Home Assistant handles undecodable JSON already; the gap is valid JSON of the wrong shape, which it hands back intact. Bookkeeping for a notification is not worth an integration that will not load, so the load falls back to empty, keeps whatever rows are well formed, and self-heals on the next raise. `read_translations` gets the same guard for the same reason. Three mutations that survived the previous round now die: recording only the previously-announced set (which re-announced every entity on every restart, forever -- the nothing-added path returns before that line, so the test that looked like it covered it never reached it), dropping the `async_on_unload` wrapper on the dismissal watch, and dropping the store shape guard. One mutation still survives and the docstring was wrong rather than the code: the snapshot in `_persist` is defensive, not load-bearing. A Store keeps one pending write and every mutation site re-persists, so the difference is unobservable. Said so plainly instead of claiming a property no test can hold. `requirements_test.txt` pinned b4 against the manifest's b6/b5/b6. Nothing reads it, which is how it drifted; it now matches. Also dropped a redundant `_attr_name` assignment in `AdoptedSensor` that restated what its base class had already set. --- custom_components/span_panel/additions.py | 16 ++- custom_components/span_panel/adoption.py | 1 - custom_components/span_panel/notices.py | 71 +++++++++-- requirements_test.txt | 6 +- tests/test_new_entity_announcement.py | 80 ++++++++++++- tests/test_notices.py | 138 ++++++++++++++++++++++ 6 files changed, 296 insertions(+), 16 deletions(-) diff --git a/custom_components/span_panel/additions.py b/custom_components/span_panel/additions.py index 724c8e68..b591ba40 100644 --- a/custom_components/span_panel/additions.py +++ b/custom_components/span_panel/additions.py @@ -149,6 +149,16 @@ def _message(hass: HomeAssistant, added: list[er.RegistryEntry], text: dict[str, needs nothing; a disabled one records nothing at all until the user turns it on, and saying so is the difference between a notice they can act on and one they can only acknowledge. + + **Adopted devices belong on the switched-off side of that split**, because + every adopted entity registers disabled -- `AdoptedEntity` sets + `_attr_entity_registry_enabled_default = False` for all of them. Listing them + anywhere else said the opposite of the truth twice over: under the + ready-to-use heading they read as already recording, and being counted apart + from `disabled` meant a release whose only additions were adopted rendered no + heading and, worse, suppressed `how_to_enable` -- the one actionable string in + the message. A vendor device appearing is the case adoption exists for, and it + was the case that told the user least. """ devices = dr.async_get(hass) enabled: list[str] = [] @@ -178,12 +188,10 @@ def _message(hass: HomeAssistant, added: list[er.RegistryEntry], text: dict[str, lines += [f"**{_text(text, 'enabled_heading')}**", ""] lines += [f"- {label}" for label in sorted(enabled)] lines.append("") - if adopted: - lines += [f"- {name} ({count} entities)" for name, count in sorted(adopted.items())] - lines.append("") - if disabled: + if disabled or adopted: lines += [f"**{_text(text, 'disabled_heading')}**", ""] lines += [f"- {label}" for label in sorted(disabled)] + lines += [f"- {name} ({count} entities)" for name, count in sorted(adopted.items())] lines += ["", _text(text, "how_to_enable"), ""] lines.append(_text(text, "nothing_broken")) return "\n".join(lines) diff --git a/custom_components/span_panel/adoption.py b/custom_components/span_panel/adoption.py index 55c0307f..09869d57 100644 --- a/custom_components/span_panel/adoption.py +++ b/custom_components/span_panel/adoption.py @@ -362,7 +362,6 @@ def __init__( device_class=DEVICE_CLASS_BY_UNIT.get(declaration.unit or ""), native_unit_of_measurement=declaration.unit, ) - self._attr_name = _humanised(declaration.property_id) @property def native_value(self) -> str | float | None: diff --git a/custom_components/span_panel/notices.py b/custom_components/span_panel/notices.py index 2b9480ab..9acb6e83 100644 --- a/custom_components/span_panel/notices.py +++ b/custom_components/span_panel/notices.py @@ -137,9 +137,7 @@ async def async_restore(hass: HomeAssistant, entry: ConfigEntry) -> None: store: Store[StoredNotices] = Store( hass, _STORE_VERSION, f"{DOMAIN}.notices.{entry.entry_id}" ) - stored = await store.async_load() - standing = dict(stored.get("standing", {})) if stored else {} - notices = _Notices(store=store, standing=standing) + notices = _Notices(store=store, standing=_load(await store.async_load(), entry)) known[entry.entry_id] = notices for notice_id, notice in notices.standing.items(): @@ -154,6 +152,54 @@ async def async_restore(hass: HomeAssistant, entry: ConfigEntry) -> None: entry.async_on_unload(async_register_callback(hass, partial(_on_change, hass, entry))) +def _load(stored: object, entry: ConfigEntry) -> dict[str, StandingNotice]: + """Read the standing set off disk, tolerating a file that is not what we wrote. + + `StoredNotices` and `StandingNotice` are compile-time only. On-disk data + violates them freely -- a hand edit, a partially restored backup, a file + written by a later version and read after a rollback -- and every such shape + used to raise straight out of setup. Home Assistant already handles + *undecodable* JSON by renaming the file and raising a core repair; the gap is + valid JSON of the wrong shape, which it hands back intact. + + Failing there is the wrong trade by a wide margin. This module exists to tell + the user about something that already happened; a panel that will not load + because its *notification bookkeeping* is malformed has turned a cosmetic + record into a dead integration, and one that stays dead, because setup is not + retried on a bad shape. Falling back to empty loses only the memory of which + notices were standing, and the next raise overwrites the file with a valid + one -- so it self-heals rather than needing the user to find and delete it. + """ + if stored is None: + return {} + standing = stored.get("standing") if isinstance(stored, dict) else None + if not isinstance(standing, dict): + _LOGGER.warning( + "Ignoring the notice record for %s: expected an object with a 'standing' " + "mapping, found %s. Any notice already dismissed stays dismissed; one still " + "standing may be shown again.", + entry.entry_id, + type(standing if isinstance(stored, dict) else stored).__name__, + ) + return {} + kept: dict[str, StandingNotice] = {} + for notice_id, notice in standing.items(): + if ( + isinstance(notice, dict) + and isinstance(notice.get("title"), str) + and isinstance(notice.get("message"), str) + ): + kept[str(notice_id)] = StandingNotice(title=notice["title"], message=notice["message"]) + else: + _LOGGER.warning( + "Dropping malformed notice %s for %s: a notice needs a title and a " + "message, both strings", + notice_id, + entry.entry_id, + ) + return kept + + @callback def async_raise( hass: HomeAssistant, entry: ConfigEntry, notice_id: str, *, title: str, message: str @@ -224,9 +270,18 @@ def _on_change( def _persist(notices: _Notices) -> None: """Queue a write of the current standing set. - The snapshot is taken now rather than in the callback: `async_delay_save` - calls its argument when the write fires, and by then the live dict may have - moved on -- so the deferred write would record a state nobody was ever shown. + The snapshot is taken now rather than read through in the callback, because + `async_delay_save` calls its argument when the write fires rather than when it + is queued. Defensive rather than load-bearing, and worth being precise about: + a `Store` keeps only one pending write, so the newest queued function is the + one that runs, and every site that mutates the standing set calls this + immediately afterwards. There is therefore no reachable state today in which + the live dict has moved on and no newer write has superseded this one -- a + mutation test confirms the difference is unobservable through the store. + + It stays a snapshot anyway. The cost is one shallow dict copy, and the + property it buys -- what is queued is what was true when it was queued -- does + not then depend on every future mutation site remembering to re-persist. """ written = StoredNotices(standing=dict(notices.standing)) notices.store.async_delay_save(lambda: written, _SAVE_DELAY) @@ -274,7 +329,9 @@ def read_translations(language: str, section: str) -> dict[str, str]: except (OSError, ValueError): _LOGGER.debug("Could not read notification strings from %s", path, exc_info=True) continue + if not isinstance(loaded, dict): + continue strings = loaded.get("notifications", {}).get(section, {}) - if strings: + if isinstance(strings, dict) and strings: return {str(key): str(value) for key, value in strings.items()} return {} diff --git a/requirements_test.txt b/requirements_test.txt index 0ecdb95d..ebddc177 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -3,6 +3,6 @@ pytest-asyncio>=1.3.0 setuptools>=65.7.0 pytest-homeassistant-custom-component==0.13.354 homeassistant==2026.8.0 -span-panel-api==3.0.0b4 -span-panel-api-schema-0==1.0.0b4 -span-panel-api-schema-1==0.1.0b4 +span-panel-api==3.0.0b6 +span-panel-api-schema-0==1.0.0b5 +span-panel-api-schema-1==0.1.0b6 diff --git a/tests/test_new_entity_announcement.py b/tests/test_new_entity_announcement.py index 97064f0c..801ee3b4 100644 --- a/tests/test_new_entity_announcement.py +++ b/tests/test_new_entity_announcement.py @@ -19,6 +19,7 @@ from typing import Any +from homeassistant.components.persistent_notification import async_dismiss from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er import pytest @@ -219,6 +220,83 @@ async def test_an_adopted_device_contributes_one_line_with_a_count( assert "Curated Addition" in message +async def test_an_adopted_device_is_listed_as_switched_off( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Every adopted entity registers disabled, so it must not read as ready to use. + + `AdoptedEntity` sets `_attr_entity_registry_enabled_default = False` for all of + them. Listing them beside the enabled additions said the opposite of the truth. + """ + _register(hass, entry, "sp3-001_existing", name="Existing") + await async_announce_new_entities(hass, entry) + + device = dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, "sp3-001_adopted_generator-1")}, + name="Backup Generator", + ) + _register(hass, entry, "sp3-001_adopted_0", name="Adopted 0", device_id=device.id) + _register(hass, entry, "sp3-001_ready", name="Ready Sensor", disabled=False) + await async_announce_new_entities(hass, entry) + + message = _text(_announcement(hass, entry)) + enabled_at = message.index("Added and ready to use") + disabled_at = message.index("Added but switched off") + assert enabled_at < message.index("Ready Sensor") < disabled_at + assert disabled_at < message.index("Backup Generator (1 entities)") + + +async def test_an_adopted_only_release_still_says_how_to_switch_them_on( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """The case adoption exists for was the case that told the user least. + + A vendor device appearing is often the *only* addition in a release. Counting + adopted entities apart from the disabled ones meant no heading rendered and, + worse, `how_to_enable` was suppressed -- the one string in the message with an + action attached. + """ + _register(hass, entry, "sp3-001_existing", name="Existing") + await async_announce_new_entities(hass, entry) + + device = dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, "sp3-001_adopted_generator-1")}, + name="Backup Generator", + ) + _register(hass, entry, "sp3-001_adopted_0", name="Adopted 0", device_id=device.id) + await async_announce_new_entities(hass, entry) + + message = _text(_announcement(hass, entry)) + assert "Added but switched off" in message + assert "show its disabled entities" in message + assert "Added and ready to use" not in message + + +async def test_an_announced_entity_is_not_announced_again_after_a_restart( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """The record must grow by what was registered, not be replaced by what it held. + + Recording only the previously-announced set would leave every entity announced + in this pass still absent from the record, so the next startup would find them + new again -- and every startup after that, forever. The nothing-added path + returns before the record is written, so it cannot catch this. + """ + _register(hass, entry, "sp3-001_existing", name="Existing") + await async_announce_new_entities(hass, entry) + + _register(hass, entry, _PART_NUMBER, name="Part Number") + await async_announce_new_entities(hass, entry) + assert _announcement(hass, entry) is not None + + async_dismiss(hass, f"{DOMAIN}_new_entities_{entry.entry_id}") + await async_announce_new_entities(hass, entry) + + assert _announcement(hass, entry) is None + + # -- Translations ------------------------------------------------------------ @@ -276,7 +354,7 @@ async def test_removing_the_entry_forgets_what_was_announced( def _sub_device(hass: HomeAssistant, entry: MockConfigEntry, name: str, ident: str) -> str: - """A device hanging off the panel, the way every SPAN sub-device does.""" + """Return a device hanging off the panel, the way every SPAN sub-device does.""" devices = dr.async_get(hass) panel = devices.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, "sp3-001")}, name="SPAN Panel" diff --git a/tests/test_notices.py b/tests/test_notices.py index b0172329..91c5b348 100644 --- a/tests/test_notices.py +++ b/tests/test_notices.py @@ -20,9 +20,11 @@ from custom_components.span_panel.const import DOMAIN from custom_components.span_panel.notices import ( _DATA, + _STORE_VERSION, async_forget, async_raise, async_restore, + read_translations, ) _NOTICE = "panel_upgraded" @@ -257,3 +259,139 @@ async def test_raising_against_an_untracked_entry_still_reaches_the_user( assert _standing(hass)[_id(entry)]["message"] == "Body" assert entry.entry_id not in hass.data.get(_DATA, {}) + + +# -- A store file that is not what we wrote ------------------------------------- +# +# `StoredNotices` and `StandingNotice` are compile-time only, so the disk can hold +# anything. Every shape below used to raise straight out of `async_setup_entry` -- +# and out of the part of it above the `try`, so the entry went to SETUP_ERROR with +# no retry and stayed dead until somebody found and deleted the file by hand. +# Bookkeeping for a notification is not worth an integration that will not load. + + +def _seed(hass_storage: dict[str, Any], entry: MockConfigEntry, data: object) -> None: + key = f"{DOMAIN}.notices.{entry.entry_id}" + hass_storage[key] = {"version": _STORE_VERSION, "key": key, "data": data} + + +@pytest.mark.parametrize( + ("shape", "description"), + [ + ({"standing": ["panel_upgraded"]}, "a list where the mapping should be"), + ({"standing": {"panel_upgraded": "oops"}}, "a string where the notice should be"), + ({"standing": {"panel_upgraded": {"title": "T"}}}, "a notice with no message"), + ({"standing": {"panel_upgraded": {"title": 1, "message": 2}}}, "non-string text"), + (["standing"], "a list at the top level"), + ({}, "an object with no standing key"), + ("nonsense", "a bare string"), + ], +) +async def test_a_malformed_store_does_not_take_the_integration_down( + hass: HomeAssistant, + hass_storage: dict[str, Any], + entry: MockConfigEntry, + shape: object, + description: str, +) -> None: + """Setup must survive every one of these; the record is recoverable, the entry is not.""" + _seed(hass_storage, entry, shape) + + await async_restore(hass, entry) + + assert hass.data[_DATA][entry.entry_id].standing == {} + + +async def test_a_malformed_store_is_overwritten_by_the_next_notice( + hass: HomeAssistant, hass_storage: dict[str, Any], entry: MockConfigEntry +) -> None: + """Self-healing is what makes falling back to empty the right trade. + + The user never has to find the file, because the next raise replaces it. + """ + _seed(hass_storage, entry, {"standing": "nonsense"}) + await async_restore(hass, entry) + + async_raise(hass, entry, _NOTICE, title="Upgraded", message="Body") + await _restart(hass, entry) + + assert _standing(hass)[_id(entry)]["message"] == "Body" + + +async def test_one_malformed_notice_does_not_discard_its_healthy_neighbours( + hass: HomeAssistant, hass_storage: dict[str, Any], entry: MockConfigEntry +) -> None: + """Dropping the whole file for one bad row would lose notices that are still valid.""" + _seed( + hass_storage, + entry, + { + "standing": { + "panel_upgraded": {"title": "Upgraded", "message": "Body"}, + "broken": {"title": "no message"}, + } + }, + ) + + await async_restore(hass, entry) + + assert _standing(hass)[_id(entry)]["message"] == "Body" + assert "broken" not in hass.data[_DATA][entry.entry_id].standing + + +# -- The dismissal watch is unregistered on unload ------------------------------ + + +async def test_unloading_stops_the_dismissal_watch( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Every setup registers a dispatcher handler, and the dispatcher fans out to all of them. + + Without `entry.async_on_unload` a reload leaves the previous handler behind, + bound to an entry that is gone, and the leak grows by one per reload. + + Asserted through what a leaked handler would *do* rather than by counting + subscribers: after unload nothing of ours is listening, so a dismissal reaches + no one and the standing set is untouched. + """ + await async_restore(hass, entry) + async_raise(hass, entry, _NOTICE, title="Upgraded", message="Body") + + await entry._async_process_on_unload(hass) + async_dismiss(hass, _id(entry)) + await hass.async_block_till_done() + + assert _NOTICE in hass.data[_DATA][entry.entry_id].standing + + +# -- A queued write records what was shown, not what came after ----------------- + + +async def test_a_queued_write_records_the_set_as_it_was_when_raised( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """`async_delay_save` calls its argument when the write fires, not when it is queued. + + Closing over the live dict would record whatever the set had become by then -- + a state nobody was ever shown. + """ + await async_restore(hass, entry) + async_raise(hass, entry, _NOTICE, title="Upgraded", message="Body") + + # A second notice queues its own write; the first write must still be able to + # produce the snapshot it was given rather than reading through to this one. + async_raise(hass, entry, "second", title="Second", message="Also") + await _flush(hass) + hass.data[_DATA].pop(entry.entry_id) + await async_restore(hass, entry) + + assert _standing(hass)[_id(entry)]["message"] == "Body" + assert _standing(hass)[_id(entry, "second")]["message"] == "Also" + + +# -- Translations off disk ------------------------------------------------------ + + +def test_a_translation_file_of_the_wrong_shape_falls_through_to_english() -> None: + """Same class as the store: our own files are fine, and the disk is not ours.""" + assert read_translations("xx", "panel_upgraded")["title"] From 54e84affe14229e8a50691836a847cf8b3010a55 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:28:27 -0700 Subject: [PATCH 079/345] feat(sensor): the grid power sensor says whether it is metering the grid `sensor.*_instant_grid_power` reads the upstream lugs' meter, and that is grid flow only where those lugs are the utility connection point. A BESS wired ahead of the main lugs, or a panel fed by another panel, leaves it metering that panel's own feed while `Grid Power Flow` stays site-level. Both readings are correct and they stop being the same number, so somebody comparing them has been unable to tell a topology from a fault. `at_service_entrance` answers that on the sensor they are already looking at. An attribute rather than an entity, deliberately. Topology is static -- a panel's position in a chain does not change without an electrician -- so a binary sensor would be a permanent row recording one unchanging boolean into the database forever, which is the stats pollution we have refused elsewhere. It is also additive on an entity that already exists, so it reaches an upgraded install without touching the registry: no entity_id, no unique_id, no state_class, no statistics. Attributes are announced by the release notes rather than by the additions notification, which is the exception already recorded in the delta document. Not a Repair, because nothing is broken and there is nothing to act on. The panel is wired the way it is wired. Confined to the grid sensor. `SpanPanelPowerSensor` also backs feedthrough, battery and PV, and those are what they say they are wherever the panel sits -- an attribute qualifying the grid label would be noise on them, and worse, would read as qualifying their value. Diagnostics carries the same fact next to both grid figures, since it is the first thing worth knowing on a "my grid sensor reads wrong" report. Two mutations verified dead: dropping the key check so every power sensor carries it, and hardcoding the value True. --- custom_components/span_panel/diagnostics.py | 7 +++ custom_components/span_panel/sensor_panel.py | 35 ++++++++++++- tests/factories.py | 6 +++ tests/test_diagnostics.py | 9 ++++ tests/test_sensor_entities.py | 53 +++++++++++++++++++- uv.lock | 7 ++- 6 files changed, 112 insertions(+), 5 deletions(-) diff --git a/custom_components/span_panel/diagnostics.py b/custom_components/span_panel/diagnostics.py index d7b050f5..b033ba5d 100644 --- a/custom_components/span_panel/diagnostics.py +++ b/custom_components/span_panel/diagnostics.py @@ -193,6 +193,13 @@ async def async_get_config_entry_diagnostics( "serial_number": snapshot.serial_number, "firmware_version": snapshot.firmware_version, "panel_size": snapshot.panel_size, + # Whether the upstream lugs are the utility connection point. First thing + # worth knowing on a "my grid sensor reads wrong" report: where this is + # False, `instant_grid_power_w` is this panel's feed rather than the + # site's grid, and the two grid figures differ legitimately. + "lugs_at_service_entrance": snapshot.lugs_at_service_entrance, + "instant_grid_power_w": snapshot.instant_grid_power_w, + "power_flow_grid": snapshot.power_flow_grid, } if snapshot.wifi_ssid is not None: diff --git a/custom_components/span_panel/sensor_panel.py b/custom_components/span_panel/sensor_panel.py index efc64516..79d5bc90 100644 --- a/custom_components/span_panel/sensor_panel.py +++ b/custom_components/span_panel/sensor_panel.py @@ -460,8 +460,38 @@ def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanBatterySnapshot: return snapshot.battery +_GRID_POWER_KEY = "instantGridPowerW" +"""The one power sensor whose name is conditional on topology. + +This class backs four sensors -- grid, feedthrough, battery and PV -- and only +the grid one reads a meter whose meaning depends on where the panel sits. +""" + + class SpanPanelPowerSensor(SpanSensorBase[SpanPanelDataSensorEntityDescription, SpanPanelSnapshot]): - """Panel power sensor with calculated amperage attribute.""" + """Panel power sensor with calculated amperage attribute. + + **The grid sensor carries `at_service_entrance`.** It reads the upstream lugs' + meter, which is grid flow only where those lugs are the utility connection + point. A BESS wired ahead of the main lugs, or a panel fed by another panel, + leaves it metering that panel's own feed instead, and `power_flow_grid` -- + the `Grid Power Flow` sensor -- is then the site-level figure. Both readings + are correct; they simply stop being the same number. + + That disagreement is what this attribute exists for. Someone whose two grid + figures differ has no way to tell a topology from a fault, and the answer now + sits on the sensor they are already looking at. + + An attribute rather than an entity, deliberately. Topology is static -- a + panel's position in a chain does not change without an electrician -- so a + binary sensor would be a permanent row recording one unchanging boolean into + the database forever. It is also additive on an entity that already exists, + so it reaches an upgraded install without touching the registry: no + `entity_id`, no `unique_id`, no `state_class`, no statistics. + + Not a Repair, for the reason the Repairs list means something: nothing is + broken and there is nothing to act on. The panel is wired the way it is wired. + """ def __init__( self, @@ -523,6 +553,9 @@ def extra_state_attributes(self) -> dict[str, Any] | None: else: attributes["amperage"] = 0.0 + if self._description_key == _GRID_POWER_KEY: + attributes["at_service_entrance"] = self.coordinator.data.lugs_at_service_entrance + return attributes diff --git a/tests/factories.py b/tests/factories.py index 2594a44c..2420529a 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -243,6 +243,11 @@ def create( downstream_l2_current_a: float | None = None, pv: SpanPVSnapshot | None = None, evse: dict[str, SpanEvseSnapshot] | None = None, + # Defaults True for the same reason the library field does: a panel at the + # service entrance is the ordinary case, and flat firmware cannot be + # anything else. A fixture opts out to model a chained panel or one behind + # an upstream DER. + lugs_at_service_entrance: bool = True, ) -> SpanPanelSnapshot: """Create a SpanPanelSnapshot with reasonable defaults.""" if circuits is None: @@ -287,6 +292,7 @@ def create( panel_size=panel_size, power_flow_pv=power_flow_pv, power_flow_grid=power_flow_grid, + lugs_at_service_entrance=lugs_at_service_entrance, upstream_l1_current_a=upstream_l1_current_a, upstream_l2_current_a=upstream_l2_current_a, downstream_l1_current_a=downstream_l1_current_a, diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 6d010a63..f036a610 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -93,6 +93,9 @@ async def test_config_entry_diagnostics_includes_redacted_runtime_data( "serial_number": "sp3-diag-001", "firmware_version": "spanos2/r202603/05", "panel_size": 32, + "lugs_at_service_entrance": True, + "instant_grid_power_w": 2500.75, + "power_flow_grid": None, "wifi_ssid": "Span WiFi", "eth0_link": True, "wlan_link": False, @@ -155,6 +158,9 @@ async def test_config_entry_diagnostics_omits_optional_sections_when_unavailable evse={}, battery=None, adopted_devices=(), + lugs_at_service_entrance=True, + instant_grid_power_w=0.0, + power_flow_grid=None, ) coordinator = MagicMock() coordinator.data = snapshot @@ -171,6 +177,9 @@ async def test_config_entry_diagnostics_omits_optional_sections_when_unavailable "serial_number": "sp3-diag-002", "firmware_version": "spanos2/r202603/06", "panel_size": None, + "lugs_at_service_entrance": True, + "instant_grid_power_w": 0.0, + "power_flow_grid": None, } assert result["circuits"]["uuid_minimal"] == { "name": None, diff --git a/tests/test_sensor_entities.py b/tests/test_sensor_entities.py index ff2f5a9f..e3aa3090 100644 --- a/tests/test_sensor_entities.py +++ b/tests/test_sensor_entities.py @@ -110,7 +110,11 @@ def test_panel_power_sensor_extra_state_attributes_include_amperage() -> None: sensor._update_native_value() assert sensor.native_value == 480.0 - assert sensor.extra_state_attributes == {"voltage": 240, "amperage": 2.0} + assert sensor.extra_state_attributes == { + "voltage": 240, + "amperage": 2.0, + "at_service_entrance": True, + } def test_panel_power_sensor_defaults_amperage_when_value_not_numeric() -> None: @@ -123,7 +127,52 @@ def test_panel_power_sensor_defaults_amperage_when_value_not_numeric() -> None: sensor._attr_native_value = STATE_UNKNOWN - assert sensor.extra_state_attributes == {"voltage": 240, "amperage": 0.0} + assert sensor.extra_state_attributes == { + "voltage": 240, + "amperage": 0.0, + "at_service_entrance": True, + } + + +def test_grid_power_says_when_the_lugs_are_not_the_utility_connection() -> None: + """The attribute exists for the case where the two grid figures disagree. + + A BESS ahead of the main lugs, or a panel fed by another panel, leaves this + sensor metering that panel's own feed while `power_flow_grid` stays + site-level. Both readings are right and they stop being the same number, and + without this the user cannot tell that from a fault. + """ + snapshot = SpanPanelSnapshotFactory.create( + instant_grid_power_w=480.0, lugs_at_service_entrance=False + ) + coordinator = _make_coordinator(snapshot) + 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 False + # The label is conditional; the measurement is not. + assert sensor.native_value == 480.0 + + +def test_only_the_grid_sensor_carries_the_topology_attribute() -> None: + """The same class backs four sensors and only one reads a topology-dependent meter. + + Feedthrough, battery and PV are what they say they are wherever the panel + sits, so an attribute qualifying the grid label would be noise on them -- + and worse, would read as qualifying *their* value. + """ + snapshot = SpanPanelSnapshotFactory.create( + instant_grid_power_w=480.0, feedthrough_power_w=120.0, lugs_at_service_entrance=False + ) + coordinator = _make_coordinator(snapshot) + description = next(desc for desc in PANEL_POWER_SENSORS if desc.key == "feedthroughPowerW") + + sensor = SpanPanelPowerSensor(coordinator, description, snapshot) + sensor._update_native_value() + + assert "at_service_entrance" not in (sensor.extra_state_attributes or {}) def test_panel_sensor_default_friendly_names_cover_fallback_branches() -> None: diff --git a/uv.lock b/uv.lock index 04f4fe2d..63f55678 100644 --- a/uv.lock +++ b/uv.lock @@ -2560,10 +2560,13 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "httpx", specifier = ">=0.28.1" }, + { name = "httpx", specifier = ">=0.28.1,<1.0" }, { name = "paho-mqtt", specifier = ">=2.0.0,<3.0.0" }, { name = "pyyaml", specifier = ">=6.0.0" }, + { name = "span-panel-api-schema-0", marker = "extra == 'schema-0'", editable = "../../span/span-panel-api-p3/packages/schema-0" }, + { name = "span-panel-api-schema-1", marker = "extra == 'schema-1'", editable = "../../span/span-panel-api-p3/packages/schema-1" }, ] +provides-extras = ["schema-0", "schema-1"] [package.metadata.requires-dev] dev = [ @@ -2607,7 +2610,7 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "ebus-sdk", specifier = ">=0.19.0,<1.0" }, + { name = "ebus-sdk", specifier = ">=0.19.0,<0.23" }, { name = "span-panel-api", editable = "../../span/span-panel-api-p3" }, ] From cda28ab050481e77e89db4d15d31d8187d278dcf Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:43:24 -0700 Subject: [PATCH 080/345] feat: hand the panel client Home Assistant's shared HTTP client `quality_scale.yaml` has declared `inject-websession: done` for a while, and it was true of the config flow and of nothing that ran after it. Every runtime schema read built a throwaway client: once at connect, and once per attempt inside the library's retry loop, which runs while a panel finishes rebooting after a firmware upgrade and can go five times in a row. Home Assistant already owns a client, shares it across integrations, and closes it at shutdown. The library gained the parameter in 3.0.0b7; this passes it. Requires that release, so all three pin sites move together -- manifest, pyproject and requirements_test -- since the sync script's regex stops at the first non-numeric character and is inert on beta versions. `get_async_client(hass)` rather than the config flow's `verify_ssl=False` variant, and not because one is safer: the library's bootstrap URLs are plain `http://`, so TLS never happens on this path either way, which also means the config flow's `verify_ssl=False` is inert today. The default client is the one every other integration already shares; asking for the no-verify variant would stand up a second connection pool for this integration alone and imply a TLS decision nobody is making. The test asserts identity rather than presence, because a test that only checked something was passed would pass equally for a fresh client built here -- which is the thing being removed. Both mutations die: dropping the argument, and asking for HA's other client. --- custom_components/span_panel/__init__.py | 14 +++++++ custom_components/span_panel/manifest.json | 2 +- pyproject.toml | 2 +- requirements_test.txt | 2 +- tests/test_setup_entry.py | 44 ++++++++++++++++++++++ uv.lock | 2 +- 6 files changed, 62 insertions(+), 4 deletions(-) diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py index aba6954b..3944c531 100644 --- a/custom_components/span_panel/__init__.py +++ b/custom_components/span_panel/__init__.py @@ -17,6 +17,7 @@ ConfigEntryNotReady, ) from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.typing import ConfigType from span_panel_api import SpanMqttClient, SpanPanelSnapshot from span_panel_api.exceptions import ( @@ -202,6 +203,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> broker_config, snapshot_interval=snapshot_interval, panel_http_port=panel_http_port, + # Home Assistant's shared client, which it owns and closes at + # shutdown. Without this the library built one per schema read -- + # once at connect, and once per retry while a panel finishes + # rebooting after a firmware upgrade. `quality_scale.yaml` claims + # `inject-websession: done`, and until now that was true of the + # config flow and of nothing that ran afterwards. + # + # Verification is not the reason for the default client rather + # than the config flow's `verify_ssl=False` one: the library's + # bootstrap URLs are plain `http://`, so TLS never happens on + # this path either way. It is the default client because that is + # the one every other integration already shares. + httpx_client=get_async_client(hass), ) try: await client.connect() diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 9a7ca8d3..c7f04a54 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -22,7 +22,7 @@ ], "quality_scale": "gold", "requirements": [ - "span-panel-api==3.0.0b6", + "span-panel-api==3.0.0b7", "span-panel-api-schema-0==1.0.0b5", "span-panel-api-schema-1==0.1.0b6" ], diff --git a/pyproject.toml b/pyproject.toml index 9c496798..6e49956e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ readme = "README.md" requires-python = ">=3.14.3,<3.15" dependencies = [ "homeassistant==2026.8.0", - "span-panel-api==3.0.0b6", + "span-panel-api==3.0.0b7", # 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, diff --git a/requirements_test.txt b/requirements_test.txt index ebddc177..c47eaa23 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -3,6 +3,6 @@ pytest-asyncio>=1.3.0 setuptools>=65.7.0 pytest-homeassistant-custom-component==0.13.354 homeassistant==2026.8.0 -span-panel-api==3.0.0b6 +span-panel-api==3.0.0b7 span-panel-api-schema-0==1.0.0b5 span-panel-api-schema-1==0.1.0b6 diff --git a/tests/test_setup_entry.py b/tests/test_setup_entry.py index be66441d..c49707e3 100644 --- a/tests/test_setup_entry.py +++ b/tests/test_setup_entry.py @@ -12,6 +12,7 @@ from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.httpx_client import get_async_client import pytest from pytest_homeassistant_custom_component.common import MockConfigEntry from span_panel_api.exceptions import SpanPanelAuthError @@ -102,6 +103,49 @@ async def test_async_setup_entry_v2_success_sets_runtime_data_and_title( mock_update_entry.assert_called_once_with(entry, title="SPAN Panel") +async def test_the_panel_client_is_given_home_assistants_shared_http_client( + hass: HomeAssistant, +) -> None: + """Not a client of its own, and not a copy: the one instance HA hands out. + + Without this the library builds a throwaway client per schema read -- once at + connect, and once per retry while a panel finishes rebooting after a firmware + upgrade. `quality_scale.yaml` declares `inject-websession: done`, and that was + true of the config flow and of nothing that ran afterwards. + + Asserted by identity rather than by type. A test that only checked something + was passed would pass just as well for a fresh client built here, which is + the thing being removed -- HA owns this one and closes it at shutdown. + """ + entry = _create_v2_entry() + entry.add_to_hass(hass) + client = MagicMock() + client.connect = AsyncMock() + coordinator = MagicMock() + coordinator.async_config_entry_first_refresh = AsyncMock() + coordinator.async_setup_streaming = AsyncMock() + coordinator.data = SpanPanelSnapshotFactory.create(serial_number="sp3-setup-001") + + with ( + patch("custom_components.span_panel.async_register_commands"), + patch( + "custom_components.span_panel.SpanMqttClient", return_value=client + ) as mock_client_cls, + patch( + "custom_components.span_panel.SpanPanelCoordinator", return_value=coordinator + ), + patch( + "custom_components.span_panel.ensure_device_registered", + AsyncMock(return_value="panel-device-id"), + ), + patch.object(hass.config_entries, "async_forward_entry_setups", AsyncMock()), + patch.object(hass.config_entries, "async_update_entry"), + ): + assert await async_setup_entry(hass, entry) is True + + assert mock_client_cls.call_args.kwargs["httpx_client"] is get_async_client(hass) + + async def test_async_setup_entry_v2_missing_mqtt_credentials_raises_auth_failed( hass: HomeAssistant, ) -> None: diff --git a/uv.lock b/uv.lock index 63f55678..152ce486 100644 --- a/uv.lock +++ b/uv.lock @@ -2550,7 +2550,7 @@ dev = [ [[package]] name = "span-panel-api" -version = "3.0.0b6" +version = "3.0.0b7" source = { editable = "../../span/span-panel-api-p3" } dependencies = [ { name = "httpx" }, From 8eb8b31fcfcc5b552d50308ea1b187efd616a021 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:27:18 -0700 Subject: [PATCH 081/345] fix(scripts): make the dependency sync actually match a version It has been inert for its entire life and said so to nobody. Versions were matched with `[0-9.]+`, so `3.0.0b7` was read as `3.0.0`, which matched nothing and rewrote nothing -- and every version this project has ever shipped is a pre-release, so this was not an edge case, it was every case. It also only looked for the bootstrap package, entering that branch for `span-panel-api-schema-0` on the shared prefix and then failing its own regex on the `-schema-0` that followed, so neither adapter was ever synced by anything. And it had never heard of `requirements_test.txt`, which is how that file came to pin b4 against a manifest requiring b7. It runs on every commit through `prek.toml` with `always_run`, and again in CI under `prek run --all-files`, reporting success each time. The fault is the shape rather than the regex. Nothing parsed, so nothing could report that it had not matched, and a hook that matches nothing produces no changes -- which is indistinguishable from a tree that was already correct. This version parses each requirement, looks the name up in the manifest, and then reads the files back and checks them against the manifest, so a miss is an error rather than a silent no-op. Scoped to `[project] dependencies` rather than the whole file, and that scoping is load-bearing: `pyproject.toml` names these same packages as editable path overrides under `[tool.uv.sources]`, in mypy's search path, and potentially as a uv constraint -- a specifier someone set deliberately to a different value, which a whole-file rewrite would silently drag to the manifest's pin. Nine tests, in `tests/` rather than beside the script in `tests/scripts/`, which `pytest.ini` excludes with `norecursedirs`: a test that is never collected is the same failure as a hook that never matches. Two mutations verified dead -- the old `[0-9.]+` version pattern, and dropping the scoping. --- scripts/sync-dependencies.py | 257 +++++++++++++++++++++++----------- tests/test_dependency_sync.py | 215 ++++++++++++++++++++++++++++ 2 files changed, 391 insertions(+), 81 deletions(-) create mode 100644 tests/test_dependency_sync.py diff --git a/scripts/sync-dependencies.py b/scripts/sync-dependencies.py index f318e568..f6d92c4a 100755 --- a/scripts/sync-dependencies.py +++ b/scripts/sync-dependencies.py @@ -1,105 +1,200 @@ #!/usr/bin/env python3 -"""Synchronize dependency versions from manifest.json to pyproject.toml. - -This script reads the dependency versions from custom_components/span_panel/manifest.json -and updates the corresponding dependencies in pyproject.toml to match. - -Used as a pre-commit hook to ensure pyproject.toml stays in sync with manifest versions. +"""Hold every declaration of a library pin to the one in manifest.json. + +Home Assistant installs what `custom_components/span_panel/manifest.json` +requires, so that file is the only one whose pins reach a user. Every other +place the same library is named -- `pyproject.toml` for local development, +`requirements_test.txt` for a bare pip setup -- is a copy, and a copy that drifts +is worse than no copy: the tests pass against one version while the integration +ships another. + +Run as a pre-commit hook. Rewrites the copies and exits non-zero when it changed +something, so the commit stops and the corrected files are re-staged. + +**Why this was rewritten.** The previous version matched versions with `[0-9.]+` +and so stopped at the first letter: `3.0.0b7` was seen as `3.0.0`, which matched +nothing and silently rewrote nothing. Every version this project has ever shipped +is a pre-release, so the hook has been inert for its whole life while reporting +success on every commit. It also only ever looked for the bootstrap package -- +`span-panel-api-schema-0` starts with `span-panel-api`, so it entered the branch +and then failed its own regex -- and it never knew about `requirements_test.txt` +at all, which is how that file came to pin `b4` against a manifest requiring `b7`. + +The lesson is in the shape rather than the regex: nothing parsed, so nothing +could report that it had not matched. This version parses the requirement, looks +the name up, and verifies the result, so a miss is an error rather than a +no-change. """ +from __future__ import annotations + import json from pathlib import Path import re import sys +import tomllib +REPO = Path(__file__).resolve().parent.parent +MANIFEST = REPO / "custom_components" / "span_panel" / "manifest.json" +PYPROJECT = REPO / "pyproject.toml" +REQUIREMENTS_TEST = REPO / "requirements_test.txt" -def get_manifest_versions(): - """Extract dependency versions from manifest.json.""" - manifest_path = Path("custom_components/span_panel/manifest.json") - - if not manifest_path.exists(): - return None - - try: - with open(manifest_path) as f: - manifest = json.load(f) - - requirements = manifest.get("requirements", []) - versions = {} - - for req in requirements: - if req.startswith("span-panel-api"): - # Extract full specifier (e.g. ==2.3.0, >=2.0.0, ~=1.1.0) - match = re.search(r"span-panel-api([>~=!]+[0-9.]+)", req) - if match: - versions["span-panel-api"] = match.group(1) - elif req.startswith("ha-synthetic-sensors"): - # Extract full specifier (e.g. >=1.0.8, ~=1.0.8) - match = re.search(r"ha-synthetic-sensors([>~=!]+[0-9.]+)", req) - if match: - versions["ha-synthetic-sensors"] = match.group(1) - - return versions +# One requirement: a PEP 508 name, then a specifier that runs to the end. The +# version half is deliberately unconstrained -- `3.0.0b7`, `1.0.0rc1`, `2.6.4`, +# `1.0.0.post1` and `>=1,<2` all have to survive it, and enumerating version +# grammar is what broke the last one. +REQUIREMENT = re.compile( + r"^(?P[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)\s*(?P[<>=!~].*)$" +) - except Exception: - return None +class SyncError(Exception): + """Something is wrong with the inputs, as opposed to merely out of date.""" -def update_pyproject_dependencies(versions): - """Update pyproject.toml dependencies with manifest versions.""" - pyproject_path = Path("pyproject.toml") - if not pyproject_path.exists(): - return False +def manifest_pins() -> dict[str, str]: + """Return the manifest's requirements, keyed by package name. + Raises rather than returning empty on a bad manifest. An unreadable source of + truth is not a reason to leave every copy alone and report success. + """ + if not MANIFEST.is_file(): + raise SyncError(f"no manifest at {MANIFEST.relative_to(REPO)}") try: - with open(pyproject_path) as f: - content = f.read() - - original_content = content - - # Update span-panel-api version in [project] dependencies - if "span-panel-api" in versions: - span_spec = versions["span-panel-api"] - content = re.sub( - r'"span-panel-api[><=~!]+[0-9.]+"', - f'"span-panel-api{span_spec}"', - content, + manifest = json.loads(MANIFEST.read_text(encoding="utf-8")) + except ValueError as exc: + raise SyncError(f"{MANIFEST.relative_to(REPO)} is not valid JSON: {exc}") from exc + + pins: dict[str, str] = {} + for requirement in manifest.get("requirements", []): + match = REQUIREMENT.match(str(requirement).strip()) + if match is None: + raise SyncError( + f"manifest requirement {requirement!r} has no version specifier; " + "Home Assistant installs these verbatim, so an unpinned one is a bug " + "rather than something to copy" ) - - # Update ha-synthetic-sensors version in [project] dependencies - if "ha-synthetic-sensors" in versions: - ha_spec = versions["ha-synthetic-sensors"] - content = re.sub( - r'"ha-synthetic-sensors[><=~!]+[0-9.]+"', - f'"ha-synthetic-sensors{ha_spec}"', - content, + pins[match["name"]] = match.group(0) + if not pins: + raise SyncError(f"{MANIFEST.relative_to(REPO)} declares no requirements") + return pins + + +def _project_dependencies_span(text: str) -> tuple[int, int]: + """Return the character span of `[project]`'s `dependencies = [...]` array. + + Scoped rather than global on purpose. `pyproject.toml` names these same + packages twice more -- as editable path overrides under `[tool.uv.sources]`, + and inside mypy's search path -- and rewriting either would replace a + filesystem path with a version specifier. + """ + project = re.search(r"^\[project\]\s*$", text, re.MULTILINE) + if project is None: + raise SyncError("pyproject.toml has no [project] table") + next_table = re.search(r"^\[", text[project.end() :], re.MULTILINE) + end_of_project = project.end() + ( + next_table.start() if next_table else len(text) - project.end() + ) + + array = re.search( + r"^dependencies\s*=\s*\[", text[project.start() : end_of_project], re.MULTILINE + ) + if array is None: + raise SyncError("[project] declares no dependencies array") + start = project.start() + array.end() + closing = text.find("]", start) + if closing == -1: + raise SyncError("[project] dependencies array is never closed") + return start, closing + + +def sync_pyproject(pins: dict[str, str]) -> list[str]: + """Rewrite pinned requirements inside `[project] dependencies`. Return the changes.""" + text = PYPROJECT.read_text(encoding="utf-8") + start, end = _project_dependencies_span(text) + changes: list[str] = [] + + def replace(match: re.Match[str]) -> str: + requirement = REQUIREMENT.match(match["req"]) + if requirement is None or requirement["name"] not in pins: + return match.group(0) + wanted = pins[requirement["name"]] + if match["req"] == wanted: + return match.group(0) + changes.append(f"pyproject.toml: {match['req']} -> {wanted}") + return f'"{wanted}"' + + body = re.sub(r'"(?P[^"]+)"', replace, text[start:end]) + if changes: + PYPROJECT.write_text(text[:start] + body + text[end:], encoding="utf-8") + return changes + + +def sync_requirements_test(pins: dict[str, str]) -> list[str]: + """Rewrite pinned requirements in `requirements_test.txt`. Return the changes.""" + if not REQUIREMENTS_TEST.is_file(): + return [] + lines = REQUIREMENTS_TEST.read_text(encoding="utf-8").splitlines() + changes: list[str] = [] + + for index, line in enumerate(lines): + requirement = REQUIREMENT.match(line.strip()) + if requirement is None or requirement["name"] not in pins: + continue + wanted = pins[requirement["name"]] + if line.strip() != wanted: + changes.append(f"requirements_test.txt: {line.strip()} -> {wanted}") + lines[index] = wanted + + if changes: + REQUIREMENTS_TEST.write_text("\n".join(lines) + "\n", encoding="utf-8") + return changes + + +def verify(pins: dict[str, str]) -> None: + """Read the rewritten files back and confirm they say what was intended. + + The point of the whole rewrite. A regex that matches nothing produces no + changes, which is indistinguishable from a file that was already correct -- + that is exactly how the previous version stayed silently broken. Checking the + result against the manifest turns a miss into a failure. + """ + declared = tomllib.loads(PYPROJECT.read_text(encoding="utf-8"))["project"]["dependencies"] + for requirement in declared: + match = REQUIREMENT.match(str(requirement).strip()) + if match and match["name"] in pins and match.group(0) != pins[match["name"]]: + raise SyncError( + f"pyproject.toml still declares {requirement!r} after syncing; " + f"the manifest pins {pins[match['name']]!r}" ) - if content != original_content: - with open(pyproject_path, "w") as f: - f.write(content) - return True - - return False + if REQUIREMENTS_TEST.is_file(): + for line in REQUIREMENTS_TEST.read_text(encoding="utf-8").splitlines(): + match = REQUIREMENT.match(line.strip()) + if match and match["name"] in pins and match.group(0) != pins[match["name"]]: + raise SyncError( + f"requirements_test.txt still declares {line.strip()!r} after syncing; " + f"the manifest pins {pins[match['name']]!r}" + ) - except Exception: - return False +def main() -> int: + """Sync, verify, and fail the commit if anything moved.""" + try: + pins = manifest_pins() + changes = sync_pyproject(pins) + sync_requirements_test(pins) + verify(pins) + except SyncError as exc: + print(f"sync-dependencies: {exc}", file=sys.stderr) + return 1 -def main(): - """Main function.""" - versions = get_manifest_versions() - if not versions: - sys.exit(1) - - changes_made = update_pyproject_dependencies(versions) - - if changes_made: - sys.exit(1) # Exit with error to fail pre-commit - else: - sys.exit(0) + if not changes: + return 0 + for change in changes: + print(f"sync-dependencies: {change}") + print("sync-dependencies: files updated to match the manifest; re-stage and commit again") + return 1 if __name__ == "__main__": - main() + sys.exit(main()) diff --git a/tests/test_dependency_sync.py b/tests/test_dependency_sync.py new file mode 100644 index 00000000..d0cae0ff --- /dev/null +++ b/tests/test_dependency_sync.py @@ -0,0 +1,215 @@ +"""The pin sync must fail loudly rather than quietly do nothing. + +`scripts/sync-dependencies.py` holds `pyproject.toml` and `requirements_test.txt` +to the pins in `manifest.json`, which is the only file whose versions reach a +user. It runs on every commit through `prek.toml` and again in CI. + +Lives here rather than beside the script in `tests/scripts/`, which `pytest.ini` +excludes with `norecursedirs`. A test that is never collected is the same failure +as a hook that never matches: green, silent, and worth nothing. + +It is tested because the previous version was inert for its entire life and said +so to nobody. It matched versions with `[0-9.]+`, so `3.0.0b7` was read as +`3.0.0`, which matched nothing and rewrote nothing -- and every version this +project ships is a pre-release. A hook that matches nothing produces no changes, +which is indistinguishable from a tree that was already correct. That is the +failure these tests exist to catch, so most of them drift a file on purpose and +check that the hook notices. +""" + +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import sys + +import pytest + +REPO = Path(__file__).resolve().parent.parent +SCRIPT = REPO / "scripts" / "sync-dependencies.py" + + +def _run(cwd: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(SCRIPT)], cwd=cwd, capture_output=True, text=True, check=False + ) + + +@pytest.fixture +def repo(tmp_path: Path) -> Path: + """Return a miniature of this repository: a manifest and the two files it governs. + + Built rather than copied so a test cannot pass by accident of what the real + tree happens to hold today, and so the pins can be pre-release versions -- + the case the old implementation could not see. + """ + component = tmp_path / "custom_components" / "span_panel" + component.mkdir(parents=True) + (component / "manifest.json").write_text( + json.dumps( + { + "domain": "span_panel", + "requirements": [ + "span-panel-api==3.0.0b7", + "span-panel-api-schema-0==1.0.0b5", + "span-panel-api-schema-1==0.1.0b6", + ], + }, + indent=2, + ) + ) + (tmp_path / "pyproject.toml").write_text( + "[project]\n" + 'name = "span-panel"\n' + "dependencies = [\n" + ' "span-panel-api==3.0.0b7",\n' + ' "span-panel-api-schema-0==1.0.0b5",\n' + ' "span-panel-api-schema-1==0.1.0b6",\n' + ' "homeassistant>=2026.8.0",\n' + "]\n" + "\n" + "[tool.uv.sources]\n" + 'span-panel-api = { path = "../span-panel-api", editable = true }\n' + "\n" + "[tool.uv]\n" + "# A deliberately different pin, in a table the manifest does not govern.\n" + 'constraint-dependencies = ["span-panel-api==3.0.0b3"]\n' + ) + (tmp_path / "requirements_test.txt").write_text( + "pytest>=9.0.3\nspan-panel-api==3.0.0b7\nspan-panel-api-schema-0==1.0.0b5\n" + "span-panel-api-schema-1==0.1.0b6\n" + ) + return tmp_path + + +def _write_script_into(repo: Path) -> None: + """Give the miniature its own copy, since the script locates files from its own path.""" + scripts = repo / "scripts" + scripts.mkdir(exist_ok=True) + (scripts / "sync-dependencies.py").write_text(SCRIPT.read_text()) + + +def _sync(repo: Path) -> subprocess.CompletedProcess[str]: + _write_script_into(repo) + return subprocess.run( + [sys.executable, str(repo / "scripts" / "sync-dependencies.py")], + cwd=repo, + capture_output=True, + text=True, + check=False, + ) + + +def test_an_already_synced_tree_passes(repo: Path) -> None: + result = _sync(repo) + assert result.returncode == 0, result.stderr + + +def test_a_pre_release_pin_is_synced(repo: Path) -> None: + """The exact case the previous implementation could not see. + + `3.0.0b7` was read as `3.0.0` by a `[0-9.]+` version pattern, so the + substitution never matched and the hook reported success on a stale file. + Every version this project has shipped is a pre-release, so this was not an + edge case -- it was every case. + """ + pyproject = repo / "pyproject.toml" + pyproject.write_text(pyproject.read_text().replace("3.0.0b7", "3.0.0b4")) + + result = _sync(repo) + + assert result.returncode == 1, "a stale pin must stop the commit" + assert "span-panel-api==3.0.0b7" in pyproject.read_text() + assert "3.0.0b4" not in pyproject.read_text() + + +def test_the_adapter_packages_are_synced_too(repo: Path) -> None: + """`span-panel-api-schema-0` starts with `span-panel-api`, which is how it was missed. + + The old implementation entered its bootstrap branch on the prefix and then + failed its own regex on the `-schema-0` that followed, so the adapters were + never synced by anything. + """ + pyproject = repo / "pyproject.toml" + pyproject.write_text(pyproject.read_text().replace("0.1.0b6", "0.1.0b3")) + + assert _sync(repo).returncode == 1 + assert "span-panel-api-schema-1==0.1.0b6" in pyproject.read_text() + + +def test_requirements_test_is_governed(repo: Path) -> None: + """It drifted to a stale beta precisely because nothing looked at it.""" + requirements = repo / "requirements_test.txt" + requirements.write_text(requirements.read_text().replace("3.0.0b7", "3.0.0b4")) + + assert _sync(repo).returncode == 1 + assert "span-panel-api==3.0.0b7" in requirements.read_text() + + +def test_only_the_project_dependencies_are_governed(repo: Path) -> None: + """The manifest governs `[project] dependencies` and no other table. + + `pyproject.toml` names these same packages in several places: as editable + path overrides under `[tool.uv.sources]`, in mypy's search path, and -- the + case that makes scoping load-bearing rather than merely tidy -- as a uv + constraint, which is a version specifier someone set deliberately and to a + different value. A rewrite that walked the whole file would silently drag it + to the manifest's pin and undo the constraint. + """ + pyproject = repo / "pyproject.toml" + pyproject.write_text( + pyproject.read_text().replace( + '"span-panel-api==3.0.0b7",\n', '"span-panel-api==3.0.0b4",\n', 1 + ) + ) + + assert _sync(repo).returncode == 1 + body = pyproject.read_text() + + assert '"span-panel-api==3.0.0b7",' in body, "the governed pin is synced" + assert 'constraint-dependencies = ["span-panel-api==3.0.0b3"]' in body, ( + "the ungoverned one is not" + ) + assert '{ path = "../span-panel-api", editable = true }' in body + + +def test_a_dependency_the_manifest_does_not_pin_is_untouched(repo: Path) -> None: + """The manifest governs its own requirements and nothing else.""" + pyproject = repo / "pyproject.toml" + pyproject.write_text(pyproject.read_text().replace("3.0.0b7", "3.0.0b4")) + + _sync(repo) + + assert '"homeassistant>=2026.8.0"' in pyproject.read_text() + + +def test_an_unparseable_manifest_fails_rather_than_reporting_success(repo: Path) -> None: + """An unreadable source of truth is not a reason to leave every copy alone.""" + (repo / "custom_components" / "span_panel" / "manifest.json").write_text("{not json") + + result = _sync(repo) + + assert result.returncode == 1 + assert "not valid JSON" in result.stderr + + +def test_an_unpinned_manifest_requirement_is_an_error(repo: Path) -> None: + """Home Assistant installs these verbatim, so a missing specifier is a bug.""" + manifest = repo / "custom_components" / "span_panel" / "manifest.json" + manifest.write_text(json.dumps({"requirements": ["span-panel-api"]}, indent=2)) + + result = _sync(repo) + + assert result.returncode == 1 + assert "no version specifier" in result.stderr + + +def test_the_real_repository_is_in_sync() -> None: + """The hook runs on every commit, so this should already be true. + + Here so that a stale pin is reported by the test suite as well as by the + hook: CI runs both, and a developer who bypasses hooks still gets told. + """ + result = _run(REPO) + assert result.returncode == 0, f"{result.stdout}{result.stderr}" From b659d573c4d6c08863fd26616b055bccc8886fae Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:28:48 -0700 Subject: [PATCH 082/345] chore: declare 2.1.0b1, so the version reads as the beta it is `manifest.json` and `pyproject.toml` both said `2.1.0`, a final version string, while the thing being cut is a beta. HACS lists the release tag and Home Assistant reports the manifest version on the device page, so shipping a beta that calls itself `2.1.0` means a tester's diagnostics are indistinguishable from the eventual release -- and `v2.1.0` is the tag the real release needs. Both declarations move together, and `uv.lock` records the project's own version so it moves with them. There is no fourth: the component has no `__version__` or `VERSION` constant, and nothing in the suite asserts this number (the `2.1.0` strings in the test factories are panel firmware `sw_version`, unrelated). Unlike the library, this repository has no release workflow that checks a tag against the committed version, so the agreement is maintained by hand. --- custom_components/span_panel/manifest.json | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index c7f04a54..06cce2f4 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -26,7 +26,7 @@ "span-panel-api-schema-0==1.0.0b5", "span-panel-api-schema-1==0.1.0b6" ], - "version": "2.1.0", + "version": "2.1.0b1", "zeroconf": [ { "type": "_span._tcp.local." diff --git a/pyproject.toml b/pyproject.toml index 6e49956e..6c326002 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span" -version = "2.1.0" +version = "2.1.0b1" description = "Span Panel Custom Integration for Home Assistant" authors = [{name = "SpanPanel"}] license = {text = "MIT"} diff --git a/uv.lock b/uv.lock index 152ce486..0249d4e9 100644 --- a/uv.lock +++ b/uv.lock @@ -2491,7 +2491,7 @@ wheels = [ [[package]] name = "span" -version = "2.1.0" +version = "2.1.0b1" source = { virtual = "." } dependencies = [ { name = "homeassistant" }, From 8b3d1e6b7748ce60984f9c10689322c510fc0d79 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:01:50 -0700 Subject: [PATCH 083/345] chore: 2.1.0b2, because b1 was cut from the wrong branch `v2.1.0b1` exists on the remote and points at `94d55ae`, the head of `feat/schema-1-adapter`. That branch is an ancestor of this one -- zero commits this branch does not have, twenty behind it -- and its manifest pins `span-panel-api==3.0.0b5`, `schema-0==1.0.0b4` and `schema-1==0.1.0b5`, none of which were ever published. So HACS offered the tag, Home Assistant tried to install three versions that do not exist, and setup never ran. A tag that has been published to users is not worth reusing even when it points somewhere wrong: anyone who already pulled it has that content cached under that name. Burn the number and move on. `2.1.0b2` sorts above `2.1.0b1` under AwesomeVersion, which is what HACS compares, so an install sitting on the bad tag will be offered this as an upgrade rather than having to be removed first. --- custom_components/span_panel/manifest.json | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 06cce2f4..c5902420 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -26,7 +26,7 @@ "span-panel-api-schema-0==1.0.0b5", "span-panel-api-schema-1==0.1.0b6" ], - "version": "2.1.0b1", + "version": "2.1.0b2", "zeroconf": [ { "type": "_span._tcp.local." diff --git a/pyproject.toml b/pyproject.toml index 6c326002..3e682d96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span" -version = "2.1.0b1" +version = "2.1.0b2" description = "Span Panel Custom Integration for Home Assistant" authors = [{name = "SpanPanel"}] license = {text = "MIT"} diff --git a/uv.lock b/uv.lock index 0249d4e9..faa716bd 100644 --- a/uv.lock +++ b/uv.lock @@ -2491,7 +2491,7 @@ wheels = [ [[package]] name = "span" -version = "2.1.0b1" +version = "2.1.0b2" source = { virtual = "." } dependencies = [ { name = "homeassistant" }, From b294509c02aeb85051a6b8d23453014c89083415 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 21 Aug 2026 02:06:37 -0700 Subject: [PATCH 084/345] feat(diagnostics): report the entity registry, because the UI will not An upgrade complaint is settled in the entity registry and none of it is reachable from the UI. Home Assistant shows "This entity is disabled" without saying by what, and `.storage/core.entity_registry` needs shell access that a Home Assistant OS user does not have. Four causes look identical on screen -- the registration default applied to something the registry considers new, a user action, a disabled config entry, a disabled device -- and they need four different fixes. `disabled_by` names which one it was. `unique_id` answers the question underneath, because an entity whose id changed is a new entity however familiar its name, and that is the difference between an upgrade defect and a surprise. `hidden_by` and `device_id` come along for the same reason: both are registry state an upgrade can move, and neither is legible from a screenshot. Safe by construction rather than by redaction, which is the rule this payload is already built to: every field is registry bookkeeping, and the serial embedded in `unique_id` is already carried by `panel.serial_number`. Written after spending an afternoon guessing between those four causes from cropped screenshots and getting it wrong twice. --- custom_components/span_panel/diagnostics.py | 51 +++++++++++++++++++++ tests/test_diagnostics.py | 41 +++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/custom_components/span_panel/diagnostics.py b/custom_components/span_panel/diagnostics.py index b033ba5d..51057836 100644 --- a/custom_components/span_panel/diagnostics.py +++ b/custom_components/span_panel/diagnostics.py @@ -7,6 +7,7 @@ from homeassistant.components.diagnostics import 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 from . import SpanPanelConfigEntry @@ -35,6 +36,55 @@ """ +class EntityRow(TypedDict): + """One registry entry, as it appears in the payload.""" + + entity_id: str + unique_id: str + disabled_by: str | None + hidden_by: str | None + device_id: str | None + + +def _entity_registry_rows(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> list[EntityRow]: + """Every registry entry this config entry owns, with the fields that explain it. + + The registry is where an upgrade complaint is actually settled, and none of it + is reachable from the UI: Home Assistant shows "This entity is disabled" and + not *by what*, and a user without shell access to `.storage` has no way to + read it. Diagnosing a sensor that came back disabled after an upgrade meant + guessing between four causes that look identical on screen. + + `disabled_by` names which of them it was -- `integration` is the registration + default having been applied, which only happens to an entity the registry + considers new; `user`, `config_entry` and `device` are three different things + and three different fixes. `unique_id` answers the question underneath it, + because an entity whose id changed is a new entity no matter how familiar its + name looks, and that is the one answer that would make an upgrade a defect + rather than a surprise. + + Safe by construction rather than by redaction, which is the rule this payload + is built to: every field is registry bookkeeping. `unique_id` embeds the panel + serial, which `panel.serial_number` already carries. + """ + registry = er.async_get(hass) + return sorted( + ( + EntityRow( + entity_id=registry_entry.entity_id, + unique_id=registry_entry.unique_id, + disabled_by=registry_entry.disabled_by.value + if registry_entry.disabled_by + else None, + hidden_by=registry_entry.hidden_by.value if registry_entry.hidden_by else None, + device_id=registry_entry.device_id, + ) + for registry_entry in er.async_entries_for_config_entry(registry, entry.entry_id) + ), + key=lambda row: row["entity_id"], + ) + + class DiscoveredRow(TypedDict): """One declared-but-unread property, as it appears in the payload.""" @@ -248,6 +298,7 @@ async def async_get_config_entry_diagnostics( return { "config_entry": async_redact_data(entry.as_dict(), TO_REDACT), + "entities": _entity_registry_rows(hass, entry), "panel": panel_data, "circuits": circuit_data, "evse": evse_data, diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index f036a610..cda1abc1 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -18,6 +18,7 @@ ) from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .factories import ( SpanBatterySnapshotFactory, @@ -198,3 +199,43 @@ async def test_config_entry_diagnostics_omits_optional_sections_when_unavailable "panel_offline": True, "last_update_success": False, } + + +async def test_diagnostics_reports_the_entity_registry(hass: HomeAssistant) -> None: + """The registry is where an upgrade complaint is settled, and the UI hides it. + + Home Assistant says "This entity is disabled" without saying by what, and a + user without shell access to `.storage` cannot read `disabled_by` at all. Four + causes look identical on screen and need four different fixes, so the field + that distinguishes them has to leave the machine somehow. + + `unique_id` rides along because it answers the question underneath: an entity + whose id changed is a new entity however familiar its name, and that is the + difference between an upgrade defect and a surprise. + """ + entry = MockConfigEntry(domain=DOMAIN, data={}, title="SPAN Panel") + entry.add_to_hass(hass) + registry = er.async_get(hass) + registry.async_get_or_create( + "sensor", + DOMAIN, + "span_sp3_diag_003_l1_voltage", + config_entry=entry, + suggested_object_id="span_panel_l1_voltage", + disabled_by=er.RegistryEntryDisabler.INTEGRATION, + ) + + coordinator = MagicMock() + coordinator.data = SpanPanelSnapshotFactory.create(serial_number="sp3-diag-003") + coordinator.panel_offline = False + coordinator.last_update_success = True + coordinator.schema_findings = None + entry.runtime_data = SimpleNamespace(coordinator=coordinator) + + result = await async_get_config_entry_diagnostics(hass, entry) + + rows = {row["entity_id"]: row for row in result["entities"]} + assert "sensor.span_panel_l1_voltage" in rows + row = rows["sensor.span_panel_l1_voltage"] + assert row["disabled_by"] == "integration" + assert row["unique_id"] == "span_sp3_diag_003_l1_voltage" From faf7e49a88a59e9a1f51390bd5052975406f9c64 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 21 Aug 2026 02:43:00 -0700 Subject: [PATCH 085/345] docs(changelog): one 2.1.0b2 section, and the PV area move it was missing `[Unreleased]` and `[2.1.0]` described the same unreleased release. 2.1.0 was never cut, so they are merged under the version actually being published rather than left as two lists a reader has to combine. **The PV entry was wrong in a way worth naming.** It said the five relocated entities keep their ids "so dashboards, automations and history follow them across untouched. Only the card they appear on changes." The ids do survive and that half was checked three ways. The area does not: an entity takes its area from its device, and the new solar device starts with none, so on an upgraded install these leave whatever area the panel is in and land in no area at all. Area-scoped dashboards, automations and voice targeting stop matching them silently. "Only the card changes" is exactly the sentence that would send someone looking in the wrong place, so it now says what to do instead: assign the Solar device an area. Added the two things this release surfaces that had no entry: the `at_service_entrance` attribute on Grid Power, and the entity registry in diagnostics. --- CHANGELOG.md | 85 +++++++++++++++++++++++++++------------------------- 1 file changed, 44 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a0f127c..f951d373 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,44 @@ All notable changes to this project will be documented in this file. -## [Unreleased] +## [2.1.0b2] - 8/2026 + +Support for the eBus v1.0 (parent/child) data model your panel moves to on firmware r202633 and later, and a clean transition when it does. + +### Requires Home Assistant 2026.8.0 or newer + +This release raises the minimum from 2026.5.4. Home Assistant 2026.8 replaced the two device-registry calls this integration relies on — the old forms stop +working entirely in 2027.8 — and their replacements do not exist in 2026.5 through 2026.7, so there is no version of this release that runs on both. If you are +on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not offer you this release. ### Added +- **Your panel's Microgrid Interconnect Device appears as its own device** on the new data model, carrying **Grid State** — the health of the utility supply + itself, which the previous firmware never reported. Everything about it is additive; no existing entity moves or changes id. +- **The integration notices a firmware upgrade and reloads itself.** A panel that becomes v1.0 while Home Assistant is running used to keep reading the tree + with the old parser, reporting every circuit as missing until you reloaded by hand. It now detects the change, reloads, writes a log line, and raises a + one-time notice explaining what changed. +- **Grid-forming device name** as an attribute on the GFE sensor. +- **Grid Power now says whether it is really measuring the grid**, through a new `at_service_entrance` attribute. That sensor reads your panel's upstream lugs, + which is grid flow only when those lugs are where the utility actually connects. Put a battery between the utility and your main lugs, or feed the panel from + another panel, and the same reading becomes that panel's own supply while **Grid Power Flow** stays the whole-site figure — so the two legitimately disagree, + and until now there was no way to tell that apart from a fault. The eBus specification was corrected on 2026-08-20 to say exactly this, after this project + supplied the capture that prompted it. Both readings were always correct; only the label was ever conditional. +- **Diagnostics now include your entity registry.** Every entity this integration owns, with its unique id and — the part you cannot get anywhere else — what + disabled it, if anything. Home Assistant tells you an entity is disabled without telling you by what, and reading that yourself needs shell access to + `.storage` that a Home Assistant OS install does not give you. Four causes look identical on screen and need four different answers. + ### Changed +- **`DSM Grid State` is now more trustworthy on the new data model.** It keeps its entity id and all of its history. Previously it was _inferred_ — from the + battery if one was fitted, otherwise from the dominant power source and whether power was crossing the grid connection. It now reads the islanding state the + Microgrid Interconnect Device actually senses. +- **`Grid Islandable` keeps working** across the upgrade. v1.0 publishes no panel-level islandable property, so the entity now reflects whether a Microgrid + Interconnect Device is present, which is how v1.0 says backup capability is detected. +- **Battery model** may read differently after upgrading: the new data model separates the human-readable designation from the SKU, and this entity now shows + the designation. This is a library-level normalisation applied to both data models, so it happens once, at this release, rather than unpredictably during a + firmware update. + - **Five panel sensors are switched off for new installations, because the eBus specification's own maintainer has documented that their values cannot be relied on.** A conformance note for SPAN firmware r202633 identifies three defects in what the panel publishes, all of which predate that release: the feedthrough (downstream lugs) energy registers are computed from two unrelated counters and can decrease or go negative — on a panel with no feedthrough load they report @@ -84,8 +116,11 @@ All notable changes to this project will be documented in this file. enclosure were an Enphase inverter. It now has a card like the battery and the chargers already do, carrying the firmware version the panel has been publishing all along. - **If you already have these sensors, nothing about them changes.** The five entities that move to the new card — PV Power, PV Vendor, PV Product, PV Nameplate - Capacity and PV Panel Link — keep the entity ids and unique ids they have today, so dashboards, automations and history follow them across untouched. Only the - card they appear on changes. + Capacity and PV Panel Link — keep the entity ids and unique ids they have today, so dashboards, automations and history follow them across untouched. +- **They do lose the panel's area, though, and nothing warns you.** An entity takes its area from the device it sits on unless you set one, and the new solar + device starts with no area. So if your panel is assigned to an area, these entities were in it yesterday and are in no area today — which quietly stops them + matching area-scoped dashboards, area-scoped automations and scripts, and voice commands that target a room. Assign the new **Solar** device to an area and + they behave as before. Worth doing before you go looking for what broke. - **New installations get different entity ids for these five, and that is intended.** Home Assistant derives a new entity's id from the name of the device it sits on, so a system installed from now on gets `sensor.span_panel_solar_pv_vendor` where a system installed before this release keeps `sensor.span_panel_pv_vendor`. Both are correct and neither will change again: an existing system must never have an id renamed under it, and a new one gets @@ -165,6 +200,12 @@ All notable changes to this project will be documented in this file. ### Fixed +- **Enum sensors advertise the states they can actually report.** Nine sensors declared only `unknown`, so `DSM Grid State` sitting at `On Grid` showed + "Possible states: Unknown". The lists are now derived from the translations and checked against them by a test. +- **The GFE override button reads the right signal** for deciding when it applies, so it is no longer permanently enabled on the new data model. +- **Sub-devices link to the panel by registry id**, replacing a form Home Assistant deprecated in 2026.8. No user-visible effect; required for the version bump + above. + - **The Wi-Fi network name came back.** Panels on the older data model report the SSID they are joined to, and this integration has shown it as an attribute on the panel status sensor for as long as it has existed. On the v1.0 data model nothing read it, so the attribute quietly emptied when your panel upgraded — a value you had, silently gone, with no error and nothing in the log. It is read again, and it is now published on the Wi-Fi Link binary sensor rather than on @@ -177,44 +218,6 @@ All notable changes to this project will be documented in this file. - **The README described Battery Power's sign backwards** (`+discharge, -charge`). The sensor has always reported charging as positive; only the documentation was wrong. No entity changed. -## [2.1.0] - 8/2026 - -Support for the eBus v1.0 (parent/child) data model your panel moves to on firmware r202633 and later, and a clean transition when it does. - -### Requires Home Assistant 2026.8.0 or newer - -This release raises the minimum from 2026.5.4. Home Assistant 2026.8 replaced the two device-registry calls this integration relies on — the old forms stop -working entirely in 2027.8 — and their replacements do not exist in 2026.5 through 2026.7, so there is no version of this release that runs on both. If you are -on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not offer you this release. - -### Added - -- **Your panel's Microgrid Interconnect Device appears as its own device** on the new data model, carrying **Grid State** — the health of the utility supply - itself, which the previous firmware never reported. Everything about it is additive; no existing entity moves or changes id. -- **The integration notices a firmware upgrade and reloads itself.** A panel that becomes v1.0 while Home Assistant is running used to keep reading the tree - with the old parser, reporting every circuit as missing until you reloaded by hand. It now detects the change, reloads, writes a log line, and raises a - one-time notice explaining what changed. -- **Grid-forming device name** as an attribute on the GFE sensor. - -### Changed - -- **`DSM Grid State` is now more trustworthy on the new data model.** It keeps its entity id and all of its history. Previously it was _inferred_ — from the - battery if one was fitted, otherwise from the dominant power source and whether power was crossing the grid connection. It now reads the islanding state the - Microgrid Interconnect Device actually senses. -- **`Grid Islandable` keeps working** across the upgrade. v1.0 publishes no panel-level islandable property, so the entity now reflects whether a Microgrid - Interconnect Device is present, which is how v1.0 says backup capability is detected. -- **Battery model** may read differently after upgrading: the new data model separates the human-readable designation from the SKU, and this entity now shows - the designation. This is a library-level normalisation applied to both data models, so it happens once, at this release, rather than unpredictably during a - firmware update. - -### Fixed - -- **Enum sensors advertise the states they can actually report.** Nine sensors declared only `unknown`, so `DSM Grid State` sitting at `On Grid` showed - "Possible states: Unknown". The lists are now derived from the translations and checked against them by a test. -- **The GFE override button reads the right signal** for deciding when it applies, so it is no longer permanently enabled on the new data model. -- **Sub-devices link to the panel by registry id**, replacing a form Home Assistant deprecated in 2026.8. No user-visible effect; required for the version bump - above. - ## [2.0.8] - 5/2026 ### Fixed From 7da9603fd3665b61641c37cf968c0ae0f4640180 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 21 Aug 2026 02:54:11 -0700 Subject: [PATCH 086/345] docs: warn before the firmware lands, and correct the battery sign in both docs **The README's headline is now the consequence, not the mechanism.** Firmware `r202633` breaks every release up to 2.0.8, and the failure is quiet: the integration stays connected, stays loaded, and reports every circuit as missing. Nobody outside SPAN knows when a given panel updates and it cannot be deferred from Home Assistant, so the only safe position is to be on 2.1.x already. Upgrading early costs nothing -- on current firmware 2.1.x reads the panel exactly as 2.0.8 does -- and the changeover is then seamless, with entity ids, unique ids and statistics all surviving. What actually changes in the wire format is still recorded, in a collapsed section, because it explains the failure rather than motivating the action. **The battery sign was documented backwards in both user-facing documents, and this file had "corrected" it in the wrong direction.** Release 2.0.5 established discharge-positive (#184); a later entry claimed the sensor had always been charge-positive and the README was edited to match, so both told the reader to expect the wrong sign. The code never moved: `bess_meter_power` returns `battery.power_w` and `battery_power` negates `power_flow_battery`, and a measured panel -- battery in self-consumption, grid at exactly zero, PV 4181 W plus battery 1917 W meeting a 6099 W load -- reads `+1917.49` on both while discharging. No entity changed and no reading moved; only the documentation was ever wrong, in both directions. **The changelog is user-facing only now.** `[Unreleased]` and `[2.1.0]` described the same unreleased release and are one section. Nine bullets that documented how something is built rather than what it does are gone: proxy reporting in diagnostics, the announcement record's restart behaviour, `info`/`connection` handling, adopted-identity freezing, notice counting, adoption diagnostics, the write-authorisation argument, and the device-registry link form. **README sensor documentation caught up with the code.** PV metadata moved to the Solar sub-device and loses the panel's area, which nothing warned about; Grid Power gained `at_service_entrance` and the reason it needs one; the five unreliable sensors are marked off-by-default with the conformance note behind it; adopted devices are documented for the first time. --- CHANGELOG.md | 53 +++++++++++++----------------- README.md | 93 ++++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 108 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f951d373..862b05e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,19 @@ All notable changes to this project will be documented in this file. ## [2.1.0b2] - 8/2026 -Support for the eBus v1.0 (parent/child) data model your panel moves to on firmware r202633 and later, and a clean transition when it does. +### You will need this release when SPAN updates your panel + +SPAN firmware `r202633` replaces the way the panel publishes its data — the flat model every release up to 2.0.8 reads is retired in the same update that +introduces the new one. There is no overlap and no setting to keep the old behaviour. **2.0.8 cannot read a panel on `r202633`**: it stays connected, reports +every circuit as missing, and shows nothing useful. + +We do not control when that update reaches you, and nobody has published a schedule. Panels update on SPAN's timing, not on yours or ours. So the safe order is +to be on this release **before** your panel changes rather than after, because afterwards you are looking at a blank integration while you work out why. + +**The transition itself is seamless, and that is the point of this release.** Install it and it keeps reading your panel exactly as before, on either firmware. +When your panel does change over, the integration notices on the wire, reloads itself, and carries on — no reconfiguration, no re-pairing, no lost history. Your +entities keep their entity ids, their unique ids and their statistics across the change. New things appear because the new firmware genuinely publishes more; +nothing you already had goes away. ### Requires Home Assistant 2026.8.0 or newer @@ -58,22 +70,12 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of - **Your other panel readings are unaffected.** The upstream lugs, the main panel meter and every circuit are in the correct frame. So is the power-flow group, which the specification has now been corrected to describe the way the panel has always published it. -- **Diagnostics now say whether an adopted device is proxied by another device rather than by the panel.** Reported as a yes/no, never as the parent's - identifier: a device identifier can contain a serial number, and diagnostics deliberately carry no serials. - - **New entities are now announced in a notification that names them — whether or not they arrived switched on.** Previously only entities added _disabled_ were mentioned, on the reasoning that an enabled one is already visible in your entity list and its history. That is only true if you are watching your entity list, which nobody is: an addition that breaks nothing was indistinguishable from no addition at all. The notification names every entity that was added, splits them by whether they are ready to use or still switched off, and tells you where to turn the switched-off ones on. - **It is a notification rather than a Repair, because an addition is not a repair.** Nothing is broken and nothing needs fixing. Any new-entity item still sitting in your Repairs list from a previous version is removed on upgrade. -- **And it can no longer be missed by a restart landing in the wrong place.** The old notice compared the entity registry before and after startup, which - answers correctly exactly once — on the next startup the entity is already registered and the comparison is empty. What has been announced is now recorded, so - the question is whether you have been told rather than whether it happened in the last few seconds. It stays quiet on a first install, and quiet once more on - the first startup after this change, so upgrading does not announce a release's worth of history. -- **An adopted device now appears even when it publishes no readings at all.** A device that advertises what it is — its vendor, model and firmware — before - publishing any measurement resolves entirely to its device card, so it created no entities, and because devices were created as a side effect of entity - creation it produced nothing whatsoever: no device, no entity, no notification. It is now registered in its own right. - **A device the panel publishes that this integration has never modelled now appears, instead of appearing nowhere.** SPAN positions the panel as the hub for whatever plugs into it and the eBus schema is explicitly vendor-extensible, so a device type nobody modelled is an expected arrival — and until now it @@ -89,24 +91,10 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of `TOTAL_INCREASING`, same unit and same device class — and a wrong one writes corrupt statistics that fixing the panel afterwards does not repair. Enrolling a property nobody asked for into long-term statistics is also a permanent write to your recorder database. If you want statistics from an adopted reading, wrap it in a template sensor, a Riemann-sum integration or a utility meter: that is your call, made on an entity you chose to enable. -- **`info` becomes the device card and `connection` becomes the device link — neither becomes entities.** A panel publishing its own build metadata and its own - wiring topology should not arrive as a handful of sensors holding version strings and opaque device ids. The split is keyed on the Homie node rather than on - property names, because the capability catalogs carry no marker for "this value is a device reference" and a hard-coded name list goes stale silently. -- **An adopted device keeps the identity it was first seen under.** A serial number arriving after the device was adopted is recorded on its card and moves - nothing, and a device adopted under its serial survives its wire id changing. Either move would read to Home Assistant as a device _replacement_ and would - take the device's entities and their history with it. -- **The new-entity notice counts an adopted device rather than listing its entities.** A vendor device declaring a dozen properties would otherwise spend the - whole notice on itself and teach you that the category is noise — which would cost you the curated additions too. -- **Diagnostics report which device types and properties were adopted, and never their values.** The same rule the declared-but-unread report follows: a - diagnostics attachment leaves the house, and the redaction that protects your config entry is key-based and knows nothing about wire property names. - **A property an adopted device accepts writes to becomes a control, not just a reading.** A declared `boolean` becomes a switch, an `enum` with its option list becomes a select, and a number with its `min:max:step` becomes a number entity — all disabled and diagnostic like every other adopted entity, so a control appears only if you go and enable it. A settable property that declares no value domain stays a reading: a select with no options and a number with no bounds are broken controls, not safe ones. -- **The write cannot reach a device this integration does model, by construction.** It is authorised by looking the property up in the current snapshot rather - than by its arguments, and a modelled device produces no adopted record to find. That matters because the curated controls do real work on the way out — the - islanding assertion translates its value, and the EV charge limit refuses one above what your charger was commissioned for — and a generic write would route - around both. - **Your panel stays the authority on the value.** Nothing is translated or clamped on the way out: this integration knows an adopted property's declaration and nothing else, so inventing a bound would be inventing a fact about your hardware. The control constrains you to what the device declared, and the panel accepts or refuses. @@ -161,8 +149,11 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of discharging at, as distinct from the panel's **Battery Power**, which is the enclosure's arbitrated figure. Both have been on the wire since firmware r202633 and nothing read either. Meter Power is enabled by default; **Communication State** — the BESS's own `OK` / `DEGRADED` / `LOST` / `UNKNOWN` report on its link — is a diagnostic and is off by default, since it is only interesting when something is wrong. -- **Both battery power sensors read positive when the battery is charging.** They come off the wire in opposite sign conventions and are normalised to one, so - the two sitting side by side on the BESS device can never point opposite ways. +- **Both battery power sensors read positive when the battery is _discharging_**, and that direction was settled by measurement rather than by reading. With the + battery driven into self-consumption and the grid at exactly zero — PV 4181 W plus battery 1917 W meeting a 6099 W load, so the battery can only be supplying + — both sensors read `+1917.49`. The two values arrive from the panel in opposite sign conventions and are normalised to this one, so they agree with each + other and with the sensors beside them: PV Power is positive while producing, Grid Power Flow positive while importing, Battery Power positive while + discharging. Every one is "positive means power flowing toward the house". - **Communication State is not the same thing as BESS Connected.** The binary sensor is the _panel's_ view of the link, from the enclosure's connection record; the new sensor is the _battery's_ view of it. A BESS can report its own link lost while the panel still claims it, and now you can see that. - Both sensors are created only where the BESS publishes the reading behind them — a battery on the older data model, or one whose firmware publishes only one @@ -203,8 +194,6 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of - **Enum sensors advertise the states they can actually report.** Nine sensors declared only `unknown`, so `DSM Grid State` sitting at `On Grid` showed "Possible states: Unknown". The lists are now derived from the translations and checked against them by a test. - **The GFE override button reads the right signal** for deciding when it applies, so it is no longer permanently enabled on the new data model. -- **Sub-devices link to the panel by registry id**, replacing a form Home Assistant deprecated in 2026.8. No user-visible effect; required for the version bump - above. - **The Wi-Fi network name came back.** Panels on the older data model report the SSID they are joined to, and this integration has shown it as an attribute on the panel status sensor for as long as it has existed. On the v1.0 data model nothing read it, so the attribute quietly emptied when your panel upgraded — a @@ -215,8 +204,10 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of Home Assistant was running published the data, matched every rule for creating the entities, and asked for no reload — so the new entities appeared only the next time you restarted. This affected the Microgrid Interconnect Device before this release too. -- **The README described Battery Power's sign backwards** (`+discharge, -charge`). The sensor has always reported charging as positive; only the documentation - was wrong. No entity changed. +- **The README described Battery Power's sign backwards, and an earlier entry in this file "corrected" it the wrong way.** The sensor reports **discharging** as + positive and always has — that is what release 2.0.5 established (#184) and what a measured panel confirms. A later note claimed the opposite and the README + was edited to match it, so both documents told you to expect the wrong sign. Both are now right. **No entity changed and no reading moved**; only the + documentation was ever wrong, in both directions. ## [2.0.8] - 5/2026 diff --git a/README.md b/README.md index 0029b23a..10bf9df7 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,40 @@ This integration communicates with the SPAN Panel over your local network using infrastructure. eBus uses the [Homie Convention](https://homieiot.github.io/) for MQTT topics and messages, with the panel's built-in MQTT broker delivering real-time state updates without polling. +## ⚠️ Upgrade to 2.1.x before your panel's firmware updates, or the integration will stop working + +**SPAN firmware `r202633` breaks every integration release up to and including 2.0.8.** When your panel takes that update, 2.0.8 stops being able to read it. +The integration still connects, still shows as loaded, and reports every circuit as missing — sensors go unavailable, automations stop firing, dashboards go +blank. It does not fail loudly. It goes quiet. + +**Nobody outside SPAN knows when your panel will update, and you cannot defer it from Home Assistant.** Panels update on SPAN's timing. There is no schedule to +plan around, which is why the safe move is to be on 2.1.x already rather than to wait for a signal that never comes. + +**Upgrading first costs you nothing.** On your current firmware, 2.1.x reads your panel exactly as 2.0.8 does — same entities, same ids, same history. It simply +also knows how to read the new format when it arrives. + +**And then the changeover is seamless.** The integration detects the new format on the wire, reloads itself, and carries on: + +- No reconfiguration, no re-pairing, no re-authentication. +- Entity ids, unique ids and long-term statistics all survive. Dashboards, automations and history follow. +- New entities appear because the new firmware genuinely publishes more. Nothing you already had is removed. + +Upgrade afterwards instead and you reach the same place — after however long it takes you to notice, and to work out that a firmware update you were never told +about is the reason your panel went silent. + +
+What actually changes in the firmware + +`r202633` rewrites how the panel publishes everything, rather than adding to it. The MQTT topic structure changes, and the panel stops presenting itself as a +single flat device with a long list of properties. It presents a tree instead: the enclosure, with its lugs, circuits, battery, chargers and Microgrid +Interconnect Device as separate devices beneath it. Every topic an integration reads moves. The old format is retired in the same update that introduces the new +one — there is no overlap and no setting to keep the old behaviour. + +This has happened before, in exactly this shape. Firmware `spanos2/r202603/05` removed the v1 REST API this integration was originally built on, which is what +the 2.0.x breaking-change notice below is about. `r202633` does the same thing to the flat MQTT format that replaced it. + +
+ ## 1.1.x Integration Sunset (v1) Users MUST upgrade by the end of 2026 to avoid disruption. Upgrade to the latest 1.1.x version BEFORE upgrading to 2.0.x. @@ -145,9 +179,20 @@ The following terms appear throughout this document and in the integration's sen | L2 Voltage | Voltage | V | L2 leg actual voltage | | Upstream L1 Current | Current | A | Upstream lugs L1 current | | Upstream L2 Current | Current | A | Upstream lugs L2 current | -| Downstream L1 Current | Current | A | Downstream lugs L1 current | -| Downstream L2 Current | Current | A | Downstream lugs L2 current | -| Main Breaker Rating | Current | A | Main breaker amperage | +| Downstream L1 Current | Current | A | Downstream lugs L1 current. Off by default from 2.1.x — see below | +| Downstream L2 Current | Current | A | Downstream lugs L2 current. Off by default from 2.1.x — see below | +| Main Breaker Rating | Current | A | Main breaker amperage. Off by default | + +L1/L2 Voltage and Main Breaker Rating have always been off by default; enable them from the panel's device page if you want them. + +**The two Downstream current sensors are off by default from 2.1.x, and so are the three Feedthrough sensors.** The eBus specification's maintainer has +documented that the panel's feedthrough (downstream lugs) figures cannot be relied on: the energy registers are computed from unrelated counters and can +decrease or go negative, the power reading is inverted relative to every other terminal, and the downstream currents report the **upstream** service conductors +rather than a downstream measurement. These defects predate `r202633` and are not introduced by it. + +If you already have any of those five they stay exactly where they are, with their history and their entity ids — Home Assistant applies the off-by-default +setting only when an entity is first created, so this reaches new installations only. If you use them on a dashboard or in an automation they are worth +removing, but that is your call, not something an upgrade should do to you. ### Shed Forecast Sensors (v1.0 data model only) @@ -198,16 +243,23 @@ On **Import Limit**, and present only when the panel publishes them. These are t | ------------- | ------------ | ---- | ------------------------------------------------------------------------------- | | Grid Power | Power | W | Grid power flow | | Site Power | Power | W | Total site power (grid + PV + battery) | -| Battery Power | Power | W | Battery charge/discharge (+charging, -discharging). Only when BESS commissioned | +| Battery Power | Power | W | Battery charge/discharge (**+discharging, −charging**). Only when BESS commissioned | | PV Power | Power | W | PV generation (+producing). Only when PV commissioned | -### PV Metadata Sensors (v2 only, on main panel device) +### PV Metadata Sensors (v2 only, on the Solar sub-device) + +From 2.1.x these live on a **Solar** device of their own rather than on the panel's card, alongside PV Power and PV Panel Link. | Sensor | Device Class | Unit | Notes | | ------------------ | ------------ | ---- | --------------------------------------------- | | PV Vendor | — | — | PV inverter vendor (e.g., "Enphase", "Other") | | PV Product | — | — | PV inverter product (e.g., "IQ8+") | -| Nameplate Capacity | Power | kW | Rated inverter capacity | +| Nameplate Capacity | Power | kW | Rated inverter capacity. Off by default | + +If you upgraded, these keep their entity ids, unique ids and history — only the card changes. **They do not keep the panel's area.** An entity takes its area +from its device, and the new Solar device starts without one, so anything area-scoped (dashboards, automations, voice targeting a room) stops matching them +until you assign the Solar device to an area. New installations get ids derived from the new device name, so a system installed from 2.1.x onward has +`sensor.span_panel_solar_pv_vendor` where an upgraded one keeps `sensor.span_panel_pv_vendor`. Both are correct and neither changes again. **Deprecated:** @@ -215,6 +267,23 @@ On **Import Limit**, and present only when the panel publishes them. These are t | -------------- | ------------------------------------------------------------------------------------------------------------------------- | | DSM Grid State | Deprecated — still available, but users should rely on `DSM State` as `DSM Grid State` may be removed in a future version | +### Adopted Devices (v1.0 data model only) + +The eBus schema is vendor-extensible, so your panel can publish a device type this integration has never modelled. Rather than ignoring it, the integration +gives it a card of its own hanging off the panel, carrying whatever identity it publishes, with its readings as entities beneath it. + +Everything adopted arrives **disabled and diagnostic**, so nothing reaches a dashboard uninvited, and the new-entity notification names the device so you can +find it. A property the device accepts writes to becomes a control rather than a reading — a `boolean` becomes a switch, an enumeration becomes a select, a +number becomes a number entity — and those arrive switched off too. + +Two things worth knowing before you build on one: + +- **Nothing adopted enters long-term statistics.** No adopted entity carries a `state_class`, because the correct one is not published on the wire and guessing + wrong writes corrupt statistics that fixing the panel afterwards does not repair. If you want statistics from an adopted reading, wrap it in a template + sensor, a Riemann-sum integration or a utility meter — a deliberate choice on an entity you enabled. +- **Devices this integration already models are never adopted.** A new property on a circuit, the battery, a charger or the panel is curated in a release + instead, because that is where the judgement lives about whether it should be an entity, an attribute or a line on a device card. + ### Power Sensor Attributes Applies to Current Power, Feed Through Power, Battery Power, PV Power, Grid Power, and Site Power sensors. @@ -224,6 +293,16 @@ Applies to Current Power, Feed Through Power, Battery Power, PV Power, Grid Powe | `voltage` | string | Nominal panel voltage ("240") | | `amperage` | string | Calculated current (power / voltage) | +**Grid Power** carries one more, because its name is only true in some wiring: + +| Attribute | Type | Notes | +| ---------------------- | ------- | --------------------------------------------------------------------------- | +| `at_service_entrance` | boolean | Whether this panel's upstream lugs are where the utility actually connects | + +Grid Power reads the upstream lugs. That is grid flow when those lugs are the utility connection point, which is the ordinary case. Put a battery between the +utility and your main lugs, or feed this panel from another panel, and the same reading becomes **this panel's** supply while **Grid Power Flow** stays the +whole-site figure — so the two legitimately disagree. When `at_service_entrance` is `false`, use Grid Power Flow for site-level grid import and export. + ### Software Version Sensor Attributes | Attribute | Type | Notes | @@ -309,7 +388,7 @@ device uses manufacturer, model, serial number, and software version from batter | ------------------- | -------------- | ---- | ------------------------------------------------------------------------------------------------ | | Battery Level | Battery | % | State of energy as percentage | | Battery Power | Power | W | Same entity as Power Flow Battery Power, shown on BESS sub-device | -| Meter Power | Power | W | The BESS's own meter (+charging, -discharging). v1.0 data model only | +| Meter Power | Power | W | The BESS's own meter (**+discharging, −charging**), agreeing with Battery Power. v1.0 data model only | | Communication State | — | — | The BESS's report of its own link health (diagnostic, disabled by default). v1.0 data model only | | BESS Vendor | — | — | Battery system vendor (diagnostic) | | BESS Model | — | — | Battery system model (diagnostic) | From 218285a682075d9cf0fcdcf5b192680fced76200 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:02:24 -0700 Subject: [PATCH 087/345] docs(readme): the entities the reference tables never caught up with Cross-checked every `name` in `strings.json` against the reference tables rather than reading for plausibility. Sixty-one entity names, seven absent, five of those label mismatches where the README says "BESS Firmware" for an entity displayed as "Firmware Version". Two were real. **The Microgrid Interconnect Device had no entity documentation at all** -- only a Key Terms definition of what a MID is. Its `Grid State` sensor is the one genuinely new reading on the v1.0 data model, since the previous firmware never reported the state of the utility supply, and it is easy to confuse with `DSM Grid State`, which is whether the *home* is islanded. The grid can be down while the house runs happily off the battery. Now documented together so the distinction is visible where someone would look for it. **The circuit select is named "Circuit Priority"**, not "Circuit Shed Priority" as the table said. A reader searching their entity list for the documented name found nothing. Also missing from the tables: EVSE Part Number entirely, and BESS Part Number. Both are off by default, as are BESS Nameplate Capacity and PV Nameplate Capacity, which the tables described without saying so -- the difference between "I do not have this sensor" and "I have not switched it on". --- README.md | 21 +++++++++++++++++++-- uv.lock | 4 ++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 10bf9df7..9051960b 100644 --- a/README.md +++ b/README.md @@ -267,6 +267,21 @@ until you assign the Solar device to an area. New installations get ids derived | -------------- | ------------------------------------------------------------------------------------------------------------------------- | | DSM Grid State | Deprecated — still available, but users should rely on `DSM State` as `DSM Grid State` may be removed in a future version | +### Microgrid Interconnect Device (v1.0 data model only) + +Panels on the v1.0 data model publish their Microgrid Interconnect Device — the switch that disconnects your home from the utility during an outage — as a +device of its own, linked to the panel. It appears automatically where the panel reports one; nothing existing moves onto it. + +| Sensor | Device Class | Unit | Notes | +| ---------- | ------------ | ---- | --------------------------------------------------------------------------- | +| Grid State | Enum | — | Health of the utility supply itself: `up`, `down`, `degraded` or `unknown` | + +This is genuinely new information — the previous firmware did not report the state of the utility supply at all. It is not the same thing as **DSM Grid State**, +which is whether **your home** is islanded. The grid can be down while your home runs happily off the battery. + +`DSM Grid State` also becomes more trustworthy on this data model. It keeps its entity id and its history, but where it used to be inferred — from the battery +when one was fitted, otherwise from the dominant power source — it now reads the islanding state the MID actually senses. + ### Adopted Devices (v1.0 data model only) The eBus schema is vendor-extensible, so your panel can publish a device type this integration has never modelled. Rather than ignoring it, the integration @@ -344,6 +359,7 @@ feature. A display suffix differentiates multiple chargers on the same panel: | Charger Status | Enum | — | OCPP-based states: AVAILABLE, PREPARING, CHARGING, SUSPENDED_EV, etc. Translated | | Advertised Current | Current | A | Amps offered to the vehicle | | Lock State | Enum | — | LOCKED, UNLOCKED, UNKNOWN. Translated | +| Part Number | — | — | Charger part number (diagnostic, **off by default**). v1.0 data model only | #### EVSE Binary Sensors (per charger) @@ -392,9 +408,10 @@ device uses manufacturer, model, serial number, and software version from batter | Communication State | — | — | The BESS's report of its own link health (diagnostic, disabled by default). v1.0 data model only | | BESS Vendor | — | — | Battery system vendor (diagnostic) | | BESS Model | — | — | Battery system model (diagnostic) | +| BESS Part Number | — | — | Battery system part number (diagnostic, **off by default**). v1.0 data model only | | BESS Serial Number | — | — | Battery system serial number (diagnostic) | | BESS Firmware | — | — | Battery system firmware (diagnostic) | -| Nameplate Capacity | Energy Storage | kWh | Rated battery capacity (diagnostic) | +| Nameplate Capacity | Energy Storage | kWh | Rated battery capacity (diagnostic, **off by default**) | | Stored Energy | Energy Storage | kWh | Current stored energy (diagnostic) | #### BESS Binary Sensors @@ -466,7 +483,7 @@ Applies to Main Meter and Feed Through energy sensors. | Entity | Type | Notes | | --------------------- | ------ | -------------------------------------------------------------------------- | | Breaker | Switch | On/off relay control | -| Circuit Shed Priority | Select | (v2) Controls when circuit is shed during off-grid (translated, see below) | +| Circuit Priority | Select | (v2) Controls when the circuit is shed during off-grid (translated, see below) | ### Panel Controls diff --git a/uv.lock b/uv.lock index faa716bd..d6d4dc0b 100644 --- a/uv.lock +++ b/uv.lock @@ -2601,7 +2601,7 @@ requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-ap [[package]] name = "span-panel-api-schema-1" -version = "0.1.0b6" +version = "0.1.0b7" source = { editable = "../../span/span-panel-api-p3/packages/schema-1" } dependencies = [ { name = "ebus-sdk" }, @@ -2610,7 +2610,7 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "ebus-sdk", specifier = ">=0.19.0,<0.23" }, + { name = "ebus-sdk", specifier = ">=0.19.0,<0.24" }, { name = "span-panel-api", editable = "../../span/span-panel-api-p3" }, ] From de1a3059504a43a2fdc35528dc8edce70151750a Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:02:41 -0700 Subject: [PATCH 088/345] chore: 2.1.0b3, on the library release that fixes the upgrade reload `v2.1.0.b2` was cut before the diagnostics and documentation work, so b3 carries four commits it does not have plus the library fix that matters most. Pins move to `span-panel-api==3.0.0b8` and `span-panel-api-schema-1==0.1.0b7`; schema-0 is unchanged at `1.0.0b5` because nothing in it moved. b8 is the release where a panel answering 502 during its reboot is understood as "not ready yet" rather than as a hard failure -- the path that cost a live upgrade its automatic reload on two Home Assistant instances, and the one thing this release exists to promise. The pin propagation was done by `scripts/sync-dependencies.py` rather than by hand, which is the first real use since it was repaired. It found and rewrote both copies and failed the commit so they would be re-staged, which is what it was always supposed to do and never once did. --- CHANGELOG.md | 28 ++++++++++++++++++++++ custom_components/span_panel/manifest.json | 6 ++--- pyproject.toml | 6 ++--- requirements_test.txt | 4 ++-- uv.lock | 4 ++-- 5 files changed, 38 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 862b05e7..e23006a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,34 @@ All notable changes to this project will be documented in this file. +## [2.1.0b3] - 8/2026 + +### Fixed + +- **The integration now reloads itself after the firmware upgrade in the case that actually happens.** This is the whole promise of the release, and a live + upgrade found the one path where it did not hold. When a panel takes the new firmware it drops its MQTT connection, comes back a few minutes later, and + starts serving HTTP a little after that — and while it is still starting, it answers with `502` rather than refusing the connection, because a booting device + brings its network front end up before the application behind it. The integration retried a refused connection and a timeout, but treated an answered-with-502 + as a hard failure and gave up on the first try, leaving the old reader in place. It was caught on two Home Assistant instances watching one panel through the + same upgrade: both went quiet and neither recovered until reloaded by hand. A `502` is now understood as **not ready yet** and waited out, for as long as a real + reboot takes. +- **If following the upgrade ever fails for some other reason, you are now told what to do about it.** Previously that surfaced as a bare error in the log with + no indication that anything needed doing, while the integration carried on reading the panel with the wrong reader. It now says plainly that a reload is + needed once the panel is back up. + +### Added + +- **Diagnostics include your entity registry.** Every entity this integration owns, with its unique id and — the part you cannot get anywhere else — what + disabled it, if anything. Home Assistant tells you an entity is disabled without telling you by what, and reading that yourself needs shell access that a + Home Assistant OS install does not give you. + +### Changed + +- **The README now leads with the upgrade warning**, and documents the Microgrid Interconnect Device, adopted devices, the `at_service_entrance` attribute on + Grid Power, and which sensors arrive switched off. +- **The battery power sign was documented backwards in both the README and this file, and is corrected.** Positive means **discharging**, which is what release + 2.0.5 established and what a measured panel confirms. No entity changed and no reading moved; only the documentation was wrong. + ## [2.1.0b2] - 8/2026 ### You will need this release when SPAN updates your panel diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index c5902420..23d4c60e 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -22,11 +22,11 @@ ], "quality_scale": "gold", "requirements": [ - "span-panel-api==3.0.0b7", + "span-panel-api==3.0.0b8", "span-panel-api-schema-0==1.0.0b5", - "span-panel-api-schema-1==0.1.0b6" + "span-panel-api-schema-1==0.1.0b7" ], - "version": "2.1.0b2", + "version": "2.1.0b3", "zeroconf": [ { "type": "_span._tcp.local." diff --git a/pyproject.toml b/pyproject.toml index 3e682d96..f8aad226 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span" -version = "2.1.0b2" +version = "2.1.0b3" 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.8.0", - "span-panel-api==3.0.0b7", + "span-panel-api==3.0.0b8", # 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.0.0b5", - "span-panel-api-schema-1==0.1.0b6", + "span-panel-api-schema-1==0.1.0b7", ] [dependency-groups] diff --git a/requirements_test.txt b/requirements_test.txt index c47eaa23..a3abaaa0 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -3,6 +3,6 @@ pytest-asyncio>=1.3.0 setuptools>=65.7.0 pytest-homeassistant-custom-component==0.13.354 homeassistant==2026.8.0 -span-panel-api==3.0.0b7 +span-panel-api==3.0.0b8 span-panel-api-schema-0==1.0.0b5 -span-panel-api-schema-1==0.1.0b6 +span-panel-api-schema-1==0.1.0b7 diff --git a/uv.lock b/uv.lock index d6d4dc0b..b6ef41dd 100644 --- a/uv.lock +++ b/uv.lock @@ -2491,7 +2491,7 @@ wheels = [ [[package]] name = "span" -version = "2.1.0b2" +version = "2.1.0b3" source = { virtual = "." } dependencies = [ { name = "homeassistant" }, @@ -2550,7 +2550,7 @@ dev = [ [[package]] name = "span-panel-api" -version = "3.0.0b7" +version = "3.0.0b8" source = { editable = "../../span/span-panel-api-p3" } dependencies = [ { name = "httpx" }, From cc8e2844f9e1f863d6622745378807b93342ac94 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:13:42 -0700 Subject: [PATCH 089/345] chore: pin span-panel-api 3.0.0b9 b8's 502 handling shipped without its retry window: the exception was caught and the loop still gave up after about twenty-three seconds, against a panel that took four minutes to come back. b9 carries the widening -- twelve attempts backing off to thirty seconds -- and a test that pins the total against the observed reboot. Still 2.1.0b3; the integration itself has not changed, only what it installs. --- custom_components/span_panel/manifest.json | 2 +- pyproject.toml | 2 +- requirements_test.txt | 2 +- uv.lock | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 23d4c60e..c3694efa 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -22,7 +22,7 @@ ], "quality_scale": "gold", "requirements": [ - "span-panel-api==3.0.0b8", + "span-panel-api==3.0.0b9", "span-panel-api-schema-0==1.0.0b5", "span-panel-api-schema-1==0.1.0b7" ], diff --git a/pyproject.toml b/pyproject.toml index f8aad226..c0b0b1d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ readme = "README.md" requires-python = ">=3.14.3,<3.15" dependencies = [ "homeassistant==2026.8.0", - "span-panel-api==3.0.0b8", + "span-panel-api==3.0.0b9", # 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, diff --git a/requirements_test.txt b/requirements_test.txt index a3abaaa0..43d3b88c 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -3,6 +3,6 @@ pytest-asyncio>=1.3.0 setuptools>=65.7.0 pytest-homeassistant-custom-component==0.13.354 homeassistant==2026.8.0 -span-panel-api==3.0.0b8 +span-panel-api==3.0.0b9 span-panel-api-schema-0==1.0.0b5 span-panel-api-schema-1==0.1.0b7 diff --git a/uv.lock b/uv.lock index b6ef41dd..8e577d5d 100644 --- a/uv.lock +++ b/uv.lock @@ -2550,7 +2550,7 @@ dev = [ [[package]] name = "span-panel-api" -version = "3.0.0b8" +version = "3.0.0b9" source = { editable = "../../span/span-panel-api-p3" } dependencies = [ { name = "httpx" }, From d15f36792e3f2e723bc5724eddb754e6a45ea2f3 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:49:27 -0700 Subject: [PATCH 090/345] chore: 2.1.0b4 on span-panel-api 3.0.0b10 b10 stops the wait for a rebooting panel from giving up. The bound was wrong twice for the same reason -- sized against a reboot somebody had measured, and the next reboot was not that reboot -- and exhausting it meant stranded rather than "try again later", because nothing starts the attempt again. It also recognises four more mid-boot answers as "still starting" rather than as failure: a connection reset mid-request, a gateway closing without replying, and a reply that arrives truncated. This release also catches that condition at setup. A panel that is not ready when Home Assistant starts now produces a retrying `ConfigEntryNotReady` instead of a dead entry needing a human -- which matters because the two events correlate: one power interruption takes out both the house's electrical panel and the machine watching it, and they race each other back up. Pins propagated by `scripts/sync-dependencies.py`, third consecutive release it has done correctly. --- CHANGELOG.md | 18 ++++++++++++ custom_components/span_panel/__init__.py | 17 +++++++++-- custom_components/span_panel/manifest.json | 4 +-- pyproject.toml | 4 +-- requirements_test.txt | 2 +- tests/test_setup_entry.py | 34 ++++++++++++++++++++++ uv.lock | 4 +-- 7 files changed, 74 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e23006a9..e94a9f07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to this project will be documented in this file. +## [2.1.0b4] - 8/2026 + +### Fixed + +- **A panel that is still booting no longer needs a manual reload, however long it takes.** b3 taught the integration to keep waiting when a rebooting panel + answers with an error instead of refusing outright, but it still gave up after a few minutes — and giving up was worse than it sounds, because nothing starts + it again. A panel that finished booting after that point stayed unread until you reloaded by hand. It now waits for as long as your panel takes, checking + every thirty seconds once it settles, so a slow reboot is just a slow reboot. +- **Your energy history is unaffected by that wait.** Energy sensors already hold their last reading through an outage for the grace period you configure + (fifteen minutes by default), which is what stops a gap becoming an `unknown` and a spike in your statistics. Waiting longer for the panel does not change + that. +- **Several other ways a booting panel answers are now recognised as "still starting" rather than as a fault** — a connection reset mid-request, a gateway that + closes without replying, and a reply that arrives truncated. Each of those previously stopped the integration following the upgrade, the same way the original + error did. +- **A panel that is not ready when Home Assistant starts now retries instead of failing setup.** Previously that left the integration in an error state needing + a human, which matters because the two events correlate: one power interruption takes out both your panel and the machine watching it, and they come back at + their own pace. + ## [2.1.0b3] - 8/2026 ### Fixed diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py index 3944c531..dedf71f2 100644 --- a/custom_components/span_panel/__init__.py +++ b/custom_components/span_panel/__init__.py @@ -23,6 +23,7 @@ from span_panel_api.exceptions import ( SpanPanelAuthError, SpanPanelConnectionError, + SpanPanelServerError, SpanPanelTimeoutError, ) from span_panel_api.mqtt.models import MqttClientConfig @@ -222,9 +223,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> except SpanPanelAuthError as err: await client.close() raise ConfigEntryAuthFailed(f"MQTT authentication failed: {err}") from err - except (SpanPanelConnectionError, SpanPanelTimeoutError) as err: + except ( + SpanPanelConnectionError, + SpanPanelTimeoutError, + # A panel part-way through a reboot answers rather than refusing: + # 5xx from the front end while the application behind it starts, + # or a 200 with nothing usable in it. That is not a broken + # install, it is a panel that is not up yet, and the two arrive + # together more often than they look -- one power event takes out + # the house's panel and the Home Assistant host that watches it, + # and they race each other back. Uncaught it produced a dead entry + # needing a human, for a condition that clears itself in minutes. + SpanPanelServerError, + ) as err: await client.close() - raise ConfigEntryNotReady(f"Failed to connect to SPAN panel: {err}") from err + raise ConfigEntryNotReady(f"SPAN panel is not ready yet: {err}") from err coordinator = SpanPanelCoordinator(hass, client, entry) await coordinator.async_config_entry_first_refresh() diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index c3694efa..dded056b 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -22,11 +22,11 @@ ], "quality_scale": "gold", "requirements": [ - "span-panel-api==3.0.0b9", + "span-panel-api==3.0.0b10", "span-panel-api-schema-0==1.0.0b5", "span-panel-api-schema-1==0.1.0b7" ], - "version": "2.1.0b3", + "version": "2.1.0b4", "zeroconf": [ { "type": "_span._tcp.local." diff --git a/pyproject.toml b/pyproject.toml index c0b0b1d7..e89c752f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span" -version = "2.1.0b3" +version = "2.1.0b4" description = "Span Panel Custom Integration for Home Assistant" authors = [{name = "SpanPanel"}] license = {text = "MIT"} @@ -8,7 +8,7 @@ readme = "README.md" requires-python = ">=3.14.3,<3.15" dependencies = [ "homeassistant==2026.8.0", - "span-panel-api==3.0.0b9", + "span-panel-api==3.0.0b10", # 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, diff --git a/requirements_test.txt b/requirements_test.txt index 43d3b88c..ab9cbb6f 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -3,6 +3,6 @@ pytest-asyncio>=1.3.0 setuptools>=65.7.0 pytest-homeassistant-custom-component==0.13.354 homeassistant==2026.8.0 -span-panel-api==3.0.0b9 +span-panel-api==3.0.0b10 span-panel-api-schema-0==1.0.0b5 span-panel-api-schema-1==0.1.0b7 diff --git a/tests/test_setup_entry.py b/tests/test_setup_entry.py index c49707e3..86c11752 100644 --- a/tests/test_setup_entry.py +++ b/tests/test_setup_entry.py @@ -11,6 +11,7 @@ ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant +from span_panel_api.exceptions import SpanPanelServerError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.httpx_client import get_async_client import pytest @@ -146,6 +147,39 @@ async def test_the_panel_client_is_given_home_assistants_shared_http_client( assert mock_client_cls.call_args.kwargs["httpx_client"] is get_async_client(hass) +async def test_a_panel_that_is_not_ready_yet_retries_rather_than_dying( + hass: HomeAssistant, +) -> None: + """A rebooting panel answers rather than refusing, and that is not a broken install. + + 5xx from its front end while the application behind it starts, or a 200 with + nothing usable in it, both arrive as `SpanPanelServerError`. Uncaught they + produced SETUP_ERROR with a traceback and no automatic retry — a human needed, + for a condition that clears itself in minutes. + + The two conditions correlate more than they look: one power event takes out + the house's electrical panel and the Home Assistant host watching it, and they + race each other back up. `ConfigEntryNotReady` is what makes the race + survivable. + """ + entry = _create_v2_entry() + entry.add_to_hass(hass) + client = MagicMock() + client.connect = AsyncMock( + side_effect=SpanPanelServerError("Panel not ready: HTTP 502", 502) + ) + client.close = AsyncMock() + + with ( + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + pytest.raises(ConfigEntryNotReady), + ): + await async_setup_entry(hass, entry) + + client.close.assert_awaited_once() + + async def test_async_setup_entry_v2_missing_mqtt_credentials_raises_auth_failed( hass: HomeAssistant, ) -> None: diff --git a/uv.lock b/uv.lock index 8e577d5d..a61ad09d 100644 --- a/uv.lock +++ b/uv.lock @@ -2491,7 +2491,7 @@ wheels = [ [[package]] name = "span" -version = "2.1.0b3" +version = "2.1.0b4" source = { virtual = "." } dependencies = [ { name = "homeassistant" }, @@ -2550,7 +2550,7 @@ dev = [ [[package]] name = "span-panel-api" -version = "3.0.0b9" +version = "3.0.0b10" source = { editable = "../../span/span-panel-api-p3" } dependencies = [ { name = "httpx" }, From 9b5c1a67b2a823fd3192238329774de4dda99b5e Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:10:47 -0700 Subject: [PATCH 091/345] fix(binary_sensor): create Grid Islandable when the answer is knowable Observed on a live upgrade: the entity read `Off` on flat and went `Unavailable` the moment the panel took r202633, on an install with no battery. Nothing about the site changed. This gate has now been narrowed twice for the same symptom. Gating on the flat property alone left the entity unavailable after a v1.0 upgrade -- the panel had not lost the capability, only the property that used to report it -- and its own comment says so. Adding `has_mid` rescued panels that have a battery and left every battery-less one exactly where it was, because it admitted the entity only when the answer was going to be `True`. For a boolean whose `False` is informative that is backwards. No MID is not missing information, it is the information: `devices/bess.md` makes the signal structural -- "a MID `grid` child means premises-segment backup ... neither means no backup", and "there is no single 'islanded?' bit to reconcile" -- so a panel without one does not island, and saying so is the answer rather than the absence of one. `_grid_islandable` has always computed exactly that; the gate refused to create the entity precisely when it would have said `False`. Flat is untouched, which is the constraint that matters until the fleet moves. A flat panel publishes the property, so the value comes from the panel as it always has and MID presence is never consulted. A flat panel that stops publishing it has a metadata row that fails to resolve, which is what makes the entity unavailable and raises the Repair -- that path is deliberately left alone, because it is the one that must not become a default presented as a reading. Two wrong diagnoses on the way here, both discarded after reading rather than shipped: the availability probe, which skips a field path with no metadata row at all, and the `SCHEMA_CONDITIONAL_FIELD` labelling on three descriptions, which is harmless for the same reason. It was the creation gate. Marked `DUAL-SCHEMA` so the retirement is mechanical: nothing here is removed when flat goes, since an unconditional append is already the end state -- the branch that goes is the flat property read inside `_grid_islandable`. Both mutations verified: withholding the v1.0 answer fails, and ignoring the flat property fails. --- custom_components/span_panel/binary_sensor.py | 31 ++++++++++++--- tests/test_binary_sensor_platform.py | 39 +++++++++++++++++++ uv.lock | 4 +- 3 files changed, 66 insertions(+), 8 deletions(-) diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py index f25ce6db..78c16461 100644 --- a/custom_components/span_panel/binary_sensor.py +++ b/custom_components/span_panel/binary_sensor.py @@ -582,13 +582,32 @@ async def async_setup_entry( for description in BINARY_SENSORS ] - # Add grid islandable binary sensor when v2 data is available snapshot: SpanPanelSnapshot = coordinator.data - # Created on either generation. Gating on the flat property alone is what left this - # entity `unavailable` after a v1.0 upgrade -- the panel had not lost the - # capability, only the property that used to report it. - if snapshot.grid_islandable is not None or has_mid(snapshot): - entities.append(SpanPanelBinarySensor(coordinator, GRID_ISLANDABLE_SENSOR)) + + # Created unconditionally, because on both generations the answer is knowable. + # + # This gate has been narrowed twice for the same symptom. Gating on the flat + # property alone left the entity `unavailable` after a v1.0 upgrade -- the + # panel had not lost the capability, only the property that used to report + # it. Adding `has_mid` rescued panels that have a battery and left every + # battery-less one exactly where it was, because it admitted the entity only + # when the answer was going to be `True`. For a boolean whose `False` is + # informative that is backwards: no MID is not missing information, it is the + # information. `devices/bess.md` makes the signal structural -- "a MID `grid` + # child means premises-segment backup ... neither means no backup" -- so a + # panel without one does not island, and saying so is the answer rather than + # the absence of one. + # + # Nothing about flat changes. Its property is published, so `_grid_islandable` + # returns it exactly as before; and a flat panel that stops publishing it has + # a metadata row that fails to resolve, which is what makes the entity + # unavailable and raises the Repair. That path is untouched, and it is the one + # that must not become a default presented as a reading. + # + # DUAL-SCHEMA: nothing to remove here when the flat path retires -- an + # unconditional append is already the end state. The branch that goes is the + # first one in `_grid_islandable`, which is where the flat property is read. + entities.append(SpanPanelBinarySensor(coordinator, GRID_ISLANDABLE_SENSOR)) # Add BESS connected sensor on the BESS sub-device when battery is commissioned if has_bess(snapshot): diff --git a/tests/test_binary_sensor_platform.py b/tests/test_binary_sensor_platform.py index 297160ef..60d57e97 100644 --- a/tests/test_binary_sensor_platform.py +++ b/tests/test_binary_sensor_platform.py @@ -102,6 +102,45 @@ def test_evse_binary_sensor_reports_unknown_when_panel_offline() -> None: assert entity.is_on is None +def test_grid_islandable_is_created_on_a_v1_panel_with_no_battery() -> None: + """No MID is the answer, not the absence of one. + + Observed on a live upgrade: this entity read `Off` on flat and went + `Unavailable` the moment the panel took v1.0, on an install with no battery. + Nothing about the site changed — v1.0 simply retired the property, and the + gate admitted the entity only when the answer was going to be `True`. + + `devices/bess.md` makes the signal structural: "a MID `grid` child means + premises-segment backup ... neither means no backup". So a panel without one + does not island, and `False` is a reading rather than a default. + """ + snapshot = SpanPanelSnapshotFactory.create(grid_islandable=None, mid=None) + coordinator = _make_coordinator(snapshot) + entity = SpanPanelBinarySensor(coordinator, GRID_ISLANDABLE_SENSOR) + entity.async_write_ha_state = MagicMock() + + entity._handle_coordinator_update() + + assert entity.is_on is False, "no MID means it cannot island, which is an answer" + assert entity.available is True, "and the entity must exist to give it" + + +def test_grid_islandable_reads_the_flat_property_when_the_panel_publishes_one() -> None: + """Flat is unchanged, which is the constraint that matters until the fleet moves. + + A flat panel publishes `core/grid-islandable`, so the value comes from the + panel exactly as it always has, and MID presence is never consulted. + """ + snapshot = SpanPanelSnapshotFactory.create(grid_islandable=True, mid=None) + coordinator = _make_coordinator(snapshot) + entity = SpanPanelBinarySensor(coordinator, GRID_ISLANDABLE_SENSOR) + entity.async_write_ha_state = MagicMock() + + entity._handle_coordinator_update() + + assert entity.is_on is True, "the published value wins over the structural fallback" + + def test_grid_islandable_sensor_uses_online_status_value() -> None: """Non-status binary sensors should mirror their live boolean value when online.""" snapshot = SpanPanelSnapshotFactory.create(grid_islandable=False) diff --git a/uv.lock b/uv.lock index a61ad09d..6389a52f 100644 --- a/uv.lock +++ b/uv.lock @@ -2550,7 +2550,7 @@ dev = [ [[package]] name = "span-panel-api" -version = "3.0.0b10" +version = "3.0.0b11" source = { editable = "../../span/span-panel-api-p3" } dependencies = [ { name = "httpx" }, @@ -2601,7 +2601,7 @@ requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-ap [[package]] name = "span-panel-api-schema-1" -version = "0.1.0b7" +version = "0.1.0b8" source = { editable = "../../span/span-panel-api-p3/packages/schema-1" } dependencies = [ { name = "ebus-sdk" }, From 0442c04a56c653abbbd2cd5cf7f9b82ea2b3743a Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:25:12 -0700 Subject: [PATCH 092/345] chore: 2.1.0b5 on 3.0.0b11, and document the two blank entities Pins move to `span-panel-api==3.0.0b11` and `span-panel-api-schema-1==0.1.0b8`, which carry the `dominant_power_source` fix; schema-0 stays at `1.0.0b5` because nothing in it moved. README gains the battery-less case in both places a reader would look: the Grid Forming Entity deep dive now states that GFE is Grid on a panel with no battery on both firmware generations, with the elimination that makes it so, and the MID section says that having no MID is itself the answer for Grid Islandable rather than a missing reading. The Grid Islandable table row no longer says "only when reported", which was true of the gate and not of the entity. The delta document records both, and the pattern behind them: v1.0 expresses by *structure* what flat expressed by *property*, and code written against the property reads the structural form as ignorance. Three instances in two days -- `grid_islandable`, `dominant_power_source`, and the PV entities' area -- so the question to ask of every remaining `SCHEMA_CONDITIONAL_FIELD` is not "does this field still resolve" but "is there an answer the absence itself supplies". Both wrong diagnoses are recorded too, since the second would have quietly changed flat's degraded behaviour had it shipped. --- CHANGELOG.md | 18 ++++++++++++++++++ README.md | 12 +++++++++++- custom_components/span_panel/manifest.json | 6 +++--- pyproject.toml | 6 +++--- requirements_test.txt | 4 ++-- uv.lock | 2 +- 6 files changed, 38 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e94a9f07..ea5f7f73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to this project will be documented in this file. +## [2.1.0b5] - 8/2026 + +### Fixed + +Two diagnostic entities went blank on a battery-less panel after the firmware upgrade, both because v1.0 retired the property they were reading and the +integration treated the absence as ignorance rather than as an answer. Nothing about either site changed; the panel simply stopped publishing a value it now +expresses another way. + +- **Grid Forming Entity reads `Grid` again instead of `Unknown`.** Flat published a source class outright; v1.0 names the forming **device**, and it names it on + the Microgrid Interconnect Device — which a panel without a battery does not have. But the answer is settled by what cannot be there: a battery needs a MID, a + solar inverter cannot form a grid on its own, and a panel supplying nothing is not publishing. What remains is a generator, and SPAN with no generator + interface treats one as the grid. So on a panel with no MID the grid is what is forming, which is exactly what flat had been reporting all along. +- **Grid Islandable reads `Off` again instead of going unavailable.** v1.0 publishes no islandable property at all — the specification makes it structural + instead, where the presence of a MID is what says a panel can island. Having no MID is therefore the answer rather than the absence of one, and the entity was + being withheld precisely when it would have said "no". +- **Neither changes anything on a panel still running the older firmware.** Both keep reading the published property exactly as before, and both still go + unavailable if a panel that should publish one stops — which is a real fault worth surfacing, as distinct from a property the newer firmware never had. + ## [2.1.0b4] - 8/2026 ### Fixed diff --git a/README.md b/README.md index 9051960b..92887ba5 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,10 @@ device of its own, linked to the panel. It appears automatically where the panel This is genuinely new information — the previous firmware did not report the state of the utility supply at all. It is not the same thing as **DSM Grid State**, which is whether **your home** is islanded. The grid can be down while your home runs happily off the battery. +**A panel with no battery has no MID, and that is itself an answer.** The specification makes backup capability structural rather than a property — the presence +of a MID is what says a panel can island — so **Grid Islandable** reads `Off` on such a panel rather than going unavailable, and **Grid Forming Entity** reads +`Grid`. Neither is a default standing in for a missing reading; both are what the absence of islanding hardware means. + `DSM Grid State` also becomes more trustworthy on this data model. It keeps its entity id and its history, but where it used to be inferred — from the battery when one was fitted, otherwise from the dominant power source — it now reads the islanding state the MID actually senses. @@ -468,7 +472,7 @@ Applies to Main Meter and Feed Through energy sensors. | Ethernet Link | Connectivity | Wired network status | | Wi-Fi Link | Connectivity | Wireless network status | | Panel Status | Connectivity | Overall panel online/offline | -| Grid Islandable | — | (v2) Whether the panel can island from the grid. Only when reported | +| Grid Islandable | — | (v2) Whether the panel can island from the grid. Off on a panel with no MID — see below | | PCS Active | Running | (v1.0) Whether the Power Control System is limiting import right now. Only when the panel runs one | | PV Panel Link | Connectivity | (v1.0) Whether the panel can reach the solar inverter. Only when the feeding circuit reports it | @@ -515,6 +519,12 @@ When a BESS is installed, the panel relies on the BESS to determine whether the while the panel is islanded, the GFE value becomes stale — it may show Battery when the grid has actually been restored, causing unnecessary shedding to continue. +**On a panel with no battery, GFE is Grid, on both firmware generations.** The newer firmware moves this value onto the Microgrid Interconnect Device, which is +part of a battery system — so a panel without one has nothing publishing it. The answer is still settled, by what cannot be there: Battery needs a BESS (which +brings a MID), PV cannot form a grid on its own (anything that can is a grid-forming inverter, which is a MID), and None describes a panel supplying nothing, +which is a panel that is not reporting at all. What remains is a generator, and SPAN with no generator interface treats one as the grid. So Grid is what a +battery-less panel reports, which is what the older firmware reported too. + #### What the Panel Can Detect **Grid loss** — The panel independently detects grid loss via its own voltage monitoring, even if BESS communication is already lost. The MID is still closed at diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index dded056b..0b528d96 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -22,11 +22,11 @@ ], "quality_scale": "gold", "requirements": [ - "span-panel-api==3.0.0b10", + "span-panel-api==3.0.0b11", "span-panel-api-schema-0==1.0.0b5", - "span-panel-api-schema-1==0.1.0b7" + "span-panel-api-schema-1==0.1.0b8" ], - "version": "2.1.0b4", + "version": "2.1.0b5", "zeroconf": [ { "type": "_span._tcp.local." diff --git a/pyproject.toml b/pyproject.toml index e89c752f..dbf1c442 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span" -version = "2.1.0b4" +version = "2.1.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.8.0", - "span-panel-api==3.0.0b10", + "span-panel-api==3.0.0b11", # 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.0.0b5", - "span-panel-api-schema-1==0.1.0b7", + "span-panel-api-schema-1==0.1.0b8", ] [dependency-groups] diff --git a/requirements_test.txt b/requirements_test.txt index ab9cbb6f..7ea9a044 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -3,6 +3,6 @@ pytest-asyncio>=1.3.0 setuptools>=65.7.0 pytest-homeassistant-custom-component==0.13.354 homeassistant==2026.8.0 -span-panel-api==3.0.0b10 +span-panel-api==3.0.0b11 span-panel-api-schema-0==1.0.0b5 -span-panel-api-schema-1==0.1.0b7 +span-panel-api-schema-1==0.1.0b8 diff --git a/uv.lock b/uv.lock index 6389a52f..3ea09101 100644 --- a/uv.lock +++ b/uv.lock @@ -2491,7 +2491,7 @@ wheels = [ [[package]] name = "span" -version = "2.1.0b4" +version = "2.1.0b5" source = { virtual = "." } dependencies = [ { name = "homeassistant" }, From 08c9142652574171cf743108cb4cba2095f90976 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:36:46 -0700 Subject: [PATCH 093/345] refactor(sensor): file Import Limit as a diagnostic, with its siblings It was the only panel-level ampere value on the primary card, and the only member of its own PCS group outside the Diagnostic section -- both of its siblings were already there. This reverses a deliberate decision, which had a test and a rationale: "a PCS throttling import is a fact about the user's electricity supply, not about the integration's health, so it is not filed as a diagnostic". That reads `DIAGNOSTIC` as being about the integration. Home Assistant's category is about the *device* -- "a configuration parameter or diagnostics of a device" -- and by the earlier standard `l1_voltage` and `main_breaker_rating` are facts about the user's supply too, and both are diagnostic. The line this codebase actually draws is not "amps are diagnostic", which would misfile two entities that are correctly primary: `circuit_current` and `evse_advertised_current` are amps and are what a circuit or a charger is doing right now. It is that the panel's own voltages, lug currents, breaker ratings and limits describe the installation rather than its activity. Import Limit is one of those -- a ceiling the panel arbitrated, carrying the four limits it was arbitrated from as attributes. Presentational only. The entity id, its history and its long-term statistics are untouched, `state_class` stays, and automations and templates are unaffected; the category groups it on the device page and keeps it out of auto-generated dashboards. Done now because the entity is days old and has appeared in exactly one release's addition notice, so no installation has a dashboard or history depending on where it sits. Later would cost something; today costs nothing. --- CHANGELOG.md | 8 +++++ README.md | 2 +- .../span_panel/sensor_definitions.py | 18 +++++++++++ tests/test_pcs.py | 32 ++++++++++++++++--- 4 files changed, 55 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea5f7f73..bce3906b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. ## [2.1.0b5] - 8/2026 +### Changed + +- **Import Limit moves to the Diagnostic section of the device page.** It sits with the panel's other electrical characteristics — voltages, lug currents, + breaker ratings — rather than beside the power readings, which is where the rest of the integration files a limit the panel arbitrated as opposed to a + measurement of what is flowing. Both of its Power Control System siblings were already there. Nothing else changes: the entity id, its history and its + long-term statistics are untouched, and automations and templates referring to it are unaffected. Moved now because the entity is days old and has appeared in + one release, so no dashboard depends on where it sits. + ### Fixed Two diagnostic entities went blank on a battery-less panel after the firmware upgrade, both because v1.0 retired the property they were reading and the diff --git a/README.md b/README.md index 92887ba5..19db6460 100644 --- a/README.md +++ b/README.md @@ -220,7 +220,7 @@ state, not an absence. | Sensor | Device Class | Unit | Notes | | ------------------ | ------------ | ---- | -------------------------------------------------------------------------------------------------------------------------- | -| Import Limit | Current | A | The limit actually being enforced: the most restrictive of every active constraint | +| Import Limit | Current | A | The limit actually being enforced: the most restrictive active constraint (diagnostic) | | Binding Constraint | Enum | — | Which constraint sets that limit: Firm Service Rating, Grid Envelope, Voltage Support, Off-Grid, Requested, Operator, None | #### Power Control System Sensor Attributes diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py index 56e1c8f0..4de27762 100644 --- a/custom_components/span_panel/sensor_definitions.py +++ b/custom_components/span_panel/sensor_definitions.py @@ -609,6 +609,24 @@ class SpanPcsSensorEntityDescription(SensorEntityDescription, SpanPcsRequiredKey device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + # Diagnostic, which is where this integration files a panel-level + # electrical characteristic. The line is not "amps are diagnostic" -- + # `circuit_current` and `evse_advertised_current` are amps and are + # correctly primary, because they are what a circuit or a charger is doing + # right now. It is that the panel's own voltages, lug currents, breaker + # ratings and limits describe the installation rather than its activity, + # and all of those are diagnostic. `main_breaker_rating` is the closest + # analogue -- also an ampere ceiling on the panel -- and so are both of + # this sensor's own PCS siblings, which left this the only member of its + # group on the primary card. + # + # It is a ceiling the panel arbitrated, not a measurement of what is + # flowing, and it carries the four constraint limits it was arbitrated + # from as attributes -- which is what a diagnostic looks like. The + # category costs nothing operationally: it groups the entity on the device + # page and keeps it out of auto-generated dashboards, and automations, + # templates and long-term statistics are unaffected. + entity_category=EntityCategory.DIAGNOSTIC, suggested_display_precision=1, value_fn=lambda p: p.import_limit_a, attributes_fn=pcs_arbitration_attributes, diff --git a/tests/test_pcs.py b/tests/test_pcs.py index 53755d61..d4500660 100644 --- a/tests/test_pcs.py +++ b/tests/test_pcs.py @@ -733,16 +733,40 @@ def test_a_flat_circuit_shows_neither_attribute() -> None: # --------------------------------------------------------------------------- -def test_the_import_limit_is_an_ampere_measurement_enabled_by_default() -> None: - """A PCS throttling import is a fact about the user's electricity supply, - not about the integration's health, so it is not filed as a diagnostic.""" +def test_the_import_limit_is_an_ampere_measurement_filed_as_a_diagnostic() -> None: + """Filed where every other ampere value in this integration is filed. + + This reverses an earlier decision, which read `DIAGNOSTIC` as "about the + integration's health" and reasoned that a PCS throttling import is a fact + about the user's electricity supply instead. Home Assistant's category is + about the *device* — "a configuration parameter or diagnostics of a device" — + and by that earlier standard `l1_voltage` and `main_breaker_rating` are facts + about the user's supply too, and both are diagnostic. + + The line this codebase actually draws is not "amps are diagnostic": + `circuit_current` and `evse_advertised_current` are amps and are correctly + primary, because they are what a circuit or a charger is doing right now. It + is that the panel's own voltages, lug currents, breaker ratings and limits + describe the installation rather than its activity. Import Limit is one of + those, and was the only entity outside the rule — and the only member of its + own PCS group on the primary card. + + It is a ceiling the panel arbitrated rather than a measurement of what is + flowing, and it carries the four limits it was arbitrated from as attributes. + Changed while the entity was days old and had appeared in exactly one + release's addition notice, so no installation has a dashboard or history + depending on where it sits. + + The category is presentational: automations, templates and long-term + statistics are unaffected, which is why `state_class` stays. + """ description = next(d for d in PCS_SENSORS if d.key == IMPORT_LIMIT_KEY) assert description.device_class is SensorDeviceClass.CURRENT assert description.state_class is SensorStateClass.MEASUREMENT assert description.native_unit_of_measurement == UnitOfElectricCurrent.AMPERE assert description.entity_registry_enabled_default is True - assert description.entity_category is not EntityCategory.DIAGNOSTIC + assert description.entity_category is EntityCategory.DIAGNOSTIC def test_the_binding_constraint_is_an_enum_diagnostic_enabled_by_default() -> None: From e1413de6f021cbe02b1314f95e27906488c72d9f Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:41:53 -0700 Subject: [PATCH 094/345] chore: 2.1.0b6, so the Import Limit move ships `v2.1.0b5` was tagged at `0442c04`, one commit before the category change, so it went out without it. The changelog credited the move to b5, which was wrong the moment that tag was cut -- an entry describing something a release does not contain is worse than no entry, because it is the release notes that a user checks when the thing they read about is not there. Moved to its own `[2.1.0b6]` section. Library pins are unchanged at `3.0.0b11` / `1.0.0b5` / `0.1.0b8`; nothing in them moved. Also dropped "has appeared in one release" from the rationale, which stopped being true when b5 shipped. The argument does not depend on it: the entity is days old either way, and the same change after general firmware rollout would cost something. --- CHANGELOG.md | 8 +++++--- custom_components/span_panel/manifest.json | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bce3906b..e17e9d10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,15 +2,17 @@ All notable changes to this project will be documented in this file. -## [2.1.0b5] - 8/2026 +## [2.1.0b6] - 8/2026 ### Changed - **Import Limit moves to the Diagnostic section of the device page.** It sits with the panel's other electrical characteristics — voltages, lug currents, breaker ratings — rather than beside the power readings, which is where the rest of the integration files a limit the panel arbitrated as opposed to a measurement of what is flowing. Both of its Power Control System siblings were already there. Nothing else changes: the entity id, its history and its - long-term statistics are untouched, and automations and templates referring to it are unaffected. Moved now because the entity is days old and has appeared in - one release, so no dashboard depends on where it sits. + long-term statistics are untouched, and automations and templates referring to it are unaffected. Moved now because the entity is days old, so no dashboard + depends on where it sits; the same change after general firmware rollout would cost something. + +## [2.1.0b5] - 8/2026 ### Fixed diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 0b528d96..25d9a1f0 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -26,7 +26,7 @@ "span-panel-api-schema-0==1.0.0b5", "span-panel-api-schema-1==0.1.0b8" ], - "version": "2.1.0b5", + "version": "2.1.0b6", "zeroconf": [ { "type": "_span._tcp.local." diff --git a/pyproject.toml b/pyproject.toml index dbf1c442..29510978 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span" -version = "2.1.0b5" +version = "2.1.0b6" description = "Span Panel Custom Integration for Home Assistant" authors = [{name = "SpanPanel"}] license = {text = "MIT"} diff --git a/uv.lock b/uv.lock index 3ea09101..0193b512 100644 --- a/uv.lock +++ b/uv.lock @@ -2491,7 +2491,7 @@ wheels = [ [[package]] name = "span" -version = "2.1.0b5" +version = "2.1.0b6" source = { virtual = "." } dependencies = [ { name = "homeassistant" }, From 973c6154502cb17cdeed4f2689bd89554aa7b7be Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:12:03 -0700 Subject: [PATCH 095/345] fix(circuits): report the voltage we can justify, not a default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `construct_voltage_attribute` returned None for a breaker occupying more than two positions, and every caller turned that into 240 with `or 240` — so the one case we cannot infer became the one case we asserted most confidently. The topology service had its own copy of the inference and answered 120 for everything that was not exactly two-pole. Callers now omit the attribute when the pole count does not determine the voltage, and the topology service reads the same helper as the entities. `construct_tabs_attribute` and the circuit-number entity id builder no longer branch on one-or-two positions either: they name every position the breaker occupies. Three positions used to drop the attribute and log that the hardware was "not valid for US electrical system", which is a poor way to meet input we have merely not seen. SPAN publishes only 1- and 2-pole breakers, so the wider form is unreachable on SPAN hardware and exists so unexpected input degrades into something readable. --- .../span_panel/entity_resolver.py | 18 ++-- custom_components/span_panel/helpers.py | 97 +++++++++++-------- custom_components/span_panel/select.py | 5 +- .../span_panel/sensor_circuit.py | 10 +- custom_components/span_panel/switch.py | 5 +- custom_components/span_panel/websocket.py | 8 +- tests/test_tabs_attribute.py | 44 +++++++-- 7 files changed, 121 insertions(+), 66 deletions(-) diff --git a/custom_components/span_panel/entity_resolver.py b/custom_components/span_panel/entity_resolver.py index 88458876..bf637cc9 100644 --- a/custom_components/span_panel/entity_resolver.py +++ b/custom_components/span_panel/entity_resolver.py @@ -330,14 +330,18 @@ def construct_single_circuit_entity_id( use_circuit_numbers = coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) if use_circuit_numbers: - # Check if this is a 240V circuit (2 tabs) or 120V circuit (1 tab) - if circuit_data.tabs and len(circuit_data.tabs) == 2: - # 240V circuit - use both tab numbers + # Every position the breaker occupies, joined in order: `circuit_30_32` + # for a two-pole breaker, `circuit_28` for a single-pole. Naming the + # positions rather than branching on how many there are keeps this + # consistent with `construct_tabs_attribute`, which formats the same + # list. A breaker occupying three or more is not something SPAN + # publishes -- it has stated its panels "are split-phase and publish + # only 1- or 2-pole breakers" -- so the wider form is unreachable on + # SPAN hardware and exists so unexpected input degrades into a readable + # id instead of falling through to the circuit UUID. + if circuit_data.tabs: sorted_tabs = sorted(circuit_data.tabs) - circuit_part = f"circuit_{sorted_tabs[0]}_{sorted_tabs[1]}" - elif circuit_data.tabs and len(circuit_data.tabs) == 1: - # 120V circuit - use single tab number - circuit_part = f"circuit_{circuit_data.tabs[0]}" + circuit_part = "circuit_" + "_".join(str(tab) for tab in sorted_tabs) else: # No tabs available — use the API circuit_id as fallback circuit_part = ( diff --git a/custom_components/span_panel/helpers.py b/custom_components/span_panel/helpers.py index 8609a208..ea07f873 100644 --- a/custom_components/span_panel/helpers.py +++ b/custom_components/span_panel/helpers.py @@ -149,75 +149,92 @@ def construct_circuit_identifier_from_tabs(tabs: list[int], circuit_id: str = "" Used as a fallback when a circuit has no panel-assigned name. Args: - tabs: List of tab numbers (1 for 120V, 2 for 240V dipole) + tabs: Every panel position the breaker occupies circuit_id: Fallback identifier when tabs are unavailable Returns: - String like "Circuit 30 32" for 240V or "Circuit 15" for 120V + String like "Circuit 30 32" for a two-pole breaker or "Circuit 15" for a + single-pole one, naming every position however many there are """ - if tabs and len(tabs) == 2: - sorted_tabs = sorted(tabs) - return f"Circuit {sorted_tabs[0]} {sorted_tabs[1]}" - if tabs and len(tabs) == 1: - return f"Circuit {tabs[0]}" + if tabs: + return "Circuit " + " ".join(str(tab) for tab in sorted(tabs)) return f"Circuit {circuit_id}" def construct_tabs_attribute(circuit: SpanCircuitSnapshot) -> str | None: """Construct tabs attribute string from circuit data. - For US electrical systems, circuits can only have 1 tab (120V) or 2 tabs (240V). + Names every position the breaker occupies, however many that is. v1.0 + publishes them literally in ``info/spaces``; the flat schema published one + space plus a ``dipole`` flag and its adapter recovers the pair from that. So + this sees at most two positions on flat, and on v1.0 exactly what the panel + reported. + + **Accepting more than two is defensive, not a fix for observed hardware.** + SPAN has stated that its panels "are split-phase and publish only 1- or + 2-pole breakers", and no circuit on any panel captured so far occupies more + than two positions. The ``1:4:1`` range on ``breaker/poles`` is the generic + eBus catalog, which covers load centres that are not SPAN. What this + replaces is a hard failure: three positions used to drop the attribute + entirely and log that the hardware was "not valid for US electrical + system", which is a poor way to meet input we merely have not seen. Args: circuit: SpanCircuitSnapshot object with tabs information Returns: - Tabs attribute string like "tabs [30:32]" for 240V or "tabs [28]" for 120V, - or None if no tabs information is available + Tabs attribute string like "tabs [30:32]", or None if no tabs + information is available Examples: - Single tab (120V): "tabs [28]" - Two tabs (240V): "tabs [30:32]" + Single tab: "tabs [28]" + Two tabs: "tabs [30:32]" + Three tabs: "tabs [17:19:21]" No tabs: None """ if not circuit.tabs: return None - # Sort tabs for consistent ordering - sorted_tabs = sorted(circuit.tabs) - - if len(sorted_tabs) == 1: - # Single tab (120V) - return f"tabs [{sorted_tabs[0]}]" - if len(sorted_tabs) == 2: - # Two tabs (240V) - format as range - return f"tabs [{sorted_tabs[0]}:{sorted_tabs[1]}]" - # More than 2 tabs is not valid for US electrical system - _LOGGER.warning( - "Circuit %s has %d tabs, which is not valid for US electrical system (expected 1 or 2)", - circuit.circuit_id, - len(sorted_tabs), - ) - return None + return f"tabs [{':'.join(str(tab) for tab in sorted(circuit.tabs))}]" def construct_voltage_attribute(circuit: SpanCircuitSnapshot) -> int | None: - """Construct voltage attribute for a circuit based on tab count. - - For US electrical systems, circuits can only have 1 tab (120V) or 2 tabs (240V). + """Return the nominal voltage for a circuit, inferred from its pole count. + + **Nominal, not measured, and there is nothing better to read.** The eBus + circuit ``meter`` capability publishes current, active power and energy + only; voltage is a panel-level quantity, published as the enclosure's + ``meter/voltage-a`` / ``voltage-b``. No per-circuit voltage exists on the + wire. + + **It is derived from the pole count, not from the positions.** Those are + different claims and only the second would be unsound: the specification + defines ``spaces`` as identifying every occupied slot "without assuming a + numbering convention", so reading a leg out of a position *number* is + exactly what the property exists to make unnecessary. The count comes from + ``breaker/poles``, published outright. Given the count, SPAN supplies the + rest -- it has stated that its panels "are split-phase and publish only 1- + or 2-pole breakers" -- and on a split-phase service a two-pole breaker is + line-to-line across both legs. So 240 rests on a vendor statement about + service type, not on a layout convention. + + **Which is also why it stops at two poles.** Three or more is not a + split-phase circuit at all -- 208V line-to-line on a three-phase wye + service, 240V on a high-leg delta -- and nothing published distinguishes + them. Deriving it from ``P / I`` does not rescue it either: that yields + ``V * pf`` through a 0.1A quantiser, which on real circuits lands within 1% + once in 27 and reads 0V for any circuit drawing standby current at zero + real power. None means we do not know, and callers omit the attribute + rather than publish a guess. Args: circuit: SpanCircuitSnapshot object with tabs information Returns: - Voltage in volts (120 for single tab, 240 for double tab), or None if no tabs information - - Examples: - Single tab (120V): 120 - Two tabs (240V): 240 - No tabs: None + 120 for a single-pole circuit, 240 for a two-pole one, or None when + there is no tab information or the pole count does not determine it """ if not circuit.tabs: @@ -227,12 +244,6 @@ def construct_voltage_attribute(circuit: SpanCircuitSnapshot) -> int | None: return 120 if len(circuit.tabs) == 2: return 240 - # More than 2 tabs is not valid for US electrical system - _LOGGER.warning( - "Circuit %s has %d tabs, which is not valid for US electrical system (expected 1 or 2)", - circuit.circuit_id, - len(circuit.tabs), - ) return None diff --git a/custom_components/span_panel/select.py b/custom_components/span_panel/select.py index 42f76e2d..ebf87070 100644 --- a/custom_components/span_panel/select.py +++ b/custom_components/span_panel/select.py @@ -274,8 +274,9 @@ def extra_state_attributes(self) -> Mapping[str, Any] | None: if tabs_result is not None: attributes["tabs"] = tabs_result - voltage = construct_voltage_attribute(circuit) or 240 - attributes["voltage"] = voltage + voltage = construct_voltage_attribute(circuit) + if voltage is not None: + attributes["voltage"] = voltage if circuit.priority_target is not None: attributes["priority_target"] = circuit.priority_target diff --git a/custom_components/span_panel/sensor_circuit.py b/custom_components/span_panel/sensor_circuit.py index 0e06c6c1..83cba192 100644 --- a/custom_components/span_panel/sensor_circuit.py +++ b/custom_components/span_panel/sensor_circuit.py @@ -244,8 +244,9 @@ def extra_state_attributes(self) -> dict[str, Any] | None: attributes["tabs"] = tabs_result # Voltage derived from tab count - voltage = construct_voltage_attribute(circuit) or 240 - attributes["voltage"] = voltage + voltage = construct_voltage_attribute(circuit) + if voltage is not None: + attributes["voltage"] = voltage attributes["always_on"] = circuit.always_on attributes["relay_state"] = circuit.relay_state @@ -452,8 +453,9 @@ def extra_state_attributes(self) -> dict[str, Any] | None: if tabs is not None: attributes["tabs"] = tabs - voltage = construct_voltage_attribute(circuit) or 240 - attributes["voltage"] = voltage + voltage = construct_voltage_attribute(circuit) + if voltage is not None: + attributes["voltage"] = voltage return attributes or None diff --git a/custom_components/span_panel/switch.py b/custom_components/span_panel/switch.py index a8ea3e73..a4dba851 100644 --- a/custom_components/span_panel/switch.py +++ b/custom_components/span_panel/switch.py @@ -247,8 +247,9 @@ def extra_state_attributes(self) -> Mapping[str, Any] | None: if tabs_result is not None: attributes["tabs"] = tabs_result - voltage = construct_voltage_attribute(circuit) or 240 - attributes["voltage"] = voltage + voltage = construct_voltage_attribute(circuit) + if voltage is not None: + attributes["voltage"] = voltage if circuit.relay_state_target is not None: attributes["relay_state_target"] = circuit.relay_state_target diff --git a/custom_components/span_panel/websocket.py b/custom_components/span_panel/websocket.py index b969bf8b..e06611e6 100644 --- a/custom_components/span_panel/websocket.py +++ b/custom_components/span_panel/websocket.py @@ -11,7 +11,7 @@ import voluptuous as vol from .const import DOMAIN -from .helpers import build_panel_unique_id +from .helpers import build_panel_unique_id, construct_voltage_attribute from .id_builder import build_binary_sensor_unique_id from .util import classify_sub_device_identifier @@ -157,7 +157,11 @@ async def handle_panel_topology( circuits[circuit_id] = { "tabs": tabs, "name": circuit.name or None, - "voltage": 240 if len(tabs) == 2 else 120, + # Same inference as the entity attribute, from the same helper: a + # pole count answers this for one and two poles and not beyond, and + # null says so rather than the previous 120, which this branch + # returned for every circuit that was not exactly two-pole. + "voltage": construct_voltage_attribute(circuit), "device_type": circuit.device_type, "relay_state": circuit.relay_state, "relay_state_target": circuit.relay_state_target, diff --git a/tests/test_tabs_attribute.py b/tests/test_tabs_attribute.py index f1358ac6..27b747ef 100644 --- a/tests/test_tabs_attribute.py +++ b/tests/test_tabs_attribute.py @@ -8,9 +8,7 @@ ) -def _make_circuit( - tabs: list[int], instant_power_w: float = 100.0 -) -> SpanCircuitSnapshot: +def _make_circuit(tabs: list[int], instant_power_w: float = 100.0) -> SpanCircuitSnapshot: """Create a minimal SpanCircuitSnapshot for tab/voltage tests.""" return SpanCircuitSnapshot( circuit_id="test", @@ -38,8 +36,27 @@ def test_tabs_attribute_construction() -> None: # No tabs assert construct_tabs_attribute(_make_circuit([])) is None - # More than 2 tabs (invalid for US electrical system) - assert construct_tabs_attribute(_make_circuit([1, 3, 5])) is None + # Unsorted input is ordered, whatever the pole count + assert construct_tabs_attribute(_make_circuit([32, 30])) == "tabs [30:32]" + + +def test_tabs_attribute_names_every_position_of_a_multipole_breaker() -> None: + """A 3- or 4-pole breaker keeps every position it occupies. + + Defensive rather than observed: SPAN has stated its panels "are split-phase + and publish only 1- or 2-pole breakers", and no circuit on any captured + panel occupies more than two positions. The `1:4:1` range on `breaker/poles` + belongs to the generic eBus catalog, which covers load centres that are not + SPAN. + + What is tested here is that unexpected input degrades instead of failing. + Three positions used to drop the attribute entirely and log that the + hardware was "not valid for US electrical system". + """ + assert construct_tabs_attribute(_make_circuit([17, 19, 21])) == "tabs [17:19:21]" + assert construct_tabs_attribute(_make_circuit([2, 4, 6, 8])) == "tabs [2:4:6:8]" + # Ordering is by position, not by arrival. + assert construct_tabs_attribute(_make_circuit([21, 17, 19])) == "tabs [17:19:21]" def test_voltage_attribute_construction() -> None: @@ -47,7 +64,22 @@ def test_voltage_attribute_construction() -> None: assert construct_voltage_attribute(_make_circuit([28])) == 120 assert construct_voltage_attribute(_make_circuit([30, 32])) == 240 assert construct_voltage_attribute(_make_circuit([])) is None - assert construct_voltage_attribute(_make_circuit([1, 3, 5])) is None + + +def test_voltage_is_not_claimed_for_a_multipole_breaker() -> None: + """Three or more poles is not a split-phase circuit, so we do not guess. + + 208V line-to-line on a three-phase wye service and 240V on a high-leg delta + are both plausible and nothing published distinguishes them. The position + numbers cannot settle it either: `spaces` is specified as identifying every + occupied slot "without assuming a numbering convention". + + This is the half that must NOT follow the tabs fix. Naming three positions + is reporting what the panel published; naming a voltage for them would be + inventing one. + """ + assert construct_voltage_attribute(_make_circuit([17, 19, 21])) is None + assert construct_voltage_attribute(_make_circuit([2, 4, 6, 8])) is None def test_end_to_end_tabs_workflow() -> None: From 66624a5cb4429844b027f1ab98b63be141676e7e Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:12:38 -0700 Subject: [PATCH 096/345] docs: one 2.1.0 entry, written for the people upgrading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changelog carried five separate beta sections whose combined story was mostly internal: a retry that was fixed three times, documentation corrected in both directions, entities promised in b2 and repaired in b5. A reader upgrading from 2.0.8 sees none of that history — they see one release. So the betas are collapsed into a single 2.1.0 entry stating final behaviour, keeping the SPAN firmware transition and the self-reloading upgrade path, which are the reasons the release exists. Added is one sentence per item rather than a paragraph. Both documents also framed the schema generation as something a reader chooses between. The v2 eBus prerequisites were repeated at the top of 2.0.2 through 2.0.6 as live cautions and are now stated once, in past tense, on the 2.0.1 entry that introduced them. Every `v1.0 data model only` and `v2 only` marker is gone from the README's headings, tables and prose: it is just the firmware the panel will be running, and the conditions that genuinely vary per install — "only when BESS commissioned", "only when the feeding circuit reports it" — were already stated where they matter. --- CHANGELOG.md | 364 +++++++++++++++------------------------------------ README.md | 286 ++++++++++++++++++++-------------------- 2 files changed, 242 insertions(+), 408 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e17e9d10..6b729d30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,95 +2,35 @@ All notable changes to this project will be documented in this file. -## [2.1.0b6] - 8/2026 +## [2.1.0] - 8/2026 -### Changed - -- **Import Limit moves to the Diagnostic section of the device page.** It sits with the panel's other electrical characteristics — voltages, lug currents, - breaker ratings — rather than beside the power readings, which is where the rest of the integration files a limit the panel arbitrated as opposed to a - measurement of what is flowing. Both of its Power Control System siblings were already there. Nothing else changes: the entity id, its history and its - long-term statistics are untouched, and automations and templates referring to it are unaffected. Moved now because the entity is days old, so no dashboard - depends on where it sits; the same change after general firmware rollout would cost something. - -## [2.1.0b5] - 8/2026 - -### Fixed - -Two diagnostic entities went blank on a battery-less panel after the firmware upgrade, both because v1.0 retired the property they were reading and the -integration treated the absence as ignorance rather than as an answer. Nothing about either site changed; the panel simply stopped publishing a value it now -expresses another way. - -- **Grid Forming Entity reads `Grid` again instead of `Unknown`.** Flat published a source class outright; v1.0 names the forming **device**, and it names it on - the Microgrid Interconnect Device — which a panel without a battery does not have. But the answer is settled by what cannot be there: a battery needs a MID, a - solar inverter cannot form a grid on its own, and a panel supplying nothing is not publishing. What remains is a generator, and SPAN with no generator - interface treats one as the grid. So on a panel with no MID the grid is what is forming, which is exactly what flat had been reporting all along. -- **Grid Islandable reads `Off` again instead of going unavailable.** v1.0 publishes no islandable property at all — the specification makes it structural - instead, where the presence of a MID is what says a panel can island. Having no MID is therefore the answer rather than the absence of one, and the entity was - being withheld precisely when it would have said "no". -- **Neither changes anything on a panel still running the older firmware.** Both keep reading the published property exactly as before, and both still go - unavailable if a panel that should publish one stops — which is a real fault worth surfacing, as distinct from a property the newer firmware never had. - -## [2.1.0b4] - 8/2026 - -### Fixed - -- **A panel that is still booting no longer needs a manual reload, however long it takes.** b3 taught the integration to keep waiting when a rebooting panel - answers with an error instead of refusing outright, but it still gave up after a few minutes — and giving up was worse than it sounds, because nothing starts - it again. A panel that finished booting after that point stayed unread until you reloaded by hand. It now waits for as long as your panel takes, checking - every thirty seconds once it settles, so a slow reboot is just a slow reboot. -- **Your energy history is unaffected by that wait.** Energy sensors already hold their last reading through an outage for the grace period you configure - (fifteen minutes by default), which is what stops a gap becoming an `unknown` and a spike in your statistics. Waiting longer for the panel does not change - that. -- **Several other ways a booting panel answers are now recognised as "still starting" rather than as a fault** — a connection reset mid-request, a gateway that - closes without replying, and a reply that arrives truncated. Each of those previously stopped the integration following the upgrade, the same way the original - error did. -- **A panel that is not ready when Home Assistant starts now retries instead of failing setup.** Previously that left the integration in an error state needing - a human, which matters because the two events correlate: one power interruption takes out both your panel and the machine watching it, and they come back at - their own pace. - -## [2.1.0b3] - 8/2026 - -### Fixed - -- **The integration now reloads itself after the firmware upgrade in the case that actually happens.** This is the whole promise of the release, and a live - upgrade found the one path where it did not hold. When a panel takes the new firmware it drops its MQTT connection, comes back a few minutes later, and - starts serving HTTP a little after that — and while it is still starting, it answers with `502` rather than refusing the connection, because a booting device - brings its network front end up before the application behind it. The integration retried a refused connection and a timeout, but treated an answered-with-502 - as a hard failure and gave up on the first try, leaving the old reader in place. It was caught on two Home Assistant instances watching one panel through the - same upgrade: both went quiet and neither recovered until reloaded by hand. A `502` is now understood as **not ready yet** and waited out, for as long as a real - reboot takes. -- **If following the upgrade ever fails for some other reason, you are now told what to do about it.** Previously that surfaced as a bare error in the log with - no indication that anything needed doing, while the integration carried on reading the panel with the wrong reader. It now says plainly that a reload is - needed once the panel is back up. - -### Added - -- **Diagnostics include your entity registry.** Every entity this integration owns, with its unique id and — the part you cannot get anywhere else — what - disabled it, if anything. Home Assistant tells you an entity is disabled without telling you by what, and reading that yourself needs shell access that a - Home Assistant OS install does not give you. +### You will need this release when SPAN updates your panel -### Changed +SPAN firmware `r202633` replaces the way the panel publishes its data — the wire model every release up to 2.0.8 is retired in the same update that introduces +the new one. There is no wire overlap and no setting to keep the old behaviour. **2.0.8 cannot read a panel on `r202633`**: it stays connected, reports every +circuit as missing, and shows nothing useful. -- **The README now leads with the upgrade warning**, and documents the Microgrid Interconnect Device, adopted devices, the `at_service_entrance` attribute on - Grid Power, and which sensors arrive switched off. -- **The battery power sign was documented backwards in both the README and this file, and is corrected.** Positive means **discharging**, which is what release - 2.0.5 established and what a measured panel confirms. No entity changed and no reading moved; only the documentation was wrong. +We do not control when that update reaches you, and the schedule belongs exclusively to SPAN. Panels update on SPAN's timing, not on yours or ours. A mandatory +upgrade that keeps working therefore demands a release that can adapt on connection, and adapt again as necessary. If uninterrupted integration matters to you, +be on this release **before** your panel changes — afterwards you are looking at a blank integration while you work out why. -## [2.1.0b2] - 8/2026 +**The transition itself is seamless.** Install 2.1.0 early and your only outage is the firmware update itself. When your panel changes over, the integration +notices on the wire, reloads itself, notifies you, and carries on — no reconfiguration, no re-pairing, no lost history. Your entities keep their entity ids, +their unique ids and their statistics across the change. New things appear because the new firmware publishes a bit more and because devices appropriate to your +install are added; nothing you already had goes away. A log line and a one-time notification tell you exactly what happened, so take a screenshot. If you +experience an extended delay, reload the integration. -### You will need this release when SPAN updates your panel +**A panel that is still rebooting is waited out, for as long as your panel takes.** Taking the firmware upgrade drops the panel's connection for several minutes +— four or more is not uncommon once the panel receives its upgrade file. The integration keeps checking until the panel answers properly. -SPAN firmware `r202633` replaces the way the panel publishes its data — the flat model every release up to 2.0.8 reads is retired in the same update that -introduces the new one. There is no overlap and no setting to keep the old behaviour. **2.0.8 cannot read a panel on `r202633`**: it stays connected, reports -every circuit as missing, and shows nothing useful. +**Your energy history is unaffected by that wait.** Energy sensors hold their last reading through an outage for the grace period you configure (fifteen minutes +by default), which is what stops a gap becoming an `unknown` and a spike in your statistics. -We do not control when that update reaches you, and nobody has published a schedule. Panels update on SPAN's timing, not on yours or ours. So the safe order is -to be on this release **before** your panel changes rather than after, because afterwards you are looking at a blank integration while you work out why. +**A firmware upgrade that adds a capability reloads as well**, so hardware your panel starts reporting — the Microgrid Interconnect Device, the shed forecast, +the power control system, battery telemetry, DER link health — turns into entities when it appears rather than at your next restart. -**The transition itself is seamless, and that is the point of this release.** Install it and it keeps reading your panel exactly as before, on either firmware. -When your panel does change over, the integration notices on the wire, reloads itself, and carries on — no reconfiguration, no re-pairing, no lost history. Your -entities keep their entity ids, their unique ids and their statistics across the change. New things appear because the new firmware genuinely publishes more; -nothing you already had goes away. +**If following the upgrade ever fails for some other reason, you are told what to do about it**: plainly, that a reload is needed once the panel is back up, +rather than a bare error in the log while the integration carries on reading the panel with the wrong reader. ### Requires Home Assistant 2026.8.0 or newer @@ -100,31 +40,80 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of ### Added -- **Your panel's Microgrid Interconnect Device appears as its own device** on the new data model, carrying **Grid State** — the health of the utility supply - itself, which the previous firmware never reported. Everything about it is additive; no existing entity moves or changes id. -- **The integration notices a firmware upgrade and reloads itself.** A panel that becomes v1.0 while Home Assistant is running used to keep reading the tree - with the old parser, reporting every circuit as missing until you reloaded by hand. It now detects the change, reloads, writes a log line, and raises a - one-time notice explaining what changed. -- **Grid-forming device name** as an attribute on the GFE sensor. -- **Grid Power now says whether it is really measuring the grid**, through a new `at_service_entrance` attribute. That sensor reads your panel's upstream lugs, - which is grid flow only when those lugs are where the utility actually connects. Put a battery between the utility and your main lugs, or feed the panel from - another panel, and the same reading becomes that panel's own supply while **Grid Power Flow** stays the whole-site figure — so the two legitimately disagree, - and until now there was no way to tell that apart from a fault. The eBus specification was corrected on 2026-08-20 to say exactly this, after this project - supplied the capture that prompted it. Both readings were always correct; only the label was ever conditional. -- **Diagnostics now include your entity registry.** Every entity this integration owns, with its unique id and — the part you cannot get anywhere else — what - disabled it, if anything. Home Assistant tells you an entity is disabled without telling you by what, and reading that yourself needs shell access to - `.storage` that a Home Assistant OS install does not give you. Four causes look identical on screen and need four different answers. +- **Your panel's Microgrid Interconnect Device appears as its own device**, carrying **Grid State** — the health of the utility supply itself, which your panel + never reported before. +- **Grid Power now says whether it is really measuring the grid**, through a new `at_service_entrance` attribute: false where a battery or another panel sits + between the utility and your main lugs, which is when it and **Grid Power Flow** legitimately disagree. + +- **Your solar inverter gets a device of its own** rather than rendering as diagnostic sensors on the panel's card; the five PV entities keep the entity ids, + unique ids and history they have today. +- **Assign the new Solar device to an area** — those five no longer inherit the panel's area, which quietly drops them from area-scoped dashboards, automations + and voice commands until you do. +- New installations get `sensor.span_panel_solar_pv_vendor` and friends where existing ones keep `sensor.span_panel_pv_vendor`; both are correct and neither + will change again. + +- **EVSE Charge Current Limit** — a settable ceiling on each commissioned SPAN Drive, the first control this integration has that changes something on a charger + rather than on the panel. +- The maximum is the one your installer commissioned, read from the panel: a higher value is refused rather than quietly rounded down, and the control is + unavailable rather than invented where the rating is not published. +- The control appears only where the panel says the limit can be changed, and reports a change still in flight as a `charge_current_limit_target` attribute. + +- **PV Panel Link and EVSE Panel Link** — the panel's own report of the link to your inverter and to each charger, which is the fact **BESS Connected** has + always shown for the battery. +- **EVSE Panel Link is not EV Connected**: one is the panel reaching the charger at all, the other is the charger reporting a plugged-in vehicle, and they can + disagree mid-session. + +- **BESS Meter Power** — what the battery itself reports it is charging or discharging at, beside the panel's arbitrated **Battery Power**. Enabled by default. +- **BESS Communication State** — the battery's own `OK` / `DEGRADED` / `LOST` / `UNKNOWN` view of its link, which can differ from the panel's. Diagnostic, off + by default. +- **Both battery power sensors read positive when discharging**, settled by measurement on a live panel — the same convention as PV Power producing and Grid + Power Flow importing. + +- **Time to Priority Shed and Backup Time Remaining** — how long before the panel starts shedding circuits and how long before the battery is spent. Enabled by + default. +- Each forecast carries its full-charge equivalent and the panel's own `forecast_confidence` as attributes, rather than as two more near-constant entities. + +- **Import Limit, Binding Constraint and PCS Active** — the current limit your panel enforces, which rule set it, and whether anything is being throttled right + now. Filed under Diagnostics with the panel's other electrical characteristics. +- **Import Limit carries the whole arbitration as attributes**: the four constraint limits the panel reconciled, each one's `_enablement` and `_active` flag, + and `pcs_enabled`. +- **Every circuit's power sensor gains `pcs_managed` and `pcs_priority`** where the circuit reports them — the shed order when an import limit binds, which is + not the backup tier the existing `shed_priority` names. +- All three entities appear even when the PCS is switched off, because that is a state, and the state most panels are in. + +- **New kinds of device your panel gains no longer wait for a release.** SPAN's data model is vendor-extensible, so a device type nobody has modelled can turn + up at any time; one now gets a card of its own hanging off the panel, with whatever it publishes as entities beneath it, all disabled and diagnostic. +- **A property such a device accepts writes to becomes a control**: a boolean becomes a switch, an enum a select, a bounded number a number entity, constrained + to what the device declared and nothing invented. +- **Nothing adopted enters long-term statistics** — `state_class` is not declared on the wire and a wrong guess writes corrupt statistics; wrap an adopted + reading in a template sensor or utility meter if you want them. +- Devices this integration does model are deliberately not adopted: a new property on a circuit, the battery, a charger or the panel stays curated in a release, + where the judgement about it lives. + +- **Your panel's own card shows what the panel says it is** — manufacturer, model and hardware revision read from the enclosure rather than assumed, once your + panel publishes them. +- **Part Number** diagnostic sensor on every SPAN Drive, matching the one the battery already has. Off by default. +- **Circuit Priority's shed policy is readable**: `dsm_state` gains `shed_algorithm` and the two state-of-charge thresholds that decide when circuits shed and + when they come back. +- **Grid-forming device name** as an attribute on the Grid Forming Entity sensor. +- **Diagnostics include your entity registry** — every entity this integration owns, its unique id, and what disabled it, which Home Assistant will not + otherwise tell you. ### Changed -- **`DSM Grid State` is now more trustworthy on the new data model.** It keeps its entity id and all of its history. Previously it was _inferred_ — from the - battery if one was fitted, otherwise from the dominant power source and whether power was crossing the grid connection. It now reads the islanding state the - Microgrid Interconnect Device actually senses. -- **`Grid Islandable` keeps working** across the upgrade. v1.0 publishes no panel-level islandable property, so the entity now reflects whether a Microgrid - Interconnect Device is present, which is how v1.0 says backup capability is detected. -- **Battery model** may read differently after upgrading: the new data model separates the human-readable designation from the SKU, and this entity now shows - the designation. This is a library-level normalisation applied to both data models, so it happens once, at this release, rather than unpredictably during a - firmware update. +- **`DSM Grid State` is now more trustworthy.** It keeps its entity id and all of its history. Previously it was _inferred_ — from the battery if one was + fitted, otherwise from the dominant power source and whether power was crossing the grid connection. It now reads the islanding state the Microgrid + Interconnect Device actually senses. +- **`Grid Islandable` keeps working** across the upgrade. The new firmware publishes no panel-level islandable property — it makes the question structural + instead, where the presence of a Microgrid Interconnect Device is what says a panel can island — so the entity now reflects whether that device is present. A + panel without one reads `Off`, which is an answer, rather than going unavailable. +- **`Grid Forming Entity` keeps working** across the upgrade too. Your panel used to publish a source class outright; the new firmware names the forming + _device_, and it names it on the Microgrid Interconnect Device, which a panel without a battery does not have. On such a panel the answer is settled by what + cannot be there — a battery needs that device, a solar inverter cannot form a grid on its own, and a panel supplying nothing is not publishing — so the entity + reads `Grid`, which is exactly what your panel reported before. +- **Battery model** may read differently after upgrading: the new firmware separates the human-readable designation from the SKU, and this entity now shows the + designation. The normalisation happens in the library on both sides of the upgrade, so it lands once, at this release, rather than unpredictably when your + panel changes over. - **Five panel sensors are switched off for new installations, because the eBus specification's own maintainer has documented that their values cannot be relied on.** A conformance note for SPAN firmware r202633 identifies three defects in what the panel publishes, all of which predate that release: the feedthrough @@ -136,13 +125,10 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of entity is first created, so an existing installation keeps them, keeps its history and keeps its entity IDs. This stops new installations picking them up; it cannot reach back. If you use any of the five on a dashboard or in an automation, they are worth removing — but that is your decision to make, not something an upgrade should do to you. -- **A new BESS Meter Power sensor, reading the battery's own meter alongside the existing Battery Power.** The two answer slightly different questions — one is - the battery's own view, the other the panel's arbitrated figure — and they agree by construction, so where they ever differ that is worth being able to see. -- **Your battery power readings are unchanged, and that is now checked against a real panel rather than assumed.** A capture from a live upgraded panel arrived - alongside the conformance note. The panel's four power-flow values sum to zero exactly, in the frame the specification now describes, and the battery power - sensor's definition is byte-for-byte what 2.0.8 shipped — same source, same conversion. Nothing about what you see has moved. -- **Your other panel readings are unaffected.** The upstream lugs, the main panel meter and every circuit are in the correct frame. So is the power-flow group, - which the specification has now been corrected to describe the way the panel has always published it. +- **Your other panel readings are unaffected, and that is now checked against a real panel rather than assumed.** A capture from a live upgraded panel arrived + alongside the conformance note: the panel's four power-flow values sum to zero exactly, and the battery power sensor's definition is byte-for-byte what 2.0.8 + shipped. The upstream lugs, the main panel meter and every circuit are in the correct frame, as is the power-flow group, which the specification has now been + corrected to describe the way the panel has always published it. - **New entities are now announced in a notification that names them — whether or not they arrived switched on.** Previously only entities added _disabled_ were mentioned, on the reasoning that an enabled one is already visible in your entity list and its history. That is only true if you are watching your entity @@ -151,112 +137,6 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of - **It is a notification rather than a Repair, because an addition is not a repair.** Nothing is broken and nothing needs fixing. Any new-entity item still sitting in your Repairs list from a previous version is removed on upgrade. -- **A device the panel publishes that this integration has never modelled now appears, instead of appearing nowhere.** SPAN positions the panel as the hub for - whatever plugs into it and the eBus schema is explicitly vendor-extensible, so a device type nobody modelled is an expected arrival — and until now it - produced no device, no entity and no sign it was there. Such a device now gets a card of its own hanging off the panel, carrying whatever `info` it publishes, - with its readings as entities beneath it. Everything adopted arrives **disabled and diagnostic**: nothing reaches a dashboard uninvited, and the new-entity - notice names the device so you can find it. -- **Devices this integration does model are left alone, deliberately.** A new property on a circuit, the battery, a charger or the panel is not adopted — it is - curated in a release, because that is where the judgement lives about whether it should be an entity, an attribute, or a line on a device card. Automatic - adoption would spend an `entity_id` permanently on a machine-derived shape before anyone made that call. The sixteen Power Control System properties that - curation collapsed into one entity and thirteen attributes are the worked example of what a rule cannot produce. -- **Nothing adopted enters long-term statistics, and that is a decision rather than an omission.** No adopted entity carries a `state_class`. It is not declared - on the wire and cannot be derived from one — this integration ships `feedthroughEnergyProducedWh` as `TOTAL` beside `mainMeterEnergyProducedWh` as - `TOTAL_INCREASING`, same unit and same device class — and a wrong one writes corrupt statistics that fixing the panel afterwards does not repair. Enrolling a - property nobody asked for into long-term statistics is also a permanent write to your recorder database. If you want statistics from an adopted reading, wrap - it in a template sensor, a Riemann-sum integration or a utility meter: that is your call, made on an entity you chose to enable. -- **A property an adopted device accepts writes to becomes a control, not just a reading.** A declared `boolean` becomes a switch, an `enum` with its option - list becomes a select, and a number with its `min:max:step` becomes a number entity — all disabled and diagnostic like every other adopted entity, so a - control appears only if you go and enable it. A settable property that declares no value domain stays a reading: a select with no options and a number with no - bounds are broken controls, not safe ones. -- **Your panel stays the authority on the value.** Nothing is translated or clamped on the way out: this integration knows an adopted property's declaration and - nothing else, so inventing a bound would be inventing a fact about your hardware. The control constrains you to what the device declared, and the panel - accepts or refuses. - -- **Your solar inverter gets a device of its own, on panels running the v1.0 data model.** Its vendor, model and nameplate capacity used to render as diagnostic - sensors on the _panel's_ card, beside the panel's own manufacturer and model — so the card whose job is telling you which enclosure this is read as though the - enclosure were an Enphase inverter. It now has a card like the battery and the chargers already do, carrying the firmware version the panel has been - publishing all along. -- **If you already have these sensors, nothing about them changes.** The five entities that move to the new card — PV Power, PV Vendor, PV Product, PV Nameplate - Capacity and PV Panel Link — keep the entity ids and unique ids they have today, so dashboards, automations and history follow them across untouched. -- **They do lose the panel's area, though, and nothing warns you.** An entity takes its area from the device it sits on unless you set one, and the new solar - device starts with no area. So if your panel is assigned to an area, these entities were in it yesterday and are in no area today — which quietly stops them - matching area-scoped dashboards, area-scoped automations and scripts, and voice commands that target a room. Assign the new **Solar** device to an area and - they behave as before. Worth doing before you go looking for what broke. -- **New installations get different entity ids for these five, and that is intended.** Home Assistant derives a new entity's id from the name of the device it - sits on, so a system installed from now on gets `sensor.span_panel_solar_pv_vendor` where a system installed before this release keeps - `sensor.span_panel_pv_vendor`. Both are correct and neither will change again: an existing system must never have an id renamed under it, and a new one gets - the id Home Assistant would give it. If you are comparing two SPAN systems and their PV entity ids differ, install date is why. The unique ids are the same on - both, and both sets of entities sit on the same new card. - -- **Your panel's own card now shows what the panel says it is** — manufacturer, model and hardware revision, read from the enclosure rather than assumed. A - panel on the older data model publishes none of the three and keeps exactly the card it has today; the hardware revision row is left off rather than shown - blank where no revision is published. -- **Every SPAN Drive gets a Part Number** diagnostic sensor, matching the one the battery already has. Off by default. -- **Circuit Priority's shed policy is readable.** The `dsm_state` sensor gains `shed_algorithm` and the two state-of-charge thresholds that decide when circuits - shed and when they come back — the numbers that make the panel's shed behaviour predictable rather than surprising. A policy this integration does not - recognise keeps its name and carries the panel's raw document beside it, so you can read what a parser could not. - -- **A charge-current limit you can set, on panels running the v1.0 data model.** Each commissioned SPAN Drive gets an **EVSE Charge Current Limit** number on - its own device — the ceiling the charger offers your vehicle, which you can lower to charge more slowly and raise back. It is the first control this - integration has that changes something on a charger rather than on the panel. -- The maximum you can ask for is the one your installer commissioned, read from the panel rather than assumed: the box will not accept a value above the - charger's rated current, and neither will anything else — a value beyond it is refused before it is sent, not quietly rounded down to something you did not - ask for. If the panel has not yet published what the charger is rated for, the control reports unavailable instead of offering an invented range. -- The control appears only where the panel says the limit can be changed. A charger that publishes its limit as read-only gets no control, which is the same - distinction **Circuit Priority** already makes for a circuit commissioned never-backup. -- While the panel is acknowledging a change it has not yet applied, the requested value shows as a `charge_current_limit_target` attribute and the state stays - the limit the charger is still enforcing — the same way Circuit Priority reports a priority change in flight. - -- **Whether your panel can reach your solar inverter and each of your chargers, on panels running the v1.0 data model.** **PV Panel Link** and **EVSE Panel - Link** are the same fact **BESS Connected** has always shown for the battery: the panel's own report of the link to a device it feeds. The battery's version - worked because the panel publishes it on the main lugs; the inverter's and each charger's are published by the circuit that feeds them, and nothing read that - half — so one of your three device classes had a link sensor and the others did not. -- **EVSE Panel Link is not EV Connected.** EV Connected is the charger reporting that a vehicle is plugged in. EVSE Panel Link is the panel reporting that it - can reach the charger at all. A charger part-way through a session behind a link the panel has lost shows a plugged-in vehicle and a dead link at the same - time, which is exactly the case where you want to know which of the two you are looking at. The new sensors are diagnostics; EV Connected is unchanged. -- Each sensor is created only where a circuit publishes the link record for that device, and per charger rather than per panel — two chargers whose circuits - report differently get two sensors that say different things. A circuit that feeds ordinary loads publishes no such record, which is normal and is not - reported as a fault, and a panel that starts publishing one picks the sensors up on the reload the integration already performs. - -- **Your battery's own meter and its own link health, on panels running the v1.0 data model.** **Meter Power** is what the BESS itself reports it is charging or - discharging at, as distinct from the panel's **Battery Power**, which is the enclosure's arbitrated figure. Both have been on the wire since firmware r202633 - and nothing read either. Meter Power is enabled by default; **Communication State** — the BESS's own `OK` / `DEGRADED` / `LOST` / `UNKNOWN` report on its link - — is a diagnostic and is off by default, since it is only interesting when something is wrong. -- **Both battery power sensors read positive when the battery is _discharging_**, and that direction was settled by measurement rather than by reading. With the - battery driven into self-consumption and the grid at exactly zero — PV 4181 W plus battery 1917 W meeting a 6099 W load, so the battery can only be supplying - — both sensors read `+1917.49`. The two values arrive from the panel in opposite sign conventions and are normalised to this one, so they agree with each - other and with the sensors beside them: PV Power is positive while producing, Grid Power Flow positive while importing, Battery Power positive while - discharging. Every one is "positive means power flowing toward the house". -- **Communication State is not the same thing as BESS Connected.** The binary sensor is the _panel's_ view of the link, from the enclosure's connection record; - the new sensor is the _battery's_ view of it. A BESS can report its own link lost while the panel still claims it, and now you can see that. -- Both sensors are created only where the BESS publishes the reading behind them — a battery on the older data model, or one whose firmware publishes only one - of the two, gets no entity for what it cannot report rather than one permanently unknown, and a BESS that gains the capability on a firmware upgrade picks the - sensors up on the reload the integration already performs. -- **Backup planning, in minutes: two new sensors on panels running the v1.0 data model.** **Time to Priority Shed** is how long before the panel starts shedding - circuits, and **Backup Time Remaining** is how long before the battery is spent. Your panel has been publishing both since firmware r202633 and nothing read - them; they are the numbers you would actually set an alarm on, so they are enabled by default and sit beside the power and energy sensors rather than under - diagnostics. -- **Each forecast sensor carries the refinements that qualify it** as attributes: `full_charge_time_to_priority_shed` / `full_charge_total_time_remaining` — - what the same estimate would be from a full battery — and `forecast_confidence`, the panel's own `LOW` / `MEDIUM` / `HIGH` assessment of the estimate. They - refine a number already on screen rather than adding two near-constant entities to your entity list. -- Both sensors are created only where the panel publishes the estimate behind them. A panel on the older data model, or one whose firmware publishes only part - of the forecast, gets no entity for what it cannot report rather than one permanently unknown — and a panel that gains the capability on a firmware upgrade - picks the sensors up on the reload the integration already performs. -- **What is limiting your import, in amps: three new entities on panels running the v1.0 data model.** **Import Limit** is the current limit your panel is - actually enforcing, **Binding Constraint** names which rule set it — your service rating, a utility envelope, an operator cap, a limit you asked for — and - **PCS Active** says whether anything is being throttled right now. Your panel has published all of this since firmware r202633 and nothing read it. -- **Import Limit carries the whole arbitration as attributes**: the four constraint limits the panel reconciled (`feed_import_limit`, `operator_import_limit`, - `off_grid_import_limit`, `requested_import_limit`), each one's `_enablement` and `_active` flag, and `pcs_enabled`. They explain the enforced number rather - than being numbers to watch, and most of them change only when somebody reconfigures the panel — so they refine an entity you already have instead of adding - twelve to your entity list. -- **Every circuit's power sensor gains `pcs_managed` and `pcs_priority`** where the circuit reports them: whether the Power Control System manages that circuit, - and where it sits in the shed order when an import limit binds. `pcs_priority` is a different thing from the existing `shed_priority`, which is the backup - tier — a circuit may take part in one policy, both, or neither. -- All three entities are created wherever the panel publishes the capability, **including when the PCS is switched off**. A panel reporting a 0 A limit with - everything unconfigured is reporting a state, and that is the state most panels are in; entities that vanished until somebody configured a limit would be - entities nobody could build a dashboard on. - - **The Wi-Fi network name moved to the Wi-Fi Link sensor**, which is where you would look for it: the entity that tells you whether Wi-Fi is up now also tells you which network it is up on, as a `wifi_ssid` attribute. It is absent rather than blank on a panel that publishes no SSID. - **It is no longer an attribute of the Software Version sensor.** A network name on a firmware-version sensor never made sense — it sat there because @@ -266,22 +146,9 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of ### Fixed - **Enum sensors advertise the states they can actually report.** Nine sensors declared only `unknown`, so `DSM Grid State` sitting at `On Grid` showed - "Possible states: Unknown". The lists are now derived from the translations and checked against them by a test. -- **The GFE override button reads the right signal** for deciding when it applies, so it is no longer permanently enabled on the new data model. - -- **The Wi-Fi network name came back.** Panels on the older data model report the SSID they are joined to, and this integration has shown it as an attribute on - the panel status sensor for as long as it has existed. On the v1.0 data model nothing read it, so the attribute quietly emptied when your panel upgraded — a - value you had, silently gone, with no error and nothing in the log. It is read again, and it is now published on the Wi-Fi Link binary sensor rather than on - the Software Version sensor — see above. -- **A firmware upgrade that adds a capability now actually reloads.** The check that decides whether new hardware warrants a reload knew about four capabilities - where the rest of the integration knew about nine. A panel that gained the shed forecast, the power control system, battery telemetry or DER link health while - Home Assistant was running published the data, matched every rule for creating the entities, and asked for no reload — so the new entities appeared only the - next time you restarted. This affected the Microgrid Interconnect Device before this release too. - -- **The README described Battery Power's sign backwards, and an earlier entry in this file "corrected" it the wrong way.** The sensor reports **discharging** as - positive and always has — that is what release 2.0.5 established (#184) and what a measured panel confirms. A later note claimed the opposite and the README - was edited to match it, so both documents told you to expect the wrong sign. Both are now right. **No entity changed and no reading moved**; only the - documentation was ever wrong, in both directions. + "Possible states: Unknown". +- **The README described Battery Power's sign backwards.** The sensor reports **discharging** as positive and always has — that is what release 2.0.5 + established (#184) and what a measured panel confirms. **No entity changed and no reading moved**; only the documentation was ever wrong. ## [2.0.8] - 5/2026 @@ -316,11 +183,6 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of ## [2.0.6] - 4/2026 -**Important** 2.0.x cautions still apply — read those carefully if not already on 2.0.x BEFORE proceeding: - -- Requires firmware `spanos2/r202603/05` or later (v2 eBus MQTT) -- You _must_ already be on v1.3.x or later of the SpanPanel/span integration if upgrading - ### Added - **By Activity and By Area views** — Two new circuit views available in both the integration panel and the Lovelace card (span-card 0.9.2): @@ -342,11 +204,6 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of ## [2.0.5] - 4/2026 -**Important** 2.0.x cautions still apply — read those carefully if not already on 2.0.x BEFORE proceeding: - -- Requires firmware `spanos2/r202603/05` or later (v2 eBus MQTT) -- You _must_ already be on v1.3.x or later of the SpanPanel/span integration if upgrading - ### Added - **Current monitoring and dashboard** — Real-time monitoring of circuit and mains current draw, managed from a new sidebar panel with Panel, Monitoring, and @@ -380,11 +237,6 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of ## [2.0.4] - 3/2026 -**Important** 2.0.1 cautions still apply — read those carefully if not already on 2.0.1 BEFORE proceeding: - -- Requires firmware `spanos2/r202603/05` or later (v2 eBus MQTT) -- You _must_ already be on v1.3.x or later of the SpanPanel/span integration if upgrading - ### Added - **Grid Power sensor** — New `Grid Power`. Previously only `Current Power` (upstream lugs measurement) was available; the new sensor surfaces the panel's own @@ -414,11 +266,6 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of ## [2.0.3] - 3/2026 -**Important** 2.0.1 cautions still apply — read those carefully if not already on 2.0.1 BEFORE proceeding: - -- Requires firmware `spanos2/r202603/05` or later (v2 eBus MQTT) -- You _must_ already be on v1.3.x or later of the SpanPanel/span integration if upgrading - ### Fixed - **Force dependency re-resolution** — Version bump to ensure HACS re-installs `span-panel-api` for users who had the earlier 2.0.2 release. Users upgrading HA @@ -426,11 +273,6 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of ## [2.0.2] - 3/2026 -**Important** 2.0.1 cautions still apply — read those carefully if not already on 2.0.1 BEFORE proceeding: - -- Requires firmware `spanos2/r202603/05` or later (v2 eBus MQTT) -- You _must_ already be on v1.3.x or later of the SpanPanel/span integration if upgrading - ### Fixed - **Panel size always available** — `panel_size` is now sourced from the Homie schema by the underlying `span-panel-api` Previously some users could see fewer @@ -446,13 +288,13 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of ## [2.0.1] - 3/2026 -⚠️ **STOP — If your SPAN panel is not on firmware `spanos2/r202603/05` or later, do not upgrade. Ensure you are on v1.3.0 or later BEFORE upgrading to 2.0. This -upgrade migrates to the SPAN official eBus API. Make a backup first.** ⚠️ +**This is the release that moved the integration to the SPAN official eBus API**, which every release since reads and every panel now runs. The prerequisites +below applied while panels were still being updated to `spanos2/r202603/05`; they are recorded here for history. ### Breaking Changes -- Requires firmware `spanos2/r202603/05` or later (v2 eBus MQTT) -- You _must_ already be on v1.3.0 or later of the SpanPanel/span integration if upgrading +- Required firmware `spanos2/r202603/05` or later (the eBus MQTT API) +- You had to already be on v1.3.0 or later of the SpanPanel/span integration to upgrade - After upgrading, you must re-authenticate using your **panel passphrase** (found in the SPAN mobile app under On-premise settings) or **proof of proximity** (open and close the panel door 3 times). See the [README](README.md) for details. - If you were running a beta or RC, ensure you reload the integration after upgrade diff --git a/README.md b/README.md index 19db6460..9de13b52 100644 --- a/README.md +++ b/README.md @@ -44,47 +44,41 @@ This integration communicates with the SPAN Panel over your local network using infrastructure. eBus uses the [Homie Convention](https://homieiot.github.io/) for MQTT topics and messages, with the panel's built-in MQTT broker delivering real-time state updates without polling. -## ⚠️ Upgrade to 2.1.x before your panel's firmware updates, or the integration will stop working +## ⚠️ Backup and Upgrade to 2.1.x before your panel's firmware updates, or the integration will stop working (upgrade only from v2.0.8!) -**SPAN firmware `r202633` breaks every integration release up to and including 2.0.8.** When your panel takes that update, 2.0.8 stops being able to read it. -The integration still connects, still shows as loaded, and reports every circuit as missing — sensors go unavailable, automations stop firing, dashboards go +**SPAN firmware `r202633` changes the API in a non-compatible way after the firmware hits** When your panel takes that update, 2.0.8 stops being able to read +it. The integration still connects, still shows as loaded, and reports every circuit as missing — sensors go unavailable, automations stop firing, dashboards go blank. It does not fail loudly. It goes quiet. **Nobody outside SPAN knows when your panel will update, and you cannot defer it from Home Assistant.** Panels update on SPAN's timing. There is no schedule to -plan around, which is why the safe move is to be on 2.1.x already rather than to wait for a signal that never comes. +plan around, which is why the safe move is to be on 2.1.x already rather than to wait for a signal that is less appealing. -**Upgrading first costs you nothing.** On your current firmware, 2.1.x reads your panel exactly as 2.0.8 does — same entities, same ids, same history. It simply -also knows how to read the new format when it arrives. +**Upgrading first costs you nothing.** On your current firmware, 2.1.x reads your panel exactly as 2.0.8 does. -**And then the changeover is seamless.** The integration detects the new format on the wire, reloads itself, and carries on: +The changeover is designed to be seamless.** The integration detects the new format on the wire, reloads itself, and carries on: -- No reconfiguration, no re-pairing, no re-authentication. -- Entity ids, unique ids and long-term statistics all survive. Dashboards, automations and history follow. +- no re-pairing, no re-authentication. +- Entity ids, unique ids and long-term statistics survive. Dashboards, automations and history follow. - New entities appear because the new firmware genuinely publishes more. Nothing you already had is removed. -Upgrade afterwards instead and you reach the same place — after however long it takes you to notice, and to work out that a firmware update you were never told -about is the reason your panel went silent. +Upgrade afterwards instead and you reach the same place — after however long it takes you to notice, and to work out that a firmware update is the reason your +panel went silent. Worst case should be a reload. + +Take another backup after the upgrade.
-What actually changes in the firmware +What changes in the firmware -`r202633` rewrites how the panel publishes everything, rather than adding to it. The MQTT topic structure changes, and the panel stops presenting itself as a -single flat device with a long list of properties. It presents a tree instead: the enclosure, with its lugs, circuits, battery, chargers and Microgrid -Interconnect Device as separate devices beneath it. Every topic an integration reads moves. The old format is retired in the same update that introduces the new -one — there is no overlap and no setting to keep the old behaviour. +The firmware upgrade `r202633` rewrites _how_ the panel publishes its self-describing BOM. The MQTT topic structure changes, and the panel stops presenting +itself as a long list of properties. It presents a tree that can proxy other devices instead. Every topic moves. -This has happened before, in exactly this shape. Firmware `spanos2/r202603/05` removed the v1 REST API this integration was originally built on, which is what -the 2.0.x breaking-change notice below is about. `r202633` does the same thing to the flat MQTT format that replaced it. +The old format is retired in the same update that introduces the new one — there is no overlap and no setting to keep the old behaviour.
-## 1.1.x Integration Sunset (v1) - -Users MUST upgrade by the end of 2026 to avoid disruption. Upgrade to the latest 1.1.x version BEFORE upgrading to 2.0.x. - ## 2.0.x Breaking Changes (v2) -**Do NOT upgrade unless your panel is running firmware `spanos2/r202603/05` or later.** +**Do NOT upgrade unless your panel is running firmware `spanos2/r202603/05` or later and you are on 2.0.8 of the integration.** **What you need:** @@ -166,35 +160,33 @@ The following terms appear throughout this document and in the integration's sen | Feed Through Net Energy | Energy | Wh | Feedthrough net energy | | DSM State | — | — | dsm_on_grid (grid connected), dsm_off_grid (islanded), unknown. Derived from multiple eBus signals | | Current Run Config | — | — | panel_on_grid (grid connected), panel_off_grid (islanded on PV/generator), panel_backup (islanded on battery), unknown | -| Grid Forming Entity | — | — | (v2) GRID, BATTERY, PV, GENERATOR, NONE, UNKNOWN. See [Grid Forming Entity](#grid-forming-entity) | +| Grid Forming Entity | — | — | GRID, BATTERY, PV, GENERATOR, NONE, UNKNOWN. See[Grid Forming Entity](#grid-forming-entity) | | Main Relay State | — | — | closed (power flowing), open (disconnected), unknown | -| Vendor Cloud | — | — | (v2) CONNECTED, UNCONNECTED, UNKNOWN | +| Vendor Cloud | — | — | CONNECTED, UNCONNECTED, UNKNOWN | | Software Version | — | — | Firmware version string | -### Panel Diagnostic Sensors (v2 only) +### Panel Diagnostic Sensors -| Sensor | Device Class | Unit | Notes | -| --------------------- | ------------ | ---- | -------------------------- | -| L1 Voltage | Voltage | V | L1 leg actual voltage | -| L2 Voltage | Voltage | V | L2 leg actual voltage | -| Upstream L1 Current | Current | A | Upstream lugs L1 current | -| Upstream L2 Current | Current | A | Upstream lugs L2 current | +| Sensor | Device Class | Unit | Notes | +| --------------------- | ------------ | ---- | ----------------------------------------------------------------- | +| L1 Voltage | Voltage | V | L1 leg actual voltage | +| L2 Voltage | Voltage | V | L2 leg actual voltage | +| Upstream L1 Current | Current | A | Upstream lugs L1 current | +| Upstream L2 Current | Current | A | Upstream lugs L2 current | | Downstream L1 Current | Current | A | Downstream lugs L1 current. Off by default from 2.1.x — see below | | Downstream L2 Current | Current | A | Downstream lugs L2 current. Off by default from 2.1.x — see below | -| Main Breaker Rating | Current | A | Main breaker amperage. Off by default | +| Main Breaker Rating | Current | A | Main breaker amperage. Off by default | L1/L2 Voltage and Main Breaker Rating have always been off by default; enable them from the panel's device page if you want them. -**The two Downstream current sensors are off by default from 2.1.x, and so are the three Feedthrough sensors.** The eBus specification's maintainer has -documented that the panel's feedthrough (downstream lugs) figures cannot be relied on: the energy registers are computed from unrelated counters and can -decrease or go negative, the power reading is inverted relative to every other terminal, and the downstream currents report the **upstream** service conductors -rather than a downstream measurement. These defects predate `r202633` and are not introduced by it. +**The three Feedthrough sensors and the two Downstream current sensors are off by default from 2.1.x.** The eBus specification's maintainer has documented that +the panel's feedthrough (downstream lugs) figures cannot be relied on: the energy registers can decrease or go negative, the power reading is inverted relative +to every other terminal, and the downstream currents report the **upstream** service conductors. The defects predate `r202633`. -If you already have any of those five they stay exactly where they are, with their history and their entity ids — Home Assistant applies the off-by-default -setting only when an entity is first created, so this reaches new installations only. If you use them on a dashboard or in an automation they are worth -removing, but that is your call, not something an upgrade should do to you. +Existing installations keep all five, with their history and entity ids — Home Assistant applies the setting only when an entity is first created. If you use +them somewhere, removing them is worth considering, but that is your call. -### Shed Forecast Sensors (v1.0 data model only) +### Shed Forecast Sensors Created only when your panel publishes the `shed-forecast` capability, and only for the estimates it actually publishes. @@ -207,46 +199,46 @@ Created only when your panel publishes the `shed-forecast` capability, and only Present only when the panel publishes them. -| Attribute | Type | On | Notes | -| ----------------------------------- | ------ | --------------------- | ------------------------------------------------------ | -| `full_charge_time_to_priority_shed` | int | Time to Priority Shed | The same estimate assuming the battery starts full | -| `full_charge_total_time_remaining` | int | Backup Time Remaining | The same estimate assuming the battery starts full | -| `forecast_confidence` | string | both | The panel's own assessment: `LOW`, `MEDIUM`, or `HIGH` | +| Attribute | Type | On | Notes | +| ----------------------------------- | ------ | --------------------- | ----------------------------------------------------- | +| `full_charge_time_to_priority_shed` | int | Time to Priority Shed | The same estimate assuming the battery starts full | +| `full_charge_total_time_remaining` | int | Backup Time Remaining | The same estimate assuming the battery starts full | +| `forecast_confidence` | string | both | The panel's own assessment:`LOW`, `MEDIUM`, or `HIGH` | -### Power Control System Sensors (v1.0 data model only) +### Power Control System Sensors Created only when your panel publishes the `pcs` capability, and created whether or not the PCS is switched on — a PCS reporting a limit of 0 A is reporting a state, not an absence. | Sensor | Device Class | Unit | Notes | | ------------------ | ------------ | ---- | -------------------------------------------------------------------------------------------------------------------------- | -| Import Limit | Current | A | The limit actually being enforced: the most restrictive active constraint (diagnostic) | +| Import Limit | Current | A | The limit actually being enforced: the most restrictive active constraint (diagnostic) | | Binding Constraint | Enum | — | Which constraint sets that limit: Firm Service Rating, Grid Envelope, Voltage Support, Off-Grid, Requested, Operator, None | #### Power Control System Sensor Attributes On **Import Limit**, and present only when the panel publishes them. These are the inputs the panel reconciled to produce the enforced limit above. -| Attribute | Type | Notes | -| ------------------------ | ------ | --------------------------------------------------------------- | -| `pcs_enabled` | bool | Whether the panel's PCS is enabled at all | -| `feed_import_limit` | float | The Firm Service Rating: the commissioned, always-on floor (A) | -| `operator_import_limit` | float | A cap imposed by a fleet or aggregator operator (A) | -| `off_grid_import_limit` | float | The import cap while islanded (A) | -| `requested_import_limit` | float | A voluntary limit requested by the owner or installer (A) | -| `_enablement` | string | Per limit: `UNSPECIFIED`, `UNCONFIGURED`, `DISABLED`, `ENABLED` | -| `_active` | bool | Per limit: whether that constraint is currently enforcing | - -### Power Flow Sensors (v2 only) - -| Sensor | Device Class | Unit | Notes | -| ------------- | ------------ | ---- | ------------------------------------------------------------------------------- | -| Grid Power | Power | W | Grid power flow | -| Site Power | Power | W | Total site power (grid + PV + battery) | +| Attribute | Type | Notes | +| ------------------------ | ------ | -------------------------------------------------------------- | +| `pcs_enabled` | bool | Whether the panel's PCS is enabled at all | +| `feed_import_limit` | float | The Firm Service Rating: the commissioned, always-on floor (A) | +| `operator_import_limit` | float | A cap imposed by a fleet or aggregator operator (A) | +| `off_grid_import_limit` | float | The import cap while islanded (A) | +| `requested_import_limit` | float | A voluntary limit requested by the owner or installer (A) | +| `_enablement` | string | Per limit:`UNSPECIFIED`, `UNCONFIGURED`, `DISABLED`, `ENABLED` | +| `_active` | bool | Per limit: whether that constraint is currently enforcing | + +### Power Flow Sensors + +| Sensor | Device Class | Unit | Notes | +| ------------- | ------------ | ---- | ----------------------------------------------------------------------------------- | +| Grid Power | Power | W | Grid power flow | +| Site Power | Power | W | Total site power (grid + PV + battery) | | Battery Power | Power | W | Battery charge/discharge (**+discharging, −charging**). Only when BESS commissioned | -| PV Power | Power | W | PV generation (+producing). Only when PV commissioned | +| PV Power | Power | W | PV generation (+producing). Only when PV commissioned | -### PV Metadata Sensors (v2 only, on the Solar sub-device) +### PV Metadata Sensors (on the Solar sub-device) From 2.1.x these live on a **Solar** device of their own rather than on the panel's card, alongside PV Power and PV Panel Link. @@ -254,39 +246,37 @@ From 2.1.x these live on a **Solar** device of their own rather than on the pane | ------------------ | ------------ | ---- | --------------------------------------------- | | PV Vendor | — | — | PV inverter vendor (e.g., "Enphase", "Other") | | PV Product | — | — | PV inverter product (e.g., "IQ8+") | -| Nameplate Capacity | Power | kW | Rated inverter capacity. Off by default | +| Nameplate Capacity | Power | kW | Rated inverter capacity. Off by default | -If you upgraded, these keep their entity ids, unique ids and history — only the card changes. **They do not keep the panel's area.** An entity takes its area -from its device, and the new Solar device starts without one, so anything area-scoped (dashboards, automations, voice targeting a room) stops matching them -until you assign the Solar device to an area. New installations get ids derived from the new device name, so a system installed from 2.1.x onward has -`sensor.span_panel_solar_pv_vendor` where an upgraded one keeps `sensor.span_panel_pv_vendor`. Both are correct and neither changes again. +If you upgraded, these keep their entity ids, unique ids and history — but not the panel's area, since an entity takes its area from its device and the Solar +device starts without one. Assign it an area, or anything area-scoped (dashboards, automations, voice targeting a room) stops matching them. New installations +get ids from the new device name — `sensor.span_panel_solar_pv_vendor` rather than `sensor.span_panel_pv_vendor`. Both are correct and neither changes again. **Deprecated:** -| Sensor | Reason | -| -------------- | ------------------------------------------------------------------------------------------------------------------------- | -| DSM Grid State | Deprecated — still available, but users should rely on `DSM State` as `DSM Grid State` may be removed in a future version | +| Sensor | Reason | +| -------------- | ------------------------------------------------------------------------------------------------------------------------ | +| DSM Grid State | Deprecated — still available, but users should rely on`DSM State` as `DSM Grid State` may be removed in a future version | -### Microgrid Interconnect Device (v1.0 data model only) +### Microgrid Interconnect Device -Panels on the v1.0 data model publish their Microgrid Interconnect Device — the switch that disconnects your home from the utility during an outage — as a -device of its own, linked to the panel. It appears automatically where the panel reports one; nothing existing moves onto it. +Your panel publishes its Microgrid Interconnect Device — the switch that disconnects your home from the utility during an outage — as a device of its own, +linked to the panel. It appears automatically where the panel reports one; nothing existing moves onto it. -| Sensor | Device Class | Unit | Notes | -| ---------- | ------------ | ---- | --------------------------------------------------------------------------- | -| Grid State | Enum | — | Health of the utility supply itself: `up`, `down`, `degraded` or `unknown` | +| Sensor | Device Class | Unit | Notes | +| ---------- | ------------ | ---- | ------------------------------------------------------------------------- | +| Grid State | Enum | — | Health of the utility supply itself:`up`, `down`, `degraded` or `unknown` | -This is genuinely new information — the previous firmware did not report the state of the utility supply at all. It is not the same thing as **DSM Grid State**, -which is whether **your home** is islanded. The grid can be down while your home runs happily off the battery. +This is new information — the previous firmware did not report the utility supply at all. It is not **DSM Grid State**, which is whether **your home** is +islanded: the grid can be down while your home runs happily off the battery. -**A panel with no battery has no MID, and that is itself an answer.** The specification makes backup capability structural rather than a property — the presence -of a MID is what says a panel can island — so **Grid Islandable** reads `Off` on such a panel rather than going unavailable, and **Grid Forming Entity** reads -`Grid`. Neither is a default standing in for a missing reading; both are what the absence of islanding hardware means. +**A panel with no battery has no MID, and that is itself an answer.** The specification makes backup capability structural — having a MID is what says a panel +can island — so **Grid Islandable** reads `Off` and **Grid Forming Entity** reads `Grid` rather than either going unavailable. -`DSM Grid State` also becomes more trustworthy on this data model. It keeps its entity id and its history, but where it used to be inferred — from the battery -when one was fitted, otherwise from the dominant power source — it now reads the islanding state the MID actually senses. +`DSM Grid State` keeps its entity id and history but is no longer inferred from the battery or the dominant power source; it now reads the islanding state the +MID actually senses. -### Adopted Devices (v1.0 data model only) +### Adopted Devices The eBus schema is vendor-extensible, so your panel can publish a device type this integration has never modelled. Rather than ignoring it, the integration gives it a card of its own hanging off the panel, carrying whatever identity it publishes, with its readings as entities beneath it. @@ -314,9 +304,9 @@ Applies to Current Power, Feed Through Power, Battery Power, PV Power, Grid Powe **Grid Power** carries one more, because its name is only true in some wiring: -| Attribute | Type | Notes | -| ---------------------- | ------- | --------------------------------------------------------------------------- | -| `at_service_entrance` | boolean | Whether this panel's upstream lugs are where the utility actually connects | +| Attribute | Type | Notes | +| --------------------- | ------- | -------------------------------------------------------------------------- | +| `at_service_entrance` | boolean | Whether this panel's upstream lugs are where the utility actually connects | Grid Power reads the upstream lugs. That is grid flow when those lugs are the utility connection point, which is the ordinary case. Put a battery between the utility and your main lugs, or feed this panel from another panel, and the same reading becomes **this panel's** supply while **Grid Power Flow** stays the @@ -363,21 +353,21 @@ feature. A display suffix differentiates multiple chargers on the same panel: | Charger Status | Enum | — | OCPP-based states: AVAILABLE, PREPARING, CHARGING, SUSPENDED_EV, etc. Translated | | Advertised Current | Current | A | Amps offered to the vehicle | | Lock State | Enum | — | LOCKED, UNLOCKED, UNKNOWN. Translated | -| Part Number | — | — | Charger part number (diagnostic, **off by default**). v1.0 data model only | +| Part Number | — | — | Charger part number (diagnostic,**off by default**) | #### EVSE Binary Sensors (per charger) -| Sensor | Device Class | Notes | -| --------------- | ---------------- | ------------------------------------------------------------------------------------------------------- | -| Charging | Battery Charging | ON when status is CHARGING | -| EV Connected | Plug | ON when status is PREPARING, CHARGING, SUSPENDED\_\*, or FINISHING — a vehicle is plugged in | -| EVSE Panel Link | Connectivity | (v1.0) Whether the panel can reach the charger. A different fact from EV Connected, and it can disagree | +| Sensor | Device Class | Notes | +| --------------- | ---------------- | ------------------------------------------------------------------------------------------------ | +| Charging | Battery Charging | ON when status is CHARGING | +| EV Connected | Plug | ON when status is PREPARING, CHARGING, SUSPENDED\_\*, or FINISHING — a vehicle is plugged in | +| EVSE Panel Link | Connectivity | Whether the panel can reach the charger. A different fact from EV Connected, and it can disagree | **EVSE Panel Link is not EV Connected.** EV Connected is what the charger says about the cable in front of it; EVSE Panel Link is what the panel says about whether it can reach the charger at all. A charger part-way through a session behind a lost link reports a plugged-in vehicle and a dead link at the same time. EVSE Panel Link is a diagnostic and appears only where the circuit feeding that charger publishes the link record. -#### EVSE Controls (per charger, v1.0) +#### EVSE Controls (per charger) | Control | Platform | Unit | Notes | | ------------------------- | -------- | ---- | --------------------------------------------------------------------------------------- | @@ -397,26 +387,26 @@ charger is still enforcing. | Serial Number | `serial-number` | | Software Version | `software-version` | -### BESS Sub-Device (v2 only, conditional) +### BESS Sub-Device (conditional) When a Battery Energy Storage System (BESS) is commissioned, the integration creates a separate BESS sub-device linked to the panel via `via_device`. The BESS device uses manufacturer, model, serial number, and software version from battery metadata as device info attributes. #### BESS Sensors -| Sensor | Device Class | Unit | Notes | -| ------------------- | -------------- | ---- | ------------------------------------------------------------------------------------------------ | -| Battery Level | Battery | % | State of energy as percentage | -| Battery Power | Power | W | Same entity as Power Flow Battery Power, shown on BESS sub-device | -| Meter Power | Power | W | The BESS's own meter (**+discharging, −charging**), agreeing with Battery Power. v1.0 data model only | -| Communication State | — | — | The BESS's report of its own link health (diagnostic, disabled by default). v1.0 data model only | -| BESS Vendor | — | — | Battery system vendor (diagnostic) | -| BESS Model | — | — | Battery system model (diagnostic) | -| BESS Part Number | — | — | Battery system part number (diagnostic, **off by default**). v1.0 data model only | -| BESS Serial Number | — | — | Battery system serial number (diagnostic) | -| BESS Firmware | — | — | Battery system firmware (diagnostic) | -| Nameplate Capacity | Energy Storage | kWh | Rated battery capacity (diagnostic, **off by default**) | -| Stored Energy | Energy Storage | kWh | Current stored energy (diagnostic) | +| Sensor | Device Class | Unit | Notes | +| ------------------- | -------------- | ---- | ------------------------------------------------------------------------------- | +| Battery Level | Battery | % | State of energy as percentage | +| Battery Power | Power | W | Same entity as Power Flow Battery Power, shown on BESS sub-device | +| Meter Power | Power | W | The BESS's own meter (**+discharging, −charging**), agreeing with Battery Power | +| Communication State | — | — | The BESS's report of its own link health (diagnostic, disabled by default) | +| BESS Vendor | — | — | Battery system vendor (diagnostic) | +| BESS Model | — | — | Battery system model (diagnostic) | +| BESS Part Number | — | — | Battery system part number (diagnostic,**off by default**) | +| BESS Serial Number | — | — | Battery system serial number (diagnostic) | +| BESS Firmware | — | — | Battery system firmware (diagnostic) | +| Nameplate Capacity | Energy Storage | kWh | Rated battery capacity (diagnostic,**off by default**) | +| Stored Energy | Energy Storage | kWh | Current stored energy (diagnostic) | #### BESS Binary Sensors @@ -440,22 +430,22 @@ Applies to Main Meter and Feed Through energy sensors. | Produced Energy | Energy | Wh | Cumulative energy produced | | Consumed Energy | Energy | Wh | Cumulative energy consumed | | Net Energy | Energy | Wh | Net energy (sign depends on device type — PV circuits invert) | -| Current | Current | A | (v2) Measured circuit current. Only when panel reports `current_a` | -| Breaker Rating | Current | A | (v2) Circuit breaker amperage (diagnostic). Only when reported | +| Current | Current | A | Measured circuit current. Only when panel reports`current_a` | +| Breaker Rating | Current | A | Circuit breaker amperage (diagnostic). Only when reported | ### Circuit Power Sensor Attributes -| Attribute | Type | Notes | -| ----------------- | ------ | ----------------------------------------------------------------------------------------------------------------------- | -| `tabs` | string | Breaker slot position(s) | -| `voltage` | string | 120 or 240 (derived from tab count) | -| `always_on` | bool | Whether circuit is always-on | -| `relay_state` | string | OPEN / CLOSED / UNKNOWN | -| `relay_requester` | string | Who requested relay state | -| `shed_priority` | string | API value: NEVER / SOC_THRESHOLD / OFF_GRID / UNKNOWN | -| `is_sheddable` | bool | Whether circuit can be shed | -| `pcs_managed` | bool | (v1.0) Whether the panel's Power Control System manages this circuit. Present only when the circuit reports it | -| `pcs_priority` | int | (v1.0) This circuit's shed order under an active import limit — distinct from `shed_priority`, which is the backup tier | +| Attribute | Type | Notes | +| ----------------- | ------ | --------------------------------------------------------------------------------------------------------------- | +| `tabs` | string | Breaker slot position(s) | +| `voltage` | string | 120 or 240 (derived from tab count) | +| `always_on` | bool | Whether circuit is always-on | +| `relay_state` | string | OPEN / CLOSED / UNKNOWN | +| `relay_requester` | string | Who requested relay state | +| `shed_priority` | string | API value: NEVER / SOC_THRESHOLD / OFF_GRID / UNKNOWN | +| `is_sheddable` | bool | Whether circuit can be shed | +| `pcs_managed` | bool | Whether the panel's Power Control System manages this circuit. Present only when the circuit reports it | +| `pcs_priority` | int | This circuit's shed order under an active import limit — distinct from`shed_priority`, which is the backup tier | ### Circuit Energy Sensor Attributes @@ -466,34 +456,34 @@ Applies to Main Meter and Feed Through energy sensors. ### Binary Sensors -| Sensor | Device Class | Notes | -| --------------- | ------------ | -------------------------------------------------------------------------------------------------- | -| Door State | Tamper | Panel door open/closed | -| Ethernet Link | Connectivity | Wired network status | -| Wi-Fi Link | Connectivity | Wireless network status | -| Panel Status | Connectivity | Overall panel online/offline | -| Grid Islandable | — | (v2) Whether the panel can island from the grid. Off on a panel with no MID — see below | -| PCS Active | Running | (v1.0) Whether the Power Control System is limiting import right now. Only when the panel runs one | -| PV Panel Link | Connectivity | (v1.0) Whether the panel can reach the solar inverter. Only when the feeding circuit reports it | +| Sensor | Device Class | Notes | +| --------------- | ------------ | ------------------------------------------------------------------------------------------- | +| Door State | Tamper | Panel door open/closed | +| Ethernet Link | Connectivity | Wired network status | +| Wi-Fi Link | Connectivity | Wireless network status | +| Panel Status | Connectivity | Overall panel online/offline | +| Grid Islandable | — | Whether the panel can island from the grid. Off on a panel with no MID — see below | +| PCS Active | Running | Whether the Power Control System is limiting import right now. Only when the panel runs one | +| PV Panel Link | Connectivity | Whether the panel can reach the solar inverter. Only when the feeding circuit reports it | **Removed from binary sensors:** -| Sensor | Reason | -| --------------- | ------------------------------------------------------ | -| Cellular (wwan) | Replaced by `Vendor Cloud` sensor (cloud connectivity) | +| Sensor | Reason | +| --------------- | ----------------------------------------------------- | +| Cellular (wwan) | Replaced by`Vendor Cloud` sensor (cloud connectivity) | ### Circuit Controls (per user-controllable circuit) -| Entity | Type | Notes | -| --------------------- | ------ | -------------------------------------------------------------------------- | -| Breaker | Switch | On/off relay control | -| Circuit Priority | Select | (v2) Controls when the circuit is shed during off-grid (translated, see below) | +| Entity | Type | Notes | +| ---------------- | ------ | ------------------------------------------------------------------------- | +| Breaker | Switch | On/off relay control | +| Circuit Priority | Select | Controls when the circuit is shed during off-grid (translated, see below) | ### Panel Controls -| Entity | Type | Notes | -| ---------------------------- | ------ | ---------------------------------------------------------------------- | -| GFE Override: Grid Connected | Button | (v2) Tell the panel the grid is up when BESS communication interrupted | +| Entity | Type | Notes | +| ---------------------------- | ------ | ----------------------------------------------------------------- | +| GFE Override: Grid Connected | Button | Tell the panel the grid is up when BESS communication interrupted | ### BESS & Grid Management @@ -582,12 +572,14 @@ Configure via `Settings` > `Devices & Services` > `SPAN Panel` > `Configure` > ` The integration provides flexible entity naming patterns, configured during initial setup: 1. **Friendly Names** (Recommended for new installations) + - Entity IDs use descriptive circuit names from your SPAN panel - Example: `sensor.span_panel_kitchen_outlets_power` - Automatically updates when you rename circuits in the SPAN panel - More intuitive for automations and scripts 2. **Circuit Numbers** (Stable entity IDs) + - Entity IDs use generic circuit numbers - Example: `sensor.span_panel_circuit_15_power` - Entity IDs remain stable even when circuits are renamed @@ -636,9 +628,9 @@ See [WebSocket API Reference](websocket-api.md) for the full schema, response fo | Issue | Symptoms | Resolution | | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Energy Dashboard spikes after firmware updates** | Huge energy-consumption spikes after panel firmware updates; charts showing untracked values that dwarf normal usage; negative energy values in statistics. Caused by the panel reporting decreased values on otherwise `TOTAL_INCREASING` sensors. | **Prevention:** enable [Energy Dip Compensation](#energy-dip-compensation) in General Options (on by default for new installs). **Fix existing spikes:** in **Developer Tools → Statistics**, search for the affected sensor (e.g. `sensor.span_panel_main_meter_consumed_energy`) and use **Adjust sum** to correct the errant entry. The integration also notifies when a decrease in the main meter consumed sensor is detected. | -| **High CPU usage** | Elevated CPU on low-power hardware (e.g. Raspberry Pi). The integration rebuilds a full panel snapshot from MQTT messages at a configurable interval (default 1 s). | Increase **Snapshot Update Interval** in **General Options**. 10–15 s is recommended for resource-constrained systems. Setting it to 0 disables debouncing and rebuilds on every MQTT message — not recommended. | -| **Replaced sub-device shows the old serial number** | After replacing a SPAN sub-device (Drive / EVSE, BESS, PV inverter), the device entry in Home Assistant keeps showing the previous hardware's serial number. The integration keys entities off the panel-assigned node identity, which is intentionally stable across hardware swaps so long-term history (e.g. lifetime charging kWh for a Drive) is preserved. The device-registry serial number, however, does not auto-refresh. | In **Settings → Devices & Services → Span Panel**, open the affected sub-device and delete it, then reload the integration (or restart Home Assistant). The device re-registers with the new serial number. Entity IDs and their recorded history are preserved. | +| **Energy Dashboard spikes after firmware updates** | Huge energy-consumption spikes after panel firmware updates; charts showing untracked values that dwarf normal usage; negative energy values in statistics. Caused by the panel reporting decreased values on otherwise`TOTAL_INCREASING` sensors. | **Prevention:** enable [Energy Dip Compensation](#energy-dip-compensation) in General Options (on by default for new installs). **Fix existing spikes:** in **Developer Tools → Statistics**, search for the affected sensor (e.g. `sensor.span_panel_main_meter_consumed_energy`) and use **Adjust sum** to correct the errant entry. The integration also notifies when a decrease in the main meter consumed sensor is detected. | +| **High CPU usage** | Elevated CPU on low-power hardware (e.g. Raspberry Pi). The integration rebuilds a full panel snapshot from MQTT messages at a configurable interval (default 1 s). | Increase**Snapshot Update Interval** in **General Options**. 10–15 s is recommended for resource-constrained systems. Setting it to 0 disables debouncing and rebuilds on every MQTT message — not recommended. | +| **Replaced sub-device shows the old serial number** | After replacing a SPAN sub-device (Drive / EVSE, BESS, PV inverter), the device entry in Home Assistant keeps showing the previous hardware's serial number. The integration keys entities off the panel-assigned node identity, which is intentionally stable across hardware swaps so long-term history (e.g. lifetime charging kWh for a Drive) is preserved. The device-registry serial number, however, does not auto-refresh. | In**Settings → Devices & Services → Span Panel**, open the affected sub-device and delete it, then reload the integration (or restart Home Assistant). The device re-registers with the new serial number. Entity IDs and their recorded history are preserved. | | **Door sensor unavailable** | The SPAN API returns UNKNOWN if the cabinet door has not been operated recently. This is a defect in the SPAN API. | The integration reports the sensor as unavailable until a proper value arrives. Opening or closing the door publishes the correct state. The door is classified as a tamper sensor (`Detected` / `Clear`) to differentiate it from a normal entry door. | | **No switch on a circuit** | A circuit has no switch entity exposed in Home Assistant. | The circuit is configured in the SPAN App as one of the "Always on Circuits". The API does not permit user control of those circuits, so no switch is created. | From 2120074050d2f321eb127352580c081f48d10fb1 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:28:50 -0700 Subject: [PATCH 097/345] feat(extension): surface vendor properties on devices we already model A publisher adding a property to the BESS, a charger, a circuit or the panel reached the user nowhere: it became a declaration-only discovery row, visible in a diagnostics attachment and nowhere else. This integration should not be the gatekeeper on what a publisher can reach a user with, so those properties now become entities on the device card they belong to -- disabled, diagnostic, and named in plain wire vocabulary so they read as uncurated, which they are. **An adopted extension is a terminal identity.** Nothing promotes, re-homes or migrates one. It changes only on an external trigger: the publisher stops publishing it, or better metadata arrives. Curation is never blocked by one existing -- ids are permanent, identity is not. **Nothing is ever removed.** A row the user deletes returns, disabled, while the property is still published, so deletion is not suppression and none is built. Deletion sticks exactly when publishing has stopped, because then nothing exists to recreate it from. The unique_id is the one irreversible commitment, so it ships with its tests: span_{serial}_adopted_{scope}/{node}/{property} anchored on the serial and the curated scope, addressed by the wire path verbatim -- upstream's own capability-catalog spelling. Verbatim is what makes it injective: `battery-2` + `cell-temperature` and `battery` + `2-cell- temperature` collapse under any normalising scheme and stay distinct here. Not the eBus proxy composition, whose ids upstream states are local handles rather than identities and are unstable across the proxy-to-native transition. Read-only, no controls: these sit beside curated controls that do real work -- the EVSE limit refuses a value above the commissioned ceiling, the islanding assertion translates GRID into ON_GRID -- and a generic write path would have neither. The platform is a one-way door enforced in `resolve_platform`: an entity domain is part of entity_id and the registry refuses a cross-domain rename, so whatever domain a row is registered under wins however the declaration later changes. Metadata may reshape everything else freely, which is safe precisely because no extension entity carries a `state_class` and so has no statistics for a unit change to corrupt. A per-device cap bounds a misbehaving publisher, since rows are permanent and nothing removes them, and the additions notice collapses them one line per device -- fifteen new vendor readings must not cost the user the curated additions in the same message. Requires span-panel-api 3.0.0b12 and span-panel-api-schema-1 0.1.0b9, which carry `extension_properties`. --- custom_components/span_panel/additions.py | 30 ++ custom_components/span_panel/adoption.py | 14 +- custom_components/span_panel/binary_sensor.py | 13 +- custom_components/span_panel/extension.py | 502 ++++++++++++++++++ custom_components/span_panel/manifest.json | 4 +- custom_components/span_panel/sensor.py | 14 +- pyproject.toml | 4 +- requirements_test.txt | 4 +- tests/test_extension_entities.py | 343 ++++++++++++ tests/test_extension_ids.py | 178 +++++++ tests/test_new_entity_announcement.py | 36 ++ uv.lock | 4 +- 12 files changed, 1128 insertions(+), 18 deletions(-) create mode 100644 custom_components/span_panel/extension.py create mode 100644 tests/test_extension_entities.py create mode 100644 tests/test_extension_ids.py diff --git a/custom_components/span_panel/additions.py b/custom_components/span_panel/additions.py index b591ba40..516b19ec 100644 --- a/custom_components/span_panel/additions.py +++ b/custom_components/span_panel/additions.py @@ -34,6 +34,7 @@ from homeassistant.helpers.storage import Store from .const import DOMAIN +from .extension import is_extension_unique_id from .notices import async_raise, read_translations from .util import ADOPTED_IDENTIFIER_TOKEN @@ -167,6 +168,11 @@ def _message(hass: HomeAssistant, added: list[er.RegistryEntry], text: dict[str, for registry_entry in added: device_name = _adopted_device_name(devices, registry_entry) + if device_name is None: + # Vendor extensions collapse the same way, and need their own + # detector: they live on *curated* devices, so the identifier test + # above cannot see them. Their unique_id is what says what they are. + device_name = _extension_device_name(devices, registry_entry) if device_name is not None: adopted[device_name] = adopted.get(device_name, 0) + 1 continue @@ -218,6 +224,30 @@ def _adopted_device_name( return device.name_by_user or device.name or registry_entry.entity_id +def _extension_device_name( + devices: dr.DeviceRegistry, registry_entry: er.RegistryEntry +) -> str | None: + """Return the curated device a vendor extension belongs to, or None for anything else. + + Detected by unique_id rather than by device identifier, because that is the + only thing that distinguishes these: an extension entity sits on a *curated* + card beside curated entities, so the card says nothing about it. + + Collapsed for the same reason adopted devices are, and the arithmetic is + worse here: a firmware update adding fifteen vendor properties to the battery + would spend the entire notification on them and teach the user to skip it, + costing them the curated additions in the same message. + """ + if registry_entry.device_id is None or not registry_entry.unique_id: + return None + if not is_extension_unique_id(registry_entry.unique_id): + return None + device = devices.async_get(registry_entry.device_id) + if device is None: + return None + return device.name_by_user or device.name or registry_entry.entity_id + + def _label(devices: dr.DeviceRegistry, registry_entry: er.RegistryEntry) -> str: """Return what to call an entity the user has never seen. diff --git a/custom_components/span_panel/adoption.py b/custom_components/span_panel/adoption.py index 09869d57..ab9b9403 100644 --- a/custom_components/span_panel/adoption.py +++ b/custom_components/span_panel/adoption.py @@ -238,9 +238,9 @@ def adopted_device_info( """ return DeviceInfo( identifiers={(DOMAIN, identifier)}, - name=device.name or _humanised(device.device_type.rsplit(".", 1)[-1]), + name=device.name or humanised(device.device_type.rsplit(".", 1)[-1]), manufacturer=device.vendor_name or "Unknown", - model=device.model or _humanised(device.device_type.rsplit(".", 1)[-1]), + model=device.model or humanised(device.device_type.rsplit(".", 1)[-1]), serial_number=device.serial_number, sw_version=device.software_version, hw_version=device.hardware_version, @@ -248,7 +248,7 @@ def adopted_device_info( ) -def _humanised(wire_token: str) -> str: +def humanised(wire_token: str) -> str: """`backup-generator` -> `Backup Generator`, for a name with no translation. Deliberately plain. An adopted entity renders from wire vocabulary until it @@ -319,7 +319,7 @@ def __init__( self._device_wire_id = device.device_id self._declaration_path = declaration.path self._attr_unique_id = adopted_unique_id(identifier, declaration) - self._attr_name = _humanised(declaration.property_id) + self._attr_name = humanised(declaration.property_id) self._attr_device_info = adopted_device_info( identifier, device, panel_device_id=panel_device_id ) @@ -392,7 +392,7 @@ class AdoptedBinarySensor(AdoptedEntity, BinarySensorEntity): @property def is_on(self) -> bool | None: """Homie spells a boolean `true`/`false`; anything else is not an answer.""" - return _boolean(self._published()) + return homie_boolean(self._published()) class AdoptedControl(AdoptedEntity): @@ -440,7 +440,7 @@ class AdoptedSwitch(AdoptedControl, SwitchEntity): @property def is_on(self) -> bool | None: """Homie spells a boolean `true`/`false`; anything else is not an answer.""" - return _boolean(self._published()) + return homie_boolean(self._published()) async def async_turn_on(self, **kwargs: object) -> None: """Publish the vocabulary Homie defines for a boolean, not HA's.""" @@ -531,7 +531,7 @@ async def async_set_native_value(self, value: float) -> None: await self._publish(str(int(value)) if self._integral else str(value)) -def _boolean(published: str | None) -> bool | None: +def homie_boolean(published: str | None) -> bool | None: """Return Homie's `true`/`false`, with anything else meaning no answer.""" if published is None: return None diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py index 78c16461..16cb1633 100644 --- a/custom_components/span_panel/binary_sensor.py +++ b/custom_components/span_panel/binary_sensor.py @@ -14,7 +14,7 @@ BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -34,6 +34,7 @@ ) from .coordinator import SpanPanelCoordinator from .entity import SpanPanelEntity +from .extension import create_extension_binary_sensors from .field_paths import DerivedReason, FieldPathDeclarationMixin from .helpers import ( build_binary_sensor_unique_id_for_entry, @@ -682,5 +683,15 @@ async def async_setup_entry( dr.async_get(hass), panel_device_id=config_entry.runtime_data.panel_device_id, ), + # Vendor extensions on devices this integration *does* model. A + # separate inventory from adoption's for the same reason adoption is + # separate from the curated descriptions: different question, and + # `extension` owns the answer. + *create_extension_binary_sensors( + coordinator, + snapshot, + dr.async_get(hass), + er.async_get(hass), + ), ] ) diff --git a/custom_components/span_panel/extension.py b/custom_components/span_panel/extension.py new file mode 100644 index 00000000..6e2aa8fe --- /dev/null +++ b/custom_components/span_panel/extension.py @@ -0,0 +1,502 @@ +"""Identity for vendor extension properties on devices this integration models. + +The other half of vendor extensibility from `adoption.py`. That module adopts a +whole *device* nobody modelled; this one carries a new *property* on a device +this integration already models -- a battery vendor hanging `battery-2/ +cell-temperature` off the BESS -- which until now reached the user nowhere. + +**An adopted extension is a terminal identity.** It is a vendor reading on the +correct existing device card, disabled and diagnostic, in plain wire vocabulary, +and it stays that until an external trigger changes it: the publisher stops +publishing the property, or better metadata arrives. Nothing here promotes, +re-homes or migrates one on its own schedule, and curation is never blocked by +one existing -- an entity's `unique_id` and `entity_id` are permanent, its +*identity* carries no expectation of permanence beyond that. + +**Nothing is ever removed by this integration.** A row the user deletes is +recreated -- disabled, as it arrives -- for as long as the property is still +published, so deletion is not suppression and no suppression feature is needed. +Deletion sticks exactly when publishing has stopped, because then nothing exists +to recreate it from. The delete button therefore already means "hide until next +setup" for a live reading and "clear this out" for a dead one, decided by the +wire rather than by a feature. +""" + +from __future__ import annotations + +import logging +import re +from typing import Final + +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.const import EntityCategory, Platform +from homeassistant.helpers.device_registry import DeviceInfo, DeviceRegistry +from homeassistant.helpers.entity_registry import EntityRegistry +from span_panel_api import ExtensionProperty, ExtensionSubject, SpanPanelSnapshot + +from .adoption import BOOLEAN_DATATYPE, DEVICE_CLASS_BY_UNIT, homie_boolean, humanised +from .const import DOMAIN +from .coordinator import SpanPanelCoordinator +from .entity import SpanPanelEntity +from .util import ( + ADOPTED_IDENTIFIER_TOKEN, + SUB_DEVICE_BESS, + SUB_DEVICE_EVSE, + SUB_DEVICE_MID, + SUB_DEVICE_PV, +) + +_LOGGER = logging.getLogger(__name__) + +SCOPE_PANEL: Final = "panel" + +HINT_READING: Final = "reading" +HINT_DETAIL: Final = "detail" + +IDENTITY_FAMILY: Final = frozenset( + {"vendor", "model", "serial", "part-number", "firmware", "hardware", "build", "revision"} +) +"""Property-name tokens that mark a declaration as device description. + +Matched outside the `info` node, which never reaches adoption at all -- so a hit +here is catching a vendor that put identity on a capability node rather than +re-checking what `info` already resolved. +""" + +HOMIE_ID: Final = re.compile(r"^[a-z0-9-]+$") +"""The Homie id charset: lowercase alphanumerics and hyphens, nothing else. + +Load-bearing rather than decorative. The grammar below joins the node and the +property with `/`, and that separator is only unambiguous because an id cannot +contain one -- which is the same reason upstream's own capability-catalog paths +and `discovery_path()` are spelled with slashes. An id outside this charset is +refused adoption rather than sanitised, because sanitising is what would make +the split ambiguous again. +""" + +_SCOPE_BY_KIND: Final[dict[str, str]] = { + "panel": SCOPE_PANEL, + "battery": SUB_DEVICE_BESS, + "mid": SUB_DEVICE_MID, + "pv": SUB_DEVICE_PV, +} +"""Library subject kind → the scope segment this integration's ids already use. + +The singletons only. `evse` and `circuit` carry an instance key and are built +below, because their segment is `{kind}_{key}` rather than a constant. +""" + + +def extension_scope(subject: ExtensionSubject) -> str | None: + """Return the id scope segment for a subject, or `None` if it names no device. + + `None` rather than a fallback string: a subject kind this integration does + not place on a device card has no card to hang an entity on, and inventing a + scope would mint a permanent id for an entity with nowhere to live. + """ + if subject.kind in _SCOPE_BY_KIND: + return _SCOPE_BY_KIND[subject.kind] + if subject.instance_key is None: + return None + if subject.kind == "evse": + return f"{SUB_DEVICE_EVSE}_{subject.instance_key}" + if subject.kind == "circuit": + return f"circuit_{subject.instance_key}" + return None + + +def extension_unique_id( + serial: str, subject: ExtensionSubject, node_id: str, property_id: str +) -> str | None: + """Return the unique id for one extension property, or `None` if it is unadoptable. + + span_{serial}_adopted_{scope}/{node}/{property} + + Anchored on what is stable and ours -- the panel serial and the curated scope + -- and addressed by the wire path **verbatim**, which is upstream's own + capability-catalog spelling (`AdoptedProperty.path`, `discovery_path()`). + + **Verbatim, and never through `get_user_friendly_suffix`.** That helper + de-*dots* rather than de-hyphens and substitutes a curated suffix on a + mapping hit, so routing a wire address through it would both mangle the + address and let a vendor string collide with a curated spelling. Carrying the + path as published is also what makes this injective: the id *is* the wire + address, so two distinct addresses cannot collapse into one. Normalising + hyphens to underscores would collapse `battery-2` + `cell-temperature` and + `battery` + `2-cell-temperature` into the same id. + + **Not the eBus proxy composition.** `{proxier-id}-{proxied-id}` is upstream's + device-handle spelling, and upstream is explicit that those handles are not + identities: they differ across enclosures by design and are unstable across + the proxy-to-native transition. A permanent id anchored on one would rename + itself when a device stopped being proxied, stranding every entity keyed on + it -- and nothing migrates, so there would be no recovery. + + The `adopted` token sits immediately after the serial, so the id is + namespaced by prefix. Device-level adoption's ids contain no `/`, so the + presence of a slash is what tells the two adoption grammars apart -- no + reader has to count token positions. + """ + scope = extension_scope(subject) + if scope is None: + _LOGGER.debug( + "Extension property %s/%s names no device card (subject kind %s); not adopted", + node_id, + property_id, + subject.kind, + ) + return None + if not HOMIE_ID.match(node_id) or not HOMIE_ID.match(property_id): + # Refused rather than sanitised: see `HOMIE_ID`. The property is still + # reported in diagnostics, so the case is visible rather than silent. + _LOGGER.warning( + "Extension property %s/%s is outside the Homie id charset and is not adopted; " + "it remains visible in diagnostics", + node_id, + property_id, + ) + return None + return f"span_{serial}_{ADOPTED_IDENTIFIER_TOKEN}_{scope}/{node_id}/{property_id}" + + +def is_extension_unique_id(unique_id: str) -> bool: + """Whether an id was minted by the grammar above. + + The slash is the discriminator, for the reason `extension_unique_id` + documents: a curated id has no `adopted` token, and a device-level adopted id + has the token but no slash. + """ + return f"_{ADOPTED_IDENTIFIER_TOKEN}_" in unique_id and "/" in unique_id + + +def extension_device_identifier(panel_identifier: str, subject: ExtensionSubject) -> str | None: + """Return the registry identifier of the curated device this property belongs on. + + The identifier only, never a rebuilt `DeviceInfo`. An extension entity joins + a card that already exists; restating that card's name, manufacturer or model + here would be a second implementation of `util`'s builders, free to drift and + able to rename a user's device by disagreeing with them. + + A circuit's entities live on the panel's own card in this integration, so a + circuit subject resolves there too -- the entity's *name* is what says which + circuit it came from. + """ + if subject.kind in ("panel", "circuit"): + return panel_identifier + if subject.kind == "battery": + return f"{panel_identifier}_{SUB_DEVICE_BESS}" + if subject.kind == "mid": + return f"{panel_identifier}_{SUB_DEVICE_MID}" + if subject.kind == "pv": + return f"{panel_identifier}_{SUB_DEVICE_PV}" + if subject.kind == "evse" and subject.instance_key is not None: + return f"{panel_identifier}_{SUB_DEVICE_EVSE}_{subject.instance_key}" + return None + + +def classify_extension(datatype: str) -> Platform: + """Return the platform a declared datatype surfaces on. + + Two platforms, because `adoption.classify`'s three control rows are + deliberately absent: an extension property lives on a device whose curated + controls do real safety work -- the EVSE limit refuses a value above the + commissioned ceiling, the islanding assertion translates `GRID` into + `ON_GRID` -- and a generic write path would sit beside them on the same wire + with neither. A settable property therefore surfaces as a reading. + + **Derived once, at first sighting, and never re-derived.** See + `resolve_platform`: the domain is baked into `entity_id`, so a later + datatype change must not move an existing row. + """ + return Platform.BINARY_SENSOR if datatype == BOOLEAN_DATATYPE else Platform.SENSOR + + +def resolve_platform(registry: EntityRegistry, unique_id: str, datatype: str) -> Platform: + """Return the platform this id already uses, or the one its datatype implies. + + **The platform is a one-way door**, and this function is the door. An entity + domain is part of `entity_id`, and the registry refuses a cross-domain rename + outright -- `async_update_entity` raises `ValueError("New entity ID should be + same domain")`. So a row born a `sensor` can never become a `binary_sensor`: + re-deriving the platform from a changed declaration would not move it, it + would strand it and mint a second entity beside it. + + Metadata may reshape everything else about a standing entity -- category, + device class, unit, name, icon -- and safely, because these entities carry no + `state_class` and so have no statistics for a unit change to corrupt. The + platform is the exception, and the exception is enforced here rather than + remembered: whatever domain the id is already registered under wins. + """ + for platform in (Platform.SENSOR, Platform.BINARY_SENSOR): + if registry.async_get_entity_id(platform.value, DOMAIN, unique_id) is not None: + return platform + return classify_extension(datatype) + + +def prominence_hint(row: ExtensionProperty) -> str: + """Return an advisory ranking for one extension property. + + Advisory, and only advisory: every extension entity arrives DIAGNOSTIC + whatever this says. `entity_category` is the one attribute that is free to + revise later -- no id change, no statistics consequence -- so the conservative + default costs a line in a future release, while the mistakes that are *not* + free are simply never made here. + + Ranked by confidence, and each signal's failure mode is why it sits where it + does: + + 1. **Identity-family naming → `detail`.** Highest confidence because it is a + purely *negative* signal: a property named for a vendor, model, serial, + part number or firmware build is device description, not a reading. Fails + only on a vendor using an identity word for a live value. + 2. **A unit with a device class → `reading`.** Moderate: a physical + measurement is more likely a headline than a knob. Fails *systematically* + in one direction -- the most headline-worthy number a battery publishes is + a `%` state of charge, and `%` is deliberately absent from + `DEVICE_CLASS_BY_UNIT` because it is equally a confidence or a duty cycle. + So this signal may promote and never demote. + 3. **Everything else → `detail`**, with `node_has_curated_siblings` recorded + beside it as corroboration rather than as a decision. Homie nodes are + organisational rather than editorial: vendors hang configuration knobs off + `meter` because that is where the code was. + + The upstream `role` declaration proposed alongside this design would retire + all three for compliant publishers, which is why it is worth asking for. + """ + if any(token in row.property_id for token in IDENTITY_FAMILY): + return HINT_DETAIL + if row.unit and row.unit in DEVICE_CLASS_BY_UNIT: + return HINT_READING + return HINT_DETAIL + + +MAX_PER_DEVICE: Final = 60 +"""How many extension entities one device may mint before the rest are declined. + +A registry row is permanent in a way a reading is not: nothing here removes one, +and a row a user deletes returns at the next setup while the property is still +published. So a vendor node declaring hundreds of properties would put hundreds +of rows in every entity picker on every install that met it, and no later +release could take them back. + +The cap is deliberately generous -- far above any real device, and the sixteen +`pcs` properties are the largest curated example -- so it is a backstop against a +misbehaving publisher rather than a policy on normal ones. Declining is reported +through a notice naming the device, because a silent truncation would read as +"that is everything the vendor publishes" when it is not. +""" + + +class ExtensionEntity(SpanPanelEntity): + """Base for an entity built from a vendor extension on a curated device. + + Disabled and diagnostic without exception, exactly as `AdoptedEntity` is: the + integration's job here is to make the reading reachable, not to put it on + somebody's dashboard. + """ + + _attr_entity_registry_enabled_default = False + _attr_entity_category: EntityCategory | None = EntityCategory.DIAGNOSTIC + + def __init__( + self, + coordinator: SpanPanelCoordinator, + unique_id: str, + row: ExtensionProperty, + *, + device_identifier: str, + ) -> None: + """Bind this entity to one extension property of one curated device.""" + super().__init__(coordinator) + self._subject_kind = row.subject.kind + self._instance_key = row.subject.instance_key + self._declaration_path = row.path + self._attr_unique_id = unique_id + # Node-prefixed on purpose. Curated names on these cards carry no wire + # vocabulary, so a collision with a curated "Power" or "Status" is + # avoided by construction rather than by a registration-order-dependent + # dedup -- and two vendor nodes on one device disambiguate without a + # special case. Plain, clunky, and honestly so: it marks the entity as + # uncurated, which is what it is. + self._attr_name = f"{humanised(row.node_id)} {humanised(row.property_id)}" + # Identifiers only: the card already exists and belongs to `util`'s + # builders. Restating its name or model here could rename a user's device + # by disagreeing with them. + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_identifier)}) + self._attr_extra_state_attributes = { + "prominence_hint": prominence_hint(row), + "wire_path": row.path, + } + + def _row(self) -> ExtensionProperty | None: + """Return this property's current row, or None when it has left the tree. + + Matched out of the snapshot each cycle rather than captured at + construction, so a device that leaves and returns keeps reporting through + the same entity. A property the publisher stops publishing simply stops + being found: the entity reads unknown and is never removed, because + absence on the wire is ambiguous between "gone" and "not yet arrived". + """ + snapshot: SpanPanelSnapshot = self.coordinator.data + for row in snapshot.extension_properties: + if ( + row.subject.kind == self._subject_kind + and row.subject.instance_key == self._instance_key + and row.path == self._declaration_path + ): + return row + return None + + def _published(self) -> str | None: + """Return the published value, or None when nothing has arrived.""" + row = self._row() + return None if row is None else row.value + + +class ExtensionSensor(ExtensionEntity, SensorEntity): + """A vendor reading on a curated device, with no `state_class`. + + No `state_class`, ever, and that is what makes the rest of this safe: an + entity writing no long-term statistics has nothing for a later unit or + device-class change to corrupt, so metadata may reshape it freely. + """ + + def __init__( + self, + coordinator: SpanPanelCoordinator, + unique_id: str, + row: ExtensionProperty, + *, + device_identifier: str, + ) -> None: + """Take the unit and device class from what the publisher declared.""" + super().__init__(coordinator, unique_id, row, device_identifier=device_identifier) + self.entity_description = SensorEntityDescription( + key=row.path, + device_class=DEVICE_CLASS_BY_UNIT.get(row.unit or ""), + native_unit_of_measurement=row.unit, + ) + + @property + def native_value(self) -> str | float | None: + """Return the published value, parsed to a number only where one is declared.""" + raw = self._published() + if raw is None: + return None + if self.entity_description.native_unit_of_measurement is None: + return raw + try: + return float(raw) + except ValueError: + _LOGGER.debug( + "Extension %s published %r, which is not a number", self._declaration_path, raw + ) + return None + + +class ExtensionBinarySensor(ExtensionEntity, BinarySensorEntity): + """A declared `boolean` vendor extension on a curated device.""" + + @property + def is_on(self) -> bool | None: + """Homie spells a boolean `true`/`false`; anything else is not an answer.""" + return homie_boolean(self._published()) + + +def create_extension_sensors( + coordinator: SpanPanelCoordinator, + snapshot: SpanPanelSnapshot, + device_registry: DeviceRegistry, + entity_registry: EntityRegistry, +) -> list[ExtensionSensor]: + """Every extension property that is not a declared boolean.""" + return _create( + ExtensionSensor, coordinator, snapshot, device_registry, entity_registry, Platform.SENSOR + ) + + +def create_extension_binary_sensors( + coordinator: SpanPanelCoordinator, + snapshot: SpanPanelSnapshot, + device_registry: DeviceRegistry, + entity_registry: EntityRegistry, +) -> list[ExtensionBinarySensor]: + """Every extension property declared `boolean`.""" + return _create( + ExtensionBinarySensor, + coordinator, + snapshot, + device_registry, + entity_registry, + Platform.BINARY_SENSOR, + ) + + +def _create[ExtensionT: ExtensionEntity]( + entity_class: type[ExtensionT], + coordinator: SpanPanelCoordinator, + snapshot: SpanPanelSnapshot, + device_registry: DeviceRegistry, + entity_registry: EntityRegistry, + platform: Platform, +) -> list[ExtensionT]: + """Build one platform's share of the extension properties. + + One partition function rather than two bodies, so `resolve_platform` stays + the only place a property's platform is decided -- two bodies would each + restate the predicate, and a property could then reach both platforms or + neither. + """ + built: list[ExtensionT] = [] + for row, unique_id, device_identifier in adoptable(snapshot, device_registry): + if resolve_platform(entity_registry, unique_id, row.datatype) is not platform: + continue + built.append(entity_class(coordinator, unique_id, row, device_identifier=device_identifier)) + return built + + +def adoptable( + snapshot: SpanPanelSnapshot, device_registry: DeviceRegistry +) -> list[tuple[ExtensionProperty, str, str]]: + """Every extension property that can become an entity, with its id and card. + + Three reasons a declared property is declined here, all of them stated rather + than silent: its subject resolves to no device card, its card is not in the + registry yet, or its address is outside the Homie charset. The first two are + ordinary states on a setup that raced a capability -- the entity appears on + the next reload, as capability-gated platforms already do. + """ + per_device: dict[str, int] = {} + declined: dict[str, int] = {} + adoptable_rows: list[tuple[ExtensionProperty, str, str]] = [] + for row in snapshot.extension_properties: + identifier = extension_device_identifier(snapshot.serial_number, row.subject) + if identifier is None: + continue + if device_registry.async_get_device(identifiers={(DOMAIN, identifier)}) is None: + _LOGGER.debug( + "Extension property %s has no registered device for %s yet; deferred to the next reload", + row.path, + identifier, + ) + continue + unique_id = extension_unique_id( + snapshot.serial_number, row.subject, row.node_id, row.property_id + ) + if unique_id is None: + continue + if per_device.get(identifier, 0) >= MAX_PER_DEVICE: + declined[identifier] = declined.get(identifier, 0) + 1 + continue + per_device[identifier] = per_device.get(identifier, 0) + 1 + adoptable_rows.append((row, unique_id, identifier)) + for identifier, count in declined.items(): + _LOGGER.warning( + "Device %s declares more than %d vendor properties; %d were not adopted. " + "They remain visible in this integration's diagnostics", + identifier, + MAX_PER_DEVICE, + count, + ) + return adoptable_rows diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 25d9a1f0..ece3c4bd 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -22,9 +22,9 @@ ], "quality_scale": "gold", "requirements": [ - "span-panel-api==3.0.0b11", + "span-panel-api==3.0.0b12", "span-panel-api-schema-0==1.0.0b5", - "span-panel-api-schema-1==0.1.0b8" + "span-panel-api-schema-1==0.1.0b9" ], "version": "2.1.0b6", "zeroconf": [ diff --git a/custom_components/span_panel/sensor.py b/custom_components/span_panel/sensor.py index 2d1d111e..fefbcb36 100644 --- a/custom_components/span_panel/sensor.py +++ b/custom_components/span_panel/sensor.py @@ -6,7 +6,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from span_panel_api import SpanPanelSnapshot @@ -21,6 +21,7 @@ USE_CIRCUIT_NUMBERS, ) from .coordinator import SpanPanelCoordinator +from .extension import create_extension_sensors from .helpers import ( has_bess, has_bess_telemetry, @@ -130,8 +131,17 @@ async def async_setup_entry( panel_device_id=config_entry.runtime_data.panel_device_id, ) + # Vendor extensions on devices this integration *does* model, which + # adoption deliberately leaves alone -- see `extension`. + extensions = create_extension_sensors( + coordinator, + snapshot, + dr.async_get(hass), + er.async_get(hass), + ) + # Add all native sensor entities - async_add_entities([*entities, *adopted]) + async_add_entities([*entities, *adopted, *extensions]) # Force immediate coordinator refresh to ensure all sensors update right away await coordinator.async_request_refresh() diff --git a/pyproject.toml b/pyproject.toml index 29510978..ef351471 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,13 +8,13 @@ readme = "README.md" requires-python = ">=3.14.3,<3.15" dependencies = [ "homeassistant==2026.8.0", - "span-panel-api==3.0.0b11", + "span-panel-api==3.0.0b12", # 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.0.0b5", - "span-panel-api-schema-1==0.1.0b8", + "span-panel-api-schema-1==0.1.0b9", ] [dependency-groups] diff --git a/requirements_test.txt b/requirements_test.txt index 7ea9a044..5bcb64a4 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -3,6 +3,6 @@ pytest-asyncio>=1.3.0 setuptools>=65.7.0 pytest-homeassistant-custom-component==0.13.354 homeassistant==2026.8.0 -span-panel-api==3.0.0b11 +span-panel-api==3.0.0b12 span-panel-api-schema-0==1.0.0b5 -span-panel-api-schema-1==0.1.0b8 +span-panel-api-schema-1==0.1.0b9 diff --git a/tests/test_extension_entities.py b/tests/test_extension_entities.py new file mode 100644 index 00000000..77bfcb1f --- /dev/null +++ b/tests/test_extension_entities.py @@ -0,0 +1,343 @@ +"""Vendor extensions on curated devices become entities, on the right card, forever. + +Three properties carry this half of the design and each fails loudly here if it +stops holding: an extension entity lands on the device it belongs to and never +mints a card of its own, the platform a row is born under is the platform it +keeps however the declaration changes, and nothing is created for a card that is +not there yet. +""" + +from __future__ import annotations + +from dataclasses import replace +from typing import TYPE_CHECKING +from unittest.mock import MagicMock + +from homeassistant.const import EntityCategory, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry +from span_panel_api import ExtensionProperty, ExtensionSubject + +from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.extension import ( + HINT_DETAIL, + HINT_READING, + MAX_PER_DEVICE, + ExtensionBinarySensor, + ExtensionSensor, + adoptable, + classify_extension, + create_extension_binary_sensors, + create_extension_sensors, + extension_device_identifier, + prominence_hint, + resolve_platform, +) +from custom_components.span_panel.util import SUB_DEVICE_BESS + +from .factories import SpanPanelSnapshotFactory + +if TYPE_CHECKING: + from span_panel_api import SpanPanelSnapshot + +PANEL_SERIAL = "sp3-242424-001" +BESS_IDENTIFIER = f"{PANEL_SERIAL}_{SUB_DEVICE_BESS}" + + +@pytest.fixture +def registered_panel(hass: HomeAssistant) -> tuple[str, str]: + """Return a config entry with the panel and its BESS card registered, as setup leaves them.""" + mock = MockConfigEntry(domain=DOMAIN, data={}, unique_id=PANEL_SERIAL) + mock.add_to_hass(hass) + registry = dr.async_get(hass) + panel = registry.async_get_or_create( + config_entry_id=mock.entry_id, + identifiers={(DOMAIN, PANEL_SERIAL)}, + name="Span Panel", + ) + registry.async_get_or_create( + config_entry_id=mock.entry_id, + identifiers={(DOMAIN, BESS_IDENTIFIER)}, + name="Span Panel Battery", + via_device_id=panel.id, + ) + return str(mock.entry_id), panel.id + + +def _row( + node_id: str = "battery-2", + property_id: str = "cell-temperature", + datatype: str = "float", + unit: str | None = "°C", + value: str | None = "31.4", + kind: str = "battery", + instance_key: str | None = None, + settable: bool = False, +) -> ExtensionProperty: + return ExtensionProperty( + subject=ExtensionSubject(kind=kind, instance_key=instance_key), + node_id=node_id, + property_id=property_id, + datatype=datatype, + unit=unit, + value=value, + settable=settable, + ) + + +def _snapshot(*rows: ExtensionProperty) -> SpanPanelSnapshot: + """Return a complete curated snapshot carrying the given extension rows.""" + return replace( + SpanPanelSnapshotFactory.create_complete(serial_number=PANEL_SERIAL), + extension_properties=rows, + ) + + +def _coordinator(snapshot: SpanPanelSnapshot) -> MagicMock: + coordinator = MagicMock() + coordinator.data = snapshot + return coordinator + + +# --- the platform table, and the one-way door ------------------------------- + + +@pytest.mark.parametrize( + ("datatype", "expected"), + [ + ("boolean", Platform.BINARY_SENSOR), + ("float", Platform.SENSOR), + ("integer", Platform.SENSOR), + ("enum", Platform.SENSOR), + ("string", Platform.SENSOR), + ], +) +def test_two_platforms_only(datatype: str, expected: Platform) -> None: + """No controls, whatever the declaration says -- adoption's three rows are absent.""" + assert classify_extension(datatype) is expected + + +def test_a_settable_property_is_still_a_reading() -> None: + """The read-only ruling, at the classifier. + + A control here would sit beside curated controls that do real safety work on + the same wire -- the EVSE limit refuses a value above the commissioned + ceiling -- with none of their translation or bounds. + """ + assert classify_extension("enum") is Platform.SENSOR + assert classify_extension("float") is Platform.SENSOR + + +def test_the_platform_a_row_is_born_under_is_the_one_it_keeps(hass: HomeAssistant) -> None: + """Metadata may reshape an entity; it may never move its domain. + + The registry refuses a cross-domain rename outright, so re-deriving the + platform from a changed declaration would not move the row -- it would strand + it and mint a second entity beside it. + """ + registry = er.async_get(hass) + unique_id = "span_sp3-242424-001_adopted_bess/battery-2/cell-temperature" + registry.async_get_or_create( + Platform.SENSOR.value, DOMAIN, unique_id, suggested_object_id="battery_2_cell_temperature" + ) + + # The publisher relabels the property as a boolean. The row stays a sensor. + assert resolve_platform(registry, unique_id, "boolean") is Platform.SENSOR + + +def test_an_unregistered_id_takes_the_platform_its_datatype_implies(hass: HomeAssistant) -> None: + assert ( + resolve_platform(er.async_get(hass), "span_x_adopted_bess/n/p", "boolean") + is Platform.BINARY_SENSOR + ) + + +# --- placement -------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("kind", "instance_key", "expected"), + [ + ("panel", None, PANEL_SERIAL), + ("circuit", "abc123", PANEL_SERIAL), + ("battery", None, f"{PANEL_SERIAL}_bess"), + ("mid", None, f"{PANEL_SERIAL}_mid"), + ("pv", None, f"{PANEL_SERIAL}_pv"), + ("evse", "acme-001", f"{PANEL_SERIAL}_evse_acme-001"), + ], +) +def test_each_subject_resolves_to_an_existing_card( + kind: str, instance_key: str | None, expected: str +) -> None: + """A circuit's entities live on the panel's card, as its curated ones do.""" + subject = ExtensionSubject(kind=kind, instance_key=instance_key) + assert extension_device_identifier(PANEL_SERIAL, subject) == expected + + +def test_a_row_whose_card_is_not_registered_yet_is_deferred( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """A capability race defers the entity to the next reload rather than minting a card.""" + snapshot = _snapshot(_row(kind="pv")) + assert adoptable(snapshot, dr.async_get(hass)) == [] + + +def test_a_row_on_a_registered_card_is_adoptable( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + snapshot = _snapshot(_row()) + adoptable_rows = adoptable(snapshot, dr.async_get(hass)) + assert len(adoptable_rows) == 1 + row, unique_id, identifier = adoptable_rows[0] + assert identifier == BESS_IDENTIFIER + assert unique_id == "span_sp3-242424-001_adopted_bess/battery-2/cell-temperature" + assert row.path == "battery-2/cell-temperature" + + +def test_an_off_charset_address_is_declined_rather_than_sanitised( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + snapshot = _snapshot(_row(property_id="Cell_Temperature")) + assert adoptable(snapshot, dr.async_get(hass)) == [] + + +# --- the cap ---------------------------------------------------------------- + + +def test_a_vendor_flooding_one_device_is_capped( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """Registry rows are permanent and nothing removes them, so the flood is bounded.""" + rows = tuple(_row(property_id=f"reading-{index}") for index in range(MAX_PER_DEVICE + 25)) + adopted = adoptable(_snapshot(*rows), dr.async_get(hass)) + assert len(adopted) == MAX_PER_DEVICE + + +def test_the_cap_is_per_device_not_per_panel( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """One noisy vendor device must not crowd out a quiet one on another card.""" + battery_rows = tuple( + _row(property_id=f"reading-{index}") for index in range(MAX_PER_DEVICE + 5) + ) + panel_row = _row(kind="panel", node_id="acme", property_id="site-reading") + adopted = adoptable(_snapshot(*battery_rows, panel_row), dr.async_get(hass)) + assert sum(1 for _row_, _uid, identifier in adopted if identifier == PANEL_SERIAL) == 1 + + +# --- the entities themselves ------------------------------------------------ + + +def test_a_sensor_arrives_disabled_diagnostic_and_without_statistics( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """The arrival state, and the one guarantee that makes reshaping safe. + + No `state_class` means no long-term statistics, so a later unit or + device-class change has nothing to corrupt. + """ + snapshot = _snapshot(_row()) + sensors = create_extension_sensors( + _coordinator(snapshot), snapshot, dr.async_get(hass), er.async_get(hass) + ) + assert len(sensors) == 1 + sensor = sensors[0] + assert isinstance(sensor, ExtensionSensor) + assert sensor._attr_entity_registry_enabled_default is False + assert sensor._attr_entity_category is EntityCategory.DIAGNOSTIC + assert getattr(sensor.entity_description, "state_class", None) is None + assert sensor.native_value == 31.4 + assert sensor.entity_description.native_unit_of_measurement == "°C" + + +def test_a_name_carries_the_node_so_it_cannot_collide_with_a_curated_one( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """Curated names on these cards carry no wire vocabulary, so prefixing avoids collisions.""" + snapshot = _snapshot(_row()) + sensor = create_extension_sensors( + _coordinator(snapshot), snapshot, dr.async_get(hass), er.async_get(hass) + )[0] + assert sensor._attr_name == "Battery 2 Cell Temperature" + + +def test_a_declared_boolean_becomes_a_binary_sensor( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + snapshot = _snapshot( + _row(property_id="pack-enabled", datatype="boolean", unit=None, value="true") + ) + binary = create_extension_binary_sensors( + _coordinator(snapshot), snapshot, dr.async_get(hass), er.async_get(hass) + ) + assert len(binary) == 1 + assert isinstance(binary[0], ExtensionBinarySensor) + assert binary[0].is_on is True + # And it is not also a sensor: one property, one platform. + assert ( + create_extension_sensors( + _coordinator(snapshot), snapshot, dr.async_get(hass), er.async_get(hass) + ) + == [] + ) + + +def test_a_property_that_stops_being_published_reads_unknown_rather_than_vanishing( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """Absence on the wire is ambiguous, so the entity stays and reports nothing.""" + snapshot = _snapshot(_row()) + sensor = create_extension_sensors( + _coordinator(snapshot), snapshot, dr.async_get(hass), er.async_get(hass) + )[0] + + sensor.coordinator.data = _snapshot() + assert sensor.native_value is None + + +def test_an_unparseable_number_is_reported_as_nothing_rather_than_as_text( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """A string behind a unit and a device class is a worse lie than no reading.""" + snapshot = _snapshot(_row(value="not-a-number")) + sensor = create_extension_sensors( + _coordinator(snapshot), snapshot, dr.async_get(hass), er.async_get(hass) + )[0] + assert sensor.native_value is None + + +# --- the prominence hint ---------------------------------------------------- + + +def test_identity_naming_outranks_a_unit() -> None: + """The highest-confidence signal is negative, which is why it is checked first.""" + assert prominence_hint(_row(property_id="firmware-version", unit=None)) == HINT_DETAIL + assert prominence_hint(_row(property_id="pack-serial-number", unit="W")) == HINT_DETAIL + + +def test_a_unit_with_a_device_class_leans_reading() -> None: + assert prominence_hint(_row(property_id="cell-temperature", unit="°C")) == HINT_READING + assert prominence_hint(_row(property_id="acme-power", unit="W")) == HINT_READING + + +def test_a_percentage_is_never_promoted() -> None: + """`%` is a state of charge, a confidence, or a duty cycle, and nothing tells them apart. + + The systematic false negative the design accepts: the most headline-worthy + number a battery publishes lands as a detail, and `entity_category` is free + to revise later. + """ + assert prominence_hint(_row(property_id="state-of-charge", unit="%")) == HINT_DETAIL + + +def test_the_hint_is_carried_on_the_entity_for_curation_triage( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + snapshot = _snapshot(_row()) + sensor = create_extension_sensors( + _coordinator(snapshot), snapshot, dr.async_get(hass), er.async_get(hass) + )[0] + assert sensor._attr_extra_state_attributes["prominence_hint"] == HINT_READING + assert sensor._attr_extra_state_attributes["wire_path"] == "battery-2/cell-temperature" diff --git a/tests/test_extension_ids.py b/tests/test_extension_ids.py new file mode 100644 index 00000000..f50a07bc --- /dev/null +++ b/tests/test_extension_ids.py @@ -0,0 +1,178 @@ +"""The identity grammar for vendor extension properties, and what holds it honest. + +The unique id is the one irreversible commitment in extension adoption: nothing +migrates, so an id minted wrong is minted wrong forever. Three properties are +asserted here rather than assumed. + +**Injectivity.** Two distinct wire addresses must never produce one id. The +grammar buys this by carrying the wire path verbatim -- the id *is* the address +-- and the adversarial pairs below are the ones any normalising scheme would +collapse. + +**Namespace closure.** No curated description key contains the `adopted` token, +so a curated id and an extension id cannot be confused for one another. + +**Refusal over sanitisation.** An id outside the Homie charset is refused, not +cleaned up, because cleaning it is what would make the slash-split ambiguous. +""" + +from __future__ import annotations + +import pytest +from span_panel_api import ExtensionSubject + +from custom_components.span_panel.extension import ( + HOMIE_ID, + extension_scope, + extension_unique_id, + is_extension_unique_id, +) +from custom_components.span_panel.field_paths import platform_descriptions +from custom_components.span_panel.util import ADOPTED_IDENTIFIER_TOKEN + +SERIAL = "sp3-000000-001" + + +def _id(kind: str, node: str, prop: str, instance_key: str | None = None) -> str | None: + return extension_unique_id( + SERIAL, ExtensionSubject(kind=kind, instance_key=instance_key), node, prop + ) + + +# --- the grammar ------------------------------------------------------------ + + +def test_the_grammar_is_serial_token_scope_then_the_wire_path() -> None: + assert ( + _id("battery", "battery-2", "cell-temperature") + == "span_sp3-000000-001_adopted_bess/battery-2/cell-temperature" + ) + + +@pytest.mark.parametrize( + ("kind", "instance_key", "scope"), + [ + ("panel", None, "panel"), + ("battery", None, "bess"), + ("mid", None, "mid"), + ("pv", None, "pv"), + ("evse", "acme-001", "evse_acme-001"), + ("circuit", "0ab966b95f92a6a51ec548485aa85f54", "circuit_0ab966b95f92a6a51ec548485aa85f54"), + ], +) +def test_each_subject_maps_to_the_scope_its_curated_entities_use( + kind: str, instance_key: str | None, scope: str +) -> None: + assert extension_scope(ExtensionSubject(kind=kind, instance_key=instance_key)) == scope + + +def test_a_multi_instance_subject_without_a_key_names_no_device() -> None: + """No card to hang it on, so no id: inventing a scope would mint a homeless entity.""" + assert extension_scope(ExtensionSubject(kind="evse")) is None + assert _id("evse", "acme", "charge-limit") is None + + +def test_an_unknown_subject_kind_is_refused_rather_than_guessed() -> None: + assert extension_scope(ExtensionSubject(kind="something-new")) is None + + +# --- injectivity ------------------------------------------------------------ + + +def test_the_pairs_a_normalising_grammar_would_collapse_stay_distinct() -> None: + """The concrete counterexample the verbatim rule exists to defeat. + + Any scheme that turned hyphens into underscores and joined the two segments + with an underscore would render both of these as + `..._battery_2_cell_temperature`. + """ + first = _id("battery", "battery-2", "cell-temperature") + second = _id("battery", "battery", "2-cell-temperature") + assert first is not None + assert second is not None + assert first != second + + +def test_ids_are_injective_over_adversarial_addresses() -> None: + """Distinct addresses, distinct ids -- asserted over a set built to collide.""" + addresses = [ + ("battery-2", "cell-temperature"), + ("battery", "2-cell-temperature"), + ("battery-2-cell", "temperature"), + ("meter", "acme-cell-balance"), + ("meter-acme", "cell-balance"), + ("a", "b-c"), + ("a-b", "c"), + ] + minted: dict[str, tuple[str, str]] = {} + for node, prop in addresses: + unique_id = _id("battery", node, prop) + assert unique_id is not None, (node, prop) + assert unique_id not in minted, f"{(node, prop)} collides with {minted[unique_id]}" + minted[unique_id] = (node, prop) + + +def test_the_same_address_on_two_instances_stays_distinct() -> None: + """Two chargers publishing the same vendor node are two entities, not one.""" + first = _id("evse", "acme", "cell-temperature", instance_key="acme-001") + second = _id("evse", "acme", "cell-temperature", instance_key="acme-002") + assert first != second + + +# --- namespace closure ------------------------------------------------------ + + +def test_no_curated_description_key_contains_the_adopted_token() -> None: + """The namespace cannot be invaded silently by a curated key. + + The same closed-mapping idiom the suffix tests use: asserted over every + platform description rather than over a hand-listed sample. + """ + invaded = [ + description.key + for description in platform_descriptions() + if ADOPTED_IDENTIFIER_TOKEN in description.key.lower() + ] + assert not invaded, f"curated keys carrying the adoption token: {invaded}" + + +def test_an_extension_id_is_distinguishable_from_a_device_adoption_id() -> None: + """The slash is the discriminator; device-level adoption ids carry none.""" + extension = _id("battery", "battery-2", "cell-temperature") + assert extension is not None + assert is_extension_unique_id(extension) + + # The device-level grammar, as `adoption.adopted_unique_id` builds it. + device_level = f"span_{SERIAL}_{ADOPTED_IDENTIFIER_TOKEN}_acme-001_meter_active_power" + assert not is_extension_unique_id(device_level) + + curated = f"span_{SERIAL}_bess_battery_power" + assert not is_extension_unique_id(curated) + + +# --- refusal over sanitisation ---------------------------------------------- + + +@pytest.mark.parametrize( + "node", + ["Battery-2", "battery_2", "battery/2", "battery 2", "battery.2", ""], +) +def test_an_off_charset_node_is_refused(node: str) -> None: + assert _id("battery", node, "cell-temperature") is None + + +@pytest.mark.parametrize( + "prop", + ["Cell-Temperature", "cell_temperature", "cell/temperature", "cell temperature", ""], +) +def test_an_off_charset_property_is_refused(prop: str) -> None: + assert _id("battery", "battery-2", prop) is None + + +def test_the_charset_is_exactly_the_homie_one() -> None: + """Lowercase alphanumerics and hyphens, and nothing that could break the split.""" + assert HOMIE_ID.match("battery-2") + assert HOMIE_ID.match("a1") + assert not HOMIE_ID.match("A") + assert not HOMIE_ID.match("a_b") + assert not HOMIE_ID.match("a/b") diff --git a/tests/test_new_entity_announcement.py b/tests/test_new_entity_announcement.py index 801ee3b4..8477a4b7 100644 --- a/tests/test_new_entity_announcement.py +++ b/tests/test_new_entity_announcement.py @@ -414,3 +414,39 @@ async def test_a_panel_entity_is_not_prefixed(hass: HomeAssistant, entry: MockCo message = _text(_announcement(hass, entry)) assert "DSM State" in message assert "SPAN Panel DSM State" not in message + + +async def test_vendor_extensions_on_a_curated_device_collapse_to_one_line( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Fifteen new vendor properties are one line, not fifteen. + + These sit on a *curated* card, so the adopted-device detector cannot see + them: the card is the battery's, shared with curated entities that must + still be listed individually. Their unique_id is what says what they are. + """ + _register(hass, entry, "sp3-001_existing", name="Existing") + await async_announce_new_entities(hass, entry) + + battery = dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, "sp3-001_bess")}, + name="Span Panel Battery", + ) + for index in range(15): + _register( + hass, + entry, + f"span_sp3-001_adopted_bess/battery-2/reading-{index}", + name=f"Battery 2 Reading {index}", + device_id=battery.id, + ) + # A curated entity added to the same device in the same release is still + # named: collapsing is for the vendor surface, not for the card. + _register(hass, entry, "sp3-001_bess_meter_power", name="Meter Power", device_id=battery.id) + await async_announce_new_entities(hass, entry) + + message = _text(_announcement(hass, entry)) + assert "Span Panel Battery (15 entities)" in message + assert "Battery 2 Reading 0" not in message + assert "Meter Power" in message diff --git a/uv.lock b/uv.lock index 0193b512..a0873061 100644 --- a/uv.lock +++ b/uv.lock @@ -2550,7 +2550,7 @@ dev = [ [[package]] name = "span-panel-api" -version = "3.0.0b11" +version = "3.0.0b12" source = { editable = "../../span/span-panel-api-p3" } dependencies = [ { name = "httpx" }, @@ -2601,7 +2601,7 @@ requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-ap [[package]] name = "span-panel-api-schema-1" -version = "0.1.0b8" +version = "0.1.0b9" source = { editable = "../../span/span-panel-api-p3/packages/schema-1" } dependencies = [ { name = "ebus-sdk" }, From 1a7639d39ce2a1a6cc097dfee3567021cbfecba0 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:56:38 -0700 Subject: [PATCH 098/345] chore: 2.1.0b7, and the changelog stops saying the opposite The version bump the extension work needed: b6 ships neither the new entities nor the library pins they require, so HACS would offer nobody the change. The changelog entry replaces a bullet rather than adding one. It said a new property on a device this integration models "stays curated in a release" -- true when it was written, and the precise thing this release changes. --- CHANGELOG.md | 10 ++++++++-- custom_components/span_panel/manifest.json | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b729d30..9b313695 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,8 +87,14 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of to what the device declared and nothing invented. - **Nothing adopted enters long-term statistics** — `state_class` is not declared on the wire and a wrong guess writes corrupt statistics; wrap an adopted reading in a template sensor or utility meter if you want them. -- Devices this integration does model are deliberately not adopted: a new property on a circuit, the battery, a charger or the panel stays curated in a release, - where the judgement about it lives. +- **A new reading a vendor adds to a device you already have now appears too**, on that device's own card — the battery, a charger, the solar inverter, a + circuit or the panel. Previously only whole new _devices_ were picked up, so a battery vendor adding a field reached you nowhere. +- These arrive switched off and filed as diagnostics, like everything else adopted, and they are readings only — never switches or number boxes, because a + control here would sit beside the curated ones without their limits and translations. +- **They keep the panel's own wording** (`Battery 2 Cell Temperature`), which is deliberately plainer than a curated entity's name so you can tell at a glance + which is which. +- **Deleting one hides it until the next reload while your panel is still publishing it, and removes it for good once your panel stops.** There is no setting to + suppress one, because the delete button already does both jobs, decided by what your panel is actually sending. - **Your panel's own card shows what the panel says it is** — manufacturer, model and hardware revision read from the enclosure rather than assumed, once your panel publishes them. diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index ece3c4bd..7b28683c 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -26,7 +26,7 @@ "span-panel-api-schema-0==1.0.0b5", "span-panel-api-schema-1==0.1.0b9" ], - "version": "2.1.0b6", + "version": "2.1.0b7", "zeroconf": [ { "type": "_span._tcp.local." From d15b5f825850324fd9cdd03c82fcbb13a350f04b Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:20:08 -0700 Subject: [PATCH 099/345] docs(readme): adopted vendor readings, and what the delete button does The Adopted Devices section still said devices this integration models are never adopted, which this release reverses, so that bullet is replaced rather than joined by a contradicting one. The new section covers what a vendor reading is, why it arrives switched off and read-only, why it keeps the panel's plain wording, and the delete behaviour, which is the part nobody would guess: deleting one hides it until the next reload while the property is still published and removes it for good once publishing stops. It also states plainly that these are permanent in id but not in identity -- a later curated entity is a new entity, which is the trade for surfacing a reading the moment it appears. --- README.md | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9de13b52..aaf32cec 100644 --- a/README.md +++ b/README.md @@ -290,8 +290,41 @@ Two things worth knowing before you build on one: - **Nothing adopted enters long-term statistics.** No adopted entity carries a `state_class`, because the correct one is not published on the wire and guessing wrong writes corrupt statistics that fixing the panel afterwards does not repair. If you want statistics from an adopted reading, wrap it in a template sensor, a Riemann-sum integration or a utility meter — a deliberate choice on an entity you enabled. -- **Devices this integration already models are never adopted.** A new property on a circuit, the battery, a charger or the panel is curated in a release - instead, because that is where the judgement lives about whether it should be an entity, an attribute or a line on a device card. +- **A new property on a device this integration already models is adopted too**, but as a reading on that device's existing card rather than as a device of its + own. See [Adopted Vendor Readings](#adopted-vendor-readings) below. + +### Adopted Vendor Readings + +The other half of vendor extensibility. A publisher can add a property to a device this integration already models — the battery, a charger, the solar inverter, +a circuit or the panel itself — and until 2.1.x that reading went nowhere: it appeared in the diagnostics download and in no entity list. It now becomes an +entity on that device's own card. The wire already says which device and node it belongs to, so it has a home; what it does not say is how important it is. + +They behave like adopted devices in the ways that matter, and differ in two: + +- **They arrive switched off and filed as diagnostics**, so nothing lands on a dashboard uninvited, and the new-entity notification names the device with a + count rather than listing each one — fifteen new vendor readings would otherwise cost you the curated additions in the same message. +- **They are readings only — never switches, selects or number boxes**, even where the panel says the property accepts writes. These sit beside curated controls + that do real work, such as the charge limit that refuses a value above what your charger was commissioned for, and a generic control would sit there with none + of that. If a control is worth having, it arrives curated in a release. +- **They keep the panel's own wording**, so a vendor property on the battery reads `Battery 2 Cell Temperature` rather than something tidied up. Deliberately + plainer than a curated entity's name: it is how you tell at a glance which entities this integration designed and which it is passing through. +- **Nothing adopted enters long-term statistics**, exactly as for adopted devices, and here it buys something extra: with no statistics behind them, a later + release can correct one of these entities' units, device class or category with nothing to repair. + +**What the delete button does**, since it is not quite what you would expect: + +- Delete one while your panel is still publishing that property and it comes back — switched off — at the next reload. There is no setting to suppress it, + because leaving it switched off is already that. +- Delete one after your panel has stopped publishing it and it stays gone, because nothing exists to recreate it from. + +So deletion means "hide it until next time" for a live reading and "clear it out" for a dead one, and your panel decides which. A property your panel stops +publishing is left in place reading unknown rather than removed: silence on the wire does not distinguish a property that is gone from one that has not arrived +yet, and deleting your entity on a guess is not something an upgrade should do. + +**These entities are permanent in id, not in identity.** If one of these readings is later curated properly, the curated entity is a new entity with its own id +and its own history — the adopted one is not renamed into it. That is the trade for surfacing a reading the moment it appears rather than waiting for a release +to model it, and it is why a vendor reading you have come to depend on is worth mentioning in an issue: curation is what turns it into something with a real +name, a proper category and statistics. ### Power Sensor Attributes From 9b8513ead81e84bde8b6b9168b81a55e3905c9bf Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:34:36 -0700 Subject: [PATCH 100/345] fix(additions): name a handful of vendor readings instead of counting them A live b7 install added two vendor readings to the panel -- a postal code and a time zone, the only two properties that panel declares and this integration maps nowhere -- and the notification said "Span Panel (2 entities)". That is strictly less than naming them: the collapse was written for a firmware update adding fifteen at once, where the flood would cost the reader the curated additions in the same message, and two is not that. The distinction the first version missed is which device the line names. An adopted *device* collapses at any count because its line names a device that did not exist before, which is itself the news. A vendor reading sits on a card the user already has, so the card's name says nothing about what appeared on it. Collapse now applies above five, per notification rather than per device lifetime: five readings announced last month and one today is a one-line update, not a flood. --- custom_components/span_panel/additions.py | 49 ++++++-- tests/test_new_entity_announcement.py | 129 ++++++++++++++++++++-- 2 files changed, 158 insertions(+), 20 deletions(-) diff --git a/custom_components/span_panel/additions.py b/custom_components/span_panel/additions.py index 516b19ec..dce1ffbf 100644 --- a/custom_components/span_panel/additions.py +++ b/custom_components/span_panel/additions.py @@ -47,6 +47,20 @@ _STORE_VERSION: Final = 1 _ANNOUNCED: Final = "announced_unique_ids" +COLLAPSE_ABOVE: Final = 5 +"""How many vendor readings one curated device may add before they are collapsed. + +Above this, the device gets one line with a count; at or below it, each entity is +named. The flood this guards against is a firmware update adding fifteen +properties at once, which would spend the whole notification on one device and +cost the reader the curated additions in the same message. Two or three is not +that, and naming them is what tells the reader anything at all -- the card is one +they already have, so its name alone says nothing new. + +Adopted *devices* are collapsed regardless of count, because their line names a +device that did not exist before, which is itself the news. +""" + _SECTION: Final = "new_entities" """Names both the translation section and the notice id. They describe the same thing, and keeping them one symbol means a rename cannot leave a standing notice @@ -164,17 +178,21 @@ def _message(hass: HomeAssistant, added: list[er.RegistryEntry], text: dict[str, devices = dr.async_get(hass) enabled: list[str] = [] disabled: list[str] = [] - adopted: dict[str, int] = {} + collapsed: dict[str, int] = {} + extensions: dict[str, list[str]] = {} for registry_entry in added: device_name = _adopted_device_name(devices, registry_entry) - if device_name is None: - # Vendor extensions collapse the same way, and need their own - # detector: they live on *curated* devices, so the identifier test - # above cannot see them. Their unique_id is what says what they are. - device_name = _extension_device_name(devices, registry_entry) if device_name is not None: - adopted[device_name] = adopted.get(device_name, 0) + 1 + collapsed[device_name] = collapsed.get(device_name, 0) + 1 + continue + # Vendor extensions need their own detector: they live on *curated* + # devices, so the identifier test above cannot see them. Their unique_id + # is what says what they are. Held as labels rather than counted, + # because whether they collapse depends on how many there turn out to be. + extension_device = _extension_device_name(devices, registry_entry) + if extension_device is not None: + extensions.setdefault(extension_device, []).append(_label(devices, registry_entry)) continue target = ( disabled @@ -183,6 +201,19 @@ def _message(hass: HomeAssistant, added: list[er.RegistryEntry], text: dict[str, ) target.append(_label(devices, registry_entry)) + # Collapse a vendor's additions only once there are enough of them to crowd + # the message out. Below the threshold the names are the useful part: an + # adopted *device*'s line at least names a new device the user can go find, + # while a curated card is one they already have, so "Span Panel (2 entities)" + # tells them strictly less than naming the two would. Extension entities are + # always disabled -- that is their arrival state -- so the uncollapsed ones + # join that list rather than being sorted again. + for device_name, labels in extensions.items(): + if len(labels) > COLLAPSE_ABOVE: + collapsed[device_name] = collapsed.get(device_name, 0) + len(labels) + else: + disabled.extend(labels) + # Two keys rather than one with a plural placeholder: a plural rule the caller # picks a *word* for is an English rule, and the five languages here do not # share it. @@ -194,10 +225,10 @@ def _message(hass: HomeAssistant, added: list[er.RegistryEntry], text: dict[str, lines += [f"**{_text(text, 'enabled_heading')}**", ""] lines += [f"- {label}" for label in sorted(enabled)] lines.append("") - if disabled or adopted: + if disabled or collapsed: lines += [f"**{_text(text, 'disabled_heading')}**", ""] lines += [f"- {label}" for label in sorted(disabled)] - lines += [f"- {name} ({count} entities)" for name, count in sorted(adopted.items())] + lines += [f"- {name} ({count} entities)" for name, count in sorted(collapsed.items())] lines += ["", _text(text, "how_to_enable"), ""] lines.append(_text(text, "nothing_broken")) return "\n".join(lines) diff --git a/tests/test_new_entity_announcement.py b/tests/test_new_entity_announcement.py index 8477a4b7..fe14b70d 100644 --- a/tests/test_new_entity_announcement.py +++ b/tests/test_new_entity_announcement.py @@ -27,6 +27,7 @@ from custom_components.span_panel.additions import ( _SECTION, + COLLAPSE_ABOVE, async_announce_new_entities, async_forget_announcements, ) @@ -82,7 +83,9 @@ def _text(notification: Any) -> str: # -- Silence where silence is right ------------------------------------------ -async def test_a_first_install_announces_nothing(hass: HomeAssistant, entry: MockConfigEntry) -> None: +async def test_a_first_install_announces_nothing( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: """Every entity is new on a first install, so the notice would name them all. Which would teach the user that this category is noise, and cost them the @@ -112,7 +115,9 @@ async def test_an_install_that_predates_the_record_announces_nothing_once( assert _announcement(hass, entry) is None -async def test_a_restart_that_adds_nothing_announces_nothing(hass: HomeAssistant, entry: MockConfigEntry) -> None: +async def test_a_restart_that_adds_nothing_announces_nothing( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: """The failure that would make the whole thing worse than useless.""" _register(hass, entry, _PART_NUMBER, name="Part Number") await async_announce_new_entities(hass, entry) @@ -141,7 +146,9 @@ async def test_a_disabled_addition_is_announced_and_says_it_needs_enabling( assert "enable the ones you want" in message -async def test_an_enabled_addition_is_announced_too(hass: HomeAssistant, entry: MockConfigEntry) -> None: +async def test_an_enabled_addition_is_announced_too( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: """The gap this replaced the Repair to close. An enabled entity is visible in the entity list and starts recording, which is @@ -158,7 +165,9 @@ async def test_an_enabled_addition_is_announced_too(hass: HomeAssistant, entry: assert "Added and ready to use" in message -async def test_both_kinds_are_split_rather_than_pooled(hass: HomeAssistant, entry: MockConfigEntry) -> None: +async def test_both_kinds_are_split_rather_than_pooled( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: """The split is the actionable part: one kind needs an action, the other does not.""" _register(hass, entry, "sp3-001_existing", name="Existing") await async_announce_new_entities(hass, entry) @@ -172,8 +181,10 @@ async def test_both_kinds_are_split_rather_than_pooled(hass: HomeAssistant, entr assert message.index("Added but switched off") < message.index("Switched Off") -async def test_every_added_entity_is_named_rather_than_sampled(hass: HomeAssistant, entry: MockConfigEntry) -> None: - """"What exactly was added" means all of it. +async def test_every_added_entity_is_named_rather_than_sampled( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Naming what was added means naming all of it. The Repair this replaced showed a count plus three examples, which tells a user that something happened and not what. @@ -210,7 +221,9 @@ async def test_an_adopted_device_contributes_one_line_with_a_count( name="Backup Generator", ) for index in range(6): - _register(hass, entry, f"sp3-001_adopted_{index}", name=f"Adopted {index}", device_id=device.id) + _register( + hass, entry, f"sp3-001_adopted_{index}", name=f"Adopted {index}", device_id=device.id + ) _register(hass, entry, "sp3-001_curated", name="Curated Addition") await async_announce_new_entities(hass, entry) @@ -322,10 +335,7 @@ def test_every_shipped_locale_carries_the_notification_strings(language: str) -> def test_an_unknown_language_falls_back_to_english_rather_than_to_nothing() -> None: - assert ( - read_translations("xx", _SECTION)["title"] - == read_translations("en", _SECTION)["title"] - ) + assert read_translations("xx", _SECTION)["title"] == read_translations("en", _SECTION)["title"] def test_a_regional_language_resolves_to_its_base(hass: HomeAssistant) -> None: @@ -450,3 +460,100 @@ async def test_vendor_extensions_on_a_curated_device_collapse_to_one_line( assert "Span Panel Battery (15 entities)" in message assert "Battery 2 Reading 0" not in message assert "Meter Power" in message + + +async def test_a_few_vendor_extensions_are_named_rather_than_collapsed( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Collapsing two tells the reader less than naming them. + + The collapse exists for a firmware update adding fifteen properties at once. + An adopted *device*'s line at least names a device that did not exist + before; a curated card is one the user already has, so its name alone says + nothing about what appeared on it -- which is what a live install showed, + reading "Span Panel (2 entities)" for a postal code and a time zone. + """ + _register(hass, entry, "sp3-001_existing", name="Existing") + await async_announce_new_entities(hass, entry) + + panel = dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, "sp3-001")}, + name="Span Panel", + ) + for wire_property, label in ( + ("postal-code", "Status Postal Code"), + ("time-zone", "Status Time Zone"), + ): + _register( + hass, + entry, + f"span_sp3-001_adopted_panel/status/{wire_property}", + name=label, + device_id=panel.id, + ) + await async_announce_new_entities(hass, entry) + + message = _text(_announcement(hass, entry)) + assert "Status Postal Code" in message + assert "Status Time Zone" in message + assert "(2 entities)" not in message + + +async def test_at_the_threshold_they_are_still_named( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Exactly the threshold is named, so the boundary is asserted from both sides.""" + _register(hass, entry, "sp3-001_existing", name="Existing") + await async_announce_new_entities(hass, entry) + + panel = dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, "sp3-001")}, + name="Span Panel", + ) + for index in range(COLLAPSE_ABOVE): + _register( + hass, + entry, + f"span_sp3-001_adopted_panel/acme/reading-{index}", + name=f"Acme Reading {index}", + device_id=panel.id, + ) + await async_announce_new_entities(hass, entry) + + message = _text(_announcement(hass, entry)) + assert "Acme Reading 0" in message + assert "entities)" not in message + + +async def test_one_past_the_threshold_collapses( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """One more than the threshold, in the same update, and the names give way to a count. + + Counted per notification rather than per device lifetime, because the + message describes this update: five readings announced last month and one + today is a one-line update, not a flood. + """ + _register(hass, entry, "sp3-001_existing", name="Existing") + await async_announce_new_entities(hass, entry) + + panel = dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, "sp3-001")}, + name="Span Panel", + ) + for index in range(COLLAPSE_ABOVE + 1): + _register( + hass, + entry, + f"span_sp3-001_adopted_panel/acme/reading-{index}", + name=f"Acme Reading {index}", + device_id=panel.id, + ) + await async_announce_new_entities(hass, entry) + + message = _text(_announcement(hass, entry)) + assert f"Span Panel ({COLLAPSE_ABOVE + 1} entities)" in message + assert "Acme Reading 0" not in message From 13e68ed435fb78bcce4638367becafbd7dece5dc Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:37:20 -0700 Subject: [PATCH 101/345] chore: 2.1.0b8, so the naming fix reaches the panel that showed the problem Also corrects the README, which described the notification as always giving a device and a count -- true when written, and the behaviour b8 changes. --- README.md | 5 +++-- custom_components/span_panel/manifest.json | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index aaf32cec..22b3c32e 100644 --- a/README.md +++ b/README.md @@ -301,8 +301,9 @@ entity on that device's own card. The wire already says which device and node it They behave like adopted devices in the ways that matter, and differ in two: -- **They arrive switched off and filed as diagnostics**, so nothing lands on a dashboard uninvited, and the new-entity notification names the device with a - count rather than listing each one — fifteen new vendor readings would otherwise cost you the curated additions in the same message. +- **They arrive switched off and filed as diagnostics**, so nothing lands on a dashboard uninvited. The new-entity notification names each one, up to five per + device; beyond that it gives the device and a count instead, because fifteen new vendor readings at once would otherwise cost you the curated additions in the + same message. - **They are readings only — never switches, selects or number boxes**, even where the panel says the property accepts writes. These sit beside curated controls that do real work, such as the charge limit that refuses a value above what your charger was commissioned for, and a generic control would sit there with none of that. If a control is worth having, it arrives curated in a release. diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 7b28683c..319b050c 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -26,7 +26,7 @@ "span-panel-api-schema-0==1.0.0b5", "span-panel-api-schema-1==0.1.0b9" ], - "version": "2.1.0b7", + "version": "2.1.0b8", "zeroconf": [ { "type": "_span._tcp.local." From 9567cb942c769bdd6d77dc066918cb88966faae4 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 22 Aug 2026 02:28:35 -0700 Subject: [PATCH 102/345] fix(extension): four defects an adversarial review found in b8 **Two lugs devices shared one identity.** The library paired both with the `panel` subject, so two lugs declaring the same vendor property minted one unique_id for two readings -- Home Assistant drops the second, and the survivor shows whichever sorted first. Identical firmware on both lugs makes that the expected case, not a coincidence. They are `kind="lugs"` keyed by direction now, and `_SCOPE_PREFIX_BY_KIND` gives them their own scope while `extension_device_identifier` still renders them on the panel's card: identity must distinguish what placement merges. **The cap could strand a user's enabled entity.** It admitted rows in adapter emission order, which tracks the wire, so a firmware update declaring a property earlier could evict a standing entity -- whose registry row is permanent, and for which nothing would build an entity again. Unavailable forever, stranger in its slot, no migration path by design. `adoptable` now admits everything the registry already holds before capping the rest. **The cap counted cards, not devices.** Panel, every circuit and both lugs render on the panel's card, so thirty-five wire devices shared one allowance: two vendor properties per circuit truncates a 32-circuit panel with no misbehaving publisher anywhere. It counts `subject_key` now. The test that claimed this behaviour only exercised battery-vs-panel and passed while the code was wrong; it exercises the shared card now. **Overflow was silent.** `MAX_PER_DEVICE`'s own docstring and the design both promised a notice; the code logged, twice per setup, once per platform. `async_notice_declined_extensions` raises a durable notice once at setup, translated into all five locales. Also clamps a vendor string to 255 characters rather than letting Home Assistant raise on the state write. The baseline reasons for `status/postal-code` and `status/time-zone` said surfacing them would copy location into recorder history and duplicate what Home Assistant owns. Both were written when surfacing meant a default-on curated entity. They now surface as disabled vendor readings, where the recorder cost is opt-in, so the reasons say what is now true: no curated entity, which is a different decision from invisibility. Requires span-panel-api 3.0.0b13 and span-panel-api-schema-1 0.1.0b10. --- custom_components/span_panel/__init__.py | 13 +- custom_components/span_panel/extension.py | 196 +++++++++++++++--- custom_components/span_panel/manifest.json | 6 +- custom_components/span_panel/strings.json | 4 + .../span_panel/translations/en.json | 4 + .../span_panel/translations/es.json | 4 + .../span_panel/translations/fr.json | 4 + .../span_panel/translations/ja.json | 4 + .../span_panel/translations/pt.json | 4 + developer.md | 163 +++++++++++++-- pyproject.toml | 4 +- requirements_test.txt | 4 +- .../unread_declarations_baseline.json | 4 +- tests/test_extension_entities.py | 87 +++++++- uv.lock | 4 +- 15 files changed, 441 insertions(+), 64 deletions(-) diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py index dedf71f2..09fd5d51 100644 --- a/custom_components/span_panel/__init__.py +++ b/custom_components/span_panel/__init__.py @@ -16,7 +16,11 @@ ConfigEntryError, ConfigEntryNotReady, ) -from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.typing import ConfigType from span_panel_api import SpanMqttClient, SpanPanelSnapshot @@ -45,6 +49,7 @@ ) from .coordinator import SpanPanelCoordinator from .current_monitor import CurrentMonitor +from .extension import async_notice_declined_extensions from .frontend import ( PANEL_FRONTEND_DIR as PANEL_FRONTEND_DIR, PANEL_URL as PANEL_URL, @@ -306,6 +311,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # After the forward, because it reports on what the platforms just built + # -- and once, rather than from each platform's own call to `adoptable`. + await async_notice_declined_extensions( + hass, entry, snapshot, dr.async_get(hass), er.async_get(hass) + ) + # After the platforms, not before: schema validation runs on the first # refresh, which is awaited above, but the Repairs it raises name the # entities an unresolved field took down — and those entities only diff --git a/custom_components/span_panel/extension.py b/custom_components/span_panel/extension.py index 6e2aa8fe..d25cfb48 100644 --- a/custom_components/span_panel/extension.py +++ b/custom_components/span_panel/extension.py @@ -30,7 +30,9 @@ from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, Platform +from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo, DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry from span_panel_api import ExtensionProperty, ExtensionSubject, SpanPanelSnapshot @@ -39,6 +41,7 @@ from .const import DOMAIN from .coordinator import SpanPanelCoordinator from .entity import SpanPanelEntity +from .notices import async_raise, read_translations from .util import ( ADOPTED_IDENTIFIER_TOKEN, SUB_DEVICE_BESS, @@ -51,6 +54,9 @@ SCOPE_PANEL: Final = "panel" +MAX_STATE_LENGTH: Final = 255 +"""Home Assistant's hard limit on a state string; a longer one raises on write.""" + HINT_READING: Final = "reading" HINT_DETAIL: Final = "detail" @@ -83,8 +89,23 @@ } """Library subject kind → the scope segment this integration's ids already use. -The singletons only. `evse` and `circuit` carry an instance key and are built -below, because their segment is `{kind}_{key}` rather than a constant. +The singletons only. The kinds below carry an instance key, so their segment is +`{prefix}_{key}` rather than a constant. +""" + +_SCOPE_PREFIX_BY_KIND: Final[dict[str, str]] = { + "evse": SUB_DEVICE_EVSE, + "circuit": "circuit", + "lugs": "lugs", +} +"""Multi-instance subject kinds, and the prefix their scope segment takes. + +`lugs` is here rather than folded into `panel` even though a lugs device's +curated fields land on the panel: the two lugs devices run identical firmware, +so a vendor extension on one is the expected case of the same extension on +both, and one scope for both would mint one id for two readings. They still +*render* on the panel's card -- see `extension_device_identifier`, where the +divergence between identity and placement is deliberate. """ @@ -99,10 +120,8 @@ def extension_scope(subject: ExtensionSubject) -> str | None: return _SCOPE_BY_KIND[subject.kind] if subject.instance_key is None: return None - if subject.kind == "evse": - return f"{SUB_DEVICE_EVSE}_{subject.instance_key}" - if subject.kind == "circuit": - return f"circuit_{subject.instance_key}" + if subject.kind in _SCOPE_PREFIX_BY_KIND: + return f"{_SCOPE_PREFIX_BY_KIND[subject.kind]}_{subject.instance_key}" return None @@ -180,9 +199,11 @@ def extension_device_identifier(panel_identifier: str, subject: ExtensionSubject A circuit's entities live on the panel's own card in this integration, so a circuit subject resolves there too -- the entity's *name* is what says which - circuit it came from. + circuit it came from. Lugs are the same: two devices for identity, one card + for rendering, which is why this mapping and `extension_scope` are separate + functions rather than one. Identity must distinguish what placement merges. """ - if subject.kind in ("panel", "circuit"): + if subject.kind in ("panel", "circuit", "lugs"): return panel_identifier if subject.kind == "battery": return f"{panel_identifier}_{SUB_DEVICE_BESS}" @@ -385,6 +406,18 @@ def native_value(self) -> str | float | None: if raw is None: return None if self.entity_description.native_unit_of_measurement is None: + # Truncated rather than passed through: Home Assistant refuses a + # state over 255 characters, and a vendor string is unbounded on the + # wire. Raising once per update for a value nobody chose is worse + # than showing the first 255 characters of it. + if len(raw) > MAX_STATE_LENGTH: + _LOGGER.debug( + "Extension %s published %d characters; truncated to %d", + self._declaration_path, + len(raw), + MAX_STATE_LENGTH, + ) + return raw[:MAX_STATE_LENGTH] return raw try: return float(raw) @@ -449,15 +482,30 @@ def _create[ExtensionT: ExtensionEntity]( neither. """ built: list[ExtensionT] = [] - for row, unique_id, device_identifier in adoptable(snapshot, device_registry): + for row, unique_id, device_identifier in adoptable(snapshot, device_registry, entity_registry): if resolve_platform(entity_registry, unique_id, row.datatype) is not platform: continue built.append(entity_class(coordinator, unique_id, row, device_identifier=device_identifier)) return built +def subject_key(subject: ExtensionSubject) -> str: + """Return the wire device a subject names, as one string. + + What the cap counts. Not the device *card*: `panel`, every circuit and both + lugs render on the panel's card, so counting per card would pool thirty-five + wire devices against one allowance -- two vendor properties on each circuit + of a 32-circuit panel would truncate with no misbehaving publisher anywhere. + """ + return ( + subject.kind if subject.instance_key is None else f"{subject.kind}:{subject.instance_key}" + ) + + def adoptable( - snapshot: SpanPanelSnapshot, device_registry: DeviceRegistry + snapshot: SpanPanelSnapshot, + device_registry: DeviceRegistry, + entity_registry: EntityRegistry, ) -> list[tuple[ExtensionProperty, str, str]]: """Every extension property that can become an entity, with its id and card. @@ -466,10 +514,44 @@ def adoptable( registry yet, or its address is outside the Homie charset. The first two are ordinary states on a setup that raced a capability -- the entity appears on the next reload, as capability-gated platforms already do. + + **An id the registry already holds is never displaced by the cap.** The cap + admits rows in the order the adapter emitted them, and that order tracks the + wire: a firmware update declaring a new property earlier in a description + shifts everything after it. Capping on arrival order alone would therefore + let a *new* property evict a standing entity -- whose registry row is + permanent, and for which nothing here would ever build an entity again, so + it would read unavailable forever while a stranger took its slot. Nothing + migrates in this design, so there would be no recovery. Two passes instead: + everything already registered is admitted first, and the cap applies only to + what is new. """ - per_device: dict[str, int] = {} - declined: dict[str, int] = {} - adoptable_rows: list[tuple[ExtensionProperty, str, str]] = [] + return _partition(snapshot, device_registry, entity_registry)[0] + + +def declined_extensions( + snapshot: SpanPanelSnapshot, + device_registry: DeviceRegistry, + entity_registry: EntityRegistry, +) -> dict[str, int]: + """How many properties each wire device declared beyond the cap. + + Separate from `adoptable` so the overflow is reported once at setup rather + than once per platform: both platforms build their share from the same + partition, and a warning per platform would double-count in the log while + saying nothing new. + """ + return _partition(snapshot, device_registry, entity_registry)[1] + + +def _partition( + snapshot: SpanPanelSnapshot, + device_registry: DeviceRegistry, + entity_registry: EntityRegistry, +) -> tuple[list[tuple[ExtensionProperty, str, str]], dict[str, int]]: + """Split the declared properties into what is adopted and what the cap declined.""" + known: list[tuple[ExtensionProperty, str, str]] = [] + fresh: list[tuple[ExtensionProperty, str, str]] = [] for row in snapshot.extension_properties: identifier = extension_device_identifier(snapshot.serial_number, row.subject) if identifier is None: @@ -486,17 +568,81 @@ def adoptable( ) if unique_id is None: continue - if per_device.get(identifier, 0) >= MAX_PER_DEVICE: - declined[identifier] = declined.get(identifier, 0) + 1 + registered = any( + entity_registry.async_get_entity_id(platform.value, DOMAIN, unique_id) is not None + for platform in (Platform.SENSOR, Platform.BINARY_SENSOR) + ) + (known if registered else fresh).append((row, unique_id, identifier)) + + per_subject: dict[str, int] = {} + for row, _unique_id, _identifier in known: + key = subject_key(row.subject) + per_subject[key] = per_subject.get(key, 0) + 1 + + adoptable_rows = list(known) + declined: dict[str, int] = {} + for row, unique_id, identifier in fresh: + key = subject_key(row.subject) + if per_subject.get(key, 0) >= MAX_PER_DEVICE: + declined[key] = declined.get(key, 0) + 1 continue - per_device[identifier] = per_device.get(identifier, 0) + 1 + per_subject[key] = per_subject.get(key, 0) + 1 adoptable_rows.append((row, unique_id, identifier)) - for identifier, count in declined.items(): - _LOGGER.warning( - "Device %s declares more than %d vendor properties; %d were not adopted. " - "They remain visible in this integration's diagnostics", - identifier, - MAX_PER_DEVICE, - count, - ) - return adoptable_rows + return adoptable_rows, declined + + +_OVERFLOW_NOTICE: Final = "extension_overflow" + +_OVERFLOW_FALLBACK: Final[dict[str, str]] = { + "title": "SPAN Panel: some vendor readings were not added", + "body": ( + "A device on your panel declares more vendor readings than this integration will add " + "for one device ({limit}). The rest were left out: {devices}.\n\n" + "Nothing you already have is affected, and nothing is broken. The readings that were " + "left out are still listed in this integration's diagnostics download, which is what " + "to attach if you want them surfaced." + ), +} +"""English text for the overflow notice, used when a translation cannot be read. + +Carried here rather than only in `strings.json` for the reason `additions` +documents: an unreadable file should cost the translation, never the notice -- +and a silently truncated surface is exactly the thing that must not be silent. +""" + + +async def async_notice_declined_extensions( + hass: HomeAssistant, + entry: ConfigEntry, + snapshot: SpanPanelSnapshot, + device_registry: DeviceRegistry, + entity_registry: EntityRegistry, +) -> None: + """Tell the user when the cap left vendor readings out, or say nothing. + + A durable notice rather than a log line, because the alternative is a + truncation the user cannot see: an entity list showing sixty of a device's + eighty readings looks exactly like a device with sixty readings. Raised once + at setup rather than from `adoptable`, which both platforms call. + """ + declined = declined_extensions(snapshot, device_registry, entity_registry) + if not declined: + return + rendered = ", ".join(f"{key} ({count})" for key, count in sorted(declined.items())) + _LOGGER.warning( + "Vendor readings beyond the per-device limit of %d were not adopted: %s", + MAX_PER_DEVICE, + rendered, + ) + text = await hass.async_add_executor_job( + read_translations, hass.config.language, _OVERFLOW_NOTICE + ) + async_raise( + hass, + entry, + _OVERFLOW_NOTICE, + title=text.get("title") or _OVERFLOW_FALLBACK["title"], + message=(text.get("body") or _OVERFLOW_FALLBACK["body"]).format( + limit=MAX_PER_DEVICE, devices=rendered + ), + ) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 319b050c..03ab55ca 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -22,11 +22,11 @@ ], "quality_scale": "gold", "requirements": [ - "span-panel-api==3.0.0b12", + "span-panel-api==3.0.0b13", "span-panel-api-schema-0==1.0.0b5", - "span-panel-api-schema-1==0.1.0b9" + "span-panel-api-schema-1==0.1.0b10" ], - "version": "2.1.0b8", + "version": "2.1.0b9", "zeroconf": [ { "type": "_span._tcp.local." diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index 71a4b769..a27c0cde 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -787,6 +787,10 @@ "disabled_heading": "Added but switched off", "how_to_enable": "Switched-off entities record nothing until you turn them on. Open the SPAN Panel device page, show its disabled entities, and enable the ones you want.", "nothing_broken": "Nothing is broken and no action is required. Entities appear for several reasons: an update that surfaces more of what your panel reports, a circuit added to the panel, or hardware such as a battery, solar or a charger coming online." + }, + "extension_overflow": { + "title": "SPAN Panel: some vendor readings were not added", + "body": "A device on your panel declares more vendor readings than this integration will add for one device ({limit}). The rest were left out: {devices}.\n\nNothing you already have is affected, and nothing is broken. The readings that were left out are still listed in this integration's diagnostics download, which is what to attach if you want them surfaced." } } } diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index 71a4b769..a27c0cde 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -787,6 +787,10 @@ "disabled_heading": "Added but switched off", "how_to_enable": "Switched-off entities record nothing until you turn them on. Open the SPAN Panel device page, show its disabled entities, and enable the ones you want.", "nothing_broken": "Nothing is broken and no action is required. Entities appear for several reasons: an update that surfaces more of what your panel reports, a circuit added to the panel, or hardware such as a battery, solar or a charger coming online." + }, + "extension_overflow": { + "title": "SPAN Panel: some vendor readings were not added", + "body": "A device on your panel declares more vendor readings than this integration will add for one device ({limit}). The rest were left out: {devices}.\n\nNothing you already have is affected, and nothing is broken. The readings that were left out are still listed in this integration's diagnostics download, which is what to attach if you want them surfaced." } } } diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index 8d2ed679..d1846ebb 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -787,6 +787,10 @@ "disabled_heading": "Añadidas pero desactivadas", "how_to_enable": "Las entidades desactivadas no registran nada hasta que las actives. Abre la página del dispositivo Panel SPAN, muestra sus entidades desactivadas y activa las que quieras.", "nothing_broken": "No hay ningún problema y no se requiere ninguna acción. Las entidades aparecen por varios motivos: una actualización que expone más de lo que informa tu panel, un circuito añadido al panel, o hardware como una batería, paneles solares o un cargador que se pone en línea." + }, + "extension_overflow": { + "title": "Panel SPAN: algunas lecturas del fabricante no se añadieron", + "body": "Un dispositivo de tu panel declara más lecturas del fabricante de las que esta integración añade para un solo dispositivo ({limit}). El resto quedó fuera: {devices}.\n\nNada de lo que ya tienes se ve afectado y nada está roto. Las lecturas que quedaron fuera siguen apareciendo en la descarga de diagnósticos de esta integración, que es lo que conviene adjuntar si quieres que se muestren." } } } diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index ab154286..0b42bdf0 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -787,6 +787,10 @@ "disabled_heading": "Ajoutées mais désactivées", "how_to_enable": "Les entités désactivées n'enregistrent rien tant que vous ne les activez pas. Ouvrez la page de l'appareil Panneau SPAN, affichez ses entités désactivées et activez celles que vous souhaitez.", "nothing_broken": "Rien n'est cassé et aucune action n'est requise. Des entités apparaissent pour plusieurs raisons : une mise à jour qui expose davantage de ce que votre panneau rapporte, un circuit ajouté au panneau, ou du matériel tel qu'une batterie, du solaire ou un chargeur qui se met en ligne." + }, + "extension_overflow": { + "title": "Panneau SPAN : certaines mesures du fabricant n'ont pas été ajoutées", + "body": "Un appareil de votre panneau déclare plus de mesures du fabricant que cette intégration n'en ajoute pour un seul appareil ({limit}). Les autres ont été laissées de côté : {devices}.\n\nRien de ce que vous avez déjà n'est affecté et rien n'est cassé. Les mesures laissées de côté figurent toujours dans le téléchargement des diagnostics de cette intégration, qu'il convient de joindre si vous souhaitez les voir apparaître." } } } diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index 2e174645..6795877e 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -787,6 +787,10 @@ "disabled_heading": "追加されましたが無効です", "how_to_enable": "無効なエンティティは、有効にするまで何も記録しません。SPAN パネルのデバイスページを開き、無効なエンティティを表示して、必要なものを有効にしてください。", "nothing_broken": "問題はなく、対応は不要です。エンティティが追加される理由はいくつかあります: パネルが報告する内容をより多く公開するアップデート、パネルに追加された回路、またはバッテリー、太陽光、充電器などのハードウェアがオンラインになった場合です。" + }, + "extension_overflow": { + "title": "SPAN パネル: 一部のベンダー測定値は追加されませんでした", + "body": "パネル上のあるデバイスが、この統合が 1 台あたりに追加する上限({limit})を超える数のベンダー測定値を宣言しています。残りは追加されませんでした: {devices}。\n\n既存のものには影響はなく、故障でもありません。追加されなかった測定値は、この統合の診断ダウンロードには引き続き記載されています。表示を希望する場合は、それを添付してください。" } } } diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index 501a80ef..bf140185 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -787,6 +787,10 @@ "disabled_heading": "Adicionadas mas desativadas", "how_to_enable": "As entidades desativadas não registam nada até as ativar. Abra a página do dispositivo Painel SPAN, mostre as suas entidades desativadas e ative as que quiser.", "nothing_broken": "Nada está avariado e não é necessária qualquer ação. As entidades aparecem por vários motivos: uma atualização que expõe mais do que o seu painel reporta, um circuito adicionado ao painel, ou hardware como uma bateria, solar ou um carregador a ficar online." + }, + "extension_overflow": { + "title": "Painel SPAN: algumas leituras do fabricante não foram adicionadas", + "body": "Um dispositivo do seu painel declara mais leituras do fabricante do que esta integração adiciona para um único dispositivo ({limit}). As restantes ficaram de fora: {devices}.\n\nNada do que já tem é afetado e nada está avariado. As leituras que ficaram de fora continuam listadas na transferência de diagnósticos desta integração, que é o que deve anexar se quiser que sejam apresentadas." } } } diff --git a/developer.md b/developer.md index e76bddf9..57a134d0 100644 --- a/developer.md +++ b/developer.md @@ -187,10 +187,17 @@ When you surface a property, delete its baseline line in the same change. The te line you left. When you decide a property should _stay_ unread, add a line with an honest reason. The reasons are load-bearing — they are what stops the file becoming a list -of things nobody remembers deciding. The current entries are all permanent: deliberate skips (`status/postal-code` copies location into recorder history; Home -Assistant owns `status/time-zone`), values held for identity reasons (`pv/info/serial-number`), redundant echoes (`connection/*-device-type` dereferences to a +of things nobody remembers deciding. The current entries are all permanent: deliberate skips (`status/postal-code` and `status/time-zone`, which Home Assistant +already owns or has no use for), values held for identity reasons (`pv/info/serial-number`), redundant echoes (`connection/*-device-type` dereferences to a declared `$type`), and properties no producer publishes (`connection/count`). +**"Unread" here means "no curated entity", not "invisible".** Since 2.1.0b7 an unread property on a _modelled_ device also surfaces through +[extension adoption](#extension-properties) as a disabled diagnostic entity, so a baseline line records a decision not to **curate** something — to give it a +designed name, a category and a place — rather than a decision to withhold it. The two skips above were written before that distinction existed and read as +though a baseline line hid a property; it does not, and the entries were reworded rather than left to mislead. A reason that turns on the cost of a _default-on_ +entity is worth re-reading in that light: `postal-code`'s original reason was that surfacing it copies the user's location into recorder history, which an +opt-in disabled entity does only if the user asks for it. + ### What it does not cover The gate reads the **vendored fixture**, so it answers "what does our capture declare that we do not read". It cannot see a property a real panel starts @@ -221,8 +228,12 @@ the panel has published a value for the property, which is the declared-but-neve key-based over the config entry: it knows nothing about wire property names and could not protect a value added here. `test_schema_discovery` asserts that against the capture's own published values rather than leaving it to review. -This is **maintainer-facing only**. Nothing creates an entity, a Repair or a notification from it — including for a device that _is_ adopted, whose properties -are reported here as declarations exactly like any other. Adoption itself is the next section. +This block is **maintainer-facing only**: nothing creates an entity, a Repair or a notification from a `schema_discovery` row — including for a device that _is_ +adopted, whose properties are reported here as declarations exactly like any other. + +That is a statement about this block, not about the properties in it. The same properties on a _modelled_ device also arrive as `snapshot.extension_properties`, +which does carry values and does become entities — see [Extension properties](#extension-properties). Two artefacts describing one property, joined by its +`{node}/{property}` path, with opposite audiences and opposite rules about values. Adoption itself is the next section. ### Why discovered rows cannot reach the curated inventories @@ -270,16 +281,19 @@ second inverter, which the eBus schema explicitly permits. Such a device used to ### The rule -**The unit of adoption is a device, never a property.** +Both halves of vendor extensibility are adopted, and **the half decides the shape**: a device nobody modelled becomes a device, a property on a device we do +model becomes a reading on that device's existing card. -| What arrives | What happens | -| ------------------------------------------------ | -------------------------------------------------------------------------------- | -| A device type `MODELLED_TYPES` does not name | **Adopt.** One sub-device, its properties surfaced beneath it. | -| A new node or property on a device we _do_ model | **Do not adopt.** It lands in `schema_discovery`; curate it in the next release. | -| A new property on a device already adopted | Adopt, with its siblings. | +| What arrives | What happens | +| ------------------------------------------------ | -------------------------------------------------------------------------------------------------- | +| A device type `MODELLED_TYPES` does not name | **Adopt as a device.** One sub-device, its properties surfaced beneath it. This section. | +| A new node or property on a device we _do_ model | **Adopt as a reading** on that device's card — never a new device. [Below](#extension-properties). | +| A new property on a device already adopted | Adopt, with its siblings. | +| A second instance of a modelled type | **Not adopted.** See below. | -The asymmetry is where the cost calculus actually points. An adopted entity's id is machine-derived and permanent once it registers, which is only a loss where -curation is coming. On a type nobody modelled, no better id is coming and the alternative is silence. +The two differ in what they can promise. An adopted _device_ is a card nothing else was ever going to describe. An adopted _property_ sits beside curated +entities on a card this integration designed, so it is deliberately the plainer thing: read-only, wire-named, and carrying no expectation that curation will one +day rename it into something better. Extra instances of a modelled type are **not** adopted. A second BESS is a multiplicity limitation of the snapshot model, not an unmodelled device, and adopting it would stand a machine-named card beside the curated Battery describing the same hardware. The gap stays visible as a gap. @@ -426,9 +440,14 @@ Three things it does that the Repair did not: It stays silent on a first install, and silent once more on the first run after this mechanism ships: an install predating the record has entities that were never announced but are not new either, so the first pass adopts them as known. -Adopted devices are collapsed to one line with a count — `Backup Generator (6 entities)` — for the reason the whole design is device-scoped: a vendor device -declaring a dozen properties would otherwise spend the entire notification on itself and teach the user to skip it, costing them the curated additions in the -same message. +Adopted devices are collapsed to one line with a count — `Backup Generator (6 entities)` — because a vendor device declaring a dozen properties would otherwise +spend the entire notification on itself and teach the user to skip it, costing them the curated additions in the same message. + +**Extension properties collapse only above `additions.COLLAPSE_ABOVE` (five), and the asymmetry is the point.** An adopted device's line names a device that did +not exist before, which is itself the news at any count. An extension property sits on a card the user already has, so `Span Panel (2 entities)` tells them +strictly less than the two names would — which is exactly what a live b7 install produced for a postal code and a time zone. Counted per notification rather +than per device lifetime: five readings announced last month and one today is a one-line update, not a flood. The detector is `_extension_device_name`, which +tests the **unique_id** rather than the device identifier, because these live on curated cards and the card says nothing about them. **Translations are read from this component's own `translations/` directory**, not through `homeassistant.helpers.translation`. That helper filters to the categories Home Assistant defines, and a persistent notification is not one of them — a custom category loads as nothing at all, which was verified rather than @@ -444,7 +463,119 @@ cannot protect a wire value put there. ### Adopted entities declare no field paths `snapshot.adopted_devices` is outside the curated field-path vocabulary by construction — it carries no metadata row, so the producible gate has nothing to -check it against. `adoption.py` is therefore absent from `residual_field_paths()`'s import list, and its entity classes declare no `_residual_field_paths`. +check it against. `adoption.py` is therefore absent from `residual_field_paths()`'s import list, and its entity classes declare no `_residual_field_paths`. The +same holds for `extension.py` and `snapshot.extension_properties`, for the same reason. + +## Extension properties + +A property on a device this integration **does** model — `battery-2/cell-temperature` hung off the BESS by a battery vendor. Until 2.1.0b7 it reached the user +nowhere: it became a `DiscoveredMetadata` row and stopped at the diagnostics download. `extension.py` turns it into an entity on that device's existing card. + +### Where the value comes from + +The library carries it, in a type built for the purpose: + +| Type | Carries | Audience | +| -------------------------------------------- | ----------------------------- | --------------------------- | +| `DiscoveredMetadata` (`discovered.*` paths) | Declaration only, never value | Maintainer, via diagnostics | +| `ExtensionProperty` (`extension_properties`) | Declaration **and** value | User, via entities | + +The same wire property appears in both, joined by its `{node}/{property}` path. **`ExtensionProperty` is deliberately not a `FieldMetadata`**: `partition()` +walks `build_field_metadata()`, so a type that cannot enter that map has no path into a payload that leaves the machine. That is the diagnostics guarantee as a +shape rather than as a rule somebody has to remember, and `test_extension_property_is_not_field_metadata` asserts it. + +`schema-1`'s `addressed_rows()` is shared by `build_discovery` and `build_extension_properties` so the two cannot disagree about what "unaddressed" means. A +property counted as addressed by one and not the other would appear as an entity the diagnostics claim is ignored, or the reverse. + +### The identity, which is the irreversible part + +```text +span_{serial}_adopted_{scope}/{node}/{property} +``` + +- **Anchored on what is stable and ours** — the panel serial and the curated scope (`bess`, `mid`, `pv`, `panel`, `evse_{node}`, `circuit_{id}`). +- **Addressed by the wire path verbatim**, which is upstream's own capability-catalog spelling (`AdoptedProperty.path`, `discovery_path()`). Verbatim is what + makes it injective: the id _is_ the address. Normalising hyphens would collapse `battery-2` + `cell-temperature` and `battery` + `2-cell-temperature` into one + id, which `test_the_pairs_a_normalising_grammar_would_collapse_stay_distinct` pins. +- **Never through `get_user_friendly_suffix`**, which de-_dots_ rather than de-hyphens and substitutes a curated suffix on a mapping hit. +- **Not the eBus proxy composition.** `{proxier-id}-{proxied-id}` is upstream's device-handle spelling, and upstream states those handles are not identities: + they differ across enclosures and are unstable across the proxy-to-native transition. An id anchored on one would rename itself when a device stopped being + proxied, and nothing here migrates, so there would be no recovery. +- An address outside the Homie charset (`[a-z0-9-]`) is **refused, not sanitised** — sanitising is what would make the slash-split ambiguous. It stays visible + in diagnostics. + +The slash distinguishes the two adoption grammars: device-level ids contain none. + +### Terminal identity + +An adopted extension is never promoted, re-sourced, re-homed or migrated. It changes only on an **external** trigger — the publisher stops publishing it, or +better metadata arrives. Three consequences worth knowing before changing any of this: + +- **Curation is never blocked by one existing.** If a property is later curated, the curated entity is a _new_ entity with its own id and history; the adopted + one is not renamed into it. Ids are permanent, identity is not, and an earlier draft of this design built a registry take-over path to avoid that — it was + cut, because it rested on the library ceasing to emit the row in lockstep with curation, a two-repo promise whose conformance test cannot distinguish + "curation mapped it" from "the capture was regenerated without it". +- **Nothing is ever removed by this integration**, and there is no engagement test anywhere. A row the user deletes is recreated — disabled — at the next setup + while the property is still published, so deletion is not suppression. It sticks exactly when publishing has stopped, because then nothing exists to recreate + it from. That is why no suppression feature exists: the delete button already means "hide until next reload" for a live reading and "clear it out" for a dead + one, decided by the wire. +- **A property that stops being published reads unknown rather than disappearing.** Silence does not distinguish gone from not-yet-arrived. + +### What metadata may reshape, and what it may not + +| Attribute | Revisable later? | +| ------------------------------------------- | --------------------------------------------- | +| `entity_category`, device class, unit, name | **Yes**, freely — no id change, no statistics | +| Platform (`sensor` vs `binary_sensor`) | **No.** The domain is baked into `entity_id` | +| `state_class` | Never set at all | + +The free half is free _because_ of the never half: these entities carry no `state_class`, so they write no long-term statistics, so a later unit or device-class +change has nothing to reinterpret. Contrast a curated entity, where changing a unit under a `state_class` is the unrepairable case. + +The platform is enforced in `resolve_platform`, not remembered: whatever domain the id is already registered under wins, however the declaration later changes. +`async_update_entity` raises `ValueError("New entity ID should be same domain")`, so re-deriving the platform from better metadata would not move a row — it +would strand it and mint a second entity beside it. `test_the_platform_a_row_is_born_under_is_the_one_it_keeps` is the guard. + +### Read-only, and why disabled-by-default does not gate it + +No switches, selects or number boxes, even where the panel declares the property settable — `classify_extension` is `adoption.classify` with its three control +rows deleted. The worked bypass: a vendor publishes `acme/charge-limit` beside the curated EVSE limit. The curated number goes through +`evse_charge_limit_payload()`, which **refuses** a value above the commissioned ceiling. An auto-generated number on the same device, fed by a generic set +topic, publishes whatever the user types on the same wire. The islanding assertion is the same shape — schema_1 translates `GRID` into `ON_GRID`. + +"Disabled-by-default gates the control", which `classify` argues for unmodelled devices, does not transfer: there the hazard is user intent, here it is semantic +interaction with curated logic the user cannot see. The library enforces it structurally — `ExtensionProperty` has no set-topic member to populate, and +`set_adopted_property` still resolves only against `adopted_devices`. + +### The cap + +`MAX_PER_DEVICE` (60) bounds what one **wire device** may mint — counted on `subject_key`, not on the card. The panel, every circuit and both lugs render on the +panel's card, so counting per card would pool thirty-five devices against one allowance and truncate a 32-circuit panel at two vendor properties each, with no +misbehaving publisher anywhere. Overflow raises a durable notice (`async_notice_declined_extensions`, once at setup rather than once per platform), because a +truncation the user cannot see is the one thing worse than the truncation: sixty of a device's eighty readings looks exactly like a device with sixty. + +**An id the registry already holds is never displaced.** The cap admits in adapter emission order, which tracks the wire, so a firmware update declaring a new +property earlier shifts everything after it. Capping on arrival order alone would let a new property evict a standing entity — whose registry row is permanent, +and for which nothing would ever build an entity again, leaving it unavailable forever with a stranger in its slot and no migration path by design. `adoptable` +therefore partitions registered from new, admits every registered row, and applies the cap only to the rest. Registry rows are permanent here and nothing +removes them, so a vendor node declaring hundreds of properties would otherwise put hundreds of rows in every entity picker on every install that met it, with +no later release able to take them back. Deliberately far above any real device — the sixteen `pcs` properties are the largest curated example — so it is a +backstop against a misbehaving publisher rather than a policy on normal ones. + +### The prominence hint + +`prominence_hint()` is advisory only: everything arrives `DIAGNOSTIC` regardless, and the hint rides along as an entity attribute for curation triage. Ranked by +confidence, and the ranking is the argument: + +1. **Identity-family naming → detail.** Highest confidence because it is purely _negative_ — a property named for a vendor, model, serial, part number or + firmware build is device description. +2. **A unit in `DEVICE_CLASS_BY_UNIT` → reading.** Moderate, and it may promote but never demote, because it fails systematically in one direction: the most + headline-worthy number a battery publishes is a `%` state of charge, and `%` is absent from that map on purpose, being equally a confidence or a duty cycle. +3. **Everything else → detail**, with `node_has_curated_siblings` recorded as corroboration rather than as a decision. Homie nodes are organisational, not + editorial. + +The real fix is upstream: a declared `role` on the property, proposed in `SpanPanel_Docs/span/docs/dev/ebus-property-role-proposal.md`. Until then the ranking +is the shipping plan, and `entity_category` being free to revise is what makes a conservative default cheap. ## Linting and Type Checking diff --git a/pyproject.toml b/pyproject.toml index ef351471..a386548e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,13 +8,13 @@ readme = "README.md" requires-python = ">=3.14.3,<3.15" dependencies = [ "homeassistant==2026.8.0", - "span-panel-api==3.0.0b12", + "span-panel-api==3.0.0b13", # 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.0.0b5", - "span-panel-api-schema-1==0.1.0b9", + "span-panel-api-schema-1==0.1.0b10", ] [dependency-groups] diff --git a/requirements_test.txt b/requirements_test.txt index 5bcb64a4..b7ca2546 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -3,6 +3,6 @@ pytest-asyncio>=1.3.0 setuptools>=65.7.0 pytest-homeassistant-custom-component==0.13.354 homeassistant==2026.8.0 -span-panel-api==3.0.0b12 +span-panel-api==3.0.0b13 span-panel-api-schema-0==1.0.0b5 -span-panel-api-schema-1==0.1.0b9 +span-panel-api-schema-1==0.1.0b10 diff --git a/tests/fixtures/unread_declarations_baseline.json b/tests/fixtures/unread_declarations_baseline.json index 8cfe362e..07f3e8ae 100644 --- a/tests/fixtures/unread_declarations_baseline.json +++ b/tests/fixtures/unread_declarations_baseline.json @@ -2,8 +2,8 @@ "circuit/breaker/poles": "reaches circuit.is_240v and stops there; the voltage attribute is derived from the tab count instead, so no entity or attribute reads the field", "circuit/connection/count": "microinverter/pack count; no producer publishes it, so there is no fixture value an entity could be tested against", "circuit/connection/feeds-device-type": "redundant: the library resolves a DER's class by dereferencing feeds-device-id against the tree's declared $type", - "distribution-enclosure/status/postal-code": "deliberate skip: location data with no Home Assistant value, and surfacing it would copy the user's location into recorder history", - "distribution-enclosure/status/time-zone": "deliberate skip: Home Assistant owns the time zone, so a mismatch is a Repair rather than an entity", + "distribution-enclosure/status/postal-code": "deliberate skip: no curated entity, because location has no Home Assistant value. It does surface as a disabled vendor reading through extension adoption, where the recorder cost is opt-in rather than imposed", + "distribution-enclosure/status/time-zone": "deliberate skip: no curated entity, because Home Assistant owns the time zone and a mismatch is a Repair rather than an entity. It does surface as a disabled vendor reading through extension adoption", "lugs/connection/count": "the lugs counterpart of circuit/connection/count, with no fed-device story upstream and no producer publishing it", "lugs/connection/fed-by-device-type": "redundant: the id dereferences to the device's declared $type, which is how the library already resolves classes", "lugs/connection/feeds-device-status": "feedthrough link-health; the feeds-* half of the downstream lugs record is topology to consume, and no producer publishes it", diff --git a/tests/test_extension_entities.py b/tests/test_extension_entities.py index 77bfcb1f..44ad55a6 100644 --- a/tests/test_extension_entities.py +++ b/tests/test_extension_entities.py @@ -32,8 +32,10 @@ create_extension_binary_sensors, create_extension_sensors, extension_device_identifier, + extension_unique_id, prominence_hint, resolve_platform, + subject_key, ) from custom_components.span_panel.util import SUB_DEVICE_BESS @@ -181,14 +183,14 @@ def test_a_row_whose_card_is_not_registered_yet_is_deferred( ) -> None: """A capability race defers the entity to the next reload rather than minting a card.""" snapshot = _snapshot(_row(kind="pv")) - assert adoptable(snapshot, dr.async_get(hass)) == [] + assert adoptable(snapshot, dr.async_get(hass), er.async_get(hass)) == [] def test_a_row_on_a_registered_card_is_adoptable( hass: HomeAssistant, registered_panel: tuple[str, str] ) -> None: snapshot = _snapshot(_row()) - adoptable_rows = adoptable(snapshot, dr.async_get(hass)) + adoptable_rows = adoptable(snapshot, dr.async_get(hass), er.async_get(hass)) assert len(adoptable_rows) == 1 row, unique_id, identifier = adoptable_rows[0] assert identifier == BESS_IDENTIFIER @@ -200,7 +202,7 @@ def test_an_off_charset_address_is_declined_rather_than_sanitised( hass: HomeAssistant, registered_panel: tuple[str, str] ) -> None: snapshot = _snapshot(_row(property_id="Cell_Temperature")) - assert adoptable(snapshot, dr.async_get(hass)) == [] + assert adoptable(snapshot, dr.async_get(hass), er.async_get(hass)) == [] # --- the cap ---------------------------------------------------------------- @@ -211,20 +213,83 @@ def test_a_vendor_flooding_one_device_is_capped( ) -> None: """Registry rows are permanent and nothing removes them, so the flood is bounded.""" rows = tuple(_row(property_id=f"reading-{index}") for index in range(MAX_PER_DEVICE + 25)) - adopted = adoptable(_snapshot(*rows), dr.async_get(hass)) + adopted = adoptable(_snapshot(*rows), dr.async_get(hass), er.async_get(hass)) assert len(adopted) == MAX_PER_DEVICE -def test_the_cap_is_per_device_not_per_panel( +def test_the_cap_is_per_wire_device_not_per_card( hass: HomeAssistant, registered_panel: tuple[str, str] ) -> None: - """One noisy vendor device must not crowd out a quiet one on another card.""" - battery_rows = tuple( - _row(property_id=f"reading-{index}") for index in range(MAX_PER_DEVICE + 5) + """One noisy device must not crowd out a quiet one that shares its card. + + The panel, every circuit and both lugs render on the panel's card. Counting + per card would pool thirty-five wire devices against one allowance, so two + vendor properties on each circuit of a 32-circuit panel would truncate with + no misbehaving publisher anywhere. The cap counts the wire device. + """ + noisy = tuple( + _row(kind="circuit", instance_key="circuit-a", node_id="acme", property_id=f"reading-{index}") + for index in range(MAX_PER_DEVICE + 5) + ) + quiet = ( + _row(kind="circuit", instance_key="circuit-b", node_id="acme", property_id="reading-0"), + _row(kind="panel", node_id="acme", property_id="site-reading"), + _row(kind="lugs", instance_key="upstream", node_id="acme", property_id="phase-balance"), ) - panel_row = _row(kind="panel", node_id="acme", property_id="site-reading") - adopted = adoptable(_snapshot(*battery_rows, panel_row), dr.async_get(hass)) - assert sum(1 for _row_, _uid, identifier in adopted if identifier == PANEL_SERIAL) == 1 + adopted = adoptable(_snapshot(*noisy, *quiet), dr.async_get(hass), er.async_get(hass)) + + # Every quiet device keeps its readings, though all four share the panel card. + adopted_keys = [subject_key(row.subject) for row, _uid, _identifier in adopted] + assert adopted_keys.count("circuit:circuit-a") == MAX_PER_DEVICE + assert adopted_keys.count("circuit:circuit-b") == 1 + assert adopted_keys.count("panel") == 1 + assert adopted_keys.count("lugs:upstream") == 1 + assert {identifier for _row_, _uid, identifier in adopted} == {PANEL_SERIAL} + + +def test_two_lugs_publishing_the_same_property_get_two_identities( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """The collision that folding lugs into `panel` produced. + + Both lugs devices run the same firmware, so a vendor extension on one is the + expected case of the same extension on both. One subject for the pair minted + one unique_id for two readings: Home Assistant drops the second, and the + survivor shows whichever device sorted first. + """ + upstream = _row(kind="lugs", instance_key="upstream", node_id="acme", property_id="phase-balance", value="1.5") + downstream = _row(kind="lugs", instance_key="downstream", node_id="acme", property_id="phase-balance", value="99.9") + adopted = adoptable(_snapshot(upstream, downstream), dr.async_get(hass), er.async_get(hass)) + + ids = [unique_id for _row_, unique_id, _identifier in adopted] + assert len(ids) == len(set(ids)) == 2 + # Both still render on the panel's card: identity distinguishes, placement merges. + assert {identifier for _row_, _uid, identifier in adopted} == {PANEL_SERIAL} + + +def test_a_registered_entity_is_never_displaced_by_the_cap( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """A standing entity outranks a new arrival, whatever order the wire sends them in. + + The row order tracks the wire, so a firmware update declaring a property + earlier shifts everything after it. Capping on arrival order alone would let + a new property evict a standing entity whose registry row is permanent and + for which nothing would ever build an entity again -- unavailable forever, + with no migration path by design. + """ + registry = er.async_get(hass) + standing = _row(property_id="long-standing") + standing_id = extension_unique_id(PANEL_SERIAL, standing.subject, standing.node_id, standing.property_id) + assert standing_id is not None + registry.async_get_or_create(Platform.SENSOR.value, DOMAIN, standing_id) + + # The standing property now arrives *last*, behind a full cap of new ones. + newcomers = tuple(_row(property_id=f"new-{index}") for index in range(MAX_PER_DEVICE)) + adopted = adoptable(_snapshot(*newcomers, standing), dr.async_get(hass), registry) + + assert standing_id in {unique_id for _row_, unique_id, _identifier in adopted} + assert len(adopted) == MAX_PER_DEVICE # --- the entities themselves ------------------------------------------------ diff --git a/uv.lock b/uv.lock index a0873061..a52d9a7e 100644 --- a/uv.lock +++ b/uv.lock @@ -2550,7 +2550,7 @@ dev = [ [[package]] name = "span-panel-api" -version = "3.0.0b12" +version = "3.0.0b13" source = { editable = "../../span/span-panel-api-p3" } dependencies = [ { name = "httpx" }, @@ -2601,7 +2601,7 @@ requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-ap [[package]] name = "span-panel-api-schema-1" -version = "0.1.0b9" +version = "0.1.0b10" source = { editable = "../../span/span-panel-api-p3/packages/schema-1" } dependencies = [ { name = "ebus-sdk" }, From ce5a79b4573d1b9e32d3bffd9effdbee22d31b01 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:46:51 -0700 Subject: [PATCH 103/345] chore(deps): follow span-panel-api to its release versions span-panel-api 3.0.0, span-panel-api-schema-0 1.0.0 and span-panel-api-schema-1 1.0.0 replace the beta pins. Nothing about this integration's behaviour changes: the libraries are the same code their last betas carried, renumbered for the first public release. The pins live in three files and all three move together. pyproject.toml is the development environment, manifest.json is the list Home Assistant actually reads at install time, and requirements_test.txt is derived from the manifest -- which is not a fact worth remembering, because test_the_real_repository_is_in_sync runs sync-dependencies and fails if any of them disagree. It caught the third file here after the first two were edited by hand. manifest.json now names versions that are not on PyPI yet, so the release order is the libraries first -- v3.0.0, then schema-0-v1.0.0 and schema-1-v1.0.0 -- and this integration afterwards. Until then the editable [tool.uv.sources] paths supply them, which is what the local lock resolves. Home Assistant pins are deliberately untouched. homeassistant 2026.8.0, homeassistant-stubs 2026.8.0 and pytest-homeassistant-custom-component 0.13.354 already agree with each other and with the 2026.8.0 floor hacs.json declares, and the floor is the version worth testing against: it is the oldest one a user can be running, while the newest is only where developers drift. --- custom_components/span_panel/manifest.json | 6 +++--- pyproject.toml | 6 +++--- requirements_test.txt | 6 +++--- uv.lock | 6 +++--- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 03ab55ca..1e14cb9f 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -22,9 +22,9 @@ ], "quality_scale": "gold", "requirements": [ - "span-panel-api==3.0.0b13", - "span-panel-api-schema-0==1.0.0b5", - "span-panel-api-schema-1==0.1.0b10" + "span-panel-api==3.0.0", + "span-panel-api-schema-0==1.0.0", + "span-panel-api-schema-1==1.0.0" ], "version": "2.1.0b9", "zeroconf": [ diff --git a/pyproject.toml b/pyproject.toml index a386548e..a83199ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,13 +8,13 @@ readme = "README.md" requires-python = ">=3.14.3,<3.15" dependencies = [ "homeassistant==2026.8.0", - "span-panel-api==3.0.0b13", + "span-panel-api==3.0.0", # 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.0.0b5", - "span-panel-api-schema-1==0.1.0b10", + "span-panel-api-schema-0==1.0.0", + "span-panel-api-schema-1==1.0.0", ] [dependency-groups] diff --git a/requirements_test.txt b/requirements_test.txt index b7ca2546..dffd818b 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -3,6 +3,6 @@ pytest-asyncio>=1.3.0 setuptools>=65.7.0 pytest-homeassistant-custom-component==0.13.354 homeassistant==2026.8.0 -span-panel-api==3.0.0b13 -span-panel-api-schema-0==1.0.0b5 -span-panel-api-schema-1==0.1.0b10 +span-panel-api==3.0.0 +span-panel-api-schema-0==1.0.0 +span-panel-api-schema-1==1.0.0 diff --git a/uv.lock b/uv.lock index a52d9a7e..841f401b 100644 --- a/uv.lock +++ b/uv.lock @@ -2550,7 +2550,7 @@ dev = [ [[package]] name = "span-panel-api" -version = "3.0.0b13" +version = "3.0.0" source = { editable = "../../span/span-panel-api-p3" } dependencies = [ { name = "httpx" }, @@ -2590,7 +2590,7 @@ dev = [ [[package]] name = "span-panel-api-schema-0" -version = "1.0.0b5" +version = "1.0.0" source = { editable = "../../span/span-panel-api-p3/packages/schema-0" } dependencies = [ { name = "span-panel-api" }, @@ -2601,7 +2601,7 @@ requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-ap [[package]] name = "span-panel-api-schema-1" -version = "0.1.0b10" +version = "1.0.0" source = { editable = "../../span/span-panel-api-p3/packages/schema-1" } dependencies = [ { name = "ebus-sdk" }, From ac786711f54aafa0045fa191d70efa3f767778e9 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:27:36 -0700 Subject: [PATCH 104/345] fix(naming): circuit energy descriptors match the suffixes they ship with The display names and the entity-id suffixes for circuit energy sensors disagreed on word order: name="Produced Energy" against a suffix of energy_produced, and the same for consumed and net. Nothing surfaced it while this integration assembles entity ids itself, because the two never meet -- the id comes from the suffix mapping and the name is only a label. They meet the moment Home Assistant composes the id from the name, which is what core 2026.8's entity_id_parts does. Composition would slugify "Kitchen Produced Energy" to kitchen_produced_energy while the entity has shipped as kitchen_energy_produced since before 2.0.8, and "Recreate entity IDs" would then offer to reorder the suffix of every circuit energy sensor on the panel -- three per circuit, 96 on a fully mapped 40-space install. Those offers say nothing about the circuit names a user set in the SPAN app, which are identical on both sides; they are purely our own two spellings disagreeing. So the names move to the ids rather than the other way round. Verified: every descriptor in CIRCUIT_SENSORS and UNMAPPED_SENSORS now slugifies to exactly the suffix get_user_friendly_suffix returns for it, through the api_key_mapping in SpanCircuitEnergySensor that resolves circuit_energy_produced to producedEnergyWh. Nothing structural moves. unique_id is built from description_key and never reads a name, so it is byte-identical and stays single-form and predictable, which is what a future migration depends on. Entity ids are untouched. What does change is user-visible: cards read "Kitchen Energy Produced" rather than "Kitchen Produced Energy". Panel-level descriptors are left alone -- they already match their own suffixes (main_meter_produced_energy), which are noun-last, so the two levels necessarily read differently. That asymmetry is inherited from the ids, not introduced here. This is a precondition for adopting core's composition rather than a change that stands on its own; taken alone it is a relabel. --- custom_components/span_panel/sensor_definitions.py | 10 +++++----- tests/test_sensor_entities.py | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py index 4de27762..15081511 100644 --- a/custom_components/span_panel/sensor_definitions.py +++ b/custom_components/span_panel/sensor_definitions.py @@ -200,7 +200,7 @@ class SpanPanelBatterySensorEntityDescription( SpanPanelCircuitsSensorEntityDescription( key="producedEnergyWh", field_path="circuit.produced_energy_wh", - name="Produced Energy", + name="Energy Produced", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, suggested_display_precision=2, @@ -212,7 +212,7 @@ class SpanPanelBatterySensorEntityDescription( SpanPanelCircuitsSensorEntityDescription( key="consumedEnergyWh", field_path="circuit.consumed_energy_wh", - name="Consumed Energy", + name="Energy Consumed", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, suggested_display_precision=2, @@ -1168,7 +1168,7 @@ class SpanPVMetadataSensorEntityDescription( SpanPanelCircuitsSensorEntityDescription( key="circuit_energy_produced", field_path="circuit.produced_energy_wh", - name="Produced Energy", + name="Energy Produced", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, suggested_display_precision=2, @@ -1180,7 +1180,7 @@ class SpanPVMetadataSensorEntityDescription( SpanPanelCircuitsSensorEntityDescription( key="circuit_energy_consumed", field_path="circuit.consumed_energy_wh", - name="Consumed Energy", + name="Energy Consumed", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, suggested_display_precision=2, @@ -1192,7 +1192,7 @@ class SpanPVMetadataSensorEntityDescription( SpanPanelCircuitsSensorEntityDescription( key="circuit_energy_net", derived=DerivedReason.MULTIPLE_FIELDS, - name="Net Energy", + name="Energy Net", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL, suggested_display_precision=2, diff --git a/tests/test_sensor_entities.py b/tests/test_sensor_entities.py index e3aa3090..cdb824ef 100644 --- a/tests/test_sensor_entities.py +++ b/tests/test_sensor_entities.py @@ -460,11 +460,11 @@ def test_circuit_energy_sensor_missing_circuit_uses_fallback_names() -> None: assert ( sensor._generate_friendly_name(snapshot, sensor.entity_description) - == "Circuit c9 Consumed Energy" + == "Circuit c9 Energy Consumed" ) assert ( sensor._generate_panel_name(snapshot, sensor.entity_description) - == "Circuit c9 Consumed Energy" + == "Circuit c9 Energy Consumed" ) @@ -483,8 +483,8 @@ def test_circuit_energy_sensor_subdevice_uses_description_only() -> None: device_info_override={"identifiers": {("span_panel", "evse")}}, ) - assert sensor._generate_friendly_name(snapshot, sensor.entity_description) == "Consumed Energy" - assert sensor._generate_panel_name(snapshot, sensor.entity_description) == "Consumed Energy" + assert sensor._generate_friendly_name(snapshot, sensor.entity_description) == "Energy Consumed" + assert sensor._generate_panel_name(snapshot, sensor.entity_description) == "Energy Consumed" def test_circuit_energy_sensor_extra_attributes_only_include_base_when_circuit_missing() -> None: From 541121293260aeef87d0644386c00a9936c7bf17 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:12:31 -0700 Subject: [PATCH 105/345] chore(deps): pin span-panel-api 3.0.1 The manifest is the pin that reaches a user; `sync-dependencies` propagated it to pyproject.toml and requirements_test.txt. The schema adapters stay at 1.0.0 -- only the bootstrap moved. `uv.lock` is unchanged, and that is not an oversight: `[tool.uv.sources]` points span-panel-api at the local worktree, and uv lets a path source override the version specifier rather than checking against it. Local development therefore resolves to whatever that worktree holds, pin or no pin. CI strips the sources block and re-locks from PyPI, which is the only place this specifier is enforced. --- custom_components/span_panel/manifest.json | 2 +- pyproject.toml | 2 +- requirements_test.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 1e14cb9f..775c5489 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -22,7 +22,7 @@ ], "quality_scale": "gold", "requirements": [ - "span-panel-api==3.0.0", + "span-panel-api==3.0.1", "span-panel-api-schema-0==1.0.0", "span-panel-api-schema-1==1.0.0" ], diff --git a/pyproject.toml b/pyproject.toml index a83199ec..c0512e87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ readme = "README.md" requires-python = ">=3.14.3,<3.15" dependencies = [ "homeassistant==2026.8.0", - "span-panel-api==3.0.0", + "span-panel-api==3.0.1", # 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, diff --git a/requirements_test.txt b/requirements_test.txt index dffd818b..5c371cd4 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -3,6 +3,6 @@ pytest-asyncio>=1.3.0 setuptools>=65.7.0 pytest-homeassistant-custom-component==0.13.354 homeassistant==2026.8.0 -span-panel-api==3.0.0 +span-panel-api==3.0.1 span-panel-api-schema-0==1.0.0 span-panel-api-schema-1==1.0.0 From 83bf98b518e120f111d1c76848f176b0f7fe8f73 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:18:07 -0700 Subject: [PATCH 106/345] chore(deps): point the local sources at the primary checkout The library half of this branch merged and released, so the `-p3` worktree these paths named is gone. A missing path source is not a soft failure -- `uv run` cannot build the environment, so nothing in the repo runs until it is corrected. Repointing also lets the 3.0.1 pin resolve: the lock now records span-panel-api 3.0.1 from the primary checkout instead of 3.0.0 from a worktree that no longer exists. The Pyright extraPath moved with it. CI never reads this block -- it strips `[tool.uv.sources]` and re-locks from PyPI -- so a stale path here breaks local work only. --- pyproject.toml | 21 ++++++++++++--------- uv.lock | 27 ++++++++++++++------------- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c0512e87..df96c150 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,14 +45,17 @@ dev = [ package = false [tool.uv.sources] -# This branch develops against the in-progress schema-1 adapter, which lives in a -# worktree rather than the primary checkout the symlink points at. `-p3` carries -# the library half of `feat/discovery-and-catalog-validation`; the two halves are -# one change and pointing at the wrong worktree means the branch is not being -# tested against the library it needs. -span-panel-api = { path = "../../span/span-panel-api-p3", editable = true } -span-panel-api-schema-0 = { path = "../../span/span-panel-api-p3/packages/schema-0", editable = true } -span-panel-api-schema-1 = { path = "../../span/span-panel-api-p3/packages/schema-1", editable = true } +# The library half of this branch is merged and released, so the primary checkout +# carries it and the `-p3` worktree these paths used to name is gone. A path that +# does not exist is not a soft failure: `uv run` cannot build the environment at +# all, so nothing in this repo runs until it is corrected. +# +# CI never reads this block -- it deletes it and re-locks from PyPI (.github/ +# workflows/ci.yml) -- so a stale path here breaks only local work, and only +# noisily. +span-panel-api = { path = "../../span/span-panel-api", editable = true } +span-panel-api-schema-0 = { path = "../../span/span-panel-api/packages/schema-0", editable = true } +span-panel-api-schema-1 = { path = "../../span/span-panel-api/packages/schema-1", editable = true } [tool.jscpd] path = ["custom_components/span_panel", "./*.{html,md}"] @@ -132,7 +135,7 @@ exclude = [ ] extraPaths = [ "./custom_components", - "../../span/span-panel-api-p3/src", + "../../span/span-panel-api/src", "../ha-synthetic-sensors/src" ] pythonPlatform = "Darwin" diff --git a/uv.lock b/uv.lock index 841f401b..340c79c8 100644 --- a/uv.lock +++ b/uv.lock @@ -2523,9 +2523,9 @@ dev = [ [package.metadata] requires-dist = [ { name = "homeassistant", specifier = "==2026.8.0" }, - { name = "span-panel-api", editable = "../../span/span-panel-api-p3" }, - { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api-p3/packages/schema-0" }, - { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api-p3/packages/schema-1" }, + { name = "span-panel-api", editable = "../../span/span-panel-api" }, + { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api/packages/schema-0" }, + { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api/packages/schema-1" }, ] [package.metadata.requires-dev] @@ -2550,8 +2550,8 @@ dev = [ [[package]] name = "span-panel-api" -version = "3.0.0" -source = { editable = "../../span/span-panel-api-p3" } +version = "3.0.1" +source = { editable = "../../span/span-panel-api" } dependencies = [ { name = "httpx" }, { name = "paho-mqtt" }, @@ -2563,8 +2563,8 @@ requires-dist = [ { name = "httpx", specifier = ">=0.28.1,<1.0" }, { name = "paho-mqtt", specifier = ">=2.0.0,<3.0.0" }, { name = "pyyaml", specifier = ">=6.0.0" }, - { name = "span-panel-api-schema-0", marker = "extra == 'schema-0'", editable = "../../span/span-panel-api-p3/packages/schema-0" }, - { name = "span-panel-api-schema-1", marker = "extra == 'schema-1'", editable = "../../span/span-panel-api-p3/packages/schema-1" }, + { name = "span-panel-api-schema-0", marker = "extra == 'schema-0'", editable = "../../span/span-panel-api/packages/schema-0" }, + { name = "span-panel-api-schema-1", marker = "extra == 'schema-1'", editable = "../../span/span-panel-api/packages/schema-1" }, ] provides-extras = ["schema-0", "schema-1"] @@ -2573,6 +2573,7 @@ dev = [ { name = "bandit", specifier = ">=1.9.4" }, { name = "black" }, { name = "coverage" }, + { name = "cryptography", specifier = ">=50.0.0" }, { name = "mypy" }, { name = "pre-commit" }, { name = "pylint" }, @@ -2581,8 +2582,8 @@ dev = [ { name = "pytest-cov" }, { name = "radon" }, { name = "ruff", specifier = ">=0.15.5" }, - { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api-p3/packages/schema-0" }, - { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api-p3/packages/schema-1" }, + { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api/packages/schema-0" }, + { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api/packages/schema-1" }, { name = "twine", specifier = ">=7.0" }, { name = "types-pyyaml", specifier = ">=6.0.12.20250915" }, { name = "vulture", specifier = ">=2.14" }, @@ -2591,18 +2592,18 @@ dev = [ [[package]] name = "span-panel-api-schema-0" version = "1.0.0" -source = { editable = "../../span/span-panel-api-p3/packages/schema-0" } +source = { editable = "../../span/span-panel-api/packages/schema-0" } dependencies = [ { name = "span-panel-api" }, ] [package.metadata] -requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-api-p3" }] +requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-api" }] [[package]] name = "span-panel-api-schema-1" version = "1.0.0" -source = { editable = "../../span/span-panel-api-p3/packages/schema-1" } +source = { editable = "../../span/span-panel-api/packages/schema-1" } dependencies = [ { name = "ebus-sdk" }, { name = "span-panel-api" }, @@ -2611,7 +2612,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "ebus-sdk", specifier = ">=0.19.0,<0.24" }, - { name = "span-panel-api", editable = "../../span/span-panel-api-p3" }, + { name = "span-panel-api", editable = "../../span/span-panel-api" }, ] [[package]] From 87c640e02a6fbf19fe5de8cd9afca27b51077f18 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:07:52 -0700 Subject: [PATCH 107/345] fix(naming): Recreate entity IDs proposes what the panel says now Renaming a circuit in the SPAN app left "Recreate entity IDs" offering the entity its own ID, so the button appeared to do nothing (#252). The registry generates an ID from the user's `name` override, else `suggested_object_id`, else `object_id_base`. Friendly-names mode writes no registry `name` -- the panel name arrives as `original_name` -- so the suggestion decides, and `construct_single_circuit_entity_id` was handing back the entity's stored ID on every reload. The suggestion was therefore frozen at whatever the circuit was called the day it was added. It now computes the ID from current panel data and the naming flags, for existing entities as much as new ones. That cannot move a live entity ID: an entity_id set before add is only a suggestion, and for a unique_id already on file `async_get_or_create` routes to `_async_update_entity` with no `new_entity_id` before `entity.entity_id` is reassigned from the stored entry. Unique IDs are untouched -- they derive from the description key and never read a name. Circuit-numbers mode is deliberately unchanged, guarded by a test. There the registry `name` phase 2 sync writes is both what shows the panel's name in the UI and what outranks the suggestion, so Recreate keeps composing from that name. Correcting it would mean rerouting phase 2 sync, which is a product decision, not part of this fix. The registry lookup was the only use of the `unique_id` argument, and `existing_entity_id` the only use of that parameter on `_construct_entity_id`; both are gone rather than left as parameters that no longer decide anything. Every test reloads before asserting -- asserting straight after creation exercises the first-add path, where the suggestion is trivially current and the bug cannot appear. The three that demonstrate the bug fail on the previous code; the five guards pass on both. --- .../span_panel/entity_resolver.py | 40 +-- custom_components/span_panel/select.py | 7 +- custom_components/span_panel/sensor_base.py | 8 +- .../span_panel/sensor_circuit.py | 4 - custom_components/span_panel/switch.py | 7 +- tests/test_recreate_entity_ids.py | 306 ++++++++++++++++++ 6 files changed, 334 insertions(+), 38 deletions(-) create mode 100644 tests/test_recreate_entity_ids.py diff --git a/custom_components/span_panel/entity_resolver.py b/custom_components/span_panel/entity_resolver.py index bf637cc9..3443f5a7 100644 --- a/custom_components/span_panel/entity_resolver.py +++ b/custom_components/span_panel/entity_resolver.py @@ -284,10 +284,25 @@ def construct_single_circuit_entity_id( platform: str, suffix: str, circuit_data: SpanCircuitSnapshot, - unique_id: str | None = None, device_name: str | None = None, ) -> str | None: - """Construct entity ID for single-circuit sensors. + """Construct the entity ID the current panel data and naming flags produce. + + Always computed, never read back from the registry -- including for a circuit + that already has entities. Home Assistant treats an entity_id set before the + entity is added as a *suggestion*: `EntityPlatform` splits it into + `suggested_object_id`, hands that to `async_get_or_create`, and for a + unique_id already on file that call routes to `_async_update_entity` with no + `new_entity_id`, then reassigns `entity.entity_id` from the stored entry. A + live entity ID therefore cannot move from here; only the stored suggestion + changes. + + That suggestion is the field "Recreate entity IDs" regenerates from when the + registry holds no user `name` override, which in friendly-names mode is + always the case -- the panel name reaches the UI as `original_name`. Handing + back the stored entity ID froze the suggestion at whatever the circuit was + called on the day it was added, so a circuit renamed in the SPAN app was + offered its own ID and Recreate looked broken (issue #252). Args: coordinator: The coordinator instance @@ -295,33 +310,12 @@ def construct_single_circuit_entity_id( platform: Platform name ("sensor", "switch", "select") suffix: Entity-specific suffix ("power", "energy_produced", etc.) circuit_data: Circuit data object - unique_id: The unique ID for this entity (None to skip registry lookup) device_name: Device name for entity ID construction (None to use from config entry) Returns: Constructed entity ID string or None if device info unavailable """ - # Check registry first only if unique_id is provided - if unique_id is not None: - entity_registry = er.async_get(coordinator.hass) - existing_entity_id = entity_registry.async_get_entity_id(platform, DOMAIN, unique_id) - - _LOGGER.debug( - "Circuit helper registry lookup - unique_id=%s, found_entity_id=%s", - unique_id, - existing_entity_id, - ) - - if existing_entity_id: - return existing_entity_id - # FATAL ERROR: Expected unique_id not found in registry - raise ValueError( - f"REGISTRY LOOKUP ERROR: Expected unique_id '{unique_id}' not found in registry. " - f"This indicates a migration or configuration mismatch." - ) - _LOGGER.debug("Circuit helper - no unique_id provided, skipping registry lookup") - # Get device info device_info = snapshot_to_device_info(snapshot, device_name) if not device_info or not device_info.get("name"): diff --git a/custom_components/span_panel/select.py b/custom_components/span_panel/select.py index ebf87070..b08176c3 100644 --- a/custom_components/span_panel/select.py +++ b/custom_components/span_panel/select.py @@ -166,16 +166,15 @@ def __init__( self._attr_name = None # Explicitly set entity_id using construct_single_circuit_entity_id - # which correctly handles 240V two-tab circuits. - # Only pass unique_id for existing entities (registry lookup); - # for new entities pass None to get the constructed default. + # which correctly handles 240V two-tab circuits. For an entity already + # in the registry this is a suggestion HA records and does not act on -- + # the stored entity_id stands. See the helper's docstring. constructed_id = construct_single_circuit_entity_id( coordinator, snapshot, "select", description.entity_description.key, circuit, - unique_id=self._attr_unique_id if existing_entity_id else None, ) if constructed_id: self.entity_id = constructed_id diff --git a/custom_components/span_panel/sensor_base.py b/custom_components/span_panel/sensor_base.py index 88dbf41f..d40152ae 100644 --- a/custom_components/span_panel/sensor_base.py +++ b/custom_components/span_panel/sensor_base.py @@ -123,7 +123,7 @@ def __init__( ) # Wire explicit entity_id via subclass helper - entity_id = self._construct_entity_id(snapshot, description, existing_entity_id) + entity_id = self._construct_entity_id(snapshot, description) if entity_id: self.entity_id = entity_id else: @@ -228,17 +228,19 @@ def _construct_entity_id( self, snapshot: SpanPanelSnapshot, description: T, - existing_entity_id: str | None = None, ) -> str | None: """Construct explicit entity_id for the sensor. Subclasses may override to use entity_id helpers from helpers.py. Returns None to let HA auto-generate from _attr_name. + Whether the entity is already in the registry is deliberately not an + input: the value is what current panel data and the naming flags + produce, and HA keeps an existing entity's stored ID regardless. + Args: snapshot: The panel snapshot data description: The sensor description - existing_entity_id: The existing entity_id from registry, or None for new entities """ return None diff --git a/custom_components/span_panel/sensor_circuit.py b/custom_components/span_panel/sensor_circuit.py index 83cba192..c9802957 100644 --- a/custom_components/span_panel/sensor_circuit.py +++ b/custom_components/span_panel/sensor_circuit.py @@ -204,7 +204,6 @@ def _construct_entity_id( self, snapshot: SpanPanelSnapshot, description: SpanPanelCircuitsSensorEntityDescription, - existing_entity_id: str | None = None, ) -> str | None: """Construct explicit entity_id for circuit power sensors.""" circuit = snapshot.circuits.get(self.circuit_id) @@ -219,7 +218,6 @@ def _construct_entity_id( "sensor", suffix, circuit, - unique_id=self._attr_unique_id if existing_entity_id else None, ) def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanCircuitSnapshot: @@ -389,7 +387,6 @@ def _construct_entity_id( self, snapshot: SpanPanelSnapshot, description: SpanPanelCircuitsSensorEntityDescription, - existing_entity_id: str | None = None, ) -> str | None: """Construct explicit entity_id for circuit energy sensors.""" circuit = snapshot.circuits.get(self.circuit_id) @@ -408,7 +405,6 @@ def _construct_entity_id( "sensor", suffix, circuit, - unique_id=self._attr_unique_id if existing_entity_id else None, ) # Map original_key to the energy type used for coordinator dip offset tracking diff --git a/custom_components/span_panel/switch.py b/custom_components/span_panel/switch.py index a4dba851..35923d95 100644 --- a/custom_components/span_panel/switch.py +++ b/custom_components/span_panel/switch.py @@ -120,16 +120,15 @@ def __init__( super().__init__(coordinator) # Explicitly set entity_id using construct_single_circuit_entity_id - # which correctly handles 240V two-tab circuits. - # Only pass unique_id for existing entities (registry lookup); - # for new entities pass None to get the constructed default. + # which correctly handles 240V two-tab circuits. For an entity already + # in the registry this is a suggestion HA records and does not act on -- + # the stored entity_id stands. See the helper's docstring. constructed_id = construct_single_circuit_entity_id( coordinator, snapshot, "switch", "breaker", circuit, - unique_id=self._attr_unique_id if existing_entity_id else None, ) if constructed_id: self.entity_id = constructed_id diff --git a/tests/test_recreate_entity_ids.py b/tests/test_recreate_entity_ids.py new file mode 100644 index 00000000..4d664a39 --- /dev/null +++ b/tests/test_recreate_entity_ids.py @@ -0,0 +1,306 @@ +"""Recreate entity IDs proposes the ID the current panel data would produce. + +Issue #252: renaming a circuit in the SPAN app left "Recreate entity IDs" (the +HA registry's `async_regenerate_entity_id`) proposing the entity's own ID, so +the button appeared to do nothing. + +The registry generates an ID from three fields in priority order: the user's +`name` override, then `suggested_object_id`, then `object_id_base`. In +friendly-names mode this integration never writes a registry `name` -- the +panel name reaches the UI through `original_name` -- so `name` is None, the +generator short-circuits to `suggested_object_id`, and that field is whatever +was suggested when the entity was first added. It was frozen because the +integration preset the entity's *stored* ID on every reload, suggesting the +value already in place. + +Every case here reloads before asserting. Asserting straight after creation +tests the first-add path, where the suggestion is trivially current and the bug +cannot appear. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from pytest_homeassistant_custom_component.common import ( + MockConfigEntry, + MockEntityPlatform, +) + +from custom_components.span_panel import SpanPanelRuntimeData +from custom_components.span_panel.const import ( + DOMAIN, + USE_CIRCUIT_NUMBERS, + USE_DEVICE_PREFIX, +) +from custom_components.span_panel.sensor_circuit import SpanCircuitPowerSensor +from custom_components.span_panel.sensor_definitions import CIRCUIT_SENSORS +from custom_components.span_panel.switch import SpanPanelCircuitsSwitch + +from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory + +CIRCUIT_ID = "15" +SERIAL = "sp3-recreate-001" + +ORIGINAL_NAME = "Refrigerator" +RENAMED = "Beer Fridge" + +ORIGINAL_ENTITY_ID = "sensor.span_panel_refrigerator_power" +RENAMED_ENTITY_ID = "sensor.span_panel_beer_fridge_power" +CIRCUIT_NUMBERS_ENTITY_ID = "sensor.span_panel_circuit_15_power" + +FRIENDLY_NAMES = {USE_DEVICE_PREFIX: True, USE_CIRCUIT_NUMBERS: False} +CIRCUIT_NUMBERS = {USE_DEVICE_PREFIX: True, USE_CIRCUIT_NUMBERS: True} + +POWER_DESCRIPTION = next(desc for desc in CIRCUIT_SENSORS if desc.key == "circuit_power") + + +def _snapshot(circuit_name: str): + """Build a one-circuit panel snapshot with the circuit named as given.""" + circuit = SpanCircuitSnapshotFactory.create( + circuit_id=CIRCUIT_ID, name=circuit_name, tabs=[15] + ) + return SpanPanelSnapshotFactory.create(serial_number=SERIAL, circuits={CIRCUIT_ID: circuit}) + + +def _coordinator(hass: HomeAssistant, snapshot, entry: MockConfigEntry) -> MagicMock: + """Build a coordinator standing in for a live one, bound to the real hass.""" + coordinator = MagicMock() + coordinator.hass = hass + coordinator.data = snapshot + coordinator.panel_offline = False + coordinator.config_entry = entry + coordinator.request_reload = MagicMock() + coordinator.register_circuit_energy_sensor = MagicMock() + coordinator.get_circuit_dip_offset = MagicMock(return_value=0.0) + return coordinator + + +class _Install: + """One install of the sensor platform, reloadable. + + `load` a second time is what a reload is: the entry's entities are torn down + and rebuilt from the current snapshot, against the entity registry that + survived. Nothing here is faked -- `async_add_entities` is the real + `EntityPlatform` path, so a preset entity_id travels the same route into + `async_get_or_create` that it does in a running install, and the teardown is + the same `async_reset` an entry unload performs. + """ + + def __init__(self, hass: HomeAssistant, entry: MockConfigEntry) -> None: + self._hass = hass + self._entry = entry + self._platform: MockEntityPlatform | None = None + + async def load(self, circuit_name: str) -> SpanCircuitPowerSensor: + """Tear down any previous platform, then set one up from fresh panel data.""" + if self._platform is not None: + await self._platform.async_reset() + + snapshot = _snapshot(circuit_name) + coordinator = _coordinator(self._hass, snapshot, self._entry) + self._entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, panel_device_id="panel-device-id" + ) + + self._platform = MockEntityPlatform(self._hass, domain="sensor", platform_name=DOMAIN) + self._platform.config_entry = self._entry + + sensor = SpanCircuitPowerSensor(coordinator, POWER_DESCRIPTION, snapshot, CIRCUIT_ID) + await self._platform.async_add_entities([sensor]) + await self._hass.async_block_till_done() + + assert sensor.hass is not None, "entity was rejected before it reached the registry" + return sensor + + +@pytest.fixture +def entry(hass: HomeAssistant) -> MockConfigEntry: + """A config entry in friendly-names mode.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.50", "device_name": "SPAN Panel"}, + options=dict(FRIENDLY_NAMES), + title="SPAN Panel", + unique_id=SERIAL, + entry_id="entry-recreate", + ) + config_entry.add_to_hass(hass) + return config_entry + + +async def test_the_first_install_takes_its_entity_id_from_the_circuit_name( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Baseline for every case below: the ID before any rename.""" + sensor = await _Install(hass, entry).load(ORIGINAL_NAME) + + assert sensor.entity_id == ORIGINAL_ENTITY_ID + + +async def test_renaming_a_circuit_does_not_move_an_existing_entity_id( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """The non-negotiable one: a rename must never move a live entity_id. + + Dashboards, automations, and recorder history all key off the entity_id. + Recreate is an offer the user accepts; a rename is not. + """ + install = _Install(hass, entry) + await install.load(ORIGINAL_NAME) + sensor = await install.load(RENAMED) + + assert sensor.entity_id == ORIGINAL_ENTITY_ID + + registry = er.async_get(hass) + assert registry.async_get(ORIGINAL_ENTITY_ID) is not None + assert registry.async_get(RENAMED_ENTITY_ID) is None + + +async def test_renaming_a_circuit_does_not_move_the_unique_id( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Unique IDs are derived from the description key and never from a name. + + A moved unique_id orphans the entity and drops its long-term statistics, and + it would break any future migration that has to predict what a unique_id + looks like. + """ + install = _Install(hass, entry) + before = await install.load(ORIGINAL_NAME) + unique_id_before = before.unique_id + + after = await install.load(RENAMED) + + assert after.unique_id == unique_id_before + + registry = er.async_get(hass) + entry_after = registry.async_get(ORIGINAL_ENTITY_ID) + assert entry_after is not None + assert entry_after.unique_id == unique_id_before + + +async def test_renaming_a_circuit_refreshes_the_registrys_entity_id_suggestion( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """The stored suggestion has to track the panel, not the install date. + + This is the field `async_regenerate_entity_id` reads when there is no user + `name` override, which in friendly-names mode is always. + """ + install = _Install(hass, entry) + await install.load(ORIGINAL_NAME) + await install.load(RENAMED) + + registry = er.async_get(hass) + registry_entry = registry.async_get(ORIGINAL_ENTITY_ID) + assert registry_entry is not None + assert registry_entry.suggested_object_id == "span_panel_beer_fridge_power" + + +async def test_recreate_entity_ids_proposes_the_renamed_id( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Issue #252 itself, at the API the button calls.""" + install = _Install(hass, entry) + await install.load(ORIGINAL_NAME) + await install.load(RENAMED) + + registry = er.async_get(hass) + registry_entry = registry.async_get(ORIGINAL_ENTITY_ID) + assert registry_entry is not None + + proposed = registry.async_regenerate_entity_id(registry_entry) + + assert proposed == RENAMED_ENTITY_ID + assert proposed != registry_entry.entity_id + + +async def test_an_unrenamed_circuit_is_offered_its_own_entity_id( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Recreate must be a no-op when nothing changed. + + Without this the previous test passes for the wrong reason -- a suggestion + that moves on every reload would satisfy it while offering every user a + pointless rename. + """ + install = _Install(hass, entry) + await install.load(ORIGINAL_NAME) + await install.load(ORIGINAL_NAME) + + registry = er.async_get(hass) + registry_entry = registry.async_get(ORIGINAL_ENTITY_ID) + assert registry_entry is not None + + assert registry.async_regenerate_entity_id(registry_entry) == ORIGINAL_ENTITY_ID + + +async def test_circuit_numbers_mode_keeps_its_id_its_display_name_and_its_sync( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Regression guard: nothing in circuit-numbers mode may change. + + There the registry `name` written by phase 2 name sync is both what puts the + panel's name in the UI and what outranks `suggested_object_id` during + regeneration. That second effect means Recreate in this mode proposes a + friendly-name ID for a circuit-numbered entity -- a known limitation, and + the assertion below pins it deliberately: it is what the mode did before + this fix, and this fix must not disturb it. + + The two are the same write, so correcting Recreate here would mean dropping + or rerouting phase 2 sync. That is a product decision, recorded in the design + doc, not something to change while fixing friendly-names mode. + """ + hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS)) + + install = _Install(hass, entry) + await install.load(ORIGINAL_NAME) + sensor = await install.load(RENAMED) + + assert sensor.entity_id == CIRCUIT_NUMBERS_ENTITY_ID + + registry = er.async_get(hass) + registry_entry = registry.async_get(CIRCUIT_NUMBERS_ENTITY_ID) + assert registry_entry is not None + + # Phase 2 sync still writes the panel's name as the display name. + assert registry_entry.name == f"{RENAMED} Power" + + # And that name still outranks the suggestion, so the offer is composed + # from it -- unchanged, limitation included. + assert registry.async_regenerate_entity_id(registry_entry) == RENAMED_ENTITY_ID + + +async def test_the_breaker_switch_gets_the_same_refreshed_suggestion( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Switches and selects preset their IDs through the same helper. + + They call it from their own constructors rather than through + `_construct_entity_id`, so a fix that only reached the sensor path would + leave a renamed circuit's breaker switch still offering its old ID. + """ + platform = MockEntityPlatform(hass, domain="switch", platform_name=DOMAIN) + platform.config_entry = entry + + for circuit_name in (ORIGINAL_NAME, RENAMED): + snapshot = _snapshot(circuit_name) + coordinator = _coordinator(hass, snapshot, entry) + switch = SpanPanelCircuitsSwitch(coordinator, CIRCUIT_ID, circuit_name, "SPAN Panel") + await platform.async_add_entities([switch]) + await hass.async_block_till_done() + await platform.async_reset() + + registry = er.async_get(hass) + registry_entry = registry.async_get("switch.span_panel_refrigerator_breaker") + assert registry_entry is not None + assert registry_entry.suggested_object_id == "span_panel_beer_fridge_breaker" + assert ( + registry.async_regenerate_entity_id(registry_entry) + == "switch.span_panel_beer_fridge_breaker" + ) From f91d3b255ad106880bd50eef6ff35d7f4c36cbb3 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:25:49 -0700 Subject: [PATCH 108/345] chore(release): 2.1.0b10 Cut from the entity-id branch so the "Recreate entity IDs" fix gets field time before it merges. Carries span-panel-api 3.0.1 and both adapters at 1.0.0. --- custom_components/span_panel/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 775c5489..a409fab8 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -26,7 +26,7 @@ "span-panel-api-schema-0==1.0.0", "span-panel-api-schema-1==1.0.0" ], - "version": "2.1.0b9", + "version": "2.1.0b10", "zeroconf": [ { "type": "_span._tcp.local." From 28e6035419e37aace116fda20bb23f3804672a95 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:33:54 -0700 Subject: [PATCH 109/345] docs(changelog): the entity-id fix and the energy sensor rename Both are outward facing and neither was recorded. Written against 2.1.0, not a beta -- the changelog describes the public release, and the steps between betas are not what a reader is looking for. --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b313695..0a0a8380 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -149,8 +149,18 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of `panel_size` was already in that attribute block. If you have a template reading `state_attr('sensor.span_panel_software_version', 'wifi_ssid')`, point it at the Wi-Fi Link binary sensor instead. `panel_size` is unaffected and stays where it is. +- **The three circuit energy sensors are renamed to match the entity ids they have always had.** "Produced Energy", "Consumed Energy" and "Net Energy" become + **Energy Produced**, **Energy Consumed** and **Energy Net** — the order their entity ids (`..._energy_produced`, `..._energy_consumed`, `..._energy_net`) have + used since those sensors shipped. **Entity ids, unique ids and history are unchanged**; only the name shown in the UI reorders. Left alone, the word order + would have had Recreate entity IDs offering you a rename for every circuit on your panel. + ### Fixed +- **Recreate entity IDs proposes the ids your panel would produce now.** Renaming a circuit in the SPAN app used to leave the button offering each entity the id + it already had, so it looked like it did nothing (#252). The proposal was frozen at whatever the circuit was called when the entity was first created; it now + follows the panel. **It is still an offer you accept** — a rename in the SPAN app never moves a live entity id by itself, and unique ids and statistics are + untouched. Rename nothing and Recreate proposes the ids you already have. Circuit-numbers installations are unchanged: there the display name written by name + sync is also what Home Assistant builds the proposal from, so the button behaves exactly as it did. - **Enum sensors advertise the states they can actually report.** Nine sensors declared only `unknown`, so `DSM Grid State` sitting at `On Grid` showed "Possible states: Unknown". - **The README described Battery Power's sign backwards.** The sensor reports **discharging** as positive and always has — that is what release 2.0.5 From d1c580a09af988a346d6a1817e4da6a8beb420b3 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:54:53 -0700 Subject: [PATCH 110/345] fix(naming): keep the entity-id suffix an install already has Recreate offered 74 renames on a real panel after the previous beta: every circuit energy sensor, `_consumed_energy` to `_energy_consumed`. Only one of the 74 was a circuit the owner had renamed, and that one was buried. Installs predating the point where the suffix mapping reached entity ids took their id from the descriptor name, so they carry `_consumed_energy` while their own unique id carries `_energy_consumed`. The two have disagreed all along; recomputing the suggestion is what made the disagreement visible. The descriptor rename is not the cause and reverting it would not have removed a single offer: in friendly-names mode the registry holds no `name`, so `suggested_object_id` -- our suffix-based preset -- outranks `original_name` and the descriptor name never reaches the id. Measured both ways; the proposal is `_energy_consumed` either way. So an existing entity keeps the suffix it shipped with and only the circuit-name half of its id follows the panel. `LEGACY_ENTITY_ID_SUFFIXES` records the older spellings; comparing with the suffix removed means a renamed circuit still gets the computed id, which is the whole point of #252. A trailing segment that merely looks like a suffix change -- "Kitchen Outlets" renamed to "Kitchen" -- is a rename and is still offered. `_construct_entity_id` takes `existing_entity_id` again. It no longer decides whether to compute an id, only which suffix the computed one carries. Four tests, two of which fail with preservation disabled. The earlier ones all built their entities with current code, where preset and live id are the same string by construction and this could not appear -- which is why it reached a beta. --- CHANGELOG.md | 15 +- .../span_panel/entity_resolver.py | 12 +- custom_components/span_panel/id_builder.py | 90 +++++++++-- custom_components/span_panel/manifest.json | 2 +- custom_components/span_panel/select.py | 1 + custom_components/span_panel/sensor_base.py | 10 +- .../span_panel/sensor_circuit.py | 4 + custom_components/span_panel/switch.py | 1 + tests/test_recreate_entity_ids.py | 144 +++++++++++++++++- 9 files changed, 254 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a0a8380..22001379 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -149,18 +149,21 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of `panel_size` was already in that attribute block. If you have a template reading `state_attr('sensor.span_panel_software_version', 'wifi_ssid')`, point it at the Wi-Fi Link binary sensor instead. `panel_size` is unaffected and stays where it is. -- **The three circuit energy sensors are renamed to match the entity ids they have always had.** "Produced Energy", "Consumed Energy" and "Net Energy" become - **Energy Produced**, **Energy Consumed** and **Energy Net** — the order their entity ids (`..._energy_produced`, `..._energy_consumed`, `..._energy_net`) have - used since those sensors shipped. **Entity ids, unique ids and history are unchanged**; only the name shown in the UI reorders. Left alone, the word order - would have had Recreate entity IDs offering you a rename for every circuit on your panel. +- **The three circuit energy sensors are renamed to match the ids they are given.** "Produced Energy", "Consumed Energy" and "Net Energy" become **Energy + Produced**, **Energy Consumed** and **Energy Net**, the order used by the `energy_produced`, `energy_consumed` and `energy_net` suffixes that these sensors' + unique ids carry and that new entities are given. **Entity ids, unique ids and history are unchanged**; only the name shown in the UI reorders. ### Fixed - **Recreate entity IDs proposes the ids your panel would produce now.** Renaming a circuit in the SPAN app used to leave the button offering each entity the id it already had, so it looked like it did nothing (#252). The proposal was frozen at whatever the circuit was called when the entity was first created; it now follows the panel. **It is still an offer you accept** — a rename in the SPAN app never moves a live entity id by itself, and unique ids and statistics are - untouched. Rename nothing and Recreate proposes the ids you already have. Circuit-numbers installations are unchanged: there the display name written by name - sync is also what Home Assistant builds the proposal from, so the button behaves exactly as it did. + untouched. Circuit-numbers installations are unchanged: there the display name written by name sync is also what Home Assistant builds the proposal from, so + the button behaves exactly as it did. +- **Only circuits you actually renamed are offered.** Installations old enough to predate the current suffixes carry entity ids ending `_consumed_energy`, + `_produced_energy`, `_net_energy` or `_current_power`, where an entity created today would end `_energy_consumed`, `_energy_produced`, `_energy_net` or + `_power`. Those ids keep the suffix they have. Renormalising them would have offered a rename for **every circuit on the panel** — seventy-four on one we + measured — burying the one circuit that had actually been renamed and breaking the dashboards and automations of anyone who accepted. - **Enum sensors advertise the states they can actually report.** Nine sensors declared only `unknown`, so `DSM Grid State` sitting at `On Grid` showed "Possible states: Unknown". - **The README described Battery Power's sign backwards.** The sensor reports **discharging** as positive and always has — that is what release 2.0.5 diff --git a/custom_components/span_panel/entity_resolver.py b/custom_components/span_panel/entity_resolver.py index 3443f5a7..d006705a 100644 --- a/custom_components/span_panel/entity_resolver.py +++ b/custom_components/span_panel/entity_resolver.py @@ -25,6 +25,7 @@ build_select_unique_id, build_switch_unique_id, construct_synthetic_unique_id, + preserve_legacy_entity_id_suffix, ) from .util import snapshot_to_device_info @@ -285,6 +286,7 @@ def construct_single_circuit_entity_id( suffix: str, circuit_data: SpanCircuitSnapshot, device_name: str | None = None, + existing_entity_id: str | None = None, ) -> str | None: """Construct the entity ID the current panel data and naming flags produce. @@ -311,6 +313,8 @@ def construct_single_circuit_entity_id( suffix: Entity-specific suffix ("power", "energy_produced", etc.) circuit_data: Circuit data object device_name: Device name for entity ID construction (None to use from config entry) + existing_entity_id: This entity's id in the registry, when it has one, so an + id predating the suffix mapping keeps the suffix it shipped with Returns: Constructed entity ID string or None if device info unavailable @@ -366,7 +370,13 @@ def construct_single_circuit_entity_id( if suffix and not circuit_part.endswith(f"_{suffix}"): parts.append(suffix) - return f"{platform}.{'_'.join(parts)}" + # An entity created before the suffix mapping reached entity ids carries the + # older spelling. Renormalising it would offer a rename to every circuit on + # the panel, so the circuit-name half follows the panel and the suffix half + # stays as it shipped. + return preserve_legacy_entity_id_suffix( + f"{platform}.{'_'.join(parts)}", existing_entity_id, suffix + ) def construct_unmapped_entity_id( diff --git a/custom_components/span_panel/id_builder.py b/custom_components/span_panel/id_builder.py index 5d02bb8d..61055dca 100644 --- a/custom_components/span_panel/id_builder.py +++ b/custom_components/span_panel/id_builder.py @@ -34,10 +34,14 @@ """**Closed.** A compatibility shim for the keys that predate snake_case, not a house style. Every entry here translates a legacy camelCase description key into the suffix -its entities have carried since before 2.0.8 -- and that suffix is shared by the -`unique_id` *and* the `entity_id`, so a changed entry moves both on every -installed panel. A moved `unique_id` costs the statistics; a moved `entity_id` -breaks the templates and automations a user wrote. +its entities have carried since before 2.0.8, so a changed entry moves a live +`unique_id` on every installed panel, and a moved `unique_id` costs the +statistics. + +It governs the `entity_id` too, but only for entities created since the +integration began presetting one. Older entities took their id from the +descriptor name instead, which used the opposite word order -- that is what +`LEGACY_ENTITY_ID_SUFFIXES` records, and why it has to exist. So the rule for anything new is **verbatim**: a description key added from here on resolves to itself, exactly as the sub-device builders (`build_bess_unique_id`, @@ -50,6 +54,60 @@ """ +# Entity-id suffixes that predate the mapping above, keyed by the suffix that +# replaced them. +LEGACY_ENTITY_ID_SUFFIXES: dict[str, frozenset[str]] = { + "power": frozenset({"current_power"}), + "energy_produced": frozenset({"produced_energy"}), + "energy_consumed": frozenset({"consumed_energy"}), + "energy_net": frozenset({"net_energy"}), +} +"""Entity ids only -- never a `unique_id`, which has always used the canonical form. + +Before the integration preset an `entity_id`, Home Assistant composed one from the +descriptor name: "Consumed Energy" gave `..._consumed_energy` where the mapping +above says `energy_consumed`. Installs from that era carry an entity id whose +suffix disagrees with their own unique id; the two orders were only reconciled +going forward. + +That disagreement is not a defect to correct on a user's behalf. Renormalising it +offers a rename for every circuit on the panel -- seventy-four on a measured one -- +which buries the circuit they actually renamed and breaks every dashboard and +automation belonging to anyone who accepts. So an existing entity keeps the suffix +it has; only the circuit-name half of its id follows the panel. + +Entries are historical fact, so this table only grows by discovering another form +that shipped. `energy_imported`, `energy_exported`, `priority`, `current` and +`breaker_rating` have no entry because they were never named the other way round. +""" + + +def preserve_legacy_entity_id_suffix( + computed_entity_id: str, existing_entity_id: str | None, suffix: str +) -> str: + """Return the id to use, keeping an existing entity's legacy suffix form. + + The ids are compared with the suffix removed, so the existing id wins only + when the circuit-name half already agrees and the suffix is a known older + spelling. A circuit renamed on the panel differs in that half and gets the + computed id, which is what issue #252 is about. + """ + if not existing_entity_id or existing_entity_id == computed_entity_id: + return computed_entity_id + + legacy_forms = LEGACY_ENTITY_ID_SUFFIXES.get(suffix) + if not legacy_forms: + return computed_entity_id + + stem = computed_entity_id.removesuffix(f"_{suffix}") + if stem == computed_entity_id: + return computed_entity_id + + if any(existing_entity_id == f"{stem}_{form}" for form in legacy_forms): + return existing_entity_id + return computed_entity_id + + # Panel sensor API field mappings (used by get_user_friendly_suffix) # Includes main meter/feedthrough produced, consumed, and net energy PANEL_SUFFIX_MAPPING = { @@ -70,10 +128,14 @@ """**Closed.** A compatibility shim for the keys that predate snake_case, not a house style. Every entry here translates a legacy camelCase description key into the suffix -its entities have carried since before 2.0.8 -- and that suffix is shared by the -`unique_id` *and* the `entity_id`, so a changed entry moves both on every -installed panel. A moved `unique_id` costs the statistics; a moved `entity_id` -breaks the templates and automations a user wrote. +its entities have carried since before 2.0.8, so a changed entry moves a live +`unique_id` on every installed panel, and a moved `unique_id` costs the +statistics. + +It governs the `entity_id` too, but only for entities created since the +integration began presetting one. Older entities took their id from the +descriptor name instead, which used the opposite word order -- that is what +`LEGACY_ENTITY_ID_SUFFIXES` records, and why it has to exist. So the rule for anything new is **verbatim**: a description key added from here on resolves to itself, exactly as the sub-device builders (`build_bess_unique_id`, @@ -107,10 +169,14 @@ """**Closed.** A compatibility shim for the keys that predate snake_case, not a house style. Every entry here translates a legacy camelCase description key into the suffix -its entities have carried since before 2.0.8 -- and that suffix is shared by the -`unique_id` *and* the `entity_id`, so a changed entry moves both on every -installed panel. A moved `unique_id` costs the statistics; a moved `entity_id` -breaks the templates and automations a user wrote. +its entities have carried since before 2.0.8, so a changed entry moves a live +`unique_id` on every installed panel, and a moved `unique_id` costs the +statistics. + +It governs the `entity_id` too, but only for entities created since the +integration began presetting one. Older entities took their id from the +descriptor name instead, which used the opposite word order -- that is what +`LEGACY_ENTITY_ID_SUFFIXES` records, and why it has to exist. So the rule for anything new is **verbatim**: a description key added from here on resolves to itself, exactly as the sub-device builders (`build_bess_unique_id`, diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index a409fab8..53a8d03e 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -26,7 +26,7 @@ "span-panel-api-schema-0==1.0.0", "span-panel-api-schema-1==1.0.0" ], - "version": "2.1.0b10", + "version": "2.1.0b11", "zeroconf": [ { "type": "_span._tcp.local." diff --git a/custom_components/span_panel/select.py b/custom_components/span_panel/select.py index b08176c3..88ae38a8 100644 --- a/custom_components/span_panel/select.py +++ b/custom_components/span_panel/select.py @@ -175,6 +175,7 @@ def __init__( "select", description.entity_description.key, circuit, + existing_entity_id=existing_entity_id, ) if constructed_id: self.entity_id = constructed_id diff --git a/custom_components/span_panel/sensor_base.py b/custom_components/span_panel/sensor_base.py index d40152ae..6d85ca4a 100644 --- a/custom_components/span_panel/sensor_base.py +++ b/custom_components/span_panel/sensor_base.py @@ -123,7 +123,7 @@ def __init__( ) # Wire explicit entity_id via subclass helper - entity_id = self._construct_entity_id(snapshot, description) + entity_id = self._construct_entity_id(snapshot, description, existing_entity_id) if entity_id: self.entity_id = entity_id else: @@ -228,19 +228,21 @@ def _construct_entity_id( self, snapshot: SpanPanelSnapshot, description: T, + existing_entity_id: str | None = None, ) -> str | None: """Construct explicit entity_id for the sensor. Subclasses may override to use entity_id helpers from helpers.py. Returns None to let HA auto-generate from _attr_name. - Whether the entity is already in the registry is deliberately not an - input: the value is what current panel data and the naming flags - produce, and HA keeps an existing entity's stored ID regardless. + The value is what current panel data and the naming flags produce; an + existing id is not consulted to decide *whether* to compute one, only so + that an id predating the suffix mapping keeps the suffix it shipped with. Args: snapshot: The panel snapshot data description: The sensor description + existing_entity_id: This entity's id in the registry, or None if new """ return None diff --git a/custom_components/span_panel/sensor_circuit.py b/custom_components/span_panel/sensor_circuit.py index c9802957..33da8579 100644 --- a/custom_components/span_panel/sensor_circuit.py +++ b/custom_components/span_panel/sensor_circuit.py @@ -204,6 +204,7 @@ def _construct_entity_id( self, snapshot: SpanPanelSnapshot, description: SpanPanelCircuitsSensorEntityDescription, + existing_entity_id: str | None = None, ) -> str | None: """Construct explicit entity_id for circuit power sensors.""" circuit = snapshot.circuits.get(self.circuit_id) @@ -218,6 +219,7 @@ def _construct_entity_id( "sensor", suffix, circuit, + existing_entity_id=existing_entity_id, ) def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanCircuitSnapshot: @@ -387,6 +389,7 @@ def _construct_entity_id( self, snapshot: SpanPanelSnapshot, description: SpanPanelCircuitsSensorEntityDescription, + existing_entity_id: str | None = None, ) -> str | None: """Construct explicit entity_id for circuit energy sensors.""" circuit = snapshot.circuits.get(self.circuit_id) @@ -405,6 +408,7 @@ def _construct_entity_id( "sensor", suffix, circuit, + existing_entity_id=existing_entity_id, ) # Map original_key to the energy type used for coordinator dip offset tracking diff --git a/custom_components/span_panel/switch.py b/custom_components/span_panel/switch.py index 35923d95..bf9f988b 100644 --- a/custom_components/span_panel/switch.py +++ b/custom_components/span_panel/switch.py @@ -129,6 +129,7 @@ def __init__( "switch", "breaker", circuit, + existing_entity_id=existing_entity_id, ) if constructed_id: self.entity_id = constructed_id diff --git a/tests/test_recreate_entity_ids.py b/tests/test_recreate_entity_ids.py index 4d664a39..d8298fc4 100644 --- a/tests/test_recreate_entity_ids.py +++ b/tests/test_recreate_entity_ids.py @@ -37,7 +37,14 @@ USE_CIRCUIT_NUMBERS, USE_DEVICE_PREFIX, ) -from custom_components.span_panel.sensor_circuit import SpanCircuitPowerSensor +from custom_components.span_panel.id_builder import ( + build_circuit_unique_id, + preserve_legacy_entity_id_suffix, +) +from custom_components.span_panel.sensor_circuit import ( + SpanCircuitEnergySensor, + SpanCircuitPowerSensor, +) from custom_components.span_panel.sensor_definitions import CIRCUIT_SENSORS from custom_components.span_panel.switch import SpanPanelCircuitsSwitch @@ -304,3 +311,138 @@ async def test_the_breaker_switch_gets_the_same_refreshed_suggestion( registry.async_regenerate_entity_id(registry_entry) == "switch.span_panel_beer_fridge_breaker" ) + + +# --- Entities that predate the suffix mapping reaching entity ids ------------- +# +# Every case above builds its entities with the current code, so the preset and +# the live id are the same string by construction and a suffix disagreement +# cannot appear. A real install upgrading is the case that matters: those +# entities took their id from the descriptor name ("Consumed Energy" -> +# `..._consumed_energy`) where the mapping says `energy_consumed`, so their +# entity id and their own unique id have always disagreed. Recomputing the +# suggestion surfaces that, and on a measured panel it offered 74 renames. + +LEGACY_ENTITY_ID = "sensor.span_panel_refrigerator_consumed_energy" +CANONICAL_ENTITY_ID = "sensor.span_panel_refrigerator_energy_consumed" +RENAMED_LEGACY_ENTITY_ID = "sensor.span_panel_beer_fridge_energy_consumed" + +ENERGY_DESCRIPTION = next( + desc for desc in CIRCUIT_SENSORS if desc.key == "circuit_energy_consumed" +) + + +class _LegacyInstall(_Install): + """An install whose energy sensor id was composed from the descriptor name.""" + + def _seed(self) -> str: + """Register the entity the way a pre-preset install left it.""" + registry = er.async_get(self._hass) + unique_id = build_circuit_unique_id(SERIAL, CIRCUIT_ID, "consumedEnergyWh") + entry = registry.async_get_or_create( + "sensor", + DOMAIN, + unique_id, + suggested_object_id="span_panel_refrigerator_consumed_energy", + original_name=f"{ORIGINAL_NAME} Consumed Energy", + config_entry=self._entry, + ) + return entry.entity_id + + async def load(self, circuit_name: str) -> SpanCircuitEnergySensor: # type: ignore[override] + """Set the platform up for the energy sensor, tearing down any previous one.""" + if self._platform is not None: + await self._platform.async_reset() + + snapshot = _snapshot(circuit_name) + coordinator = _coordinator(self._hass, snapshot, self._entry) + self._entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, panel_device_id="panel-device-id" + ) + + self._platform = MockEntityPlatform(self._hass, domain="sensor", platform_name=DOMAIN) + self._platform.config_entry = self._entry + + sensor = SpanCircuitEnergySensor( + coordinator, ENERGY_DESCRIPTION, snapshot, CIRCUIT_ID + ) + await self._platform.async_add_entities([sensor]) + await self._hass.async_block_till_done() + + assert sensor.hass is not None, "entity was rejected before it reached the registry" + return sensor + + +async def test_upgrading_does_not_offer_to_renormalise_a_legacy_suffix( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """The 74-rename case. Nothing was renamed on the panel, so nothing is offered.""" + install = _LegacyInstall(hass, entry) + seeded = install._seed() + assert seeded == LEGACY_ENTITY_ID + + sensor = await install.load(ORIGINAL_NAME) + + assert sensor.entity_id == LEGACY_ENTITY_ID + + registry = er.async_get(hass) + registry_entry = registry.async_get(LEGACY_ENTITY_ID) + assert registry_entry is not None + assert registry.async_regenerate_entity_id(registry_entry) == LEGACY_ENTITY_ID + + +async def test_a_legacy_entity_still_follows_a_circuit_rename( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Preserving the suffix must not cost the fix. + + A renamed circuit differs in the name half, so the proposal is the computed + id -- carrying the canonical suffix, because there is no older spelling of + `beer_fridge` to preserve. + """ + install = _LegacyInstall(hass, entry) + install._seed() + + await install.load(ORIGINAL_NAME) + sensor = await install.load(RENAMED) + + assert sensor.entity_id == LEGACY_ENTITY_ID + + registry = er.async_get(hass) + registry_entry = registry.async_get(LEGACY_ENTITY_ID) + assert registry_entry is not None + assert registry.async_regenerate_entity_id(registry_entry) == RENAMED_LEGACY_ENTITY_ID + + +async def test_a_new_install_gets_the_canonical_suffix( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Preservation is for ids that already exist; nothing new inherits the old form.""" + sensor = await _LegacyInstall(hass, entry).load(ORIGINAL_NAME) + + assert sensor.entity_id == CANONICAL_ENTITY_ID + + +def test_a_suffix_with_no_older_spelling_is_left_alone() -> None: + """A name that merely looks like a suffix change is a rename, not a legacy form. + + Renaming a circuit "Kitchen Outlets" to "Kitchen" leaves an existing id whose + trailing segments differ from the computed suffix. That is exactly the case + #252 exists to offer, so it must not be mistaken for an older spelling. + """ + assert ( + preserve_legacy_entity_id_suffix( + "sensor.span_panel_kitchen_power", + "sensor.span_panel_kitchen_outlets_power", + "power", + ) + == "sensor.span_panel_kitchen_power" + ) + assert ( + preserve_legacy_entity_id_suffix( + "sensor.span_panel_kitchen_energy_consumed", + "sensor.span_panel_kitchen_consumed_energy", + "energy_consumed", + ) + == "sensor.span_panel_kitchen_consumed_energy" + ) From 348754ebcff4fcf75bd5d3e9f98d28b509a552cb Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:00:04 -0700 Subject: [PATCH 111/345] fix(naming): stop the registry name deciding circuit-numbered entity ids Recreate in circuit-numbers mode proposed a friendly-name id for a circuit-numbered entity, so accepting it converted the whole panel to the naming the mode exists to avoid. Pre-existing -- it shipped in b9 and everything before -- but only reachable now that Recreate works at all. `suggested_object_id` was always correct in that mode. It was outranked: phase 2 sync wrote the panel's name into the registry's `name`, and `_async_get_full_entity_name` reads the suggestion only when `name` is None (entity_registry.py:513). One field was doing two jobs, and the second was invisible. The name now travels as `original_name`, which becomes `object_id_base` and ranks below the suggestion, so it reaches the UI without touching the id. Clearing it alone does nothing -- measured, the old sync writes it straight back on the same load -- so the write had to stop as well. No migration pass. The six sites that wrote the name already look up their own entity and already hold the ownership test, so each releases the field as its entity loads: idempotent, self-healing, and only ever touching a name this integration wrote. A name the user set fails that test and is left alone, keeping today's behaviour for those entities. Both modes now share one naming path -- `_attr_name` from the panel, registry `name` never written -- leaving the entity_id preset as the only difference between them, which is the only thing they are supposed to differ in. Costs, both measured and recorded in the spec: a rename in the SPAN app refreshes the display at reload rather than in place, and `friendly_name` gains the device prefix that friendly-names mode already carries. Neither the SPAN card nor a device page shows the prefix -- both strip it. Six new tests, four of which fail on the previous code. Eight existing tests asserted the old scheme and now assert the new one. --- CHANGELOG.md | 8 ++ custom_components/span_panel/manifest.json | 2 +- custom_components/span_panel/select.py | 119 ++++++---------- custom_components/span_panel/sensor_base.py | 141 +++++++++---------- custom_components/span_panel/switch.py | 114 ++++++---------- tests/test_circuit_control.py | 32 +++-- tests/test_recreate_entity_ids.py | 143 ++++++++++++++------ tests/test_select.py | 19 ++- tests/test_sensor_entities.py | 24 ++-- 9 files changed, 295 insertions(+), 307 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22001379..4b169dfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -160,6 +160,14 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of follows the panel. **It is still an offer you accept** — a rename in the SPAN app never moves a live entity id by itself, and unique ids and statistics are untouched. Circuit-numbers installations are unchanged: there the display name written by name sync is also what Home Assistant builds the proposal from, so the button behaves exactly as it did. +- **Recreate entity IDs no longer offers to convert a tab-named panel to friendly names.** In Tab Based Names mode the button used to propose + `sensor.span_panel_air_conditioner_power` for a live `sensor.span_panel_circuit_15_power` — accepting it undid, for every circuit at once, the one thing that + mode exists to provide. It now proposes the tab-based ID the entity already has, so Recreate is a no-op unless a breaker actually moved. +- **Names still follow your panel in that mode**, and still stop at a name you set yourself. What changed is where the name is kept: in a field that describes + the entity rather than the one Home Assistant reads when it builds an entity ID. A name written by an earlier release is handed back the first time each + entity loads — nothing to run, nothing to confirm. **One consequence:** a circuit renamed in the SPAN app now updates the display after the integration + reloads, a few seconds rather than immediately, which is what Circuit Friendly Names mode has always done. + - **Only circuits you actually renamed are offered.** Installations old enough to predate the current suffixes carry entity ids ending `_consumed_energy`, `_produced_energy`, `_net_energy` or `_current_power`, where an entity created today would end `_energy_consumed`, `_energy_produced`, `_energy_net` or `_power`. Those ids keep the suffix they have. Renormalising them would have offered a rename for **every circuit on the panel** — seventy-four on one we diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 53a8d03e..a4b9602b 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -26,7 +26,7 @@ "span-panel-api-schema-0==1.0.0", "span-panel-api-schema-1==1.0.0" ], - "version": "2.1.0b11", + "version": "2.1.0b12", "zeroconf": [ { "type": "_span._tcp.local." diff --git a/custom_components/span_panel/select.py b/custom_components/span_panel/select.py index 88ae38a8..af033269 100644 --- a/custom_components/span_panel/select.py +++ b/custom_components/span_panel/select.py @@ -130,27 +130,26 @@ def __init__( desc_name = description.entity_description.name if existing_entity_id: - # Entity exists - use circuit-based name when configured, else panel name - if use_circuit_numbers: - circuit_identifier = construct_circuit_identifier_from_tabs( - circuit.tabs, circuit_id - ) - self._attr_name = f"{circuit_identifier} {desc_name}" - elif circuit.name: + # Phase 2: the panel's name, in both modes. It reaches the UI as + # `original_name`, which ranks below `suggested_object_id` and so + # cannot decide what "Recreate entity IDs" proposes. + if circuit.name: self._attr_name = f"{circuit.name} {desc_name}" else: fallback = _unnamed_select_fallback(circuit, circuit_id) self._attr_name = f"{fallback} {desc_name}" - # Sync the panel friendly name to the entity registry display name - # so the UI shows e.g. "Air Conditioner Circuit Priority" while the - # entity_id stays circuit-based. - if existing_entity_id and use_circuit_numbers and circuit.name: + # Circuit-numbers mode used to deliver the panel's name by writing the + # registry's `name`. That field is the user's override, and Home Assistant + # reads it ahead of `suggested_object_id` when generating an entity id, so + # occupying it made "Recreate entity IDs" propose a friendly-name id for a + # circuit-numbered entity. The name travels as `original_name` now, so all + # that is left is to let go of what the old scheme wrote -- and only that: + # any other name is the user's. + if existing_entity_id and circuit.name: entity_entry = entity_registry.async_get(existing_entity_id) - if entity_entry: - expected_name = f"{circuit.name} {desc_name}" - if entity_entry.name is None or entity_entry.name == expected_name: - entity_registry.async_update_entity(existing_entity_id, name=expected_name) + if entity_entry and entity_entry.name == f"{circuit.name} {desc_name}": + entity_registry.async_update_entity(existing_entity_id, name=None) if not existing_entity_id: # Initial install - use flag-based name for entity_id generation @@ -289,70 +288,38 @@ def _handle_coordinator_update(self) -> None: circuit = snapshot.circuits.get(self.id) if circuit: current_circuit_name = circuit.name - use_circuit_numbers = self.coordinator.config_entry.options.get( - USE_CIRCUIT_NUMBERS, False - ) - desc_name = self.description_wrapper.entity_description.name - if use_circuit_numbers: - # Circuit-numbers mode: update registry display name, no reload - if self.entity_id and current_circuit_name: - entity_registry = er.async_get(self.hass) - entity_entry = entity_registry.async_get(self.entity_id) - if entity_entry: - # Compute old expected display BEFORE updating - # _previous_circuit_name - old_display = ( - f"{self._previous_circuit_name} {desc_name}" - if isinstance(self._previous_circuit_name, str) - else None - ) - new_display = f"{current_circuit_name} {desc_name}" - - # User override: registry name differs from both old - # and new expected display names - user_has_override = ( - entity_entry.name is not None - and entity_entry.name not in {old_display, new_display} - ) - - if not user_has_override and ( - self._previous_circuit_name is _NAME_UNSET - or current_circuit_name != self._previous_circuit_name - ): - entity_registry.async_update_entity(self.entity_id, name=new_display) + # One path for both modes: the name is carried by `original_name`, + # which is written when the entity is added, so a reload is what + # refreshes it. A name in the registry is one the user set. + user_has_override = False + if self.entity_id: + entity_registry = er.async_get(self.hass) + entity_entry = entity_registry.async_get(self.entity_id) + if entity_entry and entity_entry.name: + user_has_override = True + _LOGGER.debug( + "User has customized name for %s, skipping sync", + self.entity_id, + ) + if user_has_override: self._previous_circuit_name = current_circuit_name - else: - # Friendly-names mode: existing reload behavior - user_has_override = False - if self.entity_id: - entity_registry = er.async_get(self.hass) - entity_entry = entity_registry.async_get(self.entity_id) - if entity_entry and entity_entry.name: - user_has_override = True - _LOGGER.debug( - "User has customized name for %s, skipping sync", - self.entity_id, - ) - - if user_has_override: - self._previous_circuit_name = current_circuit_name - elif self._previous_circuit_name is _NAME_UNSET: - _LOGGER.info( - "First update: syncing entity name to panel name '%s' for select, requesting reload", - current_circuit_name, - ) - self._previous_circuit_name = current_circuit_name - self.coordinator.request_reload() - elif current_circuit_name != self._previous_circuit_name: - _LOGGER.info( - "Auto-sync detected circuit name change from '%s' to '%s' for select, requesting integration reload", - self._previous_circuit_name, - current_circuit_name, - ) - self._previous_circuit_name = current_circuit_name - self.coordinator.request_reload() + elif self._previous_circuit_name is _NAME_UNSET: + _LOGGER.info( + "First update: syncing entity name to panel name '%s' for select, requesting reload", + current_circuit_name, + ) + self._previous_circuit_name = current_circuit_name + self.coordinator.request_reload() + elif current_circuit_name != self._previous_circuit_name: + _LOGGER.info( + "Auto-sync detected circuit name change from '%s' to '%s' for select, requesting integration reload", + self._previous_circuit_name, + current_circuit_name, + ) + self._previous_circuit_name = current_circuit_name + self.coordinator.request_reload() # Update options and current option based on coordinator data circuit = self._get_circuit() diff --git a/custom_components/span_panel/sensor_base.py b/custom_components/span_panel/sensor_base.py index 6d85ca4a..974117a8 100644 --- a/custom_components/span_panel/sensor_base.py +++ b/custom_components/span_panel/sensor_base.py @@ -22,7 +22,7 @@ from homeassistant.helpers.typing import StateType from span_panel_api import SpanPanelSnapshot -from .const import DOMAIN, ENABLE_ENERGY_DIP_COMPENSATION, USE_CIRCUIT_NUMBERS +from .const import DOMAIN, ENABLE_ENERGY_DIP_COMPENSATION from .coordinator import SpanPanelCoordinator from .energy_dip import build_dip_attributes, process_energy_dip from .entity import SpanPanelEntity @@ -99,26 +99,17 @@ def __init__( "sensor", DOMAIN, self._attr_unique_id ) - use_circuit_numbers = data_coordinator.config_entry.options.get( - USE_CIRCUIT_NUMBERS, False - ) - if existing_entity_id: - if use_circuit_numbers: - # Circuit-numbers mode: keep circuit-based name for entity_id stability - self._attr_name = self._generate_friendly_name(snapshot, description) - else: - # Friendly-names mode: use panel name for sync - self._attr_name = self._generate_panel_name(snapshot, description) + # Phase 2: the panel's name, in both modes. It reaches the UI + # as `original_name`, which ranks below `suggested_object_id` + # and so cannot decide what "Recreate entity IDs" proposes. + self._attr_name = self._generate_panel_name(snapshot, description) else: - # Initial install - use flag-based name + # Phase 1: the flag-based name the mode calls for. self._attr_name = self._generate_friendly_name(snapshot, description) - # Sync panel friendly name to registry display name in - # circuit-numbers mode so the UI shows e.g. - # "Kitchen Power" while entity_id stays circuit-based. - if existing_entity_id and use_circuit_numbers: - self._sync_friendly_name_to_registry( + if existing_entity_id: + self._release_synced_registry_name( snapshot, description, entity_registry, existing_entity_id ) @@ -205,24 +196,35 @@ def _generate_panel_name(self, snapshot: SpanPanelSnapshot, description: T) -> s # For now, fall back to friendly name return self._generate_friendly_name(snapshot, description) - def _sync_friendly_name_to_registry( + def _release_synced_registry_name( self, snapshot: SpanPanelSnapshot, description: T, entity_registry: er.EntityRegistry, existing_entity_id: str, ) -> None: - """Sync panel circuit name to registry display name in circuit-numbers mode.""" + """Give the registry's `name` back to the user, where an older release took it. + + Circuit-numbers mode used to deliver the panel's name by writing the + registry's `name`. That field is the *user's* override, and Home Assistant + reads it ahead of `suggested_object_id` when generating an entity id -- so + occupying it made "Recreate entity IDs" propose a friendly-name id for a + circuit-numbered entity, converting the whole panel if accepted. + + The name now travels as `original_name` instead, so this only has to let + go of what the old scheme wrote. Only a name this integration would have + written is cleared; anything else is the user's and is left exactly where + it is, which is the same test the write used to gate on. + """ + entity_entry = entity_registry.async_get(existing_entity_id) + if not entity_entry or entity_entry.name is None: + return circuit = snapshot.circuits.get(getattr(self, "circuit_id", "")) if not (circuit and circuit.name): return - entity_entry = entity_registry.async_get(existing_entity_id) - if not entity_entry: - return description_suffix = str(getattr(description, "name", None) or "Sensor") - expected_name = f"{circuit.name} {description_suffix}" - if entity_entry.name is None or entity_entry.name == expected_name: - entity_registry.async_update_entity(existing_entity_id, name=expected_name) + if entity_entry.name == f"{circuit.name} {description_suffix}": + entity_registry.async_update_entity(existing_entity_id, name=None) def _construct_entity_id( self, @@ -248,7 +250,14 @@ def _construct_entity_id( return None def _sync_circuit_name(self) -> None: - """Sync circuit name changes: registry display in circuit-numbers mode, reload in friendly-names mode.""" + """Follow a circuit renamed on the panel, by reloading so the name is rebuilt. + + One path for both modes. The name is carried by `original_name`, which is + written when the entity is added, so a reload is what refreshes it -- + circuit-numbers mode used to write the registry's `name` in place instead, + which was quicker but handed that field the last word over entity id + generation. See `_release_synced_registry_name`. + """ if not (hasattr(self, "circuit_id") and hasattr(self.coordinator.data, "circuits")): return @@ -257,61 +266,33 @@ def _sync_circuit_name(self) -> None: return current_circuit_name = circuit.name - use_circuit_numbers = self.coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) - - if use_circuit_numbers: - # Circuit-numbers mode: update registry display name, no reload - if self.entity_id: - entity_registry = er.async_get(self.hass) - entity_entry = entity_registry.async_get(self.entity_id) - if entity_entry: - description_suffix = str( - getattr(self.entity_description, "name", None) or "Sensor" - ) - old_display = ( - f"{self._previous_circuit_name} {description_suffix}" - if isinstance(self._previous_circuit_name, str) - else None - ) - new_display = f"{current_circuit_name} {description_suffix}" - - user_has_override = entity_entry.name is not None and entity_entry.name not in { - old_display, - new_display, - } - - if not user_has_override and ( - self._previous_circuit_name is _NAME_UNSET - or current_circuit_name != self._previous_circuit_name - ): - entity_registry.async_update_entity(self.entity_id, name=new_display) + + # A name in the registry is one the user set: theirs outranks the panel's, + # and reloading would not change what is displayed anyway. + user_has_override = False + if self.entity_id: + entity_registry = er.async_get(self.hass) + entity_entry = entity_registry.async_get(self.entity_id) + if entity_entry and entity_entry.name: + user_has_override = True + + if user_has_override: self._previous_circuit_name = current_circuit_name - else: - # Friendly-names mode: existing reload behavior - user_has_override = False - if self.entity_id: - entity_registry = er.async_get(self.hass) - entity_entry = entity_registry.async_get(self.entity_id) - if entity_entry and entity_entry.name: - user_has_override = True - - if user_has_override: - self._previous_circuit_name = current_circuit_name - elif self._previous_circuit_name is _NAME_UNSET: - _LOGGER.info( - "First update: syncing sensor name to panel name '%s', requesting reload", - current_circuit_name, - ) - self._previous_circuit_name = current_circuit_name - self.coordinator.request_reload() - elif current_circuit_name != self._previous_circuit_name: - _LOGGER.info( - "Auto-sync detected circuit name change from '%s' to '%s' for sensor, requesting integration reload", - self._previous_circuit_name, - current_circuit_name, - ) - self._previous_circuit_name = current_circuit_name - self.coordinator.request_reload() + elif self._previous_circuit_name is _NAME_UNSET: + _LOGGER.info( + "First update: syncing sensor name to panel name '%s', requesting reload", + current_circuit_name, + ) + self._previous_circuit_name = current_circuit_name + self.coordinator.request_reload() + elif current_circuit_name != self._previous_circuit_name: + _LOGGER.info( + "Auto-sync detected circuit name change from '%s' to '%s' for sensor, requesting integration reload", + self._previous_circuit_name, + current_circuit_name, + ) + self._previous_circuit_name = current_circuit_name + self.coordinator.request_reload() def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" diff --git a/custom_components/span_panel/switch.py b/custom_components/span_panel/switch.py index bf9f988b..be75e47d 100644 --- a/custom_components/span_panel/switch.py +++ b/custom_components/span_panel/switch.py @@ -82,27 +82,26 @@ def __init__( use_circuit_numbers = coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) if existing_entity_id: - # Entity exists - use circuit-based name when configured, else panel name - if use_circuit_numbers: - circuit_identifier = construct_circuit_identifier_from_tabs( - circuit.tabs, circuit_id - ) - self._attr_name = f"{circuit_identifier} Breaker" - elif circuit.name: + # Phase 2: the panel's name, in both modes. It reaches the UI as + # `original_name`, which ranks below `suggested_object_id` and so + # cannot decide what "Recreate entity IDs" proposes. + if circuit.name: self._attr_name = f"{circuit.name} Breaker" else: fallback = _unnamed_switch_fallback(circuit, circuit_id) self._attr_name = f"{fallback} Breaker" - # Sync the panel friendly name to the entity registry display name - # so the UI shows e.g. "Air Conditioner Breaker" while the entity_id - # stays circuit-based (e.g. switch.span_panel_circuit_15_breaker). - if existing_entity_id and use_circuit_numbers and circuit.name: + # Circuit-numbers mode used to deliver the panel's name by writing the + # registry's `name`. That field is the user's override, and Home Assistant + # reads it ahead of `suggested_object_id` when generating an entity id, so + # occupying it made "Recreate entity IDs" propose a friendly-name id for a + # circuit-numbered entity. The name travels as `original_name` now, so all + # that is left is to let go of what the old scheme wrote -- and only that: + # any other name is the user's. + if existing_entity_id and circuit.name: entity_entry = entity_registry.async_get(existing_entity_id) - if entity_entry: - expected_name = f"{circuit.name} Breaker" - if entity_entry.name is None or entity_entry.name == expected_name: - entity_registry.async_update_entity(existing_entity_id, name=expected_name) + if entity_entry and entity_entry.name == f"{circuit.name} Breaker": + entity_registry.async_update_entity(existing_entity_id, name=None) if not existing_entity_id: # Initial install - use flag-based name for entity_id generation @@ -157,66 +156,35 @@ def _handle_coordinator_update(self) -> None: circuit = snapshot.circuits.get(self._circuit_id) if circuit: current_circuit_name = circuit.name - use_circuit_numbers = self.coordinator.config_entry.options.get( - USE_CIRCUIT_NUMBERS, False - ) - - if use_circuit_numbers: - # Circuit-numbers mode: update registry display name, no reload - if self.entity_id and current_circuit_name: - entity_registry = er.async_get(self.hass) - entity_entry = entity_registry.async_get(self.entity_id) - if entity_entry: - # Compute old expected display BEFORE updating - # _previous_circuit_name - old_display = ( - f"{self._previous_circuit_name} Breaker" - if isinstance(self._previous_circuit_name, str) - else None - ) - new_display = f"{current_circuit_name} Breaker" - - # User override: registry name differs from both old - # and new expected display names - user_has_override = ( - entity_entry.name is not None - and entity_entry.name not in {old_display, new_display} - ) - - if not user_has_override and ( - self._previous_circuit_name is _NAME_UNSET - or current_circuit_name != self._previous_circuit_name - ): - entity_registry.async_update_entity(self.entity_id, name=new_display) + # One path for both modes: the name is carried by `original_name`, + # which is written when the entity is added, so a reload is what + # refreshes it. A name in the registry is one the user set. + user_has_override = False + if self.entity_id: + entity_registry = er.async_get(self.hass) + entity_entry = entity_registry.async_get(self.entity_id) + if entity_entry and entity_entry.name: + user_has_override = True + + if user_has_override: self._previous_circuit_name = current_circuit_name - else: - # Friendly-names mode: existing reload behavior - user_has_override = False - if self.entity_id: - entity_registry = er.async_get(self.hass) - entity_entry = entity_registry.async_get(self.entity_id) - if entity_entry and entity_entry.name: - user_has_override = True - - if user_has_override: - self._previous_circuit_name = current_circuit_name - elif self._previous_circuit_name is _NAME_UNSET: - _LOGGER.info( - "First update: syncing entity name to panel name '%s' for switch, requesting reload", - current_circuit_name, - ) - self._previous_circuit_name = current_circuit_name - self.coordinator.request_reload() - elif current_circuit_name != self._previous_circuit_name: - _LOGGER.info( - "Auto-sync detected circuit name change from '%s' to '%s' for " - "switch, requesting integration reload", - self._previous_circuit_name, - current_circuit_name, - ) - self._previous_circuit_name = current_circuit_name - self.coordinator.request_reload() + elif self._previous_circuit_name is _NAME_UNSET: + _LOGGER.info( + "First update: syncing entity name to panel name '%s' for switch, requesting reload", + current_circuit_name, + ) + self._previous_circuit_name = current_circuit_name + self.coordinator.request_reload() + elif current_circuit_name != self._previous_circuit_name: + _LOGGER.info( + "Auto-sync detected circuit name change from '%s' to '%s' for " + "switch, requesting integration reload", + self._previous_circuit_name, + current_circuit_name, + ) + self._previous_circuit_name = current_circuit_name + self.coordinator.request_reload() self._update_is_on() super()._handle_coordinator_update() diff --git a/tests/test_circuit_control.py b/tests/test_circuit_control.py index 59ac49f3..c4d08199 100644 --- a/tests/test_circuit_control.py +++ b/tests/test_circuit_control.py @@ -560,7 +560,11 @@ def test_switch_relay_state_target_absent_when_none() -> None: def test_switch_circuit_numbers_entity_id_stable_after_reload( hass: HomeAssistant, ) -> None: - """Entity_id must stay circuit-based after name sync sets friendly display name.""" + """Entity_id stays circuit-based while the displayed name follows the panel. + + Phase 1 names the entity for the mode; phase 2 replaces that with the panel's + name. The entity_id is preset either way, so it does not follow the name. + """ circuit = SpanCircuitSnapshotFactory.create( circuit_id="2", name="Air Conditioner", @@ -601,8 +605,9 @@ def test_switch_circuit_numbers_entity_id_stable_after_reload( coordinator, "2", "Air Conditioner", "SPAN Panel" ) - # Entity_id must still be circuit-based - assert switch2.name == "Circuit 15 17 Breaker" + # Phase 2: the panel's name, carried by original_name rather than the + # registry's `name`, which would outrank the preset id. + assert switch2.name == "Air Conditioner Breaker" assert switch2.entity_id == "switch.span_panel_circuit_15_17_breaker" @@ -636,10 +641,10 @@ def test_switch_circuit_numbers_entity_id_120v_single_tab( assert switch.entity_id == "switch.span_panel_circuit_10_breaker" -def test_switch_circuit_numbers_syncs_friendly_name_to_registry( +def test_switch_circuit_numbers_releases_the_synced_registry_name( hass: HomeAssistant, ) -> None: - """Registry display name should be synced to the panel friendly name.""" + """A name an older release wrote is handed back, so it stops deciding the id.""" circuit = SpanCircuitSnapshotFactory.create( circuit_id="2", name="Air Conditioner", @@ -660,7 +665,7 @@ def test_switch_circuit_numbers_syncs_friendly_name_to_registry( # Use PropertyMock because MagicMock(name=...) sets the mock's # internal label rather than the .name attribute. entity_entry = MagicMock() - type(entity_entry).name = PropertyMock(return_value=None) + type(entity_entry).name = PropertyMock(return_value="Air Conditioner Breaker") registry.async_get.return_value = entity_entry mp.setattr( "custom_components.span_panel.switch.er.async_get", @@ -669,7 +674,7 @@ def test_switch_circuit_numbers_syncs_friendly_name_to_registry( SpanPanelCircuitsSwitch(coordinator, "2", "Air Conditioner", "SPAN Panel") registry.async_update_entity.assert_called_once_with( - "switch.span_panel_circuit_15_17_breaker", name="Air Conditioner Breaker" + "switch.span_panel_circuit_15_17_breaker", name=None ) @@ -708,10 +713,10 @@ def test_switch_circuit_numbers_preserves_user_custom_name( registry.async_update_entity.assert_not_called() -def test_switch_coordinator_update_circuit_numbers_updates_registry( +def test_switch_coordinator_update_circuit_numbers_requests_reload( hass: HomeAssistant, ) -> None: - """In circuit-numbers mode, a name change should update the registry display name.""" + """A renamed circuit reloads, which is what rebuilds original_name.""" circuit = SpanCircuitSnapshotFactory.create( circuit_id="2", name="Air Conditioner", @@ -750,7 +755,8 @@ def test_switch_coordinator_update_circuit_numbers_updates_registry( with pytest.MonkeyPatch.context() as mp: runtime_registry = MagicMock() runtime_entry = MagicMock() - type(runtime_entry).name = PropertyMock(return_value="Air Conditioner Breaker") + # Released at construction, so nothing occupies the field any more. + type(runtime_entry).name = PropertyMock(return_value=None) runtime_registry.async_get.return_value = runtime_entry mp.setattr( "custom_components.span_panel.switch.er.async_get", @@ -758,10 +764,8 @@ def test_switch_coordinator_update_circuit_numbers_updates_registry( ) switch._handle_coordinator_update() - runtime_registry.async_update_entity.assert_called_once_with( - "switch.span_panel_circuit_15_17_breaker", name="Kitchen AC Breaker" - ) - coordinator.request_reload.assert_not_called() + coordinator.request_reload.assert_called_once() + runtime_registry.async_update_entity.assert_not_called() def test_switch_coordinator_update_circuit_numbers_preserves_user_override( diff --git a/tests/test_recreate_entity_ids.py b/tests/test_recreate_entity_ids.py index d8298fc4..c5ea9fd9 100644 --- a/tests/test_recreate_entity_ids.py +++ b/tests/test_recreate_entity_ids.py @@ -247,21 +247,17 @@ async def test_an_unrenamed_circuit_is_offered_its_own_entity_id( assert registry.async_regenerate_entity_id(registry_entry) == ORIGINAL_ENTITY_ID -async def test_circuit_numbers_mode_keeps_its_id_its_display_name_and_its_sync( +async def test_circuit_numbers_mode_is_offered_its_own_tab_based_id( hass: HomeAssistant, entry: MockConfigEntry ) -> None: - """Regression guard: nothing in circuit-numbers mode may change. - - There the registry `name` written by phase 2 name sync is both what puts the - panel's name in the UI and what outranks `suggested_object_id` during - regeneration. That second effect means Recreate in this mode proposes a - friendly-name ID for a circuit-numbered entity -- a known limitation, and - the assertion below pins it deliberately: it is what the mode did before - this fix, and this fix must not disturb it. - - The two are the same write, so correcting Recreate here would mean dropping - or rerouting phase 2 sync. That is a product decision, recorded in the design - doc, not something to change while fixing friendly-names mode. + """Recreate must not offer to convert a circuit-numbered panel to friendly names. + + The mode exists so an id follows the breaker position rather than the name. + Accepting a friendly-name proposal would undo that for every circuit at once. + + It used to be offered because phase 2 sync wrote the panel's name into the + registry's `name`, which Home Assistant reads ahead of `suggested_object_id`. + The name now travels as `original_name`, which ranks below it. """ hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS)) @@ -275,42 +271,113 @@ async def test_circuit_numbers_mode_keeps_its_id_its_display_name_and_its_sync( registry_entry = registry.async_get(CIRCUIT_NUMBERS_ENTITY_ID) assert registry_entry is not None - # Phase 2 sync still writes the panel's name as the display name. - assert registry_entry.name == f"{RENAMED} Power" + assert registry.async_regenerate_entity_id(registry_entry) == CIRCUIT_NUMBERS_ENTITY_ID + + +async def test_circuit_numbers_mode_still_shows_the_panels_name( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Phase 2 sync still follows the panel -- through a field that cannot move an id.""" + hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS)) + + install = _Install(hass, entry) + await install.load(ORIGINAL_NAME) + sensor = await install.load(RENAMED) + + registry = er.async_get(hass) + registry_entry = registry.async_get(CIRCUIT_NUMBERS_ENTITY_ID) + assert registry_entry is not None + + assert registry_entry.original_name == f"{RENAMED} Power" + assert sensor.name == f"{RENAMED} Power" + assert registry_entry.name is None + + +async def test_circuit_numbers_mode_releases_a_name_an_older_release_wrote( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """An install upgrading has the old scheme's name handed back to it.""" + hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS)) + + install = _Install(hass, entry) + sensor = await install.load(ORIGINAL_NAME) + + registry = er.async_get(hass) + # Exactly what the previous release's phase 2 sync would have written. + registry.async_update_entity(sensor.entity_id, name=f"{ORIGINAL_NAME} Power") + assert registry.async_get(sensor.entity_id).name == f"{ORIGINAL_NAME} Power" + + await install.load(ORIGINAL_NAME) - # And that name still outranks the suggestion, so the offer is composed - # from it -- unchanged, limitation included. - assert registry.async_regenerate_entity_id(registry_entry) == RENAMED_ENTITY_ID + registry_entry = registry.async_get(CIRCUIT_NUMBERS_ENTITY_ID) + assert registry_entry is not None + assert registry_entry.name is None + assert registry.async_regenerate_entity_id(registry_entry) == CIRCUIT_NUMBERS_ENTITY_ID -async def test_the_breaker_switch_gets_the_same_refreshed_suggestion( +async def test_releasing_the_name_is_idempotent( hass: HomeAssistant, entry: MockConfigEntry ) -> None: - """Switches and selects preset their IDs through the same helper. + """Two reloads in a row are the same as one. There is no migration to run twice.""" + hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS)) - They call it from their own constructors rather than through - `_construct_entity_id`, so a fix that only reached the sensor path would - leave a renamed circuit's breaker switch still offering its old ID. + install = _Install(hass, entry) + sensor = await install.load(ORIGINAL_NAME) + registry = er.async_get(hass) + registry.async_update_entity(sensor.entity_id, name=f"{ORIGINAL_NAME} Power") + + await install.load(ORIGINAL_NAME) + await install.load(ORIGINAL_NAME) + + registry_entry = registry.async_get(CIRCUIT_NUMBERS_ENTITY_ID) + assert registry_entry is not None + assert registry_entry.name is None + assert registry_entry.entity_id == CIRCUIT_NUMBERS_ENTITY_ID + + +async def test_a_name_the_user_set_is_never_released( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """The registry's `name` is the user's field, and only their own writes are theirs. + + A name we did not write is left exactly where it is -- which also means it + keeps outranking the suggestion, so Recreate composes from it. That is what + Home Assistant does for any integration once a user names an entity. """ - platform = MockEntityPlatform(hass, domain="switch", platform_name=DOMAIN) - platform.config_entry = entry + hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS)) - for circuit_name in (ORIGINAL_NAME, RENAMED): - snapshot = _snapshot(circuit_name) - coordinator = _coordinator(hass, snapshot, entry) - switch = SpanPanelCircuitsSwitch(coordinator, CIRCUIT_ID, circuit_name, "SPAN Panel") - await platform.async_add_entities([switch]) - await hass.async_block_till_done() - await platform.async_reset() + install = _Install(hass, entry) + sensor = await install.load(ORIGINAL_NAME) registry = er.async_get(hass) - registry_entry = registry.async_get("switch.span_panel_refrigerator_breaker") + registry.async_update_entity(sensor.entity_id, name="Beverage Cooling") + + await install.load(ORIGINAL_NAME) + + registry_entry = registry.async_get(CIRCUIT_NUMBERS_ENTITY_ID) assert registry_entry is not None - assert registry_entry.suggested_object_id == "span_panel_beer_fridge_breaker" - assert ( - registry.async_regenerate_entity_id(registry_entry) - == "switch.span_panel_beer_fridge_breaker" - ) + assert registry_entry.name == "Beverage Cooling" + assert registry_entry.entity_id == CIRCUIT_NUMBERS_ENTITY_ID + + +async def test_circuit_numbers_mode_does_not_move_ids_across_the_change( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Neither id moves, whatever happens to the name.""" + hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS)) + + install = _Install(hass, entry) + before = await install.load(ORIGINAL_NAME) + unique_id_before = before.unique_id + + registry = er.async_get(hass) + registry.async_update_entity(before.entity_id, name=f"{ORIGINAL_NAME} Power") + + after = await install.load(RENAMED) + + assert after.entity_id == CIRCUIT_NUMBERS_ENTITY_ID + assert after.unique_id == unique_id_before + assert registry.async_get(RENAMED_ENTITY_ID) is None # --- Entities that predate the suffix mapping reaching entity ids ------------- diff --git a/tests/test_select.py b/tests/test_select.py index 54af76ed..d6997817 100644 --- a/tests/test_select.py +++ b/tests/test_select.py @@ -515,8 +515,9 @@ def test_select_circuit_numbers_entity_id_stable_after_reload( "SPAN Panel", ) - # Entity_id must still be circuit-based - assert select2.name == "Circuit 15 17 Circuit Priority" + # Phase 2: the panel's name, carried by original_name rather than the + # registry's `name`, which would outrank the preset id. + assert select2.name == "Air Conditioner Circuit Priority" assert select2.entity_id == "select.span_panel_circuit_15_17_circuit_priority" @@ -558,7 +559,7 @@ def test_select_circuit_numbers_entity_id_120v_single_tab( assert select.entity_id == "select.span_panel_circuit_10_circuit_priority" -def test_select_coordinator_update_circuit_numbers_updates_registry( +def test_select_coordinator_update_circuit_numbers_requests_reload( hass: HomeAssistant, ) -> None: """In circuit-numbers mode, a name change should update the registry display name.""" @@ -610,9 +611,8 @@ def test_select_coordinator_update_circuit_numbers_updates_registry( with pytest.MonkeyPatch.context() as mp: runtime_registry = MagicMock() runtime_entry = MagicMock() - type(runtime_entry).name = PropertyMock( - return_value="Air Conditioner Circuit Priority" - ) + # Released at construction, so nothing occupies the field any more. + type(runtime_entry).name = PropertyMock(return_value=None) runtime_registry.async_get.return_value = runtime_entry mp.setattr( "custom_components.span_panel.select.er.async_get", @@ -620,8 +620,5 @@ def test_select_coordinator_update_circuit_numbers_updates_registry( ) select._handle_coordinator_update() - runtime_registry.async_update_entity.assert_called_once_with( - "select.span_panel_circuit_15_17_circuit_priority", - name="Kitchen AC Circuit Priority", - ) - coordinator.request_reload.assert_not_called() + coordinator.request_reload.assert_called_once() + runtime_registry.async_update_entity.assert_not_called() diff --git a/tests/test_sensor_entities.py b/tests/test_sensor_entities.py index cdb824ef..4c114415 100644 --- a/tests/test_sensor_entities.py +++ b/tests/test_sensor_entities.py @@ -1198,8 +1198,8 @@ def test_energy_sensor_name_change_requests_reload() -> None: assert sensor._previous_circuit_name == "Renamed Kitchen" -def test_circuit_sensor_entity_id_stable_in_circuit_numbers_mode() -> None: - """Entity name should be circuit-based in circuit-numbers mode for entity_id stability.""" +def test_circuit_sensor_takes_the_panel_name_in_circuit_numbers_mode() -> None: + """The name follows the panel; the entity_id does not follow the name.""" circuit = SpanCircuitSnapshotFactory.create(circuit_id="c1", name="Kitchen", tabs=[5]) snapshot = SpanPanelSnapshotFactory.create(circuits={"c1": circuit}) coordinator = _make_coordinator(snapshot, options={USE_CIRCUIT_NUMBERS: True}) @@ -1214,14 +1214,13 @@ def test_circuit_sensor_entity_id_stable_in_circuit_numbers_mode() -> None: sensor = SpanCircuitPowerSensor(coordinator, CIRCUIT_CURRENT_SENSOR, snapshot, "c1") - # In circuit-numbers mode, _attr_name should be circuit-based (contains "Circuit") - assert sensor._attr_name is not None - assert "Circuit" in sensor._attr_name + assert sensor._attr_name == "Kitchen Current" + assert sensor.entity_id == "sensor.span_panel_circuit_5_current" assert sensor._previous_circuit_name == "Kitchen" -def test_circuit_sensor_name_change_updates_registry_in_circuit_numbers_mode() -> None: - """In circuit-numbers mode, name changes update registry display name without reload.""" +def test_circuit_sensor_name_change_requests_reload_in_circuit_numbers_mode() -> None: + """A renamed circuit reloads, which is what rebuilds original_name.""" circuit = SpanCircuitSnapshotFactory.create(circuit_id="c1", name="Kitchen", tabs=[5]) snapshot = SpanPanelSnapshotFactory.create(circuits={"c1": circuit}) coordinator = _make_coordinator(snapshot, options={USE_CIRCUIT_NUMBERS: True}) @@ -1248,15 +1247,12 @@ def test_circuit_sensor_name_change_updates_registry_in_circuit_numbers_mode() - with patch("custom_components.span_panel.sensor_base.er.async_get") as mock_async_get: runtime_registry = MagicMock() runtime_entry = MagicMock() - runtime_entry.name = "Kitchen Current" + # Released at construction, so nothing occupies the field any more. + runtime_entry.name = None runtime_registry.async_get.return_value = runtime_entry mock_async_get.return_value = runtime_registry sensor._handle_coordinator_update() - # Registry should be updated with the new display name - runtime_registry.async_update_entity.assert_called_once_with( - "sensor.circuit_5_current", name="Renamed Kitchen Current" - ) - # No reload should be requested in circuit-numbers mode - coordinator.request_reload.assert_not_called() + coordinator.request_reload.assert_called_once() + runtime_registry.async_update_entity.assert_not_called() assert sensor._previous_circuit_name == "Renamed Kitchen" From fc6dfe1055378ad27053028db440c17f81504aa6 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sun, 23 Aug 2026 12:48:30 -0700 Subject: [PATCH 112/345] docs: correct the 2.1.0 notes and the adoption docstring adoption.py claimed "the unit of adoption is a device, never a property" and argued against ever minting an entity for a property on a modelled device -- which extension.py has done since it shipped. It now states the split between the two modules and why a settable property becomes a control on an adopted device and a reading on a curated one. The five sensors disabled for SPAN API defects repeated the same eight-line note at each site. One paragraph in the module docstring names all five; each site keeps a one-line pointer to #234, which is now the pinned tracking issue for them. CHANGELOG, 2.1.0 only: - Every entry trimmed to at most two sentences, and the four entries covering "Recreate entity IDs" collapsed into one line stating the fixed behaviour rather than the sequence of beta fixes. - Three entries corrected against the code: circuit and lugs vendor readings land on the panel's card rather than a card of their own; the new-entity notification collapses an adopted device, and any card gaining more than five vendor readings, into a line with a count; and the controls-vs-readings bullets now name their subject, since adopted devices do get switches and selects while extensions never do. - Dropped the Grid Forming Entity entry: resolve_dominant_power_source preserves the value space deliberately, so nothing observable changed. - Spelling and grammar. --- CHANGELOG.md | 139 ++++++------------ custom_components/span_panel/adoption.py | 14 +- .../span_panel/sensor_definitions.py | 91 +++++------- 3 files changed, 91 insertions(+), 153 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b169dfb..8b659a79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,39 +4,27 @@ All notable changes to this project will be documented in this file. ## [2.1.0] - 8/2026 -### You will need this release when SPAN updates your panel +### You will need this release before SPAN updates your panel SPAN firmware `r202633` replaces the way the panel publishes its data — the wire model every release up to 2.0.8 is retired in the same update that introduces the new one. There is no wire overlap and no setting to keep the old behaviour. **2.0.8 cannot read a panel on `r202633`**: it stays connected, reports every circuit as missing, and shows nothing useful. -We do not control when that update reaches you, and the schedule belongs exclusively to SPAN. Panels update on SPAN's timing, not on yours or ours. A mandatory -upgrade that keeps working therefore demands a release that can adapt on connection, and adapt again as necessary. If uninterrupted integration matters to you, -be on this release **before** your panel changes — afterwards you are looking at a blank integration while you work out why. +We do not control when that update reaches you, and the schedule belongs exclusively to SPAN. Panels update on SPAN's timing, not on yours or ours. If +uninterrupted integration matters to you, install this release **before** your panel changes — afterwards you are looking at a blank integration while you work +out why. -**The transition itself is seamless.** Install 2.1.0 early and your only outage is the firmware update itself. When your panel changes over, the integration -notices on the wire, reloads itself, notifies you, and carries on — no reconfiguration, no re-pairing, no lost history. Your entities keep their entity ids, -their unique ids and their statistics across the change. New things appear because the new firmware publishes a bit more and because devices appropriate to your -install are added; nothing you already had goes away. A log line and a one-time notification tell you exactly what happened, so take a screenshot. If you -experience an extended delay, reload the integration. +**The transition itself is seamless.** Install 2.1.0 early and your only outage is the firmware update itself. A log line and a one-time notification tell you +exactly what happened, so take a screenshot. **A panel that is still rebooting is waited out, for as long as your panel takes.** Taking the firmware upgrade drops the panel's connection for several minutes -— four or more is not uncommon once the panel receives its upgrade file. The integration keeps checking until the panel answers properly. - -**Your energy history is unaffected by that wait.** Energy sensors hold their last reading through an outage for the grace period you configure (fifteen minutes -by default), which is what stops a gap becoming an `unknown` and a spike in your statistics. - -**A firmware upgrade that adds a capability reloads as well**, so hardware your panel starts reporting — the Microgrid Interconnect Device, the shed forecast, -the power control system, battery telemetry, DER link health — turns into entities when it appears rather than at your next restart. - -**If following the upgrade ever fails for some other reason, you are told what to do about it**: plainly, that a reload is needed once the panel is back up, -rather than a bare error in the log while the integration carries on reading the panel with the wrong reader. +— four or more is not uncommon once the panel receives its upgrade file. If you experience an extended delay (over 5 minutes), reload the integration (reload is +a menu item, not a reinstallation). ### Requires Home Assistant 2026.8.0 or newer This release raises the minimum from 2026.5.4. Home Assistant 2026.8 replaced the two device-registry calls this integration relies on — the old forms stop -working entirely in 2027.8 — and their replacements do not exist in 2026.5 through 2026.7, so there is no version of this release that runs on both. If you are -on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not offer you this release. +working entirely in 2027.8. ### Added @@ -74,23 +62,20 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of - Each forecast carries its full-charge equivalent and the panel's own `forecast_confidence` as attributes, rather than as two more near-constant entities. - **Import Limit, Binding Constraint and PCS Active** — the current limit your panel enforces, which rule set it, and whether anything is being throttled right - now. Filed under Diagnostics with the panel's other electrical characteristics. -- **Import Limit carries the whole arbitration as attributes**: the four constraint limits the panel reconciled, each one's `_enablement` and `_active` flag, - and `pcs_enabled`. + now. Filed under Diagnostics with the panel's other electrical characteristics. Import Limit carries the whole arbitration as attributes. - **Every circuit's power sensor gains `pcs_managed` and `pcs_priority`** where the circuit reports them — the shed order when an import limit binds, which is not the backup tier the existing `shed_priority` names. -- All three entities appear even when the PCS is switched off, because that is a state, and the state most panels are in. - - **New kinds of device your panel gains no longer wait for a release.** SPAN's data model is vendor-extensible, so a device type nobody has modelled can turn up at any time; one now gets a card of its own hanging off the panel, with whatever it publishes as entities beneath it, all disabled and diagnostic. -- **A property such a device accepts writes to becomes a control**: a boolean becomes a switch, an enum a select, a bounded number a number entity, constrained - to what the device declared and nothing invented. +- **A property an adopted device accepts writes to becomes a control**: a boolean becomes a switch, an enum a select, a bounded number a number entity, + constrained to what the device declared and nothing invented. - **Nothing adopted enters long-term statistics** — `state_class` is not declared on the wire and a wrong guess writes corrupt statistics; wrap an adopted reading in a template sensor or utility meter if you want them. -- **A new reading a vendor adds to a device you already have now appears too**, on that device's own card — the battery, a charger, the solar inverter, a - circuit or the panel. Previously only whole new _devices_ were picked up, so a battery vendor adding a field reached you nowhere. -- These arrive switched off and filed as diagnostics, like everything else adopted, and they are readings only — never switches or number boxes, because a - control here would sit beside the curated ones without their limits and translations. +- **A new reading a vendor adds to a device you already have now appears too**, on the card that device already has — the battery, the Microgrid Interconnect + Device, a SPAN Drive or the solar inverter, with circuit and lugs readings landing on the panel's own card. Previously only whole new _devices_ were picked + up, so a battery vendor adding a field reached you nowhere. +- These new readings arrive switched off and filed as diagnostics, like everything else adopted, and they are readings only — never switches or number boxes, + because a control here would sit beside the curated ones without their limits and translations. - **They keep the panel's own wording** (`Battery 2 Cell Temperature`), which is deliberately plainer than a curated entity's name so you can tell at a glance which is which. - **Deleting one hides it until the next reload while your panel is still publishing it, and removes it for good once your panel stops.** There is no setting to @@ -107,75 +92,45 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of ### Changed -- **`DSM Grid State` is now more trustworthy.** It keeps its entity id and all of its history. Previously it was _inferred_ — from the battery if one was - fitted, otherwise from the dominant power source and whether power was crossing the grid connection. It now reads the islanding state the Microgrid - Interconnect Device actually senses. -- **`Grid Islandable` keeps working** across the upgrade. The new firmware publishes no panel-level islandable property — it makes the question structural - instead, where the presence of a Microgrid Interconnect Device is what says a panel can island — so the entity now reflects whether that device is present. A - panel without one reads `Off`, which is an answer, rather than going unavailable. -- **`Grid Forming Entity` keeps working** across the upgrade too. Your panel used to publish a source class outright; the new firmware names the forming - _device_, and it names it on the Microgrid Interconnect Device, which a panel without a battery does not have. On such a panel the answer is settled by what - cannot be there — a battery needs that device, a solar inverter cannot form a grid on its own, and a panel supplying nothing is not publishing — so the entity - reads `Grid`, which is exactly what your panel reported before. +- **`DSM Grid State` is now more trustworthy**, reading the islanding state the Microgrid Interconnect Device actually senses rather than inferring it from the + battery, the dominant power source and which way power was crossing the grid connection. It keeps its entity id and all of its history. +- **`Grid Islandable` keeps working** across the upgrade: the new firmware publishes no panel-level islandable property and makes the question structural + instead, so the entity now reflects whether a Microgrid Interconnect Device is present. A panel without one reads `Off`, which is an answer, rather than going + unavailable. - **Battery model** may read differently after upgrading: the new firmware separates the human-readable designation from the SKU, and this entity now shows the designation. The normalisation happens in the library on both sides of the upgrade, so it lands once, at this release, rather than unpredictably when your panel changes over. -- **Five panel sensors are switched off for new installations, because the eBus specification's own maintainer has documented that their values cannot be relied - on.** A conformance note for SPAN firmware r202633 identifies three defects in what the panel publishes, all of which predate that release: the feedthrough - (downstream lugs) energy registers are computed from two unrelated counters and can decrease or go negative — on a panel with no feedthrough load they report - roughly whole-panel figures where the truth is zero — the feedthrough power reading is inverted relative to every other terminal, and the feedthrough currents - report the _upstream_ service conductors rather than a downstream measurement. The affected entities are **Feedthrough Produced Energy**, **Feedthrough - Consumed Energy**, **Feedthrough Power**, and the two **Downstream** current sensors. -- **If you already have those five, nothing changes and they stay exactly where they are.** Home Assistant consults the enabled-by-default setting only when an - entity is first created, so an existing installation keeps them, keeps its history and keeps its entity IDs. This stops new installations picking them up; it - cannot reach back. If you use any of the five on a dashboard or in an automation, they are worth removing — but that is your decision to make, not something - an upgrade should do to you. -- **Your other panel readings are unaffected, and that is now checked against a real panel rather than assumed.** A capture from a live upgraded panel arrived - alongside the conformance note: the panel's four power-flow values sum to zero exactly, and the battery power sensor's definition is byte-for-byte what 2.0.8 - shipped. The upstream lugs, the main panel meter and every circuit are in the correct frame, as is the power-flow group, which the specification has now been - corrected to describe the way the panel has always published it. - -- **New entities are now announced in a notification that names them — whether or not they arrived switched on.** Previously only entities added _disabled_ were - mentioned, on the reasoning that an enabled one is already visible in your entity list and its history. That is only true if you are watching your entity - list, which nobody is: an addition that breaks nothing was indistinguishable from no addition at all. The notification names every entity that was added, - splits them by whether they are ready to use or still switched off, and tells you where to turn the switched-off ones on. -- **It is a notification rather than a Repair, because an addition is not a repair.** Nothing is broken and nothing needs fixing. Any new-entity item still - sitting in your Repairs list from a previous version is removed on upgrade. - -- **The Wi-Fi network name moved to the Wi-Fi Link sensor**, which is where you would look for it: the entity that tells you whether Wi-Fi is up now also tells - you which network it is up on, as a `wifi_ssid` attribute. It is absent rather than blank on a panel that publishes no SSID. -- **It is no longer an attribute of the Software Version sensor.** A network name on a firmware-version sensor never made sense — it sat there because - `panel_size` was already in that attribute block. If you have a template reading `state_attr('sensor.span_panel_software_version', 'wifi_ssid')`, point it at - the Wi-Fi Link binary sensor instead. `panel_size` is unaffected and stays where it is. - -- **The three circuit energy sensors are renamed to match the ids they are given.** "Produced Energy", "Consumed Energy" and "Net Energy" become **Energy - Produced**, **Energy Consumed** and **Energy Net**, the order used by the `energy_produced`, `energy_consumed` and `energy_net` suffixes that these sensors' - unique ids carry and that new entities are given. **Entity ids, unique ids and history are unchanged**; only the name shown in the UI reorders. +- **Five panel sensors are switched off for new installations** (#234), because the SPAN API documented three defects that predate SPAN firmware r202633: the + feedthrough energy registers can decrease or go negative, the feedthrough power reading is inverted relative to every other terminal, and the feedthrough + currents report the _upstream_ service conductors. The affected entities are **Feedthrough Produced Energy**, **Feedthrough Consumed Energy**, **Feedthrough + Power**, and the two **Downstream** current sensors. +- **If you already have those five, nothing changes and they stay exactly where they are**, because Home Assistant consults the enabled-by-default setting only + when an entity is first created — so an existing installation keeps them, keeps its history and keeps its entity IDs. If you use any of the five on a + dashboard or in an automation they are worth removing, but that is your decision to make, not something an upgrade should do to you. +- **Your other panel readings are unaffected, and that is now checked against a real panel rather than assumed.** A capture from a live upgraded panel shows the + four power-flow values summing to zero exactly and the battery power definition byte-for-byte what 2.0.8 shipped, with the upstream lugs, the main panel meter + and every circuit in the correct frame. + +- **New entities are now announced in a notification, whether or not they arrived switched on.** The notification now splits what arrived into ready to use and + switched off and names each entity. + +- **The Wi-Fi network name attribute moved to the Wi-Fi Link sensor**, which is where you would look for it: the entity that tells you whether Wi-Fi is up now + also tells you which network it is up on, as a `wifi_ssid` attribute. It is absent rather than blank on a panel that publishes no SSID. +- **It is no longer an attribute of the Software Version sensor**, where a network name never made sense attribute block. If you have a template reading + `state_attr('sensor.span_panel_software_version', 'wifi_ssid')`, point it at the Wi-Fi Link binary sensor instead. + +- **The three circuit energy sensors are renamed to match the ids they are given**: "Produced Energy", "Consumed Energy" and "Net Energy" become **Energy + Produced**, **Energy Consumed** and **Energy Net**, the order the `energy_produced`, `energy_consumed` and `energy_net` suffixes already use. **Entity ids, + unique ids and history are unchanged**; only the name shown in the UI reorders. ### Fixed -- **Recreate entity IDs proposes the ids your panel would produce now.** Renaming a circuit in the SPAN app used to leave the button offering each entity the id - it already had, so it looked like it did nothing (#252). The proposal was frozen at whatever the circuit was called when the entity was first created; it now - follows the panel. **It is still an offer you accept** — a rename in the SPAN app never moves a live entity id by itself, and unique ids and statistics are - untouched. Circuit-numbers installations are unchanged: there the display name written by name sync is also what Home Assistant builds the proposal from, so - the button behaves exactly as it did. -- **Recreate entity IDs no longer offers to convert a tab-named panel to friendly names.** In Tab Based Names mode the button used to propose - `sensor.span_panel_air_conditioner_power` for a live `sensor.span_panel_circuit_15_power` — accepting it undid, for every circuit at once, the one thing that - mode exists to provide. It now proposes the tab-based ID the entity already has, so Recreate is a no-op unless a breaker actually moved. -- **Names still follow your panel in that mode**, and still stop at a name you set yourself. What changed is where the name is kept: in a field that describes - the entity rather than the one Home Assistant reads when it builds an entity ID. A name written by an earlier release is handed back the first time each - entity loads — nothing to run, nothing to confirm. **One consequence:** a circuit renamed in the SPAN app now updates the display after the integration - reloads, a few seconds rather than immediately, which is what Circuit Friendly Names mode has always done. - -- **Only circuits you actually renamed are offered.** Installations old enough to predate the current suffixes carry entity ids ending `_consumed_energy`, - `_produced_energy`, `_net_energy` or `_current_power`, where an entity created today would end `_energy_consumed`, `_energy_produced`, `_energy_net` or - `_power`. Those ids keep the suffix they have. Renormalising them would have offered a rename for **every circuit on the panel** — seventy-four on one we - measured — burying the one circuit that had actually been renamed and breaking the dashboards and automations of anyone who accepted. +- **Recreate entity IDs proposes the ids your panel would produce now** (#252), in your installation's naming style; unique ids and statistics are untouched. - **Enum sensors advertise the states they can actually report.** Nine sensors declared only `unknown`, so `DSM Grid State` sitting at `On Grid` showed "Possible states: Unknown". -- **The README described Battery Power's sign backwards.** The sensor reports **discharging** as positive and always has — that is what release 2.0.5 - established (#184) and what a measured panel confirms. **No entity changed and no reading moved**; only the documentation was ever wrong. +- **The README described Battery Power's sign backwards.** The sensor reports **discharging** as positive and always has, as release 2.0.5 established (#184) + and a measured panel confirms — **no entity changed and no reading moved**, only the documentation was ever wrong. ## [2.0.8] - 5/2026 diff --git a/custom_components/span_panel/adoption.py b/custom_components/span_panel/adoption.py index ab9b9403..74bebfe5 100644 --- a/custom_components/span_panel/adoption.py +++ b/custom_components/span_panel/adoption.py @@ -5,12 +5,14 @@ arrives as an expected event rather than a hypothetical one -- and until now it arrived as nothing at all: no device, no entity, no sign it was there. -**The unit of adoption is a device, never a property.** A new property on a device -this integration already models is a curation task with a short turnaround, and -minting an entity for it automatically would spend an `entity_id` permanently on -a shape a human would likely have chosen differently. That cost only bites where -curation is coming. On a device type nobody has modelled, no better identity is -coming, so a disabled diagnostic entity is strictly better than the silence. +**This module's unit is a device. A property on a device already modelled is +`extension.py`'s**, and the split is where the two costs differ. On a device type +nobody has modelled, no better identity is coming, so a disabled diagnostic +entity is strictly better than the silence -- and a settable property becomes a +real control, because there is no curated control beside it for a generic write +path to undercut. On a curated device, curation is plausibly coming and its +controls do real work, so `extension.py` mints a terminal identity in plain wire +vocabulary and surfaces even a settable property as a reading. **Nothing adopted enters long-term statistics.** No adopted entity carries a `state_class`, ever. Three reasons, and the third is the one that shapes the diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py index 15081511..0ac8957e 100644 --- a/custom_components/span_panel/sensor_definitions.py +++ b/custom_components/span_panel/sensor_definitions.py @@ -7,6 +7,11 @@ - Circuit power and energy sensors - Unmapped circuit sensors (invisible backing data) - Battery sensor + +Disabled by default from 2.1.0, because the SPAN API's own values are unreliable +(#234): Feedthrough Power, Feedthrough Produced Energy, Feedthrough Consumed +Energy, Downstream L1 Current, Downstream L2 Current. The default applies at +first registration only, so existing installs keep them. """ from __future__ import annotations @@ -297,42 +302,36 @@ class SpanPanelBatterySensorEntityDescription( ) ) -DOWNSTREAM_L1_CURRENT_SENSOR: SpanPanelDataSensorEntityDescription = SpanPanelDataSensorEntityDescription( - key="downstream_l1_current", - field_path="panel.downstream_l1_current_a", - # Disabled by default from 2.1.0: the eBus maintainer's r202633 conformance - # note says this cannot be relied on -- reports the UPSTREAM service conductors, not a downstream measurement. - # Disabled rather than removed because it shipped in 2.0.8, so an existing - # install has it with history and entities must not vanish. This changes - # nothing for those installs -- the default applies at first registration - # only -- it stops NEW installs adopting it. See the delta document. - entity_registry_enabled_default=False, - translation_key="downstream_l1_current", - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - entity_category=EntityCategory.DIAGNOSTIC, - suggested_display_precision=2, - value_fn=lambda s: s.downstream_l1_current_a, +DOWNSTREAM_L1_CURRENT_SENSOR: SpanPanelDataSensorEntityDescription = ( + SpanPanelDataSensorEntityDescription( + key="downstream_l1_current", + field_path="panel.downstream_l1_current_a", + # Unreliable in the SPAN API; see the module docstring (#234). + entity_registry_enabled_default=False, + translation_key="downstream_l1_current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=2, + value_fn=lambda s: s.downstream_l1_current_a, + ) ) -DOWNSTREAM_L2_CURRENT_SENSOR: SpanPanelDataSensorEntityDescription = SpanPanelDataSensorEntityDescription( - key="downstream_l2_current", - field_path="panel.downstream_l2_current_a", - # Disabled by default from 2.1.0: the eBus maintainer's r202633 conformance - # note says this cannot be relied on -- reports the UPSTREAM service conductors, not a downstream measurement. - # Disabled rather than removed because it shipped in 2.0.8, so an existing - # install has it with history and entities must not vanish. This changes - # nothing for those installs -- the default applies at first registration - # only -- it stops NEW installs adopting it. See the delta document. - entity_registry_enabled_default=False, - translation_key="downstream_l2_current", - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - entity_category=EntityCategory.DIAGNOSTIC, - suggested_display_precision=2, - value_fn=lambda s: s.downstream_l2_current_a, +DOWNSTREAM_L2_CURRENT_SENSOR: SpanPanelDataSensorEntityDescription = ( + SpanPanelDataSensorEntityDescription( + key="downstream_l2_current", + field_path="panel.downstream_l2_current_a", + # Unreliable in the SPAN API; see the module docstring (#234). + entity_registry_enabled_default=False, + translation_key="downstream_l2_current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=2, + value_fn=lambda s: s.downstream_l2_current_a, + ) ) # Main breaker rating sensor (v2 only, conditionally created) @@ -986,13 +985,7 @@ class SpanPVMetadataSensorEntityDescription( SpanPanelDataSensorEntityDescription( key="feedthroughPowerW", field_path="panel.feedthrough_power_w", - # Disabled by default from 2.1.0: the eBus maintainer's r202633 conformance - # note says this cannot be relied on -- inverted: positive when power leaves the enclosure, where every other - # enclosure terminal is positive inbound. Negate to compensate. - # Disabled rather than removed because it shipped in 2.0.8, so an existing - # install has it with history and entities must not vanish. This changes - # nothing for those installs -- the default applies at first registration - # only -- it stops NEW installs adopting it. See the delta document. + # Unreliable in the SPAN API; see the module docstring (#234). entity_registry_enabled_default=False, translation_key="feedthrough_power", native_unit_of_measurement=UnitOfPower.WATT, @@ -1083,13 +1076,7 @@ class SpanPVMetadataSensorEntityDescription( SpanPanelDataSensorEntityDescription( key="feedthroughEnergyProducedWh", field_path="panel.feedthrough_energy_produced_wh", - # Disabled by default from 2.1.0: the eBus maintainer's r202633 conformance - # note says this cannot be relied on -- the difference of two unrelated counters, non-monotonic, and ~whole-panel - # figures where the truth is zero. No transformation recovers a real value. - # Disabled rather than removed because it shipped in 2.0.8, so an existing - # install has it with history and entities must not vanish. This changes - # nothing for those installs -- the default applies at first registration - # only -- it stops NEW installs adopting it. See the delta document. + # Unreliable in the SPAN API; see the module docstring (#234). entity_registry_enabled_default=False, translation_key="feedthrough_produced_energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -1101,13 +1088,7 @@ class SpanPVMetadataSensorEntityDescription( SpanPanelDataSensorEntityDescription( key="feedthroughEnergyConsumedWh", field_path="panel.feedthrough_energy_consumed_wh", - # Disabled by default from 2.1.0: the eBus maintainer's r202633 conformance - # note says this cannot be relied on -- the difference of two unrelated counters, non-monotonic, and ~whole-panel - # figures where the truth is zero. No transformation recovers a real value. - # Disabled rather than removed because it shipped in 2.0.8, so an existing - # install has it with history and entities must not vanish. This changes - # nothing for those installs -- the default applies at first registration - # only -- it stops NEW installs adopting it. See the delta document. + # Unreliable in the SPAN API; see the module docstring (#234). entity_registry_enabled_default=False, translation_key="feedthrough_consumed_energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, From f3eb9295d14374fb28acdc11a168c84ebfc6dbb1 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sun, 23 Aug 2026 12:49:54 -0700 Subject: [PATCH 113/345] chore: 2.1.0b13 --- custom_components/span_panel/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index a4b9602b..e13979f9 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -26,7 +26,7 @@ "span-panel-api-schema-0==1.0.0", "span-panel-api-schema-1==1.0.0" ], - "version": "2.1.0b12", + "version": "2.1.0b13", "zeroconf": [ { "type": "_span._tcp.local." From b5074a98d0f3584c32db310079f18c86e799d3f6 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:02:12 -0700 Subject: [PATCH 114/345] chore: 2.1.0b14 --- custom_components/span_panel/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index e13979f9..4d3294d7 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -26,7 +26,7 @@ "span-panel-api-schema-0==1.0.0", "span-panel-api-schema-1==1.0.0" ], - "version": "2.1.0b13", + "version": "2.1.0b14", "zeroconf": [ { "type": "_span._tcp.local." From f76f32c643cb0fa7cebebeaca567db6aca5f1be4 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:08:22 -0700 Subject: [PATCH 115/345] docs: troubleshooting entry for entity IDs surviving a reinstall --- README.md | 21 ++------------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 22b3c32e..e239c50c 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ This integration communicates with the SPAN Panel over your local network using infrastructure. eBus uses the [Homie Convention](https://homieiot.github.io/) for MQTT topics and messages, with the panel's built-in MQTT broker delivering real-time state updates without polling. -## ⚠️ Backup and Upgrade to 2.1.x before your panel's firmware updates, or the integration will stop working (upgrade only from v2.0.8!) +## ⚠️ Backup and Upgrade to v2.1.x before your panel's firmware updates, or the integration will stop working (upgrade only from v2.0.8!) **SPAN firmware `r202633` changes the API in a non-compatible way after the firmware hits** When your panel takes that update, 2.0.8 stops being able to read it. The integration still connects, still shows as loaded, and reports every circuit as missing — sensors go unavailable, automations stop firing, dashboards go @@ -76,24 +76,6 @@ The old format is retired in the same update that introduces the new one — the -## 2.0.x Breaking Changes (v2) - -**Do NOT upgrade unless your panel is running firmware `spanos2/r202603/05` or later and you are on 2.0.8 of the integration.** - -**What you need:** - -- SPAN Panel firmware `spanos2/r202603/05` or later -- Panel passphrase (found in the SPAN mobile app, On-premise settings) **or** physical access to the panel door for proof-of-proximity authentication - -**Breaking:** - -- Requires firmware `spanos2/r202603/05` or later — panels on older firmware will not work -- `Cellular` binary sensor removed — replaced by `Vendor Cloud` sensor - -> Running older firmware? See [v1 Legacy Documentation](v1-legacy.md). - -See [CHANGELOG.md](CHANGELOG.md) for all additions or value changes. - ## Prerequisites - [Home Assistant](https://www.home-assistant.io/) installed @@ -667,6 +649,7 @@ See [WebSocket API Reference](websocket-api.md) for the full schema, response fo | **Replaced sub-device shows the old serial number** | After replacing a SPAN sub-device (Drive / EVSE, BESS, PV inverter), the device entry in Home Assistant keeps showing the previous hardware's serial number. The integration keys entities off the panel-assigned node identity, which is intentionally stable across hardware swaps so long-term history (e.g. lifetime charging kWh for a Drive) is preserved. The device-registry serial number, however, does not auto-refresh. | In**Settings → Devices & Services → Span Panel**, open the affected sub-device and delete it, then reload the integration (or restart Home Assistant). The device re-registers with the new serial number. Entity IDs and their recorded history are preserved. | | **Door sensor unavailable** | The SPAN API returns UNKNOWN if the cabinet door has not been operated recently. This is a defect in the SPAN API. | The integration reports the sensor as unavailable until a proper value arrives. Opening or closing the door publishes the correct state. The door is classified as a tamper sensor (`Detected` / `Clear`) to differentiate it from a normal entry door. | | **No switch on a circuit** | A circuit has no switch entity exposed in Home Assistant. | The circuit is configured in the SPAN App as one of the "Always on Circuits". The API does not permit user control of those circuits, so no switch is created. | +| **Reinstalling to change the entity ID style gives back the old entity IDs** | The naming style is chosen at install and cannot be changed from the options, so reinstalling looks like the way to switch. It is not: every entity returns with the entity ID it had before. | Home Assistant remembers a removed entity for **30 days**, keyed on its unique ID, and restores that record's entity ID — along with its name, area, labels and icon — as soon as an entity with the same unique ID appears again. This integration's unique IDs do not change with the naming style, so the remembered ID wins over the one the new style asks for. Either clear the leftover registry entries between removing and reinstalling, or wait out the 30 days and let Home Assistant discard them. A tool such as [ha-registry-clean](https://github.com/LegoTypes/ha-registry-clean) can do the clearing; it is a separate project, not part of this integration. Clearing also discards the names, areas and labels you had assigned. | ## Development From a7776a9c82d528f12c7b48c57503b3d844f39661 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:17:01 -0700 Subject: [PATCH 116/345] fix(translations): move notification strings out of the hassfest-validated files hassfest validates strings.json and translations/en.json against Home Assistant's own schema and rejects any key it does not define, so the `notifications` section those files carried failed the check outright -- "extra keys not allowed @ data['notifications']". That is what an unsupported custom category looks like from the outside, and it is the same fact notices.py already documents as its reason for reading these strings itself rather than through homeassistant.helpers.translation. The strings move to a notifications/ directory beside translations/, one file per language, so hassfest sees only the categories it defines and the notices keep their per-language files. read_translations reads the new directory; its language-chain fallback is unchanged. sync_translations.py validates the new directory the way it validates translations/, against notifications/en.json instead of strings.json -- without that these five files would have silently lost the key-parity guarantee the move was supposed to preserve. Verified by removing a key from the French file and watching the hook fail. 1269 tests passing; full prek set green. --- custom_components/span_panel/notices.py | 14 ++++++-- .../span_panel/notifications/en.json | 19 +++++++++++ .../span_panel/notifications/es.json | 19 +++++++++++ .../span_panel/notifications/fr.json | 19 +++++++++++ .../span_panel/notifications/ja.json | 19 +++++++++++ .../span_panel/notifications/pt.json | 19 +++++++++++ custom_components/span_panel/strings.json | 19 ----------- .../span_panel/translations/en.json | 19 ----------- .../span_panel/translations/es.json | 19 ----------- .../span_panel/translations/fr.json | 19 ----------- .../span_panel/translations/ja.json | 19 ----------- .../span_panel/translations/pt.json | 19 ----------- scripts/sync_translations.py | 34 ++++++++++++++----- 13 files changed, 132 insertions(+), 125 deletions(-) create mode 100644 custom_components/span_panel/notifications/en.json create mode 100644 custom_components/span_panel/notifications/es.json create mode 100644 custom_components/span_panel/notifications/fr.json create mode 100644 custom_components/span_panel/notifications/ja.json create mode 100644 custom_components/span_panel/notifications/pt.json diff --git a/custom_components/span_panel/notices.py b/custom_components/span_panel/notices.py index 9acb6e83..15b3545d 100644 --- a/custom_components/span_panel/notices.py +++ b/custom_components/span_panel/notices.py @@ -306,20 +306,28 @@ async def async_forget(hass: HomeAssistant, entry: ConfigEntry) -> None: def read_translations(language: str, section: str) -> dict[str, str]: """One notification's strings for one language, or an empty mapping. - Read from this component's `translations/` directory rather than through + Read from this component's `notifications/` directory rather than through `homeassistant.helpers.translation`, because that helper filters to the categories Home Assistant defines and a persistent notification is not one of them -- a custom category loads as nothing at all. These are this integration's own package files, so reading them is not reaching into somebody else's layout. + **A directory of its own, not `translations/`.** hassfest validates + `strings.json` and `translations/en.json` against Home Assistant's schema and + rejects any key it does not define, so a `notifications` section there fails + the check outright -- which is what a custom category being unsupported looks + like from the outside. Keeping these strings beside those files rather than + inside them is what makes both true at once: hassfest sees only what it + defines, and the notices keep per-language files. + Falls back along the language chain -- `pt-BR`, then `pt`, then `en` -- so a regional variant with no file of its own still gets its language rather than English. Blocking file I/O. Callers run it in an executor. """ - directory = Path(__file__).parent / "translations" + directory = Path(__file__).parent / "notifications" for candidate in (f"{language}.json", f"{language.split('-')[0]}.json", "en.json"): path = directory / candidate if not path.is_file(): @@ -331,7 +339,7 @@ def read_translations(language: str, section: str) -> dict[str, str]: continue if not isinstance(loaded, dict): continue - strings = loaded.get("notifications", {}).get(section, {}) + strings = loaded.get(section, {}) if isinstance(strings, dict) and strings: return {str(key): str(value) for key, value in strings.items()} return {} diff --git a/custom_components/span_panel/notifications/en.json b/custom_components/span_panel/notifications/en.json new file mode 100644 index 00000000..e04a307c --- /dev/null +++ b/custom_components/span_panel/notifications/en.json @@ -0,0 +1,19 @@ +{ + "panel_upgraded": { + "title": "SPAN Panel firmware upgraded", + "body": "Your SPAN Panel reported a new eBus data model (**{previous} → {current}**), which happens after a firmware upgrade. The integration reloaded so its devices and entities match what the panel now publishes.\n\nNothing you rely on has gone away, and no automation changes are required — this is a note about what changed.\n\n**DSM Grid State** keeps its entity ID and its history, and is now more trustworthy. On the previous firmware it was *inferred* — from the battery when one was fitted, otherwise from the dominant power source and whether any power was crossing the grid connection. It now reads the islanding state the Microgrid Interconnect Device (MID) actually senses.\n\n**Grid Islandable** also keeps working. The new data model publishes no panel-level `grid-islandable` property, so it now reflects whether a MID is present, which is how backup capability is detected.\n\n**New:** a Microgrid Interconnect Device appears among your devices, carrying **Grid State** — the health of the utility supply itself, which the previous firmware did not report. Any entities the upgrade added are listed in their own notification.\n\nEntities that were renamed or replaced by the upgrade may need to be removed manually if they remain unavailable.\n\nDismiss this once you have had a look." + }, + "new_entities": { + "title": "SPAN Panel added new entities", + "intro_one": "This update added 1 new entity to your SPAN Panel.", + "intro_many": "This update added {count} new entities to your SPAN Panel.", + "enabled_heading": "Added and ready to use", + "disabled_heading": "Added but switched off", + "how_to_enable": "Switched-off entities record nothing until you turn them on. Open the SPAN Panel device page, show its disabled entities, and enable the ones you want.", + "nothing_broken": "Nothing is broken and no action is required. Entities appear for several reasons: an update that surfaces more of what your panel reports, a circuit added to the panel, or hardware such as a battery, solar or a charger coming online." + }, + "extension_overflow": { + "title": "SPAN Panel: some vendor readings were not added", + "body": "A device on your panel declares more vendor readings than this integration will add for one device ({limit}). The rest were left out: {devices}.\n\nNothing you already have is affected, and nothing is broken. The readings that were left out are still listed in this integration's diagnostics download, which is what to attach if you want them surfaced." + } +} diff --git a/custom_components/span_panel/notifications/es.json b/custom_components/span_panel/notifications/es.json new file mode 100644 index 00000000..c5e9d6d9 --- /dev/null +++ b/custom_components/span_panel/notifications/es.json @@ -0,0 +1,19 @@ +{ + "panel_upgraded": { + "title": "Firmware del Panel SPAN actualizado", + "body": "Su Panel SPAN ha comunicado un nuevo modelo de datos eBus (**{previous} → {current}**), lo que ocurre tras una actualización de firmware. La integración se ha recargado para que sus dispositivos y entidades coincidan con lo que el panel publica ahora.\n\nNada de lo que usted utiliza ha desaparecido y no es necesario modificar ninguna automatización: este aviso solo explica lo que ha cambiado.\n\n**Estado de Red DSM** conserva su ID de entidad y su historial, y ahora es más fiable. Con el firmware anterior se *deducía*: a partir de la batería cuando había una instalada y, en su defecto, de la fuente de energía dominante y de si circulaba potencia por la conexión a la red. Ahora lee el estado de aislamiento que el Dispositivo de Interconexión de Microrred (MID) detecta realmente.\n\n**Aislable de la Red** también sigue funcionando. El nuevo modelo de datos no publica ninguna propiedad `grid-islandable` a nivel de panel, por lo que ahora refleja si hay un MID presente, que es como se determina la capacidad de respaldo.\n\n**Novedad:** aparece un Dispositivo de Interconexión de Microrred entre sus dispositivos, con **Estado de la Red**, es decir, el estado del suministro eléctrico, que el firmware anterior no informaba. Las entidades que la actualización haya añadido se enumeran en su propio aviso.\n\nLas entidades renombradas o sustituidas por la actualización pueden tener que eliminarse manualmente si siguen sin estar disponibles.\n\nPuede descartar este aviso cuando lo haya revisado." + }, + "new_entities": { + "title": "El Panel SPAN ha añadido nuevas entidades", + "intro_one": "Esta actualización añadió 1 entidad nueva a tu Panel SPAN.", + "intro_many": "Esta actualización añadió {count} entidades nuevas a tu Panel SPAN.", + "enabled_heading": "Añadidas y listas para usar", + "disabled_heading": "Añadidas pero desactivadas", + "how_to_enable": "Las entidades desactivadas no registran nada hasta que las actives. Abre la página del dispositivo Panel SPAN, muestra sus entidades desactivadas y activa las que quieras.", + "nothing_broken": "No hay ningún problema y no se requiere ninguna acción. Las entidades aparecen por varios motivos: una actualización que expone más de lo que informa tu panel, un circuito añadido al panel, o hardware como una batería, paneles solares o un cargador que se pone en línea." + }, + "extension_overflow": { + "title": "Panel SPAN: algunas lecturas del fabricante no se añadieron", + "body": "Un dispositivo de tu panel declara más lecturas del fabricante de las que esta integración añade para un solo dispositivo ({limit}). El resto quedó fuera: {devices}.\n\nNada de lo que ya tienes se ve afectado y nada está roto. Las lecturas que quedaron fuera siguen apareciendo en la descarga de diagnósticos de esta integración, que es lo que conviene adjuntar si quieres que se muestren." + } +} diff --git a/custom_components/span_panel/notifications/fr.json b/custom_components/span_panel/notifications/fr.json new file mode 100644 index 00000000..fb937c90 --- /dev/null +++ b/custom_components/span_panel/notifications/fr.json @@ -0,0 +1,19 @@ +{ + "panel_upgraded": { + "title": "Micrologiciel du Panneau SPAN mis à jour", + "body": "Votre Panneau SPAN a signalé un nouveau modèle de données eBus (**{previous} → {current}**), ce qui se produit après une mise à jour du micrologiciel. L'intégration a été rechargée afin que ses appareils et ses entités correspondent à ce que le panneau publie désormais.\n\nRien de ce que vous utilisez n'a disparu et aucune modification de vos automatisations n'est nécessaire : cet avis explique simplement ce qui a changé.\n\n**État Réseau DSM** conserve son identifiant d'entité et son historique, et gagne en fiabilité. Avec le micrologiciel précédent, il était *déduit* : à partir de la batterie lorsqu'il y en avait une, sinon à partir de la source d'énergie dominante et de la présence de puissance transitant par le raccordement réseau. Il lit désormais l'état d'îlotage réellement mesuré par le Dispositif d'Interconnexion de Micro-réseau (MID).\n\n**Îlotage Réseau Possible** continue également de fonctionner. Le nouveau modèle de données ne publie aucune propriété `grid-islandable` au niveau du panneau ; ce capteur indique donc maintenant la présence d'un MID, ce qui est la façon dont la capacité de secours est déterminée.\n\n**Nouveau :** un Dispositif d'Interconnexion de Micro-réseau apparaît parmi vos appareils, avec **État du Réseau** — l'état de l'alimentation du réseau public, que le micrologiciel précédent ne signalait pas. Les entités ajoutées par la mise à jour sont répertoriées dans un avis distinct.\n\nLes entités renommées ou remplacées par la mise à jour peuvent devoir être supprimées manuellement si elles restent indisponibles.\n\nVous pouvez ignorer cet avis après consultation." + }, + "new_entities": { + "title": "Le Panneau SPAN a ajouté de nouvelles entités", + "intro_one": "Cette mise à jour a ajouté 1 nouvelle entité à votre Panneau SPAN.", + "intro_many": "Cette mise à jour a ajouté {count} nouvelles entités à votre Panneau SPAN.", + "enabled_heading": "Ajoutées et prêtes à l'emploi", + "disabled_heading": "Ajoutées mais désactivées", + "how_to_enable": "Les entités désactivées n'enregistrent rien tant que vous ne les activez pas. Ouvrez la page de l'appareil Panneau SPAN, affichez ses entités désactivées et activez celles que vous souhaitez.", + "nothing_broken": "Rien n'est cassé et aucune action n'est requise. Des entités apparaissent pour plusieurs raisons : une mise à jour qui expose davantage de ce que votre panneau rapporte, un circuit ajouté au panneau, ou du matériel tel qu'une batterie, du solaire ou un chargeur qui se met en ligne." + }, + "extension_overflow": { + "title": "Panneau SPAN : certaines mesures du fabricant n'ont pas été ajoutées", + "body": "Un appareil de votre panneau déclare plus de mesures du fabricant que cette intégration n'en ajoute pour un seul appareil ({limit}). Les autres ont été laissées de côté : {devices}.\n\nRien de ce que vous avez déjà n'est affecté et rien n'est cassé. Les mesures laissées de côté figurent toujours dans le téléchargement des diagnostics de cette intégration, qu'il convient de joindre si vous souhaitez les voir apparaître." + } +} diff --git a/custom_components/span_panel/notifications/ja.json b/custom_components/span_panel/notifications/ja.json new file mode 100644 index 00000000..e5c79d7f --- /dev/null +++ b/custom_components/span_panel/notifications/ja.json @@ -0,0 +1,19 @@ +{ + "panel_upgraded": { + "title": "SPAN パネルのファームウェアが更新されました", + "body": "SPAN パネルが新しい eBus データモデル (**{previous} → {current}**) を報告しました。これはファームウェアの更新後に発生します。パネルが現在公開している内容にデバイスとエンティティを合わせるため、統合を再読み込みしました。\n\nご利用中のものが失われることはなく、オートメーションの変更も必要ありません。この通知は変更点をお知らせするものです。\n\n**DSM 系統状態** はエンティティ ID と履歴をそのまま維持し、さらに信頼性が向上しました。以前のファームウェアでは *推定* されていました。バッテリーがある場合はバッテリーから、ない場合は主電源と系統接続を流れる電力の有無から判断していました。現在はマイクログリッド相互接続装置 (MID) が実際に検知した自立運転状態を読み取ります。\n\n**系統自立運転可能** も引き続き動作します。新しいデータモデルではパネル単位の `grid-islandable` プロパティは公開されないため、現在は MID の有無を示します。これがバックアップ能力の判定方法です。\n\n**新規:** デバイス一覧にマイクログリッド相互接続装置が追加され、**系統状態** (電力会社からの供給状態) を報告します。これは以前のファームウェアにはなかった情報です。今回の更新で追加されたエンティティは、別の通知に一覧表示されます。\n\n更新によって名称が変更された、または置き換えられたエンティティが利用不可のままの場合は、手動で削除する必要があることがあります。\n\n内容を確認したら、この通知は閉じて構いません。" + }, + "new_entities": { + "title": "SPAN パネルに新しいエンティティが追加されました", + "intro_one": "このアップデートで SPAN パネルに新しいエンティティが 1 個追加されました。", + "intro_many": "このアップデートで SPAN パネルに新しいエンティティが {count} 個追加されました。", + "enabled_heading": "追加され、すぐに使用できます", + "disabled_heading": "追加されましたが無効です", + "how_to_enable": "無効なエンティティは、有効にするまで何も記録しません。SPAN パネルのデバイスページを開き、無効なエンティティを表示して、必要なものを有効にしてください。", + "nothing_broken": "問題はなく、対応は不要です。エンティティが追加される理由はいくつかあります: パネルが報告する内容をより多く公開するアップデート、パネルに追加された回路、またはバッテリー、太陽光、充電器などのハードウェアがオンラインになった場合です。" + }, + "extension_overflow": { + "title": "SPAN パネル: 一部のベンダー測定値は追加されませんでした", + "body": "パネル上のあるデバイスが、この統合が 1 台あたりに追加する上限({limit})を超える数のベンダー測定値を宣言しています。残りは追加されませんでした: {devices}。\n\n既存のものには影響はなく、故障でもありません。追加されなかった測定値は、この統合の診断ダウンロードには引き続き記載されています。表示を希望する場合は、それを添付してください。" + } +} diff --git a/custom_components/span_panel/notifications/pt.json b/custom_components/span_panel/notifications/pt.json new file mode 100644 index 00000000..752171ba --- /dev/null +++ b/custom_components/span_panel/notifications/pt.json @@ -0,0 +1,19 @@ +{ + "panel_upgraded": { + "title": "Firmware do Painel SPAN atualizado", + "body": "O seu Painel SPAN comunicou um novo modelo de dados eBus (**{previous} → {current}**), o que acontece após uma atualização de firmware. A integração foi recarregada para que os seus dispositivos e entidades correspondam ao que o painel publica agora.\n\nNada do que utiliza desapareceu e não é necessário alterar automações — este aviso apenas explica o que mudou.\n\n**Estado da Rede DSM** mantém o seu ID de entidade e o seu histórico, e passa a ser mais fiável. No firmware anterior era *inferido*: a partir da bateria quando existia uma e, caso contrário, a partir da fonte de energia dominante e da existência de potência a atravessar a ligação à rede. Agora lê o estado de ilhamento que o Dispositivo de Interligação de Microrrede (MID) realmente deteta.\n\n**Isolável da Rede** também continua a funcionar. O novo modelo de dados não publica qualquer propriedade `grid-islandable` ao nível do painel, pelo que agora reflete a presença de um MID, que é como a capacidade de reserva é determinada.\n\n**Novidade:** aparece um Dispositivo de Interligação de Microrrede entre os seus dispositivos, com **Estado da Rede** — o estado do fornecimento da rede elétrica, que o firmware anterior não comunicava. As entidades que a atualização tenha adicionado são listadas num aviso próprio.\n\nAs entidades renomeadas ou substituídas pela atualização podem ter de ser removidas manualmente se continuarem indisponíveis.\n\nPode dispensar este aviso depois de o consultar." + }, + "new_entities": { + "title": "O Painel SPAN adicionou novas entidades", + "intro_one": "Esta atualização adicionou 1 nova entidade ao seu Painel SPAN.", + "intro_many": "Esta atualização adicionou {count} novas entidades ao seu Painel SPAN.", + "enabled_heading": "Adicionadas e prontas a usar", + "disabled_heading": "Adicionadas mas desativadas", + "how_to_enable": "As entidades desativadas não registam nada até as ativar. Abra a página do dispositivo Painel SPAN, mostre as suas entidades desativadas e ative as que quiser.", + "nothing_broken": "Nada está avariado e não é necessária qualquer ação. As entidades aparecem por vários motivos: uma atualização que expõe mais do que o seu painel reporta, um circuito adicionado ao painel, ou hardware como uma bateria, solar ou um carregador a ficar online." + }, + "extension_overflow": { + "title": "Painel SPAN: algumas leituras do fabricante não foram adicionadas", + "body": "Um dispositivo do seu painel declara mais leituras do fabricante do que esta integração adiciona para um único dispositivo ({limit}). As restantes ficaram de fora: {devices}.\n\nNada do que já tem é afetado e nada está avariado. As leituras que ficaram de fora continuam listadas na transferência de diagnósticos desta integração, que é o que deve anexar se quiser que sejam apresentadas." + } +} diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index a27c0cde..34629521 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -773,24 +773,5 @@ "title": "SPAN Panel unit mismatch", "description": "Your panel reports `{field_path}` in {schema_unit}, but this integration expects {ha_unit}. Entities affected: {count} (for example: {examples}). Their readings and long-term statistics may be wrong. They have not been changed automatically." } - }, - "notifications": { - "panel_upgraded": { - "title": "SPAN Panel firmware upgraded", - "body": "Your SPAN Panel reported a new eBus data model (**{previous} → {current}**), which happens after a firmware upgrade. The integration reloaded so its devices and entities match what the panel now publishes.\n\nNothing you rely on has gone away, and no automation changes are required — this is a note about what changed.\n\n**DSM Grid State** keeps its entity ID and its history, and is now more trustworthy. On the previous firmware it was *inferred* — from the battery when one was fitted, otherwise from the dominant power source and whether any power was crossing the grid connection. It now reads the islanding state the Microgrid Interconnect Device (MID) actually senses.\n\n**Grid Islandable** also keeps working. The new data model publishes no panel-level `grid-islandable` property, so it now reflects whether a MID is present, which is how backup capability is detected.\n\n**New:** a Microgrid Interconnect Device appears among your devices, carrying **Grid State** — the health of the utility supply itself, which the previous firmware did not report. Any entities the upgrade added are listed in their own notification.\n\nEntities that were renamed or replaced by the upgrade may need to be removed manually if they remain unavailable.\n\nDismiss this once you have had a look." - }, - "new_entities": { - "title": "SPAN Panel added new entities", - "intro_one": "This update added 1 new entity to your SPAN Panel.", - "intro_many": "This update added {count} new entities to your SPAN Panel.", - "enabled_heading": "Added and ready to use", - "disabled_heading": "Added but switched off", - "how_to_enable": "Switched-off entities record nothing until you turn them on. Open the SPAN Panel device page, show its disabled entities, and enable the ones you want.", - "nothing_broken": "Nothing is broken and no action is required. Entities appear for several reasons: an update that surfaces more of what your panel reports, a circuit added to the panel, or hardware such as a battery, solar or a charger coming online." - }, - "extension_overflow": { - "title": "SPAN Panel: some vendor readings were not added", - "body": "A device on your panel declares more vendor readings than this integration will add for one device ({limit}). The rest were left out: {devices}.\n\nNothing you already have is affected, and nothing is broken. The readings that were left out are still listed in this integration's diagnostics download, which is what to attach if you want them surfaced." - } } } diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index a27c0cde..34629521 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -773,24 +773,5 @@ "title": "SPAN Panel unit mismatch", "description": "Your panel reports `{field_path}` in {schema_unit}, but this integration expects {ha_unit}. Entities affected: {count} (for example: {examples}). Their readings and long-term statistics may be wrong. They have not been changed automatically." } - }, - "notifications": { - "panel_upgraded": { - "title": "SPAN Panel firmware upgraded", - "body": "Your SPAN Panel reported a new eBus data model (**{previous} → {current}**), which happens after a firmware upgrade. The integration reloaded so its devices and entities match what the panel now publishes.\n\nNothing you rely on has gone away, and no automation changes are required — this is a note about what changed.\n\n**DSM Grid State** keeps its entity ID and its history, and is now more trustworthy. On the previous firmware it was *inferred* — from the battery when one was fitted, otherwise from the dominant power source and whether any power was crossing the grid connection. It now reads the islanding state the Microgrid Interconnect Device (MID) actually senses.\n\n**Grid Islandable** also keeps working. The new data model publishes no panel-level `grid-islandable` property, so it now reflects whether a MID is present, which is how backup capability is detected.\n\n**New:** a Microgrid Interconnect Device appears among your devices, carrying **Grid State** — the health of the utility supply itself, which the previous firmware did not report. Any entities the upgrade added are listed in their own notification.\n\nEntities that were renamed or replaced by the upgrade may need to be removed manually if they remain unavailable.\n\nDismiss this once you have had a look." - }, - "new_entities": { - "title": "SPAN Panel added new entities", - "intro_one": "This update added 1 new entity to your SPAN Panel.", - "intro_many": "This update added {count} new entities to your SPAN Panel.", - "enabled_heading": "Added and ready to use", - "disabled_heading": "Added but switched off", - "how_to_enable": "Switched-off entities record nothing until you turn them on. Open the SPAN Panel device page, show its disabled entities, and enable the ones you want.", - "nothing_broken": "Nothing is broken and no action is required. Entities appear for several reasons: an update that surfaces more of what your panel reports, a circuit added to the panel, or hardware such as a battery, solar or a charger coming online." - }, - "extension_overflow": { - "title": "SPAN Panel: some vendor readings were not added", - "body": "A device on your panel declares more vendor readings than this integration will add for one device ({limit}). The rest were left out: {devices}.\n\nNothing you already have is affected, and nothing is broken. The readings that were left out are still listed in this integration's diagnostics download, which is what to attach if you want them surfaced." - } } } diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index d1846ebb..dba3f3ef 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -773,24 +773,5 @@ "title": "Discrepancia de unidades del Panel SPAN", "description": "Su panel informa de `{field_path}` en {schema_unit}, pero esta integración espera {ha_unit}. Entidades afectadas: {count} (por ejemplo: {examples}). Sus lecturas y estadísticas a largo plazo pueden ser incorrectas. No se han modificado automáticamente." } - }, - "notifications": { - "panel_upgraded": { - "title": "Firmware del Panel SPAN actualizado", - "body": "Su Panel SPAN ha comunicado un nuevo modelo de datos eBus (**{previous} → {current}**), lo que ocurre tras una actualización de firmware. La integración se ha recargado para que sus dispositivos y entidades coincidan con lo que el panel publica ahora.\n\nNada de lo que usted utiliza ha desaparecido y no es necesario modificar ninguna automatización: este aviso solo explica lo que ha cambiado.\n\n**Estado de Red DSM** conserva su ID de entidad y su historial, y ahora es más fiable. Con el firmware anterior se *deducía*: a partir de la batería cuando había una instalada y, en su defecto, de la fuente de energía dominante y de si circulaba potencia por la conexión a la red. Ahora lee el estado de aislamiento que el Dispositivo de Interconexión de Microrred (MID) detecta realmente.\n\n**Aislable de la Red** también sigue funcionando. El nuevo modelo de datos no publica ninguna propiedad `grid-islandable` a nivel de panel, por lo que ahora refleja si hay un MID presente, que es como se determina la capacidad de respaldo.\n\n**Novedad:** aparece un Dispositivo de Interconexión de Microrred entre sus dispositivos, con **Estado de la Red**, es decir, el estado del suministro eléctrico, que el firmware anterior no informaba. Las entidades que la actualización haya añadido se enumeran en su propio aviso.\n\nLas entidades renombradas o sustituidas por la actualización pueden tener que eliminarse manualmente si siguen sin estar disponibles.\n\nPuede descartar este aviso cuando lo haya revisado." - }, - "new_entities": { - "title": "El Panel SPAN ha añadido nuevas entidades", - "intro_one": "Esta actualización añadió 1 entidad nueva a tu Panel SPAN.", - "intro_many": "Esta actualización añadió {count} entidades nuevas a tu Panel SPAN.", - "enabled_heading": "Añadidas y listas para usar", - "disabled_heading": "Añadidas pero desactivadas", - "how_to_enable": "Las entidades desactivadas no registran nada hasta que las actives. Abre la página del dispositivo Panel SPAN, muestra sus entidades desactivadas y activa las que quieras.", - "nothing_broken": "No hay ningún problema y no se requiere ninguna acción. Las entidades aparecen por varios motivos: una actualización que expone más de lo que informa tu panel, un circuito añadido al panel, o hardware como una batería, paneles solares o un cargador que se pone en línea." - }, - "extension_overflow": { - "title": "Panel SPAN: algunas lecturas del fabricante no se añadieron", - "body": "Un dispositivo de tu panel declara más lecturas del fabricante de las que esta integración añade para un solo dispositivo ({limit}). El resto quedó fuera: {devices}.\n\nNada de lo que ya tienes se ve afectado y nada está roto. Las lecturas que quedaron fuera siguen apareciendo en la descarga de diagnósticos de esta integración, que es lo que conviene adjuntar si quieres que se muestren." - } } } diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index 0b42bdf0..afb0e5e8 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -773,24 +773,5 @@ "title": "Incohérence d'unité du Panneau SPAN", "description": "Votre panneau signale `{field_path}` en {schema_unit}, alors que cette intégration attend {ha_unit}. Entités concernées : {count} (par exemple : {examples}). Leurs relevés et leurs statistiques à long terme peuvent être erronés. Elles n'ont pas été modifiées automatiquement." } - }, - "notifications": { - "panel_upgraded": { - "title": "Micrologiciel du Panneau SPAN mis à jour", - "body": "Votre Panneau SPAN a signalé un nouveau modèle de données eBus (**{previous} → {current}**), ce qui se produit après une mise à jour du micrologiciel. L'intégration a été rechargée afin que ses appareils et ses entités correspondent à ce que le panneau publie désormais.\n\nRien de ce que vous utilisez n'a disparu et aucune modification de vos automatisations n'est nécessaire : cet avis explique simplement ce qui a changé.\n\n**État Réseau DSM** conserve son identifiant d'entité et son historique, et gagne en fiabilité. Avec le micrologiciel précédent, il était *déduit* : à partir de la batterie lorsqu'il y en avait une, sinon à partir de la source d'énergie dominante et de la présence de puissance transitant par le raccordement réseau. Il lit désormais l'état d'îlotage réellement mesuré par le Dispositif d'Interconnexion de Micro-réseau (MID).\n\n**Îlotage Réseau Possible** continue également de fonctionner. Le nouveau modèle de données ne publie aucune propriété `grid-islandable` au niveau du panneau ; ce capteur indique donc maintenant la présence d'un MID, ce qui est la façon dont la capacité de secours est déterminée.\n\n**Nouveau :** un Dispositif d'Interconnexion de Micro-réseau apparaît parmi vos appareils, avec **État du Réseau** — l'état de l'alimentation du réseau public, que le micrologiciel précédent ne signalait pas. Les entités ajoutées par la mise à jour sont répertoriées dans un avis distinct.\n\nLes entités renommées ou remplacées par la mise à jour peuvent devoir être supprimées manuellement si elles restent indisponibles.\n\nVous pouvez ignorer cet avis après consultation." - }, - "new_entities": { - "title": "Le Panneau SPAN a ajouté de nouvelles entités", - "intro_one": "Cette mise à jour a ajouté 1 nouvelle entité à votre Panneau SPAN.", - "intro_many": "Cette mise à jour a ajouté {count} nouvelles entités à votre Panneau SPAN.", - "enabled_heading": "Ajoutées et prêtes à l'emploi", - "disabled_heading": "Ajoutées mais désactivées", - "how_to_enable": "Les entités désactivées n'enregistrent rien tant que vous ne les activez pas. Ouvrez la page de l'appareil Panneau SPAN, affichez ses entités désactivées et activez celles que vous souhaitez.", - "nothing_broken": "Rien n'est cassé et aucune action n'est requise. Des entités apparaissent pour plusieurs raisons : une mise à jour qui expose davantage de ce que votre panneau rapporte, un circuit ajouté au panneau, ou du matériel tel qu'une batterie, du solaire ou un chargeur qui se met en ligne." - }, - "extension_overflow": { - "title": "Panneau SPAN : certaines mesures du fabricant n'ont pas été ajoutées", - "body": "Un appareil de votre panneau déclare plus de mesures du fabricant que cette intégration n'en ajoute pour un seul appareil ({limit}). Les autres ont été laissées de côté : {devices}.\n\nRien de ce que vous avez déjà n'est affecté et rien n'est cassé. Les mesures laissées de côté figurent toujours dans le téléchargement des diagnostics de cette intégration, qu'il convient de joindre si vous souhaitez les voir apparaître." - } } } diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index 6795877e..95fe751e 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -773,24 +773,5 @@ "title": "SPAN パネルの単位の不一致", "description": "パネルは `{field_path}` を {schema_unit} で報告していますが、この統合は {ha_unit} を想定しています。{count} 個のエンティティが影響を受けます (例: {examples})。これらの測定値と長期統計が誤っている可能性があります。エンティティは自動的には変更されていません。" } - }, - "notifications": { - "panel_upgraded": { - "title": "SPAN パネルのファームウェアが更新されました", - "body": "SPAN パネルが新しい eBus データモデル (**{previous} → {current}**) を報告しました。これはファームウェアの更新後に発生します。パネルが現在公開している内容にデバイスとエンティティを合わせるため、統合を再読み込みしました。\n\nご利用中のものが失われることはなく、オートメーションの変更も必要ありません。この通知は変更点をお知らせするものです。\n\n**DSM 系統状態** はエンティティ ID と履歴をそのまま維持し、さらに信頼性が向上しました。以前のファームウェアでは *推定* されていました。バッテリーがある場合はバッテリーから、ない場合は主電源と系統接続を流れる電力の有無から判断していました。現在はマイクログリッド相互接続装置 (MID) が実際に検知した自立運転状態を読み取ります。\n\n**系統自立運転可能** も引き続き動作します。新しいデータモデルではパネル単位の `grid-islandable` プロパティは公開されないため、現在は MID の有無を示します。これがバックアップ能力の判定方法です。\n\n**新規:** デバイス一覧にマイクログリッド相互接続装置が追加され、**系統状態** (電力会社からの供給状態) を報告します。これは以前のファームウェアにはなかった情報です。今回の更新で追加されたエンティティは、別の通知に一覧表示されます。\n\n更新によって名称が変更された、または置き換えられたエンティティが利用不可のままの場合は、手動で削除する必要があることがあります。\n\n内容を確認したら、この通知は閉じて構いません。" - }, - "new_entities": { - "title": "SPAN パネルに新しいエンティティが追加されました", - "intro_one": "このアップデートで SPAN パネルに新しいエンティティが 1 個追加されました。", - "intro_many": "このアップデートで SPAN パネルに新しいエンティティが {count} 個追加されました。", - "enabled_heading": "追加され、すぐに使用できます", - "disabled_heading": "追加されましたが無効です", - "how_to_enable": "無効なエンティティは、有効にするまで何も記録しません。SPAN パネルのデバイスページを開き、無効なエンティティを表示して、必要なものを有効にしてください。", - "nothing_broken": "問題はなく、対応は不要です。エンティティが追加される理由はいくつかあります: パネルが報告する内容をより多く公開するアップデート、パネルに追加された回路、またはバッテリー、太陽光、充電器などのハードウェアがオンラインになった場合です。" - }, - "extension_overflow": { - "title": "SPAN パネル: 一部のベンダー測定値は追加されませんでした", - "body": "パネル上のあるデバイスが、この統合が 1 台あたりに追加する上限({limit})を超える数のベンダー測定値を宣言しています。残りは追加されませんでした: {devices}。\n\n既存のものには影響はなく、故障でもありません。追加されなかった測定値は、この統合の診断ダウンロードには引き続き記載されています。表示を希望する場合は、それを添付してください。" - } } } diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index bf140185..af2cc18a 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -773,24 +773,5 @@ "title": "Discrepância de unidades do Painel SPAN", "description": "O seu painel comunica `{field_path}` em {schema_unit}, mas esta integração espera {ha_unit}. Entidades afetadas: {count} (por exemplo: {examples}). As suas leituras e estatísticas de longo prazo podem estar erradas. Não foram alteradas automaticamente." } - }, - "notifications": { - "panel_upgraded": { - "title": "Firmware do Painel SPAN atualizado", - "body": "O seu Painel SPAN comunicou um novo modelo de dados eBus (**{previous} → {current}**), o que acontece após uma atualização de firmware. A integração foi recarregada para que os seus dispositivos e entidades correspondam ao que o painel publica agora.\n\nNada do que utiliza desapareceu e não é necessário alterar automações — este aviso apenas explica o que mudou.\n\n**Estado da Rede DSM** mantém o seu ID de entidade e o seu histórico, e passa a ser mais fiável. No firmware anterior era *inferido*: a partir da bateria quando existia uma e, caso contrário, a partir da fonte de energia dominante e da existência de potência a atravessar a ligação à rede. Agora lê o estado de ilhamento que o Dispositivo de Interligação de Microrrede (MID) realmente deteta.\n\n**Isolável da Rede** também continua a funcionar. O novo modelo de dados não publica qualquer propriedade `grid-islandable` ao nível do painel, pelo que agora reflete a presença de um MID, que é como a capacidade de reserva é determinada.\n\n**Novidade:** aparece um Dispositivo de Interligação de Microrrede entre os seus dispositivos, com **Estado da Rede** — o estado do fornecimento da rede elétrica, que o firmware anterior não comunicava. As entidades que a atualização tenha adicionado são listadas num aviso próprio.\n\nAs entidades renomeadas ou substituídas pela atualização podem ter de ser removidas manualmente se continuarem indisponíveis.\n\nPode dispensar este aviso depois de o consultar." - }, - "new_entities": { - "title": "O Painel SPAN adicionou novas entidades", - "intro_one": "Esta atualização adicionou 1 nova entidade ao seu Painel SPAN.", - "intro_many": "Esta atualização adicionou {count} novas entidades ao seu Painel SPAN.", - "enabled_heading": "Adicionadas e prontas a usar", - "disabled_heading": "Adicionadas mas desativadas", - "how_to_enable": "As entidades desativadas não registam nada até as ativar. Abra a página do dispositivo Painel SPAN, mostre as suas entidades desativadas e ative as que quiser.", - "nothing_broken": "Nada está avariado e não é necessária qualquer ação. As entidades aparecem por vários motivos: uma atualização que expõe mais do que o seu painel reporta, um circuito adicionado ao painel, ou hardware como uma bateria, solar ou um carregador a ficar online." - }, - "extension_overflow": { - "title": "Painel SPAN: algumas leituras do fabricante não foram adicionadas", - "body": "Um dispositivo do seu painel declara mais leituras do fabricante do que esta integração adiciona para um único dispositivo ({limit}). As restantes ficaram de fora: {devices}.\n\nNada do que já tem é afetado e nada está avariado. As leituras que ficaram de fora continuam listadas na transferência de diagnósticos desta integração, que é o que deve anexar se quiser que sejam apresentadas." - } } } diff --git a/scripts/sync_translations.py b/scripts/sync_translations.py index 0423cb1f..47f5e0da 100644 --- a/scripts/sync_translations.py +++ b/scripts/sync_translations.py @@ -15,6 +15,15 @@ STRINGS_PATH = COMPONENT_DIR / "strings.json" TRANSLATIONS_DIR = COMPONENT_DIR / "translations" EN_PATH = TRANSLATIONS_DIR / "en.json" +NOTIFICATIONS_DIR = COMPONENT_DIR / "notifications" +NOTIFICATIONS_EN = NOTIFICATIONS_DIR / "en.json" +"""Persistent-notification strings, deliberately outside `translations/`. + +hassfest validates `strings.json` and `translations/en.json` against Home +Assistant's own schema and rejects any key it does not define, so these cannot +live there. They still need the same key-parity guarantee across languages, +which is why this script checks them too rather than leaving them unchecked. +""" def collect_leaf_keys(obj: dict | str, prefix: str = "") -> set[str]: @@ -67,17 +76,17 @@ def sync_en(source: dict) -> bool: return True -def validate_translations( - source_all_keys: set[str], source_leaf_keys: set[str] +def validate_dir( + directory: Path, source_all_keys: set[str], source_leaf_keys: set[str], source_name: str ) -> list[str]: - """Validate all non-en translation files. Return list of error messages.""" + """Validate every non-en file in `directory` against the English source.""" errors: list[str] = [] - for lang_file in sorted(TRANSLATIONS_DIR.glob("*.json")): + for lang_file in sorted(directory.glob("*.json")): if lang_file.name == "en.json": continue - lang = lang_file.stem + lang = f"{directory.name}/{lang_file.stem}" try: translation = json.loads(lang_file.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: @@ -87,14 +96,14 @@ def validate_translations( orphaned = find_orphaned_keys(source_all_keys, translation) if orphaned: errors.append( - f"{lang}: {len(orphaned)} orphaned key(s) not in strings.json:\n" + f"{lang}: {len(orphaned)} orphaned key(s) not in {source_name}:\n" + "\n".join(f" - {k}" for k in orphaned) ) missing = find_missing_keys(source_leaf_keys, translation) if missing: errors.append( - f"{lang}: {len(missing)} missing key(s) from strings.json:\n" + f"{lang}: {len(missing)} missing key(s) from {source_name}:\n" + "\n".join(f" - {k}" for k in missing) ) @@ -118,7 +127,16 @@ def main() -> int: display_path = EN_PATH print(f"Updated {display_path}") - errors = validate_translations(source_all_keys, source_leaf_keys) + errors = validate_dir(TRANSLATIONS_DIR, source_all_keys, source_leaf_keys, "strings.json") + + if NOTIFICATIONS_EN.exists(): + notifications = json.loads(NOTIFICATIONS_EN.read_text(encoding="utf-8")) + errors += validate_dir( + NOTIFICATIONS_DIR, + collect_all_keys(notifications), + collect_leaf_keys(notifications), + "notifications/en.json", + ) if errors: print("Translation validation failed:", file=sys.stderr) for error in errors: From c65ef646fe11a7d49e1fc6cfaf5ff56f4ab308be Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:51:04 -0700 Subject: [PATCH 117/345] fix(select): gate the priority select on priority settability, not the relay The shed-priority select was created for every circuit whose relay is user-controllable, which is a different commissioning flag from the one that governs whether priority can be written at all. Under v1.0 a panel expresses never-backup as the absence of `$settable` on `load-shed/priority`, and the adapter carries that as `is_never_backup` -- independent of `switch/relay-controllable`, which it carries as `is_user_controllable`. SPAN's migration guide is explicit that the flat booleans consolidate per-property rather than per-combination, so the two are free to disagree. Using the relay flag as a proxy meant offering a priority control on a circuit whose priority the panel refuses to change. `is_never_backup` was parsed, carried on the snapshot, and read nowhere in this integration. Conservative on purpose: the existing relay gate stays, so no circuit gains a select and no entity is added. Whether a relay-locked circuit should show a priority select -- its priority is settable but inert, since a locked relay is exempt from shedding -- is a separate question this does not answer. The panels we have captures from declare every circuit's priority settable, so the new test drives the flags directly rather than a wire fixture. --- custom_components/span_panel/select.py | 6 ++++ tests/test_select.py | 46 ++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/custom_components/span_panel/select.py b/custom_components/span_panel/select.py index af033269..cc7df43b 100644 --- a/custom_components/span_panel/select.py +++ b/custom_components/span_panel/select.py @@ -363,6 +363,12 @@ async def async_setup_entry( for circuit_id, circuit_data in snapshot.circuits.items(): if not circuit_data.is_user_controllable: continue + # Priority settability is its own commissioning flag, not the relay's. + # v1.0 expresses never-backup as the absence of `$settable` on + # `load-shed/priority`, which the adapter carries here; offering the + # control without checking it means offering a write the panel refuses. + if circuit_data.is_never_backup: + continue # PV/EVSE circuits only get selects if they have a physical breaker # (relative_position == "DOWNSTREAM" means connected at a breaker slot) if ( diff --git a/tests/test_select.py b/tests/test_select.py index d6997817..e815b898 100644 --- a/tests/test_select.py +++ b/tests/test_select.py @@ -459,6 +459,52 @@ async def test_async_setup_entry_filters_supported_circuits() -> None: assert {entity.id for entity in entities} == {"main-1", "pv-1"} +async def test_async_setup_entry_skips_circuits_whose_priority_is_not_settable() -> None: + """A never-backup circuit gets no priority select, even with a controllable relay. + + Relay controllability and priority settability are two independent + commissioning flags. Under v1.0 the panel expresses never-backup as the + absence of `$settable` on `load-shed/priority`, which the adapter carries as + `is_never_backup` -- a different property from `switch/relay-controllable`, + which it carries as `is_user_controllable`. + + Gating this platform on the relay flag alone offers a priority control on a + circuit whose priority the panel will refuse to change. The panels we have + captures from declare every circuit's priority settable, so hardware does not + exercise this today; the flag exists because a panel may say otherwise, and + it is the only thing that says so. + """ + settable_priority = SpanCircuitSnapshotFactory.create( + circuit_id="main-1", + name="Kitchen", + is_user_controllable=True, + is_never_backup=False, + tabs=[1], + ) + locked_priority = SpanCircuitSnapshotFactory.create( + circuit_id="main-2", + name="Water Heater", + is_user_controllable=True, + is_never_backup=True, + tabs=[2], + ) + + coordinator = MagicMock() + coordinator.data = SpanPanelSnapshotFactory.create( + circuits={"main-1": settable_priority, "main-2": locked_priority} + ) + config_entry = MagicMock() + config_entry.title = "SPAN Panel" + config_entry.data = {} + config_entry.runtime_data = MagicMock(coordinator=coordinator) + async_add_entities = MagicMock() + + await async_setup_entry(MagicMock(), config_entry, async_add_entities) + + entities = async_add_entities.call_args.args[0] + assert {entity.id for entity in entities} == {"main-1"} + + def test_select_circuit_numbers_entity_id_stable_after_reload( hass: HomeAssistant, ) -> None: From 76a978aaf26d85c0924591dc4801724a7ec56390 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:16:58 -0700 Subject: [PATCH 118/345] feat(websocket): carry priority settability, and pin the circuit record The topology record told a consumer whether the relay could be commanded but not whether the priority could be written. Those are two independent commissioning flags: a never-backup circuit has a working relay and a priority the panel pins, so relay controllability is not a usable proxy for it in either direction. That gap has a consumer. The card decides whether to render a circuit's shedding priority from whether a select entity exists, which is an entity-existence check standing in for a semantic question this integration owns -- so c65ef64, which stopped creating a select for a circuit whose priority is pinned, also stopped the card rendering that circuit's priority, in a repository no test here can reach. `is_never_backup` is carried now so that decision can be made from the fact rather than inferred from an artifact of it. The record's key set is pinned by a test for the same reason: a consumer outside this repository renders from these keys, so adding or removing one is a contract change and should fail here rather than surface as a rendering change somewhere else. Both new tests were mutation-verified -- dropping the key fails them. No entity or platform behaviour changes. --- custom_components/span_panel/websocket.py | 9 ++ tests/test_websocket.py | 123 ++++++++++++++++++++++ 2 files changed, 132 insertions(+) diff --git a/custom_components/span_panel/websocket.py b/custom_components/span_panel/websocket.py index e06611e6..dfbc59c8 100644 --- a/custom_components/span_panel/websocket.py +++ b/custom_components/span_panel/websocket.py @@ -170,6 +170,15 @@ async def handle_panel_topology( "always_on": circuit.always_on, "priority": circuit.priority, "priority_target": circuit.priority_target, + # Whether `priority` above can be written, which is a different + # commissioning flag from `is_user_controllable`: that one governs + # the relay. A never-backup circuit has a working relay and a + # priority the panel pins, so a consumer that infers priority + # settability from relay controllability gets it wrong in both + # directions. Carried even though no entity is created for such a + # circuit, because a consumer rendering from this record has no + # other way to tell a pinned priority from an absent one. + "is_never_backup": circuit.is_never_backup, "entities": entity_map.get(circuit_id, {}), } diff --git a/tests/test_websocket.py b/tests/test_websocket.py index d11647f4..c9fc6bb5 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -756,6 +756,129 @@ async def test_topology_includes_always_on_and_priority(self, hass: HomeAssistan assert hvac_data["always_on"] is False assert hvac_data["priority"] == "SOC_THRESHOLD" + @pytest.mark.asyncio + async def test_topology_circuit_record_carries_the_documented_keys(self, hass: HomeAssistant): + """The circuit record's key set is a contract, so pin it. + + A consumer rendering from this payload -- the card -- decides what to + show from these keys, and it lives in another repository that no test + here can reach. Adding or removing a key silently changes what it + renders, so the set is asserted exactly rather than by presence: a + deliberate change updates this list, an accidental one fails. + """ + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="uuid_kitchen", + name="Kitchen", + tabs=[3], + ) + snapshot = SpanPanelSnapshotFactory.create( + serial_number="sp3-contract-001", + circuits={"uuid_kitchen": circuit}, + ) + + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + entry_id="span_entry", + unique_id="sp3-contract-001", + ) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=_make_coordinator(snapshot), + panel_device_id="panel-device-id", + ) + + device = _register_panel_device(hass, "span_entry", serial="sp3-contract-001") + + connection = _make_mock_connection() + msg = {"id": 1, "type": "span_panel/panel_topology", "device_id": device.id} + + await _handle_panel_topology_inner(hass, connection, msg) + + result = connection.send_result.call_args[0][1] + + assert set(result["circuits"]["uuid_kitchen"]) == { + "tabs", + "name", + "voltage", + "device_type", + "relay_state", + "relay_state_target", + "is_user_controllable", + "breaker_rating_a", + "always_on", + "priority", + "priority_target", + "is_never_backup", + "entities", + } + + @pytest.mark.asyncio + async def test_topology_reports_priority_settability_apart_from_the_relay( + self, hass: HomeAssistant + ): + """`is_never_backup` is carried, and is independent of the relay flag. + + The two are separate commissioning flags: a never-backup circuit has a + working relay and a priority the panel pins, and a relay-locked circuit + may have a priority it will happily accept. No entity is created for a + circuit whose priority is pinned, so this record is the only thing that + can tell a consumer the difference between a pinned priority and an + absent one. + """ + pinned_priority = SpanCircuitSnapshotFactory.create( + circuit_id="uuid_pinned", + name="Well Pump", + tabs=[11], + is_user_controllable=True, + is_never_backup=True, + priority="OFF_GRID", + ) + locked_relay = SpanCircuitSnapshotFactory.create( + circuit_id="uuid_locked", + name="Networking", + tabs=[13], + is_user_controllable=False, + is_never_backup=False, + priority="NEVER", + ) + snapshot = SpanPanelSnapshotFactory.create( + serial_number="sp3-backup-001", + circuits={"uuid_pinned": pinned_priority, "uuid_locked": locked_relay}, + ) + + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + entry_id="span_entry", + unique_id="sp3-backup-001", + ) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=_make_coordinator(snapshot), + panel_device_id="panel-device-id", + ) + + device = _register_panel_device(hass, "span_entry", serial="sp3-backup-001") + + connection = _make_mock_connection() + msg = {"id": 1, "type": "span_panel/panel_topology", "device_id": device.id} + + await _handle_panel_topology_inner(hass, connection, msg) + + circuits = connection.send_result.call_args[0][1]["circuits"] + + pinned = circuits["uuid_pinned"] + assert pinned["is_never_backup"] is True + assert pinned["is_user_controllable"] is True + assert pinned["priority"] == "OFF_GRID" + + locked = circuits["uuid_locked"] + assert locked["is_never_backup"] is False + assert locked["is_user_controllable"] is False + @pytest.mark.asyncio async def test_topology_includes_panel_status_entity(self, hass: HomeAssistant): """panel_status binary sensor entity_id is included in the topology panel_entities map.""" From 1f9db685b6515b0b1d6efe552d3fe155bd34abaa Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:21:44 -0700 Subject: [PATCH 119/345] security: stop persisting the panel passphrase, add rotate_credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent pieces of the security-hardening work that do not depend on span-panel-api 3.1.0. **The panel passphrase is no longer stored.** It is an input to registration and nothing afterwards reads it back — the only read anywhere was diagnostics redacting it. A stored copy buys nothing and can mint fresh panel credentials at any time, so it is no longer written to the config entry, and a v6→v7 migration pops it from entries that already carry one. A reauth on an unmigrated entry drops it too, and the flow no longer keeps a copy in memory for the life of the flow. Both version constants move to 7. `CURRENT_CONFIG_VERSION` drives the migration body, but it is `SpanPanelConfigFlow.VERSION` that Home Assistant compares against `entry.version` to decide whether to call `async_migrate_entry` at all; bumping only the first would leave the migration as dead code that a hand-built v6 entry would still appear to exercise. A test goes through `async_setup` so the two cannot drift apart. `CONF_HOP_PASSPHRASE` stays in `const.py` and in the diagnostics redaction tuple — it is the key the migration pops, and an unmigrated entry must still be redacted. **New action `span_panel.rotate_credentials`** asks the panel for a fresh eBus MQTT broker password, stores it, and reloads the entry, for use after a contractor visit or a suspected exposure. The previous broker password stops working immediately, so every other local client using it has to be re-provisioned; the description says so, because a user who runs this without knowing will break their own tooling and have no idea why. Failure paths leave the existing credentials untouched. The admin check is explicit rather than `verify_domain_control`, which is not an admin gate: it returns early for a call with no `user_id`, and otherwise checks `POLICY_CONTROL`, which the default user policy grants every non-admin. A contextless call is refused outright — an unattended automation has no business rotating credentials. **Documentation**: a README security section covering what the integration stores, what rotation costs, and the deployment choices that actually bound the panel's exposure — VLAN isolation, a locked enclosure, read-only Home Assistant users, encrypted backups. Plus a troubleshooting entry and release note for the one-way nature of the version bump: once an entry reaches version 7, an older build refuses to load it. --- CHANGELOG.md | 13 + README.md | 76 +++++- custom_components/span_panel/__init__.py | 2 + custom_components/span_panel/config_flow.py | 22 +- custom_components/span_panel/diagnostics.py | 2 + custom_components/span_panel/migrations.py | 26 +- custom_components/span_panel/services.py | 134 +++++++++- custom_components/span_panel/services.yaml | 7 + custom_components/span_panel/strings.json | 28 ++ .../span_panel/translations/en.json | 28 ++ .../span_panel/translations/es.json | 28 ++ .../span_panel/translations/fr.json | 28 ++ .../span_panel/translations/ja.json | 28 ++ .../span_panel/translations/pt.json | 28 ++ tests/test_diagnostics.py | 3 + tests/test_rotate_credentials_service.py | 239 ++++++++++++++++++ tests/test_v2_config_flow.py | 83 +++++- 17 files changed, 747 insertions(+), 28 deletions(-) create mode 100644 tests/test_rotate_credentials_service.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b659a79..cf69c61b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -132,6 +132,19 @@ working entirely in 2027.8. - **The README described Battery Power's sign backwards.** The sensor reports **discharging** as positive and always has, as release 2.0.5 established (#184) and a measured panel confirms — **no entity changed and no reading moved**, only the documentation was ever wrong. +### Security + +- **The panel passphrase is no longer stored.** It is an input to registration and nothing afterwards read it, but a stored copy could mint fresh panel + credentials at any time. Upgrading migrates the config entry to version 7 and removes it; nothing else in the entry changes and no entity is affected. + Reauthenticating removes it too, if you upgrade later. **Once an entry reaches version 7, an older build of the integration will refuse to load it** — Home + Assistant does not downgrade config entries, so roll back from a backup rather than by reinstalling. +- **New action `span_panel.rotate_credentials`** asks the panel for a new eBus MQTT broker password, stores it, and reloads the integration — for use after a + contractor visit or a suspected exposure. It is restricted to Home Assistant administrators and cannot be called from an automation or script. **The previous + broker password stops working immediately**, so any other local client using it must be re-provisioned from the panel. The panel access token and the panel + passphrase are unchanged, and a failed rotation leaves the existing credentials in place. +- **A new [Security](README.md#security) section in the README** covers what the integration stores, what rotation costs, and the deployment choices that + actually bound the panel's exposure — VLAN isolation, a locked enclosure, read-only Home Assistant users, and encrypted backups. + ## [2.0.8] - 5/2026 ### Fixed diff --git a/README.md b/README.md index e239c50c..50152bfd 100644 --- a/README.md +++ b/README.md @@ -633,6 +633,65 @@ precision 2 (e.g., `0.00`), except battery percentage which uses precision 0 (e. You can change the display precision for any entity via `Settings` > `Devices & Services` > `Entities` tab. Find the entity, click on it, click the gear wheel, and select your preferred precision from the "Display Precision" menu. +## Security + +### What the integration stores + +Setting up a v2 panel exchanges your panel passphrase for two long-lived secrets, which are kept in the config entry (`.storage/core.config_entries`): + +- the **eBus MQTT broker password**, which grants control of every relay, circuit priority, islanding state and EVSE limit on the panel and its sub-devices, and +- the **REST access token**, which grants panel management — client registration, FQDN registration and credential rotation. + +The **panel passphrase itself is no longer stored.** It is an input to registration and nothing afterwards reads it, but a stored copy could mint fresh +credentials at any time. Upgrading migrates the config entry to version 7 and removes it; nothing else in the entry changes and no entity is affected. If you +have not yet upgraded, reauthenticating also removes it. + +> **Downgrade note.** Once an entry has reached version 7, installing an older build of the integration will fail to set it up — Home Assistant refuses to load +> a config entry whose version is newer than the installed integration supports. Restore a backup taken before the upgrade if you need to roll back. + +These are the limits of what the integration can enforce. Anything that already holds the broker password — including another integration running in the same +Home Assistant process — talks to the panel directly and is not subject to Home Assistant's permission model at all. + +### Rotating panel credentials + +The `span_panel.rotate_credentials` action asks the panel for a new eBus MQTT broker password, stores it, and reloads the integration. + +```yaml +action: span_panel.rotate_credentials +data: {} +``` + +Run it after a contractor visit, a suspected credential exposure, or any event that put someone else in front of the panel. + +**Know the blast radius before you run it.** The previous broker password stops working the moment the panel issues the new one, so every other local client +using it — a second Home Assistant instance, a script, third-party tooling — must be re-provisioned from the panel before it will reconnect. This integration +re-provisions itself automatically; nothing else does. The panel access token and the panel passphrase are not changed. + +Only a Home Assistant administrator can run it, and it cannot be called from an automation or script: a call arriving without a logged-in user is refused +outright. If the panel rejects the stored access token, reauthenticate the integration first — the existing credentials are left untouched on every failure +path. + +### Recommended deployment + +The panel credential is a single all-or-nothing secret, and anyone standing at the panel can mint a fresh one with three presses of the door switch. Network +topology and physical control of the panel are the real boundary; everything above is defense in depth behind it. + +- **Put the panel on its own VLAN**, with default-deny between VLANs, and allow only the Home Assistant host to reach it. The integration currently uses + `tcp/80` for the REST bootstrap and `tcp/8883` for MQTTS. Deny `tcp/9001` and `tcp/9002` (plaintext and WebSocket MQTT) from every source unless you are + actively using the SPAN Home on-premise UI. +- **Use the IP address or an FQDN, not the `.local` name.** mDNS does not cross VLAN boundaries. The panel's IP is in its certificate SAN, so hostname + verification still works; for an FQDN, the integration registers it with the panel so the panel adds it to the SAN. +- **If Home Assistant cannot be on the panel's VLAN**, put a reverse proxy on the panel's VLAN and restrict its inbound to the Home Assistant host. The proxy + holds no panel credential; it only relays. Note that MQTTS is a TCP stream, not HTTP — a plain HTTP reverse proxy covers the REST port only, and `tcp/8883` + needs a stream proxy (HAProxy, nginx `stream`, or Caddy's `layer4` plugin). +- **Lock the panel enclosure.** The three-press proximity bypass hands out full credentials to anyone who can open the door; it is the equivalent of a printed + root password. This outranks every software control on this page. +- **Put dashboard-only household members in Home Assistant's read-only group.** Home Assistant's default user policy grants every non-admin user control of + every entity, which includes this integration's circuit switches and priority selects. +- **Encrypt your Home Assistant backups.** An unencrypted backup contains `.storage/core.config_entries`, and the panel credentials in it are in plaintext. +- **Enable multi-factor authentication on Home Assistant** and do not expose its API directly to the Internet. A compromised Home Assistant administrator + account is a compromised panel. + ## WebSocket API The integration provides a `span_panel/panel_topology` WebSocket command that returns the full physical layout of a panel in a single call — circuits with their @@ -642,14 +701,15 @@ See [WebSocket API Reference](websocket-api.md) for the full schema, response fo ## Troubleshooting -| Issue | Symptoms | Resolution | -| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Energy Dashboard spikes after firmware updates** | Huge energy-consumption spikes after panel firmware updates; charts showing untracked values that dwarf normal usage; negative energy values in statistics. Caused by the panel reporting decreased values on otherwise`TOTAL_INCREASING` sensors. | **Prevention:** enable [Energy Dip Compensation](#energy-dip-compensation) in General Options (on by default for new installs). **Fix existing spikes:** in **Developer Tools → Statistics**, search for the affected sensor (e.g. `sensor.span_panel_main_meter_consumed_energy`) and use **Adjust sum** to correct the errant entry. The integration also notifies when a decrease in the main meter consumed sensor is detected. | -| **High CPU usage** | Elevated CPU on low-power hardware (e.g. Raspberry Pi). The integration rebuilds a full panel snapshot from MQTT messages at a configurable interval (default 1 s). | Increase**Snapshot Update Interval** in **General Options**. 10–15 s is recommended for resource-constrained systems. Setting it to 0 disables debouncing and rebuilds on every MQTT message — not recommended. | -| **Replaced sub-device shows the old serial number** | After replacing a SPAN sub-device (Drive / EVSE, BESS, PV inverter), the device entry in Home Assistant keeps showing the previous hardware's serial number. The integration keys entities off the panel-assigned node identity, which is intentionally stable across hardware swaps so long-term history (e.g. lifetime charging kWh for a Drive) is preserved. The device-registry serial number, however, does not auto-refresh. | In**Settings → Devices & Services → Span Panel**, open the affected sub-device and delete it, then reload the integration (or restart Home Assistant). The device re-registers with the new serial number. Entity IDs and their recorded history are preserved. | -| **Door sensor unavailable** | The SPAN API returns UNKNOWN if the cabinet door has not been operated recently. This is a defect in the SPAN API. | The integration reports the sensor as unavailable until a proper value arrives. Opening or closing the door publishes the correct state. The door is classified as a tamper sensor (`Detected` / `Clear`) to differentiate it from a normal entry door. | -| **No switch on a circuit** | A circuit has no switch entity exposed in Home Assistant. | The circuit is configured in the SPAN App as one of the "Always on Circuits". The API does not permit user control of those circuits, so no switch is created. | -| **Reinstalling to change the entity ID style gives back the old entity IDs** | The naming style is chosen at install and cannot be changed from the options, so reinstalling looks like the way to switch. It is not: every entity returns with the entity ID it had before. | Home Assistant remembers a removed entity for **30 days**, keyed on its unique ID, and restores that record's entity ID — along with its name, area, labels and icon — as soon as an entity with the same unique ID appears again. This integration's unique IDs do not change with the naming style, so the remembered ID wins over the one the new style asks for. Either clear the leftover registry entries between removing and reinstalling, or wait out the 30 days and let Home Assistant discard them. A tool such as [ha-registry-clean](https://github.com/LegoTypes/ha-registry-clean) can do the clearing; it is a separate project, not part of this integration. Clearing also discards the names, areas and labels you had assigned. | +| Issue | Symptoms | Resolution | +| ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Energy Dashboard spikes after firmware updates** | Huge energy-consumption spikes after panel firmware updates; charts showing untracked values that dwarf normal usage; negative energy values in statistics. Caused by the panel reporting decreased values on otherwise`TOTAL_INCREASING` sensors. | **Prevention:** enable [Energy Dip Compensation](#energy-dip-compensation) in General Options (on by default for new installs). **Fix existing spikes:** in **Developer Tools → Statistics**, search for the affected sensor (e.g. `sensor.span_panel_main_meter_consumed_energy`) and use **Adjust sum** to correct the errant entry. The integration also notifies when a decrease in the main meter consumed sensor is detected. | +| **High CPU usage** | Elevated CPU on low-power hardware (e.g. Raspberry Pi). The integration rebuilds a full panel snapshot from MQTT messages at a configurable interval (default 1 s). | Increase**Snapshot Update Interval** in **General Options**. 10–15 s is recommended for resource-constrained systems. Setting it to 0 disables debouncing and rebuilds on every MQTT message — not recommended. | +| **Replaced sub-device shows the old serial number** | After replacing a SPAN sub-device (Drive / EVSE, BESS, PV inverter), the device entry in Home Assistant keeps showing the previous hardware's serial number. The integration keys entities off the panel-assigned node identity, which is intentionally stable across hardware swaps so long-term history (e.g. lifetime charging kWh for a Drive) is preserved. The device-registry serial number, however, does not auto-refresh. | In**Settings → Devices & Services → Span Panel**, open the affected sub-device and delete it, then reload the integration (or restart Home Assistant). The device re-registers with the new serial number. Entity IDs and their recorded history are preserved. | +| **Door sensor unavailable** | The SPAN API returns UNKNOWN if the cabinet door has not been operated recently. This is a defect in the SPAN API. | The integration reports the sensor as unavailable until a proper value arrives. Opening or closing the door publishes the correct state. The door is classified as a tamper sensor (`Detected` / `Clear`) to differentiate it from a normal entry door. | +| **No switch on a circuit** | A circuit has no switch entity exposed in Home Assistant. | The circuit is configured in the SPAN App as one of the "Always on Circuits". The API does not permit user control of those circuits, so no switch is created. | +| **Reinstalling to change the entity ID style gives back the old entity IDs** | The naming style is chosen at install and cannot be changed from the options, so reinstalling looks like the way to switch. It is not: every entity returns with the entity ID it had before. | Home Assistant remembers a removed entity for **30 days**, keyed on its unique ID, and restores that record's entity ID — along with its name, area, labels and icon — as soon as an entity with the same unique ID appears again. This integration's unique IDs do not change with the naming style, so the remembered ID wins over the one the new style asks for. Either clear the leftover registry entries between removing and reinstalling, or wait out the 30 days and let Home Assistant discard them. A tool such as [ha-registry-clean](https://github.com/LegoTypes/ha-registry-clean) can do the clearing; it is a separate project, not part of this integration. Clearing also discards the names, areas and labels you had assigned. | +| **Setup fails after downgrading the integration** | After installing an older release, the SPAN Panel config entry fails to set up and Home Assistant reports an unsupported configuration version. | The release that stopped storing the panel passphrase migrated the config entry to version 7. Home Assistant refuses to load a config entry whose version is newer than the installed integration understands, and there is no automatic downgrade. Reinstall the newer release, or restore a backup taken before the upgrade. Removing and re-adding the integration also works and preserves entity IDs, but needs the panel passphrase or physical access to the door again. | ## Development diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py index 09fd5d51..8a40ff4a 100644 --- a/custom_components/span_panel/__init__.py +++ b/custom_components/span_panel/__init__.py @@ -68,6 +68,7 @@ async_clear_schema_issues, ) from .services import ( # noqa: F401 + _async_register_credential_services, _async_register_favorites_services, _async_register_graph_horizon_services, _async_register_monitoring_services, @@ -121,6 +122,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: _async_register_monitoring_services(hass) _async_register_graph_horizon_services(hass) _async_register_favorites_services(hass) + _async_register_credential_services(hass) await async_apply_panel_registration(hass) diff --git a/custom_components/span_panel/config_flow.py b/custom_components/span_panel/config_flow.py index 75e149fd..bbf1a193 100644 --- a/custom_components/span_panel/config_flow.py +++ b/custom_components/span_panel/config_flow.py @@ -109,7 +109,7 @@ class TriggerFlowType(enum.Enum): class SpanPanelConfigFlow(config_entries.ConfigFlow): """Handle a config flow for Span Panel.""" - VERSION = 6 + VERSION = 7 MINOR_VERSION = 1 domain = DOMAIN @@ -136,7 +136,6 @@ def __init__(self) -> None: self._v2_broker_port: int | None = None self._v2_broker_username: str | None = None self._v2_broker_password: str | None = None - self._v2_passphrase: str | None = None self._v2_panel_serial: str | None = None self._http_port: int = 80 # Energy dip compensation default for fresh installs @@ -447,7 +446,7 @@ async def async_step_auth_proximity_confirm( except (SpanPanelAuthError, SpanPanelConnectionError): return await self.async_step_auth_proximity() - self._store_v2_auth_result(result, passphrase="") # nosec B106 + self._store_v2_auth_result(result) return await self._async_finalize_v2_auth() async def async_step_auth_passphrase( @@ -489,17 +488,21 @@ async def async_step_auth_passphrase( errors={"base": "cannot_connect"}, ) - self._store_v2_auth_result(result, passphrase) + self._store_v2_auth_result(result) return await self._async_finalize_v2_auth() - def _store_v2_auth_result(self, result: V2AuthResponse, passphrase: str) -> None: - """Store v2 auth credentials from registration result.""" + def _store_v2_auth_result(self, result: V2AuthResponse) -> None: + """Store v2 auth credentials from registration result. + + The passphrase that produced this result is deliberately not kept. It is + an input to registration and nothing afterwards reads it, so holding it + only widens what a flow in progress has in memory. + """ self.access_token = result.access_token self._v2_broker_host = result.ebus_broker_host self._v2_broker_port = result.ebus_broker_mqtts_port self._v2_broker_username = result.ebus_broker_username self._v2_broker_password = result.ebus_broker_password - self._v2_passphrase = passphrase self._v2_panel_serial = result.serial_number async def _async_finalize_v2_auth(self) -> ConfigFlowResult: @@ -610,7 +613,6 @@ def create_new_entry( CONF_EBUS_BROKER_PORT: self._v2_broker_port, CONF_EBUS_BROKER_USERNAME: self._v2_broker_username, CONF_EBUS_BROKER_PASSWORD: self._v2_broker_password, - CONF_HOP_PASSPHRASE: self._v2_passphrase, CONF_PANEL_SERIAL: self._v2_panel_serial, } @@ -645,8 +647,10 @@ def _update_v2_entry(self, entry_id: str) -> ConfigFlowResult: updated_data[CONF_EBUS_BROKER_PORT] = self._v2_broker_port updated_data[CONF_EBUS_BROKER_USERNAME] = self._v2_broker_username updated_data[CONF_EBUS_BROKER_PASSWORD] = self._v2_broker_password - updated_data[CONF_HOP_PASSPHRASE] = self._v2_passphrase updated_data[CONF_PANEL_SERIAL] = self._v2_panel_serial + # A reauth on an entry that predates v7 is also the moment to drop the + # passphrase it still carries; the migration only sees entries at setup. + updated_data.pop(CONF_HOP_PASSPHRASE, None) if self._http_port != 80: updated_data[CONF_HTTP_PORT] = self._http_port diff --git a/custom_components/span_panel/diagnostics.py b/custom_components/span_panel/diagnostics.py index 51057836..078f38e3 100644 --- a/custom_components/span_panel/diagnostics.py +++ b/custom_components/span_panel/diagnostics.py @@ -23,6 +23,8 @@ CONF_ACCESS_TOKEN, CONF_EBUS_BROKER_PASSWORD, CONF_EBUS_BROKER_USERNAME, + # No longer persisted as of entry version 7, but an entry that has not yet + # been through the migration still carries it. CONF_HOP_PASSPHRASE, "password", "username", diff --git a/custom_components/span_panel/migrations.py b/custom_components/span_panel/migrations.py index 2e6f61e6..872ab194 100644 --- a/custom_components/span_panel/migrations.py +++ b/custom_components/span_panel/migrations.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .const import CONF_API_VERSION +from .const import CONF_API_VERSION, CONF_HOP_PASSPHRASE if TYPE_CHECKING: from . import SpanPanelConfigEntry @@ -16,14 +16,14 @@ _LOGGER = logging.getLogger(__name__) # Must match the storage version produced by the latest supported entry format. -CURRENT_CONFIG_VERSION = 6 +CURRENT_CONFIG_VERSION = 7 async def async_migrate_entry(hass: HomeAssistant, config_entry: SpanPanelConfigEntry) -> bool: """Migrate config entry through successive versions. Supports upgrades from v1.3.1+ (config version 2) through to the - current version 6. Each step mutates only the fields relevant to + current version 7. Each step mutates only the fields relevant to that version boundary. """ if config_entry.version >= CURRENT_CONFIG_VERSION: @@ -124,4 +124,24 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: SpanPanelConfig ) _LOGGER.debug("Migrated config entry %s to version 6", config_entry.entry_id) + # --- v6 → v7: drop the stored panel passphrase --- + if config_entry.version < 7: + updated_data = dict(config_entry.data) + # The passphrase is a registration input only — nothing at runtime reads + # it back. Holding it in `.storage` bought nothing and cost a credential + # that re-registers any client against the panel. + removed = updated_data.pop(CONF_HOP_PASSPHRASE, None) is not None + + hass.config_entries.async_update_entry( + config_entry, + data=updated_data, + version=7, + ) + if removed: + _LOGGER.info( + "Removed the stored panel passphrase from config entry %s", + config_entry.entry_id, + ) + _LOGGER.debug("Migrated config entry %s to version 7", config_entry.entry_id) + return True diff --git a/custom_components/span_panel/services.py b/custom_components/span_panel/services.py index 24581d91..fa65db63 100644 --- a/custom_components/span_panel/services.py +++ b/custom_components/span_panel/services.py @@ -5,13 +5,28 @@ import logging from typing import TYPE_CHECKING, cast -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, SupportsResponse from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.httpx_client import get_async_client +from span_panel_api import regenerate_passphrase +from span_panel_api.exceptions import ( + SpanPanelAPIError, + SpanPanelAuthError, + SpanPanelConnectionError, + SpanPanelTimeoutError, +) import voluptuous as vol -from .const import DEFAULT_GRAPH_HORIZON, DOMAIN, VALID_GRAPH_HORIZONS +from .const import ( + CONF_API_VERSION, + CONF_EBUS_BROKER_PASSWORD, + CONF_HTTP_PORT, + DEFAULT_GRAPH_HORIZON, + DOMAIN, + VALID_GRAPH_HORIZONS, +) from .current_monitor import CurrentMonitor from .frontend import FavoriteKind, async_get_favorites, async_set_favorite from .graph_horizon import GraphHorizonManager @@ -644,3 +659,118 @@ async def async_handle_remove_favorite(call: ServiceCall) -> ServiceResponse: schema=_favorite_mutation_schema, supports_response=SupportsResponse.OPTIONAL, ) + + +async def _async_require_admin_caller(hass: HomeAssistant, call: ServiceCall) -> None: + """Refuse a service call that does not come from a logged-in administrator. + + `verify_domain_control` is deliberately not used here. It returns early for + a call with no `user_id` — every automation, script and integration — and + otherwise checks `POLICY_CONTROL`, which Home Assistant's default user + policy grants to non-admins. Neither is an administrator check. + + A contextless call is refused outright rather than being treated as + trusted: an unattended automation has no business rotating credentials. + """ + user_id = call.context.user_id + if user_id is None: + raise ServiceValidationError( + "Credential rotation must be run by an administrator from the " + "user interface, not from an automation or script.", + translation_domain=DOMAIN, + translation_key="rotate_credentials_requires_user", + ) + + user = await hass.auth.async_get_user(user_id) + if user is None or not user.is_admin: + raise ServiceValidationError( + "Only a Home Assistant administrator can rotate SPAN Panel credentials.", + translation_domain=DOMAIN, + translation_key="rotate_credentials_requires_admin", + ) + + +def _async_register_credential_services(hass: HomeAssistant) -> None: + """Register credential-rotation services.""" + + def _get_v2_entry(config_entry_id: str | None) -> ConfigEntry: + """Return the loaded v2 entry to rotate, or explain why there isn't one.""" + from . import SpanPanelRuntimeData # pylint: disable=import-outside-toplevel + + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + if config_entry_id is not None and entry.entry_id != config_entry_id: + continue + if not hasattr(entry, "runtime_data") or not isinstance( + entry.runtime_data, SpanPanelRuntimeData + ): + continue + if entry.data.get(CONF_API_VERSION) != "v2": + continue + return entry + + raise ServiceValidationError( + "No loaded SPAN panel using the v2 API was found.", + translation_domain=DOMAIN, + translation_key="rotate_credentials_no_entry", + ) + + async def async_handle_rotate_credentials(call: ServiceCall) -> None: + await _async_require_admin_caller(hass, call) + + entry = _get_v2_entry(call.data.get("config_entry_id")) + host = str(entry.data[CONF_HOST]) + token = str(entry.data.get(CONF_ACCESS_TOKEN, "")) + if not token: + raise ServiceValidationError( + "This SPAN panel has no stored access token. Reauthenticate before rotating.", + translation_domain=DOMAIN, + translation_key="rotate_credentials_no_token", + ) + + try: + new_password = await regenerate_passphrase( + host, + token, + port=int(entry.data.get(CONF_HTTP_PORT, 80)), + httpx_client=get_async_client(hass, verify_ssl=False), + ) + except SpanPanelAuthError as err: + raise ServiceValidationError( + "The stored access token was rejected by the panel. Reauthenticate first.", + translation_domain=DOMAIN, + translation_key="rotate_credentials_auth_failed", + ) from err + except ( + SpanPanelConnectionError, + SpanPanelTimeoutError, + SpanPanelAPIError, + ) as err: + # Nothing has been written yet, so the entry still holds the + # credential the panel still accepts. + raise ServiceValidationError( + f"The SPAN panel at {host} did not complete the rotation.", + translation_domain=DOMAIN, + translation_key="rotate_credentials_failed", + translation_placeholders={"host": host}, + ) from err + + updated_data = dict(entry.data) + updated_data[CONF_EBUS_BROKER_PASSWORD] = new_password + hass.config_entries.async_update_entry(entry, data=updated_data) + + # Awaited rather than fired and forgotten: the running client still + # holds the password the panel just invalidated, so the call should not + # report success until the entry is back up on the new one. + await hass.config_entries.async_reload(entry.entry_id) + + _LOGGER.info( + "Rotated the MQTT broker credential for SPAN panel entry %s", + entry.entry_id, + ) + + hass.services.async_register( + DOMAIN, + "rotate_credentials", + async_handle_rotate_credentials, + schema=vol.Schema({vol.Optional("config_entry_id"): str}), + ) diff --git a/custom_components/span_panel/services.yaml b/custom_components/span_panel/services.yaml index 530b9fcb..03412477 100644 --- a/custom_components/span_panel/services.yaml +++ b/custom_components/span_panel/services.yaml @@ -283,3 +283,10 @@ remove_favorite: entity: domain: sensor integration: span_panel + +rotate_credentials: + fields: + config_entry_id: + selector: + config_entry: + integration: span_panel diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index 34629521..25279a7e 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -446,6 +446,24 @@ }, "panel_not_ready": { "message": "The SPAN Panel is not ready. Please check that the panel is online." + }, + "rotate_credentials_auth_failed": { + "message": "The SPAN Panel rejected the stored access token. Reauthenticate the integration before rotating credentials." + }, + "rotate_credentials_failed": { + "message": "The SPAN Panel at {host} did not complete the credential rotation. The existing credentials are unchanged." + }, + "rotate_credentials_no_entry": { + "message": "No loaded SPAN panel using the v2 API was found." + }, + "rotate_credentials_no_token": { + "message": "This SPAN panel has no stored access token. Reauthenticate the integration before rotating credentials." + }, + "rotate_credentials_requires_admin": { + "message": "Only a Home Assistant administrator can rotate SPAN Panel credentials." + }, + "rotate_credentials_requires_user": { + "message": "Credential rotation must be run by an administrator from the user interface. Automations, scripts and other integrations cannot call it." } }, "options": { @@ -762,6 +780,16 @@ "description": "Any sensor of the circuit or sub-device to remove from favorites." } } + }, + "rotate_credentials": { + "name": "Rotate panel credentials", + "description": "Ask the SPAN panel for a new eBus MQTT broker password, store it, and reload the integration. The previous broker password stops working immediately, so any other local client using it — a second Home Assistant instance, a script, or third-party tooling — must be re-provisioned from the panel before it will reconnect. The panel access token and the panel passphrase are not changed. Only a Home Assistant administrator can run this, and it cannot be called from an automation or script.", + "fields": { + "config_entry_id": { + "name": "Config entry", + "description": "Optional config entry id. Only needed when more than one SPAN panel is configured." + } + } } }, "issues": { diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index 34629521..25279a7e 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -446,6 +446,24 @@ }, "panel_not_ready": { "message": "The SPAN Panel is not ready. Please check that the panel is online." + }, + "rotate_credentials_auth_failed": { + "message": "The SPAN Panel rejected the stored access token. Reauthenticate the integration before rotating credentials." + }, + "rotate_credentials_failed": { + "message": "The SPAN Panel at {host} did not complete the credential rotation. The existing credentials are unchanged." + }, + "rotate_credentials_no_entry": { + "message": "No loaded SPAN panel using the v2 API was found." + }, + "rotate_credentials_no_token": { + "message": "This SPAN panel has no stored access token. Reauthenticate the integration before rotating credentials." + }, + "rotate_credentials_requires_admin": { + "message": "Only a Home Assistant administrator can rotate SPAN Panel credentials." + }, + "rotate_credentials_requires_user": { + "message": "Credential rotation must be run by an administrator from the user interface. Automations, scripts and other integrations cannot call it." } }, "options": { @@ -762,6 +780,16 @@ "description": "Any sensor of the circuit or sub-device to remove from favorites." } } + }, + "rotate_credentials": { + "name": "Rotate panel credentials", + "description": "Ask the SPAN panel for a new eBus MQTT broker password, store it, and reload the integration. The previous broker password stops working immediately, so any other local client using it — a second Home Assistant instance, a script, or third-party tooling — must be re-provisioned from the panel before it will reconnect. The panel access token and the panel passphrase are not changed. Only a Home Assistant administrator can run this, and it cannot be called from an automation or script.", + "fields": { + "config_entry_id": { + "name": "Config entry", + "description": "Optional config entry id. Only needed when more than one SPAN panel is configured." + } + } } }, "issues": { diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index dba3f3ef..29dff13e 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -446,6 +446,24 @@ }, "panel_not_ready": { "message": "El SPAN Panel no está listo. Por favor verifique que el panel esté en línea." + }, + "rotate_credentials_auth_failed": { + "message": "El SPAN Panel rechazó el token de acceso almacenado. Vuelva a autenticar la integración antes de rotar las credenciales." + }, + "rotate_credentials_failed": { + "message": "El SPAN Panel en {host} no completó la rotación de credenciales. Las credenciales existentes no han cambiado." + }, + "rotate_credentials_no_entry": { + "message": "No se encontró ningún SPAN Panel cargado que use la API v2." + }, + "rotate_credentials_no_token": { + "message": "Este SPAN Panel no tiene un token de acceso almacenado. Vuelva a autenticar la integración antes de rotar las credenciales." + }, + "rotate_credentials_requires_admin": { + "message": "Solo un administrador de Home Assistant puede rotar las credenciales del SPAN Panel." + }, + "rotate_credentials_requires_user": { + "message": "La rotación de credenciales debe ejecutarla un administrador desde la interfaz de usuario. Las automatizaciones, los scripts y otras integraciones no pueden llamarla." } }, "options": { @@ -762,6 +780,16 @@ "description": "Cualquier sensor del circuito o subdispositivo a eliminar de favoritos." } } + }, + "rotate_credentials": { + "name": "Rotar las credenciales del panel", + "description": "Pide al SPAN Panel una nueva contraseña del broker MQTT eBus, la guarda y recarga la integración. La contraseña anterior deja de funcionar de inmediato, por lo que cualquier otro cliente local que la utilice — una segunda instancia de Home Assistant, un script o herramientas de terceros — deberá volver a aprovisionarse desde el panel antes de poder reconectarse. El token de acceso del panel y la contraseña del panel no cambian. Solo un administrador de Home Assistant puede ejecutarlo, y no se puede llamar desde una automatización o un script.", + "fields": { + "config_entry_id": { + "name": "Entrada de configuración", + "description": "Id de entrada de configuración opcional. Solo es necesario cuando hay más de un SPAN Panel configurado." + } + } } }, "issues": { diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index afb0e5e8..8b2e1278 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -446,6 +446,24 @@ }, "panel_not_ready": { "message": "Le SPAN Panel n'est pas prêt. Veuillez vérifier que le panneau est en ligne." + }, + "rotate_credentials_auth_failed": { + "message": "Le SPAN Panel a rejeté le jeton d'accès enregistré. Réauthentifiez l'intégration avant de renouveler les identifiants." + }, + "rotate_credentials_failed": { + "message": "Le SPAN Panel à l'adresse {host} n'a pas terminé le renouvellement des identifiants. Les identifiants existants sont inchangés." + }, + "rotate_credentials_no_entry": { + "message": "Aucun SPAN Panel chargé utilisant l'API v2 n'a été trouvé." + }, + "rotate_credentials_no_token": { + "message": "Ce SPAN Panel n'a aucun jeton d'accès enregistré. Réauthentifiez l'intégration avant de renouveler les identifiants." + }, + "rotate_credentials_requires_admin": { + "message": "Seul un administrateur Home Assistant peut renouveler les identifiants du SPAN Panel." + }, + "rotate_credentials_requires_user": { + "message": "Le renouvellement des identifiants doit être lancé par un administrateur depuis l'interface utilisateur. Les automatisations, les scripts et les autres intégrations ne peuvent pas l'appeler." } }, "options": { @@ -762,6 +780,16 @@ "description": "N'importe quel capteur du circuit ou du sous-appareil à retirer des favoris." } } + }, + "rotate_credentials": { + "name": "Renouveler les identifiants du panneau", + "description": "Demande au SPAN Panel un nouveau mot de passe pour le broker MQTT eBus, l'enregistre et recharge l'intégration. Le mot de passe précédent cesse immédiatement de fonctionner : tout autre client local qui l'utilise — une seconde instance Home Assistant, un script ou un outil tiers — devra être réapprovisionné depuis le panneau avant de pouvoir se reconnecter. Le jeton d'accès du panneau et le mot de passe du panneau ne sont pas modifiés. Seul un administrateur Home Assistant peut l'exécuter, et il ne peut pas être appelé depuis une automatisation ou un script.", + "fields": { + "config_entry_id": { + "name": "Entrée de configuration", + "description": "Id d'entrée de configuration facultatif. Nécessaire uniquement lorsque plusieurs SPAN Panel sont configurés." + } + } } }, "issues": { diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index 95fe751e..69e7a5ed 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -446,6 +446,24 @@ }, "panel_not_ready": { "message": "SPAN Panelの準備ができていません。パネルがオンラインであることを確認してください。" + }, + "rotate_credentials_auth_failed": { + "message": "SPAN Panel が保存済みのアクセストークンを拒否しました。認証情報をローテーションする前に、統合を再認証してください。" + }, + "rotate_credentials_failed": { + "message": "{host} の SPAN Panel が認証情報のローテーションを完了しませんでした。既存の認証情報は変更されていません。" + }, + "rotate_credentials_no_entry": { + "message": "v2 API を使用する読み込み済みの SPAN Panel が見つかりません。" + }, + "rotate_credentials_no_token": { + "message": "この SPAN Panel には保存済みのアクセストークンがありません。認証情報をローテーションする前に、統合を再認証してください。" + }, + "rotate_credentials_requires_admin": { + "message": "SPAN Panel の認証情報をローテーションできるのは Home Assistant の管理者だけです。" + }, + "rotate_credentials_requires_user": { + "message": "認証情報のローテーションは、管理者がユーザーインターフェースから実行する必要があります。オートメーション、スクリプト、他の統合からは呼び出せません。" } }, "options": { @@ -762,6 +780,16 @@ "description": "お気に入りから削除する回路またはサブデバイスの任意のセンサー。" } } + }, + "rotate_credentials": { + "name": "パネルの認証情報をローテーション", + "description": "SPAN Panel に新しい eBus MQTT ブローカーのパスワードを要求し、保存して統合を再読み込みします。以前のブローカーのパスワードは直ちに無効になるため、それを使用している他のローカルクライアント(2 台目の Home Assistant、スクリプト、サードパーティのツールなど)は、再接続する前にパネルから再プロビジョニングする必要があります。パネルのアクセストークンとパネルのパスフレーズは変更されません。実行できるのは Home Assistant の管理者だけで、オートメーションやスクリプトからは呼び出せません。", + "fields": { + "config_entry_id": { + "name": "設定エントリ", + "description": "任意の設定エントリ id。SPAN Panel を複数設定している場合にのみ必要です。" + } + } } }, "issues": { diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index af2cc18a..c42a2998 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -446,6 +446,24 @@ }, "panel_not_ready": { "message": "O SPAN Panel não está pronto. Verifique se o painel está online." + }, + "rotate_credentials_auth_failed": { + "message": "O SPAN Panel rejeitou o token de acesso guardado. Volte a autenticar a integração antes de rodar as credenciais." + }, + "rotate_credentials_failed": { + "message": "O SPAN Panel em {host} não concluiu a rotação de credenciais. As credenciais existentes mantêm-se inalteradas." + }, + "rotate_credentials_no_entry": { + "message": "Não foi encontrado nenhum SPAN Panel carregado que utilize a API v2." + }, + "rotate_credentials_no_token": { + "message": "Este SPAN Panel não tem um token de acesso guardado. Volte a autenticar a integração antes de rodar as credenciais." + }, + "rotate_credentials_requires_admin": { + "message": "Apenas um administrador do Home Assistant pode rodar as credenciais do SPAN Panel." + }, + "rotate_credentials_requires_user": { + "message": "A rotação de credenciais tem de ser executada por um administrador a partir da interface de utilizador. Automações, scripts e outras integrações não a podem invocar." } }, "options": { @@ -762,6 +780,16 @@ "description": "Qualquer sensor do circuito ou sub-dispositivo a remover dos favoritos." } } + }, + "rotate_credentials": { + "name": "Rodar as credenciais do painel", + "description": "Pede ao SPAN Panel uma nova palavra-passe do broker MQTT eBus, guarda-a e recarrega a integração. A palavra-passe anterior deixa de funcionar imediatamente, pelo que qualquer outro cliente local que a utilize — uma segunda instância do Home Assistant, um script ou ferramentas de terceiros — terá de ser reaprovisionado a partir do painel antes de conseguir voltar a ligar-se. O token de acesso do painel e a frase-passe do painel não são alterados. Só um administrador do Home Assistant o pode executar, e não pode ser invocado a partir de uma automação ou script.", + "fields": { + "config_entry_id": { + "name": "Entrada de configuração", + "description": "Id da entrada de configuração, opcional. Apenas necessário quando existe mais do que um SPAN Panel configurado." + } + } } }, "issues": { diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index cda1abc1..812840d3 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -70,8 +70,11 @@ async def test_config_entry_diagnostics_includes_redacted_runtime_data( # than as the "no metadata yet" state it actually is. coordinator.schema_findings = 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. entry = MockConfigEntry( domain=DOMAIN, + version=6, data={ CONF_ACCESS_TOKEN: "access-secret", CONF_EBUS_BROKER_PASSWORD: "mqtt-password", diff --git a/tests/test_rotate_credentials_service.py b/tests/test_rotate_credentials_service.py new file mode 100644 index 00000000..88387a20 --- /dev/null +++ b/tests/test_rotate_credentials_service.py @@ -0,0 +1,239 @@ +"""Tests for the rotate_credentials service.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from span_panel_api.exceptions import SpanPanelAuthError, SpanPanelConnectionError + +from custom_components.span_panel import ( + SpanPanelRuntimeData, + _async_register_credential_services, +) +from custom_components.span_panel.const import ( + CONF_API_VERSION, + CONF_EBUS_BROKER_HOST, + CONF_EBUS_BROKER_PASSWORD, + CONF_EBUS_BROKER_PORT, + CONF_EBUS_BROKER_USERNAME, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from pytest_homeassistant_custom_component.common import MockConfigEntry, MockUser + +OLD_BROKER_PASSWORD = "old-broker-password" +NEW_BROKER_PASSWORD = "new-broker-password" + + +def _add_v2_entry(hass: HomeAssistant) -> MockConfigEntry: + """Add a loaded v2 entry with runtime data attached.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=7, + data={ + CONF_HOST: "192.168.1.100", + CONF_ACCESS_TOKEN: "panel-access-token", + CONF_API_VERSION: "v2", + CONF_EBUS_BROKER_HOST: "192.168.1.100", + CONF_EBUS_BROKER_PORT: 8883, + CONF_EBUS_BROKER_USERNAME: "span-user", + CONF_EBUS_BROKER_PASSWORD: OLD_BROKER_PASSWORD, + }, + entry_id="span_entry", + unique_id="sp3-test-001", + ) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=MagicMock(), + panel_device_id="panel-device-id", + ) + return entry + + +def _admin_context(hass: HomeAssistant) -> Context: + """Return a Context belonging to an administrator.""" + user = MockUser(is_owner=True).add_to_hass(hass) + return Context(user_id=user.id) + + +def _non_admin_context(hass: HomeAssistant) -> Context: + """Return a Context belonging to a non-administrator.""" + user = MockUser().add_to_hass(hass) + return Context(user_id=user.id) + + +async def _call_rotate(hass: HomeAssistant, context: Context | None) -> None: + """Call the service, blocking so exceptions propagate.""" + await hass.services.async_call( + DOMAIN, + "rotate_credentials", + {}, + blocking=True, + context=context, + ) + + +@pytest.mark.asyncio +async def test_admin_rotation_stores_the_new_password_and_reloads( + hass: HomeAssistant, +) -> None: + """An administrator gets the new broker password persisted and the entry reloaded.""" + entry = _add_v2_entry(hass) + _async_register_credential_services(hass) + + reload_mock = AsyncMock(return_value=True) + with ( + patch( + "custom_components.span_panel.services.regenerate_passphrase", + AsyncMock(return_value=NEW_BROKER_PASSWORD), + ) as rotate, + patch.object(hass.config_entries, "async_reload", reload_mock), + ): + await _call_rotate(hass, _admin_context(hass)) + + assert rotate.await_count == 1 + assert rotate.await_args.args[0] == "192.168.1.100" + assert rotate.await_args.args[1] == "panel-access-token" + assert entry.data[CONF_EBUS_BROKER_PASSWORD] == NEW_BROKER_PASSWORD + reload_mock.assert_awaited_once_with(entry.entry_id) + + +@pytest.mark.asyncio +async def test_non_admin_is_refused(hass: HomeAssistant) -> None: + """A logged-in non-admin cannot rotate, and nothing reaches the panel.""" + entry = _add_v2_entry(hass) + _async_register_credential_services(hass) + + with patch( + "custom_components.span_panel.services.regenerate_passphrase", + AsyncMock(return_value=NEW_BROKER_PASSWORD), + ) as rotate: + with pytest.raises(ServiceValidationError) as err: + await _call_rotate(hass, _non_admin_context(hass)) + + assert err.value.translation_key == "rotate_credentials_requires_admin" + rotate.assert_not_awaited() + assert entry.data[CONF_EBUS_BROKER_PASSWORD] == OLD_BROKER_PASSWORD + + +@pytest.mark.asyncio +async def test_contextless_call_is_refused(hass: HomeAssistant) -> None: + """An automation, script or integration has no user and is refused outright.""" + entry = _add_v2_entry(hass) + _async_register_credential_services(hass) + + with patch( + "custom_components.span_panel.services.regenerate_passphrase", + AsyncMock(return_value=NEW_BROKER_PASSWORD), + ) as rotate: + with pytest.raises(ServiceValidationError) as err: + await _call_rotate(hass, None) + + assert err.value.translation_key == "rotate_credentials_requires_user" + rotate.assert_not_awaited() + assert entry.data[CONF_EBUS_BROKER_PASSWORD] == OLD_BROKER_PASSWORD + + +@pytest.mark.asyncio +async def test_a_deleted_user_is_refused(hass: HomeAssistant) -> None: + """A user_id that no longer resolves is not an administrator.""" + _add_v2_entry(hass) + _async_register_credential_services(hass) + + with pytest.raises(ServiceValidationError) as err: + await _call_rotate(hass, Context(user_id="user-who-no-longer-exists")) + + assert err.value.translation_key == "rotate_credentials_requires_admin" + + +@pytest.mark.asyncio +async def test_connection_failure_leaves_the_old_password_in_place( + hass: HomeAssistant, +) -> None: + """A panel that never answers must not cost the credential that still works.""" + entry = _add_v2_entry(hass) + _async_register_credential_services(hass) + + reload_mock = AsyncMock(return_value=True) + with ( + patch( + "custom_components.span_panel.services.regenerate_passphrase", + AsyncMock(side_effect=SpanPanelConnectionError("unreachable")), + ), + patch.object(hass.config_entries, "async_reload", reload_mock), + ): + with pytest.raises(ServiceValidationError) as err: + await _call_rotate(hass, _admin_context(hass)) + + assert err.value.translation_key == "rotate_credentials_failed" + assert entry.data[CONF_EBUS_BROKER_PASSWORD] == OLD_BROKER_PASSWORD + reload_mock.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_rejected_token_asks_for_reauthentication(hass: HomeAssistant) -> None: + """A stale access token gets its own message rather than a generic failure.""" + entry = _add_v2_entry(hass) + _async_register_credential_services(hass) + + with patch( + "custom_components.span_panel.services.regenerate_passphrase", + AsyncMock(side_effect=SpanPanelAuthError("401")), + ): + with pytest.raises(ServiceValidationError) as err: + await _call_rotate(hass, _admin_context(hass)) + + assert err.value.translation_key == "rotate_credentials_auth_failed" + assert entry.data[CONF_EBUS_BROKER_PASSWORD] == OLD_BROKER_PASSWORD + + +@pytest.mark.asyncio +async def test_missing_access_token_is_reported_before_any_call( + hass: HomeAssistant, +) -> None: + """An entry with no token cannot authenticate the rotation.""" + entry = _add_v2_entry(hass) + data = dict(entry.data) + del data[CONF_ACCESS_TOKEN] + hass.config_entries.async_update_entry(entry, data=data) + _async_register_credential_services(hass) + + with patch( + "custom_components.span_panel.services.regenerate_passphrase", + AsyncMock(return_value=NEW_BROKER_PASSWORD), + ) as rotate: + with pytest.raises(ServiceValidationError) as err: + await _call_rotate(hass, _admin_context(hass)) + + assert err.value.translation_key == "rotate_credentials_no_token" + rotate.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_no_v2_entry_is_reported(hass: HomeAssistant) -> None: + """Only v2 entries have a broker credential to rotate.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=7, + data={CONF_HOST: "192.168.1.100", CONF_API_VERSION: "v1"}, + entry_id="span_v1_entry", + unique_id="sp3-test-002", + ) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=MagicMock(), + panel_device_id="panel-device-id", + ) + _async_register_credential_services(hass) + + with pytest.raises(ServiceValidationError) as err: + await _call_rotate(hass, _admin_context(hass)) + + assert err.value.translation_key == "rotate_credentials_no_entry" diff --git a/tests/test_v2_config_flow.py b/tests/test_v2_config_flow.py index 5ec001e2..c5e6b16a 100644 --- a/tests/test_v2_config_flow.py +++ b/tests/test_v2_config_flow.py @@ -379,7 +379,8 @@ async def test_v2_entry_contains_mqtt_credentials(hass: HomeAssistant) -> None: assert data[CONF_EBUS_BROKER_PORT] == 8883 assert data[CONF_EBUS_BROKER_USERNAME] == "span-user" assert data[CONF_EBUS_BROKER_PASSWORD] == "mqtt-secret" - assert data[CONF_HOP_PASSPHRASE] == MOCK_PASSPHRASE + # The passphrase is a registration input, never entry data. + assert CONF_HOP_PASSPHRASE not in data assert data[CONF_PANEL_SERIAL] == "SPAN-V2-001" @@ -397,7 +398,7 @@ async def test_config_flow_uses_current_config_entry_version() -> None: async def test_migration_updates_older_entry_to_current_version( hass: HomeAssistant, ) -> None: - """v1.3.1 entries (version 2) should migrate through v3→v4→v5→v6.""" + """v1.3.1 entries (version 2) should migrate through to the current version.""" entry = MockConfigEntry( version=2, minor_version=1, @@ -416,14 +417,14 @@ async def test_migration_updates_older_entry_to_current_version( result = await async_migrate_entry(hass, entry) assert result is True - assert entry.version == 6 + assert entry.version == CURRENT_CONFIG_VERSION # v2→v3 migration adds api_version field assert entry.data[CONF_API_VERSION] == "v1" @pytest.mark.asyncio async def test_simulation_entry_migrates_normally(hass: HomeAssistant) -> None: - """Simulation entries migrate to v6; setup will fail naturally at connection time.""" + """Simulation entries migrate forward; setup will fail naturally at connection time.""" entry = MockConfigEntry( version=5, minor_version=1, @@ -443,7 +444,77 @@ async def test_simulation_entry_migrates_normally(hass: HomeAssistant) -> None: result = await async_migrate_entry(hass, entry) assert result is True - assert entry.version == 6 + assert entry.version == CURRENT_CONFIG_VERSION + + +@pytest.mark.asyncio +async def test_v6_migration_drops_the_stored_passphrase(hass: HomeAssistant) -> None: + """v6 entries lose the persisted passphrase and keep everything else.""" + entry = MockConfigEntry( + version=6, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "v2-token-abc", + CONF_API_VERSION: "v2", + CONF_EBUS_BROKER_HOST: MOCK_HOST, + CONF_EBUS_BROKER_PORT: 8883, + CONF_EBUS_BROKER_USERNAME: "span-user", + CONF_EBUS_BROKER_PASSWORD: "mqtt-secret", + CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE, + CONF_PANEL_SERIAL: "SPAN-V2-001", + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + assert await async_migrate_entry(hass, entry) is True + + assert entry.version == 7 + assert CONF_HOP_PASSPHRASE not in entry.data + assert entry.data[CONF_ACCESS_TOKEN] == "v2-token-abc" + assert entry.data[CONF_EBUS_BROKER_PASSWORD] == "mqtt-secret" + + +@pytest.mark.asyncio +async def test_home_assistant_actually_runs_the_v7_migration(hass: HomeAssistant) -> None: + """Core must decide to migrate a v6 entry. + + `CURRENT_CONFIG_VERSION` drives the migration body, but it is + `SpanPanelConfigFlow.VERSION` that core compares against `entry.version` to + decide whether to call `async_migrate_entry` at all. Calling the migration + directly cannot catch the two drifting apart, so this goes through + `async_setup` with the integration's own entry setup stubbed out. + """ + entry = MockConfigEntry( + version=6, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "v2-token-abc", + CONF_API_VERSION: "v2", + CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE, + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + with patch( + "custom_components.span_panel.async_setup_entry", + AsyncMock(return_value=True), + ): + assert await hass.config_entries.async_setup(entry.entry_id) is True + + assert entry.version == CURRENT_CONFIG_VERSION + assert CONF_HOP_PASSPHRASE not in entry.data # ---------- zeroconf v2 discovery ---------- @@ -1922,7 +1993,7 @@ async def test_update_v2_entry_missing_entry_aborts_with_reauth_failed( flow.serial_number = "SPAN-V2-001" flow.access_token = MOCK_V2_AUTH.access_token flow._is_flow_setup = True - flow._store_v2_auth_result(MOCK_V2_AUTH, MOCK_PASSPHRASE) + flow._store_v2_auth_result(MOCK_V2_AUTH) result = await flow._async_finalize_v2_auth() From f23040da5873b7a2db5f2f9d2dcc77a70f7df8e0 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:22:25 -0700 Subject: [PATCH 120/345] test(services): cover panel selection in rotate_credentials The `config_entry_id` branch and the runtime-data guard were the two uncovered paths in the new service. The first is user-facing on any multi-panel install; the second is the guard AGENTS.md requires. --- tests/test_rotate_credentials_service.py | 51 ++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/test_rotate_credentials_service.py b/tests/test_rotate_credentials_service.py index 88387a20..a39894e3 100644 --- a/tests/test_rotate_credentials_service.py +++ b/tests/test_rotate_credentials_service.py @@ -237,3 +237,54 @@ async def test_no_v2_entry_is_reported(hass: HomeAssistant) -> None: await _call_rotate(hass, _admin_context(hass)) assert err.value.translation_key == "rotate_credentials_no_entry" + + +@pytest.mark.asyncio +async def test_config_entry_id_selects_the_named_panel(hass: HomeAssistant) -> None: + """With two panels configured, the call rotates only the one named.""" + first = _add_v2_entry(hass) + second = MockConfigEntry( + domain=DOMAIN, + version=7, + data=dict(first.data) | {CONF_HOST: "192.168.1.101"}, + entry_id="span_entry_two", + unique_id="sp3-test-003", + ) + second.add_to_hass(hass) + second.mock_state(hass, ConfigEntryState.LOADED) + second.runtime_data = SpanPanelRuntimeData( + coordinator=MagicMock(), + panel_device_id="panel-device-id-two", + ) + _async_register_credential_services(hass) + + with ( + patch( + "custom_components.span_panel.services.regenerate_passphrase", + AsyncMock(return_value=NEW_BROKER_PASSWORD), + ), + patch.object(hass.config_entries, "async_reload", AsyncMock(return_value=True)), + ): + await hass.services.async_call( + DOMAIN, + "rotate_credentials", + {"config_entry_id": second.entry_id}, + blocking=True, + context=_admin_context(hass), + ) + + assert second.data[CONF_EBUS_BROKER_PASSWORD] == NEW_BROKER_PASSWORD + assert first.data[CONF_EBUS_BROKER_PASSWORD] == OLD_BROKER_PASSWORD + + +@pytest.mark.asyncio +async def test_entry_without_runtime_data_is_skipped(hass: HomeAssistant) -> None: + """An entry that reports loaded but carries no runtime data is not a candidate.""" + entry = _add_v2_entry(hass) + del entry.runtime_data + _async_register_credential_services(hass) + + with pytest.raises(ServiceValidationError) as err: + await _call_rotate(hass, _admin_context(hass)) + + assert err.value.translation_key == "rotate_credentials_no_entry" From 3cbf02a6703b8c56944e33dab2c0cc4a68193680 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:37:24 -0700 Subject: [PATCH 121/345] build: span-panel-api 3.1.0, adapters 1.1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All five pins move together. The adapter pins are not incidental: the library's write-then-verify needs a `ControlTarget` member on `SchemaAdapter`, and `ADAPTER_CONTRACT_VERSION` rejects an adapter missing a newly added method on that basis alone — so a partial bump fails adapter discovery at runtime rather than at install. Two consequences of the bump, both mechanical: - The five setters return `PublishOutcome` instead of `None`, which breaks `number.py`'s `set_fn` under strict mypy. The description-table signature moves to `Awaitable[PublishOutcome]`; nothing consumes the outcome yet. - `AsyncMqttBridge.publish` no longer takes `qos` from its caller — it is fixed at 1 inside the transport, where the PUBACK is now awaited. Three EVSE tests assert on that internal call and drop the argument. The uv path overrides point at the `-security` worktree while 3.1.0 is unpublished. Against the primary checkout, still on 3.0.1, the tests would resolve the old library and pass without exercising any of this. CI deletes that block and re-locks from PyPI, so CI cannot go green until 3.1.0 and both 1.1.0 adapters are published. --- custom_components/span_panel/manifest.json | 6 ++--- custom_components/span_panel/number.py | 9 +++++-- pyproject.toml | 29 ++++++++++++--------- requirements_test.txt | 6 ++--- tests/test_evse_charge_limit.py | 6 ++--- uv.lock | 30 +++++++++++----------- 6 files changed, 48 insertions(+), 38 deletions(-) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 4d3294d7..b3ffad2e 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -22,9 +22,9 @@ ], "quality_scale": "gold", "requirements": [ - "span-panel-api==3.0.1", - "span-panel-api-schema-0==1.0.0", - "span-panel-api-schema-1==1.0.0" + "span-panel-api==3.1.0", + "span-panel-api-schema-0==1.1.0", + "span-panel-api-schema-1==1.1.0" ], "version": "2.1.0b14", "zeroconf": [ diff --git a/custom_components/span_panel/number.py b/custom_components/span_panel/number.py index 1bd7f9bb..d2872f57 100644 --- a/custom_components/span_panel/number.py +++ b/custom_components/span_panel/number.py @@ -44,7 +44,12 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from span_panel_api import EvseControlProtocol, SpanEvseSnapshot, SpanPanelSnapshot +from span_panel_api import ( + EvseControlProtocol, + PublishOutcome, + SpanEvseSnapshot, + SpanPanelSnapshot, +) from span_panel_api.exceptions import SpanPanelServerError from . import SpanPanelConfigEntry @@ -72,7 +77,7 @@ class SpanEvseNumberRequiredKeysMixin(FieldPathDeclarationMixin): maximum_fn: Callable[[SpanEvseSnapshot], int | None] target_fn: Callable[[SpanEvseSnapshot], int | None] settable_fn: Callable[[SpanEvseSnapshot], bool] - set_fn: Callable[[EvseControlProtocol, str, int], Awaitable[None]] + set_fn: Callable[[EvseControlProtocol, str, int], Awaitable[PublishOutcome]] @dataclass(frozen=True, kw_only=True) diff --git a/pyproject.toml b/pyproject.toml index df96c150..6794db86 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,13 +8,13 @@ readme = "README.md" requires-python = ">=3.14.3,<3.15" dependencies = [ "homeassistant==2026.8.0", - "span-panel-api==3.0.1", + "span-panel-api==3.1.0", # 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.0.0", - "span-panel-api-schema-1==1.0.0", + "span-panel-api-schema-0==1.1.0", + "span-panel-api-schema-1==1.1.0", ] [dependency-groups] @@ -45,17 +45,22 @@ dev = [ package = false [tool.uv.sources] -# The library half of this branch is merged and released, so the primary checkout -# carries it and the `-p3` worktree these paths used to name is gone. A path that -# does not exist is not a soft failure: `uv run` cannot build the environment at -# all, so nothing in this repo runs until it is corrected. +# Pointed at the `-security` worktree while 3.1.0 is unreleased. The primary +# checkout is still on 3.0.1 and has none of this work, so pointing here is not a +# convenience -- against the primary checkout the security tests would resolve the +# old library and pass without exercising anything. Move these back to +# `../../span/span-panel-api` once 3.1.0 is merged and published. +# +# A path that does not exist is not a soft failure: `uv run` cannot build the +# environment at all, so nothing in this repo runs until it is corrected. # # CI never reads this block -- it deletes it and re-locks from PyPI (.github/ # workflows/ci.yml) -- so a stale path here breaks only local work, and only -# noisily. -span-panel-api = { path = "../../span/span-panel-api", editable = true } -span-panel-api-schema-0 = { path = "../../span/span-panel-api/packages/schema-0", editable = true } -span-panel-api-schema-1 = { path = "../../span/span-panel-api/packages/schema-1", editable = true } +# noisily. It does mean CI cannot pass until 3.1.0 and both 1.1.0 adapters are on +# PyPI. +span-panel-api = { path = "../../span/span-panel-api-security", editable = true } +span-panel-api-schema-0 = { path = "../../span/span-panel-api-security/packages/schema-0", editable = true } +span-panel-api-schema-1 = { path = "../../span/span-panel-api-security/packages/schema-1", editable = true } [tool.jscpd] path = ["custom_components/span_panel", "./*.{html,md}"] @@ -135,7 +140,7 @@ exclude = [ ] extraPaths = [ "./custom_components", - "../../span/span-panel-api/src", + "../../span/span-panel-api-security/src", "../ha-synthetic-sensors/src" ] pythonPlatform = "Darwin" diff --git a/requirements_test.txt b/requirements_test.txt index 5c371cd4..9f9926e2 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -3,6 +3,6 @@ pytest-asyncio>=1.3.0 setuptools>=65.7.0 pytest-homeassistant-custom-component==0.13.354 homeassistant==2026.8.0 -span-panel-api==3.0.1 -span-panel-api-schema-0==1.0.0 -span-panel-api-schema-1==1.0.0 +span-panel-api==3.1.0 +span-panel-api-schema-0==1.1.0 +span-panel-api-schema-1==1.1.0 diff --git a/tests/test_evse_charge_limit.py b/tests/test_evse_charge_limit.py index 186d2542..6046f396 100644 --- a/tests/test_evse_charge_limit.py +++ b/tests/test_evse_charge_limit.py @@ -518,7 +518,7 @@ async def test_the_write_reaches_the_wire_as_one_publish(hass: HomeAssistant) -> assert entity._evse_id != EVSE bridge.publish.assert_called_once_with( - f"ebus/5/{EVSE}/config/user-max-charge-current/set", str(asked), qos=1 + f"ebus/5/{EVSE}/config/user-max-charge-current/set", str(asked) ) entity.coordinator.async_request_refresh.assert_awaited_once() @@ -532,7 +532,7 @@ async def test_the_write_goes_to_the_charger_the_entity_belongs_to(hass: HomeAss await _for(created, tree, EVSE_2).async_set_native_value(float(asked)) bridge.publish.assert_called_once_with( - f"ebus/5/{EVSE_2}/config/user-max-charge-current/set", str(asked), qos=1 + f"ebus/5/{EVSE_2}/config/user-max-charge-current/set", str(asked) ) @@ -551,7 +551,7 @@ async def test_a_fractional_request_truncates_downward(hass: HomeAssistant) -> N await _for(created, tree, EVSE).async_set_native_value(whole + 0.7) bridge.publish.assert_called_once_with( - f"ebus/5/{EVSE}/config/user-max-charge-current/set", str(whole), qos=1 + f"ebus/5/{EVSE}/config/user-max-charge-current/set", str(whole) ) diff --git a/uv.lock b/uv.lock index 340c79c8..f5df6d09 100644 --- a/uv.lock +++ b/uv.lock @@ -2523,9 +2523,9 @@ dev = [ [package.metadata] requires-dist = [ { name = "homeassistant", specifier = "==2026.8.0" }, - { name = "span-panel-api", editable = "../../span/span-panel-api" }, - { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api/packages/schema-0" }, - { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api/packages/schema-1" }, + { name = "span-panel-api", editable = "../../span/span-panel-api-security" }, + { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api-security/packages/schema-0" }, + { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api-security/packages/schema-1" }, ] [package.metadata.requires-dev] @@ -2550,8 +2550,8 @@ dev = [ [[package]] name = "span-panel-api" -version = "3.0.1" -source = { editable = "../../span/span-panel-api" } +version = "3.1.0" +source = { editable = "../../span/span-panel-api-security" } dependencies = [ { name = "httpx" }, { name = "paho-mqtt" }, @@ -2563,8 +2563,8 @@ requires-dist = [ { name = "httpx", specifier = ">=0.28.1,<1.0" }, { name = "paho-mqtt", specifier = ">=2.0.0,<3.0.0" }, { name = "pyyaml", specifier = ">=6.0.0" }, - { name = "span-panel-api-schema-0", marker = "extra == 'schema-0'", editable = "../../span/span-panel-api/packages/schema-0" }, - { name = "span-panel-api-schema-1", marker = "extra == 'schema-1'", editable = "../../span/span-panel-api/packages/schema-1" }, + { name = "span-panel-api-schema-0", marker = "extra == 'schema-0'", editable = "../../span/span-panel-api-security/packages/schema-0" }, + { name = "span-panel-api-schema-1", marker = "extra == 'schema-1'", editable = "../../span/span-panel-api-security/packages/schema-1" }, ] provides-extras = ["schema-0", "schema-1"] @@ -2582,8 +2582,8 @@ dev = [ { name = "pytest-cov" }, { name = "radon" }, { name = "ruff", specifier = ">=0.15.5" }, - { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api/packages/schema-0" }, - { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api/packages/schema-1" }, + { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api-security/packages/schema-0" }, + { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api-security/packages/schema-1" }, { name = "twine", specifier = ">=7.0" }, { name = "types-pyyaml", specifier = ">=6.0.12.20250915" }, { name = "vulture", specifier = ">=2.14" }, @@ -2591,19 +2591,19 @@ dev = [ [[package]] name = "span-panel-api-schema-0" -version = "1.0.0" -source = { editable = "../../span/span-panel-api/packages/schema-0" } +version = "1.1.0" +source = { editable = "../../span/span-panel-api-security/packages/schema-0" } dependencies = [ { name = "span-panel-api" }, ] [package.metadata] -requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-api" }] +requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-api-security" }] [[package]] name = "span-panel-api-schema-1" -version = "1.0.0" -source = { editable = "../../span/span-panel-api/packages/schema-1" } +version = "1.1.0" +source = { editable = "../../span/span-panel-api-security/packages/schema-1" } dependencies = [ { name = "ebus-sdk" }, { name = "span-panel-api" }, @@ -2612,7 +2612,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "ebus-sdk", specifier = ">=0.19.0,<0.24" }, - { name = "span-panel-api", editable = "../../span/span-panel-api" }, + { name = "span-panel-api", editable = "../../span/span-panel-api-security" }, ] [[package]] From c8653411bf59f97d9adb77b4cf32e0f16fae5adc Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:56:15 -0700 Subject: [PATCH 122/345] security: pin the panel's CA, and refuse to re-anchor without a person MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bridge previously fetched the panel's CA over unauthenticated plaintext HTTP on every connect and trusted whatever came back, which is not a trust anchor — it is whatever answered. 3.1.0 accepts a pinned PEM instead and makes no CA request on any path once it has one. **Acquisition.** New entries fetch the CA in the config flow, confirm the certificate the panel actually serves chains to it, and show the fingerprint for the user to accept. A CA that cannot validate the panel's own leaf is not the panel's CA, and it is checked before the fingerprint is shown so nobody is asked to accept a value that already failed. The step text says this is trust on first use rather than implying verification the integration cannot perform. Failure is not fatal. The user can retry or continue unpinned, and continuing sets `panel_ca_pending`, which every later setup retries. **Existing entries** take the same flag from the v7 migration, which does no I/O — `async_migrate_entry` runs during startup, so a fetch there would delay boot for an unreachable panel and a failure would have nowhere to recover to. The deferred fetch logs at WARNING with the fingerprint, because it is an accepted TOFU on the upgrade path and the user should be able to find the value afterwards. v2 only: v1 fails setup before it reaches a panel and a simulation entry has none. **A changed CA raises a Repair and stops.** `SpanPanelCAChangedError` is terminal on both paths — `ConfigEntryError` at setup, `UpdateFailed` in the coordinator rather than the offline branch, which would keep serving the snapshot read before the pin broke. The Repair carries both fingerprints because the two remedies are opposite and only a person can choose between them, and its fix flow re-fetches, re-shows and re-pins only on explicit acceptance. Never auto-accepted: a rotated CA and an intercepted connection are indistinguishable from here. **The credential-bearing REST calls move onto the pin.** `PanelRestTransport` resolves one entry's transport once — TLS port and a context from the pinned PEM, or the plaintext port and Home Assistant's shared client. httpx fixes its trust store at construction, so a pinned call cannot use the shared client and the library builds its own; that cost is real and is why this is resolved in one place rather than at five call sites. Reauth, reconfigure, FQDN registration and `rotate_credentials` all take it. Initial registration cannot — the anchor does not exist until later in the same flow. `CONF_HTTPS_PORT` is prompted only when the HTTP port is non-default, since that is the install most likely to be reaching the panel through something that moved the TLS port too. It is needed: the library refuses an explicit port 80 alongside a context rather than guessing which of the two readings was meant. Diagnostics gains the fingerprint and redacts the PEM, which is public but multi-KB and would drown the dump. Also fixes `check_fqdn_tls_ready`, which built its own SSL context and so kept `VERIFY_X509_STRICT`. The panel's CA omits the Authority Key Identifier that flag requires, so the probe could never succeed on Python 3.13+ and FQDN registration always ran to its timeout. It now uses the library's builder, which is the one place that workaround belongs. --- custom_components/span_panel/__init__.py | 110 ++++- custom_components/span_panel/ca_repairs.py | 87 ++++ custom_components/span_panel/config_flow.py | 171 ++++++- .../span_panel/config_flow_validation.py | 181 +++++++- custom_components/span_panel/const.py | 15 + custom_components/span_panel/coordinator.py | 22 +- custom_components/span_panel/diagnostics.py | 28 +- custom_components/span_panel/manifest.json | 3 +- custom_components/span_panel/migrations.py | 13 +- custom_components/span_panel/repairs.py | 110 +++++ custom_components/span_panel/services.py | 12 +- custom_components/span_panel/strings.json | 40 +- .../span_panel/translations/en.json | 40 +- .../span_panel/translations/es.json | 38 ++ .../span_panel/translations/fr.json | 38 ++ .../span_panel/translations/ja.json | 38 ++ .../span_panel/translations/pt.json | 38 ++ tests/test_config_flow_validation.py | 65 ++- tests/test_panel_ca_pinning.py | 420 ++++++++++++++++++ tests/test_v2_config_flow.py | 230 +++++++++- 20 files changed, 1605 insertions(+), 94 deletions(-) create mode 100644 custom_components/span_panel/ca_repairs.py create mode 100644 custom_components/span_panel/repairs.py create mode 100644 tests/test_panel_ca_pinning.py diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py index 8a40ff4a..d3c0b742 100644 --- a/custom_components/span_panel/__init__.py +++ b/custom_components/span_panel/__init__.py @@ -10,7 +10,7 @@ from homeassistant.components.panel_custom import async_register_panel as async_register_panel from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform -from homeassistant.core import CoreState, HomeAssistant +from homeassistant.core import CoreState, HomeAssistant, callback from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryError, @@ -23,12 +23,16 @@ ) from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.typing import ConfigType -from span_panel_api import SpanMqttClient, SpanPanelSnapshot +from span_panel_api import SpanMqttClient, SpanPanelSnapshot, ca_fingerprint from span_panel_api.exceptions import ( + SpanPanelAPIError, SpanPanelAuthError, + SpanPanelCAChangedError, SpanPanelConnectionError, + SpanPanelError, SpanPanelServerError, SpanPanelTimeoutError, + SpanPanelValidationError, ) from span_panel_api.mqtt.models import MqttClientConfig @@ -36,6 +40,8 @@ from . import config_flow # noqa: F401 from .additions import async_announce_new_entities, async_forget_announcements from .adoption import async_register_adopted_devices +from .ca_repairs import async_clear_ca_changed, async_raise_ca_changed +from .config_flow_validation import async_fetch_panel_ca from .const import ( CONF_API_VERSION, CONF_EBUS_BROKER_HOST, @@ -43,9 +49,11 @@ CONF_EBUS_BROKER_PORT, CONF_EBUS_BROKER_USERNAME, CONF_HTTP_PORT, + CONF_PANEL_CA_PEM, DEFAULT_SNAPSHOT_INTERVAL, DOMAIN, ENABLE_CURRENT_MONITORING, + PANEL_CA_PENDING, ) from .coordinator import SpanPanelCoordinator from .current_monitor import CurrentMonitor @@ -129,6 +137,65 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +async def _async_pinned_ca( + hass: HomeAssistant, entry: SpanPanelConfigEntry, host: str, http_port: int +) -> str | None: + """Return the CA to anchor this entry's broker connection on. + + An entry provisioned since CA pinning landed already carries one. An entry + that predates it carries `panel_ca_pending` instead, put there by the v7 + migration, and this is where that is settled — at setup, where the panel is + about to be reachable anyway, rather than in the migration, which runs during + startup and would delay boot for an unreachable panel. + + A failed fetch is not a setup failure. The flag survives, the next setup + retries for free, and in the meantime the library falls back to its 3.0.1 + behaviour of refetching the CA on each connect. Refusing to start would trade + a partial control for no integration at all. + + The store is logged at WARNING with the fingerprint on purpose. This is + trust-on-first-use on an upgrade path — nobody confirmed this certificate + against anything — and the user should be able to find the value afterwards + to compare it against another install or the panel's own label. + """ + pinned = entry.data.get(CONF_PANEL_CA_PEM) + if pinned: + return str(pinned) + + if not entry.data.get(PANEL_CA_PENDING): + return None + + try: + ca_pem = await async_fetch_panel_ca(hass, host, http_port=http_port) + fingerprint = ca_fingerprint(ca_pem) + except ( + SpanPanelAPIError, + SpanPanelConnectionError, + SpanPanelTimeoutError, + SpanPanelValidationError, + ) as err: + _LOGGER.warning( + "Could not acquire the CA for SPAN panel %s (%s). Continuing unpinned; " + "the fetch is retried on the next setup", + entry.title, + err, + ) + return None + + data = dict(entry.data) + data[CONF_PANEL_CA_PEM] = ca_pem + data.pop(PANEL_CA_PENDING, None) + hass.config_entries.async_update_entry(entry, data=data) + _LOGGER.warning( + "Pinned the CA advertised by SPAN panel %s: SHA-256 %s. Nothing has confirmed " + "this certificate — compare it against another install or the panel itself if " + "you can, and see the integration's security documentation", + entry.title, + fingerprint, + ) + return ca_pem + + async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> bool: """Set up Span Panel from a config entry.""" _LOGGER.debug("Setting up entry %s (version %s)", entry.entry_id, entry.version) @@ -193,15 +260,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> host, ) + panel_http_port = int(config.get(CONF_HTTP_PORT, 80)) + + # Before the client is built, because the pin is a constructor + # argument: supplying it is what stops the bridge fetching a CA over + # plaintext HTTP on every connect and trusting whatever answers. + ca_pem = await _async_pinned_ca(hass, entry, host, panel_http_port) + broker_config = MqttClientConfig( broker_host=host, username=config[CONF_EBUS_BROKER_USERNAME], password=config[CONF_EBUS_BROKER_PASSWORD], mqtts_port=int(config[CONF_EBUS_BROKER_PORT]), + ca_pem=ca_pem, ) - panel_http_port = int(config.get(CONF_HTTP_PORT, 80)) - snapshot_interval = entry.options.get( SNAPSHOT_UPDATE_INTERVAL, DEFAULT_SNAPSHOT_INTERVAL ) @@ -227,6 +300,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> ) try: await client.connect() + except SpanPanelCAChangedError as err: + # Terminal on purpose, and `ConfigEntryError` rather than + # `ConfigEntryNotReady` for the same reason the library refuses + # to retry: a client that keeps trying is a client waiting to + # succeed against whatever is answering, which is the outcome + # pinning exists to prevent. The Repair is the only way forward, + # and it requires a person. + await client.close() + async_raise_ca_changed( + hass, entry, err.expected_fingerprint, err.observed_fingerprint + ) + raise ConfigEntryError(str(err)) from err except SpanPanelAuthError as err: await client.close() raise ConfigEntryAuthFailed(f"MQTT authentication failed: {err}") from err @@ -246,6 +331,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> await client.close() raise ConfigEntryNotReady(f"SPAN panel is not ready yet: {err}") from err + # The connection got as far as a handshake under the current pin, so + # any standing Repair describes a state that no longer holds. + async_clear_ca_changed(hass, entry) + + # The other half of the same condition: the reconnect loop runs + # fire-and-forget, so a CA that changes mid-session cannot surface as + # an exception on anybody's call stack. The library gives it a + # channel of its own precisely so a consumer can act on it. + @callback + def _on_fatal_transport_error(error: SpanPanelError) -> None: + if isinstance(error, SpanPanelCAChangedError): + async_raise_ca_changed( + hass, entry, error.expected_fingerprint, error.observed_fingerprint + ) + + entry.async_on_unload(client.register_fatal_error_callback(_on_fatal_transport_error)) + coordinator = SpanPanelCoordinator(hass, client, entry) await coordinator.async_config_entry_first_refresh() await coordinator.async_setup_streaming() diff --git a/custom_components/span_panel/ca_repairs.py b/custom_components/span_panel/ca_repairs.py new file mode 100644 index 00000000..5fe4a14e --- /dev/null +++ b/custom_components/span_panel/ca_repairs.py @@ -0,0 +1,87 @@ +"""The Repair raised when a pinned panel starts advertising a different CA. + +Separate from `schema_repairs` because the two are reconciled on opposite terms. +A schema finding is re-derived from live state on every pass and deleted the +moment it stops being true; this one describes an event that already happened on +a transport that has stopped for good, and there is nothing left running to +re-derive it from. It is raised once, persists across restarts, and is cleared +only by the user accepting the new fingerprint or by a setup that connects +cleanly again. + +It is also the only Repair here that is fixable, and the fix is the point. A +rotated CA and an intercepted connection are indistinguishable from inside the +integration — the library says so and refuses to choose — so the only honest +resolution is a person comparing the new fingerprint against a value they hold +and saying yes. Re-pinning automatically on reconnect would be precisely the +substitution the pin exists to prevent. +""" + +from __future__ import annotations + +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +CA_CHANGED_ISSUE_PREFIX = "panel_ca_changed_" + + +def ca_changed_issue_id(entry_id: str) -> str: + """Issue id for one entry's CA change.""" + return f"{CA_CHANGED_ISSUE_PREFIX}{entry_id}" + + +@callback +def async_raise_ca_changed( + hass: HomeAssistant, + entry: ConfigEntry, + expected_fingerprint: str, + observed_fingerprint: str, +) -> None: + """Raise the Repair, carrying both fingerprints. + + Both, because the two remedies are opposite and only the user can choose + between them: re-pin, if a firmware upgrade or a factory reset rotated the + CA legitimately, or investigate, if nothing should have. + + `is_persistent` because the transport this describes is already dead. A + non-persistent issue reloads as a tombstone, and this one has no live state + to re-assert it from — the client that would have noticed is gone. + """ + _LOGGER.error( + "SPAN panel %s is advertising CA %s where %s was pinned. Not reconnecting: a " + "rotated CA and an intercepted connection look identical from here. Confirm the " + "new fingerprint out of band, then accept it in Settings > Repairs", + entry.title, + observed_fingerprint, + expected_fingerprint, + ) + ir.async_create_issue( + hass, + DOMAIN, + ca_changed_issue_id(entry.entry_id), + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.ERROR, + translation_key="panel_ca_changed", + translation_placeholders={ + "panel": entry.title, + "expected_fingerprint": expected_fingerprint, + "observed_fingerprint": observed_fingerprint, + }, + data={ + "entry_id": entry.entry_id, + "observed_fingerprint": observed_fingerprint, + }, + ) + + +@callback +def async_clear_ca_changed(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Drop the Repair, on a setup that reached the panel under the current pin.""" + ir.async_delete_issue(hass, DOMAIN, ca_changed_issue_id(entry.entry_id)) diff --git a/custom_components/span_panel/config_flow.py b/custom_components/span_panel/config_flow.py index bbf1a193..498f6626 100644 --- a/custom_components/span_panel/config_flow.py +++ b/custom_components/span_panel/config_flow.py @@ -21,6 +21,7 @@ from homeassistant.util.network import is_ipv4_address from span_panel_api import ( V2AuthResponse, + ca_fingerprint, delete_fqdn, detect_api_version, register_fqdn, @@ -39,8 +40,12 @@ process_general_options_input, ) from .config_flow_validation import ( + PanelRestTransport, + async_fetch_panel_ca, + async_leaf_chains_to_ca, check_fqdn_tls_ready, is_fqdn, + panel_rest_transport, validate_host, validate_v2_passphrase, validate_v2_proximity, @@ -53,11 +58,15 @@ CONF_EBUS_BROKER_USERNAME, CONF_HOP_PASSPHRASE, CONF_HTTP_PORT, + CONF_HTTPS_PORT, + CONF_PANEL_CA_PEM, CONF_PANEL_SERIAL, CONF_REGISTERED_FQDN, + DEFAULT_HTTPS_PORT, DOMAIN, ENABLE_ENERGY_DIP_COMPENSATION, ENTITY_NAMING_PATTERN, + PANEL_CA_PENDING, USE_CIRCUIT_NUMBERS, USE_DEVICE_PREFIX, EntityNamingPattern, @@ -138,6 +147,14 @@ def __init__(self) -> None: self._v2_broker_password: str | None = None self._v2_panel_serial: str | None = None self._http_port: int = 80 + self._https_port: int = DEFAULT_HTTPS_PORT + # The panel CA, once fetched and leaf-confirmed. None means the flow + # could not acquire one and the entry is created with the pending flag. + self._panel_ca_pem: str | None = None + # How this flow's REST calls reach the panel. Set from the existing + # entry on reauth and reconfigure, where a pin may already exist; left + # None on initial setup, which has nothing pinned yet by construction. + self._rest_transport: PanelRestTransport | None = None # Energy dip compensation default for fresh installs self._enable_dip_compensation: bool = True # FQDN registration task (async_show_progress) @@ -342,12 +359,16 @@ async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> ConfigFlowRe """Handle a flow initiated by re-auth.""" host = entry_data[CONF_HOST] self._http_port = int(entry_data.get(CONF_HTTP_PORT, 80)) + # This entry may already have a pinned CA, and a reauth is precisely + # where fresh credentials cross the wire. Adopted before the first call. + self._rest_transport = panel_rest_transport(self.hass, entry_data) # Detect current API version of the panel detection = await detect_api_version( host, - port=self._http_port, - httpx_client=get_async_client(self.hass, verify_ssl=False), + port=self._rest_transport.port, + httpx_client=self._rest_transport.httpx_client, + ssl_context=self._rest_transport.ssl_context, ) if detection.probe_failed: return self.async_abort(reason="cannot_connect") @@ -442,7 +463,9 @@ async def async_step_auth_proximity_confirm( return await self.async_step_auth_proximity() try: - result = await validate_v2_proximity(self.hass, self.host, port=self._http_port) + result = await validate_v2_proximity( + self.hass, self.host, port=self._http_port, transport=self._rest_transport + ) except (SpanPanelAuthError, SpanPanelConnectionError): return await self.async_step_auth_proximity() @@ -473,7 +496,11 @@ async def async_step_auth_passphrase( try: result = await validate_v2_passphrase( - self.hass, self.host, passphrase, port=self._http_port + self.hass, + self.host, + passphrase, + port=self._http_port, + transport=self._rest_transport, ) except SpanPanelAuthError: return self.async_show_form( @@ -514,7 +541,7 @@ async def _async_finalize_v2_auth(self) -> ConfigFlowResult: # If host is an FQDN, register it with the panel for TLS cert SAN inclusion if self.host and is_fqdn(self.host): return await self.async_step_register_fqdn() - return await self.async_step_choose_entity_naming_initial() + return await self.async_step_panel_ca_start() async def async_step_register_fqdn( self, user_input: dict[str, Any] | None = None @@ -541,20 +568,28 @@ async def async_step_register_fqdn( return self.async_show_progress_done(next_step_id="fqdn_failed") self._fqdn_task = None - return self.async_show_progress_done(next_step_id="choose_entity_naming_initial") + return self.async_show_progress_done(next_step_id="panel_ca_start") async def _async_register_fqdn_and_wait(self) -> None: """Register the FQDN and poll until the TLS cert includes it.""" if not self.host or not self.access_token: raise ConfigFlowError("Host and access token required for FQDN registration") - httpx_client = get_async_client(self.hass, verify_ssl=False) + # Carries the access token, so it goes over the pin where this flow has + # one — which is the reconfigure path. Initial setup has not fetched a CA + # yet at this point and falls back to the plaintext bootstrap port. + transport = self._rest_transport or PanelRestTransport( + port=self._http_port, + ssl_context=None, + httpx_client=get_async_client(self.hass, verify_ssl=False), + ) await register_fqdn( self.host, self.access_token, self.host, - port=self._http_port, - httpx_client=httpx_client, + port=transport.port, + httpx_client=transport.httpx_client, + ssl_context=transport.ssl_context, ) mqtts_port = self._v2_broker_port or 8883 @@ -578,13 +613,110 @@ async def async_step_fqdn_failed( ) -> ConfigFlowResult: """Handle FQDN registration failure — user may continue without it.""" if user_input is not None: - return await self.async_step_choose_entity_naming_initial() + return await self.async_step_panel_ca_start() return self.async_show_form( step_id="fqdn_failed", data_schema=vol.Schema({}), errors={"base": "fqdn_registration_failed"}, ) + async def async_step_panel_ca_start( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Enter CA acquisition, asking for the HTTPS port first when it is not 443. + + The port is only prompted for on an install that already moved the HTTP + port, because that is the install most likely to be reaching the panel + through something that also moved the TLS one. Asking everybody would put + a question about reverse proxies in front of users who have none. + """ + if self._http_port == 80: + return await self.async_step_panel_ca() + return await self.async_step_panel_https_port() + + async def async_step_panel_https_port( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Collect the port the panel serves TLS on.""" + if user_input is None: + return self.async_show_form( + step_id="panel_https_port", + data_schema=vol.Schema( + { + vol.Required(CONF_HTTPS_PORT, default=DEFAULT_HTTPS_PORT): int, + } + ), + ) + self._https_port = int(user_input[CONF_HTTPS_PORT]) + return await self.async_step_panel_ca() + + async def async_step_panel_ca( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Fetch the panel's CA, prove it signs what the panel serves, and show it. + + `user_input` is `None` on the way in — including on a retry chosen from + the failure menu — and a submitted (empty) mapping when the user has + confirmed the fingerprint. + """ + if user_input is not None: + return await self.async_step_choose_entity_naming_initial() + + if not self.host: + return self.async_abort(reason="host_not_set") + + try: + ca_pem = await async_fetch_panel_ca(self.hass, self.host, http_port=self._http_port) + except ( + SpanPanelAPIError, + SpanPanelConnectionError, + SpanPanelTimeoutError, + ) as err: + _LOGGER.warning("Could not fetch the CA from panel %s: %s", self.host, err) + return await self.async_step_panel_ca_failed() + + # A CA that cannot validate the certificate the panel actually serves is + # not the panel's CA. Checked before the fingerprint is shown, so the + # user is never asked to accept a value that already failed. + if not await async_leaf_chains_to_ca(self.host, self._https_port, ca_pem): + _LOGGER.warning( + "The certificate served by %s on port %s does not chain to the CA the " + "panel published; not pinning it", + self.host, + self._https_port, + ) + return await self.async_step_panel_ca_failed() + + self._panel_ca_pem = ca_pem + return self.async_show_form( + step_id="panel_ca", + data_schema=vol.Schema({}), + description_placeholders={"fingerprint": ca_fingerprint(ca_pem)}, + ) + + async def async_step_panel_ca_failed( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Offer a retry, or setting the panel up without a pin. + + Continuing is permitted because refusing would make an unreachable + certificate endpoint a hard setup failure for a panel that is otherwise + working, and the entry is created with `panel_ca_pending` so every later + setup retries the fetch. Until one succeeds the library keeps 3.0.1's + behaviour of refetching the CA on each connect. + """ + return self.async_show_menu( + step_id="panel_ca_failed", + menu_options=["panel_ca", "panel_ca_skip"], + ) + + async def async_step_panel_ca_skip( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Continue without a pinned CA.""" + self._panel_ca_pem = None + return await self.async_step_choose_entity_naming_initial() + def create_new_entry( self, host: str, serial_number: str, access_token: str ) -> ConfigFlowResult: @@ -618,6 +750,14 @@ def create_new_entry( if self._http_port != 80: entry_data[CONF_HTTP_PORT] = self._http_port + if self._https_port != DEFAULT_HTTPS_PORT: + entry_data[CONF_HTTPS_PORT] = self._https_port + if self._panel_ca_pem is not None: + entry_data[CONF_PANEL_CA_PEM] = self._panel_ca_pem + else: + # Acquisition failed and the user chose to continue. Every later + # setup retries it, on the same flag the migration uses. + entry_data[PANEL_CA_PENDING] = True if is_fqdn(host): entry_data[CONF_REGISTERED_FQDN] = host @@ -729,11 +869,13 @@ async def async_step_reconfigure( # Validate the host is reachable and is a v2 panel http_port = int(reconfigure_entry.data.get(CONF_HTTP_PORT, 80)) + self._rest_transport = panel_rest_transport(self.hass, reconfigure_entry.data) try: detection = await detect_api_version( host, - port=http_port, - httpx_client=get_async_client(self.hass, verify_ssl=False), + port=self._rest_transport.port, + httpx_client=self._rest_transport.httpx_client, + ssl_context=self._rest_transport.ssl_context, ) except ( ValueError, @@ -783,8 +925,9 @@ async def async_step_reconfigure( await delete_fqdn( host, access_token, - port=http_port, - httpx_client=get_async_client(self.hass, verify_ssl=False), + port=self._rest_transport.port, + httpx_client=self._rest_transport.httpx_client, + ssl_context=self._rest_transport.ssl_context, ) except ( SpanPanelAPIError, diff --git a/custom_components/span_panel/config_flow_validation.py b/custom_components/span_panel/config_flow_validation.py index c5f79775..25c851c8 100644 --- a/custom_components/span_panel/config_flow_validation.py +++ b/custom_components/span_panel/config_flow_validation.py @@ -3,18 +3,20 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping +from dataclasses import dataclass import ipaddress import logging -from pathlib import Path import socket import ssl -import tempfile from homeassistant.core import HomeAssistant from homeassistant.helpers.httpx_client import get_async_client from homeassistant.util.network import is_ipv4_address +import httpx from span_panel_api import ( V2AuthResponse, + build_panel_ssl_context, detect_api_version, download_ca_cert, register_v2, @@ -25,6 +27,13 @@ SpanPanelTimeoutError, ) +from .const import ( + CONF_HTTP_PORT, + CONF_HTTPS_PORT, + CONF_PANEL_CA_PEM, + DEFAULT_HTTPS_PORT, +) + _LOGGER = logging.getLogger(__name__) @@ -51,18 +60,35 @@ async def validate_host( async def validate_v2_passphrase( - hass: HomeAssistant, host: str, passphrase: str, port: int = 80 + hass: HomeAssistant, + host: str, + passphrase: str, + port: int = 80, + transport: PanelRestTransport | None = None, ) -> V2AuthResponse: """Validate a v2 panel passphrase and return MQTT credentials. + `transport` carries a pinned CA where one exists, which is the case on every + reauth of an already-pinned entry. Initial registration has no pin yet — the + anchor is fetched later in the same flow — and passes `port` alone. + Raises: SpanPanelAuthError: on invalid passphrase (401/403). SpanPanelConnectionError: on network/timeout failures. SpanPanelTimeoutError: on request timeout. """ - client = get_async_client(hass, verify_ssl=False) - return await register_v2(host, "Home Assistant", passphrase, port=port, httpx_client=client) + resolved = transport or PanelRestTransport( + port=port, ssl_context=None, httpx_client=get_async_client(hass, verify_ssl=False) + ) + return await register_v2( + host, + "Home Assistant", + passphrase, + port=resolved.port, + httpx_client=resolved.httpx_client, + ssl_context=resolved.ssl_context, + ) def is_fqdn(host: str) -> bool: @@ -105,32 +131,70 @@ async def check_fqdn_tls_ready( ): return False + return await async_leaf_chains_to_ca(fqdn, mqtts_port, ca_pem) + + +async def async_fetch_panel_ca(hass: HomeAssistant, host: str, http_port: int = 80) -> str: + """Fetch the panel's CA over plain HTTP. + + Unauthenticated and unverified by construction — it fetches the very anchor + everything else is checked against, so there is nothing for it to check + itself against. Anything on the path can answer with a CA of its own. The + caller owes the trust decision; see `async_leaf_chains_to_ca` for the one + check that can be made without a value from another channel, and the confirm + step for the fingerprint a user can compare against one. + + Raises whatever `download_ca_cert` raises; the caller decides what a failure + to acquire means, and that differs between a config flow and a deferred fetch + at setup. + """ + return await download_ca_cert( + host, port=http_port, httpx_client=get_async_client(hass, verify_ssl=False) + ) + + +async def async_leaf_chains_to_ca(host: str, tls_port: int, ca_pem: str) -> bool: + """Whether the certificate served on `tls_port` validates against `ca_pem` alone. + + A CA that cannot validate the certificate the panel actually serves is not + the panel's CA. That does not make a fetched PEM trustworthy — an attacker + who can substitute the CA can serve a leaf signed by it — but it does catch + the fetch that returned something unrelated, and it is the only check + available without a fingerprint from another channel. + + Hostname verification stays on, so this also fails when the panel's + certificate does not name the address being used, which is the condition + `check_fqdn_tls_ready` is polling for. + """ loop = asyncio.get_running_loop() def _check() -> bool: - ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) - ctx.check_hostname = True - ctx.verify_mode = ssl.CERT_REQUIRED - - with tempfile.NamedTemporaryFile(mode="w", suffix=".pem", delete=False) as tmp: - tmp.write(ca_pem) - ca_path = Path(tmp.name) try: - ctx.load_verify_locations(str(ca_path)) + # The library's builder, not a hand-rolled context: the panel's CA + # omits the Authority Key Identifier extension, which Python's + # default-on VERIFY_X509_STRICT rejects outright. A context built + # here without clearing that flag fails against a healthy panel. + ctx = build_panel_ssl_context(ca_pem) + except (ssl.SSLError, ValueError): + return False + try: with ( - socket.create_connection((fqdn, mqtts_port), timeout=5) as sock, - ctx.wrap_socket(sock, server_hostname=fqdn), + socket.create_connection((host, tls_port), timeout=5) as sock, + ctx.wrap_socket(sock, server_hostname=host), ): return True except (ssl.SSLCertVerificationError, ssl.SSLError, OSError, TimeoutError): return False - finally: - ca_path.unlink(missing_ok=True) return await loop.run_in_executor(None, _check) -async def validate_v2_proximity(hass: HomeAssistant, host: str, port: int = 80) -> V2AuthResponse: +async def validate_v2_proximity( + hass: HomeAssistant, + host: str, + port: int = 80, + transport: PanelRestTransport | None = None, +) -> V2AuthResponse: """Validate v2 panel proximity (door bypass) and return MQTT credentials. Calls register_v2 without a passphrase, which triggers door-bypass @@ -143,5 +207,82 @@ async def validate_v2_proximity(hass: HomeAssistant, host: str, port: int = 80) SpanPanelTimeoutError: on request timeout. """ - client = get_async_client(hass, verify_ssl=False) - return await register_v2(host, "Home Assistant", port=port, httpx_client=client) + resolved = transport or PanelRestTransport( + port=port, ssl_context=None, httpx_client=get_async_client(hass, verify_ssl=False) + ) + return await register_v2( + host, + "Home Assistant", + port=resolved.port, + httpx_client=resolved.httpx_client, + ssl_context=resolved.ssl_context, + ) + + +@dataclass(frozen=True, slots=True) +class PanelRestTransport: + """How to reach one panel's bootstrap REST API. + + Two shapes, and the difference is not cosmetic. With a pinned CA the calls + go to the TLS port under an anchor built from it, and Home Assistant's shared + httpx client cannot be used — httpx fixes its trust store at construction, so + a context cannot be applied to a client somebody else built, and the library + builds a dedicated one rather than silently ignoring the pin. Without a pin + they go to the plaintext port on the shared client, exactly as before. + + Mixing the two is refused by the library rather than guessed at: an explicit + `port=80` alongside an `ssl_context` raises, because "HTTPS on port 80" and + "a port stored before the CA was pinned" are both plausible readings. + """ + + port: int + ssl_context: ssl.SSLContext | None + httpx_client: httpx.AsyncClient | None + + +def _as_port(value: object, default: int) -> int: + """Read a port out of untyped entry data without trusting its type. + + `entry.data` round-trips through JSON and is typed `Any` at the boundary; a + hand-edited `.storage` can put a string there. Anything unusable falls back + to the default rather than raising, because a bad port must not be the reason + an otherwise healthy entry cannot start. + """ + if isinstance(value, bool): + return default + if isinstance(value, int): + return value + if isinstance(value, str) and value.isdigit(): + return int(value) + return default + + +def panel_rest_transport( + hass: HomeAssistant, entry_data: Mapping[str, object] +) -> PanelRestTransport: + """Decide how this entry's REST calls should reach the panel. + + A malformed stored PEM falls back to plaintext rather than raising. The + alternative is a config entry that cannot make a single REST call until + somebody edits `.storage` by hand, which is a worse outcome than the + transport this entry had before it was pinned — and the CA-changed repair is + the path back to a good pin. + """ + pem = entry_data.get(CONF_PANEL_CA_PEM) + if pem: + try: + context = build_panel_ssl_context(str(pem)) + except (ssl.SSLError, ValueError): + _LOGGER.warning( + "The stored panel CA is not a certificate this system accepts; " + "falling back to plaintext HTTP for REST calls" + ) + else: + https_port = _as_port(entry_data.get(CONF_HTTPS_PORT), DEFAULT_HTTPS_PORT) + return PanelRestTransport(port=https_port, ssl_context=context, httpx_client=None) + + return PanelRestTransport( + port=_as_port(entry_data.get(CONF_HTTP_PORT), 80), + ssl_context=None, + httpx_client=get_async_client(hass, verify_ssl=False), + ) diff --git a/custom_components/span_panel/const.py b/custom_components/span_panel/const.py index 1fcb803d..c3586fb8 100644 --- a/custom_components/span_panel/const.py +++ b/custom_components/span_panel/const.py @@ -17,9 +17,24 @@ CONF_EBUS_BROKER_PORT = "ebus_broker_mqtts_port" CONF_HOP_PASSPHRASE = "hop_passphrase" # nosec B105 CONF_HTTP_PORT = "http_port" +CONF_HTTPS_PORT = "https_port" +CONF_PANEL_CA_PEM = "panel_ca_pem" CONF_PANEL_SERIAL = "panel_serial" CONF_REGISTERED_FQDN = "registered_fqdn" +DEFAULT_HTTPS_PORT = 443 +"""Where the panel serves TLS. Configurable for the same reason `http_port` is: +a reverse proxy on the panel's VLAN does not have to listen on 443.""" + +PANEL_CA_PENDING = "panel_ca_pending" +"""Set in `entry.data` for a v2 entry that predates CA pinning. + +The CA cannot be fetched in `async_migrate_entry` — that runs during startup, so +a network call there delays boot whenever the panel is unreachable and leaves a +failed fetch with no recovery path. The flag defers it to the first successful +setup, which retries on every subsequent one for free. Same shape as +`solar_migration_pending`.""" + # Binary sensor / status field keys (used in entity definitions) SYSTEM_DOOR_STATE = "doorState" SYSTEM_DOOR_STATE_CLOSED = "CLOSED" diff --git a/custom_components/span_panel/coordinator.py b/custom_components/span_panel/coordinator.py index a312cd1d..3583166e 100644 --- a/custom_components/span_panel/coordinator.py +++ b/custom_components/span_panel/coordinator.py @@ -21,9 +21,13 @@ HomeAssistantError, ) from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from span_panel_api import SpanMqttClient, SpanPanelClientProtocol, SpanPanelSnapshot -from span_panel_api.exceptions import SpanPanelAuthError, SpanPanelStaleDataError +from span_panel_api.exceptions import ( + SpanPanelAuthError, + SpanPanelCAChangedError, + SpanPanelStaleDataError, +) from .const import DOMAIN from .helpers import detect_capabilities @@ -716,6 +720,20 @@ async def _async_update_data(self) -> SpanPanelSnapshot: await self._run_post_update_tasks(snapshot) + except SpanPanelCAChangedError as err: + # Not an outage, and must not be handled as one. The offline branch + # below keeps serving the last snapshot so a brief broker drop does + # not blank the dashboard, which is right for a transport that will + # come back and wrong for one that has stopped for good: it would + # leave every entity showing a plausible value read before the pin + # broke. `UpdateFailed` takes them unavailable instead. + # + # Nothing is retried or torn down here. The library refuses to + # reconnect, the Repair is raised from the fatal-error channel in + # `async_setup_entry`, and re-pinning requires a person. + self._mark_panel_offline(err) + raise UpdateFailed(str(err)) from err + except SpanPanelAuthError as err: raise ConfigEntryAuthFailed from err diff --git a/custom_components/span_panel/diagnostics.py b/custom_components/span_panel/diagnostics.py index 078f38e3..06302877 100644 --- a/custom_components/span_panel/diagnostics.py +++ b/custom_components/span_panel/diagnostics.py @@ -8,7 +8,8 @@ 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 +from span_panel_api import SpanPanelSnapshot, ca_fingerprint +from span_panel_api.exceptions import SpanPanelValidationError from . import SpanPanelConfigEntry from .adoption import adopted_control_count, classify @@ -16,6 +17,8 @@ CONF_EBUS_BROKER_PASSWORD, CONF_EBUS_BROKER_USERNAME, CONF_HOP_PASSPHRASE, + CONF_PANEL_CA_PEM, + PANEL_CA_PENDING, ) from .schema_validation import SchemaFindings @@ -26,6 +29,11 @@ # No longer persisted as of entry version 7, but an entry that has not yet # been through the migration still carries it. CONF_HOP_PASSPHRASE, + # Not a secret — it is a public certificate — but multi-KB, and it would + # drown the dump. The diagnostically useful value is its fingerprint, which + # is injected separately below; `async_redact_data` is key-based and cannot + # transform a value. + CONF_PANEL_CA_PEM, "password", "username", } @@ -233,6 +241,23 @@ def _adoption(snapshot: SpanPanelSnapshot) -> AdoptionBlock: } +def _panel_ca(entry: SpanPanelConfigEntry) -> dict[str, Any]: + """How this entry's broker connection is anchored, without the PEM itself. + + The fingerprint is what a support conversation actually needs: it says + whether two installs see the same panel CA, and whether the anchor moved. + A malformed stored PEM is reported as such rather than raising — diagnostics + that fail to render are worse than diagnostics that name the problem. + """ + pem = entry.data.get(CONF_PANEL_CA_PEM) + if not pem: + return {"pinned": False, "pending": bool(entry.data.get(PANEL_CA_PENDING))} + try: + return {"pinned": True, "pending": False, "fingerprint": ca_fingerprint(str(pem))} + except SpanPanelValidationError as err: + return {"pinned": True, "pending": False, "fingerprint_error": str(err)} + + async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: SpanPanelConfigEntry, @@ -300,6 +325,7 @@ async def async_get_config_entry_diagnostics( return { "config_entry": async_redact_data(entry.as_dict(), TO_REDACT), + "panel_ca": _panel_ca(entry), "entities": _entity_registry_rows(hass, entry), "panel": panel_data, "circuits": circuit_data, diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index b3ffad2e..12ed6628 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -10,7 +10,8 @@ "frontend", "http", "panel_custom", - "persistent_notification" + "persistent_notification", + "repairs" ], "documentation": "https://github.com/SpanPanel/span", "integration_type": "device", diff --git a/custom_components/span_panel/migrations.py b/custom_components/span_panel/migrations.py index 872ab194..e2007b8e 100644 --- a/custom_components/span_panel/migrations.py +++ b/custom_components/span_panel/migrations.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .const import CONF_API_VERSION, CONF_HOP_PASSPHRASE +from .const import CONF_API_VERSION, CONF_HOP_PASSPHRASE, PANEL_CA_PENDING if TYPE_CHECKING: from . import SpanPanelConfigEntry @@ -124,7 +124,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: SpanPanelConfig ) _LOGGER.debug("Migrated config entry %s to version 6", config_entry.entry_id) - # --- v6 → v7: drop the stored panel passphrase --- + # --- v6 → v7: drop the stored passphrase, and queue the CA acquisition --- if config_entry.version < 7: updated_data = dict(config_entry.data) # The passphrase is a registration input only — nothing at runtime reads @@ -132,6 +132,15 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: SpanPanelConfig # that re-registers any client against the panel. removed = updated_data.pop(CONF_HOP_PASSPHRASE, None) is not None + # No I/O here, deliberately. This runs during startup, so a fetch would + # delay boot whenever the panel is unreachable and a failure would have + # nowhere to recover to. The flag defers it to the first successful + # setup and is cleared there; the same shape as solar_migration_pending + # above. Only v2 entries: v1 fails setup before it reaches a panel, and + # a simulation entry has none to fetch from. + if updated_data.get(CONF_API_VERSION) == "v2": + updated_data[PANEL_CA_PENDING] = True + hass.config_entries.async_update_entry( config_entry, data=updated_data, diff --git a/custom_components/span_panel/repairs.py b/custom_components/span_panel/repairs.py new file mode 100644 index 00000000..ffbb4064 --- /dev/null +++ b/custom_components/span_panel/repairs.py @@ -0,0 +1,110 @@ +"""Fix flows for this integration's Repairs. + +Discovered by name: Home Assistant looks for `repairs.async_create_fix_flow` in +the integration whenever an issue it raised is marked `is_fixable`. + +One issue is fixable, and everything here exists for it. See `ca_repairs`. +""" + +from __future__ import annotations + +import logging + +from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from span_panel_api import ca_fingerprint +from span_panel_api.exceptions import ( + SpanPanelAPIError, + SpanPanelConnectionError, + SpanPanelTimeoutError, + SpanPanelValidationError, +) +import voluptuous as vol + +from .ca_repairs import CA_CHANGED_ISSUE_PREFIX +from .config_flow_validation import async_fetch_panel_ca +from .const import CONF_HTTP_PORT, CONF_PANEL_CA_PEM, DOMAIN, PANEL_CA_PENDING + +_LOGGER = logging.getLogger(__name__) + + +class PanelCAChangedRepairFlow(RepairsFlow): + """Re-pin a panel's CA, but only on an explicit human acceptance. + + The fingerprint is taken from a *fresh* fetch rather than from the issue's + stored one. The two should agree, and if they do not the panel has changed + its CA twice — at which point the value the user is being asked to accept + has to be the one that will actually be stored, not a record of an earlier + observation. Accepting a fingerprint and pinning a different certificate + would make the confirmation meaningless. + """ + + def __init__(self, entry_id: str) -> None: + """Hold the entry this flow re-pins.""" + self._entry_id = entry_id + self._ca_pem: str | None = None + + async def async_step_init(self, user_input: dict[str, str] | None = None) -> RepairsFlowResult: + """Start at the confirmation.""" + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> RepairsFlowResult: + """Show the CA the panel is advertising now, and re-pin it if accepted.""" + entry = self.hass.config_entries.async_get_entry(self._entry_id) + if entry is None: + return self.async_abort(reason="entry_gone") + + if user_input is not None and self._ca_pem is not None: + data = dict(entry.data) + data[CONF_PANEL_CA_PEM] = self._ca_pem + data.pop(PANEL_CA_PENDING, None) + self.hass.config_entries.async_update_entry(entry, data=data) + _LOGGER.warning( + "Re-pinned the CA for SPAN panel %s to %s on explicit confirmation", + entry.title, + ca_fingerprint(self._ca_pem), + ) + # The issue is cleared by the fix flow completing; the reload is what + # puts the new anchor into a live transport. + await self.hass.config_entries.async_reload(entry.entry_id) + return self.async_create_entry(data={}) + + host = str(entry.data.get(CONF_HOST, "")) + try: + ca_pem = await async_fetch_panel_ca( + self.hass, host, http_port=int(entry.data.get(CONF_HTTP_PORT, 80)) + ) + fingerprint = ca_fingerprint(ca_pem) + except ( + SpanPanelAPIError, + SpanPanelConnectionError, + SpanPanelTimeoutError, + SpanPanelValidationError, + ) as err: + _LOGGER.warning("Could not re-read the CA from panel %s: %s", host, err) + return self.async_abort(reason="ca_unreadable") + + self._ca_pem = ca_pem + return self.async_show_form( + step_id="confirm", + data_schema=vol.Schema({}), + description_placeholders={ + "panel": entry.title, + "fingerprint": fingerprint, + }, + ) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Return the flow that fixes `issue_id`.""" + if issue_id.startswith(CA_CHANGED_ISSUE_PREFIX): + entry_id = issue_id.removeprefix(CA_CHANGED_ISSUE_PREFIX) + return PanelCAChangedRepairFlow(entry_id) + raise ValueError(f"{DOMAIN} raised no fixable issue with id {issue_id}") diff --git a/custom_components/span_panel/services.py b/custom_components/span_panel/services.py index fa65db63..f926c99c 100644 --- a/custom_components/span_panel/services.py +++ b/custom_components/span_panel/services.py @@ -9,7 +9,6 @@ from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, SupportsResponse from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.httpx_client import get_async_client from span_panel_api import regenerate_passphrase from span_panel_api.exceptions import ( SpanPanelAPIError, @@ -19,10 +18,10 @@ ) import voluptuous as vol +from .config_flow_validation import panel_rest_transport from .const import ( CONF_API_VERSION, CONF_EBUS_BROKER_PASSWORD, - CONF_HTTP_PORT, DEFAULT_GRAPH_HORIZON, DOMAIN, VALID_GRAPH_HORIZONS, @@ -727,12 +726,17 @@ async def async_handle_rotate_credentials(call: ServiceCall) -> None: translation_key="rotate_credentials_no_token", ) + # Over the pinned CA where this entry has one. A credential-rotation + # service that delivered fresh secrets over unverified HTTP would undo + # the point of pinning at the one moment it matters most. + transport = panel_rest_transport(hass, entry.data) try: new_password = await regenerate_passphrase( host, token, - port=int(entry.data.get(CONF_HTTP_PORT, 80)), - httpx_client=get_async_client(hass, verify_ssl=False), + port=transport.port, + httpx_client=transport.httpx_client, + ssl_context=transport.ssl_context, ) except SpanPanelAuthError as err: raise ServiceValidationError( diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index 25279a7e..1fc4babb 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -9,7 +9,8 @@ "reauth_successful": "Authentication successful.", "reconfigure_successful": "Host updated successfully.", "unique_id_mismatch": "The panel at this address has a different serial number. You cannot switch to a different panel during reconfiguration.", - "v1_not_supported": "v1 firmware is not supported. Please upgrade your panel firmware to v2." + "v1_not_supported": "v1 firmware is not supported. Please upgrade your panel firmware to v2.", + "host_not_set": "No panel address was set; restart the setup." }, "error": { "cannot_connect": "Failed to connect to Span Panel", @@ -119,6 +120,28 @@ }, "description": "Enter the connection details for your SPAN Panel.", "title": "Connect to the Span Panel" + }, + "panel_https_port": { + "title": "Panel HTTPS port", + "description": "You set a non-standard HTTP port for this panel, so it may serve TLS on a non-standard port too — for instance through a reverse proxy. Enter the port the panel's certificate is served on.", + "data": { + "https_port": "HTTPS port" + }, + "data_description": { + "https_port": "Leave at 443 unless a proxy or a port mapping moved it." + } + }, + "panel_ca": { + "title": "Trust this panel's certificate", + "description": "The panel published this certificate authority, and the certificate it serves is signed by it:\n\n**{fingerprint}**\n\nHome Assistant will accept only this authority for the connection to your panel from now on, and will stop and ask you before accepting a different one.\n\nThis is trust on first use. The integration fetched the authority over the local network and cannot tell it apart from one substituted by something else on that network — it can only tell you that nothing has changed since now. If you can read the fingerprint from another source, such as an existing install of this integration on the same panel, compare it before continuing." + }, + "panel_ca_failed": { + "title": "Could not verify the panel's certificate", + "description": "The panel's certificate authority could not be fetched, or the certificate the panel serves is not signed by it.\n\nYou can try again, or finish setting the panel up without pinning. Without a pin the integration still connects over TLS, but it re-fetches the authority on every connection and trusts whatever answers — so a device on your network could stand in for the panel. The integration retries the fetch every time it starts, and pins the authority as soon as one succeeds.", + "menu_options": { + "panel_ca": "Try again", + "panel_ca_skip": "Continue without pinning" + } } } }, @@ -800,6 +823,21 @@ "schema_unit_mismatch": { "title": "SPAN Panel unit mismatch", "description": "Your panel reports `{field_path}` in {schema_unit}, but this integration expects {ha_unit}. Entities affected: {count} (for example: {examples}). Their readings and long-term statistics may be wrong. They have not been changed automatically." + }, + "panel_ca_changed": { + "title": "SPAN Panel certificate authority changed", + "fix_flow": { + "step": { + "confirm": { + "title": "Trust the new certificate authority?", + "description": "{panel} is now advertising this certificate authority:\n\n**{fingerprint}**\n\nAccepting pins it and reconnects. Only do that if you know why it changed — a firmware upgrade or a factory reset rotates it legitimately.\n\nIf nothing should have changed, do not accept. A device on your network standing in for your panel looks exactly like this, and accepting would hand it the connection." + } + }, + "abort": { + "entry_gone": "That SPAN Panel is no longer configured.", + "ca_unreadable": "The panel's certificate authority could not be read. Check that the panel is reachable and try again." + } + } } } } diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index 25279a7e..1fc4babb 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -9,7 +9,8 @@ "reauth_successful": "Authentication successful.", "reconfigure_successful": "Host updated successfully.", "unique_id_mismatch": "The panel at this address has a different serial number. You cannot switch to a different panel during reconfiguration.", - "v1_not_supported": "v1 firmware is not supported. Please upgrade your panel firmware to v2." + "v1_not_supported": "v1 firmware is not supported. Please upgrade your panel firmware to v2.", + "host_not_set": "No panel address was set; restart the setup." }, "error": { "cannot_connect": "Failed to connect to Span Panel", @@ -119,6 +120,28 @@ }, "description": "Enter the connection details for your SPAN Panel.", "title": "Connect to the Span Panel" + }, + "panel_https_port": { + "title": "Panel HTTPS port", + "description": "You set a non-standard HTTP port for this panel, so it may serve TLS on a non-standard port too — for instance through a reverse proxy. Enter the port the panel's certificate is served on.", + "data": { + "https_port": "HTTPS port" + }, + "data_description": { + "https_port": "Leave at 443 unless a proxy or a port mapping moved it." + } + }, + "panel_ca": { + "title": "Trust this panel's certificate", + "description": "The panel published this certificate authority, and the certificate it serves is signed by it:\n\n**{fingerprint}**\n\nHome Assistant will accept only this authority for the connection to your panel from now on, and will stop and ask you before accepting a different one.\n\nThis is trust on first use. The integration fetched the authority over the local network and cannot tell it apart from one substituted by something else on that network — it can only tell you that nothing has changed since now. If you can read the fingerprint from another source, such as an existing install of this integration on the same panel, compare it before continuing." + }, + "panel_ca_failed": { + "title": "Could not verify the panel's certificate", + "description": "The panel's certificate authority could not be fetched, or the certificate the panel serves is not signed by it.\n\nYou can try again, or finish setting the panel up without pinning. Without a pin the integration still connects over TLS, but it re-fetches the authority on every connection and trusts whatever answers — so a device on your network could stand in for the panel. The integration retries the fetch every time it starts, and pins the authority as soon as one succeeds.", + "menu_options": { + "panel_ca": "Try again", + "panel_ca_skip": "Continue without pinning" + } } } }, @@ -800,6 +823,21 @@ "schema_unit_mismatch": { "title": "SPAN Panel unit mismatch", "description": "Your panel reports `{field_path}` in {schema_unit}, but this integration expects {ha_unit}. Entities affected: {count} (for example: {examples}). Their readings and long-term statistics may be wrong. They have not been changed automatically." + }, + "panel_ca_changed": { + "title": "SPAN Panel certificate authority changed", + "fix_flow": { + "step": { + "confirm": { + "title": "Trust the new certificate authority?", + "description": "{panel} is now advertising this certificate authority:\n\n**{fingerprint}**\n\nAccepting pins it and reconnects. Only do that if you know why it changed — a firmware upgrade or a factory reset rotates it legitimately.\n\nIf nothing should have changed, do not accept. A device on your network standing in for your panel looks exactly like this, and accepting would hand it the connection." + } + }, + "abort": { + "entry_gone": "That SPAN Panel is no longer configured.", + "ca_unreadable": "The panel's certificate authority could not be read. Check that the panel is reachable and try again." + } + } } } } diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index 29dff13e..6fc6a1be 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -3,6 +3,7 @@ "abort": { "already_configured": "Span Panel ya esta configurado. Solo una configuración es posible.", "cannot_connect": "No se logró establecer conexión con Span Panel", + "host_not_set": "No se ha definido la dirección del panel; reinicie la configuración.", "no_devices_found": "No se hallaron aparatos en la red", "no_host": "No se proporcionó una dirección de host desde el complemento.", "no_serial": "No se pudo determinar el número de serie del panel.", @@ -119,6 +120,28 @@ }, "description": "Ingrese los detalles de conexión para su SPAN Panel.", "title": "Establecer conexión al Span Panel" + }, + "panel_https_port": { + "title": "Puerto HTTPS del panel", + "description": "Ha configurado un puerto HTTP no estándar para este panel, por lo que también podría servir TLS en un puerto no estándar, por ejemplo a través de un proxy inverso. Introduzca el puerto en el que se sirve el certificado del panel.", + "data": { + "https_port": "Puerto HTTPS" + }, + "data_description": { + "https_port": "Déjelo en 443 salvo que un proxy o una redirección de puertos lo haya cambiado." + } + }, + "panel_ca": { + "title": "Confiar en el certificado de este panel", + "description": "El panel ha publicado esta autoridad de certificación, y el certificado que sirve está firmado por ella:\n\n**{fingerprint}**\n\nA partir de ahora, Home Assistant aceptará únicamente esta autoridad para la conexión con su panel, y se detendrá a preguntarle antes de aceptar otra distinta.\n\nEsto es confianza en el primer uso. La integración ha obtenido la autoridad a través de la red local y no puede distinguirla de una suplantada por otro equipo de esa red; solo puede avisarle si cambia a partir de ahora. Si puede leer la huella digital desde otra fuente, como otra instalación de esta integración sobre el mismo panel, compárela antes de continuar." + }, + "panel_ca_failed": { + "title": "No se ha podido verificar el certificado del panel", + "description": "No se ha podido obtener la autoridad de certificación del panel, o el certificado que sirve el panel no está firmado por ella.\n\nPuede volver a intentarlo o terminar de configurar el panel sin fijar la autoridad. Sin fijarla, la integración sigue conectándose por TLS, pero vuelve a obtener la autoridad en cada conexión y confía en lo que responda, de modo que un dispositivo de su red podría hacerse pasar por el panel. La integración reintenta la obtención cada vez que se inicia y fija la autoridad en cuanto lo consigue.", + "menu_options": { + "panel_ca": "Volver a intentarlo", + "panel_ca_skip": "Continuar sin fijar la autoridad" + } } } }, @@ -800,6 +823,21 @@ "schema_unit_mismatch": { "title": "Discrepancia de unidades del Panel SPAN", "description": "Su panel informa de `{field_path}` en {schema_unit}, pero esta integración espera {ha_unit}. Entidades afectadas: {count} (por ejemplo: {examples}). Sus lecturas y estadísticas a largo plazo pueden ser incorrectas. No se han modificado automáticamente." + }, + "panel_ca_changed": { + "title": "La autoridad de certificación del SPAN Panel ha cambiado", + "fix_flow": { + "step": { + "confirm": { + "title": "¿Confiar en la nueva autoridad de certificación?", + "description": "{panel} anuncia ahora esta autoridad de certificación:\n\n**{fingerprint}**\n\nAceptarla la fija y vuelve a conectar. Hágalo solo si sabe por qué ha cambiado: una actualización de firmware o un restablecimiento de fábrica la rotan de forma legítima.\n\nSi no debería haber cambiado nada, no la acepte. Un dispositivo de su red que suplante a su panel tiene exactamente este aspecto, y aceptarla le entregaría la conexión." + } + }, + "abort": { + "entry_gone": "Ese SPAN Panel ya no está configurado.", + "ca_unreadable": "No se ha podido leer la autoridad de certificación del panel. Compruebe que el panel es accesible e inténtelo de nuevo." + } + } } } } diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index 8b2e1278..7b07b09c 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -3,6 +3,7 @@ "abort": { "already_configured": "Span Panel déjà configuré. Une seule configuration est possible.", "cannot_connect": "Échec de la connexion au Span Panel", + "host_not_set": "Aucune adresse de panneau n'a été définie ; recommencez la configuration.", "no_devices_found": "Aucun appareil trouvé sur le réseau", "no_host": "Aucune adresse d'hôte n'a été fournie par le module complémentaire.", "no_serial": "Impossible de déterminer le numéro de série du panneau.", @@ -119,6 +120,28 @@ }, "description": "Entrez les détails de connexion pour votre SPAN Panel.", "title": "Connectez-vous au Span Panel" + }, + "panel_https_port": { + "title": "Port HTTPS du panneau", + "description": "Vous avez défini un port HTTP non standard pour ce panneau : il sert peut-être aussi TLS sur un port non standard, par exemple derrière un proxy inverse. Indiquez le port sur lequel le certificat du panneau est servi.", + "data": { + "https_port": "Port HTTPS" + }, + "data_description": { + "https_port": "Laissez 443, sauf si un proxy ou une redirection de port l'a déplacé." + } + }, + "panel_ca": { + "title": "Faire confiance au certificat de ce panneau", + "description": "Le panneau a publié cette autorité de certification, et le certificat qu'il présente est signé par elle :\n\n**{fingerprint}**\n\nDésormais, Home Assistant n'acceptera que cette autorité pour la connexion à votre panneau, et s'arrêtera pour vous demander avant d'en accepter une autre.\n\nIl s'agit d'une confiance à la première utilisation. L'intégration a récupéré l'autorité sur le réseau local et ne peut pas la distinguer d'une autorité substituée par un autre appareil de ce réseau ; elle peut seulement vous signaler un changement ultérieur. Si vous pouvez lire l'empreinte ailleurs — par exemple sur une autre installation de cette intégration reliée au même panneau — comparez-la avant de continuer." + }, + "panel_ca_failed": { + "title": "Impossible de vérifier le certificat du panneau", + "description": "L'autorité de certification du panneau n'a pas pu être récupérée, ou le certificat présenté par le panneau n'est pas signé par elle.\n\nVous pouvez réessayer ou terminer la configuration sans épinglage. Sans épinglage, l'intégration se connecte toujours en TLS, mais elle récupère l'autorité à chaque connexion et fait confiance à ce qui répond : un appareil de votre réseau pourrait donc se faire passer pour le panneau. L'intégration retente la récupération à chaque démarrage et épingle l'autorité dès qu'elle y parvient.", + "menu_options": { + "panel_ca": "Réessayer", + "panel_ca_skip": "Continuer sans épinglage" + } } } }, @@ -800,6 +823,21 @@ "schema_unit_mismatch": { "title": "Incohérence d'unité du Panneau SPAN", "description": "Votre panneau signale `{field_path}` en {schema_unit}, alors que cette intégration attend {ha_unit}. Entités concernées : {count} (par exemple : {examples}). Leurs relevés et leurs statistiques à long terme peuvent être erronés. Elles n'ont pas été modifiées automatiquement." + }, + "panel_ca_changed": { + "title": "L'autorité de certification du SPAN Panel a changé", + "fix_flow": { + "step": { + "confirm": { + "title": "Faire confiance à la nouvelle autorité de certification ?", + "description": "{panel} annonce désormais cette autorité de certification :\n\n**{fingerprint}**\n\nL'accepter l'épingle et rétablit la connexion. Ne le faites que si vous savez pourquoi elle a changé : une mise à jour du micrologiciel ou une réinitialisation d'usine la renouvelle légitimement.\n\nSi rien n'aurait dû changer, n'acceptez pas. Un appareil de votre réseau se faisant passer pour votre panneau ressemble exactement à ceci, et accepter lui remettrait la connexion." + } + }, + "abort": { + "entry_gone": "Ce SPAN Panel n'est plus configuré.", + "ca_unreadable": "L'autorité de certification du panneau n'a pas pu être lue. Vérifiez que le panneau est joignable et réessayez." + } + } } } } diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index 69e7a5ed..f27f922a 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -3,6 +3,7 @@ "abort": { "already_configured": "スパンパネルがすでに設定されています。設定は一つのみです。", "cannot_connect": "スパンパネルへの接続に失敗しました", + "host_not_set": "パネルのアドレスが設定されていません。セットアップをやり直してください。", "no_devices_found": "ネットワーク上にデバイスが見つかりません", "no_host": "アドオンからホストアドレスが提供されませんでした。", "no_serial": "パネルのシリアル番号を特定できませんでした。", @@ -119,6 +120,28 @@ }, "description": "SPAN Panelの接続詳細を入力してください。", "title": "スパンパネルに接続" + }, + "panel_https_port": { + "title": "パネルの HTTPS ポート", + "description": "このパネルに標準以外の HTTP ポートを設定しているため、リバースプロキシ経由などで TLS も標準以外のポートで提供されている可能性があります。パネルの証明書が提供されているポートを入力してください。", + "data": { + "https_port": "HTTPS ポート" + }, + "data_description": { + "https_port": "プロキシやポート変換で変更していない限り、443 のままにしてください。" + } + }, + "panel_ca": { + "title": "このパネルの証明書を信頼する", + "description": "パネルは次の認証局を公開しており、パネルが提供する証明書はこの認証局によって署名されています。\n\n**{fingerprint}**\n\n今後 Home Assistant は、パネルへの接続にこの認証局だけを受け入れ、別の認証局を受け入れる前に必ず停止して確認します。\n\nこれは最初の接続を信頼する方式(TOFU)です。統合はローカルネットワーク経由で認証局を取得しており、同じネットワーク上の別の機器がすり替えたものと区別できません。分かるのは、以後に変化があったかどうかだけです。同じパネルに接続している別のインストールなど、他の経路でフィンガープリントを確認できる場合は、続行する前に照合してください。" + }, + "panel_ca_failed": { + "title": "パネルの証明書を検証できませんでした", + "description": "パネルの認証局を取得できなかったか、パネルが提供する証明書がその認証局で署名されていません。\n\n再試行するか、ピン留めせずにセットアップを終えることができます。ピン留めしない場合も統合は TLS で接続しますが、接続のたびに認証局を取得し直して応答したものを信頼するため、ネットワーク上の別の機器がパネルになりすます余地が残ります。統合は起動のたびに取得を再試行し、成功した時点で認証局をピン留めします。", + "menu_options": { + "panel_ca": "再試行する", + "panel_ca_skip": "ピン留めせずに続行する" + } } } }, @@ -800,6 +823,21 @@ "schema_unit_mismatch": { "title": "SPAN パネルの単位の不一致", "description": "パネルは `{field_path}` を {schema_unit} で報告していますが、この統合は {ha_unit} を想定しています。{count} 個のエンティティが影響を受けます (例: {examples})。これらの測定値と長期統計が誤っている可能性があります。エンティティは自動的には変更されていません。" + }, + "panel_ca_changed": { + "title": "SPAN Panel の認証局が変更されました", + "fix_flow": { + "step": { + "confirm": { + "title": "新しい認証局を信頼しますか?", + "description": "{panel} は現在、次の認証局を通知しています。\n\n**{fingerprint}**\n\n受け入れるとピン留めして再接続します。変更された理由が分かっている場合にのみ実行してください。ファームウェアの更新や工場出荷時リセットでは正当に更新されます。\n\n何も変わっていないはずなら、受け入れないでください。ネットワーク上の別の機器がパネルになりすましている場合もまったく同じように見え、受け入れるとその機器に接続を渡すことになります。" + } + }, + "abort": { + "entry_gone": "その SPAN Panel はすでに設定されていません。", + "ca_unreadable": "パネルの認証局を読み取れませんでした。パネルに到達できることを確認して、もう一度お試しください。" + } + } } } } diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index c42a2998..f3de98c0 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -3,6 +3,7 @@ "abort": { "already_configured": "Painel Span já configurado. Só é possível uma única configuração.", "cannot_connect": "Falha ao ligar ao Painel Span", + "host_not_set": "Não foi definido nenhum endereço do painel; reinicie a configuração.", "no_devices_found": "Não foram encontrados dispositivos na rede", "no_host": "Nenhum endereço de anfitrião foi fornecido pelo add-on.", "no_serial": "Não foi possível determinar o número de série do painel.", @@ -119,6 +120,28 @@ }, "description": "Insira os detalhes de conexão para seu SPAN Panel.", "title": "Ligar ao Painel Span" + }, + "panel_https_port": { + "title": "Porta HTTPS do painel", + "description": "Definiu uma porta HTTP não padrão para este painel, pelo que também poderá servir TLS numa porta não padrão — por exemplo através de um proxy inverso. Indique a porta em que o certificado do painel é servido.", + "data": { + "https_port": "Porta HTTPS" + }, + "data_description": { + "https_port": "Deixe em 443, a não ser que um proxy ou um mapeamento de portas a tenha mudado." + } + }, + "panel_ca": { + "title": "Confiar no certificado deste painel", + "description": "O painel publicou esta autoridade de certificação, e o certificado que serve está assinado por ela:\n\n**{fingerprint}**\n\nA partir de agora o Home Assistant aceitará apenas esta autoridade para a ligação ao seu painel, e irá parar e perguntar-lhe antes de aceitar outra.\n\nIsto é confiança na primeira utilização. A integração obteve a autoridade através da rede local e não a consegue distinguir de uma substituída por outro equipamento dessa rede — apenas lhe pode dizer se mudar daqui em diante. Se conseguir ler a impressão digital noutra origem, como outra instalação desta integração ligada ao mesmo painel, compare-a antes de continuar." + }, + "panel_ca_failed": { + "title": "Não foi possível verificar o certificado do painel", + "description": "Não foi possível obter a autoridade de certificação do painel, ou o certificado que o painel serve não está assinado por ela.\n\nPode tentar novamente ou terminar a configuração sem fixar a autoridade. Sem a fixar, a integração continua a ligar-se por TLS, mas volta a obter a autoridade em cada ligação e confia no que responder — pelo que um equipamento da sua rede se poderia fazer passar pelo painel. A integração repete a tentativa sempre que arranca e fixa a autoridade assim que uma tentativa tiver êxito.", + "menu_options": { + "panel_ca": "Tentar novamente", + "panel_ca_skip": "Continuar sem fixar a autoridade" + } } } }, @@ -800,6 +823,21 @@ "schema_unit_mismatch": { "title": "Discrepância de unidades do Painel SPAN", "description": "O seu painel comunica `{field_path}` em {schema_unit}, mas esta integração espera {ha_unit}. Entidades afetadas: {count} (por exemplo: {examples}). As suas leituras e estatísticas de longo prazo podem estar erradas. Não foram alteradas automaticamente." + }, + "panel_ca_changed": { + "title": "A autoridade de certificação do SPAN Panel mudou", + "fix_flow": { + "step": { + "confirm": { + "title": "Confiar na nova autoridade de certificação?", + "description": "{panel} anuncia agora esta autoridade de certificação:\n\n**{fingerprint}**\n\nAceitá-la fixa-a e volta a ligar. Faça-o apenas se souber por que mudou — uma atualização de firmware ou uma reposição de fábrica rodam-na legitimamente.\n\nSe nada devia ter mudado, não aceite. Um equipamento da sua rede a fazer-se passar pelo seu painel tem exatamente este aspeto, e aceitar entregar-lhe-ia a ligação." + } + }, + "abort": { + "entry_gone": "Esse SPAN Panel já não está configurado.", + "ca_unreadable": "Não foi possível ler a autoridade de certificação do painel. Verifique se o painel está acessível e tente novamente." + } + } } } } diff --git a/tests/test_config_flow_validation.py b/tests/test_config_flow_validation.py index e61e3236..443af10d 100644 --- a/tests/test_config_flow_validation.py +++ b/tests/test_config_flow_validation.py @@ -10,6 +10,7 @@ from span_panel_api.exceptions import SpanPanelConnectionError from custom_components.span_panel.config_flow_validation import ( + async_leaf_chains_to_ca, check_fqdn_tls_ready, is_fqdn, validate_host, @@ -164,9 +165,12 @@ async def test_validate_v2_helpers_delegate_register_v2() -> None: "Home Assistant", "passphrase", ) + # No transport given, so the plaintext bootstrap shape: shared client, no + # context. Initial registration is the case with nothing pinned yet. assert mock_register.await_args_list[0].kwargs == { "port": 8080, "httpx_client": fake_client, + "ssl_context": None, } assert mock_register.await_args_list[1].args == ( "panel.example.com", @@ -175,6 +179,7 @@ async def test_validate_v2_helpers_delegate_register_v2() -> None: assert mock_register.await_args_list[1].kwargs == { "port": 9090, "httpx_client": fake_client, + "ssl_context": None, } @@ -240,20 +245,10 @@ def __enter__(self): def __exit__(self, exc_type, exc, tb): return False - class FakeWrappedSocket(FakeSocket): - pass - class FakeSSLContext: - def __init__(self, _protocol) -> None: - self.check_hostname = False - self.verify_mode = ssl.CERT_NONE - - def load_verify_locations(self, _path: str) -> None: - return None - def wrap_socket(self, _sock, server_hostname: str): assert server_hostname == "panel.example.com" - return FakeWrappedSocket() + return FakeSocket() hass = MagicMock() fake_client = MagicMock() @@ -271,9 +266,9 @@ def wrap_socket(self, _sock, server_hostname: str): return_value=FakeLoop(), ), patch( - "custom_components.span_panel.config_flow_validation.ssl.SSLContext", - side_effect=FakeSSLContext, - ), + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=FakeSSLContext(), + ) as build_context, patch( "custom_components.span_panel.config_flow_validation.socket.create_connection", return_value=FakeSocket(), @@ -281,6 +276,10 @@ def wrap_socket(self, _sock, server_hostname: str): ): assert await check_fqdn_tls_ready(hass, "panel.example.com", 8883) is True + # The library's builder, not a hand-rolled context: it is the one that clears + # VERIFY_X509_STRICT, which the panel's AKI-less CA fails without. + build_context.assert_called_once_with("pem-data") + @pytest.mark.asyncio async def test_check_fqdn_tls_ready_returns_false_on_ssl_error() -> None: @@ -298,13 +297,6 @@ def __exit__(self, exc_type, exc, tb): return False class FakeSSLContext: - def __init__(self, _protocol) -> None: - self.check_hostname = False - self.verify_mode = ssl.CERT_NONE - - def load_verify_locations(self, _path: str) -> None: - return None - def wrap_socket(self, _sock, server_hostname: str): raise ssl.SSLError(f"bad cert for {server_hostname}") @@ -324,8 +316,8 @@ def wrap_socket(self, _sock, server_hostname: str): return_value=FakeLoop(), ), patch( - "custom_components.span_panel.config_flow_validation.ssl.SSLContext", - side_effect=FakeSSLContext, + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=FakeSSLContext(), ), patch( "custom_components.span_panel.config_flow_validation.socket.create_connection", @@ -333,3 +325,30 @@ def wrap_socket(self, _sock, server_hostname: str): ), ): assert await check_fqdn_tls_ready(hass, "panel.example.com", 8883) is False + + +@pytest.mark.asyncio +async def test_leaf_validation_rejects_a_ca_that_does_not_parse() -> None: + """A PEM the ssl module will not load is not an anchor, and is not fatal.""" + + class FakeLoop: + async def run_in_executor(self, _executor, func): + return func() + + with ( + patch( + "custom_components.span_panel.config_flow_validation.asyncio.get_running_loop", + return_value=FakeLoop(), + ), + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + side_effect=ssl.SSLError("not a certificate"), + ), + patch( + "custom_components.span_panel.config_flow_validation.socket.create_connection" + ) as connect, + ): + assert await async_leaf_chains_to_ca("panel.example.com", 443, "junk") is False + + # Nothing is dialled when there is no anchor to dial with. + connect.assert_not_called() diff --git a/tests/test_panel_ca_pinning.py b/tests/test_panel_ca_pinning.py new file mode 100644 index 00000000..71c05261 --- /dev/null +++ b/tests/test_panel_ca_pinning.py @@ -0,0 +1,420 @@ +"""Tests for pinning the panel's CA: acquisition, deferral, and the change repair.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from span_panel_api.exceptions import SpanPanelCAChangedError, SpanPanelConnectionError + +from custom_components.span_panel import _async_pinned_ca, async_migrate_entry +from custom_components.span_panel.ca_repairs import ( + async_clear_ca_changed, + async_raise_ca_changed, + ca_changed_issue_id, +) +from custom_components.span_panel.const import ( + CONF_API_VERSION, + CONF_HTTP_PORT, + CONF_PANEL_CA_PEM, + DOMAIN, + PANEL_CA_PENDING, +) +from homeassistant import config_entries +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from pytest_homeassistant_custom_component.common import MockConfigEntry + +PEM = "-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n" +OTHER_PEM = "-----BEGIN CERTIFICATE-----\nb3RoZXI=\n-----END CERTIFICATE-----\n" + + +def _entry(hass: HomeAssistant, **data: object) -> MockConfigEntry: + """Add a v7 entry carrying the given data.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=7, + title="Span Panel", + data={ + CONF_HOST: "192.168.1.100", + CONF_ACCESS_TOKEN: "token", + CONF_API_VERSION: "v2", + **data, + }, + source=config_entries.SOURCE_USER, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + return entry + + +# ---------- acquisition at setup ---------- + + +@pytest.mark.asyncio +async def test_a_stored_pin_is_used_without_any_fetch(hass: HomeAssistant) -> None: + """An entry that already carries a CA does not go back to the panel for one.""" + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + + with patch( + "custom_components.span_panel.async_fetch_panel_ca", new=AsyncMock() + ) as fetch: + assert await _async_pinned_ca(hass, entry, "192.168.1.100", 80) == PEM + + fetch.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_the_deferred_fetch_stores_the_ca_and_clears_the_flag( + hass: HomeAssistant, +) -> None: + """A migrated entry acquires its CA at the first setup that reaches the panel.""" + entry = _entry(hass, **{PANEL_CA_PENDING: True}) + + with patch( + "custom_components.span_panel.async_fetch_panel_ca", + new=AsyncMock(return_value=PEM), + ) as fetch: + assert await _async_pinned_ca(hass, entry, "192.168.1.100", 8080) == PEM + + fetch.assert_awaited_once_with(hass, "192.168.1.100", http_port=8080) + assert entry.data[CONF_PANEL_CA_PEM] == PEM + assert PANEL_CA_PENDING not in entry.data + + +@pytest.mark.asyncio +async def test_a_failed_deferred_fetch_keeps_the_flag_and_does_not_fail_setup( + hass: HomeAssistant, +) -> None: + """An unreachable certificate endpoint must not cost the user their integration.""" + entry = _entry(hass, **{PANEL_CA_PENDING: True}) + + with patch( + "custom_components.span_panel.async_fetch_panel_ca", + new=AsyncMock(side_effect=SpanPanelConnectionError("unreachable")), + ): + assert await _async_pinned_ca(hass, entry, "192.168.1.100", 80) is None + + # Still pending, so the next setup retries for free. + assert entry.data[PANEL_CA_PENDING] is True + assert CONF_PANEL_CA_PEM not in entry.data + + +@pytest.mark.asyncio +async def test_an_entry_with_neither_pin_nor_flag_is_left_alone( + hass: HomeAssistant, +) -> None: + """Nothing acquires a CA behind the user's back.""" + entry = _entry(hass) + + with patch( + "custom_components.span_panel.async_fetch_panel_ca", new=AsyncMock() + ) as fetch: + assert await _async_pinned_ca(hass, entry, "192.168.1.100", 80) is None + + fetch.assert_not_awaited() + + +# ---------- the migration that queues it ---------- + + +@pytest.mark.asyncio +async def test_v7_flags_a_v2_entry_for_ca_acquisition(hass: HomeAssistant) -> None: + """The migration queues the fetch rather than performing it.""" + entry = MockConfigEntry( + version=6, + domain=DOMAIN, + title="Span Panel", + data={CONF_HOST: "192.168.1.100", CONF_API_VERSION: "v2"}, + source=config_entries.SOURCE_USER, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + with patch( + "custom_components.span_panel.migrations.async_migrate_entry", + wraps=async_migrate_entry, + ): + assert await async_migrate_entry(hass, entry) is True + + assert entry.data[PANEL_CA_PENDING] is True + # No PEM: the migration does no I/O at all. + assert CONF_PANEL_CA_PEM not in entry.data + + +@pytest.mark.asyncio +@pytest.mark.parametrize("api_version", ["v1", "simulation"]) +async def test_non_v2_entries_are_never_flagged( + hass: HomeAssistant, api_version: str +) -> None: + """v1 fails setup before it reaches a panel, and a simulation has none.""" + entry = MockConfigEntry( + version=6, + domain=DOMAIN, + title="Span Panel", + data={CONF_HOST: "192.168.1.100", CONF_API_VERSION: api_version}, + source=config_entries.SOURCE_USER, + unique_id=f"SPAN-{api_version}", + ) + entry.add_to_hass(hass) + + assert await async_migrate_entry(hass, entry) is True + + assert PANEL_CA_PENDING not in entry.data + + +# ---------- the repair ---------- + + +@pytest.mark.asyncio +async def test_a_changed_ca_raises_a_fixable_repair_carrying_both_fingerprints( + hass: HomeAssistant, +) -> None: + """The user needs both values, because the two remedies are opposite.""" + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + + async_raise_ca_changed(hass, entry, "aa" * 32, "bb" * 32) + + issue = ir.async_get(hass).async_get_issue(DOMAIN, ca_changed_issue_id(entry.entry_id)) + assert issue is not None + assert issue.is_fixable is True + # Persistent, because the transport it describes is already dead — there is + # no live state left to re-assert it from. + assert issue.is_persistent is True + assert issue.severity == ir.IssueSeverity.ERROR + assert issue.translation_placeholders is not None + assert issue.translation_placeholders["expected_fingerprint"] == "aa" * 32 + assert issue.translation_placeholders["observed_fingerprint"] == "bb" * 32 + + +@pytest.mark.asyncio +async def test_a_clean_connection_clears_a_standing_repair(hass: HomeAssistant) -> None: + """The Repair describes a state that a successful handshake disproves.""" + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + async_raise_ca_changed(hass, entry, "aa" * 32, "bb" * 32) + + async_clear_ca_changed(hass, entry) + + assert ( + ir.async_get(hass).async_get_issue(DOMAIN, ca_changed_issue_id(entry.entry_id)) + is None + ) + + +@pytest.mark.asyncio +async def test_the_fix_flow_re_pins_only_on_an_explicit_confirmation( + hass: HomeAssistant, +) -> None: + """Showing the fingerprint must not itself accept it.""" + from custom_components.span_panel.repairs import PanelCAChangedRepairFlow + + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM, CONF_HTTP_PORT: 8080}) + flow = PanelCAChangedRepairFlow(entry.entry_id) + flow.hass = hass + + with patch( + "custom_components.span_panel.repairs.async_fetch_panel_ca", + new=AsyncMock(return_value=OTHER_PEM), + ) as fetch: + shown = await flow.async_step_init() + + fetch.assert_awaited_once_with(hass, "192.168.1.100", http_port=8080) + assert shown["type"] == "form" + assert shown["description_placeholders"] is not None + # The fingerprint offered is of the certificate that would actually be + # stored, not a record of an earlier observation. + assert shown["description_placeholders"]["fingerprint"] + # Nothing has been accepted yet. + assert entry.data[CONF_PANEL_CA_PEM] == PEM + + with patch.object(hass.config_entries, "async_reload", new=AsyncMock()) as reload: + done = await flow.async_step_confirm({}) + + assert done["type"] == "create_entry" + assert entry.data[CONF_PANEL_CA_PEM] == OTHER_PEM + reload.assert_awaited_once_with(entry.entry_id) + + +@pytest.mark.asyncio +async def test_the_fix_flow_aborts_when_the_panel_cannot_be_read( + hass: HomeAssistant, +) -> None: + """A fingerprint that cannot be read is not one a user can be asked to accept.""" + from custom_components.span_panel.repairs import PanelCAChangedRepairFlow + + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + flow = PanelCAChangedRepairFlow(entry.entry_id) + flow.hass = hass + + with patch( + "custom_components.span_panel.repairs.async_fetch_panel_ca", + new=AsyncMock(side_effect=SpanPanelConnectionError("unreachable")), + ): + result = await flow.async_step_init() + + assert result["type"] == "abort" + assert result["reason"] == "ca_unreadable" + assert entry.data[CONF_PANEL_CA_PEM] == PEM + + +@pytest.mark.asyncio +async def test_the_coordinator_takes_entities_unavailable_on_a_ca_change( + hass: HomeAssistant, +) -> None: + """A dead transport must not keep serving the snapshot read before it died.""" + from homeassistant.helpers.update_coordinator import UpdateFailed + + from custom_components.span_panel.coordinator import SpanPanelCoordinator + + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + client = MagicMock() + client.get_snapshot = AsyncMock( + side_effect=SpanPanelCAChangedError("aa" * 32, "bb" * 32) + ) + coordinator = SpanPanelCoordinator(hass, client, entry) + # A previous good snapshot, which the ordinary offline path would keep serving. + coordinator.data = MagicMock() + + with pytest.raises(UpdateFailed): + await coordinator._async_update_data() + + assert coordinator.panel_offline is True + + +# ---------- the transport those credentials travel over ---------- + + +@pytest.mark.asyncio +async def test_an_unpinned_entry_uses_the_plaintext_bootstrap_port( + hass: HomeAssistant, +) -> None: + """No pin, no context: exactly the transport this integration always had.""" + from custom_components.span_panel.config_flow_validation import panel_rest_transport + + entry = _entry(hass, **{CONF_HTTP_PORT: 8080}) + + transport = panel_rest_transport(hass, entry.data) + + assert transport.port == 8080 + assert transport.ssl_context is None + # Home Assistant's shared client, which it owns and closes at shutdown. + assert transport.httpx_client is not None + + +@pytest.mark.asyncio +async def test_a_pinned_entry_moves_to_tls_and_drops_the_shared_client( + hass: HomeAssistant, +) -> None: + """httpx fixes its trust store at construction, so a pin needs its own client.""" + from custom_components.span_panel.config_flow_validation import panel_rest_transport + + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM, CONF_HTTP_PORT: 8080}) + context = MagicMock() + + with patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=context, + ): + transport = panel_rest_transport(hass, entry.data) + + # 443, not the stored plaintext 8080 — the library refuses port 80 with a + # context outright, and a plaintext port under TLS is never what was meant. + assert transport.port == 443 + assert transport.ssl_context is context + assert transport.httpx_client is None + + +@pytest.mark.asyncio +async def test_a_pinned_entry_honours_a_configured_https_port( + hass: HomeAssistant, +) -> None: + """A reverse proxy does not have to listen on 443.""" + from custom_components.span_panel.config_flow_validation import panel_rest_transport + from custom_components.span_panel.const import CONF_HTTPS_PORT + + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM, CONF_HTTPS_PORT: 9443}) + + with patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ): + transport = panel_rest_transport(hass, entry.data) + + assert transport.port == 9443 + + +@pytest.mark.asyncio +async def test_an_unusable_stored_pem_falls_back_rather_than_bricking_the_entry( + hass: HomeAssistant, +) -> None: + """A hand-edited `.storage` must not leave an entry unable to make a call.""" + import ssl + + from custom_components.span_panel.config_flow_validation import panel_rest_transport + + entry = _entry(hass, **{CONF_PANEL_CA_PEM: "not a certificate"}) + + with patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + side_effect=ssl.SSLError("nope"), + ): + transport = panel_rest_transport(hass, entry.data) + + assert transport.ssl_context is None + assert transport.port == 80 + + +@pytest.mark.asyncio +async def test_rotation_goes_over_the_pin_when_the_entry_has_one( + hass: HomeAssistant, +) -> None: + """Fresh secrets over unverified HTTP would undo the pin where it matters most.""" + from homeassistant.config_entries import ConfigEntryState + from homeassistant.core import Context + + from custom_components.span_panel import ( + SpanPanelRuntimeData, + _async_register_credential_services, + ) + from custom_components.span_panel.const import CONF_EBUS_BROKER_PASSWORD + + from pytest_homeassistant_custom_component.common import MockUser + + entry = _entry( + hass, + **{CONF_PANEL_CA_PEM: PEM, CONF_EBUS_BROKER_PASSWORD: "old", CONF_HTTP_PORT: 8080}, + ) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=MagicMock(), panel_device_id="panel-device-id" + ) + _async_register_credential_services(hass) + + context = MagicMock() + user = MockUser(is_owner=True).add_to_hass(hass) + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=context, + ), + patch( + "custom_components.span_panel.services.regenerate_passphrase", + new=AsyncMock(return_value="new"), + ) as rotate, + patch.object(hass.config_entries, "async_reload", new=AsyncMock()), + ): + await hass.services.async_call( + DOMAIN, + "rotate_credentials", + {}, + blocking=True, + context=Context(user_id=user.id), + ) + + assert rotate.await_args is not None + assert rotate.await_args.kwargs["ssl_context"] is context + assert rotate.await_args.kwargs["port"] == 443 + assert rotate.await_args.kwargs["httpx_client"] is None + assert entry.data[CONF_EBUS_BROKER_PASSWORD] == "new" diff --git a/tests/test_v2_config_flow.py b/tests/test_v2_config_flow.py index c5e6b16a..d4b33de8 100644 --- a/tests/test_v2_config_flow.py +++ b/tests/test_v2_config_flow.py @@ -27,9 +27,12 @@ CONF_EBUS_BROKER_USERNAME, CONF_HOP_PASSPHRASE, CONF_HTTP_PORT, + CONF_HTTPS_PORT, + CONF_PANEL_CA_PEM, CONF_PANEL_SERIAL, CONF_REGISTERED_FQDN, DOMAIN, + PANEL_CA_PENDING, ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.core import HomeAssistant @@ -50,6 +53,40 @@ # ---------- helpers ---------- +FAKE_CA_PEM = "-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n" +FAKE_CA_FINGERPRINT = "0" * 64 + + +@pytest.fixture(autouse=True) +def panel_ca_available(): + """Answer the CA step without touching the network. + + Autouse because every flow that reaches entry creation now passes through + it, and a test that did not patch it would try to open a socket. + """ + with ( + patch( + "custom_components.span_panel.config_flow.async_fetch_panel_ca", + new=AsyncMock(return_value=FAKE_CA_PEM), + ), + patch( + "custom_components.span_panel.config_flow.async_leaf_chains_to_ca", + new=AsyncMock(return_value=True), + ), + patch( + "custom_components.span_panel.config_flow.ca_fingerprint", + return_value=FAKE_CA_FINGERPRINT, + ), + ): + yield + + +async def _confirm_panel_ca(hass: HomeAssistant, flow_id: str): + """Accept the fingerprint the CA step is showing.""" + result = await hass.config_entries.flow.async_configure(flow_id, {}) + return result + + MOCK_HOST = "192.168.1.100" MOCK_PASSPHRASE = "correct-horse-battery-staple" @@ -233,8 +270,14 @@ async def test_passphrase_auth_success(hass: HomeAssistant) -> None: {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE}, ) + # The CA is fetched and confirmed before anything else is asked. assert result3["type"] == FlowResultType.FORM - assert result3["step_id"] == "choose_entity_naming_initial" + assert result3["step_id"] == "panel_ca" + assert result3["description_placeholders"] == {"fingerprint": FAKE_CA_FINGERPRINT} + + result4 = await _confirm_panel_ca(hass, result3["flow_id"]) + assert result4["type"] == FlowResultType.FORM + assert result4["step_id"] == "choose_entity_naming_initial" @pytest.mark.asyncio @@ -364,9 +407,12 @@ async def test_v2_entry_contains_mqtt_credentials(hass: HomeAssistant) -> None: {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE}, ) - # Step 4: choose entity naming pattern (accept default) + # Step 4: accept the panel's CA fingerprint + result3b = await _confirm_panel_ca(hass, result3["flow_id"]) + + # Step 5: choose entity naming pattern (accept default) result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], + result3b["flow_id"], {"entity_naming_pattern": "friendly_names"}, ) @@ -384,6 +430,94 @@ async def test_v2_entry_contains_mqtt_credentials(hass: HomeAssistant) -> None: assert data[CONF_PANEL_SERIAL] == "SPAN-V2-001" +async def _reach_the_ca_step(hass: HomeAssistant): + """Drive a user flow as far as the CA step and return that result.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: MOCK_HOST} + ) + result2b = await hass.config_entries.flow.async_configure( + result2["flow_id"], {"next_step_id": "auth_passphrase"} + ) + return await hass.config_entries.flow.async_configure( + result2b["flow_id"], {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE} + ) + + +@pytest.mark.asyncio +async def test_a_ca_that_does_not_sign_what_the_panel_serves_is_not_offered( + hass: HomeAssistant, +) -> None: + """The user is never shown a fingerprint that already failed validation.""" + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch("custom_components.span_panel.config_flow.validate_host", return_value=True), + patch( + "custom_components.span_panel.config_flow.validate_v2_passphrase", + return_value=MOCK_V2_AUTH, + ), + patch( + "custom_components.span_panel.config_flow.async_leaf_chains_to_ca", + new=AsyncMock(return_value=False), + ), + ): + result = await _reach_the_ca_step(hass) + + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "panel_ca_failed" + + +@pytest.mark.asyncio +async def test_a_fetch_failure_offers_a_retry_and_a_way_past( + hass: HomeAssistant, +) -> None: + """An unreachable certificate endpoint is not a hard setup failure. + + Continuing creates the entry with the pending flag rather than a pin, so + every later setup retries the fetch on its own. + """ + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch("custom_components.span_panel.config_flow.validate_host", return_value=True), + patch( + "custom_components.span_panel.config_flow.validate_v2_passphrase", + return_value=MOCK_V2_AUTH, + ), + patch( + "custom_components.span_panel.config_flow.async_fetch_panel_ca", + new=AsyncMock(side_effect=SpanPanelConnectionError("unreachable")), + ), + patch( + "custom_components.span_panel.async_setup_entry", + new=AsyncMock(return_value=True), + ), + ): + result = await _reach_the_ca_step(hass) + assert result["type"] == FlowResultType.MENU + assert set(result["menu_options"]) == {"panel_ca", "panel_ca_skip"} + + skipped = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "panel_ca_skip"} + ) + assert skipped["step_id"] == "choose_entity_naming_initial" + + created = await hass.config_entries.flow.async_configure( + skipped["flow_id"], {"entity_naming_pattern": "friendly_names"} + ) + + assert created["type"] == FlowResultType.CREATE_ENTRY + assert CONF_PANEL_CA_PEM not in created["data"] + assert created["data"][PANEL_CA_PENDING] is True + + # ---------- config entry migration (2.0.4 baseline) ---------- @@ -872,7 +1006,11 @@ async def test_passphrase_auth_recovery_after_error(hass: HomeAssistant) -> None {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE}, ) assert result4["type"] == FlowResultType.FORM - assert result4["step_id"] == "choose_entity_naming_initial" + assert result4["step_id"] == "panel_ca" + + result5 = await _confirm_panel_ca(hass, result4["flow_id"]) + assert result5["type"] == FlowResultType.FORM + assert result5["step_id"] == "choose_entity_naming_initial" # ---------- proximity auth ---------- @@ -918,7 +1056,11 @@ async def test_proximity_auth_success(hass: HomeAssistant) -> None: ) assert result3["type"] == FlowResultType.FORM - assert result3["step_id"] == "choose_entity_naming_initial" + assert result3["step_id"] == "panel_ca" + + result4 = await _confirm_panel_ca(hass, result3["flow_id"]) + assert result4["type"] == FlowResultType.FORM + assert result4["step_id"] == "choose_entity_naming_initial" @pytest.mark.asyncio @@ -1209,11 +1351,15 @@ async def test_zeroconf_end_to_end_entry_creation(hass: HomeAssistant) -> None: result3["flow_id"], {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE}, ) - assert result4["step_id"] == "choose_entity_naming_initial" + assert result4["step_id"] == "panel_ca" + + # Step 5: accept the CA fingerprint + result4b = await _confirm_panel_ca(hass, result4["flow_id"]) + assert result4b["step_id"] == "choose_entity_naming_initial" - # Step 5: accept naming default → entry created + # Step 6: accept naming default → entry created result5 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result4b["flow_id"], {"entity_naming_pattern": "friendly_names"}, ) assert result5["type"] == FlowResultType.CREATE_ENTRY @@ -1688,17 +1834,29 @@ async def test_hassio_end_to_end_entry_creation(hass: HomeAssistant) -> None: result3["flow_id"], {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE}, ) - assert result4["step_id"] == "choose_entity_naming_initial" + # Step 5: a non-default HTTP port means the TLS one is asked for too. + assert result4["step_id"] == "panel_https_port" + result4b = await hass.config_entries.flow.async_configure( + result4["flow_id"], + {CONF_HTTPS_PORT: 9443}, + ) + + # Step 6: accept the CA fingerprint + assert result4b["step_id"] == "panel_ca" + result4c = await _confirm_panel_ca(hass, result4b["flow_id"]) + assert result4c["step_id"] == "choose_entity_naming_initial" - # Step 5: accept naming default -> entry created + # Step 7: accept naming default -> entry created result5 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result4c["flow_id"], {"entity_naming_pattern": "friendly_names"}, ) assert result5["type"] == FlowResultType.CREATE_ENTRY assert result5["data"][CONF_API_VERSION] == "v2" assert result5["data"][CONF_HOST] == "192.168.1.50" assert result5["data"][CONF_HTTP_PORT] == 9090 + assert result5["data"][CONF_HTTPS_PORT] == 9443 + assert result5["data"][CONF_PANEL_CA_PEM] == FAKE_CA_PEM # ---------- user flow: null status_info ---------- @@ -1859,8 +2017,20 @@ async def test_user_flow_fqdn_registration_progress_then_naming( result2b["flow_id"], {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE}, ) - assert result3["type"] == FlowResultType.CREATE_ENTRY - assert result3["data"][CONF_REGISTERED_FQDN] == "panel.example.com" + assert result3["type"] == FlowResultType.FORM + assert result3["step_id"] == "panel_ca" + + result4 = await _confirm_panel_ca(hass, result3["flow_id"]) + assert result4["type"] == FlowResultType.FORM + assert result4["step_id"] == "choose_entity_naming_initial" + + result5 = await hass.config_entries.flow.async_configure( + result4["flow_id"], + {"entity_naming_pattern": "friendly_names"}, + ) + assert result5["type"] == FlowResultType.CREATE_ENTRY + assert result5["data"][CONF_REGISTERED_FQDN] == "panel.example.com" + assert result5["data"][CONF_PANEL_CA_PEM] == FAKE_CA_PEM @pytest.mark.usefixtures("socket_enabled") @@ -1911,7 +2081,7 @@ async def test_user_flow_fqdn_registration_failure_can_continue( {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE}, ) assert result3["type"] == FlowResultType.FORM - assert result3["step_id"] == "choose_entity_naming_initial" + assert result3["step_id"] == "panel_ca" @pytest.mark.usefixtures("socket_enabled") @@ -1973,11 +2143,26 @@ async def test_fqdn_entry_creation_sets_registered_fqdn_and_unique_title( result2b["flow_id"], {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE}, ) - assert result3["type"] == FlowResultType.CREATE_ENTRY - assert result3["title"] == "Span Panel 2" - assert result3["data"][CONF_HOST] == "panel.example.com" - assert result3["data"][CONF_REGISTERED_FQDN] == "panel.example.com" - assert result3["data"][CONF_HTTP_PORT] == 8080 + assert result3["type"] == FlowResultType.FORM + assert result3["step_id"] == "panel_https_port" + + # Left at the default, so it is not written to the entry. + result3b = await hass.config_entries.flow.async_configure( + result3["flow_id"], + {CONF_HTTPS_PORT: 443}, + ) + result4 = await _confirm_panel_ca(hass, result3b["flow_id"]) + result5 = await hass.config_entries.flow.async_configure( + result4["flow_id"], + {"entity_naming_pattern": "friendly_names"}, + ) + + assert result5["type"] == FlowResultType.CREATE_ENTRY + assert result5["title"] == "Span Panel 2" + assert result5["data"][CONF_HOST] == "panel.example.com" + assert result5["data"][CONF_REGISTERED_FQDN] == "panel.example.com" + assert result5["data"][CONF_HTTP_PORT] == 8080 + assert CONF_HTTPS_PORT not in result5["data"] @pytest.mark.asyncio @@ -2131,8 +2316,10 @@ async def test_reconfigure_switch_from_fqdn_to_ip_clears_registration( fake_client = MagicMock() with ( + # The transport is resolved in config_flow_validation, which is where the + # shared client is taken from when the entry has no pinned CA. patch( - "custom_components.span_panel.config_flow.get_async_client", + "custom_components.span_panel.config_flow_validation.get_async_client", return_value=fake_client, ), patch( @@ -2152,8 +2339,9 @@ async def test_reconfigure_switch_from_fqdn_to_ip_clears_registration( assert result2["type"] == FlowResultType.ABORT assert result2["reason"] == "reconfigure_successful" + # Unpinned entry: the plaintext port and the shared client, and no context. mock_delete.assert_awaited_once_with( - "192.168.1.201", "token", port=80, httpx_client=fake_client + "192.168.1.201", "token", port=80, httpx_client=fake_client, ssl_context=None ) assert entry.data[CONF_HOST] == "192.168.1.201" assert entry.data[CONF_REGISTERED_FQDN] == "" From 7cec157b06cc0b5757da2734fd4bdd7938562c1a Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:11:39 -0700 Subject: [PATCH 123/345] security: gate control commands on who is asking, and record every one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Home Assistant's default user policy grants every non-admin user control of every entity, and an automation calls services with no user at all. Neither was distinguishable from an administrator at a breaker. **The gate is split in two, because the two halves live in different places.** Authorization needs caller identity, which exists only at the entity: core sets `Entity._context` from the service call immediately before the handler runs. Completeness needs a choke point, which exists only at the publish, inside the library. So `SpanPanelEntity._async_guarded_control` binds the caller to a `ContextVar` around one awaited call, and the decision is made in the library's `ControlInterceptor`. That is why there is no proxy on `coordinator.client`. A non-subclass proxy would silently drop the BESS button, which is created behind an `isinstance(coordinator.client, SpanMqttClient)` check, and a `__getattr__` delegator returns `Any`. The interceptor sees *every* control command, including one from a future path that forgets the helper. Such a command arrives with an empty `ContextVar` and is treated as contextless rather than permitted — fail-closed, and the reason the decision is not in the helper itself. `_context` is read explicitly rather than captured by overriding `async_set_context`, which would inherit core's five-second `CONTEXT_RECENT_TIME_SECONDS` staleness window and would fire for paths that are not service calls. **Four options, every default reproducing what an entry already does.** A silent tightening on upgrade that breaks a household's automations is worse than the status quo, and the user cannot diagnose it from the entity's error. **The control lock** is the honest local substitute for a second factor, since Home Assistant has no per-entity admin flag. Anyone may arm it; only an administrator may disarm it, and a contextless caller never may, regardless of `allow_contextless_control` — an automation that can unlock the panel is not a lock. Auto-relock is evaluated on read so a suspended loop cannot skip it. **`disabled` does not remove registry entries.** The entities stop being created and read as unavailable; removing them would risk a regenerated entity_id on the way back and discard the user's names and areas, which is the breakage the project's hardest rule forbids. **The audit** fires `span_panel_control_command` from `after_publish`, which the library also fires for a veto — so the refusals cannot be silently missing, which would make the audit worse than none. The refusal reason travels from `before_publish` on a second `ContextVar`, readable there because the library creates that callback's task from the same context. All four `PublishState` members reach the audit and the logbook intact. `ACCEPTED` and `UNCONFIRMED` are not failures: both were handed to the broker and may already have been acted on, and `UNCONFIRMED` most often means the value was already what was asked for. Only `FAILED` promises the command will never be delivered, and only `FAILED` stops the switch adopting its optimistic state — which now happens after the publish rather than before it, so a refusal cannot leave a relay showing a position it never took, with no coordinator update coming to correct it. Relay debounce is per circuit and refuses rather than queues: a relay command firing seconds later against a changed panel is worse than an error the automation author can see. The gate tests drive the interceptor directly. Every existing control test assigns a mock client to `coordinator.client`, and a mock has no interceptor, so a platform test proves nothing about the gate. --- custom_components/span_panel/__init__.py | 21 +- custom_components/span_panel/adoption.py | 6 +- custom_components/span_panel/button.py | 10 +- .../span_panel/config_flow_options.py | 35 + custom_components/span_panel/const.py | 1 + custom_components/span_panel/control_gate.py | 391 +++++++++ custom_components/span_panel/entity.py | 25 +- custom_components/span_panel/logbook.py | 114 +++ custom_components/span_panel/number.py | 10 +- custom_components/span_panel/options.py | 6 + custom_components/span_panel/select.py | 8 +- custom_components/span_panel/strings.json | 60 +- custom_components/span_panel/switch.py | 132 ++- .../span_panel/translations/en.json | 60 +- .../span_panel/translations/es.json | 46 +- .../span_panel/translations/fr.json | 46 +- .../span_panel/translations/ja.json | 46 +- .../span_panel/translations/pt.json | 46 +- tests/test_binary_sensor_platform.py | 3 +- tests/test_button.py | 5 +- tests/test_circuit_control.py | 5 +- tests/test_control_gate.py | 773 ++++++++++++++++++ tests/test_select.py | 5 +- tests/test_sensor_platform.py | 5 +- tests/test_setup_entry.py | 9 +- 25 files changed, 1800 insertions(+), 68 deletions(-) create mode 100644 custom_components/span_panel/control_gate.py create mode 100644 custom_components/span_panel/logbook.py create mode 100644 tests/test_control_gate.py diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py index d3c0b742..dbd98c99 100644 --- a/custom_components/span_panel/__init__.py +++ b/custom_components/span_panel/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from dataclasses import dataclass +from dataclasses import dataclass, field import logging from homeassistant.components.frontend import async_remove_panel as async_remove_panel @@ -55,6 +55,7 @@ ENABLE_CURRENT_MONITORING, PANEL_CA_PENDING, ) +from .control_gate import ControlGate, ControlLock, ControlPolicy from .coordinator import SpanPanelCoordinator from .current_monitor import CurrentMonitor from .extension import async_notice_declined_extensions @@ -104,6 +105,14 @@ class SpanPanelRuntimeData: # exists, so an id looked up here is one no caller has to handle the absence # of. See `ensure_device_registered`. panel_device_id: str + # Resolved once at setup and read by every control platform, so a single + # answer decides which entities exist and which callers may operate them. + # Defaulted rather than required because the default *is* the policy an entry + # with no control options has, and that is most of them. + control_policy: ControlPolicy = field(default_factory=ControlPolicy.default) + # Shared with the gate, and mutated by the lock entity. One object per entry: + # the gate reads it on every publish and the entity writes it. + control_lock: ControlLock = field(default_factory=ControlLock) type SpanPanelConfigEntry = ConfigEntry[SpanPanelRuntimeData] @@ -348,6 +357,14 @@ def _on_fatal_transport_error(error: SpanPanelError) -> None: entry.async_on_unload(client.register_fatal_error_callback(_on_fatal_transport_error)) + # One interceptor, installed before the coordinator starts streaming + # so no control command can reach the panel ungated — including one + # issued during the first refresh. + control_policy = ControlPolicy.from_options(entry.options) + control_lock = ControlLock() + client.set_control_interceptor(ControlGate(hass, entry, control_policy, control_lock)) + entry.async_on_unload(lambda: client.set_control_interceptor(None)) + coordinator = SpanPanelCoordinator(hass, client, entry) await coordinator.async_config_entry_first_refresh() await coordinator.async_setup_streaming() @@ -401,6 +418,8 @@ def _on_fatal_transport_error(error: SpanPanelError) -> None: # runtime_data, and platforms — which all do — are forwarded below. entry.runtime_data = SpanPanelRuntimeData( coordinator=coordinator, + control_policy=control_policy, + control_lock=control_lock, panel_device_id=await ensure_device_registered( hass, entry, snapshot, smart_device_name ), diff --git a/custom_components/span_panel/adoption.py b/custom_components/span_panel/adoption.py index 74bebfe5..80a09a5e 100644 --- a/custom_components/span_panel/adoption.py +++ b/custom_components/span_panel/adoption.py @@ -430,8 +430,10 @@ async def _publish(self, value: str) -> None: exists because a v1.0 tree reported an adopted device, and that is the same transport that carries the write. """ - await self.coordinator.client.set_adopted_property( - self._device_wire_id, self._node_id, self._property_id, value + await self._async_guarded_control( + self.coordinator.client.set_adopted_property( + self._device_wire_id, self._node_id, self._property_id, value + ) ) await self.coordinator.async_request_refresh() diff --git a/custom_components/span_panel/button.py b/custom_components/span_panel/button.py index f9b970cb..ddd904ac 100644 --- a/custom_components/span_panel/button.py +++ b/custom_components/span_panel/button.py @@ -11,6 +11,7 @@ from . import SpanPanelConfigEntry from .const import CONF_DEVICE_NAME +from .control_gate import ControlMode from .coordinator import SpanPanelCoordinator from .entity import SpanPanelEntity from .helpers import ( @@ -70,7 +71,9 @@ async def async_press(self) -> None: return try: - await client.set_dominant_power_source(self._override_value) + await self._async_guarded_control( + client.set_dominant_power_source(self._override_value) + ) await self.coordinator.async_request_refresh() except SpanPanelServerError: warning_msg = ( @@ -123,6 +126,11 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up button entities for Span Panel.""" + # Under `disabled` no control entity is created and no registry entry is + # removed. See `switch.async_setup_entry` for why the registry entries stay. + if config_entry.runtime_data.control_policy.mode is ControlMode.DISABLED: + return + coordinator = config_entry.runtime_data.coordinator entities: list[SpanPanelGFEOverrideButton] = [] diff --git a/custom_components/span_panel/config_flow_options.py b/custom_components/span_panel/config_flow_options.py index 024af7b3..3ab01bfd 100644 --- a/custom_components/span_panel/config_flow_options.py +++ b/custom_components/span_panel/config_flow_options.py @@ -5,6 +5,11 @@ from typing import Any from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) import voluptuous as vol from .const import ( @@ -19,8 +24,13 @@ USE_DEVICE_PREFIX, EntityNamingPattern, ) +from .control_gate import DEFAULT_RELAY_DEBOUNCE_SECONDS, ControlMode from .options import ( + ALLOW_CONTEXTLESS_CONTROL, + CONTROL_LOCK_TIMEOUT, + CONTROL_MODE, ENERGY_REPORTING_GRACE_PERIOD, + RELAY_DEBOUNCE_SECONDS, SNAPSHOT_UPDATE_INTERVAL, ) @@ -36,6 +46,22 @@ vol.Optional(ENABLE_UNMAPPED_CIRCUIT_SENSORS): bool, vol.Optional(ENERGY_REPORTING_GRACE_PERIOD): vol.All(int, vol.Range(min=0, max=60)), vol.Optional(ENABLE_ENERGY_DIP_COMPENSATION): bool, + # A SelectSelector rather than `vol.In`, so the three choices are + # translated from `selector.control_mode` rather than shown as raw + # option values. + vol.Optional(CONTROL_MODE): SelectSelector( + SelectSelectorConfig( + options=[mode.value for mode in ControlMode], + translation_key=CONTROL_MODE, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(ALLOW_CONTEXTLESS_CONTROL): bool, + # Minutes. `0` means the lock stays armed until somebody disarms it; a + # negative value turns the feature off, which is how "no lock entity" + # is expressed without a second option. + vol.Optional(CONTROL_LOCK_TIMEOUT): vol.All(vol.Coerce(float), vol.Range(min=-1, max=1440)), + vol.Optional(RELAY_DEBOUNCE_SECONDS): vol.All(vol.Coerce(float), vol.Range(min=0, max=60)), } ) @@ -74,6 +100,15 @@ def get_general_options_defaults( ENABLE_ENERGY_DIP_COMPENSATION: config_entry.options.get( ENABLE_ENERGY_DIP_COMPENSATION, True ), + # Every default below reproduces what an entry already does. A silent + # tightening on upgrade would break a household's automations with an + # error the user cannot diagnose from the entity. + CONTROL_MODE: config_entry.options.get(CONTROL_MODE, ControlMode.ALL_USERS.value), + ALLOW_CONTEXTLESS_CONTROL: config_entry.options.get(ALLOW_CONTEXTLESS_CONTROL, True), + CONTROL_LOCK_TIMEOUT: config_entry.options.get(CONTROL_LOCK_TIMEOUT, -1), + RELAY_DEBOUNCE_SECONDS: config_entry.options.get( + RELAY_DEBOUNCE_SECONDS, DEFAULT_RELAY_DEBOUNCE_SECONDS + ), } diff --git a/custom_components/span_panel/const.py b/custom_components/span_panel/const.py index c3586fb8..f77b0e7e 100644 --- a/custom_components/span_panel/const.py +++ b/custom_components/span_panel/const.py @@ -98,6 +98,7 @@ # Fired alongside a schema Repair so an automation can react without polling # the issue registry. Same bus-event shape as the current alert above. EVENT_SCHEMA_ISSUE = "span_panel_schema_issue" +EVENT_CONTROL_COMMAND = "span_panel_control_command" # Graph time horizon configuration VALID_GRAPH_HORIZONS: Final[tuple[str, ...]] = ("5m", "1h", "1d", "1w", "1M") diff --git a/custom_components/span_panel/control_gate.py b/custom_components/span_panel/control_gate.py new file mode 100644 index 00000000..22f8b4e5 --- /dev/null +++ b/custom_components/span_panel/control_gate.py @@ -0,0 +1,391 @@ +"""Who may operate this panel through Home Assistant, and a record of who did. + +**What this is a boundary against.** Callers arriving through Home Assistant, +and nothing else. It does not constrain anything holding the broker credential: +a process with that credential publishes to the panel's broker directly and +never reaches this code, and that includes a malicious or buggy custom +integration running in the same Home Assistant process. Presenting this as a +security boundary around the *panel* would be the most damaging thing it could +do, because a user would stop looking for the real one — which is network +topology and a lock on the enclosure. The README says so too. + +**Why it is split in two.** Authorization needs to know who is asking, and that +is knowable only at the entity: Home Assistant sets `Entity._context` from the +service call immediately before the handler runs. Completeness needs a single +choke point, and that exists only at the publish, inside the library. So +identity is captured at the entity into a `ContextVar` (see +`SpanPanelEntity._async_guarded_control`) and the decision is made in the +library's `ControlInterceptor`, which sees every control command including ones +a future code path forgets to route through the helper. Such a command arrives +with an empty `ContextVar` and is treated as contextless rather than silently +permitted — that is the fail-closed default, and it is the reason the gate lives +here rather than in the helper. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from contextvars import ContextVar, Token +from dataclasses import dataclass +from enum import StrEnum +import logging +import time + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import Context, HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError +from span_panel_api import ControlCommand, PublishOutcome, PublishState + +from .const import DOMAIN, EVENT_CONTROL_COMMAND +from .options import ( + ALLOW_CONTEXTLESS_CONTROL, + CONTROL_LOCK_TIMEOUT, + CONTROL_MODE, + RELAY_DEBOUNCE_SECONDS, +) + +_LOGGER = logging.getLogger(__name__) + +RELAY_PROPERTY = "relay" +"""The property id both schema adapters use for a circuit relay. + +Flat spells the address `(serial, circuit_id, "relay")` and v1.0 spells it +`(circuit_id, "switch", "relay")` — the device and node differ, the property does +not. That is what makes it usable here without teaching this module either +schema's topic grammar. +""" + +DEFAULT_RELAY_DEBOUNCE_SECONDS = 2.0 + + +class ControlMode(StrEnum): + """Who may operate the panel's controls.""" + + ALL_USERS = "all_users" + """Home Assistant's own default: any user who can call a service.""" + + ADMIN_ONLY = "admin_only" + """Administrators only. Home Assistant's default user policy grants every + non-admin control of every entity, so this is the only way to distinguish + them.""" + + DISABLED = "disabled" + """No control entities are created at all. Their registry entries are kept — + see the platforms.""" + + +@dataclass(frozen=True, slots=True) +class ControlCaller: + """Who issued the command currently being published, if anyone. + + `entity_id` is carried alongside the Context because the interceptor sees a + wire address and cannot map it back to an entity — and the audit trail is + worth far less without it. + """ + + context: Context | None + entity_id: str | None + + +CONTROL_CALLER: ContextVar[ControlCaller | None] = ContextVar( + "span_panel_control_caller", default=None +) +"""Bound around one awaited control call, and reset in a `finally`. + +A `ContextVar` rather than an attribute on the entity because a mutable +attribute races across concurrent service calls, and rather than an override of +`async_set_context` because that would inherit core's five-second +`CONTEXT_RECENT_TIME_SECONDS` staleness window and would also capture paths that +are not service calls at all. +""" + +_REFUSAL_REASON: ContextVar[str | None] = ContextVar("span_panel_control_refusal", default=None) +"""Why `before_publish` vetoed, read back by `after_publish`. + +The library fires `after_publish` for a veto — so an audit built on it cannot +silently omit the refusals, which would make it worse than no audit — but hands +it only a `FAILED` outcome with `detail="vetoed"`, not the reason. The reason is +left here on the way out. + +This works because the library fires that callback as a task created from the +same context this was set in, and `loop.create_task` copies the current context. +The entity's `finally` runs after the task exists, so the copy keeps the value. +""" + + +def _as_bool(value: object, default: bool) -> bool: + """Read a bool out of untyped option data.""" + return value if isinstance(value, bool) else default + + +def _as_float(value: object, default: float) -> float: + """Read a non-negative float out of untyped option data.""" + if isinstance(value, bool): + return default + if isinstance(value, int | float): + return float(value) if value >= 0 else default + return default + + +@dataclass(frozen=True, slots=True) +class ControlPolicy: + """The four options, resolved once. + + Every default reproduces the behaviour an existing entry already has. A + silent tightening on upgrade that breaks a household's automations is a worse + outcome than the status quo, and the user cannot diagnose it from the + entity's error. + """ + + mode: ControlMode + allow_contextless: bool + lock_timeout_minutes: float | None + relay_debounce_seconds: float + + @property + def lock_enabled(self) -> bool: + """Whether the control-lock entity exists at all.""" + return self.lock_timeout_minutes is not None + + @classmethod + def default(cls) -> ControlPolicy: + """Return the policy an entry with no control options configured has. + + Written down once, here, so "the default" is a single object rather than + four constants repeated at each reader. + """ + return cls( + mode=ControlMode.ALL_USERS, + allow_contextless=True, + lock_timeout_minutes=None, + relay_debounce_seconds=DEFAULT_RELAY_DEBOUNCE_SECONDS, + ) + + @classmethod + def from_options(cls, options: Mapping[str, object]) -> ControlPolicy: + """Resolve the policy from one entry's options.""" + raw_mode = options.get(CONTROL_MODE) + try: + mode = ControlMode(raw_mode) if isinstance(raw_mode, str) else ControlMode.ALL_USERS + except ValueError: + _LOGGER.warning("Unknown control_mode %r; falling back to all_users", raw_mode) + mode = ControlMode.ALL_USERS + + raw_timeout = options.get(CONTROL_LOCK_TIMEOUT) + # Absent means the lock feature is off; 0 means armed until manually + # disarmed. The two are different states and a plain float cannot hold + # both, which is why this is nullable. + timeout: float | None = None + if isinstance(raw_timeout, int | float) and not isinstance(raw_timeout, bool): + timeout = float(raw_timeout) if raw_timeout >= 0 else None + + return cls( + mode=mode, + allow_contextless=_as_bool(options.get(ALLOW_CONTEXTLESS_CONTROL), True), + lock_timeout_minutes=timeout, + relay_debounce_seconds=_as_float( + options.get(RELAY_DEBOUNCE_SECONDS), DEFAULT_RELAY_DEBOUNCE_SECONDS + ), + ) + + +class ControlLock: + """An armed/disarmed flag that refuses every control while armed. + + Home Assistant has no per-entity admin flag, so "an admin-only switch entity" + is not a thing that exists. This is the honest local substitute: the + alarm-panel "armed" pattern, defending against misclicks and runaway + automations, which is what a local second factor can actually do. + + Arming is permitted for anyone, including a contextless caller. Disarming + requires an administrator, and is refused outright for a contextless caller + regardless of `allow_contextless_control` — an automation that can unlock the + panel defeats the purpose of the lock. Making a household safer should not + require admin; making it less safe should. + """ + + def __init__(self) -> None: + """Start disarmed.""" + self._armed = False + self._relock_at: float | None = None + + @property + def armed(self) -> bool: + """Whether control is currently locked out. + + Auto-relock is evaluated on read rather than on a timer, so it cannot be + missed by a restart or a suspended event loop, and so the lock never + stays open longer than it was asked to. + """ + if self._relock_at is not None and time.monotonic() >= self._relock_at: + self._armed = True + self._relock_at = None + return self._armed + + def arm(self) -> None: + """Lock control out.""" + self._armed = True + self._relock_at = None + + def disarm(self, timeout_minutes: float) -> None: + """Allow control, re-arming after `timeout_minutes` unless that is zero.""" + self._armed = False + self._relock_at = None if timeout_minutes <= 0 else time.monotonic() + timeout_minutes * 60 + + +class ControlGate: + """One veto-and-observe point for every control command on one entry.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + policy: ControlPolicy, + lock: ControlLock, + ) -> None: + """Hold what the decision needs.""" + self._hass = hass + self._entry = entry + self._policy = policy + self._lock = lock + # Monotonic timestamp of the last relay command allowed through, per + # circuit. Keyed on the address the adapter produced rather than on an + # entity id, so it holds for a caller that never went through an entity. + self._last_relay: dict[tuple[str, str], float] = {} + + # -- ControlInterceptor ------------------------------------------------- + + async def before_publish(self, command: ControlCommand) -> None: + """Decide whether this command may be published. Raise to veto. + + The exception reaches the service caller unchanged — the library + propagates it rather than translating it — which is what lets a + `ServiceValidationError` carrying a translated message arrive intact. + """ + caller = CONTROL_CALLER.get() + context = caller.context if caller is not None else None + user_id = context.user_id if context is not None else None + + if self._lock.armed: + self._refuse( + "control_locked", + "The SPAN panel's control lock is armed. Disarm it before operating circuits.", + ) + + if user_id is None and not self._policy.allow_contextless: + self._refuse( + "contextless_control_refused", + "This SPAN panel refuses control commands that do not come from a logged-in user.", + parent_id=context.parent_id if context is not None else None, + ) + + if self._policy.mode is ControlMode.ADMIN_ONLY and user_id is not None: + user = await self._hass.auth.async_get_user(user_id) + if user is None or not user.is_admin: + self._refuse( + "admin_only_control", + "Only a Home Assistant administrator may operate this SPAN panel.", + ) + + if command.property_id == RELAY_PROPERTY: + self._check_relay_debounce(command) + + async def after_publish(self, command: ControlCommand, outcome: PublishOutcome) -> None: + """Record what happened to every command, refusals included.""" + caller = CONTROL_CALLER.get() + context = caller.context if caller is not None else None + reason = _REFUSAL_REASON.get() + + result = f"refused:{reason}" if reason is not None else str(outcome.state) + event = { + "entry_id": self._entry.entry_id, + "user_id": context.user_id if context is not None else None, + "parent_id": context.parent_id if context is not None else None, + "entity_id": caller.entity_id if caller is not None else None, + "command": f"{command.node_id}/{command.property_id}", + "topic": command.topic, + "value": command.value, + "outcome": result, + "no_op": outcome.no_op, + } + self._hass.bus.async_fire(EVENT_CONTROL_COMMAND, event) + + # INFO on every publish, not only on failures. A panel that operates a + # breaker is worth a log line whichever way it went, and `unconfirmed` in + # particular is invisible otherwise. + _LOGGER.info( + "SPAN control %s = %s by %s: %s%s", + command.topic, + command.value, + caller.entity_id if caller is not None and caller.entity_id else "an unknown caller", + result, + f" ({outcome.detail})" if outcome.detail else "", + ) + + # -- internals ---------------------------------------------------------- + + def _check_relay_debounce(self, command: ControlCommand) -> None: + """Refuse a relay command that follows too closely on the last one. + + Refused, never queued. A queued relay command firing seconds later + against a changed panel state is worse than a refusal the automation + author can see and fix. + """ + window = self._policy.relay_debounce_seconds + if window <= 0: + return + + circuit = (command.device_id, command.node_id) + now = time.monotonic() + last = self._last_relay.get(circuit) + if last is not None and now - last < window: + self._refuse( + "relay_debounced", + f"That circuit's relay was operated less than {window:g} seconds ago. " + "The command was refused rather than queued.", + ) + self._last_relay[circuit] = now + + def _refuse(self, reason: str, message: str, parent_id: str | None = None) -> None: + """Record the reason for `after_publish`, then veto.""" + _REFUSAL_REASON.set(reason) + if parent_id is not None: + # Names *what* asked when there is no *who* — the automation or + # script the command came from. Without it a contextless refusal is + # a blank line in the log. + _LOGGER.warning( + "Refused a SPAN control command (%s) originating from context %s", + reason, + parent_id, + ) + raise ServiceValidationError( + message, + translation_domain=DOMAIN, + translation_key=reason, + ) + + +@callback +def async_bind_caller( + context: Context | None, entity_id: str | None +) -> Token[ControlCaller | None]: + """Bind the caller for the duration of one control call. + + Returns the reset token; the caller must reset it in a `finally`. Kept here + rather than inlined at the entity so the two `ContextVar`s are set and + cleared in one place. + """ + _REFUSAL_REASON.set(None) + return CONTROL_CALLER.set(ControlCaller(context=context, entity_id=entity_id)) + + +def outcome_is_failure(outcome: PublishOutcome) -> bool: + """Whether this outcome means the command will never be delivered. + + Only `FAILED` is a promise about the future. `ACCEPTED` and `UNCONFIRMED` + both mean the command was handed over and may already have been acted on — + `UNCONFIRMED` most often means the value was already what was asked for — and + presenting either as a failure would tell a user something untrue about their + panel. + """ + return outcome.state is PublishState.FAILED diff --git a/custom_components/span_panel/entity.py b/custom_components/span_panel/entity.py index eae3dc85..eac0b154 100644 --- a/custom_components/span_panel/entity.py +++ b/custom_components/span_panel/entity.py @@ -2,14 +2,16 @@ from __future__ import annotations +from collections.abc import Awaitable from typing import ClassVar from homeassistant.const import CONF_HOST from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from span_panel_api import SpanPanelSnapshot +from span_panel_api import PublishOutcome, SpanPanelSnapshot from .const import CONF_DEVICE_NAME +from .control_gate import CONTROL_CALLER, async_bind_caller from .coordinator import SpanPanelCoordinator from .field_paths import FieldPathDeclarationMixin from .util import snapshot_to_device_info @@ -37,6 +39,27 @@ class SpanPanelEntity(CoordinatorEntity[SpanPanelCoordinator]): description instead, where the declaration and the reader are one object. """ + async def _async_guarded_control(self, action: Awaitable[PublishOutcome]) -> PublishOutcome: + """Run one control call with this entity's caller bound to it. + + `self._context` is set by core immediately before a service handler runs + (`helpers/service.py`), in the same task, so reading it here is reading + the call that is happening right now. It is read explicitly rather than + captured by overriding `async_set_context`, which would inherit core's + five-second `CONTEXT_RECENT_TIME_SECONDS` staleness window and would also + fire for paths that are not service calls. + + The binding is around the awaited call and reset in a `finally`, so it + cannot leak into an unrelated task the way a mutable attribute would. The + decision itself is made in `ControlGate`, at the publish — see that + module for why the two halves are separate. + """ + token = async_bind_caller(self._context, self.entity_id) + try: + return await action + finally: + CONTROL_CALLER.reset(token) + async def async_added_to_hass(self) -> None: """Tell the coordinator which snapshot fields this entity reads. diff --git a/custom_components/span_panel/logbook.py b/custom_components/span_panel/logbook.py new file mode 100644 index 00000000..590c6079 --- /dev/null +++ b/custom_components/span_panel/logbook.py @@ -0,0 +1,114 @@ +"""Render control commands in the logbook. + +Discovered by name: Home Assistant looks for `logbook.async_describe_events` in +the integration and calls it once at startup. No manifest change is needed. + +The audit event carries a wire address and a `user_id`; the logbook wants a +sentence. The translation is deliberately blunt about the four outcomes, because +the whole point of `PublishState` having four members rather than two is that +"the panel did it", "the broker took it and the panel did not act", "nothing came +back" and "this will never be delivered" send an investigation in four different +directions. + +When there is no user, the originating automation or script is resolved from the +context so a contextless write is attributed to *what* rather than left blank — +which is the case the audit exists for. +""" + +from __future__ import annotations + +from collections.abc import Callable + +from homeassistant.components.logbook import ( + LOGBOOK_ENTRY_ENTITY_ID, + LOGBOOK_ENTRY_MESSAGE, + LOGBOOK_ENTRY_NAME, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import Event, HomeAssistant, callback + +from .const import DOMAIN, EVENT_CONTROL_COMMAND + +_OUTCOME_PHRASES: dict[str, str] = { + "confirmed": "and the panel confirmed it", + # Not a success and not a failure. The honest message is that the panel + # accepted the command and never reported the change, which most often means + # the value was already what was asked for. + "accepted": "and the broker accepted it, but the panel reported no change", + "unconfirmed": "with no acknowledgement and no reported change", + # The one state that is a promise about the future. + "failed": "but it was never sent", +} + +_REFUSAL_PHRASES: dict[str, str] = { + "control_locked": "but the control lock is armed", + "contextless_control_refused": "but commands without a logged-in user are refused", + "admin_only_control": "but only administrators may operate this panel", + "relay_debounced": "but that circuit was operated moments ago", +} + + +def _describe_outcome(outcome: str) -> str: + """Turn one `outcome` field into a clause.""" + if outcome.startswith("refused:"): + reason = outcome.removeprefix("refused:") + return _REFUSAL_PHRASES.get(reason, f"but it was refused ({reason})") + return _OUTCOME_PHRASES.get(outcome, f"with an unrecognised outcome ({outcome})") + + +def _describe_actor(hass: HomeAssistant, user_id: str | None, parent_id: str | None) -> str: + """Name who or what issued the command. + + A `parent_id` is the context of whatever caused this call, so an automation + that operates a breaker is named rather than reduced to "no user". The + lookup is best-effort: contexts are not retained forever, and a name that + cannot be resolved is better rendered as "an automation or script" than as + a raw id nobody can act on. + """ + if user_id is not None: + # The id rather than the name: resolving a name goes through + # `hass.auth.async_get_user`, which is a coroutine, and a logbook + # describer is a synchronous callback. Home Assistant's own logbook + # renders the id here too. + return f"user {user_id}" + if parent_id is None: + return "an unattended caller" + for state in hass.states.async_all(("automation", "script")): + if state.context.id == parent_id: + return str(state.name) or state.entity_id + return "an automation or script" + + +@callback +def async_describe_events( + hass: HomeAssistant, + async_describe_event: Callable[[str, str, Callable[[Event], dict[str, str]]], None], +) -> None: + """Register the describer for this integration's control-command event.""" + + @callback + def describe(event: Event) -> dict[str, str]: + data = event.data + command = str(data.get("command", "a control")) + value = str(data.get("value", "")) + outcome = str(data.get("outcome", "unknown")) + user_id = data.get("user_id") + parent_id = data.get("parent_id") + actor = _describe_actor( + hass, + str(user_id) if isinstance(user_id, str) else None, + str(parent_id) if isinstance(parent_id, str) else None, + ) + entity_id = data.get(ATTR_ENTITY_ID) + + described = { + LOGBOOK_ENTRY_NAME: "SPAN Panel", + LOGBOOK_ENTRY_MESSAGE: ( + f"{command} was set to {value} by {actor} {_describe_outcome(outcome)}" + ), + } + if isinstance(entity_id, str): + described[LOGBOOK_ENTRY_ENTITY_ID] = entity_id + return described + + async_describe_event(DOMAIN, EVENT_CONTROL_COMMAND, describe) diff --git a/custom_components/span_panel/number.py b/custom_components/span_panel/number.py index d2872f57..7e2385c7 100644 --- a/custom_components/span_panel/number.py +++ b/custom_components/span_panel/number.py @@ -55,6 +55,7 @@ from . import SpanPanelConfigEntry from .adoption import AdoptedNumber, create_adopted_numbers from .const import CONF_DEVICE_NAME, DOMAIN, USE_CIRCUIT_NUMBERS +from .control_gate import ControlMode from .coordinator import SpanPanelCoordinator from .entity import SpanPanelEntity from .field_paths import DerivedReason, FieldPathDeclarationMixin @@ -261,7 +262,9 @@ async def async_set_native_value(self, value: float) -> None: translation_placeholders={"charger": self._evse_id}, ) try: - await self._description.set_fn(client, self._evse_id, int(value)) + await self._async_guarded_control( + self._description.set_fn(client, self._evse_id, int(value)) + ) except SpanPanelServerError as err: _LOGGER.warning("SPAN panel refused a charge-current limit: %s", err) raise HomeAssistantError( @@ -285,6 +288,11 @@ async def async_setup_entry( """Set up number entities for Span Panel.""" _LOGGER.debug("ASYNC SETUP ENTRY NUMBER") + # Under `disabled` no control entity is created and no registry entry is + # removed. See `switch.async_setup_entry` for why the registry entries stay. + if config_entry.runtime_data.control_policy.mode is ControlMode.DISABLED: + return + coordinator = config_entry.runtime_data.coordinator snapshot: SpanPanelSnapshot = coordinator.data diff --git a/custom_components/span_panel/options.py b/custom_components/span_panel/options.py index 232afd03..c4f11218 100644 --- a/custom_components/span_panel/options.py +++ b/custom_components/span_panel/options.py @@ -14,3 +14,9 @@ NOTIFICATION_TITLE_TEMPLATE = "notification_title_template" NOTIFICATION_MESSAGE_TEMPLATE = "notification_message_template" NOTIFICATION_PRIORITY = "notification_priority" + +# Control authorization and flap protection. See `control_gate`. +CONTROL_MODE = "control_mode" +ALLOW_CONTEXTLESS_CONTROL = "allow_contextless_control" +CONTROL_LOCK_TIMEOUT = "control_lock_timeout" +RELAY_DEBOUNCE_SECONDS = "relay_debounce_seconds" diff --git a/custom_components/span_panel/select.py b/custom_components/span_panel/select.py index cc7df43b..7ebc8302 100644 --- a/custom_components/span_panel/select.py +++ b/custom_components/span_panel/select.py @@ -16,6 +16,7 @@ from . import SpanPanelConfigEntry from .adoption import AdoptedSelect, create_adopted_selects from .const import DOMAIN, USE_CIRCUIT_NUMBERS, CircuitPriority +from .control_gate import ControlMode from .coordinator import SpanPanelCoordinator from .entity import SpanPanelEntity from .helpers import ( @@ -213,7 +214,7 @@ async def async_select_option(self, option: str) -> None: priority = CircuitPriority(option) try: - await client.set_circuit_priority(self.id, priority.name) + await self._async_guarded_control(client.set_circuit_priority(self.id, priority.name)) await self.coordinator.async_request_refresh() except ServiceNotFound as snf: _LOGGER.warning( @@ -352,6 +353,11 @@ async def async_setup_entry( _LOGGER.debug("ASYNC SETUP ENTRY SELECT") + # Under `disabled` no control entity is created and no registry entry is + # removed. See `switch.async_setup_entry` for why the registry entries stay. + if config_entry.runtime_data.control_policy.mode is ControlMode.DISABLED: + return + coordinator = config_entry.runtime_data.coordinator snapshot: SpanPanelSnapshot = coordinator.data diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index 1fc4babb..77b4aa2c 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -419,15 +419,35 @@ "bess_meter_power": { "name": "Meter Power" } + }, + "switch": { + "control_lock": { + "name": "Control lock" + } } }, "exceptions": { + "admin_only_control": { + "message": "Only a Home Assistant administrator may operate this SPAN panel." + }, "circuit_priority_failed": { "message": "Failed to set circuit priority for {circuit}." }, "circuit_relay_failed": { "message": "Failed to set circuit relay state for {circuit}." }, + "contextless_control_refused": { + "message": "This SPAN panel refuses control commands that do not come from a logged-in user." + }, + "control_lock_disarm_requires_admin": { + "message": "Only a Home Assistant administrator can disarm the SPAN panel control lock." + }, + "control_lock_disarm_requires_user": { + "message": "The SPAN panel control lock can only be disarmed by a logged-in administrator, not by an automation or script." + }, + "control_locked": { + "message": "The SPAN panel's control lock is armed. Disarm it before operating circuits." + }, "evse_charge_limit_failed": { "message": "Failed to set the charge-current limit for charger {charger}: {reason}" }, @@ -437,20 +457,20 @@ "export_manifest_no_entries": { "message": "No SPAN panel configuration entries are loaded. Add and configure a SPAN panel before calling this service." }, - "favorite_not_span_entity": { - "message": "Entity {entity_id} is not a SPAN Panel entity. Pick a circuit, BESS, or EVSE sensor." + "favorite_no_circuit_uuid": { + "message": "Could not derive a favorite target from entity {entity_id}. Pick a circuit sensor (current/power) or a sub-device sensor." }, "favorite_no_device": { "message": "Entity {entity_id} is not attached to a device. Pick a SPAN circuit or sub-device sensor." }, - "favorite_subdevice_no_span_parent": { - "message": "Sub-device {entity_id} has no SPAN Panel parent device — its via_device_id does not point at a SPAN panel." - }, "favorite_no_unique_id": { "message": "Entity {entity_id} has no unique id and cannot be resolved to a SPAN target." }, - "favorite_no_circuit_uuid": { - "message": "Could not derive a favorite target from entity {entity_id}. Pick a circuit sensor (current/power) or a sub-device sensor." + "favorite_not_span_entity": { + "message": "Entity {entity_id} is not a SPAN Panel entity. Pick a circuit, BESS, or EVSE sensor." + }, + "favorite_subdevice_no_span_parent": { + "message": "Sub-device {entity_id} has no SPAN Panel parent device — its via_device_id does not point at a SPAN panel." }, "gfe_override_failed": { "message": "Failed to send GFE override to the panel." @@ -470,6 +490,9 @@ "panel_not_ready": { "message": "The SPAN Panel is not ready. Please check that the panel is online." }, + "relay_debounced": { + "message": "That circuit's relay was operated moments ago. The command was refused rather than queued." + }, "rotate_credentials_auth_failed": { "message": "The SPAN Panel rejected the stored access token. Reauthenticate the integration before rotating credentials." }, @@ -493,23 +516,31 @@ "step": { "init": { "data": { - "show_panel": "Show Dashboard in Sidebar", - "panel_admin_only": "Admin Users Only", + "allow_contextless_control": "Allow control without a logged-in user", + "control_lock_timeout": "Control lock auto-relock (minutes)", + "control_mode": "Who may operate the panel", "enable_circuit_net_energy_sensors": "Circuit Net Energy", "enable_energy_dip_compensation": "Auto-Compensate Energy Dips", "enable_panel_net_energy_sensors": "Panel Net Energy", "enable_unmapped_circuit_sensors": "Unmapped Circuit Sensors", "energy_reporting_grace_period": "Energy Sensor Grace Period (minutes)", + "panel_admin_only": "Admin Users Only", + "relay_debounce_seconds": "Relay debounce (seconds)", + "show_panel": "Show Dashboard in Sidebar", "snapshot_update_interval": "Snapshot Update Interval (seconds)" }, "data_description": { - "show_panel": "Display the SPAN Panel dashboard as a sidebar entry.", - "panel_admin_only": "Restrict the dashboard sidebar entry to admin users.", + "allow_contextless_control": "Automations, scripts and other integrations call services with no user attached. Turning this off refuses those commands. Leave it on unless your automations do not operate circuits.", + "control_lock_timeout": "-1 turns the control lock off and removes its switch. 0 keeps the lock armed until someone disarms it. Any other value re-arms it that many minutes after it is disarmed. Only administrators can disarm it.", + "control_mode": "Home Assistant grants every non-admin user control of every entity by default. Restricting to administrators is the only way to change that. Disabling stops creating the control entities; their registry entries, names and areas are kept.", "enable_circuit_net_energy_sensors": "Net energy per circuit, accounting for reactive power.", "enable_energy_dip_compensation": "Compensate when panel reports lower energy readings. Disabling clears offsets.", "enable_panel_net_energy_sensors": "Net energy for main meter and feed-through, accounting for bidirectional flows.", "enable_unmapped_circuit_sensors": "Backing sensors for tabs not assigned to named breakers.", "energy_reporting_grace_period": "Minutes sensors hold last value when panel is offline (0–60). Default: 15.", + "panel_admin_only": "Restrict the dashboard sidebar entry to admin users.", + "relay_debounce_seconds": "Refuse a second command to the same circuit's relay within this many seconds. Refused, never queued: a relay command firing seconds late against a changed panel is worse than an error the automation author can see. 0 disables it.", + "show_panel": "Display the SPAN Panel dashboard as a sidebar entry.", "snapshot_update_interval": "Seconds between snapshot rebuilds (0–15). Lower = faster, more CPU. 0 = no debounce." }, "description": "Configure SPAN Panel integration settings.", @@ -518,6 +549,13 @@ } }, "selector": { + "control_mode": { + "options": { + "admin_only": "Administrators only", + "all_users": "Any Home Assistant user", + "disabled": "Nobody — do not create control entities" + } + }, "entity_naming_pattern": { "options": { "circuit_numbers": "Circuit Numbers (e.g., span_panel_circuit_15_power)", diff --git a/custom_components/span_panel/switch.py b/custom_components/span_panel/switch.py index be75e47d..86d87375 100644 --- a/custom_components/span_panel/switch.py +++ b/custom_components/span_panel/switch.py @@ -6,13 +6,16 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from span_panel_api import SpanCircuitSnapshot, SpanPanelSnapshot from . import SpanPanelConfigEntry from .adoption import AdoptedSwitch, create_adopted_switches from .const import DOMAIN, USE_CIRCUIT_NUMBERS, CircuitRelayState +from .control_gate import ControlLock, ControlMode, ControlPolicy, outcome_is_failure from .coordinator import SpanPanelCoordinator from .entity import SpanPanelEntity from .helpers import ( @@ -22,6 +25,7 @@ construct_tabs_attribute, construct_voltage_attribute, ) +from .util import snapshot_to_device_info _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -260,24 +264,44 @@ def turn_off(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - client = self.coordinator.client - if not hasattr(client, "set_circuit_relay"): - _LOGGER.warning("Client does not support relay control") - return - - await client.set_circuit_relay(self._circuit_id, "CLOSED") - self._attr_is_on = True - self.async_write_ha_state() + await self._async_set_relay("CLOSED", is_on=True) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" + await self._async_set_relay("OPEN", is_on=False) + + async def _async_set_relay(self, state: str, *, is_on: bool) -> None: + """Operate the relay and show the result honestly. + + The optimistic write is deliberately *after* the publish, not before. A + gate refusal or a debounce rejection raises out of the awaited call, and + setting the state first would leave the switch showing a position the + relay never took — with no coordinator update coming to correct it, + because nothing on the panel changed. + + `FAILED` is the only outcome that means the command will never be + delivered. `ACCEPTED` and `UNCONFIRMED` were both handed to the broker + and may already have been acted on; `UNCONFIRMED` most often means the + relay was already in the requested position. Neither is a failure and + neither should discard the requested state. + """ client = self.coordinator.client if not hasattr(client, "set_circuit_relay"): _LOGGER.warning("Client does not support relay control") return - await client.set_circuit_relay(self._circuit_id, "OPEN") - self._attr_is_on = False + outcome = await self._async_guarded_control( + client.set_circuit_relay(self._circuit_id, state) + ) + if outcome_is_failure(outcome): + _LOGGER.warning( + "Relay command for %s was not delivered: %s", + self.entity_id, + outcome.detail or outcome.state, + ) + return + + self._attr_is_on = is_on self.async_write_ha_state() def _construct_switch_unique_id( @@ -292,6 +316,72 @@ def _construct_switch_unique_id( ) +class SpanPanelControlLockSwitch(SpanPanelEntity, SwitchEntity): + """Arms and disarms this panel's control lock. + + Not a control over the panel — it never publishes anything — so it is + deliberately outside `_async_guarded_control` and is never seen by the + interceptor. It is the thing the interceptor consults. + + The asymmetry is the whole design. Arming is permitted for anyone, including + a contextless caller, because making a household safer should not require + admin. Disarming requires an administrator, and is refused for a contextless + caller regardless of `allow_contextless_control`: an automation that can + unlock the panel is not a lock. + """ + + _attr_entity_category: EntityCategory | None = EntityCategory.CONFIG + _attr_translation_key: str | None = "control_lock" + + def __init__( + self, + coordinator: SpanPanelCoordinator, + lock: ControlLock, + policy: ControlPolicy, + device_name: str, + ) -> None: + """Bind the entity to this entry's single lock object.""" + super().__init__(coordinator) + snapshot: SpanPanelSnapshot = coordinator.data + self._lock = lock + self._policy = policy + self._attr_device_info = snapshot_to_device_info(snapshot, device_name) + self._attr_unique_id = f"span_{snapshot.serial_number}_control_lock" + + @property + def is_on(self) -> bool: + """Armed reads as on, matching an alarm panel rather than a door.""" + return self._lock.armed + + async def async_turn_on(self, **kwargs: Any) -> None: + """Arm the lock. Anyone may do this, including an automation.""" + self._lock.arm() + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Disarm the lock, for an administrator only.""" + context = self._context + user_id = context.user_id if context is not None else None + if user_id is None: + raise ServiceValidationError( + "The SPAN panel control lock can only be disarmed by a logged-in " + "administrator, not by an automation or script.", + translation_domain=DOMAIN, + translation_key="control_lock_disarm_requires_user", + ) + user = await self.hass.auth.async_get_user(user_id) + if user is None or not user.is_admin: + raise ServiceValidationError( + "Only a Home Assistant administrator can disarm the SPAN panel control lock.", + translation_domain=DOMAIN, + translation_key="control_lock_disarm_requires_admin", + ) + + timeout = self._policy.lock_timeout_minutes + self._lock.disarm(timeout if timeout is not None else 0) + self.async_write_ha_state() + + async def async_setup_entry( hass: HomeAssistant, config_entry: SpanPanelConfigEntry, @@ -300,12 +390,32 @@ async def async_setup_entry( """Set up sensor platform.""" coordinator = config_entry.runtime_data.coordinator + policy = config_entry.runtime_data.control_policy snapshot: SpanPanelSnapshot = coordinator.data # Get device name from config entry data _device_name = config_entry.data.get("device_name", config_entry.title) - entities: list[SpanPanelCircuitsSwitch | AdoptedSwitch] = [] + entities: list[SpanPanelCircuitsSwitch | AdoptedSwitch | SpanPanelControlLockSwitch] = [] + + if policy.lock_enabled: + entities.append( + SpanPanelControlLockSwitch( + coordinator, + config_entry.runtime_data.control_lock, + policy, + _device_name, + ) + ) + + # Under `disabled` the control entities are not created, and their registry + # entries are deliberately left in place. Removing them would risk a + # regenerated entity_id on the way back — a `_2` suffix if anything else + # claimed the slug meanwhile — and would discard the user's names, areas and + # customizations. They read as unavailable instead, which is recoverable. + if policy.mode is ControlMode.DISABLED: + async_add_entities(entities) + return for circuit_id, circuit_data in snapshot.circuits.items(): if not circuit_data.is_user_controllable: diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index 1fc4babb..77b4aa2c 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -419,15 +419,35 @@ "bess_meter_power": { "name": "Meter Power" } + }, + "switch": { + "control_lock": { + "name": "Control lock" + } } }, "exceptions": { + "admin_only_control": { + "message": "Only a Home Assistant administrator may operate this SPAN panel." + }, "circuit_priority_failed": { "message": "Failed to set circuit priority for {circuit}." }, "circuit_relay_failed": { "message": "Failed to set circuit relay state for {circuit}." }, + "contextless_control_refused": { + "message": "This SPAN panel refuses control commands that do not come from a logged-in user." + }, + "control_lock_disarm_requires_admin": { + "message": "Only a Home Assistant administrator can disarm the SPAN panel control lock." + }, + "control_lock_disarm_requires_user": { + "message": "The SPAN panel control lock can only be disarmed by a logged-in administrator, not by an automation or script." + }, + "control_locked": { + "message": "The SPAN panel's control lock is armed. Disarm it before operating circuits." + }, "evse_charge_limit_failed": { "message": "Failed to set the charge-current limit for charger {charger}: {reason}" }, @@ -437,20 +457,20 @@ "export_manifest_no_entries": { "message": "No SPAN panel configuration entries are loaded. Add and configure a SPAN panel before calling this service." }, - "favorite_not_span_entity": { - "message": "Entity {entity_id} is not a SPAN Panel entity. Pick a circuit, BESS, or EVSE sensor." + "favorite_no_circuit_uuid": { + "message": "Could not derive a favorite target from entity {entity_id}. Pick a circuit sensor (current/power) or a sub-device sensor." }, "favorite_no_device": { "message": "Entity {entity_id} is not attached to a device. Pick a SPAN circuit or sub-device sensor." }, - "favorite_subdevice_no_span_parent": { - "message": "Sub-device {entity_id} has no SPAN Panel parent device — its via_device_id does not point at a SPAN panel." - }, "favorite_no_unique_id": { "message": "Entity {entity_id} has no unique id and cannot be resolved to a SPAN target." }, - "favorite_no_circuit_uuid": { - "message": "Could not derive a favorite target from entity {entity_id}. Pick a circuit sensor (current/power) or a sub-device sensor." + "favorite_not_span_entity": { + "message": "Entity {entity_id} is not a SPAN Panel entity. Pick a circuit, BESS, or EVSE sensor." + }, + "favorite_subdevice_no_span_parent": { + "message": "Sub-device {entity_id} has no SPAN Panel parent device — its via_device_id does not point at a SPAN panel." }, "gfe_override_failed": { "message": "Failed to send GFE override to the panel." @@ -470,6 +490,9 @@ "panel_not_ready": { "message": "The SPAN Panel is not ready. Please check that the panel is online." }, + "relay_debounced": { + "message": "That circuit's relay was operated moments ago. The command was refused rather than queued." + }, "rotate_credentials_auth_failed": { "message": "The SPAN Panel rejected the stored access token. Reauthenticate the integration before rotating credentials." }, @@ -493,23 +516,31 @@ "step": { "init": { "data": { - "show_panel": "Show Dashboard in Sidebar", - "panel_admin_only": "Admin Users Only", + "allow_contextless_control": "Allow control without a logged-in user", + "control_lock_timeout": "Control lock auto-relock (minutes)", + "control_mode": "Who may operate the panel", "enable_circuit_net_energy_sensors": "Circuit Net Energy", "enable_energy_dip_compensation": "Auto-Compensate Energy Dips", "enable_panel_net_energy_sensors": "Panel Net Energy", "enable_unmapped_circuit_sensors": "Unmapped Circuit Sensors", "energy_reporting_grace_period": "Energy Sensor Grace Period (minutes)", + "panel_admin_only": "Admin Users Only", + "relay_debounce_seconds": "Relay debounce (seconds)", + "show_panel": "Show Dashboard in Sidebar", "snapshot_update_interval": "Snapshot Update Interval (seconds)" }, "data_description": { - "show_panel": "Display the SPAN Panel dashboard as a sidebar entry.", - "panel_admin_only": "Restrict the dashboard sidebar entry to admin users.", + "allow_contextless_control": "Automations, scripts and other integrations call services with no user attached. Turning this off refuses those commands. Leave it on unless your automations do not operate circuits.", + "control_lock_timeout": "-1 turns the control lock off and removes its switch. 0 keeps the lock armed until someone disarms it. Any other value re-arms it that many minutes after it is disarmed. Only administrators can disarm it.", + "control_mode": "Home Assistant grants every non-admin user control of every entity by default. Restricting to administrators is the only way to change that. Disabling stops creating the control entities; their registry entries, names and areas are kept.", "enable_circuit_net_energy_sensors": "Net energy per circuit, accounting for reactive power.", "enable_energy_dip_compensation": "Compensate when panel reports lower energy readings. Disabling clears offsets.", "enable_panel_net_energy_sensors": "Net energy for main meter and feed-through, accounting for bidirectional flows.", "enable_unmapped_circuit_sensors": "Backing sensors for tabs not assigned to named breakers.", "energy_reporting_grace_period": "Minutes sensors hold last value when panel is offline (0–60). Default: 15.", + "panel_admin_only": "Restrict the dashboard sidebar entry to admin users.", + "relay_debounce_seconds": "Refuse a second command to the same circuit's relay within this many seconds. Refused, never queued: a relay command firing seconds late against a changed panel is worse than an error the automation author can see. 0 disables it.", + "show_panel": "Display the SPAN Panel dashboard as a sidebar entry.", "snapshot_update_interval": "Seconds between snapshot rebuilds (0–15). Lower = faster, more CPU. 0 = no debounce." }, "description": "Configure SPAN Panel integration settings.", @@ -518,6 +549,13 @@ } }, "selector": { + "control_mode": { + "options": { + "admin_only": "Administrators only", + "all_users": "Any Home Assistant user", + "disabled": "Nobody — do not create control entities" + } + }, "entity_naming_pattern": { "options": { "circuit_numbers": "Circuit Numbers (e.g., span_panel_circuit_15_power)", diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index 6fc6a1be..8b513915 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -419,15 +419,35 @@ "bess_meter_power": { "name": "Potencia del Medidor" } + }, + "switch": { + "control_lock": { + "name": "Bloqueo de control" + } } }, "exceptions": { + "admin_only_control": { + "message": "Solo un administrador de Home Assistant puede manejar este SPAN Panel." + }, "circuit_priority_failed": { "message": "Error al establecer la prioridad del circuito para {circuit}." }, "circuit_relay_failed": { "message": "Error al establecer el estado del relé del circuito para {circuit}." }, + "contextless_control_refused": { + "message": "Este SPAN Panel rechaza las órdenes de control que no proceden de un usuario con sesión iniciada." + }, + "control_lock_disarm_requires_admin": { + "message": "Solo un administrador de Home Assistant puede desactivar el bloqueo de control del SPAN Panel." + }, + "control_lock_disarm_requires_user": { + "message": "El bloqueo de control del SPAN Panel solo puede desactivarlo un administrador con sesión iniciada, no una automatización ni un script." + }, + "control_locked": { + "message": "El bloqueo de control del SPAN Panel está activado. Desactívelo antes de manejar circuitos." + }, "evse_charge_limit_failed": { "message": "No se pudo establecer el límite de corriente de carga para el cargador {charger}: {reason}" }, @@ -470,6 +490,9 @@ "panel_not_ready": { "message": "El SPAN Panel no está listo. Por favor verifique que el panel esté en línea." }, + "relay_debounced": { + "message": "El relé de ese circuito se manejó hace unos instantes. La orden se rechazó en lugar de encolarse." + }, "rotate_credentials_auth_failed": { "message": "El SPAN Panel rechazó el token de acceso almacenado. Vuelva a autenticar la integración antes de rotar las credenciales." }, @@ -493,23 +516,31 @@ "step": { "init": { "data": { - "show_panel": "Mostrar Panel en la Barra Lateral", - "panel_admin_only": "Solo Usuarios Administradores", + "allow_contextless_control": "Permitir el control sin un usuario con sesión iniciada", + "control_lock_timeout": "Rearme automático del bloqueo de control (minutos)", + "control_mode": "Quién puede manejar el panel", "enable_circuit_net_energy_sensors": "Energía Neta del Circuito", "enable_energy_dip_compensation": "Compensar Caídas de Energía Automáticamente", "enable_panel_net_energy_sensors": "Energía Neta del Panel", "enable_unmapped_circuit_sensors": "Sensores de Circuitos No Asignados", "energy_reporting_grace_period": "Período de Gracia del Sensor de Energía (minutos)", + "panel_admin_only": "Solo Usuarios Administradores", + "relay_debounce_seconds": "Antirrebote del relé (segundos)", + "show_panel": "Mostrar Panel en la Barra Lateral", "snapshot_update_interval": "Intervalo de Actualización de Snapshot (segundos)" }, "data_description": { - "show_panel": "Mostrar el panel de control de SPAN como entrada de barra lateral.", - "panel_admin_only": "Restringir la entrada de la barra lateral del panel de control solo a usuarios administradores.", + "allow_contextless_control": "Las automatizaciones, los scripts y otras integraciones llaman a los servicios sin ningún usuario asociado. Al desactivarlo se rechazan esas órdenes. Déjelo activado salvo que sus automatizaciones no manejen circuitos.", + "control_lock_timeout": "-1 desactiva el bloqueo de control y elimina su interruptor. 0 mantiene el bloqueo activado hasta que alguien lo desactive. Cualquier otro valor lo rearma esos minutos después de desactivarlo. Solo los administradores pueden desactivarlo.", + "control_mode": "De forma predeterminada, Home Assistant concede a todos los usuarios no administradores el control de todas las entidades. Restringirlo a administradores es la única forma de cambiarlo. Al desactivarlo dejan de crearse las entidades de control; se conservan sus entradas del registro, nombres y áreas.", "enable_circuit_net_energy_sensors": "Energía neta por circuito, contabilizando potencia reactiva.", "enable_energy_dip_compensation": "Compensar cuando el panel reporta lecturas de energía más bajas. Al deshabilitar se borran las compensaciones.", "enable_panel_net_energy_sensors": "Energía neta para medidor principal y derivación, contabilizando flujos bidireccionales.", "enable_unmapped_circuit_sensors": "Sensores de respaldo para pestañas no asignadas a interruptores con nombre.", "energy_reporting_grace_period": "Minutos que los sensores mantienen el último valor cuando el panel está fuera de línea (0–60). Predeterminado: 15.", + "panel_admin_only": "Restringir la entrada de la barra lateral del panel de control solo a usuarios administradores.", + "relay_debounce_seconds": "Rechaza una segunda orden al relé del mismo circuito dentro de estos segundos. Se rechaza, nunca se encola: una orden de relé que se ejecuta segundos tarde contra un panel que ha cambiado es peor que un error que el autor de la automatización puede ver. 0 lo desactiva.", + "show_panel": "Mostrar el panel de control de SPAN como entrada de barra lateral.", "snapshot_update_interval": "Segundos entre reconstrucciones de snapshot (0–15). Menor = más rápido, más CPU. 0 = sin debounce." }, "description": "Configurar los ajustes de la integración SPAN Panel.", @@ -518,6 +549,13 @@ } }, "selector": { + "control_mode": { + "options": { + "admin_only": "Solo administradores", + "all_users": "Cualquier usuario de Home Assistant", + "disabled": "Nadie: no crear entidades de control" + } + }, "entity_naming_pattern": { "options": { "circuit_numbers": "Números de Circuito (ej., span_panel_circuit_15_power)", diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index 7b07b09c..9428c60e 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -419,15 +419,35 @@ "bess_meter_power": { "name": "Puissance du Compteur" } + }, + "switch": { + "control_lock": { + "name": "Verrou de contrôle" + } } }, "exceptions": { + "admin_only_control": { + "message": "Seul un administrateur Home Assistant peut piloter ce SPAN Panel." + }, "circuit_priority_failed": { "message": "Échec de la définition de la priorité du circuit pour {circuit}." }, "circuit_relay_failed": { "message": "Échec de la définition de l'état du relais du circuit pour {circuit}." }, + "contextless_control_refused": { + "message": "Ce SPAN Panel refuse les commandes de contrôle qui ne proviennent pas d'un utilisateur connecté." + }, + "control_lock_disarm_requires_admin": { + "message": "Seul un administrateur Home Assistant peut désarmer le verrou de contrôle du SPAN Panel." + }, + "control_lock_disarm_requires_user": { + "message": "Le verrou de contrôle du SPAN Panel ne peut être désarmé que par un administrateur connecté, pas par une automatisation ou un script." + }, + "control_locked": { + "message": "Le verrou de contrôle du SPAN Panel est armé. Désarmez-le avant de piloter des circuits." + }, "evse_charge_limit_failed": { "message": "Échec du réglage de la limite de courant de charge pour le chargeur {charger} : {reason}" }, @@ -470,6 +490,9 @@ "panel_not_ready": { "message": "Le SPAN Panel n'est pas prêt. Veuillez vérifier que le panneau est en ligne." }, + "relay_debounced": { + "message": "Le relais de ce circuit a été actionné il y a quelques instants. La commande a été refusée plutôt que mise en file d'attente." + }, "rotate_credentials_auth_failed": { "message": "Le SPAN Panel a rejeté le jeton d'accès enregistré. Réauthentifiez l'intégration avant de renouveler les identifiants." }, @@ -493,23 +516,31 @@ "step": { "init": { "data": { - "show_panel": "Afficher le Panneau dans la Barre Latérale", - "panel_admin_only": "Utilisateurs Administrateurs Uniquement", + "allow_contextless_control": "Autoriser le contrôle sans utilisateur connecté", + "control_lock_timeout": "Réarmement automatique du verrou de contrôle (minutes)", + "control_mode": "Qui peut piloter le panneau", "enable_circuit_net_energy_sensors": "Énergie Nette du Circuit", "enable_energy_dip_compensation": "Compenser les Baisses d'Énergie Automatiquement", "enable_panel_net_energy_sensors": "Énergie Nette du Panneau", "enable_unmapped_circuit_sensors": "Capteurs de Circuits Non Assignés", "energy_reporting_grace_period": "Période de grâce du capteur d'énergie (minutes)", + "panel_admin_only": "Utilisateurs Administrateurs Uniquement", + "relay_debounce_seconds": "Anti-rebond du relais (secondes)", + "show_panel": "Afficher le Panneau dans la Barre Latérale", "snapshot_update_interval": "Intervalle de Mise à Jour du Snapshot (secondes)" }, "data_description": { - "show_panel": "Afficher le tableau de bord du panneau SPAN comme entrée de barre latérale.", - "panel_admin_only": "Restreindre l'entrée de la barre latérale du tableau de bord aux utilisateurs administrateurs.", + "allow_contextless_control": "Les automatisations, les scripts et les autres intégrations appellent les services sans utilisateur associé. Désactiver cette option refuse ces commandes. Laissez-la activée sauf si vos automatisations ne pilotent pas de circuits.", + "control_lock_timeout": "-1 désactive le verrou de contrôle et supprime son interrupteur. 0 maintient le verrou armé jusqu'à ce que quelqu'un le désarme. Toute autre valeur le réarme ce nombre de minutes après son désarmement. Seuls les administrateurs peuvent le désarmer.", + "control_mode": "Par défaut, Home Assistant accorde à chaque utilisateur non administrateur le contrôle de toutes les entités. Le restreindre aux administrateurs est le seul moyen de changer cela. Le désactiver arrête la création des entités de contrôle ; leurs entrées de registre, noms et zones sont conservés.", "enable_circuit_net_energy_sensors": "Énergie nette par circuit, tenant compte de la puissance réactive.", "enable_energy_dip_compensation": "Compenser lorsque le panneau signale des lectures d'énergie inférieures. La désactivation efface les compensations.", "enable_panel_net_energy_sensors": "Énergie nette pour le compteur principal et la dérivation, tenant compte des flux bidirectionnels.", "enable_unmapped_circuit_sensors": "Capteurs sous-jacents pour les onglets non assignés à des disjoncteurs nommés.", "energy_reporting_grace_period": "Minutes pendant lesquelles les capteurs conservent leur dernière valeur lorsque le panneau est hors ligne (0–60). Par défaut : 15.", + "panel_admin_only": "Restreindre l'entrée de la barre latérale du tableau de bord aux utilisateurs administrateurs.", + "relay_debounce_seconds": "Refuse une seconde commande au relais du même circuit pendant ce nombre de secondes. Refusée, jamais mise en file : une commande de relais déclenchée quelques secondes plus tard sur un panneau qui a changé est pire qu'une erreur visible par l'auteur de l'automatisation. 0 la désactive.", + "show_panel": "Afficher le tableau de bord du panneau SPAN comme entrée de barre latérale.", "snapshot_update_interval": "Secondes entre les reconstructions de snapshot (0–15). Plus bas = plus rapide, plus de CPU. 0 = pas de temporisation." }, "description": "Configurer les paramètres de l'intégration SPAN Panel.", @@ -518,6 +549,13 @@ } }, "selector": { + "control_mode": { + "options": { + "admin_only": "Administrateurs uniquement", + "all_users": "Tout utilisateur Home Assistant", + "disabled": "Personne — ne pas créer d'entités de contrôle" + } + }, "entity_naming_pattern": { "options": { "circuit_numbers": "Numéros de Circuit (ex., span_panel_circuit_15_power)", diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index f27f922a..099331c6 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -419,15 +419,35 @@ "bess_meter_power": { "name": "メーター電力" } + }, + "switch": { + "control_lock": { + "name": "コントロールロック" + } } }, "exceptions": { + "admin_only_control": { + "message": "この SPAN Panel を操作できるのは Home Assistant の管理者だけです。" + }, "circuit_priority_failed": { "message": "{circuit}の回路優先度の設定に失敗しました。" }, "circuit_relay_failed": { "message": "{circuit}の回路リレー状態の設定に失敗しました。" }, + "contextless_control_refused": { + "message": "この SPAN Panel は、ログインユーザーからではない操作コマンドを拒否します。" + }, + "control_lock_disarm_requires_admin": { + "message": "SPAN Panel のコントロールロックを解除できるのは Home Assistant の管理者だけです。" + }, + "control_lock_disarm_requires_user": { + "message": "SPAN Panel のコントロールロックを解除できるのは、ログインした管理者だけです。オートメーションやスクリプトからは解除できません。" + }, + "control_locked": { + "message": "SPAN Panel のコントロールロックが施錠されています。回路を操作する前に解除してください。" + }, "evse_charge_limit_failed": { "message": "充電器 {charger} の充電電流上限を設定できませんでした: {reason}" }, @@ -470,6 +490,9 @@ "panel_not_ready": { "message": "SPAN Panelの準備ができていません。パネルがオンラインであることを確認してください。" }, + "relay_debounced": { + "message": "その回路のリレーはついさきほど操作されました。コマンドはキューに入れずに拒否しました。" + }, "rotate_credentials_auth_failed": { "message": "SPAN Panel が保存済みのアクセストークンを拒否しました。認証情報をローテーションする前に、統合を再認証してください。" }, @@ -493,23 +516,31 @@ "step": { "init": { "data": { - "show_panel": "サイドバーにダッシュボードを表示", - "panel_admin_only": "管理ユーザーのみ", + "allow_contextless_control": "ログインユーザーなしの操作を許可する", + "control_lock_timeout": "コントロールロックの自動再施錠(分)", + "control_mode": "パネルを操作できるユーザー", "enable_circuit_net_energy_sensors": "回路正味エネルギー", "enable_energy_dip_compensation": "エネルギー低下を自動補正", "enable_panel_net_energy_sensors": "パネル正味エネルギー", "enable_unmapped_circuit_sensors": "未割当回路センサー", "energy_reporting_grace_period": "エネルギーセンサー猶予期間 (分)", + "panel_admin_only": "管理ユーザーのみ", + "relay_debounce_seconds": "リレーのデバウンス(秒)", + "show_panel": "サイドバーにダッシュボードを表示", "snapshot_update_interval": "スナップショット更新間隔(秒)" }, "data_description": { - "show_panel": "SPANパネルダッシュボードをサイドバーエントリとして表示します。", - "panel_admin_only": "ダッシュボードサイドバーエントリを管理ユーザーのみに制限します。", + "allow_contextless_control": "オートメーション、スクリプト、他の統合は、ユーザーが紐づかない状態でサービスを呼び出します。無効にすると、そうしたコマンドを拒否します。オートメーションで回路を操作していない場合を除き、有効のままにしてください。", + "control_lock_timeout": "-1 はコントロールロックを無効にし、そのスイッチを削除します。0 は誰かが解除するまで施錠したままにします。それ以外の値は、解除してからその分数後に再施錠します。解除できるのは管理者だけです。", + "control_mode": "Home Assistant は既定で、管理者以外のすべてのユーザーにすべてのエンティティの操作を許可します。管理者のみに制限することが、これを変更する唯一の方法です。無効にすると操作用エンティティが作成されなくなりますが、レジストリのエントリ、名前、エリアは保持されます。", "enable_circuit_net_energy_sensors": "無効電力を考慮した回路ごとの正味エネルギー。", "enable_energy_dip_compensation": "パネルがより低いエネルギー読み取り値を報告した場合に補正します。無効にするとオフセットがクリアされます。", "enable_panel_net_energy_sensors": "双方向フローを考慮した主幹メーターとフィードスルーの正味エネルギー。", "enable_unmapped_circuit_sensors": "名前付きブレーカーに割り当てられていないタブのバッキングセンサー。", "energy_reporting_grace_period": "パネルがオフライン時にセンサーが最後の値を保持する分数(0〜60)。デフォルト:15。", + "panel_admin_only": "ダッシュボードサイドバーエントリを管理ユーザーのみに制限します。", + "relay_debounce_seconds": "同じ回路のリレーへの 2 回目のコマンドを、この秒数のあいだ拒否します。キューに入れずに拒否します。状態の変わったパネルに対して数秒遅れで発火するリレーコマンドは、オートメーションの作成者が気づけるエラーよりも危険だからです。0 で無効になります。", + "show_panel": "SPANパネルダッシュボードをサイドバーエントリとして表示します。", "snapshot_update_interval": "スナップショット再構築の間隔(秒)(0〜15)。低い値=高速、CPU負荷増。0=デバウンスなし。" }, "description": "SPAN Panel統合の設定を構成します。", @@ -518,6 +549,13 @@ } }, "selector": { + "control_mode": { + "options": { + "admin_only": "管理者のみ", + "all_users": "すべての Home Assistant ユーザー", + "disabled": "なし — 操作用エンティティを作成しない" + } + }, "entity_naming_pattern": { "options": { "circuit_numbers": "回路番号(例:span_panel_circuit_15_power)", diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index f3de98c0..f43117a8 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -419,15 +419,35 @@ "bess_meter_power": { "name": "Potência do Medidor" } + }, + "switch": { + "control_lock": { + "name": "Bloqueio de controlo" + } } }, "exceptions": { + "admin_only_control": { + "message": "Apenas um administrador do Home Assistant pode operar este SPAN Panel." + }, "circuit_priority_failed": { "message": "Falha ao definir a prioridade do circuito para {circuit}." }, "circuit_relay_failed": { "message": "Falha ao definir o estado do relé do circuito para {circuit}." }, + "contextless_control_refused": { + "message": "Este SPAN Panel recusa comandos de controlo que não venham de um utilizador com sessão iniciada." + }, + "control_lock_disarm_requires_admin": { + "message": "Apenas um administrador do Home Assistant pode desarmar o bloqueio de controlo do SPAN Panel." + }, + "control_lock_disarm_requires_user": { + "message": "O bloqueio de controlo do SPAN Panel só pode ser desarmado por um administrador com sessão iniciada, não por uma automação ou script." + }, + "control_locked": { + "message": "O bloqueio de controlo do SPAN Panel está armado. Desarme-o antes de operar circuitos." + }, "evse_charge_limit_failed": { "message": "Falha ao definir o limite de corrente de carga do carregador {charger}: {reason}" }, @@ -470,6 +490,9 @@ "panel_not_ready": { "message": "O SPAN Panel não está pronto. Verifique se o painel está online." }, + "relay_debounced": { + "message": "O relé desse circuito foi operado há instantes. O comando foi recusado em vez de colocado em fila." + }, "rotate_credentials_auth_failed": { "message": "O SPAN Panel rejeitou o token de acesso guardado. Volte a autenticar a integração antes de rodar as credenciais." }, @@ -493,23 +516,31 @@ "step": { "init": { "data": { - "show_panel": "Mostrar Painel na Barra Lateral", - "panel_admin_only": "Apenas Usuários Administradores", + "allow_contextless_control": "Permitir controlo sem um utilizador com sessão iniciada", + "control_lock_timeout": "Rearme automático do bloqueio de controlo (minutos)", + "control_mode": "Quem pode operar o painel", "enable_circuit_net_energy_sensors": "Energia Líquida do Circuito", "enable_energy_dip_compensation": "Compensar Quedas de Energia Automaticamente", "enable_panel_net_energy_sensors": "Energia Líquida do Painel", "enable_unmapped_circuit_sensors": "Sensores de Circuitos Não Mapeados", "energy_reporting_grace_period": "Período de Graça do Sensor de Energia (minutos)", + "panel_admin_only": "Apenas Usuários Administradores", + "relay_debounce_seconds": "Antirrepique do relé (segundos)", + "show_panel": "Mostrar Painel na Barra Lateral", "snapshot_update_interval": "Intervalo de Atualização do Snapshot (segundos)" }, "data_description": { - "show_panel": "Exiba o painel de controle do SPAN como uma entrada de barra lateral.", - "panel_admin_only": "Restrinja a entrada da barra lateral do painel de controle apenas aos usuários administradores.", + "allow_contextless_control": "Automações, scripts e outras integrações invocam serviços sem qualquer utilizador associado. Desativar esta opção recusa esses comandos. Deixe-a ativa a não ser que as suas automações não operem circuitos.", + "control_lock_timeout": "-1 desativa o bloqueio de controlo e remove o respetivo interruptor. 0 mantém o bloqueio armado até alguém o desarmar. Qualquer outro valor rearma-o esse número de minutos depois de ser desarmado. Só os administradores o podem desarmar.", + "control_mode": "Por predefinição, o Home Assistant concede a todos os utilizadores não administradores o controlo de todas as entidades. Restringir a administradores é a única forma de alterar isso. Desativar deixa de criar as entidades de controlo; as respetivas entradas de registo, nomes e áreas são mantidos.", "enable_circuit_net_energy_sensors": "Energia líquida por circuito, contabilizando potência reativa.", "enable_energy_dip_compensation": "Compensar quando o painel reporta leituras de energia mais baixas. Ao desativar, as compensações são apagadas.", "enable_panel_net_energy_sensors": "Energia líquida para o medidor principal e derivação, contabilizando fluxos bidirecionais.", "enable_unmapped_circuit_sensors": "Sensores de apoio para posições não atribuídas a disjuntores nomeados.", "energy_reporting_grace_period": "Minutos que os sensores mantêm o último valor quando o painel está offline (0–60). Padrão: 15.", + "panel_admin_only": "Restrinja a entrada da barra lateral do painel de controle apenas aos usuários administradores.", + "relay_debounce_seconds": "Recusa um segundo comando ao relé do mesmo circuito dentro deste número de segundos. Recusado, nunca colocado em fila: um comando de relé disparado segundos mais tarde contra um painel que mudou é pior do que um erro que o autor da automação consegue ver. 0 desativa.", + "show_panel": "Exiba o painel de controle do SPAN como uma entrada de barra lateral.", "snapshot_update_interval": "Segundos entre reconstruções do snapshot (0–15). Menor = mais rápido, mais CPU. 0 = sem debounce." }, "description": "Configurar os ajustes da integração SPAN Panel.", @@ -518,6 +549,13 @@ } }, "selector": { + "control_mode": { + "options": { + "admin_only": "Apenas administradores", + "all_users": "Qualquer utilizador do Home Assistant", + "disabled": "Ninguém — não criar entidades de controlo" + } + }, "entity_naming_pattern": { "options": { "circuit_numbers": "Números de Circuito (ex., span_panel_circuit_15_power)", diff --git a/tests/test_binary_sensor_platform.py b/tests/test_binary_sensor_platform.py index 60d57e97..69445d71 100644 --- a/tests/test_binary_sensor_platform.py +++ b/tests/test_binary_sensor_platform.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock from custom_components.span_panel import SpanPanelRuntimeData +from custom_components.span_panel.control_gate import ControlPolicy from custom_components.span_panel.binary_sensor import ( BESS_CONNECTED_SENSOR, BINARY_SENSORS, @@ -230,7 +231,7 @@ async def test_binary_sensor_async_setup_entry_adds_panel_bess_and_evse_entities ) coordinator = _make_coordinator(snapshot) config_entry = MockConfigEntry(domain=DOMAIN, data={}, title="SPAN Panel") - config_entry.runtime_data = MagicMock(coordinator=coordinator) + config_entry.runtime_data = MagicMock(control_policy=ControlPolicy.default(), coordinator=coordinator) async_add_entities = MagicMock() await async_setup_entry(hass, config_entry, async_add_entities) diff --git a/tests/test_button.py b/tests/test_button.py index 02de4eb4..e327281d 100644 --- a/tests/test_button.py +++ b/tests/test_button.py @@ -13,6 +13,7 @@ async_setup_entry, ) from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.control_gate import ControlPolicy from homeassistant.core import HomeAssistant from .factories import SpanBatterySnapshotFactory, SpanPanelSnapshotFactory @@ -151,7 +152,7 @@ class FakeSpanMqttClient: coordinator = _make_button_coordinator(snapshot) coordinator.client = FakeSpanMqttClient() config_entry = MockConfigEntry(domain=DOMAIN, data={}, title="SPAN Panel") - config_entry.runtime_data = MagicMock(coordinator=coordinator) + config_entry.runtime_data = MagicMock(control_policy=ControlPolicy.default(), coordinator=coordinator) async_add_entities = MagicMock() with patch( @@ -169,7 +170,7 @@ class FakeSpanMqttClient: ) ) coordinator_no_bess.client = FakeSpanMqttClient() - config_entry.runtime_data = MagicMock(coordinator=coordinator_no_bess) + config_entry.runtime_data = MagicMock(control_policy=ControlPolicy.default(), coordinator=coordinator_no_bess) async_add_entities = MagicMock() with patch( diff --git a/tests/test_circuit_control.py b/tests/test_circuit_control.py index c4d08199..d62d5657 100644 --- a/tests/test_circuit_control.py +++ b/tests/test_circuit_control.py @@ -6,6 +6,7 @@ import pytest +from custom_components.span_panel.control_gate import ControlPolicy from custom_components.span_panel.switch import ( SpanPanelCircuitsSwitch, async_setup_entry, @@ -61,7 +62,7 @@ async def test_switch_creation_for_controllable_circuit(hass: HomeAssistant) -> mock_entry = MagicMock() mock_entry.title = "SPAN Panel" mock_entry.data = {} - mock_entry.runtime_data = MagicMock(coordinator=coordinator) + mock_entry.runtime_data = MagicMock(control_policy=ControlPolicy.default(), coordinator=coordinator) await async_setup_entry(hass, mock_entry, lambda e, **kw: entities.extend(e)) @@ -294,7 +295,7 @@ async def test_switch_async_setup_entry_filters_supported_circuits( mock_entry = MagicMock() mock_entry.title = "SPAN Panel" mock_entry.data = {} - mock_entry.runtime_data = MagicMock(coordinator=coordinator) + mock_entry.runtime_data = MagicMock(control_policy=ControlPolicy.default(), coordinator=coordinator) await async_setup_entry(hass, mock_entry, lambda e, **kw: entities.extend(e)) diff --git a/tests/test_control_gate.py b/tests/test_control_gate.py new file mode 100644 index 00000000..c225feb8 --- /dev/null +++ b/tests/test_control_gate.py @@ -0,0 +1,773 @@ +"""Tests for the control gate: who may operate the panel, and the record of it. + +These exercise the interceptor directly rather than going through the platforms. +Every existing control test assigns a mock client to `coordinator.client`, and a +mock has no interceptor — so a platform test proves nothing about the gate, and +assuming otherwise would leave the gate untested while looking covered. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from span_panel_api import ControlCommand, PublishOutcome, PublishState + +from custom_components.span_panel.const import DOMAIN, EVENT_CONTROL_COMMAND +from custom_components.span_panel.control_gate import ( + CONTROL_CALLER, + ControlGate, + ControlLock, + ControlMode, + ControlPolicy, + async_bind_caller, + outcome_is_failure, +) +from homeassistant.core import Context, Event, HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from pytest_homeassistant_custom_component.common import MockConfigEntry, MockUser + +RELAY = ControlCommand( + device_id="sp3-test-001", + node_id="circuit-1", + property_id="relay", + value="CLOSED", + topic="ebus/5/circuit-1/switch/relay/set", +) +PRIORITY = ControlCommand( + device_id="sp3-test-001", + node_id="circuit-1", + property_id="priority", + value="OFF_GRID", + topic="ebus/5/circuit-1/load-shed/priority/set", +) + +CONFIRMED = PublishOutcome(state=PublishState.CONFIRMED, topic=RELAY.topic, value="CLOSED") +VETOED = PublishOutcome( + state=PublishState.FAILED, topic=RELAY.topic, value="CLOSED", detail="vetoed" +) + + +def _entry(hass: HomeAssistant) -> MockConfigEntry: + entry = MockConfigEntry(domain=DOMAIN, title="Span Panel", version=7) + entry.add_to_hass(hass) + return entry + + +def _gate( + hass: HomeAssistant, + *, + mode: ControlMode = ControlMode.ALL_USERS, + allow_contextless: bool = True, + debounce: float = 0.0, + lock: ControlLock | None = None, +) -> ControlGate: + policy = ControlPolicy( + mode=mode, + allow_contextless=allow_contextless, + lock_timeout_minutes=None, + relay_debounce_seconds=debounce, + ) + return ControlGate(hass, _entry(hass), policy, lock or ControlLock()) + + +# ---------- authorization ---------- + + +@pytest.mark.asyncio +async def test_all_users_lets_anyone_through(hass: HomeAssistant) -> None: + """The default reproduces the behaviour every existing entry already has.""" + gate = _gate(hass) + user = MockUser().add_to_hass(hass) + + token = async_bind_caller(Context(user_id=user.id), "switch.span_panel_kitchen") + try: + await gate.before_publish(RELAY) + finally: + CONTROL_CALLER.reset(token) + + +@pytest.mark.asyncio +async def test_admin_only_allows_an_admin(hass: HomeAssistant) -> None: + """An administrator is exactly who admin_only is for.""" + gate = _gate(hass, mode=ControlMode.ADMIN_ONLY) + admin = MockUser(is_owner=True).add_to_hass(hass) + + token = async_bind_caller(Context(user_id=admin.id), "switch.span_panel_kitchen") + try: + await gate.before_publish(RELAY) + finally: + CONTROL_CALLER.reset(token) + + +@pytest.mark.asyncio +async def test_admin_only_refuses_a_non_admin(hass: HomeAssistant) -> None: + """Home Assistant's default user policy grants a non-admin control of everything.""" + gate = _gate(hass, mode=ControlMode.ADMIN_ONLY) + user = MockUser().add_to_hass(hass) + + token = async_bind_caller(Context(user_id=user.id), "switch.span_panel_kitchen") + try: + with pytest.raises(ServiceValidationError) as err: + await gate.before_publish(RELAY) + finally: + CONTROL_CALLER.reset(token) + + assert err.value.translation_key == "admin_only_control" + + +@pytest.mark.asyncio +async def test_admin_only_refuses_a_user_that_no_longer_resolves( + hass: HomeAssistant, +) -> None: + """A deleted user is not an administrator.""" + gate = _gate(hass, mode=ControlMode.ADMIN_ONLY) + + token = async_bind_caller(Context(user_id="gone"), "switch.span_panel_kitchen") + try: + with pytest.raises(ServiceValidationError) as err: + await gate.before_publish(RELAY) + finally: + CONTROL_CALLER.reset(token) + + assert err.value.translation_key == "admin_only_control" + + +@pytest.mark.asyncio +async def test_admin_only_does_not_by_itself_refuse_a_contextless_caller( + hass: HomeAssistant, +) -> None: + """The two options are independent, and conflating them would be a surprise. + + `admin_only` is about *which user*; there is no user here to judge. Refusing + an automation is what `allow_contextless_control` is for, and an installation + that tightened one option must not silently get the other. + """ + gate = _gate(hass, mode=ControlMode.ADMIN_ONLY, allow_contextless=True) + + token = async_bind_caller(Context(), "switch.span_panel_kitchen") + try: + await gate.before_publish(RELAY) + finally: + CONTROL_CALLER.reset(token) + + +@pytest.mark.asyncio +async def test_contextless_control_can_be_refused(hass: HomeAssistant) -> None: + """An automation, script or integration calls with no user attached.""" + gate = _gate(hass, allow_contextless=False) + + token = async_bind_caller(Context(parent_id="automation-context"), "switch.x") + try: + with pytest.raises(ServiceValidationError) as err: + await gate.before_publish(RELAY) + finally: + CONTROL_CALLER.reset(token) + + assert err.value.translation_key == "contextless_control_refused" + + +@pytest.mark.asyncio +async def test_an_unbound_caller_is_treated_as_contextless(hass: HomeAssistant) -> None: + """This is the fail-closed default, and the reason the gate is not in the helper. + + A future control path that forgets `_async_guarded_control` arrives here with + nothing bound. Permitting it would make the gate a suggestion. + """ + gate = _gate(hass, allow_contextless=False) + + # Deliberately no `async_bind_caller`. + with pytest.raises(ServiceValidationError) as err: + await gate.before_publish(RELAY) + + assert err.value.translation_key == "contextless_control_refused" + + +# ---------- the control lock ---------- + + +@pytest.mark.asyncio +async def test_an_armed_lock_refuses_every_path(hass: HomeAssistant) -> None: + """Armed means armed, for an administrator as much as for anyone else.""" + lock = ControlLock() + lock.arm() + gate = _gate(hass, lock=lock) + admin = MockUser(is_owner=True).add_to_hass(hass) + + for command in (RELAY, PRIORITY): + token = async_bind_caller(Context(user_id=admin.id), "switch.x") + try: + with pytest.raises(ServiceValidationError) as err: + await gate.before_publish(command) + finally: + CONTROL_CALLER.reset(token) + assert err.value.translation_key == "control_locked" + + +def test_a_disarm_with_no_timeout_stays_disarmed() -> None: + """Zero means manual relock, which is a real choice and not "immediately".""" + lock = ControlLock() + lock.arm() + + lock.disarm(0) + + assert lock.armed is False + + +def test_auto_relock_fires_once_the_window_passes() -> None: + """Evaluated on read, so a suspended event loop cannot skip it.""" + lock = ControlLock() + lock.arm() + + with patch("custom_components.span_panel.control_gate.time.monotonic", return_value=0.0): + lock.disarm(5) + assert lock.armed is False + + with patch("custom_components.span_panel.control_gate.time.monotonic", return_value=299.0): + assert lock.armed is False + + with patch("custom_components.span_panel.control_gate.time.monotonic", return_value=301.0): + assert lock.armed is True + + +# ---------- relay debounce ---------- + + +@pytest.mark.asyncio +async def test_a_second_relay_command_inside_the_window_is_refused( + hass: HomeAssistant, +) -> None: + """Refused, not queued: a late relay command against a changed panel is worse.""" + gate = _gate(hass, debounce=2.0) + + with patch( + "custom_components.span_panel.control_gate.time.monotonic", return_value=100.0 + ): + await gate.before_publish(RELAY) + + with patch( + "custom_components.span_panel.control_gate.time.monotonic", return_value=101.0 + ): + with pytest.raises(ServiceValidationError) as err: + await gate.before_publish(RELAY) + + assert err.value.translation_key == "relay_debounced" + + +@pytest.mark.asyncio +async def test_the_debounce_is_per_circuit(hass: HomeAssistant) -> None: + """Two breakers are two independent physical things.""" + gate = _gate(hass, debounce=2.0) + other = ControlCommand( + device_id=RELAY.device_id, + node_id="circuit-2", + property_id="relay", + value="OPEN", + topic="ebus/5/circuit-2/switch/relay/set", + ) + + with patch( + "custom_components.span_panel.control_gate.time.monotonic", return_value=100.0 + ): + await gate.before_publish(RELAY) + await gate.before_publish(other) + + +@pytest.mark.asyncio +async def test_the_debounce_does_not_touch_other_controls(hass: HomeAssistant) -> None: + """A priority write is not a relay actuation and has no contactor to protect.""" + gate = _gate(hass, debounce=2.0) + + with patch( + "custom_components.span_panel.control_gate.time.monotonic", return_value=100.0 + ): + await gate.before_publish(PRIORITY) + await gate.before_publish(PRIORITY) + + +@pytest.mark.asyncio +async def test_a_zero_debounce_disables_it(hass: HomeAssistant) -> None: + """The option has to be switchable off, and zero is how.""" + gate = _gate(hass, debounce=0.0) + + await gate.before_publish(RELAY) + await gate.before_publish(RELAY) + + +# ---------- the audit ---------- + + +def _events(hass: HomeAssistant) -> list[Event]: + captured: list[Event] = [] + hass.bus.async_listen(EVENT_CONTROL_COMMAND, captured.append) + return captured + + +@pytest.mark.asyncio +async def test_a_successful_command_is_recorded_with_its_caller( + hass: HomeAssistant, +) -> None: + """The audit needs who, what and how it went.""" + gate = _gate(hass) + captured = _events(hass) + user = MockUser().add_to_hass(hass) + + token = async_bind_caller(Context(user_id=user.id), "switch.span_panel_kitchen") + try: + await gate.after_publish(RELAY, CONFIRMED) + finally: + CONTROL_CALLER.reset(token) + await hass.async_block_till_done() + + assert len(captured) == 1 + data = captured[0].data + assert data["user_id"] == user.id + assert data["entity_id"] == "switch.span_panel_kitchen" + assert data["command"] == "circuit-1/relay" + assert data["value"] == "CLOSED" + assert data["outcome"] == "confirmed" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "state", + [PublishState.CONFIRMED, PublishState.ACCEPTED, PublishState.UNCONFIRMED, PublishState.FAILED], +) +async def test_every_publish_state_reaches_the_audit_intact( + hass: HomeAssistant, state: PublishState +) -> None: + """Four states, not two — each sends an investigation somewhere different.""" + gate = _gate(hass) + captured = _events(hass) + + await gate.after_publish( + RELAY, PublishOutcome(state=state, topic=RELAY.topic, value="CLOSED") + ) + await hass.async_block_till_done() + + assert captured[0].data["outcome"] == state.value + + +@pytest.mark.asyncio +async def test_a_refusal_is_recorded_with_its_reason(hass: HomeAssistant) -> None: + """An audit that omitted the refusals would be worse than no audit. + + The reason survives from `before_publish` to `after_publish` because the + library fires the callback as a task created from the same context. + """ + gate = _gate(hass, mode=ControlMode.ADMIN_ONLY) + captured = _events(hass) + user = MockUser().add_to_hass(hass) + + token = async_bind_caller(Context(user_id=user.id), "switch.span_panel_kitchen") + try: + with pytest.raises(ServiceValidationError): + await gate.before_publish(RELAY) + # The library fires this itself on a veto, with FAILED and "vetoed". + await gate.after_publish(RELAY, VETOED) + finally: + CONTROL_CALLER.reset(token) + await hass.async_block_till_done() + + assert captured[0].data["outcome"] == "refused:admin_only_control" + + +@pytest.mark.asyncio +async def test_a_contextless_command_is_attributed_to_its_parent( + hass: HomeAssistant, +) -> None: + """A write with no user is attributed to *what* rather than left blank.""" + gate = _gate(hass) + captured = _events(hass) + + token = async_bind_caller(Context(parent_id="automation-context"), "switch.x") + try: + await gate.after_publish(RELAY, CONFIRMED) + finally: + CONTROL_CALLER.reset(token) + await hass.async_block_till_done() + + assert captured[0].data["user_id"] is None + assert captured[0].data["parent_id"] == "automation-context" + + +# ---------- outcome classification ---------- + + +@pytest.mark.parametrize( + ("state", "is_failure"), + [ + (PublishState.CONFIRMED, False), + # Handed to the broker and possibly already acted on. Calling either of + # these a failure would tell a user something untrue about their panel. + (PublishState.ACCEPTED, False), + (PublishState.UNCONFIRMED, False), + (PublishState.FAILED, True), + ], +) +def test_only_failed_means_it_will_never_be_delivered( + state: PublishState, is_failure: bool +) -> None: + """`FAILED` is the one state that is a promise about the future.""" + outcome = PublishOutcome(state=state, topic=RELAY.topic, value="CLOSED") + + assert outcome_is_failure(outcome) is is_failure + + +# ---------- policy resolution ---------- + + +def test_the_defaults_change_nothing_for_an_existing_entry() -> None: + """A silent tightening on upgrade is worse than the status quo.""" + policy = ControlPolicy.from_options({}) + + assert policy.mode is ControlMode.ALL_USERS + assert policy.allow_contextless is True + assert policy.lock_enabled is False + assert policy.relay_debounce_seconds == 2.0 + + +def test_an_unrecognised_mode_falls_back_to_the_permissive_one() -> None: + """A typo in `.storage` must not lock a household out of its own panel.""" + policy = ControlPolicy.from_options({"control_mode": "nonsense"}) + + assert policy.mode is ControlMode.ALL_USERS + + +def test_a_negative_lock_timeout_turns_the_feature_off() -> None: + """Absent and zero are different states, so the option needs a third value.""" + assert ControlPolicy.from_options({"control_lock_timeout": -1}).lock_enabled is False + assert ControlPolicy.from_options({"control_lock_timeout": 0}).lock_enabled is True + + +@pytest.mark.asyncio +async def test_a_disabled_entry_creates_no_control_entities(hass: HomeAssistant) -> None: + """No control entities, and no registry entries removed either.""" + from custom_components.span_panel.switch import async_setup_entry + + from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory + + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen", is_user_controllable=True + ) + coordinator = MagicMock() + coordinator.data = SpanPanelSnapshotFactory.create(circuits={"1": circuit}) + coordinator.config_entry = MagicMock() + coordinator.config_entry.options = {} + coordinator.client = AsyncMock() + + entry = MagicMock() + entry.title = "SPAN Panel" + entry.data = {} + entry.runtime_data = MagicMock( + coordinator=coordinator, + control_policy=ControlPolicy( + mode=ControlMode.DISABLED, + allow_contextless=True, + lock_timeout_minutes=None, + relay_debounce_seconds=2.0, + ), + ) + + added: list[object] = [] + await async_setup_entry(hass, entry, lambda e, **kw: added.extend(e)) + + assert added == [] + + +# ---------- the lock entity's own gate ---------- + + +def _lock_switch(hass: HomeAssistant, lock: ControlLock, timeout: float | None): + """Build the control-lock switch against a mock coordinator.""" + from custom_components.span_panel.switch import SpanPanelControlLockSwitch + + from .factories import SpanPanelSnapshotFactory + + coordinator = MagicMock() + coordinator.data = SpanPanelSnapshotFactory.create(circuits={}) + coordinator.config_entry = MagicMock() + coordinator.config_entry.options = {} + + policy = ControlPolicy( + mode=ControlMode.ALL_USERS, + allow_contextless=True, + lock_timeout_minutes=timeout, + relay_debounce_seconds=0.0, + ) + entity = SpanPanelControlLockSwitch(coordinator, lock, policy, "SPAN Panel") + entity.hass = hass + entity.async_write_ha_state = MagicMock() + return entity + + +@pytest.mark.asyncio +async def test_anyone_may_arm_the_lock(hass: HomeAssistant) -> None: + """Making a household safer should not require admin.""" + lock = ControlLock() + entity = _lock_switch(hass, lock, 0) + user = MockUser().add_to_hass(hass) + entity._context = Context(user_id=user.id) + + await entity.async_turn_on() + + assert lock.armed is True + assert entity.is_on is True + + +@pytest.mark.asyncio +async def test_an_automation_may_arm_the_lock(hass: HomeAssistant) -> None: + """Arming is the safe direction, so it is not gated on a user at all.""" + lock = ControlLock() + entity = _lock_switch(hass, lock, 0) + entity._context = Context() + + await entity.async_turn_on() + + assert lock.armed is True + + +@pytest.mark.asyncio +async def test_a_non_admin_cannot_disarm_the_lock(hass: HomeAssistant) -> None: + """A lock a non-admin can disarm is not a lock.""" + lock = ControlLock() + lock.arm() + entity = _lock_switch(hass, lock, 0) + user = MockUser().add_to_hass(hass) + entity._context = Context(user_id=user.id) + + with pytest.raises(ServiceValidationError) as err: + await entity.async_turn_off() + + assert err.value.translation_key == "control_lock_disarm_requires_admin" + assert lock.armed is True + + +@pytest.mark.asyncio +async def test_an_automation_can_never_disarm_the_lock(hass: HomeAssistant) -> None: + """Refused regardless of `allow_contextless_control`. + + An automation that can unlock the panel defeats the purpose of the lock, so + this is not the same decision as whether an automation may operate circuits. + """ + lock = ControlLock() + lock.arm() + entity = _lock_switch(hass, lock, 0) + entity._context = Context() + + with pytest.raises(ServiceValidationError) as err: + await entity.async_turn_off() + + assert err.value.translation_key == "control_lock_disarm_requires_user" + assert lock.armed is True + + +@pytest.mark.asyncio +async def test_an_admin_can_disarm_the_lock(hass: HomeAssistant) -> None: + """Making a household less safe is the direction that needs admin.""" + lock = ControlLock() + lock.arm() + entity = _lock_switch(hass, lock, 0) + admin = MockUser(is_owner=True).add_to_hass(hass) + entity._context = Context(user_id=admin.id) + + await entity.async_turn_off() + + assert lock.armed is False + + +# ---------- optimistic state ---------- + + +@pytest.mark.asyncio +async def test_a_refused_relay_leaves_the_switch_where_it_was( + hass: HomeAssistant, +) -> None: + """A gate refusal must not leave the UI showing a position the relay never took. + + Nothing changed on the panel, so no coordinator update is coming to correct + an optimistic write — it would simply stay wrong. + """ + from custom_components.span_panel.switch import SpanPanelCircuitsSwitch + + from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory + + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen", relay_state="OPEN", is_user_controllable=True + ) + coordinator = MagicMock() + coordinator.data = SpanPanelSnapshotFactory.create(circuits={"1": circuit}) + coordinator.config_entry = MagicMock() + coordinator.config_entry.options = {} + coordinator.config_entry.title = "SPAN Panel" + coordinator.config_entry.data = {} + coordinator.client = MagicMock() + coordinator.client.set_circuit_relay = AsyncMock( + side_effect=ServiceValidationError("refused") + ) + + entity = SpanPanelCircuitsSwitch(coordinator, "1", "Kitchen", "SPAN Panel") + entity.hass = hass + entity.async_write_ha_state = MagicMock() + before = entity.is_on + + with pytest.raises(ServiceValidationError): + await entity.async_turn_on() + + assert entity.is_on == before + entity.async_write_ha_state.assert_not_called() + + +@pytest.mark.asyncio +async def test_an_undelivered_relay_command_does_not_move_the_switch( + hass: HomeAssistant, +) -> None: + """`FAILED` is the one outcome that promises the command will never arrive.""" + from custom_components.span_panel.switch import SpanPanelCircuitsSwitch + + from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory + + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen", relay_state="OPEN", is_user_controllable=True + ) + coordinator = MagicMock() + coordinator.data = SpanPanelSnapshotFactory.create(circuits={"1": circuit}) + coordinator.config_entry = MagicMock() + coordinator.config_entry.options = {} + coordinator.config_entry.title = "SPAN Panel" + coordinator.config_entry.data = {} + coordinator.client = MagicMock() + coordinator.client.set_circuit_relay = AsyncMock( + return_value=PublishOutcome( + state=PublishState.FAILED, + topic=RELAY.topic, + value="CLOSED", + detail="broker not connected; refused rather than queued", + ) + ) + + entity = SpanPanelCircuitsSwitch(coordinator, "1", "Kitchen", "SPAN Panel") + entity.hass = hass + entity.async_write_ha_state = MagicMock() + before = entity.is_on + + await entity.async_turn_on() + + assert entity.is_on == before + entity.async_write_ha_state.assert_not_called() + + +@pytest.mark.asyncio +async def test_an_unconfirmed_relay_command_still_shows_the_requested_state( + hass: HomeAssistant, +) -> None: + """`UNCONFIRMED` most often means the relay was already in that position.""" + from custom_components.span_panel.switch import SpanPanelCircuitsSwitch + + from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory + + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen", relay_state="OPEN", is_user_controllable=True + ) + coordinator = MagicMock() + coordinator.data = SpanPanelSnapshotFactory.create(circuits={"1": circuit}) + coordinator.config_entry = MagicMock() + coordinator.config_entry.options = {} + coordinator.config_entry.title = "SPAN Panel" + coordinator.config_entry.data = {} + coordinator.client = MagicMock() + coordinator.client.set_circuit_relay = AsyncMock( + return_value=PublishOutcome( + state=PublishState.UNCONFIRMED, + topic=RELAY.topic, + value="CLOSED", + no_op=True, + detail="the property already reports this value", + ) + ) + + entity = SpanPanelCircuitsSwitch(coordinator, "1", "Kitchen", "SPAN Panel") + entity.hass = hass + entity.async_write_ha_state = MagicMock() + + await entity.async_turn_on() + + assert entity.is_on is True + entity.async_write_ha_state.assert_called_once() + + +# ---------- the logbook rendering ---------- + + +def _describe(hass: HomeAssistant, data: dict[str, object]) -> dict[str, str]: + """Run the registered describer over one event's data.""" + from custom_components.span_panel.logbook import async_describe_events + + described: dict[str, object] = {} + + def register(domain: str, event: str, describer: object) -> None: + described["fn"] = describer + + async_describe_events(hass, register) # type: ignore[arg-type] + describer = described["fn"] + assert callable(describer) + return describer(Event(EVENT_CONTROL_COMMAND, data)) + + +@pytest.mark.asyncio +async def test_the_logbook_names_the_user_and_the_outcome(hass: HomeAssistant) -> None: + """A logbook line has to say who and how it went, or it is not worth writing.""" + described = _describe( + hass, + { + "user_id": "abc123", + "parent_id": None, + "entity_id": "switch.span_panel_kitchen", + "command": "circuit-1/relay", + "value": "CLOSED", + "outcome": "confirmed", + }, + ) + + assert "circuit-1/relay" in described["message"] + assert "abc123" in described["message"] + assert "confirmed it" in described["message"] + assert described["entity_id"] == "switch.span_panel_kitchen" + + +@pytest.mark.asyncio +async def test_the_logbook_does_not_call_unconfirmed_a_failure( + hass: HomeAssistant, +) -> None: + """The distinction is the reason `PublishState` has four members.""" + described = _describe( + hass, + { + "user_id": None, + "parent_id": None, + "command": "circuit-1/relay", + "value": "CLOSED", + "outcome": "unconfirmed", + }, + ) + + assert "fail" not in described["message"].lower() + assert "no acknowledgement" in described["message"] + + +@pytest.mark.asyncio +async def test_the_logbook_explains_a_refusal(hass: HomeAssistant) -> None: + """A refused command that read as a plain failure would send someone hunting.""" + described = _describe( + hass, + { + "user_id": None, + "parent_id": None, + "command": "circuit-1/relay", + "value": "CLOSED", + "outcome": "refused:relay_debounced", + }, + ) + + assert "operated moments ago" in described["message"] diff --git a/tests/test_select.py b/tests/test_select.py index e815b898..be140106 100644 --- a/tests/test_select.py +++ b/tests/test_select.py @@ -7,6 +7,7 @@ from span_panel_api.exceptions import SpanPanelServerError from custom_components.span_panel.const import CircuitPriority +from custom_components.span_panel.control_gate import ControlPolicy from custom_components.span_panel.select import ( CIRCUIT_PRIORITY_DESCRIPTION, SpanPanelCircuitsSelect, @@ -449,7 +450,7 @@ async def test_async_setup_entry_filters_supported_circuits() -> None: config_entry = MagicMock() config_entry.title = "SPAN Panel" config_entry.data = {} - config_entry.runtime_data = MagicMock(coordinator=coordinator) + config_entry.runtime_data = MagicMock(control_policy=ControlPolicy.default(), coordinator=coordinator) async_add_entities = MagicMock() await async_setup_entry(MagicMock(), config_entry, async_add_entities) @@ -496,7 +497,7 @@ async def test_async_setup_entry_skips_circuits_whose_priority_is_not_settable() config_entry = MagicMock() config_entry.title = "SPAN Panel" config_entry.data = {} - config_entry.runtime_data = MagicMock(coordinator=coordinator) + config_entry.runtime_data = MagicMock(control_policy=ControlPolicy.default(), coordinator=coordinator) async_add_entities = MagicMock() await async_setup_entry(MagicMock(), config_entry, async_add_entities) diff --git a/tests/test_sensor_platform.py b/tests/test_sensor_platform.py index 4370eb20..7bf84535 100644 --- a/tests/test_sensor_platform.py +++ b/tests/test_sensor_platform.py @@ -8,6 +8,7 @@ import pytest from custom_components.span_panel import SpanPanelRuntimeData +from custom_components.span_panel.control_gate import ControlPolicy from custom_components.span_panel.const import ( DOMAIN, ENABLE_CIRCUIT_NET_ENERGY_SENSORS, @@ -49,7 +50,7 @@ async def test_sensor_async_setup_entry_adds_entities_and_refreshes( coordinator.data = snapshot coordinator.async_request_refresh = AsyncMock() entry = MockConfigEntry(domain=DOMAIN, data={}, title="SPAN Panel") - entry.runtime_data = MagicMock(coordinator=coordinator) + entry.runtime_data = MagicMock(control_policy=ControlPolicy.default(), coordinator=coordinator) entities = [MagicMock(), MagicMock()] async_add_entities = MagicMock() @@ -71,7 +72,7 @@ async def test_sensor_async_setup_entry_logs_and_reraises_errors( coordinator = MagicMock() coordinator.data = SpanPanelSnapshotFactory.create() entry = MockConfigEntry(domain=DOMAIN, data={}, title="SPAN Panel") - entry.runtime_data = MagicMock(coordinator=coordinator) + entry.runtime_data = MagicMock(control_policy=ControlPolicy.default(), coordinator=coordinator) caplog.set_level(logging.ERROR) diff --git a/tests/test_setup_entry.py b/tests/test_setup_entry.py index 86c11752..eb529b85 100644 --- a/tests/test_setup_entry.py +++ b/tests/test_setup_entry.py @@ -89,9 +89,12 @@ async def test_async_setup_entry_v2_success_sets_runtime_data_and_title( # The panel's registry id is carried forward, not recomputed: every sub-device # links to it with `via_device_id`, and registration is the only place it is known. - assert entry.runtime_data == SpanPanelRuntimeData( - coordinator=coordinator, panel_device_id="panel-device-id" - ) + assert isinstance(entry.runtime_data, SpanPanelRuntimeData) + assert entry.runtime_data.coordinator is coordinator + assert entry.runtime_data.panel_device_id == "panel-device-id" + # The control lock is one object per entry, shared with the gate, so it is + # compared by identity elsewhere and never by value. + assert entry.runtime_data.control_lock.armed is False assert hass.data[DOMAIN]["websocket_registered"] is True mock_ws.assert_called_once_with(hass) mock_client_cls.assert_called_once() From 0c600b5099073f5b692d83729bc0fd2a19ec9c13 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:13:58 -0700 Subject: [PATCH 124/345] docs: the CA pin, the control options, and what none of it protects against Completes the documentation the earlier phases could not write, because the features it describes did not exist yet. The options table says what each one does *and* what it does not, in the same row. The section that follows says the thing worth saying out loud: none of this constrains something that already holds the broker credential, including a custom integration in this same process reading it out of memory. A user who believes otherwise stops looking for the real boundary, which is network topology and a lock on the enclosure. The frontend card is named rather than left to be discovered: its toggles call `switch.turn_on` and `select.select_option` directly, so under `admin_only` a non-admin gets a refusal with nothing on the card to explain it, and under `disabled` the toggles point at unavailable entities. Two troubleshooting entries. The CA-change repair walks through the decision the user actually has to make, and says why the integration will not decide it for them. The `unconfirmed` entry exists because the outcome looks like a failure and is not: it usually means the value was already what was asked for, and it is indistinguishable from a silent panel rejection only because SPAN's firmware sends no reason code. --- CHANGELOG.md | 13 +++++++++++ README.md | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf69c61b..f565a522 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -144,6 +144,19 @@ working entirely in 2027.8. passphrase are unchanged, and a failed rotation leaves the existing credentials in place. - **A new [Security](README.md#security) section in the README** covers what the integration stores, what rotation costs, and the deployment choices that actually bound the panel's exposure — VLAN isolation, a locked enclosure, read-only Home Assistant users, and encrypted backups. +- **The panel's certificate authority is pinned.** Setup fetches it, checks that the certificate your panel actually serves is signed by it, and shows you the + fingerprint to accept. From then on nothing else is accepted for either the MQTT connection or the REST calls. Previously the authority was re-fetched over + plaintext HTTP on every connection and whatever answered was trusted. Panels configured before this release are pinned on the first startup that reaches them, + logged at `WARNING` with the fingerprint. +- **A changed authority stops the integration and raises a repair** carrying both fingerprints, rather than reconnecting. A legitimate rotation and a device + impersonating your panel look identical from here, so re-pinning takes an explicit acceptance from you. Nothing retries in the meantime. +- **Four new options decide who may operate the panel** — restrict controls to administrators, refuse commands from automations, add an admin-only control lock, + and debounce repeated relay commands. **Every default is what your panel already does**, so upgrading changes nothing until you choose otherwise. See + [Restricting who can operate the panel](README.md#restricting-who-can-operate-the-panel), which is also explicit about what these do _not_ protect against. +- **Every control command is now recorded** — in the logbook, on the event bus as `span_panel_control_command`, and at `INFO`. Commands from an automation are + attributed to that automation rather than to nobody. +- **Controls now report what happened to them.** A command that was refused, or that was never handed to the broker, no longer looks the same as one the panel + confirmed — and a switch no longer shows a position its relay never took. ## [2.0.8] - 5/2026 diff --git a/README.md b/README.md index 50152bfd..9a39fba0 100644 --- a/README.md +++ b/README.md @@ -652,6 +652,68 @@ have not yet upgraded, reauthenticating also removes it. These are the limits of what the integration can enforce. Anything that already holds the broker password — including another integration running in the same Home Assistant process — talks to the panel directly and is not subject to Home Assistant's permission model at all. +### The panel's certificate authority + +The panel issues its own certificate authority and signs its TLS certificate with it. Setting up a panel fetches that authority, checks that the certificate the +panel actually serves is signed by it, and shows you the SHA-256 fingerprint. From then on the integration accepts only that authority for both the MQTT +connection and its REST calls, and stops rather than accepting a different one. + +**This is trust on first use.** The authority is fetched over your local network on a connection that has nothing to verify itself against — it is the anchor +everything else is checked against — so a device sitting between Home Assistant and the panel could answer with an authority of its own and sign a certificate +with it. What pinning buys you is that nothing can change afterwards without you being asked. If you can read the fingerprint from another source, such as an +existing install of this integration on the same panel, compare it before accepting. + +Panels configured before this release are pinned on the first successful startup that reaches the panel, and that acquisition is logged at `WARNING` with the +fingerprint so you can find the value afterwards. If the panel is unreachable the integration starts anyway and retries on the next startup; until it succeeds, +the connection behaves as it did before. + +Diagnostics report the fingerprint under `panel_ca`. The certificate itself is not included — it is public, but multi-KB, and the fingerprint is the part worth +reading. + +If your panel serves TLS somewhere other than port 443 — behind a reverse proxy, say — the setup flow asks for the port, but only when you have already changed +the HTTP port from 80. + +### Restricting who can operate the panel + +Four options in **Settings → Devices & Services → Span Panel → Configure**. **Every one defaults to the behaviour your panel already has**, so upgrading changes +nothing until you choose otherwise. + +| Option | What it does | What it does not do | +| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| **Who may operate the panel** | `Administrators only` refuses circuit switches, priority selects, the GFE override, EVSE limits and adopted controls from non-admin users. `Nobody` stops creating those entities altogether. | Neither affects sensors, and neither constrains anything holding the broker password. | +| **Allow control without a logged-in user** | Turning it off refuses commands from automations, scripts and other integrations, which arrive with no user attached. | It cannot tell a well-behaved automation from a runaway one — only that neither has a user. | +| **Control lock auto-relock** | Adds a switch that, while armed, refuses every control command. Anyone can arm it; only an administrator can disarm it, and never an automation. | It is not a password. It defends against misclicks and runaway automations, which is what a local control can actually do. | +| **Relay debounce** | Refuses a second command to the same circuit's relay within the window. | It is per circuit, so an automation cycling many circuits still gets through. | + +**Why `Administrators only` is worth setting.** Home Assistant's default user policy grants every non-admin user control of every entity. Until you change this, +a dashboard-only household member can open any breaker in the house. + +**What none of this defends against.** These options constrain callers arriving through Home Assistant. They do not constrain anything that already holds the +broker credential — a second Home Assistant instance, a script you wrote, or a malicious custom integration running inside this same Home Assistant process, +which reads the credential straight out of memory. For that, see [Recommended deployment](#recommended-deployment) below; network topology and a locked +enclosure are the real boundary. + +**Nothing is deleted when you choose `Nobody`.** The control entities stop being created and read as unavailable; their registry entries, names, areas and +customizations are kept, so turning the option back on restores exactly the entities you had. Dashboards and automations referencing them will show them +unavailable in the meantime — including the shipped SPAN Panel card, whose toggles call `switch.turn_on` and `select.select_option` directly and have no +card-side message for a refusal. A non-admin using that card under `Administrators only` gets a refusal with no explanation on the card. + +### The record of what was commanded + +Every control command fires a `span_panel_control_command` event and appears in the logbook, whether it succeeded, was refused, or never reached the panel. When +there is no user — an automation — the originating automation or script is named instead, so an unattended write is attributed to _what_ rather than left blank. +Every command is also logged at `INFO`. + +Commands report one of four outcomes, and the distinctions matter: + +| Outcome | Meaning | +| ------------- | ---------------------------------------------------------------------------------------- | +| `confirmed` | The panel reported the value you asked for. | +| `accepted` | The broker acknowledged the message and the panel did not report a change. | +| `unconfirmed` | Nothing came back within the deadline. **Not an error** — see the troubleshooting entry. | +| `failed` | The command was never handed to the broker and will not be delivered. | +| `refused:…` | This integration refused it, for the named reason. | + ### Rotating panel credentials The `span_panel.rotate_credentials` action asks the panel for a new eBus MQTT broker password, stores it, and reloads the integration. @@ -710,6 +772,8 @@ See [WebSocket API Reference](websocket-api.md) for the full schema, response fo | **No switch on a circuit** | A circuit has no switch entity exposed in Home Assistant. | The circuit is configured in the SPAN App as one of the "Always on Circuits". The API does not permit user control of those circuits, so no switch is created. | | **Reinstalling to change the entity ID style gives back the old entity IDs** | The naming style is chosen at install and cannot be changed from the options, so reinstalling looks like the way to switch. It is not: every entity returns with the entity ID it had before. | Home Assistant remembers a removed entity for **30 days**, keyed on its unique ID, and restores that record's entity ID — along with its name, area, labels and icon — as soon as an entity with the same unique ID appears again. This integration's unique IDs do not change with the naming style, so the remembered ID wins over the one the new style asks for. Either clear the leftover registry entries between removing and reinstalling, or wait out the 30 days and let Home Assistant discard them. A tool such as [ha-registry-clean](https://github.com/LegoTypes/ha-registry-clean) can do the clearing; it is a separate project, not part of this integration. Clearing also discards the names, areas and labels you had assigned. | | **Setup fails after downgrading the integration** | After installing an older release, the SPAN Panel config entry fails to set up and Home Assistant reports an unsupported configuration version. | The release that stopped storing the panel passphrase migrated the config entry to version 7. Home Assistant refuses to load a config entry whose version is newer than the installed integration understands, and there is no automatic downgrade. Reinstall the newer release, or restore a backup taken before the upgrade. Removing and re-adding the integration also works and preserves entity IDs, but needs the panel passphrase or physical access to the door again. | +| **"SPAN Panel certificate authority changed" repair** | The integration has stopped connecting and a repair reports two fingerprints: the one it pinned and the one the panel now advertises. Entities are unavailable. | The panel is presenting a different certificate authority than the one you accepted at setup. Two things look identical from here and only you can tell them apart: a firmware upgrade or a factory reset rotates the authority legitimately, and so does a device on your network standing in for your panel. **If you know why it changed**, open the repair, compare the new fingerprint, and accept it — that re-pins and reconnects. **If nothing should have changed**, do not accept. Check what else is on the panel's network segment first. The integration will not reconnect on its own and will not re-pin on its own, deliberately: retrying would mean waiting to succeed against whatever is answering. | +| **A control reports `unconfirmed`** | The logbook or the `span_panel_control_command` event says a command was `unconfirmed`. Nothing appears broken. | **This is not an error.** It means the panel took the command and did not report a change within the deadline, and the most common reason by far is that there was no change to report — the relay was already open, the priority was already that value. It is also indistinguishable from a silent rejection by the panel, because SPAN's firmware sends no reason code; the integration reports what it observed rather than guessing. The two outcomes that do mean something went wrong are `failed`, which means the command was never sent, and `refused:…`, which means this integration refused it and names why. | ## Development From 2a4e4e758ca4e14b7bc9f66cbb4e0d42d40231d9 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:25:30 -0700 Subject: [PATCH 125/345] security: pin the CA before registering, so the passphrase is never sent in clear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I2 pinned the CA after authentication, which left the one exchange most worth protecting outside the pin. `register_v2` sends the panel passphrase and returns both the access token and the broker password — the highest-value message on the network — and it was still going out over plaintext HTTP on every new install. The parent spec's stated benefit, "removes passphrase/token/ broker password from cleartext LAN", was therefore true of reauth and steady state and false of first contact. Nothing forced that order: `GET /api/v2/certificate/ca` is unauthenticated. The flow now fetches the CA, proves the panel's own leaf chains to it, shows the fingerprint for acceptance, and only then offers the authentication menu — with `register_v2` running over the accepted anchor. Reauth is unchanged; it already adopts the pin its entry carries. **The step text says what this does and does not buy.** Pinning before registering closes the passive listener, which is the common case. It does not close a device actively standing between Home Assistant and the panel at that first fetch, which could answer with an authority of its own and still read the exchange. Comparing the fingerprint out of band is what closes that, and is why it is shown rather than accepted silently. Claiming the exchange is simply protected would be worse than claiming nothing. **The "continue without pinning" option is gone from the create path.** It was defensible when the CA was acquired after registration and nothing further depended on it. It is not defensible now: the very next thing the flow does is send the passphrase, so an opt-out would quietly restore the plaintext exchange this reordering exists to remove, at the moment a user is least likely to weigh it. A failure is an actionable flow error instead, and resubmitting retries the fetch — the recovery a briefly unreachable panel actually needs. Entry creation raises rather than defaulting if it is somehow reached with no pin. `panel_ca_pending` is now set only by the migration. An upgraded entry exchanged its credentials long ago and nothing crosses the wire at its setup, so starting unpinned and retrying costs nothing; a new entry has no such excuse and cannot be created without a pin. --- CHANGELOG.md | 11 +- README.md | 38 +- custom_components/span_panel/config_flow.py | 111 ++++-- custom_components/span_panel/const.py | 9 +- custom_components/span_panel/strings.json | 16 +- .../span_panel/translations/en.json | 16 +- .../span_panel/translations/es.json | 16 +- .../span_panel/translations/fr.json | 16 +- .../span_panel/translations/ja.json | 16 +- .../span_panel/translations/pt.json | 16 +- tests/test_v2_config_flow.py | 342 +++++++++--------- 11 files changed, 334 insertions(+), 273 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f565a522..d44ed75f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -144,10 +144,13 @@ working entirely in 2027.8. passphrase are unchanged, and a failed rotation leaves the existing credentials in place. - **A new [Security](README.md#security) section in the README** covers what the integration stores, what rotation costs, and the deployment choices that actually bound the panel's exposure — VLAN isolation, a locked enclosure, read-only Home Assistant users, and encrypted backups. -- **The panel's certificate authority is pinned.** Setup fetches it, checks that the certificate your panel actually serves is signed by it, and shows you the - fingerprint to accept. From then on nothing else is accepted for either the MQTT connection or the REST calls. Previously the authority was re-fetched over - plaintext HTTP on every connection and whatever answered was trusted. Panels configured before this release are pinned on the first startup that reaches them, - logged at `WARNING` with the fingerprint. +- **The panel's certificate authority is pinned, before your passphrase is ever sent.** Setup fetches it, checks that the certificate your panel actually serves + is signed by it, and shows you the fingerprint to accept — and only then asks you to authenticate. Registration is the exchange that carries your passphrase + and returns both the access token and the broker password, and it used to cross your network in the clear; it now travels over the authority you accepted, as + does everything after it. Previously the authority was also re-fetched over plaintext HTTP on every connection and whatever answered was trusted. **Anyone + merely listening on your network can no longer read those credentials.** A device actively impersonating your panel at that first fetch still could, which is + why the fingerprint is shown for you to compare rather than accepted silently. Panels configured before this release exchanged their credentials long ago and + are simply pinned on the first startup that reaches them, logged at `WARNING` with the fingerprint. - **A changed authority stops the integration and raises a repair** carrying both fingerprints, rather than reconnecting. A legitimate rotation and a device impersonating your panel look identical from here, so re-pinning takes an explicit acceptance from you. Nothing retries in the meantime. - **Four new options decide who may operate the panel** — restrict controls to administrators, refuse commands from automations, add an admin-only control lock, diff --git a/README.md b/README.md index 9a39fba0..d47fd846 100644 --- a/README.md +++ b/README.md @@ -654,18 +654,32 @@ Home Assistant process — talks to the panel directly and is not subject to Hom ### The panel's certificate authority -The panel issues its own certificate authority and signs its TLS certificate with it. Setting up a panel fetches that authority, checks that the certificate the -panel actually serves is signed by it, and shows you the SHA-256 fingerprint. From then on the integration accepts only that authority for both the MQTT -connection and its REST calls, and stops rather than accepting a different one. - -**This is trust on first use.** The authority is fetched over your local network on a connection that has nothing to verify itself against — it is the anchor -everything else is checked against — so a device sitting between Home Assistant and the panel could answer with an authority of its own and sign a certificate -with it. What pinning buys you is that nothing can change afterwards without you being asked. If you can read the fingerprint from another source, such as an -existing install of this integration on the same panel, compare it before accepting. - -Panels configured before this release are pinned on the first successful startup that reaches the panel, and that acquisition is logged at `WARNING` with the -fingerprint so you can find the value afterwards. If the panel is unreachable the integration starts anyway and retries on the next startup; until it succeeds, -the connection behaves as it did before. +The panel issues its own certificate authority and signs its TLS certificate with it. **Before you are asked for your passphrase**, setup fetches that +authority, checks that the certificate the panel actually serves is signed by it, and shows you the SHA-256 fingerprint to accept. Everything after that — +registration itself, and every later connection — runs over the authority you accepted, and the integration stops rather than accepting a different one. + +The ordering is the point. Registration is the exchange that sends your passphrase and returns both the access token and the broker password, so it is the +single most valuable message on your network. It now travels over a verified connection instead of in the clear. + +**What that does and does not buy you.** The authority is fetched over your local network on a connection that has nothing to verify itself against — it is the +anchor everything else is checked against. So: + +- Anyone merely **listening** on your network can no longer read your passphrase or the credentials the panel returns for it. That is the common case, and it is + closed. +- A device **actively standing between** Home Assistant and your panel at that first fetch could answer with an authority of its own, sign a certificate with + it, and still see them. Pinning cannot detect that on its own. + +Comparing the fingerprint against another source is what closes the second case, and it is exactly why the fingerprint is shown rather than accepted silently. +Another install of this integration on the same panel reports the same value. After the first acceptance, nothing can change the authority without stopping and +asking you. + +**There is no "continue without pinning" for a new panel.** If the authority cannot be read, or the certificate the panel serves is not signed by it, setup +shows an error and stops. Submitting the form again retries the fetch, which is what a panel that was briefly unreachable needs. An opt-out would quietly +restore the plaintext credential exchange this ordering exists to remove, at the moment you are least likely to weigh it. + +**Panels configured before this release are different**, because their credentials were exchanged long ago and nothing crosses the wire at startup. They are +pinned on the first startup that reaches the panel, logged at `WARNING` with the fingerprint so you can find the value afterwards. If the panel is unreachable +the integration starts anyway and retries on the next startup; until it succeeds, the connection behaves as it did before. Diagnostics report the fingerprint under `panel_ca`. The certificate itself is not included — it is public, but multi-KB, and the fingerprint is the part worth reading. diff --git a/custom_components/span_panel/config_flow.py b/custom_components/span_panel/config_flow.py index 498f6626..2b68ecfa 100644 --- a/custom_components/span_panel/config_flow.py +++ b/custom_components/span_panel/config_flow.py @@ -6,6 +6,7 @@ from collections.abc import Mapping import enum import logging +import ssl from typing import TYPE_CHECKING, Any from homeassistant import config_entries @@ -21,6 +22,7 @@ from homeassistant.util.network import is_ipv4_address from span_panel_api import ( V2AuthResponse, + build_panel_ssl_context, ca_fingerprint, delete_fqdn, detect_api_version, @@ -31,6 +33,7 @@ SpanPanelAuthError, SpanPanelConnectionError, SpanPanelTimeoutError, + SpanPanelValidationError, ) import voluptuous as vol @@ -66,7 +69,6 @@ DOMAIN, ENABLE_ENERGY_DIP_COMPENSATION, ENTITY_NAMING_PATTERN, - PANEL_CA_PENDING, USE_CIRCUIT_NUMBERS, USE_DEVICE_PREFIX, EntityNamingPattern, @@ -350,7 +352,9 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Con } self._is_flow_setup = True await self.ensure_not_already_configured(raise_on_progress=False) - return await self.async_step_choose_v2_auth() + # The CA first, then authentication over it. Registration is the one + # exchange that carries the passphrase and returns both credentials. + return await self.async_step_panel_ca_start() # Non-v2 panels are not supported return self.async_abort(reason="v1_not_supported") @@ -415,7 +419,7 @@ async def async_step_confirm_discovery( }, ) - return await self.async_step_choose_v2_auth() + return await self.async_step_panel_ca_start() async def async_step_choose_v2_auth( self, user_input: dict[str, Any] | None = None @@ -541,7 +545,7 @@ async def _async_finalize_v2_auth(self) -> ConfigFlowResult: # If host is an FQDN, register it with the panel for TLS cert SAN inclusion if self.host and is_fqdn(self.host): return await self.async_step_register_fqdn() - return await self.async_step_panel_ca_start() + return await self.async_step_choose_entity_naming_initial() async def async_step_register_fqdn( self, user_input: dict[str, Any] | None = None @@ -568,7 +572,7 @@ async def async_step_register_fqdn( return self.async_show_progress_done(next_step_id="fqdn_failed") self._fqdn_task = None - return self.async_show_progress_done(next_step_id="panel_ca_start") + return self.async_show_progress_done(next_step_id="choose_entity_naming_initial") async def _async_register_fqdn_and_wait(self) -> None: """Register the FQDN and poll until the TLS cert includes it.""" @@ -613,7 +617,7 @@ async def async_step_fqdn_failed( ) -> ConfigFlowResult: """Handle FQDN registration failure — user may continue without it.""" if user_input is not None: - return await self.async_step_panel_ca_start() + return await self.async_step_choose_entity_naming_initial() return self.async_show_form( step_id="fqdn_failed", data_schema=vol.Schema({}), @@ -653,15 +657,18 @@ async def async_step_panel_https_port( async def async_step_panel_ca( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Fetch the panel's CA, prove it signs what the panel serves, and show it. + """Fetch the panel's CA, prove it signs what the panel serves, and offer it. - `user_input` is `None` on the way in — including on a retry chosen from - the failure menu — and a submitted (empty) mapping when the user has - confirmed the fingerprint. - """ - if user_input is not None: - return await self.async_step_choose_entity_naming_initial() + This runs **before** authentication, which is the whole point of its + position in the flow: registration is the exchange that carries the + passphrase and returns both the access token and the broker password, and + it is the message most worth protecting. Everything after this step goes + over the anchor accepted here. + `user_input` is ignored. The step does its work on entry, and a submitted + error form re-enters it — which is exactly the retry semantics wanted, + since the failures are transient network ones. + """ if not self.host: return self.async_abort(reason="host_not_set") @@ -673,7 +680,7 @@ async def async_step_panel_ca( SpanPanelTimeoutError, ) as err: _LOGGER.warning("Could not fetch the CA from panel %s: %s", self.host, err) - return await self.async_step_panel_ca_failed() + return self._async_show_ca_error("ca_unavailable") # A CA that cannot validate the certificate the panel actually serves is # not the panel's CA. Checked before the fingerprint is shown, so the @@ -681,41 +688,63 @@ async def async_step_panel_ca( if not await async_leaf_chains_to_ca(self.host, self._https_port, ca_pem): _LOGGER.warning( "The certificate served by %s on port %s does not chain to the CA the " - "panel published; not pinning it", + "panel published; refusing to pin it", self.host, self._https_port, ) - return await self.async_step_panel_ca_failed() + return self._async_show_ca_error("ca_leaf_mismatch") + + try: + fingerprint = ca_fingerprint(ca_pem) + except SpanPanelValidationError as err: + _LOGGER.warning("Panel %s served an unreadable CA: %s", self.host, err) + return self._async_show_ca_error("ca_unavailable") self._panel_ca_pem = ca_pem return self.async_show_form( - step_id="panel_ca", + step_id="panel_ca_confirm", data_schema=vol.Schema({}), - description_placeholders={"fingerprint": ca_fingerprint(ca_pem)}, + description_placeholders={"fingerprint": fingerprint}, ) - async def async_step_panel_ca_failed( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Offer a retry, or setting the panel up without a pin. + def _async_show_ca_error(self, reason: str) -> ConfigFlowResult: + """Re-show the CA step with an actionable error. - Continuing is permitted because refusing would make an unreachable - certificate endpoint a hard setup failure for a panel that is otherwise - working, and the entry is created with `panel_ca_pending` so every later - setup retries the fetch. Until one succeeds the library keeps 3.0.1's - behaviour of refetching the CA on each connect. + Deliberately not a way past. There is no "carry on unpinned" option here, + because the next thing this flow does is send the panel passphrase: an + opt-out would quietly restore the plaintext credential exchange that + pinning before registration exists to remove, at the moment a user is + least likely to weigh it. + + Submitting the form re-enters `async_step_panel_ca`, so a panel that was + briefly unreachable is one click away from working. """ - return self.async_show_menu( - step_id="panel_ca_failed", - menu_options=["panel_ca", "panel_ca_skip"], + return self.async_show_form( + step_id="panel_ca", + data_schema=vol.Schema({}), + errors={"base": reason}, ) - async def async_step_panel_ca_skip( + async def async_step_panel_ca_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Continue without a pinned CA.""" - self._panel_ca_pem = None - return await self.async_step_choose_entity_naming_initial() + """Adopt the accepted anchor, then authenticate over it.""" + if user_input is None or self._panel_ca_pem is None: + return await self.async_step_panel_ca() + + try: + context = build_panel_ssl_context(self._panel_ca_pem) + except (ssl.SSLError, ValueError) as err: + # Unreachable in practice — the leaf check above already built a + # context from this PEM. Handled rather than assumed, because the + # alternative to raising here is registering in plaintext. + _LOGGER.warning("Accepted CA from %s could not be used: %s", self.host, err) + return self._async_show_ca_error("ca_unavailable") + + self._rest_transport = PanelRestTransport( + port=self._https_port, ssl_context=context, httpx_client=None + ) + return await self.async_step_choose_v2_auth() def create_new_entry( self, host: str, serial_number: str, access_token: str @@ -752,12 +781,14 @@ def create_new_entry( entry_data[CONF_HTTP_PORT] = self._http_port if self._https_port != DEFAULT_HTTPS_PORT: entry_data[CONF_HTTPS_PORT] = self._https_port - if self._panel_ca_pem is not None: - entry_data[CONF_PANEL_CA_PEM] = self._panel_ca_pem - else: - # Acquisition failed and the user chose to continue. Every later - # setup retries it, on the same flag the migration uses. - entry_data[PANEL_CA_PENDING] = True + if self._panel_ca_pem is None: + # Unreachable: the flow cannot reach entity naming without passing + # the CA confirmation, and authentication ran over the anchor + # accepted there. Raised rather than defaulted, because the only + # other option is to write an unpinned entry whose credentials were + # nonetheless exchanged over TLS — a state nothing else expects. + raise ConfigFlowError("Reached entry creation with no pinned panel CA") + entry_data[CONF_PANEL_CA_PEM] = self._panel_ca_pem if is_fqdn(host): entry_data[CONF_REGISTERED_FQDN] = host diff --git a/custom_components/span_panel/const.py b/custom_components/span_panel/const.py index f77b0e7e..5ff803c1 100644 --- a/custom_components/span_panel/const.py +++ b/custom_components/span_panel/const.py @@ -33,7 +33,14 @@ a network call there delays boot whenever the panel is unreachable and leaves a failed fetch with no recovery path. The flag defers it to the first successful setup, which retries on every subsequent one for free. Same shape as -`solar_migration_pending`.""" +`solar_migration_pending`. + +**Only the migration sets this.** A newly configured entry always carries a pin, +because the config flow fetches and confirms the CA before it authenticates and +refuses to continue without one — registration is the exchange that carries the +passphrase. An upgraded entry is a different case: its credentials were +exchanged long ago, nothing crosses the wire at setup, and refusing to start +would take down a working integration to no benefit.""" # Binary sensor / status field keys (used in entity definitions) SYSTEM_DOOR_STATE = "doorState" diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index 77b4aa2c..4c8dc045 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -13,6 +13,8 @@ "host_not_set": "No panel address was set; restart the setup." }, "error": { + "ca_leaf_mismatch": "The certificate this panel serves is not signed by the authority it published. Check that the HTTPS port is correct and that nothing is intercepting the connection, then try again.", + "ca_unavailable": "The panel's certificate authority could not be read. Check that the panel is reachable and powered on, then try again. Setup cannot continue without it, because your passphrase would otherwise be sent in the clear.", "cannot_connect": "Failed to connect to Span Panel", "fqdn_registration_failed": "Could not register the domain name with the panel or the TLS certificate was not updated in time.", "host_required": "Host is required", @@ -132,16 +134,12 @@ } }, "panel_ca": { - "title": "Trust this panel's certificate", - "description": "The panel published this certificate authority, and the certificate it serves is signed by it:\n\n**{fingerprint}**\n\nHome Assistant will accept only this authority for the connection to your panel from now on, and will stop and ask you before accepting a different one.\n\nThis is trust on first use. The integration fetched the authority over the local network and cannot tell it apart from one substituted by something else on that network — it can only tell you that nothing has changed since now. If you can read the fingerprint from another source, such as an existing install of this integration on the same panel, compare it before continuing." + "title": "Reading the panel's certificate", + "description": "Home Assistant fetches the certificate authority your panel publishes, and checks that the certificate the panel serves is signed by it. This happens before you enter your passphrase, so the passphrase and the credentials the panel returns for it travel over a verified connection rather than in the clear.\n\nSelect **Submit** to try again." }, - "panel_ca_failed": { - "title": "Could not verify the panel's certificate", - "description": "The panel's certificate authority could not be fetched, or the certificate the panel serves is not signed by it.\n\nYou can try again, or finish setting the panel up without pinning. Without a pin the integration still connects over TLS, but it re-fetches the authority on every connection and trusts whatever answers — so a device on your network could stand in for the panel. The integration retries the fetch every time it starts, and pins the authority as soon as one succeeds.", - "menu_options": { - "panel_ca": "Try again", - "panel_ca_skip": "Continue without pinning" - } + "panel_ca_confirm": { + "title": "Trust this panel's certificate", + "description": "The panel published this certificate authority, and the certificate it serves is signed by it:\n\n**{fingerprint}**\n\nAccepting it pins it. Your passphrase is sent over this connection, and everything afterwards uses it. Home Assistant will stop and ask you before ever accepting a different one.\n\n**What this does and does not protect.** This authority was fetched over your local network on a connection that had nothing to verify itself against. Anyone merely *listening* on your network can no longer read your passphrase or the credentials the panel returns — that is the common case and it is now closed. But a device actively standing between Home Assistant and your panel right now could have answered with an authority of its own, and would still see them.\n\nComparing this fingerprint against another source is what closes that case, and it is why it is shown. Another install of this integration on the same panel will report the same value." } } }, diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index 77b4aa2c..4c8dc045 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -13,6 +13,8 @@ "host_not_set": "No panel address was set; restart the setup." }, "error": { + "ca_leaf_mismatch": "The certificate this panel serves is not signed by the authority it published. Check that the HTTPS port is correct and that nothing is intercepting the connection, then try again.", + "ca_unavailable": "The panel's certificate authority could not be read. Check that the panel is reachable and powered on, then try again. Setup cannot continue without it, because your passphrase would otherwise be sent in the clear.", "cannot_connect": "Failed to connect to Span Panel", "fqdn_registration_failed": "Could not register the domain name with the panel or the TLS certificate was not updated in time.", "host_required": "Host is required", @@ -132,16 +134,12 @@ } }, "panel_ca": { - "title": "Trust this panel's certificate", - "description": "The panel published this certificate authority, and the certificate it serves is signed by it:\n\n**{fingerprint}**\n\nHome Assistant will accept only this authority for the connection to your panel from now on, and will stop and ask you before accepting a different one.\n\nThis is trust on first use. The integration fetched the authority over the local network and cannot tell it apart from one substituted by something else on that network — it can only tell you that nothing has changed since now. If you can read the fingerprint from another source, such as an existing install of this integration on the same panel, compare it before continuing." + "title": "Reading the panel's certificate", + "description": "Home Assistant fetches the certificate authority your panel publishes, and checks that the certificate the panel serves is signed by it. This happens before you enter your passphrase, so the passphrase and the credentials the panel returns for it travel over a verified connection rather than in the clear.\n\nSelect **Submit** to try again." }, - "panel_ca_failed": { - "title": "Could not verify the panel's certificate", - "description": "The panel's certificate authority could not be fetched, or the certificate the panel serves is not signed by it.\n\nYou can try again, or finish setting the panel up without pinning. Without a pin the integration still connects over TLS, but it re-fetches the authority on every connection and trusts whatever answers — so a device on your network could stand in for the panel. The integration retries the fetch every time it starts, and pins the authority as soon as one succeeds.", - "menu_options": { - "panel_ca": "Try again", - "panel_ca_skip": "Continue without pinning" - } + "panel_ca_confirm": { + "title": "Trust this panel's certificate", + "description": "The panel published this certificate authority, and the certificate it serves is signed by it:\n\n**{fingerprint}**\n\nAccepting it pins it. Your passphrase is sent over this connection, and everything afterwards uses it. Home Assistant will stop and ask you before ever accepting a different one.\n\n**What this does and does not protect.** This authority was fetched over your local network on a connection that had nothing to verify itself against. Anyone merely *listening* on your network can no longer read your passphrase or the credentials the panel returns — that is the common case and it is now closed. But a device actively standing between Home Assistant and your panel right now could have answered with an authority of its own, and would still see them.\n\nComparing this fingerprint against another source is what closes that case, and it is why it is shown. Another install of this integration on the same panel will report the same value." } } }, diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index 8b513915..22c671f2 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -13,6 +13,8 @@ "v1_not_supported": "El firmware v1 no es compatible. Actualice el firmware de su panel a v2." }, "error": { + "ca_leaf_mismatch": "El certificado que sirve este panel no está firmado por la autoridad que ha publicado. Compruebe que el puerto HTTPS es correcto y que nada intercepta la conexión, y vuelva a intentarlo.", + "ca_unavailable": "No se ha podido leer la autoridad de certificación del panel. Compruebe que el panel es accesible y está encendido, y vuelva a intentarlo. La configuración no puede continuar sin ella, porque de lo contrario su contraseña se enviaría en claro.", "cannot_connect": "No se logró establecer conexión con Span Panel", "fqdn_registration_failed": "No se pudo registrar el nombre de dominio en el panel o el certificado TLS no se actualizó a tiempo.", "host_required": "Se requiere host", @@ -132,16 +134,12 @@ } }, "panel_ca": { - "title": "Confiar en el certificado de este panel", - "description": "El panel ha publicado esta autoridad de certificación, y el certificado que sirve está firmado por ella:\n\n**{fingerprint}**\n\nA partir de ahora, Home Assistant aceptará únicamente esta autoridad para la conexión con su panel, y se detendrá a preguntarle antes de aceptar otra distinta.\n\nEsto es confianza en el primer uso. La integración ha obtenido la autoridad a través de la red local y no puede distinguirla de una suplantada por otro equipo de esa red; solo puede avisarle si cambia a partir de ahora. Si puede leer la huella digital desde otra fuente, como otra instalación de esta integración sobre el mismo panel, compárela antes de continuar." + "title": "Leyendo el certificado del panel", + "description": "Home Assistant obtiene la autoridad de certificación que publica su panel y comprueba que el certificado que este sirve está firmado por ella. Esto ocurre antes de que introduzca su contraseña, de modo que la contraseña y las credenciales que el panel devuelve a cambio viajan por una conexión verificada y no en claro.\n\nSeleccione **Enviar** para volver a intentarlo." }, - "panel_ca_failed": { - "title": "No se ha podido verificar el certificado del panel", - "description": "No se ha podido obtener la autoridad de certificación del panel, o el certificado que sirve el panel no está firmado por ella.\n\nPuede volver a intentarlo o terminar de configurar el panel sin fijar la autoridad. Sin fijarla, la integración sigue conectándose por TLS, pero vuelve a obtener la autoridad en cada conexión y confía en lo que responda, de modo que un dispositivo de su red podría hacerse pasar por el panel. La integración reintenta la obtención cada vez que se inicia y fija la autoridad en cuanto lo consigue.", - "menu_options": { - "panel_ca": "Volver a intentarlo", - "panel_ca_skip": "Continuar sin fijar la autoridad" - } + "panel_ca_confirm": { + "title": "Confiar en el certificado de este panel", + "description": "El panel ha publicado esta autoridad de certificación, y el certificado que sirve está firmado por ella:\n\n**{fingerprint}**\n\nAceptarla la fija. Su contraseña se envía por esta conexión, y todo lo posterior la utiliza. Home Assistant se detendrá a preguntarle antes de aceptar otra distinta.\n\n**Qué protege y qué no.** Esta autoridad se obtuvo a través de su red local por una conexión que no tenía nada con lo que verificarse. Quien simplemente *escuche* en su red ya no puede leer su contraseña ni las credenciales que devuelve el panel: ese es el caso habitual y queda cerrado. Pero un dispositivo que en este momento se interponga activamente entre Home Assistant y su panel podría haber respondido con una autoridad propia, y sí las vería.\n\nComparar esta huella digital con otra fuente es lo que cierra ese caso, y por eso se muestra. Otra instalación de esta integración sobre el mismo panel indicará el mismo valor." } } }, diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index 9428c60e..d2249333 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -13,6 +13,8 @@ "v1_not_supported": "Le micrologiciel v1 n'est pas pris en charge. Veuillez mettre à jour le micrologiciel de votre panneau vers la version v2." }, "error": { + "ca_leaf_mismatch": "Le certificat présenté par ce panneau n'est pas signé par l'autorité qu'il a publiée. Vérifiez que le port HTTPS est correct et que rien n'intercepte la connexion, puis réessayez.", + "ca_unavailable": "L'autorité de certification du panneau n'a pas pu être lue. Vérifiez que le panneau est joignable et sous tension, puis réessayez. La configuration ne peut pas continuer sans elle, car votre mot de passe serait sinon envoyé en clair.", "cannot_connect": "Échec de la connexion au Span Panel", "fqdn_registration_failed": "Impossible d'enregistrer le nom de domaine auprès du panneau ou le certificat TLS n'a pas été mis à jour à temps.", "host_required": "L'hôte est requis", @@ -132,16 +134,12 @@ } }, "panel_ca": { - "title": "Faire confiance au certificat de ce panneau", - "description": "Le panneau a publié cette autorité de certification, et le certificat qu'il présente est signé par elle :\n\n**{fingerprint}**\n\nDésormais, Home Assistant n'acceptera que cette autorité pour la connexion à votre panneau, et s'arrêtera pour vous demander avant d'en accepter une autre.\n\nIl s'agit d'une confiance à la première utilisation. L'intégration a récupéré l'autorité sur le réseau local et ne peut pas la distinguer d'une autorité substituée par un autre appareil de ce réseau ; elle peut seulement vous signaler un changement ultérieur. Si vous pouvez lire l'empreinte ailleurs — par exemple sur une autre installation de cette intégration reliée au même panneau — comparez-la avant de continuer." + "title": "Lecture du certificat du panneau", + "description": "Home Assistant récupère l'autorité de certification publiée par votre panneau et vérifie que le certificat présenté par celui-ci est signé par elle. Cela se produit avant que vous ne saisissiez votre mot de passe : celui-ci, ainsi que les identifiants que le panneau renvoie en échange, transitent donc par une connexion vérifiée plutôt qu'en clair.\n\nSélectionnez **Valider** pour réessayer." }, - "panel_ca_failed": { - "title": "Impossible de vérifier le certificat du panneau", - "description": "L'autorité de certification du panneau n'a pas pu être récupérée, ou le certificat présenté par le panneau n'est pas signé par elle.\n\nVous pouvez réessayer ou terminer la configuration sans épinglage. Sans épinglage, l'intégration se connecte toujours en TLS, mais elle récupère l'autorité à chaque connexion et fait confiance à ce qui répond : un appareil de votre réseau pourrait donc se faire passer pour le panneau. L'intégration retente la récupération à chaque démarrage et épingle l'autorité dès qu'elle y parvient.", - "menu_options": { - "panel_ca": "Réessayer", - "panel_ca_skip": "Continuer sans épinglage" - } + "panel_ca_confirm": { + "title": "Faire confiance au certificat de ce panneau", + "description": "Le panneau a publié cette autorité de certification, et le certificat qu'il présente est signé par elle :\n\n**{fingerprint}**\n\nL'accepter l'épingle. Votre mot de passe est envoyé sur cette connexion, et tout ce qui suit l'utilise. Home Assistant s'arrêtera pour vous demander avant d'en accepter une autre.\n\n**Ce que cela protège et ce que cela ne protège pas.** Cette autorité a été récupérée sur votre réseau local, par une connexion qui n'avait rien pour se vérifier elle-même. Quiconque se contente d'*écouter* votre réseau ne peut plus lire votre mot de passe ni les identifiants renvoyés par le panneau — c'est le cas courant, et il est désormais fermé. Mais un appareil qui s'interpose activement entre Home Assistant et votre panneau en ce moment même a pu répondre avec sa propre autorité, et les verrait quand même.\n\nComparer cette empreinte avec une autre source est ce qui ferme ce cas, et c'est pourquoi elle est affichée. Une autre installation de cette intégration reliée au même panneau indiquera la même valeur." } } }, diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index 099331c6..e1de2754 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -13,6 +13,8 @@ "v1_not_supported": "v1ファームウェアはサポートされていません。パネルのファームウェアをv2にアップグレードしてください。" }, "error": { + "ca_leaf_mismatch": "このパネルが提供する証明書は、パネルが公開した認証局で署名されていません。HTTPS ポートが正しいこと、接続が傍受されていないことを確認してから、もう一度お試しください。", + "ca_unavailable": "パネルの認証局を読み取れませんでした。パネルに到達でき、電源が入っていることを確認してから、もう一度お試しください。これがないとセットアップは続行できません。続行するとパスフレーズが平文で送信されてしまうためです。", "cannot_connect": "スパンパネルへの接続に失敗しました", "fqdn_registration_failed": "パネルにドメイン名を登録できなかったか、TLS証明書が時間内に更新されませんでした。", "host_required": "ホストが必要です", @@ -132,16 +134,12 @@ } }, "panel_ca": { - "title": "このパネルの証明書を信頼する", - "description": "パネルは次の認証局を公開しており、パネルが提供する証明書はこの認証局によって署名されています。\n\n**{fingerprint}**\n\n今後 Home Assistant は、パネルへの接続にこの認証局だけを受け入れ、別の認証局を受け入れる前に必ず停止して確認します。\n\nこれは最初の接続を信頼する方式(TOFU)です。統合はローカルネットワーク経由で認証局を取得しており、同じネットワーク上の別の機器がすり替えたものと区別できません。分かるのは、以後に変化があったかどうかだけです。同じパネルに接続している別のインストールなど、他の経路でフィンガープリントを確認できる場合は、続行する前に照合してください。" + "title": "パネルの証明書を読み取っています", + "description": "Home Assistant は、パネルが公開している認証局を取得し、パネルが提供する証明書がその認証局で署名されていることを確認します。これはパスフレーズを入力する前に行われるため、パスフレーズと、その引き換えにパネルが返す認証情報は、平文ではなく検証済みの接続を通ります。\n\n**送信**を選ぶと再試行します。" }, - "panel_ca_failed": { - "title": "パネルの証明書を検証できませんでした", - "description": "パネルの認証局を取得できなかったか、パネルが提供する証明書がその認証局で署名されていません。\n\n再試行するか、ピン留めせずにセットアップを終えることができます。ピン留めしない場合も統合は TLS で接続しますが、接続のたびに認証局を取得し直して応答したものを信頼するため、ネットワーク上の別の機器がパネルになりすます余地が残ります。統合は起動のたびに取得を再試行し、成功した時点で認証局をピン留めします。", - "menu_options": { - "panel_ca": "再試行する", - "panel_ca_skip": "ピン留めせずに続行する" - } + "panel_ca_confirm": { + "title": "このパネルの証明書を信頼する", + "description": "パネルは次の認証局を公開しており、パネルが提供する証明書はこの認証局によって署名されています。\n\n**{fingerprint}**\n\n受け入れるとピン留めします。パスフレーズはこの接続で送信され、以降のすべてがこの接続を使用します。Home Assistant は別の認証局を受け入れる前に、必ず停止して確認します。\n\n**保護されるものと、されないもの。** この認証局は、自身を検証する手段のない接続でローカルネットワーク経由で取得されました。ネットワークを単に*盗聴*しているだけの相手は、もうパスフレーズやパネルが返す認証情報を読み取れません。これが一般的なケースであり、いま塞がれました。しかし、いまこの瞬間に Home Assistant とパネルのあいだに能動的に割り込んでいる機器は、自前の認証局で応答できた可能性があり、その場合は読み取れます。\n\nこのフィンガープリントを別の経路と照合することが、そのケースを塞ぐ唯一の方法であり、ここに表示している理由です。同じパネルに接続している別のインストールは、同じ値を示します。" } } }, diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index f43117a8..390b173c 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -13,6 +13,8 @@ "v1_not_supported": "O firmware v1 não é suportado. Atualize o firmware do seu painel para v2." }, "error": { + "ca_leaf_mismatch": "O certificado que este painel serve não está assinado pela autoridade que publicou. Verifique se a porta HTTPS está correta e se nada está a intercetar a ligação, e tente novamente.", + "ca_unavailable": "Não foi possível ler a autoridade de certificação do painel. Verifique se o painel está acessível e ligado e tente novamente. A configuração não pode continuar sem ela, porque de outro modo a sua frase-passe seria enviada em claro.", "cannot_connect": "Falha ao ligar ao Painel Span", "fqdn_registration_failed": "Não foi possível registar o nome de domínio no painel ou o certificado TLS não foi atualizado a tempo.", "host_required": "O anfitrião é necessário", @@ -132,16 +134,12 @@ } }, "panel_ca": { - "title": "Confiar no certificado deste painel", - "description": "O painel publicou esta autoridade de certificação, e o certificado que serve está assinado por ela:\n\n**{fingerprint}**\n\nA partir de agora o Home Assistant aceitará apenas esta autoridade para a ligação ao seu painel, e irá parar e perguntar-lhe antes de aceitar outra.\n\nIsto é confiança na primeira utilização. A integração obteve a autoridade através da rede local e não a consegue distinguir de uma substituída por outro equipamento dessa rede — apenas lhe pode dizer se mudar daqui em diante. Se conseguir ler a impressão digital noutra origem, como outra instalação desta integração ligada ao mesmo painel, compare-a antes de continuar." + "title": "A ler o certificado do painel", + "description": "O Home Assistant obtém a autoridade de certificação que o seu painel publica e verifica que o certificado servido pelo painel está assinado por ela. Isto acontece antes de introduzir a sua frase-passe, pelo que a frase-passe e as credenciais que o painel devolve em troca viajam por uma ligação verificada e não em claro.\n\nSelecione **Submeter** para tentar novamente." }, - "panel_ca_failed": { - "title": "Não foi possível verificar o certificado do painel", - "description": "Não foi possível obter a autoridade de certificação do painel, ou o certificado que o painel serve não está assinado por ela.\n\nPode tentar novamente ou terminar a configuração sem fixar a autoridade. Sem a fixar, a integração continua a ligar-se por TLS, mas volta a obter a autoridade em cada ligação e confia no que responder — pelo que um equipamento da sua rede se poderia fazer passar pelo painel. A integração repete a tentativa sempre que arranca e fixa a autoridade assim que uma tentativa tiver êxito.", - "menu_options": { - "panel_ca": "Tentar novamente", - "panel_ca_skip": "Continuar sem fixar a autoridade" - } + "panel_ca_confirm": { + "title": "Confiar no certificado deste painel", + "description": "O painel publicou esta autoridade de certificação, e o certificado que serve está assinado por ela:\n\n**{fingerprint}**\n\nAceitá-la fixa-a. A sua frase-passe é enviada por esta ligação, e tudo o que se segue utiliza-a. O Home Assistant irá parar e perguntar-lhe antes de aceitar outra.\n\n**O que isto protege e o que não protege.** Esta autoridade foi obtida através da sua rede local, numa ligação que não tinha nada com que se verificar a si própria. Quem apenas *escuta* a sua rede já não consegue ler a sua frase-passe nem as credenciais devolvidas pelo painel — esse é o caso comum e está agora fechado. Mas um equipamento que neste momento se interponha activamente entre o Home Assistant e o seu painel poderia ter respondido com uma autoridade própria, e continuaria a vê-las.\n\nComparar esta impressão digital com outra origem é o que fecha esse caso, e é por isso que é mostrada. Outra instalação desta integração ligada ao mesmo painel indicará o mesmo valor." } } }, diff --git a/tests/test_v2_config_flow.py b/tests/test_v2_config_flow.py index d4b33de8..0530753d 100644 --- a/tests/test_v2_config_flow.py +++ b/tests/test_v2_config_flow.py @@ -77,14 +77,33 @@ def panel_ca_available(): "custom_components.span_panel.config_flow.ca_fingerprint", return_value=FAKE_CA_FINGERPRINT, ), + # The fake PEM is not a certificate the ssl module will load, and the + # accepted anchor is turned into a real context before authentication + # runs over it. + patch( + "custom_components.span_panel.config_flow.build_panel_ssl_context", + return_value=MagicMock(), + ), ): yield async def _confirm_panel_ca(hass: HomeAssistant, flow_id: str): """Accept the fingerprint the CA step is showing.""" - result = await hass.config_entries.flow.async_configure(flow_id, {}) - return result + return await hass.config_entries.flow.async_configure(flow_id, {}) + + +async def _submit_host_and_pin(hass: HomeAssistant, flow_id: str, data: dict): + """Submit the host form and accept the CA the panel serves. + + The CA step sits between the host form and the authentication menu, because + registration is the exchange that carries the passphrase and it runs over the + pin. Tests that are not about the CA itself elide it here rather than + repeating the same two lines twenty times. + """ + result = await hass.config_entries.flow.async_configure(flow_id, data) + assert result["step_id"] == "panel_ca_confirm", result["step_id"] + return await _confirm_panel_ca(hass, result["flow_id"]) MOCK_HOST = "192.168.1.100" @@ -159,9 +178,8 @@ async def test_user_flow_detects_v2_and_shows_auth_choice(hass: HomeAssistant) - assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: MOCK_HOST}, + result2 = await _submit_host_and_pin( + hass, result["flow_id"], {CONF_HOST: MOCK_HOST} ) assert result2["type"] == FlowResultType.MENU @@ -252,9 +270,8 @@ async def test_passphrase_auth_success(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: MOCK_HOST}, + result2 = await _submit_host_and_pin( + hass, result["flow_id"], {CONF_HOST: MOCK_HOST} ) assert result2["step_id"] == "choose_v2_auth" @@ -270,14 +287,8 @@ async def test_passphrase_auth_success(hass: HomeAssistant) -> None: {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE}, ) - # The CA is fetched and confirmed before anything else is asked. assert result3["type"] == FlowResultType.FORM - assert result3["step_id"] == "panel_ca" - assert result3["description_placeholders"] == {"fingerprint": FAKE_CA_FINGERPRINT} - - result4 = await _confirm_panel_ca(hass, result3["flow_id"]) - assert result4["type"] == FlowResultType.FORM - assert result4["step_id"] == "choose_entity_naming_initial" + assert result3["step_id"] == "choose_entity_naming_initial" @pytest.mark.asyncio @@ -301,9 +312,8 @@ async def test_passphrase_auth_bad_passphrase(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: MOCK_HOST}, + result2 = await _submit_host_and_pin( + hass, result["flow_id"], {CONF_HOST: MOCK_HOST} ) # Select passphrase auth from the menu @@ -343,9 +353,8 @@ async def test_passphrase_auth_connection_error(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: MOCK_HOST}, + result2 = await _submit_host_and_pin( + hass, result["flow_id"], {CONF_HOST: MOCK_HOST} ) # Select passphrase auth from the menu @@ -390,9 +399,8 @@ async def test_v2_entry_contains_mqtt_credentials(hass: HomeAssistant) -> None: ) # Step 1: submit host - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: MOCK_HOST}, + result2 = await _submit_host_and_pin( + hass, result["flow_id"], {CONF_HOST: MOCK_HOST} ) # Step 2: choose auth method (passphrase) @@ -407,12 +415,9 @@ async def test_v2_entry_contains_mqtt_credentials(hass: HomeAssistant) -> None: {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE}, ) - # Step 4: accept the panel's CA fingerprint - result3b = await _confirm_panel_ca(hass, result3["flow_id"]) - - # Step 5: choose entity naming pattern (accept default) + # Step 4: choose entity naming pattern (accept default) result4 = await hass.config_entries.flow.async_configure( - result3b["flow_id"], + result3["flow_id"], {"entity_naming_pattern": "friendly_names"}, ) @@ -431,26 +436,28 @@ async def test_v2_entry_contains_mqtt_credentials(hass: HomeAssistant) -> None: async def _reach_the_ca_step(hass: HomeAssistant): - """Drive a user flow as far as the CA step and return that result.""" + """Drive a user flow as far as the CA step and return that result. + + That is only two steps now: the CA is fetched before authentication, so the + host form leads straight into it. + """ result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_HOST: MOCK_HOST} - ) - result2b = await hass.config_entries.flow.async_configure( - result2["flow_id"], {"next_step_id": "auth_passphrase"} - ) return await hass.config_entries.flow.async_configure( - result2b["flow_id"], {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE} + result["flow_id"], {CONF_HOST: MOCK_HOST} ) @pytest.mark.asyncio -async def test_a_ca_that_does_not_sign_what_the_panel_serves_is_not_offered( +async def test_the_ca_is_pinned_before_the_passphrase_is_ever_sent( hass: HomeAssistant, ) -> None: - """The user is never shown a fingerprint that already failed validation.""" + """Registration carries the passphrase and returns both credentials. + + It is the one exchange most worth protecting, so the anchor has to be + accepted before the authentication menu is even offered. + """ with ( patch( "custom_components.span_panel.config_flow.detect_api_version", @@ -460,7 +467,44 @@ async def test_a_ca_that_does_not_sign_what_the_panel_serves_is_not_offered( patch( "custom_components.span_panel.config_flow.validate_v2_passphrase", return_value=MOCK_V2_AUTH, + ) as register, + ): + shown = await _reach_the_ca_step(hass) + assert shown["step_id"] == "panel_ca_confirm" + assert shown["description_placeholders"] == {"fingerprint": FAKE_CA_FINGERPRINT} + + # Nothing has been sent to the panel yet. + register.assert_not_called() + + menu = await _confirm_panel_ca(hass, shown["flow_id"]) + assert menu["step_id"] == "choose_v2_auth" + + picked = await hass.config_entries.flow.async_configure( + menu["flow_id"], {"next_step_id": "auth_passphrase"} + ) + await hass.config_entries.flow.async_configure( + picked["flow_id"], {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE} + ) + + # And when it was, it went over the accepted anchor rather than plaintext. + transport = register.call_args.kwargs["transport"] + assert transport is not None + assert transport.ssl_context is not None + assert transport.httpx_client is None + assert transport.port == 443 + + +@pytest.mark.asyncio +async def test_a_ca_that_does_not_sign_what_the_panel_serves_is_not_offered( + hass: HomeAssistant, +) -> None: + """The user is never shown a fingerprint that already failed validation.""" + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, ), + patch("custom_components.span_panel.config_flow.validate_host", return_value=True), patch( "custom_components.span_panel.config_flow.async_leaf_chains_to_ca", new=AsyncMock(return_value=False), @@ -468,18 +512,22 @@ async def test_a_ca_that_does_not_sign_what_the_panel_serves_is_not_offered( ): result = await _reach_the_ca_step(hass) - assert result["type"] == FlowResultType.MENU - assert result["step_id"] == "panel_ca_failed" + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "panel_ca" + assert result["errors"] == {"base": "ca_leaf_mismatch"} @pytest.mark.asyncio -async def test_a_fetch_failure_offers_a_retry_and_a_way_past( +async def test_a_fetch_failure_is_a_flow_error_with_no_way_past( hass: HomeAssistant, ) -> None: - """An unreachable certificate endpoint is not a hard setup failure. + """There is deliberately no "carry on unpinned" option here. - Continuing creates the entry with the pending flag rather than a pin, so - every later setup retries the fetch on its own. + The next thing this flow does is send the panel passphrase. An opt-out would + quietly restore the plaintext credential exchange that pinning before + registration exists to remove, at the moment a user is least likely to weigh + it. Resubmitting the form retries the fetch, which is the recovery a + transient network failure actually needs. """ with ( patch( @@ -490,32 +538,52 @@ async def test_a_fetch_failure_offers_a_retry_and_a_way_past( patch( "custom_components.span_panel.config_flow.validate_v2_passphrase", return_value=MOCK_V2_AUTH, - ), + ) as register, patch( "custom_components.span_panel.config_flow.async_fetch_panel_ca", new=AsyncMock(side_effect=SpanPanelConnectionError("unreachable")), + ) as fetch, + ): + failed = await _reach_the_ca_step(hass) + assert failed["type"] == FlowResultType.FORM + assert failed["step_id"] == "panel_ca" + assert failed["errors"] == {"base": "ca_unavailable"} + # No menu, so no option that leads anywhere but back through the fetch. + assert "menu_options" not in failed + + # Resubmitting retries rather than continuing. + retried = await hass.config_entries.flow.async_configure(failed["flow_id"], {}) + assert retried["step_id"] == "panel_ca" + assert fetch.await_count == 2 + + # The panel was never asked to register anything. + register.assert_not_called() + + +@pytest.mark.asyncio +async def test_a_recovered_panel_can_be_retried_into_a_successful_pin( + hass: HomeAssistant, +) -> None: + """The error form is a retry, so a panel that comes back needs no restart.""" + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, ), + patch("custom_components.span_panel.config_flow.validate_host", return_value=True), patch( - "custom_components.span_panel.async_setup_entry", - new=AsyncMock(return_value=True), + "custom_components.span_panel.config_flow.async_fetch_panel_ca", + new=AsyncMock( + side_effect=[SpanPanelConnectionError("unreachable"), FAKE_CA_PEM] + ), ), ): - result = await _reach_the_ca_step(hass) - assert result["type"] == FlowResultType.MENU - assert set(result["menu_options"]) == {"panel_ca", "panel_ca_skip"} - - skipped = await hass.config_entries.flow.async_configure( - result["flow_id"], {"next_step_id": "panel_ca_skip"} - ) - assert skipped["step_id"] == "choose_entity_naming_initial" + failed = await _reach_the_ca_step(hass) + assert failed["errors"] == {"base": "ca_unavailable"} - created = await hass.config_entries.flow.async_configure( - skipped["flow_id"], {"entity_naming_pattern": "friendly_names"} - ) + recovered = await hass.config_entries.flow.async_configure(failed["flow_id"], {}) - assert created["type"] == FlowResultType.CREATE_ENTRY - assert CONF_PANEL_CA_PEM not in created["data"] - assert created["data"][PANEL_CA_PENDING] is True + assert recovered["step_id"] == "panel_ca_confirm" # ---------- config entry migration (2.0.4 baseline) ---------- @@ -914,9 +982,8 @@ async def test_user_flow_recovery_after_bad_host(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "cannot_connect"} # Second attempt succeeds - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - {CONF_HOST: MOCK_HOST}, + result3 = await _submit_host_and_pin( + hass, result2["flow_id"], {CONF_HOST: MOCK_HOST} ) assert result3["type"] == FlowResultType.MENU assert result3["step_id"] == "choose_v2_auth" @@ -942,9 +1009,8 @@ async def test_passphrase_auth_empty_passphrase(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: MOCK_HOST}, + result2 = await _submit_host_and_pin( + hass, result["flow_id"], {CONF_HOST: MOCK_HOST} ) result2b = await hass.config_entries.flow.async_configure( @@ -983,9 +1049,8 @@ async def test_passphrase_auth_recovery_after_error(hass: HomeAssistant) -> None DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: MOCK_HOST}, + result2 = await _submit_host_and_pin( + hass, result["flow_id"], {CONF_HOST: MOCK_HOST} ) result2b = await hass.config_entries.flow.async_configure( @@ -1006,11 +1071,7 @@ async def test_passphrase_auth_recovery_after_error(hass: HomeAssistant) -> None {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE}, ) assert result4["type"] == FlowResultType.FORM - assert result4["step_id"] == "panel_ca" - - result5 = await _confirm_panel_ca(hass, result4["flow_id"]) - assert result5["type"] == FlowResultType.FORM - assert result5["step_id"] == "choose_entity_naming_initial" + assert result4["step_id"] == "choose_entity_naming_initial" # ---------- proximity auth ---------- @@ -1037,9 +1098,8 @@ async def test_proximity_auth_success(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: MOCK_HOST}, + result2 = await _submit_host_and_pin( + hass, result["flow_id"], {CONF_HOST: MOCK_HOST} ) assert result2["step_id"] == "choose_v2_auth" @@ -1056,11 +1116,7 @@ async def test_proximity_auth_success(hass: HomeAssistant) -> None: ) assert result3["type"] == FlowResultType.FORM - assert result3["step_id"] == "panel_ca" - - result4 = await _confirm_panel_ca(hass, result3["flow_id"]) - assert result4["type"] == FlowResultType.FORM - assert result4["step_id"] == "choose_entity_naming_initial" + assert result3["step_id"] == "choose_entity_naming_initial" @pytest.mark.asyncio @@ -1080,9 +1136,8 @@ async def test_proximity_not_proven_returns_to_menu(hass: HomeAssistant) -> None DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: MOCK_HOST}, + result2 = await _submit_host_and_pin( + hass, result["flow_id"], {CONF_HOST: MOCK_HOST} ) result2b = await hass.config_entries.flow.async_configure( @@ -1118,9 +1173,8 @@ async def test_proximity_switch_to_passphrase(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: MOCK_HOST}, + result2 = await _submit_host_and_pin( + hass, result["flow_id"], {CONF_HOST: MOCK_HOST} ) result2b = await hass.config_entries.flow.async_configure( @@ -1331,11 +1385,8 @@ async def test_zeroconf_end_to_end_entry_creation(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.FORM assert result["step_id"] == "confirm_discovery" - # Step 2: confirm → auth choice - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {}, - ) + # Step 2: confirm → accept the panel's CA → auth choice + result2 = await _submit_host_and_pin(hass, result["flow_id"], {}) assert result2["type"] == FlowResultType.MENU assert result2["step_id"] == "choose_v2_auth" @@ -1351,15 +1402,11 @@ async def test_zeroconf_end_to_end_entry_creation(hass: HomeAssistant) -> None: result3["flow_id"], {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE}, ) - assert result4["step_id"] == "panel_ca" - - # Step 5: accept the CA fingerprint - result4b = await _confirm_panel_ca(hass, result4["flow_id"]) - assert result4b["step_id"] == "choose_entity_naming_initial" + assert result4["step_id"] == "choose_entity_naming_initial" - # Step 6: accept naming default → entry created + # Step 5: accept naming default → entry created result5 = await hass.config_entries.flow.async_configure( - result4b["flow_id"], + result4["flow_id"], {"entity_naming_pattern": "friendly_names"}, ) assert result5["type"] == FlowResultType.CREATE_ENTRY @@ -1814,10 +1861,11 @@ async def test_hassio_end_to_end_entry_creation(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.FORM assert result["step_id"] == "confirm_discovery" - # Step 2: confirm -> auth choice - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {}, + # Step 2: confirm -> HTTPS port (this panel moved its HTTP port) -> CA + port_step = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert port_step["step_id"] == "panel_https_port" + result2 = await _submit_host_and_pin( + hass, port_step["flow_id"], {CONF_HTTPS_PORT: 9443} ) assert result2["type"] == FlowResultType.MENU assert result2["step_id"] == "choose_v2_auth" @@ -1834,21 +1882,10 @@ async def test_hassio_end_to_end_entry_creation(hass: HomeAssistant) -> None: result3["flow_id"], {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE}, ) - # Step 5: a non-default HTTP port means the TLS one is asked for too. - assert result4["step_id"] == "panel_https_port" - result4b = await hass.config_entries.flow.async_configure( - result4["flow_id"], - {CONF_HTTPS_PORT: 9443}, - ) - - # Step 6: accept the CA fingerprint - assert result4b["step_id"] == "panel_ca" - result4c = await _confirm_panel_ca(hass, result4b["flow_id"]) - assert result4c["step_id"] == "choose_entity_naming_initial" - - # Step 7: accept naming default -> entry created + # Step 5: accept naming default -> entry created + assert result4["step_id"] == "choose_entity_naming_initial" result5 = await hass.config_entries.flow.async_configure( - result4c["flow_id"], + result4["flow_id"], {"entity_naming_pattern": "friendly_names"}, ) assert result5["type"] == FlowResultType.CREATE_ENTRY @@ -2005,9 +2042,8 @@ async def test_user_flow_fqdn_registration_progress_then_naming( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: "panel.example.com"}, + result2 = await _submit_host_and_pin( + hass, result["flow_id"], {CONF_HOST: "panel.example.com"} ) result2b = await hass.config_entries.flow.async_configure( result2["flow_id"], @@ -2017,20 +2053,12 @@ async def test_user_flow_fqdn_registration_progress_then_naming( result2b["flow_id"], {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE}, ) - assert result3["type"] == FlowResultType.FORM - assert result3["step_id"] == "panel_ca" - - result4 = await _confirm_panel_ca(hass, result3["flow_id"]) - assert result4["type"] == FlowResultType.FORM - assert result4["step_id"] == "choose_entity_naming_initial" - - result5 = await hass.config_entries.flow.async_configure( - result4["flow_id"], - {"entity_naming_pattern": "friendly_names"}, - ) - assert result5["type"] == FlowResultType.CREATE_ENTRY - assert result5["data"][CONF_REGISTERED_FQDN] == "panel.example.com" - assert result5["data"][CONF_PANEL_CA_PEM] == FAKE_CA_PEM + # The progress step carries its triggering input through to the naming step, + # which takes the default and creates the entry in one go. + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["data"][CONF_REGISTERED_FQDN] == "panel.example.com" + # Registration itself ran over this anchor, not just what followed it. + assert result3["data"][CONF_PANEL_CA_PEM] == FAKE_CA_PEM @pytest.mark.usefixtures("socket_enabled") @@ -2068,9 +2096,8 @@ async def test_user_flow_fqdn_registration_failure_can_continue( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: "panel.example.com"}, + result2 = await _submit_host_and_pin( + hass, result["flow_id"], {CONF_HOST: "panel.example.com"} ) result2b = await hass.config_entries.flow.async_configure( result2["flow_id"], @@ -2081,7 +2108,7 @@ async def test_user_flow_fqdn_registration_failure_can_continue( {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE}, ) assert result3["type"] == FlowResultType.FORM - assert result3["step_id"] == "panel_ca" + assert result3["step_id"] == "choose_entity_naming_initial" @pytest.mark.usefixtures("socket_enabled") @@ -2131,10 +2158,15 @@ async def test_fqdn_entry_creation_sets_registered_fqdn_and_unique_title( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await hass.config_entries.flow.async_configure( + port_step = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: "panel.example.com", CONF_HTTP_PORT: 8080}, ) + assert port_step["step_id"] == "panel_https_port" + # Left at the default, so it is not written to the entry. + result2 = await _submit_host_and_pin( + hass, port_step["flow_id"], {CONF_HTTPS_PORT: 443} + ) result2b = await hass.config_entries.flow.async_configure( result2["flow_id"], {"next_step_id": "auth_passphrase"}, @@ -2143,26 +2175,12 @@ async def test_fqdn_entry_creation_sets_registered_fqdn_and_unique_title( result2b["flow_id"], {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE}, ) - assert result3["type"] == FlowResultType.FORM - assert result3["step_id"] == "panel_https_port" - - # Left at the default, so it is not written to the entry. - result3b = await hass.config_entries.flow.async_configure( - result3["flow_id"], - {CONF_HTTPS_PORT: 443}, - ) - result4 = await _confirm_panel_ca(hass, result3b["flow_id"]) - result5 = await hass.config_entries.flow.async_configure( - result4["flow_id"], - {"entity_naming_pattern": "friendly_names"}, - ) - - assert result5["type"] == FlowResultType.CREATE_ENTRY - assert result5["title"] == "Span Panel 2" - assert result5["data"][CONF_HOST] == "panel.example.com" - assert result5["data"][CONF_REGISTERED_FQDN] == "panel.example.com" - assert result5["data"][CONF_HTTP_PORT] == 8080 - assert CONF_HTTPS_PORT not in result5["data"] + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "Span Panel 2" + assert result3["data"][CONF_HOST] == "panel.example.com" + assert result3["data"][CONF_REGISTERED_FQDN] == "panel.example.com" + assert result3["data"][CONF_HTTP_PORT] == 8080 + assert CONF_HTTPS_PORT not in result3["data"] @pytest.mark.asyncio From 752509d19eb48eb8c927d183d05cfe16528fe5a9 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:30:00 -0700 Subject: [PATCH 126/345] docs: correct why an unpinned upgraded entry is allowed to start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reasoning rested on a false claim. It said nothing crosses the wire at an upgraded entry's setup; something does. The MQTTS session authenticates with the broker password, and with `ca_pem` absent the library falls back to fetching the CA over unauthenticated plaintext HTTP on every connect and trusting whatever answers — which is exactly the substitution the pin exists to close. An unpinned upgraded entry is exposed on each connect until the flag settles. The conclusion is unchanged and now rests on the accurate claim: that exposure is the status quo ante, identical to what the entry already had under 3.0.1, and refusing to start does not remove it — it removes the integration, and an integration that will not start protects nothing. Retrying converts a pre-existing, bounded exposure into a closed one at the first successful setup. Refusing converts it into a guaranteed outage with the credential no safer. This matters because of why the docstring exists at all: to stop a later reader "fixing" the asymmetry between a new entry, which cannot be created unpinned, and an upgraded one, which may start that way. Resting it on "nothing crosses the wire" would have failed precisely against the reader who noticed the broker password does — they would have concluded the reasoning was wrong and changed the behaviour, which is the outcome it was written to prevent. Same correction in the README, which repeated the phrasing. --- README.md | 11 ++++++++--- custom_components/span_panel/const.py | 18 +++++++++++++++--- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index d47fd846..28d7a230 100644 --- a/README.md +++ b/README.md @@ -677,9 +677,14 @@ asking you. shows an error and stops. Submitting the form again retries the fetch, which is what a panel that was briefly unreachable needs. An opt-out would quietly restore the plaintext credential exchange this ordering exists to remove, at the moment you are least likely to weigh it. -**Panels configured before this release are different**, because their credentials were exchanged long ago and nothing crosses the wire at startup. They are -pinned on the first startup that reaches the panel, logged at `WARNING` with the fingerprint so you can find the value afterwards. If the panel is unreachable -the integration starts anyway and retries on the next startup; until it succeeds, the connection behaves as it did before. +**Panels configured before this release are pinned differently, and are not risk-free until they are.** They are pinned on the first startup that reaches the +panel, logged at `WARNING` with the fingerprint so you can find the value afterwards. Until that succeeds, the connection to the broker still authenticates with +your panel's password while the authority is re-fetched over plaintext HTTP on every connection and whatever answers is trusted — the substitution pinning +exists to close. + +If the panel is unreachable the integration starts anyway and retries on the next startup. That is deliberate. The exposure in the meantime is exactly the one +the entry already had before this release, and refusing to start would not remove it — it would only remove the integration, leaving the credential no safer +while guaranteeing an outage. Retrying closes it at the first opportunity instead. Diagnostics report the fingerprint under `panel_ca`. The certificate itself is not included — it is public, but multi-KB, and the fingerprint is the part worth reading. diff --git a/custom_components/span_panel/const.py b/custom_components/span_panel/const.py index 5ff803c1..4661247b 100644 --- a/custom_components/span_panel/const.py +++ b/custom_components/span_panel/const.py @@ -38,9 +38,21 @@ **Only the migration sets this.** A newly configured entry always carries a pin, because the config flow fetches and confirms the CA before it authenticates and refuses to continue without one — registration is the exchange that carries the -passphrase. An upgraded entry is a different case: its credentials were -exchanged long ago, nothing crosses the wire at setup, and refusing to start -would take down a working integration to no benefit.""" +passphrase. + +An upgraded entry is a different case, but not a risk-free one, and the +difference is not that nothing is at stake. Its MQTTS session authenticates with +the broker password, and with no `ca_pem` the library falls back to fetching the +CA over unauthenticated plaintext HTTP on every connect and trusting whatever +answers — which is the substitution this pin exists to close. So an unpinned +upgraded entry is exposed on each connect until the flag settles. + +Starting anyway is still right, for a different reason: that exposure is exactly +the status quo ante. It is what the entry already had under 3.0.1, and refusing +to start does not remove it — it removes the integration, and an integration +that will not start protects nothing. Retrying converts a pre-existing, bounded +exposure into a closed one at the first successful setup; refusing converts it +into a guaranteed outage with the credential no safer.""" # Binary sensor / status field keys (used in entity definitions) SYSTEM_DOOR_STATE = "doorState" From e1e75e7408b0f033c66d032c3704045a754df743 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:29:19 -0700 Subject: [PATCH 127/345] fix(control): report a refused panel command where the user made it The library refuses a circuit relay or priority command for a circuit the panel declares non-settable, raising SpanPanelServerError instead of returning an outcome. Three things followed from that. switch.py had no handling for it at all, so a refused relay command propagated past the outcome check as a traceback and left the toggle springing back with no explanation. It now raises a translated error, after the publish and never before it, so the switch is never left showing a position the relay did not take. select.py's message named panel firmware as the cause. That was already a guess -- SpanMqttClient raises this exception from exactly one place, its refusal path -- and it is now wrong for a user whose circuit is simply commissioned non-settable. All three messages state the mechanism, which does not change, and carry the library's own reason verbatim rather than diagnosing a cause the exception does not name. select.py and button.py filed persistent notifications with hardcoded English text. They raise HomeAssistantError instead, as number.py already did, through the three translation keys that were already in strings.json and unused: a refusal published nothing, so there is nothing to correct later and nobody to tell but the caller, and a notification keyed on the circuit outlived the failure. --- CHANGELOG.md | 7 +++ custom_components/span_panel/button.py | 41 ++++++------ custom_components/span_panel/helpers.py | 16 +++++ custom_components/span_panel/select.py | 37 ++++++----- custom_components/span_panel/strings.json | 6 +- custom_components/span_panel/switch.py | 33 ++++++++-- .../span_panel/translations/en.json | 6 +- .../span_panel/translations/es.json | 6 +- .../span_panel/translations/fr.json | 6 +- .../span_panel/translations/ja.json | 6 +- .../span_panel/translations/pt.json | 6 +- tests/test_button.py | 18 +++--- tests/test_circuit_control.py | 62 +++++++++++++++++++ tests/test_dps_and_bess.py | 24 +++---- tests/test_select.py | 45 ++++++++------ 15 files changed, 223 insertions(+), 96 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d44ed75f..14503f88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -131,6 +131,13 @@ working entirely in 2027.8. "Possible states: Unknown". - **The README described Battery Power's sign backwards.** The sensor reports **discharging** as positive and always has, as release 2.0.5 established (#184) and a measured panel confirms — **no entity changed and no reading moved**, only the documentation was ever wrong. +- **A breaker your panel refuses to operate now says so.** The switch had no handling for a refused relay command at all, so the failure surfaced as a traceback + in the log and a toggle that quietly sprang back. It now reports the panel's own reason where you pressed it, and the switch stays where the relay actually + is. +- **A refused circuit priority no longer blames your firmware.** The old message said the panel's firmware did not support the operation, which stopped being + the only explanation once your panel could also declare an individual circuit's priority fixed. The priority select and the GFE override button now carry the + panel's reason instead of guessing at one, and report it as an error on the control you used rather than as a notification that sits in the sidebar until you + dismiss it by hand. ### Security diff --git a/custom_components/span_panel/button.py b/custom_components/span_panel/button.py index ddd904ac..7ffe47fa 100644 --- a/custom_components/span_panel/button.py +++ b/custom_components/span_panel/button.py @@ -5,20 +5,17 @@ from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from span_panel_api import SpanMqttClient, SpanPanelSnapshot from span_panel_api.exceptions import SpanPanelServerError from . import SpanPanelConfigEntry -from .const import CONF_DEVICE_NAME +from .const import CONF_DEVICE_NAME, DOMAIN from .control_gate import ControlMode from .coordinator import SpanPanelCoordinator from .entity import SpanPanelEntity -from .helpers import ( - async_create_span_notification, - construct_panel_unique_id_for_entry, - has_bess, -) +from .helpers import construct_panel_unique_id_for_entry, has_bess _LOGGER = logging.getLogger(__name__) @@ -64,7 +61,13 @@ def __init__( ) async def async_press(self) -> None: - """Publish the GFE override to the panel.""" + """Publish the GFE override to the panel. + + A refusal is raised at the caller rather than filed as a persistent + notification, the same way the circuit controls report one: nothing was + published, so there is nothing to correct later, and the person who + pressed the button is the one who needs to hear about it. + """ client = self.coordinator.client if not hasattr(client, "set_dominant_power_source"): _LOGGER.warning("Client does not support GFE override") @@ -74,19 +77,19 @@ async def async_press(self) -> None: await self._async_guarded_control( client.set_dominant_power_source(self._override_value) ) - await self.coordinator.async_request_refresh() - except SpanPanelServerError: - warning_msg = ( - f"SPAN API returned a server error attempting " - f"to override GFE to {self._override_value}." - ) - _LOGGER.warning(warning_msg) - await async_create_span_notification( - self.hass, - message=warning_msg, - title="SPAN API Error", - notification_id="span_panel_gfe_override_error", + except SpanPanelServerError as err: + _LOGGER.warning( + "SPAN panel did not accept a GFE override to %s: %s", self._override_value, err ) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="gfe_override_failed", + translation_placeholders={ + "value": self._override_value, + "reason": str(err), + }, + ) from err + await self.coordinator.async_request_refresh() @property def available(self) -> bool: diff --git a/custom_components/span_panel/helpers.py b/custom_components/span_panel/helpers.py index ea07f873..927430f1 100644 --- a/custom_components/span_panel/helpers.py +++ b/custom_components/span_panel/helpers.py @@ -76,6 +76,7 @@ "build_switch_unique_id_for_entry", "construct_binary_sensor_unique_id", "construct_circuit_identifier_from_tabs", + "construct_circuit_label", "construct_circuit_unique_id", "construct_circuit_unique_id_for_entry", "construct_multi_circuit_entity_id", @@ -162,6 +163,21 @@ def construct_circuit_identifier_from_tabs(tabs: list[int], circuit_id: str = "" return f"Circuit {circuit_id}" +def construct_circuit_label(circuit: SpanCircuitSnapshot | None, circuit_id: str) -> str: + """Name a circuit the way its owner knows it, for a message addressed to them. + + The panel's own name where there is one, and the tab identifier otherwise -- + which is what the entity itself was named from, so an error names the thing + the user is looking at rather than a wire UUID. `None` is accepted because a + circuit can drop out of a snapshot between a command and its failure, and an + error message is the worst possible place to raise a second error. + """ + if circuit is not None and circuit.name: + return circuit.name + tabs = circuit.tabs if circuit is not None else [] + return construct_circuit_identifier_from_tabs(tabs, circuit_id) + + def construct_tabs_attribute(circuit: SpanCircuitSnapshot) -> str | None: """Construct tabs attribute string from circuit data. diff --git a/custom_components/span_panel/select.py b/custom_components/span_panel/select.py index 7ebc8302..c56c851d 100644 --- a/custom_components/span_panel/select.py +++ b/custom_components/span_panel/select.py @@ -6,7 +6,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceNotFound +from homeassistant.exceptions import HomeAssistantError, ServiceNotFound from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -23,6 +23,7 @@ async_create_span_notification, build_select_unique_id_for_entry, construct_circuit_identifier_from_tabs, + construct_circuit_label, construct_single_circuit_entity_id, construct_tabs_attribute, construct_voltage_attribute, @@ -204,7 +205,15 @@ async def async_will_remove_from_hass(self) -> None: await super().async_will_remove_from_hass() async def async_select_option(self, option: str) -> None: - """Change the selected option.""" + """Change the selected option. + + A refusal is raised at the caller rather than filed as a persistent + notification. The library refuses a priority the panel declares + unsettable before anything is published, so there is nothing to correct + later and nobody to tell but the person who just made the choice -- and + a notification keyed on the circuit would outlive the failure and sit in + the sidebar until someone dismissed it by hand. + """ _LOGGER.debug("Selecting option: %s", option) client = self.coordinator.client if not hasattr(client, "set_circuit_priority"): @@ -228,20 +237,18 @@ async def async_select_option(self, option: str) -> None: title="Service Not Found", notification_id=f"span_panel_service_not_found_{self.id}", ) - except SpanPanelServerError: - warning_msg = ( - f"SPAN API returned a server error attempting " - f"to change the circuit priority for {self._attr_name}. " - f"This typically indicates panel firmware doesn't support " - f"this operation." - ) - _LOGGER.warning("SPAN API may not support setting priority") - await async_create_span_notification( - self.hass, - message=warning_msg, - title="SPAN API Error", - notification_id=f"span_panel_api_error_{self.id}", + except SpanPanelServerError as err: + _LOGGER.warning( + "SPAN panel did not accept a priority change for %s: %s", self.entity_id, err ) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="circuit_priority_failed", + translation_placeholders={ + "circuit": construct_circuit_label(self._get_circuit(), self.id), + "reason": str(err), + }, + ) from err def select_option(self, option: str) -> None: """Select an option synchronously.""" diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index 4c8dc045..17ce7f9e 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -429,10 +429,10 @@ "message": "Only a Home Assistant administrator may operate this SPAN panel." }, "circuit_priority_failed": { - "message": "Failed to set circuit priority for {circuit}." + "message": "The priority change for {circuit} was refused: {reason}. Nothing was sent to the panel, so the priority is unchanged." }, "circuit_relay_failed": { - "message": "Failed to set circuit relay state for {circuit}." + "message": "The breaker command for {circuit} was refused: {reason}. Nothing was sent to the panel, so the breaker has not moved." }, "contextless_control_refused": { "message": "This SPAN panel refuses control commands that do not come from a logged-in user." @@ -471,7 +471,7 @@ "message": "Sub-device {entity_id} has no SPAN Panel parent device — its via_device_id does not point at a SPAN panel." }, "gfe_override_failed": { - "message": "Failed to send GFE override to the panel." + "message": "The GFE override to {value} was refused: {reason}. Nothing was sent to the panel." }, "graph_horizon_not_available": { "message": "Graph horizon manager is not available for the selected SPAN panel." diff --git a/custom_components/span_panel/switch.py b/custom_components/span_panel/switch.py index 86d87375..c6127f35 100644 --- a/custom_components/span_panel/switch.py +++ b/custom_components/span_panel/switch.py @@ -6,11 +6,12 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from span_panel_api import SpanCircuitSnapshot, SpanPanelSnapshot +from span_panel_api.exceptions import SpanPanelServerError from . import SpanPanelConfigEntry from .adoption import AdoptedSwitch, create_adopted_switches @@ -21,6 +22,7 @@ from .helpers import ( build_switch_unique_id_for_entry, construct_circuit_identifier_from_tabs, + construct_circuit_label, construct_single_circuit_entity_id, construct_tabs_attribute, construct_voltage_attribute, @@ -284,15 +286,38 @@ async def _async_set_relay(self, state: str, *, is_on: bool) -> None: and may already have been acted on; `UNCONFIRMED` most often means the relay was already in the requested position. Neither is a failure and neither should discard the requested state. + + A refusal is the third thing that can happen, and it is raised rather + than logged. The library refuses a relay command the panel declares + non-commandable before anything reaches the broker, so there is no + publish to report on and no coordinator update coming; swallowing it + would leave the person who pressed the switch with a control that + silently does nothing. """ client = self.coordinator.client if not hasattr(client, "set_circuit_relay"): _LOGGER.warning("Client does not support relay control") return - outcome = await self._async_guarded_control( - client.set_circuit_relay(self._circuit_id, state) - ) + try: + outcome = await self._async_guarded_control( + client.set_circuit_relay(self._circuit_id, state) + ) + except SpanPanelServerError as err: + _LOGGER.warning( + "SPAN panel did not accept a relay command for %s: %s", self.entity_id, err + ) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="circuit_relay_failed", + translation_placeholders={ + "circuit": construct_circuit_label( + self.coordinator.data.circuits.get(self._circuit_id), + self._circuit_id, + ), + "reason": str(err), + }, + ) from err if outcome_is_failure(outcome): _LOGGER.warning( "Relay command for %s was not delivered: %s", diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index 4c8dc045..17ce7f9e 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -429,10 +429,10 @@ "message": "Only a Home Assistant administrator may operate this SPAN panel." }, "circuit_priority_failed": { - "message": "Failed to set circuit priority for {circuit}." + "message": "The priority change for {circuit} was refused: {reason}. Nothing was sent to the panel, so the priority is unchanged." }, "circuit_relay_failed": { - "message": "Failed to set circuit relay state for {circuit}." + "message": "The breaker command for {circuit} was refused: {reason}. Nothing was sent to the panel, so the breaker has not moved." }, "contextless_control_refused": { "message": "This SPAN panel refuses control commands that do not come from a logged-in user." @@ -471,7 +471,7 @@ "message": "Sub-device {entity_id} has no SPAN Panel parent device — its via_device_id does not point at a SPAN panel." }, "gfe_override_failed": { - "message": "Failed to send GFE override to the panel." + "message": "The GFE override to {value} was refused: {reason}. Nothing was sent to the panel." }, "graph_horizon_not_available": { "message": "Graph horizon manager is not available for the selected SPAN panel." diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index 22c671f2..aad2c239 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -429,10 +429,10 @@ "message": "Solo un administrador de Home Assistant puede manejar este SPAN Panel." }, "circuit_priority_failed": { - "message": "Error al establecer la prioridad del circuito para {circuit}." + "message": "Se rechazó el cambio de prioridad para {circuit}: {reason}. No se envió nada al panel, por lo que la prioridad no ha cambiado." }, "circuit_relay_failed": { - "message": "Error al establecer el estado del relé del circuito para {circuit}." + "message": "Se rechazó el comando del interruptor para {circuit}: {reason}. No se envió nada al panel, por lo que el interruptor no ha cambiado de posición." }, "contextless_control_refused": { "message": "Este SPAN Panel rechaza las órdenes de control que no proceden de un usuario con sesión iniciada." @@ -471,7 +471,7 @@ "message": "El subdispositivo {entity_id} no tiene un dispositivo SPAN Panel principal — su via_device_id no apunta a un panel SPAN." }, "gfe_override_failed": { - "message": "Error al enviar la anulación GFE al panel." + "message": "Se rechazó la anulación GFE a {value}: {reason}. No se envió nada al panel." }, "graph_horizon_not_available": { "message": "El gestor del horizonte del gráfico no está disponible para el SPAN Panel seleccionado." diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index d2249333..d183b150 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -429,10 +429,10 @@ "message": "Seul un administrateur Home Assistant peut piloter ce SPAN Panel." }, "circuit_priority_failed": { - "message": "Échec de la définition de la priorité du circuit pour {circuit}." + "message": "Le changement de priorité pour {circuit} a été refusé : {reason}. Rien n'a été envoyé au panneau, la priorité est donc inchangée." }, "circuit_relay_failed": { - "message": "Échec de la définition de l'état du relais du circuit pour {circuit}." + "message": "La commande du disjoncteur pour {circuit} a été refusée : {reason}. Rien n'a été envoyé au panneau, le disjoncteur n'a donc pas changé de position." }, "contextless_control_refused": { "message": "Ce SPAN Panel refuse les commandes de contrôle qui ne proviennent pas d'un utilisateur connecté." @@ -471,7 +471,7 @@ "message": "Le sous-appareil {entity_id} n'a pas de panneau SPAN parent — son via_device_id ne pointe pas vers un panneau SPAN." }, "gfe_override_failed": { - "message": "Échec de l'envoi du forçage GFE au panneau." + "message": "Le forçage GFE vers {value} a été refusé : {reason}. Rien n'a été envoyé au panneau." }, "graph_horizon_not_available": { "message": "Le gestionnaire d'horizon de graphique n'est pas disponible pour le panneau SPAN sélectionné." diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index e1de2754..8a9a1a07 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -429,10 +429,10 @@ "message": "この SPAN Panel を操作できるのは Home Assistant の管理者だけです。" }, "circuit_priority_failed": { - "message": "{circuit}の回路優先度の設定に失敗しました。" + "message": "{circuit}の優先度変更は拒否されました: {reason}。パネルには何も送信されていないため、優先度は変わっていません。" }, "circuit_relay_failed": { - "message": "{circuit}の回路リレー状態の設定に失敗しました。" + "message": "{circuit}のブレーカー操作は拒否されました: {reason}。パネルには何も送信されていないため、ブレーカーの状態は変わっていません。" }, "contextless_control_refused": { "message": "この SPAN Panel は、ログインユーザーからではない操作コマンドを拒否します。" @@ -471,7 +471,7 @@ "message": "サブデバイス {entity_id} に親のSPAN Panelデバイスがありません。via_device_id がSPANパネルを指していません。" }, "gfe_override_failed": { - "message": "パネルへのGFEオーバーライドの送信に失敗しました。" + "message": "{value}へのGFEオーバーライドは拒否されました: {reason}。パネルには何も送信されていません。" }, "graph_horizon_not_available": { "message": "選択されたSPANパネルではグラフホライズンマネージャーが利用できません。" diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index 390b173c..5174da09 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -429,10 +429,10 @@ "message": "Apenas um administrador do Home Assistant pode operar este SPAN Panel." }, "circuit_priority_failed": { - "message": "Falha ao definir a prioridade do circuito para {circuit}." + "message": "A alteração de prioridade para {circuit} foi recusada: {reason}. Nada foi enviado ao painel, portanto a prioridade permanece inalterada." }, "circuit_relay_failed": { - "message": "Falha ao definir o estado do relé do circuito para {circuit}." + "message": "O comando do disjuntor para {circuit} foi recusado: {reason}. Nada foi enviado ao painel, portanto o disjuntor não mudou de posição." }, "contextless_control_refused": { "message": "Este SPAN Panel recusa comandos de controlo que não venham de um utilizador com sessão iniciada." @@ -471,7 +471,7 @@ "message": "O sub-dispositivo {entity_id} não tem um painel SPAN pai — o seu via_device_id não aponta para um painel SPAN." }, "gfe_override_failed": { - "message": "Falha ao enviar substituição GFE para o painel." + "message": "A substituição GFE para {value} foi recusada: {reason}. Nada foi enviado ao painel." }, "graph_horizon_not_available": { "message": "O gerenciador de horizonte do gráfico não está disponível para o painel SPAN selecionado." diff --git a/tests/test_button.py b/tests/test_button.py index e327281d..1a824d95 100644 --- a/tests/test_button.py +++ b/tests/test_button.py @@ -15,6 +15,7 @@ from custom_components.span_panel.const import DOMAIN from custom_components.span_panel.control_gate import ControlPolicy from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from .factories import SpanBatterySnapshotFactory, SpanPanelSnapshotFactory @@ -77,8 +78,8 @@ async def test_gfe_override_button_logs_when_client_lacks_support( @pytest.mark.asyncio -async def test_gfe_override_button_server_error_creates_notification() -> None: - """Server errors should notify the user that override failed.""" +async def test_gfe_override_button_refusal_is_raised_at_the_caller() -> None: + """A refused override reaches the person who pressed the button.""" snapshot = SpanPanelSnapshotFactory.create( battery=SpanBatterySnapshotFactory.create(connected=False), dominant_power_source="BATTERY", @@ -86,18 +87,19 @@ async def test_gfe_override_button_server_error_creates_notification() -> None: coordinator = _make_button_coordinator(snapshot) coordinator.client = MagicMock() coordinator.client.set_dominant_power_source = AsyncMock( - side_effect=SpanPanelServerError("unsupported") + side_effect=SpanPanelServerError("Core node not found in panel topology") ) button = SpanPanelGFEOverrideButton(coordinator, GFE_OVERRIDE_DESCRIPTION, "GRID") button.hass = MagicMock() - with patch( - "custom_components.span_panel.button.async_create_span_notification", - new=AsyncMock(), - ) as mock_notification: + with pytest.raises(HomeAssistantError) as raised: await button.async_press() - mock_notification.assert_awaited_once() + assert raised.value.translation_key == "gfe_override_failed" + placeholders = raised.value.translation_placeholders + assert placeholders is not None + assert placeholders["value"] == "GRID" + assert placeholders["reason"] == "Core node not found in panel topology" coordinator.async_request_refresh.assert_not_awaited() diff --git a/tests/test_circuit_control.py b/tests/test_circuit_control.py index d62d5657..a6f801db 100644 --- a/tests/test_circuit_control.py +++ b/tests/test_circuit_control.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, PropertyMock import pytest +from span_panel_api.exceptions import SpanPanelServerError from custom_components.span_panel.control_gate import ControlPolicy from custom_components.span_panel.switch import ( @@ -12,6 +13,7 @@ async_setup_entry, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory @@ -106,6 +108,66 @@ async def test_switch_turn_off_operation() -> None: assert switch.is_on is False +@pytest.mark.asyncio +async def test_a_refused_relay_command_is_raised_and_leaves_the_switch_alone() -> None: + """The panel declares this circuit's relay non-commandable. + + Nothing is published, so no coordinator update is coming to correct an + optimistic write -- which is why `_async_set_relay` writes its state only + after the publish, and why the refusal has to reach the caller instead of + being logged and swallowed. + """ + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", + name="Kitchen Outlets", + relay_state="CLOSED", + ) + coordinator = _make_coordinator({"1": circuit}) + coordinator.client.set_circuit_relay = AsyncMock( + side_effect=SpanPanelServerError("Circuit '1' declares its relay non-commandable") + ) + + switch = SpanPanelCircuitsSwitch(coordinator, "1", "Kitchen Outlets", "SPAN Panel") + switch.async_write_ha_state = MagicMock() + + with pytest.raises(HomeAssistantError) as raised: + await switch.async_turn_off() + + assert raised.value.translation_key == "circuit_relay_failed" + placeholders = raised.value.translation_placeholders + assert placeholders is not None + assert placeholders["circuit"] == "Kitchen Outlets" + assert placeholders["reason"] == "Circuit '1' declares its relay non-commandable" + # The relay never moved, so neither did the switch. + assert switch.is_on is True + switch.async_write_ha_state.assert_not_called() + + +@pytest.mark.asyncio +async def test_a_refusal_names_an_unnamed_circuit_by_its_panel_positions() -> None: + """The identifier the entity was named from, not the wire id.""" + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="abc123", + name="", + relay_state="OPEN", + tabs=[30, 32], + ) + coordinator = _make_coordinator({"abc123": circuit}) + coordinator.client.set_circuit_relay = AsyncMock( + side_effect=SpanPanelServerError("Circuit 'abc123' declares its relay non-commandable") + ) + + switch = SpanPanelCircuitsSwitch(coordinator, "abc123", "", "SPAN Panel") + switch.async_write_ha_state = MagicMock() + + with pytest.raises(HomeAssistantError) as raised: + await switch.async_turn_on() + + placeholders = raised.value.translation_placeholders + assert placeholders is not None + assert placeholders["circuit"] == "Circuit 30 32" + + def test_switch_state_reflects_relay_state() -> None: """Switch is_on mirrors the circuit relay state.""" closed = SpanCircuitSnapshotFactory.create( diff --git a/tests/test_dps_and_bess.py b/tests/test_dps_and_bess.py index d8a64bfa..0bb3f160 100644 --- a/tests/test_dps_and_bess.py +++ b/tests/test_dps_and_bess.py @@ -15,6 +15,7 @@ from custom_components.span_panel.helpers import has_bess from custom_components.span_panel.sensor_definitions import BESS_METADATA_SENSORS from custom_components.span_panel.sensor_panel import _grid_forming_device_name +from homeassistant.exceptions import HomeAssistantError from .factories import SpanPanelSnapshotFactory @@ -141,26 +142,21 @@ async def test_button_missing_control_method(self) -> None: @pytest.mark.asyncio async def test_button_server_error(self) -> None: - """SpanPanelServerError triggers a notification.""" + """SpanPanelServerError surfaces as a translated Home Assistant error.""" coordinator = _make_gfe_coordinator() - with patch( - "custom_components.span_panel.button.async_create_span_notification", - new_callable=AsyncMock, - ) as mock_notification: - button = SpanPanelGFEOverrideButton( - coordinator, GFE_OVERRIDE_DESCRIPTION, "GRID" - ) - button.hass = MagicMock() + button = SpanPanelGFEOverrideButton(coordinator, GFE_OVERRIDE_DESCRIPTION, "GRID") + button.hass = MagicMock() - coordinator.client = AsyncMock() - coordinator.client.set_dominant_power_source = AsyncMock( - side_effect=SpanPanelServerError("test error") - ) + coordinator.client = AsyncMock() + coordinator.client.set_dominant_power_source = AsyncMock( + side_effect=SpanPanelServerError("test error") + ) + with pytest.raises(HomeAssistantError) as raised: await button.async_press() - mock_notification.assert_called_once() + assert raised.value.translation_key == "gfe_override_failed" def test_available_when_bess_offline_and_not_grid(self) -> None: """Button is available when BESS is offline and the panel is not on grid.""" diff --git a/tests/test_select.py b/tests/test_select.py index be140106..5758e615 100644 --- a/tests/test_select.py +++ b/tests/test_select.py @@ -14,7 +14,7 @@ async_setup_entry, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceNotFound +from homeassistant.exceptions import HomeAssistantError, ServiceNotFound from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory @@ -95,30 +95,39 @@ async def test_async_select_option_service_not_found() -> None: @pytest.mark.asyncio -async def test_async_select_option_server_error() -> None: - """Test that SpanPanelServerError triggers a notification.""" +async def test_async_select_option_refusal_is_raised_at_the_caller() -> None: + """A refused priority reaches the person who chose it, carrying the reason. + + The panel refuses this for two different situations -- the circuit is + commissioned never-backup, or the panel could not carry the change out -- + and the exception distinguishes them only in its message. So the message is + passed through rather than diagnosed here, and the entity contributes the + name the user knows the circuit by. + """ coordinator = _make_coordinator_with_circuit() circuit = coordinator.data.circuits["id"] - with patch( - "custom_components.span_panel.select.async_create_span_notification", - new_callable=AsyncMock, - ) as mock_notification: - select = SpanPanelCircuitsSelect( - coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "name", "Test Device" - ) - select.coordinator = coordinator - select.hass = MagicMock() + select = SpanPanelCircuitsSelect( + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "name", "Test Device" + ) + select.coordinator = coordinator + select.hass = MagicMock() - coordinator.client = AsyncMock() - coordinator.client.set_circuit_priority = AsyncMock( - side_effect=SpanPanelServerError("test error") - ) + coordinator.client = AsyncMock() + coordinator.client.set_circuit_priority = AsyncMock( + side_effect=SpanPanelServerError("Circuit 'id' declares its shed priority not settable") + ) - select._get_circuit = MagicMock(return_value=circuit) + select._get_circuit = MagicMock(return_value=circuit) + with pytest.raises(HomeAssistantError) as raised: await select.async_select_option(CircuitPriority.SOC_THRESHOLD.value) - mock_notification.assert_called_once() + assert raised.value.translation_key == "circuit_priority_failed" + placeholders = raised.value.translation_placeholders + assert placeholders is not None + assert placeholders["circuit"] == "name" + assert placeholders["reason"] == "Circuit 'id' declares its shed priority not settable" + coordinator.async_request_refresh.assert_not_called() @pytest.mark.asyncio From 26ce558e1dcd81667cb9c511906dfd11a0e1270e Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:45:25 -0700 Subject: [PATCH 128/345] fix(control): raise a command that was never handed to the broker A FAILED outcome is the one state the library makes a promise about -- never handed to paho, and refused rather than queued precisely so nothing fires minutes later against a panel nobody is watching. That promise was written to the log and nowhere else on the relay switch, and the other three controls discarded the outcome entirely without ever checking it. All four now raise a translated error, worded apart from a refusal because it is a different fact: a refusal never resolved an address, this resolved one and never handed it over. The library's own detail is carried through as the reason, the way a refusal's message already is. ACCEPTED and UNCONFIRMED stay non-failures and still take the optimistic write, which is now pinned for both rather than for UNCONFIRMED alone, and the switch's write stays last so neither raise can leave it showing a position the relay never took. Also documents why settability is read at setup and deliberately not re-read: permanent entity IDs are not the obstacle, since a reload restores them from the registry -- the obstacle is a $settable the publisher warns can be stale. --- CHANGELOG.md | 3 ++ README.md | 1 + custom_components/span_panel/button.py | 20 ++++++++-- custom_components/span_panel/control_gate.py | 12 ++++++ custom_components/span_panel/number.py | 20 +++++++++- custom_components/span_panel/select.py | 26 +++++++++++-- custom_components/span_panel/strings.json | 12 ++++++ custom_components/span_panel/switch.py | 39 +++++++++++++------ .../span_panel/translations/en.json | 12 ++++++ .../span_panel/translations/es.json | 12 ++++++ .../span_panel/translations/fr.json | 12 ++++++ .../span_panel/translations/ja.json | 12 ++++++ .../span_panel/translations/pt.json | 12 ++++++ developer.md | 30 ++++++++++++++ tests/test_button.py | 31 +++++++++++++++ tests/test_control_gate.py | 35 +++++++++++++---- tests/test_evse_charge_limit.py | 28 +++++++++++++ tests/test_select.py | 31 +++++++++++++++ 18 files changed, 320 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14503f88..a2d0059c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -134,6 +134,9 @@ working entirely in 2027.8. - **A breaker your panel refuses to operate now says so.** The switch had no handling for a refused relay command at all, so the failure surfaced as a traceback in the log and a toggle that quietly sprang back. It now reports the panel's own reason where you pressed it, and the switch stays where the relay actually is. +- **A control command that never reached your panel now says so.** A relay, priority, GFE override or charge-limit command that the transport could not hand + over — the broker was disconnected, so it was refused rather than queued — was written to the log and nowhere else. It is reported where you issued it, and + says plainly that nothing was queued and nothing will happen later. - **A refused circuit priority no longer blames your firmware.** The old message said the panel's firmware did not support the operation, which stopped being the only explanation once your panel could also declare an individual circuit's priority fixed. The priority select and the GFE override button now carry the panel's reason instead of guessing at one, and report it as an error on the control you used rather than as a notification that sits in the sidebar until you diff --git a/README.md b/README.md index 28d7a230..297ea654 100644 --- a/README.md +++ b/README.md @@ -792,6 +792,7 @@ See [WebSocket API Reference](websocket-api.md) for the full schema, response fo | **Reinstalling to change the entity ID style gives back the old entity IDs** | The naming style is chosen at install and cannot be changed from the options, so reinstalling looks like the way to switch. It is not: every entity returns with the entity ID it had before. | Home Assistant remembers a removed entity for **30 days**, keyed on its unique ID, and restores that record's entity ID — along with its name, area, labels and icon — as soon as an entity with the same unique ID appears again. This integration's unique IDs do not change with the naming style, so the remembered ID wins over the one the new style asks for. Either clear the leftover registry entries between removing and reinstalling, or wait out the 30 days and let Home Assistant discard them. A tool such as [ha-registry-clean](https://github.com/LegoTypes/ha-registry-clean) can do the clearing; it is a separate project, not part of this integration. Clearing also discards the names, areas and labels you had assigned. | | **Setup fails after downgrading the integration** | After installing an older release, the SPAN Panel config entry fails to set up and Home Assistant reports an unsupported configuration version. | The release that stopped storing the panel passphrase migrated the config entry to version 7. Home Assistant refuses to load a config entry whose version is newer than the installed integration understands, and there is no automatic downgrade. Reinstall the newer release, or restore a backup taken before the upgrade. Removing and re-adding the integration also works and preserves entity IDs, but needs the panel passphrase or physical access to the door again. | | **"SPAN Panel certificate authority changed" repair** | The integration has stopped connecting and a repair reports two fingerprints: the one it pinned and the one the panel now advertises. Entities are unavailable. | The panel is presenting a different certificate authority than the one you accepted at setup. Two things look identical from here and only you can tell them apart: a firmware upgrade or a factory reset rotates the authority legitimately, and so does a device on your network standing in for your panel. **If you know why it changed**, open the repair, compare the new fingerprint, and accept it — that re-pins and reconnects. **If nothing should have changed**, do not accept. Check what else is on the panel's network segment first. The integration will not reconnect on its own and will not re-pin on its own, deliberately: retrying would mean waiting to succeed against whatever is answering. | +| **A circuit re-commissioned in the SPAN App has the wrong controls** | A circuit whose configuration changed in the SPAN App — made controllable, locked, or set to never back up — still has the controls it had before. A newly controllable circuit has no Breaker switch. A newly locked one still shows its switch, and operating it reports that the command was refused. | Reload the integration (**Settings → Devices & Services → Span Panel → ⋮ → Reload**). Which control entities exist is decided when the integration starts, from what the panel declares about each circuit at that moment; a change made afterwards is picked up at the next reload or restart. Reloading is safe — entity IDs, history, names and areas are preserved, because they are keyed on identifiers that do not change. Until you reload, a control the panel no longer accepts is refused rather than published: the command is not queued and the breaker does not move. | | **A control reports `unconfirmed`** | The logbook or the `span_panel_control_command` event says a command was `unconfirmed`. Nothing appears broken. | **This is not an error.** It means the panel took the command and did not report a change within the deadline, and the most common reason by far is that there was no change to report — the relay was already open, the priority was already that value. It is also indistinguishable from a silent rejection by the panel, because SPAN's firmware sends no reason code; the integration reports what it observed rather than guessing. The two outcomes that do mean something went wrong are `failed`, which means the command was never sent, and `refused:…`, which means this integration refused it and names why. | ## Development diff --git a/custom_components/span_panel/button.py b/custom_components/span_panel/button.py index 7ffe47fa..f0ccc154 100644 --- a/custom_components/span_panel/button.py +++ b/custom_components/span_panel/button.py @@ -12,7 +12,7 @@ from . import SpanPanelConfigEntry from .const import CONF_DEVICE_NAME, DOMAIN -from .control_gate import ControlMode +from .control_gate import ControlMode, outcome_failure_reason, outcome_is_failure from .coordinator import SpanPanelCoordinator from .entity import SpanPanelEntity from .helpers import construct_panel_unique_id_for_entry, has_bess @@ -66,7 +66,9 @@ async def async_press(self) -> None: A refusal is raised at the caller rather than filed as a persistent notification, the same way the circuit controls report one: nothing was published, so there is nothing to correct later, and the person who - pressed the button is the one who needs to hear about it. + pressed the button is the one who needs to hear about it. A `FAILED` + outcome -- handed to nobody, and promised not to arrive later -- is + raised on the same grounds and worded as the different fact it is. """ client = self.coordinator.client if not hasattr(client, "set_dominant_power_source"): @@ -74,7 +76,7 @@ async def async_press(self) -> None: return try: - await self._async_guarded_control( + outcome = await self._async_guarded_control( client.set_dominant_power_source(self._override_value) ) except SpanPanelServerError as err: @@ -89,6 +91,18 @@ async def async_press(self) -> None: "reason": str(err), }, ) from err + + if outcome_is_failure(outcome): + reason = outcome_failure_reason(outcome) + _LOGGER.warning( + "GFE override to %s was not delivered: %s", self._override_value, reason + ) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="gfe_override_not_delivered", + translation_placeholders={"value": self._override_value, "reason": reason}, + ) + await self.coordinator.async_request_refresh() @property diff --git a/custom_components/span_panel/control_gate.py b/custom_components/span_panel/control_gate.py index 22f8b4e5..654f9de0 100644 --- a/custom_components/span_panel/control_gate.py +++ b/custom_components/span_panel/control_gate.py @@ -379,6 +379,18 @@ def async_bind_caller( return CONTROL_CALLER.set(ControlCaller(context=context, entity_id=entity_id)) +def outcome_failure_reason(outcome: PublishOutcome) -> str: + """Return the library's own account of why a command was not delivered. + + `detail` is the library's own words for why -- "broker not connected; + refused rather than queued" -- and is documented as free text for a human + reading a log or an audit row. So it is displayed and never parsed, the same + way a refusal's message is. The state is the fallback for an outcome + carrying no detail, which is worth saying poorly rather than not at all. + """ + return outcome.detail or str(outcome.state) + + def outcome_is_failure(outcome: PublishOutcome) -> bool: """Whether this outcome means the command will never be delivered. diff --git a/custom_components/span_panel/number.py b/custom_components/span_panel/number.py index 7e2385c7..f8f68115 100644 --- a/custom_components/span_panel/number.py +++ b/custom_components/span_panel/number.py @@ -55,7 +55,7 @@ from . import SpanPanelConfigEntry from .adoption import AdoptedNumber, create_adopted_numbers from .const import CONF_DEVICE_NAME, DOMAIN, USE_CIRCUIT_NUMBERS -from .control_gate import ControlMode +from .control_gate import ControlMode, outcome_failure_reason, outcome_is_failure from .coordinator import SpanPanelCoordinator from .entity import SpanPanelEntity from .field_paths import DerivedReason, FieldPathDeclarationMixin @@ -253,6 +253,10 @@ async def async_set_native_value(self, value: float) -> None: ceiling the safe direction is down: asking for 16.7 A and getting 16 is a slower charge, asking for it and getting 17 is a current the user did not request. + + Both ways the write does not happen reach the caller. A refusal never + resolved an address; a `FAILED` outcome resolved one and was never + handed over, which the library promises means it will not arrive later. """ client = self.coordinator.client if not isinstance(client, EvseControlProtocol): @@ -262,7 +266,7 @@ async def async_set_native_value(self, value: float) -> None: translation_placeholders={"charger": self._evse_id}, ) try: - await self._async_guarded_control( + outcome = await self._async_guarded_control( self._description.set_fn(client, self._evse_id, int(value)) ) except SpanPanelServerError as err: @@ -272,6 +276,18 @@ async def async_set_native_value(self, value: float) -> None: translation_key="evse_charge_limit_failed", translation_placeholders={"charger": self._evse_id, "reason": str(err)}, ) from err + + if outcome_is_failure(outcome): + reason = outcome_failure_reason(outcome) + _LOGGER.warning( + "Charge-current limit for %s was not delivered: %s", self._evse_id, reason + ) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="evse_charge_limit_not_delivered", + translation_placeholders={"charger": self._evse_id, "reason": reason}, + ) + await self.coordinator.async_request_refresh() def _handle_coordinator_update(self) -> None: diff --git a/custom_components/span_panel/select.py b/custom_components/span_panel/select.py index c56c851d..cd0c53cb 100644 --- a/custom_components/span_panel/select.py +++ b/custom_components/span_panel/select.py @@ -16,7 +16,7 @@ from . import SpanPanelConfigEntry from .adoption import AdoptedSelect, create_adopted_selects from .const import DOMAIN, USE_CIRCUIT_NUMBERS, CircuitPriority -from .control_gate import ControlMode +from .control_gate import ControlMode, outcome_failure_reason, outcome_is_failure from .coordinator import SpanPanelCoordinator from .entity import SpanPanelEntity from .helpers import ( @@ -213,6 +213,10 @@ async def async_select_option(self, option: str) -> None: later and nobody to tell but the person who just made the choice -- and a notification keyed on the circuit would outlive the failure and sit in the sidebar until someone dismissed it by hand. + + A `FAILED` outcome is raised too, and separately worded: that command + resolved an address and was never handed to the broker, which the + library promises means it will not arrive later either. """ _LOGGER.debug("Selecting option: %s", option) client = self.coordinator.client @@ -223,8 +227,9 @@ async def async_select_option(self, option: str) -> None: priority = CircuitPriority(option) try: - await self._async_guarded_control(client.set_circuit_priority(self.id, priority.name)) - await self.coordinator.async_request_refresh() + outcome = await self._async_guarded_control( + client.set_circuit_priority(self.id, priority.name) + ) except ServiceNotFound as snf: _LOGGER.warning( "Service not found when setting priority: %s.%s", @@ -237,6 +242,7 @@ async def async_select_option(self, option: str) -> None: title="Service Not Found", notification_id=f"span_panel_service_not_found_{self.id}", ) + return except SpanPanelServerError as err: _LOGGER.warning( "SPAN panel did not accept a priority change for %s: %s", self.entity_id, err @@ -250,6 +256,20 @@ async def async_select_option(self, option: str) -> None: }, ) from err + if outcome_is_failure(outcome): + reason = outcome_failure_reason(outcome) + _LOGGER.warning("Priority change for %s was not delivered: %s", self.entity_id, reason) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="circuit_priority_not_delivered", + translation_placeholders={ + "circuit": construct_circuit_label(self._get_circuit(), self.id), + "reason": reason, + }, + ) + + await self.coordinator.async_request_refresh() + def select_option(self, option: str) -> None: """Select an option synchronously.""" _LOGGER.debug("Selecting option synchronously: %s", option) diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index 17ce7f9e..f44ed986 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -431,9 +431,15 @@ "circuit_priority_failed": { "message": "The priority change for {circuit} was refused: {reason}. Nothing was sent to the panel, so the priority is unchanged." }, + "circuit_priority_not_delivered": { + "message": "The priority change for {circuit} could not be delivered to the panel: {reason}. It was refused rather than queued, so the priority is unchanged." + }, "circuit_relay_failed": { "message": "The breaker command for {circuit} was refused: {reason}. Nothing was sent to the panel, so the breaker has not moved." }, + "circuit_relay_not_delivered": { + "message": "The breaker command for {circuit} could not be delivered to the panel: {reason}. It was refused rather than queued, so the breaker has not moved and will not move later." + }, "contextless_control_refused": { "message": "This SPAN panel refuses control commands that do not come from a logged-in user." }, @@ -449,6 +455,9 @@ "evse_charge_limit_failed": { "message": "Failed to set the charge-current limit for charger {charger}: {reason}" }, + "evse_charge_limit_not_delivered": { + "message": "The charge-current limit for charger {charger} could not be delivered to the panel: {reason}. It was refused rather than queued, so the limit is unchanged." + }, "evse_charge_limit_unsupported": { "message": "This SPAN panel offers no charge-current control for charger {charger}." }, @@ -473,6 +482,9 @@ "gfe_override_failed": { "message": "The GFE override to {value} was refused: {reason}. Nothing was sent to the panel." }, + "gfe_override_not_delivered": { + "message": "The GFE override to {value} could not be delivered to the panel: {reason}. It was refused rather than queued, so nothing will happen later." + }, "graph_horizon_not_available": { "message": "Graph horizon manager is not available for the selected SPAN panel." }, diff --git a/custom_components/span_panel/switch.py b/custom_components/span_panel/switch.py index c6127f35..6098eb13 100644 --- a/custom_components/span_panel/switch.py +++ b/custom_components/span_panel/switch.py @@ -16,7 +16,13 @@ from . import SpanPanelConfigEntry from .adoption import AdoptedSwitch, create_adopted_switches from .const import DOMAIN, USE_CIRCUIT_NUMBERS, CircuitRelayState -from .control_gate import ControlLock, ControlMode, ControlPolicy, outcome_is_failure +from .control_gate import ( + ControlLock, + ControlMode, + ControlPolicy, + outcome_failure_reason, + outcome_is_failure, +) from .coordinator import SpanPanelCoordinator from .entity import SpanPanelEntity from .helpers import ( @@ -287,12 +293,14 @@ async def _async_set_relay(self, state: str, *, is_on: bool) -> None: relay was already in the requested position. Neither is a failure and neither should discard the requested state. - A refusal is the third thing that can happen, and it is raised rather - than logged. The library refuses a relay command the panel declares - non-commandable before anything reaches the broker, so there is no - publish to report on and no coordinator update coming; swallowing it - would leave the person who pressed the switch with a control that - silently does nothing. + The two ways a command does not happen are reported the same way and + worded differently, because they are different facts. A refusal never + resolved an address -- the panel declares this relay non-commandable. A + `FAILED` outcome resolved one and never handed it over, because the + transport was closed or the broker was disconnected; the library + refuses such a write rather than letting paho queue it, so it is a + promise that nothing fires later against a panel nobody is watching. + Both raise: a control that silently does nothing is the defect. """ client = self.coordinator.client if not hasattr(client, "set_circuit_relay"): @@ -319,12 +327,19 @@ async def _async_set_relay(self, state: str, *, is_on: bool) -> None: }, ) from err if outcome_is_failure(outcome): - _LOGGER.warning( - "Relay command for %s was not delivered: %s", - self.entity_id, - outcome.detail or outcome.state, + reason = outcome_failure_reason(outcome) + _LOGGER.warning("Relay command for %s was not delivered: %s", self.entity_id, reason) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="circuit_relay_not_delivered", + translation_placeholders={ + "circuit": construct_circuit_label( + self.coordinator.data.circuits.get(self._circuit_id), + self._circuit_id, + ), + "reason": reason, + }, ) - return self._attr_is_on = is_on self.async_write_ha_state() diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index 17ce7f9e..f44ed986 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -431,9 +431,15 @@ "circuit_priority_failed": { "message": "The priority change for {circuit} was refused: {reason}. Nothing was sent to the panel, so the priority is unchanged." }, + "circuit_priority_not_delivered": { + "message": "The priority change for {circuit} could not be delivered to the panel: {reason}. It was refused rather than queued, so the priority is unchanged." + }, "circuit_relay_failed": { "message": "The breaker command for {circuit} was refused: {reason}. Nothing was sent to the panel, so the breaker has not moved." }, + "circuit_relay_not_delivered": { + "message": "The breaker command for {circuit} could not be delivered to the panel: {reason}. It was refused rather than queued, so the breaker has not moved and will not move later." + }, "contextless_control_refused": { "message": "This SPAN panel refuses control commands that do not come from a logged-in user." }, @@ -449,6 +455,9 @@ "evse_charge_limit_failed": { "message": "Failed to set the charge-current limit for charger {charger}: {reason}" }, + "evse_charge_limit_not_delivered": { + "message": "The charge-current limit for charger {charger} could not be delivered to the panel: {reason}. It was refused rather than queued, so the limit is unchanged." + }, "evse_charge_limit_unsupported": { "message": "This SPAN panel offers no charge-current control for charger {charger}." }, @@ -473,6 +482,9 @@ "gfe_override_failed": { "message": "The GFE override to {value} was refused: {reason}. Nothing was sent to the panel." }, + "gfe_override_not_delivered": { + "message": "The GFE override to {value} could not be delivered to the panel: {reason}. It was refused rather than queued, so nothing will happen later." + }, "graph_horizon_not_available": { "message": "Graph horizon manager is not available for the selected SPAN panel." }, diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index aad2c239..bb6626e5 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -431,9 +431,15 @@ "circuit_priority_failed": { "message": "Se rechazó el cambio de prioridad para {circuit}: {reason}. No se envió nada al panel, por lo que la prioridad no ha cambiado." }, + "circuit_priority_not_delivered": { + "message": "El cambio de prioridad para {circuit} no se pudo entregar al panel: {reason}. Se rechazó en lugar de ponerse en cola, por lo que la prioridad no ha cambiado." + }, "circuit_relay_failed": { "message": "Se rechazó el comando del interruptor para {circuit}: {reason}. No se envió nada al panel, por lo que el interruptor no ha cambiado de posición." }, + "circuit_relay_not_delivered": { + "message": "El comando del interruptor para {circuit} no se pudo entregar al panel: {reason}. Se rechazó en lugar de ponerse en cola, por lo que el interruptor no ha cambiado de posición ni lo hará más tarde." + }, "contextless_control_refused": { "message": "Este SPAN Panel rechaza las órdenes de control que no proceden de un usuario con sesión iniciada." }, @@ -449,6 +455,9 @@ "evse_charge_limit_failed": { "message": "No se pudo establecer el límite de corriente de carga para el cargador {charger}: {reason}" }, + "evse_charge_limit_not_delivered": { + "message": "El límite de corriente de carga del cargador {charger} no se pudo entregar al panel: {reason}. Se rechazó en lugar de ponerse en cola, por lo que el límite no ha cambiado." + }, "evse_charge_limit_unsupported": { "message": "Este panel SPAN no ofrece control de corriente de carga para el cargador {charger}." }, @@ -473,6 +482,9 @@ "gfe_override_failed": { "message": "Se rechazó la anulación GFE a {value}: {reason}. No se envió nada al panel." }, + "gfe_override_not_delivered": { + "message": "La anulación GFE a {value} no se pudo entregar al panel: {reason}. Se rechazó en lugar de ponerse en cola, por lo que no ocurrirá nada más tarde." + }, "graph_horizon_not_available": { "message": "El gestor del horizonte del gráfico no está disponible para el SPAN Panel seleccionado." }, diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index d183b150..55c3290c 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -431,9 +431,15 @@ "circuit_priority_failed": { "message": "Le changement de priorité pour {circuit} a été refusé : {reason}. Rien n'a été envoyé au panneau, la priorité est donc inchangée." }, + "circuit_priority_not_delivered": { + "message": "Le changement de priorité pour {circuit} n'a pas pu être transmis au panneau : {reason}. Il a été refusé plutôt que mis en file d'attente, la priorité est donc inchangée." + }, "circuit_relay_failed": { "message": "La commande du disjoncteur pour {circuit} a été refusée : {reason}. Rien n'a été envoyé au panneau, le disjoncteur n'a donc pas changé de position." }, + "circuit_relay_not_delivered": { + "message": "La commande du disjoncteur pour {circuit} n'a pas pu être transmise au panneau : {reason}. Elle a été refusée plutôt que mise en file d'attente, le disjoncteur n'a donc pas changé de position et ne changera pas plus tard." + }, "contextless_control_refused": { "message": "Ce SPAN Panel refuse les commandes de contrôle qui ne proviennent pas d'un utilisateur connecté." }, @@ -449,6 +455,9 @@ "evse_charge_limit_failed": { "message": "Échec du réglage de la limite de courant de charge pour le chargeur {charger} : {reason}" }, + "evse_charge_limit_not_delivered": { + "message": "La limite de courant de charge du chargeur {charger} n'a pas pu être transmise au panneau : {reason}. Elle a été refusée plutôt que mise en file d'attente, la limite est donc inchangée." + }, "evse_charge_limit_unsupported": { "message": "Ce panneau SPAN n'offre aucun contrôle du courant de charge pour le chargeur {charger}." }, @@ -473,6 +482,9 @@ "gfe_override_failed": { "message": "Le forçage GFE vers {value} a été refusé : {reason}. Rien n'a été envoyé au panneau." }, + "gfe_override_not_delivered": { + "message": "Le forçage GFE vers {value} n'a pas pu être transmis au panneau : {reason}. Il a été refusé plutôt que mis en file d'attente, rien ne se produira donc plus tard." + }, "graph_horizon_not_available": { "message": "Le gestionnaire d'horizon de graphique n'est pas disponible pour le panneau SPAN sélectionné." }, diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index 8a9a1a07..8e222003 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -431,9 +431,15 @@ "circuit_priority_failed": { "message": "{circuit}の優先度変更は拒否されました: {reason}。パネルには何も送信されていないため、優先度は変わっていません。" }, + "circuit_priority_not_delivered": { + "message": "{circuit}の優先度変更をパネルに送信できませんでした: {reason}。キューに入れずに拒否したため、優先度は変わっていません。" + }, "circuit_relay_failed": { "message": "{circuit}のブレーカー操作は拒否されました: {reason}。パネルには何も送信されていないため、ブレーカーの状態は変わっていません。" }, + "circuit_relay_not_delivered": { + "message": "{circuit}のブレーカー操作をパネルに送信できませんでした: {reason}。キューに入れずに拒否したため、ブレーカーの状態は変わっておらず、後から変わることもありません。" + }, "contextless_control_refused": { "message": "この SPAN Panel は、ログインユーザーからではない操作コマンドを拒否します。" }, @@ -449,6 +455,9 @@ "evse_charge_limit_failed": { "message": "充電器 {charger} の充電電流上限を設定できませんでした: {reason}" }, + "evse_charge_limit_not_delivered": { + "message": "充電器 {charger} の充電電流上限をパネルに送信できませんでした: {reason}。キューに入れずに拒否したため、上限は変わっていません。" + }, "evse_charge_limit_unsupported": { "message": "このSPANパネルは充電器 {charger} の充電電流制御に対応していません。" }, @@ -473,6 +482,9 @@ "gfe_override_failed": { "message": "{value}へのGFEオーバーライドは拒否されました: {reason}。パネルには何も送信されていません。" }, + "gfe_override_not_delivered": { + "message": "{value}へのGFEオーバーライドをパネルに送信できませんでした: {reason}。キューに入れずに拒否したため、後から実行されることもありません。" + }, "graph_horizon_not_available": { "message": "選択されたSPANパネルではグラフホライズンマネージャーが利用できません。" }, diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index 5174da09..3c47573f 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -431,9 +431,15 @@ "circuit_priority_failed": { "message": "A alteração de prioridade para {circuit} foi recusada: {reason}. Nada foi enviado ao painel, portanto a prioridade permanece inalterada." }, + "circuit_priority_not_delivered": { + "message": "A alteração de prioridade para {circuit} não pôde ser entregue ao painel: {reason}. Foi recusada em vez de enfileirada, portanto a prioridade permanece inalterada." + }, "circuit_relay_failed": { "message": "O comando do disjuntor para {circuit} foi recusado: {reason}. Nada foi enviado ao painel, portanto o disjuntor não mudou de posição." }, + "circuit_relay_not_delivered": { + "message": "O comando do disjuntor para {circuit} não pôde ser entregue ao painel: {reason}. Foi recusado em vez de enfileirado, portanto o disjuntor não mudou de posição nem mudará mais tarde." + }, "contextless_control_refused": { "message": "Este SPAN Panel recusa comandos de controlo que não venham de um utilizador com sessão iniciada." }, @@ -449,6 +455,9 @@ "evse_charge_limit_failed": { "message": "Falha ao definir o limite de corrente de carga do carregador {charger}: {reason}" }, + "evse_charge_limit_not_delivered": { + "message": "O limite de corrente de carga do carregador {charger} não pôde ser entregue ao painel: {reason}. Foi recusado em vez de enfileirado, portanto o limite permanece inalterado." + }, "evse_charge_limit_unsupported": { "message": "Este painel SPAN não oferece controlo da corrente de carga para o carregador {charger}." }, @@ -473,6 +482,9 @@ "gfe_override_failed": { "message": "A substituição GFE para {value} foi recusada: {reason}. Nada foi enviado ao painel." }, + "gfe_override_not_delivered": { + "message": "A substituição GFE para {value} não pôde ser entregue ao painel: {reason}. Foi recusada em vez de enfileirada, portanto nada acontecerá mais tarde." + }, "graph_horizon_not_available": { "message": "O gerenciador de horizonte do gráfico não está disponível para o painel SPAN selecionado." }, diff --git a/developer.md b/developer.md index 57a134d0..37891e14 100644 --- a/developer.md +++ b/developer.md @@ -274,6 +274,36 @@ This closes the question of whether to go verbatim everywhere. The migration mec `entity_id`s — `sensor.span_panel_kitchen_instantPowerW`, breaking every user reference — or a decoupling of the two, which throws away the consistency the helper exists to provide. Closing the mapping gets the whole benefit for none of that. +## Settability is read at setup, and deliberately not re-read + +Which curated control entities exist is decided once, in each platform's `async_setup_entry`, from what the panel declares about each circuit at that moment. +`switch` gates on `is_user_controllable`; `select` gates on that and on `is_never_backup`, which is a separate commissioning flag — a never-backup circuit has a +working relay and a priority the panel pins. + +SPAN documents that re-commissioning a circuit in place cycles that child device's `$state` and republishes its `$description` with a new `$settable`. So a +circuit's settability can change while the integration is running, in either direction, and the setup-time gate cannot see it: + +- **A circuit that stops being controllable keeps its entity.** The entity outlives its own controllability and every write it attempts now fails. +- **A circuit that becomes controllable gets no entity.** Nothing is watching it, because no entity exists to watch, so this direction cannot be noticed from an + entity at all. + +Both are safe, and the first is now well behaved. `span-panel-api` refuses a circuit relay or priority command for a circuit the panel declares non-settable, +raising rather than publishing, and each control reports that refusal as a translated error on the control the user touched. Nothing is silently published and +nothing is silently swallowed. The remedy for both directions is a reload. + +**Nothing re-evaluates settability at runtime, and the reason is not the one to expect.** Permanent entity IDs are not the obstacle: the entity registry maps +`unique_id` to `entity_id`, so a reload gives every entity back the ID it had, both platforms already leave the registry entries of controls they do not create +in place, and `coordinator.request_reload()` is already used when a circuit is renamed. The mechanism is available and precedented. + +The obstacle is the signal. SPAN reports a firmware defect in which the `$settable` re-toggle on the runtime re-commissioning path is skipped until the service +restarts — which is why the library refuses when _either_ the declaration or the `relay-controllable` value says no. Driving an entry-wide reload off a flag the +publisher warns can be stale would tear down every entity in the entry, and with them the grace-period, energy-dip and current-monitor state, on a value that +may not be true yet. An entity that exists and cleanly explains why it cannot act is a better answer than one that vanishes and reappears. + +**If this is ever built, build it as a Repair rather than as a reload.** `schema_repairs` is the module for something re-derived from live state on every +refresh: raise a Repair naming the circuit that changed, touch no entity, and let the user choose when to reload. Waiting is cheap, and worth doing until a +panel is actually observed changing a circuit's settability in the field — so far that behaviour is documented by SPAN rather than seen here. + ## Adopting a device this integration models nothing for The section above is about properties on devices we already read. This one is about a device type nobody modelled at all — a vendor's generator, heat pump or diff --git a/tests/test_button.py b/tests/test_button.py index 1a824d95..855a4101 100644 --- a/tests/test_button.py +++ b/tests/test_button.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from span_panel_api import PublishOutcome, PublishState from span_panel_api.exceptions import SpanPanelServerError from custom_components.span_panel.button import ( @@ -103,6 +104,36 @@ async def test_gfe_override_button_refusal_is_raised_at_the_caller() -> None: coordinator.async_request_refresh.assert_not_awaited() +@pytest.mark.asyncio +async def test_gfe_override_button_undelivered_is_raised_at_the_caller() -> None: + """A `FAILED` outcome is the promise this override will not arrive later.""" + snapshot = SpanPanelSnapshotFactory.create( + battery=SpanBatterySnapshotFactory.create(connected=False), + dominant_power_source="BATTERY", + ) + coordinator = _make_button_coordinator(snapshot) + coordinator.client = MagicMock() + coordinator.client.set_dominant_power_source = AsyncMock( + return_value=PublishOutcome( + state=PublishState.FAILED, + topic=None, + value="GRID", + detail="broker not connected; refused rather than queued", + ) + ) + button = SpanPanelGFEOverrideButton(coordinator, GFE_OVERRIDE_DESCRIPTION, "GRID") + button.hass = MagicMock() + + with pytest.raises(HomeAssistantError) as raised: + await button.async_press() + + assert raised.value.translation_key == "gfe_override_not_delivered" + placeholders = raised.value.translation_placeholders + assert placeholders is not None + assert placeholders["reason"] == "broker not connected; refused rather than queued" + coordinator.async_request_refresh.assert_not_awaited() + + def test_gfe_override_button_available_only_when_override_is_relevant() -> None: """Availability should reflect panel state and whether firmware already has control. diff --git a/tests/test_control_gate.py b/tests/test_control_gate.py index c225feb8..a556503b 100644 --- a/tests/test_control_gate.py +++ b/tests/test_control_gate.py @@ -24,7 +24,7 @@ outcome_is_failure, ) from homeassistant.core import Context, Event, HomeAssistant -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from pytest_homeassistant_custom_component.common import MockConfigEntry, MockUser @@ -623,7 +623,13 @@ async def test_a_refused_relay_leaves_the_switch_where_it_was( async def test_an_undelivered_relay_command_does_not_move_the_switch( hass: HomeAssistant, ) -> None: - """`FAILED` is the one outcome that promises the command will never arrive.""" + """`FAILED` is the one outcome that promises the command will never arrive. + + Raised, not logged. It means the same thing to the person who pressed the + switch as a refusal does -- the command did not happen and will not happen + later -- and it is worded as the different fact it is: this one resolved an + address and was never handed to the broker. + """ from custom_components.span_panel.switch import SpanPanelCircuitsSwitch from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory @@ -652,17 +658,30 @@ async def test_an_undelivered_relay_command_does_not_move_the_switch( entity.async_write_ha_state = MagicMock() before = entity.is_on - await entity.async_turn_on() + with pytest.raises(HomeAssistantError) as raised: + await entity.async_turn_on() + assert raised.value.translation_key == "circuit_relay_not_delivered" + placeholders = raised.value.translation_placeholders + assert placeholders is not None + assert placeholders["reason"] == "broker not connected; refused rather than queued" + # The optimistic write is last, so the raise cannot have reached it. assert entity.is_on == before entity.async_write_ha_state.assert_not_called() @pytest.mark.asyncio -async def test_an_unconfirmed_relay_command_still_shows_the_requested_state( - hass: HomeAssistant, +@pytest.mark.parametrize("state", [PublishState.ACCEPTED, PublishState.UNCONFIRMED]) +async def test_a_handed_over_relay_command_still_shows_the_requested_state( + hass: HomeAssistant, state: PublishState ) -> None: - """`UNCONFIRMED` most often means the relay was already in that position.""" + """Neither is a failure, and neither may discard what the user asked for. + + `UNCONFIRMED` most often means the relay was already in that position, and + `ACCEPTED` means the broker took the message. Both were handed over and may + already have been acted on, so both keep the optimistic write that `FAILED` + and a refusal are denied. + """ from custom_components.span_panel.switch import SpanPanelCircuitsSwitch from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory @@ -679,10 +698,10 @@ async def test_an_unconfirmed_relay_command_still_shows_the_requested_state( coordinator.client = MagicMock() coordinator.client.set_circuit_relay = AsyncMock( return_value=PublishOutcome( - state=PublishState.UNCONFIRMED, + state=state, topic=RELAY.topic, value="CLOSED", - no_op=True, + no_op=state is PublishState.UNCONFIRMED, detail="the property already reports this value", ) ) diff --git a/tests/test_evse_charge_limit.py b/tests/test_evse_charge_limit.py index 6046f396..8bb65d1d 100644 --- a/tests/test_evse_charge_limit.py +++ b/tests/test_evse_charge_limit.py @@ -577,6 +577,34 @@ async def test_a_value_above_the_ceiling_is_refused_before_it_reaches_the_wire( bridge.publish.assert_not_called() +async def test_a_command_the_transport_never_handed_over_is_reported( + hass: HomeAssistant, +) -> None: + """`FAILED`, produced by the real transport declining while disconnected. + + The bridge refuses rather than letting paho queue the publish across a + reconnect, which is what makes `FAILED` a promise: nothing fires later + against a panel nobody is watching. A promise that specific is worth telling + the user, and it is a different fact from a refusal -- this one resolved an + address and never handed it over. + """ + tree = schema_one_tree() + client, bridge = _live_client() + bridge.publish.return_value = None + created = await _created(hass, schema_one_snapshot(tree), client) + entity = _for(created, tree, EVSE) + asked = _published(tree, EVSE, CEILING_TOPIC) - 8 + + with pytest.raises(HomeAssistantError) as raised: + await entity.async_set_native_value(float(asked)) + + assert raised.value.translation_key == "evse_charge_limit_not_delivered" + placeholders = raised.value.translation_placeholders + assert placeholders is not None + assert placeholders["reason"] == "broker not connected; refused rather than queued" + entity.coordinator.async_request_refresh.assert_not_awaited() + + async def test_the_range_home_assistant_checks_is_the_commissioned_one(hass: HomeAssistant) -> None: """The first of the two checks, asserted through the range the entity reports. diff --git a/tests/test_select.py b/tests/test_select.py index 5758e615..d4153c74 100644 --- a/tests/test_select.py +++ b/tests/test_select.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch import pytest +from span_panel_api import PublishOutcome, PublishState from span_panel_api.exceptions import SpanPanelServerError from custom_components.span_panel.const import CircuitPriority @@ -130,6 +131,36 @@ async def test_async_select_option_refusal_is_raised_at_the_caller() -> None: coordinator.async_request_refresh.assert_not_called() +@pytest.mark.asyncio +async def test_async_select_option_undelivered_is_raised_at_the_caller() -> None: + """A `FAILED` outcome is the promise this change will not arrive later.""" + coordinator = _make_coordinator_with_circuit() + circuit = coordinator.data.circuits["id"] + + select = SpanPanelCircuitsSelect( + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "name", "Test Device" + ) + select.coordinator = coordinator + select.hass = MagicMock() + + coordinator.client = AsyncMock() + coordinator.client.set_circuit_priority = AsyncMock( + return_value=PublishOutcome( + state=PublishState.FAILED, topic=None, value="NEVER", detail="transport is closed" + ) + ) + + select._get_circuit = MagicMock(return_value=circuit) + with pytest.raises(HomeAssistantError) as raised: + await select.async_select_option(CircuitPriority.SOC_THRESHOLD.value) + + assert raised.value.translation_key == "circuit_priority_not_delivered" + placeholders = raised.value.translation_placeholders + assert placeholders is not None + assert placeholders["reason"] == "transport is closed" + coordinator.async_request_refresh.assert_not_called() + + @pytest.mark.asyncio async def test_async_select_option_success_refreshes_coordinator() -> None: """Successful priority changes should refresh coordinator data.""" From 05468774ee2bf4245b0e34df839bf43e0124919a Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:26:39 -0700 Subject: [PATCH 129/345] test(fixtures): vendor the schema-1 capture, with a version guard The parent/child capture is test data. Reading it by import made this suite depend on a runtime wheel continuing to ship files nothing at runtime reads, and put 56 KB of them on every user's disk. The objection to a copy was that it goes stale in silence. That needs the version claim, not the file: `schema_one_tree.source` records the release the copy came from, and `importlib.metadata` reports the release installed. When the pin moves and nobody refreshes the capture, `test_fixture_provenance.py` fails and names the refresh -- no checkout, no network call, no second copy to compare against. The recorded version sits beside the payload rather than inside it because a refresh is a byte-for-byte copy: a key added to the JSON would be overwritten by the next one. It is written as a pinned requirement so it names the distribution as well as the version, in the same vocabulary as the manifest requirement it has to agree with. The guard is one test rather than a check in the loader. Stale provenance is a maintenance fact, not a broken payload -- a loader-level assertion would turn one actionable failure into several hundred identical ones. `tests/fixtures/README.md` documented this arrangement all along, including the file name; it is true again, and its derived-variant section now describes the in-memory derivation that replaced the committed variants. --- tests/adapter_fixtures.py | 60 +++++-- tests/fixtures/README.md | 66 ++++---- tests/fixtures/schema_one_tree.json | 234 ++++++++++++++++++++++++++ tests/fixtures/schema_one_tree.source | 1 + tests/test_fixture_provenance.py | 87 ++++++++++ 5 files changed, 409 insertions(+), 39 deletions(-) create mode 100644 tests/fixtures/schema_one_tree.json create mode 100644 tests/fixtures/schema_one_tree.source create mode 100644 tests/test_fixture_provenance.py diff --git a/tests/adapter_fixtures.py b/tests/adapter_fixtures.py index d2fd0b20..22f739cb 100644 --- a/tests/adapter_fixtures.py +++ b/tests/adapter_fixtures.py @@ -15,15 +15,55 @@ import json import pathlib +from typing import NamedTuple from ebus_sdk.homie import DiscoveredDevice from span_panel_api.models import FieldMetadata, SpanPanelSnapshot -from span_panel_api_schema_1.reference_payloads import parent_child_tree from custom_components.span_panel.schema_validation import DiscoveredProperty _FIXTURES = pathlib.Path(__file__).parent / "fixtures" +SCHEMA_ONE_TREE = _FIXTURES / "schema_one_tree.json" +"""The vendored parent/child capture. See `tests/fixtures/README.md` for provenance.""" + +SCHEMA_ONE_TREE_SOURCE = _FIXTURES / "schema_one_tree.source" +"""The release `SCHEMA_ONE_TREE` was copied from, checked by `test_fixture_provenance.py`.""" + + +class VendoredSource(NamedTuple): + """The distribution and release a vendored fixture was copied from.""" + + distribution: str + version: str + + +def schema_one_source() -> VendoredSource: + """Read the release recorded beside the vendored capture. + + Recorded as a pinned requirement -- `span-panel-api-schema-1==1.1.0` -- rather + than a bare version, for two reasons. It names *which* distribution the claim + is about, and this repository pins three of them; and it is written in the + same vocabulary as the `manifest.json` requirement it has to agree with, so + the two can be compared by eye during a bump as well as by + `test_fixture_provenance.py`. + + It lives in its own file rather than inside the payload because the refresh + procedure is a byte-for-byte copy: a key added to the JSON would be + overwritten by every refresh, and a payload that differs from its source is + exactly what the README forbids. + """ + recorded = SCHEMA_ONE_TREE_SOURCE.read_text().strip() + distribution, separator, version = recorded.partition("==") + if not (separator and distribution and version): + raise ValueError( + f"{SCHEMA_ONE_TREE_SOURCE} must hold one pinned requirement naming the " + f"release the vendored capture was copied from, such as " + f"'span-panel-api-schema-1==1.1.0'. It holds {recorded!r}." + ) + return VendoredSource(distribution, version) + + SCHEMA_ONE_PANEL = "example-40t-001" """Device id of the enclosure in the adapter's published capture. @@ -60,14 +100,13 @@ def _devices_from(tree: dict[str, dict[str, str]]) -> list[DiscoveredDevice]: def schema_one_tree(without: str | None = None) -> dict[str, dict[str, str]]: """A mutable copy of the parent/child capture, ready to be rewritten. - **Read from the library's package data, not vendored here.** The adapter ships - `parent_child_tree.json` precisely so a consumer can read it -- its own README - says "never by path", and that a consumer pinning a version gets the bytes - that version's parser was written against. This repository used to keep a - byte-identical copy under `tests/fixtures/`, which is one more artifact to go - stale and nothing checked the two still agreed. Reading the published one - means the capture moves when the pinned adapter moves, and the library's own - peer-conformance check against the producer covers it transitively. + **Vendored here, not read from the library's package data.** The capture is + test data; carrying it in the runtime wheel put 56 KB of it on every user's + disk, and reading it by import made this suite depend on a distribution + continuing to ship files nothing at runtime reads. The objection to a copy was + that it goes stale in silence -- answered by `schema_one_source()` and + `test_fixture_provenance.py`, which hold the recorded release against the one + actually installed, so a moved pin with an unrefreshed copy fails CI by name. Copied per call, and one level deep, which is as deep as a topic goes: a test proves a reading came off the wire by republishing it and asserting the entity @@ -78,7 +117,8 @@ def schema_one_tree(without: str | None = None) -> dict[str, dict[str, str]]: from the base by construction -- the only difference each ever had was the one missing device. """ - tree = {device_id: dict(topics) for device_id, topics in parent_child_tree().items()} + capture: dict[str, dict[str, str]] = json.loads(SCHEMA_ONE_TREE.read_text()) + tree = {device_id: dict(topics) for device_id, topics in capture.items()} if without is not None: assert without in tree, f"{without!r} is not in the capture; nothing to drop" del tree[without] diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index 9c458c60..d2d1a1ca 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -1,52 +1,60 @@ # Adapter fixtures Real schema-adapter inputs, used by the field-path conformance tests via `tests/adapter_fixtures.py`. They are **committed rather than generated** so the test -suite has no cross-repo dependency and CI needs no checkout of the library. The cost of that choice is that they go stale silently — hence this file. +suite has no cross-repo dependency and CI needs no checkout of the library — and so no runtime wheel has to keep shipping test data for this repository's +benefit. The cost of a copy is that it can go stale, which for `schema_one_tree.json` is answered by the version guard below. ## Provenance Both are byte-identical copies from the `span-panel-api` repository: -| File here | Source in `span-panel-api` | -| ------------------------ | --------------------------------------------------------------------- | -| `schema_zero_types.json` | `tests/fixtures/v2/homie_schema.json` | -| `schema_one_tree.json` | `packages/schema-1/src/.../reference_payloads/parent_child_tree.json` | +| File here | Source in `span-panel-api` | Version guard | +| ------------------------ | ------------------------------------------------- | ------------------------ | +| `schema_zero_types.json` | `tests/fixtures/v2/homie_schema.json` | none | +| `schema_one_tree.json` | `tests/reference_payloads/parent_child_tree.json` | `schema_one_tree.source` | Refresh by copying them again, and keep `schema_one_tree.json` byte-identical to its source: the library pins what that capture leaves unvalued against panelbench's own baseline (`tests/test_reference_tree_values.py` there), so a copy that has drifted puts these tests on a wire no producer sends. +Through the 1.1.0 release the parent/child capture also shipped as package data, at `span_panel_api_schema_1/reference_payloads/parent_child_tree.json` inside +the installed wheel, and this repository imported it from there. `span-panel-api#162` takes it back out of the wheels, so the repository path above is the one +to copy from. + If a copy changes shape rather than content, the loader in `tests/adapter_fixtures.py` is what needs updating — note that `schema_one_tree.json` is a **dict -keyed by device id**, whose `$description` value is a **JSON string**, not a parsed object. +keyed by device id**, whose `$description` value is a **JSON string**, not a parsed object. `tests/test_fixture_provenance.py` pins that shape, so a capture +that arrives pre-parsed fails there rather than somewhere far from the fixture. -## Derived variants +## The version guard -Both are produced from `schema_one_tree.json` by dropping every device whose parsed `$description["type"]` contains a marker. Regenerate with: +`schema_one_tree.source` records the release the capture was copied from, as a pinned requirement: -```bash -uv run python - << 'PY' -import json, pathlib +```text +span-panel-api-schema-1==1.1.0 +``` -tree = json.loads(pathlib.Path("tests/fixtures/schema_one_tree.json").read_text()) +`tests/test_fixture_provenance.py` holds that against `importlib.metadata.version("span-panel-api-schema-1")` — the release actually installed. When the pin in +`manifest.json` moves and nobody refreshes the capture, that test fails and names the refresh, so staleness is loud rather than silent. It needs no checkout of +the library and no network call, which is why a copy is safe to keep here at all. -def drop(marker: str, out_name: str) -> None: - kept = { - device_id: topics - for device_id, topics in tree.items() - if marker not in json.loads(topics.get("$description", "{}")).get("type", "") - } - pathlib.Path(f"tests/fixtures/{out_name}").write_text(json.dumps(kept, indent=2) + "\n") - print(f"{out_name}: {len(tree)} -> {len(kept)} devices") +It is a separate file rather than a key inside the payload because a refresh is a byte-for-byte copy: anything added to the JSON would be overwritten by the +next one. It names the distribution as well as the version because this repository pins three of them. -drop(".bess", "schema_one_tree_batteryless.json") -drop(".pv", "schema_one_tree_no_pv.json") -PY -``` +To refresh, in one commit: + +1. Copy `tests/reference_payloads/parent_child_tree.json` from `span-panel-api` over `schema_one_tree.json`, with no reformatting. +2. Set the version in `schema_one_tree.source` to the release it came from. + +The guard only sees the version claim, not the bytes. A capture edited in place under an unchanged version is invisible to it — refresh from a release, never +from a working tree that is ahead of one. + +## Derived variants -Each must remove **exactly one** device (13 -> 12) and must retain the panel and both lugs devices. A variant that removed more would make the conformance tests -pass for the wrong reason. Note `bess-mid` is typed `energy.ebus.device.mid` and correctly survives the `.bess` filter. +The batteryless and PV-less trees are **derived in memory**, not committed: `adapter_fixtures.schema_one_tree(without="bess")` and `without="pv"` return the +capture with that one device dropped. They were separate files once; deriving them means they cannot drift from the base, since the only difference either ever +had was the one missing device. Each drops exactly one device (13 -> 12) and retains the panel and both lugs devices — a variant that removed more would make +the conformance tests pass for the wrong reason. Note `bess-mid` is typed `energy.ebus.device.mid` and is not the BESS. ## Why these exist -`schema_one_tree_batteryless.json` proves a panel with no BESS produces **no** `battery.*` entries — hardware absence, not degradation. -`schema_one_tree_no_pv.json` proves the same for a panel that has power-flows telemetry but no PV device, which is the case telemetry-based capability detection -gets wrong. +The batteryless tree proves a panel with no BESS produces **no** `battery.*` entries — hardware absence, not degradation. The PV-less tree proves the same for a +panel that has power-flows telemetry but no PV device, which is the case telemetry-based capability detection gets wrong. diff --git a/tests/fixtures/schema_one_tree.json b/tests/fixtures/schema_one_tree.json new file mode 100644 index 00000000..0aee1b83 --- /dev/null +++ b/tests/fixtures/schema_one_tree.json @@ -0,0 +1,234 @@ +{ + "0ab966b95f92a6a51ec548485aa85f54": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "15", + "info/name": "Kitchen Lights", + "info/spaces": "1", + "load-shed/priority": "UNKNOWN", + "meter/active-power": "-121.0", + "meter/current": "1.0083333333333333", + "meter/exported-energy": "2.0166666666666666", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "1", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "573066aaddd7b75114c4563ce3af18c4": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Solar Inverter\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "30", + "connection/feeds-device-id": "pv", + "connection/feeds-device-status": "OK", + "connection/feeds-device-type": "energy.ebus.device.pv", + "info/name": "Solar Inverter", + "info/spaces": "36,38", + "load-shed/priority": "NEVER", + "meter/active-power": "8500.0", + "meter/current": "35.416666666666664", + "meter/exported-energy": "0.0", + "meter/imported-energy": "141.66666666666666", + "pcs/managed": "false", + "pcs/priority": "5", + "switch/relay": "CLOSED", + "switch/relay-controllable": "false", + "switch/relay-requester": "NONE" + }, + "62d0e03897b337b57101aae82f1e9ba2": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "50", + "connection/feeds-device-id": "evse", + "connection/feeds-device-status": "OK", + "connection/feeds-device-type": "energy.ebus.device.evse", + "info/name": "SPAN Drive - Garage", + "info/spaces": "32,34", + "load-shed/priority": "OFF_GRID", + "meter/active-power": "-2410.0", + "meter/current": "10.041666666666666", + "meter/exported-energy": "40.166666666666664", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "3", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "bess": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.bess\", \"name\": \"Battery\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"soc\": {\"name\": \"soc\", \"type\": \"energy.ebus.capability.soc\", \"properties\": {\"soc\": {\"name\": \"State of charge\", \"datatype\": \"float\", \"unit\": \"%\"}, \"soe\": {\"name\": \"State of energy\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"communication-state\": {\"name\": \"Communication state\", \"datatype\": \"enum\", \"format\": \"OK,DEGRADED,LOST,UNKNOWN\"}}}}, \"children\": [\"bess-mid\"], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "info/firmware-version": "example-bess/v0.1.0", + "info/model": "Example BESS", + "info/nameplate-capacity": "13.5", + "info/part-number": "SPN-BESS-001", + "info/serial-number": "EXAMPLE-BESS-40T-001", + "info/vendor-name": "Span", + "meter/active-power": "-3500.0", + "soc/soc": "50.410493827160494", + "soc/soe": "6.805416666666667", + "status/communication-state": "OK" + }, + "bess-mid": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.mid\", \"name\": \"Microgrid Interconnect Device\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}}}, \"grid\": {\"name\": \"grid\", \"type\": \"energy.ebus.capability.grid\", \"properties\": {\"islanding-state\": {\"name\": \"Islanding state of the BESS-integrated grid-forming device\", \"datatype\": \"enum\", \"format\": \"ON_GRID,OFF_GRID,UNKNOWN\"}, \"grid-state\": {\"name\": \"Sensed grid condition\", \"datatype\": \"enum\", \"format\": \"UP,DOWN,DEGRADED,UNKNOWN\"}, \"grid-forming-entity\": {\"name\": \"Identity of the currently grid-forming entity\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"bess\", \"extensions\": []}", + "$state": "ready", + "grid/grid-forming-entity": "GRID", + "grid/grid-state": "UP", + "grid/islanding-state": "ON_GRID", + "info/firmware-version": "example-mid/v0.1.0", + "info/hardware-version": "rev1", + "info/model": "SPAN MID", + "info/serial-number": "EXAMPLE-BESS-40T-001-mid", + "info/vendor-name": "Span" + }, + "d3724e0d660ba506aa79c1cafe5d1181": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garage Outlet\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "20", + "info/name": "Garage Outlet", + "info/spaces": "2", + "load-shed/priority": "UNKNOWN", + "meter/active-power": "-122.0", + "meter/current": "1.0166666666666666", + "meter/exported-energy": "2.033333333333333", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "2", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "evse": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "config/max-charge-current": "32", + "config/user-max-charge-current": "32", + "info/firmware-version": "example/v0.1.0", + "info/model": "SPAN Drive", + "info/part-number": "SPN-DRV-001", + "info/serial-number": "SIM-EVSE-example-40t-001", + "info/vendor-name": "SPAN", + "meter/advertised-current": "32.0", + "status/status": "CHARGING", + "switch/lock-state": "LOCKED" + }, + "evse-2": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "config/max-charge-current": "32", + "config/user-max-charge-current": "32", + "info/firmware-version": "example/v0.1.0", + "info/model": "SPAN Drive", + "info/part-number": "SPN-DRV-001", + "info/serial-number": "SIM-EVSE-example-40t-001-2", + "info/vendor-name": "SPAN", + "meter/advertised-current": "32.0", + "status/status": "AVAILABLE", + "switch/lock-state": "UNLOCKED" + }, + "example-40t-001": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Example 40-tab Panel\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"enum\", \"format\": \"MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"data-model-version\": {\"name\": \"eBus data-model version (parent/child schema discriminator)\", \"datatype\": \"string\"}}}, \"door\": {\"name\": \"door\", \"type\": \"energy.ebus.capability.door\", \"properties\": {\"state\": {\"name\": \"Door state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"voltage-a\": {\"name\": \"L1 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}, \"voltage-b\": {\"name\": \"L2 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Main breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"enabled\": {\"name\": \"PCS system enabled\", \"datatype\": \"boolean\"}, \"active\": {\"name\": \"PCS system actively controlling one (or more) loads\", \"datatype\": \"boolean\"}, \"import-limit\": {\"name\": \"The power import limit currently being managed to\", \"datatype\": \"float\", \"unit\": \"A\"}, \"binding-constraint\": {\"name\": \"Which constraint class currently sets the import limit\", \"datatype\": \"enum\", \"format\": \"FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN\"}, \"feed-import-limit\": {\"name\": \"Limit of maximum power feeding the distribution enclosure\", \"datatype\": \"float\", \"unit\": \"A\"}, \"feed-import-limit-enablement\": {\"name\": \"Enablement status of the feed-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"feed-import-limit-active\": {\"name\": \"Is feed-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"operator-import-limit\": {\"name\": \"Operator-imposed maximum import limit\", \"datatype\": \"float\", \"unit\": \"A\"}, \"operator-import-limit-enablement\": {\"name\": \"Enablement status of the operator-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"operator-import-limit-active\": {\"name\": \"Is operator-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"off-grid-import-limit\": {\"name\": \"Off-Grid limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"off-grid-import-limit-enablement\": {\"name\": \"Enablement status of the off-grid-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"off-grid-import-limit-active\": {\"name\": \"Is off-grid-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"requested-import-limit\": {\"name\": \"Requested limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"requested-import-limit-enablement\": {\"name\": \"Enablement status of the requested-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"requested-import-limit-active\": {\"name\": \"Is requested-import-limit currently being enforced?\", \"datatype\": \"boolean\"}}}, \"shed-forecast\": {\"name\": \"shed-forecast\", \"type\": \"energy.ebus.capability.shed-forecast\", \"properties\": {\"total-time-remaining\": {\"name\": \"Estimated total time before all sheddable circuits are shed (off-grid runtime)\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"time-to-priority-shed\": {\"name\": \"Estimated time before the next priority tier is shed\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-total-time-remaining\": {\"name\": \"Estimated total time assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-time-to-priority-shed\": {\"name\": \"Estimated time to next priority shed assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"confidence\": {\"name\": \"Confidence of the shed-forecast estimate\", \"datatype\": \"enum\", \"format\": \"LOW,MEDIUM,HIGH\"}}}, \"shed\": {\"name\": \"shed\", \"type\": \"energy.ebus.capability.shed\", \"properties\": {\"asserted-islanding-state\": {\"name\": \"Consumer-asserted islanding-state (grid-state override during MID/BESS comm-loss)\", \"datatype\": \"enum\", \"format\": \"NONE,ON_GRID,OFF_GRID\", \"settable\": true}, \"policy\": {\"name\": \"Shed policy (algorithm and parameters)\", \"datatype\": \"json\", \"format\": \"{\\\"$id\\\":\\\"soc-priority.v1\\\",\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"algorithm\\\",\\\"parameters\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"algorithm\\\":{\\\"const\\\":\\\"soc-priority.v1\\\"},\\\"parameters\\\":{\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"soc-threshold-shed\\\",\\\"soc-threshold-release\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"soc-threshold-shed\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent below which SOC_THRESHOLD circuits shed\\\"},\\\"soc-threshold-release\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent above which shed SOC_THRESHOLD circuits restore\\\"}}}}}\"}}}, \"power-flows\": {\"name\": \"power-flows\", \"type\": \"energy.ebus.capability.power-flows\", \"properties\": {\"pv\": {\"name\": \"PV power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"battery\": {\"name\": \"Battery/BESS power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"grid\": {\"name\": \"Grid power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"site\": {\"name\": \"Site power flow\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"relay\": {\"name\": \"Main relay\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"ethernet\": {\"name\": \"Is Ethernet network interface operational?\", \"datatype\": \"boolean\"}, \"wifi\": {\"name\": \"Is Wi-Fi network interface operational?\", \"datatype\": \"boolean\"}, \"wifi-ssid\": {\"name\": \"SSID to which Wi-Fi network interface is connected\", \"datatype\": \"string\"}, \"cloud-connection\": {\"name\": \"Device connected to vendor cloud?\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,UNCONNECTED,CONNECTED\"}, \"postal-code\": {\"name\": \"Postal (Zip) code\", \"datatype\": \"string\"}, \"time-zone\": {\"name\": \"Time zone\", \"datatype\": \"string\"}}}}, \"children\": [\"bess\", \"0ab966b95f92a6a51ec548485aa85f54\", \"d3724e0d660ba506aa79c1cafe5d1181\", \"62d0e03897b337b57101aae82f1e9ba2\", \"fe8b85c15bc9610c1b8b4ebc6f82488d\", \"573066aaddd7b75114c4563ce3af18c4\", \"evse\", \"evse-2\", \"lugs-upstream\", \"lugs-downstream\", \"pv\"], \"extensions\": []}", + "$state": "ready", + "breaker/rating": "200", + "door/state": "CLOSED", + "info/data-model-version": "1.0", + "info/firmware-version": "example/v0.1.0", + "info/hardware-version": "rev2", + "info/model": "MAIN_40", + "info/serial-number": "example-40t-001", + "info/vendor-name": "Span", + "meter/voltage-a": "120.0", + "meter/voltage-b": "120.0", + "pcs/active": "false", + "pcs/binding-constraint": "NONE", + "pcs/enabled": "false", + "pcs/feed-import-limit": "0.0", + "pcs/feed-import-limit-active": "false", + "pcs/feed-import-limit-enablement": "UNCONFIGURED", + "pcs/import-limit": "0.0", + "pcs/off-grid-import-limit": "0.0", + "pcs/off-grid-import-limit-active": "false", + "pcs/off-grid-import-limit-enablement": "UNCONFIGURED", + "pcs/operator-import-limit": "0.0", + "pcs/operator-import-limit-active": "false", + "pcs/operator-import-limit-enablement": "UNCONFIGURED", + "pcs/requested-import-limit": "0.0", + "pcs/requested-import-limit-active": "false", + "pcs/requested-import-limit-enablement": "UNCONFIGURED", + "power-flows/battery": "-3500.0", + "power-flows/grid": "-2347.0", + "power-flows/pv": "8500.0", + "power-flows/site": "2653.0", + "shed-forecast/confidence": "HIGH", + "shed-forecast/full-charge-time-to-priority-shed": "3038", + "shed-forecast/full-charge-total-time-remaining": "4320", + "shed-forecast/time-to-priority-shed": "3037", + "shed-forecast/total-time-remaining": "4320", + "shed/asserted-islanding-state": "NONE", + "shed/policy": "{\"algorithm\": \"soc-priority.v1\", \"parameters\": {\"soc-threshold-shed\": 20, \"soc-threshold-release\": 30}}", + "status/cloud-connection": "CONNECTED", + "status/ethernet": "true", + "status/postal-code": "94103", + "status/relay": "CLOSED", + "status/time-zone": "America/Los_Angeles", + "status/wifi": "true", + "status/wifi-ssid": "example-wifi" + }, + "fe8b85c15bc9610c1b8b4ebc6f82488d": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "50", + "connection/feeds-device-id": "evse-2", + "connection/feeds-device-status": "OK", + "connection/feeds-device-type": "energy.ebus.device.evse", + "info/name": "SPAN Drive - Driveway", + "info/spaces": "35,37", + "load-shed/priority": "OFF_GRID", + "meter/active-power": "0.0", + "meter/current": "0.0", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "4", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "lugs-downstream": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Downstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "info/direction": "DOWNSTREAM", + "meter/active-power": "-5847.0", + "meter/current-a": "46.46666666666666", + "meter/current-b": "46.474999999999994", + "meter/exported-energy": "141.66666666666666", + "meter/imported-energy": "44.21666666666666" + }, + "lugs-upstream": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Upstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "connection/fed-by-device-id": "bess", + "connection/fed-by-device-status": "OK", + "connection/fed-by-device-type": "energy.ebus.device.bess", + "info/direction": "UPSTREAM", + "meter/active-power": "-5847.0", + "meter/current-a": "46.46666666666666", + "meter/current-b": "46.474999999999994", + "meter/exported-energy": "141.66666666666666", + "meter/imported-energy": "44.21666666666666" + }, + "pv": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.pv\", \"name\": \"Solar\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nominal-power\": {\"name\": \"Nominal power\", \"datatype\": \"float\", \"unit\": \"W\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "info/firmware-version": "example-pv/v0.1.0", + "info/model": "IQ8PLUS-72-2-US", + "info/nominal-power": "10000.0", + "info/vendor-name": "Enphase" + } +} diff --git a/tests/fixtures/schema_one_tree.source b/tests/fixtures/schema_one_tree.source new file mode 100644 index 00000000..64b9da88 --- /dev/null +++ b/tests/fixtures/schema_one_tree.source @@ -0,0 +1 @@ +span-panel-api-schema-1==1.1.0 diff --git a/tests/test_fixture_provenance.py b/tests/test_fixture_provenance.py new file mode 100644 index 00000000..52293704 --- /dev/null +++ b/tests/test_fixture_provenance.py @@ -0,0 +1,87 @@ +"""The vendored capture must name the release it came from, and be right about it. + +`tests/fixtures/schema_one_tree.json` is a byte-identical copy of a payload that +lives in the `span-panel-api` repository. Committing it is what keeps this suite +free of a cross-repo dependency, and free of the expectation that a runtime wheel +keeps shipping test data. The price of a copy is that it can go stale, and a +stale capture is the worst kind of green: the conformance tests keep passing, +against a wire no panel sends. + +Detecting that does not need the file, only the version claim. `schema_one_tree.source` +records the release the copy was taken from, and `importlib.metadata` reports the +release actually installed -- with no checkout, no network call and no second copy +to compare against. The moment the pin moves and the capture is not refreshed, +this test fails and says what to do about it. + +This is one test rather than a check inside `adapter_fixtures.schema_one_tree()` +deliberately. Stale provenance is a maintenance fact, not a broken payload: the +capture still parses and still drives every adapter. A loader-level assertion +would turn one actionable failure into several hundred identical ones, burying +whatever else the run had to say -- and it would make the refresh itself +undebuggable, since the bump and the copy cannot land in the same keystroke. +""" + +from __future__ import annotations + +from importlib.metadata import PackageNotFoundError, version +import json + +import pytest + +from .adapter_fixtures import SCHEMA_ONE_TREE, SCHEMA_ONE_TREE_SOURCE, schema_one_source + + +def _refresh(distribution: str, installed: str) -> str: + """Say what to do about a stale copy, to someone who has never refreshed one.""" + return ( + f"Copy `parent_child_tree.json` from {distribution} at {installed} over " + f"{SCHEMA_ONE_TREE.name} -- byte for byte, no reformatting -- and set " + f"{SCHEMA_ONE_TREE_SOURCE.name} to '{distribution}=={installed}'. " + f"tests/fixtures/README.md has the source path." + ) + + +def test_the_vendored_capture_records_where_it_came_from() -> None: + """A copy with no provenance cannot be checked, which is how it goes stale.""" + source = schema_one_source() + + assert source.distribution == "span-panel-api-schema-1" + assert source.version, "the recorded release must not be empty" + + +def test_the_vendored_capture_matches_the_installed_adapter() -> None: + """The pin moved and the copy did not: the failure this whole arrangement exists for.""" + source = schema_one_source() + + try: + installed = version(source.distribution) + except PackageNotFoundError: + pytest.fail( + f"{SCHEMA_ONE_TREE_SOURCE.name} records {source.distribution!r}, which is not " + f"installed. The vendored capture cannot be checked against a distribution " + f"this environment does not have; correct the name or install it." + ) + + assert installed == source.version, ( + f"The vendored capture {SCHEMA_ONE_TREE.name} was copied from " + f"{source.distribution} {source.version}, but {installed} is installed. " + f"The adapter is not broken -- the copy is out of date. " + f"{_refresh(source.distribution, installed)}" + ) + + +def test_the_vendored_capture_is_the_shape_the_loader_expects() -> None: + """A dict keyed by device id, whose `$description` is a JSON string, not an object. + + Pinned here because a refresh is a manual copy, and the one thing a copy can + get wrong that the version claim cannot see is a change of shape. The loader + reads `$description` with `update_description`, which parses; a capture that + had been helpfully pre-parsed would fail far from here. + """ + capture: dict[str, dict[str, str]] = json.loads(SCHEMA_ONE_TREE.read_text()) + + assert capture, "the capture is empty" + for device_id, topics in capture.items(): + description = topics["$description"] + assert isinstance(description, str), f"{device_id}: $description must be a JSON string" + assert isinstance(json.loads(description), dict), f"{device_id}: $description must parse" From 411ab4cac8518988cf94996d7e07c1a904cf95b1 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:47:58 -0700 Subject: [PATCH 130/345] test(fixtures): compare the vendored capture to its source, not just to a version string The existing provenance guard holds `schema_one_tree.source` against `importlib.metadata.version("span-panel-api-schema-1")`. That catches a copy that was never refreshed after a pin moved. It cannot catch one that was refreshed wrongly -- reformatted, hand-edited, or taken from a working tree ahead of the release it names -- because all three leave the version claim correct and change only the bytes. So compare the bytes. `SPAN_PANEL_API_DIR`, which already exists in `.env.example` for editable installs, now also names the checkout the vendored capture is compared against, byte for byte with no reformatting tolerance. The comparison first confirms the checkout is on the release the capture claims: written without that check it passed against a checkout sitting one release behind, whose bytes happened to be the ones vendored here, which proves only that the copy matches something. Skips when no checkout is configured, or when the one configured is on another release -- not every developer keeps a sibling checkout, and failing them for it is what gets a check deleted rather than configured. Fails rather than skips under `CI`, where the workflow clones one, following span-panel-api's stated reason: a skip reads in a summary line exactly like a pass, and that is how a stale vendored capture went unnoticed there for nine days. Both halves of that asymmetry are themselves asserted, in both environments, for every state the checkout resolver distinguishes. `tests/conftest.py` reads `.env` directly, with no dotenv dependency, the same way span-panel-api's does -- stripping the `export ` prefix this repository's `.env.example` writes, and never overriding a value already exported. CI clones SpanPanel/span-panel-api at a tag derived from `schema_one_tree.source` rather than hardcoded: `span-panel-api-schema-1==1.1.0` becomes `schema-1-v1.1.0`. A second copy of the pin could disagree with the first, which would be a new instance of exactly the staleness this removes. Both source locations are checked, newest first. span-panel-api#162 moves the capture out of the schema_1 wheel into `tests/reference_payloads/`, and until no pinnable release predates that merge the old path stays reachable. --- .env.example | 20 ++- .github/workflows/ci.yml | 48 +++++ tests/conftest.py | 44 +++++ tests/fixtures/README.md | 59 +++++-- tests/test_fixture_provenance.py | 291 +++++++++++++++++++++++++++++++ 5 files changed, 448 insertions(+), 14 deletions(-) diff --git a/.env.example b/.env.example index 5e916e73..e85215ac 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,24 @@ # Copy this file to .env and adjust paths for your local setup. +# +# `tests/conftest.py` reads this file directly, so no direnv or dotenv package is +# needed for the test suite. A value already exported in your shell wins over +# anything here -- the file supplies defaults, it does not override an +# intentional choice. -# Path to span-panel-api repo (for editable pip install) +# Path to span-panel-api repo. Used for the editable pip install, and read by +# tests/test_fixture_provenance.py, which byte-compares the vendored capture +# `tests/fixtures/schema_one_tree.json` against the copy in that checkout. +# +# Optional here: without it that one comparison skips and the rest of the suite +# runs in full. It skips again if the checkout is not on the release +# `tests/fixtures/schema_one_tree.source` records, because comparing against +# another release reports drift rather than corruption -- position the checkout +# on that release's tag before believing a failure. +# +# CI is not optional: it clones span-panel-api at that release and this check +# fails there rather than skipping. A skip reads in a summary line exactly like a +# pass, and that is how a stale vendored capture went unnoticed for nine days in +# span-panel-api (its DEVELOPMENT.md, "A skip here is not a pass"). export SPAN_PANEL_API_DIR=../span-panel-api # Path to span-card frontend repo (for build-frontend.sh) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebecee83..19c1f08b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,54 @@ jobs: - name: Checkout code uses: actions/checkout@v7 + - name: Check out span-panel-api at the release the vendored capture names + run: | + set -euo pipefail + # The tag is derived, never written down twice. `schema_one_tree.source` + # is the one place that records which release the vendored capture was + # copied from, and this clones that release so the byte comparison is + # against the source the copy claims -- not against whatever `main` + # happens to hold. A hardcoded tag here could disagree with that file, + # which would be a new instance of the staleness this check exists to + # remove. + # + # `span-panel-api-schema-1==1.1.0` -> tag `schema-1-v1.1.0`: the + # monorepo tags a per-package release as `-v`, and the + # package directory is the distribution name with the `span-panel-api-` + # prefix dropped (see RELEASE.md there). The bootstrap distribution is + # tagged plain `v`, which is why this derives from the adapter + # pin rather than from the requirement list generally. + # + # `git clone` rather than actions/checkout because the ref has to be + # computed from a file in the workspace first, and splitting that across + # a step output would put the derivation in two places. The repository + # is public, so no token beyond the default is involved. + pin="$(tr -d '[:space:]' < tests/fixtures/schema_one_tree.source)" + case "$pin" in + *==*) ;; + *) + echo "schema_one_tree.source must hold one pinned requirement, got '$pin'" >&2 + exit 1 + ;; + esac + distribution="${pin%%==*}" + release="${pin##*==}" + tag="${distribution#span-panel-api-}-v${release}" + echo "Vendored capture records $pin; cloning SpanPanel/span-panel-api at $tag" + # Under RUNNER_TEMP rather than in the workspace: a second checkout + # inside it would be collected by pytest and walked by every + # --all-files linter. + checkout="$RUNNER_TEMP/span-panel-api" + git clone --depth 1 --branch "$tag" \ + https://github.com/SpanPanel/span-panel-api.git "$checkout" + # Exported here rather than as a job-level `env:` because the `runner` + # context is not available there, and because two later steps run the + # suite -- the prek hooks (`pytest-cov-summary` always runs) and the + # coverage step. Either one running without it would fail the byte + # comparison for want of a checkout, which is exactly the "wiring came + # undone" state that check refuses to skip. + echo "SPAN_PANEL_API_DIR=$checkout" >> "$GITHUB_ENV" + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 with: diff --git a/tests/conftest.py b/tests/conftest.py index 9499daaa..9e0463d1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ """Configure test framework.""" import logging +import os from pathlib import Path import sys import types @@ -14,6 +15,49 @@ _PROJECT_ROOT = str(Path(__file__).resolve().parent.parent) _CC_DIR = str(Path(_PROJECT_ROOT) / "custom_components") +_DOTENV = Path(_PROJECT_ROOT) / ".env" + + +def _load_dotenv() -> None: + """Populate the environment from `.env`, without overriding what is set. + + Read directly rather than through python-dotenv. Until now `.env` was only + ever consumed by shell scripts, which source it; the fixture provenance + comparison in `test_fixture_provenance.py` is the first thing inside the + suite that needs `SPAN_PANEL_API_DIR`, and taking a dependency to parse a + handful of `export KEY=value` lines would put a package on the test path to + save nothing. `span-panel-api` reads its own `.env` the same way and for the + same reason. + + `setdefault`, never assignment. An exported value is a deliberate choice for + this run -- pointing at a different checkout to reproduce something -- and a + file silently winning over it is the kind of surprise that costs an + afternoon. + + The `export ` prefix is stripped because this repository's `.env.example` + writes it, so a loader that took the line verbatim would define a variable + named `export SPAN_PANEL_API_DIR` and the checks that read it would go on + skipping, which is the failure mode they exist to remove. + + Absence is fine; the checks that need a checkout skip themselves. See + `.env.example`. + """ + if not _DOTENV.is_file(): + return + for raw in _DOTENV.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + if line.startswith("export "): + line = line[len("export ") :].strip() + if "=" not in line: + continue + key, _, value = line.partition("=") + os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) + + +_load_dotenv() + def _ensure_span_panel_importable() -> None: """Ensure custom_components.span_panel can be imported. diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index d2d1a1ca..ab8dd265 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -1,30 +1,40 @@ # Adapter fixtures Real schema-adapter inputs, used by the field-path conformance tests via `tests/adapter_fixtures.py`. They are **committed rather than generated** so the test -suite has no cross-repo dependency and CI needs no checkout of the library — and so no runtime wheel has to keep shipping test data for this repository's -benefit. The cost of a copy is that it can go stale, which for `schema_one_tree.json` is answered by the version guard below. +suite has no cross-repo dependency and no runtime wheel has to keep shipping test data for this repository's benefit. The cost of a copy is that it can go +stale, which for `schema_one_tree.json` is answered by the two guards below. ## Provenance Both are byte-identical copies from the `span-panel-api` repository: -| File here | Source in `span-panel-api` | Version guard | +| File here | Source in `$SPAN_PANEL_API_DIR` | Guarded by | | ------------------------ | ------------------------------------------------- | ------------------------ | -| `schema_zero_types.json` | `tests/fixtures/v2/homie_schema.json` | none | +| `schema_zero_types.json` | `tests/fixtures/v2/homie_schema.json` | nothing | | `schema_one_tree.json` | `tests/reference_payloads/parent_child_tree.json` | `schema_one_tree.source` | Refresh by copying them again, and keep `schema_one_tree.json` byte-identical to its source: the library pins what that capture leaves unvalued against panelbench's own baseline (`tests/test_reference_tree_values.py` there), so a copy that has drifted puts these tests on a wire no producer sends. -Through the 1.1.0 release the parent/child capture also shipped as package data, at `span_panel_api_schema_1/reference_payloads/parent_child_tree.json` inside -the installed wheel, and this repository imported it from there. `span-panel-api#162` takes it back out of the wheels, so the repository path above is the one -to copy from. +Through the 1.1.0 release the parent/child capture also shipped as package data, at `packages/schema-1/src/span_panel_api_schema_1/reference_payloads/` in the +repository and inside the installed wheel, and this repository imported it from there. `span-panel-api#162` takes it back out of the wheels and moves it to +`tests/reference_payloads/`, so the path in the table above is the one to copy from. The byte comparison below checks both locations, newest first, so it works +against a checkout on either side of that merge; the older path can be dropped once no release this repository can pin still predates it. If a copy changes shape rather than content, the loader in `tests/adapter_fixtures.py` is what needs updating — note that `schema_one_tree.json` is a **dict keyed by device id**, whose `$description` value is a **JSON string**, not a parsed object. `tests/test_fixture_provenance.py` pins that shape, so a capture that arrives pre-parsed fails there rather than somewhere far from the fixture. -## The version guard +## The two guards + +They catch different failures, and neither one substitutes for the other. + +| Guard | Compares | Catches | Needs a checkout | +| --------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------- | +| Version claim | `schema_one_tree.source` against the installed distribution | a pin that moved while the copy stayed put | no | +| Byte comparison | `schema_one_tree.json` against the capture in that release | a copy that was refreshed **wrongly** — reformatted, edited, or taken from a working tree ahead of the release it names | yes | + +### The version claim `schema_one_tree.source` records the release the capture was copied from, as a pinned requirement: @@ -34,18 +44,41 @@ span-panel-api-schema-1==1.1.0 `tests/test_fixture_provenance.py` holds that against `importlib.metadata.version("span-panel-api-schema-1")` — the release actually installed. When the pin in `manifest.json` moves and nobody refreshes the capture, that test fails and names the refresh, so staleness is loud rather than silent. It needs no checkout of -the library and no network call, which is why a copy is safe to keep here at all. +the library and no network call, which is why it runs everywhere and why a copy is safe to keep here at all. It is a separate file rather than a key inside the payload because a refresh is a byte-for-byte copy: anything added to the JSON would be overwritten by the next one. It names the distribution as well as the version because this repository pins three of them. -To refresh, in one commit: +What it cannot see is the bytes. A capture edited in place, reindented on the way in, or copied from a working tree that is ahead of the release it names +satisfies this guard completely — the claim is a string, and the string is correct. + +### The byte comparison + +`test_the_vendored_capture_is_byte_identical_to_its_source` closes exactly that hole. It reads `SPAN_PANEL_API_DIR` (see `.env.example`), confirms the checkout +is on the release `schema_one_tree.source` names, and compares the vendored copy against the capture there byte for byte, with no reformatting tolerance. + +The release check is not decoration. Written without it, the comparison passed against a checkout sitting one release behind the recorded one, whose bytes +happened to be the ones vendored here — proving only that the copy matched _something_, and "something" includes the working tree it was mistakenly taken from. + +**A skip here is not a pass.** Without a checkout, or with one on another release, the comparison skips: not every developer keeps a sibling checkout, and +failing them for it is what gets a check deleted rather than configured. Under `CI` it fails instead, because the workflow clones `span-panel-api` at the +release the pin names, so an unset or misplaced path there means the wiring has come undone rather than that the check is unavailable. `span-panel-api` paid for +this asymmetry and states the reason plainly in its `DEVELOPMENT.md`, under "A skip here is not a pass": _"A skip reads in a summary line exactly like a pass, +and that is how a stale vendored capture went unnoticed for nine days."_ Do not make the skip unconditional. + +CI derives the tag from `schema_one_tree.source` rather than hardcoding it — `span-panel-api-schema-1==1.1.0` becomes `schema-1-v1.1.0` — so there is no second +copy of the pin that could disagree with the first. + +### Refreshing + +In one commit: -1. Copy `tests/reference_payloads/parent_child_tree.json` from `span-panel-api` over `schema_one_tree.json`, with no reformatting. +1. Copy `tests/reference_payloads/parent_child_tree.json` from `$SPAN_PANEL_API_DIR` over `schema_one_tree.json`, with no reformatting. 2. Set the version in `schema_one_tree.source` to the release it came from. -The guard only sees the version claim, not the bytes. A capture edited in place under an unchanged version is invisible to it — refresh from a release, never -from a working tree that is ahead of one. +Copy from a checkout positioned on a **released tag**, never from a working tree that is ahead of one. A capture taken from unreleased work carries a version +claim nothing can ever be positioned to verify: the byte comparison clones the tag that claim names, and until that tag exists there is nothing to compare +against. ## Derived variants diff --git a/tests/test_fixture_provenance.py b/tests/test_fixture_provenance.py index 52293704..f8037d57 100644 --- a/tests/test_fixture_provenance.py +++ b/tests/test_fixture_provenance.py @@ -13,6 +13,19 @@ to compare against. The moment the pin moves and the capture is not refreshed, this test fails and says what to do about it. +That is half the problem. A version claim catches a copy that was never +refreshed; it cannot catch one that was refreshed *wrongly* -- reformatted, +hand-edited, or taken from a working tree ahead of the release it names -- because +those leave the claim intact and change only the bytes. So the second guard here +compares the bytes, against the capture in a `span-panel-api` checkout named by +`SPAN_PANEL_API_DIR`. It needs something the version guard deliberately does not, +and therefore skips when no checkout is configured -- but **fails** rather than +skips under `CI`, where the workflow clones one. The library states the reason +plainly, having paid for it: "A skip reads in a summary line exactly like a pass, +and that is how a stale vendored capture went unnoticed for nine days" +(`span-panel-api`, DEVELOPMENT.md, "A skip here is not a pass"). Anyone tempted to +make the skip unconditional should read that first. + This is one test rather than a check inside `adapter_fixtures.schema_one_tree()` deliberately. Stale provenance is a maintenance fact, not a broken payload: the capture still parses and still drives every adapter. A loader-level assertion @@ -25,11 +38,50 @@ from importlib.metadata import PackageNotFoundError, version import json +import os +from pathlib import Path +import re +from typing import NoReturn import pytest from .adapter_fixtures import SCHEMA_ONE_TREE, SCHEMA_ONE_TREE_SOURCE, schema_one_source +CHECKOUT_VARIABLE = "SPAN_PANEL_API_DIR" +"""Names a `span-panel-api` checkout. Already defined in `.env.example` for editable installs.""" + +_REPO_ROOT = Path(__file__).resolve().parent.parent + +_SOURCE_PATHS = ( + Path("tests") / "reference_payloads" / "parent_child_tree.json", + Path("packages") + / "schema-1" + / "src" + / "span_panel_api_schema_1" + / "reference_payloads" + / "parent_child_tree.json", +) +"""Where the capture lives in `span-panel-api`, newest location first. + +Two of them because the file is moving. `span-panel-api#162` takes the reference +payloads out of the schema_1 wheel and makes them ordinary test fixtures, which +is the whole reason this repository vendors a copy rather than importing one -- +but that change is unmerged, so a checkout on `main` today still holds the file +under `packages/schema-1/`. Checking the new path first means the comparison +follows the file rather than the merge, and works against a checkout on either +side of it. + +Delete the second entry once #162 is merged **and** no release this repository +can pin still predates it -- the CI clone is positioned at the release +`schema_one_tree.source` records, so the old path stays reachable for as long as +that pin can name a pre-#162 release. +""" + +_RELEASE_DECLARATION = Path("packages") / "schema-1" / "pyproject.toml" +"""Where the checkout declares which release it is. Read only to explain a failure.""" + +_VERSION = re.compile(r'^version = "([^"]+)"', re.MULTILINE) + def _refresh(distribution: str, installed: str) -> str: """Say what to do about a stale copy, to someone who has never refreshed one.""" @@ -85,3 +137,242 @@ def test_the_vendored_capture_is_the_shape_the_loader_expects() -> None: description = topics["$description"] assert isinstance(description, str), f"{device_id}: $description must be a JSON string" assert isinstance(json.loads(description), dict), f"{device_id}: $description must parse" + + +def _unconfigured(reason: str) -> NoReturn: + """Not configured: skip on a developer machine, fail in CI. + + Locally, skipping is right. Not every developer keeps a `span-panel-api` + checkout beside this one, and a provenance check is not what they are running + the suite for. Failing on them is what makes a developer delete the check + rather than configure it. + + In CI it is the opposite. The workflow clones the library at the release + `schema_one_tree.source` names and exports `SPAN_PANEL_API_DIR`, so an unset + or unusable path there does not mean "unavailable", it means the wiring that + makes this check run has come undone. Skipping on that reads in the summary + line exactly like passing -- which is how the equivalent check in + `span-panel-api` stayed silent for the nine days it took a vendored capture to + go stale (DEVELOPMENT.md, "A skip here is not a pass"). A check an environment + can switch off by omitting a variable is a check nobody can rely on. + + `CI` rather than a variable of our own, because it is what GitHub Actions and + every other runner already set: an environment that stops supplying a path has + to opt *out* of being an environment, which is not something a workflow edit + does by accident. + """ + if os.environ.get("CI"): + pytest.fail( + f"{reason}. CI clones span-panel-api and exports {CHECKOUT_VARIABLE}, so this " + f"is the provenance wiring being broken rather than a check that is " + f"unavailable -- and a skip here is indistinguishable from a pass." + ) + pytest.skip(reason) + + +def _checkout() -> Path: + """Return the `span-panel-api` checkout named by the environment, or take the unconfigured exit. + + A variable that is unset, one pointing at a directory that is gone, and one + pointing at a directory that is not a checkout of this library are all the + same situation -- the source is not available to compare against -- and all + take the same exit. Letting a bad path through instead produces a + `FileNotFoundError` from inside a comparison, which reads as a broken test + rather than an unconfigured one. + + The third state is not hypothetical: `SPAN_PANEL_API_DIR` predates this check + and exists to point `pip install -e` at a library checkout, so it is exactly + the kind of variable someone has already set to something almost right. + `packages/schema-1/pyproject.toml` is what distinguishes a real checkout, and + it is also where a failure reads the release the checkout is on. + + A relative value is resolved against the repository root rather than the + working directory, because that is what `.env.example`'s `../span-panel-api` + means -- and pytest can be run from anywhere. + """ + configured = os.environ.get(CHECKOUT_VARIABLE) + if not configured: + _unconfigured( + f"set {CHECKOUT_VARIABLE} to a span-panel-api checkout to compare the " + f"vendored capture against its source" + ) + path = Path(configured) + if not path.is_absolute(): + path = _REPO_ROOT / path + if not path.is_dir(): + _unconfigured(f"{CHECKOUT_VARIABLE}={configured} does not exist; point it at a checkout") + if not (path / _RELEASE_DECLARATION).is_file(): + _unconfigured( + f"{CHECKOUT_VARIABLE}={configured} has no {_RELEASE_DECLARATION}, so it is not a " + f"span-panel-api checkout (or not a complete one); re-clone it" + ) + return path + + +def _checkout_release(checkout: Path) -> str: + """Read the release a checkout declares, from the package it would build.""" + declared = _VERSION.search((checkout / _RELEASE_DECLARATION).read_text(encoding="utf-8")) + return declared.group(1) if declared else "an undeclared release" + + +def _source_capture(checkout: Path, recorded: str) -> Path: + """Return the capture inside a checkout on the release that `recorded` names. + + The position check is not decoration, and this is not a hypothetical: written + without it, this comparison passed against a checkout sitting on the previous + release, whose bytes happened to be the ones vendored here. A byte comparison + that will accept whichever revision it is handed proves only that the copy + matches *something* -- and "something" includes the working tree the copy was + mistakenly taken from, which is the exact defect it is here to find. + + Being on the wrong release takes the same exit as having no checkout at all, + rather than failing outright, because it is the same situation: the release + this capture claims to come from is not available to compare against. Locally + that is ordinary -- `SPAN_PANEL_API_DIR` predates this check and points at + whatever a developer is working on -- and failing them for it is what makes a + developer delete the check rather than move a checkout. In CI it cannot happen + innocently: the workflow clones at the tag `schema_one_tree.source` names, so a + checkout on any other release means the derivation has come undone, and + `_unconfigured` fails there for that reason. + """ + declared = _checkout_release(checkout) + if declared != recorded: + _unconfigured( + f"{CHECKOUT_VARIABLE} names a checkout on span-panel-api-schema-1 {declared}, and " + f"{SCHEMA_ONE_TREE_SOURCE.name} records the capture as a copy of {recorded}. Move it " + f"to the schema-1-v{recorded} tag; comparing against another release reports drift " + f"rather than corruption" + ) + + for relative in _SOURCE_PATHS: + source = checkout / relative + if source.is_file(): + return source + pytest.fail( + f"{checkout} is span-panel-api-schema-1 {declared} as recorded, but holds the capture at " + f"neither {_SOURCE_PATHS[0]} nor {_SOURCE_PATHS[1]}. Either the checkout is incomplete, " + f"or that release moved the file again and this list needs the new path." + ) + + +def test_the_vendored_capture_is_byte_identical_to_its_source() -> None: + """The bytes, against the release the copy says it came from. + + The check the version guard cannot make. `schema_one_tree.source` agreeing + with the installed distribution proves only that somebody bumped a string; it + says nothing about whether the accompanying copy was faithful, and a capture + that was reformatted on the way in, or taken from a working tree ahead of the + release it names, satisfies the version guard completely. + + Byte for byte, with no reformatting tolerance, because the two repositories + have to be able to compare these files trivially and because the library pins + what this capture leaves unvalued against its producer's own baseline. A copy + that has been re-serialised -- reindented, reordered, floats rounded -- is one + nothing upstream can hold to that baseline any more, and it puts these tests + on a wire no panel sends. + """ + recorded = schema_one_source() + source = _source_capture(_checkout(), recorded.version) + + assert source.read_bytes() == SCHEMA_ONE_TREE.read_bytes(), ( + f"{SCHEMA_ONE_TREE.name} is not a faithful copy of {source}, which is " + f"{recorded.distribution} {recorded.version} -- the release " + f"{SCHEMA_ONE_TREE_SOURCE.name} records it as a copy of. The version claim is right and " + f"the bytes are not, so the copy was edited, reformatted, or taken from a working tree " + f"rather than from that release. {_refresh(recorded.distribution, recorded.version)}" + ) + + +def _stub_checkout(root: Path, release: str) -> Path: + """Build the smallest thing `_checkout` accepts: a tree declaring a schema-1 release.""" + declaration = root / _RELEASE_DECLARATION + declaration.parent.mkdir(parents=True, exist_ok=True) + declaration.write_text( + f'[project]\nname = "span-panel-api-schema-1"\nversion = "{release}"\n', encoding="utf-8" + ) + return root + + +def test_a_checkout_on_another_release_fails_in_ci_and_skips_locally( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A present checkout on the wrong release is unavailable, not acceptable. + + Written as its own test because it is the state that actually occurred: a + real, complete, perfectly healthy checkout, sitting one release behind the + one `schema_one_tree.source` names, holding bytes that matched. Without the + position check the comparison passed and reported provenance it had not + verified -- a green that is worse than a skip, because a skip at least says + it did not look. + """ + recorded = schema_one_source() + elsewhere = "0.0.0-not-the-recorded-release" + assert elsewhere != recorded.version, "the stub must not accidentally be the recorded release" + checkout = _stub_checkout(tmp_path / "span-panel-api", elsewhere) + + monkeypatch.delenv("CI", raising=False) + with pytest.raises((pytest.fail.Exception, pytest.skip.Exception), match=elsewhere) as local: + _source_capture(checkout, recorded.version) + assert local.type is pytest.skip.Exception, ( + f"off CI a checkout on another release must skip, got {local.typename}. A developer's " + f"checkout is wherever their work is, and failing them for it is what gets the check " + f"deleted" + ) + + monkeypatch.setenv("CI", "true") + with pytest.raises((pytest.fail.Exception, pytest.skip.Exception)) as in_ci: + _source_capture(checkout, recorded.version) + assert in_ci.type is pytest.fail.Exception, ( + f"under CI a checkout on another release must fail rather than {in_ci.typename.lower()}: " + f"the workflow clones at the tag the recorded release names, so this can only mean the " + f"derivation has come undone" + ) + + +def test_an_unavailable_checkout_fails_in_ci_and_skips_locally( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The guard on the guard. + + The byte comparison is worth exactly as much as the thing that decides whether + it runs, and that thing is one `if`. In `span-panel-api` it went wrong in the + quiet direction -- the variable named a directory that did not exist, every + provenance check skipped, and nine days of drift accumulated behind a summary + line that read like a pass. + + So both halves are asserted, in both environments, for all three of the states + `_checkout` distinguishes. Asserting only the CI half would leave the local + half free to become a failure, which is the change that makes a developer + without a sibling checkout delete the check instead of configuring it. + + `_checkout` is exercised through its public behaviour -- the exception it + raises -- rather than by inspecting `_unconfigured`, so this keeps holding if + the branch moves into the caller. + """ + outcomes = (pytest.fail.Exception, pytest.skip.Exception) + missing = "/nonexistent/span-panel-api" + + monkeypatch.delenv("CI", raising=False) + monkeypatch.setenv(CHECKOUT_VARIABLE, missing) + with pytest.raises(outcomes, match="does not exist") as local: + _checkout() + assert local.type is pytest.skip.Exception, ( + f"off CI an unavailable checkout must skip, got {local.typename}. Failing instead is " + f"what makes a developer without a span-panel-api checkout delete the check rather " + f"than configure it" + ) + + monkeypatch.setenv("CI", "true") + for value, why in ( + ("", "unset"), + (missing, "a path that is gone"), + (str(_REPO_ROOT), "a directory that is not a span-panel-api checkout"), + ): + monkeypatch.setenv(CHECKOUT_VARIABLE, value) + with pytest.raises(outcomes) as raised: + _checkout() + assert raised.type is pytest.fail.Exception, ( + f"under CI, {why} must fail rather than {raised.typename.lower()}: a provenance " + f"check that skips is one an environment can switch off, and the summary line " + f"cannot tell the difference between that and a pass" + ) From c684a6cfeb7f6d583324cd3ef10d54b326c07950 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:58:00 -0700 Subject: [PATCH 131/345] build(fixtures): stop the file-rewriting hooks from touching vendored captures The byte comparison added in the previous commit justified itself before it ever ran in CI: pointed at a checkout on the release `schema_one_tree.source` names, it fails, and the diagnosis it prints is right. The cause is not Prettier. Nothing here runs Prettier over JSON -- `scripts/fix-markdown.sh` passes only `*.md` globs and `prek.toml` has no Prettier hook -- and `.prettierrc` sets `tabWidth: 2` while the vendored capture carries 1-space indentation, so it could not have produced these bytes in any case. The capture is byte-identical to the `schema-1-v1.0.0` release artifact, 1-space indentation included, and its blob has not changed across any commit that contained it. What is wrong is the claim, not the copy: `schema_one_tree.source` records 1.1.0, and upstream re-captured and reformatted the payload between the two releases. The hazard is real, though, and lives in different hooks. `trailing-whitespace`, `end-of-file-fixer` and `mixed-line-ending` rewrite files and cover `tests/`. They have left the captures alone only because both happen to be newline-terminated with LF endings and no trailing whitespace. Strip the final newline from `schema_one_tree.json` and `end-of-file-fixer` reports `Fixing tests/fixtures/schema_one_tree.json`; the first capture vendored without one would have been silently rewritten on commit, and the guard would then have failed against a source the copy genuinely matched when it was made. A vendored byte-identical fixture and an unconditional formatter cannot both exist, and it is the formatter that yields: the bytes are the artifact. So exclude `tests/fixtures/schema_*.json` from those three hooks, and from Prettier as a precaution against a JSON glob being added later. Scoped to `schema_*.json` rather than the directory because only the adapter captures have this property -- the README is prose we own, the migration YAMLs are hand-written, and `unread_declarations_baseline.json` is a hand-maintained inventory of one-line explanations. `schema_*` is the vocabulary `tests/adapter_fixtures.py` already uses, so the next capture vendored is covered without anyone remembering to widen the rule. The read-only hooks keep covering these files; `check-json` especially should. `schema_zero_types.json` was checked for the same drift and has none: it is byte-identical to `homie_schema.json` at `schema-1-v1.0.0`, `v3.0.1`, `main` and the post-#162 location. Its documented source path was wrong -- there is no `tests/fixtures/v2/homie_schema.json` -- and both payloads' real paths, before and after #162, are now recorded. It gets no byte guard: it comes from the bootstrap distribution, which is released on its own tag line, and CI clones the library once at one revision, so a comparison against the schema-1 tag would be against an arbitrary bootstrap revision rather than a release. The README records that gap rather than leaving it to be rediscovered. --- .prettierignore | 12 +++++++++ prek.toml | 21 ++++++++++++--- tests/fixtures/README.md | 58 ++++++++++++++++++++++++++++++++++++---- 3 files changed, 83 insertions(+), 8 deletions(-) diff --git a/.prettierignore b/.prettierignore index 2c66719a..2cdc941b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -40,4 +40,16 @@ poetry.lock # Test files (optional - remove if you want to format test files) # tests/ +# Vendored adapter captures: byte copies of files span-panel-api owns, held to +# their source byte for byte by tests/test_fixture_provenance.py. A formatter and +# a byte-identical vendored fixture cannot both be unconditional, and it is the +# formatter that has to yield -- the bytes are the artifact. +# +# Prophylactic today: nothing here runs Prettier over JSON (scripts/fix-markdown.sh +# passes only *.md globs, and prek.toml has no Prettier hook). But .prettierrc sets +# tabWidth 2 and these captures are 1-space indented as their producer wrote them, +# so the day a JSON glob is added they would be silently reindented and the guard +# would start failing with nobody knowing why. +tests/fixtures/schema_*.json + # Keep docs directory included for formatting diff --git a/prek.toml b/prek.toml index fb65adcb..3fadb63a 100644 --- a/prek.toml +++ b/prek.toml @@ -1,5 +1,20 @@ default_stages = ["pre-commit"] +# `CAPTURES` is the vendored adapter captures: byte copies of files span-panel-api +# owns, held to their source byte for byte by tests/test_fixture_provenance.py. +# The three hooks below that *rewrite* files -- trailing-whitespace, +# end-of-file-fixer and mixed-line-ending -- are excluded from them, because a +# formatter and a byte-identical vendored fixture cannot both be unconditional and +# it is the formatter that has to yield: the bytes are the artifact. +# +# Not hypothetical, merely not yet triggered. Those three cover tests/ today, and +# they leave the current captures alone only because both happen to be +# newline-terminated with LF endings and no trailing whitespace. The first capture +# vendored without a final newline would be rewritten on commit, and the guard +# would then fail against a source the copy had genuinely matched when it was made. +# +# The read-only hooks keep covering them. check-json in particular should: a +# capture that does not parse is worth hearing about wherever it came from. [[repos]] repo = "https://github.com/pre-commit/pre-commit-hooks" rev = "v5.0.0" @@ -10,9 +25,9 @@ hooks = [ { id = "check-added-large-files" }, { id = "check-merge-conflict", exclude = '\.\S*_cache/.*|dist/.*|venv/.*' }, { id = "debug-statements", exclude = '^tests/.*|\.\S*_cache/.*|dist/.*|venv/.*', language_version = "python3.14" }, - { id = "trailing-whitespace", exclude = '\.\S*_cache/.*|dist/.*|venv/.*' }, - { id = "end-of-file-fixer", exclude = '\.\S*_cache/.*|dist/.*|venv/.*' }, - { id = "mixed-line-ending", args = ["--fix=lf"], exclude = '\.\S*_cache/.*|dist/.*|venv/.*' }, + { id = "trailing-whitespace", exclude = '^tests/fixtures/schema_.*\.json$|\.\S*_cache/.*|dist/.*|venv/.*' }, + { id = "end-of-file-fixer", exclude = '^tests/fixtures/schema_.*\.json$|\.\S*_cache/.*|dist/.*|venv/.*' }, + { id = "mixed-line-ending", args = ["--fix=lf"], exclude = '^tests/fixtures/schema_.*\.json$|\.\S*_cache/.*|dist/.*|venv/.*' }, ] # Sync translations/en.json from strings.json and validate translation files diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index ab8dd265..acc6000b 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -10,16 +10,25 @@ Both are byte-identical copies from the `span-panel-api` repository: | File here | Source in `$SPAN_PANEL_API_DIR` | Guarded by | | ------------------------ | ------------------------------------------------- | ------------------------ | -| `schema_zero_types.json` | `tests/fixtures/v2/homie_schema.json` | nothing | +| `schema_zero_types.json` | `tests/reference_payloads/homie_schema.json` | nothing | | `schema_one_tree.json` | `tests/reference_payloads/parent_child_tree.json` | `schema_one_tree.source` | Refresh by copying them again, and keep `schema_one_tree.json` byte-identical to its source: the library pins what that capture leaves unvalued against panelbench's own baseline (`tests/test_reference_tree_values.py` there), so a copy that has drifted puts these tests on a wire no producer sends. -Through the 1.1.0 release the parent/child capture also shipped as package data, at `packages/schema-1/src/span_panel_api_schema_1/reference_payloads/` in the -repository and inside the installed wheel, and this repository imported it from there. `span-panel-api#162` takes it back out of the wheels and moves it to -`tests/reference_payloads/`, so the path in the table above is the one to copy from. The byte comparison below checks both locations, newest first, so it works -against a checkout on either side of that merge; the older path can be dropped once no release this repository can pin still predates it. +Both payloads shipped as package data until recently, and this repository imported them from the installed wheels rather than copying them: + +| File here | Path before `span-panel-api#162` | +| ------------------------ | ----------------------------------------------------------------------------------------- | +| `schema_zero_types.json` | `src/span_panel_api/reference_payloads/homie_schema.json` | +| `schema_one_tree.json` | `packages/schema-1/src/span_panel_api_schema_1/reference_payloads/parent_child_tree.json` | + +`span-panel-api#162` takes both back out of the wheels and moves them to `tests/reference_payloads/`, so the paths in the first table are the ones to copy from +now. The byte comparison below checks both locations for the tree, newest first, so it works against a checkout on either side of that merge; the older path can +be dropped once no release this repository can pin still predates it. + +`schema_zero_types.json` has been verified byte-identical to `homie_schema.json` at `schema-1-v1.0.0`, `v3.0.1`, `main`, and the post-#162 location — the +bootstrap payload has not moved a byte across any of them. It has no automated guard: see "Why the flat capture has no byte guard" below. If a copy changes shape rather than content, the loader in `tests/adapter_fixtures.py` is what needs updating — note that `schema_one_tree.json` is a **dict keyed by device id**, whose `$description` value is a **JSON string**, not a parsed object. `tests/test_fixture_provenance.py` pins that shape, so a capture @@ -80,6 +89,45 @@ Copy from a checkout positioned on a **released tag**, never from a working tree claim nothing can ever be positioned to verify: the byte comparison clones the tag that claim names, and until that tag exists there is nothing to compare against. +## These files are exempt from formatting + +`tests/fixtures/schema_*.json` is excluded from every hook that rewrites files — `trailing-whitespace`, `end-of-file-fixer` and `mixed-line-ending` in +`prek.toml`, and `tests/fixtures/schema_*.json` in `.prettierignore`. + +The reason is the whole point of the byte comparison: **these are captured bytes, not source we own.** A vendored fixture held byte-identical to its source and +an unconditional formatter cannot both exist, and it is the formatter that has to yield. A capture reindented on the way in is one nothing upstream can hold to +its own baseline any more, and the resulting failure names the fixture rather than the hook that broke it — so the person debugging it starts in the wrong +repository. + +It is not hypothetical, only untriggered so far. Those three hooks cover `tests/` and left the captures alone only because both happen to be newline-terminated +with LF endings and no trailing whitespace. Stripping the final newline from `schema_one_tree.json` and running `prek run end-of-file-fixer --all-files` reports +`Fixing tests/fixtures/schema_one_tree.json` without the exclusion, and leaves it untouched with it. The first capture vendored without a final newline would +have been rewritten on commit, and the guard would then have failed against a source the copy genuinely matched when it was made. + +Prettier is excluded as a precaution rather than a fix: nothing here currently runs it over JSON — `scripts/fix-markdown.sh` passes only `*.md` globs and +`prek.toml` has no Prettier hook — but `.prettierrc` sets `tabWidth: 2` while these captures carry their producer's own 1-space indentation, so a JSON glob +added later would silently reindent them. + +The scope is `schema_*.json` rather than the whole directory, because only the adapter captures have this property. `tests/fixtures/README.md` is prose this +repository owns and should keep being formatted; the migration YAMLs are hand-written source; and `unread_declarations_baseline.json`, despite being a +mechanically-checked inventory, is hand-maintained — its values are one-line human explanations. The `schema_*` prefix is the vocabulary +`tests/adapter_fixtures.py` already uses for adapter captures, so the next one vendored is covered without anyone remembering to widen the rule. + +The read-only hooks still cover these files, `check-json` in particular. A capture that does not parse is worth hearing about wherever it came from. + +## Why the flat capture has no byte guard + +`schema_zero_types.json` is vendored on the same terms as the tree but has no `.source` file and no byte comparison, and that is a deliberate gap rather than an +oversight. + +It comes from the **bootstrap** distribution, `span-panel-api`, which is released on its own tag line (`v3.1.0`), while the tree comes from +`span-panel-api-schema-1` (`schema-1-v1.1.0`). CI clones the library once, at one revision. A checkout positioned at the schema-1 tag holds the bootstrap +sources at whatever state they happened to be in at that commit, which is not the same thing as a bootstrap _release_ — so a byte comparison there would be +against an arbitrary revision, which is exactly the "proves only that the copy matches something" failure the tree's release check exists to prevent. + +Guarding it properly means either a second checkout at the bootstrap tag or a second job, and neither is free. Against that: the payload has been byte-stable +upstream across every revision checked. The gap is recorded here so the next person weighs it rather than rediscovering it. + ## Derived variants The batteryless and PV-less trees are **derived in memory**, not committed: `adapter_fixtures.schema_one_tree(without="bess")` and `without="pv"` return the From 64bb0f17773034a3199812d34ea8a6231843de0b Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:13:12 -0700 Subject: [PATCH 132/345] fix(fixtures): re-vendor the schema-1 capture, which recorded a producer defect The byte comparison added two commits ago justified itself the first time it ran against a real checkout, and it caught a class of error nothing else in either repository could see. `tests/fixtures/schema_one_tree.json` was vendored from a stale sibling checkout. `[tool.uv.sources]` pointed at a `span-panel-api` worktree that predated span-panel-api#161, so `span_panel_api_schema_1.reference_payloads` resolved there and the copy inherited a capture that still recorded the emitter's `$settable` defect on a locked relay -- circuit `573066aa` advertising `settable: true` on a relay the specification declares locked -- together with `load-shed/priority` values of `UNKNOWN` that no producer publishes. This branch has been testing its locked-circuit and priority-settability work against exactly that defect. The copy itself was faithful: byte-identical to the `schema-1-v1.0.0` release artifact, and `schema_one_tree.source` claimed 1.1.0. Both halves looked right in isolation. The version guard compared a string to a string and passed; the conformance tests passed because they were asserting against the capture. Only holding the bytes against the release the claim names showed the mismatch. So: repoint the path overrides at the checkout that tracks `main`, and re-vendor byte for byte from it. A path override redirects what every import in the suite resolves to, and one left aimed at a stale checkout silently tests against a producer that no longer exists. Seven tests moved with the capture, none of them wrong to assert on the fixture: `test_bess_telemetry.py` (3) carried the pre-#39 emitter frame, which its own docstring named and told the next person to revisit on refresh. `power-flows` 0.3 defines a node balance -- every term positive when power flows into the thing it names -- so the four terms sum to zero rather than three summing to the fourth, `pv` is negative while producing, and a charging battery publishes positive. The identity is now asserted against zero, which makes it a statement about the frame rather than about four numbers. `test_charging_reads_positive` becomes `test_charging_reads_negative`: the wire is charge-positive and each path negates once, so both battery sensors are discharge-positive. The library pins the same four values and the same relation on the same capture and says the earlier fixture was the thing that was wrong. No production code 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. `test_declared_but_unread.py` (2) and `test_schema_discovery.py` (2) followed `circuit/connection/count` and `lugs/connection/count`, which the corrected capture no longer declares; their baseline entries are dropped as the test's own message instructs. The diagnostics assertion now addresses its row by path instead of by position -- the ordering is pinned once already, and re-pinning it there is why a dropped property failed that test with a diff about the wrong row. `schema_zero_types.json` was checked for the same problem and has none: still byte-identical to `homie_schema.json` on the updated `main`, which now includes span-panel-api#165. --- .prettierignore | 15 +- pyproject.toml | 27 +- tests/fixtures/README.md | 32 +- tests/fixtures/schema_one_tree.json | 464 +++++++++--------- .../unread_declarations_baseline.json | 2 - tests/test_bess_telemetry.py | 101 ++-- tests/test_schema_discovery.py | 18 +- uv.lock | 24 +- 8 files changed, 370 insertions(+), 313 deletions(-) diff --git a/.prettierignore b/.prettierignore index 2cdc941b..0bcdde13 100644 --- a/.prettierignore +++ b/.prettierignore @@ -45,11 +45,16 @@ poetry.lock # a byte-identical vendored fixture cannot both be unconditional, and it is the # formatter that has to yield -- the bytes are the artifact. # -# Prophylactic today: nothing here runs Prettier over JSON (scripts/fix-markdown.sh -# passes only *.md globs, and prek.toml has no Prettier hook). But .prettierrc sets -# tabWidth 2 and these captures are 1-space indented as their producer wrote them, -# so the day a JSON glob is added they would be silently reindented and the guard -# would start failing with nobody knowing why. +# PRECAUTIONARY. Prettier has never touched these files and did not cause any +# drift we have seen: nothing here runs it over JSON (scripts/fix-markdown.sh +# passes only *.md globs, and prek.toml has no Prettier hook). The drift the byte +# guard actually caught was a stale source checkout, not a formatter. +# +# It is not idle, though. `schema_zero_types.json` carries its producer's 4-space +# indentation, and Prettier under .prettierrc rewrites it from 14675 to 11779 +# bytes -- and that file has no byte guard, so nothing would report it. The day a +# JSON glob is added, this line is what stands between a captured payload and a +# silent 3 KB rewrite. tests/fixtures/schema_*.json # Keep docs directory included for formatting diff --git a/pyproject.toml b/pyproject.toml index 6794db86..a864815a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,22 +45,27 @@ dev = [ package = false [tool.uv.sources] -# Pointed at the `-security` worktree while 3.1.0 is unreleased. The primary -# checkout is still on 3.0.1 and has none of this work, so pointing here is not a -# convenience -- against the primary checkout the security tests would resolve the -# old library and pass without exercising anything. Move these back to -# `../../span/span-panel-api` once 3.1.0 is merged and published. +# The primary checkout, which is where 3.1.0 and both 1.1.0 adapters now live. +# +# These pointed at a `-security` worktree while that work was unmerged. That +# outlived its reason and became actively harmful: the worktree never received +# span-panel-api#161, so `span_panel_api_schema_1.reference_payloads` resolved to +# a parent/child capture that still recorded the emitter's `$settable` defect on +# a locked relay, and `tests/fixtures/schema_one_tree.json` was vendored from it. +# A path override is a redirection of what every import in the suite resolves to; +# leaving one aimed at a stale checkout silently tests against a producer that no +# longer exists. Point these at a checkout that tracks `main`, and move them only +# for as long as an unmerged branch is actually under test. # # A path that does not exist is not a soft failure: `uv run` cannot build the # environment at all, so nothing in this repo runs until it is corrected. # # CI never reads this block -- it deletes it and re-locks from PyPI (.github/ -# workflows/ci.yml) -- so a stale path here breaks only local work, and only -# noisily. It does mean CI cannot pass until 3.1.0 and both 1.1.0 adapters are on -# PyPI. -span-panel-api = { path = "../../span/span-panel-api-security", editable = true } -span-panel-api-schema-0 = { path = "../../span/span-panel-api-security/packages/schema-0", editable = true } -span-panel-api-schema-1 = { path = "../../span/span-panel-api-security/packages/schema-1", editable = true } +# workflows/ci.yml) -- so a stale path here breaks only local work. It does mean +# CI cannot pass until 3.1.0 and both 1.1.0 adapters are on PyPI. +span-panel-api = { path = "../../span/span-panel-api", editable = true } +span-panel-api-schema-0 = { path = "../../span/span-panel-api/packages/schema-0", editable = true } +span-panel-api-schema-1 = { path = "../../span/span-panel-api/packages/schema-1", editable = true } [tool.jscpd] path = ["custom_components/span_panel", "./*.{html,md}"] diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index acc6000b..66ed033e 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -78,6 +78,23 @@ and that is how a stale vendored capture went unnoticed for nine days."_ Do not CI derives the tag from `schema_one_tree.source` rather than hardcoding it — `span-panel-api-schema-1==1.1.0` becomes `schema-1-v1.1.0` — so there is no second copy of the pin that could disagree with the first. +### What it caught the first time it ran + +Worth recording, because it is the failure mode the guard was built for and it had already happened. + +`schema_one_tree.json` was vendored from a **stale sibling checkout**. `pyproject.toml` pointed its `[tool.uv.sources]` path overrides at a `span-panel-api` +worktree that predated span-panel-api#161, so `span_panel_api_schema_1.reference_payloads` resolved there, and the copy inherited a capture that still recorded +the emitter's `$settable` defect on a locked relay together with `load-shed/priority` values (`UNKNOWN`) that no producer publishes. The copy itself was +faithful — byte-identical to the `schema-1-v1.0.0` release artifact — and `schema_one_tree.source` claimed 1.1.0. Both halves looked right in isolation; only +holding the bytes against the release the claim names showed the mismatch. + +Nothing else in either repository could see it. The version guard compared a string to a string and passed. The conformance tests passed, because they were +asserting against the capture. The integration was testing its locked-circuit and priority-settability work against a producer defect that upstream had already +fixed. + +The fix was to repoint the path overrides at a checkout that tracks `main` and re-vendor. A path override redirects what every import in the suite resolves to, +so one aimed at a stale checkout quietly tests against a producer that no longer exists — worth checking before believing any fixture taken through one. + ### Refreshing In one commit: @@ -85,9 +102,11 @@ In one commit: 1. Copy `tests/reference_payloads/parent_child_tree.json` from `$SPAN_PANEL_API_DIR` over `schema_one_tree.json`, with no reformatting. 2. Set the version in `schema_one_tree.source` to the release it came from. -Copy from a checkout positioned on a **released tag**, never from a working tree that is ahead of one. A capture taken from unreleased work carries a version -claim nothing can ever be positioned to verify: the byte comparison clones the tag that claim names, and until that tag exists there is nothing to compare -against. +Copy from a checkout positioned on the release `schema_one_tree.source` names, and prefer a released tag. The current copy is an exception made knowingly: it +comes from `main`, because `schema-1-v1.1.0` is not cut yet and the alternative was to keep shipping a capture that records a producer defect. It verifies +against a `main` checkout today and will verify against the tag once 1.1.0 is released from these bytes; if `main` moves the capture again first, the guard says +so, which is the whole point. What is never acceptable is a capture taken from a checkout that is _behind_ the recorded release — that is how this one went +wrong. ## These files are exempt from formatting @@ -104,9 +123,10 @@ with LF endings and no trailing whitespace. Stripping the final newline from `sc `Fixing tests/fixtures/schema_one_tree.json` without the exclusion, and leaves it untouched with it. The first capture vendored without a final newline would have been rewritten on commit, and the guard would then have failed against a source the copy genuinely matched when it was made. -Prettier is excluded as a precaution rather than a fix: nothing here currently runs it over JSON — `scripts/fix-markdown.sh` passes only `*.md` globs and -`prek.toml` has no Prettier hook — but `.prettierrc` sets `tabWidth: 2` while these captures carry their producer's own 1-space indentation, so a JSON glob -added later would silently reindent them. +Prettier is excluded as a **precaution, not a fix.** It has never touched these files and caused none of the drift we have seen — nothing here runs it over JSON +(`scripts/fix-markdown.sh` passes only `*.md` globs, `prek.toml` has no Prettier hook), and the drift the byte guard actually caught was a stale source +checkout. The line still earns its place: `schema_zero_types.json` carries its producer's 4-space indentation, and Prettier under `.prettierrc` rewrites it from +14675 to 11779 bytes. That file has no byte guard, so nothing would report it. The scope is `schema_*.json` rather than the whole directory, because only the adapter captures have this property. `tests/fixtures/README.md` is prose this repository owns and should keep being formatted; the migration YAMLs are hand-written source; and `unread_declarations_baseline.json`, despite being a diff --git a/tests/fixtures/schema_one_tree.json b/tests/fixtures/schema_one_tree.json index 0aee1b83..c6700deb 100644 --- a/tests/fixtures/schema_one_tree.json +++ b/tests/fixtures/schema_one_tree.json @@ -1,234 +1,234 @@ { - "0ab966b95f92a6a51ec548485aa85f54": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "breaker/poles": "1", - "breaker/rating": "15", - "info/name": "Kitchen Lights", - "info/spaces": "1", - "load-shed/priority": "UNKNOWN", - "meter/active-power": "-121.0", - "meter/current": "1.0083333333333333", - "meter/exported-energy": "2.0166666666666666", - "meter/imported-energy": "0.0", - "pcs/managed": "true", - "pcs/priority": "1", - "switch/relay": "CLOSED", - "switch/relay-controllable": "true", - "switch/relay-requester": "NONE" - }, - "573066aaddd7b75114c4563ce3af18c4": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Solar Inverter\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "breaker/poles": "2", - "breaker/rating": "30", - "connection/feeds-device-id": "pv", - "connection/feeds-device-status": "OK", - "connection/feeds-device-type": "energy.ebus.device.pv", - "info/name": "Solar Inverter", - "info/spaces": "36,38", - "load-shed/priority": "NEVER", - "meter/active-power": "8500.0", - "meter/current": "35.416666666666664", - "meter/exported-energy": "0.0", - "meter/imported-energy": "141.66666666666666", - "pcs/managed": "false", - "pcs/priority": "5", - "switch/relay": "CLOSED", - "switch/relay-controllable": "false", - "switch/relay-requester": "NONE" - }, - "62d0e03897b337b57101aae82f1e9ba2": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "breaker/poles": "2", - "breaker/rating": "50", - "connection/feeds-device-id": "evse", - "connection/feeds-device-status": "OK", - "connection/feeds-device-type": "energy.ebus.device.evse", - "info/name": "SPAN Drive - Garage", - "info/spaces": "32,34", - "load-shed/priority": "OFF_GRID", - "meter/active-power": "-2410.0", - "meter/current": "10.041666666666666", - "meter/exported-energy": "40.166666666666664", - "meter/imported-energy": "0.0", - "pcs/managed": "true", - "pcs/priority": "3", - "switch/relay": "CLOSED", - "switch/relay-controllable": "true", - "switch/relay-requester": "NONE" - }, - "bess": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.bess\", \"name\": \"Battery\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"soc\": {\"name\": \"soc\", \"type\": \"energy.ebus.capability.soc\", \"properties\": {\"soc\": {\"name\": \"State of charge\", \"datatype\": \"float\", \"unit\": \"%\"}, \"soe\": {\"name\": \"State of energy\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"communication-state\": {\"name\": \"Communication state\", \"datatype\": \"enum\", \"format\": \"OK,DEGRADED,LOST,UNKNOWN\"}}}}, \"children\": [\"bess-mid\"], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "info/firmware-version": "example-bess/v0.1.0", - "info/model": "Example BESS", - "info/nameplate-capacity": "13.5", - "info/part-number": "SPN-BESS-001", - "info/serial-number": "EXAMPLE-BESS-40T-001", - "info/vendor-name": "Span", - "meter/active-power": "-3500.0", - "soc/soc": "50.410493827160494", - "soc/soe": "6.805416666666667", - "status/communication-state": "OK" - }, - "bess-mid": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.mid\", \"name\": \"Microgrid Interconnect Device\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}}}, \"grid\": {\"name\": \"grid\", \"type\": \"energy.ebus.capability.grid\", \"properties\": {\"islanding-state\": {\"name\": \"Islanding state of the BESS-integrated grid-forming device\", \"datatype\": \"enum\", \"format\": \"ON_GRID,OFF_GRID,UNKNOWN\"}, \"grid-state\": {\"name\": \"Sensed grid condition\", \"datatype\": \"enum\", \"format\": \"UP,DOWN,DEGRADED,UNKNOWN\"}, \"grid-forming-entity\": {\"name\": \"Identity of the currently grid-forming entity\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"bess\", \"extensions\": []}", - "$state": "ready", - "grid/grid-forming-entity": "GRID", - "grid/grid-state": "UP", - "grid/islanding-state": "ON_GRID", - "info/firmware-version": "example-mid/v0.1.0", - "info/hardware-version": "rev1", - "info/model": "SPAN MID", - "info/serial-number": "EXAMPLE-BESS-40T-001-mid", - "info/vendor-name": "Span" - }, - "d3724e0d660ba506aa79c1cafe5d1181": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garage Outlet\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "breaker/poles": "1", - "breaker/rating": "20", - "info/name": "Garage Outlet", - "info/spaces": "2", - "load-shed/priority": "UNKNOWN", - "meter/active-power": "-122.0", - "meter/current": "1.0166666666666666", - "meter/exported-energy": "2.033333333333333", - "meter/imported-energy": "0.0", - "pcs/managed": "true", - "pcs/priority": "2", - "switch/relay": "CLOSED", - "switch/relay-controllable": "true", - "switch/relay-requester": "NONE" - }, - "evse": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "config/max-charge-current": "32", - "config/user-max-charge-current": "32", - "info/firmware-version": "example/v0.1.0", - "info/model": "SPAN Drive", - "info/part-number": "SPN-DRV-001", - "info/serial-number": "SIM-EVSE-example-40t-001", - "info/vendor-name": "SPAN", - "meter/advertised-current": "32.0", - "status/status": "CHARGING", - "switch/lock-state": "LOCKED" - }, - "evse-2": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "config/max-charge-current": "32", - "config/user-max-charge-current": "32", - "info/firmware-version": "example/v0.1.0", - "info/model": "SPAN Drive", - "info/part-number": "SPN-DRV-001", - "info/serial-number": "SIM-EVSE-example-40t-001-2", - "info/vendor-name": "SPAN", - "meter/advertised-current": "32.0", - "status/status": "AVAILABLE", - "switch/lock-state": "UNLOCKED" - }, - "example-40t-001": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Example 40-tab Panel\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"enum\", \"format\": \"MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"data-model-version\": {\"name\": \"eBus data-model version (parent/child schema discriminator)\", \"datatype\": \"string\"}}}, \"door\": {\"name\": \"door\", \"type\": \"energy.ebus.capability.door\", \"properties\": {\"state\": {\"name\": \"Door state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"voltage-a\": {\"name\": \"L1 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}, \"voltage-b\": {\"name\": \"L2 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Main breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"enabled\": {\"name\": \"PCS system enabled\", \"datatype\": \"boolean\"}, \"active\": {\"name\": \"PCS system actively controlling one (or more) loads\", \"datatype\": \"boolean\"}, \"import-limit\": {\"name\": \"The power import limit currently being managed to\", \"datatype\": \"float\", \"unit\": \"A\"}, \"binding-constraint\": {\"name\": \"Which constraint class currently sets the import limit\", \"datatype\": \"enum\", \"format\": \"FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN\"}, \"feed-import-limit\": {\"name\": \"Limit of maximum power feeding the distribution enclosure\", \"datatype\": \"float\", \"unit\": \"A\"}, \"feed-import-limit-enablement\": {\"name\": \"Enablement status of the feed-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"feed-import-limit-active\": {\"name\": \"Is feed-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"operator-import-limit\": {\"name\": \"Operator-imposed maximum import limit\", \"datatype\": \"float\", \"unit\": \"A\"}, \"operator-import-limit-enablement\": {\"name\": \"Enablement status of the operator-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"operator-import-limit-active\": {\"name\": \"Is operator-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"off-grid-import-limit\": {\"name\": \"Off-Grid limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"off-grid-import-limit-enablement\": {\"name\": \"Enablement status of the off-grid-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"off-grid-import-limit-active\": {\"name\": \"Is off-grid-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"requested-import-limit\": {\"name\": \"Requested limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"requested-import-limit-enablement\": {\"name\": \"Enablement status of the requested-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"requested-import-limit-active\": {\"name\": \"Is requested-import-limit currently being enforced?\", \"datatype\": \"boolean\"}}}, \"shed-forecast\": {\"name\": \"shed-forecast\", \"type\": \"energy.ebus.capability.shed-forecast\", \"properties\": {\"total-time-remaining\": {\"name\": \"Estimated total time before all sheddable circuits are shed (off-grid runtime)\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"time-to-priority-shed\": {\"name\": \"Estimated time before the next priority tier is shed\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-total-time-remaining\": {\"name\": \"Estimated total time assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-time-to-priority-shed\": {\"name\": \"Estimated time to next priority shed assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"confidence\": {\"name\": \"Confidence of the shed-forecast estimate\", \"datatype\": \"enum\", \"format\": \"LOW,MEDIUM,HIGH\"}}}, \"shed\": {\"name\": \"shed\", \"type\": \"energy.ebus.capability.shed\", \"properties\": {\"asserted-islanding-state\": {\"name\": \"Consumer-asserted islanding-state (grid-state override during MID/BESS comm-loss)\", \"datatype\": \"enum\", \"format\": \"NONE,ON_GRID,OFF_GRID\", \"settable\": true}, \"policy\": {\"name\": \"Shed policy (algorithm and parameters)\", \"datatype\": \"json\", \"format\": \"{\\\"$id\\\":\\\"soc-priority.v1\\\",\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"algorithm\\\",\\\"parameters\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"algorithm\\\":{\\\"const\\\":\\\"soc-priority.v1\\\"},\\\"parameters\\\":{\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"soc-threshold-shed\\\",\\\"soc-threshold-release\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"soc-threshold-shed\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent below which SOC_THRESHOLD circuits shed\\\"},\\\"soc-threshold-release\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent above which shed SOC_THRESHOLD circuits restore\\\"}}}}}\"}}}, \"power-flows\": {\"name\": \"power-flows\", \"type\": \"energy.ebus.capability.power-flows\", \"properties\": {\"pv\": {\"name\": \"PV power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"battery\": {\"name\": \"Battery/BESS power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"grid\": {\"name\": \"Grid power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"site\": {\"name\": \"Site power flow\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"relay\": {\"name\": \"Main relay\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"ethernet\": {\"name\": \"Is Ethernet network interface operational?\", \"datatype\": \"boolean\"}, \"wifi\": {\"name\": \"Is Wi-Fi network interface operational?\", \"datatype\": \"boolean\"}, \"wifi-ssid\": {\"name\": \"SSID to which Wi-Fi network interface is connected\", \"datatype\": \"string\"}, \"cloud-connection\": {\"name\": \"Device connected to vendor cloud?\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,UNCONNECTED,CONNECTED\"}, \"postal-code\": {\"name\": \"Postal (Zip) code\", \"datatype\": \"string\"}, \"time-zone\": {\"name\": \"Time zone\", \"datatype\": \"string\"}}}}, \"children\": [\"bess\", \"0ab966b95f92a6a51ec548485aa85f54\", \"d3724e0d660ba506aa79c1cafe5d1181\", \"62d0e03897b337b57101aae82f1e9ba2\", \"fe8b85c15bc9610c1b8b4ebc6f82488d\", \"573066aaddd7b75114c4563ce3af18c4\", \"evse\", \"evse-2\", \"lugs-upstream\", \"lugs-downstream\", \"pv\"], \"extensions\": []}", - "$state": "ready", - "breaker/rating": "200", - "door/state": "CLOSED", - "info/data-model-version": "1.0", - "info/firmware-version": "example/v0.1.0", - "info/hardware-version": "rev2", - "info/model": "MAIN_40", - "info/serial-number": "example-40t-001", - "info/vendor-name": "Span", - "meter/voltage-a": "120.0", - "meter/voltage-b": "120.0", - "pcs/active": "false", - "pcs/binding-constraint": "NONE", - "pcs/enabled": "false", - "pcs/feed-import-limit": "0.0", - "pcs/feed-import-limit-active": "false", - "pcs/feed-import-limit-enablement": "UNCONFIGURED", - "pcs/import-limit": "0.0", - "pcs/off-grid-import-limit": "0.0", - "pcs/off-grid-import-limit-active": "false", - "pcs/off-grid-import-limit-enablement": "UNCONFIGURED", - "pcs/operator-import-limit": "0.0", - "pcs/operator-import-limit-active": "false", - "pcs/operator-import-limit-enablement": "UNCONFIGURED", - "pcs/requested-import-limit": "0.0", - "pcs/requested-import-limit-active": "false", - "pcs/requested-import-limit-enablement": "UNCONFIGURED", - "power-flows/battery": "-3500.0", - "power-flows/grid": "-2347.0", - "power-flows/pv": "8500.0", - "power-flows/site": "2653.0", - "shed-forecast/confidence": "HIGH", - "shed-forecast/full-charge-time-to-priority-shed": "3038", - "shed-forecast/full-charge-total-time-remaining": "4320", - "shed-forecast/time-to-priority-shed": "3037", - "shed-forecast/total-time-remaining": "4320", - "shed/asserted-islanding-state": "NONE", - "shed/policy": "{\"algorithm\": \"soc-priority.v1\", \"parameters\": {\"soc-threshold-shed\": 20, \"soc-threshold-release\": 30}}", - "status/cloud-connection": "CONNECTED", - "status/ethernet": "true", - "status/postal-code": "94103", - "status/relay": "CLOSED", - "status/time-zone": "America/Los_Angeles", - "status/wifi": "true", - "status/wifi-ssid": "example-wifi" - }, - "fe8b85c15bc9610c1b8b4ebc6f82488d": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "breaker/poles": "2", - "breaker/rating": "50", - "connection/feeds-device-id": "evse-2", - "connection/feeds-device-status": "OK", - "connection/feeds-device-type": "energy.ebus.device.evse", - "info/name": "SPAN Drive - Driveway", - "info/spaces": "35,37", - "load-shed/priority": "OFF_GRID", - "meter/active-power": "0.0", - "meter/current": "0.0", - "meter/exported-energy": "0.0", - "meter/imported-energy": "0.0", - "pcs/managed": "true", - "pcs/priority": "4", - "switch/relay": "CLOSED", - "switch/relay-controllable": "true", - "switch/relay-requester": "NONE" - }, - "lugs-downstream": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Downstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "info/direction": "DOWNSTREAM", - "meter/active-power": "-5847.0", - "meter/current-a": "46.46666666666666", - "meter/current-b": "46.474999999999994", - "meter/exported-energy": "141.66666666666666", - "meter/imported-energy": "44.21666666666666" - }, - "lugs-upstream": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Upstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "connection/fed-by-device-id": "bess", - "connection/fed-by-device-status": "OK", - "connection/fed-by-device-type": "energy.ebus.device.bess", - "info/direction": "UPSTREAM", - "meter/active-power": "-5847.0", - "meter/current-a": "46.46666666666666", - "meter/current-b": "46.474999999999994", - "meter/exported-energy": "141.66666666666666", - "meter/imported-energy": "44.21666666666666" - }, - "pv": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.pv\", \"name\": \"Solar\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nominal-power\": {\"name\": \"Nominal power\", \"datatype\": \"float\", \"unit\": \"W\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "info/firmware-version": "example-pv/v0.1.0", - "info/model": "IQ8PLUS-72-2-US", - "info/nominal-power": "10000.0", - "info/vendor-name": "Enphase" - } + "0ab966b95f92a6a51ec548485aa85f54": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787706163063, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "15", + "info/name": "Kitchen Lights", + "info/spaces": "1", + "load-shed/priority": "SOC_THRESHOLD", + "meter/active-power": "-121.0", + "meter/current": "1.0083333333333333", + "meter/exported-energy": "4.033333333333333", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "1", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "573066aaddd7b75114c4563ce3af18c4": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787706163064, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Solar Inverter\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "30", + "connection/feeds-device-id": "pv", + "connection/feeds-device-status": "OK", + "connection/feeds-device-type": "energy.ebus.device.pv", + "info/name": "Solar Inverter", + "info/spaces": "36,38", + "load-shed/priority": "NEVER", + "meter/active-power": "8500.0", + "meter/current": "35.416666666666664", + "meter/exported-energy": "0.0", + "meter/imported-energy": "182.16666666666666", + "pcs/managed": "false", + "pcs/priority": "5", + "switch/relay": "CLOSED", + "switch/relay-controllable": "false", + "switch/relay-requester": "CONFIGURATION" + }, + "62d0e03897b337b57101aae82f1e9ba2": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787706163063, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "50", + "connection/feeds-device-id": "evse", + "connection/feeds-device-status": "OK", + "connection/feeds-device-type": "energy.ebus.device.evse", + "info/name": "SPAN Drive - Garage", + "info/spaces": "32,34", + "load-shed/priority": "OFF_GRID", + "meter/active-power": "-2410.0", + "meter/current": "10.041666666666666", + "meter/exported-energy": "80.33333333333333", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "3", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "bess": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787706163064, \"type\": \"energy.ebus.device.bess\", \"name\": \"Battery\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"soc\": {\"name\": \"soc\", \"type\": \"energy.ebus.capability.soc\", \"properties\": {\"soc\": {\"name\": \"State of charge\", \"datatype\": \"float\", \"unit\": \"%\"}, \"soe\": {\"name\": \"State of energy\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"communication-state\": {\"name\": \"Communication state\", \"datatype\": \"enum\", \"format\": \"OK,DEGRADED,LOST,UNKNOWN\"}}}}, \"children\": [\"bess-mid\"], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "info/firmware-version": "example-bess/v0.1.0", + "info/model": "Example BESS", + "info/nameplate-capacity": "13.5", + "info/part-number": "SPN-BESS-001", + "info/serial-number": "EXAMPLE-BESS-40T-001", + "info/vendor-name": "Span", + "meter/active-power": "3500.0", + "soc/soc": "50.410493827160494", + "soc/soe": "6.805416666666667", + "status/communication-state": "OK" + }, + "bess-mid": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787706163064, \"type\": \"energy.ebus.device.mid\", \"name\": \"Microgrid Interconnect Device\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}}}, \"grid\": {\"name\": \"grid\", \"type\": \"energy.ebus.capability.grid\", \"properties\": {\"islanding-state\": {\"name\": \"Islanding state of the BESS-integrated grid-forming device\", \"datatype\": \"enum\", \"format\": \"ON_GRID,OFF_GRID,UNKNOWN\"}, \"grid-state\": {\"name\": \"Sensed grid condition\", \"datatype\": \"enum\", \"format\": \"UP,DOWN,DEGRADED,UNKNOWN\"}, \"grid-forming-entity\": {\"name\": \"Identity of the currently grid-forming entity\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"bess\", \"extensions\": []}", + "$state": "ready", + "grid/grid-forming-entity": "GRID", + "grid/grid-state": "UP", + "grid/islanding-state": "ON_GRID", + "info/firmware-version": "example-mid/v0.1.0", + "info/hardware-version": "rev1", + "info/model": "SPAN MID", + "info/serial-number": "EXAMPLE-BESS-40T-001-mid", + "info/vendor-name": "Span" + }, + "d3724e0d660ba506aa79c1cafe5d1181": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787706163063, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garage Outlet\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "20", + "info/name": "Garage Outlet", + "info/spaces": "2", + "load-shed/priority": "SOC_THRESHOLD", + "meter/active-power": "-122.0", + "meter/current": "1.0166666666666666", + "meter/exported-energy": "4.066666666666666", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "2", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "evse": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787706163064, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "config/max-charge-current": "32", + "config/user-max-charge-current": "32", + "info/firmware-version": "example/v0.1.0", + "info/model": "SPAN Drive", + "info/part-number": "SPN-DRV-001", + "info/serial-number": "SIM-EVSE-example-40t-001", + "info/vendor-name": "SPAN", + "meter/advertised-current": "32.0", + "status/status": "CHARGING", + "switch/lock-state": "LOCKED" + }, + "evse-2": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787706163064, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "config/max-charge-current": "32", + "config/user-max-charge-current": "32", + "info/firmware-version": "example/v0.1.0", + "info/model": "SPAN Drive", + "info/part-number": "SPN-DRV-001", + "info/serial-number": "SIM-EVSE-example-40t-001-2", + "info/vendor-name": "SPAN", + "meter/advertised-current": "32.0", + "status/status": "AVAILABLE", + "switch/lock-state": "UNLOCKED" + }, + "example-40t-001": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787706163064, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Example 40-tab Panel\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"enum\", \"format\": \"MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"data-model-version\": {\"name\": \"eBus data-model version (parent/child schema discriminator)\", \"datatype\": \"string\"}}}, \"door\": {\"name\": \"door\", \"type\": \"energy.ebus.capability.door\", \"properties\": {\"state\": {\"name\": \"Door state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"voltage-a\": {\"name\": \"L1 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}, \"voltage-b\": {\"name\": \"L2 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Main breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"enabled\": {\"name\": \"PCS system enabled\", \"datatype\": \"boolean\"}, \"active\": {\"name\": \"PCS system actively controlling one (or more) loads\", \"datatype\": \"boolean\"}, \"import-limit\": {\"name\": \"The power import limit currently being managed to\", \"datatype\": \"float\", \"unit\": \"A\"}, \"binding-constraint\": {\"name\": \"Which constraint class currently sets the import limit\", \"datatype\": \"enum\", \"format\": \"FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN\"}, \"feed-import-limit\": {\"name\": \"Limit of maximum power feeding the distribution enclosure\", \"datatype\": \"float\", \"unit\": \"A\"}, \"feed-import-limit-enablement\": {\"name\": \"Enablement status of the feed-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"feed-import-limit-active\": {\"name\": \"Is feed-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"operator-import-limit\": {\"name\": \"Operator-imposed maximum import limit\", \"datatype\": \"float\", \"unit\": \"A\"}, \"operator-import-limit-enablement\": {\"name\": \"Enablement status of the operator-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"operator-import-limit-active\": {\"name\": \"Is operator-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"off-grid-import-limit\": {\"name\": \"Off-Grid limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"off-grid-import-limit-enablement\": {\"name\": \"Enablement status of the off-grid-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"off-grid-import-limit-active\": {\"name\": \"Is off-grid-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"requested-import-limit\": {\"name\": \"Requested limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"requested-import-limit-enablement\": {\"name\": \"Enablement status of the requested-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"requested-import-limit-active\": {\"name\": \"Is requested-import-limit currently being enforced?\", \"datatype\": \"boolean\"}}}, \"shed-forecast\": {\"name\": \"shed-forecast\", \"type\": \"energy.ebus.capability.shed-forecast\", \"properties\": {\"total-time-remaining\": {\"name\": \"Estimated total time before all sheddable circuits are shed (off-grid runtime)\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"time-to-priority-shed\": {\"name\": \"Estimated time before the next priority tier is shed\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-total-time-remaining\": {\"name\": \"Estimated total time assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-time-to-priority-shed\": {\"name\": \"Estimated time to next priority shed assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"confidence\": {\"name\": \"Confidence of the shed-forecast estimate\", \"datatype\": \"enum\", \"format\": \"LOW,MEDIUM,HIGH\"}}}, \"shed\": {\"name\": \"shed\", \"type\": \"energy.ebus.capability.shed\", \"properties\": {\"asserted-islanding-state\": {\"name\": \"Consumer-asserted islanding-state (grid-state override during MID/BESS comm-loss)\", \"datatype\": \"enum\", \"format\": \"NONE,ON_GRID,OFF_GRID\", \"settable\": true}, \"policy\": {\"name\": \"Shed policy (algorithm and parameters)\", \"datatype\": \"json\", \"format\": \"{\\\"$id\\\":\\\"soc-priority.v1\\\",\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"algorithm\\\",\\\"parameters\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"algorithm\\\":{\\\"const\\\":\\\"soc-priority.v1\\\"},\\\"parameters\\\":{\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"soc-threshold-shed\\\",\\\"soc-threshold-release\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"soc-threshold-shed\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent below which SOC_THRESHOLD circuits shed\\\"},\\\"soc-threshold-release\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent above which shed SOC_THRESHOLD circuits restore\\\"}}}}}\"}}}, \"power-flows\": {\"name\": \"power-flows\", \"type\": \"energy.ebus.capability.power-flows\", \"properties\": {\"pv\": {\"name\": \"PV power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"battery\": {\"name\": \"Battery/BESS power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"grid\": {\"name\": \"Grid power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"site\": {\"name\": \"Site power flow\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"relay\": {\"name\": \"Main relay\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"ethernet\": {\"name\": \"Is Ethernet network interface operational?\", \"datatype\": \"boolean\"}, \"wifi\": {\"name\": \"Is Wi-Fi network interface operational?\", \"datatype\": \"boolean\"}, \"wifi-ssid\": {\"name\": \"SSID to which Wi-Fi network interface is connected\", \"datatype\": \"string\"}, \"cloud-connection\": {\"name\": \"Device connected to vendor cloud?\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,UNCONNECTED,CONNECTED\"}, \"postal-code\": {\"name\": \"Postal (Zip) code\", \"datatype\": \"string\"}, \"time-zone\": {\"name\": \"Time zone\", \"datatype\": \"string\"}}}}, \"children\": [\"bess\", \"0ab966b95f92a6a51ec548485aa85f54\", \"d3724e0d660ba506aa79c1cafe5d1181\", \"62d0e03897b337b57101aae82f1e9ba2\", \"fe8b85c15bc9610c1b8b4ebc6f82488d\", \"573066aaddd7b75114c4563ce3af18c4\", \"evse\", \"evse-2\", \"lugs-upstream\", \"lugs-downstream\", \"pv\"], \"extensions\": []}", + "$state": "ready", + "breaker/rating": "200", + "door/state": "CLOSED", + "info/data-model-version": "1.0", + "info/firmware-version": "example/v0.1.0", + "info/hardware-version": "rev2", + "info/model": "MAIN_40", + "info/serial-number": "example-40t-001", + "info/vendor-name": "Span", + "meter/voltage-a": "120.0", + "meter/voltage-b": "120.0", + "pcs/active": "false", + "pcs/binding-constraint": "NONE", + "pcs/enabled": "false", + "pcs/feed-import-limit": "0.0", + "pcs/feed-import-limit-active": "false", + "pcs/feed-import-limit-enablement": "UNCONFIGURED", + "pcs/import-limit": "0.0", + "pcs/off-grid-import-limit": "0.0", + "pcs/off-grid-import-limit-active": "false", + "pcs/off-grid-import-limit-enablement": "UNCONFIGURED", + "pcs/operator-import-limit": "0.0", + "pcs/operator-import-limit-active": "false", + "pcs/operator-import-limit-enablement": "UNCONFIGURED", + "pcs/requested-import-limit": "0.0", + "pcs/requested-import-limit-active": "false", + "pcs/requested-import-limit-enablement": "UNCONFIGURED", + "power-flows/battery": "3500.0", + "power-flows/grid": "2347.0", + "power-flows/pv": "-8500.0", + "power-flows/site": "2653.0", + "shed-forecast/confidence": "HIGH", + "shed-forecast/full-charge-time-to-priority-shed": "3038", + "shed-forecast/full-charge-total-time-remaining": "4320", + "shed-forecast/time-to-priority-shed": "3037", + "shed-forecast/total-time-remaining": "4320", + "shed/asserted-islanding-state": "NONE", + "shed/policy": "{\"algorithm\": \"soc-priority.v1\", \"parameters\": {\"soc-threshold-shed\": 20, \"soc-threshold-release\": 30}}", + "status/cloud-connection": "CONNECTED", + "status/ethernet": "true", + "status/postal-code": "94103", + "status/relay": "CLOSED", + "status/time-zone": "America/Los_Angeles", + "status/wifi": "true", + "status/wifi-ssid": "example-wifi" + }, + "fe8b85c15bc9610c1b8b4ebc6f82488d": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787706163063, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "50", + "connection/feeds-device-id": "evse-2", + "connection/feeds-device-status": "OK", + "connection/feeds-device-type": "energy.ebus.device.evse", + "info/name": "SPAN Drive - Driveway", + "info/spaces": "35,37", + "load-shed/priority": "OFF_GRID", + "meter/active-power": "0.0", + "meter/current": "0.0", + "meter/exported-energy": "40.333333333333336", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "4", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "lugs-downstream": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787706163064, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Downstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "info/direction": "DOWNSTREAM", + "meter/active-power": "-5847.0", + "meter/current-a": "46.46666666666666", + "meter/current-b": "46.474999999999994", + "meter/exported-energy": "97.45", + "meter/imported-energy": "44.05" + }, + "lugs-upstream": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787706163064, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Upstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "connection/fed-by-device-id": "bess", + "connection/fed-by-device-status": "OK", + "connection/fed-by-device-type": "energy.ebus.device.bess", + "info/direction": "UPSTREAM", + "meter/active-power": "-5847.0", + "meter/current-a": "46.46666666666666", + "meter/current-b": "46.474999999999994", + "meter/exported-energy": "97.45", + "meter/imported-energy": "44.05" + }, + "pv": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787706163064, \"type\": \"energy.ebus.device.pv\", \"name\": \"Solar\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nominal-power\": {\"name\": \"Nominal power\", \"datatype\": \"float\", \"unit\": \"W\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "info/firmware-version": "example-pv/v0.1.0", + "info/model": "IQ8PLUS-72-2-US", + "info/nominal-power": "10000.0", + "info/vendor-name": "Enphase" + } } diff --git a/tests/fixtures/unread_declarations_baseline.json b/tests/fixtures/unread_declarations_baseline.json index 07f3e8ae..5af3dcef 100644 --- a/tests/fixtures/unread_declarations_baseline.json +++ b/tests/fixtures/unread_declarations_baseline.json @@ -1,10 +1,8 @@ { "circuit/breaker/poles": "reaches circuit.is_240v and stops there; the voltage attribute is derived from the tab count instead, so no entity or attribute reads the field", - "circuit/connection/count": "microinverter/pack count; no producer publishes it, so there is no fixture value an entity could be tested against", "circuit/connection/feeds-device-type": "redundant: the library resolves a DER's class by dereferencing feeds-device-id against the tree's declared $type", "distribution-enclosure/status/postal-code": "deliberate skip: no curated entity, because location has no Home Assistant value. It does surface as a disabled vendor reading through extension adoption, where the recorder cost is opt-in rather than imposed", "distribution-enclosure/status/time-zone": "deliberate skip: no curated entity, because Home Assistant owns the time zone and a mismatch is a Repair rather than an entity. It does surface as a disabled vendor reading through extension adoption", - "lugs/connection/count": "the lugs counterpart of circuit/connection/count, with no fed-device story upstream and no producer publishing it", "lugs/connection/fed-by-device-type": "redundant: the id dereferences to the device's declared $type, which is how the library already resolves classes", "lugs/connection/feeds-device-status": "feedthrough link-health; the feeds-* half of the downstream lugs record is topology to consume, and no producer publishes it", "lugs/connection/feeds-device-type": "redundant: the id dereferences to the device's declared $type, which is how the library already resolves classes", diff --git a/tests/test_bess_telemetry.py b/tests/test_bess_telemetry.py index 72e917c9..eeac77b2 100644 --- a/tests/test_bess_telemetry.py +++ b/tests/test_bess_telemetry.py @@ -11,29 +11,34 @@ 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 negative `meter/active-power` -because the enclosure meters the BESS the way it meters a circuit it feeds. The -snapshot negates that into its charge-positive frame, and `battery_power` — the -sensor already on this device, reading the enclosure's discharge-positive -`power-flows/battery` — negates too. Two negations of two opposite wire -conventions, landing on one convention in the UI. 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. +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.** -Both wire properties carry the *same* sign as each other -- a live panel capture -and `ebus-panel-sim` 0.6.0 both publish `-3500.0` for the pair -- and each path -applies exactly one negation, so the two entities cannot disagree whatever the +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. That is what the agreement assertions below pin, and it holds -independently of which way is charging. - -Which way *is* charging is read off the vendored capture, and that capture -predates `distribution-enclosure-simulator#39`: its four power-flow terms satisfy -`grid + pv + battery == site` rather than summing to zero, so it carries the -pre-fix frame. The only live capture available has the battery idle at 100 -percent SoC with both properties exactly zero, so nothing has settled the -absolute direction. Refresh the fixture from a 0.6.0-era emitter and revisit -these assertions together; do not change one without the other. +independently of which way is charging. Neither negation changed here: +`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. + +Which way *is* charging is read off the vendored capture, and the capture now +carries the post-`distribution-enclosure-simulator#39` frame: `power-flows` 0.3 +defines the node balance as every term positive when power flows into the thing +it names, so `pv` is negative while producing and the four terms sum to zero. +The capture this module was first written against predated that fix and satisfied +`grid + pv + battery == site` instead, which is why these assertions read the +other way until span-panel-api re-captured the reference tree. The library pins +the same four values and states the same frame (`test_schema_one_panel.py`, +`test_power_flows`). Refresh the fixture and revisit these assertions together; +do not change one without the other. """ from __future__ import annotations @@ -207,7 +212,13 @@ def test_the_capture_is_a_charging_battery() -> None: The capture: 8500 W of PV meets 2653 W of site load and exports 2347 W, and the 3500 W left over is going into the battery. So this battery is charging, - and the enclosure publishes that as a *negative* number. + and the enclosure publishes that as a *positive* number. + + `power-flows` 0.3 defines the balance as a node balance — every term positive + when power flows into the thing it names — so the four sum to zero rather than + three of them summing to the fourth, and PV reads negative while producing. + Asserting the sum against zero rather than against `site` is what makes this a + statement about the frame and not just about four numbers. Were the capture ever retaken with the battery discharging, this fails first and says so, rather than the sign tests failing and reading as a wiring bug. @@ -217,12 +228,13 @@ def test_the_capture_is_a_charging_battery() -> None: for name in ("pv", "battery", "grid", "site") } - assert flows["pv"] + flows["battery"] + flows["grid"] == pytest.approx(flows["site"]) + assert sum(flows.values()) == pytest.approx(0.0, abs=1e-6) # PV alone exceeds the site load, so the surplus has nowhere to go but the - # battery and the grid — and the grid term is an export. - assert flows["pv"] > flows["site"] - assert flows["grid"] < 0 - assert flows["battery"] < 0 + # battery and the grid — and the grid term is an export, which in this frame + # is power flowing into the grid and therefore positive. + assert -flows["pv"] > flows["site"] + assert flows["grid"] > 0 + assert flows["battery"] > 0 def test_the_bess_meter_agrees_with_the_enclosure_about_direction() -> None: @@ -244,30 +256,36 @@ def test_the_bess_meter_agrees_with_the_enclosure_about_direction() -> None: # --------------------------------------------------------------------------- -def test_charging_reads_positive() -> 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-negative and the snapshot is charge-positive, so the - sensor's state is the negation of the published value. Magnitude and sign are - asserted separately on purpose: losing the negation keeps the magnitude, so - only the sign check catches it. + 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). + + Magnitude and sign are asserted separately on purpose: losing the negation + keeps the magnitude, so only the sign check catches it. """ published = float(_published(BESS, POWER_TOPIC)) state = _state(schema_one_snapshot(), POWER_KEY) assert state == -published - assert isinstance(state, float) and state > 0 + assert isinstance(state, float) and state < 0 def test_it_agrees_with_the_battery_power_sensor_beside_it() -> None: """The two battery-power sensors on this device must not contradict each other. - `battery_power` reads the enclosure's arbitrated `power-flows/battery`, which - the capability catalog defines as discharge-positive; this one reads the - BESS's own meter, which the enclosure publishes charge-negative. Two opposite - wire conventions, and the UI shows one — so the check is on the states, not - on either definition. A flip on either side fails here even if the side that - flipped still looks self-consistent. + `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 this firmware publishes them with the *same* sign + instead — an alignment `sensor_definitions.py` records as the spec's violation + rather than its rule. Each path negates exactly once, 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() @@ -282,7 +300,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 negative together. + two values negated — and both sensors must go positive together, the sensors + being discharge-positive. """ snapshot = _republishing_both( power=-float(_published(BESS, POWER_TOPIC)), @@ -292,8 +311,8 @@ def test_they_agree_when_the_battery_discharges_too() -> None: own_meter = _state(snapshot, POWER_KEY) enclosure_flow = _state(snapshot, ENCLOSURE_FLOW_KEY) - assert isinstance(own_meter, float) and own_meter < 0 - assert isinstance(enclosure_flow, float) and enclosure_flow < 0 + assert isinstance(own_meter, float) and own_meter > 0 + assert isinstance(enclosure_flow, float) and enclosure_flow > 0 def _republishing_both(*, power: float, enclosure_flow: float) -> SpanPanelSnapshot: diff --git a/tests/test_schema_discovery.py b/tests/test_schema_discovery.py index 0124f09d..cf1ea8ae 100644 --- a/tests/test_schema_discovery.py +++ b/tests/test_schema_discovery.py @@ -104,7 +104,11 @@ def test_a_discovered_row_carries_the_declaration_and_the_retention() -> None: assert entry.datatype == row.datatype assert entry.unit == row.unit assert by_path["discovered.distribution-enclosure/status/time-zone"].retained is True - assert by_path["discovered.circuit/connection/count"].retained is False + # Both retention states have to appear, or the row is not carrying retention at + # all. `lugs/connection/feeds-device-status` is the unretained one for the same + # reason `circuit/connection/count` was before the capture dropped it: the + # panel declares the property and no producer publishes a value for it. + assert by_path["discovered.lugs/connection/feeds-device-status"].retained is False def test_a_namespaced_row_without_the_enriched_type_still_reports() -> None: @@ -202,9 +206,15 @@ async def test_diagnostics_carries_the_discovery_report(hass: HomeAssistant) -> assert block["available"] is True assert block["count"] == len(findings.discovered) assert block["count"] > 0 - assert block["properties"][0] == { - "path": "discovered.circuit/connection/count", - "datatype": "integer", + # Addressed by path rather than by position. The ordering is pinned once, in + # `test_partition_splits_the_map_and_loses_nothing`; re-pinning it here meant + # that a capture dropping any earlier-sorting property failed this test with a + # diff about the wrong row, which is what happened when `connection/count` went + # away. + by_path = {row["path"]: row for row in block["properties"]} + assert by_path["discovered.pv/info/serial-number"] == { + "path": "discovered.pv/info/serial-number", + "datatype": "string", "unit": None, "retained": False, } diff --git a/uv.lock b/uv.lock index f5df6d09..cd19b282 100644 --- a/uv.lock +++ b/uv.lock @@ -2523,9 +2523,9 @@ dev = [ [package.metadata] requires-dist = [ { name = "homeassistant", specifier = "==2026.8.0" }, - { name = "span-panel-api", editable = "../../span/span-panel-api-security" }, - { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api-security/packages/schema-0" }, - { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api-security/packages/schema-1" }, + { name = "span-panel-api", editable = "../../span/span-panel-api" }, + { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api/packages/schema-0" }, + { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api/packages/schema-1" }, ] [package.metadata.requires-dev] @@ -2551,7 +2551,7 @@ dev = [ [[package]] name = "span-panel-api" version = "3.1.0" -source = { editable = "../../span/span-panel-api-security" } +source = { editable = "../../span/span-panel-api" } dependencies = [ { name = "httpx" }, { name = "paho-mqtt" }, @@ -2563,8 +2563,8 @@ requires-dist = [ { name = "httpx", specifier = ">=0.28.1,<1.0" }, { name = "paho-mqtt", specifier = ">=2.0.0,<3.0.0" }, { name = "pyyaml", specifier = ">=6.0.0" }, - { name = "span-panel-api-schema-0", marker = "extra == 'schema-0'", editable = "../../span/span-panel-api-security/packages/schema-0" }, - { name = "span-panel-api-schema-1", marker = "extra == 'schema-1'", editable = "../../span/span-panel-api-security/packages/schema-1" }, + { name = "span-panel-api-schema-0", marker = "extra == 'schema-0'", editable = "../../span/span-panel-api/packages/schema-0" }, + { name = "span-panel-api-schema-1", marker = "extra == 'schema-1'", editable = "../../span/span-panel-api/packages/schema-1" }, ] provides-extras = ["schema-0", "schema-1"] @@ -2582,8 +2582,8 @@ dev = [ { name = "pytest-cov" }, { name = "radon" }, { name = "ruff", specifier = ">=0.15.5" }, - { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api-security/packages/schema-0" }, - { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api-security/packages/schema-1" }, + { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api/packages/schema-0" }, + { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api/packages/schema-1" }, { name = "twine", specifier = ">=7.0" }, { name = "types-pyyaml", specifier = ">=6.0.12.20250915" }, { name = "vulture", specifier = ">=2.14" }, @@ -2592,18 +2592,18 @@ dev = [ [[package]] name = "span-panel-api-schema-0" version = "1.1.0" -source = { editable = "../../span/span-panel-api-security/packages/schema-0" } +source = { editable = "../../span/span-panel-api/packages/schema-0" } dependencies = [ { name = "span-panel-api" }, ] [package.metadata] -requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-api-security" }] +requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-api" }] [[package]] name = "span-panel-api-schema-1" version = "1.1.0" -source = { editable = "../../span/span-panel-api-security/packages/schema-1" } +source = { editable = "../../span/span-panel-api/packages/schema-1" } dependencies = [ { name = "ebus-sdk" }, { name = "span-panel-api" }, @@ -2612,7 +2612,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "ebus-sdk", specifier = ">=0.19.0,<0.24" }, - { name = "span-panel-api", editable = "../../span/span-panel-api-security" }, + { name = "span-panel-api", editable = "../../span/span-panel-api" }, ] [[package]] From 531ad1cbfeefcb32f4b6c5fd9be415b00e401dc5 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:25:29 -0700 Subject: [PATCH 133/345] build(fixtures): refresh the vendored capture and its provenance in one command `tests/fixtures/schema_one_tree.json` and `tests/fixtures/schema_one_tree.source` are one fact in two files, and until now two different actions updated them -- a copy and an edit. Doing one and not the other was a single missed keystroke, and that is precisely what happened: a capture whose claim about itself was wrong, which every conformance test then passed against. `scripts/refresh-vendored-capture.py` does both, so they cannot disagree. It copies with `shutil.copyfile`, never a json round trip -- re-serialising rewrites indentation, key order and float spelling, which is exactly what the byte comparison rejects -- and reads the release out of the installed distribution rather than taking it as an argument, so there is no third place for the claim to be wrong. Both source locations are handled, newest first, with the pre-#162 package-data path as the fallback. It refuses rather than guesses, and the refusals are the point. No `SPAN_PANEL_API_DIR`, a path that does not resolve, a directory that is not a checkout, a checkout whose schema-1 release is not the one installed, a checkout with the file at neither location -- and a checkout that is not standing on the release's tag. That last check is what makes this a safeguard rather than a convenience, and it was added after finding the obvious version comparison insufficient: the stale worktree the bad capture came from *declared* 1.1.0 while sitting on unreleased work behind `main`, so the declaration and the pin agreed while the bytes were a release old. A version in a working tree is intent; a tag is a fact, and only a tag can be checked. Pointed at that worktree the script now stops. Copying from unreleased work stays possible behind `--allow-unreleased`, because it is occasionally correct -- the current copy is that case, taken from `main` because schema-1-v1.1.0 is not cut. It is a flag rather than a warning deliberately: this repository has already established that a message which stops nothing reads exactly like success. `SCHEMA_ONE_SOURCE_PATHS` and its companions move to `tests/adapter_fixtures.py`, which already owns the vendored capture's vocabulary, so the script and `test_fixture_provenance.py` read one list. A second copy is how the #162 fallback would silently outlive the merge in one place and not the other. The `.env` reader is duplicated rather than shared, because `conftest.py` has to stay free of heavy imports at collection time; it is twelve stable lines and a divergence is harmless. Documented in `tests/fixtures/README.md` and `developer.md`: the one command, the manual steps it performs for anyone without a checkout to hand, and which of the two guards tells you to run it -- the version claim catches a pin that moved while the copy stayed put, the byte comparison catches a copy refreshed wrongly. Both docs also record why the capture is **not** copied at test time, because someone will propose it. It would put back the cross-repo dependency vendoring removed, and it would make the byte comparison vacuous: a copy compared against what it was just copied from can only ever pass. Committed bytes that are allowed to disagree with their source are the whole mechanism -- that freedom is what let the comparison find a capture vendored through a stale path override. --- .env.example | 6 +- developer.md | 49 +++++ scripts/refresh-vendored-capture.py | 323 ++++++++++++++++++++++++++++ tests/adapter_fixtures.py | 37 ++++ tests/fixtures/README.md | 45 +++- tests/test_fixture_provenance.py | 40 +--- 6 files changed, 458 insertions(+), 42 deletions(-) create mode 100755 scripts/refresh-vendored-capture.py diff --git a/.env.example b/.env.example index e85215ac..b5893afc 100644 --- a/.env.example +++ b/.env.example @@ -5,9 +5,11 @@ # anything here -- the file supplies defaults, it does not override an # intentional choice. -# Path to span-panel-api repo. Used for the editable pip install, and read by +# Path to span-panel-api repo. Used for the editable pip install; read by # tests/test_fixture_provenance.py, which byte-compares the vendored capture -# `tests/fixtures/schema_one_tree.json` against the copy in that checkout. +# `tests/fixtures/schema_one_tree.json` against the copy in that checkout; and +# read by scripts/refresh-vendored-capture.py, which is how that capture is +# refreshed when the comparison fails. # # Optional here: without it that one comparison skips and the rest of the suite # runs in full. It skips again if the checkout is not on the release diff --git a/developer.md b/developer.md index 37891e14..33cef77c 100644 --- a/developer.md +++ b/developer.md @@ -162,6 +162,55 @@ python -m pytest tests/test_current_monitor.py -q python -m pytest tests/ --cov=custom_components/span_panel --cov-report=term-missing ``` +## The vendored adapter captures + +`tests/fixtures/schema_one_tree.json` and `tests/fixtures/schema_zero_types.json` are byte copies of payloads that live in `span-panel-api`. Committing them is +what keeps this suite free of a cross-repo dependency: the tests run with no sibling checkout, and no runtime wheel has to keep shipping test data. + +### Refreshing the parent/child capture + +```bash +# SPAN_PANEL_API_DIR names the checkout; see .env.example +uv run python scripts/refresh-vendored-capture.py +``` + +The script copies the capture byte for byte and writes the release it came from into `tests/fixtures/schema_one_tree.source`, in one action. **That is the +reason it exists.** Those two facts — the bytes, and the claim about which release they are — were previously updated by two different actions, so doing one and +not the other was a single missed keystroke; doing both from one command makes them incapable of disagreeing. + +It refuses more than it accepts, deliberately. No `SPAN_PANEL_API_DIR`, a path that does not resolve, a directory that is not a checkout, a checkout whose +schema-1 release is not the one installed, a checkout that has the file at neither known location, or a checkout that is not standing on the release's tag — all +of these stop before anything is written. The last one matters most: the refresh that went wrong copied from a worktree that _declared_ the installed release +while sitting on unreleased work behind `main`, so the declaration and the pin agreed while the bytes were a release old. A version in a working tree is intent; +a tag is a fact. Copying from unreleased work is still possible with `--allow-unreleased`, because it is occasionally correct — it is how the capture was +corrected while the fix was only on `main` — but it has to be said out loud rather than warned about and ignored. + +Commit both files together. They are one fact in two files. + +### Which guard tells you to run it + +| Guard | Compares | Catches | Needs a checkout | +| ----------------------------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------ | ---------------- | +| `test_the_vendored_capture_matches_the_installed_adapter` | `schema_one_tree.source` against the installed distribution | a pin that moved while the copy stayed put | no | +| `test_the_vendored_capture_is_byte_identical_to_its_source` | the capture against the copy in that release | a copy refreshed **wrongly** — reformatted, edited, or taken from the wrong checkout | yes | + +The first fails everywhere, including CI, the moment `manifest.json` moves and nobody refreshes. The second needs `SPAN_PANEL_API_DIR`, so it **skips** locally +when no checkout is configured or the configured one is on another release, and **fails** under `CI`, where the workflow clones `span-panel-api` at the release +the pin names. That asymmetry is deliberate and should not be "fixed": `span-panel-api` paid for it and records the reason in its `DEVELOPMENT.md`, under "A +skip here is not a pass" — _"A skip reads in a summary line exactly like a pass, and that is how a stale vendored capture went unnoticed for nine days."_ + +Either failure names the refresh. Run the script. + +### Why the capture is not copied at test time + +Someone will eventually propose having the suite copy the fixture from `SPAN_PANEL_API_DIR` on every run, so it can never be stale. It would undo both things +vendoring bought. + +It puts the cross-repo dependency back — every test run needing a `span-panel-api` checkout, which is exactly what committing the bytes removed. And it makes +the byte comparison vacuous: a copy compared against the thing it was just copied from can only ever pass. **Committed bytes that are allowed to disagree with +their source are the whole mechanism.** That freedom to disagree is what let the comparison find a capture vendored from a stale path override, which nothing +else in either repository could see. A fixture that silently re-syncs itself cannot report anything. + ## Knowing what the panel publishes that nothing reads The panel declares more than this integration surfaces, and the gap is tracked mechanically rather than by memory. diff --git a/scripts/refresh-vendored-capture.py b/scripts/refresh-vendored-capture.py new file mode 100755 index 00000000..17365ceb --- /dev/null +++ b/scripts/refresh-vendored-capture.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python3 +"""Refresh the vendored schema-1 capture and the release it records, in one action. + +`tests/fixtures/schema_one_tree.json` is a byte copy of a payload that lives in +`span-panel-api`, and `tests/fixtures/schema_one_tree.source` records which +release it was copied from. Two files, two facts, and **that is the whole reason +this script exists**: until now they were updated by two different actions -- a +copy and an edit -- so doing one and not the other was a single missed keystroke, +and the result was a capture whose claim about itself was wrong. Performing both +from one command makes them incapable of disagreeing, which is a stronger +guarantee than any amount of care with a two-step README. + +It is not hypothetical. The capture was once refreshed from a checkout that sat +behind the recorded release, producing a copy that was faithful to *a* release +and mislabelled as another; it recorded a producer defect that upstream had +already fixed, and every conformance test kept passing against it. So this script +refuses to copy from a checkout whose schema-1 release is not the one installed, +and refuses again unless that checkout is standing on the release's *tag* -- +because the stale worktree declared the right version while sitting on the wrong +commit, and only a tag can be checked. Copying from unreleased work is still +possible, with `--allow-unreleased`, because it is occasionally correct; it just +has to be said out loud. Making the wrong refresh hard is more of the point than +making the right one convenient. + +**The capture is deliberately not copied at test time.** Doing that would undo +both things vendoring bought. It would put the cross-repo dependency back -- +every test run needing a `span-panel-api` checkout, which is exactly what +committing the bytes removed -- and it would make the byte comparison in +`tests/test_fixture_provenance.py` vacuous, since a copy compared against the +thing it was just copied from can only ever pass. Committed bytes that are +*allowed to disagree with their source* are what let that comparison find the +stale checkout. A refresh is a deliberate act, taken here and reviewed in a diff. + +Usage, from the repository root: + + uv run python scripts/refresh-vendored-capture.py + +`SPAN_PANEL_API_DIR` names the checkout; see `.env.example`. A correct run when +nothing has moved upstream is a no-op and says so. +""" + +from __future__ import annotations + +from importlib.metadata import PackageNotFoundError, version +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys + +REPO = Path(__file__).resolve().parent.parent + +sys.path.insert(0, str(REPO)) + +from tests.adapter_fixtures import ( # noqa: E402 + CHECKOUT_VARIABLE, + SCHEMA_ONE_DISTRIBUTION, + SCHEMA_ONE_RELEASE_DECLARATION, + SCHEMA_ONE_SOURCE_PATHS, + SCHEMA_ONE_TREE, + SCHEMA_ONE_TREE_SOURCE, +) + +DOTENV = REPO / ".env" + +_VERSION = re.compile(r'^version = "([^"]+)"', re.MULTILINE) + + +class RefreshError(Exception): + """Something about the environment or the checkout makes a refresh unsafe.""" + + +def load_dotenv() -> None: + """Populate the environment from `.env`, without overriding what is set. + + Read directly rather than through python-dotenv, for the reason + `tests/conftest.py` gives: parsing a handful of `export KEY=value` lines does + not justify a dependency. This is a second small implementation rather than a + shared one because `conftest.py` must stay free of heavy imports at + collection time, and anything this script could import it from is not. + """ + if not DOTENV.is_file(): + return + for raw in DOTENV.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + if line.startswith("export "): + line = line[len("export ") :].strip() + if "=" not in line: + continue + key, _, value = line.partition("=") + os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) + + +def installed_release() -> str: + """Return the release of the adapter this environment actually has. + + Read rather than accepted as an argument, because a version typed on a + command line is a third place for the claim to be wrong. The pin has one + home in `manifest.json`, reaches the environment through `pyproject.toml`, + and this is that same value read back out. + """ + try: + return version(SCHEMA_ONE_DISTRIBUTION) + except PackageNotFoundError as exc: + raise RefreshError( + f"{SCHEMA_ONE_DISTRIBUTION} is not installed, so there is no release to " + f"record. Run this through the project environment: " + f"`uv run python scripts/{Path(__file__).name}`." + ) from exc + + +def checkout() -> Path: + """Resolve the `span-panel-api` checkout named by the environment. + + A relative value is resolved against the repository root rather than the + working directory, because that is what `.env.example`'s `../span-panel-api` + means and this can be run from anywhere. + """ + configured = os.environ.get(CHECKOUT_VARIABLE) + if not configured: + raise RefreshError( + f"{CHECKOUT_VARIABLE} is not set. Point it at a span-panel-api checkout -- " + f"copy `.env.example` to `.env` and edit it, or export it for this run. " + f"There is no default: a refresh has to name the checkout it trusts." + ) + path = Path(configured) + if not path.is_absolute(): + path = REPO / path + if not path.is_dir(): + raise RefreshError( + f"{CHECKOUT_VARIABLE}={configured} does not exist. Point it at a " + f"span-panel-api checkout." + ) + if not (path / SCHEMA_ONE_RELEASE_DECLARATION).is_file(): + raise RefreshError( + f"{CHECKOUT_VARIABLE}={configured} has no {SCHEMA_ONE_RELEASE_DECLARATION}, " + f"so it is not a span-panel-api checkout (or not a complete one). Re-clone it." + ) + return path + + +def declared_release(source: Path) -> str: + """Read the release a checkout declares, from the package it would build.""" + declaration = (source / SCHEMA_ONE_RELEASE_DECLARATION).read_text(encoding="utf-8") + found = _VERSION.search(declaration) + if found is None: + raise RefreshError( + f"{source / SCHEMA_ONE_RELEASE_DECLARATION} declares no version, so there is " + f"no way to tell which release its capture belongs to." + ) + return found.group(1) + + +def _git(source: Path, *arguments: str) -> str | None: + """Run a read-only git command in the checkout, or None if git cannot answer. + + None covers a checkout that is not a git repository at all -- an unpacked + sdist, a copied directory -- which is a legitimate way to have the files and + simply means the position check cannot be made. + """ + try: + finished = subprocess.run( # noqa: S603 + ["git", "-C", str(source), *arguments], + capture_output=True, + text=True, + check=False, + ) + except OSError: + return None + if finished.returncode != 0: + return None + return finished.stdout.strip() + + +def require_position_at_the_release(source: Path, release: str, allow_unreleased: bool) -> None: + """Refuse a checkout that is not sitting on the release it claims. + + **This is the check that would have caught the refresh that went wrong, and + the version comparison above is not.** The stale worktree the capture came + from *declared* the installed release while sitting on unreleased work behind + `main`, so the declaration and the pin agreed while the bytes were a release + old. A version string in a working tree is a statement of intent; a tag is a + fact, and only the tag can be checked. + + Three outcomes, because they call for different actions: + + * Positioned at the tag -- nothing to say, this is the ordinary case. + * Positioned elsewhere while the tag exists -- refuse. A release is available + and there is no reason to copy from anything else. + * Tag absent -- refuse unless `--allow-unreleased` was passed. The release is + not cut, so unreleased work is the only thing there is to copy from, and + that is sometimes the right call: it is how this capture was corrected when + the fix was on `main` and 1.1.0 was not yet tagged. But it is a decision, not + a detail, and it must be made deliberately. A warning on stderr is not good + enough -- this repository already learned that a message which does not stop + anything reads exactly like success. + """ + tag = f"schema-1-v{release}" + tagged = _git(source, "rev-list", "-n", "1", tag) + + if tagged is None: + if not allow_unreleased: + raise RefreshError( + f"{source} declares {release}, but {tag} does not exist there (or it is " + f"not a git checkout), so these bytes are unreleased work that nothing " + f"can verify against a release. That is occasionally the right thing to " + f"vendor -- it is how this capture was corrected while the fix was only " + f"on `main` -- but it has to be deliberate. Fetch the tag if you expected " + f"one (`git -C {source} fetch --tags`), or re-run with --allow-unreleased " + f"and say in the commit message why." + ) + print( + f"warning: {tag} does not exist in {source}, so this vendors unreleased " + f"work. Nothing can verify these bytes against a release until {tag} is " + f"cut; refresh again once it is.", + file=sys.stderr, + ) + return + + head = _git(source, "rev-parse", "HEAD") + if head is not None and head != tagged: + raise RefreshError( + f"{source} is on {head[:12]}, not on {tag} ({tagged[:12]}), even though " + f"{tag} exists. Copy from the release, not from a working tree that happens " + f"to declare it -- that difference is exactly how this capture went wrong " + f"before. `git -C {source} checkout {tag}` and run this again." + ) + + +def capture_in(source: Path, release: str) -> Path: + """Locate the capture inside a checkout, at whichever of the two paths holds it.""" + for relative in SCHEMA_ONE_SOURCE_PATHS: + candidate = source / relative + if candidate.is_file(): + return candidate + locations = " nor ".join(str(relative) for relative in SCHEMA_ONE_SOURCE_PATHS) + raise RefreshError( + f"{source} is {SCHEMA_ONE_DISTRIBUTION} {release} but holds the capture at " + f"neither {locations}. Either the checkout is incomplete, or that release " + f"moved the file again and SCHEMA_ONE_SOURCE_PATHS needs the new location." + ) + + +def refresh(allow_unreleased: bool) -> int: + """Copy the capture and write the release it came from. Return an exit code.""" + load_dotenv() + + installed = installed_release() + source = checkout() + declared = declared_release(source) + + if declared != installed: + raise RefreshError( + f"{CHECKOUT_VARIABLE} names a checkout on {SCHEMA_ONE_DISTRIBUTION} " + f"{declared}, and this environment has {installed} installed. Refreshing " + f"from it would vendor bytes from one release and record another -- which " + f"is how the capture came to carry a producer defect that upstream had " + f"already fixed. Move the checkout to {installed} (its tag is " + f"schema-1-v{installed}), or change the pin in manifest.json and re-sync." + ) + + require_position_at_the_release(source, installed, allow_unreleased) + capture = capture_in(source, declared) + + # `shutil.copyfile`, never a json.load/json.dump round trip. Re-serialising is + # what turns a copy into a reformatting: it rewrites indentation, key order and + # float spelling, and the byte comparison in tests/test_fixture_provenance.py + # exists precisely to reject the result. + unchanged = SCHEMA_ONE_TREE.is_file() and SCHEMA_ONE_TREE.read_bytes() == capture.read_bytes() + shutil.copyfile(capture, SCHEMA_ONE_TREE) + + record = f"{SCHEMA_ONE_DISTRIBUTION}=={installed}\n" + record_unchanged = ( + SCHEMA_ONE_TREE_SOURCE.is_file() + and SCHEMA_ONE_TREE_SOURCE.read_text(encoding="utf-8") == record + ) + SCHEMA_ONE_TREE_SOURCE.write_text(record, encoding="utf-8") + + print(f"source: {capture}") + print(f"release: {SCHEMA_ONE_DISTRIBUTION}=={installed}") + if unchanged and record_unchanged: + print("Already current; nothing changed.") + return 0 + print(f"updated: {SCHEMA_ONE_TREE.relative_to(REPO)}") + print(f"updated: {SCHEMA_ONE_TREE_SOURCE.relative_to(REPO)}") + print( + "Review the diff before committing, and commit both files together -- " + "they are one fact in two files." + ) + return 0 + + +ALLOW_UNRELEASED = "--allow-unreleased" + + +def main(argv: list[str]) -> int: + """Run the refresh, reporting a bad environment as prose rather than a traceback. + + Arguments are checked by hand rather than with argparse: there is one flag, + and an unrecognised one has to be an error instead of being ignored -- a + misspelled `--allow-unreleased` that silently did nothing would turn the + deliberate decision back into an accident. + """ + unknown = [argument for argument in argv if argument != ALLOW_UNRELEASED] + if unknown: + print( + f"refresh-vendored-capture: unrecognised argument(s) {' '.join(unknown)}. " + f"The only option is {ALLOW_UNRELEASED}.", + file=sys.stderr, + ) + return 2 + try: + return refresh(allow_unreleased=ALLOW_UNRELEASED in argv) + except RefreshError as exc: + print(f"refresh-vendored-capture: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tests/adapter_fixtures.py b/tests/adapter_fixtures.py index 22f739cb..6548a9a3 100644 --- a/tests/adapter_fixtures.py +++ b/tests/adapter_fixtures.py @@ -30,6 +30,43 @@ SCHEMA_ONE_TREE_SOURCE = _FIXTURES / "schema_one_tree.source" """The release `SCHEMA_ONE_TREE` was copied from, checked by `test_fixture_provenance.py`.""" +SCHEMA_ONE_DISTRIBUTION = "span-panel-api-schema-1" +"""The distribution that publishes the capture, and whose installed version the claim names.""" + +CHECKOUT_VARIABLE = "SPAN_PANEL_API_DIR" +"""Names a `span-panel-api` checkout. Already in `.env.example` for editable installs.""" + +SCHEMA_ONE_SOURCE_PATHS = ( + pathlib.Path("tests") / "reference_payloads" / "parent_child_tree.json", + pathlib.Path("packages") + / "schema-1" + / "src" + / "span_panel_api_schema_1" + / "reference_payloads" + / "parent_child_tree.json", +) +"""Where the capture lives inside a `span-panel-api` checkout, newest location first. + +Two of them because the file is moving. `span-panel-api#162` takes the reference +payloads out of the schema_1 wheel and makes them ordinary test fixtures -- which +is the whole reason this repository vendors a copy rather than importing one -- +but that change is unmerged, so a checkout on `main` today still holds the file +under `packages/schema-1/`. Checking the new path first follows the file rather +than the merge, and works against a checkout on either side of it. + +Delete the second entry once #162 is merged **and** no release this repository can +pin still predates it: the CI clone and `scripts/refresh-vendored-capture.py` both +position themselves at a recorded release, so the old path stays reachable for as +long as that release can be a pre-#162 one. + +Here rather than in `test_fixture_provenance.py` because the refresh script needs +the same two paths. A second copy of this list is how the fallback silently +outlives the merge in one place and not the other. +""" + +SCHEMA_ONE_RELEASE_DECLARATION = pathlib.Path("packages") / "schema-1" / "pyproject.toml" +"""Where a checkout declares which schema-1 release it is.""" + class VendoredSource(NamedTuple): """The distribution and release a vendored fixture was copied from.""" diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index 66ed033e..3b310205 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -97,16 +97,45 @@ so one aimed at a stale checkout quietly tests against a producer that no longer ### Refreshing -In one commit: +Either guard failing names the refresh, and the refresh is one command: -1. Copy `tests/reference_payloads/parent_child_tree.json` from `$SPAN_PANEL_API_DIR` over `schema_one_tree.json`, with no reformatting. -2. Set the version in `schema_one_tree.source` to the release it came from. +```bash +# SPAN_PANEL_API_DIR names the checkout; see .env.example +uv run python scripts/refresh-vendored-capture.py +``` + +Commit both files it touches together — they are one fact in two files. + +What it does, for anyone reading this without the checkout to hand: + +1. Copies `tests/reference_payloads/parent_child_tree.json` from `$SPAN_PANEL_API_DIR` over `schema_one_tree.json`, byte for byte — `shutil.copyfile`, never a + `json.load`/`json.dump` round trip, which would rewrite indentation, key order and float spelling and produce exactly what the byte comparison rejects. It + checks the pre-#162 package-data path as a fallback, so it works against a checkout on either side of that merge. +2. Writes `schema_one_tree.source` from the **installed** distribution's version, rather than from anything typed in. + +**Doing both from one command is the point, not the convenience.** The bytes and the recorded release used to be updated by two different actions, so doing one +and not the other was a single missed keystroke — and the result was a capture whose claim about itself was wrong, which every conformance test then passed +against. One command cannot do half of it. + +It refuses rather than guesses: no `SPAN_PANEL_API_DIR`, a path that does not resolve, a directory that is not a checkout, a checkout whose schema-1 release is +not the one installed, a checkout with the file at neither location, or a checkout that is not standing on the release's tag. That last one is the one that +matters: the refresh that went wrong copied from a worktree that _declared_ the installed release while sitting on unreleased work behind `main`, so the +declaration and the pin agreed while the bytes were a release old. A version in a working tree is intent; a tag is a fact, and only a tag can be checked. + +Copying from unreleased work is still possible with `--allow-unreleased`, because it is occasionally correct — the current copy is exactly that case, taken from +`main` because `schema-1-v1.1.0` is not cut and the alternative was to keep shipping a capture that records a producer defect. It verifies against a `main` +checkout today and will verify against the tag once 1.1.0 is released from these bytes; if `main` moves the capture again first, the guard says so, which is the +whole point. What is never acceptable is a capture taken from a checkout _behind_ the recorded release — that is how this one went wrong. + +### Why the capture is not copied at test time + +Someone will eventually propose having the suite copy the fixture from `$SPAN_PANEL_API_DIR` on every run, so it can never be stale. It would undo both things +vendoring bought. -Copy from a checkout positioned on the release `schema_one_tree.source` names, and prefer a released tag. The current copy is an exception made knowingly: it -comes from `main`, because `schema-1-v1.1.0` is not cut yet and the alternative was to keep shipping a capture that records a producer defect. It verifies -against a `main` checkout today and will verify against the tag once 1.1.0 is released from these bytes; if `main` moves the capture again first, the guard says -so, which is the whole point. What is never acceptable is a capture taken from a checkout that is _behind_ the recorded release — that is how this one went -wrong. +It puts the cross-repo dependency back — every test run needing a `span-panel-api` checkout, which is exactly what committing the bytes removed. And it makes +the byte comparison vacuous: a copy compared against the thing it was just copied from can only ever pass. **Committed bytes that are allowed to disagree with +their source are the whole mechanism.** That freedom to disagree is what let the comparison find a capture vendored from a stale path override, which nothing +else in either repository could see. A fixture that silently re-syncs itself cannot report anything. ## These files are exempt from formatting diff --git a/tests/test_fixture_provenance.py b/tests/test_fixture_provenance.py index f8037d57..053209da 100644 --- a/tests/test_fixture_provenance.py +++ b/tests/test_fixture_provenance.py @@ -45,40 +45,16 @@ import pytest -from .adapter_fixtures import SCHEMA_ONE_TREE, SCHEMA_ONE_TREE_SOURCE, schema_one_source - -CHECKOUT_VARIABLE = "SPAN_PANEL_API_DIR" -"""Names a `span-panel-api` checkout. Already defined in `.env.example` for editable installs.""" - -_REPO_ROOT = Path(__file__).resolve().parent.parent - -_SOURCE_PATHS = ( - Path("tests") / "reference_payloads" / "parent_child_tree.json", - Path("packages") - / "schema-1" - / "src" - / "span_panel_api_schema_1" - / "reference_payloads" - / "parent_child_tree.json", +from .adapter_fixtures import ( + CHECKOUT_VARIABLE, + SCHEMA_ONE_RELEASE_DECLARATION as _RELEASE_DECLARATION, + SCHEMA_ONE_SOURCE_PATHS as _SOURCE_PATHS, + SCHEMA_ONE_TREE, + SCHEMA_ONE_TREE_SOURCE, + schema_one_source, ) -"""Where the capture lives in `span-panel-api`, newest location first. - -Two of them because the file is moving. `span-panel-api#162` takes the reference -payloads out of the schema_1 wheel and makes them ordinary test fixtures, which -is the whole reason this repository vendors a copy rather than importing one -- -but that change is unmerged, so a checkout on `main` today still holds the file -under `packages/schema-1/`. Checking the new path first means the comparison -follows the file rather than the merge, and works against a checkout on either -side of it. - -Delete the second entry once #162 is merged **and** no release this repository -can pin still predates it -- the CI clone is positioned at the release -`schema_one_tree.source` records, so the old path stays reachable for as long as -that pin can name a pre-#162 release. -""" -_RELEASE_DECLARATION = Path("packages") / "schema-1" / "pyproject.toml" -"""Where the checkout declares which release it is. Read only to explain a failure.""" +_REPO_ROOT = Path(__file__).resolve().parent.parent _VERSION = re.compile(r'^version = "([^"]+)"', re.MULTILINE) From 82a512f215ce3b0d8107cb1ea7068abcd1ab1298 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:39:33 -0700 Subject: [PATCH 134/345] build: point the type-checker path at the library checkout, not a worktree `extraPaths` named `span-panel-api-security`, a scratch worktree created for the security work and merged since. Two consequences, one of which was visible all along: Pyright resolved `span_panel_api` through both that path and the installed distribution, so every symbol crossing the boundary reported as two incompatible types with the same name -- `models.V2HomieSchema` is not assignable to `span_panel_api.models.V2HomieSchema`. mypy, which reads the installed distribution alone, was clean throughout. The second is that the path was about to break: the worktree is being removed now that its branch is merged. `[tool.uv.sources]` above already names the real checkout; this line was the one left behind. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a864815a..a4a364bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -145,7 +145,7 @@ exclude = [ ] extraPaths = [ "./custom_components", - "../../span/span-panel-api-security/src", + "../../span/span-panel-api/src", "../ha-synthetic-sensors/src" ] pythonPlatform = "Darwin" From cb6b9d4d8e3f60c19318c7fefe3d702e8fe95d6c Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:02:48 -0700 Subject: [PATCH 135/345] fix(sensors): a derived reading is unknown while its inputs are MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit span-panel-api now reports a meter value the panel has not sent as None rather than fabricating 0.0, because on a cumulative counter the two are not interchangeable — a fabricated zero is indistinguishable from the reset the dip compensator exists to absorb (SpanPanel/span#259). The four derived value functions flattened that distinction straight back out. Net energy, at circuit and panel level, was `(a or 0) - (b or 0)`, so a circuit that had reported nothing answered a confident 0 Wh; those are TOTAL, not TOTAL_INCREASING, so the cost is a false dip to zero in history rather than corrupted statistics, but it is the same invented reading one layer further out and past the point where anything downstream can tell. Circuit power failed the other way: `-c.instant_power_w` raises TypeError on None, caught upstream and logged as a value-function failure for every PV circuit on every replay. Both now compose through helpers that propagate None, which reaches _process_raw_value and renders the sensor unknown. The panel pair were the worst exposed: both lugs devices are told apart by one `direction` property, so until it arrives neither role resolves and all four inputs are absent together. Test factories widen to float | None to match the snapshot model. --- .../span_panel/sensor_definitions.py | 58 ++++++- tests/factories.py | 18 +-- .../test_unreported_readings_stay_unknown.py | 151 ++++++++++++++++++ 3 files changed, 211 insertions(+), 16 deletions(-) create mode 100644 tests/test_unreported_readings_stay_unknown.py diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py index 0ac8957e..e27b87a2 100644 --- a/custom_components/span_panel/sensor_definitions.py +++ b/custom_components/span_panel/sensor_definitions.py @@ -240,6 +240,50 @@ class SpanPanelBatterySensorEntityDescription( value_fn=lambda b: b.soe_percentage, ) + +# --------------------------------------------------------------------------- +# Composing readings that may not have been reported +# --------------------------------------------------------------------------- +# +# `span-panel-api` reports a reading the panel has not sent as `None` rather +# than fabricating `0.0`, because on a cumulative counter the two are not +# interchangeable: a consumer compensating for firmware counter resets reads a +# fabricated zero as a reset and books the whole counter as an offset +# (`SpanPanel/span#259`). +# +# A derived sensor has to carry that distinction rather than flatten it. These +# were written as `(a or 0) - (b or 0)`, which answers `0` for a circuit that +# has reported nothing — reintroducing exactly the fabrication the library +# stopped making, one layer further out and past the point where anything can +# tell. Returning `None` instead reaches `_process_raw_value`, which renders the +# sensor unknown and, on a `TOTAL_INCREASING` sensor, skips dip compensation so +# the absent reading never becomes a baseline. + + +def _difference(minuend: float | None, subtrahend: float | None) -> float | None: + """`minuend - subtrahend`, or `None` while either side is unreported. + + Half a difference is not a smaller difference, it is no answer: a circuit + that has published its consumed counter and not its produced one knows + nothing yet about the net of the two. + """ + if minuend is None or subtrahend is None: + return None + return minuend - subtrahend + + +def _negated(value: float | None) -> float | None: + """Flip the sign of a reading, preserving `None` and avoiding `-0.0`. + + Negative zero compares equal to `0.0` and renders as "-0.0", which is the + reason the original expression ended in `or 0.0`. That guard could not also + survive `None`, because `-None` raises before it is reached. + """ + if value is None: + return None + return -value or 0.0 + + # --------------------------------------------------------------------------- # Panel diagnostic sensors (promoted from attributes) # --------------------------------------------------------------------------- @@ -1106,8 +1150,8 @@ class SpanPVMetadataSensorEntityDescription( suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, entity_registry_enabled_default=False, - value_fn=lambda s: ( - (s.main_meter_energy_consumed_wh or 0) - (s.main_meter_energy_produced_wh or 0) + value_fn=lambda s: _difference( + s.main_meter_energy_consumed_wh, s.main_meter_energy_produced_wh ), ), SpanPanelDataSensorEntityDescription( @@ -1119,8 +1163,8 @@ class SpanPVMetadataSensorEntityDescription( suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, entity_registry_enabled_default=False, - value_fn=lambda s: ( - (s.feedthrough_energy_consumed_wh or 0) - (s.feedthrough_energy_produced_wh or 0) + value_fn=lambda s: _difference( + s.feedthrough_energy_consumed_wh, s.feedthrough_energy_produced_wh ), ), ) @@ -1141,7 +1185,7 @@ class SpanPVMetadataSensorEntityDescription( suggested_display_precision=0, device_class=SensorDeviceClass.POWER, value_fn=lambda c: ( - (-c.instant_power_w or 0.0) if c.device_type == "pv" else c.instant_power_w + _negated(c.instant_power_w) if c.device_type == "pv" else c.instant_power_w ), entity_registry_enabled_default=True, entity_registry_visible_default=True, @@ -1179,9 +1223,9 @@ class SpanPVMetadataSensorEntityDescription( suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, value_fn=lambda c: ( - (c.produced_energy_wh or 0) - (c.consumed_energy_wh or 0) + _difference(c.produced_energy_wh, c.consumed_energy_wh) if c.device_type == "pv" - else (c.consumed_energy_wh or 0) - (c.produced_energy_wh or 0) + else _difference(c.consumed_energy_wh, c.produced_energy_wh) ), entity_registry_enabled_default=True, entity_registry_visible_default=True, diff --git a/tests/factories.py b/tests/factories.py index 2420529a..31765276 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -34,9 +34,9 @@ def create( circuit_id: str = "1", name: str = "Test Circuit", relay_state: str = CircuitRelayState.CLOSED.name, - instant_power_w: float = 150.5, - consumed_energy_wh: float = 1500.0, - produced_energy_wh: float = 0.0, + instant_power_w: float | None = 150.5, + consumed_energy_wh: float | None = 1500.0, + produced_energy_wh: float | None = 0.0, tabs: list[int] | None = None, priority: str = CircuitPriority.SOC_THRESHOLD.name, is_user_controllable: bool = True, @@ -206,12 +206,12 @@ def create( serial_number: str = "sp3-242424-001", firmware_version: str = "1.2.3", main_relay_state: str = "CLOSED", - instant_grid_power_w: float = 2500.75, - feedthrough_power_w: float = 0.0, - main_meter_energy_consumed_wh: float = 2500.0, - main_meter_energy_produced_wh: float = 0.0, - feedthrough_energy_consumed_wh: float = 0.0, - feedthrough_energy_produced_wh: float = 0.0, + instant_grid_power_w: float | None = 2500.75, + feedthrough_power_w: float | None = 0.0, + main_meter_energy_consumed_wh: float | None = 2500.0, + main_meter_energy_produced_wh: float | None = 0.0, + feedthrough_energy_consumed_wh: float | None = 0.0, + feedthrough_energy_produced_wh: float | None = 0.0, dsm_state: str = DSM_ON_GRID, current_run_config: str = PANEL_ON_GRID, door_state: str = SYSTEM_DOOR_STATE_CLOSED, diff --git a/tests/test_unreported_readings_stay_unknown.py b/tests/test_unreported_readings_stay_unknown.py new file mode 100644 index 00000000..560a174e --- /dev/null +++ b/tests/test_unreported_readings_stay_unknown.py @@ -0,0 +1,151 @@ +"""A derived sensor whose inputs are unreported is unknown, not zero. + +`span-panel-api` reports a reading the panel has not sent as `None` rather than +fabricating `0.0` (`SpanPanel/span#259`). The derived sensors here compose two +such readings, and each was coalescing with `or 0` — so a circuit that had +reported nothing produced a confident net energy of `0`, and the fabrication the +library stopped making reappeared one layer out. + +`None` in, `None` out. `_process_raw_value` already maps that to the sensor's +unknown value and, on a `TOTAL_INCREASING` sensor, skips dip compensation +entirely — leaving `_last_panel_reading` untouched, which is what keeps the +absent reading from being mistaken for a counter reset when the real value +lands. +""" + +from __future__ import annotations + +from collections.abc import Callable + +import pytest +from span_panel_api import SpanCircuitSnapshot, SpanPanelSnapshot + +from custom_components.span_panel.sensor_definitions import ( + CIRCUIT_SENSORS, + PANEL_ENERGY_SENSORS, +) +from tests.factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory + +_NET_ENERGY_SENSORS = [ + ("mainMeterNetEnergyWh", "main_meter_energy_consumed_wh", "main_meter_energy_produced_wh"), + ("feedthroughNetEnergyWh", "feedthrough_energy_consumed_wh", "feedthrough_energy_produced_wh"), +] + + +def _circuit_value_fn(key: str) -> Callable[[SpanCircuitSnapshot], float | None]: + """Return the value function of the named circuit sensor description.""" + return next(desc for desc in CIRCUIT_SENSORS if desc.key == key).value_fn + + +def _panel_value_fn(key: str) -> Callable[[SpanPanelSnapshot], float | str | None]: + """Return the value function of the named panel energy sensor description.""" + return next(desc for desc in PANEL_ENERGY_SENSORS if desc.key == key).value_fn + + +class TestCircuitNetEnergy: + """`circuit_energy_net` composes the two circuit counters.""" + + def test_unknown_while_neither_counter_has_reported(self) -> None: + """A circuit that has published nothing knows nothing about its net.""" + circuit = SpanCircuitSnapshotFactory.create( + consumed_energy_wh=None, produced_energy_wh=None + ) + + assert _circuit_value_fn("circuit_energy_net")(circuit) is None + + def test_unknown_while_only_one_counter_has_reported(self) -> None: + """Half a difference is not a smaller difference, it is no answer.""" + circuit = SpanCircuitSnapshotFactory.create( + consumed_energy_wh=1500.0, produced_energy_wh=None + ) + + assert _circuit_value_fn("circuit_energy_net")(circuit) is None + + def test_reported_zeros_still_compute(self) -> None: + """Two counters that have published zero have a net of zero.""" + circuit = SpanCircuitSnapshotFactory.create(consumed_energy_wh=0.0, produced_energy_wh=0.0) + + assert _circuit_value_fn("circuit_energy_net")(circuit) == 0.0 + + def test_the_arithmetic_is_unchanged_when_both_report(self) -> None: + """Consumption less generation, exactly as before.""" + circuit = SpanCircuitSnapshotFactory.create( + consumed_energy_wh=1500.0, produced_energy_wh=400.0 + ) + + assert _circuit_value_fn("circuit_energy_net")(circuit) == pytest.approx(1100.0) + + def test_a_pv_circuit_keeps_its_inverted_sign(self) -> None: + """A PV circuit nets generation less consumption, the other way round.""" + circuit = SpanCircuitSnapshotFactory.create( + device_type="pv", consumed_energy_wh=400.0, produced_energy_wh=1500.0 + ) + + assert _circuit_value_fn("circuit_energy_net")(circuit) == pytest.approx(1100.0) + + +class TestCircuitPower: + """`circuit_power` negates for PV, which cannot be done to `None`.""" + + def test_unknown_while_the_meter_has_not_reported(self) -> None: + """An unreported power reading passes straight through as unknown.""" + circuit = SpanCircuitSnapshotFactory.create(instant_power_w=None) + + assert _circuit_value_fn("circuit_power")(circuit) is None + + def test_unknown_for_a_pv_circuit_that_has_not_reported(self) -> None: + """The negation is why this one raised rather than lying. + + `-None` is a `TypeError`, caught upstream and logged as a value-function + failure on every PV circuit, on every replay. + """ + circuit = SpanCircuitSnapshotFactory.create(device_type="pv", instant_power_w=None) + + assert _circuit_value_fn("circuit_power")(circuit) is None + + def test_a_pv_circuit_still_reports_generation_as_positive(self) -> None: + """Backfeed is negative on the wire and positive on the sensor.""" + circuit = SpanCircuitSnapshotFactory.create(device_type="pv", instant_power_w=-1500.0) + + assert _circuit_value_fn("circuit_power")(circuit) == pytest.approx(1500.0) + + def test_a_pv_circuit_at_zero_does_not_report_negative_zero(self) -> None: + """Preserved from the original expression. + + `-0.0` compares equal to `0.0` but renders as "-0.0" in the UI. + """ + circuit = SpanCircuitSnapshotFactory.create(device_type="pv", instant_power_w=0.0) + + result = _circuit_value_fn("circuit_power")(circuit) + + assert result == 0.0 + assert str(result) == "0.0" + + +class TestPanelNetEnergy: + """The two panel-level net sensors compose the lugs counters. + + Worse exposed than a circuit's: both lugs devices are told apart by a single + `direction` property, so until it arrives neither role resolves and all four + inputs are unreported at once. + """ + + @pytest.mark.parametrize(("key", "consumed_field", "produced_field"), _NET_ENERGY_SENSORS) + def test_unknown_while_the_lugs_have_not_reported( + self, key: str, consumed_field: str, produced_field: str + ) -> None: + """Neither lugs role resolved means no net to report.""" + snapshot = SpanPanelSnapshotFactory.create(**{consumed_field: None, produced_field: None}) + + assert _panel_value_fn(key)(snapshot) is None + + @pytest.mark.parametrize(("key", "consumed_field", "produced_field"), _NET_ENERGY_SENSORS) + def test_the_arithmetic_is_unchanged_when_both_report( + self, key: str, consumed_field: str, produced_field: str + ) -> None: + """Import less export, exactly as before.""" + snapshot = SpanPanelSnapshotFactory.create( + **{consumed_field: 5000.0, produced_field: 1200.0} + ) + + assert _panel_value_fn(key)(snapshot) == pytest.approx(3800.0) From f16c208316ee46e20d52ed3cc75f99d128d0fca8 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:26:24 -0700 Subject: [PATCH 136/345] fix(dip): a compensation offset the next reading disproves is retracted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dip compensation booked an offset the moment a counter read lower than it had, on the evidence of one sample, and that write is irreversible: it lands in long-term statistics, where undoing it is a manual adjustment per affected hour. SpanPanel/span#259 is what a wrong sample costs there — a transient zero booking a circuit's whole lifetime counter, every restart, reaching 8.18 MWh on one circuit. A firmware counter reset is permanent: the counter restarts low and counts up from there. So a reading that drops and then returns was never a reset. Compensation is still applied the moment the drop is seen, because the sensor has to stay correct meanwhile, but the conclusion is held open: retract raw >= baseline - 1.0 the counter came back confirm dipped_raw < raw < baseline-1.0 it is counting from the lower base hold raw == dipped_raw no evidence either way Confirmation needs independent progress rather than a second low reading. Snapshots dispatch on a debounce, so one replay can deliver the same incomplete state twice; a rule satisfied by any low reading would be satisfied by a second copy of the sample it exists to reject. The notification moves from detection to confirmation. Firing on sight is what made #259 so loud — a notification naming essentially every circuit on the panel for an event that had not happened. A retracted dip now produces none, because nothing happened. The pending record persists, because a restart is exactly when one is most likely to be outstanding: the reading that settles a dip is the one after the reading that booked it, and HA can stop in between. Forgetting it was provisional would make the restart itself the thing that makes a bad offset permanent. Two existing tests pinned the old behaviour and now state the new: a counter that came back used to leave its offset behind for good, and the coordinator was told on detection. Cost: a genuine reset on a circuit drawing nothing stays unconfirmed until it next moves. Only the notification waits; compensation applies throughout. --- custom_components/span_panel/energy_dip.py | 136 +++++++++-- custom_components/span_panel/grace_period.py | 11 + custom_components/span_panel/sensor_base.py | 67 +++++- tests/test_energy_dip_compensation.py | 137 ++++++++++- tests/test_energy_dip_retraction.py | 237 +++++++++++++++++++ tests/test_grace_period_restoration.py | 14 +- 6 files changed, 556 insertions(+), 46 deletions(-) create mode 100644 tests/test_energy_dip_retraction.py diff --git a/custom_components/span_panel/energy_dip.py b/custom_components/span_panel/energy_dip.py index 515b7b38..1e9746f8 100644 --- a/custom_components/span_panel/energy_dip.py +++ b/custom_components/span_panel/energy_dip.py @@ -3,40 +3,148 @@ Pure functions for detecting energy value dips (typically caused by panel firmware resets) and computing the compensated value to maintain monotonic TOTAL_INCREASING sensor readings. + +A dip is compensated as soon as it is seen, because the sensor must stay correct +in the meantime, but the *conclusion* that a reset happened is held open until a +later reading settles it. That is the difference between this and a plain +"lower reading means reset" rule, and the reason is that the write is +irreversible: the offset lands in Home Assistant's long-term statistics, where +undoing it is a manual statistics adjustment per affected hour. + +See `process_energy_dip` for the rule and `tests/test_energy_dip_retraction.py` +for the sequences it is written against. """ from __future__ import annotations -from typing import Any +from enum import StrEnum +from typing import Any, NamedTuple + +DIP_THRESHOLD_WH = 1.0 +"""How far a counter must fall before the drop counts as a dip. + +Also how far it must return before the drop counts as undone, so the two +decisions are made against the same tolerance rather than one being exact. +""" + + +class PendingDip(NamedTuple): + """A booked but unsettled dip. + + `baseline` is what the counter read before any of it — the high-water mark + a recovery is judged against, which does not move if the counter falls + again. `delta` is how much has been added to the offset provisionally, and + is what gets removed if the dip turns out not to have happened. + """ + + baseline: float + delta: float + + +class DipEvent(StrEnum): + """What a reading did to the pending state, when it did anything.""" + + BOOKED = "booked" + """A drop was seen and compensated. Not yet believed.""" + + CONFIRMED = "confirmed" + """The counter counted up from the lower base, so the reset was real.""" + + RETRACTED = "retracted" + """The counter came back, so no reset happened and the offset is removed.""" + + +class DipOutcome(NamedTuple): + """The result of feeding one raw reading through the rule.""" + + offset: float + pending: PendingDip | None + compensated: float + event: DipEvent | None + delta: float | None + """The amount the event concerns, or None when nothing happened.""" def process_energy_dip( raw_value: float, last_panel_reading: float | None, current_offset: float, -) -> tuple[float, float | None, float]: - """Detect an energy dip and return updated offset, optional dip delta, and compensated value. - - A "dip" occurs when the panel reports a raw energy value that is at least 1.0 - lower than the previous reading (e.g., after a firmware reset). When detected, - the difference is added to the cumulative offset so downstream sensors keep a - monotonically increasing total. + pending: PendingDip | None, +) -> DipOutcome: + """Advance dip compensation by one reading. + + A firmware counter reset is permanent: the counter restarts low and counts + up from there. So a reading that drops and then returns to where it was is + not a reset — it is a transport artefact, a stale sample, or a reading the + panel never actually made. Both look identical on the tick they arrive, and + that single sample used to be enough to add a circuit's whole lifetime + counter to its offset for good (`SpanPanel/span#259`). + + The rule keeps the compensation and defers the belief: + + - **A drop of at least `DIP_THRESHOLD_WH`** is compensated immediately and + recorded as pending. The compensated value does not move on this tick, + which is why the spike shows up one sample later. + - **A later reading back at the baseline** (within the same tolerance) + retracts it: the counter never reset, so the offset is removed. + - **A later reading above the dipped one but still below the baseline** + confirms it: the counter is demonstrably counting up from the lower base, + which only a reset produces. + - **A repeat of the dipped reading** settles nothing and is held. + + That last case is why confirmation needs *progress* rather than just a + second low reading. Snapshots are dispatched on a debounce, so one replay + can deliver the same incomplete state twice; a rule that accepted any low + reading as corroboration would be satisfied by a second copy of the very + sample it exists to reject. + + A counter that falls again before anything is settled accumulates into the + same pending record and keeps the original baseline, because a recovery has + to be judged against what the counter read before any of the fall. + + The cost is that a genuine reset on a circuit drawing nothing stays + unconfirmed until it next moves, deferring only the notification — + compensation is applied throughout, so the sensor reads correctly while the + evidence is outstanding. Args: raw_value: The current raw energy reading from the panel. last_panel_reading: The previous raw reading, or None if this is the first. current_offset: The cumulative compensation offset so far. + pending: An unsettled dip from an earlier reading, if there is one. Returns: - A 3-tuple of (new_offset, dip_delta_or_none, compensated_value). + A `DipOutcome`. `event` names what happened, if anything; `delta` is the + amount it concerns; `pending` is the state to carry into the next call. """ - if last_panel_reading is not None and last_panel_reading - raw_value >= 1.0: + if last_panel_reading is not None and last_panel_reading - raw_value >= DIP_THRESHOLD_WH: dip = last_panel_reading - raw_value - new_offset = current_offset + dip - return (new_offset, dip, raw_value + new_offset) - - return (current_offset, None, raw_value + current_offset) + offset = current_offset + dip + # The baseline is the high-water mark, so a second fall extends the + # provisional amount without lowering the mark it will be judged against. + booked = ( + PendingDip(baseline=last_panel_reading, delta=dip) + if pending is None + else PendingDip(baseline=pending.baseline, delta=pending.delta + dip) + ) + return DipOutcome(offset, booked, raw_value + offset, DipEvent.BOOKED, dip) + + if pending is not None: + if raw_value >= pending.baseline - DIP_THRESHOLD_WH: + offset = current_offset - pending.delta + return DipOutcome(offset, None, raw_value + offset, DipEvent.RETRACTED, pending.delta) + + if last_panel_reading is not None and raw_value > last_panel_reading: + return DipOutcome( + current_offset, + None, + raw_value + current_offset, + DipEvent.CONFIRMED, + pending.delta, + ) + + return DipOutcome(current_offset, pending, raw_value + current_offset, None, None) def build_dip_attributes( diff --git a/custom_components/span_panel/grace_period.py b/custom_components/span_panel/grace_period.py index aa499174..6b52453b 100644 --- a/custom_components/span_panel/grace_period.py +++ b/custom_components/span_panel/grace_period.py @@ -64,6 +64,13 @@ class SpanEnergyExtraStoredData(ExtraStoredData): energy_offset: float | None = None last_panel_reading: float | None = None last_dip_delta: float | None = None + # An unsettled dip, carried across restarts because a restart is exactly + # when one is most likely to be outstanding: the reading that would settle + # it is the one after the reading that booked it, and Home Assistant may + # stop in between. Dropping it would silently confirm the offset by + # forgetting it was ever provisional. + pending_dip_baseline: float | None = None + pending_dip_delta: float | None = None def as_dict(self) -> dict[str, Any]: """Return a dict representation of the extra data.""" @@ -75,6 +82,8 @@ def as_dict(self) -> dict[str, Any]: "energy_offset": self.energy_offset, "last_panel_reading": self.last_panel_reading, "last_dip_delta": self.last_dip_delta, + "pending_dip_baseline": self.pending_dip_baseline, + "pending_dip_delta": self.pending_dip_delta, } @classmethod @@ -97,6 +106,8 @@ def from_dict(cls, restored: dict[str, Any]) -> Self | None: energy_offset=restored.get("energy_offset"), last_panel_reading=restored.get("last_panel_reading"), last_dip_delta=restored.get("last_dip_delta"), + pending_dip_baseline=restored.get("pending_dip_baseline"), + pending_dip_delta=restored.get("pending_dip_delta"), ) except (AttributeError, KeyError, TypeError): return None diff --git a/custom_components/span_panel/sensor_base.py b/custom_components/span_panel/sensor_base.py index 974117a8..a4f57680 100644 --- a/custom_components/span_panel/sensor_base.py +++ b/custom_components/span_panel/sensor_base.py @@ -24,7 +24,13 @@ from .const import DOMAIN, ENABLE_ENERGY_DIP_COMPENSATION from .coordinator import SpanPanelCoordinator -from .energy_dip import build_dip_attributes, process_energy_dip +from .energy_dip import ( + DipEvent, + DipOutcome, + PendingDip, + build_dip_attributes, + process_energy_dip, +) from .entity import SpanPanelEntity from .grace_period import ( # noqa: F401 SpanEnergyExtraStoredData, @@ -505,6 +511,10 @@ def __init__( self._energy_offset: float = 0.0 self._last_panel_reading: float | None = None self._last_dip_delta: float | None = None + # A dip that has been compensated but not yet settled — see + # `process_energy_dip`. Non-None means part of `_energy_offset` is + # provisional and may still be taken back. + self._pending_dip: PendingDip | None = None self._is_total_increasing: bool = ( getattr(description, "state_class", None) == SensorStateClass.TOTAL_INCREASING ) @@ -525,22 +535,49 @@ def _process_raw_value(self, raw_value: float | str | None) -> None: and isinstance(raw_value, float | int) ): raw_float = float(raw_value) - new_offset, dip_delta, compensated = process_energy_dip( - raw_float, self._last_panel_reading, self._energy_offset + outcome = process_energy_dip( + raw_float, self._last_panel_reading, self._energy_offset, self._pending_dip ) - if dip_delta is not None: - self._last_dip_delta = dip_delta - self.coordinator.report_energy_dip( - self.entity_id or self._attr_unique_id or "unknown", - dip_delta, - new_offset, - ) - self._energy_offset = new_offset + self._apply_dip_event(outcome) + self._energy_offset = outcome.offset + self._pending_dip = outcome.pending self._last_panel_reading = raw_float - super()._process_raw_value(compensated) + super()._process_raw_value(outcome.compensated) else: super()._process_raw_value(raw_value) + def _apply_dip_event(self, outcome: DipOutcome) -> None: + """Record the diagnostic, and tell the coordinator once a dip is believed. + + The notification moves to confirmation rather than firing when the dip + is first seen. Reporting on sight is what made `SpanPanel/span#259` so + loud — a notification naming essentially every circuit on the panel, + for an event that had not happened. A dip that gets retracted now + produces no notification at all, because nothing happened. + + `last_dip_delta` is set when the dip is booked, since it describes what + the counter did and the compensation is applied from that moment, and + cleared on retraction, since by then the counter turns out not to have + done it. + """ + if outcome.event is DipEvent.BOOKED: + self._last_dip_delta = outcome.delta + elif outcome.event is DipEvent.RETRACTED: + self._last_dip_delta = None + _LOGGER.debug( + "Energy dip retracted for %s: %s Wh returned to %s, offset back to %s", + self.entity_id or self._attr_unique_id, + outcome.delta, + self._pending_dip.baseline if self._pending_dip else None, + outcome.offset, + ) + elif outcome.event is DipEvent.CONFIRMED and outcome.delta is not None: + self.coordinator.report_energy_dip( + self.entity_id or self._attr_unique_id or "unknown", + outcome.delta, + outcome.offset, + ) + async def async_added_to_hass(self) -> None: """Restore grace period state when entity is added to Home Assistant. @@ -573,6 +610,10 @@ async def async_added_to_hass(self) -> None: self._last_panel_reading = restored.last_panel_reading if restored.last_dip_delta is not None: self._last_dip_delta = restored.last_dip_delta + if restored.pending_dip_baseline is not None and restored.pending_dip_delta is not None: + self._pending_dip = PendingDip( + baseline=restored.pending_dip_baseline, delta=restored.pending_dip_delta + ) _LOGGER.debug( "Restored energy dip compensation for %s: offset=%s, last_reading=%s, last_dip=%s", self.entity_id or self._attr_unique_id, @@ -667,6 +708,8 @@ def extra_restore_state_data(self) -> SensorExtraStoredData: energy_offset=self._energy_offset or None, last_panel_reading=self._last_panel_reading, last_dip_delta=self._last_dip_delta, + pending_dip_baseline=self._pending_dip.baseline if self._pending_dip else None, + pending_dip_delta=self._pending_dip.delta if self._pending_dip else None, ), ) diff --git a/tests/test_energy_dip_compensation.py b/tests/test_energy_dip_compensation.py index 394b878a..8299aede 100644 --- a/tests/test_energy_dip_compensation.py +++ b/tests/test_energy_dip_compensation.py @@ -7,6 +7,7 @@ from homeassistant.components.sensor import SensorStateClass from custom_components.span_panel.const import ENABLE_ENERGY_DIP_COMPENSATION +from custom_components.span_panel.energy_dip import PendingDip from custom_components.span_panel.options import ENERGY_REPORTING_GRACE_PERIOD from custom_components.span_panel.sensor_base import ( SpanEnergyExtraStoredData, @@ -55,9 +56,8 @@ def __init__( # pylint: disable=super-init-not-called self._energy_offset: float = 0.0 self._last_panel_reading: float | None = None self._last_dip_delta: float | None = None - self._is_total_increasing: bool = ( - state_class == SensorStateClass.TOTAL_INCREASING - ) + self._pending_dip: PendingDip | None = None + self._is_total_increasing: bool = state_class == SensorStateClass.TOTAL_INCREASING self._dip_compensation_enabled: bool = dip_enabled def _generate_unique_id(self, snapshot, description): @@ -225,7 +225,13 @@ def test_below_threshold_ignored(self): assert sensor._last_dip_delta is None def test_multiple_dips_accumulate(self): - """Multiple dips accumulate the offset.""" + """Multiple confirmed dips accumulate the offset. + + Each has to be corroborated by the counter climbing from its new, lower + base — which is what a firmware reset actually looks like — rather than + by the counter returning to where it was, which is what a transient bad + reading looks like and is now retracted instead. + """ sensor = DummyDipSensor(dip_enabled=True) sensor._mock_panel_value = 1000.0 @@ -237,16 +243,47 @@ def test_multiple_dips_accumulate(self): assert sensor._energy_offset == 20.0 assert sensor._attr_native_value == 1000.0 - # Normal increase - sensor._mock_panel_value = 1010.0 + # Counting up from the lower base confirms it + sensor._mock_panel_value = 985.0 + sensor._update_native_value() + assert sensor._energy_offset == 20.0 + assert sensor._attr_native_value == 1005.0 # 985 + 20 + + # Second dip: 25 Wh + sensor._mock_panel_value = 960.0 + sensor._update_native_value() + assert sensor._energy_offset == 45.0 # 20 + 25 + assert sensor._attr_native_value == 1005.0 # 960 + 45 + + sensor._mock_panel_value = 965.0 + sensor._update_native_value() + assert sensor._energy_offset == 45.0 + assert sensor._attr_native_value == 1010.0 # 965 + 45 + + def test_a_counter_that_comes_back_leaves_no_offset_behind(self): + """The behaviour this rule changed, stated directly. + + A reading that dipped and then rose past where it started was compensated + permanently: the offset stayed, inflating the sensor by the size of a + drop that never happened. `SpanPanel/span#259` is the same arithmetic on + a lifetime counter instead of 20 Wh. + """ + sensor = DummyDipSensor(dip_enabled=True) + + sensor._mock_panel_value = 1000.0 sensor._update_native_value() - assert sensor._attr_native_value == 1030.0 # 1010 + 20 - # Second dip: 30 Wh sensor._mock_panel_value = 980.0 sensor._update_native_value() - assert sensor._energy_offset == 50.0 # 20 + 30 - assert sensor._attr_native_value == 1030.0 # 980 + 50 + assert sensor._energy_offset == 20.0 + + sensor._mock_panel_value = 1010.0 + sensor._update_native_value() + + assert sensor._energy_offset == 0.0 + assert sensor._attr_native_value == 1010.0 + assert sensor._last_dip_delta is None + sensor.coordinator.report_energy_dip.assert_not_called() def test_disabled_passthrough(self): """When disabled, dips pass through without compensation.""" @@ -279,8 +316,14 @@ def test_non_total_increasing_passthrough(self): assert sensor._attr_native_value == 950.0 assert sensor._energy_offset == 0.0 - def test_dip_reports_to_coordinator(self): - """Dip detection calls coordinator.report_energy_dip.""" + def test_dip_reports_to_coordinator_once_confirmed(self): + """Confirmation calls coordinator.report_energy_dip, not detection. + + The notification waits for the counter to corroborate the drop. Firing + on sight is what produced the `SpanPanel/span#259` notification naming + essentially every circuit on the panel for an event that had not + happened. + """ sensor = DummyDipSensor(dip_enabled=True) sensor.entity_id = "sensor.test_energy" @@ -289,6 +332,10 @@ def test_dip_reports_to_coordinator(self): sensor._mock_panel_value = 950.0 sensor._update_native_value() + sensor.coordinator.report_energy_dip.assert_not_called() + + sensor._mock_panel_value = 955.0 + sensor._update_native_value() sensor.coordinator.report_energy_dip.assert_called_once_with( "sensor.test_energy", 50.0, 50.0 @@ -454,3 +501,69 @@ def test_extra_restore_state_data_zero_offset_stored_as_none(self): stored = sensor.extra_restore_state_data d = stored.as_dict() assert d["energy_offset"] is None + + +# ============================================================================= +# An unsettled dip across a restart +# ============================================================================= + + +class TestPendingDipSurvivesARestart: + """A restart between booking a dip and settling it must not settle it. + + This is the sequence that matters most, because the two are adjacent: the + reading that would settle a dip is the one after the reading that booked it, + and Home Assistant can stop in between. Forgetting the dip was provisional + would confirm it by default — turning the restart itself into the thing that + makes a bad offset permanent, which is `SpanPanel/span#259` exactly. + """ + + def test_the_pending_record_is_persisted(self): + sensor = DummyDipSensor(dip_enabled=True) + + sensor._mock_panel_value = 1000.0 + sensor._update_native_value() + sensor._mock_panel_value = 0.0 + sensor._update_native_value() + + stored = sensor.extra_restore_state_data.as_dict() + + assert stored["pending_dip_baseline"] == 1000.0 + assert stored["pending_dip_delta"] == 1000.0 + + def test_nothing_pending_is_persisted_as_none(self): + sensor = DummyDipSensor(dip_enabled=True) + sensor._attr_native_value = 1000.0 + + stored = sensor.extra_restore_state_data.as_dict() + + assert stored["pending_dip_baseline"] is None + assert stored["pending_dip_delta"] is None + + def test_a_restart_mid_dip_still_retracts_when_the_counter_returns(self): + """The offset booked before the restart is given back after it.""" + before = DummyDipSensor(dip_enabled=True) + before._mock_panel_value = 1000.0 + before._update_native_value() + before._mock_panel_value = 0.0 + before._update_native_value() + stored = before.extra_restore_state_data.as_dict() + + after = DummyDipSensor(dip_enabled=True) + restored = SpanEnergyExtraStoredData.from_dict(stored) + assert restored is not None + after._energy_offset = restored.energy_offset or 0.0 + after._last_panel_reading = restored.last_panel_reading + after._last_dip_delta = restored.last_dip_delta + assert restored.pending_dip_baseline is not None + assert restored.pending_dip_delta is not None + after._pending_dip = PendingDip( + baseline=restored.pending_dip_baseline, delta=restored.pending_dip_delta + ) + + after._mock_panel_value = 1007.6 + after._update_native_value() + + assert after._energy_offset == 0.0 + assert after._attr_native_value == 1007.6 + after.coordinator.report_energy_dip.assert_not_called() diff --git a/tests/test_energy_dip_retraction.py b/tests/test_energy_dip_retraction.py new file mode 100644 index 00000000..1811d32f --- /dev/null +++ b/tests/test_energy_dip_retraction.py @@ -0,0 +1,237 @@ +"""A counter reset is permanent, so a dip the next reading disproves is retracted. + +Dip compensation books an offset the moment a counter reads lower than it did, +and that write is irreversible: it lands in Home Assistant's long-term +statistics. The evidence for it is one sample. `SpanPanel/span#259` is what that +costs when the sample is wrong — a transient zero booked the whole lifetime +counter as an offset on every restart, reaching megawatt-hours. + +The library no longer fabricates that particular zero (an unreported reading is +`None`, which skips compensation entirely). This is the layer under it: whatever +the source, a reading that drops and then comes back was never a reset, because +a reset is permanent. So the offset is provisional until a later reading either +disproves it — the counter returns to where it was — or corroborates it, by +counting up from the lower base. + +**Corroboration needs independent progress, not merely a second low reading.** +Confirming on any reading below the baseline would confirm on a second copy of +the same bad sample, which is exactly the shape a replay produces: snapshots are +dispatched on a debounce, so one burst can deliver the same incomplete state +twice. That is the failure this rule exists to catch, so it must not be the +failure that satisfies it. + +The cost is that a genuine reset on a circuit drawing nothing stays unconfirmed +until it next moves. Compensation is applied throughout — only the notification +waits — so the sensor reads correctly while the evidence is outstanding. +""" + +from __future__ import annotations + +import pytest + +from custom_components.span_panel.energy_dip import ( + DipEvent, + PendingDip, + process_energy_dip, +) + +BASELINE = 163562.4 +"""A plausible lifetime counter, from the report in #259.""" + + +def _book(raw: float = 0.0, offset: float = 0.0) -> tuple[float, PendingDip]: + """Run the tick that books a dip, returning the new offset and pending record.""" + outcome = process_energy_dip(raw, BASELINE, offset, None) + + assert outcome.event is DipEvent.BOOKED + assert outcome.pending is not None + return outcome.offset, outcome.pending + + +class TestBooking: + """The tick on which the counter first reads low.""" + + def test_a_drop_books_an_offset_and_leaves_it_unconfirmed(self) -> None: + """Compensated at once, believed later.""" + outcome = process_energy_dip(0.0, BASELINE, 0.0, None) + + assert outcome.event is DipEvent.BOOKED + assert outcome.offset == pytest.approx(BASELINE) + assert outcome.pending == PendingDip(baseline=BASELINE, delta=BASELINE) + + def test_the_compensated_value_does_not_move_on_the_booking_tick(self) -> None: + """The dip tick is invisible downstream. + + Which is what makes the spike arrive one sample later and look like real + consumption. + """ + outcome = process_energy_dip(0.0, BASELINE, 0.0, None) + + assert outcome.compensated == pytest.approx(BASELINE) + + def test_a_drop_below_the_threshold_books_nothing(self) -> None: + """Noise is not a dip.""" + outcome = process_energy_dip(BASELINE - 0.5, BASELINE, 0.0, None) + + assert outcome.event is None + assert outcome.pending is None + assert outcome.offset == 0.0 + + def test_the_first_reading_of_all_books_nothing(self) -> None: + """Nothing to compare a first reading against.""" + outcome = process_energy_dip(BASELINE, None, 0.0, None) + + assert outcome.event is None + assert outcome.pending is None + + +class TestRetraction: + """The counter comes back, so no reset happened.""" + + def test_a_reading_back_at_the_baseline_retracts_the_offset(self) -> None: + """The counter is where it started, so it never reset.""" + offset, pending = _book() + + outcome = process_energy_dip(BASELINE, 0.0, offset, pending) + + assert outcome.event is DipEvent.RETRACTED + assert outcome.offset == 0.0 + assert outcome.pending is None + + def test_a_reading_above_the_baseline_retracts_too(self) -> None: + """The ordinary case: the panel kept counting while the reading was lost.""" + offset, pending = _book() + + outcome = process_energy_dip(BASELINE + 7.6, 0.0, offset, pending) + + assert outcome.event is DipEvent.RETRACTED + assert outcome.offset == 0.0 + + def test_the_compensated_value_returns_to_the_uninflated_reading(self) -> None: + """The whole point: no step. + + #259 saw the counter's full value added to the sensor in a single sample. + """ + offset, pending = _book() + + outcome = process_energy_dip(BASELINE + 7.6, 0.0, offset, pending) + + assert outcome.compensated == pytest.approx(BASELINE + 7.6) + + def test_an_earlier_offset_survives_the_retraction(self) -> None: + """Retraction removes the provisional amount, not the whole offset. + + A real reset compensated last month is not undone by this month's + artefact. + """ + offset, pending = _book(offset=500.0) + + outcome = process_energy_dip(BASELINE, 0.0, offset, pending) + + assert outcome.offset == pytest.approx(500.0) + + def test_a_reading_just_under_the_baseline_still_retracts(self) -> None: + """Symmetric with the booking threshold. + + A difference under 1.0 Wh is not a difference. + """ + offset, pending = _book() + + outcome = process_energy_dip(BASELINE - 0.5, 0.0, offset, pending) + + assert outcome.event is DipEvent.RETRACTED + + +class TestConfirmation: + """The counter counts up from the lower base, so the reset was real.""" + + def test_progress_from_the_dipped_reading_confirms(self) -> None: + """Counting up from the lower base is what a reset looks like.""" + offset, pending = _book() + + outcome = process_energy_dip(5.0, 0.0, offset, pending) + + assert outcome.event is DipEvent.CONFIRMED + assert outcome.delta == pytest.approx(BASELINE) + assert outcome.pending is None + assert outcome.offset == pytest.approx(BASELINE) + + def test_the_offset_stays_applied_through_confirmation(self) -> None: + """Confirming settles the belief, it does not change the arithmetic.""" + offset, pending = _book() + + outcome = process_energy_dip(5.0, 0.0, offset, pending) + + assert outcome.compensated == pytest.approx(BASELINE + 5.0) + + +class TestHolding: + """No evidence either way, so the decision waits.""" + + def test_a_repeat_of_the_dipped_reading_does_not_confirm(self) -> None: + """The reason a single-tick confirmation is unsafe. + + Snapshots are dispatched on a debounce, so one replay can deliver the + same incomplete state more than once. A rule that took any low reading + as corroboration would be satisfied by the second copy of the very + sample it exists to catch. + """ + offset, pending = _book() + + outcome = process_energy_dip(0.0, 0.0, offset, pending) + + assert outcome.event is None + assert outcome.pending == pending + assert outcome.offset == pytest.approx(BASELINE) + + def test_holding_keeps_the_sensor_compensated(self) -> None: + """Waiting for evidence must not make the reading wrong meanwhile.""" + offset, pending = _book() + + outcome = process_energy_dip(0.0, 0.0, offset, pending) + + assert outcome.compensated == pytest.approx(BASELINE) + + def test_a_second_phantom_zero_then_recovery_still_retracts(self) -> None: + """The #259 sequence end to end, with the burst delivering twice.""" + offset, pending = _book() + + held = process_energy_dip(0.0, 0.0, offset, pending) + recovered = process_energy_dip(BASELINE + 7.6, 0.0, held.offset, held.pending) + + assert recovered.event is DipEvent.RETRACTED + assert recovered.offset == 0.0 + assert recovered.compensated == pytest.approx(BASELINE + 7.6) + + +class TestASecondDropWhileUnconfirmed: + """A counter that keeps falling before anything is settled.""" + + def test_the_provisional_amount_accumulates(self) -> None: + """A second fall extends the amount that is still provisional.""" + offset, pending = _book(raw=100.0) + + outcome = process_energy_dip(20.0, 100.0, offset, pending) + + # Two falls, BASELINE -> 100 and 100 -> 20, so the counter is short by + # everything above its current reading. + assert outcome.event is DipEvent.BOOKED + assert outcome.offset == pytest.approx(BASELINE - 20.0) + assert outcome.pending is not None + assert outcome.pending.delta == pytest.approx(BASELINE - 20.0) + + def test_the_baseline_stays_the_high_water_mark(self) -> None: + """Retraction restores what the counter read before any of it. + + So a later, lower reading must not move the mark it is judged against. + """ + offset, pending = _book(raw=100.0) + second = process_energy_dip(20.0, 100.0, offset, pending) + + assert second.pending is not None + assert second.pending.baseline == pytest.approx(BASELINE) + + recovered = process_energy_dip(BASELINE, 20.0, second.offset, second.pending) + + assert recovered.event is DipEvent.RETRACTED + assert recovered.offset == pytest.approx(0.0) diff --git a/tests/test_grace_period_restoration.py b/tests/test_grace_period_restoration.py index 9617e354..f8a96f25 100644 --- a/tests/test_grace_period_restoration.py +++ b/tests/test_grace_period_restoration.py @@ -37,6 +37,8 @@ def test_as_dict_with_all_values(self): "energy_offset": None, "last_panel_reading": None, "last_dip_delta": None, + "pending_dip_baseline": None, + "pending_dip_delta": None, } def test_as_dict_with_none_values(self): @@ -58,6 +60,8 @@ def test_as_dict_with_none_values(self): "energy_offset": None, "last_panel_reading": None, "last_dip_delta": None, + "pending_dip_baseline": None, + "pending_dip_delta": None, } def test_from_dict_with_all_values(self): @@ -137,9 +141,7 @@ def test_roundtrip_serialization(self): assert restored is not None assert restored.native_value == original.native_value - assert ( - restored.native_unit_of_measurement == original.native_unit_of_measurement - ) + assert restored.native_unit_of_measurement == original.native_unit_of_measurement assert restored.last_valid_state == original.last_valid_state assert restored.last_valid_changed == original.last_valid_changed @@ -221,11 +223,7 @@ def test_grace_period_edge_case_exactly_at_limit(self): # At exactly the limit, should still be within grace period (<= comparison) # Allow small timing difference assert ( - abs( - time_since_last_valid.total_seconds() - - grace_period_duration.total_seconds() - ) - < 1 + abs(time_since_last_valid.total_seconds() - grace_period_duration.total_seconds()) < 1 ) def test_grace_period_zero_disabled(self): From 0cb4379e4b3c37ea92e62c9ec709dc9222cfaa6a Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:16:58 -0700 Subject: [PATCH 137/345] build: hold span-panel-api to the checkout, at commit time and at run time `3cbf02a` pointed `[tool.uv.sources]` and `[tool.pyright].extraPaths` at a scratch worktree and committed it. It stood until `82a512f` -- several commits and hours of other work -- and while it stood a test fixture was vendored through that resolution from a checkout that still recorded a producer defect the library had already fixed. Every version-based check passed, because the stale worktree declared the same version number as the corrected code. Only a byte comparison caught it. A version string does not identify content. Only a filesystem location does. Three layers, so the defect cannot repeat. `tests/test_library_resolution.py` reads `span_panel_api.__file__` and compares it against what `pyproject.toml` declares and what `manifest.json` pins -- neither written down here, both derived. The pinned version out of the declared location passes. That location at another version fails everywhere: nothing else can see it, because every copy of the pin agrees with the checkout's own declaration. A deliberate editable override skips locally, naming the path and version it found, and fails under `CI`, where the workflow deletes `[tool.uv.sources]` and no legitimate override exists. The fourth case is the dangerous one. A worktree on `PYTHONPATH` or `MYPYPATH` has no distribution metadata, so `importlib.metadata` keeps describing the installed distribution -- right name, right version, right location -- while the code that runs comes from somewhere else. Every version check passes on facts about a copy that is not running. Classified before the version is compared, because in that state the comparison is not about the code under test. `developer.md` documents the alternative to editing a committed file: install the worktree into the venv, `uv sync` to undo it. The root cause was that working against unreleased library code required editing a shared file; it does not any more. `scripts/check-library-path.py` rejects a `pyproject.toml` whose span-panel-api paths name anything but `../../span/span-panel-api`, and says how to redirect the library without touching the file. Both blocks, not just the sources one: they went wrong together and were corrected separately, `extraPaths` lagging by several commits because nothing was watching it. An absent block passes -- CI deletes it before installing and re-runs these hooks against what is left. The skip-locally, fail-in-CI asymmetry and its reasoning are `test_fixture_provenance.py`'s: a skip reads in a summary line exactly like a pass. --- .env.example | 8 +- developer.md | 65 +++++ prek.toml | 18 ++ scripts/check-library-path.py | 156 ++++++++++ tests/test_library_path_hook.py | 151 ++++++++++ tests/test_library_resolution.py | 472 +++++++++++++++++++++++++++++++ 6 files changed, 869 insertions(+), 1 deletion(-) create mode 100644 scripts/check-library-path.py create mode 100644 tests/test_library_path_hook.py create mode 100644 tests/test_library_resolution.py diff --git a/.env.example b/.env.example index b5893afc..88dde549 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,13 @@ # anything here -- the file supplies defaults, it does not override an # intentional choice. -# Path to span-panel-api repo. Used for the editable pip install; read by +# Path to span-panel-api repo. Names the *checkout*, not a worktree: to test +# against unreleased library work, install the worktree into the venv instead of +# naming it in any committed file. See developer.md, "Working against unreleased +# library code" -- `pyproject.toml` names the same checkout for the same reason, +# and a prek hook holds it there. +# +# Used for the editable pip install; read by # tests/test_fixture_provenance.py, which byte-compares the vendored capture # `tests/fixtures/schema_one_tree.json` against the copy in that checkout; and # read by scripts/refresh-vendored-capture.py, which is how that capture is diff --git a/developer.md b/developer.md index 33cef77c..d4bb626b 100644 --- a/developer.md +++ b/developer.md @@ -78,6 +78,71 @@ echo 'dotenv' > .envrc direnv allow ``` +## Working against unreleased library code + +`pyproject.toml` installs `span-panel-api` and both adapters from `../../span/span-panel-api` as editable path dependencies. That is a committed, shared file: +whatever it names is what every import in this suite resolves to, for everyone, on every machine. + +Sooner or later you need to test against library work that is not on `main` yet — an unmerged branch in a worktree beside the checkout. **Do not edit +`pyproject.toml` to point at it.** Put the worktree in your virtual environment instead: + +```bash +uv sync # canonical, from the committed file +uv pip install -e ../../span/span-panel-api- # local override, this venv only +uv sync # back to canonical +``` + +`uv sync` is the undo. It rebuilds the environment from `pyproject.toml` and `uv.lock`, which never mentioned the worktree, so the override disappears with no +record of having been there. Nothing is committed at any point. + +### What the second install actually does + +The venv holds one `.pth` file per editable distribution — `_editable_impl_span_panel_api.pth`, `_editable_impl_span_panel_api_schema_0.pth`, +`_editable_impl_span_panel_api_schema_1.pth` — each holding the `src` directory of the main checkout. `uv pip install -e ` **replaces** the one for +the distribution it installs: same filename, rewritten to the worktree, with the `dist-info` (including `direct_url.json`, which is where the redirection is +recorded as data) replaced alongside it. There is no stacking and no shadowing, so no ordering question — the old target is gone. + +It replaces only that distribution. `span-panel-api` and the two adapters are three separate distributions, and installing the bootstrap from a worktree leaves +`span_panel_api_schema_0` and `span_panel_api_schema_1` resolving to the main checkout. If the branch under test changes an adapter, or moves +`ADAPTER_CONTRACT_VERSION`, install all three: + +```bash +uv pip install -e ../../span/span-panel-api- \ + -e ../../span/span-panel-api-/packages/schema-0 \ + -e ../../span/span-panel-api-/packages/schema-1 +``` + +### What not to do: `PYTHONPATH` and `MYPYPATH` + +Prefixing a worktree onto `PYTHONPATH` or `MYPYPATH` looks like the same thing and is not. A worktree on the path has **no distribution metadata of its own**, +so `importlib.metadata` goes on describing the installed distribution: the right name, the right version, the right location — all of it about a copy of the +library that is not the one running. `span_panel_api.__file__` is in the worktree and every version-based check in this suite reads the installed distribution +and passes. + +That is the worst available state, because it is the one where everything is green. The editable install moves the code and the metadata together, so they +cannot disagree; the path prefix moves one and not the other. + +### The two guards + +| Guard | When | Catches | +| ------------------------------------------- | ----------- | ---------------------------------------------------------------------------- | +| `scripts/check-library-path.py` (prek hook) | commit time | a `span-panel-api` path in `pyproject.toml` naming anything but the checkout | +| `tests/test_library_resolution.py` | every run | the library that actually resolved not being the one `manifest.json` pins | + +The hook is why editing `pyproject.toml` is not the route: it rejects the commit and names this section. The test reads `span_panel_api.__file__` rather than +metadata, so it sees the `PYTHONPATH` case as well as a stale checkout. A deliberate editable override **skips** it locally, naming the path and version it +found, and **fails** under `CI` — where `[tool.uv.sources]` is deleted before installing and no legitimate override exists. A version behind or ahead of the pin +fails everywhere; that one is never intentional. + +### Why the version alone is not enough + +`3cbf02a` pointed both `[tool.uv.sources]` and `[tool.pyright].extraPaths` at a scratch worktree and committed it. It survived several commits and hours of +other work. While it stood, a test fixture was vendored through that resolution and captured a producer defect the library had already fixed — and every +version-based check passed, because the stale worktree declared the same version number as the corrected code. Only a byte comparison caught it. + +**A version string does not identify content. Only a filesystem location does.** That is what both guards check and why neither of them checks a version on its +own. + ## Pre-commit Hooks This project uses prek for pre-commit hooks. Hooks run automatically on `git commit` and check formatting, linting, type checking, translations, and test diff --git a/prek.toml b/prek.toml index 3fadb63a..abc6f81d 100644 --- a/prek.toml +++ b/prek.toml @@ -30,6 +30,24 @@ hooks = [ { id = "mixed-line-ending", args = ["--fix=lf"], exclude = '^tests/fixtures/schema_.*\.json$|\.\S*_cache/.*|dist/.*|venv/.*' }, ] +# The library path override in pyproject.toml must name the checkout, not a +# worktree. `3cbf02a` committed a scratch worktree into `[tool.uv.sources]` and +# `[tool.pyright].extraPaths`; it redirected every import in the suite for +# several commits, and a fixture vendored through it captured a producer defect +# the library had already fixed. Runs early -- it is a read of one small file, and +# a run that resolves the wrong library has nothing useful to say afterwards. +[[repos]] +repo = "local" +hooks = [ + { + id = "library-path", + name = "span-panel-api paths name the checkout, not a worktree", + entry = "python scripts/check-library-path.py", + language = "system", + files = '^pyproject\.toml$', + }, +] + # Sync translations/en.json from strings.json and validate translation files [[repos]] repo = "local" diff --git a/scripts/check-library-path.py b/scripts/check-library-path.py new file mode 100644 index 00000000..41c116c2 --- /dev/null +++ b/scripts/check-library-path.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Hold every `span-panel-api` path in `pyproject.toml` to the library checkout. + +`pyproject.toml` is a committed, shared file, and two blocks in it decide where +`span_panel_api` comes from: `[tool.uv.sources]`, which every import in the suite +resolves through, and `[tool.pyright].extraPaths`, which decides what the type +checker reads. Both are written as relative paths so they mean the same thing in +the primary checkout and in every worktree beside it. + +Working against unreleased library code needs those paths aimed somewhere else +for a while, and the obvious way to do that is to edit them. That is what +happened in `3cbf02a`: both blocks were pointed at a scratch worktree and +committed. It stood for several commits and a few hours of other people's work, +and while it stood a test fixture was vendored through it from a checkout that +still recorded a producer defect the library had already fixed. Every +version-based check passed -- the stale worktree declared the same version number +as the corrected code -- and only a byte comparison caught it. A version string +does not identify content; only a filesystem location does. + +So the redirection does not belong in a committed file at all. It belongs in the +virtual environment, where it affects one developer's next command and nothing +else, and `developer.md` ("Working against unreleased library code") says how. +This hook is what makes that the only route: it rejects a `pyproject.toml` whose +`span-panel-api` paths name anything but the checkout. + +Absence is not a failure. CI deletes the whole `[tool.uv.sources]` block before +installing, so it resolves from PyPI and re-runs these hooks against a file that +no longer has one. A block that is not there declares nothing to be wrong about. +""" + +from __future__ import annotations + +from pathlib import Path +import sys +import tomllib + +CHECKOUT = "../../span/span-panel-api" +"""The library checkout, relative to this repository's root. + +Relative rather than absolute because this repository is worked in through git +worktrees, and `../../span/span-panel-api` resolves to the same directory from +every one of them. An absolute path would be correct on exactly one machine. +""" + +LIBRARY = "span-panel-api" +"""The name that makes a path this hook's business. + +Matched as a substring rather than by parsing the path, because the failure being +caught is a *sibling* of the checkout -- `span-panel-api-security`, +`span-panel-api-p3` -- and a sibling shares every path component up to the last. +""" + +WORKFLOW = "developer.md, 'Working against unreleased library code'" +"""Where the supported alternative to editing this file is written down.""" + + +def _table(parent: dict[str, object], name: str) -> dict[str, object]: + """Return a sub-table of a parsed TOML document, or an empty one. + + Missing and present-but-not-a-table are the same answer deliberately: this + hook reports paths that are wrong, and `check-toml` in the same hook run + already reports a file that does not parse. Two hooks failing on one + malformed file says nothing the first one did not. + """ + child = parent.get(name) + if not isinstance(child, dict): + return {} + narrowed: dict[str, object] = {} + for key, value in child.items(): + narrowed[str(key)] = value + return narrowed + + +def declared_paths(document: dict[str, object]) -> list[tuple[str, str]]: + """Return every path in the document that names the library, with where it came from. + + Both blocks, not just `[tool.uv.sources]`. They went wrong together in + `3cbf02a` and were corrected separately -- `82a512f` moved `extraPaths` back + six hours and several commits after the sources block, because nothing was + looking at it. A hook that covers one of two places that hold the same fact + leaves the slower half of the same defect in place. + """ + tool = _table(document, "tool") + found: list[tuple[str, str]] = [] + + sources = _table(_table(tool, "uv"), "sources") + for name, source in sorted(sources.items()): + if not isinstance(source, dict): + continue + path = source.get("path") + if isinstance(path, str) and LIBRARY in path: + found.append((f"[tool.uv.sources] {name}", path)) + + extra_paths = _table(tool, "pyright").get("extraPaths") + if isinstance(extra_paths, list): + for entry in extra_paths: + if isinstance(entry, str) and LIBRARY in entry: + found.append(("[tool.pyright] extraPaths", entry)) + + return found + + +def is_within_checkout(path: str) -> bool: + """Whether a declared path is the checkout itself or something inside it. + + The trailing separator on the prefix is the whole check. + `../../span/span-panel-api-security` starts with `../../span/span-panel-api` + as a string and is a different directory, which is exactly the mistake being + caught; `../../span/span-panel-api/src` does not survive that comparison by + accident. + """ + return path == CHECKOUT or path.startswith(f"{CHECKOUT}/") + + +def offences(path: Path) -> list[str]: + """Return one line per path that names the library and is not inside the checkout.""" + document: dict[str, object] = dict(tomllib.loads(path.read_text(encoding="utf-8"))) + return [ + f"{path}: {where} = {declared!r}" + for where, declared in declared_paths(document) + if not is_within_checkout(declared) + ] + + +def main(arguments: list[str]) -> int: + """Check each file named on the command line; default to this repository's own.""" + repository = Path(__file__).resolve().parent.parent + targets = [Path(argument) for argument in arguments] or [repository / "pyproject.toml"] + + found: list[str] = [] + for target in targets: + if target.is_file(): + found.extend(offences(target)) + + if not found: + return 0 + + print("A span-panel-api path names something other than the library checkout:") + for offence in found: + print(f" {offence}") + print( + f"\nEvery path here must be {CHECKOUT!r} or a directory inside it. To test against " + f"unreleased library work, put the worktree in the virtual environment instead of in " + f"this file:\n" + f"\n uv pip install -e ../../span/span-panel-api-\n" + f"\nThat redirects your next command and nothing else, and `uv sync` puts it back. " + f"See {WORKFLOW}.\n" + f"\nThis file is shared: a worktree named here redirects every import in the suite, " + f"for everyone, for as long as it goes unnoticed -- and a stale checkout declares the " + f"same version as the current one, so nothing that compares versions can see it." + ) + return 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tests/test_library_path_hook.py b/tests/test_library_path_hook.py new file mode 100644 index 00000000..c8741550 --- /dev/null +++ b/tests/test_library_path_hook.py @@ -0,0 +1,151 @@ +"""The commit-time gate on `pyproject.toml`'s library paths must actually fire. + +`scripts/check-library-path.py` runs on every commit that touches +`pyproject.toml` and rejects a `span-panel-api` path naming anything but +`../../span/span-panel-api`. It exists because `3cbf02a` committed a scratch +worktree into both blocks that decide where the library comes from, and nothing +noticed for several commits. + +Tested for the same reason `tests/test_dependency_sync.py` exists: a hook that +matches nothing produces no output, and no output is indistinguishable from a +clean tree. Most of what follows hands the hook a file that is wrong on purpose +and checks that it says so. + +Lives here rather than beside the script in `tests/scripts/`, which `pytest.ini` +excludes with `norecursedirs`. A test that is never collected is the same failure +as a hook that never matches. +""" + +from __future__ import annotations + +from pathlib import Path +import subprocess +import sys + +import pytest + +REPO = Path(__file__).resolve().parent.parent +SCRIPT = REPO / "scripts" / "check-library-path.py" +PYPROJECT = REPO / "pyproject.toml" + +CHECKOUT = "../../span/span-panel-api" +WORKTREE = "../../span/span-panel-api-security" +"""The worktree `3cbf02a` actually named. A sibling of the checkout, sharing every +path component but the last, which is why a prefix comparison has to be +separator-aware to see the difference.""" + + +def _run(*targets: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(SCRIPT), *(str(target) for target in targets)], + capture_output=True, + text=True, + check=False, + ) + + +def _pyproject(root: Path, sources: str | None, pyright: str | None) -> Path: + """Write a miniature `pyproject.toml` declaring the library at the given roots. + + Built rather than copied so a case cannot pass by accident of what the real + tree happens to hold today, and so `None` can express a block that is absent + -- which is the shape CI installs from. + """ + lines = ['[project]', 'name = "span"', ""] + if sources is not None: + lines += [ + "[tool.uv.sources]", + f'span-panel-api = {{ path = "{sources}", editable = true }}', + f'span-panel-api-schema-0 = {{ path = "{sources}/packages/schema-0", editable = true }}', + f'span-panel-api-schema-1 = {{ path = "{sources}/packages/schema-1", editable = true }}', + "", + ] + if pyright is not None: + lines += [ + "[tool.pyright]", + f'extraPaths = ["./custom_components", "{pyright}/src", "../ha-synthetic-sensors/src"]', + "", + ] + written = root / "pyproject.toml" + written.write_text("\n".join(lines), encoding="utf-8") + return written + + +def test_the_committed_file_passes() -> None: + """The real `pyproject.toml`, which is the state the hook must be silent on. + + A gate nobody can commit through is not a gate, it is an outage. This is the + half of the proof that is easy to skip and the one that decides whether the + hook survives its first day. + """ + result = _run(PYPROJECT) + + assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("sources", "pyright"), + [(WORKTREE, WORKTREE), (WORKTREE, CHECKOUT), (CHECKOUT, WORKTREE)], + ids=["both blocks", "the sources block alone", "the type-checker path alone"], +) +def test_a_worktree_in_either_block_is_rejected( + tmp_path: Path, sources: str, pyright: str +) -> None: + """`3cbf02a` in miniature, and each half of it alone. + + Both blocks are checked because both were wrong and they were corrected + separately: `82a512f` moved `extraPaths` back several commits and hours after + the sources block, because nothing was watching it. The third case here is + that interval -- a file half-corrected, which is exactly the state a hook + covering only `[tool.uv.sources]` would call clean. + """ + result = _run(_pyproject(tmp_path, sources, pyright)) + + assert result.returncode == 1, result.stdout or "the hook said nothing" + assert WORKTREE in result.stdout, "the failure does not name the offending path" + + +def test_the_checkout_in_both_blocks_passes(tmp_path: Path) -> None: + """The corrected state, including the sub-paths the adapters and pyright use.""" + result = _run(_pyproject(tmp_path, CHECKOUT, CHECKOUT)) + + assert result.returncode == 0, result.stdout + result.stderr + + +def test_an_absent_sources_block_passes(tmp_path: Path) -> None: + """CI deletes `[tool.uv.sources]` before installing, then re-runs these hooks. + + A block that is not there declares nothing to be wrong about, and failing on + its absence would make the hook impossible to satisfy in the one environment + that resolves from PyPI. + """ + result = _run(_pyproject(tmp_path, None, CHECKOUT)) + + assert result.returncode == 0, result.stdout + result.stderr + + +def test_the_failure_says_what_to_do_instead(tmp_path: Path) -> None: + """Somebody hits this precisely when they are trying to do the thing it forbids. + + The message has to carry the alternative, because a gate that only says "no" + to a developer with a real reason to redirect the library teaches them to + pass `--no-verify` rather than to use the venv. + """ + result = _run(_pyproject(tmp_path, WORKTREE, CHECKOUT)) + + assert "uv pip install -e" in result.stdout, "the failure does not name the alternative" + assert "uv sync" in result.stdout, "the failure does not say how to undo it" + assert "developer.md" in result.stdout, "the failure does not point at the documentation" + + +def test_an_unrelated_path_is_left_alone(tmp_path: Path) -> None: + """`extraPaths` carries other repositories, and they are none of this hook's business.""" + target = tmp_path / "pyproject.toml" + target.write_text( + '[tool.pyright]\nextraPaths = ["../ha-synthetic-sensors/src", "./custom_components"]\n', + encoding="utf-8", + ) + + result = _run(target) + + assert result.returncode == 0, result.stdout + result.stderr diff --git a/tests/test_library_resolution.py b/tests/test_library_resolution.py new file mode 100644 index 00000000..9532b9a3 --- /dev/null +++ b/tests/test_library_resolution.py @@ -0,0 +1,472 @@ +"""The `span_panel_api` under test must be the one the pins name. + +Every other guard in this suite reads a version. `manifest.json` pins one, +`scripts/sync-dependencies.py` holds the copies to it, `test_fixture_provenance.py` +compares the vendored capture against it. All of that assumes the version string +identifies the code, and it does not: a checkout sitting on unreleased work +declares the same version as the release it is ahead of, and a scratch worktree +declares the same version as the checkout it was branched from. A version is a +claim; a filesystem location is a fact. + +That gap is not hypothetical here. `3cbf02a` pointed `[tool.uv.sources]` at a +scratch worktree and committed it, so every import in the suite resolved through +a checkout that had never received span-panel-api#161. A fixture was vendored +through it while it stood, capturing an emitter defect that had already been +fixed. Every version check in both repositories passed. Only a byte comparison +saw it, and only because that comparison had a real second copy to look at. + +So this file checks the one thing no version can: **where the module actually came +from**. It reads `span_panel_api.__file__`, not metadata, because those two can +disagree -- see `Verdict.INCONSISTENT` -- and when they do it is metadata that is +wrong. + +Nothing here writes down a version or a path. The pinned version is read from +`manifest.json`, which is the only file whose pins reach a user, and the expected +location is read from `[tool.uv.sources]` in `pyproject.toml`, which is what the +environment is actually built from. A guard carrying its own copy of the fact it +is guarding is the same duplicated-knowledge defect in miniature, and it fails +the day somebody bumps the real one. + +Deliberate local overrides **skip** rather than fail, and only off CI. Testing +against an unreleased library is a supported workflow -- `developer.md`, "Working +against unreleased library code" -- and a guard that punishes the workflow it is +protecting gets deleted. Under `CI` there is no such thing as a legitimate +override: the workflow deletes `[tool.uv.sources]` and resolves from PyPI, so +anything but the pinned distribution in the environment's own site-packages means +the wiring has come undone. The asymmetry, and the reason a skip is not a pass, +are `test_fixture_provenance.py`'s -- "A skip reads in a summary line exactly like +a pass, and that is how a stale vendored capture went unnoticed for nine days" +(`span-panel-api`, DEVELOPMENT.md). +""" + +from __future__ import annotations + +from enum import Enum +from importlib.metadata import Distribution, PackageNotFoundError, distribution +import json +import os +from pathlib import Path +import tomllib +from typing import NamedTuple +from urllib.parse import unquote, urlparse + +import pytest +import span_panel_api + +_REPO_ROOT = Path(__file__).resolve().parent.parent + +DISTRIBUTION = "span-panel-api" +"""The bootstrap distribution: the one every import in this suite goes through.""" + +MANIFEST = _REPO_ROOT / "custom_components" / "span_panel" / "manifest.json" +PYPROJECT = _REPO_ROOT / "pyproject.toml" + +WORKFLOW = "developer.md, 'Working against unreleased library code'" +"""Where the supported way to test against unreleased library work is written down.""" + + +class Verdict(Enum): + """What the resolved module is, relative to what the pins name.""" + + AS_PINNED = "the module the pins name, at the version they pin" + STALE = "the location the pins name, at a version they do not" + OVERRIDDEN = "a deliberate local override: somewhere else, consistently" + INCONSISTENT = "metadata and the imported module disagree about where the code is" + + +class Resolution(NamedTuple): + """Where `span_panel_api` came from, and where it was supposed to come from. + + Four facts rather than two because two of them can lie independently. + `installed` and `metadata_origin` both come from the installed distribution's + metadata and describe each other; `imported` is the module Python actually + loaded. `expected` is the declaration those are judged against. + """ + + imported: Path + """`span_panel_api.__file__` -- the code this run will execute.""" + + metadata_origin: Path + """Where the installed distribution says its code lives: an editable target, or site-packages.""" + + expected: Path + """Where `pyproject.toml` says it should come from, or site-packages when nothing overrides.""" + + installed: str + """The version `importlib.metadata` reports.""" + + pinned: str + """The version `manifest.json` requires.""" + + +def verdict(resolution: Resolution) -> Verdict: + """Classify a resolution. Pure, so every outcome can be exercised directly. + + Order matters. The consistency check comes first because when metadata and + the imported module disagree, every fact derived from metadata -- including + the version -- describes a different copy of the library than the one about to + run, and comparing it against anything is meaningless. That is the state a + `PYTHONPATH` prefix produces, and it is the most dangerous of the four + precisely because the version check it invalidates still passes. + """ + if not resolution.imported.is_relative_to(resolution.metadata_origin): + return Verdict.INCONSISTENT + if not resolution.imported.is_relative_to(resolution.expected): + return Verdict.OVERRIDDEN + if resolution.installed != resolution.pinned: + return Verdict.STALE + return Verdict.AS_PINNED + + +def explain(outcome: Verdict, resolution: Resolution) -> str: + """Say what is resolved and what to do about it, to someone who has not read this file.""" + if outcome is Verdict.INCONSISTENT: + return ( + f"{DISTRIBUTION} metadata and the imported module name different directories. " + f"The installed distribution is {resolution.installed} at " + f"{resolution.metadata_origin}, and `span_panel_api.__file__` is " + f"{resolution.imported}. A worktree on `PYTHONPATH` or `MYPYPATH` does this: " + f"it has no distribution metadata of its own, so every version check reads the " + f"installed distribution and passes while the code under test comes from " + f"somewhere else entirely. Install the worktree instead -- " + f"`uv pip install -e ` gives a consistent path and consistent " + f"metadata, and `uv sync` puts it back. See {WORKFLOW}." + ) + if outcome is Verdict.OVERRIDDEN: + return ( + f"{DISTRIBUTION} resolves to {resolution.imported} at {resolution.installed}, " + f"not to {resolution.expected}, which is what pyproject.toml names. That is what " + f"a local override looks like and it is a supported one -- `uv sync` ends it. " + f"This check cannot say anything useful about a library it was not pointed at, " + f"so it stops here rather than reporting a pass it did not earn. See {WORKFLOW}." + ) + return ( + f"{DISTRIBUTION} resolves to {resolution.imported}, which is where pyproject.toml " + f"names, but the installed version is {resolution.installed} and manifest.json pins " + f"{resolution.pinned}. The checkout is behind or ahead of the pin, and every version " + f"check in this suite reads {resolution.installed} and agrees with itself. Run " + f"`uv sync` to rebuild the environment from the pins, or move the checkout to the " + f"release {resolution.pinned} names." + ) + + +def _pinned_version() -> str: + """Read the `span-panel-api` pin out of `manifest.json`. + + From the manifest rather than from `pyproject.toml` because the manifest is + the only file whose pins reach a user; `scripts/sync-dependencies.py` exists + to hold every other declaration to it. Reading a copy would make this guard + agree with a drifted file. + """ + manifest: object = json.loads(MANIFEST.read_text(encoding="utf-8")) + requirements = manifest.get("requirements") if isinstance(manifest, dict) else None + if isinstance(requirements, list): + for requirement in requirements: + if isinstance(requirement, str) and requirement.startswith(f"{DISTRIBUTION}=="): + return requirement.split("==", 1)[1].strip() + raise AssertionError( + f"{MANIFEST} has no exact `{DISTRIBUTION}==` requirement. That pin is what Home " + f"Assistant installs and what this check reads; it cannot be derived from anything else." + ) + + +def _declared_source() -> Path | None: + """Return the checkout `[tool.uv.sources]` points `span-panel-api` at, if it points anywhere. + + `None` is the ordinary CI answer, not an error. The workflow deletes the whole + block before installing so that uv resolves from PyPI, then re-runs the suite + against a file that no longer has one. Nothing is declared, so nothing is + overridden, and site-packages is where the library is supposed to be. + + Relative values resolve against the repository root rather than the working + directory, which is what they mean in the file uv reads -- and pytest can be + run from anywhere. + """ + document: object = tomllib.loads(PYPROJECT.read_text(encoding="utf-8")) + if not isinstance(document, dict): + return None + for key in ("tool", "uv", "sources", DISTRIBUTION): + document = document.get(key) if isinstance(document, dict) else None + path = document.get("path") if isinstance(document, dict) else None + if not isinstance(path, str): + return None + declared = Path(path) + return (declared if declared.is_absolute() else _REPO_ROOT / declared).resolve() + + +def _metadata_origin(installed: Distribution) -> Path: + """Return where the installed distribution says its code lives. + + An editable install records its target in `direct_url.json` (PEP 610), which + is the only place the redirection is written down as data -- the `.pth` file + that implements it holds a `src` directory rather than the checkout. A + non-editable install has no such record and its code is under site-packages, + which is what `locate_file` returns. + + This is deliberately not used to decide *which* code runs. It is used to + decide whether metadata is describing the code that runs at all. + """ + record = installed.read_text("direct_url.json") + if record is not None: + direct_url: object = json.loads(record) + if isinstance(direct_url, dict): + url = direct_url.get("url") + info = direct_url.get("dir_info") + editable = isinstance(info, dict) and info.get("editable") is True + if editable and isinstance(url, str): + return Path(unquote(urlparse(url).path)).resolve() + return Path(str(installed.locate_file(""))).resolve() + + +def resolve() -> Resolution: + """Gather the four facts about this environment's `span_panel_api`.""" + try: + installed = distribution(DISTRIBUTION) + except PackageNotFoundError: + pytest.fail( + f"{DISTRIBUTION} is not installed, so there is nothing for this suite to test " + f"against. Run `uv sync`." + ) + + imported = span_panel_api.__file__ + if imported is None: + pytest.fail( + f"span_panel_api has no `__file__`, so it was imported as a namespace package " + f"rather than from a distribution -- there is a directory of that name on the " + f"path with no code in it. Run `uv sync`." + ) + + metadata_origin = _metadata_origin(installed) + return Resolution( + imported=Path(imported).resolve(), + metadata_origin=metadata_origin, + expected=_declared_source() or metadata_origin, + installed=installed.version, + pinned=_pinned_version(), + ) + + +def report(resolution: Resolution) -> None: + """Return on a clean resolution; otherwise stop the run, failing or skipping. + + `STALE` stops everywhere, CI or not: the pins and the environment disagree in + the one place nothing downstream can see, and that is a defect wherever it + happens. The other two are only defects in CI, where no legitimate override + exists -- locally they are the supported workflow, reported loudly enough that + nobody mistakes the skip for a clean run. + """ + outcome = verdict(resolution) + if outcome is Verdict.AS_PINNED: + return + + reason = explain(outcome, resolution) + if outcome is Verdict.STALE or os.environ.get("CI"): + pytest.fail(reason) + pytest.skip(reason) + + +def test_the_library_under_test_is_the_one_the_pins_name() -> None: + """The guard itself, against this environment.""" + report(resolve()) + + +def _resolution( + imported: str, metadata: str, expected: str, installed: str, pinned: str +) -> Resolution: + """Build a resolution from strings, so a case reads as the situation it describes.""" + return Resolution( + imported=Path(imported), + metadata_origin=Path(metadata), + expected=Path(expected), + installed=installed, + pinned=pinned, + ) + + +_CHECKOUT = "/w/span/span-panel-api" +_WORKTREE = "/w/span/span-panel-api-security" +_SITE_PACKAGES = "/w/span/.venv/lib/python3.14/site-packages" + + +def test_the_checkout_at_the_pinned_version_is_the_only_clean_answer() -> None: + """Case 1: the module is where the pins point, at the version they pin.""" + resolution = _resolution( + imported=f"{_CHECKOUT}/src/span_panel_api/__init__.py", + metadata=_CHECKOUT, + expected=_CHECKOUT, + installed="3.1.0", + pinned="3.1.0", + ) + + assert verdict(resolution) is Verdict.AS_PINNED + + +def test_the_checkout_at_another_version_is_the_defect_this_file_exists_for() -> None: + """Case 2: the right location, the wrong content. + + A checkout that has not been re-synced after a pin moves, or one sitting on + unreleased work. Nothing else in either repository can see this: the version + the environment reports is the checkout's own, so every copy of the pin agrees + with it, and the tests pass against a library that is not the one being + shipped. + """ + resolution = _resolution( + imported=f"{_CHECKOUT}/src/span_panel_api/__init__.py", + metadata=_CHECKOUT, + expected=_CHECKOUT, + installed="3.0.1", + pinned="3.1.0", + ) + + assert verdict(resolution) is Verdict.STALE + + +def test_an_editable_worktree_is_an_override_rather_than_a_defect() -> None: + """Case 3: somewhere else, but metadata says so too. + + `uv pip install -e ` rewrites the distribution's `direct_url.json` + along with the path, so the location and the metadata move together. This is + the workflow `developer.md` documents in place of editing `pyproject.toml`, + and treating it as a failure is what would drive somebody back to editing the + committed file. + """ + resolution = _resolution( + imported=f"{_WORKTREE}/src/span_panel_api/__init__.py", + metadata=_WORKTREE, + expected=_CHECKOUT, + installed="3.2.0", + pinned="3.1.0", + ) + + assert verdict(resolution) is Verdict.OVERRIDDEN + + +def test_metadata_describing_one_copy_while_another_runs_is_not_a_pass() -> None: + """Case 4: the version check passes and means nothing. + + A worktree prefixed onto `PYTHONPATH` or `MYPYPATH` has no distribution + metadata of its own. `importlib.metadata` therefore keeps reporting the + installed distribution -- the right name, the right version, the right + location -- while `span_panel_api.__file__` is inside the worktree. Every + version-based check in this suite passes on facts about a copy that is not + running. + + Classified before the version is compared, and before the location is judged + against the pins, because in this state neither comparison is about the code + that will execute. + """ + resolution = _resolution( + imported=f"{_WORKTREE}/src/span_panel_api/__init__.py", + metadata=_CHECKOUT, + expected=_CHECKOUT, + installed="3.1.0", + pinned="3.1.0", + ) + + assert verdict(resolution) is Verdict.INCONSISTENT + + +def test_site_packages_is_the_expected_location_when_nothing_overrides() -> None: + """CI: no `[tool.uv.sources]`, so the pins name PyPI and the venv is where it lands. + + The workflow deletes the block before installing. `_declared_source` returns + `None` there and the expected location becomes the distribution's own, which + makes the check in CI exactly "the pinned version, out of site-packages" -- + the only correct answer when there is nothing to override with. + """ + resolution = _resolution( + imported=f"{_SITE_PACKAGES}/span_panel_api/__init__.py", + metadata=_SITE_PACKAGES, + expected=_SITE_PACKAGES, + installed="3.1.0", + pinned="3.1.0", + ) + + assert verdict(resolution) is Verdict.AS_PINNED + + +@pytest.mark.parametrize( + "outcome", + [Verdict.OVERRIDDEN, Verdict.INCONSISTENT], + ids=["editable override", "metadata and module disagree"], +) +def test_an_override_fails_in_ci_and_skips_locally( + monkeypatch: pytest.MonkeyPatch, outcome: Verdict +) -> None: + """The guard on the guard, for the two outcomes whose severity depends on where they happen. + + Both halves are asserted, in both environments. Asserting only the CI half + would leave the local half free to become a failure, which is what makes a + developer delete a check rather than work around it; asserting only the local + half would leave CI free to skip, and a skip reads in a summary line exactly + like a pass. That is not a hypothetical -- it is how a stale vendored capture + went unnoticed for nine days in `span-panel-api` (DEVELOPMENT.md, "A skip here + is not a pass"). + """ + elsewhere = _WORKTREE if outcome is Verdict.OVERRIDDEN else _CHECKOUT + resolution = _resolution( + imported=f"{_WORKTREE}/src/span_panel_api/__init__.py", + metadata=elsewhere, + expected=_CHECKOUT, + installed="3.1.0", + pinned="3.1.0", + ) + assert verdict(resolution) is outcome + + outcomes = (pytest.fail.Exception, pytest.skip.Exception) + + monkeypatch.delenv("CI", raising=False) + with pytest.raises(outcomes) as local: + report(resolution) + assert local.type is pytest.skip.Exception, ( + f"off CI, {outcome.value} must skip rather than {local.typename.lower()}: pointing this " + f"suite at unreleased library work is a documented workflow, and failing it is what " + f"sends somebody back to editing pyproject.toml" + ) + + monkeypatch.setenv("CI", "true") + with pytest.raises(outcomes) as in_ci: + report(resolution) + assert in_ci.type is pytest.fail.Exception, ( + f"under CI, {outcome.value} must fail rather than {in_ci.typename.lower()}: the " + f"workflow deletes [tool.uv.sources] and resolves from PyPI, so no legitimate " + f"override exists there and a skip is indistinguishable from a pass" + ) + + +def test_every_message_names_the_paths_it_is_talking_about() -> None: + """A guard that stops a run has to say what it found, not that it found something. + + Each of these is read by somebody who has just had a green suite stop, and the + first question is always which library is actually loaded. The answer is the + path, and it is not derivable from anywhere else in the output. + """ + resolution = _resolution( + imported=f"{_WORKTREE}/src/span_panel_api/__init__.py", + metadata=_CHECKOUT, + expected=_CHECKOUT, + installed="3.0.1", + pinned="3.1.0", + ) + + for outcome in (Verdict.STALE, Verdict.OVERRIDDEN, Verdict.INCONSISTENT): + reason = explain(outcome, resolution) + assert str(resolution.imported) in reason, f"{outcome.name} does not name what resolved" + assert resolution.installed in reason, f"{outcome.name} does not name the version found" + + +def test_the_pinned_version_is_read_from_the_manifest_rather_than_written_here() -> None: + """The derivation, against the real file. + + Not a tautology: it asserts the pin can still be *found*, by checking that + what came back reconstitutes a requirement the manifest actually lists. A + manifest that stopped using an exact `==` requirement, or renamed the + distribution, would otherwise leave this whole file comparing against a + version nobody declared -- which is the failure mode + `scripts/sync-dependencies.py` was rewritten for, a matcher that quietly found + nothing and reported success. + """ + requirements: object = json.loads(MANIFEST.read_text(encoding="utf-8"))["requirements"] + + assert isinstance(requirements, list) + assert f"{DISTRIBUTION}=={_pinned_version()}" in requirements From 6349b7ec50d7e42e290430c3a24de389da1060f7 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:47:04 -0700 Subject: [PATCH 138/345] test(library-path): prove the gate against the commits it exists for The hook's failing cases were miniature pyproject.toml files written here. A synthetic defect is written by somebody who already knows what the rule checks, so it exercises the rule rather than the mistake -- a gate proven against a made-up example is proven against the wrong thing. Both failing cases are now byte copies of this repository's own pyproject.toml, taken out of its history and held to it: pyproject_both_blocks_redirected.toml 3cbf02a, both blocks on the scratch worktree pyproject_type_checker_path_left_behind.toml 82a512f^, sources corrected and extraPaths not The second is the interval between the two corrections, several commits during which the repository looked fixed. It is the case a hook covering only `[tool.uv.sources]` calls clean, and it asserts that the corrected half is *not* reported -- so what fails there is the half that was genuinely still broken. Each copy has a `.source` recording its commit, and a guard holding the bytes to it, or the obvious way to make a failing case pass is to edit the fixture. That guard skips when the object is unreachable, which this directory's other provenance guard refuses to do. The difference is that a commit is content-addressed: `3cbf02a:pyproject.toml` cannot become different bytes, so the comparison can only ever report a copy that was wrong when it was vendored, and it reports that wherever the history exists. A shallow clone has no object to compare against, and asserting anyway would fail on the clone rather than on the fixture. Every case drives `scripts/check-library-path.py` as a subprocess, the way prek invokes it -- never the module's functions, never the rule restated in the test. `test_the_hook_prek_runs_is_the_one_tested` closes what that leaves open by reading the entry back out of `prek.toml`: rename the script or the hook and every other case here goes on passing against something prek no longer runs, and an unregistered hook produces no output either. It reads the file as text because `prek.toml` spreads each hook's inline table over several lines, which is TOML 1.1 and `tomllib` rejects. The two vendored copies join the adapter captures in being exempt from the rewriting hooks, for the reason already recorded there: a formatter and a byte-identical fixture cannot both be unconditional, and it is the formatter that yields. --- prek.toml | 29 +- tests/fixtures/README.md | 53 +++- .../pyproject_both_blocks_redirected.source | 1 + .../pyproject_both_blocks_redirected.toml | 227 ++++++++++++++++ ...oject_type_checker_path_left_behind.source | 1 + ...project_type_checker_path_left_behind.toml | 232 ++++++++++++++++ tests/test_library_path_hook.py | 249 ++++++++++++++---- 7 files changed, 720 insertions(+), 72 deletions(-) create mode 100644 tests/fixtures/pyproject_both_blocks_redirected.source create mode 100644 tests/fixtures/pyproject_both_blocks_redirected.toml create mode 100644 tests/fixtures/pyproject_type_checker_path_left_behind.source create mode 100644 tests/fixtures/pyproject_type_checker_path_left_behind.toml diff --git a/prek.toml b/prek.toml index abc6f81d..1b734798 100644 --- a/prek.toml +++ b/prek.toml @@ -1,20 +1,29 @@ default_stages = ["pre-commit"] -# `CAPTURES` is the vendored adapter captures: byte copies of files span-panel-api -# owns, held to their source byte for byte by tests/test_fixture_provenance.py. +# Two kinds of vendored byte copy live under tests/fixtures/, and neither is +# source this repository owns: +# +# schema_*.json captures of files span-panel-api owns, held to their source +# byte for byte by tests/test_fixture_provenance.py +# pyproject_*.toml this file's own pyproject.toml at the commits where the +# library path override went wrong, held to those commits by +# tests/test_library_path_hook.py -- they are what proves the +# library-path gate fires, so they have to stay the real files +# rather than approximations of them +# # The three hooks below that *rewrite* files -- trailing-whitespace, -# end-of-file-fixer and mixed-line-ending -- are excluded from them, because a +# end-of-file-fixer and mixed-line-ending -- are excluded from both, because a # formatter and a byte-identical vendored fixture cannot both be unconditional and # it is the formatter that has to yield: the bytes are the artifact. # # Not hypothetical, merely not yet triggered. Those three cover tests/ today, and -# they leave the current captures alone only because both happen to be -# newline-terminated with LF endings and no trailing whitespace. The first capture +# they leave the current copies alone only because all of them happen to be +# newline-terminated with LF endings and no trailing whitespace. The first one # vendored without a final newline would be rewritten on commit, and the guard # would then fail against a source the copy had genuinely matched when it was made. # -# The read-only hooks keep covering them. check-json in particular should: a -# capture that does not parse is worth hearing about wherever it came from. +# The read-only hooks keep covering them. check-json and check-toml in particular +# should: a copy that does not parse is worth hearing about wherever it came from. [[repos]] repo = "https://github.com/pre-commit/pre-commit-hooks" rev = "v5.0.0" @@ -25,9 +34,9 @@ hooks = [ { id = "check-added-large-files" }, { id = "check-merge-conflict", exclude = '\.\S*_cache/.*|dist/.*|venv/.*' }, { id = "debug-statements", exclude = '^tests/.*|\.\S*_cache/.*|dist/.*|venv/.*', language_version = "python3.14" }, - { id = "trailing-whitespace", exclude = '^tests/fixtures/schema_.*\.json$|\.\S*_cache/.*|dist/.*|venv/.*' }, - { id = "end-of-file-fixer", exclude = '^tests/fixtures/schema_.*\.json$|\.\S*_cache/.*|dist/.*|venv/.*' }, - { id = "mixed-line-ending", args = ["--fix=lf"], exclude = '^tests/fixtures/schema_.*\.json$|\.\S*_cache/.*|dist/.*|venv/.*' }, + { id = "trailing-whitespace", exclude = '^tests/fixtures/(schema_.*\.json|pyproject_.*\.toml)$|\.\S*_cache/.*|dist/.*|venv/.*' }, + { id = "end-of-file-fixer", exclude = '^tests/fixtures/(schema_.*\.json|pyproject_.*\.toml)$|\.\S*_cache/.*|dist/.*|venv/.*' }, + { id = "mixed-line-ending", args = ["--fix=lf"], exclude = '^tests/fixtures/(schema_.*\.json|pyproject_.*\.toml)$|\.\S*_cache/.*|dist/.*|venv/.*' }, ] # The library path override in pyproject.toml must name the checkout, not a diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index 3b310205..551f501c 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -1,8 +1,15 @@ -# Adapter fixtures +# Test fixtures -Real schema-adapter inputs, used by the field-path conformance tests via `tests/adapter_fixtures.py`. They are **committed rather than generated** so the test -suite has no cross-repo dependency and no runtime wheel has to keep shipping test data for this repository's benefit. The cost of a copy is that it can go -stale, which for `schema_one_tree.json` is answered by the two guards below. +Two kinds of vendored byte copy live here, on the same terms: committed rather than generated, held to their source by a guard, and exempt from the formatters +because in both cases the bytes are the artifact. + +**Adapter captures** (`schema_*.json`) are real schema-adapter inputs, used by the field-path conformance tests via `tests/adapter_fixtures.py`. They are +committed so the test suite has no cross-repo dependency and no runtime wheel has to keep shipping test data for this repository's benefit. The cost of a copy +is that it can go stale, which for `schema_one_tree.json` is answered by the two guards below. Everything from here to "Why the flat capture has no byte guard" +is about them. + +**Historical `pyproject.toml` copies** (`pyproject_*.toml`) are this repository's own file at the two commits where the library path override went wrong. See +"[The historical pyproject copies](#the-historical-pyproject-copies)". ## Provenance @@ -137,10 +144,34 @@ the byte comparison vacuous: a copy compared against the thing it was just copie their source are the whole mechanism.** That freedom to disagree is what let the comparison find a capture vendored from a stale path override, which nothing else in either repository could see. A fixture that silently re-syncs itself cannot report anything. +## The historical pyproject copies + +`pyproject_both_blocks_redirected.toml` and `pyproject_type_checker_path_left_behind.toml` are byte copies of this repository's own `pyproject.toml`, taken from +its git history. Each has a `.source` file beside it recording the commit it came from, and `tests/test_library_path_hook.py` holds the copy to that commit. + +| File | Commit | State | +| ---------------------------------------------- | ---------- | ----------------------------------------------------------------------------- | +| `pyproject_both_blocks_redirected.toml` | `3cbf02a` | `[tool.uv.sources]` **and** `[tool.pyright].extraPaths` on a scratch worktree | +| `pyproject_type_checker_path_left_behind.toml` | `82a512f^` | sources block corrected, `extraPaths` still on the worktree | + +They are the failing cases for `scripts/check-library-path.py`, the hook that rejects a `span-panel-api` path naming anything but `../../span/span-panel-api`. +The real files rather than constructed ones, because **a gate proven against a made-up example is proven against the wrong thing** — a synthetic version of a +defect is written by somebody who already knows what the rule checks, so it exercises the rule rather than the mistake. The second file matters as much as the +first: it is the interval between the two corrections, several commits during which the repository looked fixed, and a hook covering only `[tool.uv.sources]` +calls it clean. + +Unlike the adapter captures, these cannot go stale. A commit is content-addressed, so `3cbf02a:pyproject.toml` cannot become different bytes; the only failure +the comparison can report is a copy that was wrong when it was vendored. That is why its guard **skips** when the object is unreachable — a shallow clone has no +history to compare against, and failing there would report the clone rather than the fixture. This is the one place in this directory where a skip is not hiding +a moving target; "A skip here is not a pass" above is the case where it would be, and that distinction is the whole reason this one is allowed. + +Re-vendor either with `git show :pyproject.toml > tests/fixtures/.toml`, and only to correct a copy — never to make a failing case pass. + ## These files are exempt from formatting -`tests/fixtures/schema_*.json` is excluded from every hook that rewrites files — `trailing-whitespace`, `end-of-file-fixer` and `mixed-line-ending` in -`prek.toml`, and `tests/fixtures/schema_*.json` in `.prettierignore`. +`tests/fixtures/schema_*.json` and `tests/fixtures/pyproject_*.toml` are excluded from every hook that rewrites files — `trailing-whitespace`, +`end-of-file-fixer` and `mixed-line-ending` in `prek.toml`, and `tests/fixtures/schema_*.json` in `.prettierignore` (Prettier cannot format TOML, so the +historical copies need no line there). The reason is the whole point of the byte comparison: **these are captured bytes, not source we own.** A vendored fixture held byte-identical to its source and an unconditional formatter cannot both exist, and it is the formatter that has to yield. A capture reindented on the way in is one nothing upstream can hold to @@ -157,12 +188,14 @@ Prettier is excluded as a **precaution, not a fix.** It has never touched these checkout. The line still earns its place: `schema_zero_types.json` carries its producer's 4-space indentation, and Prettier under `.prettierrc` rewrites it from 14675 to 11779 bytes. That file has no byte guard, so nothing would report it. -The scope is `schema_*.json` rather than the whole directory, because only the adapter captures have this property. `tests/fixtures/README.md` is prose this +The scope is two prefixes rather than the whole directory, because only the vendored byte copies have this property. `tests/fixtures/README.md` is prose this repository owns and should keep being formatted; the migration YAMLs are hand-written source; and `unread_declarations_baseline.json`, despite being a -mechanically-checked inventory, is hand-maintained — its values are one-line human explanations. The `schema_*` prefix is the vocabulary -`tests/adapter_fixtures.py` already uses for adapter captures, so the next one vendored is covered without anyone remembering to widen the rule. +mechanically-checked inventory, is hand-maintained — its values are one-line human explanations. Each prefix is a vocabulary something already uses — `schema_*` +is `tests/adapter_fixtures.py`'s for adapter captures, `pyproject_*` is `tests/test_library_path_hook.py`'s for historical copies of this repository's own +`pyproject.toml` — so the next one vendored under either is covered without anyone remembering to widen the rule. -The read-only hooks still cover these files, `check-json` in particular. A capture that does not parse is worth hearing about wherever it came from. +The read-only hooks still cover these files, `check-json` and `check-toml` in particular. A copy that does not parse is worth hearing about wherever it came +from. ## Why the flat capture has no byte guard diff --git a/tests/fixtures/pyproject_both_blocks_redirected.source b/tests/fixtures/pyproject_both_blocks_redirected.source new file mode 100644 index 00000000..30d4be69 --- /dev/null +++ b/tests/fixtures/pyproject_both_blocks_redirected.source @@ -0,0 +1 @@ +3cbf02a6703b8c56944e33dab2c0cc4a68193680 diff --git a/tests/fixtures/pyproject_both_blocks_redirected.toml b/tests/fixtures/pyproject_both_blocks_redirected.toml new file mode 100644 index 00000000..6794db86 --- /dev/null +++ b/tests/fixtures/pyproject_both_blocks_redirected.toml @@ -0,0 +1,227 @@ +[project] +name = "span" +version = "2.1.0b6" +description = "Span Panel Custom Integration for Home Assistant" +authors = [{name = "SpanPanel"}] +license = {text = "MIT"} +readme = "README.md" +requires-python = ">=3.14.3,<3.15" +dependencies = [ + "homeassistant==2026.8.0", + "span-panel-api==3.1.0", + # 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.1.0", + "span-panel-api-schema-1==1.1.0", +] + +[dependency-groups] +dev = [ + "homeassistant-stubs==2026.8.0", + "types-requests", + "types-PyYAML", + "mypy==1.20.2", + "pyright==1.1.409", + "ruff==0.15.12", + "bandit[toml]==1.9.4", + "prek>=0.3.11", + "voluptuous-stubs", + "python-direnv", + "prettier", + "radon==6.0.1", + "pylint==4.0.5", + # Pinned, not floored: each release pins one exact Home Assistant, so this + # and the `homeassistant` pin above are a single choice rather than two. + # 0.13.354 is the release that pins 2026.8.0, the version this integration + # declares as its floor and therefore the one worth testing against. + "pytest-homeassistant-custom-component==0.13.354", + "isort", + "vulture>=2.14", +] + +[tool.uv] +package = false + +[tool.uv.sources] +# Pointed at the `-security` worktree while 3.1.0 is unreleased. The primary +# checkout is still on 3.0.1 and has none of this work, so pointing here is not a +# convenience -- against the primary checkout the security tests would resolve the +# old library and pass without exercising anything. Move these back to +# `../../span/span-panel-api` once 3.1.0 is merged and published. +# +# A path that does not exist is not a soft failure: `uv run` cannot build the +# environment at all, so nothing in this repo runs until it is corrected. +# +# CI never reads this block -- it deletes it and re-locks from PyPI (.github/ +# workflows/ci.yml) -- so a stale path here breaks only local work, and only +# noisily. It does mean CI cannot pass until 3.1.0 and both 1.1.0 adapters are on +# PyPI. +span-panel-api = { path = "../../span/span-panel-api-security", editable = true } +span-panel-api-schema-0 = { path = "../../span/span-panel-api-security/packages/schema-0", editable = true } +span-panel-api-schema-1 = { path = "../../span/span-panel-api-security/packages/schema-1", editable = true } + +[tool.jscpd] +path = ["custom_components/span_panel", "./*.{html,md}"] +format = ["python", "javascript", "json", "markup", "markdown"] +ignore = "custom_components/span_panel/translations/**|**/translations/**|.github/**|env/**|**/site-packages/**|**/.direnv/**" +reporters = ["console"] +output = "./jscpdReport" +gitignore = true + +[tool.mypy] +platform = "linux" +show_error_codes = true +follow_imports = "normal" + +# Type checking settings +strict_equality = true +no_implicit_optional = true +warn_incomplete_stub = true +warn_redundant_casts = true +warn_unused_configs = true +local_partial_types = true +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +warn_return_any = true +strict_optional = true + +# Package handling for HA custom integration +mypy_path = "." +namespace_packages = false +explicit_package_bases = false + +# Module search paths - set this explicitly to prevent duplicate module resolution +files = ["custom_components/span_panel"] + +# Exclude patterns +exclude = [ + "venv/.*", + ".venv/.*", + "scripts/.*" +] + +# Error codes +enable_error_code = [ + "deprecated", + "ignore-without-code", + "redundant-self", + "truthy-iterable", + "mutable-override" +] +disable_error_code = [ + "annotation-unchecked", + "import-not-found", + "import-untyped", + "override", + "misc", +] + +[tool.pydantic-mypy] +init_forbid_extra = true +init_typed = true +warn_required_dynamic_aliases = true +warn_untyped_fields = true + +[tool.pyright] +include = ["custom_components/span_panel"] +exclude = [ + "venv", + ".venv", + "scripts", + ".git", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", "**/node_modules", "**/__pycache__", "**/.*" +] +extraPaths = [ + "./custom_components", + "../../span/span-panel-api-security/src", + "../ha-synthetic-sensors/src" +] +pythonPlatform = "Darwin" +pythonVersion = "3.14" +typeCheckingMode = "basic" +useLibraryCodeForTypes = true +autoImportCompletions = true +reportMissingImports = "warning" +reportMissingTypeStubs = false +venvPath = "." +venv = ".venv" + +[tool.ruff] +line-length = 100 +target-version = "py313" + +[tool.ruff.lint] +select = [ + "B007", # Loop control variable {name} not used within loop body + "B014", # Exception handler is an `except` clause that only reraises + "C", # complexity + "D", # docstrings + "E", # pycodestyle (includes E402 - module level import not at top of file) + "F", # pyflakes/autoflake + "I", # isort - import sorting and organization + "ICN001", # import concentions; {name} should be imported as {asname} + "PGH004", # Use specific rule codes when using noqa + "PLC0414", # Useless import alias. Import alias does not rename original package. + "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass + "SIM117", # Combine multiple with statements + "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() + "SIM201", # Use {left} != {right} instead of not {left} == {right} + "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} + "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. + "SIM401", # Use get from dict with default instead of conditional assignment + "T20", # flake8-print + "TID", # flake8-tidy-imports - import organization and toplevel checking + "TRY004", # Prefer TypeError exception for invalid type + "RUF006", # Store a reference to the return value of asyncio.create_task + "UP", # pyupgrade + "W", # pycodestyle +] + +ignore = [ + "D202", # No blank lines allowed after function docstring + "D203", # 1 blank line required before class docstring + "D213", # Multi-line docstring summary should start at the second line + "D406", # Section name should end with a newline + "D407", # Section name underlining + "E501", # line too long + "E731", # do not assign a lambda expression, use a def +] + +# Per-file ignores for test and debug files +[tool.ruff.lint.per-file-ignores] +"test_*.py" = ["T201", "D103", "D100", "E402", "TID252"] # Allow print statements, missing docstrings, and imports inside functions in test files +"debug_*.py" = ["T201", "D103", "D100", "E402", "TID252"] # Allow print statements, missing docstrings, and imports inside functions in debug files +"tests/**/*.py" = ["T201", "D103", "D100", "E402", "TID252"] # Allow print statements, missing docstrings, and imports inside functions in test directory + +# Bandit configuration +[tool.bandit] +exclude_dirs = ["tests", "scripts"] +skips = ["B101", "B108"] # Skip assert_used and hardcoded_tmp_directory for test files + +[tool.ruff.lint.flake8-pytest-style] +fixture-parentheses = false + +[tool.ruff.lint.pyupgrade] +keep-runtime-typing = true + +[tool.ruff.lint.mccabe] +max-complexity = 25 + +[tool.ruff.format] +# Ruff is the primary formatter +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" + +[tool.ruff.lint.isort] +known-first-party = ["custom_components", "span"] +force-sort-within-sections = true +combine-as-imports = true diff --git a/tests/fixtures/pyproject_type_checker_path_left_behind.source b/tests/fixtures/pyproject_type_checker_path_left_behind.source new file mode 100644 index 00000000..3661d20e --- /dev/null +++ b/tests/fixtures/pyproject_type_checker_path_left_behind.source @@ -0,0 +1 @@ +531ad1cbfeefcb32f4b6c5fd9be415b00e401dc5 diff --git a/tests/fixtures/pyproject_type_checker_path_left_behind.toml b/tests/fixtures/pyproject_type_checker_path_left_behind.toml new file mode 100644 index 00000000..a864815a --- /dev/null +++ b/tests/fixtures/pyproject_type_checker_path_left_behind.toml @@ -0,0 +1,232 @@ +[project] +name = "span" +version = "2.1.0b6" +description = "Span Panel Custom Integration for Home Assistant" +authors = [{name = "SpanPanel"}] +license = {text = "MIT"} +readme = "README.md" +requires-python = ">=3.14.3,<3.15" +dependencies = [ + "homeassistant==2026.8.0", + "span-panel-api==3.1.0", + # 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.1.0", + "span-panel-api-schema-1==1.1.0", +] + +[dependency-groups] +dev = [ + "homeassistant-stubs==2026.8.0", + "types-requests", + "types-PyYAML", + "mypy==1.20.2", + "pyright==1.1.409", + "ruff==0.15.12", + "bandit[toml]==1.9.4", + "prek>=0.3.11", + "voluptuous-stubs", + "python-direnv", + "prettier", + "radon==6.0.1", + "pylint==4.0.5", + # Pinned, not floored: each release pins one exact Home Assistant, so this + # and the `homeassistant` pin above are a single choice rather than two. + # 0.13.354 is the release that pins 2026.8.0, the version this integration + # declares as its floor and therefore the one worth testing against. + "pytest-homeassistant-custom-component==0.13.354", + "isort", + "vulture>=2.14", +] + +[tool.uv] +package = false + +[tool.uv.sources] +# The primary checkout, which is where 3.1.0 and both 1.1.0 adapters now live. +# +# These pointed at a `-security` worktree while that work was unmerged. That +# outlived its reason and became actively harmful: the worktree never received +# span-panel-api#161, so `span_panel_api_schema_1.reference_payloads` resolved to +# a parent/child capture that still recorded the emitter's `$settable` defect on +# a locked relay, and `tests/fixtures/schema_one_tree.json` was vendored from it. +# A path override is a redirection of what every import in the suite resolves to; +# leaving one aimed at a stale checkout silently tests against a producer that no +# longer exists. Point these at a checkout that tracks `main`, and move them only +# for as long as an unmerged branch is actually under test. +# +# A path that does not exist is not a soft failure: `uv run` cannot build the +# environment at all, so nothing in this repo runs until it is corrected. +# +# CI never reads this block -- it deletes it and re-locks from PyPI (.github/ +# workflows/ci.yml) -- so a stale path here breaks only local work. It does mean +# CI cannot pass until 3.1.0 and both 1.1.0 adapters are on PyPI. +span-panel-api = { path = "../../span/span-panel-api", editable = true } +span-panel-api-schema-0 = { path = "../../span/span-panel-api/packages/schema-0", editable = true } +span-panel-api-schema-1 = { path = "../../span/span-panel-api/packages/schema-1", editable = true } + +[tool.jscpd] +path = ["custom_components/span_panel", "./*.{html,md}"] +format = ["python", "javascript", "json", "markup", "markdown"] +ignore = "custom_components/span_panel/translations/**|**/translations/**|.github/**|env/**|**/site-packages/**|**/.direnv/**" +reporters = ["console"] +output = "./jscpdReport" +gitignore = true + +[tool.mypy] +platform = "linux" +show_error_codes = true +follow_imports = "normal" + +# Type checking settings +strict_equality = true +no_implicit_optional = true +warn_incomplete_stub = true +warn_redundant_casts = true +warn_unused_configs = true +local_partial_types = true +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +warn_return_any = true +strict_optional = true + +# Package handling for HA custom integration +mypy_path = "." +namespace_packages = false +explicit_package_bases = false + +# Module search paths - set this explicitly to prevent duplicate module resolution +files = ["custom_components/span_panel"] + +# Exclude patterns +exclude = [ + "venv/.*", + ".venv/.*", + "scripts/.*" +] + +# Error codes +enable_error_code = [ + "deprecated", + "ignore-without-code", + "redundant-self", + "truthy-iterable", + "mutable-override" +] +disable_error_code = [ + "annotation-unchecked", + "import-not-found", + "import-untyped", + "override", + "misc", +] + +[tool.pydantic-mypy] +init_forbid_extra = true +init_typed = true +warn_required_dynamic_aliases = true +warn_untyped_fields = true + +[tool.pyright] +include = ["custom_components/span_panel"] +exclude = [ + "venv", + ".venv", + "scripts", + ".git", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", "**/node_modules", "**/__pycache__", "**/.*" +] +extraPaths = [ + "./custom_components", + "../../span/span-panel-api-security/src", + "../ha-synthetic-sensors/src" +] +pythonPlatform = "Darwin" +pythonVersion = "3.14" +typeCheckingMode = "basic" +useLibraryCodeForTypes = true +autoImportCompletions = true +reportMissingImports = "warning" +reportMissingTypeStubs = false +venvPath = "." +venv = ".venv" + +[tool.ruff] +line-length = 100 +target-version = "py313" + +[tool.ruff.lint] +select = [ + "B007", # Loop control variable {name} not used within loop body + "B014", # Exception handler is an `except` clause that only reraises + "C", # complexity + "D", # docstrings + "E", # pycodestyle (includes E402 - module level import not at top of file) + "F", # pyflakes/autoflake + "I", # isort - import sorting and organization + "ICN001", # import concentions; {name} should be imported as {asname} + "PGH004", # Use specific rule codes when using noqa + "PLC0414", # Useless import alias. Import alias does not rename original package. + "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass + "SIM117", # Combine multiple with statements + "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() + "SIM201", # Use {left} != {right} instead of not {left} == {right} + "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} + "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. + "SIM401", # Use get from dict with default instead of conditional assignment + "T20", # flake8-print + "TID", # flake8-tidy-imports - import organization and toplevel checking + "TRY004", # Prefer TypeError exception for invalid type + "RUF006", # Store a reference to the return value of asyncio.create_task + "UP", # pyupgrade + "W", # pycodestyle +] + +ignore = [ + "D202", # No blank lines allowed after function docstring + "D203", # 1 blank line required before class docstring + "D213", # Multi-line docstring summary should start at the second line + "D406", # Section name should end with a newline + "D407", # Section name underlining + "E501", # line too long + "E731", # do not assign a lambda expression, use a def +] + +# Per-file ignores for test and debug files +[tool.ruff.lint.per-file-ignores] +"test_*.py" = ["T201", "D103", "D100", "E402", "TID252"] # Allow print statements, missing docstrings, and imports inside functions in test files +"debug_*.py" = ["T201", "D103", "D100", "E402", "TID252"] # Allow print statements, missing docstrings, and imports inside functions in debug files +"tests/**/*.py" = ["T201", "D103", "D100", "E402", "TID252"] # Allow print statements, missing docstrings, and imports inside functions in test directory + +# Bandit configuration +[tool.bandit] +exclude_dirs = ["tests", "scripts"] +skips = ["B101", "B108"] # Skip assert_used and hardcoded_tmp_directory for test files + +[tool.ruff.lint.flake8-pytest-style] +fixture-parentheses = false + +[tool.ruff.lint.pyupgrade] +keep-runtime-typing = true + +[tool.ruff.lint.mccabe] +max-complexity = 25 + +[tool.ruff.format] +# Ruff is the primary formatter +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" + +[tool.ruff.lint.isort] +known-first-party = ["custom_components", "span"] +force-sort-within-sections = true +combine-as-imports = true diff --git a/tests/test_library_path_hook.py b/tests/test_library_path_hook.py index c8741550..399dafe0 100644 --- a/tests/test_library_path_hook.py +++ b/tests/test_library_path_hook.py @@ -2,14 +2,34 @@ `scripts/check-library-path.py` runs on every commit that touches `pyproject.toml` and rejects a `span-panel-api` path naming anything but -`../../span/span-panel-api`. It exists because `3cbf02a` committed a scratch -worktree into both blocks that decide where the library comes from, and nothing -noticed for several commits. +`../../span/span-panel-api`. It exists because `3cbf02a` pointed both blocks that +decide where the library comes from at a scratch worktree and committed it, and +nothing noticed for several commits. Tested for the same reason `tests/test_dependency_sync.py` exists: a hook that matches nothing produces no output, and no output is indistinguishable from a -clean tree. Most of what follows hands the hook a file that is wrong on purpose -and checks that it says so. +clean tree. + +Two things this file is careful about, because getting either wrong would leave a +gate that is only proven against itself. + +**It drives the real entry point.** Every case runs +`scripts/check-library-path.py` as a subprocess and reads its exit status and +output, exactly as prek invokes it -- never the module's functions, and never a +reimplementation of the rule in the test. A test that reimplements the logic +proves the test's copy of the logic. `test_the_hook_prek_runs_is_the_one_tested` +closes the remaining gap by reading the entry out of `prek.toml`: a hook renamed +there and not here would otherwise go unregistered while these tests kept passing. + +**The failing cases are the real files.** `tests/fixtures/pyproject_*.toml` are +byte copies of `pyproject.toml` at the two commits where this actually went +wrong, taken straight out of this repository's history and held to it by +`test_the_vendored_copies_are_the_commits_they_name`. A gate proven against a +made-up example is proven against the wrong thing: the synthetic version of a +defect is written by someone who already knows what the rule checks, so it +exercises the rule rather than the mistake. The two states nothing in history +provides -- an absent `[tool.uv.sources]` block, an `extraPaths` naming only +unrelated repositories -- are the only ones constructed here. Lives here rather than beside the script in `tests/scripts/`, which `pytest.ini` excludes with `norecursedirs`. A test that is never collected is the same failure @@ -19,6 +39,7 @@ from __future__ import annotations from pathlib import Path +import re import subprocess import sys @@ -27,15 +48,36 @@ REPO = Path(__file__).resolve().parent.parent SCRIPT = REPO / "scripts" / "check-library-path.py" PYPROJECT = REPO / "pyproject.toml" +PREK = REPO / "prek.toml" +FIXTURES = Path(__file__).resolve().parent / "fixtures" + +HOOK_ID = "library-path" +"""The hook's id in `prek.toml`, which is how prek is asked to run it.""" CHECKOUT = "../../span/span-panel-api" WORKTREE = "../../span/span-panel-api-security" -"""The worktree `3cbf02a` actually named. A sibling of the checkout, sharing every -path component but the last, which is why a prefix comparison has to be -separator-aware to see the difference.""" +"""The worktree `3cbf02a` actually named. + +A sibling of the checkout, sharing every path component but the last, which is +why a prefix comparison has to be separator-aware to tell them apart. Not written +here as the definition of the defect -- the vendored copies are that -- but as +what the failure output is checked to name. +""" + +BOTH_BLOCKS = "pyproject_both_blocks_redirected" +"""`3cbf02a`: `[tool.uv.sources]` and `[tool.pyright].extraPaths` both on the worktree.""" + +TYPE_CHECKER_ONLY = "pyproject_type_checker_path_left_behind" +"""`82a512f^`: the sources block corrected, `extraPaths` still on the worktree. + +The interval between the two corrections -- several commits, during which the +repository looked fixed. A hook covering only `[tool.uv.sources]` calls this +clean, which is precisely how the second half survived as long as it did. +""" def _run(*targets: Path) -> subprocess.CompletedProcess[str]: + """Invoke the hook the way prek does: the script, given filenames, judged by exit status.""" return subprocess.run( [sys.executable, str(SCRIPT), *(str(target) for target in targets)], capture_output=True, @@ -44,20 +86,28 @@ def _run(*targets: Path) -> subprocess.CompletedProcess[str]: ) -def _pyproject(root: Path, sources: str | None, pyright: str | None) -> Path: - """Write a miniature `pyproject.toml` declaring the library at the given roots. +def _historical(name: str) -> Path: + """Return the vendored copy of `pyproject.toml` at one historical commit.""" + return FIXTURES / f"{name}.toml" + + +def _recorded_commit(name: str) -> str: + """Return the commit a vendored copy records itself as having come from.""" + return (FIXTURES / f"{name}.source").read_text(encoding="utf-8").strip() - Built rather than copied so a case cannot pass by accident of what the real - tree happens to hold today, and so `None` can express a block that is absent - -- which is the shape CI installs from. + +def _synthetic(root: Path, sources: str | None, pyright: str | None) -> Path: + """Write a miniature `pyproject.toml` for a state this repository's history does not hold. + + Deliberately not used for the failing cases. History has real instances of + those, and a real instance is worth more than a constructed one -- see this + module's docstring. """ - lines = ['[project]', 'name = "span"', ""] + lines = ["[project]", 'name = "span"', ""] if sources is not None: lines += [ "[tool.uv.sources]", f'span-panel-api = {{ path = "{sources}", editable = true }}', - f'span-panel-api-schema-0 = {{ path = "{sources}/packages/schema-0", editable = true }}', - f'span-panel-api-schema-1 = {{ path = "{sources}/packages/schema-1", editable = true }}', "", ] if pyright is not None: @@ -71,6 +121,51 @@ def _pyproject(root: Path, sources: str | None, pyright: str | None) -> Path: return written +_HOOK_TABLE = re.compile(r"\{(?P[^{}]*)\}", re.DOTALL) +"""One hook's inline table. `prek.toml` nests no braces inside one, so this is exact.""" + +_ENTRY = re.compile(r'^\s*entry\s*=\s*"(?P[^"]*)"', re.MULTILINE) + + +def _declared_entries(hook_id: str) -> list[str]: + """Return the `entry` of every hook in `prek.toml` with this id. + + Read as text rather than through `tomllib`, because `prek.toml` is not TOML + 1.0: it writes each hook as an inline table spread over several lines, which + the spec allows only from 1.1 and `tomllib` rejects outright. prek's own + parser accepts it and so does `check-toml`, so the file is not wrong -- a + strict parser is simply the wrong tool for reading it, and reformatting the + file to suit one test would be the tail wagging the dog. + """ + text = PREK.read_text(encoding="utf-8") + entries: list[str] = [] + for table in _HOOK_TABLE.finditer(text): + body = table.group("body") + if f'id = "{hook_id}"' not in body: + continue + entry = _ENTRY.search(body) + if entry is not None: + entries.append(entry.group("entry")) + return entries + + +def test_the_hook_prek_runs_is_the_one_tested() -> None: + """`prek.toml` must invoke the script these tests invoke. + + The gap this closes is a quiet one: rename the script or the hook and every + case below goes on passing against a file prek no longer runs. Nothing else + would report it, because an unregistered hook produces no output either. + """ + entries = _declared_entries(HOOK_ID) + + assert entries, f"prek.toml declares no hook with id {HOOK_ID!r}; nothing runs at commit time" + assert len(entries) == 1, f"prek.toml declares {HOOK_ID!r} more than once: {entries}" + assert SCRIPT.name in entries[0], ( + f"prek runs {entries[0]!r} for {HOOK_ID!r} and these tests exercise {SCRIPT.name}. " + f"One of the two is not the gate." + ) + + def test_the_committed_file_passes() -> None: """The real `pyproject.toml`, which is the state the hook must be silent on. @@ -83,61 +178,72 @@ def test_the_committed_file_passes() -> None: assert result.returncode == 0, result.stdout + result.stderr -@pytest.mark.parametrize( - ("sources", "pyright"), - [(WORKTREE, WORKTREE), (WORKTREE, CHECKOUT), (CHECKOUT, WORKTREE)], - ids=["both blocks", "the sources block alone", "the type-checker path alone"], -) -def test_a_worktree_in_either_block_is_rejected( - tmp_path: Path, sources: str, pyright: str -) -> None: - """`3cbf02a` in miniature, and each half of it alone. - - Both blocks are checked because both were wrong and they were corrected - separately: `82a512f` moved `extraPaths` back several commits and hours after - the sources block, because nothing was watching it. The third case here is - that interval -- a file half-corrected, which is exactly the state a hook - covering only `[tool.uv.sources]` would call clean. - """ - result = _run(_pyproject(tmp_path, sources, pyright)) - - assert result.returncode == 1, result.stdout or "the hook said nothing" - assert WORKTREE in result.stdout, "the failure does not name the offending path" +def test_the_commit_that_redirected_both_blocks_is_rejected() -> None: + """`3cbf02a` itself, byte for byte, not an approximation of it. + All four declarations it moved are reported, rather than the first one found. + Reporting one at a time would have made correcting this a four-commit + conversation with the hook, and it is the last one -- `extraPaths` -- that + nobody was looking at. + """ + result = _run(_historical(BOTH_BLOCKS)) -def test_the_checkout_in_both_blocks_passes(tmp_path: Path) -> None: - """The corrected state, including the sub-paths the adapters and pyright use.""" - result = _run(_pyproject(tmp_path, CHECKOUT, CHECKOUT)) - - assert result.returncode == 0, result.stdout + result.stderr + assert result.returncode == 1, result.stdout or "the hook said nothing about 3cbf02a" + for declaration in ( + f"span-panel-api = '{WORKTREE}'", + f"span-panel-api-schema-0 = '{WORKTREE}/packages/schema-0'", + f"span-panel-api-schema-1 = '{WORKTREE}/packages/schema-1'", + f"extraPaths = '{WORKTREE}/src'", + ): + assert declaration in result.stdout, f"the failure does not report {declaration}" -def test_an_absent_sources_block_passes(tmp_path: Path) -> None: - """CI deletes `[tool.uv.sources]` before installing, then re-runs these hooks. +def test_the_commit_that_left_the_type_checker_path_behind_is_rejected() -> None: + """`82a512f^`: half-corrected, and still wrong. - A block that is not there declares nothing to be wrong about, and failing on - its absence would make the hook impossible to satisfy in the one environment - that resolves from PyPI. + The sources block had been moved back to the checkout by this point and the + type-checker path had not. The assertion that matters is the second one: the + corrected half must not be reported, so what fails here is the half that was + genuinely still broken rather than a hook that objects to the file in general. """ - result = _run(_pyproject(tmp_path, None, CHECKOUT)) + result = _run(_historical(TYPE_CHECKER_ONLY)) - assert result.returncode == 0, result.stdout + result.stderr + assert result.returncode == 1, result.stdout or "the hook said nothing about 82a512f^" + assert f"extraPaths = '{WORKTREE}/src'" in result.stdout + assert f"[tool.uv.sources] span-panel-api = '{WORKTREE}'" not in result.stdout, ( + "the sources block was already corrected at this commit; reporting it would mean the " + "hook is not reading the file it was given" + ) -def test_the_failure_says_what_to_do_instead(tmp_path: Path) -> None: +def test_the_failure_says_what_to_do_instead() -> None: """Somebody hits this precisely when they are trying to do the thing it forbids. The message has to carry the alternative, because a gate that only says "no" - to a developer with a real reason to redirect the library teaches them to - pass `--no-verify` rather than to use the venv. + to a developer with a real reason to redirect the library teaches them to pass + `--no-verify` rather than to use the venv. """ - result = _run(_pyproject(tmp_path, WORKTREE, CHECKOUT)) + result = _run(_historical(BOTH_BLOCKS)) assert "uv pip install -e" in result.stdout, "the failure does not name the alternative" assert "uv sync" in result.stdout, "the failure does not say how to undo it" assert "developer.md" in result.stdout, "the failure does not point at the documentation" +def test_an_absent_sources_block_passes(tmp_path: Path) -> None: + """CI deletes `[tool.uv.sources]` before installing, then re-runs these hooks. + + Constructed rather than taken from history, because no commit here has ever + been in this state -- it exists only inside a CI job, after a `sed`. A block + that is not there declares nothing to be wrong about, and failing on its + absence would make the hook impossible to satisfy in the one environment that + resolves from PyPI. + """ + result = _run(_synthetic(tmp_path, None, CHECKOUT)) + + assert result.returncode == 0, result.stdout + result.stderr + + def test_an_unrelated_path_is_left_alone(tmp_path: Path) -> None: """`extraPaths` carries other repositories, and they are none of this hook's business.""" target = tmp_path / "pyproject.toml" @@ -149,3 +255,42 @@ def test_an_unrelated_path_is_left_alone(tmp_path: Path) -> None: result = _run(target) assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.parametrize("name", [BOTH_BLOCKS, TYPE_CHECKER_ONLY]) +def test_the_vendored_copies_are_the_commits_they_name(name: str) -> None: + """Each copy must still be the blob its `.source` records. + + Without this, "the real historical file" is a claim in a docstring, and the + obvious way to make a failing case pass is to edit the fixture. + + **A skip here is genuinely not the pass that a skip usually hides**, and the + difference is worth stating because this repository's other provenance guard + refuses exactly this exit. That one tracks a moving target -- a library + release that can be re-cut, re-tagged, or drift -- so skipping means not + looking at something that changes. A commit is content-addressed: the bytes at + `3cbf02a:pyproject.toml` cannot become different bytes. The only way this + comparison can fail is a copy that was wrong when it was vendored, and that is + caught wherever the history is reachable, which is every developer machine and + any CI checkout with depth. A shallow clone does not have the object to + compare against, and asserting against an object that is not there would fail + on the clone rather than on the fixture. + """ + recorded = _recorded_commit(name) + blob = subprocess.run( + ["git", "-C", str(REPO), "cat-file", "-p", f"{recorded}:pyproject.toml"], + capture_output=True, + check=False, + ) + if blob.returncode != 0: + pytest.skip( + f"{recorded} is not reachable from this checkout (a shallow clone has no history " + f"to compare against); the copy is verified wherever the commit is present" + ) + + assert blob.stdout == _historical(name).read_bytes(), ( + f"tests/fixtures/{name}.toml is no longer a byte copy of {recorded}:pyproject.toml, " + f"which is what makes it evidence rather than an example. Re-vendor it with " + f"`git show {recorded}:pyproject.toml > tests/fixtures/{name}.toml`, or correct the " + f"commit in {name}.source if the copy was meant to come from a different one." + ) From 2d4405fcd7cf6ded8dad01f5a1025f3afb37a1c0 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:51:46 -0700 Subject: [PATCH 139/345] test(dip): an outage delays a dip's verdict rather than deciding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grace period and dip compensation share a stored record but not a decision, and nothing pinned the boundary. While the panel is offline `_update_native_value` hands off to the grace period and returns before any dip processing, so an outage cannot book, confirm or retract anything. That division is deliberate: grace period answers what a sensor should read while the panel is unreachable, dip compensation answers what the counter did, and an outage is evidence about the transport rather than about the counter. The alternative matters — an outage landing between a dip and the reading that would settle it could otherwise confirm a bad offset by default, which is the same shape as forgetting the pending record across a restart. Covers the three parts: the pending record and offset survive the outage untouched, grace holds the compensated value HA was already showing, and the dip settles normally once the panel returns. --- tests/test_energy_dip_compensation.py | 61 +++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/tests/test_energy_dip_compensation.py b/tests/test_energy_dip_compensation.py index 8299aede..a6d4fc44 100644 --- a/tests/test_energy_dip_compensation.py +++ b/tests/test_energy_dip_compensation.py @@ -567,3 +567,64 @@ def test_a_restart_mid_dip_still_retracts_when_the_counter_returns(self): assert after._energy_offset == 0.0 assert after._attr_native_value == 1007.6 after.coordinator.report_energy_dip.assert_not_called() + + +class TestAnOutageDoesNotSettleADip: + """Grace period and dip compensation share a stored record but not a decision. + + While the panel is offline `_update_native_value` hands off to the grace + period and returns before any dip processing, so an outage cannot book, + confirm or retract anything. That is the intended division: grace period + answers "what should this sensor read while the panel is unreachable", dip + compensation answers "what did the counter do", and an outage is evidence + about the transport rather than about the counter. + """ + + def test_an_outage_leaves_an_unsettled_dip_exactly_as_it_was(self): + sensor = DummyDipSensor(dip_enabled=True) + sensor._mock_panel_value = 1000.0 + sensor._update_native_value() + sensor._mock_panel_value = 0.0 + sensor._update_native_value() + booked = sensor._pending_dip + + sensor.coordinator.panel_offline = True + sensor._mock_panel_value = 12345.0 # must not be read while offline + sensor._update_native_value() + + assert sensor._pending_dip == booked + assert sensor._energy_offset == 1000.0 + assert sensor._last_panel_reading == 0.0 + + def test_grace_period_holds_the_compensated_value(self): + """What HA was showing is what it keeps showing, offset included.""" + sensor = DummyDipSensor(dip_enabled=True) + sensor._mock_panel_value = 1000.0 + sensor._update_native_value() + sensor._mock_panel_value = 0.0 + sensor._update_native_value() + + sensor.coordinator.panel_offline = True + sensor._update_native_value() + + assert sensor._attr_native_value == 1000.0 + assert sensor._last_valid_state == 1000.0 + + def test_the_dip_settles_normally_once_the_panel_returns(self): + """The outage delays the verdict rather than deciding it.""" + sensor = DummyDipSensor(dip_enabled=True) + sensor._mock_panel_value = 1000.0 + sensor._update_native_value() + sensor._mock_panel_value = 0.0 + sensor._update_native_value() + + sensor.coordinator.panel_offline = True + sensor._update_native_value() + sensor.coordinator.panel_offline = False + sensor._mock_panel_value = 1007.6 + sensor._update_native_value() + + assert sensor._energy_offset == 0.0 + assert sensor._pending_dip is None + assert sensor._attr_native_value == 1007.6 + sensor.coordinator.report_energy_dip.assert_not_called() From 6a705af8a9b7ba5d280df36df39a1ef7ab3d7f07 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:52:24 -0700 Subject: [PATCH 140/345] docs: state what dip compensation now does, and how to check an old offset Two corrections to the README, both consequences of the retraction rule. The feature section said a persistent notification lists affected sensors "when a dip is detected". That is no longer true and teaches the wrong model: a dip is compensated on sight but not believed, so the notification fires on corroboration a reading later, and a disproved dip produces none at all. Someone watching a momentary dip pass with no notification would reasonably conclude the feature had failed, when that silence is it working. The troubleshooting row offered dip compensation as the prevention for energy dashboard spikes, which after #259 is half the story -- compensation could manufacture them. It now also says how to check: an `energy_offset` far larger than the sensor's own reading is compensation for a dip that never happened, and toggling the option off discards it. It says plainly what that cannot do. Offsets booked before 2.1.0 were never corroborated, so the integration cannot tell a good one from a bad one retrospectively -- which is why there is no repair flow offering to. And clearing an offset fixes the future only; spikes already written into long-term statistics still need Adjust sum. Table rows reflow because the column padding is recomputed; the text change is confined to the one cell. Both edits are `scripts/fix-markdown.sh` output. --- README.md | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 297ea654..f6256bc2 100644 --- a/README.md +++ b/README.md @@ -614,7 +614,13 @@ increasing value. - **Threshold:** 1.0 Wh minimum to avoid false triggers from float precision noise - **Disabling:** Clears all accumulated offsets (starts fresh if re-enabled) -When a dip is detected, a persistent notification lists the affected sensors and their dip amounts. +A dip is compensated as soon as it is seen, but not believed straight away. A counter reset is permanent — the counter restarts low and counts up from there — +so a reading that drops and then returns to where it was is a transport artifact rather than a reset, and its offset is taken back. The offset stays provisional +until a later reading either disproves it (the counter comes back) or corroborates it (the counter climbs from the new, lower base). + +**The persistent notification therefore lists a dip once it is corroborated, one reading after it is seen, and a dip that is disproved produces no notification +at all** — the sensor was compensated the whole time and nothing needs your attention. Seeing no notification after a momentary dip is the feature working, not +failing. **Diagnostic attributes** (visible when compensation is active): @@ -782,18 +788,18 @@ See [WebSocket API Reference](websocket-api.md) for the full schema, response fo ## Troubleshooting -| Issue | Symptoms | Resolution | -| ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Energy Dashboard spikes after firmware updates** | Huge energy-consumption spikes after panel firmware updates; charts showing untracked values that dwarf normal usage; negative energy values in statistics. Caused by the panel reporting decreased values on otherwise`TOTAL_INCREASING` sensors. | **Prevention:** enable [Energy Dip Compensation](#energy-dip-compensation) in General Options (on by default for new installs). **Fix existing spikes:** in **Developer Tools → Statistics**, search for the affected sensor (e.g. `sensor.span_panel_main_meter_consumed_energy`) and use **Adjust sum** to correct the errant entry. The integration also notifies when a decrease in the main meter consumed sensor is detected. | -| **High CPU usage** | Elevated CPU on low-power hardware (e.g. Raspberry Pi). The integration rebuilds a full panel snapshot from MQTT messages at a configurable interval (default 1 s). | Increase**Snapshot Update Interval** in **General Options**. 10–15 s is recommended for resource-constrained systems. Setting it to 0 disables debouncing and rebuilds on every MQTT message — not recommended. | -| **Replaced sub-device shows the old serial number** | After replacing a SPAN sub-device (Drive / EVSE, BESS, PV inverter), the device entry in Home Assistant keeps showing the previous hardware's serial number. The integration keys entities off the panel-assigned node identity, which is intentionally stable across hardware swaps so long-term history (e.g. lifetime charging kWh for a Drive) is preserved. The device-registry serial number, however, does not auto-refresh. | In**Settings → Devices & Services → Span Panel**, open the affected sub-device and delete it, then reload the integration (or restart Home Assistant). The device re-registers with the new serial number. Entity IDs and their recorded history are preserved. | -| **Door sensor unavailable** | The SPAN API returns UNKNOWN if the cabinet door has not been operated recently. This is a defect in the SPAN API. | The integration reports the sensor as unavailable until a proper value arrives. Opening or closing the door publishes the correct state. The door is classified as a tamper sensor (`Detected` / `Clear`) to differentiate it from a normal entry door. | -| **No switch on a circuit** | A circuit has no switch entity exposed in Home Assistant. | The circuit is configured in the SPAN App as one of the "Always on Circuits". The API does not permit user control of those circuits, so no switch is created. | -| **Reinstalling to change the entity ID style gives back the old entity IDs** | The naming style is chosen at install and cannot be changed from the options, so reinstalling looks like the way to switch. It is not: every entity returns with the entity ID it had before. | Home Assistant remembers a removed entity for **30 days**, keyed on its unique ID, and restores that record's entity ID — along with its name, area, labels and icon — as soon as an entity with the same unique ID appears again. This integration's unique IDs do not change with the naming style, so the remembered ID wins over the one the new style asks for. Either clear the leftover registry entries between removing and reinstalling, or wait out the 30 days and let Home Assistant discard them. A tool such as [ha-registry-clean](https://github.com/LegoTypes/ha-registry-clean) can do the clearing; it is a separate project, not part of this integration. Clearing also discards the names, areas and labels you had assigned. | -| **Setup fails after downgrading the integration** | After installing an older release, the SPAN Panel config entry fails to set up and Home Assistant reports an unsupported configuration version. | The release that stopped storing the panel passphrase migrated the config entry to version 7. Home Assistant refuses to load a config entry whose version is newer than the installed integration understands, and there is no automatic downgrade. Reinstall the newer release, or restore a backup taken before the upgrade. Removing and re-adding the integration also works and preserves entity IDs, but needs the panel passphrase or physical access to the door again. | -| **"SPAN Panel certificate authority changed" repair** | The integration has stopped connecting and a repair reports two fingerprints: the one it pinned and the one the panel now advertises. Entities are unavailable. | The panel is presenting a different certificate authority than the one you accepted at setup. Two things look identical from here and only you can tell them apart: a firmware upgrade or a factory reset rotates the authority legitimately, and so does a device on your network standing in for your panel. **If you know why it changed**, open the repair, compare the new fingerprint, and accept it — that re-pins and reconnects. **If nothing should have changed**, do not accept. Check what else is on the panel's network segment first. The integration will not reconnect on its own and will not re-pin on its own, deliberately: retrying would mean waiting to succeed against whatever is answering. | -| **A circuit re-commissioned in the SPAN App has the wrong controls** | A circuit whose configuration changed in the SPAN App — made controllable, locked, or set to never back up — still has the controls it had before. A newly controllable circuit has no Breaker switch. A newly locked one still shows its switch, and operating it reports that the command was refused. | Reload the integration (**Settings → Devices & Services → Span Panel → ⋮ → Reload**). Which control entities exist is decided when the integration starts, from what the panel declares about each circuit at that moment; a change made afterwards is picked up at the next reload or restart. Reloading is safe — entity IDs, history, names and areas are preserved, because they are keyed on identifiers that do not change. Until you reload, a control the panel no longer accepts is refused rather than published: the command is not queued and the breaker does not move. | -| **A control reports `unconfirmed`** | The logbook or the `span_panel_control_command` event says a command was `unconfirmed`. Nothing appears broken. | **This is not an error.** It means the panel took the command and did not report a change within the deadline, and the most common reason by far is that there was no change to report — the relay was already open, the priority was already that value. It is also indistinguishable from a silent rejection by the panel, because SPAN's firmware sends no reason code; the integration reports what it observed rather than guessing. The two outcomes that do mean something went wrong are `failed`, which means the command was never sent, and `refused:…`, which means this integration refused it and names why. | +| Issue | Symptoms | Resolution | +| ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Energy Dashboard spikes after firmware updates** | Huge energy-consumption spikes after panel firmware updates; charts showing untracked values that dwarf normal usage; negative energy values in statistics. Caused by the panel reporting decreased values on otherwise`TOTAL_INCREASING` sensors. | **Prevention:** enable [Energy Dip Compensation](#energy-dip-compensation) in General Options (on by default for new installs). **Fix existing spikes:** in **Developer Tools → Statistics**, search for the affected sensor (e.g. `sensor.span_panel_main_meter_consumed_energy`) and use **Adjust sum** to correct the errant entry. The integration also notifies when a decrease in the main meter consumed sensor is detected. **If you enabled compensation before 2.1.0 and saw spikes at restart:** check `energy_offset` on your energy sensors in **Developer Tools → States** — an offset far larger than the sensor's own reading is compensation for a dip that never happened, and toggling Energy Dip Compensation off discards it. Offsets booked before 2.1.0 were never corroborated, so the integration cannot tell a good one from a bad one retrospectively; the spikes already in statistics still need **Adjust sum** either way. | +| **High CPU usage** | Elevated CPU on low-power hardware (e.g. Raspberry Pi). The integration rebuilds a full panel snapshot from MQTT messages at a configurable interval (default 1 s). | Increase**Snapshot Update Interval** in **General Options**. 10–15 s is recommended for resource-constrained systems. Setting it to 0 disables debouncing and rebuilds on every MQTT message — not recommended. | +| **Replaced sub-device shows the old serial number** | After replacing a SPAN sub-device (Drive / EVSE, BESS, PV inverter), the device entry in Home Assistant keeps showing the previous hardware's serial number. The integration keys entities off the panel-assigned node identity, which is intentionally stable across hardware swaps so long-term history (e.g. lifetime charging kWh for a Drive) is preserved. The device-registry serial number, however, does not auto-refresh. | In**Settings → Devices & Services → Span Panel**, open the affected sub-device and delete it, then reload the integration (or restart Home Assistant). The device re-registers with the new serial number. Entity IDs and their recorded history are preserved. | +| **Door sensor unavailable** | The SPAN API returns UNKNOWN if the cabinet door has not been operated recently. This is a defect in the SPAN API. | The integration reports the sensor as unavailable until a proper value arrives. Opening or closing the door publishes the correct state. The door is classified as a tamper sensor (`Detected` / `Clear`) to differentiate it from a normal entry door. | +| **No switch on a circuit** | A circuit has no switch entity exposed in Home Assistant. | The circuit is configured in the SPAN App as one of the "Always on Circuits". The API does not permit user control of those circuits, so no switch is created. | +| **Reinstalling to change the entity ID style gives back the old entity IDs** | The naming style is chosen at install and cannot be changed from the options, so reinstalling looks like the way to switch. It is not: every entity returns with the entity ID it had before. | Home Assistant remembers a removed entity for **30 days**, keyed on its unique ID, and restores that record's entity ID — along with its name, area, labels and icon — as soon as an entity with the same unique ID appears again. This integration's unique IDs do not change with the naming style, so the remembered ID wins over the one the new style asks for. Either clear the leftover registry entries between removing and reinstalling, or wait out the 30 days and let Home Assistant discard them. A tool such as [ha-registry-clean](https://github.com/LegoTypes/ha-registry-clean) can do the clearing; it is a separate project, not part of this integration. Clearing also discards the names, areas and labels you had assigned. | +| **Setup fails after downgrading the integration** | After installing an older release, the SPAN Panel config entry fails to set up and Home Assistant reports an unsupported configuration version. | The release that stopped storing the panel passphrase migrated the config entry to version 7. Home Assistant refuses to load a config entry whose version is newer than the installed integration understands, and there is no automatic downgrade. Reinstall the newer release, or restore a backup taken before the upgrade. Removing and re-adding the integration also works and preserves entity IDs, but needs the panel passphrase or physical access to the door again. | +| **"SPAN Panel certificate authority changed" repair** | The integration has stopped connecting and a repair reports two fingerprints: the one it pinned and the one the panel now advertises. Entities are unavailable. | The panel is presenting a different certificate authority than the one you accepted at setup. Two things look identical from here and only you can tell them apart: a firmware upgrade or a factory reset rotates the authority legitimately, and so does a device on your network standing in for your panel. **If you know why it changed**, open the repair, compare the new fingerprint, and accept it — that re-pins and reconnects. **If nothing should have changed**, do not accept. Check what else is on the panel's network segment first. The integration will not reconnect on its own and will not re-pin on its own, deliberately: retrying would mean waiting to succeed against whatever is answering. | +| **A circuit re-commissioned in the SPAN App has the wrong controls** | A circuit whose configuration changed in the SPAN App — made controllable, locked, or set to never back up — still has the controls it had before. A newly controllable circuit has no Breaker switch. A newly locked one still shows its switch, and operating it reports that the command was refused. | Reload the integration (**Settings → Devices & Services → Span Panel → ⋮ → Reload**). Which control entities exist is decided when the integration starts, from what the panel declares about each circuit at that moment; a change made afterwards is picked up at the next reload or restart. Reloading is safe — entity IDs, history, names and areas are preserved, because they are keyed on identifiers that do not change. Until you reload, a control the panel no longer accepts is refused rather than published: the command is not queued and the breaker does not move. | +| **A control reports `unconfirmed`** | The logbook or the `span_panel_control_command` event says a command was `unconfirmed`. Nothing appears broken. | **This is not an error.** It means the panel took the command and did not report a change within the deadline, and the most common reason by far is that there was no change to report — the relay was already open, the priority was already that value. It is also indistinguishable from a silent rejection by the panel, because SPAN's firmware sends no reason code; the integration reports what it observed rather than guessing. The two outcomes that do mean something went wrong are `failed`, which means the command was never sent, and `refused:…`, which means this integration refused it and names why. | ## Development From dd4dbbcb8cc9612ed7c44496e9f52370dcf0e16f Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:28:38 -0700 Subject: [PATCH 141/345] test(control): pin that disabling control keeps the entity_ids it stops creating `disabled` had to be a mode that stops *creating* controls rather than one that removes them, because entity_id is permanent here. Nothing asserted that. The existing test checks only that no entities are added, which both spellings satisfy -- a mode that removed every registry entry on the way past would have passed it unchanged. Its docstring claimed the other half anyway: "No control entities, and no registry entries removed either." A claim made in a docstring and checked nowhere is worse than an absent test, because it reads as coverage to anyone auditing by name -- which is how this gap was found. That sentence now says what the test actually proves and points at the one below. The new test registers an entry, customises its name and icon, runs setup under `disabled`, and asserts the entry survives with its entity_id, its unique_id mapping and its customisations intact. Customised rather than bare because the cost of getting this wrong is not the identifier alone: a user's name, area and icon travel with the registry entry and do not come back with a reissued id. Verified by injecting the defect -- removing the switch entries on the disabled path -- and confirming this fails, then restoring. A guard that has never been seen to fire is not known to be a guard. --- tests/test_control_gate.py | 93 +++++++++++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/tests/test_control_gate.py b/tests/test_control_gate.py index a556503b..31edb2e5 100644 --- a/tests/test_control_gate.py +++ b/tests/test_control_gate.py @@ -443,7 +443,12 @@ def test_a_negative_lock_timeout_turns_the_feature_off() -> None: @pytest.mark.asyncio async def test_a_disabled_entry_creates_no_control_entities(hass: HomeAssistant) -> None: - """No control entities, and no registry entries removed either.""" + """The half of `disabled` this test actually proves: nothing is created. + + That the registry entries *survive* is the other half, and it is asserted + separately below rather than in this docstring — a claim made here and + checked nowhere reads as coverage to anyone auditing by name. + """ from custom_components.span_panel.switch import async_setup_entry from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory @@ -790,3 +795,89 @@ async def test_the_logbook_explains_a_refusal(hass: HomeAssistant) -> None: ) assert "operated moments ago" in described["message"] + + +# ---------- `disabled` keeps the entity_ids it stops creating ---------- + + +@pytest.mark.asyncio +async def test_disabling_control_leaves_the_registry_entries_alone( + hass: HomeAssistant, +) -> None: + """The permanence half of §5.6, which the creation test does not reach. + + `entity_id` is permanent in this integration, so `disabled` had to be a mode + that stops *creating* controls rather than one that removes them. The + difference is invisible from the created-entity list — both spellings add + nothing — and shows up only in what the registry still holds afterwards. + + Asserted through a customised entry rather than a bare one because the cost + of getting this wrong is not the identifier alone: a user's name, area and + icon travel with the registry entry, and a removed entry loses them even if + the same `entity_id` is later reissued. + """ + from homeassistant.helpers import entity_registry as er + + from custom_components.span_panel.entity_resolver import ( + build_switch_unique_id_for_entry, + ) + from custom_components.span_panel.switch import async_setup_entry + + from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory + + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen", is_user_controllable=True + ) + coordinator = MagicMock() + coordinator.data = SpanPanelSnapshotFactory.create(circuits={"1": circuit}) + coordinator.config_entry = MagicMock() + coordinator.config_entry.options = {} + coordinator.client = AsyncMock() + + config_entry = MockConfigEntry(domain=DOMAIN, data={}, title="SPAN Panel") + config_entry.add_to_hass(hass) + + unique_id = build_switch_unique_id_for_entry( + coordinator, coordinator.data, "1", "SPAN Panel" + ) + + registry = er.async_get(hass) + existing = registry.async_get_or_create( + "switch", + DOMAIN, + unique_id, + suggested_object_id="span_panel_kitchen_breaker", + config_entry=config_entry, + ) + registry.async_update_entity( + existing.entity_id, name="Kitchen Breaker", icon="mdi:toaster" + ) + + entry = MagicMock() + entry.title = "SPAN Panel" + entry.data = {} + entry.entry_id = config_entry.entry_id + entry.runtime_data = MagicMock( + coordinator=coordinator, + control_policy=ControlPolicy( + mode=ControlMode.DISABLED, + allow_contextless=True, + lock_timeout_minutes=None, + relay_debounce_seconds=2.0, + ), + ) + + added: list[object] = [] + await async_setup_entry(hass, entry, lambda e, **kw: added.extend(e)) + + assert added == [] + + survivor = registry.async_get(existing.entity_id) + assert survivor is not None, ( + "`disabled` removed the registry entry for a control it merely stopped " + "creating. The entity_id is now free for anything else to claim, and " + "re-enabling control would reissue it with a _2 suffix at best." + ) + assert registry.async_get_entity_id("switch", DOMAIN, unique_id) == existing.entity_id + assert survivor.name == "Kitchen Breaker" + assert survivor.icon == "mdi:toaster" From 898ca9244898ad8c4c7d3e5d161590a58dd7528b Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:30:58 -0700 Subject: [PATCH 142/345] chore: 2.1.0b15 The first beta whose pins resolve. b14 named span-panel-api 3.1.0 and both adapters at 1.1.0 while none of the three existed on PyPI, so the pins were honest about what the code needed and unsatisfiable by anyone but a developer with local path overrides -- deliberately, since the alternative was pins that resolved to a library without the changes. All three published today, so CI can now install what the manifest asks for. What the beta carries beyond b14: - The panel credential no longer travels or rests in clear. The passphrase is not persisted, and the CA is fetched and confirmed before registration rather than after, so the exchange that carries the passphrase and returns both credentials is the first thing protected rather than the last. - The panel's CA is pinned and will not silently re-anchor. A change raises a repair naming both fingerprints and re-pins only on an explicit confirmation. - Control commands are gated on who is asking, and every one is recorded -- refusals included, which are the entries worth having. - No control silently swallows a command that did not happen. A refused command raises where the user made it; so does one the transport never handed over. - An absent meter reading is no longer read as zero, and a compensation offset the next reading disproves is retracted rather than kept. The vendored schema-1 capture is verified against the released schema-1-v1.1.0 rather than against unreleased bytes, which is the first time that check has had a release to verify against. --- custom_components/span_panel/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 12ed6628..43d597ed 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -27,7 +27,7 @@ "span-panel-api-schema-0==1.1.0", "span-panel-api-schema-1==1.1.0" ], - "version": "2.1.0b14", + "version": "2.1.0b15", "zeroconf": [ { "type": "_span._tcp.local." From 8f46050e7d3a3dc6a1cccf0f1294cff6b5977b67 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:41:10 -0700 Subject: [PATCH 143/345] docs(changelog): one sentence per entry in 2.1.0 Adds the energy dip fix (#259) and cuts every entry under Changed, Fixed and Security to a single sentence, without leaning on a dash to bolt a second clause onto the first. Also drops the paragraph on SPAN's upgrade timing from the release preamble, which restated what the section above it already says. What came out was mechanism and caveat: how the dip was mistaken for a counter reset, what happens to offsets stored by earlier releases, why a changed CA cannot be re-pinned automatically, what a refused control used to do instead. All of it is in the README's Security section and troubleshooting table, which is where someone chasing one of these looks. What was kept is what an upgrader cannot find out any other way: which entities are affected, that ids and history survive, and where a template reading a moved attribute should now point. --- CHANGELOG.md | 116 +++++++++++++++++++-------------------------------- 1 file changed, 43 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2d0059c..6a2a5e11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,6 @@ SPAN firmware `r202633` replaces the way the panel publishes its data — the wi the new one. There is no wire overlap and no setting to keep the old behaviour. **2.0.8 cannot read a panel on `r202633`**: it stays connected, reports every circuit as missing, and shows nothing useful. -We do not control when that update reaches you, and the schedule belongs exclusively to SPAN. Panels update on SPAN's timing, not on yours or ours. If -uninterrupted integration matters to you, install this release **before** your panel changes — afterwards you are looking at a blank integration while you work -out why. - **The transition itself is seamless.** Install 2.1.0 early and your only outage is the firmware update itself. A log line and a one-time notification tell you exactly what happened, so take a screenshot. @@ -93,83 +89,57 @@ working entirely in 2027.8. ### Changed - **`DSM Grid State` is now more trustworthy**, reading the islanding state the Microgrid Interconnect Device actually senses rather than inferring it from the - battery, the dominant power source and which way power was crossing the grid connection. It keeps its entity id and all of its history. -- **`Grid Islandable` keeps working** across the upgrade: the new firmware publishes no panel-level islandable property and makes the question structural - instead, so the entity now reflects whether a Microgrid Interconnect Device is present. A panel without one reads `Off`, which is an answer, rather than going - unavailable. -- **Battery model** may read differently after upgrading: the new firmware separates the human-readable designation from the SKU, and this entity now shows the - designation. The normalisation happens in the library on both sides of the upgrade, so it lands once, at this release, rather than unpredictably when your - panel changes over. - -- **Five panel sensors are switched off for new installations** (#234), because the SPAN API documented three defects that predate SPAN firmware r202633: the - feedthrough energy registers can decrease or go negative, the feedthrough power reading is inverted relative to every other terminal, and the feedthrough - currents report the _upstream_ service conductors. The affected entities are **Feedthrough Produced Energy**, **Feedthrough Consumed Energy**, **Feedthrough - Power**, and the two **Downstream** current sensors. -- **If you already have those five, nothing changes and they stay exactly where they are**, because Home Assistant consults the enabled-by-default setting only - when an entity is first created — so an existing installation keeps them, keeps its history and keeps its entity IDs. If you use any of the five on a - dashboard or in an automation they are worth removing, but that is your decision to make, not something an upgrade should do to you. -- **Your other panel readings are unaffected, and that is now checked against a real panel rather than assumed.** A capture from a live upgraded panel shows the - four power-flow values summing to zero exactly and the battery power definition byte-for-byte what 2.0.8 shipped, with the upstream lugs, the main panel meter - and every circuit in the correct frame. - -- **New entities are now announced in a notification, whether or not they arrived switched on.** The notification now splits what arrived into ready to use and - switched off and names each entity. - -- **The Wi-Fi network name attribute moved to the Wi-Fi Link sensor**, which is where you would look for it: the entity that tells you whether Wi-Fi is up now - also tells you which network it is up on, as a `wifi_ssid` attribute. It is absent rather than blank on a panel that publishes no SSID. -- **It is no longer an attribute of the Software Version sensor**, where a network name never made sense attribute block. If you have a template reading - `state_attr('sensor.span_panel_software_version', 'wifi_ssid')`, point it at the Wi-Fi Link binary sensor instead. - -- **The three circuit energy sensors are renamed to match the ids they are given**: "Produced Energy", "Consumed Energy" and "Net Energy" become **Energy - Produced**, **Energy Consumed** and **Energy Net**, the order the `energy_produced`, `energy_consumed` and `energy_net` suffixes already use. **Entity ids, - unique ids and history are unchanged**; only the name shown in the UI reorders. + battery and the dominant power source, and keeping its entity id and its history. +- **`Grid Islandable` keeps working across the upgrade** by reflecting whether a Microgrid Interconnect Device is present, so a panel without one reads `Off` + rather than going unavailable. +- **Battery model may read differently after upgrading**, now showing the human-readable designation rather than the SKU, normalised in the library on both + sides of the upgrade so it lands once at this release. +- **Five panel sensors are switched off for new installations** (#234) because of three SPAN-documented defects predating firmware r202633: **Feedthrough + Produced Energy**, **Feedthrough Consumed Energy**, **Feedthrough Power**, and the two **Downstream** current sensors. +- **Existing installations keep those five exactly where they are**, with their history and entity ids, because Home Assistant consults the enabled-by-default + setting only when an entity is first created. +- **Your other panel readings are unaffected**, checked against a capture from a live upgraded panel rather than assumed. +- **New entities are now announced in a notification** that splits what arrived into ready to use and switched off and names each one. +- **The Wi-Fi network name moved to the Wi-Fi Link sensor** as a `wifi_ssid` attribute, absent rather than blank on a panel that publishes no SSID. +- **It is no longer an attribute of the Software Version sensor**, so a template reading `state_attr('sensor.span_panel_software_version', 'wifi_ssid')` should + point at the Wi-Fi Link binary sensor instead. +- **The three circuit energy sensors are renamed** to **Energy Produced**, **Energy Consumed** and **Energy Net**, matching the id suffixes they already have, + with entity ids, unique ids and history unchanged. ### Fixed -- **Recreate entity IDs proposes the ids your panel would produce now** (#252), in your installation's naming style; unique ids and statistics are untouched. -- **Enum sensors advertise the states they can actually report.** Nine sensors declared only `unknown`, so `DSM Grid State` sitting at `On Grid` showed +- **Energy dip compensation no longer books a dip that never happened** (#259), where a partial reading at startup looked like a counter reset and every restart + added each circuit's whole lifetime counter to its offset. +- **Recreate entity IDs proposes the ids your panel would produce now** (#252), in your installation's naming style, leaving unique ids and statistics + untouched. +- **Enum sensors advertise the states they can actually report**, where nine sensors declared only `unknown` and `DSM Grid State` sitting at `On Grid` showed "Possible states: Unknown". -- **The README described Battery Power's sign backwards.** The sensor reports **discharging** as positive and always has, as release 2.0.5 established (#184) - and a measured panel confirms — **no entity changed and no reading moved**, only the documentation was ever wrong. -- **A breaker your panel refuses to operate now says so.** The switch had no handling for a refused relay command at all, so the failure surfaced as a traceback - in the log and a toggle that quietly sprang back. It now reports the panel's own reason where you pressed it, and the switch stays where the relay actually - is. -- **A control command that never reached your panel now says so.** A relay, priority, GFE override or charge-limit command that the transport could not hand - over — the broker was disconnected, so it was refused rather than queued — was written to the log and nowhere else. It is reported where you issued it, and - says plainly that nothing was queued and nothing will happen later. -- **A refused circuit priority no longer blames your firmware.** The old message said the panel's firmware did not support the operation, which stopped being - the only explanation once your panel could also declare an individual circuit's priority fixed. The priority select and the GFE override button now carry the - panel's reason instead of guessing at one, and report it as an error on the control you used rather than as a notification that sits in the sidebar until you - dismiss it by hand. +- **The README described Battery Power's sign backwards**, since the sensor has always reported discharging as positive (#184) and only the documentation was + ever wrong. +- **A breaker your panel refuses to operate now says so**, reporting the panel's own reason where you pressed it instead of a traceback in the log and a toggle + that quietly springs back. +- **A control command that never reached your panel now says so**, reported where you issued it rather than only in the log and stating plainly that nothing was + queued. +- **A refused circuit priority no longer blames your firmware**, carrying the panel's own reason on the control you used rather than as a notification you + dismiss by hand. ### Security -- **The panel passphrase is no longer stored.** It is an input to registration and nothing afterwards read it, but a stored copy could mint fresh panel - credentials at any time. Upgrading migrates the config entry to version 7 and removes it; nothing else in the entry changes and no entity is affected. - Reauthenticating removes it too, if you upgrade later. **Once an entry reaches version 7, an older build of the integration will refuse to load it** — Home - Assistant does not downgrade config entries, so roll back from a backup rather than by reinstalling. -- **New action `span_panel.rotate_credentials`** asks the panel for a new eBus MQTT broker password, stores it, and reloads the integration — for use after a - contractor visit or a suspected exposure. It is restricted to Home Assistant administrators and cannot be called from an automation or script. **The previous - broker password stops working immediately**, so any other local client using it must be re-provisioned from the panel. The panel access token and the panel - passphrase are unchanged, and a failed rotation leaves the existing credentials in place. +- **The panel passphrase is no longer stored** — upgrading migrates the config entry to version 7 and removes it, after which an older build of the integration + will refuse to load that entry, so roll back from a backup rather than by reinstalling. +- **New admin-only action `span_panel.rotate_credentials`** replaces the eBus MQTT broker password on demand, which immediately breaks any other local client + using the old one. +- **The panel's certificate authority is pinned before your passphrase is ever sent**, so the registration exchange that carries it — and everything after — no + longer crosses your network in the clear. +- **A changed authority stops the integration and raises a repair** carrying both fingerprints, because a legitimate rotation and an impersonated panel look + identical from here. +- **Four new options decide who may operate the panel** — administrators only, no commands without a logged-in user, an admin-only control lock, and a relay + debounce — every one defaulting to what your panel already does. +- **Every control command is now recorded** in the logbook, on the event bus as `span_panel_control_command`, and at `INFO`, attributed to the automation that + issued it rather than to nobody. +- **Controls now report what happened to them**, so a command that was refused or never handed to the broker no longer looks like one the panel confirmed. - **A new [Security](README.md#security) section in the README** covers what the integration stores, what rotation costs, and the deployment choices that - actually bound the panel's exposure — VLAN isolation, a locked enclosure, read-only Home Assistant users, and encrypted backups. -- **The panel's certificate authority is pinned, before your passphrase is ever sent.** Setup fetches it, checks that the certificate your panel actually serves - is signed by it, and shows you the fingerprint to accept — and only then asks you to authenticate. Registration is the exchange that carries your passphrase - and returns both the access token and the broker password, and it used to cross your network in the clear; it now travels over the authority you accepted, as - does everything after it. Previously the authority was also re-fetched over plaintext HTTP on every connection and whatever answered was trusted. **Anyone - merely listening on your network can no longer read those credentials.** A device actively impersonating your panel at that first fetch still could, which is - why the fingerprint is shown for you to compare rather than accepted silently. Panels configured before this release exchanged their credentials long ago and - are simply pinned on the first startup that reaches them, logged at `WARNING` with the fingerprint. -- **A changed authority stops the integration and raises a repair** carrying both fingerprints, rather than reconnecting. A legitimate rotation and a device - impersonating your panel look identical from here, so re-pinning takes an explicit acceptance from you. Nothing retries in the meantime. -- **Four new options decide who may operate the panel** — restrict controls to administrators, refuse commands from automations, add an admin-only control lock, - and debounce repeated relay commands. **Every default is what your panel already does**, so upgrading changes nothing until you choose otherwise. See - [Restricting who can operate the panel](README.md#restricting-who-can-operate-the-panel), which is also explicit about what these do _not_ protect against. -- **Every control command is now recorded** — in the logbook, on the event bus as `span_panel_control_command`, and at `INFO`. Commands from an automation are - attributed to that automation rather than to nobody. -- **Controls now report what happened to them.** A command that was refused, or that was never handed to the broker, no longer looks the same as one the panel - confirmed — and a switch no longer shows a position its relay never took. + actually bound the panel's exposure. ## [2.0.8] - 5/2026 From 0c82de73785b2f9897e08d84235c248581212b7d Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 01:25:43 -0700 Subject: [PATCH 144/345] fix(config-flow): pin the panel's authority without stopping to ask Setup showed a step headed "Trust this panel's certificate": a 64-character fingerprint, four paragraphs on what pinning does and does not protect against, and one button. It asked the user to make a decision they cannot make. At first contact there is nothing to compare the fingerprint against. SPAN publishes it nowhere, so the screen said "comparing this fingerprint against another source is what closes that case" and then offered no source. It also told them a device standing between Home Assistant and the panel "would still see them" -- accurate, unactionable, and delivered at the moment they are least equipped to weigh it. The only available answer was Submit, which makes the question decoration. The protection was never in the asking. It is in pinning *before* registration, so the exchange that carries the passphrase and returns both credentials runs over a verified connection. That is unchanged: the CA is fetched, the panel's own certificate is checked against it, and it is pinned, before authentication is offered. One less step, and the same guarantee. The fingerprint moves to where it can be acted on rather than acknowledged: diagnostics report it under `panel_ca`, setup logs it at INFO so the value is recoverable from the install that pinned it, and the Repair raised if it ever changes carries both the pinned and the observed value. That last one is the moment the question becomes answerable, because there is a prior value to compare against and something has actually happened. Kept: the error step. A CA that cannot be fetched, or a certificate the panel serves that its own authority did not sign, still stops setup and offers a retry -- that failure has a remedy, so it is worth a screen. `test_the_ca_is_pinned_before_the_passphrase_is_ever_sent` now asserts the property rather than the screen that used to carry it: when the auth menu is offered, the panel has been asked nothing yet, and when registration does run it carries a transport with an SSL context on port 443. --- README.md | 14 +++++--- custom_components/span_panel/config_flow.py | 33 +++++++++++++------ custom_components/span_panel/strings.json | 4 --- .../span_panel/translations/en.json | 4 --- .../span_panel/translations/es.json | 4 --- .../span_panel/translations/fr.json | 4 --- .../span_panel/translations/ja.json | 4 --- .../span_panel/translations/pt.json | 4 --- tests/test_v2_config_flow.py | 23 +++++-------- 9 files changed, 40 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index f6256bc2..171799ad 100644 --- a/README.md +++ b/README.md @@ -661,8 +661,8 @@ Home Assistant process — talks to the panel directly and is not subject to Hom ### The panel's certificate authority The panel issues its own certificate authority and signs its TLS certificate with it. **Before you are asked for your passphrase**, setup fetches that -authority, checks that the certificate the panel actually serves is signed by it, and shows you the SHA-256 fingerprint to accept. Everything after that — -registration itself, and every later connection — runs over the authority you accepted, and the integration stops rather than accepting a different one. +authority, checks that the certificate the panel actually serves is signed by it, and pins it. Everything after that — registration itself, and every later +connection — runs over that authority, and the integration stops rather than accepting a different one. The ordering is the point. Registration is the exchange that sends your passphrase and returns both the access token and the broker password, so it is the single most valuable message on your network. It now travels over a verified connection instead of in the clear. @@ -675,9 +675,13 @@ anchor everything else is checked against. So: - A device **actively standing between** Home Assistant and your panel at that first fetch could answer with an authority of its own, sign a certificate with it, and still see them. Pinning cannot detect that on its own. -Comparing the fingerprint against another source is what closes the second case, and it is exactly why the fingerprint is shown rather than accepted silently. -Another install of this integration on the same panel reports the same value. After the first acceptance, nothing can change the authority without stopping and -asking you. +Comparing the fingerprint against another source is what closes the second case. Setup does not stop to ask you to do that, because at first contact there is +nothing to compare against: SPAN does not publish the value, so the question could only be answered by pressing Submit. The fingerprint is put where it can +actually be used instead — diagnostics report it under `panel_ca`, it is logged at setup, and another install of this integration on the same panel reports the +same value. + +After the first pin, nothing can change the authority without stopping and asking you — and _that_ is a question you can answer, because there is a prior value +to compare against. See the certificate-authority-changed entry in Troubleshooting. **There is no "continue without pinning" for a new panel.** If the authority cannot be read, or the certificate the panel serves is not signed by it, setup shows an error and stops. Submitting the form again retries the fetch, which is what a panel that was briefly unreachable needs. An opt-out would quietly diff --git a/custom_components/span_panel/config_flow.py b/custom_components/span_panel/config_flow.py index 2b68ecfa..3e3f5abf 100644 --- a/custom_components/span_panel/config_flow.py +++ b/custom_components/span_panel/config_flow.py @@ -700,12 +700,13 @@ async def async_step_panel_ca( _LOGGER.warning("Panel %s served an unreadable CA: %s", self.host, err) return self._async_show_ca_error("ca_unavailable") - self._panel_ca_pem = ca_pem - return self.async_show_form( - step_id="panel_ca_confirm", - data_schema=vol.Schema({}), - description_placeholders={"fingerprint": fingerprint}, + _LOGGER.info( + "Pinned the certificate authority published by panel %s (SHA-256 %s)", + self.host, + fingerprint, ) + self._panel_ca_pem = ca_pem + return await self._async_adopt_anchor_and_authenticate() def _async_show_ca_error(self, reason: str) -> ConfigFlowResult: """Re-show the CA step with an actionable error. @@ -725,11 +726,23 @@ def _async_show_ca_error(self, reason: str) -> ConfigFlowResult: errors={"base": reason}, ) - async def async_step_panel_ca_confirm( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Adopt the accepted anchor, then authenticate over it.""" - if user_input is None or self._panel_ca_pem is None: + async def _async_adopt_anchor_and_authenticate(self) -> ConfigFlowResult: + """Adopt the pinned anchor, then authenticate over it. + + Not a step, and deliberately not a confirmation. Pinning happens either + way — the protection is that registration runs over the anchor, not that + somebody pressed Submit — and at first contact there is nothing to check + the fingerprint against: SPAN publishes it nowhere, so a screen asking a + user to accept it offers a decision they cannot make and an alarm they + cannot act on. + + The fingerprint is surfaced where it is actionable instead: in + diagnostics, and in the Repair raised if it ever changes, where there is + a prior value to compare against and a real decision to take. It is also + logged here, so the value is recoverable from the moment of the install + that pinned it. + """ + if self._panel_ca_pem is None: return await self.async_step_panel_ca() try: diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index f44ed986..2899018b 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -136,10 +136,6 @@ "panel_ca": { "title": "Reading the panel's certificate", "description": "Home Assistant fetches the certificate authority your panel publishes, and checks that the certificate the panel serves is signed by it. This happens before you enter your passphrase, so the passphrase and the credentials the panel returns for it travel over a verified connection rather than in the clear.\n\nSelect **Submit** to try again." - }, - "panel_ca_confirm": { - "title": "Trust this panel's certificate", - "description": "The panel published this certificate authority, and the certificate it serves is signed by it:\n\n**{fingerprint}**\n\nAccepting it pins it. Your passphrase is sent over this connection, and everything afterwards uses it. Home Assistant will stop and ask you before ever accepting a different one.\n\n**What this does and does not protect.** This authority was fetched over your local network on a connection that had nothing to verify itself against. Anyone merely *listening* on your network can no longer read your passphrase or the credentials the panel returns — that is the common case and it is now closed. But a device actively standing between Home Assistant and your panel right now could have answered with an authority of its own, and would still see them.\n\nComparing this fingerprint against another source is what closes that case, and it is why it is shown. Another install of this integration on the same panel will report the same value." } } }, diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index f44ed986..2899018b 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -136,10 +136,6 @@ "panel_ca": { "title": "Reading the panel's certificate", "description": "Home Assistant fetches the certificate authority your panel publishes, and checks that the certificate the panel serves is signed by it. This happens before you enter your passphrase, so the passphrase and the credentials the panel returns for it travel over a verified connection rather than in the clear.\n\nSelect **Submit** to try again." - }, - "panel_ca_confirm": { - "title": "Trust this panel's certificate", - "description": "The panel published this certificate authority, and the certificate it serves is signed by it:\n\n**{fingerprint}**\n\nAccepting it pins it. Your passphrase is sent over this connection, and everything afterwards uses it. Home Assistant will stop and ask you before ever accepting a different one.\n\n**What this does and does not protect.** This authority was fetched over your local network on a connection that had nothing to verify itself against. Anyone merely *listening* on your network can no longer read your passphrase or the credentials the panel returns — that is the common case and it is now closed. But a device actively standing between Home Assistant and your panel right now could have answered with an authority of its own, and would still see them.\n\nComparing this fingerprint against another source is what closes that case, and it is why it is shown. Another install of this integration on the same panel will report the same value." } } }, diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index bb6626e5..af48f590 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -136,10 +136,6 @@ "panel_ca": { "title": "Leyendo el certificado del panel", "description": "Home Assistant obtiene la autoridad de certificación que publica su panel y comprueba que el certificado que este sirve está firmado por ella. Esto ocurre antes de que introduzca su contraseña, de modo que la contraseña y las credenciales que el panel devuelve a cambio viajan por una conexión verificada y no en claro.\n\nSeleccione **Enviar** para volver a intentarlo." - }, - "panel_ca_confirm": { - "title": "Confiar en el certificado de este panel", - "description": "El panel ha publicado esta autoridad de certificación, y el certificado que sirve está firmado por ella:\n\n**{fingerprint}**\n\nAceptarla la fija. Su contraseña se envía por esta conexión, y todo lo posterior la utiliza. Home Assistant se detendrá a preguntarle antes de aceptar otra distinta.\n\n**Qué protege y qué no.** Esta autoridad se obtuvo a través de su red local por una conexión que no tenía nada con lo que verificarse. Quien simplemente *escuche* en su red ya no puede leer su contraseña ni las credenciales que devuelve el panel: ese es el caso habitual y queda cerrado. Pero un dispositivo que en este momento se interponga activamente entre Home Assistant y su panel podría haber respondido con una autoridad propia, y sí las vería.\n\nComparar esta huella digital con otra fuente es lo que cierra ese caso, y por eso se muestra. Otra instalación de esta integración sobre el mismo panel indicará el mismo valor." } } }, diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index 55c3290c..f92db59f 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -136,10 +136,6 @@ "panel_ca": { "title": "Lecture du certificat du panneau", "description": "Home Assistant récupère l'autorité de certification publiée par votre panneau et vérifie que le certificat présenté par celui-ci est signé par elle. Cela se produit avant que vous ne saisissiez votre mot de passe : celui-ci, ainsi que les identifiants que le panneau renvoie en échange, transitent donc par une connexion vérifiée plutôt qu'en clair.\n\nSélectionnez **Valider** pour réessayer." - }, - "panel_ca_confirm": { - "title": "Faire confiance au certificat de ce panneau", - "description": "Le panneau a publié cette autorité de certification, et le certificat qu'il présente est signé par elle :\n\n**{fingerprint}**\n\nL'accepter l'épingle. Votre mot de passe est envoyé sur cette connexion, et tout ce qui suit l'utilise. Home Assistant s'arrêtera pour vous demander avant d'en accepter une autre.\n\n**Ce que cela protège et ce que cela ne protège pas.** Cette autorité a été récupérée sur votre réseau local, par une connexion qui n'avait rien pour se vérifier elle-même. Quiconque se contente d'*écouter* votre réseau ne peut plus lire votre mot de passe ni les identifiants renvoyés par le panneau — c'est le cas courant, et il est désormais fermé. Mais un appareil qui s'interpose activement entre Home Assistant et votre panneau en ce moment même a pu répondre avec sa propre autorité, et les verrait quand même.\n\nComparer cette empreinte avec une autre source est ce qui ferme ce cas, et c'est pourquoi elle est affichée. Une autre installation de cette intégration reliée au même panneau indiquera la même valeur." } } }, diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index 8e222003..0dd04625 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -136,10 +136,6 @@ "panel_ca": { "title": "パネルの証明書を読み取っています", "description": "Home Assistant は、パネルが公開している認証局を取得し、パネルが提供する証明書がその認証局で署名されていることを確認します。これはパスフレーズを入力する前に行われるため、パスフレーズと、その引き換えにパネルが返す認証情報は、平文ではなく検証済みの接続を通ります。\n\n**送信**を選ぶと再試行します。" - }, - "panel_ca_confirm": { - "title": "このパネルの証明書を信頼する", - "description": "パネルは次の認証局を公開しており、パネルが提供する証明書はこの認証局によって署名されています。\n\n**{fingerprint}**\n\n受け入れるとピン留めします。パスフレーズはこの接続で送信され、以降のすべてがこの接続を使用します。Home Assistant は別の認証局を受け入れる前に、必ず停止して確認します。\n\n**保護されるものと、されないもの。** この認証局は、自身を検証する手段のない接続でローカルネットワーク経由で取得されました。ネットワークを単に*盗聴*しているだけの相手は、もうパスフレーズやパネルが返す認証情報を読み取れません。これが一般的なケースであり、いま塞がれました。しかし、いまこの瞬間に Home Assistant とパネルのあいだに能動的に割り込んでいる機器は、自前の認証局で応答できた可能性があり、その場合は読み取れます。\n\nこのフィンガープリントを別の経路と照合することが、そのケースを塞ぐ唯一の方法であり、ここに表示している理由です。同じパネルに接続している別のインストールは、同じ値を示します。" } } }, diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index 3c47573f..26128095 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -136,10 +136,6 @@ "panel_ca": { "title": "A ler o certificado do painel", "description": "O Home Assistant obtém a autoridade de certificação que o seu painel publica e verifica que o certificado servido pelo painel está assinado por ela. Isto acontece antes de introduzir a sua frase-passe, pelo que a frase-passe e as credenciais que o painel devolve em troca viajam por uma ligação verificada e não em claro.\n\nSelecione **Submeter** para tentar novamente." - }, - "panel_ca_confirm": { - "title": "Confiar no certificado deste painel", - "description": "O painel publicou esta autoridade de certificação, e o certificado que serve está assinado por ela:\n\n**{fingerprint}**\n\nAceitá-la fixa-a. A sua frase-passe é enviada por esta ligação, e tudo o que se segue utiliza-a. O Home Assistant irá parar e perguntar-lhe antes de aceitar outra.\n\n**O que isto protege e o que não protege.** Esta autoridade foi obtida através da sua rede local, numa ligação que não tinha nada com que se verificar a si própria. Quem apenas *escuta* a sua rede já não consegue ler a sua frase-passe nem as credenciais devolvidas pelo painel — esse é o caso comum e está agora fechado. Mas um equipamento que neste momento se interponha activamente entre o Home Assistant e o seu painel poderia ter respondido com uma autoridade própria, e continuaria a vê-las.\n\nComparar esta impressão digital com outra origem é o que fecha esse caso, e é por isso que é mostrada. Outra instalação desta integração ligada ao mesmo painel indicará o mesmo valor." } } }, diff --git a/tests/test_v2_config_flow.py b/tests/test_v2_config_flow.py index 0530753d..31c7403d 100644 --- a/tests/test_v2_config_flow.py +++ b/tests/test_v2_config_flow.py @@ -88,11 +88,6 @@ def panel_ca_available(): yield -async def _confirm_panel_ca(hass: HomeAssistant, flow_id: str): - """Accept the fingerprint the CA step is showing.""" - return await hass.config_entries.flow.async_configure(flow_id, {}) - - async def _submit_host_and_pin(hass: HomeAssistant, flow_id: str, data: dict): """Submit the host form and accept the CA the panel serves. @@ -102,8 +97,8 @@ async def _submit_host_and_pin(hass: HomeAssistant, flow_id: str, data: dict): repeating the same two lines twenty times. """ result = await hass.config_entries.flow.async_configure(flow_id, data) - assert result["step_id"] == "panel_ca_confirm", result["step_id"] - return await _confirm_panel_ca(hass, result["flow_id"]) + assert result["step_id"] == "choose_v2_auth", result["step_id"] + return result MOCK_HOST = "192.168.1.100" @@ -469,15 +464,13 @@ async def test_the_ca_is_pinned_before_the_passphrase_is_ever_sent( return_value=MOCK_V2_AUTH, ) as register, ): - shown = await _reach_the_ca_step(hass) - assert shown["step_id"] == "panel_ca_confirm" - assert shown["description_placeholders"] == {"fingerprint": FAKE_CA_FINGERPRINT} + menu = await _reach_the_ca_step(hass) - # Nothing has been sent to the panel yet. - register.assert_not_called() - - menu = await _confirm_panel_ca(hass, shown["flow_id"]) + # The CA step passes straight through on success, so the menu is the + # first thing shown after the host form. What matters is not which + # screen appears but that the panel has been asked nothing yet. assert menu["step_id"] == "choose_v2_auth" + register.assert_not_called() picked = await hass.config_entries.flow.async_configure( menu["flow_id"], {"next_step_id": "auth_passphrase"} @@ -583,7 +576,7 @@ async def test_a_recovered_panel_can_be_retried_into_a_successful_pin( recovered = await hass.config_entries.flow.async_configure(failed["flow_id"], {}) - assert recovered["step_id"] == "panel_ca_confirm" + assert recovered["step_id"] == "choose_v2_auth" # ---------- config entry migration (2.0.4 baseline) ---------- From a143aa0054237982d934045e9875cc797c79458b Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 01:51:05 -0700 Subject: [PATCH 145/345] feat(config-flow): believe a panel that says where it serves TLS The HTTPS-port question exists for an install reaching the panel through something that moved the TLS port, and it is the right question when nobody else can answer it. A panel discovered from the simulator add-on is not that install: the add-on allocates a TLS port per panel and reallocates it across restarts, so it is the only party that knows, and it now publishes what it knows -- `https_port` in the Supervisor record, `httpsPort` in the mDNS TXT. Read both, and skip the prompt when either spoke. Asking after being told is asking a user to confirm a fact they have no way to check, with the wrong answer costing them a `ca_leaf_mismatch` and no way past it. Nothing moves for hardware. Real firmware publishes no TLS port, so the flag stays false and the gate is what it was: no prompt on port 80, a prompt on a moved one. A panel behind a reverse proxy advertises `httpPort` and nothing else, and is still asked -- reading a port from discovery must not become a way for that install to silently check the CA against 443. An unreadable value is treated as unpublished for the same reason. The ports also join the discovery update on an already-configured entry, since an entry left pointing at last run's ports cannot reach its panel. The inline TXT parse became `_discovered_port`, which both discovery steps now share; it keeps the old fallbacks exactly, including 80 for a value that will not parse. --- custom_components/span_panel/config_flow.py | 85 ++++++++-- tests/test_v2_config_flow.py | 171 ++++++++++++++++++++ 2 files changed, 242 insertions(+), 14 deletions(-) diff --git a/custom_components/span_panel/config_flow.py b/custom_components/span_panel/config_flow.py index 3e3f5abf..7fdacb3c 100644 --- a/custom_components/span_panel/config_flow.py +++ b/custom_components/span_panel/config_flow.py @@ -110,6 +110,30 @@ def get_user_data_schema(default_host: str = "") -> vol.Schema: ) +def _discovered_port(record: Mapping[str, Any], *names: str) -> int | None: + """Read a port out of a discovery record, or None when it names none. + + Everything in a discovery record is whatever the panel put on the wire: + mDNS TXT values arrive as strings, may be absent, and may be spelled in a + case this integration did not pick. Anything unreadable is treated as + unpublished rather than raising, because a malformed TXT record must not be + the reason a panel cannot be set up. + + None is distinct from a published value that happens to equal the default: + only None leaves the flow free to ask the user. + """ + for name in names: + raw = record.get(name) + if raw is None or raw == "": + continue + try: + return int(raw) + except (TypeError, ValueError): + _LOGGER.debug("Discovery record carried an unreadable %s: %r", name, raw) + return None + return None + + class TriggerFlowType(enum.Enum): """Types of configuration flow triggers.""" @@ -150,6 +174,12 @@ def __init__(self) -> None: self._v2_panel_serial: str | None = None self._http_port: int = 80 self._https_port: int = DEFAULT_HTTPS_PORT + # Whether the TLS port came from the panel's own discovery record + # rather than from a default. Discovery is the authority when it + # speaks: a panel that publishes the port knows where it serves, and + # asking the user for it after being told is asking them to confirm a + # fact they have no way to check. + self._https_port_discovered: bool = False # The panel CA, once fetched and leaf-confirmed. None means the flow # could not acquire one and the entry is created with the pending flag. self._panel_ca_pem: str | None = None @@ -204,13 +234,19 @@ async def async_step_zeroconf(self, discovery_info: ZeroconfServiceInfo) -> Conf # v2 panels discovered via eBus / secure-mqtt service types # Read optional httpPort from mDNS TXT records (non-standard port) props = discovery_info.properties or {} - http_port_str = props.get("httpPort", props.get("httpport", "")) - try: - http_port = int(http_port_str) if http_port_str else 80 - except (ValueError, TypeError): - http_port = 80 + discovered_http_port = _discovered_port(props, "httpPort", "httpport") + http_port = 80 if discovered_http_port is None else discovered_http_port self._http_port = http_port + # A panel that moved its HTTP port has usually moved its TLS port + # too, and it is the only party that knows where. Taking the + # published value here is what keeps the reverse-proxy question off + # the screen of a user whose panel already answered it. + https_port = _discovered_port(props, "httpsPort", "httpsport") + if https_port is not None: + self._https_port = https_port + self._https_port_discovered = True + detection = await detect_api_version( discovery_info.host, port=http_port, @@ -246,12 +282,22 @@ async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> ConfigFl """ config = discovery_info.config host = str(config.get("host", "")) - port = int(config.get("port", 80)) + discovered_port = _discovered_port(config, "port") + port = 80 if discovered_port is None else discovered_port serial = str(config.get("serial", "")) if not host: return self.async_abort(reason="no_host") + # The add-on serves TLS on a port it allocated and publishes here. + # Nobody else can tell the flow what it is, so being told is the + # difference between a silent setup and a prompt for a number the user + # would have to go read out of the add-on log. + https_port = _discovered_port(config, "https_port", "httpsPort") + if https_port is not None: + self._https_port = https_port + self._https_port_discovered = True + # Validate panel is reachable and v2 self._http_port = port detection = await detect_api_version( @@ -265,9 +311,15 @@ async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> ConfigFl if not panel_serial: return self.async_abort(reason="no_serial") - # Dedup by serial — multiple panels may share the same host IP + # Dedup by serial — multiple panels may share the same host IP. The + # ports go into the update because the add-on reallocates them across + # restarts: an entry left pointing at last run's ports is an entry that + # cannot reach its panel. await self.async_set_unique_id(panel_serial) - self._abort_if_unique_id_configured(updates={CONF_HOST: host, CONF_HTTP_PORT: port}) + updates: dict[str, Any] = {CONF_HOST: host, CONF_HTTP_PORT: port} + if self._https_port_discovered: + updates[CONF_HTTPS_PORT] = self._https_port + self._abort_if_unique_id_configured(updates=updates) # Set up flow — same path as v2 zeroconf discovery self.api_version = "v2" @@ -627,14 +679,19 @@ async def async_step_fqdn_failed( async def async_step_panel_ca_start( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Enter CA acquisition, asking for the HTTPS port first when it is not 443. + """Enter CA acquisition, asking for the HTTPS port only when nothing knows it. + + The port is prompted for on an install that already moved the HTTP port, + because that is the install most likely to be reaching the panel through + something that also moved the TLS one. Asking everybody would put a + question about reverse proxies in front of users who have none. - The port is only prompted for on an install that already moved the HTTP - port, because that is the install most likely to be reaching the panel - through something that also moved the TLS one. Asking everybody would put - a question about reverse proxies in front of users who have none. + Discovery outranks the prompt. A panel that published its TLS port has + already answered the question, and better than the user could: the + add-on allocates that port per panel and reallocates it across restarts, + so the only correct answer is the one it just gave. """ - if self._http_port == 80: + if self._http_port == 80 or self._https_port_discovered: return await self.async_step_panel_ca() return await self.async_step_panel_https_port() diff --git a/tests/test_v2_config_flow.py b/tests/test_v2_config_flow.py index 31c7403d..e75c29d1 100644 --- a/tests/test_v2_config_flow.py +++ b/tests/test_v2_config_flow.py @@ -1889,6 +1889,177 @@ async def test_hassio_end_to_end_entry_creation(hass: HomeAssistant) -> None: assert result5["data"][CONF_PANEL_CA_PEM] == FAKE_CA_PEM +@pytest.mark.usefixtures("socket_enabled") +@pytest.mark.asyncio +async def test_hassio_published_https_port_is_used_without_asking(hass: HomeAssistant) -> None: + """A discovery record naming the TLS port skips the prompt and is believed. + + The add-on allocates a TLS port per panel and reallocates it across + restarts, so it is the only party that knows the answer. Asking the user + for a number they would have to go read out of an add-on log is a question + with a worse answer available. + """ + leaf_check = AsyncMock(return_value=True) + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION_SIM, + ), + patch( + "custom_components.span_panel.config_flow.validate_v2_passphrase", + return_value=MOCK_V2_AUTH, + ), + patch( + "custom_components.span_panel.config_flow.async_leaf_chains_to_ca", + new=leaf_check, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=_hassio_service_info({**MOCK_HASSIO_CONFIG, "https_port": 10090}), + ) + assert result["step_id"] == "confirm_discovery" + + # Confirm goes straight past the port question to the auth menu. + result2 = await _submit_host_and_pin(hass, result["flow_id"], {}) + assert result2["step_id"] == "choose_v2_auth" + + # The published port is the one the CA was checked against, not 443. + assert leaf_check.await_args.args[1] == 10090 + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], {"next_step_id": "auth_passphrase"} + ) + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE} + ) + result5 = await hass.config_entries.flow.async_configure( + result4["flow_id"], {"entity_naming_pattern": "friendly_names"} + ) + + assert result5["type"] == FlowResultType.CREATE_ENTRY + assert result5["data"][CONF_HTTP_PORT] == 9090 + assert result5["data"][CONF_HTTPS_PORT] == 10090 + + +@pytest.mark.asyncio +async def test_zeroconf_https_port_txt_record_is_used_without_asking( + hass: HomeAssistant, +) -> None: + """The same holds over mDNS, where the panel publishes the port in TXT.""" + discovery_info = ZeroconfServiceInfo( + ip_address=ipaddress.IPv4Address("192.168.1.200"), + ip_addresses=[ipaddress.IPv4Address("192.168.1.200")], + hostname="span-panel.local.", + name="SPAN Panel._ebus._tcp.local.", + port=8883, + properties={"httpPort": "8081", "httpsPort": "9081"}, + type="_ebus._tcp.local.", + ) + + leaf_check = AsyncMock(return_value=True) + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow.is_ipv4_address", + return_value=True, + ), + patch( + "custom_components.span_panel.config_flow.async_leaf_chains_to_ca", + new=leaf_check, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=discovery_info, + ) + assert result["step_id"] == "confirm_discovery" + + result2 = await _submit_host_and_pin(hass, result["flow_id"], {}) + assert result2["step_id"] == "choose_v2_auth" + assert leaf_check.await_args.args[1] == 9081 + + +@pytest.mark.asyncio +async def test_moved_http_port_alone_still_asks_for_the_https_one( + hass: HomeAssistant, +) -> None: + """A panel that publishes no TLS port is still asked about, as before. + + Hardware behind a reverse proxy advertises ``httpPort`` and nothing else. + Reading a port from discovery must not become a way for that install to + silently end up checking the CA against 443. + """ + discovery_info = ZeroconfServiceInfo( + ip_address=ipaddress.IPv4Address("192.168.1.200"), + ip_addresses=[ipaddress.IPv4Address("192.168.1.200")], + hostname="span-panel.local.", + name="SPAN Panel._ebus._tcp.local.", + port=8883, + properties={"httpPort": "8080"}, + type="_ebus._tcp.local.", + ) + + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow.is_ipv4_address", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=discovery_info, + ) + port_step = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert port_step["step_id"] == "panel_https_port" + + +@pytest.mark.asyncio +async def test_unreadable_https_port_in_discovery_falls_back_to_asking( + hass: HomeAssistant, +) -> None: + """A TXT record that cannot be read is not a port, so the question stands.""" + discovery_info = ZeroconfServiceInfo( + ip_address=ipaddress.IPv4Address("192.168.1.200"), + ip_addresses=[ipaddress.IPv4Address("192.168.1.200")], + hostname="span-panel.local.", + name="SPAN Panel._ebus._tcp.local.", + port=8883, + properties={"httpPort": "8080", "httpsPort": "not-a-port"}, + type="_ebus._tcp.local.", + ) + + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow.is_ipv4_address", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=discovery_info, + ) + port_step = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert port_step["step_id"] == "panel_https_port" + + # ---------- user flow: null status_info ---------- From 06947cb974fadd5bb4551d1a2e94a76244979d37 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 01:51:50 -0700 Subject: [PATCH 146/345] chore: 2.1.0b16 Carries the discovery change: a panel that publishes its TLS port is taken at its word, so setting up a simulated panel no longer stops on a question only the add-on could answer. Needs PanelBench 2.2.0 on the other side to have a TLS port worth publishing. --- custom_components/span_panel/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 43d597ed..80e4e972 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -27,7 +27,7 @@ "span-panel-api-schema-0==1.1.0", "span-panel-api-schema-1==1.1.0" ], - "version": "2.1.0b15", + "version": "2.1.0b16", "zeroconf": [ { "type": "_span._tcp.local." From 4413c96747950e732823d81a9210c5d067727bc6 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:07:16 -0700 Subject: [PATCH 147/345] feat(naming): build the base Core composes a circuit entity id from The integration is moving off presetting entity ids onto supplying one base per circuit entity and letting Home Assistant compose the rest from the user's entity_id_parts. The base reads an existing entity's suffix wording back from its own id, so Recreate proposes it its own id; a new entity gets the noun-last wording the panel level already uses. --- custom_components/span_panel/naming.py | 88 ++++++++++++++++++++++++++ tests/test_naming.py | 88 ++++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 custom_components/span_panel/naming.py create mode 100644 tests/test_naming.py diff --git a/custom_components/span_panel/naming.py b/custom_components/span_panel/naming.py new file mode 100644 index 00000000..e92d7e95 --- /dev/null +++ b/custom_components/span_panel/naming.py @@ -0,0 +1,88 @@ +"""Identity of circuit entities: the base Home Assistant composes an id from. + +Since Home Assistant 2026.8 the user decides how an entity id is composed +(`entity_id_parts`: area, device, entity). This integration no longer presets +an `entity_id`; it supplies one *base* per circuit entity -- `Circuit 15 power` +or `Kitchen Outlets power` -- and Core assembles the rest from the user's +settings. `SpanPanelEntity.suggested_object_id` hands the base to Core. + +Two rules bound what the base may be: + +- An existing entity must be proposed its own id when nothing changed. Its + suffix wording is therefore read back from the id it already has, because + this integration has shipped two spellings (`consumed_energy` before it + preset ids, `energy_consumed` after). Recreate must never offer a rename + the user did not cause. +- A new entity gets the noun-last wording, which is what the panel-level ids + (`main_meter_consumed_energy`) and the display labels use. + +The base is *not* the display name. The two are decoupled on purpose, so a +label can be reworded without touching a single id. + +`CIRCUIT_SUFFIX_MAPPING` in `id_builder` builds unique ids and is closed; the +tables here govern entity ids only. +""" + +from __future__ import annotations + +from homeassistant.util import slugify + +ENTITY_ID_SUFFIX_FORMS: dict[str, frozenset[str]] = { + "power": frozenset({"power", "current_power"}), + "energy_produced": frozenset({"produced_energy", "energy_produced"}), + "energy_consumed": frozenset({"consumed_energy", "energy_consumed"}), + "energy_net": frozenset({"net_energy", "energy_net"}), + "current": frozenset({"current"}), + "breaker_rating": frozenset({"breaker_rating"}), + "breaker": frozenset({"breaker"}), + "circuit_priority": frozenset({"circuit_priority"}), +} +"""Every entity-id suffix form ever shipped, keyed by the canonical suffix. + +Historical fact: an entry is added only when another form is discovered to +have shipped, never to introduce one. +""" + +NEW_ENTITY_ID_SUFFIX_WORDS: dict[str, str] = { + "power": "power", + "energy_produced": "produced energy", + "energy_consumed": "consumed energy", + "energy_net": "net energy", + "current": "current", + "breaker_rating": "breaker rating", + "breaker": "breaker", + "circuit_priority": "circuit priority", +} +"""The wording a new entity's base ends with -- noun-last, like the panel level.""" + + +def _existing_suffix_form(existing_entity_id: str | None, suffix: str) -> str | None: + """Return the suffix form an existing id carries, or None if it carries none we know.""" + if existing_entity_id is None: + return None + object_id = existing_entity_id.split(".", 1)[-1] + # Longest first so `current_power` is not read as `power`. + for form in sorted(ENTITY_ID_SUFFIX_FORMS.get(suffix, ()), key=len, reverse=True): + if object_id.endswith(f"_{form}"): + return form + return None + + +def circuit_object_id_base(identifier: str, suffix: str, existing_entity_id: str | None) -> str: + """Return the base for one circuit entity. + + `identifier` is the naming-flag half (`Circuit 15`, `Kitchen Outlets`, + `Unmapped Tab 32`); `suffix` is the canonical suffix from + `id_builder.get_user_friendly_suffix`. Words are omitted when the + identifier already ends with them, which is what the preset builder did + for a circuit named "Solar Power". + """ + form = _existing_suffix_form(existing_entity_id, suffix) + words = ( + form.replace("_", " ") + if form + else NEW_ENTITY_ID_SUFFIX_WORDS.get(suffix, suffix.replace("_", " ")) + ) + if slugify(identifier).endswith(slugify(words)): + return identifier + return f"{identifier} {words}" diff --git a/tests/test_naming.py b/tests/test_naming.py new file mode 100644 index 00000000..d18a9710 --- /dev/null +++ b/tests/test_naming.py @@ -0,0 +1,88 @@ +"""The base Home Assistant composes a circuit entity's id from.""" + +from __future__ import annotations + +import pytest + +from custom_components.span_panel.naming import ( + ENTITY_ID_SUFFIX_FORMS, + NEW_ENTITY_ID_SUFFIX_WORDS, + circuit_object_id_base, +) + + +@pytest.mark.parametrize( + ("identifier", "suffix", "expected"), + [ + ("Circuit 15", "power", "Circuit 15 power"), + ("Circuit 30 32", "energy_consumed", "Circuit 30 32 consumed energy"), + ("Kitchen Outlets", "energy_produced", "Kitchen Outlets produced energy"), + ("Kitchen Outlets", "energy_net", "Kitchen Outlets net energy"), + ("Kitchen Outlets", "current", "Kitchen Outlets current"), + ("Kitchen Outlets", "breaker_rating", "Kitchen Outlets breaker rating"), + ("Kitchen Outlets", "breaker", "Kitchen Outlets breaker"), + ("Kitchen Outlets", "circuit_priority", "Kitchen Outlets circuit priority"), + ("Unmapped Tab 32", "energy_consumed", "Unmapped Tab 32 consumed energy"), + ], +) +def test_a_new_entity_gets_the_noun_last_wording( + identifier: str, suffix: str, expected: str +) -> None: + assert circuit_object_id_base(identifier, suffix, None) == expected + + +@pytest.mark.parametrize( + ("existing", "suffix", "expected"), + [ + # Preset-era spelling is kept, so Recreate proposes the entity its own id. + ( + "sensor.span_panel_circuit_15_energy_consumed", + "energy_consumed", + "Circuit 15 energy consumed", + ), + ( + "sensor.span_panel_circuit_15_energy_produced", + "energy_produced", + "Circuit 15 energy produced", + ), + ("sensor.span_panel_circuit_15_energy_net", "energy_net", "Circuit 15 energy net"), + # Pre-preset spelling is kept too. + ( + "sensor.span_panel_circuit_15_consumed_energy", + "energy_consumed", + "Circuit 15 consumed energy", + ), + # The oldest power spelling. + ("sensor.span_panel_circuit_15_current_power", "power", "Circuit 15 current power"), + # A suffix with one form only. + ("switch.span_panel_circuit_15_breaker", "breaker", "Circuit 15 breaker"), + ], +) +def test_an_existing_entity_keeps_the_suffix_wording_it_has( + existing: str, suffix: str, expected: str +) -> None: + assert circuit_object_id_base("Circuit 15", suffix, existing) == expected + + +def test_an_existing_entity_whose_id_matches_no_known_form_gets_the_default() -> None: + assert ( + circuit_object_id_base("Circuit 15", "energy_consumed", "sensor.something_else") + == "Circuit 15 consumed energy" + ) + + +def test_the_words_are_omitted_when_the_identifier_already_ends_with_them() -> None: + """Preserves the preset builder's rule: a circuit named "Solar Power" got `..._solar_power`.""" + assert circuit_object_id_base("Solar Power", "power", None) == "Solar Power" + assert ( + circuit_object_id_base("Solar Power", "power", "sensor.span_panel_solar_power") + == "Solar Power" + ) + + +def test_every_form_table_entry_has_a_new_wording_and_vice_versa() -> None: + assert set(ENTITY_ID_SUFFIX_FORMS) == set(NEW_ENTITY_ID_SUFFIX_WORDS) + for suffix, words in NEW_ENTITY_ID_SUFFIX_WORDS.items(): + assert words.replace(" ", "_") in ENTITY_ID_SUFFIX_FORMS[suffix], ( + f"the default wording for {suffix} must itself be a known form" + ) From 297f9b491c4e31f060616b68c8d0d82b58428621 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:09:02 -0700 Subject: [PATCH 148/345] feat(naming): one release of the registry name, for every name we ever wrote Three platforms each rebuilt the expected string by hand against only the description's current label, so a relabel silently stopped the release for that platform. One helper takes every label the description has carried. --- custom_components/span_panel/naming.py | 27 ++++++++++ tests/test_naming.py | 68 ++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/custom_components/span_panel/naming.py b/custom_components/span_panel/naming.py index e92d7e95..9159cd86 100644 --- a/custom_components/span_panel/naming.py +++ b/custom_components/span_panel/naming.py @@ -25,6 +25,9 @@ from __future__ import annotations +from collections.abc import Iterable + +from homeassistant.helpers import entity_registry as er from homeassistant.util import slugify ENTITY_ID_SUFFIX_FORMS: dict[str, frozenset[str]] = { @@ -86,3 +89,27 @@ def circuit_object_id_base(identifier: str, suffix: str, existing_entity_id: str if slugify(identifier).endswith(slugify(words)): return identifier return f"{identifier} {words}" + + +def release_registry_name_written_by_older_release( + registry: er.EntityRegistry, + entity_id: str, + circuit_name: str, + description_names: Iterable[str], +) -> None: + """Hand the registry's `name` back to the user where 2.0.8 took it. + + Circuit-numbers mode used to deliver the panel's name by writing the + registry's `name` -- the *user's* field, which Home Assistant reads ahead + of everything else when generating an entity id, so occupying it made + "Recreate entity IDs" propose a friendly-name id for a circuit-numbered + entity. Only a name this integration would have written is cleared: + `"{circuit_name} {description name}"` for the description's current name + and every name it has carried before. Anything else is the user's. + """ + entry = registry.async_get(entity_id) + if entry is None or entry.name is None or not circuit_name: + return + ours = {f"{circuit_name} {description_name}" for description_name in description_names} + if entry.name in ours: + registry.async_update_entity(entity_id, name=None) diff --git a/tests/test_naming.py b/tests/test_naming.py index d18a9710..7a0c7d75 100644 --- a/tests/test_naming.py +++ b/tests/test_naming.py @@ -86,3 +86,71 @@ def test_every_form_table_entry_has_a_new_wording_and_vice_versa() -> None: assert words.replace(" ", "_") in ENTITY_ID_SUFFIX_FORMS[suffix], ( f"the default wording for {suffix} must itself be a known form" ) + + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.naming import release_registry_name_written_by_older_release + + +def _seed(hass: HomeAssistant, name: str | None) -> str: + registry = er.async_get(hass) + entry = registry.async_get_or_create("sensor", DOMAIN, "uid-release-test") + registry.async_update_entity(entry.entity_id, name=name) + return entry.entity_id + + +async def test_a_name_an_older_release_wrote_is_released(hass: HomeAssistant) -> None: + entity_id = _seed(hass, "Kitchen Outlets Consumed Energy") + registry = er.async_get(hass) + + release_registry_name_written_by_older_release( + registry, entity_id, "Kitchen Outlets", ("Consumed Energy",) + ) + + assert registry.async_get(entity_id).name is None + + +async def test_a_name_written_under_a_since_changed_label_is_released_too( + hass: HomeAssistant, +) -> None: + """The label was "Energy Consumed" for a few betas; a legacy-names tuple covers it.""" + entity_id = _seed(hass, "Kitchen Outlets Energy Consumed") + registry = er.async_get(hass) + + release_registry_name_written_by_older_release( + registry, entity_id, "Kitchen Outlets", ("Consumed Energy", "Energy Consumed") + ) + + assert registry.async_get(entity_id).name is None + + +async def test_a_name_the_user_set_is_left_alone(hass: HomeAssistant) -> None: + entity_id = _seed(hass, "Beer Fridge") + registry = er.async_get(hass) + + release_registry_name_written_by_older_release( + registry, entity_id, "Kitchen Outlets", ("Consumed Energy",) + ) + + assert registry.async_get(entity_id).name == "Beer Fridge" + + +async def test_no_name_is_a_no_op(hass: HomeAssistant) -> None: + entity_id = _seed(hass, None) + registry = er.async_get(hass) + + release_registry_name_written_by_older_release( + registry, entity_id, "Kitchen Outlets", ("Consumed Energy",) + ) + + assert registry.async_get(entity_id).name is None + + +async def test_an_unknown_entity_is_a_no_op(hass: HomeAssistant) -> None: + registry = er.async_get(hass) + release_registry_name_written_by_older_release( + registry, "sensor.does_not_exist", "Kitchen Outlets", ("Consumed Energy",) + ) From ebafddfb4e4dce1d973276b8b605fb29a0ce0d4b Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:10:28 -0700 Subject: [PATCH 149/345] feat(entity): let a circuit entity hand Core its object-id base Core reads suggested_object_id into object_id_base and composes the rest from the user's entity_id_parts. Entities that set no base are unchanged. --- custom_components/span_panel/entity.py | 16 ++++++++++++++++ tests/test_naming.py | 25 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/custom_components/span_panel/entity.py b/custom_components/span_panel/entity.py index eac0b154..8e74a41e 100644 --- a/custom_components/span_panel/entity.py +++ b/custom_components/span_panel/entity.py @@ -22,6 +22,22 @@ class SpanPanelEntity(CoordinatorEntity[SpanPanelCoordinator]): _attr_has_entity_name = True + _span_object_id_base: str | None = None + """The base Home Assistant composes this entity's id from, when set. + + Circuit entities set it from `naming.circuit_object_id_base`; every other + entity leaves it None and Core composes from the display name as it + always has. Core reads `suggested_object_id` only for an entity that has + not preset `entity_id`, which after this release is every entity here. + """ + + @property + def suggested_object_id(self) -> str | None: + """Hand Core the base, or the stock answer when there is none.""" + if self._span_object_id_base is not None: + return self._span_object_id_base + return super().suggested_object_id + _residual_field_paths: ClassVar[tuple[str, ...]] = () """Snapshot fields this entity reads from entity code, not from a description. diff --git a/tests/test_naming.py b/tests/test_naming.py index 7a0c7d75..4dc2863c 100644 --- a/tests/test_naming.py +++ b/tests/test_naming.py @@ -154,3 +154,28 @@ async def test_an_unknown_entity_is_a_no_op(hass: HomeAssistant) -> None: release_registry_name_written_by_older_release( registry, "sensor.does_not_exist", "Kitchen Outlets", ("Consumed Energy",) ) + + +from unittest.mock import MagicMock + +from custom_components.span_panel.entity import SpanPanelEntity + + +class _Probe(SpanPanelEntity): + _attr_name = "Display Name" + + def __init__(self) -> None: + coordinator = MagicMock() + super().__init__(coordinator) + + +def test_the_base_is_what_core_is_told_when_one_is_set() -> None: + probe = _Probe() + probe._span_object_id_base = "Circuit 15 power" + assert probe.suggested_object_id == "Circuit 15 power" + + +def test_without_a_base_core_gets_the_stock_answer() -> None: + """Panel, BESS, EVSE and MID entities keep composing from their name.""" + probe = _Probe() + assert probe.suggested_object_id == "Display Name" From be54a12260cdf0a2b0aac5500d75f582d053f437 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:33:35 -0700 Subject: [PATCH 150/345] feat(sensor): supply a base and let Core compose the circuit sensor id The integration no longer presets a circuit sensor's entity_id. It hands Core one flag-correct base and Core assembles the id from the user's entity_id_parts, exactly as every non-circuit entity already does. An existing entity keeps the suffix wording its id already has, so Recreate proposes it its own id; a new one gets the noun-last wording the panel level uses. The energy labels return to that wording too, and a name 2.0.8 wrote under either label is handed back. --- custom_components/span_panel/naming.py | 10 +- custom_components/span_panel/sensor_base.py | 125 +++++++----- .../span_panel/sensor_circuit.py | 128 +++++++----- .../span_panel/sensor_definitions.py | 20 +- tests/test_recreate_entity_ids.py | 190 ++++++++++++++++-- tests/test_sensor_entities.py | 22 +- 6 files changed, 355 insertions(+), 140 deletions(-) diff --git a/custom_components/span_panel/naming.py b/custom_components/span_panel/naming.py index 9159cd86..37fa270a 100644 --- a/custom_components/span_panel/naming.py +++ b/custom_components/span_panel/naming.py @@ -75,10 +75,12 @@ def circuit_object_id_base(identifier: str, suffix: str, existing_entity_id: str """Return the base for one circuit entity. `identifier` is the naming-flag half (`Circuit 15`, `Kitchen Outlets`, - `Unmapped Tab 32`); `suffix` is the canonical suffix from - `id_builder.get_user_friendly_suffix`. Words are omitted when the - identifier already ends with them, which is what the preset builder did - for a circuit named "Solar Power". + `Unmapped Tab 32`); `suffix` is this entity's canonical suffix, which the + sensors derive from their description key via + `id_builder.get_user_friendly_suffix` while the switch and the select -- + which have no such key -- name theirs outright ("breaker", + "circuit_priority"). Words are omitted when the identifier already ends with + them, which is what the preset builder did for a circuit named "Solar Power". """ form = _existing_suffix_form(existing_entity_id, suffix) words = ( diff --git a/custom_components/span_panel/sensor_base.py b/custom_components/span_panel/sensor_base.py index a4f57680..6ec15983 100644 --- a/custom_components/span_panel/sensor_base.py +++ b/custom_components/span_panel/sensor_base.py @@ -39,6 +39,11 @@ handle_offline_grace_period, initialize_from_last_state, ) +from .id_builder import get_user_friendly_suffix +from .naming import ( + circuit_object_id_base, + release_registry_name_written_by_older_release, +) from .options import ENERGY_REPORTING_GRACE_PERIOD _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -64,6 +69,20 @@ ) +def _description_label(description: SensorEntityDescription) -> str: + """Return the description's own label, or a neutral word where it declares none. + + A description with a `translation_key` declares no `name` -- it declares + `None` or leaves the field `UNDEFINED` -- and a sensor that reaches here with + neither is a programming error rather than a user-visible state, so "Sensor" + keeps it readable instead of rendering a sentinel. + """ + name = description.name + if isinstance(name, str) and name: + return name + return "Sensor" + + class SpanSensorBase[T: SensorEntityDescription, D](SpanPanelEntity, SensorEntity, ABC): """Abstract base class for Span Panel sensors with overridable methods.""" @@ -105,27 +124,28 @@ def __init__( "sensor", DOMAIN, self._attr_unique_id ) - if existing_entity_id: - # Phase 2: the panel's name, in both modes. It reaches the UI - # as `original_name`, which ranks below `suggested_object_id` - # and so cannot decide what "Recreate entity IDs" proposes. - self._attr_name = self._generate_panel_name(snapshot, description) - else: - # Phase 1: the flag-based name the mode calls for. - self._attr_name = self._generate_friendly_name(snapshot, description) + # One name path, in both naming modes: the panel's name, carried + # as `original_name`. That field ranks below the object-id base + # below, so what is displayed can no longer decide what + # "Recreate entity IDs" proposes. + self._attr_name = self._generate_panel_name(snapshot, description) + + # The id itself is Home Assistant's to compose. This entity + # supplies only its base; `entity_id` is left unset so Core + # assembles the rest from the user's `entity_id_parts`. + identifier = self._object_id_identifier(snapshot, description) + if identifier is not None: + self._span_object_id_base = circuit_object_id_base( + identifier, self._object_id_suffix(description), existing_entity_id + ) if existing_entity_id: self._release_synced_registry_name( snapshot, description, entity_registry, existing_entity_id ) - - # Wire explicit entity_id via subclass helper - entity_id = self._construct_entity_id(snapshot, description, existing_entity_id) - if entity_id: - self.entity_id = entity_id else: # Fallback for entities without unique_id - self._attr_name = self._generate_friendly_name(snapshot, description) + self._attr_name = self._generate_panel_name(snapshot, description) # Set entity registry defaults if they exist in the description if hasattr(description, "entity_registry_enabled_default"): @@ -184,11 +204,36 @@ def _generate_friendly_name(self, snapshot: SpanPanelSnapshot, description: T) - """ + def _object_id_identifier(self, snapshot: SpanPanelSnapshot, description: T) -> str | None: + """Return the naming-flag half of this entity's object-id base. + + `None` -- the default, and the answer for every entity that is not a + circuit -- leaves `_span_object_id_base` unset, so Home Assistant + composes the id from the display name exactly as it always has. + + A circuit entity answers with `Circuit 15`, `Kitchen Outlets` or + `Unmapped Tab 32`, which is the one half of the id the naming flags + decide. `naming.circuit_object_id_base` supplies the other half. + """ + return None + + def _object_id_suffix(self, description: T) -> str: + """Return the canonical suffix `naming.circuit_object_id_base` keys on. + + The description key is the right question for a description whose key + survives construction. Circuit entities overwrite theirs with the + circuit id and so answer from the key they were built from. + """ + return get_user_friendly_suffix(description.key) + def _generate_panel_name(self, snapshot: SpanPanelSnapshot, description: T) -> str | None: - """Generate panel name for the sensor (always uses panel circuit name). + """Generate the displayed name for the sensor, in either naming mode. - This method is used for name sync - it always uses the panel circuit name - regardless of user preferences. + Circuit entities override this to prefix the panel's own circuit name, + which is what makes a circuit renamed in the SPAN app show through. Every + other sensor's name is its description's label -- and a description that + declares a `translation_key` instead is normally not asked at all, since + its name comes from `translations/en.json`. Args: snapshot: The panel snapshot data @@ -198,9 +243,7 @@ def _generate_panel_name(self, snapshot: SpanPanelSnapshot, description: T) -> s Panel name string """ - # This should be implemented by subclasses that need name sync - # For now, fall back to friendly name - return self._generate_friendly_name(snapshot, description) + return _description_label(description) def _release_synced_registry_name( self, @@ -221,39 +264,23 @@ def _release_synced_registry_name( go of what the old scheme wrote. Only a name this integration would have written is cleared; anything else is the user's and is left exactly where it is, which is the same test the write used to gate on. + + Every label the description has carried counts, not just its current one: + a label reworded between releases left installs holding the older string, + and a write we no longer recognise is a write we would never hand back. """ - entity_entry = entity_registry.async_get(existing_entity_id) - if not entity_entry or entity_entry.name is None: - return circuit = snapshot.circuits.get(getattr(self, "circuit_id", "")) if not (circuit and circuit.name): return - description_suffix = str(getattr(description, "name", None) or "Sensor") - if entity_entry.name == f"{circuit.name} {description_suffix}": - entity_registry.async_update_entity(existing_entity_id, name=None) - - def _construct_entity_id( - self, - snapshot: SpanPanelSnapshot, - description: T, - existing_entity_id: str | None = None, - ) -> str | None: - """Construct explicit entity_id for the sensor. - - Subclasses may override to use entity_id helpers from helpers.py. - Returns None to let HA auto-generate from _attr_name. - - The value is what current panel data and the naming flags produce; an - existing id is not consulted to decide *whether* to compute one, only so - that an id predating the suffix mapping keeps the suffix it shipped with. - - Args: - snapshot: The panel snapshot data - description: The sensor description - existing_entity_id: This entity's id in the registry, or None if new - - """ - return None + # Declared by the circuit descriptions; a description type whose label + # has never been reworded carries none. + legacy_names: tuple[str, ...] = getattr(description, "legacy_names", ()) + release_registry_name_written_by_older_release( + entity_registry, + existing_entity_id, + circuit.name, + (_description_label(description), *legacy_names), + ) def _sync_circuit_name(self) -> None: """Follow a circuit renamed on the panel, by reloading so the name is rebuilt. diff --git a/custom_components/span_panel/sensor_circuit.py b/custom_components/span_panel/sensor_circuit.py index 33da8579..82a0c205 100644 --- a/custom_components/span_panel/sensor_circuit.py +++ b/custom_components/span_panel/sensor_circuit.py @@ -14,7 +14,6 @@ from .helpers import ( construct_circuit_identifier_from_tabs, construct_circuit_unique_id_for_entry, - construct_single_circuit_entity_id, construct_tabs_attribute, construct_unmapped_friendly_name, construct_voltage_attribute, @@ -60,11 +59,16 @@ def _resolve_circuit_identifier( circuit: SpanCircuitSnapshot, circuit_id: str, options: Mapping[str, Any], -) -> str | None: +) -> str: """Resolve the circuit identifier respecting user naming preference. - Returns None when the circuit has no name and user is in friendly-name mode, - matching v1 behavior where HA handles default naming. + Always answers, because this is the half of the entity id the naming flags + decide and there is nothing sensible for Home Assistant to fall back to. An + unnamed circuit in friendly-name mode used to answer `None` and let Core + compose from the description label alone, which gives every unnamed circuit + on the panel the same `sensor._power` and leaves the registry to tell + them apart with `_2`, `_3`, ... in whatever order they were added. The tab + fallback names them after the breaker position they occupy instead. """ use_circuit_numbers = options.get(USE_CIRCUIT_NUMBERS, False) @@ -75,7 +79,7 @@ def _resolve_circuit_identifier( if name: return name - return None + return _unnamed_circuit_fallback(circuit, circuit_id) def _resolve_circuit_identifier_for_sync(circuit: SpanCircuitSnapshot, circuit_id: str) -> str: @@ -129,6 +133,7 @@ def __init__( entity_registry_enabled_default=description.entity_registry_enabled_default, entity_registry_visible_default=description.entity_registry_visible_default, entity_category=description.entity_category, + legacy_names=description.legacy_names, ) super().__init__(data_coordinator, description_with_circuit, snapshot) @@ -158,11 +163,9 @@ def _generate_friendly_name( self, snapshot: SpanPanelSnapshot, description: SpanPanelCircuitsSensorEntityDescription, - ) -> str | None: + ) -> str: """Generate friendly name for circuit power sensors based on user preferences. - Returns None when circuit has no name in friendly-name mode, - matching v1 behavior where HA handles default naming. For sub-device sensors (EVSE), returns just the description name since the device name already provides circuit context. """ @@ -178,8 +181,6 @@ def _generate_friendly_name( circuit_identifier = _resolve_circuit_identifier( circuit, self.circuit_id, self.coordinator.config_entry.options ) - if circuit_identifier is None: - return None return f"{circuit_identifier} {description.name or 'Sensor'}" def _generate_panel_name( @@ -200,28 +201,32 @@ def _generate_panel_name( circuit_identifier = _resolve_circuit_identifier_for_sync(circuit, self.circuit_id) return f"{circuit_identifier} {description.name or 'Sensor'}" - def _construct_entity_id( + def _object_id_identifier( self, snapshot: SpanPanelSnapshot, description: SpanPanelCircuitsSensorEntityDescription, - existing_entity_id: str | None = None, ) -> str | None: - """Construct explicit entity_id for circuit power sensors.""" + """Return the naming-flag half of this sensor's object-id base.""" circuit = snapshot.circuits.get(self.circuit_id) if not circuit: return None - suffix = get_user_friendly_suffix( - self._API_KEY_MAP.get(self.original_key, self.original_key) - ) - return construct_single_circuit_entity_id( - self.coordinator, - snapshot, - "sensor", - suffix, - circuit, - existing_entity_id=existing_entity_id, + if self._is_sub_device: + # On a sub-device (an EVSE's feed circuit) Core supplies that device + # as the DEVICE part of the id, so the base is the reading alone -- + # the same shape the charger's own sensors have. + return None + return _resolve_circuit_identifier( + circuit, self.circuit_id, self.coordinator.config_entry.options ) + def _object_id_suffix(self, description: SpanPanelCircuitsSensorEntityDescription) -> str: + """Return the canonical suffix, from the key this sensor was built from. + + `description.key` was overwritten with the circuit id during + construction, so the base class's answer would not be a suffix at all. + """ + return get_user_friendly_suffix(self._API_KEY_MAP.get(self.original_key, self.original_key)) + def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanCircuitSnapshot: """Get the data source for the circuit power sensor.""" return _get_circuit_data_source(self.circuit_id, snapshot) @@ -312,6 +317,7 @@ def __init__( derived=description.derived, entity_registry_enabled_default=description.entity_registry_enabled_default, entity_registry_visible_default=description.entity_registry_visible_default, + legacy_names=description.legacy_names, ) super().__init__(data_coordinator, description_with_circuit, snapshot) @@ -332,13 +338,7 @@ def _generate_unique_id( description: SpanPanelCircuitsSensorEntityDescription, ) -> str: """Generate unique ID for circuit energy sensors.""" - # Map new description keys to original API keys that migration normalized from - api_key_mapping = { - "circuit_energy_produced": "producedEnergyWh", - "circuit_energy_consumed": "consumedEnergyWh", - "circuit_energy_net": "netEnergyWh", - } - api_key = api_key_mapping.get(self.original_key, self.original_key) + api_key = self._API_KEY_MAP.get(self.original_key, self.original_key) return construct_circuit_unique_id_for_entry( self.coordinator, snapshot, self.circuit_id, api_key, self._device_name ) @@ -347,11 +347,9 @@ def _generate_friendly_name( self, snapshot: SpanPanelSnapshot, description: SpanPanelCircuitsSensorEntityDescription, - ) -> str | None: + ) -> str: """Generate friendly name for circuit energy sensors based on user preferences. - Returns None when circuit has no name in friendly-name mode, - matching v1 behavior where HA handles default naming. For sub-device sensors (EVSE), returns just the description name since the device name already provides circuit context. """ @@ -365,8 +363,6 @@ def _generate_friendly_name( circuit_identifier = _resolve_circuit_identifier( circuit, self.circuit_id, self.coordinator.config_entry.options ) - if circuit_identifier is None: - return None return f"{circuit_identifier} {description.name}" def _generate_panel_name( @@ -385,32 +381,35 @@ def _generate_panel_name( circuit_identifier = _resolve_circuit_identifier_for_sync(circuit, self.circuit_id) return f"{circuit_identifier} {description.name}" - def _construct_entity_id( + def _object_id_identifier( self, snapshot: SpanPanelSnapshot, description: SpanPanelCircuitsSensorEntityDescription, - existing_entity_id: str | None = None, ) -> str | None: - """Construct explicit entity_id for circuit energy sensors.""" + """Return the naming-flag half of this sensor's object-id base.""" circuit = snapshot.circuits.get(self.circuit_id) if not circuit: return None - api_key_mapping = { - "circuit_energy_produced": "producedEnergyWh", - "circuit_energy_consumed": "consumedEnergyWh", - "circuit_energy_net": "netEnergyWh", - } - api_key = api_key_mapping.get(self.original_key, self.original_key) - suffix = get_user_friendly_suffix(api_key) - return construct_single_circuit_entity_id( - self.coordinator, - snapshot, - "sensor", - suffix, - circuit, - existing_entity_id=existing_entity_id, + if self._is_sub_device: + # See `SpanCircuitPowerSensor._object_id_identifier`: the charger is + # the DEVICE part, so the base is the reading alone. + return None + return _resolve_circuit_identifier( + circuit, self.circuit_id, self.coordinator.config_entry.options ) + def _object_id_suffix(self, description: SpanPanelCircuitsSensorEntityDescription) -> str: + """Return the canonical suffix, from the key this sensor was built from.""" + return get_user_friendly_suffix(self._API_KEY_MAP.get(self.original_key, self.original_key)) + + # Map new description keys to the original API keys that migration + # normalized from; both the unique id and the entity-id suffix key on them. + _API_KEY_MAP: dict[str, str] = { + "circuit_energy_produced": "producedEnergyWh", + "circuit_energy_consumed": "consumedEnergyWh", + "circuit_energy_net": "netEnergyWh", + } + # Map original_key to the energy type used for coordinator dip offset tracking _ENERGY_TYPE_MAP: dict[str, str] = { "circuit_energy_consumed": "consumed", @@ -490,6 +489,7 @@ def __init__( derived=description.derived, entity_registry_enabled_default=True, entity_registry_visible_default=False, + legacy_names=description.legacy_names, ) super().__init__(data_coordinator, description_with_circuit, snapshot) @@ -514,10 +514,34 @@ def _generate_friendly_name( description: SpanPanelCircuitsSensorEntityDescription, ) -> str: """Generate friendly name for unmapped circuit sensors.""" + return self._unmapped_name(description) + + def _generate_panel_name( + self, + snapshot: SpanPanelSnapshot, + description: SpanPanelCircuitsSensorEntityDescription, + ) -> str: + """Name an unmapped tab, which has no circuit name for a mode to differ over.""" + return self._unmapped_name(description) + + def _unmapped_name(self, description: SpanPanelCircuitsSensorEntityDescription) -> str: + """Return "Unmapped Tab 32 Consumed Energy" and the like.""" tab_number = self.circuit_id.replace("unmapped_tab_", "") description_name = str(description.name) if description.name else "Sensor" return construct_unmapped_friendly_name(tab_number, description_name) + def _object_id_identifier( + self, + snapshot: SpanPanelSnapshot, + description: SpanPanelCircuitsSensorEntityDescription, + ) -> str | None: + """Return the tab this sensor backs; unmapped tabs carry no naming flag.""" + return f"Unmapped Tab {self.circuit_id.replace('unmapped_tab_', '')}" + + def _object_id_suffix(self, description: SpanPanelCircuitsSensorEntityDescription) -> str: + """Return the canonical suffix, from the key this sensor was built from.""" + return get_user_friendly_suffix(self.original_key) + def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanCircuitSnapshot: """Get the data source for the unmapped circuit sensor.""" return _get_circuit_data_source(self.circuit_id, snapshot) diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py index e27b87a2..0a0f0158 100644 --- a/custom_components/span_panel/sensor_definitions.py +++ b/custom_components/span_panel/sensor_definitions.py @@ -63,6 +63,11 @@ class SpanPanelCircuitsSensorEntityDescription( ): """Describes a Span Panel circuit sensor entity.""" + legacy_names: tuple[str, ...] = () + """Labels this description carried before, which an older release may have + written into the registry's `name`. Consulted only to hand that field back -- + never to name anything, so a label can be reworded without a migration.""" + @dataclass(frozen=True) class SpanPanelDataRequiredKeysMixin(FieldPathDeclarationMixin): @@ -205,7 +210,8 @@ class SpanPanelBatterySensorEntityDescription( SpanPanelCircuitsSensorEntityDescription( key="producedEnergyWh", field_path="circuit.produced_energy_wh", - name="Energy Produced", + name="Produced Energy", + legacy_names=("Energy Produced",), native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, suggested_display_precision=2, @@ -217,7 +223,8 @@ class SpanPanelBatterySensorEntityDescription( SpanPanelCircuitsSensorEntityDescription( key="consumedEnergyWh", field_path="circuit.consumed_energy_wh", - name="Energy Consumed", + name="Consumed Energy", + legacy_names=("Energy Consumed",), native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, suggested_display_precision=2, @@ -1193,7 +1200,8 @@ class SpanPVMetadataSensorEntityDescription( SpanPanelCircuitsSensorEntityDescription( key="circuit_energy_produced", field_path="circuit.produced_energy_wh", - name="Energy Produced", + name="Produced Energy", + legacy_names=("Energy Produced",), native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, suggested_display_precision=2, @@ -1205,7 +1213,8 @@ class SpanPVMetadataSensorEntityDescription( SpanPanelCircuitsSensorEntityDescription( key="circuit_energy_consumed", field_path="circuit.consumed_energy_wh", - name="Energy Consumed", + name="Consumed Energy", + legacy_names=("Energy Consumed",), native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, suggested_display_precision=2, @@ -1217,7 +1226,8 @@ class SpanPVMetadataSensorEntityDescription( SpanPanelCircuitsSensorEntityDescription( key="circuit_energy_net", derived=DerivedReason.MULTIPLE_FIELDS, - name="Energy Net", + name="Net Energy", + legacy_names=("Energy Net",), native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL, suggested_display_precision=2, diff --git a/tests/test_recreate_entity_ids.py b/tests/test_recreate_entity_ids.py index c5ea9fd9..36f2af00 100644 --- a/tests/test_recreate_entity_ids.py +++ b/tests/test_recreate_entity_ids.py @@ -25,7 +25,8 @@ import pytest from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import area_registry as ar, device_registry as dr, entity_registry as er +from homeassistant.helpers.entity_registry import EntityNamePart from pytest_homeassistant_custom_component.common import ( MockConfigEntry, MockEntityPlatform, @@ -140,6 +141,19 @@ def entry(hass: HomeAssistant) -> MockConfigEntry: return config_entry +@pytest.fixture +def device_and_entity_parts(hass: HomeAssistant) -> None: + """Compose ids from the device and the entity, as a user may globally choose to. + + `entity_id_parts` is a registry-wide setting whose default also includes the + area. Cases about the suffix half of an id pin it here so the assertion is + about the suffix and not about whether an area happens to exist. + """ + er.async_get(hass).async_update_settings( + entity_id_parts=[EntityNamePart.DEVICE, EntityNamePart.ENTITY] + ) + + async def test_the_first_install_takes_its_entity_id_from_the_circuit_name( hass: HomeAssistant, entry: MockConfigEntry ) -> None: @@ -194,10 +208,12 @@ async def test_renaming_a_circuit_does_not_move_the_unique_id( async def test_renaming_a_circuit_refreshes_the_registrys_entity_id_suggestion( hass: HomeAssistant, entry: MockConfigEntry ) -> None: - """The stored suggestion has to track the panel, not the install date. + """The stored base has to track the panel, not the install date. - This is the field `async_regenerate_entity_id` reads when there is no user - `name` override, which in friendly-names mode is always. + This is what `async_regenerate_entity_id` composes from when there is no + user `name` override, which in friendly-names mode is always. It is the + *base*, not a whole object id: Core prefixes the device and, where the user + has assigned one, the area. """ install = _Install(hass, entry) await install.load(ORIGINAL_NAME) @@ -206,7 +222,8 @@ async def test_renaming_a_circuit_refreshes_the_registrys_entity_id_suggestion( registry = er.async_get(hass) registry_entry = registry.async_get(ORIGINAL_ENTITY_ID) assert registry_entry is not None - assert registry_entry.suggested_object_id == "span_panel_beer_fridge_power" + assert registry_entry.object_id_base == "Beer Fridge power" + assert registry_entry.suggested_object_id is None async def test_recreate_entity_ids_proposes_the_renamed_id( @@ -380,19 +397,22 @@ async def test_circuit_numbers_mode_does_not_move_ids_across_the_change( assert registry.async_get(RENAMED_ENTITY_ID) is None -# --- Entities that predate the suffix mapping reaching entity ids ------------- +# --- The two spellings a circuit energy id has shipped with ------------------- # -# Every case above builds its entities with the current code, so the preset and -# the live id are the same string by construction and a suffix disagreement -# cannot appear. A real install upgrading is the case that matters: those -# entities took their id from the descriptor name ("Consumed Energy" -> -# `..._consumed_energy`) where the mapping says `energy_consumed`, so their -# entity id and their own unique id have always disagreed. Recomputing the -# suggestion surfaces that, and on a measured panel it offered 74 renames. +# Every case above builds its entities with the current code, so the base and +# the live id agree by construction and a suffix disagreement cannot appear. A +# real install upgrading is the case that matters, and there are two eras of +# them. Before the integration preset an entity_id, Home Assistant composed one +# from the descriptor name ("Consumed Energy" -> `..._consumed_energy`); the +# preset era spelled the same sensor `..._energy_consumed`, after the unique-id +# suffix mapping. Both are live on real panels, so the base an entity is given +# has to read its own spelling back out of the id it already carries -- +# otherwise Recreate offers a rename for every circuit at once, which on a +# measured panel was 74 of them. LEGACY_ENTITY_ID = "sensor.span_panel_refrigerator_consumed_energy" -CANONICAL_ENTITY_ID = "sensor.span_panel_refrigerator_energy_consumed" -RENAMED_LEGACY_ENTITY_ID = "sensor.span_panel_beer_fridge_energy_consumed" +PRESET_ERA_ENTITY_ID = "sensor.span_panel_refrigerator_energy_consumed" +RENAMED_LEGACY_ENTITY_ID = "sensor.span_panel_beer_fridge_consumed_energy" ENERGY_DESCRIPTION = next( desc for desc in CIRCUIT_SENSORS if desc.key == "circuit_energy_consumed" @@ -463,9 +483,10 @@ async def test_a_legacy_entity_still_follows_a_circuit_rename( ) -> None: """Preserving the suffix must not cost the fix. - A renamed circuit differs in the name half, so the proposal is the computed - id -- carrying the canonical suffix, because there is no older spelling of - `beer_fridge` to preserve. + Only the half the user changed moves. The circuit's new name reaches the + proposal; the suffix keeps the spelling this entity has always carried, + because renormalising it is a rename the user did not ask for and would ride + along with the one they did. """ install = _LegacyInstall(hass, entry) install._seed() @@ -481,13 +502,138 @@ async def test_a_legacy_entity_still_follows_a_circuit_rename( assert registry.async_regenerate_entity_id(registry_entry) == RENAMED_LEGACY_ENTITY_ID -async def test_a_new_install_gets_the_canonical_suffix( - hass: HomeAssistant, entry: MockConfigEntry +async def test_a_new_energy_sensor_gets_the_noun_last_suffix( + hass: HomeAssistant, entry: MockConfigEntry, device_and_entity_parts: None ) -> None: - """Preservation is for ids that already exist; nothing new inherits the old form.""" + """A new id uses the wording the panel level and the labels use. + + Nothing is seeded, so there is no spelling to read back and the default + applies. It is the pre-preset wording, which is also what + `main_meter_consumed_energy` has always said. + """ sensor = await _LegacyInstall(hass, entry).load(ORIGINAL_NAME) - assert sensor.entity_id == CANONICAL_ENTITY_ID + assert sensor.entity_id == "sensor.span_panel_refrigerator_consumed_energy" + + +async def test_a_preset_era_energy_sensor_is_offered_its_own_id( + hass: HomeAssistant, entry: MockConfigEntry, device_and_entity_parts: None +) -> None: + """An id spelled `..._energy_consumed` keeps that spelling in the proposal.""" + registry = er.async_get(hass) + unique_id = build_circuit_unique_id(SERIAL, CIRCUIT_ID, "consumedEnergyWh") + seeded = registry.async_get_or_create( + "sensor", + DOMAIN, + unique_id, + suggested_object_id="span_panel_refrigerator_energy_consumed", + config_entry=entry, + ) + assert seeded.entity_id == PRESET_ERA_ENTITY_ID + + install = _LegacyInstall(hass, entry) + await install.load(ORIGINAL_NAME) + await install.load(ORIGINAL_NAME) + + registry_entry = registry.async_get(seeded.entity_id) + assert registry_entry is not None + assert registry.async_regenerate_entity_id(registry_entry) == seeded.entity_id + + +async def test_a_pre_preset_energy_sensor_is_offered_its_own_id( + hass: HomeAssistant, entry: MockConfigEntry, device_and_entity_parts: None +) -> None: + """The other era: `..._consumed_energy` keeps its spelling too.""" + install = _LegacyInstall(hass, entry) + install._seed() + + sensor = await install.load(ORIGINAL_NAME) + await install.load(ORIGINAL_NAME) + + registry = er.async_get(hass) + registry_entry = registry.async_get(sensor.entity_id) + assert registry_entry is not None + assert sensor.entity_id == LEGACY_ENTITY_ID + assert registry.async_regenerate_entity_id(registry_entry) == sensor.entity_id + + +async def test_circuit_numbers_mode_releases_the_energy_name_an_older_release_wrote( + hass: HomeAssistant, entry: MockConfigEntry, device_and_entity_parts: None +) -> None: + """2.0.8 wrote " Consumed Energy" into the registry, and it must be released.""" + hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS)) + install = _LegacyInstall(hass, entry) + sensor = await install.load(ORIGINAL_NAME) + registry = er.async_get(hass) + registry.async_update_entity(sensor.entity_id, name=f"{ORIGINAL_NAME} Consumed Energy") + + await install.load(ORIGINAL_NAME) + + registry_entry = registry.async_get(sensor.entity_id) + assert registry_entry is not None + assert registry_entry.name is None + assert registry.async_regenerate_entity_id(registry_entry) == sensor.entity_id + + +async def test_a_name_written_under_the_beta_label_is_released_as_well( + hass: HomeAssistant, entry: MockConfigEntry, device_and_entity_parts: None +) -> None: + """The label read "Energy Consumed" for a few betas; that write is ours too.""" + hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS)) + install = _LegacyInstall(hass, entry) + sensor = await install.load(ORIGINAL_NAME) + registry = er.async_get(hass) + registry.async_update_entity(sensor.entity_id, name=f"{ORIGINAL_NAME} Energy Consumed") + + await install.load(ORIGINAL_NAME) + + registry_entry = registry.async_get(sensor.entity_id) + assert registry_entry is not None + assert registry_entry.name is None + + +async def test_an_area_the_user_assigned_reaches_the_proposal_under_default_parts( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """R2: the area is the user's global choice, and the integration does not exempt itself. + + A preset id could not carry one. Handing Core a base instead means the area + part of `entity_id_parts` applies here exactly as it does to every other + integration -- and only where the user has actually assigned an area. + """ + hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS)) + install = _Install(hass, entry) + sensor = await install.load(ORIGINAL_NAME) + assert sensor.entity_id == CIRCUIT_NUMBERS_ENTITY_ID # no area yet: identical to today + + area = ar.async_get(hass).async_get_or_create("Basement") + device_registry = dr.async_get(hass) + device = device_registry.async_get_device(identifiers={(DOMAIN, SERIAL)}) + assert device is not None + device_registry.async_update_device(device.id, area_id=area.id) + + registry = er.async_get(hass) + registry_entry = registry.async_get(CIRCUIT_NUMBERS_ENTITY_ID) + assert registry_entry is not None + assert registry_entry.entity_id == CIRCUIT_NUMBERS_ENTITY_ID # R5: nothing moved + assert ( + registry.async_regenerate_entity_id(registry_entry) + == "sensor.basement_span_panel_circuit_15_power" + ) + + +async def test_an_unnamed_circuit_in_friendly_mode_still_gets_a_distinct_id( + hass: HomeAssistant, entry: MockConfigEntry, device_and_entity_parts: None +) -> None: + """An unnamed circuit has no name half to compose from, so it falls back to its tabs. + + Letting Core compose from the display name alone would give every unnamed + circuit on the panel `sensor.span_panel_power`, and the registry would + disambiguate them with `_2`, `_3`, ... in whatever order they were added. + """ + sensor = await _Install(hass, entry).load("") + + assert sensor.entity_id == "sensor.span_panel_circuit_15_power" def test_a_suffix_with_no_older_spelling_is_left_alone() -> None: diff --git a/tests/test_sensor_entities.py b/tests/test_sensor_entities.py index 4c114415..c4882f1b 100644 --- a/tests/test_sensor_entities.py +++ b/tests/test_sensor_entities.py @@ -332,15 +332,20 @@ def test_circuit_power_sensor_extra_attributes_include_circuit_metadata() -> Non } -def test_circuit_power_sensor_returns_none_name_for_unnamed_friendly_mode() -> None: - """Unnamed circuits in friendly-name mode should let HA provide the default name.""" +def test_circuit_power_sensor_names_an_unnamed_circuit_after_its_tab() -> None: + """An unnamed circuit in friendly-name mode falls back to its breaker position. + + Answering `None` here let Home Assistant name every unnamed circuit on the + panel alike, and the id followed: they collided and were disambiguated with + `_2`, `_3`, ... in whatever order they happened to be added. + """ circuit = SpanCircuitSnapshotFactory.create(circuit_id="c1", name=None, tabs=[7]) snapshot = SpanPanelSnapshotFactory.create(circuits={"c1": circuit}) coordinator = _make_coordinator(snapshot, options={"use_circuit_numbers": False}) sensor = SpanCircuitPowerSensor(coordinator, CIRCUIT_BREAKER_RATING_SENSOR, snapshot, "c1") - assert sensor.name is None + assert sensor.name == "Circuit 7 Breaker Rating" def test_unnamed_circuit_fallback_uses_solar_and_evse_labels() -> None: @@ -460,11 +465,11 @@ def test_circuit_energy_sensor_missing_circuit_uses_fallback_names() -> None: assert ( sensor._generate_friendly_name(snapshot, sensor.entity_description) - == "Circuit c9 Energy Consumed" + == "Circuit c9 Consumed Energy" ) assert ( sensor._generate_panel_name(snapshot, sensor.entity_description) - == "Circuit c9 Energy Consumed" + == "Circuit c9 Consumed Energy" ) @@ -483,8 +488,8 @@ def test_circuit_energy_sensor_subdevice_uses_description_only() -> None: device_info_override={"identifiers": {("span_panel", "evse")}}, ) - assert sensor._generate_friendly_name(snapshot, sensor.entity_description) == "Energy Consumed" - assert sensor._generate_panel_name(snapshot, sensor.entity_description) == "Energy Consumed" + assert sensor._generate_friendly_name(snapshot, sensor.entity_description) == "Consumed Energy" + assert sensor._generate_panel_name(snapshot, sensor.entity_description) == "Consumed Energy" def test_circuit_energy_sensor_extra_attributes_only_include_base_when_circuit_missing() -> None: @@ -1215,7 +1220,8 @@ def test_circuit_sensor_takes_the_panel_name_in_circuit_numbers_mode() -> None: sensor = SpanCircuitPowerSensor(coordinator, CIRCUIT_CURRENT_SENSOR, snapshot, "c1") assert sensor._attr_name == "Kitchen Current" - assert sensor.entity_id == "sensor.span_panel_circuit_5_current" + assert sensor.suggested_object_id == "Circuit 5 current" + assert sensor.entity_id is None # Core assigns it when the platform adds the entity assert sensor._previous_circuit_name == "Kitchen" From e569b1317702443e82b2255dd3b78306e9a1e622 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:51:44 -0700 Subject: [PATCH 151/345] fix(sensor): keep the old id shape for entities the route change would otherwise move Handing Home Assistant the job of composing a circuit entity id changes how two shapes are spelled, for reasons belonging to this integration rather than to the user: a sensor shown on a sub-device card, whose id names the panel where composition names the charger, and any circuit entity on an install with the device prefix off, which has_entity_name prefixes anyway. Recreate would have offered both moves, and R1 says it must only ever offer one the user caused. Those entities go on being preset, from current panel data so a renamed circuit still refreshes the name half; everything new composes. The registry name release now reads its legacy labels through a typed narrow rather than getattr, and the two object-id hooks are one. --- custom_components/span_panel/naming.py | 46 +++++ custom_components/span_panel/sensor_base.py | 100 ++++++++--- .../span_panel/sensor_circuit.py | 78 ++++----- tests/test_naming.py | 95 +++++++++++ tests/test_recreate_entity_ids.py | 159 +++++++++++++++++- 5 files changed, 410 insertions(+), 68 deletions(-) diff --git a/custom_components/span_panel/naming.py b/custom_components/span_panel/naming.py index 37fa270a..c3390d1e 100644 --- a/custom_components/span_panel/naming.py +++ b/custom_components/span_panel/naming.py @@ -26,6 +26,7 @@ from __future__ import annotations from collections.abc import Iterable +from typing import Final from homeassistant.helpers import entity_registry as er from homeassistant.util import slugify @@ -93,6 +94,51 @@ def circuit_object_id_base(identifier: str, suffix: str, existing_entity_id: str return f"{identifier} {words}" +LEGACY_PRESET_DEVICE_NAME: Final = "Span Panel" +"""The device name the preset builder spelled every circuit id with. + +Not the panel's own name. `construct_single_circuit_entity_id` was called +without one, so `snapshot_to_device_info` fell back to this literal on every +install -- the second panel on a system is named "Span Panel 2" and its circuit +entity ids still began `span_panel_`. An id being *kept* has to be spelled the +way it actually is, so this is the string to keep spelling it with. +""" + + +def legacy_preset_entity_id( + platform: str, + device_slug: str | None, + identifier: str, + suffix: str, + existing_entity_id: str, +) -> str: + """Return the id an existing entity keeps, where composing one would move it. + + Two shapes Home Assistant's composition cannot reproduce, for reasons that + belong to this integration rather than to the user: + + - a circuit sensor shown on a **sub-device** card, whose id names the panel + because the preset builder always did, where composition names the charger + -- that being the entity's device; + - any circuit entity on an install with **`USE_DEVICE_PREFIX` off**, whose id + carries no device at all, where `has_entity_name` prefixes one regardless. + + R1 forbids offering either move, so those entities go on being preset. Only + those: `device_slug` is `None` for the prefix-less case and everything else + composes, which is the whole point of the release. + + The id is *computed* from current panel data and never read back from the + registry, so a circuit renamed in the SPAN app still refreshes the name half + -- issue #252, which the preset must not undo. Only the suffix half is read + back, by `circuit_object_id_base`, since this integration has shipped two + spellings of it. + """ + object_id = slugify(circuit_object_id_base(identifier, suffix, existing_entity_id)) + if device_slug: + object_id = f"{slugify(device_slug)}_{object_id}" + return f"{platform}.{object_id}" + + def release_registry_name_written_by_older_release( registry: er.EntityRegistry, entity_id: str, diff --git a/custom_components/span_panel/sensor_base.py b/custom_components/span_panel/sensor_base.py index 6ec15983..d321d00e 100644 --- a/custom_components/span_panel/sensor_base.py +++ b/custom_components/span_panel/sensor_base.py @@ -22,7 +22,7 @@ from homeassistant.helpers.typing import StateType from span_panel_api import SpanPanelSnapshot -from .const import DOMAIN, ENABLE_ENERGY_DIP_COMPENSATION +from .const import DOMAIN, ENABLE_ENERGY_DIP_COMPENSATION, USE_DEVICE_PREFIX from .coordinator import SpanPanelCoordinator from .energy_dip import ( DipEvent, @@ -39,12 +39,14 @@ handle_offline_grace_period, initialize_from_last_state, ) -from .id_builder import get_user_friendly_suffix from .naming import ( + LEGACY_PRESET_DEVICE_NAME, circuit_object_id_base, + legacy_preset_entity_id, release_registry_name_written_by_older_release, ) from .options import ENERGY_REPORTING_GRACE_PERIOD +from .sensor_definitions import SpanPanelCircuitsSensorEntityDescription _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -88,6 +90,24 @@ class SpanSensorBase[T: SensorEntityDescription, D](SpanPanelEntity, SensorEntit _attr_has_entity_name = True + _is_sub_device: bool = False + """True when this entity is shown on a sub-device's card rather than the panel's. + + Set by the circuit classes when they are handed a `device_info_override`. It + decides the id policy twice over: Core would compose a *new* one of these + from the sub-device, which is the shape its own sensors have and so the shape + wanted; an *existing* one carries the panel's name and keeps it. + """ + + _id_was_preset_by_this_integration: bool = False + """True for entities whose id this integration used to spell out in full. + + Only those can be moved by handing composition the job, so only those are + kept preset where composition disagrees. The hidden unmapped-tab sensors set + it False deliberately: Home Assistant has always composed their ids, so they + already carry a device prefix that no naming flag ever removed. + """ + def __init__( self, data_coordinator: SpanPanelCoordinator, @@ -132,12 +152,21 @@ def __init__( # The id itself is Home Assistant's to compose. This entity # supplies only its base; `entity_id` is left unset so Core - # assembles the rest from the user's `entity_id_parts`. - identifier = self._object_id_identifier(snapshot, description) - if identifier is not None: - self._span_object_id_base = circuit_object_id_base( - identifier, self._object_id_suffix(description), existing_entity_id + # assembles the rest from the user's `entity_id_parts` -- except + # where composing one would move an id this integration spelled + # its own way, which R1 forbids offering. + parts = self._object_id_parts(snapshot, description) + if parts is not None: + identifier, suffix = parts + preset = self._preset_composition_would_move( + identifier, suffix, existing_entity_id ) + if preset is not None: + self.entity_id = preset + elif not self._is_sub_device: + self._span_object_id_base = circuit_object_id_base( + identifier, suffix, existing_entity_id + ) if existing_entity_id: self._release_synced_registry_name( @@ -204,27 +233,51 @@ def _generate_friendly_name(self, snapshot: SpanPanelSnapshot, description: T) - """ - def _object_id_identifier(self, snapshot: SpanPanelSnapshot, description: T) -> str | None: - """Return the naming-flag half of this entity's object-id base. + def _object_id_parts( + self, snapshot: SpanPanelSnapshot, description: T + ) -> tuple[str, str] | None: + """Return the identifier and canonical suffix this entity's id is built from. `None` -- the default, and the answer for every entity that is not a circuit -- leaves `_span_object_id_base` unset, so Home Assistant composes the id from the display name exactly as it always has. - A circuit entity answers with `Circuit 15`, `Kitchen Outlets` or - `Unmapped Tab 32`, which is the one half of the id the naming flags - decide. `naming.circuit_object_id_base` supplies the other half. + A circuit entity answers with the naming-flag half (`Circuit 15`, + `Kitchen Outlets`, `Unmapped Tab 32`) and the suffix + `naming.circuit_object_id_base` keys on. The two travel together because + neither is any use alone, and because a circuit entity's + `description.key` has been overwritten with the circuit id by then, so + there is no suffix a default could compute. """ return None - def _object_id_suffix(self, description: T) -> str: - """Return the canonical suffix `naming.circuit_object_id_base` keys on. + def _preset_composition_would_move( + self, identifier: str, suffix: str, existing_entity_id: str | None + ) -> str | None: + """Return the id to go on presetting, or None to let Core compose one. + + R1: Recreate must never propose a move the user did not cause, and this + release moves the id-building job to Home Assistant. Two shapes it spells + differently are answered here rather than accepted -- a sensor on a + sub-device card, and any circuit entity on an install with the device + prefix off. See `naming.legacy_preset_entity_id` for both. - The description key is the right question for a description whose key - survives construction. Circuit entities overwrite theirs with the - circuit id and so answer from the key they were built from. + Existing entities only. A new one has no id to protect and composes like + everything else, which is what makes this a shrinking exception rather + than a permanent fork. """ - return get_user_friendly_suffix(description.key) + if existing_entity_id is None or not self._id_was_preset_by_this_integration: + return None + use_device_prefix: bool = self.coordinator.config_entry.options.get(USE_DEVICE_PREFIX, True) + if not (self._is_sub_device or not use_device_prefix): + return None + return legacy_preset_entity_id( + "sensor", + LEGACY_PRESET_DEVICE_NAME if use_device_prefix else None, + identifier, + suffix, + existing_entity_id, + ) def _generate_panel_name(self, snapshot: SpanPanelSnapshot, description: T) -> str | None: """Generate the displayed name for the sensor, in either naming mode. @@ -272,9 +325,14 @@ def _release_synced_registry_name( circuit = snapshot.circuits.get(getattr(self, "circuit_id", "")) if not (circuit and circuit.name): return - # Declared by the circuit descriptions; a description type whose label - # has never been reworded carries none. - legacy_names: tuple[str, ...] = getattr(description, "legacy_names", ()) + # Only the circuit descriptions declare a reworded label, and only a + # circuit entity reaches this line at all -- the guard above returns for + # anything with no circuit behind it. + legacy_names = ( + description.legacy_names + if isinstance(description, SpanPanelCircuitsSensorEntityDescription) + else () + ) release_registry_name_written_by_older_release( entity_registry, existing_entity_id, diff --git a/custom_components/span_panel/sensor_circuit.py b/custom_components/span_panel/sensor_circuit.py index 82a0c205..8113b9f0 100644 --- a/custom_components/span_panel/sensor_circuit.py +++ b/custom_components/span_panel/sensor_circuit.py @@ -95,6 +95,8 @@ class SpanCircuitPowerSensor( ): """Circuit power/current/breaker-rating sensor with extra state attributes.""" + _id_was_preset_by_this_integration = True + # Beyond the value the description declares: `name` and `tabs` build the # entity's identity (~56-83), and `tabs`, `relay_state`, `relay_requester` # and `priority` are republished as state attributes (~231-243). @@ -201,31 +203,22 @@ def _generate_panel_name( circuit_identifier = _resolve_circuit_identifier_for_sync(circuit, self.circuit_id) return f"{circuit_identifier} {description.name or 'Sensor'}" - def _object_id_identifier( + def _object_id_parts( self, snapshot: SpanPanelSnapshot, description: SpanPanelCircuitsSensorEntityDescription, - ) -> str | None: - """Return the naming-flag half of this sensor's object-id base.""" + ) -> tuple[str, str] | None: + """Return the circuit identifier and the suffix this sensor's id ends with.""" circuit = snapshot.circuits.get(self.circuit_id) if not circuit: return None - if self._is_sub_device: - # On a sub-device (an EVSE's feed circuit) Core supplies that device - # as the DEVICE part of the id, so the base is the reading alone -- - # the same shape the charger's own sensors have. - return None - return _resolve_circuit_identifier( + identifier = _resolve_circuit_identifier( circuit, self.circuit_id, self.coordinator.config_entry.options ) - - def _object_id_suffix(self, description: SpanPanelCircuitsSensorEntityDescription) -> str: - """Return the canonical suffix, from the key this sensor was built from. - - `description.key` was overwritten with the circuit id during - construction, so the base class's answer would not be a suffix at all. - """ - return get_user_friendly_suffix(self._API_KEY_MAP.get(self.original_key, self.original_key)) + suffix = get_user_friendly_suffix( + self._API_KEY_MAP.get(self.original_key, self.original_key) + ) + return identifier, suffix def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanCircuitSnapshot: """Get the data source for the circuit power sensor.""" @@ -288,9 +281,19 @@ class SpanCircuitEnergySensor( ): """Circuit energy sensor with grace period tracking.""" + _id_was_preset_by_this_integration = True + # Naming only; this sensor publishes no circuit attributes. _residual_field_paths: ClassVar[tuple[str, ...]] = ("circuit.name", "circuit.tabs") + # Map new description keys to the original API keys that migration + # normalized from; both the unique id and the entity-id suffix key on them. + _API_KEY_MAP: dict[str, str] = { + "circuit_energy_produced": "producedEnergyWh", + "circuit_energy_consumed": "consumedEnergyWh", + "circuit_energy_net": "netEnergyWh", + } + def __init__( self, data_coordinator: SpanPanelCoordinator, @@ -381,34 +384,22 @@ def _generate_panel_name( circuit_identifier = _resolve_circuit_identifier_for_sync(circuit, self.circuit_id) return f"{circuit_identifier} {description.name}" - def _object_id_identifier( + def _object_id_parts( self, snapshot: SpanPanelSnapshot, description: SpanPanelCircuitsSensorEntityDescription, - ) -> str | None: - """Return the naming-flag half of this sensor's object-id base.""" + ) -> tuple[str, str] | None: + """Return the circuit identifier and the suffix this sensor's id ends with.""" circuit = snapshot.circuits.get(self.circuit_id) if not circuit: return None - if self._is_sub_device: - # See `SpanCircuitPowerSensor._object_id_identifier`: the charger is - # the DEVICE part, so the base is the reading alone. - return None - return _resolve_circuit_identifier( + identifier = _resolve_circuit_identifier( circuit, self.circuit_id, self.coordinator.config_entry.options ) - - def _object_id_suffix(self, description: SpanPanelCircuitsSensorEntityDescription) -> str: - """Return the canonical suffix, from the key this sensor was built from.""" - return get_user_friendly_suffix(self._API_KEY_MAP.get(self.original_key, self.original_key)) - - # Map new description keys to the original API keys that migration - # normalized from; both the unique id and the entity-id suffix key on them. - _API_KEY_MAP: dict[str, str] = { - "circuit_energy_produced": "producedEnergyWh", - "circuit_energy_consumed": "consumedEnergyWh", - "circuit_energy_net": "netEnergyWh", - } + suffix = get_user_friendly_suffix( + self._API_KEY_MAP.get(self.original_key, self.original_key) + ) + return identifier, suffix # Map original_key to the energy type used for coordinator dip offset tracking _ENERGY_TYPE_MAP: dict[str, str] = { @@ -530,17 +521,16 @@ def _unmapped_name(self, description: SpanPanelCircuitsSensorEntityDescription) description_name = str(description.name) if description.name else "Sensor" return construct_unmapped_friendly_name(tab_number, description_name) - def _object_id_identifier( + def _object_id_parts( self, snapshot: SpanPanelSnapshot, description: SpanPanelCircuitsSensorEntityDescription, - ) -> str | None: + ) -> tuple[str, str] | None: """Return the tab this sensor backs; unmapped tabs carry no naming flag.""" - return f"Unmapped Tab {self.circuit_id.replace('unmapped_tab_', '')}" - - def _object_id_suffix(self, description: SpanPanelCircuitsSensorEntityDescription) -> str: - """Return the canonical suffix, from the key this sensor was built from.""" - return get_user_friendly_suffix(self.original_key) + return ( + f"Unmapped Tab {self.circuit_id.replace('unmapped_tab_', '')}", + get_user_friendly_suffix(self.original_key), + ) def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanCircuitSnapshot: """Get the data source for the unmapped circuit sensor.""" diff --git a/tests/test_naming.py b/tests/test_naming.py index 4dc2863c..a88a6e45 100644 --- a/tests/test_naming.py +++ b/tests/test_naming.py @@ -88,6 +88,101 @@ def test_every_form_table_entry_has_a_new_wording_and_vice_versa() -> None: ) +# --- The id shape kept where composition would move one -------------------- +# +# Two kinds of circuit entity are spelled differently by Home Assistant's +# composition than by the builder that preset their ids: one on a sub-device, +# where the DEVICE part is the charger and not the panel, and one on an install +# that turned the device prefix off, where composition prefixes anyway. Neither +# difference is anything the user did, so those entities keep being preset. + +from custom_components.span_panel.naming import ( + LEGACY_PRESET_DEVICE_NAME, + legacy_preset_entity_id, +) + + +def test_a_sub_device_sensor_keeps_the_panel_prefixed_id_it_has() -> None: + """Composition would spell this one with the charger's name; its id says the panel.""" + assert ( + legacy_preset_entity_id( + "sensor", + LEGACY_PRESET_DEVICE_NAME, + "Kitchen Outlets", + "power", + "sensor.span_panel_kitchen_outlets_power", + ) + == "sensor.span_panel_kitchen_outlets_power" + ) + + +def test_an_install_without_the_device_prefix_keeps_its_bare_id() -> None: + """`has_entity_name` prefixes the device unconditionally; these ids never did.""" + assert ( + legacy_preset_entity_id( + "sensor", None, "Kitchen Outlets", "power", "sensor.kitchen_outlets_power" + ) + == "sensor.kitchen_outlets_power" + ) + + +def test_a_renamed_circuit_still_refreshes_the_name_half() -> None: + """Issue #252 has to survive the preset: the id is computed, never read back.""" + assert ( + legacy_preset_entity_id( + "sensor", + LEGACY_PRESET_DEVICE_NAME, + "Beer Fridge", + "power", + "sensor.span_panel_kitchen_outlets_power", + ) + == "sensor.span_panel_beer_fridge_power" + ) + assert ( + legacy_preset_entity_id( + "sensor", None, "Beer Fridge", "power", "sensor.kitchen_outlets_power" + ) + == "sensor.beer_fridge_power" + ) + + +@pytest.mark.parametrize( + ("device_slug", "existing", "expected"), + [ + ( + LEGACY_PRESET_DEVICE_NAME, + "sensor.span_panel_kitchen_consumed_energy", + "sensor.span_panel_kitchen_consumed_energy", + ), + ( + LEGACY_PRESET_DEVICE_NAME, + "sensor.span_panel_kitchen_energy_consumed", + "sensor.span_panel_kitchen_energy_consumed", + ), + (None, "sensor.kitchen_consumed_energy", "sensor.kitchen_consumed_energy"), + (None, "sensor.kitchen_energy_consumed", "sensor.kitchen_energy_consumed"), + ], +) +def test_the_suffix_words_come_from_the_id_the_entity_already_has( + device_slug: str | None, existing: str, expected: str +) -> None: + """Both spellings this integration has shipped survive the preset, as they must.""" + assert ( + legacy_preset_entity_id("sensor", device_slug, "Kitchen", "energy_consumed", existing) + == expected + ) + + +def test_the_preset_device_name_is_the_literal_the_old_builder_fell_back_to() -> None: + """Not the panel's own name: `construct_single_circuit_entity_id` passed none. + + A second panel on a system is named "Span Panel 2" and its circuit ids still + began `span_panel_`, so the panel's name is the wrong thing to spell an + existing id with. + """ + assert LEGACY_PRESET_DEVICE_NAME == "Span Panel" + + from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er diff --git a/tests/test_recreate_entity_ids.py b/tests/test_recreate_entity_ids.py index 36f2af00..59985385 100644 --- a/tests/test_recreate_entity_ids.py +++ b/tests/test_recreate_entity_ids.py @@ -26,6 +26,7 @@ from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers import area_registry as ar, device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_registry import EntityNamePart from pytest_homeassistant_custom_component.common import ( MockConfigEntry, @@ -45,8 +46,9 @@ from custom_components.span_panel.sensor_circuit import ( SpanCircuitEnergySensor, SpanCircuitPowerSensor, + SpanUnmappedCircuitSensor, ) -from custom_components.span_panel.sensor_definitions import CIRCUIT_SENSORS +from custom_components.span_panel.sensor_definitions import CIRCUIT_SENSORS, UNMAPPED_SENSORS from custom_components.span_panel.switch import SpanPanelCircuitsSwitch from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory @@ -99,9 +101,15 @@ class _Install: the same `async_reset` an entry unload performs. """ - def __init__(self, hass: HomeAssistant, entry: MockConfigEntry) -> None: + def __init__( + self, + hass: HomeAssistant, + entry: MockConfigEntry, + device_info_override: DeviceInfo | None = None, + ) -> None: self._hass = hass self._entry = entry + self._device_info_override = device_info_override self._platform: MockEntityPlatform | None = None async def load(self, circuit_name: str) -> SpanCircuitPowerSensor: @@ -118,7 +126,13 @@ async def load(self, circuit_name: str) -> SpanCircuitPowerSensor: self._platform = MockEntityPlatform(self._hass, domain="sensor", platform_name=DOMAIN) self._platform.config_entry = self._entry - sensor = SpanCircuitPowerSensor(coordinator, POWER_DESCRIPTION, snapshot, CIRCUIT_ID) + sensor = SpanCircuitPowerSensor( + coordinator, + POWER_DESCRIPTION, + snapshot, + CIRCUIT_ID, + device_info_override=self._device_info_override, + ) await self._platform.async_add_entities([sensor]) await self._hass.async_block_till_done() @@ -636,6 +650,145 @@ async def test_an_unnamed_circuit_in_friendly_mode_still_gets_a_distinct_id( assert sensor.entity_id == "sensor.span_panel_circuit_15_power" +# --- Entities composition would spell differently than the preset did --------- +# +# Two shapes disagree with what Home Assistant composes, for reasons belonging to +# this integration and not to the user: a circuit sensor shown on a sub-device +# card, where the DEVICE part is the charger rather than the panel, and any +# entity on an install that turned the device prefix off, which `has_entity_name` +# prefixes anyway. R1 says Recreate must not offer either move, so these keep +# being preset -- from current panel data, so #252 still holds. + +EVSE_DEVICE_INFO = DeviceInfo( + identifiers={(DOMAIN, f"{SERIAL}_evse_node-1")}, + name="SPAN Panel EV Charger", +) + +LEGACY_NAMES = {USE_DEVICE_PREFIX: False, USE_CIRCUIT_NUMBERS: False} + + +def _seed_power_entity(hass: HomeAssistant, entry: MockConfigEntry, object_id: str) -> str: + """Register the circuit power entity the way an install already has it.""" + registry = er.async_get(hass) + seeded = registry.async_get_or_create( + "sensor", + DOMAIN, + build_circuit_unique_id(SERIAL, CIRCUIT_ID, "instantPowerW"), + suggested_object_id=object_id, + config_entry=entry, + ) + assert seeded.entity_id == f"sensor.{object_id}" + return seeded.entity_id + + +async def test_an_existing_sub_device_sensor_is_offered_its_own_id( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """An EVSE feed circuit's sensor is spelled with the panel, and stays so.""" + seeded = _seed_power_entity(hass, entry, "span_panel_refrigerator_power") + + install = _Install(hass, entry, device_info_override=EVSE_DEVICE_INFO) + sensor = await install.load(ORIGINAL_NAME) + await install.load(ORIGINAL_NAME) + + assert sensor.entity_id == seeded + + registry = er.async_get(hass) + registry_entry = registry.async_get(seeded) + assert registry_entry is not None + assert registry.async_regenerate_entity_id(registry_entry) == seeded + + +async def test_an_existing_sub_device_sensor_still_follows_a_rename( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Keeping the shape must not cost #252: the name half still tracks the panel.""" + seeded = _seed_power_entity(hass, entry, "span_panel_refrigerator_power") + + install = _Install(hass, entry, device_info_override=EVSE_DEVICE_INFO) + await install.load(ORIGINAL_NAME) + await install.load(RENAMED) + + registry = er.async_get(hass) + registry_entry = registry.async_get(seeded) + assert registry_entry is not None + assert registry.async_regenerate_entity_id(registry_entry) == RENAMED_ENTITY_ID + + +async def test_an_existing_sensor_on_a_no_prefix_install_is_offered_its_own_id( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """The pre-1.0.4 shape: no device prefix at all, which composition cannot produce.""" + hass.config_entries.async_update_entry(entry, options=dict(LEGACY_NAMES)) + seeded = _seed_power_entity(hass, entry, "refrigerator_power") + + install = _Install(hass, entry) + sensor = await install.load(ORIGINAL_NAME) + await install.load(ORIGINAL_NAME) + + assert sensor.entity_id == seeded + + registry = er.async_get(hass) + registry_entry = registry.async_get(seeded) + assert registry_entry is not None + assert registry.async_regenerate_entity_id(registry_entry) == seeded + + +async def test_a_new_sensor_on_a_no_prefix_install_composes_like_every_other_entity( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """The preset is for ids that exist. Nothing new inherits the prefix-less shape.""" + hass.config_entries.async_update_entry(entry, options=dict(LEGACY_NAMES)) + + sensor = await _Install(hass, entry).load(ORIGINAL_NAME) + + assert sensor.entity_id == ORIGINAL_ENTITY_ID + + +async def test_an_unmapped_tab_sensor_keeps_its_prefix_on_a_no_prefix_install( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """The exception is for ids this integration spelled, and it never spelled these. + + The hidden unmapped-tab sensors have always had their ids composed by Home + Assistant from the display name, so they carry a device prefix that the + naming flag never reached. Applying the prefix-less shape to them would + invent a move rather than prevent one. + """ + hass.config_entries.async_update_entry(entry, options=dict(LEGACY_NAMES)) + + registry = er.async_get(hass) + seeded = registry.async_get_or_create( + "sensor", + DOMAIN, + build_circuit_unique_id(SERIAL, "unmapped_tab_32", "instantPowerW"), + suggested_object_id="span_panel_unmapped_tab_32_power", + config_entry=entry, + ) + assert seeded.entity_id == "sensor.span_panel_unmapped_tab_32_power" + + circuit = SpanCircuitSnapshotFactory.create(circuit_id="unmapped_tab_32", name="", tabs=[32]) + snapshot = SpanPanelSnapshotFactory.create( + serial_number=SERIAL, circuits={"unmapped_tab_32": circuit} + ) + coordinator = _coordinator(hass, snapshot, entry) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, panel_device_id="panel-device-id" + ) + + platform = MockEntityPlatform(hass, domain="sensor", platform_name=DOMAIN) + platform.config_entry = entry + sensor = SpanUnmappedCircuitSensor(coordinator, UNMAPPED_SENSORS[0], snapshot, "unmapped_tab_32") + await platform.async_add_entities([sensor]) + await hass.async_block_till_done() + + assert sensor.entity_id == seeded.entity_id + + registry_entry = registry.async_get(seeded.entity_id) + assert registry_entry is not None + assert registry.async_regenerate_entity_id(registry_entry) == seeded.entity_id + + def test_a_suffix_with_no_older_spelling_is_left_alone() -> None: """A name that merely looks like a suffix change is a rename, not a legacy form. From f44bedc0690114eec7262ccac4996b863011a4ac Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:07:25 -0700 Subject: [PATCH 152/345] feat(switch,select): supply a base instead of presetting the entity id Same shape as the circuit sensors: one base, Core composes, the name an older release wrote is released through the shared helper. The breaker switch and the priority select were the last two circuit platforms spelling their own entity ids. Both now hand Home Assistant an object-id base -- the naming-flag half plus a suffix each names outright, "breaker" and "circuit_priority", since `get_user_friendly_suffix` has no entry for the one and maps the other to the unique_id's spelling. With the id no longer composed from the display name, the name is free to be the panel's in both naming modes rather than the mode's on a first install and the panel's afterwards. It travels as `original_name`, which ranks below the base, so what is shown cannot decide what "Recreate entity IDs" proposes -- and an unnamed circuit is named for its tabs instead of being left to HA, which gave every one of them the same id stem. R1's exception is now asked once. The two shapes composition spells differently -- a sensor on a sub-device card, and any circuit entity on an install with the device prefix off -- were a sensor-only decision; three platforms facing the same question with three copies of the answer is how they drift apart on which entities a release moves. The decision moves to `naming.legacy_preset_for_existing` and the sensors ask it too, keeping only the fact a sensor class alone knows: whether this integration ever preset that entity's id at all. --- custom_components/span_panel/naming.py | 36 +++ custom_components/span_panel/select.py | 112 +++++---- custom_components/span_panel/sensor_base.py | 33 ++- custom_components/span_panel/switch.py | 98 ++++---- tests/test_circuit_control.py | 50 ++-- tests/test_naming.py | 83 +++++++ tests/test_recreate_entity_ids.py | 252 +++++++++++++++++++- tests/test_select.py | 46 ++-- 8 files changed, 560 insertions(+), 150 deletions(-) diff --git a/custom_components/span_panel/naming.py b/custom_components/span_panel/naming.py index c3390d1e..53461921 100644 --- a/custom_components/span_panel/naming.py +++ b/custom_components/span_panel/naming.py @@ -139,6 +139,42 @@ def legacy_preset_entity_id( return f"{platform}.{object_id}" +def legacy_preset_for_existing( + platform: str, + *, + identifier: str, + suffix: str, + existing_entity_id: str | None, + use_device_prefix: bool, + is_sub_device: bool, +) -> str | None: + """Return the id this entity keeps, or None to let Home Assistant compose one. + + The whole R1 exception in one place, asked by all three circuit platforms -- + the sensors, the breaker switch and the priority select. Each used to preset + its id through the same builder, so each faces the same two shapes + composition spells differently; a copy of this decision per platform is how + three platforms drift apart on which entities the release moves. + + `None` for anything new: an entity with no id yet has none to protect, and + composing one is the point of the release. `None` too for the ordinary + existing entity, whose id composition reproduces exactly. What is left is + `legacy_preset_entity_id`'s two shapes -- see it for why each is kept and for + why the kept id is computed from current panel data rather than read back. + """ + if existing_entity_id is None: + return None + if not (is_sub_device or not use_device_prefix): + return None + return legacy_preset_entity_id( + platform, + LEGACY_PRESET_DEVICE_NAME if use_device_prefix else None, + identifier, + suffix, + existing_entity_id, + ) + + def release_registry_name_written_by_older_release( registry: er.EntityRegistry, entity_id: str, diff --git a/custom_components/span_panel/select.py b/custom_components/span_panel/select.py index cd0c53cb..1e9bc950 100644 --- a/custom_components/span_panel/select.py +++ b/custom_components/span_panel/select.py @@ -15,7 +15,7 @@ from . import SpanPanelConfigEntry from .adoption import AdoptedSelect, create_adopted_selects -from .const import DOMAIN, USE_CIRCUIT_NUMBERS, CircuitPriority +from .const import DOMAIN, USE_CIRCUIT_NUMBERS, USE_DEVICE_PREFIX, CircuitPriority from .control_gate import ControlMode, outcome_failure_reason, outcome_is_failure from .coordinator import SpanPanelCoordinator from .entity import SpanPanelEntity @@ -24,10 +24,14 @@ build_select_unique_id_for_entry, construct_circuit_identifier_from_tabs, construct_circuit_label, - construct_single_circuit_entity_id, construct_tabs_attribute, construct_voltage_attribute, ) +from .naming import ( + circuit_object_id_base, + legacy_preset_for_existing, + release_registry_name_written_by_older_release, +) # Device types that use "Solar" as the fallback identifier when unnamed. _SOLAR_DEVICE_TYPES: frozenset[str] = frozenset({"pv"}) @@ -65,6 +69,15 @@ def __init__( select_option_fn: Callable[[SpanCircuitSnapshot, str], None] | None = None, ) -> None: """Initialize the select entity description wrapper.""" + self.name: str = name + """The description's label, kept as the `str` it is. + + `SelectEntityDescription.name` is typed `str | UndefinedType | None`, + being optional for a description that declares a `translation_key` + instead. This one always declares a label, and the registry-name release + wants a `str`, so the wrapper holds on to what it was handed rather than + narrowing the frozen description's field back down at every reader. + """ self.entity_description = SelectEntityDescription( key=key, name=name, @@ -89,8 +102,8 @@ class SpanPanelCircuitsSelect(SpanPanelEntity, SelectEntity): # Read in entity code rather than through a description: the wrapper # class is not a frozen dataclass description, so it cannot carry the - # declaration. `priority` is the selected option (~80); `name` and - # `tabs` build the display name (~125-148). + # declaration. `priority` is the selected option (~96); `name` and + # `tabs` build the display name and the id base (~153-171). _residual_field_paths: ClassVar[tuple[str, ...]] = ( "circuit.priority", "circuit.name", @@ -105,7 +118,13 @@ def __init__( name: str, device_name: str, ) -> None: - """Initialize the select.""" + """Initialize the select. + + `name` is the circuit's name as of platform setup and is deliberately + unused: both the display name and the id base are read from the circuit + in the current snapshot, so a circuit renamed on the panel reaches both + on the next reload. The parameter stays because every caller passes it. + """ super().__init__(coordinator) snapshot: SpanPanelSnapshot = coordinator.data @@ -129,17 +148,42 @@ def __init__( ) use_circuit_numbers = coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) - - desc_name = description.entity_description.name - if existing_entity_id: - # Phase 2: the panel's name, in both modes. It reaches the UI as - # `original_name`, which ranks below `suggested_object_id` and so - # cannot decide what "Recreate entity IDs" proposes. - if circuit.name: - self._attr_name = f"{circuit.name} {desc_name}" - else: - fallback = _unnamed_select_fallback(circuit, circuit_id) - self._attr_name = f"{fallback} {desc_name}" + use_device_prefix: bool = coordinator.config_entry.options.get(USE_DEVICE_PREFIX, True) + desc_name = description.name + circuit_name = circuit.name or _unnamed_select_fallback(circuit, circuit_id) + + # One name path, in both naming modes: the panel's name, carried as + # `original_name`. That field ranks below the object-id base below, so + # what is displayed can no longer decide what "Recreate entity IDs" + # proposes. + self._attr_name = f"{circuit_name} {desc_name}" + + # The id itself is Home Assistant's to compose. This entity supplies only + # its base -- the naming-flag half plus the description's key -- and + # leaves `entity_id` unset so Core assembles the rest from the user's + # `entity_id_parts`. The key is used as the suffix verbatim: + # `get_user_friendly_suffix` maps `circuit_priority` to `priority`, which + # is the unique_id's spelling and not this entity's. + suffix = description.entity_description.key + identifier = ( + construct_circuit_identifier_from_tabs(circuit.tabs, circuit_id) + if use_circuit_numbers + else circuit_name + ) + preset = legacy_preset_for_existing( + "select", + identifier=identifier, + suffix=suffix, + existing_entity_id=existing_entity_id, + use_device_prefix=use_device_prefix, + is_sub_device=False, + ) + if preset is not None: + self.entity_id = preset + else: + self._span_object_id_base = circuit_object_id_base( + identifier, suffix, existing_entity_id + ) # Circuit-numbers mode used to deliver the panel's name by writing the # registry's `name`. That field is the user's override, and Home Assistant @@ -148,38 +192,10 @@ def __init__( # circuit-numbered entity. The name travels as `original_name` now, so all # that is left is to let go of what the old scheme wrote -- and only that: # any other name is the user's. - if existing_entity_id and circuit.name: - entity_entry = entity_registry.async_get(existing_entity_id) - if entity_entry and entity_entry.name == f"{circuit.name} {desc_name}": - entity_registry.async_update_entity(existing_entity_id, name=None) - - if not existing_entity_id: - # Initial install - use flag-based name for entity_id generation - if use_circuit_numbers: - circuit_identifier = construct_circuit_identifier_from_tabs( - circuit.tabs, circuit_id - ) - self._attr_name = f"{circuit_identifier} {desc_name}" - elif name: - self._attr_name = f"{name} {desc_name}" - else: - # v1 behavior: None lets HA handle default naming - self._attr_name = None - - # Explicitly set entity_id using construct_single_circuit_entity_id - # which correctly handles 240V two-tab circuits. For an entity already - # in the registry this is a suggestion HA records and does not act on -- - # the stored entity_id stands. See the helper's docstring. - constructed_id = construct_single_circuit_entity_id( - coordinator, - snapshot, - "select", - description.entity_description.key, - circuit, - existing_entity_id=existing_entity_id, - ) - if constructed_id: - self.entity_id = constructed_id + if existing_entity_id: + release_registry_name_written_by_older_release( + entity_registry, existing_entity_id, circuit.name, (desc_name,) + ) self._attr_options = description.options_fn(circuit) self._attr_current_option = description.current_option_fn(circuit) diff --git a/custom_components/span_panel/sensor_base.py b/custom_components/span_panel/sensor_base.py index d321d00e..b1ba50d8 100644 --- a/custom_components/span_panel/sensor_base.py +++ b/custom_components/span_panel/sensor_base.py @@ -40,9 +40,8 @@ initialize_from_last_state, ) from .naming import ( - LEGACY_PRESET_DEVICE_NAME, circuit_object_id_base, - legacy_preset_entity_id, + legacy_preset_for_existing, release_registry_name_written_by_older_release, ) from .options import ENERGY_REPORTING_GRACE_PERIOD @@ -256,27 +255,23 @@ def _preset_composition_would_move( ) -> str | None: """Return the id to go on presetting, or None to let Core compose one. - R1: Recreate must never propose a move the user did not cause, and this - release moves the id-building job to Home Assistant. Two shapes it spells - differently are answered here rather than accepted -- a sensor on a - sub-device card, and any circuit entity on an install with the device - prefix off. See `naming.legacy_preset_entity_id` for both. - - Existing entities only. A new one has no id to protect and composes like - everything else, which is what makes this a shrinking exception rather - than a permanent fork. + The decision itself belongs to `naming.legacy_preset_for_existing`, which + the switch and the select ask the same question of. What is added here is + the one fact only a sensor class knows: whether this integration ever + preset this entity's id at all. The hidden unmapped-tab sensors say no, + and an id Home Assistant has always composed cannot be moved by handing + Home Assistant the job. """ - if existing_entity_id is None or not self._id_was_preset_by_this_integration: + if not self._id_was_preset_by_this_integration: return None use_device_prefix: bool = self.coordinator.config_entry.options.get(USE_DEVICE_PREFIX, True) - if not (self._is_sub_device or not use_device_prefix): - return None - return legacy_preset_entity_id( + return legacy_preset_for_existing( "sensor", - LEGACY_PRESET_DEVICE_NAME if use_device_prefix else None, - identifier, - suffix, - existing_entity_id, + identifier=identifier, + suffix=suffix, + existing_entity_id=existing_entity_id, + use_device_prefix=use_device_prefix, + is_sub_device=self._is_sub_device, ) def _generate_panel_name(self, snapshot: SpanPanelSnapshot, description: T) -> str | None: diff --git a/custom_components/span_panel/switch.py b/custom_components/span_panel/switch.py index 6098eb13..b5e5acdd 100644 --- a/custom_components/span_panel/switch.py +++ b/custom_components/span_panel/switch.py @@ -15,7 +15,7 @@ from . import SpanPanelConfigEntry from .adoption import AdoptedSwitch, create_adopted_switches -from .const import DOMAIN, USE_CIRCUIT_NUMBERS, CircuitRelayState +from .const import DOMAIN, USE_CIRCUIT_NUMBERS, USE_DEVICE_PREFIX, CircuitRelayState from .control_gate import ( ControlLock, ControlMode, @@ -29,10 +29,14 @@ build_switch_unique_id_for_entry, construct_circuit_identifier_from_tabs, construct_circuit_label, - construct_single_circuit_entity_id, construct_tabs_attribute, construct_voltage_attribute, ) +from .naming import ( + circuit_object_id_base, + legacy_preset_for_existing, + release_registry_name_written_by_older_release, +) from .util import snapshot_to_device_info _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -58,7 +62,8 @@ class SpanPanelCircuitsSwitch(SpanPanelEntity, SwitchEntity): # Read in entity code rather than through a description: this platform # has no entity description at all. `relay_state` is the switch's own - # state (line ~262); `name` and `tabs` build its display name (~77-101). + # state (~258); `name` and `tabs` build its display name and its id base + # (~106-122). _residual_field_paths: ClassVar[tuple[str, ...]] = ( "circuit.relay_state", "circuit.name", @@ -72,7 +77,13 @@ def __init__( name: str, device_name: str, ) -> None: - """Initialize the values.""" + """Initialize the values. + + `name` is the circuit's name as of platform setup and is deliberately + unused: both the display name and the id base are read from the circuit + in the current snapshot, so a circuit renamed on the panel reaches both + on the next reload. The parameter stays because every caller passes it. + """ snapshot: SpanPanelSnapshot = coordinator.data circuit = snapshot.circuits.get(circuit_id) @@ -92,16 +103,39 @@ def __init__( ) use_circuit_numbers = coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) - - if existing_entity_id: - # Phase 2: the panel's name, in both modes. It reaches the UI as - # `original_name`, which ranks below `suggested_object_id` and so - # cannot decide what "Recreate entity IDs" proposes. - if circuit.name: - self._attr_name = f"{circuit.name} Breaker" - else: - fallback = _unnamed_switch_fallback(circuit, circuit_id) - self._attr_name = f"{fallback} Breaker" + use_device_prefix: bool = coordinator.config_entry.options.get(USE_DEVICE_PREFIX, True) + circuit_name = circuit.name or _unnamed_switch_fallback(circuit, circuit_id) + + # One name path, in both naming modes: the panel's name, carried as + # `original_name`. That field ranks below the object-id base below, so + # what is displayed can no longer decide what "Recreate entity IDs" + # proposes. + self._attr_name = f"{circuit_name} Breaker" + + # The id itself is Home Assistant's to compose. This entity supplies only + # its base -- the naming-flag half plus "breaker" -- and leaves + # `entity_id` unset so Core assembles the rest from the user's + # `entity_id_parts`. The suffix is named outright rather than mapped: + # `get_user_friendly_suffix` has no entry for a switch at all. + identifier = ( + construct_circuit_identifier_from_tabs(circuit.tabs, circuit_id) + if use_circuit_numbers + else circuit_name + ) + preset = legacy_preset_for_existing( + "switch", + identifier=identifier, + suffix="breaker", + existing_entity_id=existing_entity_id, + use_device_prefix=use_device_prefix, + is_sub_device=False, + ) + if preset is not None: + self.entity_id = preset + else: + self._span_object_id_base = circuit_object_id_base( + identifier, "breaker", existing_entity_id + ) # Circuit-numbers mode used to deliver the panel's name by writing the # registry's `name`. That field is the user's override, and Home Assistant @@ -110,41 +144,13 @@ def __init__( # circuit-numbered entity. The name travels as `original_name` now, so all # that is left is to let go of what the old scheme wrote -- and only that: # any other name is the user's. - if existing_entity_id and circuit.name: - entity_entry = entity_registry.async_get(existing_entity_id) - if entity_entry and entity_entry.name == f"{circuit.name} Breaker": - entity_registry.async_update_entity(existing_entity_id, name=None) - - if not existing_entity_id: - # Initial install - use flag-based name for entity_id generation - if use_circuit_numbers: - circuit_identifier = construct_circuit_identifier_from_tabs( - circuit.tabs, circuit_id - ) - self._attr_name = f"{circuit_identifier} Breaker" - elif name: - self._attr_name = f"{name} Breaker" - else: - # v1 behavior: None lets HA handle default naming - self._attr_name = None + if existing_entity_id: + release_registry_name_written_by_older_release( + entity_registry, existing_entity_id, circuit.name, ("Breaker",) + ) super().__init__(coordinator) - # Explicitly set entity_id using construct_single_circuit_entity_id - # which correctly handles 240V two-tab circuits. For an entity already - # in the registry this is a suggestion HA records and does not act on -- - # the stored entity_id stands. See the helper's docstring. - constructed_id = construct_single_circuit_entity_id( - coordinator, - snapshot, - "switch", - "breaker", - circuit, - existing_entity_id=existing_entity_id, - ) - if constructed_id: - self.entity_id = constructed_id - self._update_is_on() # Store initial circuit name for change detection in auto-sync diff --git a/tests/test_circuit_control.py b/tests/test_circuit_control.py index a6f801db..d4796dc7 100644 --- a/tests/test_circuit_control.py +++ b/tests/test_circuit_control.py @@ -4,6 +4,8 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, PropertyMock +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError import pytest from span_panel_api.exceptions import SpanPanelServerError @@ -12,8 +14,6 @@ SpanPanelCircuitsSwitch, async_setup_entry, ) -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory @@ -394,7 +394,13 @@ def test_switch_existing_entity_uses_solar_fallback_name(hass: HomeAssistant) -> def test_switch_initial_install_uses_circuit_numbers_when_enabled( hass: HomeAssistant, ) -> None: - """Initial switch names should honor the use-circuit-numbers option.""" + """The option decides the id base, and the displayed name is the panel's regardless. + + It used to decide the name too, on a first install only, because the name was + the only thing an id could be composed from. The base is composed from the + option directly now, so the name is free to be the one the panel reports -- + the same string an existing entity has always shown. + """ circuit = SpanCircuitSnapshotFactory.create( circuit_id="2", name="Kitchen", @@ -415,13 +421,19 @@ def test_switch_initial_install_uses_circuit_numbers_when_enabled( ) switch = SpanPanelCircuitsSwitch(coordinator, "2", "Kitchen", "SPAN Panel") - assert switch.name == "Circuit 2 3 Breaker" + assert switch.name == "Kitchen Breaker" + assert switch.suggested_object_id == "Circuit 2 3 breaker" -def test_switch_initial_install_without_name_lets_ha_default( +def test_switch_initial_install_without_name_falls_back_to_the_tabs( hass: HomeAssistant, ) -> None: - """Unnamed switches on first install should let HA provide the name.""" + """An unnamed circuit is named for its breaker position, not left to HA. + + Letting HA default it gave every unnamed circuit on the panel the same name + and the same id stem, which the registry then disambiguated with `_2`, `_3`, + ... in whatever order the platform happened to add them. + """ circuit = SpanCircuitSnapshotFactory.create( circuit_id="3", name="", @@ -440,7 +452,8 @@ def test_switch_initial_install_without_name_lets_ha_default( ) switch = SpanPanelCircuitsSwitch(coordinator, "3", "", "SPAN Panel") - assert switch._attr_name is None + assert switch.name == "Circuit 3 Breaker" + assert switch.suggested_object_id == "Circuit 3 breaker" assert switch._previous_circuit_name is not None @@ -623,10 +636,11 @@ def test_switch_relay_state_target_absent_when_none() -> None: def test_switch_circuit_numbers_entity_id_stable_after_reload( hass: HomeAssistant, ) -> None: - """Entity_id stays circuit-based while the displayed name follows the panel. + """The base stays circuit-based while the displayed name follows the panel. - Phase 1 names the entity for the mode; phase 2 replaces that with the panel's - name. The entity_id is preset either way, so it does not follow the name. + One name path in both modes -- the panel's -- and one id path: the base + handed to Core, which is composed from the naming flag and never from the + name. The two are decoupled, so the display name cannot move an id. """ circuit = SpanCircuitSnapshotFactory.create( circuit_id="2", @@ -651,8 +665,8 @@ def test_switch_circuit_numbers_entity_id_stable_after_reload( coordinator, "2", "Air Conditioner", "SPAN Panel" ) - assert switch.name == "Circuit 15 17 Breaker" - assert switch.entity_id == "switch.span_panel_circuit_15_17_breaker" + assert switch.name == "Air Conditioner Breaker" + assert switch.suggested_object_id == "Circuit 15 17 breaker" # --- After reload: entity EXISTS in registry --- with pytest.MonkeyPatch.context() as mp: @@ -668,16 +682,16 @@ def test_switch_circuit_numbers_entity_id_stable_after_reload( coordinator, "2", "Air Conditioner", "SPAN Panel" ) - # Phase 2: the panel's name, carried by original_name rather than the - # registry's `name`, which would outrank the preset id. + # The panel's name, carried by original_name rather than the registry's + # `name`, which would outrank the base. assert switch2.name == "Air Conditioner Breaker" - assert switch2.entity_id == "switch.span_panel_circuit_15_17_breaker" + assert switch2.suggested_object_id == "Circuit 15 17 breaker" def test_switch_circuit_numbers_entity_id_120v_single_tab( hass: HomeAssistant, ) -> None: - """120V single-tab circuit should produce entity_id with one tab number.""" + """120V single-tab circuit should produce a base with one tab number.""" circuit = SpanCircuitSnapshotFactory.create( circuit_id="5", name="Kitchen Outlets", @@ -700,8 +714,8 @@ def test_switch_circuit_numbers_entity_id_120v_single_tab( coordinator, "5", "Kitchen Outlets", "SPAN Panel" ) - assert switch.name == "Circuit 10 Breaker" - assert switch.entity_id == "switch.span_panel_circuit_10_breaker" + assert switch.name == "Kitchen Outlets Breaker" + assert switch.suggested_object_id == "Circuit 10 breaker" def test_switch_circuit_numbers_releases_the_synced_registry_name( diff --git a/tests/test_naming.py b/tests/test_naming.py index a88a6e45..d9942312 100644 --- a/tests/test_naming.py +++ b/tests/test_naming.py @@ -99,6 +99,7 @@ def test_every_form_table_entry_has_a_new_wording_and_vice_versa() -> None: from custom_components.span_panel.naming import ( LEGACY_PRESET_DEVICE_NAME, legacy_preset_entity_id, + legacy_preset_for_existing, ) @@ -183,6 +184,88 @@ def test_the_preset_device_name_is_the_literal_the_old_builder_fell_back_to() -> assert LEGACY_PRESET_DEVICE_NAME == "Span Panel" +# --- Who is offered the kept shape, and who composes ------------------------- +# +# The same decision for every platform: the sensors, the breaker switch and the +# priority select all ask this one question rather than each keeping a copy of +# the answer. A copy is how three platforms drift apart. + + +def test_a_new_entity_composes_whatever_the_install_looks_like() -> None: + """There is no id to protect, so nothing is kept and Core composes.""" + assert ( + legacy_preset_for_existing( + "switch", + identifier="Kitchen Outlets", + suffix="breaker", + existing_entity_id=None, + use_device_prefix=False, + is_sub_device=True, + ) + is None + ) + + +def test_an_ordinary_existing_entity_composes_too() -> None: + """The device prefix is on and the entity is on the panel: composition agrees.""" + assert ( + legacy_preset_for_existing( + "switch", + identifier="Kitchen Outlets", + suffix="breaker", + existing_entity_id="switch.span_panel_kitchen_outlets_breaker", + use_device_prefix=True, + is_sub_device=False, + ) + is None + ) + + +def test_an_existing_entity_on_a_no_prefix_install_keeps_its_bare_id() -> None: + """`has_entity_name` prefixes the device unconditionally; these ids never did.""" + assert ( + legacy_preset_for_existing( + "select", + identifier="Kitchen Outlets", + suffix="circuit_priority", + existing_entity_id="select.kitchen_outlets_circuit_priority", + use_device_prefix=False, + is_sub_device=False, + ) + == "select.kitchen_outlets_circuit_priority" + ) + + +def test_an_existing_sub_device_entity_keeps_the_panel_prefix() -> None: + """Composition would name the charger; the id names the panel and goes on doing so.""" + assert ( + legacy_preset_for_existing( + "sensor", + identifier="Kitchen Outlets", + suffix="power", + existing_entity_id="sensor.span_panel_kitchen_outlets_power", + use_device_prefix=True, + is_sub_device=True, + ) + == "sensor.span_panel_kitchen_outlets_power" + ) + + +def test_the_kept_id_still_follows_a_circuit_rename() -> None: + """Computed from current panel data, so #252 survives the exception.""" + assert ( + legacy_preset_for_existing( + "switch", + identifier="Beer Fridge", + suffix="breaker", + existing_entity_id="switch.kitchen_outlets_breaker", + use_device_prefix=False, + is_sub_device=False, + ) + == "switch.beer_fridge_breaker" + ) + + from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er diff --git a/tests/test_recreate_entity_ids.py b/tests/test_recreate_entity_ids.py index 59985385..69005ef9 100644 --- a/tests/test_recreate_entity_ids.py +++ b/tests/test_recreate_entity_ids.py @@ -22,12 +22,12 @@ from unittest.mock import MagicMock -import pytest from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers import area_registry as ar, device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_registry import EntityNamePart +import pytest from pytest_homeassistant_custom_component.common import ( MockConfigEntry, MockEntityPlatform, @@ -41,8 +41,14 @@ ) from custom_components.span_panel.id_builder import ( build_circuit_unique_id, + build_select_unique_id, + build_switch_unique_id, preserve_legacy_entity_id_suffix, ) +from custom_components.span_panel.select import ( + CIRCUIT_PRIORITY_DESCRIPTION, + SpanPanelCircuitsSelect, +) from custom_components.span_panel.sensor_circuit import ( SpanCircuitEnergySensor, SpanCircuitPowerSensor, @@ -140,9 +146,61 @@ async def load(self, circuit_name: str) -> SpanCircuitPowerSensor: return sensor +class _SwitchInstall(_Install): + """One install of the breaker switch, reloadable the same way.""" + + async def load(self, circuit_name: str) -> SpanPanelCircuitsSwitch: # type: ignore[override] + """Tear down any previous platform, then set one up from fresh panel data.""" + if self._platform is not None: + await self._platform.async_reset() + + snapshot = _snapshot(circuit_name) + coordinator = _coordinator(self._hass, snapshot, self._entry) + self._entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, panel_device_id="panel-device-id" + ) + + self._platform = MockEntityPlatform(self._hass, domain="switch", platform_name=DOMAIN) + self._platform.config_entry = self._entry + + switch = SpanPanelCircuitsSwitch(coordinator, CIRCUIT_ID, circuit_name, "SPAN Panel") + await self._platform.async_add_entities([switch]) + await self._hass.async_block_till_done() + + assert switch.hass is not None, "entity was rejected before it reached the registry" + return switch + + +class _SelectInstall(_Install): + """One install of the circuit-priority select, reloadable the same way.""" + + async def load(self, circuit_name: str) -> SpanPanelCircuitsSelect: # type: ignore[override] + """Tear down any previous platform, then set one up from fresh panel data.""" + if self._platform is not None: + await self._platform.async_reset() + + snapshot = _snapshot(circuit_name) + coordinator = _coordinator(self._hass, snapshot, self._entry) + self._entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, panel_device_id="panel-device-id" + ) + + self._platform = MockEntityPlatform(self._hass, domain="select", platform_name=DOMAIN) + self._platform.config_entry = self._entry + + select = SpanPanelCircuitsSelect( + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, CIRCUIT_ID, circuit_name, "SPAN Panel" + ) + await self._platform.async_add_entities([select]) + await self._hass.async_block_till_done() + + assert select.hass is not None, "entity was rejected before it reached the registry" + return select + + @pytest.fixture def entry(hass: HomeAssistant) -> MockConfigEntry: - """A config entry in friendly-names mode.""" + """Return a config entry in friendly-names mode.""" config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "192.168.1.50", "device_name": "SPAN Panel"}, @@ -812,3 +870,193 @@ def test_a_suffix_with_no_older_spelling_is_left_alone() -> None: ) == "sensor.span_panel_kitchen_consumed_energy" ) + + +# --- The two circuit controls ------------------------------------------------- +# +# The breaker switch and the priority select are the other two circuit entities +# whose ids this integration used to spell out in full. They take the same route +# as the sensors: one base, Core composes, and the only exception is the id shape +# composition cannot reproduce. + +SWITCH_ENTITY_ID = "switch.span_panel_circuit_15_breaker" +SELECT_ENTITY_ID = "select.span_panel_circuit_15_circuit_priority" + + +async def test_a_new_switch_and_select_compose_todays_ids( + hass: HomeAssistant, entry: MockConfigEntry, device_and_entity_parts: None +) -> None: + """Composition spells both exactly as the preset builder used to.""" + hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS)) + switch = await _SwitchInstall(hass, entry).load(ORIGINAL_NAME) + select = await _SelectInstall(hass, entry).load(ORIGINAL_NAME) + + assert switch.entity_id == SWITCH_ENTITY_ID + assert select.entity_id == SELECT_ENTITY_ID + + +async def test_switch_and_select_are_offered_their_own_ids_after_a_rename_in_circuit_numbers_mode( + hass: HomeAssistant, entry: MockConfigEntry, device_and_entity_parts: None +) -> None: + """#252's other half: the name follows the panel and the id does not move.""" + hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS)) + switches = _SwitchInstall(hass, entry) + await switches.load(ORIGINAL_NAME) + switch = await switches.load(RENAMED) + selects = _SelectInstall(hass, entry) + await selects.load(ORIGINAL_NAME) + select = await selects.load(RENAMED) + + registry = er.async_get(hass) + + assert switch.entity_id == SWITCH_ENTITY_ID + switch_entry = registry.async_get(switch.entity_id) + assert switch_entry is not None + assert switch_entry.original_name == f"{RENAMED} Breaker" + assert registry.async_regenerate_entity_id(switch_entry) == switch.entity_id + + assert select.entity_id == SELECT_ENTITY_ID + select_entry = registry.async_get(select.entity_id) + assert select_entry is not None + assert select_entry.original_name == f"{RENAMED} Circuit Priority" + assert registry.async_regenerate_entity_id(select_entry) == select.entity_id + + +async def test_switch_and_select_release_the_name_an_older_release_wrote( + hass: HomeAssistant, entry: MockConfigEntry, device_and_entity_parts: None +) -> None: + """2.0.8 wrote the panel's name into the registry's `name` for these two as well.""" + hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS)) + registry = er.async_get(hass) + + switches = _SwitchInstall(hass, entry) + switch = await switches.load(ORIGINAL_NAME) + registry.async_update_entity(switch.entity_id, name=f"{ORIGINAL_NAME} Breaker") + await switches.load(ORIGINAL_NAME) + + selects = _SelectInstall(hass, entry) + select = await selects.load(ORIGINAL_NAME) + registry.async_update_entity(select.entity_id, name=f"{ORIGINAL_NAME} Circuit Priority") + await selects.load(ORIGINAL_NAME) + + switch_entry = registry.async_get(switch.entity_id) + assert switch_entry is not None + assert switch_entry.name is None + assert registry.async_regenerate_entity_id(switch_entry) == SWITCH_ENTITY_ID + + select_entry = registry.async_get(select.entity_id) + assert select_entry is not None + assert select_entry.name is None + assert registry.async_regenerate_entity_id(select_entry) == SELECT_ENTITY_ID + + +async def test_a_name_the_user_set_on_a_control_is_never_released( + hass: HomeAssistant, entry: MockConfigEntry, device_and_entity_parts: None +) -> None: + """Only a name this integration would have written is handed back.""" + hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS)) + registry = er.async_get(hass) + + switches = _SwitchInstall(hass, entry) + switch = await switches.load(ORIGINAL_NAME) + registry.async_update_entity(switch.entity_id, name="Beverage Cooling") + + await switches.load(ORIGINAL_NAME) + + switch_entry = registry.async_get(switch.entity_id) + assert switch_entry is not None + assert switch_entry.name == "Beverage Cooling" + + +async def test_an_existing_switch_on_a_no_prefix_install_is_offered_its_own_id( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """The pre-1.0.4 shape reaches the controls too, and composition cannot produce it.""" + hass.config_entries.async_update_entry(entry, options=dict(LEGACY_NAMES)) + registry = er.async_get(hass) + seeded = registry.async_get_or_create( + "switch", + DOMAIN, + build_switch_unique_id(SERIAL, CIRCUIT_ID), + suggested_object_id="refrigerator_breaker", + config_entry=entry, + ) + assert seeded.entity_id == "switch.refrigerator_breaker" + + install = _SwitchInstall(hass, entry) + switch = await install.load(ORIGINAL_NAME) + await install.load(ORIGINAL_NAME) + + assert switch.entity_id == seeded.entity_id + + registry_entry = registry.async_get(seeded.entity_id) + assert registry_entry is not None + assert registry.async_regenerate_entity_id(registry_entry) == seeded.entity_id + + +async def test_an_existing_select_on_a_no_prefix_install_is_offered_its_own_id( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """The same shape, kept for the same reason.""" + hass.config_entries.async_update_entry(entry, options=dict(LEGACY_NAMES)) + registry = er.async_get(hass) + seeded = registry.async_get_or_create( + "select", + DOMAIN, + build_select_unique_id(SERIAL, CIRCUIT_ID), + suggested_object_id="refrigerator_circuit_priority", + config_entry=entry, + ) + assert seeded.entity_id == "select.refrigerator_circuit_priority" + + install = _SelectInstall(hass, entry) + select = await install.load(ORIGINAL_NAME) + await install.load(ORIGINAL_NAME) + + assert select.entity_id == seeded.entity_id + + registry_entry = registry.async_get(seeded.entity_id) + assert registry_entry is not None + assert registry.async_regenerate_entity_id(registry_entry) == seeded.entity_id + + +async def test_a_new_control_on_a_no_prefix_install_composes_like_every_other_entity( + hass: HomeAssistant, entry: MockConfigEntry, device_and_entity_parts: None +) -> None: + """The exception is for ids that exist; nothing new inherits the prefix-less shape.""" + hass.config_entries.async_update_entry(entry, options=dict(LEGACY_NAMES)) + + switch = await _SwitchInstall(hass, entry).load(ORIGINAL_NAME) + select = await _SelectInstall(hass, entry).load(ORIGINAL_NAME) + + assert switch.entity_id == "switch.span_panel_refrigerator_breaker" + assert select.entity_id == "select.span_panel_refrigerator_circuit_priority" + + +async def test_an_area_reaches_the_proposal_for_a_control_too( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """R2: the controls stop exempting themselves from the user's `entity_id_parts`. + + A preset id could not carry an area. Handing Core a base means the switch is + composed exactly like every other integration's entity -- and only where the + user has actually assigned one. + """ + hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS)) + switch = await _SwitchInstall(hass, entry).load(ORIGINAL_NAME) + assert switch.entity_id == SWITCH_ENTITY_ID # no area yet: identical to today + + area = ar.async_get(hass).async_get_or_create("Basement") + device_registry = dr.async_get(hass) + device = device_registry.async_get_device(identifiers={(DOMAIN, SERIAL)}) + assert device is not None + device_registry.async_update_device(device.id, area_id=area.id) + + registry = er.async_get(hass) + registry_entry = registry.async_get(SWITCH_ENTITY_ID) + assert registry_entry is not None + assert registry_entry.entity_id == SWITCH_ENTITY_ID # R5: nothing moved + assert ( + registry.async_regenerate_entity_id(registry_entry) + == "switch.basement_span_panel_circuit_15_breaker" + ) diff --git a/tests/test_select.py b/tests/test_select.py index d4153c74..9320d792 100644 --- a/tests/test_select.py +++ b/tests/test_select.py @@ -3,6 +3,8 @@ from dataclasses import replace from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceNotFound import pytest from span_panel_api import PublishOutcome, PublishState from span_panel_api.exceptions import SpanPanelServerError @@ -14,8 +16,6 @@ SpanPanelCircuitsSelect, async_setup_entry, ) -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceNotFound from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory @@ -219,8 +219,13 @@ async def test_async_select_option_without_priority_support_returns_early( coordinator.async_request_refresh.assert_not_awaited() -def test_select_uses_circuit_number_name_when_option_enabled() -> None: - """Number-based naming should use breaker tabs when configured.""" +def test_select_uses_circuit_numbers_for_the_base_when_the_option_is_enabled() -> None: + """The option decides the id base; the displayed name is the panel's regardless. + + It used to decide the name too, on a first install only, because the name was + the only thing an id could be composed from. The base is composed from the + option directly now. + """ coordinator = _make_coordinator_with_circuit() coordinator.config_entry.options = {"use_circuit_numbers": True} coordinator.hass = MagicMock() @@ -236,11 +241,17 @@ def test_select_uses_circuit_number_name_when_option_enabled() -> None: coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "Kitchen", "SPAN Panel" ) - assert select.name == "Circuit 1 Circuit Priority" + assert select.name == "name Circuit Priority" + assert select.suggested_object_id == "Circuit 1 circuit priority" + +def test_select_unnamed_friendly_mode_falls_back_to_the_tabs() -> None: + """An unnamed circuit is named for its breaker position, not left to HA. -def test_select_unnamed_friendly_mode_leaves_name_none() -> None: - """Unnamed selects in friendly-name mode should defer to HA naming.""" + Deferring gave every unnamed circuit on the panel the same name and the same + id stem, which the registry then disambiguated with `_2`, `_3`, ... in + whatever order the platform happened to add them. + """ coordinator = _make_coordinator_with_circuit(circuit_name="") coordinator.hass = MagicMock() @@ -255,7 +266,8 @@ def test_select_unnamed_friendly_mode_leaves_name_none() -> None: coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "", "SPAN Panel" ) - assert select.name is None + assert select.name == "Circuit 1 Circuit Priority" + assert select.suggested_object_id == "Circuit 1 circuit priority" def test_select_existing_entity_uses_solar_fallback_name() -> None: @@ -549,7 +561,7 @@ async def test_async_setup_entry_skips_circuits_whose_priority_is_not_settable() def test_select_circuit_numbers_entity_id_stable_after_reload( hass: HomeAssistant, ) -> None: - """Entity_id must stay circuit-based after name sync sets friendly display name.""" + """The base must stay circuit-based after name sync sets the friendly display name.""" circuit = SpanCircuitSnapshotFactory.create( circuit_id="2", name="Air Conditioner", @@ -581,8 +593,8 @@ def test_select_circuit_numbers_entity_id_stable_after_reload( "SPAN Panel", ) - assert select.name == "Circuit 15 17 Circuit Priority" - assert select.entity_id == "select.span_panel_circuit_15_17_circuit_priority" + assert select.name == "Air Conditioner Circuit Priority" + assert select.suggested_object_id == "Circuit 15 17 circuit priority" # --- After reload: entity EXISTS in registry --- with pytest.MonkeyPatch.context() as mp: @@ -602,16 +614,16 @@ def test_select_circuit_numbers_entity_id_stable_after_reload( "SPAN Panel", ) - # Phase 2: the panel's name, carried by original_name rather than the - # registry's `name`, which would outrank the preset id. + # The panel's name, carried by original_name rather than the registry's + # `name`, which would outrank the base. assert select2.name == "Air Conditioner Circuit Priority" - assert select2.entity_id == "select.span_panel_circuit_15_17_circuit_priority" + assert select2.suggested_object_id == "Circuit 15 17 circuit priority" def test_select_circuit_numbers_entity_id_120v_single_tab( hass: HomeAssistant, ) -> None: - """120V single-tab circuit should produce entity_id with one tab number.""" + """120V single-tab circuit should produce a base with one tab number.""" circuit = SpanCircuitSnapshotFactory.create( circuit_id="5", name="Kitchen Outlets", @@ -642,8 +654,8 @@ def test_select_circuit_numbers_entity_id_120v_single_tab( "SPAN Panel", ) - assert select.name == "Circuit 10 Circuit Priority" - assert select.entity_id == "select.span_panel_circuit_10_circuit_priority" + assert select.name == "Kitchen Outlets Circuit Priority" + assert select.suggested_object_id == "Circuit 10 circuit priority" def test_select_coordinator_update_circuit_numbers_requests_reload( From f8ab17c9583039bd198455984b14dbc5061d9963 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:15:20 -0700 Subject: [PATCH 153/345] test(naming): one generic install helper for the four circuit platforms Four platforms now take the same route into the entity registry, and the helper that drove them had four copies of it: each of the switch, select and legacy-energy installs carried its own `load` differing from the sensor's only in the domain string and the constructor call, plus a `type: ignore[override]` to excuse a return type the base had fixed to one platform's entity. `_Install` becomes generic over the entity it installs and owns the one `load`. A subclass declares a `_domain` and a `_build`, so a change to the path under test -- the real `EntityPlatform` add, the `async_reset` teardown -- reaches all four at once instead of three of them drifting. The legacy install keeps its registry seeding as its own hook, since several of its cases exist to test the unseeded path. No assertion changed. The power-sensor install is now `_SensorInstall`, the base name having gone to the generic; its call sites moved with it. --- tests/test_recreate_entity_ids.py | 206 +++++++++++++++--------------- 1 file changed, 104 insertions(+), 102 deletions(-) diff --git a/tests/test_recreate_entity_ids.py b/tests/test_recreate_entity_ids.py index 69005ef9..39c09a50 100644 --- a/tests/test_recreate_entity_ids.py +++ b/tests/test_recreate_entity_ids.py @@ -20,18 +20,21 @@ from __future__ import annotations +from typing import ClassVar from unittest.mock import MagicMock from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers import area_registry as ar, device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_registry import EntityNamePart import pytest from pytest_homeassistant_custom_component.common import ( MockConfigEntry, MockEntityPlatform, ) +from span_panel_api import SpanPanelSnapshot from custom_components.span_panel import SpanPanelRuntimeData from custom_components.span_panel.const import ( @@ -75,7 +78,7 @@ POWER_DESCRIPTION = next(desc for desc in CIRCUIT_SENSORS if desc.key == "circuit_power") -def _snapshot(circuit_name: str): +def _snapshot(circuit_name: str) -> SpanPanelSnapshot: """Build a one-circuit panel snapshot with the circuit named as given.""" circuit = SpanCircuitSnapshotFactory.create( circuit_id=CIRCUIT_ID, name=circuit_name, tabs=[15] @@ -83,7 +86,9 @@ def _snapshot(circuit_name: str): return SpanPanelSnapshotFactory.create(serial_number=SERIAL, circuits={CIRCUIT_ID: circuit}) -def _coordinator(hass: HomeAssistant, snapshot, entry: MockConfigEntry) -> MagicMock: +def _coordinator( + hass: HomeAssistant, snapshot: SpanPanelSnapshot, entry: MockConfigEntry +) -> MagicMock: """Build a coordinator standing in for a live one, bound to the real hass.""" coordinator = MagicMock() coordinator.hass = hass @@ -96,8 +101,8 @@ def _coordinator(hass: HomeAssistant, snapshot, entry: MockConfigEntry) -> Magic return coordinator -class _Install: - """One install of the sensor platform, reloadable. +class _Install[E: Entity]: + """One install of one platform's circuit entity, reloadable. `load` a second time is what a reload is: the entry's entities are torn down and rebuilt from the current snapshot, against the entity registry that @@ -105,20 +110,27 @@ class _Install: `EntityPlatform` path, so a preset entity_id travels the same route into `async_get_or_create` that it does in a running install, and the teardown is the same `async_reset` an entry unload performs. + + Four platforms take that route and it is the same route for all four, so + there is one `load` and each subclass says only what differs: the domain it + registers under and the entity it constructs. A subclass that owned a copy of + `load` instead would be free to drift away from the path under test, which is + the one thing these cases exist to exercise. """ - def __init__( - self, - hass: HomeAssistant, - entry: MockConfigEntry, - device_info_override: DeviceInfo | None = None, - ) -> None: + _domain: ClassVar[str] + """The entity platform domain this install registers under.""" + + def __init__(self, hass: HomeAssistant, entry: MockConfigEntry) -> None: self._hass = hass self._entry = entry - self._device_info_override = device_info_override self._platform: MockEntityPlatform | None = None - async def load(self, circuit_name: str) -> SpanCircuitPowerSensor: + def _build(self, coordinator: MagicMock, snapshot: SpanPanelSnapshot, circuit_name: str) -> E: + """Construct the one entity this install adds. Subclasses answer.""" + raise NotImplementedError + + async def load(self, circuit_name: str) -> E: """Tear down any previous platform, then set one up from fresh panel data.""" if self._platform is not None: await self._platform.async_reset() @@ -129,73 +141,74 @@ async def load(self, circuit_name: str) -> SpanCircuitPowerSensor: coordinator=coordinator, panel_device_id="panel-device-id" ) - self._platform = MockEntityPlatform(self._hass, domain="sensor", platform_name=DOMAIN) + self._platform = MockEntityPlatform( + self._hass, domain=self._domain, platform_name=DOMAIN + ) self._platform.config_entry = self._entry - sensor = SpanCircuitPowerSensor( - coordinator, - POWER_DESCRIPTION, - snapshot, - CIRCUIT_ID, - device_info_override=self._device_info_override, - ) - await self._platform.async_add_entities([sensor]) + entity = self._build(coordinator, snapshot, circuit_name) + await self._platform.async_add_entities([entity]) await self._hass.async_block_till_done() - assert sensor.hass is not None, "entity was rejected before it reached the registry" - return sensor + assert entity.hass is not None, "entity was rejected before it reached the registry" + return entity -class _SwitchInstall(_Install): - """One install of the breaker switch, reloadable the same way.""" +class _SensorInstall(_Install[SpanCircuitPowerSensor]): + """One install of the circuit power sensor. - async def load(self, circuit_name: str) -> SpanPanelCircuitsSwitch: # type: ignore[override] - """Tear down any previous platform, then set one up from fresh panel data.""" - if self._platform is not None: - await self._platform.async_reset() + The only one of the four that can be shown on a sub-device's card, so it is + the only one that takes a `device_info_override`. + """ - snapshot = _snapshot(circuit_name) - coordinator = _coordinator(self._hass, snapshot, self._entry) - self._entry.runtime_data = SpanPanelRuntimeData( - coordinator=coordinator, panel_device_id="panel-device-id" - ) + _domain: ClassVar[str] = "sensor" - self._platform = MockEntityPlatform(self._hass, domain="switch", platform_name=DOMAIN) - self._platform.config_entry = self._entry + def __init__( + self, + hass: HomeAssistant, + entry: MockConfigEntry, + device_info_override: DeviceInfo | None = None, + ) -> None: + super().__init__(hass, entry) + self._device_info_override = device_info_override - switch = SpanPanelCircuitsSwitch(coordinator, CIRCUIT_ID, circuit_name, "SPAN Panel") - await self._platform.async_add_entities([switch]) - await self._hass.async_block_till_done() + def _build( + self, coordinator: MagicMock, snapshot: SpanPanelSnapshot, circuit_name: str + ) -> SpanCircuitPowerSensor: + """Build the power sensor, on the panel's card or a sub-device's.""" + return SpanCircuitPowerSensor( + coordinator, + POWER_DESCRIPTION, + snapshot, + CIRCUIT_ID, + device_info_override=self._device_info_override, + ) - assert switch.hass is not None, "entity was rejected before it reached the registry" - return switch +class _SwitchInstall(_Install[SpanPanelCircuitsSwitch]): + """One install of the breaker switch.""" -class _SelectInstall(_Install): - """One install of the circuit-priority select, reloadable the same way.""" + _domain: ClassVar[str] = "switch" - async def load(self, circuit_name: str) -> SpanPanelCircuitsSelect: # type: ignore[override] - """Tear down any previous platform, then set one up from fresh panel data.""" - if self._platform is not None: - await self._platform.async_reset() + def _build( + self, coordinator: MagicMock, snapshot: SpanPanelSnapshot, circuit_name: str + ) -> SpanPanelCircuitsSwitch: + """Build the breaker switch.""" + return SpanPanelCircuitsSwitch(coordinator, CIRCUIT_ID, circuit_name, "SPAN Panel") - snapshot = _snapshot(circuit_name) - coordinator = _coordinator(self._hass, snapshot, self._entry) - self._entry.runtime_data = SpanPanelRuntimeData( - coordinator=coordinator, panel_device_id="panel-device-id" - ) - self._platform = MockEntityPlatform(self._hass, domain="select", platform_name=DOMAIN) - self._platform.config_entry = self._entry +class _SelectInstall(_Install[SpanPanelCircuitsSelect]): + """One install of the circuit-priority select.""" - select = SpanPanelCircuitsSelect( + _domain: ClassVar[str] = "select" + + def _build( + self, coordinator: MagicMock, snapshot: SpanPanelSnapshot, circuit_name: str + ) -> SpanPanelCircuitsSelect: + """Build the circuit-priority select.""" + return SpanPanelCircuitsSelect( coordinator, CIRCUIT_PRIORITY_DESCRIPTION, CIRCUIT_ID, circuit_name, "SPAN Panel" ) - await self._platform.async_add_entities([select]) - await self._hass.async_block_till_done() - - assert select.hass is not None, "entity was rejected before it reached the registry" - return select @pytest.fixture @@ -230,7 +243,7 @@ async def test_the_first_install_takes_its_entity_id_from_the_circuit_name( hass: HomeAssistant, entry: MockConfigEntry ) -> None: """Baseline for every case below: the ID before any rename.""" - sensor = await _Install(hass, entry).load(ORIGINAL_NAME) + sensor = await _SensorInstall(hass, entry).load(ORIGINAL_NAME) assert sensor.entity_id == ORIGINAL_ENTITY_ID @@ -243,7 +256,7 @@ async def test_renaming_a_circuit_does_not_move_an_existing_entity_id( Dashboards, automations, and recorder history all key off the entity_id. Recreate is an offer the user accepts; a rename is not. """ - install = _Install(hass, entry) + install = _SensorInstall(hass, entry) await install.load(ORIGINAL_NAME) sensor = await install.load(RENAMED) @@ -263,7 +276,7 @@ async def test_renaming_a_circuit_does_not_move_the_unique_id( it would break any future migration that has to predict what a unique_id looks like. """ - install = _Install(hass, entry) + install = _SensorInstall(hass, entry) before = await install.load(ORIGINAL_NAME) unique_id_before = before.unique_id @@ -287,7 +300,7 @@ async def test_renaming_a_circuit_refreshes_the_registrys_entity_id_suggestion( *base*, not a whole object id: Core prefixes the device and, where the user has assigned one, the area. """ - install = _Install(hass, entry) + install = _SensorInstall(hass, entry) await install.load(ORIGINAL_NAME) await install.load(RENAMED) @@ -302,7 +315,7 @@ async def test_recreate_entity_ids_proposes_the_renamed_id( hass: HomeAssistant, entry: MockConfigEntry ) -> None: """Issue #252 itself, at the API the button calls.""" - install = _Install(hass, entry) + install = _SensorInstall(hass, entry) await install.load(ORIGINAL_NAME) await install.load(RENAMED) @@ -325,7 +338,7 @@ async def test_an_unrenamed_circuit_is_offered_its_own_entity_id( that moves on every reload would satisfy it while offering every user a pointless rename. """ - install = _Install(hass, entry) + install = _SensorInstall(hass, entry) await install.load(ORIGINAL_NAME) await install.load(ORIGINAL_NAME) @@ -350,7 +363,7 @@ async def test_circuit_numbers_mode_is_offered_its_own_tab_based_id( """ hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS)) - install = _Install(hass, entry) + install = _SensorInstall(hass, entry) await install.load(ORIGINAL_NAME) sensor = await install.load(RENAMED) @@ -369,7 +382,7 @@ async def test_circuit_numbers_mode_still_shows_the_panels_name( """Phase 2 sync still follows the panel -- through a field that cannot move an id.""" hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS)) - install = _Install(hass, entry) + install = _SensorInstall(hass, entry) await install.load(ORIGINAL_NAME) sensor = await install.load(RENAMED) @@ -388,7 +401,7 @@ async def test_circuit_numbers_mode_releases_a_name_an_older_release_wrote( """An install upgrading has the old scheme's name handed back to it.""" hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS)) - install = _Install(hass, entry) + install = _SensorInstall(hass, entry) sensor = await install.load(ORIGINAL_NAME) registry = er.async_get(hass) @@ -410,7 +423,7 @@ async def test_releasing_the_name_is_idempotent( """Two reloads in a row are the same as one. There is no migration to run twice.""" hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS)) - install = _Install(hass, entry) + install = _SensorInstall(hass, entry) sensor = await install.load(ORIGINAL_NAME) registry = er.async_get(hass) registry.async_update_entity(sensor.entity_id, name=f"{ORIGINAL_NAME} Power") @@ -435,7 +448,7 @@ async def test_a_name_the_user_set_is_never_released( """ hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS)) - install = _Install(hass, entry) + install = _SensorInstall(hass, entry) sensor = await install.load(ORIGINAL_NAME) registry = er.async_get(hass) @@ -455,7 +468,7 @@ async def test_circuit_numbers_mode_does_not_move_ids_across_the_change( """Neither id moves, whatever happens to the name.""" hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS)) - install = _Install(hass, entry) + install = _SensorInstall(hass, entry) before = await install.load(ORIGINAL_NAME) unique_id_before = before.unique_id @@ -491,11 +504,17 @@ async def test_circuit_numbers_mode_does_not_move_ids_across_the_change( ) -class _LegacyInstall(_Install): +class _LegacyInstall(_Install[SpanCircuitEnergySensor]): """An install whose energy sensor id was composed from the descriptor name.""" + _domain: ClassVar[str] = "sensor" + def _seed(self) -> str: - """Register the entity the way a pre-preset install left it.""" + """Register the entity the way a pre-preset install left it. + + Called by the cases that need one, not by `load`: the point of several of + them is what happens when there is nothing seeded at all. + """ registry = er.async_get(self._hass) unique_id = build_circuit_unique_id(SERIAL, CIRCUIT_ID, "consumedEnergyWh") entry = registry.async_get_or_create( @@ -508,28 +527,11 @@ def _seed(self) -> str: ) return entry.entity_id - async def load(self, circuit_name: str) -> SpanCircuitEnergySensor: # type: ignore[override] - """Set the platform up for the energy sensor, tearing down any previous one.""" - if self._platform is not None: - await self._platform.async_reset() - - snapshot = _snapshot(circuit_name) - coordinator = _coordinator(self._hass, snapshot, self._entry) - self._entry.runtime_data = SpanPanelRuntimeData( - coordinator=coordinator, panel_device_id="panel-device-id" - ) - - self._platform = MockEntityPlatform(self._hass, domain="sensor", platform_name=DOMAIN) - self._platform.config_entry = self._entry - - sensor = SpanCircuitEnergySensor( - coordinator, ENERGY_DESCRIPTION, snapshot, CIRCUIT_ID - ) - await self._platform.async_add_entities([sensor]) - await self._hass.async_block_till_done() - - assert sensor.hass is not None, "entity was rejected before it reached the registry" - return sensor + def _build( + self, coordinator: MagicMock, snapshot: SpanPanelSnapshot, circuit_name: str + ) -> SpanCircuitEnergySensor: + """Build the energy sensor, whose id has shipped with two suffix spellings.""" + return SpanCircuitEnergySensor(coordinator, ENERGY_DESCRIPTION, snapshot, CIRCUIT_ID) async def test_upgrading_does_not_offer_to_renormalise_a_legacy_suffix( @@ -674,7 +676,7 @@ async def test_an_area_the_user_assigned_reaches_the_proposal_under_default_part integration -- and only where the user has actually assigned an area. """ hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS)) - install = _Install(hass, entry) + install = _SensorInstall(hass, entry) sensor = await install.load(ORIGINAL_NAME) assert sensor.entity_id == CIRCUIT_NUMBERS_ENTITY_ID # no area yet: identical to today @@ -703,7 +705,7 @@ async def test_an_unnamed_circuit_in_friendly_mode_still_gets_a_distinct_id( circuit on the panel `sensor.span_panel_power`, and the registry would disambiguate them with `_2`, `_3`, ... in whatever order they were added. """ - sensor = await _Install(hass, entry).load("") + sensor = await _SensorInstall(hass, entry).load("") assert sensor.entity_id == "sensor.span_panel_circuit_15_power" @@ -745,7 +747,7 @@ async def test_an_existing_sub_device_sensor_is_offered_its_own_id( """An EVSE feed circuit's sensor is spelled with the panel, and stays so.""" seeded = _seed_power_entity(hass, entry, "span_panel_refrigerator_power") - install = _Install(hass, entry, device_info_override=EVSE_DEVICE_INFO) + install = _SensorInstall(hass, entry, device_info_override=EVSE_DEVICE_INFO) sensor = await install.load(ORIGINAL_NAME) await install.load(ORIGINAL_NAME) @@ -763,7 +765,7 @@ async def test_an_existing_sub_device_sensor_still_follows_a_rename( """Keeping the shape must not cost #252: the name half still tracks the panel.""" seeded = _seed_power_entity(hass, entry, "span_panel_refrigerator_power") - install = _Install(hass, entry, device_info_override=EVSE_DEVICE_INFO) + install = _SensorInstall(hass, entry, device_info_override=EVSE_DEVICE_INFO) await install.load(ORIGINAL_NAME) await install.load(RENAMED) @@ -780,7 +782,7 @@ async def test_an_existing_sensor_on_a_no_prefix_install_is_offered_its_own_id( hass.config_entries.async_update_entry(entry, options=dict(LEGACY_NAMES)) seeded = _seed_power_entity(hass, entry, "refrigerator_power") - install = _Install(hass, entry) + install = _SensorInstall(hass, entry) sensor = await install.load(ORIGINAL_NAME) await install.load(ORIGINAL_NAME) @@ -798,7 +800,7 @@ async def test_a_new_sensor_on_a_no_prefix_install_composes_like_every_other_ent """The preset is for ids that exist. Nothing new inherits the prefix-less shape.""" hass.config_entries.async_update_entry(entry, options=dict(LEGACY_NAMES)) - sensor = await _Install(hass, entry).load(ORIGINAL_NAME) + sensor = await _SensorInstall(hass, entry).load(ORIGINAL_NAME) assert sensor.entity_id == ORIGINAL_ENTITY_ID From 921f2beb2ef12b18110b54de82473b6c6bd51f13 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:32:14 -0700 Subject: [PATCH 154/345] refactor(naming): delete the preset id builders construct_single_circuit_entity_id and preserve_legacy_entity_id_suffix are replaced by naming.circuit_object_id_base; the multi-circuit and unmapped builders had no callers. Three things fell out with them. _generate_friendly_name was dead across the integration -- fourteen implementations existed only to satisfy an abstract declaration nothing called, since _attr_name has always come from _generate_panel_name. The `name` constructor parameter on the breaker switch and the priority select had already been documented as deliberately unused, so it goes rather than stay as a trap. And sensor_circuit's _LOGGER had no call sites left. LEGACY_ENTITY_ID_SUFFIXES is superseded by naming.ENTITY_ID_SUFFIX_FORMS, which records every wording an id has shipped with rather than only the pre-preset ones; the paragraph of the three suffix-mapping docstrings that pointed at it now points at naming.py. CIRCUIT_SUFFIX_MAPPING itself is untouched -- it builds unique ids and is closed. --- .../span_panel/entity_resolver.py | 302 +---------------- custom_components/span_panel/helpers.py | 8 - custom_components/span_panel/id_builder.py | 72 +---- custom_components/span_panel/naming.py | 10 +- custom_components/span_panel/select.py | 9 +- custom_components/span_panel/sensor_base.py | 15 - .../span_panel/sensor_circuit.py | 57 ---- custom_components/span_panel/sensor_evse.py | 6 - custom_components/span_panel/sensor_panel.py | 101 ------ custom_components/span_panel/switch.py | 12 +- tests/test_circuit_control.py | 76 ++--- tests/test_control_gate.py | 20 +- tests/test_energy_dip_compensation.py | 4 +- tests/test_grace_period_restoration.py | 4 +- tests/test_helpers.py | 304 +----------------- tests/test_naming.py | 25 +- tests/test_recreate_entity_ids.py | 42 +-- tests/test_select.py | 42 +-- tests/test_sensor_entities.py | 77 ++--- tests/test_suffix_mappings_are_closed.py | 34 ++ tests/test_upgrade_scenarios.py | 184 ----------- 21 files changed, 181 insertions(+), 1223 deletions(-) diff --git a/custom_components/span_panel/entity_resolver.py b/custom_components/span_panel/entity_resolver.py index d006705a..3ce4ad18 100644 --- a/custom_components/span_panel/entity_resolver.py +++ b/custom_components/span_panel/entity_resolver.py @@ -1,20 +1,17 @@ """Entity resolver functions for Span Panel integration. -This module contains functions that depend on the coordinator, entity registry, -or config entry options to resolve entity IDs and unique IDs. These are the -"entry-aware" wrappers around the pure ID builders in id_builder.py. +This module contains functions that depend on the coordinator or the config +entry to resolve unique IDs. These are the "entry-aware" wrappers around the +pure ID builders in id_builder.py. Entity ids are not built here: an entity's +id is composed by Home Assistant from the base `naming.py` supplies. """ from __future__ import annotations -import logging from typing import TYPE_CHECKING -from homeassistant.helpers import entity_registry as er -from homeassistant.util import slugify -from span_panel_api import SpanCircuitSnapshot, SpanEvseSnapshot, SpanPanelSnapshot +from span_panel_api import SpanEvseSnapshot, SpanPanelSnapshot -from .const import DOMAIN, USE_CIRCUIT_NUMBERS, USE_DEVICE_PREFIX from .id_builder import ( build_bess_unique_id, build_binary_sensor_unique_id, @@ -25,15 +22,11 @@ build_select_unique_id, build_switch_unique_id, construct_synthetic_unique_id, - preserve_legacy_entity_id_suffix, ) -from .util import snapshot_to_device_info if TYPE_CHECKING: from .coordinator import SpanPanelCoordinator -_LOGGER = logging.getLogger(__name__) - def resolve_evse_display_suffix( evse: SpanEvseSnapshot, @@ -176,291 +169,6 @@ def get_device_identifier_for_entry( return _get_device_identifier_for_unique_ids(coordinator, snapshot, device_name) -def construct_multi_circuit_entity_id( - coordinator: SpanPanelCoordinator, - snapshot: SpanPanelSnapshot, - platform: str, - suffix: str, - circuit_numbers: list[int], - friendly_name: str | None = None, - unique_id: str | None = None, -) -> str | None: - """Construct entity ID for multi-circuit sensors (like solar inverters). - - Args: - coordinator: The coordinator instance - snapshot: The panel snapshot data - platform: Platform name ("sensor", "switch", "select") - suffix: Entity-specific suffix ("power", "energy_produced", etc.) - circuit_numbers: List of circuit numbers this sensor combines - friendly_name: Descriptive name for this sensor (required if unique_id is None) - unique_id: The unique ID for this entity (None to skip registry lookup) - - Returns: - Constructed entity ID string or None if device info unavailable - - """ - # Check registry first only if unique_id is provided - if unique_id is not None: - entity_registry = er.async_get(coordinator.hass) - existing_entity_id = entity_registry.async_get_entity_id(platform, DOMAIN, unique_id) - - _LOGGER.debug( - "Multi-circuit helper registry lookup (switches/selects) - unique_id=%s, found_entity_id=%s", - unique_id, - existing_entity_id, - ) - - if existing_entity_id: - return existing_entity_id - # During migration, unique_id lookup should always succeed - raise ValueError( - f"Registry lookup failed for unique_id '{unique_id}' during migration. Entity should exist in registry." - ) - _LOGGER.debug( - "Multi-circuit helper (switches/selects) - no unique_id provided, skipping registry lookup" - ) - - # Get device name from config entry data - device_name = coordinator.config_entry.data.get("device_name", coordinator.config_entry.title) - if not device_name: - return None - - use_circuit_numbers = coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) - - # If no unique_id provided, friendly_name is required when not using circuit numbers - if unique_id is None and not use_circuit_numbers and not friendly_name: - _LOGGER.error( - "Friendly_name is required when unique_id is None and not using circuit numbers for multi-circuit entity" - ) - return None - - if use_circuit_numbers: - # Use circuit number pattern: sensor.span_panel_circuit_30_32_power - if circuit_numbers: - sorted_circuits = sorted([num for num in circuit_numbers if num > 0]) - else: - sorted_circuits = [] - if sorted_circuits: - if len(sorted_circuits) == 1: - circuit_part = f"circuit_{sorted_circuits[0]}" - else: - circuit_list = "_".join(str(num) for num in sorted_circuits) - circuit_part = f"circuit_{circuit_list}" - else: - raise ValueError( - f"Circuit-based naming is enabled but no valid circuit numbers provided. " - f"Got circuit_numbers={circuit_numbers}. Multi-circuit entities require valid circuit numbers when USE_CIRCUIT_NUMBERS is True." - ) - else: - # Use friendly name pattern: sensor.span_panel_solar_inverter_power - circuit_part = slugify(friendly_name) - - # Build the entity ID. - # `False` default preserves legacy installs (no device prefix). Single-circuit - # entities default to True — see construct_single_circuit_entity_id below — - # because they never had a prefix-less form historically; multi-circuit - # synthetics did. - use_device_prefix = coordinator.config_entry.options.get(USE_DEVICE_PREFIX, False) - parts = [] - - if use_device_prefix: - if device_name: - # Sanitize device name for entity ID use - sanitized_device_name = slugify(device_name) - parts.append(sanitized_device_name) - - parts.append(circuit_part) - - # Add suffix if not already in circuit_part - if suffix and not circuit_part.endswith(f"_{suffix}"): - parts.append(suffix) - - return f"{platform}.{'_'.join(parts)}" - - -def construct_single_circuit_entity_id( - coordinator: SpanPanelCoordinator, - snapshot: SpanPanelSnapshot, - platform: str, - suffix: str, - circuit_data: SpanCircuitSnapshot, - device_name: str | None = None, - existing_entity_id: str | None = None, -) -> str | None: - """Construct the entity ID the current panel data and naming flags produce. - - Always computed, never read back from the registry -- including for a circuit - that already has entities. Home Assistant treats an entity_id set before the - entity is added as a *suggestion*: `EntityPlatform` splits it into - `suggested_object_id`, hands that to `async_get_or_create`, and for a - unique_id already on file that call routes to `_async_update_entity` with no - `new_entity_id`, then reassigns `entity.entity_id` from the stored entry. A - live entity ID therefore cannot move from here; only the stored suggestion - changes. - - That suggestion is the field "Recreate entity IDs" regenerates from when the - registry holds no user `name` override, which in friendly-names mode is - always the case -- the panel name reaches the UI as `original_name`. Handing - back the stored entity ID froze the suggestion at whatever the circuit was - called on the day it was added, so a circuit renamed in the SPAN app was - offered its own ID and Recreate looked broken (issue #252). - - Args: - coordinator: The coordinator instance - snapshot: The panel snapshot data - platform: Platform name ("sensor", "switch", "select") - suffix: Entity-specific suffix ("power", "energy_produced", etc.) - circuit_data: Circuit data object - device_name: Device name for entity ID construction (None to use from config entry) - existing_entity_id: This entity's id in the registry, when it has one, so an - id predating the suffix mapping keeps the suffix it shipped with - - Returns: - Constructed entity ID string or None if device info unavailable - - """ - # Get device info - device_info = snapshot_to_device_info(snapshot, device_name) - if not device_info or not device_info.get("name"): - return None - - use_circuit_numbers = coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) - - if use_circuit_numbers: - # Every position the breaker occupies, joined in order: `circuit_30_32` - # for a two-pole breaker, `circuit_28` for a single-pole. Naming the - # positions rather than branching on how many there are keeps this - # consistent with `construct_tabs_attribute`, which formats the same - # list. A breaker occupying three or more is not something SPAN - # publishes -- it has stated its panels "are split-phase and publish - # only 1- or 2-pole breakers" -- so the wider form is unreachable on - # SPAN hardware and exists so unexpected input degrades into a readable - # id instead of falling through to the circuit UUID. - if circuit_data.tabs: - sorted_tabs = sorted(circuit_data.tabs) - circuit_part = "circuit_" + "_".join(str(tab) for tab in sorted_tabs) - else: - # No tabs available — use the API circuit_id as fallback - circuit_part = ( - f"circuit_{circuit_data.circuit_id}" - if circuit_data.circuit_id - else "circuit_unknown" - ) - # Use friendly name pattern: sensor.span_panel_solar_east_power - elif circuit_data.name: - circuit_part = slugify(circuit_data.name) - else: - circuit_part = "single_circuit" - - # Build the entity ID (only for non-voltage-specific cases) - use_device_prefix = coordinator.config_entry.options.get(USE_DEVICE_PREFIX, True) - parts = [] - - if use_device_prefix: - device_name = device_info.get("name") - if device_name: - # Sanitize device name for entity ID use - sanitized_device_name = slugify(device_name) - parts.append(sanitized_device_name) - - parts.append(circuit_part) - - # Add suffix if not already in circuit_part - if suffix and not circuit_part.endswith(f"_{suffix}"): - parts.append(suffix) - - # An entity created before the suffix mapping reached entity ids carries the - # older spelling. Renormalising it would offer a rename to every circuit on - # the panel, so the circuit-name half follows the panel and the suffix half - # stays as it shipped. - return preserve_legacy_entity_id_suffix( - f"{platform}.{'_'.join(parts)}", existing_entity_id, suffix - ) - - -def construct_unmapped_entity_id( - snapshot: SpanPanelSnapshot, - circuit_id: str, - suffix: str, - device_name: str | None = None, -) -> str: - """Construct entity ID for unmapped tab with consistent modern naming. - - Args: - snapshot: The panel snapshot data - circuit_id: Circuit ID (e.g., "unmapped_tab_32") - suffix: Sensor suffix (e.g., "power", "energy_produced") - device_name: The device name to use for entity ID construction - - Returns: - Entity ID string like "sensor.span_panel_unmapped_tab_32_power" - - """ - # Always use device prefix for unmapped entities - # circuit_id is "unmapped_tab_32", add device prefix and suffix to create - # "sensor.span_panel_unmapped_tab_32_power" - device_info = snapshot_to_device_info(snapshot, device_name) - device_name_raw = device_info.get("name") - _LOGGER.debug( - "construct_unmapped_entity_id: circuit_id=%s, suffix=%s, device_name_raw=%s", - circuit_id, - suffix, - device_name_raw, - ) - if device_name_raw: - # Sanitize device name for entity ID use - sanitized_device_name = slugify(device_name_raw) - result = f"sensor.{sanitized_device_name}_{circuit_id}_{suffix}" - _LOGGER.debug("construct_unmapped_entity_id result with device: %s", result) - return result - result = f"sensor.{circuit_id}_{suffix}" - _LOGGER.debug("construct_unmapped_entity_id result without device: %s", result) - return result - - -def get_unmapped_circuit_entity_id( - snapshot: SpanPanelSnapshot, - tab_number: int, - suffix: str, - device_name: str | None = None, -) -> str | None: - """Get entity ID for an unmapped circuit based on tab number. - - This helper function constructs the entity ID for native unmapped circuit sensors - that should already exist in Home Assistant. It's useful for synthetic sensors - that need to reference these native entities in formulas. - - Args: - snapshot: The panel snapshot data - tab_number: The tab number (e.g., 30, 32) - suffix: The sensor suffix (e.g., "power", "energy_produced", "energy_consumed") - device_name: The device name to use for entity ID construction - - Returns: - Entity ID string like "sensor.span_panel_unmapped_tab_30_power" - or None if the circuit doesn't exist - - Examples: - get_unmapped_circuit_entity_id(snapshot, 30, "power") - # Returns: "sensor.span_panel_unmapped_tab_30_power" - - get_unmapped_circuit_entity_id(snapshot, 32, "energy_produced") - # Returns: "sensor.span_panel_unmapped_tab_32_energy_produced" - - """ - circuit_id = f"unmapped_tab_{tab_number}" - - # Verify the circuit exists in the panel data - if circuit_id not in snapshot.circuits: - _LOGGER.debug("Unmapped circuit %s not found in circuits list", circuit_id) - return None - - result_entity_id = construct_unmapped_entity_id(snapshot, circuit_id, suffix, device_name) - _LOGGER.debug("Generated unmapped entity ID: %s", result_entity_id) - return result_entity_id - - def construct_unmapped_friendly_name( circuit_number: int | str, sensor_description_name: str ) -> str: diff --git a/custom_components/span_panel/helpers.py b/custom_components/span_panel/helpers.py index 927430f1..de595552 100644 --- a/custom_components/span_panel/helpers.py +++ b/custom_components/span_panel/helpers.py @@ -19,14 +19,10 @@ build_select_unique_id_for_entry, build_switch_unique_id_for_entry, construct_circuit_unique_id_for_entry, - construct_multi_circuit_entity_id, construct_panel_unique_id_for_entry, - construct_single_circuit_entity_id, construct_synthetic_unique_id_for_entry, - construct_unmapped_entity_id, construct_unmapped_friendly_name, get_device_identifier_for_entry, - get_unmapped_circuit_entity_id, resolve_evse_display_suffix, ) from .id_builder import ( # noqa: F401 @@ -79,16 +75,13 @@ "construct_circuit_label", "construct_circuit_unique_id", "construct_circuit_unique_id_for_entry", - "construct_multi_circuit_entity_id", "construct_panel_unique_id", "construct_panel_unique_id_for_entry", "construct_select_unique_id", - "construct_single_circuit_entity_id", "construct_switch_unique_id", "construct_synthetic_unique_id", "construct_synthetic_unique_id_for_entry", "construct_tabs_attribute", - "construct_unmapped_entity_id", "construct_unmapped_friendly_name", "construct_voltage_attribute", "detect_capabilities", @@ -96,7 +89,6 @@ "get_device_identifier_for_entry", "get_panel_entity_suffix", "get_suffix_from_sensor_key", - "get_unmapped_circuit_entity_id", "get_user_friendly_suffix", "has_bess", "has_evse", diff --git a/custom_components/span_panel/id_builder.py b/custom_components/span_panel/id_builder.py index 61055dca..a53a2c16 100644 --- a/custom_components/span_panel/id_builder.py +++ b/custom_components/span_panel/id_builder.py @@ -38,10 +38,8 @@ `unique_id` on every installed panel, and a moved `unique_id` costs the statistics. -It governs the `entity_id` too, but only for entities created since the -integration began presetting one. Older entities took their id from the -descriptor name instead, which used the opposite word order -- that is what -`LEGACY_ENTITY_ID_SUFFIXES` records, and why it has to exist. +It governs the `unique_id` only -- the wording an entity id ends with lives in +`naming.py`. So the rule for anything new is **verbatim**: a description key added from here on resolves to itself, exactly as the sub-device builders (`build_bess_unique_id`, @@ -54,60 +52,6 @@ """ -# Entity-id suffixes that predate the mapping above, keyed by the suffix that -# replaced them. -LEGACY_ENTITY_ID_SUFFIXES: dict[str, frozenset[str]] = { - "power": frozenset({"current_power"}), - "energy_produced": frozenset({"produced_energy"}), - "energy_consumed": frozenset({"consumed_energy"}), - "energy_net": frozenset({"net_energy"}), -} -"""Entity ids only -- never a `unique_id`, which has always used the canonical form. - -Before the integration preset an `entity_id`, Home Assistant composed one from the -descriptor name: "Consumed Energy" gave `..._consumed_energy` where the mapping -above says `energy_consumed`. Installs from that era carry an entity id whose -suffix disagrees with their own unique id; the two orders were only reconciled -going forward. - -That disagreement is not a defect to correct on a user's behalf. Renormalising it -offers a rename for every circuit on the panel -- seventy-four on a measured one -- -which buries the circuit they actually renamed and breaks every dashboard and -automation belonging to anyone who accepts. So an existing entity keeps the suffix -it has; only the circuit-name half of its id follows the panel. - -Entries are historical fact, so this table only grows by discovering another form -that shipped. `energy_imported`, `energy_exported`, `priority`, `current` and -`breaker_rating` have no entry because they were never named the other way round. -""" - - -def preserve_legacy_entity_id_suffix( - computed_entity_id: str, existing_entity_id: str | None, suffix: str -) -> str: - """Return the id to use, keeping an existing entity's legacy suffix form. - - The ids are compared with the suffix removed, so the existing id wins only - when the circuit-name half already agrees and the suffix is a known older - spelling. A circuit renamed on the panel differs in that half and gets the - computed id, which is what issue #252 is about. - """ - if not existing_entity_id or existing_entity_id == computed_entity_id: - return computed_entity_id - - legacy_forms = LEGACY_ENTITY_ID_SUFFIXES.get(suffix) - if not legacy_forms: - return computed_entity_id - - stem = computed_entity_id.removesuffix(f"_{suffix}") - if stem == computed_entity_id: - return computed_entity_id - - if any(existing_entity_id == f"{stem}_{form}" for form in legacy_forms): - return existing_entity_id - return computed_entity_id - - # Panel sensor API field mappings (used by get_user_friendly_suffix) # Includes main meter/feedthrough produced, consumed, and net energy PANEL_SUFFIX_MAPPING = { @@ -132,10 +76,8 @@ def preserve_legacy_entity_id_suffix( `unique_id` on every installed panel, and a moved `unique_id` costs the statistics. -It governs the `entity_id` too, but only for entities created since the -integration began presetting one. Older entities took their id from the -descriptor name instead, which used the opposite word order -- that is what -`LEGACY_ENTITY_ID_SUFFIXES` records, and why it has to exist. +It governs the `unique_id` only -- the wording an entity id ends with lives in +`naming.py`. So the rule for anything new is **verbatim**: a description key added from here on resolves to itself, exactly as the sub-device builders (`build_bess_unique_id`, @@ -173,10 +115,8 @@ def preserve_legacy_entity_id_suffix( `unique_id` on every installed panel, and a moved `unique_id` costs the statistics. -It governs the `entity_id` too, but only for entities created since the -integration began presetting one. Older entities took their id from the -descriptor name instead, which used the opposite word order -- that is what -`LEGACY_ENTITY_ID_SUFFIXES` records, and why it has to exist. +It governs the `unique_id` only -- the wording an entity id ends with lives in +`naming.py`. So the rule for anything new is **verbatim**: a description key added from here on resolves to itself, exactly as the sub-device builders (`build_bess_unique_id`, diff --git a/custom_components/span_panel/naming.py b/custom_components/span_panel/naming.py index 53461921..a1afef98 100644 --- a/custom_components/span_panel/naming.py +++ b/custom_components/span_panel/naming.py @@ -97,11 +97,11 @@ def circuit_object_id_base(identifier: str, suffix: str, existing_entity_id: str LEGACY_PRESET_DEVICE_NAME: Final = "Span Panel" """The device name the preset builder spelled every circuit id with. -Not the panel's own name. `construct_single_circuit_entity_id` was called -without one, so `snapshot_to_device_info` fell back to this literal on every -install -- the second panel on a system is named "Span Panel 2" and its circuit -entity ids still began `span_panel_`. An id being *kept* has to be spelled the -way it actually is, so this is the string to keep spelling it with. +Not the panel's own name. The builder was called without one, so +`snapshot_to_device_info` fell back to this literal on every install -- the +second panel on a system is named "Span Panel 2" and its circuit entity ids +still began `span_panel_`. An id being *kept* has to be spelled the way it +actually is, so this is the string to keep spelling it with. """ diff --git a/custom_components/span_panel/select.py b/custom_components/span_panel/select.py index 1e9bc950..38207bcd 100644 --- a/custom_components/span_panel/select.py +++ b/custom_components/span_panel/select.py @@ -115,15 +115,13 @@ def __init__( coordinator: SpanPanelCoordinator, description: SpanPanelSelectEntityDescriptionWrapper, circuit_id: str, - name: str, device_name: str, ) -> None: """Initialize the select. - `name` is the circuit's name as of platform setup and is deliberately - unused: both the display name and the id base are read from the circuit - in the current snapshot, so a circuit renamed on the panel reaches both - on the next reload. The parameter stays because every caller passes it. + The circuit's name is not passed in: both the display name and the id + base are read from the circuit in the current snapshot, so a circuit + renamed on the panel reaches both on the next reload. """ super().__init__(coordinator) snapshot: SpanPanelSnapshot = coordinator.data @@ -430,7 +428,6 @@ async def async_setup_entry( coordinator, CIRCUIT_PRIORITY_DESCRIPTION, circuit_id, - circuit_data.name, device_name, ) ) diff --git a/custom_components/span_panel/sensor_base.py b/custom_components/span_panel/sensor_base.py index b1ba50d8..c3d8ac7b 100644 --- a/custom_components/span_panel/sensor_base.py +++ b/custom_components/span_panel/sensor_base.py @@ -217,21 +217,6 @@ def _generate_unique_id(self, snapshot: SpanPanelSnapshot, description: T) -> st """ - @abstractmethod - def _generate_friendly_name(self, snapshot: SpanPanelSnapshot, description: T) -> str | None: - """Generate friendly name for the sensor. - - Subclasses must implement this to define their naming strategy. - - Args: - snapshot: The panel snapshot data - description: The sensor description - - Returns: - Friendly name string, or None to let HA use default behavior - - """ - def _object_id_parts( self, snapshot: SpanPanelSnapshot, description: T ) -> tuple[str, str] | None: diff --git a/custom_components/span_panel/sensor_circuit.py b/custom_components/span_panel/sensor_circuit.py index 8113b9f0..a3ea8d6c 100644 --- a/custom_components/span_panel/sensor_circuit.py +++ b/custom_components/span_panel/sensor_circuit.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Mapping -import logging from typing import Any, ClassVar from homeassistant.helpers.device_registry import DeviceInfo @@ -22,8 +21,6 @@ from .sensor_base import SpanEnergySensorBase, SpanSensorBase from .sensor_definitions import SpanPanelCircuitsSensorEntityDescription -_LOGGER: logging.Logger = logging.getLogger(__name__) - def _get_circuit_data_source(circuit_id: str, snapshot: SpanPanelSnapshot) -> SpanCircuitSnapshot: """Look up a circuit in the snapshot, raising KeyError if temporarily missing.""" @@ -161,30 +158,6 @@ def _generate_unique_id( self.coordinator, snapshot, self.circuit_id, api_key, self._device_name ) - def _generate_friendly_name( - self, - snapshot: SpanPanelSnapshot, - description: SpanPanelCircuitsSensorEntityDescription, - ) -> str: - """Generate friendly name for circuit power sensors based on user preferences. - - For sub-device sensors (EVSE), returns just the description name - since the device name already provides circuit context. - """ - if self._is_sub_device: - return str(description.name or "Sensor") - - circuit = snapshot.circuits.get(self.circuit_id) - if not circuit: - return construct_unmapped_friendly_name( - self.circuit_id, str(description.name or "Sensor") - ) - - circuit_identifier = _resolve_circuit_identifier( - circuit, self.circuit_id, self.coordinator.config_entry.options - ) - return f"{circuit_identifier} {description.name or 'Sensor'}" - def _generate_panel_name( self, snapshot: SpanPanelSnapshot, @@ -346,28 +319,6 @@ def _generate_unique_id( self.coordinator, snapshot, self.circuit_id, api_key, self._device_name ) - def _generate_friendly_name( - self, - snapshot: SpanPanelSnapshot, - description: SpanPanelCircuitsSensorEntityDescription, - ) -> str: - """Generate friendly name for circuit energy sensors based on user preferences. - - For sub-device sensors (EVSE), returns just the description name - since the device name already provides circuit context. - """ - if self._is_sub_device: - return str(description.name) - - circuit = snapshot.circuits.get(self.circuit_id) - if not circuit: - return f"Circuit {self.circuit_id} {description.name}" - - circuit_identifier = _resolve_circuit_identifier( - circuit, self.circuit_id, self.coordinator.config_entry.options - ) - return f"{circuit_identifier} {description.name}" - def _generate_panel_name( self, snapshot: SpanPanelSnapshot, @@ -499,14 +450,6 @@ def _generate_unique_id( self._device_name, ) - def _generate_friendly_name( - self, - snapshot: SpanPanelSnapshot, - description: SpanPanelCircuitsSensorEntityDescription, - ) -> str: - """Generate friendly name for unmapped circuit sensors.""" - return self._unmapped_name(description) - def _generate_panel_name( self, snapshot: SpanPanelSnapshot, diff --git a/custom_components/span_panel/sensor_evse.py b/custom_components/span_panel/sensor_evse.py index 4d9bdf15..7d6f4219 100644 --- a/custom_components/span_panel/sensor_evse.py +++ b/custom_components/span_panel/sensor_evse.py @@ -65,12 +65,6 @@ def _generate_unique_id( self._device_name, ) - def _generate_friendly_name( - self, snapshot: SpanPanelSnapshot, description: SpanEvseSensorEntityDescription - ) -> str: - """Generate friendly name for EVSE sensors.""" - return str(description.name) - def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanEvseSnapshot: """Get the EVSE snapshot for this sensor's charger.""" return snapshot.evse.get(self._evse_id, _EMPTY_EVSE) diff --git a/custom_components/span_panel/sensor_panel.py b/custom_components/span_panel/sensor_panel.py index 79d5bc90..d7ff0b5a 100644 --- a/custom_components/span_panel/sensor_panel.py +++ b/custom_components/span_panel/sensor_panel.py @@ -6,7 +6,6 @@ from typing import Any from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.typing import UNDEFINED from span_panel_api import ( SpanBatterySnapshot, SpanMidSnapshot, @@ -127,16 +126,6 @@ def _generate_unique_id( self.coordinator, snapshot, description.key, self._device_name ) - def _generate_friendly_name( - self, - snapshot: SpanPanelSnapshot, - description: SpanPanelDataSensorEntityDescription, - ) -> str: - """Generate friendly name for panel data sensors.""" - if description.name is not None and description.name is not UNDEFINED: - return str(description.name) - return "Sensor" - def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanPanelSnapshot: """Get the data source for the panel data status sensor.""" return snapshot @@ -207,16 +196,6 @@ def _generate_unique_id( self.coordinator, snapshot, description.key, self._device_name ) - def _generate_friendly_name( - self, - snapshot: SpanPanelSnapshot, - description: SpanShedForecastSensorEntityDescription, - ) -> str: - """Generate friendly name for a shed-forecast sensor.""" - if description.name is not None and description.name is not UNDEFINED: - return str(description.name) - return "Shed Forecast" - def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanPanelSnapshot: """Get the data source for the shed-forecast sensor.""" return snapshot @@ -294,16 +273,6 @@ def _generate_unique_id( self.coordinator, snapshot, description.key, self._device_name ) - def _generate_friendly_name( - self, - snapshot: SpanPanelSnapshot, - description: SpanPcsSensorEntityDescription, - ) -> str: - """Generate friendly name for a PCS sensor.""" - if description.name is not None and description.name is not UNDEFINED: - return str(description.name) - return "Power Control System" - def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanPcsSnapshot: """Get the data source for the PCS sensor. @@ -366,16 +335,6 @@ def _generate_unique_id( self.coordinator, snapshot, description.key, self._device_name ) - def _generate_friendly_name( - self, - snapshot: SpanPanelSnapshot, - description: SpanPanelStatusSensorEntityDescription, - ) -> str: - """Generate friendly name for panel status sensors.""" - if description.name is not None and description.name is not UNDEFINED: - return str(description.name) - return "Status" - def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanPanelSnapshot: """Get the data source for the panel status sensor.""" return snapshot @@ -445,16 +404,6 @@ def _generate_unique_id( self.coordinator, snapshot, description.key, self._device_name ) - def _generate_friendly_name( - self, - snapshot: SpanPanelSnapshot, - description: SpanPanelBatterySensorEntityDescription, - ) -> str: - """Generate friendly name for battery sensors.""" - if description.name is not None and description.name is not UNDEFINED: - return str(description.name) - return "Battery" - def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanBatterySnapshot: """Get the data source for the battery sensor.""" return snapshot.battery @@ -518,16 +467,6 @@ def _generate_unique_id( self.coordinator, snapshot, entity_suffix, self._device_name ) - def _generate_friendly_name( - self, - snapshot: SpanPanelSnapshot, - description: SpanPanelDataSensorEntityDescription, - ) -> str: - """Generate friendly name for panel power sensors.""" - if description.name is not None and description.name is not UNDEFINED: - return str(description.name) - return "Power" - def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanPanelSnapshot: """Get the data source for the panel power sensor.""" return snapshot @@ -584,16 +523,6 @@ def _generate_unique_id( self.coordinator, snapshot, entity_suffix, self._device_name ) - def _generate_friendly_name( - self, - snapshot: SpanPanelSnapshot, - description: SpanPanelDataSensorEntityDescription, - ) -> str: - """Generate friendly name for panel energy sensors.""" - if description.name is not None and description.name is not UNDEFINED: - return str(description.name) - return "Energy" - def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanPanelSnapshot: """Get the data source for the panel energy sensor.""" return snapshot @@ -637,16 +566,6 @@ def _generate_unique_id( self.coordinator, snapshot, description.key, self._device_name ) - def _generate_friendly_name( - self, - snapshot: SpanPanelSnapshot, - description: SpanBessMetadataSensorEntityDescription, - ) -> str: - """Generate friendly name for BESS metadata sensors.""" - if description.name is not None and description.name is not UNDEFINED: - return str(description.name) - return "BESS Sensor" - def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanBatterySnapshot: """Get the data source for the BESS metadata sensor.""" return snapshot.battery @@ -676,16 +595,6 @@ def _generate_unique_id( self.coordinator, snapshot, description.key, self._device_name ) - def _generate_friendly_name( - self, - snapshot: SpanPanelSnapshot, - description: SpanMidSensorEntityDescription, - ) -> str: - """Generate friendly name for MID sensors.""" - if description.name is not None and description.name is not UNDEFINED: - return str(description.name) - return "MID Sensor" - def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanMidSnapshot: """Get the data source for the MID sensor. @@ -741,16 +650,6 @@ def _generate_unique_id( self.coordinator, snapshot, description.key, self._device_name ) - def _generate_friendly_name( - self, - snapshot: SpanPanelSnapshot, - description: SpanPVMetadataSensorEntityDescription, - ) -> str: - """Generate friendly name for PV metadata sensors.""" - if description.name is not None and description.name is not UNDEFINED: - return str(description.name) - return "PV Sensor" - def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanPanelSnapshot: """Get the data source for the PV metadata sensor.""" return snapshot diff --git a/custom_components/span_panel/switch.py b/custom_components/span_panel/switch.py index b5e5acdd..868c5e5e 100644 --- a/custom_components/span_panel/switch.py +++ b/custom_components/span_panel/switch.py @@ -74,15 +74,13 @@ def __init__( self, coordinator: SpanPanelCoordinator, circuit_id: str, - name: str, device_name: str, ) -> None: """Initialize the values. - `name` is the circuit's name as of platform setup and is deliberately - unused: both the display name and the id base are read from the circuit - in the current snapshot, so a circuit renamed on the panel reaches both - on the next reload. The parameter stays because every caller passes it. + The circuit's name is not passed in: both the display name and the id + base are read from the circuit in the current snapshot, so a circuit + renamed on the panel reaches both on the next reload. """ snapshot: SpanPanelSnapshot = coordinator.data @@ -473,9 +471,7 @@ async def async_setup_entry( and circuit_data.relative_position != "DOWNSTREAM" ): continue - entities.append( - SpanPanelCircuitsSwitch(coordinator, circuit_id, circuit_data.name, _device_name) - ) + entities.append(SpanPanelCircuitsSwitch(coordinator, circuit_id, _device_name)) # Settable properties on devices this integration models nothing for. # Disabled and diagnostic like every adopted entity: the panel authorises the diff --git a/tests/test_circuit_control.py b/tests/test_circuit_control.py index d4796dc7..d7393171 100644 --- a/tests/test_circuit_control.py +++ b/tests/test_circuit_control.py @@ -82,7 +82,7 @@ async def test_switch_turn_on_operation() -> None: ) coordinator = _make_coordinator({"1": circuit}) - switch = SpanPanelCircuitsSwitch(coordinator, "1", "Kitchen Outlets", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") switch.async_write_ha_state = MagicMock() await switch.async_turn_on() @@ -100,7 +100,7 @@ async def test_switch_turn_off_operation() -> None: ) coordinator = _make_coordinator({"1": circuit}) - switch = SpanPanelCircuitsSwitch(coordinator, "1", "Kitchen Outlets", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") switch.async_write_ha_state = MagicMock() await switch.async_turn_off() @@ -127,7 +127,7 @@ async def test_a_refused_relay_command_is_raised_and_leaves_the_switch_alone() - side_effect=SpanPanelServerError("Circuit '1' declares its relay non-commandable") ) - switch = SpanPanelCircuitsSwitch(coordinator, "1", "Kitchen Outlets", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") switch.async_write_ha_state = MagicMock() with pytest.raises(HomeAssistantError) as raised: @@ -157,7 +157,7 @@ async def test_a_refusal_names_an_unnamed_circuit_by_its_panel_positions() -> No side_effect=SpanPanelServerError("Circuit 'abc123' declares its relay non-commandable") ) - switch = SpanPanelCircuitsSwitch(coordinator, "abc123", "", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "abc123", "SPAN Panel") switch.async_write_ha_state = MagicMock() with pytest.raises(HomeAssistantError) as raised: @@ -175,7 +175,7 @@ def test_switch_state_reflects_relay_state() -> None: ) coordinator_closed = _make_coordinator({"1": closed}) assert ( - SpanPanelCircuitsSwitch(coordinator_closed, "1", "Kitchen", "SPAN Panel").is_on + SpanPanelCircuitsSwitch(coordinator_closed, "1", "SPAN Panel").is_on is True ) @@ -184,7 +184,7 @@ def test_switch_state_reflects_relay_state() -> None: ) coordinator_open = _make_coordinator({"1": opened}) assert ( - SpanPanelCircuitsSwitch(coordinator_open, "1", "Kitchen", "SPAN Panel").is_on + SpanPanelCircuitsSwitch(coordinator_open, "1", "SPAN Panel").is_on is False ) @@ -194,7 +194,7 @@ def test_switch_handles_missing_circuit() -> None: coordinator = _make_coordinator({}) with pytest.raises(ValueError, match="Circuit 1 not found"): - SpanPanelCircuitsSwitch(coordinator, "1", "Missing Circuit", "SPAN Panel") + SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") def test_switch_coordinator_update_handling(hass: HomeAssistant) -> None: @@ -206,7 +206,7 @@ def test_switch_coordinator_update_handling(hass: HomeAssistant) -> None: ) coordinator = _make_coordinator({"1": circuit}) - switch = SpanPanelCircuitsSwitch(coordinator, "1", "Kitchen Outlets", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") switch.hass = hass switch.entity_id = "switch.span_panel_kitchen_outlets_breaker" switch.registry_entry = MagicMock() @@ -231,7 +231,7 @@ def test_circuit_name_change_triggers_reload_request(hass: HomeAssistant) -> Non ) coordinator = _make_coordinator({"1": circuit}) - switch = SpanPanelCircuitsSwitch(coordinator, "1", "Kitchen Outlets", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") switch.hass = hass switch.entity_id = "switch.span_panel_kitchen_outlets_breaker" switch.registry_entry = MagicMock() @@ -250,7 +250,7 @@ def test_switch_unavailable_when_panel_offline() -> None: circuit = SpanCircuitSnapshotFactory.create(circuit_id="1") coordinator = _make_coordinator({"1": circuit}, panel_offline=True) - switch = SpanPanelCircuitsSwitch(coordinator, "1", "Test Circuit", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") assert switch.available is False @@ -259,7 +259,7 @@ def test_switch_extra_state_attributes_include_tabs_and_voltage() -> None: circuit = SpanCircuitSnapshotFactory.create(circuit_id="1", tabs=[1]) coordinator = _make_coordinator({"1": circuit}) - switch = SpanPanelCircuitsSwitch(coordinator, "1", "Test Circuit", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") assert switch.extra_state_attributes == {"tabs": "tabs [1]", "voltage": 120} @@ -272,7 +272,7 @@ async def test_switch_turn_on_without_relay_support_logs_and_returns( coordinator = _make_coordinator({"1": circuit}) coordinator.client = MagicMock(spec=[]) - switch = SpanPanelCircuitsSwitch(coordinator, "1", "Test Circuit", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") await switch.async_turn_on() assert "Client does not support relay control" in caplog.text @@ -286,7 +286,7 @@ def test_switch_relay_state_target_shows_pending_state() -> None: ) coordinator = _make_coordinator({"1": circuit}) - switch = SpanPanelCircuitsSwitch(coordinator, "1", "Test Circuit", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") # Target differs from actual — switch should show target (CLOSED = on) assert switch.is_on is True @@ -306,7 +306,7 @@ def test_switch_relay_state_target_none_uses_actual_state() -> None: ) coordinator = _make_coordinator({"1": circuit}) - switch = SpanPanelCircuitsSwitch(coordinator, "1", "Test Circuit", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") assert switch.is_on is False @@ -386,7 +386,7 @@ def test_switch_existing_entity_uses_solar_fallback_name(hass: HomeAssistant) -> "custom_components.span_panel.switch.er.async_get", lambda _hass: registry, ) - switch = SpanPanelCircuitsSwitch(coordinator, "15", "", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "15", "SPAN Panel") assert switch.name == "Solar Breaker" @@ -419,7 +419,7 @@ def test_switch_initial_install_uses_circuit_numbers_when_enabled( "custom_components.span_panel.switch.er.async_get", lambda _hass: registry, ) - switch = SpanPanelCircuitsSwitch(coordinator, "2", "Kitchen", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "2", "SPAN Panel") assert switch.name == "Kitchen Breaker" assert switch.suggested_object_id == "Circuit 2 3 breaker" @@ -450,7 +450,7 @@ def test_switch_initial_install_without_name_falls_back_to_the_tabs( "custom_components.span_panel.switch.er.async_get", lambda _hass: registry, ) - switch = SpanPanelCircuitsSwitch(coordinator, "3", "", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "3", "SPAN Panel") assert switch.name == "Circuit 3 Breaker" assert switch.suggested_object_id == "Circuit 3 breaker" @@ -474,7 +474,7 @@ def test_switch_first_update_requests_reload_without_user_override( "custom_components.span_panel.switch.er.async_get", lambda _hass: init_registry, ) - switch = SpanPanelCircuitsSwitch(coordinator, "1", "Kitchen", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") switch.hass = hass switch.entity_id = "switch.kitchen_breaker" @@ -507,7 +507,7 @@ def test_switch_user_override_skips_reload_on_update(hass: HomeAssistant) -> Non "custom_components.span_panel.switch.er.async_get", lambda _hass: init_registry, ) - switch = SpanPanelCircuitsSwitch(coordinator, "1", "Kitchen", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") renamed = replace(circuit, name="Renamed Kitchen") coordinator.data = SpanPanelSnapshotFactory.create(circuits={"1": renamed}) @@ -547,7 +547,7 @@ def test_switch_update_is_on_clears_state_when_circuit_disappears( "custom_components.span_panel.switch.er.async_get", lambda _hass: registry, ) - switch = SpanPanelCircuitsSwitch(coordinator, "1", "Test Circuit", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") coordinator.data = SpanPanelSnapshotFactory.create(circuits={}) switch._update_is_on() @@ -564,7 +564,7 @@ async def test_switch_turn_off_without_relay_support_logs_and_returns( coordinator = _make_coordinator({"1": circuit}) coordinator.client = MagicMock(spec=[]) - switch = SpanPanelCircuitsSwitch(coordinator, "1", "Test Circuit", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") await switch.async_turn_off() assert "Client does not support relay control" in caplog.text @@ -585,7 +585,7 @@ def test_switch_turn_on_off_schedule_async_tasks(hass: HomeAssistant) -> None: "custom_components.span_panel.switch.er.async_get", lambda _hass: registry, ) - switch = SpanPanelCircuitsSwitch(coordinator, "1", "Test Circuit", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") switch.hass = MagicMock() on_task = object() @@ -611,7 +611,7 @@ def test_switch_relay_state_target_exposed_in_attributes() -> None: ) coordinator = _make_coordinator({"1": circuit}) - switch = SpanPanelCircuitsSwitch(coordinator, "1", "Test Circuit", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") attrs = switch.extra_state_attributes assert attrs is not None assert attrs["relay_state_target"] == "CLOSED" @@ -627,7 +627,7 @@ def test_switch_relay_state_target_absent_when_none() -> None: ) coordinator = _make_coordinator({"1": circuit}) - switch = SpanPanelCircuitsSwitch(coordinator, "1", "Test Circuit", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") attrs = switch.extra_state_attributes assert attrs is not None assert "relay_state_target" not in attrs @@ -661,9 +661,7 @@ def test_switch_circuit_numbers_entity_id_stable_after_reload( "custom_components.span_panel.switch.er.async_get", lambda _hass: registry, ) - switch = SpanPanelCircuitsSwitch( - coordinator, "2", "Air Conditioner", "SPAN Panel" - ) + switch = SpanPanelCircuitsSwitch(coordinator, "2", "SPAN Panel") assert switch.name == "Air Conditioner Breaker" assert switch.suggested_object_id == "Circuit 15 17 breaker" @@ -678,9 +676,7 @@ def test_switch_circuit_numbers_entity_id_stable_after_reload( "custom_components.span_panel.switch.er.async_get", lambda _hass: registry, ) - switch2 = SpanPanelCircuitsSwitch( - coordinator, "2", "Air Conditioner", "SPAN Panel" - ) + switch2 = SpanPanelCircuitsSwitch(coordinator, "2", "SPAN Panel") # The panel's name, carried by original_name rather than the registry's # `name`, which would outrank the base. @@ -710,9 +706,7 @@ def test_switch_circuit_numbers_entity_id_120v_single_tab( "custom_components.span_panel.switch.er.async_get", lambda _hass: registry, ) - switch = SpanPanelCircuitsSwitch( - coordinator, "5", "Kitchen Outlets", "SPAN Panel" - ) + switch = SpanPanelCircuitsSwitch(coordinator, "5", "SPAN Panel") assert switch.name == "Kitchen Outlets Breaker" assert switch.suggested_object_id == "Circuit 10 breaker" @@ -748,7 +742,7 @@ def test_switch_circuit_numbers_releases_the_synced_registry_name( "custom_components.span_panel.switch.er.async_get", lambda _hass: registry, ) - SpanPanelCircuitsSwitch(coordinator, "2", "Air Conditioner", "SPAN Panel") + SpanPanelCircuitsSwitch(coordinator, "2", "SPAN Panel") registry.async_update_entity.assert_called_once_with( "switch.span_panel_circuit_15_17_breaker", name=None @@ -785,7 +779,7 @@ def test_switch_circuit_numbers_preserves_user_custom_name( "custom_components.span_panel.switch.er.async_get", lambda _hass: registry, ) - SpanPanelCircuitsSwitch(coordinator, "2", "Air Conditioner", "SPAN Panel") + SpanPanelCircuitsSwitch(coordinator, "2", "SPAN Panel") registry.async_update_entity.assert_not_called() @@ -818,9 +812,7 @@ def test_switch_coordinator_update_circuit_numbers_requests_reload( "custom_components.span_panel.switch.er.async_get", lambda _hass: registry, ) - switch = SpanPanelCircuitsSwitch( - coordinator, "2", "Air Conditioner", "SPAN Panel" - ) + switch = SpanPanelCircuitsSwitch(coordinator, "2", "SPAN Panel") # Simulate a circuit name change from "Air Conditioner" to "Kitchen AC" renamed = replace(circuit, name="Kitchen AC") @@ -873,9 +865,7 @@ def test_switch_coordinator_update_circuit_numbers_preserves_user_override( "custom_components.span_panel.switch.er.async_get", lambda _hass: registry, ) - switch = SpanPanelCircuitsSwitch( - coordinator, "2", "Air Conditioner", "SPAN Panel" - ) + switch = SpanPanelCircuitsSwitch(coordinator, "2", "SPAN Panel") # Simulate a circuit name change; user has set "My AC Unit" in the registry renamed = replace(circuit, name="Kitchen AC") @@ -926,9 +916,7 @@ def test_switch_coordinator_update_friendly_mode_still_reloads( "custom_components.span_panel.switch.er.async_get", lambda _hass: registry, ) - switch = SpanPanelCircuitsSwitch( - coordinator, "1", "Kitchen Outlets", "SPAN Panel" - ) + switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") assert switch._previous_circuit_name == "Kitchen Outlets" diff --git a/tests/test_control_gate.py b/tests/test_control_gate.py index 31edb2e5..952c6c57 100644 --- a/tests/test_control_gate.py +++ b/tests/test_control_gate.py @@ -10,7 +10,10 @@ from unittest.mock import AsyncMock, MagicMock, patch +from homeassistant.core import Context, Event, HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry, MockUser from span_panel_api import ControlCommand, PublishOutcome, PublishState from custom_components.span_panel.const import DOMAIN, EVENT_CONTROL_COMMAND @@ -23,10 +26,6 @@ async_bind_caller, outcome_is_failure, ) -from homeassistant.core import Context, Event, HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError - -from pytest_homeassistant_custom_component.common import MockConfigEntry, MockUser RELAY = ControlCommand( device_id="sp3-test-001", @@ -170,7 +169,7 @@ async def test_contextless_control_can_be_refused(hass: HomeAssistant) -> None: @pytest.mark.asyncio async def test_an_unbound_caller_is_treated_as_contextless(hass: HomeAssistant) -> None: - """This is the fail-closed default, and the reason the gate is not in the helper. + """The fail-closed default, and the reason the gate is not in the helper. A future control path that forgets `_async_guarded_control` arrives here with nothing bound. Permitting it would make the gate a suggestion. @@ -248,9 +247,8 @@ async def test_a_second_relay_command_inside_the_window_is_refused( with patch( "custom_components.span_panel.control_gate.time.monotonic", return_value=101.0 - ): - with pytest.raises(ServiceValidationError) as err: - await gate.before_publish(RELAY) + ), pytest.raises(ServiceValidationError) as err: + await gate.before_publish(RELAY) assert err.value.translation_key == "relay_debounced" @@ -612,7 +610,7 @@ async def test_a_refused_relay_leaves_the_switch_where_it_was( side_effect=ServiceValidationError("refused") ) - entity = SpanPanelCircuitsSwitch(coordinator, "1", "Kitchen", "SPAN Panel") + entity = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") entity.hass = hass entity.async_write_ha_state = MagicMock() before = entity.is_on @@ -658,7 +656,7 @@ async def test_an_undelivered_relay_command_does_not_move_the_switch( ) ) - entity = SpanPanelCircuitsSwitch(coordinator, "1", "Kitchen", "SPAN Panel") + entity = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") entity.hass = hass entity.async_write_ha_state = MagicMock() before = entity.is_on @@ -711,7 +709,7 @@ async def test_a_handed_over_relay_command_still_shows_the_requested_state( ) ) - entity = SpanPanelCircuitsSwitch(coordinator, "1", "Kitchen", "SPAN Panel") + entity = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") entity.hass = hass entity.async_write_ha_state = MagicMock() diff --git a/tests/test_energy_dip_compensation.py b/tests/test_energy_dip_compensation.py index a6d4fc44..30c34887 100644 --- a/tests/test_energy_dip_compensation.py +++ b/tests/test_energy_dip_compensation.py @@ -6,6 +6,7 @@ from unittest.mock import MagicMock from homeassistant.components.sensor import SensorStateClass + from custom_components.span_panel.const import ENABLE_ENERGY_DIP_COMPENSATION from custom_components.span_panel.energy_dip import PendingDip from custom_components.span_panel.options import ENERGY_REPORTING_GRACE_PERIOD @@ -63,9 +64,6 @@ def __init__( # pylint: disable=super-init-not-called def _generate_unique_id(self, snapshot, description): return "dummy_sensor" - def _generate_friendly_name(self, snapshot, description): - return "Dummy" - def get_data_source(self, snapshot): return "dummy_data" diff --git a/tests/test_grace_period_restoration.py b/tests/test_grace_period_restoration.py index f8a96f25..9add39ee 100644 --- a/tests/test_grace_period_restoration.py +++ b/tests/test_grace_period_restoration.py @@ -6,6 +6,7 @@ from types import SimpleNamespace from homeassistant.components.sensor import SensorStateClass + from custom_components.span_panel.const import ENABLE_ENERGY_DIP_COMPENSATION from custom_components.span_panel.options import ENERGY_REPORTING_GRACE_PERIOD from custom_components.span_panel.sensor_base import ( @@ -409,9 +410,6 @@ def __init__( # pylint: disable=super-init-not-called def _generate_unique_id(self, snapshot, description): return "dummy" - def _generate_friendly_name(self, snapshot, description): - return "dummy" - def get_data_source(self, snapshot): return "dummy_data" diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 7a5ca1da..9e5c87a0 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -4,28 +4,20 @@ from unittest.mock import MagicMock, patch +from homeassistant.util import slugify import pytest -from custom_components.span_panel.const import ( - USE_CIRCUIT_NUMBERS, - USE_DEVICE_PREFIX, -) from custom_components.span_panel.helpers import ( async_create_span_notification, construct_circuit_identifier_from_tabs, - construct_multi_circuit_entity_id, - construct_unmapped_entity_id, detect_capabilities, get_suffix_from_sensor_key, - get_unmapped_circuit_entity_id, get_user_friendly_suffix, is_panel_level_sensor_key, ) -from homeassistant.util import slugify from .factories import ( SpanBatterySnapshotFactory, - SpanCircuitSnapshotFactory, SpanEvseSnapshotFactory, SpanPanelSnapshotFactory, ) @@ -98,269 +90,9 @@ def test_is_panel_level_sensor_key(self): ) assert is_panel_level_sensor_key("invalid_format") is False - @patch("custom_components.span_panel.helpers.er.async_get") - def test_construct_multi_circuit_entity_id_config_entry_none(self, mock_registry): - """Test construct_multi_circuit_entity_id works with valid coordinator (None config_entry should be caught at coordinator level).""" - mock_registry.return_value = None - - coordinator = MagicMock() - coordinator.config_entry.options = {} - coordinator.config_entry.title = "SPAN Panel" - span_panel = MagicMock() - - # This should work fine - the coordinator should validate config_entry at construction time - result = construct_multi_circuit_entity_id( - coordinator, - span_panel, - "sensor", - "power", - circuit_numbers=[3032], - friendly_name="Solar Inverter", - ) - # With empty options, should use legacy naming (no device prefix) - assert result == "sensor.solar_inverter_power" - - @patch("custom_components.span_panel.helpers.er.async_get") - def test_construct_multi_circuit_entity_id_empty_options(self, mock_registry): - """Test construct_multi_circuit_entity_id with stable naming (synthetic sensors are always stable).""" - mock_registry.return_value = None - - coordinator = MagicMock() - coordinator.config_entry.options = {} - coordinator.config_entry.title = "SPAN Panel" - coordinator.config_entry.data = {"device_name": "SPAN Panel"} - span_panel = MagicMock() - - # Test with friendly name - legacy installation should not use device prefix - result = construct_multi_circuit_entity_id( - coordinator, - span_panel, - "sensor", - "power", - circuit_numbers=[30, 32], - friendly_name="Solar Production Power", - ) - assert result == "sensor.solar_production_power" - - # Test with default friendly name - legacy installation should not use device prefix - result = construct_multi_circuit_entity_id( - coordinator, - span_panel, - "sensor", - "power", - circuit_numbers=[30, 32], - friendly_name="Solar Inverter", - ) - assert result == "sensor.solar_inverter_power" - - @patch("custom_components.span_panel.helpers.er.async_get") - def test_construct_multi_circuit_entity_id_no_device_name(self, mock_registry): - """Test construct_multi_circuit_entity_id with no device name - should return None.""" - mock_registry.return_value = None - - coordinator = MagicMock() - coordinator.config_entry.options = {USE_CIRCUIT_NUMBERS: True} - coordinator.config_entry.title = None - coordinator.config_entry.data = {"device_name": None} - span_panel = MagicMock() - - # Multi-circuit sensors should return None if no device name available - result = construct_multi_circuit_entity_id( - coordinator, - span_panel, - "sensor", - "power", - circuit_numbers=[30, 32], - friendly_name="Solar Inverter", - ) - assert result is None - - @patch("custom_components.span_panel.helpers.er.async_get") - def test_construct_multi_circuit_entity_id_circuit_numbers_pattern( - self, mock_registry - ): - """Test construct_multi_circuit_entity_id with circuit numbers pattern.""" - mock_registry.return_value = MagicMock() - mock_registry.return_value.async_get_entity_id = MagicMock(return_value=None) - - coordinator = MagicMock() - coordinator.config_entry.options = { - USE_CIRCUIT_NUMBERS: True, - USE_DEVICE_PREFIX: True, - } - coordinator.config_entry.title = "SPAN Panel" - coordinator.config_entry.data = {"device_name": "SPAN Panel"} - coordinator.hass = MagicMock() - span_panel = MagicMock() - - # Test with multiple circuit numbers (solar inverter case) - result = construct_multi_circuit_entity_id( - coordinator, span_panel, "sensor", "power", circuit_numbers=[30, 32] - ) - assert result == "sensor.span_panel_circuit_30_32_power" - - # Test with single circuit number - result = construct_multi_circuit_entity_id( - coordinator, span_panel, "sensor", "power", circuit_numbers=[15] - ) - assert result == "sensor.span_panel_circuit_15_power" - - # Test with different suffix - result = construct_multi_circuit_entity_id( - coordinator, - span_panel, - "sensor", - "energy_produced", - circuit_numbers=[30, 32], - ) - assert result == "sensor.span_panel_circuit_30_32_energy_produced" - - @patch("custom_components.span_panel.helpers.er.async_get") - def test_construct_multi_circuit_entity_id_friendly_names_pattern( - self, mock_registry - ): - """Test construct_multi_circuit_entity_id with friendly names pattern.""" - mock_registry.return_value = MagicMock() - mock_registry.return_value.async_get_entity_id = MagicMock(return_value=None) - - coordinator = MagicMock() - coordinator.config_entry.options = { - USE_CIRCUIT_NUMBERS: False, - USE_DEVICE_PREFIX: True, - } - coordinator.config_entry.title = "SPAN Panel" - coordinator.config_entry.data = {"device_name": "SPAN Panel"} - coordinator.hass = MagicMock() - span_panel = MagicMock() - - # Test with friendly name (should ignore circuit_numbers when USE_CIRCUIT_NUMBERS is False) - result = construct_multi_circuit_entity_id( - coordinator, - span_panel, - "sensor", - "power", - circuit_numbers=[30, 32], - friendly_name="Solar Inverter", - ) - assert result == "sensor.span_panel_solar_inverter_power" - - # Test without friendly name (should return None when not using circuit numbers) - result = construct_multi_circuit_entity_id( - coordinator, span_panel, "sensor", "power", circuit_numbers=[30, 32] - ) - assert result is None - - @patch("custom_components.span_panel.helpers.er.async_get") - def test_construct_multi_circuit_entity_id_circuit_numbers_no_device_prefix( - self, mock_registry - ): - """Test construct_multi_circuit_entity_id with circuit numbers but no device prefix.""" - mock_registry.return_value = MagicMock() - mock_registry.return_value.async_get_entity_id = MagicMock(return_value=None) - - coordinator = MagicMock() - coordinator.config_entry.options = { - USE_CIRCUIT_NUMBERS: True, - USE_DEVICE_PREFIX: False, - } - coordinator.config_entry.title = "SPAN Panel" - coordinator.config_entry.data = {"device_name": "SPAN Panel"} - coordinator.hass = MagicMock() - span_panel = MagicMock() - - # Test with circuit numbers but no device prefix (should still work for multi-circuit sensors) - result = construct_multi_circuit_entity_id( - coordinator, span_panel, "sensor", "power", circuit_numbers=[30, 32] - ) - assert result == "sensor.circuit_30_32_power" - - @patch("custom_components.span_panel.helpers.er.async_get") - def test_construct_multi_circuit_entity_id_empty_circuit_numbers( - self, mock_registry - ): - """Test construct_multi_circuit_entity_id with empty circuit numbers.""" - mock_registry.return_value = MagicMock() - mock_registry.return_value.async_get_entity_id = MagicMock(return_value=None) - - coordinator = MagicMock() - coordinator.config_entry.options = { - USE_CIRCUIT_NUMBERS: True, - USE_DEVICE_PREFIX: True, - } - coordinator.config_entry.title = "SPAN Panel" - coordinator.config_entry.data = {"device_name": "SPAN Panel"} - coordinator.hass = MagicMock() - span_panel = MagicMock() - - # Test with empty circuit numbers (should raise ValueError) - with pytest.raises( - ValueError, - match="Circuit-based naming is enabled but no valid circuit numbers provided", - ): - construct_multi_circuit_entity_id( - coordinator, span_panel, "sensor", "power", circuit_numbers=[] - ) - - # Test with None circuit numbers (should raise ValueError) - with pytest.raises( - ValueError, - match="Circuit-based naming is enabled but no valid circuit numbers provided", - ): - construct_multi_circuit_entity_id( - coordinator, span_panel, "sensor", "power", circuit_numbers=None - ) - - # Test with invalid circuit numbers (should raise ValueError) - with pytest.raises( - ValueError, - match="Circuit-based naming is enabled but no valid circuit numbers provided", - ): - construct_multi_circuit_entity_id( - coordinator, span_panel, "sensor", "power", circuit_numbers=[0, -1] - ) - - @patch("custom_components.span_panel.helpers.er.async_get") - def test_construct_multi_circuit_entity_id_legacy_compatibility( - self, mock_registry - ): - """Test construct_multi_circuit_entity_id maintains legacy compatibility.""" - mock_registry.return_value = MagicMock() - mock_registry.return_value.async_get_entity_id = MagicMock(return_value=None) - - coordinator = MagicMock() - coordinator.config_entry.options = {} # Legacy installation - coordinator.config_entry.title = "SPAN Panel" - coordinator.config_entry.data = {"device_name": "SPAN Panel"} - coordinator.hass = MagicMock() - span_panel = MagicMock() - - # Test legacy compatibility - should work with circuit_numbers parameter - result = construct_multi_circuit_entity_id( - coordinator, - span_panel, - "sensor", - "power", - circuit_numbers=[30, 32], - friendly_name="Solar Inverter", - ) - assert result == "sensor.solar_inverter_power" - - # Test legacy compatibility - should work with circuit_numbers but ignore them in legacy mode - result = construct_multi_circuit_entity_id( - coordinator, - span_panel, - "sensor", - "power", - circuit_numbers=[30, 32], - friendly_name="Solar Inverter", - ) - assert result == "sensor.solar_inverter_power" - def test_construct_synthetic_friendly_name_with_user_name(self): """Test construct_synthetic_friendly_name with user-provided name.""" - result = construct_synthetic_friendly_name( - [30, 32], "Instant Power", "Solar Production" - ) + result = construct_synthetic_friendly_name([30, 32], "Instant Power", "Solar Production") assert result == "Solar Production Instant Power" def test_construct_synthetic_friendly_name_multiple_circuits(self): @@ -383,37 +115,11 @@ def test_construct_synthetic_friendly_name_empty_circuits(self): result = construct_synthetic_friendly_name([], "Instant Power") assert result == "Unknown Circuit Instant Power" - def test_construct_unmapped_entity_helpers(self): - """Test helper functions for unmapped circuits.""" - snapshot = SpanPanelSnapshotFactory.create( - circuits={ - "unmapped_tab_7": SpanCircuitSnapshotFactory.create( - circuit_id="unmapped_tab_7" - ) - } - ) - - assert ( - construct_unmapped_entity_id( - snapshot, "unmapped_tab_7", "power", "SPAN Panel" - ) - == "sensor.span_panel_unmapped_tab_7_power" - ) - assert ( - get_unmapped_circuit_entity_id(snapshot, 7, "power", "SPAN Panel") - == "sensor.span_panel_unmapped_tab_7_power" - ) - assert ( - get_unmapped_circuit_entity_id(snapshot, 99, "power", "SPAN Panel") is None - ) - def test_construct_circuit_identifier_from_tabs(self): """Test fallback circuit naming from tabs.""" assert construct_circuit_identifier_from_tabs([5, 6], "c1") == "Circuit 5 6" assert construct_circuit_identifier_from_tabs([7], "c1") == "Circuit 7" - assert ( - construct_circuit_identifier_from_tabs([], "fallback") == "Circuit fallback" - ) + assert construct_circuit_identifier_from_tabs([], "fallback") == "Circuit fallback" @patch("custom_components.span_panel.helpers.async_create") @pytest.mark.asyncio @@ -445,6 +151,4 @@ def test_detect_capabilities_helper(self): evse={"evse-0": SpanEvseSnapshotFactory.create()}, ) - assert detect_capabilities(snapshot) == frozenset( - {"bess", "evse", "power_flows", "pv"} - ) + assert detect_capabilities(snapshot) == frozenset({"bess", "evse", "power_flows", "pv"}) diff --git a/tests/test_naming.py b/tests/test_naming.py index d9942312..1f7f32d8 100644 --- a/tests/test_naming.py +++ b/tests/test_naming.py @@ -88,6 +88,29 @@ def test_every_form_table_entry_has_a_new_wording_and_vice_versa() -> None: ) +@pytest.mark.parametrize( + ("identifier", "suffix", "expected"), + [ + ("Kitchen Outlets", "power", "Kitchen Outlets power"), + ("Circuit 15", "power", "Circuit 15 power"), + ("Circuit 15 17", "power", "Circuit 15 17 power"), + ("Circuit 30 32", "power", "Circuit 30 32 power"), + ], +) +def test_the_id_shapes_the_upgrade_scenarios_pinned( + identifier: str, suffix: str, expected: str +) -> None: + """The four shapes the deleted preset builder's own tests pinned. + + A friendly name, a single-pole breaker and both two-pole spellings. The + builder produced the whole id and so had to be told the device prefix and + the naming mode; here the caller has already resolved the identifier and + only the base is at stake, which is the same string with the device part + left to Home Assistant. + """ + assert circuit_object_id_base(identifier, suffix, None) == expected + + # --- The id shape kept where composition would move one -------------------- # # Two kinds of circuit entity are spelled differently by Home Assistant's @@ -175,7 +198,7 @@ def test_the_suffix_words_come_from_the_id_the_entity_already_has( def test_the_preset_device_name_is_the_literal_the_old_builder_fell_back_to() -> None: - """Not the panel's own name: `construct_single_circuit_entity_id` passed none. + """Not the panel's own name: the preset builder passed none. A second panel on a system is named "Span Panel 2" and its circuit ids still began `span_panel_`, so the panel's name is the wrong thing to spell an diff --git a/tests/test_recreate_entity_ids.py b/tests/test_recreate_entity_ids.py index 39c09a50..c05c3a47 100644 --- a/tests/test_recreate_entity_ids.py +++ b/tests/test_recreate_entity_ids.py @@ -46,7 +46,6 @@ build_circuit_unique_id, build_select_unique_id, build_switch_unique_id, - preserve_legacy_entity_id_suffix, ) from custom_components.span_panel.select import ( CIRCUIT_PRIORITY_DESCRIPTION, @@ -126,7 +125,7 @@ def __init__(self, hass: HomeAssistant, entry: MockConfigEntry) -> None: self._entry = entry self._platform: MockEntityPlatform | None = None - def _build(self, coordinator: MagicMock, snapshot: SpanPanelSnapshot, circuit_name: str) -> E: + def _build(self, coordinator: MagicMock, snapshot: SpanPanelSnapshot) -> E: """Construct the one entity this install adds. Subclasses answer.""" raise NotImplementedError @@ -146,7 +145,7 @@ async def load(self, circuit_name: str) -> E: ) self._platform.config_entry = self._entry - entity = self._build(coordinator, snapshot, circuit_name) + entity = self._build(coordinator, snapshot) await self._platform.async_add_entities([entity]) await self._hass.async_block_till_done() @@ -173,7 +172,7 @@ def __init__( self._device_info_override = device_info_override def _build( - self, coordinator: MagicMock, snapshot: SpanPanelSnapshot, circuit_name: str + self, coordinator: MagicMock, snapshot: SpanPanelSnapshot ) -> SpanCircuitPowerSensor: """Build the power sensor, on the panel's card or a sub-device's.""" return SpanCircuitPowerSensor( @@ -191,10 +190,10 @@ class _SwitchInstall(_Install[SpanPanelCircuitsSwitch]): _domain: ClassVar[str] = "switch" def _build( - self, coordinator: MagicMock, snapshot: SpanPanelSnapshot, circuit_name: str + self, coordinator: MagicMock, snapshot: SpanPanelSnapshot ) -> SpanPanelCircuitsSwitch: """Build the breaker switch.""" - return SpanPanelCircuitsSwitch(coordinator, CIRCUIT_ID, circuit_name, "SPAN Panel") + return SpanPanelCircuitsSwitch(coordinator, CIRCUIT_ID, "SPAN Panel") class _SelectInstall(_Install[SpanPanelCircuitsSelect]): @@ -203,11 +202,11 @@ class _SelectInstall(_Install[SpanPanelCircuitsSelect]): _domain: ClassVar[str] = "select" def _build( - self, coordinator: MagicMock, snapshot: SpanPanelSnapshot, circuit_name: str + self, coordinator: MagicMock, snapshot: SpanPanelSnapshot ) -> SpanPanelCircuitsSelect: """Build the circuit-priority select.""" return SpanPanelCircuitsSelect( - coordinator, CIRCUIT_PRIORITY_DESCRIPTION, CIRCUIT_ID, circuit_name, "SPAN Panel" + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, CIRCUIT_ID, "SPAN Panel" ) @@ -528,7 +527,7 @@ def _seed(self) -> str: return entry.entity_id def _build( - self, coordinator: MagicMock, snapshot: SpanPanelSnapshot, circuit_name: str + self, coordinator: MagicMock, snapshot: SpanPanelSnapshot ) -> SpanCircuitEnergySensor: """Build the energy sensor, whose id has shipped with two suffix spellings.""" return SpanCircuitEnergySensor(coordinator, ENERGY_DESCRIPTION, snapshot, CIRCUIT_ID) @@ -849,31 +848,6 @@ async def test_an_unmapped_tab_sensor_keeps_its_prefix_on_a_no_prefix_install( assert registry.async_regenerate_entity_id(registry_entry) == seeded.entity_id -def test_a_suffix_with_no_older_spelling_is_left_alone() -> None: - """A name that merely looks like a suffix change is a rename, not a legacy form. - - Renaming a circuit "Kitchen Outlets" to "Kitchen" leaves an existing id whose - trailing segments differ from the computed suffix. That is exactly the case - #252 exists to offer, so it must not be mistaken for an older spelling. - """ - assert ( - preserve_legacy_entity_id_suffix( - "sensor.span_panel_kitchen_power", - "sensor.span_panel_kitchen_outlets_power", - "power", - ) - == "sensor.span_panel_kitchen_power" - ) - assert ( - preserve_legacy_entity_id_suffix( - "sensor.span_panel_kitchen_energy_consumed", - "sensor.span_panel_kitchen_consumed_energy", - "energy_consumed", - ) - == "sensor.span_panel_kitchen_consumed_energy" - ) - - # --- The two circuit controls ------------------------------------------------- # # The breaker switch and the priority select are the other two circuit entities diff --git a/tests/test_select.py b/tests/test_select.py index 9320d792..e8226df3 100644 --- a/tests/test_select.py +++ b/tests/test_select.py @@ -62,9 +62,7 @@ def test_select_init_missing_circuit() -> None: coordinator.config_entry.options = {} with pytest.raises(ValueError): - SpanPanelCircuitsSelect( - coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "bad_id", "name", "Test Device" - ) + SpanPanelCircuitsSelect(coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "bad_id", "Test Device") @pytest.mark.asyncio @@ -78,7 +76,7 @@ async def test_async_select_option_service_not_found() -> None: new_callable=AsyncMock, ) as mock_notification: select = SpanPanelCircuitsSelect( - coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "name", "Test Device" + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "Test Device" ) select.coordinator = coordinator select.hass = MagicMock() @@ -108,9 +106,7 @@ async def test_async_select_option_refusal_is_raised_at_the_caller() -> None: coordinator = _make_coordinator_with_circuit() circuit = coordinator.data.circuits["id"] - select = SpanPanelCircuitsSelect( - coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "name", "Test Device" - ) + select = SpanPanelCircuitsSelect(coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "Test Device") select.coordinator = coordinator select.hass = MagicMock() @@ -137,9 +133,7 @@ async def test_async_select_option_undelivered_is_raised_at_the_caller() -> None coordinator = _make_coordinator_with_circuit() circuit = coordinator.data.circuits["id"] - select = SpanPanelCircuitsSelect( - coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "name", "Test Device" - ) + select = SpanPanelCircuitsSelect(coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "Test Device") select.coordinator = coordinator select.hass = MagicMock() @@ -175,7 +169,7 @@ async def test_async_select_option_success_refreshes_coordinator() -> None: registry.async_get_entity_id.return_value = None mock_async_get.return_value = registry select = SpanPanelCircuitsSelect( - coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "Kitchen", "SPAN Panel" + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "SPAN Panel" ) coordinator.client = MagicMock() @@ -206,7 +200,7 @@ async def test_async_select_option_without_priority_support_returns_early( registry.async_get_entity_id.return_value = None mock_async_get.return_value = registry select = SpanPanelCircuitsSelect( - coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "Kitchen", "SPAN Panel" + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "SPAN Panel" ) coordinator.client = object() @@ -238,7 +232,7 @@ def test_select_uses_circuit_numbers_for_the_base_when_the_option_is_enabled() - mock_async_get.return_value = registry select = SpanPanelCircuitsSelect( - coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "Kitchen", "SPAN Panel" + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "SPAN Panel" ) assert select.name == "name Circuit Priority" @@ -263,7 +257,7 @@ def test_select_unnamed_friendly_mode_falls_back_to_the_tabs() -> None: mock_async_get.return_value = registry select = SpanPanelCircuitsSelect( - coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "", "SPAN Panel" + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "SPAN Panel" ) assert select.name == "Circuit 1 Circuit Priority" @@ -298,7 +292,7 @@ def test_select_existing_entity_uses_solar_fallback_name() -> None: mock_async_get.return_value = registry select = SpanPanelCircuitsSelect( - coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "pv-1", "", "SPAN Panel" + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "pv-1", "SPAN Panel" ) assert select.name == "Solar Circuit Priority" @@ -309,9 +303,7 @@ def test_select_available_false_when_panel_offline() -> None: coordinator = _make_coordinator_with_circuit() coordinator.panel_offline = True - select = SpanPanelCircuitsSelect( - coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "Kitchen", "SPAN Panel" - ) + select = SpanPanelCircuitsSelect(coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "SPAN Panel") assert select.available is False @@ -328,7 +320,7 @@ def test_select_extra_state_attributes_include_tabs_and_voltage() -> None: mock_async_get.return_value = registry select = SpanPanelCircuitsSelect( - coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "Kitchen", "SPAN Panel" + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "SPAN Panel" ) assert select.extra_state_attributes == {"tabs": "tabs [1]", "voltage": 120} @@ -347,7 +339,7 @@ def test_handle_coordinator_update_requests_reload_on_first_sync() -> None: mock_async_get.return_value = registry select = SpanPanelCircuitsSelect( - coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "Kitchen", "SPAN Panel" + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "SPAN Panel" ) select.hass = MagicMock() select.async_write_ha_state = MagicMock() @@ -370,7 +362,7 @@ def test_handle_coordinator_update_user_override_skips_reload() -> None: mock_async_get.return_value = registry select = SpanPanelCircuitsSelect( - coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "Kitchen", "SPAN Panel" + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "SPAN Panel" ) updated_circuit = replace(coordinator.data.circuits["id"], name="Renamed Kitchen") @@ -405,7 +397,7 @@ def test_handle_coordinator_update_requests_reload_on_name_change() -> None: mock_async_get.return_value = registry select = SpanPanelCircuitsSelect( - coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "Kitchen", "SPAN Panel" + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "SPAN Panel" ) updated_circuit = replace(coordinator.data.circuits["id"], name="Renamed Kitchen") @@ -438,7 +430,7 @@ def test_handle_coordinator_update_skips_when_circuit_missing_from_snapshot() -> mock_async_get.return_value = registry select = SpanPanelCircuitsSelect( - coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "Kitchen", "SPAN Panel" + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "SPAN Panel" ) # Simulate a partial snapshot missing this circuit @@ -589,7 +581,6 @@ def test_select_circuit_numbers_entity_id_stable_after_reload( coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "2", - "Air Conditioner", "SPAN Panel", ) @@ -610,7 +601,6 @@ def test_select_circuit_numbers_entity_id_stable_after_reload( coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "2", - "Air Conditioner", "SPAN Panel", ) @@ -650,7 +640,6 @@ def test_select_circuit_numbers_entity_id_120v_single_tab( coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "5", - "Kitchen Outlets", "SPAN Panel", ) @@ -696,7 +685,6 @@ def test_select_coordinator_update_circuit_numbers_requests_reload( coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "2", - "Air Conditioner", "SPAN Panel", ) diff --git a/tests/test_sensor_entities.py b/tests/test_sensor_entities.py index c4882f1b..60badc36 100644 --- a/tests/test_sensor_entities.py +++ b/tests/test_sensor_entities.py @@ -3,13 +3,17 @@ from __future__ import annotations import asyncio +from dataclasses import replace from datetime import UTC, datetime, timedelta from unittest.mock import AsyncMock, MagicMock, patch +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import CONF_HOST, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import State import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry from span_panel_api import SpanPVSnapshot -from homeassistant.components.sensor import SensorDeviceClass from custom_components.span_panel import SpanPanelRuntimeData from custom_components.span_panel.const import ( ENABLE_ENERGY_DIP_COMPENSATION, @@ -52,8 +56,6 @@ SpanPanelStatus, SpanPVMetadataSensor, ) -from homeassistant.const import CONF_HOST, STATE_UNAVAILABLE, STATE_UNKNOWN -from homeassistant.core import State from .factories import ( SpanBatterySnapshotFactory, @@ -62,8 +64,6 @@ SpanPanelSnapshotFactory, ) -from pytest_homeassistant_custom_component.common import MockConfigEntry - @pytest.fixture(autouse=True) def _mock_entity_registry(): @@ -175,8 +175,16 @@ def test_only_the_grid_sensor_carries_the_topology_attribute() -> None: assert "at_service_entrance" not in (sensor.extra_state_attributes or {}) -def test_panel_sensor_default_friendly_names_cover_fallback_branches() -> None: - """Panel sensor classes should return fallback names when descriptions are unnamed.""" +def test_a_panel_sensor_with_a_translation_key_falls_back_to_the_neutral_label() -> None: + """Every panel sensor now takes its displayed name from one shared answer. + + `_generate_panel_name` returns the description's own label, and a description + that declares a `translation_key` declares no name at all -- which is every + panel-level description this integration ships. Its real label reaches the UI + from `translations/en.json`, so the neutral word is what this layer can say. + The per-class fallbacks that used to answer here ("Battery", "Status", ...) + were never displayed and are gone. + """ battery = SpanBatterySnapshotFactory.create(soe_percentage=77.0) snapshot = SpanPanelSnapshotFactory.create( battery=battery, @@ -184,39 +192,24 @@ def test_panel_sensor_default_friendly_names_cover_fallback_branches() -> None: ) coordinator = _make_coordinator(snapshot) - panel_data_desc = next( - desc for desc in PANEL_DATA_STATUS_SENSORS if desc.key == "main_relay_state" - ) status_desc = next(desc for desc in STATUS_SENSORS if desc.key == "software_version") - panel_power_desc = next(desc for desc in PANEL_POWER_SENSORS if desc.key == "instantGridPowerW") - panel_energy_desc = next( - desc for desc in PANEL_ENERGY_SENSORS if desc.key == "mainMeterEnergyConsumedWh" + assert not isinstance(status_desc.name, str), ( + "this case is only interesting for a description that declares no name" ) - panel_data_sensor = SpanPanelPanelStatus(coordinator, panel_data_desc, snapshot) + status_sensor = SpanPanelStatus(coordinator, status_desc, snapshot) - battery_sensor = SpanPanelBattery(coordinator, BATTERY_SENSOR, snapshot) - power_sensor = SpanPanelPowerSensor(coordinator, panel_power_desc, snapshot) - energy_sensor = SpanPanelEnergySensor(coordinator, panel_energy_desc, snapshot) - bess_sensor = SpanBessMetadataSensor( - coordinator, - BESS_METADATA_SENSORS[0], - snapshot, - {"identifiers": {("span_panel", "bess")}}, - ) - pv_sensor = SpanPVMetadataSensor( - coordinator, - PV_METADATA_SENSORS[0], - snapshot, - {"identifiers": {("span_panel", "pv")}}, - ) + assert status_sensor._generate_panel_name(snapshot, status_desc) == "Sensor" + + +def test_a_panel_sensor_with_a_name_shows_it() -> None: + """The other half of the same answer, with the label spelled on the description.""" + snapshot = SpanPanelSnapshotFactory.create() + coordinator = _make_coordinator(snapshot) + unnamed = next(desc for desc in PANEL_DATA_STATUS_SENSORS if desc.key == "main_relay_state") + described = replace(unnamed, name="Main Relay") - assert panel_data_sensor._generate_friendly_name(snapshot, panel_data_desc) == "Sensor" - assert status_sensor._generate_friendly_name(snapshot, status_desc) == "Status" - assert battery_sensor._generate_friendly_name(snapshot, BATTERY_SENSOR) == "Battery" - assert power_sensor._generate_friendly_name(snapshot, panel_power_desc) == "Power" - assert energy_sensor._generate_friendly_name(snapshot, panel_energy_desc) == "Energy" - assert bess_sensor._generate_friendly_name(snapshot, BESS_METADATA_SENSORS[0]) == "BESS Sensor" - assert pv_sensor._generate_friendly_name(snapshot, PV_METADATA_SENSORS[0]) == "PV Sensor" + sensor = SpanPanelPanelStatus(coordinator, described, snapshot) + assert sensor._generate_panel_name(snapshot, described) == "Main Relay" def test_panel_metadata_sensors_return_expected_data_sources() -> None: @@ -378,7 +371,6 @@ def test_circuit_power_sensor_subdevice_uses_short_name() -> None: device_info_override={"identifiers": {("span_panel", "evse")}}, ) - assert sensor._generate_friendly_name(snapshot, sensor.entity_description) == "Current" assert sensor._generate_panel_name(snapshot, sensor.entity_description) == "Current" @@ -391,10 +383,6 @@ def test_circuit_power_sensor_missing_circuit_uses_unmapped_fallback_name() -> N coordinator, CIRCUIT_CURRENT_SENSOR, snapshot, "missing_circuit" ) - assert ( - sensor._generate_friendly_name(snapshot, sensor.entity_description) - == "Unmapped Tab missing_circuit Current" - ) assert ( sensor._generate_panel_name(snapshot, sensor.entity_description) == "Unmapped Tab missing_circuit Current" @@ -463,10 +451,6 @@ def test_circuit_energy_sensor_missing_circuit_uses_fallback_names() -> None: sensor = SpanCircuitEnergySensor(coordinator, description, snapshot, "c9") - assert ( - sensor._generate_friendly_name(snapshot, sensor.entity_description) - == "Circuit c9 Consumed Energy" - ) assert ( sensor._generate_panel_name(snapshot, sensor.entity_description) == "Circuit c9 Consumed Energy" @@ -488,7 +472,6 @@ def test_circuit_energy_sensor_subdevice_uses_description_only() -> None: device_info_override={"identifiers": {("span_panel", "evse")}}, ) - assert sensor._generate_friendly_name(snapshot, sensor.entity_description) == "Consumed Energy" assert sensor._generate_panel_name(snapshot, sensor.entity_description) == "Consumed Energy" @@ -520,7 +503,7 @@ def test_unmapped_circuit_sensor_generates_unmapped_friendly_name() -> None: sensor = SpanUnmappedCircuitSensor(coordinator, UNMAPPED_SENSORS[0], snapshot, "unmapped_tab_7") assert ( - sensor._generate_friendly_name(snapshot, sensor.entity_description) + sensor._generate_panel_name(snapshot, sensor.entity_description) == "Unmapped Tab 7 Power" ) diff --git a/tests/test_suffix_mappings_are_closed.py b/tests/test_suffix_mappings_are_closed.py index 56c24e2d..9d0b16a7 100644 --- a/tests/test_suffix_mappings_are_closed.py +++ b/tests/test_suffix_mappings_are_closed.py @@ -32,6 +32,7 @@ get_panel_entity_suffix, get_user_friendly_suffix, ) +from custom_components.span_panel.naming import ENTITY_ID_SUFFIX_FORMS SERIAL = "sp3-001" CIRCUIT = "0dad2f16cd514812ae1807b0457d473e" @@ -80,12 +81,34 @@ "batteryPercentage": "battery_level", } +# The entity-id table, closed for a related but distinct reason. The mappings +# above translate a description key into a canonical suffix; this records every +# *wording* of that suffix an entity id has ever shipped with, so an existing +# entity can be proposed the id it already has. An added entry claims a spelling +# shipped that never did, and Recreate then offers a rename nobody asked for. +_ENTITY_ID_SUFFIX_FORMS = { + "power": frozenset({"power", "current_power"}), + "energy_produced": frozenset({"produced_energy", "energy_produced"}), + "energy_consumed": frozenset({"consumed_energy", "energy_consumed"}), + "energy_net": frozenset({"net_energy", "energy_net"}), + "current": frozenset({"current"}), + "breaker_rating": frozenset({"breaker_rating"}), + "breaker": frozenset({"breaker"}), + "circuit_priority": frozenset({"circuit_priority"}), +} + _CLOSED = ( "This mapping is closed. Adding, removing or changing an entry moves a live " "unique_id and entity_id on every installed panel -- statistics and the user's " "templates both. A new description key needs no entry: it resolves verbatim." ) +_CLOSED_FORMS = ( + "This table is historical fact, not a style. An entry is added only when " + "another wording is discovered to have shipped -- never to introduce one, " + "which would offer every circuit on the panel a rename." +) + def test_the_circuit_suffix_mapping_is_frozen() -> None: assert CIRCUIT_SUFFIX_MAPPING == _CIRCUIT_SUFFIXES, _CLOSED @@ -99,6 +122,17 @@ def test_the_panel_entity_suffix_mapping_is_frozen() -> None: assert PANEL_ENTITY_SUFFIX_MAPPING == _PANEL_ENTITY_SUFFIXES, _CLOSED +def test_the_entity_id_suffix_forms_are_frozen() -> None: + """The wordings an entity id has shipped with, which R1 turns on. + + `naming.circuit_object_id_base` reads an existing id's suffix back through + this table so the entity is proposed its own id. A wording missing from it + reads as unknown and the entity is offered the new spelling instead; a + wording invented into it is offered to entities that never had it. + """ + assert ENTITY_ID_SUFFIX_FORMS == _ENTITY_ID_SUFFIX_FORMS, _CLOSED_FORMS + + def test_the_combined_mapping_is_exactly_its_two_halves() -> None: """`ALL_SUFFIX_MAPPINGS` is derived, so it cannot gain an entry of its own. diff --git a/tests/test_upgrade_scenarios.py b/tests/test_upgrade_scenarios.py index 04cde7b7..0c841e51 100644 --- a/tests/test_upgrade_scenarios.py +++ b/tests/test_upgrade_scenarios.py @@ -13,12 +13,6 @@ USE_DEVICE_PREFIX, EntityNamingPattern, ) -from custom_components.span_panel.helpers import ( - construct_multi_circuit_entity_id, - construct_single_circuit_entity_id, -) - -from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory @pytest.fixture @@ -45,23 +39,6 @@ def __init__(self) -> None: return MockConfigEntry() -@pytest.fixture -def mock_coordinator(): - """Create a mock coordinator.""" - coordinator = MagicMock() - coordinator.config_entry = None # Will be set in tests - coordinator.hass = MagicMock() # Mock hass for entity registry access - return coordinator - - -@pytest.fixture -def mock_span_panel(): - """Create a mock span panel.""" - panel = MagicMock() - panel.status.serial_number = "TEST123" - return panel - - class TestUpgradeScenarios: """Test upgrade scenarios for different installation types.""" @@ -130,167 +107,6 @@ def test_new_installation_gets_modern_defaults(self, mock_config_entry): ) -class TestEntityIdConstructionUpgradeScenarios: - """Test entity ID construction preserves existing patterns during upgrades.""" - - def _make_coordinator_and_snapshot(self, options, circuit_tabs=None): - """Build a coordinator mock and snapshot with a single circuit.""" - circuit = SpanCircuitSnapshotFactory.create( - circuit_id="15", - name="Kitchen Outlets", - tabs=circuit_tabs or [15], - ) - snapshot = SpanPanelSnapshotFactory.create(circuits={"15": circuit}) - coordinator = MagicMock() - coordinator.data = snapshot - coordinator.config_entry = MagicMock() - coordinator.config_entry.title = "Span Panel" - coordinator.config_entry.data = {"device_name": "Span Panel"} - coordinator.config_entry.options = options - coordinator.hass = MagicMock() - return coordinator, snapshot, circuit - - def test_legacy_entity_id_construction_preserved(self): - """Test that legacy entity ID construction is preserved.""" - coordinator, snapshot, circuit = self._make_coordinator_and_snapshot( - {USE_DEVICE_PREFIX: False, USE_CIRCUIT_NUMBERS: False} - ) - entity_id = construct_single_circuit_entity_id( - coordinator, - snapshot, - "sensor", - "power", - circuit, - ) - assert entity_id == "sensor.kitchen_outlets_power" - - def test_post_104_friendly_names_entity_id_construction_preserved(self): - """Test that post-1.0.4 friendly names entity ID construction is preserved.""" - coordinator, snapshot, circuit = self._make_coordinator_and_snapshot( - {USE_DEVICE_PREFIX: True, USE_CIRCUIT_NUMBERS: False} - ) - entity_id = construct_single_circuit_entity_id( - coordinator, - snapshot, - "sensor", - "power", - circuit, - ) - assert entity_id == "sensor.span_panel_kitchen_outlets_power" - - def test_modern_circuit_numbers_120v_entity_id_construction_preserved(self): - """Test that modern circuit numbers entity ID for 120V is preserved.""" - coordinator, snapshot, circuit = self._make_coordinator_and_snapshot( - {USE_DEVICE_PREFIX: True, USE_CIRCUIT_NUMBERS: True}, - circuit_tabs=[15], - ) - entity_id = construct_single_circuit_entity_id( - coordinator, - snapshot, - "sensor", - "power", - circuit, - ) - assert entity_id == "sensor.span_panel_circuit_15_power" - - def test_modern_circuit_numbers_240v_entity_id_includes_both_tabs(self): - """Test that 240V circuits include both tabs in entity ID.""" - coordinator, snapshot, circuit = self._make_coordinator_and_snapshot( - {USE_DEVICE_PREFIX: True, USE_CIRCUIT_NUMBERS: True}, - circuit_tabs=[15, 17], - ) - entity_id = construct_single_circuit_entity_id( - coordinator, - snapshot, - "sensor", - "power", - circuit, - ) - assert entity_id == "sensor.span_panel_circuit_15_17_power" - - -class TestSyntheticEntityUpgradeScenarios: - """Test synthetic entity construction preserves existing patterns during upgrades.""" - - @patch("custom_components.span_panel.helpers.er.async_get") - def test_legacy_synthetic_entity_construction_preserved( - self, mock_registry, mock_coordinator, mock_span_panel - ): - """Test that legacy synthetic entity construction is preserved.""" - mock_registry.return_value = None - - mock_config_entry = MagicMock() - mock_config_entry.options = { - USE_DEVICE_PREFIX: False, - USE_CIRCUIT_NUMBERS: False, - } - mock_config_entry.title = "Span Panel" - mock_config_entry.data = {"device_name": "Span Panel"} - mock_coordinator.config_entry = mock_config_entry - - entity_id = construct_multi_circuit_entity_id( - mock_coordinator, - mock_span_panel, - "sensor", - "power", - circuit_numbers=[30, 32], - friendly_name="Solar Inverter", - ) - assert entity_id == "sensor.solar_inverter_power" - - @patch("custom_components.span_panel.helpers.er.async_get") - def test_post_104_synthetic_entity_construction_preserved( - self, mock_registry, mock_coordinator, mock_span_panel - ): - """Test that post-1.0.4 synthetic entity construction is preserved.""" - mock_registry.return_value = None - - mock_config_entry = MagicMock() - mock_config_entry.options = { - USE_DEVICE_PREFIX: True, - USE_CIRCUIT_NUMBERS: False, - } - mock_config_entry.title = "Span Panel" - mock_config_entry.data = {"device_name": "Span Panel"} - mock_coordinator.config_entry = mock_config_entry - - entity_id = construct_multi_circuit_entity_id( - mock_coordinator, - mock_span_panel, - "sensor", - "power", - circuit_numbers=[30, 32], - friendly_name="Solar Inverter", - ) - assert entity_id == "sensor.span_panel_solar_inverter_power" - - @patch("custom_components.span_panel.helpers.er.async_get") - def test_modern_synthetic_entity_construction_preserved( - self, mock_registry, mock_coordinator, mock_span_panel - ): - """Test that modern synthetic entity construction is preserved.""" - mock_registry.return_value = None - - mock_config_entry = MagicMock() - mock_config_entry.options = { - USE_DEVICE_PREFIX: True, - USE_CIRCUIT_NUMBERS: True, - } - mock_config_entry.title = "Span Panel" - mock_config_entry.data = {"device_name": "Span Panel"} - mock_coordinator.config_entry = mock_config_entry - - entity_id = construct_multi_circuit_entity_id( - mock_coordinator, - mock_span_panel, - "sensor", - "power", - circuit_numbers=[30, 32], - friendly_name="Solar Inverter", - ) - assert entity_id == "sensor.span_panel_circuit_30_32_power" - - class TestGeneralOptionsPreservesNamingFlags: """Test that general options flow preserves naming flags.""" From 93d67115d73907136134bf9ae6fe06a790db931e Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:41:34 -0700 Subject: [PATCH 155/345] test(naming): composed ids match the preset shape for the names a panel carries Home Assistant slugifies "{device} {base}" as one string; the deleted preset builder slugified the circuit name alone and joined the parts with `_`. Pins that the two agree for the names a panel can actually carry -- punctuation, runs of whitespace, accents -- so no new install diverges from today's shape. One case disagreed, and the old builder won. Its suffix-omission test was `circuit_part.endswith(f"_{suffix}")`, with a leading underscore, so a circuit named exactly "Power" kept both halves and got `..._power_power`. `circuit_object_id_base` tested without the underscore and would have offered every such circuit a rename to `..._power` -- a move the user did not cause, which R1 forbids. The rule now carries the underscore, and "Solar Power" still omits the word as it always did. Also drops an unused logger from sensor_panel.py and corrects a test docstring that claimed every panel sensor takes its displayed name from `_generate_panel_name`; the constructor does not ask it for a description that declares a translation_key, whose label comes from translations. --- custom_components/span_panel/naming.py | 13 ++++-- custom_components/span_panel/sensor_panel.py | 3 -- tests/test_naming.py | 28 +++++++++++++ tests/test_recreate_entity_ids.py | 42 ++++++++++++++++++++ tests/test_sensor_entities.py | 18 +++++---- 5 files changed, 90 insertions(+), 14 deletions(-) diff --git a/custom_components/span_panel/naming.py b/custom_components/span_panel/naming.py index a1afef98..91e15d1f 100644 --- a/custom_components/span_panel/naming.py +++ b/custom_components/span_panel/naming.py @@ -80,8 +80,15 @@ def circuit_object_id_base(identifier: str, suffix: str, existing_entity_id: str sensors derive from their description key via `id_builder.get_user_friendly_suffix` while the switch and the select -- which have no such key -- name theirs outright ("breaker", - "circuit_priority"). Words are omitted when the identifier already ends with - them, which is what the preset builder did for a circuit named "Solar Power". + "circuit_priority"). + + Words are omitted when the identifier already ends with them as a whole + word, which is what the preset builder did for a circuit named "Solar + Power" (`..._solar_power`, not `..._solar_power_power`). The test carries a + leading underscore because the builder's did: a circuit named exactly + "Power" is not a repetition of anything and kept both halves, + `..._power_power`. Dropping the underscore here would offer every such + circuit a rename to `..._power`, which R1 forbids. """ form = _existing_suffix_form(existing_entity_id, suffix) words = ( @@ -89,7 +96,7 @@ def circuit_object_id_base(identifier: str, suffix: str, existing_entity_id: str if form else NEW_ENTITY_ID_SUFFIX_WORDS.get(suffix, suffix.replace("_", " ")) ) - if slugify(identifier).endswith(slugify(words)): + if slugify(identifier).endswith(f"_{slugify(words)}"): return identifier return f"{identifier} {words}" diff --git a/custom_components/span_panel/sensor_panel.py b/custom_components/span_panel/sensor_panel.py index d7ff0b5a..280b3c37 100644 --- a/custom_components/span_panel/sensor_panel.py +++ b/custom_components/span_panel/sensor_panel.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging from typing import Any from homeassistant.helpers.device_registry import DeviceInfo @@ -33,8 +32,6 @@ SpanShedForecastSensorEntityDescription, ) -_LOGGER: logging.Logger = logging.getLogger(__name__) - def _grid_forming_device_name(snapshot: SpanPanelSnapshot) -> str | None: """Return the forming device's readable name, when the library knows it. diff --git a/tests/test_naming.py b/tests/test_naming.py index 1f7f32d8..92a6e761 100644 --- a/tests/test_naming.py +++ b/tests/test_naming.py @@ -80,6 +80,34 @@ def test_the_words_are_omitted_when_the_identifier_already_ends_with_them() -> N ) +def test_an_identifier_that_is_only_the_suffix_word_keeps_both_halves() -> None: + """The builder's test was `endswith(f"_{suffix}")`, underscore included. + + A circuit named exactly "Power" therefore got `..._power_power`, because + "power" does not end with "_power" and there was nothing to omit. Omitting + it here would offer every such circuit a rename to `..._power` -- a move the + user did not cause, which R1 forbids. + """ + assert circuit_object_id_base("Power", "power", None) == "Power power" + assert ( + circuit_object_id_base("Power", "power", "sensor.span_panel_power_power") + == "Power power" + ) + + +def test_a_renamed_circuit_takes_the_new_name_and_reads_the_suffix_from_its_id() -> None: + """Issue #252 at the layer that decides it: only the name half follows the panel. + + The identifier is the circuit's *current* name; the id it already carries is + consulted for nothing but which spelling of the suffix it shipped with. So a + circuit renamed in the SPAN app is proposed the new name and the same suffix. + """ + assert ( + circuit_object_id_base("Kitchen", "power", "sensor.span_panel_kitchen_outlets_power") + == "Kitchen power" + ) + + def test_every_form_table_entry_has_a_new_wording_and_vice_versa() -> None: assert set(ENTITY_ID_SUFFIX_FORMS) == set(NEW_ENTITY_ID_SUFFIX_WORDS) for suffix, words in NEW_ENTITY_ID_SUFFIX_WORDS.items(): diff --git a/tests/test_recreate_entity_ids.py b/tests/test_recreate_entity_ids.py index c05c3a47..b5a4c3ce 100644 --- a/tests/test_recreate_entity_ids.py +++ b/tests/test_recreate_entity_ids.py @@ -247,6 +247,48 @@ async def test_the_first_install_takes_its_entity_id_from_the_circuit_name( assert sensor.entity_id == ORIGINAL_ENTITY_ID +@pytest.mark.parametrize( + ("circuit_name", "expected"), + [ + ("Kitchen Outlets", "sensor.span_panel_kitchen_outlets_power"), + ("Fridge/Freezer", "sensor.span_panel_fridge_freezer_power"), + ("A/C - Upstairs", "sensor.span_panel_a_c_upstairs_power"), + ("Garage Door", "sensor.span_panel_garage_door_power"), + ("Café Lights", "sensor.span_panel_cafe_lights_power"), + ("Solar Power", "sensor.span_panel_solar_power"), + ("Power", "sensor.span_panel_power_power"), + ("SPAN Panel", "sensor.span_panel_span_panel_power"), + ], +) +async def test_composed_ids_match_the_preset_shape_for_odd_names( + hass: HomeAssistant, + entry: MockConfigEntry, + device_and_entity_parts: None, + circuit_name: str, + expected: str, +) -> None: + """A new install must land on the id the deleted preset builder would have spelled. + + The builder slugified the circuit name on its own and joined the parts with + `_`; Home Assistant slugifies `"{device} {base}"` as one string. The two + agree for punctuation, runs of whitespace and accents, and these are the + names a panel can actually carry -- so no new install diverges from today's + shape under `(DEVICE, ENTITY)`. + + The last three are the cases where the wording, not the slug, decides: + + - "Solar Power" already ends with the suffix word, so the builder omitted it + (`circuit_part.endswith(f"_{suffix}")`) and the id reads `..._solar_power`; + - "Power" does *not* -- the builder's test carried a leading underscore, so a + circuit named exactly "Power" got `..._power_power`; + - a circuit named after the panel gets both, `..._span_panel_span_panel_power`, + because the device half and the name half are composed independently. + """ + sensor = await _SensorInstall(hass, entry).load(circuit_name) + + assert sensor.entity_id == expected + + async def test_renaming_a_circuit_does_not_move_an_existing_entity_id( hass: HomeAssistant, entry: MockConfigEntry ) -> None: diff --git a/tests/test_sensor_entities.py b/tests/test_sensor_entities.py index 60badc36..0e1556ca 100644 --- a/tests/test_sensor_entities.py +++ b/tests/test_sensor_entities.py @@ -176,14 +176,16 @@ def test_only_the_grid_sensor_carries_the_topology_attribute() -> None: def test_a_panel_sensor_with_a_translation_key_falls_back_to_the_neutral_label() -> None: - """Every panel sensor now takes its displayed name from one shared answer. - - `_generate_panel_name` returns the description's own label, and a description - that declares a `translation_key` declares no name at all -- which is every - panel-level description this integration ships. Its real label reaches the UI - from `translations/en.json`, so the neutral word is what this layer can say. - The per-class fallbacks that used to answer here ("Battery", "Status", ...) - were never displayed and are gone. + """One shared answer replaces the per-class fallbacks, and it is name-only. + + `_generate_panel_name` returns the description's own label and nothing else; + the per-class fallbacks that used to answer here ("Battery", "Status", ...) + are gone. A description that declares a `translation_key` declares no name, + so the shared answer can only offer the neutral word -- which is why the + constructor does not ask it for those at all (`sensor_base.py`: `_attr_name` + is set only `if not ... translation_key`) and their real label reaches the UI + from `translations/en.json`. This case pins what the layer says when asked, + not what such a sensor displays. """ battery = SpanBatterySnapshotFactory.create(soe_percentage=77.0) snapshot = SpanPanelSnapshotFactory.create( From 383484c1d15430c2af0f3cce2c93cabc215a48a9 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:45:50 -0700 Subject: [PATCH 156/345] test(naming): a SPAN-app rename still reaches the display name through the reload it requests Pins phase 2 name sync end to end for sensor, switch and select in both naming modes, through a real EntityPlatform: the rename asks for a reload, the reload rebuilds original_name, the entity id does not move, and a name the user set is left alone. The README promises this in both modes and the composition change moved where the name is written, so the promise needed a case that can see the registry. The per-platform unit tests drive the same code against a MagicMock registry, which cannot tell `original_name` from `name` -- and writing `name` is exactly the shortcut that made Recreate propose a friendly-name id for a circuit-numbered entity. Each case settles the install with a reload and an unchanged push before renaming, so the reload it then asks for is the one the rename caused and not the one every freshly-added entity asks for on its first update. --- tests/test_recreate_entity_ids.py | 171 +++++++++++++++++++++++++++++- 1 file changed, 169 insertions(+), 2 deletions(-) diff --git a/tests/test_recreate_entity_ids.py b/tests/test_recreate_entity_ids.py index b5a4c3ce..50283332 100644 --- a/tests/test_recreate_entity_ids.py +++ b/tests/test_recreate_entity_ids.py @@ -27,7 +27,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import area_registry as ar, device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_registry import EntityNamePart import pytest from pytest_homeassistant_custom_component.common import ( @@ -42,6 +41,7 @@ USE_CIRCUIT_NUMBERS, USE_DEVICE_PREFIX, ) +from custom_components.span_panel.entity import SpanPanelEntity from custom_components.span_panel.id_builder import ( build_circuit_unique_id, build_select_unique_id, @@ -100,7 +100,7 @@ def _coordinator( return coordinator -class _Install[E: Entity]: +class _Install[E: SpanPanelEntity]: """One install of one platform's circuit entity, reloadable. `load` a second time is what a reload is: the entry's entities are torn down @@ -124,11 +124,37 @@ def __init__(self, hass: HomeAssistant, entry: MockConfigEntry) -> None: self._hass = hass self._entry = entry self._platform: MockEntityPlatform | None = None + self._coordinator: MagicMock | None = None + self._entity: E | None = None + + @property + def coordinator(self) -> MagicMock: + """The coordinator behind the entity this install last loaded.""" + assert self._coordinator is not None, "nothing has been loaded yet" + return self._coordinator def _build(self, coordinator: MagicMock, snapshot: SpanPanelSnapshot) -> E: """Construct the one entity this install adds. Subclasses answer.""" raise NotImplementedError + async def rename_on_the_panel(self, new_name: str) -> None: + """Deliver a snapshot in which the circuit was renamed, as a push does. + + The real phase 2 path and not a stand-in for it: a poll finds a changed + `circuit.name` and the coordinator notifies its listeners, which is + `_handle_coordinator_update` -- the method each of the three platforms + hangs its name sync off. Nothing else about the entity is touched, so + what happens next is the platform's own decision. + + The install pushes to the entity it loaded rather than being handed one, + because those two must be the same object for the push to reach the + platform whose coordinator this is. + """ + assert self._entity is not None, "nothing has been loaded yet" + self.coordinator.data = _snapshot(new_name) + self._entity._handle_coordinator_update() + await self._hass.async_block_till_done() + async def load(self, circuit_name: str) -> E: """Tear down any previous platform, then set one up from fresh panel data.""" if self._platform is not None: @@ -136,6 +162,7 @@ async def load(self, circuit_name: str) -> E: snapshot = _snapshot(circuit_name) coordinator = _coordinator(self._hass, snapshot, self._entry) + self._coordinator = coordinator self._entry.runtime_data = SpanPanelRuntimeData( coordinator=coordinator, panel_device_id="panel-device-id" ) @@ -150,6 +177,7 @@ async def load(self, circuit_name: str) -> E: await self._hass.async_block_till_done() assert entity.hass is not None, "entity was rejected before it reached the registry" + self._entity = entity return entity @@ -1078,3 +1106,142 @@ async def test_an_area_reaches_the_proposal_for_a_control_too( registry.async_regenerate_entity_id(registry_entry) == "switch.basement_span_panel_circuit_15_breaker" ) + + +# --- Phase 2: a rename in the SPAN app still reaches the display name --------- +# +# The README promises it in both modes (:594, :602): friendly names +# "automatically updates when you rename circuits in the SPAN panel", and +# circuit-numbers mode keeps ids stable while "friendly names still sync from +# SPAN panel for display". One mechanism serves both. A coordinator push +# carrying a changed circuit name asks the entry to reload, and the reload is +# what rebuilds `_attr_name` -- which Home Assistant stores as `original_name`. +# The shortcut the old scheme took, writing the registry's `name` in place, is +# exactly what made Recreate propose a friendly-name id for a circuit-numbered +# entity, so the mechanism has to keep costing a reload. +# +# The per-platform unit tests drive this against a `MagicMock` registry, which +# cannot see where the name landed. These drive it through a real +# `EntityPlatform` and the real registry, for all three circuit platforms in +# both modes, because handing composition the id moved where the name is +# written. + +type _CircuitInstall = type[_SensorInstall] | type[_SwitchInstall] | type[_SelectInstall] + +CIRCUIT_INSTALLS: list[_CircuitInstall] = [_SensorInstall, _SwitchInstall, _SelectInstall] +PLATFORM_IDS = ["sensor", "switch", "select"] + + +@pytest.mark.parametrize( + "mode", [FRIENDLY_NAMES, CIRCUIT_NUMBERS], ids=["friendly", "circuit_numbers"] +) +@pytest.mark.parametrize("make_install", CIRCUIT_INSTALLS, ids=PLATFORM_IDS) +async def test_a_rename_in_the_span_app_reaches_the_display_name_after_the_reload_it_requests( + hass: HomeAssistant, + entry: MockConfigEntry, + device_and_entity_parts: None, + mode: dict[str, bool], + make_install: _CircuitInstall, +) -> None: + """The whole promise in one case: push, reload, and the name has followed. + + Two loads before the push, because that is what a real install has done by + the time a rename arrives: the first add finds no registry entry and asks + for a reload on its first update regardless, and after the reload the entity + is on file and its remembered name is the panel's. Asserting on a + freshly-added entity would let that first-update reload stand in for the one + the rename is supposed to cause. + + So the unchanged push comes first and must ask for nothing. Only then does + the rename, and only it, buy a reload. + """ + hass.config_entries.async_update_entry(entry, options=dict(mode)) + install = make_install(hass, entry) + await install.load(ORIGINAL_NAME) + entity = await install.load(ORIGINAL_NAME) + original_entity_id = entity.entity_id + + registry = er.async_get(hass) + before = registry.async_get(original_entity_id) + assert before is not None + assert before.original_name is not None + assert before.original_name.startswith(ORIGINAL_NAME) + + await install.rename_on_the_panel(ORIGINAL_NAME) + install.coordinator.request_reload.assert_not_called() + + await install.rename_on_the_panel(RENAMED) + install.coordinator.request_reload.assert_called_once() + + reloaded = await install.load(RENAMED) + + after = registry.async_get(original_entity_id) + assert after is not None + assert after.original_name is not None + assert after.original_name.startswith(RENAMED) + assert reloaded.entity_id == original_entity_id # R5: the id did not move + assert after.name is None # R4: the registry's `name` was never written + + +@pytest.mark.parametrize("make_install", CIRCUIT_INSTALLS, ids=PLATFORM_IDS) +async def test_a_name_the_user_set_is_not_overridden_by_a_span_app_rename( + hass: HomeAssistant, + entry: MockConfigEntry, + device_and_entity_parts: None, + make_install: _CircuitInstall, +) -> None: + """A user who named an entity has said what it is called, and the panel has not. + + The reload is skipped as well as the name: reloading could not change what + is displayed while the registry holds a `name`, so asking for one would be a + whole-entry teardown that no user could see the point of. + """ + install = make_install(hass, entry) + await install.load(ORIGINAL_NAME) + entity = await install.load(ORIGINAL_NAME) + + registry = er.async_get(hass) + registry.async_update_entity(entity.entity_id, name="Beverage Cooling") + + await install.rename_on_the_panel(RENAMED) + install.coordinator.request_reload.assert_not_called() + + await install.load(RENAMED) + + after = registry.async_get(entity.entity_id) + assert after is not None + assert after.name == "Beverage Cooling" + + +@pytest.mark.parametrize( + ("mode", "expected"), + [(FRIENDLY_NAMES, RENAMED_ENTITY_ID), (CIRCUIT_NUMBERS, CIRCUIT_NUMBERS_ENTITY_ID)], + ids=["friendly", "circuit_numbers"], +) +async def test_what_recreate_offers_after_a_pushed_rename_depends_on_the_mode( + hass: HomeAssistant, + entry: MockConfigEntry, + device_and_entity_parts: None, + mode: dict[str, bool], + expected: str, +) -> None: + """The two modes part company at the offer, and nowhere earlier. + + Both followed the same push and the same reload, and both display the new + name. What differs is what Recreate proposes: friendly names offers the + renamed id, which is issue #252; circuit numbers offers the entity its own + id, because an id that follows the breaker position is the point of the + mode and a whole-panel rename would undo it. + """ + hass.config_entries.async_update_entry(entry, options=dict(mode)) + install = _SensorInstall(hass, entry) + await install.load(ORIGINAL_NAME) + await install.load(ORIGINAL_NAME) + + await install.rename_on_the_panel(RENAMED) + sensor = await install.load(RENAMED) + + registry = er.async_get(hass) + registry_entry = registry.async_get(sensor.entity_id) + assert registry_entry is not None + assert registry.async_regenerate_entity_id(registry_entry) == expected From bd352bcbfffe64ce2f1e09b10a78311aaaa11f54 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:54:22 -0700 Subject: [PATCH 157/345] docs: circuit entity ids follow the user's entity-id settings The circuit sensors, the breaker switch and the priority select no longer preset an entity_id; each supplies a base and Home Assistant composes the rest from entity_id_parts. Record what an upgrading user sees: nothing moves until Recreate is pressed, what Recreate then offers, the noun-last energy suffixes for new sensors, the two populations that keep a preset, the tab fallback for an unnamed circuit, and the device prefix that now appears in a circuit-numbers install's displayed name. Drop the 2.1.0 bullet announcing the energy-label rename: the labels are back to what 2.0.8 shipped, so only the betas ever saw the other wording. developer.md gains the mechanism -- naming.py as the only place circuit id wording is written, the suggested_object_id override, the legacy preset and why R1 requires it, and the release of the registry name field. The Recreate test whose docstring claims both modes display the new name now asserts it. --- CHANGELOG.md | 23 ++++++++++++-- README.md | 13 ++++++-- developer.md | 51 +++++++++++++++++++++++++++++-- tests/test_recreate_entity_ids.py | 2 ++ 4 files changed, 82 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a2a5e11..149e42a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,8 +103,25 @@ working entirely in 2027.8. - **The Wi-Fi network name moved to the Wi-Fi Link sensor** as a `wifi_ssid` attribute, absent rather than blank on a panel that publishes no SSID. - **It is no longer an attribute of the Software Version sensor**, so a template reading `state_attr('sensor.span_panel_software_version', 'wifi_ssid')` should point at the Wi-Fi Link binary sensor instead. -- **The three circuit energy sensors are renamed** to **Energy Produced**, **Energy Consumed** and **Energy Net**, matching the id suffixes they already have, - with entity ids, unique ids and history unchanged. +- **Circuit entity ids are now composed by Home Assistant from your own entity-id settings**, the way every other entity this integration creates already was: + the integration supplies the circuit half — `Circuit 15 Power` or `Kitchen Outlets Power`, as your naming option says — and Home Assistant adds the device + name, and the area as well if your settings include it (`Settings` > `System` > `General`). +- **Nothing you already have moves**: an entity keeps the id it has until you press **Recreate entity IDs**, which offers an entity its own id back where + nothing changed, and an id reflecting your own configuration where something did — a circuit you renamed, an area you assigned, or the name you gave the panel + device. +- **A panel whose device is not named "Span Panel"** — a second panel, or one you renamed — is now offered ids carrying that real device name, because the old + scheme spelled every circuit id `span_panel_` no matter what the device was called. +- **New circuit energy sensors get `consumed_energy`, `produced_energy` and `net_energy`**, the same word order as the panel-level energy sensors; existing + sensors keep the spelling they have, whichever of the two it is, and are never offered the other one. +- **Circuit sensors on a SPAN Drive's feed circuit are named for the charger alone on new installations** — `sensor._power`, matching the charger's + other sensors — while the ones you already have go on naming the panel. +- **The legacy `use_device_prefix` option can no longer keep the device out of a _new_ entity's id**, since Home Assistant includes the device unless your own + entity-id settings exclude it; the entities such an installation already has are untouched. +- **An unnamed circuit in friendly-names mode is now named for the tab it occupies** (`Circuit 7 Power`) instead of taking Home Assistant's generic default, so + two unnamed circuits no longer collide. +- **Circuit-numbers installations now show the device in the displayed name** — `SPAN Panel Kitchen Outlets Power`, as friendly-names installations always have + — so a template or a voice alias matching the old exact string needs updating. +- **A name you set in an entity's own settings still outranks the panel's**, and this integration never writes or clears that field. ### Fixed @@ -112,6 +129,8 @@ working entirely in 2027.8. added each circuit's whole lifetime counter to its offset. - **Recreate entity IDs proposes the ids your panel would produce now** (#252), in your installation's naming style, leaving unique ids and statistics untouched. +- **The registry `Name` an older release wrote on circuit-numbers installations is handed back to you** at the first start after upgrading, for every label + those sensors have ever carried, so that field is yours again and no longer decides what Recreate proposes. - **Enum sensors advertise the states they can actually report**, where nine sensors declared only `unknown` and `DSM Grid State` sitting at `On Grid` showed "Possible states: Unknown". - **The README described Battery Power's sign backwards**, since the sensor has always reported discharging as positive (#184) and only the documentation was diff --git a/README.md b/README.md index 171799ad..54e7c992 100644 --- a/README.md +++ b/README.md @@ -362,6 +362,10 @@ feature. A display suffix differentiates multiple chargers on the same panel: | Circuit numbers | `Main House SPAN Drive (SN-EVSE-001)` | `sensor.main_house_span_drive_sn_evse_001_charger_status` | | No suffix | `Main House SPAN Drive` | `sensor.main_house_span_drive_charger_status` | +The circuit that feeds a charger has its sensors shown on the charger's device. On new installations their entity IDs name the charger alone — +`sensor.main_house_span_drive_garage_power` — matching the charger's other sensors; installations that already have those sensors keep the IDs they have, which +name the panel. + #### EVSE Sensors (per charger) | Sensor | Device Class | Unit | Notes | @@ -591,16 +595,21 @@ The integration provides flexible entity naming patterns, configured during init - Entity IDs use descriptive circuit names from your SPAN panel - Example: `sensor.span_panel_kitchen_outlets_power` - - Automatically updates when you rename circuits in the SPAN panel + - Renaming a circuit in the SPAN app updates the displayed name automatically; the entity ID changes only if you accept the offer from **Recreate entity + IDs** - More intuitive for automations and scripts 2. **Circuit Numbers** (Stable entity IDs) - Entity IDs use generic circuit numbers - Example: `sensor.span_panel_circuit_15_power` - - Entity IDs remain stable even when circuits are renamed + - Entity IDs stay stable when circuits are renamed - Friendly names still sync from SPAN panel for display +The integration supplies only the circuit half of the ID shown above — `Kitchen Outlets Power` or `Circuit 15 Power`. Home Assistant composes the rest from your +own entity ID settings (`Settings` > `System` > `General`, Home Assistant 2026.8 and newer), which decide whether the device name and the area are prefixed; +entities you already have keep the IDs they have until you press **Recreate entity IDs**. + ### Energy Dip Compensation SPAN panels occasionally report lower energy readings for cumulative energy sensors after firmware updates or resets. Home Assistant's statistics engine diff --git a/developer.md b/developer.md index d4bb626b..f0cbad76 100644 --- a/developer.md +++ b/developer.md @@ -370,15 +370,60 @@ The adapter decides "read" from four enumerations of what it addresses — the m of the report. `tests/test_schema_one_discovery.py` in the library runs the same republish-and-diff experiment this gate uses and holds every entry to it in both directions, so the report means "nothing reads this" rather than "nobody wrote it down". +## Circuit entity ids: a base, not a preset + +Since Home Assistant 2026.8 the user owns entity id composition — `entity_id_parts` says whether the area, the device and the entity name are in an id, and Core +assembles it. This integration no longer presets `entity_id` for circuit entities. It supplies one _base_ per entity and Core does the rest, which is how every +non-circuit entity here has always worked. + +`naming.py` is the only place the wording of a circuit id is written down. `circuit_object_id_base(identifier, suffix, existing_entity_id)` joins the +naming-flag half — `Circuit 15`, `Kitchen Outlets`, `Unmapped Tab 32` — to the suffix wording, and it is the base, not the display name: the two are decoupled +on purpose so a label can be reworded without a migration. The suffix wording is read back from the id an entity already has, because two spellings have shipped +(`consumed_energy` before ids were preset, `energy_consumed` after); a new entity gets the noun-last form, matching the panel-level ids. +`ENTITY_ID_SUFFIX_FORMS` records the forms that have shipped and gains an entry only when another is _discovered_, never to introduce one. + +`SpanPanelEntity.suggested_object_id` hands Core that base. It reads `_span_object_id_base`, which circuit entities set and every other entity leaves `None`, in +which case the property falls through to Core's own answer — composition from the display name, exactly as before. Core consults `suggested_object_id` only for +an entity that has not preset `entity_id`. The four circuit platforms reach the base by the same route: the sensors through `_object_id_parts`, and the switch +and the select directly. + +### The legacy preset, and why two populations keep one + +R1 — "Recreate entity IDs" must never propose a change the user did not cause — bounds what may be composed. Two shapes Core cannot reproduce, for reasons that +belong to this integration rather than to the user: + +- a circuit sensor shown on a **sub-device** card, whose id names the panel because the preset builder always did, where composition names the charger, that + being the entity's device; +- any circuit entity on an install with **`USE_DEVICE_PREFIX` off**, whose id carries no device at all, where `has_entity_name` prefixes one regardless. + +`naming.legacy_preset_for_existing` is that whole exception, asked by all three circuit platforms so a copy per platform cannot drift. It answers `None` for +anything new — an entity with no id yet has none to protect — and `None` for the ordinary existing entity, whose id composition reproduces exactly. Only an +_existing_ entity in one of the two shapes goes on being preset, and the kept id is **computed from current panel data** rather than read back from the +registry, so a circuit renamed in the SPAN app still refreshes the name half. That is issue #252, which the preset must not undo. `LEGACY_PRESET_DEVICE_NAME` is +the literal `Span Panel` the old builder spelled every circuit id with — not the panel's own name, which is why a second panel's circuits are `span_panel_…` +too, and why keeping an id means keeping that spelling. + +### Releasing the registry name + +Circuit-numbers mode used to deliver the panel's circuit name by writing the entity registry's `name` — the _user's_ field, which Core reads ahead of everything +else when generating an entity id, so occupying it made Recreate propose a friendly-name id for a circuit-numbered entity. +`naming.release_registry_name_written_by_older_release` hands that field back at the first start after upgrading. It clears only a value this integration would +have written — `"{circuit_name} {description name}"`, for the description's current label and every label it has carried, which is what +`SpanPanelCircuitsSensorEntityDescription.legacy_names` exists to record. Anything else in that field is the user's and is left alone. + +The display name now travels as `original_name` via `_attr_name` for both modes, which means a rename pushed from the SPAN app is picked up by rebuilding the +entity: `_sync_circuit_name` asks the coordinator for a reload rather than writing the registry in place. It skips both the reload and the name where the +registry holds a `name`, since the user's field outranks the panel's and reloading could not change what is displayed. + ## The suffix mappings are closed `get_user_friendly_suffix` and `get_panel_entity_suffix` translate legacy camelCase description keys (`instantPowerW`, `instantGridPowerW`, `doorState`) into the suffixes their entities have carried since before 2.0.8. **Do not add entries.** A new description key needs none: it resolves to itself, which is what the sub-device builders (`build_bess_unique_id`, `build_mid_unique_id`, `build_evse_unique_id`) have always done, since their keys were written snake_case. -The reason is that the suffix is not only in the `unique_id` — it is the segment shared with the `entity_id` (`sensor_circuit.py:213`, and -`get_panel_entity_suffix`'s own docstring says so). So an edit here moves both on every installed panel: the `unique_id` costs the long-term statistics, and the -`entity_id` breaks whatever templates and automations a user wrote against it. +The reason is that the suffix is not only in the `unique_id` — it is the segment shared with the `entity_id` (it is the `suffix` half of +`naming.circuit_object_id_base`, and `get_panel_entity_suffix`'s own docstring says so). So an edit here moves both on every installed panel: the `unique_id` +costs the long-term statistics, and the `entity_id` breaks whatever templates and automations a user wrote against it. `tests/test_suffix_mappings_are_closed.py` holds all three dictionaries to their exact contents and fails on an added key, a removed key or a changed value — verified by mutation, not by inspection. diff --git a/tests/test_recreate_entity_ids.py b/tests/test_recreate_entity_ids.py index 50283332..94a9dbc2 100644 --- a/tests/test_recreate_entity_ids.py +++ b/tests/test_recreate_entity_ids.py @@ -1244,4 +1244,6 @@ async def test_what_recreate_offers_after_a_pushed_rename_depends_on_the_mode( registry = er.async_get(hass) registry_entry = registry.async_get(sensor.entity_id) assert registry_entry is not None + assert registry_entry.original_name is not None + assert registry_entry.original_name.startswith(RENAMED) assert registry.async_regenerate_entity_id(registry_entry) == expected From 42bccf1f0a8acad5b840677cdf3a53a9a76e4f0c Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:02:57 -0700 Subject: [PATCH 158/345] docs: say exactly what the release does to the registry name field The Changed bullet claimed the integration never writes or clears the registry name, which the Fixed bullet two lines down contradicts: the release does clear the value 2.0.8 wrote. Say the narrower true thing. Also: the displayed-name example uses the default device name Span Panel; the EVSE feed-circuit wording is "sensors created from this release on", since a charger added later to an existing install composes too; and the entity-id settings are named by their key rather than by a menu path nobody has verified against the 2026.8 frontend. developer.md gains the fact most likely to be "simplified" away later -- Core files suggested_object_id as object_id_base, which the user's parts prefix, while a preset entity_id becomes a verbatim unprefixed suggestion that outranks it -- plus the third gate keeping the unmapped-tab sensors off the preset path, and the platform count corrected to three. --- CHANGELOG.md | 11 ++++++----- README.md | 10 +++++----- developer.md | 19 ++++++++++++------- 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 149e42a0..c5b700c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,7 +105,7 @@ working entirely in 2027.8. point at the Wi-Fi Link binary sensor instead. - **Circuit entity ids are now composed by Home Assistant from your own entity-id settings**, the way every other entity this integration creates already was: the integration supplies the circuit half — `Circuit 15 Power` or `Kitchen Outlets Power`, as your naming option says — and Home Assistant adds the device - name, and the area as well if your settings include it (`Settings` > `System` > `General`). + name, and the area as well if your entity-id settings (`entity_id_parts`) include it. - **Nothing you already have moves**: an entity keeps the id it has until you press **Recreate entity IDs**, which offers an entity its own id back where nothing changed, and an id reflecting your own configuration where something did — a circuit you renamed, an area you assigned, or the name you gave the panel device. @@ -113,15 +113,16 @@ working entirely in 2027.8. scheme spelled every circuit id `span_panel_` no matter what the device was called. - **New circuit energy sensors get `consumed_energy`, `produced_energy` and `net_energy`**, the same word order as the panel-level energy sensors; existing sensors keep the spelling they have, whichever of the two it is, and are never offered the other one. -- **Circuit sensors on a SPAN Drive's feed circuit are named for the charger alone on new installations** — `sensor._power`, matching the charger's - other sensors — while the ones you already have go on naming the panel. +- **Circuit sensors on a SPAN Drive's feed circuit are named for the charger alone**, for sensors created from this release on — `sensor._power`, + matching the charger's other sensors — while the ones you already have go on naming the panel. - **The legacy `use_device_prefix` option can no longer keep the device out of a _new_ entity's id**, since Home Assistant includes the device unless your own entity-id settings exclude it; the entities such an installation already has are untouched. - **An unnamed circuit in friendly-names mode is now named for the tab it occupies** (`Circuit 7 Power`) instead of taking Home Assistant's generic default, so two unnamed circuits no longer collide. -- **Circuit-numbers installations now show the device in the displayed name** — `SPAN Panel Kitchen Outlets Power`, as friendly-names installations always have +- **Circuit-numbers installations now show the device in the displayed name** — `Span Panel Kitchen Outlets Power`, as friendly-names installations always have — so a template or a voice alias matching the old exact string needs updating. -- **A name you set in an entity's own settings still outranks the panel's**, and this integration never writes or clears that field. +- **A name you set in an entity's own settings still outranks the panel's**, and this integration no longer writes that field, clearing only the value an older + release of its own put there. ### Fixed diff --git a/README.md b/README.md index 54e7c992..1287bc06 100644 --- a/README.md +++ b/README.md @@ -362,9 +362,9 @@ feature. A display suffix differentiates multiple chargers on the same panel: | Circuit numbers | `Main House SPAN Drive (SN-EVSE-001)` | `sensor.main_house_span_drive_sn_evse_001_charger_status` | | No suffix | `Main House SPAN Drive` | `sensor.main_house_span_drive_charger_status` | -The circuit that feeds a charger has its sensors shown on the charger's device. On new installations their entity IDs name the charger alone — -`sensor.main_house_span_drive_garage_power` — matching the charger's other sensors; installations that already have those sensors keep the IDs they have, which -name the panel. +The circuit that feeds a charger has its sensors shown on the charger's device. For sensors created from this release on, their entity IDs name the charger +alone — `sensor.main_house_span_drive_garage_power` — matching the charger's other sensors; sensors that already exist keep the IDs they have, which name the +panel. #### EVSE Sensors (per charger) @@ -607,8 +607,8 @@ The integration provides flexible entity naming patterns, configured during init - Friendly names still sync from SPAN panel for display The integration supplies only the circuit half of the ID shown above — `Kitchen Outlets Power` or `Circuit 15 Power`. Home Assistant composes the rest from your -own entity ID settings (`Settings` > `System` > `General`, Home Assistant 2026.8 and newer), which decide whether the device name and the area are prefixed; -entities you already have keep the IDs they have until you press **Recreate entity IDs**. +own entity ID settings (`entity_id_parts`, Home Assistant 2026.8 and newer), which decide whether the device name and the area are prefixed; entities you +already have keep the IDs they have until you press **Recreate entity IDs**. ### Energy Dip Compensation diff --git a/developer.md b/developer.md index f0cbad76..91cffc60 100644 --- a/developer.md +++ b/developer.md @@ -384,8 +384,11 @@ on purpose so a label can be reworded without a migration. The suffix wording is `SpanPanelEntity.suggested_object_id` hands Core that base. It reads `_span_object_id_base`, which circuit entities set and every other entity leaves `None`, in which case the property falls through to Core's own answer — composition from the display name, exactly as before. Core consults `suggested_object_id` only for -an entity that has not preset `entity_id`. The four circuit platforms reach the base by the same route: the sensors through `_object_id_parts`, and the switch -and the select directly. +an entity that has not preset `entity_id`. **The property and a preset `entity_id` are not two routes to the same place**: `_async_derive_object_ids` +(`entity_platform.py`) files what the property returns as the registry's `object_id_base`, which Core then prefixes with the device and the area per the user's +parts, and files a preset `entity_id` as `suggested_object_id`, which outranks the base and is taken verbatim with no prefix at all. Setting `entity_id` to the +base would therefore ship the base as the whole id. The three circuit platforms reach the base by the same route: the sensors through `_object_id_parts`, and +the switch and the select directly. ### The legacy preset, and why two populations keep one @@ -397,11 +400,13 @@ belong to this integration rather than to the user: - any circuit entity on an install with **`USE_DEVICE_PREFIX` off**, whose id carries no device at all, where `has_entity_name` prefixes one regardless. `naming.legacy_preset_for_existing` is that whole exception, asked by all three circuit platforms so a copy per platform cannot drift. It answers `None` for -anything new — an entity with no id yet has none to protect — and `None` for the ordinary existing entity, whose id composition reproduces exactly. Only an -_existing_ entity in one of the two shapes goes on being preset, and the kept id is **computed from current panel data** rather than read back from the -registry, so a circuit renamed in the SPAN app still refreshes the name half. That is issue #252, which the preset must not undo. `LEGACY_PRESET_DEVICE_NAME` is -the literal `Span Panel` the old builder spelled every circuit id with — not the panel's own name, which is why a second panel's circuits are `span_panel_…` -too, and why keeping an id means keeping that spelling. +anything new — an entity with no id yet has none to protect — and `None` for the ordinary existing entity, whose id composition reproduces exactly. A third gate +sits in front of it on the sensor side, `_id_was_preset_by_this_integration` (`sensor_base.py`, set to `True` only by the circuit sensor classes): the hidden +unmapped-tab sensors have had their ids composed by Home Assistant all along, and an id Core already composes cannot be moved by handing Core the job, so they +never reach the preset path. Only an _existing_ entity in one of the two shapes goes on being preset, and the kept id is **computed from current panel data** +rather than read back from the registry, so a circuit renamed in the SPAN app still refreshes the name half. That is issue #252, which the preset must not undo. +`LEGACY_PRESET_DEVICE_NAME` is the literal `Span Panel` the old builder spelled every circuit id with — not the panel's own name, which is why a second panel's +circuits are `span_panel_…` too, and why keeping an id means keeping that spelling. ### Releasing the registry name From 5d5e51132ed4ca92f57bfdebb55fd29e1791fbdf Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:17:14 -0700 Subject: [PATCH 159/345] fix(naming): keep ids on generated panel names, and read the suffix back anchored on the circuit Two ways "Recreate entity IDs" still offered a move nobody asked for (R1). The config flow names the panel device: the second panel on a system becomes "Span Panel 2" without anyone typing it. Composition spells the device half from that name, while every circuit id already on that panel says `span_panel_` -- the literal the deleted preset builder fell back to, since it was never told the panel's name. So the whole panel was offered `sensor.span_panel_2_...`. The preset exception now covers a generated device name that is not "Span Panel", read from the registry's `name`. A name in `name_by_user` is the user's and still composes, because Home Assistant composes from `name_by_user or name` and following that is following the user. The suffix half was read back from the end of the existing id and the omission rule decided from the identifier alone, so the two could disagree with the id they were meant to reproduce. A circuit named "Current" had `..._current_power` read as the `current_power` form and was offered `..._current_current_power`; a circuit named "Solar Power" whose id kept both halves was offered `..._solar_power`. Both questions are one question, so where the id still names the circuit they are answered together, anchored on the identifier: what follows the name is the form, and a name the id stops at means the form was omitted. A circuit renamed on the panel is unchanged -- its id no longer names it, so the suffix is read from the end as before and the name half follows the panel, which is issue #252. --- CHANGELOG.md | 12 +- custom_components/span_panel/entity.py | 22 +++ custom_components/span_panel/naming.py | 143 ++++++++++++--- custom_components/span_panel/select.py | 1 + custom_components/span_panel/sensor_base.py | 13 +- custom_components/span_panel/switch.py | 1 + tests/test_naming.py | 183 ++++++++++++++++++++ tests/test_recreate_entity_ids.py | 125 +++++++++++++ 8 files changed, 473 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5b700c7..5440b83e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -109,16 +109,20 @@ working entirely in 2027.8. - **Nothing you already have moves**: an entity keeps the id it has until you press **Recreate entity IDs**, which offers an entity its own id back where nothing changed, and an id reflecting your own configuration where something did — a circuit you renamed, an area you assigned, or the name you gave the panel device. -- **A panel whose device is not named "Span Panel"** — a second panel, or one you renamed — is now offered ids carrying that real device name, because the old - scheme spelled every circuit id `span_panel_` no matter what the device was called. +- **A second panel keeps the ids it has.** Its device is called "Span Panel 2" because this integration named it that, not because you did, so the circuit + entities already on it are still offered their own `span_panel_` ids; only circuits added from this release on are spelled `span_panel_2_`. +- **A panel device you renamed yourself is offered ids carrying that name**, since that is a change you made — the old scheme spelled every circuit id + `span_panel_` no matter what the device was called. - **New circuit energy sensors get `consumed_energy`, `produced_energy` and `net_energy`**, the same word order as the panel-level energy sensors; existing sensors keep the spelling they have, whichever of the two it is, and are never offered the other one. - **Circuit sensors on a SPAN Drive's feed circuit are named for the charger alone**, for sensors created from this release on — `sensor._power`, matching the charger's other sensors — while the ones you already have go on naming the panel. - **The legacy `use_device_prefix` option can no longer keep the device out of a _new_ entity's id**, since Home Assistant includes the device unless your own entity-id settings exclude it; the entities such an installation already has are untouched. -- **An unnamed circuit in friendly-names mode is now named for the tab it occupies** (`Circuit 7 Power`) instead of taking Home Assistant's generic default, so - two unnamed circuits no longer collide. +- **An unnamed circuit in friendly-names mode is now named for the tab it occupies** (`Circuit 7 Power`), so two unnamed circuits no longer collide — the old + scheme called every one of them `single_circuit` and let the registry tell them apart with `_2`, `_3`. +- **An unnamed circuit you already have may be offered a `circuit_7` id** if you press **Recreate entity IDs**, which is the one place this release proposes a + new id for a circuit you did not rename. Its current id keeps working until you accept. - **Circuit-numbers installations now show the device in the displayed name** — `Span Panel Kitchen Outlets Power`, as friendly-names installations always have — so a template or a voice alias matching the old exact string needs updating. - **A name you set in an entity's own settings still outranks the panel's**, and this integration no longer writes that field, clearing only the value an older diff --git a/custom_components/span_panel/entity.py b/custom_components/span_panel/entity.py index 8e74a41e..2b2f4bbd 100644 --- a/custom_components/span_panel/entity.py +++ b/custom_components/span_panel/entity.py @@ -14,6 +14,7 @@ from .control_gate import CONTROL_CALLER, async_bind_caller from .coordinator import SpanPanelCoordinator from .field_paths import FieldPathDeclarationMixin +from .naming import generated_panel_device_name from .util import snapshot_to_device_info @@ -183,3 +184,24 @@ def _build_device_info( ) host = coordinator.config_entry.data.get(CONF_HOST) return snapshot_to_device_info(snapshot, device_name, host=host) + + @staticmethod + def _generated_panel_device_name( + coordinator: SpanPanelCoordinator, + snapshot: SpanPanelSnapshot, + ) -> str | None: + """Return the panel device's name where this integration generated it. + + The registry's answer, not the config entry's: the rule turns on which + registry field the name sits in, `name` being ours and `name_by_user` + the user's, and a `DeviceInfo` cannot tell the two apart. Read from the + panel's own device even for an entity shown on a sub-device card, since + the ids at stake are the ones the preset builder spelled with the panel. + + Static, and taking the coordinator, so the three circuit platforms can + ask before `super().__init__` has run -- which is where the switch + decides its id, and so where `self.coordinator` does not exist yet. + """ + return generated_panel_device_name( + coordinator.hass, coordinator.config_entry.entry_id, snapshot.serial_number + ) diff --git a/custom_components/span_panel/naming.py b/custom_components/span_panel/naming.py index 91e15d1f..81e74018 100644 --- a/custom_components/span_panel/naming.py +++ b/custom_components/span_panel/naming.py @@ -28,9 +28,12 @@ from collections.abc import Iterable from typing import Final -from homeassistant.helpers import entity_registry as er +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util import slugify +from .const import DOMAIN + ENTITY_ID_SUFFIX_FORMS: dict[str, frozenset[str]] = { "power": frozenset({"power", "current_power"}), "energy_produced": frozenset({"produced_energy", "energy_produced"}), @@ -60,18 +63,39 @@ """The wording a new entity's base ends with -- noun-last, like the panel level.""" +def _known_forms(suffix: str) -> list[str]: + """Return every form of `suffix`, longest first so `current_power` outranks `power`.""" + return sorted(ENTITY_ID_SUFFIX_FORMS.get(suffix, ()), key=len, reverse=True) + + +def _ends_on_segment(object_id: str, segment: str) -> bool: + """Report whether `object_id` ends on `segment` at a word boundary. + + The equality arm is the install with no device prefix, whose object id *is* + the circuit half and so has no underscore in front of it. + """ + return object_id == segment or object_id.endswith(f"_{segment}") + + def _existing_suffix_form(existing_entity_id: str | None, suffix: str) -> str | None: """Return the suffix form an existing id carries, or None if it carries none we know.""" if existing_entity_id is None: return None object_id = existing_entity_id.split(".", 1)[-1] - # Longest first so `current_power` is not read as `power`. - for form in sorted(ENTITY_ID_SUFFIX_FORMS.get(suffix, ()), key=len, reverse=True): + for form in _known_forms(suffix): if object_id.endswith(f"_{form}"): return form return None +def _form_after_the_identifier(object_id: str, identifier_slug: str, suffix: str) -> str | None: + """Return the form this id spells *after* naming this circuit, if it spells one.""" + for form in _known_forms(suffix): + if _ends_on_segment(object_id, f"{identifier_slug}_{form}"): + return form + return None + + def circuit_object_id_base(identifier: str, suffix: str, existing_entity_id: str | None) -> str: """Return the base for one circuit entity. @@ -82,21 +106,43 @@ def circuit_object_id_base(identifier: str, suffix: str, existing_entity_id: str which have no such key -- name theirs outright ("breaker", "circuit_priority"). - Words are omitted when the identifier already ends with them as a whole - word, which is what the preset builder did for a circuit named "Solar - Power" (`..._solar_power`, not `..._solar_power_power`). The test carries a - leading underscore because the builder's did: a circuit named exactly - "Power" is not a repetition of anything and kept both halves, - `..._power_power`. Dropping the underscore here would offer every such - circuit a rename to `..._power`, which R1 forbids. + Where the existing id still names this circuit, what follows the name + settles both halves at once -- which form the id carries, and whether the + preset builder omitted it. They are one question: an id reading + `..._solar_power_power` carries `power` and omitted nothing, while + `..._solar_power` omitted it. Answering each half on its own -- the form by + a plain `endswith`, the omission from the identifier alone -- disagreed with + the id in both directions. A circuit named "Current" had `..._current_power` + read as the `current_power` form and was offered + `..._current_current_power`; a circuit named "Solar Power" whose id kept + both halves was offered `..._solar_power`. Both are renames the user did not + cause, which R1 forbids. + + Only where the id no longer names this circuit -- it was renamed on the + panel, issue #252 -- is the form read back by itself, from the end of the + id, and the omission decided from the *new* name. That is the whole point of + the rename: the name half follows the panel and the suffix half does not. + + The omission test carries a leading underscore because the builder's did: a + circuit named exactly "Power" is not a repetition of anything and kept both + halves, `..._power_power`. """ + identifier_slug = slugify(identifier) + if existing_entity_id is not None: + object_id = existing_entity_id.split(".", 1)[-1] + form = _form_after_the_identifier(object_id, identifier_slug, suffix) + if form is not None: + return f"{identifier} {form.replace('_', ' ')}" + if _ends_on_segment(object_id, identifier_slug): + return identifier + form = _existing_suffix_form(existing_entity_id, suffix) words = ( form.replace("_", " ") if form else NEW_ENTITY_ID_SUFFIX_WORDS.get(suffix, suffix.replace("_", " ")) ) - if slugify(identifier).endswith(f"_{slugify(words)}"): + if identifier_slug.endswith(f"_{slugify(words)}"): return identifier return f"{identifier} {words}" @@ -112,6 +158,33 @@ def circuit_object_id_base(identifier: str, suffix: str, existing_entity_id: str """ +def generated_panel_device_name( + hass: HomeAssistant, entry_id: str, serial_number: str +) -> str | None: + """Return the panel device's registered name, where this integration generated it. + + Nobody types "Span Panel 2": the config flow invents it for the second panel + on a system (`config_flow.get_unique_device_name`) and stores it as the + entry's `device_name`, which is what reaches the device registry. So a name + in `name` is ours and a name in `name_by_user` is the user's, and only the + first is a reason to keep an id from moving -- Home Assistant composes the + device half from `name_by_user or name`, so following a rename the user made + is following the user. + + `None` where the device is not registered yet, which is the first setup of a + panel: entities are built before the platform creates it, and an install + with no device has no existing id to protect either. + + Scoped to the config entry rather than searched across every one, because + identifiers are unique only within an entry -- and a system with two panels + is exactly the case this answers for. + """ + device = dr.async_get(hass).async_get_device_by_identifier((DOMAIN, serial_number), entry_id) + if device is None or device.name_by_user is not None: + return None + return device.name + + def legacy_preset_entity_id( platform: str, device_slug: str | None, @@ -121,18 +194,22 @@ def legacy_preset_entity_id( ) -> str: """Return the id an existing entity keeps, where composing one would move it. - Two shapes Home Assistant's composition cannot reproduce, for reasons that + Three shapes Home Assistant's composition cannot reproduce, for reasons that belong to this integration rather than to the user: - a circuit sensor shown on a **sub-device** card, whose id names the panel because the preset builder always did, where composition names the charger -- that being the entity's device; - any circuit entity on an install with **`USE_DEVICE_PREFIX` off**, whose id - carries no device at all, where `has_entity_name` prefixes one regardless. + carries no device at all, where `has_entity_name` prefixes one regardless; + - any circuit entity on a panel whose device name **this integration + generated** as something other than "Span Panel" -- the second panel on a + system -- whose id says `span_panel_` all the same, where composition says + `span_panel_2_`. - R1 forbids offering either move, so those entities go on being preset. Only - those: `device_slug` is `None` for the prefix-less case and everything else - composes, which is the whole point of the release. + R1 forbids offering any of those moves, so those entities go on being preset. + Only those: `device_slug` is `None` for the prefix-less case and everything + else composes, which is the whole point of the release. The id is *computed* from current panel data and never read back from the registry, so a circuit renamed in the SPAN app still refreshes the name half @@ -146,6 +223,20 @@ def legacy_preset_entity_id( return f"{platform}.{object_id}" +def _names_another_device(device_name: str | None) -> bool: + """Report whether composition would spell the device half some other way. + + Only a name this integration generated reaches here, so a name that is not + "Span Panel" is one no user chose -- and every existing circuit id on that + panel says `span_panel_` regardless, because the preset builder was never + told the panel's name. `None` is the panel whose device is not registered + yet, or one the user renamed, and neither is ours to hold still. + """ + if device_name is None: + return False + return slugify(device_name) != slugify(LEGACY_PRESET_DEVICE_NAME) + + def legacy_preset_for_existing( platform: str, *, @@ -154,24 +245,34 @@ def legacy_preset_for_existing( existing_entity_id: str | None, use_device_prefix: bool, is_sub_device: bool, + device_name: str | None, ) -> str | None: """Return the id this entity keeps, or None to let Home Assistant compose one. The whole R1 exception in one place, asked by all three circuit platforms -- the sensors, the breaker switch and the priority select. Each used to preset - its id through the same builder, so each faces the same two shapes - composition spells differently; a copy of this decision per platform is how - three platforms drift apart on which entities the release moves. + its id through the same builder, so each faces the same shapes composition + spells differently; a copy of this decision per platform is how three + platforms drift apart on which entities the release moves. + + `device_name` is what `generated_panel_device_name` answers: the panel + device's name where this integration generated it, and `None` where the user + renamed it or it is not registered yet. A generated name that is not "Span + Panel" is the third shape -- the second panel on a system, called "Span + Panel 2" by the config flow, whose circuit ids nonetheless all say + `span_panel_` because the preset builder was never told the panel's name. + Composition would offer that whole panel `sensor.span_panel_2_...`, a move + nobody asked for. `None` for anything new: an entity with no id yet has none to protect, and composing one is the point of the release. `None` too for the ordinary existing entity, whose id composition reproduces exactly. What is left is - `legacy_preset_entity_id`'s two shapes -- see it for why each is kept and for + `legacy_preset_entity_id`'s shapes -- see it for why each is kept and for why the kept id is computed from current panel data rather than read back. """ if existing_entity_id is None: return None - if not (is_sub_device or not use_device_prefix): + if not (is_sub_device or not use_device_prefix or _names_another_device(device_name)): return None return legacy_preset_entity_id( platform, diff --git a/custom_components/span_panel/select.py b/custom_components/span_panel/select.py index 38207bcd..ef2c9ea1 100644 --- a/custom_components/span_panel/select.py +++ b/custom_components/span_panel/select.py @@ -175,6 +175,7 @@ def __init__( existing_entity_id=existing_entity_id, use_device_prefix=use_device_prefix, is_sub_device=False, + device_name=self._generated_panel_device_name(coordinator, snapshot), ) if preset is not None: self.entity_id = preset diff --git a/custom_components/span_panel/sensor_base.py b/custom_components/span_panel/sensor_base.py index c3d8ac7b..760a4766 100644 --- a/custom_components/span_panel/sensor_base.py +++ b/custom_components/span_panel/sensor_base.py @@ -158,7 +158,7 @@ def __init__( if parts is not None: identifier, suffix = parts preset = self._preset_composition_would_move( - identifier, suffix, existing_entity_id + snapshot, identifier, suffix, existing_entity_id ) if preset is not None: self.entity_id = preset @@ -236,7 +236,11 @@ def _object_id_parts( return None def _preset_composition_would_move( - self, identifier: str, suffix: str, existing_entity_id: str | None + self, + snapshot: SpanPanelSnapshot, + identifier: str, + suffix: str, + existing_entity_id: str | None, ) -> str | None: """Return the id to go on presetting, or None to let Core compose one. @@ -246,6 +250,10 @@ def _preset_composition_would_move( preset this entity's id at all. The hidden unmapped-tab sensors say no, and an id Home Assistant has always composed cannot be moved by handing Home Assistant the job. + + `snapshot` is taken rather than read off the coordinator because it is + the snapshot this entity is being built from, and it carries the serial + the panel's device is registered under. """ if not self._id_was_preset_by_this_integration: return None @@ -257,6 +265,7 @@ def _preset_composition_would_move( existing_entity_id=existing_entity_id, use_device_prefix=use_device_prefix, is_sub_device=self._is_sub_device, + device_name=self._generated_panel_device_name(self.coordinator, snapshot), ) def _generate_panel_name(self, snapshot: SpanPanelSnapshot, description: T) -> str | None: diff --git a/custom_components/span_panel/switch.py b/custom_components/span_panel/switch.py index 868c5e5e..2d7d7bda 100644 --- a/custom_components/span_panel/switch.py +++ b/custom_components/span_panel/switch.py @@ -127,6 +127,7 @@ def __init__( existing_entity_id=existing_entity_id, use_device_prefix=use_device_prefix, is_sub_device=False, + device_name=self._generated_panel_device_name(coordinator, snapshot), ) if preset is not None: self.entity_id = preset diff --git a/tests/test_naming.py b/tests/test_naming.py index 92a6e761..5682dac0 100644 --- a/tests/test_naming.py +++ b/tests/test_naming.py @@ -108,6 +108,41 @@ def test_a_renamed_circuit_takes_the_new_name_and_reads_the_suffix_from_its_id() ) +@pytest.mark.parametrize( + ("identifier", "suffix", "existing", "expected"), + [ + # A circuit named "Current". The id's trailing `_current_power` is the + # identifier followed by `power`, not the `current_power` spelling of the + # suffix -- reading it as the latter proposed `..._current_current_power`. + ("Current", "power", "sensor.span_panel_current_power", "Current power"), + # A circuit named "Solar Power" whose id kept both halves. The omission + # rule looks only at the identifier, so it proposed `..._solar_power` and + # offered every such circuit a rename. + ("Solar Power", "power", "sensor.span_panel_solar_power_power", "Solar Power power"), + # The same circuit on an install whose id did omit them. + ("Solar Power", "power", "sensor.span_panel_solar_power", "Solar Power"), + # Both, on an install with no device prefix: the identifier opens the + # object id rather than following an underscore. + ("Solar Power", "power", "sensor.solar_power_power", "Solar Power power"), + ("Solar Power", "power", "sensor.solar_power", "Solar Power"), + # A circuit named exactly "Power", which never omitted anything. + ("Power", "power", "sensor.span_panel_power_power", "Power power"), + ], +) +def test_the_suffix_is_read_back_from_what_follows_the_identifier( + identifier: str, suffix: str, existing: str, expected: str +) -> None: + """Where the id names this circuit, what follows the name settles both halves. + + Both halves, because they are one question: an id that spells the identifier + and then a known form carries that form and omitted nothing, and an id that + stops at the identifier omitted the form. Reading the suffix by a plain + `endswith` and then deciding omission from the identifier alone answered + each half without the other, and disagreed with the id in both directions. + """ + assert circuit_object_id_base(identifier, suffix, existing) == expected + + def test_every_form_table_entry_has_a_new_wording_and_vice_versa() -> None: assert set(ENTITY_ID_SUFFIX_FORMS) == set(NEW_ENTITY_ID_SUFFIX_WORDS) for suffix, words in NEW_ENTITY_ID_SUFFIX_WORDS.items(): @@ -252,6 +287,7 @@ def test_a_new_entity_composes_whatever_the_install_looks_like() -> None: existing_entity_id=None, use_device_prefix=False, is_sub_device=True, + device_name=LEGACY_PRESET_DEVICE_NAME, ) is None ) @@ -267,6 +303,7 @@ def test_an_ordinary_existing_entity_composes_too() -> None: existing_entity_id="switch.span_panel_kitchen_outlets_breaker", use_device_prefix=True, is_sub_device=False, + device_name=LEGACY_PRESET_DEVICE_NAME, ) is None ) @@ -282,6 +319,7 @@ def test_an_existing_entity_on_a_no_prefix_install_keeps_its_bare_id() -> None: existing_entity_id="select.kitchen_outlets_circuit_priority", use_device_prefix=False, is_sub_device=False, + device_name=LEGACY_PRESET_DEVICE_NAME, ) == "select.kitchen_outlets_circuit_priority" ) @@ -297,6 +335,7 @@ def test_an_existing_sub_device_entity_keeps_the_panel_prefix() -> None: existing_entity_id="sensor.span_panel_kitchen_outlets_power", use_device_prefix=True, is_sub_device=True, + device_name=LEGACY_PRESET_DEVICE_NAME, ) == "sensor.span_panel_kitchen_outlets_power" ) @@ -312,11 +351,102 @@ def test_the_kept_id_still_follows_a_circuit_rename() -> None: existing_entity_id="switch.kitchen_outlets_breaker", use_device_prefix=False, is_sub_device=False, + device_name=LEGACY_PRESET_DEVICE_NAME, ) == "switch.beer_fridge_breaker" ) +# --- A panel device this integration named, rather than the user --------------- +# +# The config flow generates the device name: the second panel on a system is +# called "Span Panel 2" without anyone typing it. Composition spells the DEVICE +# part from that name, while every existing circuit id on that panel says +# `span_panel_` -- the literal the preset builder fell back to. That difference +# is ours, so those ids are kept. A name the *user* gave the device is theirs, +# and an id following it is one they caused. + + +def test_a_generated_panel_device_name_keeps_an_existing_id() -> None: + """Nobody typed "Span Panel 2", so nobody asked for `sensor.span_panel_2_...`.""" + assert ( + legacy_preset_for_existing( + "sensor", + identifier="Kitchen Outlets", + suffix="power", + existing_entity_id="sensor.span_panel_kitchen_outlets_power", + use_device_prefix=True, + is_sub_device=False, + device_name="Span Panel 2", + ) + == "sensor.span_panel_kitchen_outlets_power" + ) + + +def test_the_first_panels_generated_name_composes_because_it_agrees() -> None: + """The first panel is named "Span Panel", which is what its ids already say.""" + assert ( + legacy_preset_for_existing( + "sensor", + identifier="Kitchen Outlets", + suffix="power", + existing_entity_id="sensor.span_panel_kitchen_outlets_power", + use_device_prefix=True, + is_sub_device=False, + device_name=LEGACY_PRESET_DEVICE_NAME, + ) + is None + ) + + +def test_a_device_not_in_the_registry_yet_composes() -> None: + """No device means a first-ever setup, and nothing existing to protect.""" + assert ( + legacy_preset_for_existing( + "sensor", + identifier="Kitchen Outlets", + suffix="power", + existing_entity_id="sensor.span_panel_kitchen_outlets_power", + use_device_prefix=True, + is_sub_device=False, + device_name=None, + ) + is None + ) + + +def test_a_generated_panel_device_name_does_not_reach_a_new_entity() -> None: + """A new entity composes on every install; the exception is for ids that exist.""" + assert ( + legacy_preset_for_existing( + "sensor", + identifier="Kitchen Outlets", + suffix="power", + existing_entity_id=None, + use_device_prefix=True, + is_sub_device=False, + device_name="Span Panel 2", + ) + is None + ) + + +def test_a_generated_name_that_slugifies_the_same_composes() -> None: + """The config entry spells it "SPAN Panel"; the id says `span_panel` either way.""" + assert ( + legacy_preset_for_existing( + "sensor", + identifier="Kitchen Outlets", + suffix="power", + existing_entity_id="sensor.span_panel_kitchen_outlets_power", + use_device_prefix=True, + is_sub_device=False, + device_name="SPAN Panel", + ) + is None + ) + + from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -408,3 +538,56 @@ def test_without_a_base_core_gets_the_stock_answer() -> None: """Panel, BESS, EVSE and MID entities keep composing from their name.""" probe = _Probe() assert probe.suggested_object_id == "Display Name" + + +# --- Which panel device names this integration generated ---------------------- + +from homeassistant.helpers import device_registry as dr +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.span_panel.naming import generated_panel_device_name + +_DEVICE_SERIAL = "sp3-naming-001" + + +def _register_panel( + hass: HomeAssistant, name: str, *, name_by_user: str | None = None +) -> MockConfigEntry: + """Register a panel device under a config entry, optionally renamed by the user.""" + config_entry = MockConfigEntry(domain=DOMAIN, entry_id="entry-naming", unique_id=_DEVICE_SERIAL) + config_entry.add_to_hass(hass) + device_registry = dr.async_get(hass) + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, _DEVICE_SERIAL)}, + name=name, + ) + if name_by_user is not None: + device_registry.async_update_device(device.id, name_by_user=name_by_user) + return config_entry + + +async def test_the_name_the_config_flow_generated_is_reported(hass: HomeAssistant) -> None: + """The config flow invented "Span Panel 2" for the second panel; nobody typed it.""" + config_entry = _register_panel(hass, "Span Panel 2") + + assert ( + generated_panel_device_name(hass, config_entry.entry_id, _DEVICE_SERIAL) == "Span Panel 2" + ) + + +async def test_a_device_the_user_renamed_has_no_generated_name(hass: HomeAssistant) -> None: + """`name_by_user` is the user's field, and Home Assistant composes ids from it first.""" + config_entry = _register_panel(hass, "Span Panel 2", name_by_user="Garage Panel") + + assert generated_panel_device_name(hass, config_entry.entry_id, _DEVICE_SERIAL) is None + + +async def test_a_panel_with_no_device_registered_yet_has_no_name(hass: HomeAssistant) -> None: + """The first-ever setup, where entities are built before the device exists.""" + config_entry = MockConfigEntry( + domain=DOMAIN, entry_id="entry-naming-empty", unique_id=_DEVICE_SERIAL + ) + config_entry.add_to_hass(hass) + + assert generated_panel_device_name(hass, config_entry.entry_id, _DEVICE_SERIAL) is None diff --git a/tests/test_recreate_entity_ids.py b/tests/test_recreate_entity_ids.py index 94a9dbc2..a2fb0957 100644 --- a/tests/test_recreate_entity_ids.py +++ b/tests/test_recreate_entity_ids.py @@ -918,6 +918,131 @@ async def test_an_unmapped_tab_sensor_keeps_its_prefix_on_a_no_prefix_install( assert registry.async_regenerate_entity_id(registry_entry) == seeded.entity_id +# --- A panel device name this integration generated --------------------------- +# +# The config flow names the device: the second panel on a system becomes "Span +# Panel 2" without anyone typing it (`get_unique_device_name`). Composition +# spells the DEVICE part from that name, while every circuit id already on that +# panel says `span_panel_` -- the literal the preset builder fell back to on +# every install. Offering `sensor.span_panel_2_...` would be offering a move +# this integration caused, which R1 forbids. A name the *user* gave the device +# is theirs, and an id that follows it is one they asked for. + +SECOND_PANEL_NAME = "Span Panel 2" + + +@pytest.fixture +def second_panel_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return the config entry of a second panel, whose device name was generated.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.51", "device_name": SECOND_PANEL_NAME}, + options=dict(FRIENDLY_NAMES), + title=SECOND_PANEL_NAME, + unique_id=SERIAL, + entry_id="entry-recreate-second", + ) + config_entry.add_to_hass(hass) + return config_entry + + +async def test_an_existing_sensor_on_a_generated_second_panel_is_offered_its_own_id( + hass: HomeAssistant, second_panel_entry: MockConfigEntry, device_and_entity_parts: None +) -> None: + """The whole panel's worth of ids the generated name would otherwise move.""" + seeded = _seed_power_entity(hass, second_panel_entry, "span_panel_refrigerator_power") + + install = _SensorInstall(hass, second_panel_entry) + await install.load(ORIGINAL_NAME) + sensor = await install.load(ORIGINAL_NAME) + + assert sensor.entity_id == seeded + + registry = er.async_get(hass) + registry_entry = registry.async_get(seeded) + assert registry_entry is not None + assert registry.async_regenerate_entity_id(registry_entry) == seeded + + +async def test_the_controls_on_a_generated_second_panel_are_offered_their_own_ids( + hass: HomeAssistant, second_panel_entry: MockConfigEntry, device_and_entity_parts: None +) -> None: + """The breaker switch and the priority select carry the same ids and the same rule.""" + registry = er.async_get(hass) + seeded_switch = registry.async_get_or_create( + "switch", + DOMAIN, + build_switch_unique_id(SERIAL, CIRCUIT_ID), + suggested_object_id="span_panel_refrigerator_breaker", + config_entry=second_panel_entry, + ) + seeded_select = registry.async_get_or_create( + "select", + DOMAIN, + build_select_unique_id(SERIAL, CIRCUIT_ID), + suggested_object_id="span_panel_refrigerator_circuit_priority", + config_entry=second_panel_entry, + ) + + switches = _SwitchInstall(hass, second_panel_entry) + await switches.load(ORIGINAL_NAME) + switch = await switches.load(ORIGINAL_NAME) + selects = _SelectInstall(hass, second_panel_entry) + await selects.load(ORIGINAL_NAME) + select = await selects.load(ORIGINAL_NAME) + + assert switch.entity_id == seeded_switch.entity_id + assert select.entity_id == seeded_select.entity_id + + switch_entry = registry.async_get(seeded_switch.entity_id) + assert switch_entry is not None + assert registry.async_regenerate_entity_id(switch_entry) == seeded_switch.entity_id + + select_entry = registry.async_get(seeded_select.entity_id) + assert select_entry is not None + assert registry.async_regenerate_entity_id(select_entry) == seeded_select.entity_id + + +async def test_a_new_sensor_on_a_generated_second_panel_composes_with_that_name( + hass: HomeAssistant, second_panel_entry: MockConfigEntry, device_and_entity_parts: None +) -> None: + """The exception is for ids that exist. A new entity is spelled with its own device.""" + sensor = await _SensorInstall(hass, second_panel_entry).load(ORIGINAL_NAME) + + assert sensor.entity_id == "sensor.span_panel_2_refrigerator_power" + + +async def test_a_panel_device_the_user_renamed_is_offered_the_renamed_id( + hass: HomeAssistant, second_panel_entry: MockConfigEntry, device_and_entity_parts: None +) -> None: + """R1 cuts both ways: a device the user named is a change the user made. + + The generated name is still "Span Panel 2" underneath, so this is the case + that separates the two fields. Home Assistant composes from `name_by_user` + ahead of `name`, and so does the offer. + """ + seeded = _seed_power_entity(hass, second_panel_entry, "span_panel_refrigerator_power") + + install = _SensorInstall(hass, second_panel_entry) + await install.load(ORIGINAL_NAME) + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device(identifiers={(DOMAIN, SERIAL)}) + assert device is not None + device_registry.async_update_device(device.id, name_by_user="Garage Panel") + + await install.load(ORIGINAL_NAME) + + registry = er.async_get(hass) + registry_entry = registry.async_get(seeded) + assert registry_entry is not None + assert registry_entry.entity_id == seeded # R5: nothing moved + assert ( + registry.async_regenerate_entity_id(registry_entry) + == "sensor.garage_panel_refrigerator_power" + ) + + # --- The two circuit controls ------------------------------------------------- # # The breaker switch and the priority select are the other two circuit entities From eaaab2d79e067ee10671dfb5bf12309ebef8e4f3 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:40:20 -0700 Subject: [PATCH 160/345] security(config-flow): pin the CA on reauth before the passphrase is sent Reauth builds its REST transport from the entry's stored data, and an entry with no usable pin got a plaintext one. Registration is the exchange that carries the panel passphrase and returns the eBus broker password, so on those entries the credentials crossed the network in the clear -- the exact thing pinning before registration exists to remove, and the opposite of what the release notes tell upgrading users. Three populations reach reauth unpinned: entries still recorded as api_version "v1", which fail setup before the deferred fetch can run and which the v7 migration does not flag; v2 entries missing their broker credentials, which fail setup the same way; and entries whose deferred fetch failed. A fourth, an entry whose stored PEM no longer loads, falls back to plaintext in panel_rest_transport and is now re-pinned as well. Reauth of such an entry now routes through the CA step -- fetch, leaf check, adopt -- and only then shows the reauth menu, so both authentication methods register over the anchor. A leaf that does not chain shows ca_leaf_mismatch with no way past, as on install. _update_v2_entry keeps the acquired CA and clears the pending flag, so the entry stays pinned instead of falling back to a plaintext CA fetch on every connect. An entry that already carried a usable pin is untouched and skips the step. The entry's stored HTTPS port is adopted on reauth so a panel behind a proxy is not asked again for a port it already recorded, and is persisted alongside the CA when the user does have to name it. --- CHANGELOG.md | 2 + custom_components/span_panel/config_flow.py | 51 ++- .../span_panel/config_flow_validation.py | 14 +- tests/test_v2_config_flow.py | 295 +++++++++++++++++- 4 files changed, 337 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5440b83e..0343ed34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -155,6 +155,8 @@ working entirely in 2027.8. using the old one. - **The panel's certificate authority is pinned before your passphrase is ever sent**, so the registration exchange that carries it — and everything after — no longer crosses your network in the clear. +- **Re-authenticating an entry that was never pinned pins it first**, so an entry that arrived from an older release sends its new credentials over a verified + connection and keeps the authority afterwards. - **A changed authority stops the integration and raises a repair** carrying both fingerprints, because a legitimate rotation and an impersonated panel look identical from here. - **Four new options decide who may operate the panel** — administrators only, no commands without a logged-in user, an admin-only control lock, and a relay diff --git a/custom_components/span_panel/config_flow.py b/custom_components/span_panel/config_flow.py index 7fdacb3c..166fe0ce 100644 --- a/custom_components/span_panel/config_flow.py +++ b/custom_components/span_panel/config_flow.py @@ -44,6 +44,7 @@ ) from .config_flow_validation import ( PanelRestTransport, + as_port, async_fetch_panel_ca, async_leaf_chains_to_ca, check_fqdn_tls_ready, @@ -69,6 +70,7 @@ DOMAIN, ENABLE_ENERGY_DIP_COMPENSATION, ENTITY_NAMING_PATTERN, + PANEL_CA_PENDING, USE_CIRCUIT_NUMBERS, USE_DEVICE_PREFIX, EntityNamingPattern, @@ -174,12 +176,13 @@ def __init__(self) -> None: self._v2_panel_serial: str | None = None self._http_port: int = 80 self._https_port: int = DEFAULT_HTTPS_PORT - # Whether the TLS port came from the panel's own discovery record - # rather than from a default. Discovery is the authority when it - # speaks: a panel that publishes the port knows where it serves, and - # asking the user for it after being told is asking them to confirm a - # fact they have no way to check. - self._https_port_discovered: bool = False + # Whether something authoritative already told this flow where the panel + # serves TLS, rather than the value above being a default. Discovery is + # one such source: a panel that publishes the port knows where it + # serves, and asking the user for it after being told is asking them to + # confirm a fact they have no way to check. An existing entry is the + # other: on reauth the port it was pinned against is the answer. + self._https_port_known: bool = False # The panel CA, once fetched and leaf-confirmed. None means the flow # could not acquire one and the entry is created with the pending flag. self._panel_ca_pem: str | None = None @@ -245,7 +248,7 @@ async def async_step_zeroconf(self, discovery_info: ZeroconfServiceInfo) -> Conf https_port = _discovered_port(props, "httpsPort", "httpsport") if https_port is not None: self._https_port = https_port - self._https_port_discovered = True + self._https_port_known = True detection = await detect_api_version( discovery_info.host, @@ -296,7 +299,7 @@ async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> ConfigFl https_port = _discovered_port(config, "https_port", "httpsPort") if https_port is not None: self._https_port = https_port - self._https_port_discovered = True + self._https_port_known = True # Validate panel is reachable and v2 self._http_port = port @@ -317,7 +320,7 @@ async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> ConfigFl # cannot reach its panel. await self.async_set_unique_id(panel_serial) updates: dict[str, Any] = {CONF_HOST: host, CONF_HTTP_PORT: port} - if self._https_port_discovered: + if self._https_port_known: updates[CONF_HTTPS_PORT] = self._https_port self._abort_if_unique_id_configured(updates=updates) @@ -415,6 +418,10 @@ async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> ConfigFlowRe """Handle a flow initiated by re-auth.""" host = entry_data[CONF_HOST] self._http_port = int(entry_data.get(CONF_HTTP_PORT, 80)) + stored_https_port = entry_data.get(CONF_HTTPS_PORT) + if stored_https_port is not None: + self._https_port = as_port(stored_https_port, DEFAULT_HTTPS_PORT) + self._https_port_known = True # This entry may already have a pinned CA, and a reauth is precisely # where fresh credentials cross the wire. Adopted before the first call. self._rest_transport = panel_rest_transport(self.hass, entry_data) @@ -439,6 +446,15 @@ async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> ConfigFlowRe self.trigger_flow_type = TriggerFlowType.UPDATE_ENTRY self._is_flow_setup = True self.context["title_placeholders"] = {"host": host} + if self._rest_transport.ssl_context is None: + # Nothing pinned, and the next exchange carries the passphrase + # and returns the broker password. Entries arrive here unpinned + # by three routes — an entry still recorded as v1, a v2 entry + # missing its broker credentials, and one whose deferred fetch + # failed — and all three fail setup before the deferred fetch at + # setup can settle them, so reauth is where they get an anchor + # or do not proceed. The CA step returns to `reauth_confirm`. + return await self.async_step_panel_ca_start() return await self.async_step_reauth_confirm() # Non-v2 panels are not supported @@ -691,7 +707,7 @@ async def async_step_panel_ca_start( add-on allocates that port per panel and reallocates it across restarts, so the only correct answer is the one it just gave. """ - if self._http_port == 80 or self._https_port_discovered: + if self._http_port == 80 or self._https_port_known: return await self.async_step_panel_ca() return await self.async_step_panel_https_port() @@ -814,6 +830,10 @@ async def _async_adopt_anchor_and_authenticate(self) -> ConfigFlowResult: self._rest_transport = PanelRestTransport( port=self._https_port, ssl_context=context, httpx_client=None ) + if self.trigger_flow_type == TriggerFlowType.UPDATE_ENTRY: + # Same two methods, but the reauth wording: this user is not setting + # a panel up, they are being asked why an entry stopped working. + return await self.async_step_reauth_confirm() return await self.async_step_choose_v2_auth() def create_new_entry( @@ -894,6 +914,17 @@ def _update_v2_entry(self, entry_id: str) -> ConfigFlowResult: updated_data.pop(CONF_HOP_PASSPHRASE, None) if self._http_port != 80: updated_data[CONF_HTTP_PORT] = self._http_port + if self._panel_ca_pem is not None: + # The entry reached this reauth with nothing usable pinned, so the + # flow acquired an anchor before sending the passphrase. Keeping it + # is what stops the next reauth — and every connect in between — + # from going back to a plaintext CA fetch. `_panel_ca_pem` is None + # only when the entry already carried a usable pin, which is left + # exactly as it was. + updated_data[CONF_PANEL_CA_PEM] = self._panel_ca_pem + updated_data.pop(PANEL_CA_PENDING, None) + if self._https_port != DEFAULT_HTTPS_PORT: + updated_data[CONF_HTTPS_PORT] = self._https_port self.hass.config_entries.async_update_entry(entry, data=updated_data) self.hass.async_create_task(self.hass.config_entries.async_reload(entry_id)) diff --git a/custom_components/span_panel/config_flow_validation.py b/custom_components/span_panel/config_flow_validation.py index 25c851c8..589bc0d7 100644 --- a/custom_components/span_panel/config_flow_validation.py +++ b/custom_components/span_panel/config_flow_validation.py @@ -68,9 +68,11 @@ async def validate_v2_passphrase( ) -> V2AuthResponse: """Validate a v2 panel passphrase and return MQTT credentials. - `transport` carries a pinned CA where one exists, which is the case on every - reauth of an already-pinned entry. Initial registration has no pin yet — the - anchor is fetched later in the same flow — and passes `port` alone. + `transport` carries the pinned CA, and every path that reaches this call has + one: the CA step runs before authentication on initial setup, and a reauth + of an entry that arrived unpinned is routed back through that step before + the passphrase is collected. The `port`-only fallback is for a caller that + holds no transport, which the flow itself no longer is. Raises: SpanPanelAuthError: on invalid passphrase (401/403). @@ -240,7 +242,7 @@ class PanelRestTransport: httpx_client: httpx.AsyncClient | None -def _as_port(value: object, default: int) -> int: +def as_port(value: object, default: int) -> int: """Read a port out of untyped entry data without trusting its type. `entry.data` round-trips through JSON and is typed `Any` at the boundary; a @@ -278,11 +280,11 @@ def panel_rest_transport( "falling back to plaintext HTTP for REST calls" ) else: - https_port = _as_port(entry_data.get(CONF_HTTPS_PORT), DEFAULT_HTTPS_PORT) + https_port = as_port(entry_data.get(CONF_HTTPS_PORT), DEFAULT_HTTPS_PORT) return PanelRestTransport(port=https_port, ssl_context=context, httpx_client=None) return PanelRestTransport( - port=_as_port(entry_data.get(CONF_HTTP_PORT), 80), + port=as_port(entry_data.get(CONF_HTTP_PORT), 80), ssl_context=None, httpx_client=get_async_client(hass, verify_ssl=False), ) diff --git a/tests/test_v2_config_flow.py b/tests/test_v2_config_flow.py index e75c29d1..fe046532 100644 --- a/tests/test_v2_config_flow.py +++ b/tests/test_v2_config_flow.py @@ -5,15 +5,20 @@ import ipaddress from unittest.mock import AsyncMock, MagicMock, patch +from homeassistant import config_entries +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry from span_panel_api import DetectionResult, V2AuthResponse, V2StatusInfo from span_panel_api.exceptions import SpanPanelAuthError, SpanPanelConnectionError -from homeassistant import config_entries from custom_components.span_panel import ( CURRENT_CONFIG_VERSION, async_migrate_entry, - async_setup_entry, ) from custom_components.span_panel.config_flow import ( SpanPanelConfigFlow, @@ -34,13 +39,6 @@ DOMAIN, PANEL_CA_PENDING, ) -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers.service_info.hassio import HassioServiceInfo -from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo - -from pytest_homeassistant_custom_component.common import MockConfigEntry # Shared mock detection for a different panel (used in reconfigure/duplicate tests) MOCK_V2_DETECTION_OTHER = DetectionResult( @@ -873,6 +871,285 @@ async def test_reauth_v2_success_updates_entry(hass: HomeAssistant) -> None: assert entry.data[CONF_ACCESS_TOKEN] == "v2-token-abc" assert entry.data[CONF_EBUS_BROKER_USERNAME] == "span-user" assert entry.data[CONF_EBUS_BROKER_PASSWORD] == "mqtt-secret" + # This entry had no anchor. Reauth acquires one before the passphrase goes + # out and keeps it, so the entry comes back pinned. + assert entry.data[CONF_PANEL_CA_PEM] == FAKE_CA_PEM + + +# ---------- reauth: the CA an unpinned entry has never had ---------- + +# The three ways an entry reaches reauth with nothing pinned. (a) a v1 entry: +# setup raises ConfigEntryAuthFailed before the deferred fetch can run, and the +# v7 migration flags only v2 entries, so it carries neither a CA nor the flag. +# (b) a v2 entry missing its broker credentials: setup raises before the fetch +# too, so the flag it was migrated with is never settled. (c) a v2 entry whose +# deferred fetch failed: the flag survives for the next setup, and reauth may +# well come first. +UNPINNED_REAUTH_POPULATIONS = [ + pytest.param({CONF_API_VERSION: "v1"}, id="v1_entry"), + pytest.param( + {CONF_API_VERSION: "v2", PANEL_CA_PENDING: True}, + id="missing_credentials", + ), + pytest.param( + { + CONF_API_VERSION: "v2", + PANEL_CA_PENDING: True, + CONF_EBUS_BROKER_HOST: "old-host", + CONF_EBUS_BROKER_PORT: 8883, + CONF_EBUS_BROKER_USERNAME: "old-user", + CONF_EBUS_BROKER_PASSWORD: "old-pass", + }, + id="failed_deferred_fetch", + ), +] + + +def _unpinned_entry(hass: HomeAssistant, extra_data: dict[str, object]) -> MockConfigEntry: + """Add an entry that carries no pinned CA.""" + entry = MockConfigEntry( + version=CURRENT_CONFIG_VERSION, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "old-token", + **extra_data, + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + assert CONF_PANEL_CA_PEM not in entry.data + return entry + + +@pytest.mark.parametrize("extra_data", UNPINNED_REAUTH_POPULATIONS) +@pytest.mark.asyncio +async def test_reauth_of_an_unpinned_entry_pins_before_the_passphrase_is_sent( + hass: HomeAssistant, extra_data: dict[str, object] +) -> None: + """Reauth is where fresh credentials cross the wire; it must not cross in the clear.""" + entry = _unpinned_entry(hass, extra_data) + + order: list[str] = [] + captured: dict[str, object] = {} + + async def _fetch_ca(*_args: object, **_kwargs: object) -> str: + order.append("fetch_ca") + return FAKE_CA_PEM + + async def _register(*_args: object, **kwargs: object) -> V2AuthResponse: + order.append("register_v2") + captured.update(kwargs) + return MOCK_V2_AUTH + + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow.async_fetch_panel_ca", + new=_fetch_ca, + ), + # The boundary the flow actually reaches: `validate_v2_passphrase` is + # real here, so what it hands the library is what the panel would see. + patch( + "custom_components.span_panel.config_flow_validation.register_v2", + new=_register, + ), + patch.object(hass.config_entries, "async_reload", return_value=True), + ): + result = await entry.start_reauth_flow(hass) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "reauth_confirm" + + result1 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "auth_passphrase"} + ) + assert result1["step_id"] == "auth_passphrase" + + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE} + ) + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + + # Registration went over a verified connection, and the anchor it used was + # acquired first rather than after the fact. + assert captured["ssl_context"] is not None + assert captured["httpx_client"] is None + assert order == ["fetch_ca", "register_v2"] + + # And the anchor survives the reauth, so the next connect is pinned too. + assert entry.data[CONF_PANEL_CA_PEM] == FAKE_CA_PEM + assert PANEL_CA_PENDING not in entry.data + assert entry.data[CONF_API_VERSION] == "v2" + + +@pytest.mark.asyncio +async def test_reauth_refuses_to_send_the_passphrase_when_the_leaf_does_not_chain( + hass: HomeAssistant, +) -> None: + """A CA that cannot validate what the panel serves is no reason to fall back.""" + entry = _unpinned_entry(hass, {CONF_API_VERSION: "v2"}) + + register = AsyncMock(return_value=MOCK_V2_AUTH) + + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow.async_leaf_chains_to_ca", + new=AsyncMock(return_value=False), + ), + patch( + "custom_components.span_panel.config_flow_validation.register_v2", + new=register, + ), + ): + result = await entry.start_reauth_flow(hass) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "panel_ca" + assert result["errors"] == {"base": "ca_leaf_mismatch"} + register.assert_not_awaited() + assert CONF_PANEL_CA_PEM not in entry.data + + +@pytest.mark.asyncio +async def test_reauth_by_proximity_also_registers_over_the_pin( + hass: HomeAssistant, +) -> None: + """The door bypass sends no passphrase but is handed the same broker password back.""" + entry = _unpinned_entry(hass, {CONF_API_VERSION: "v2"}) + + captured: dict[str, object] = {} + + async def _register(*_args: object, **kwargs: object) -> V2AuthResponse: + captured.update(kwargs) + return MOCK_V2_AUTH + + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow_validation.register_v2", + new=_register, + ), + patch.object(hass.config_entries, "async_reload", return_value=True), + ): + result = await entry.start_reauth_flow(hass) + assert result["step_id"] == "reauth_confirm" + + proximity = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "auth_proximity"} + ) + done = await hass.config_entries.flow.async_configure( + proximity["flow_id"], {"next_step_id": "auth_proximity_confirm"} + ) + + assert done["reason"] == "reauth_successful" + assert captured["ssl_context"] is not None + assert captured["httpx_client"] is None + assert entry.data[CONF_PANEL_CA_PEM] == FAKE_CA_PEM + + +@pytest.mark.asyncio +async def test_reauth_of_a_pinned_entry_does_not_go_back_for_a_ca( + hass: HomeAssistant, +) -> None: + """An entry with a usable anchor already has the protection; leave it alone.""" + entry = MockConfigEntry( + version=CURRENT_CONFIG_VERSION, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "old-token", + CONF_API_VERSION: "v2", + CONF_PANEL_CA_PEM: FAKE_CA_PEM, + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + fetch = AsyncMock(return_value=FAKE_CA_PEM) + + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch("custom_components.span_panel.config_flow.async_fetch_panel_ca", new=fetch), + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + ): + result = await entry.start_reauth_flow(hass) + + assert result["step_id"] == "reauth_confirm" + fetch.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_reauth_asks_where_a_moved_panel_serves_tls_and_keeps_the_answer( + hass: HomeAssistant, +) -> None: + """An entry behind a proxy has to say where the leaf is before it can be checked.""" + entry = _unpinned_entry(hass, {CONF_API_VERSION: "v2", CONF_HTTP_PORT: 8080}) + + leaf_check = AsyncMock(return_value=True) + + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow.async_leaf_chains_to_ca", + new=leaf_check, + ), + patch( + "custom_components.span_panel.config_flow.validate_v2_passphrase", + return_value=MOCK_V2_AUTH, + ), + patch.object(hass.config_entries, "async_reload", return_value=True), + ): + result = await entry.start_reauth_flow(hass) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "panel_https_port" + + menu = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HTTPS_PORT: 9443} + ) + assert menu["step_id"] == "reauth_confirm" + + form = await hass.config_entries.flow.async_configure( + menu["flow_id"], {"next_step_id": "auth_passphrase"} + ) + done = await hass.config_entries.flow.async_configure( + form["flow_id"], {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE} + ) + + assert done["reason"] == "reauth_successful" + # Checked on the port the user named, and stored so the pin keeps pointing there. + assert leaf_check.await_args is not None + assert leaf_check.await_args.args[1] == 9443 + assert entry.data[CONF_HTTPS_PORT] == 9443 + assert entry.data[CONF_PANEL_CA_PEM] == FAKE_CA_PEM # ---------- user flow error paths ---------- From e8143a6a47e838d345b691999def8f99d372c993 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:07:45 -0700 Subject: [PATCH 161/345] fix(config-flow): bootstrap an FQDN install over the address the leaf actually names A panel's certificate names the addresses it already knows itself by -- its IP and its .local name. An FQDN joins that list only when register_fqdn tells the panel to regenerate the leaf, and that runs after authentication. Every check before it was made against the FQDN with hostname verification on, so it failed on the one thing that could not yet be true. Two flows were unusable as a result. A fresh install named by FQDN died at the CA step with ca_leaf_mismatch, which is deliberately a dead end with no way past, so the install could not complete at all. Reconfiguring a pinned entry to an FQDN died one step earlier: detect_api_version dialled the new name under the stored anchor and reported cannot_connect. Neither was caught because every FQDN test in the suite ran under an autouse fixture that stubs async_leaf_chains_to_ca and build_panel_ssl_context, so hostname verification was never exercised. The pre-registration bootstrap now dials the panel's resolved address: the CA fetch, the leaf check, registration, and the FQDN request itself. Hostname verification is not relaxed anywhere -- the address checked against changed, the check did not, and a leaf the published CA does not sign is refused exactly as before. A name that will not resolve falls back to dialling the name, which is what shipped. Only once the panel reports the regenerated certificate is the FQDN verified, on the broker port and again on the port the entry will store, before anything is persisted. check_fqdn_tls_ready took the anchor it verifies against by refetching the CA over plain HTTP every two seconds, which is a fresh trust decision on each poll against whatever answered -- and anything that can answer it can also serve a leaf signed by the CA it just handed over. It now takes the pinned PEM, carried on PanelRestTransport beside the context built from it. The plaintext fetch survives only for a reconfigure of an entry that never acquired an anchor. Folded in, from the review of the reauth pinning that preceded this: validate_v2_passphrase and validate_v2_proximity now require a transport instead of defaulting to a plaintext one, so the calls that carry the passphrase and return the broker password cannot be made unpinned by omission; the proximity pre-check, which decides whether the door challenge is believed, runs over the pin rather than an unverified client; a reauth that replaces a stored PEM this system can no longer load logs the new fingerprint at WARNING; and a stored HTTPS port counts as known only when it could actually be read, rather than when a default was substituted for it. --- CHANGELOG.md | 2 + custom_components/span_panel/config_flow.py | 192 +++++-- .../span_panel/config_flow_validation.py | 183 +++++-- tests/test_config_flow_validation.py | 190 +++++-- tests/test_v2_config_flow.py | 150 +++++- tests/test_v2_config_flow_tls.py | 509 ++++++++++++++++++ 6 files changed, 1099 insertions(+), 127 deletions(-) create mode 100644 tests/test_v2_config_flow_tls.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0343ed34..6419969d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -157,6 +157,8 @@ working entirely in 2027.8. longer crosses your network in the clear. - **Re-authenticating an entry that was never pinned pins it first**, so an entry that arrived from an older release sends its new credentials over a verified connection and keeps the authority afterwards. +- **Setting up or moving a panel to a hostname works with pinning on**, because the panel is set up over the address its certificate already names and the + hostname is verified after the panel has added it. - **A changed authority stops the integration and raises a repair** carrying both fingerprints, because a legitimate rotation and an impersonated panel look identical from here. - **Four new options decide who may operate the panel** — administrators only, no commands without a logged-in user, an admin-only control lock, and a relay diff --git a/custom_components/span_panel/config_flow.py b/custom_components/span_panel/config_flow.py index 166fe0ce..9bf6c80a 100644 --- a/custom_components/span_panel/config_flow.py +++ b/custom_components/span_panel/config_flow.py @@ -44,12 +44,14 @@ ) from .config_flow_validation import ( PanelRestTransport, - as_port, async_fetch_panel_ca, async_leaf_chains_to_ca, + async_resolve_host, check_fqdn_tls_ready, is_fqdn, + is_ip_literal, panel_rest_transport, + port_or_none, validate_host, validate_v2_passphrase, validate_v2_proximity, @@ -190,12 +192,56 @@ def __init__(self) -> None: # entry on reauth and reconfigure, where a pin may already exist; left # None on initial setup, which has nothing pinned yet by construction. self._rest_transport: PanelRestTransport | None = None + # The address this flow dials, which is not always the host it is + # setting up. A panel's certificate names the addresses it already knows + # itself by; an FQDN joins that list only once `register_fqdn` has run, + # and everything before that has to be dialled by an address the leaf + # already names. None means "dial `self.host`" -- an IP install, or a + # name that would not resolve. + self._bootstrap_host: str | None = None # Energy dip compensation default for fresh installs self._enable_dip_compensation: bool = True # FQDN registration task (async_show_progress) self._fqdn_task: asyncio.Task[None] | None = None self._reconfigure_fqdn_task: asyncio.Task[None] | None = None + @property + def _rest_host(self) -> str: + """The address this flow's REST and TLS calls dial.""" + return self._bootstrap_host or self.host or "" + + def _require_transport(self) -> PanelRestTransport: + """Return the transport this flow's credential exchanges run over. + + Raised rather than defaulted. Every path that asks for one has been + through the CA step or adopted an existing entry's pin, so a missing + transport is a routing bug -- and the only value that could stand in for + it is a plaintext one, which would send the panel passphrase in the + clear to paper over the bug. + """ + if self._rest_transport is None: + raise ConfigFlowError("Reached a panel REST call with no transport pinned") + return self._rest_transport + + async def _async_adopt_bootstrap_host(self) -> None: + """Dial the panel by address while its certificate still names only that. + + A name that does not resolve leaves the host as it was, which is what + shipped: the leaf check then runs against the name and reports what it + finds. Better than refusing to continue over a lookup the user may not + need -- an mDNS name on a host without an mDNS resolver still reaches + the panel through whatever answered the plaintext probe. + """ + self._bootstrap_host = None + host = self.host + if not host or is_ip_literal(host): + return + resolved = await async_resolve_host(self.hass, host) + if resolved is None: + _LOGGER.debug("Could not resolve %s to an address; dialling the name", host) + return + self._bootstrap_host = resolved + def ensure_flow_is_set_up(self) -> None: """Ensure the flow is set up.""" if self._is_flow_setup is False: @@ -418,9 +464,13 @@ async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> ConfigFlowRe """Handle a flow initiated by re-auth.""" host = entry_data[CONF_HOST] self._http_port = int(entry_data.get(CONF_HTTP_PORT, 80)) - stored_https_port = entry_data.get(CONF_HTTPS_PORT) + # Only a port that could actually be read counts as known. `as_port` + # would substitute the default for an unreadable stored value and this + # flow would then record "somebody authoritative told us" about a number + # nobody supplied, skipping the one question that could correct it. + stored_https_port = port_or_none(entry_data.get(CONF_HTTPS_PORT)) if stored_https_port is not None: - self._https_port = as_port(stored_https_port, DEFAULT_HTTPS_PORT) + self._https_port = stored_https_port self._https_port_known = True # This entry may already have a pinned CA, and a reauth is precisely # where fresh credentials cross the wire. Adopted before the first call. @@ -516,13 +566,18 @@ async def async_step_auth_proximity_confirm( if not self.host: return self.async_abort(reason="host_not_set") + transport = self._require_transport() # Check proximityProven before calling register_v2 (avoids 15-min block). # On older firmware the field is None — fall through to register_v2 directly. + # Over the pin, like the registration it gates: this probe is what + # decides whether the door challenge is believed, and a plaintext answer + # is one anything on the path can write. try: detection = await detect_api_version( - self.host, - port=self._http_port, - httpx_client=get_async_client(self.hass, verify_ssl=False), + self._rest_host, + port=transport.port, + httpx_client=transport.httpx_client, + ssl_context=transport.ssl_context, ) except (SpanPanelAPIError, SpanPanelConnectionError, SpanPanelTimeoutError) as err: _LOGGER.warning("Failed to detect API version during proximity auth: %s", err) @@ -535,9 +590,7 @@ async def async_step_auth_proximity_confirm( return await self.async_step_auth_proximity() try: - result = await validate_v2_proximity( - self.hass, self.host, port=self._http_port, transport=self._rest_transport - ) + result = await validate_v2_proximity(self._rest_host, transport) except (SpanPanelAuthError, SpanPanelConnectionError): return await self.async_step_auth_proximity() @@ -568,11 +621,7 @@ async def async_step_auth_passphrase( try: result = await validate_v2_passphrase( - self.hass, - self.host, - passphrase, - port=self._http_port, - transport=self._rest_transport, + self._rest_host, passphrase, self._require_transport() ) except SpanPanelAuthError: return self.async_show_form( @@ -643,20 +692,21 @@ async def async_step_register_fqdn( return self.async_show_progress_done(next_step_id="choose_entity_naming_initial") async def _async_register_fqdn_and_wait(self) -> None: - """Register the FQDN and poll until the TLS cert includes it.""" + """Register the FQDN, wait for the panel's new leaf, then verify it. + + Three addresses matter here and they are not the same one. The request + carries the access token, so it goes over the pin -- and it is dialled by + the address the current leaf names, because the FQDN is precisely what + this call is asking the panel to add. Only once the panel reports the + regenerated certificate is the FQDN itself verified, and it is verified + on the port the entry will actually use, before anything is persisted. + """ if not self.host or not self.access_token: raise ConfigFlowError("Host and access token required for FQDN registration") - # Carries the access token, so it goes over the pin where this flow has - # one — which is the reconfigure path. Initial setup has not fetched a CA - # yet at this point and falls back to the plaintext bootstrap port. - transport = self._rest_transport or PanelRestTransport( - port=self._http_port, - ssl_context=None, - httpx_client=get_async_client(self.hass, verify_ssl=False), - ) + transport = self._require_transport() await register_fqdn( - self.host, + self._rest_host, self.access_token, self.host, port=transport.port, @@ -669,17 +719,44 @@ async def _async_register_fqdn_and_wait(self) -> None: for attempt in range(max_attempts): await asyncio.sleep(2) if await check_fqdn_tls_ready( - self.hass, self.host, mqtts_port, http_port=self._http_port + self.hass, + self.host, + mqtts_port, + transport.ca_pem, + http_port=self._http_port, ): _LOGGER.debug( "FQDN %s found in TLS cert SAN after %d attempts", self.host, attempt + 1, ) + await self._async_verify_fqdn_over_pin(transport) return raise ConfigFlowError(f"Timed out waiting for TLS certificate to include FQDN {self.host}") + async def _async_verify_fqdn_over_pin(self, transport: PanelRestTransport) -> None: + """Confirm the pinned anchor validates the panel at the FQDN and REST port. + + The readiness poll watches the broker port, which is the port the MQTT + client uses. The entry's REST calls go somewhere else -- the HTTPS port + this transport holds -- and an entry is about to be written naming the + FQDN for both. Confirming the exact pair that will be stored is what + stops registration reporting success on a combination that then fails on + the first connect after setup. + + Nothing is pinned on a reconfigure of an entry whose CA fetch never + succeeded; there is no anchor to verify against and no pin to protect, + so that entry proceeds exactly as it did before. + """ + if transport.ca_pem is None: + return + if not await async_leaf_chains_to_ca(self.host or "", transport.port, transport.ca_pem): + raise ConfigFlowError( + f"Panel registered FQDN {self.host} but does not serve it on port " + f"{transport.port} under the pinned certificate authority" + ) + async def async_step_fqdn_failed( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -745,24 +822,37 @@ async def async_step_panel_ca( if not self.host: return self.async_abort(reason="host_not_set") + # Both calls below go to the panel's address rather than to the name the + # user typed. The panel's leaf names the addresses it already knows + # itself by, and an FQDN is not one of them until `register_fqdn` runs -- + # which is after authentication, which is after this. Checking a + # not-yet-registered FQDN against that leaf fails on the hostname, and + # this step is deliberately a dead end, so it would make an FQDN install + # impossible rather than merely unverified. + await self._async_adopt_bootstrap_host() + try: - ca_pem = await async_fetch_panel_ca(self.hass, self.host, http_port=self._http_port) + ca_pem = await async_fetch_panel_ca( + self.hass, self._rest_host, http_port=self._http_port + ) except ( SpanPanelAPIError, SpanPanelConnectionError, SpanPanelTimeoutError, ) as err: - _LOGGER.warning("Could not fetch the CA from panel %s: %s", self.host, err) + _LOGGER.warning("Could not fetch the CA from panel %s: %s", self._rest_host, err) return self._async_show_ca_error("ca_unavailable") # A CA that cannot validate the certificate the panel actually serves is # not the panel's CA. Checked before the fingerprint is shown, so the - # user is never asked to accept a value that already failed. - if not await async_leaf_chains_to_ca(self.host, self._https_port, ca_pem): + # user is never asked to accept a value that already failed. Hostname + # verification stays on -- the address it is checked against changed, + # the check did not. + if not await async_leaf_chains_to_ca(self._rest_host, self._https_port, ca_pem): _LOGGER.warning( "The certificate served by %s on port %s does not chain to the CA the " "panel published; refusing to pin it", - self.host, + self._rest_host, self._https_port, ) return self._async_show_ca_error("ca_leaf_mismatch") @@ -828,7 +918,10 @@ async def _async_adopt_anchor_and_authenticate(self) -> ConfigFlowResult: return self._async_show_ca_error("ca_unavailable") self._rest_transport = PanelRestTransport( - port=self._https_port, ssl_context=context, httpx_client=None + port=self._https_port, + ssl_context=context, + httpx_client=None, + ca_pem=self._panel_ca_pem, ) if self.trigger_flow_type == TriggerFlowType.UPDATE_ENTRY: # Same two methods, but the reauth wording: this user is not setting @@ -921,6 +1014,7 @@ def _update_v2_entry(self, entry_id: str) -> ConfigFlowResult: # from going back to a plaintext CA fetch. `_panel_ca_pem` is None # only when the entry already carried a usable pin, which is left # exactly as it was. + self._log_replaced_anchor(entry.data.get(CONF_PANEL_CA_PEM)) updated_data[CONF_PANEL_CA_PEM] = self._panel_ca_pem updated_data.pop(PANEL_CA_PENDING, None) if self._https_port != DEFAULT_HTTPS_PORT: @@ -930,6 +1024,31 @@ def _update_v2_entry(self, entry_id: str) -> ConfigFlowResult: self.hass.async_create_task(self.hass.config_entries.async_reload(entry_id)) return self.async_abort(reason="reauth_successful") + def _log_replaced_anchor(self, previous_pem: object) -> None: + """Say so, at WARNING, when a reauth overwrites a stored trust anchor. + + The entry only reaches here unpinned, and one of the ways it does is a + stored PEM this system can no longer load. Replacing that is silently + changing what the entry trusts, which is the one change a user needs a + record of: the fingerprint below is the value to compare against the one + logged at install, and the only trace of it if the old PEM is gone. + + A first pin is not a replacement and says nothing here -- the install + log already named it. + """ + if not previous_pem or previous_pem == self._panel_ca_pem or self._panel_ca_pem is None: + return + try: + fingerprint = ca_fingerprint(self._panel_ca_pem) + except SpanPanelValidationError: # pragma: no cover - already fingerprinted once + fingerprint = "unreadable" + _LOGGER.warning( + "Replaced the stored certificate authority for panel %s during reauthentication; " + "the entry is now pinned to SHA-256 %s", + self.host, + fingerprint, + ) + def get_unique_device_name(self, base_name: str) -> str: """Return a unique device name based on existing config entry titles.""" existing_names = {entry.title for entry in self.hass.config_entries.async_entries(DOMAIN)} @@ -1002,9 +1121,15 @@ async def async_step_reconfigure( # Validate the host is reachable and is a v2 panel http_port = int(reconfigure_entry.data.get(CONF_HTTP_PORT, 80)) self._rest_transport = panel_rest_transport(self.hass, reconfigure_entry.data) + # The new host may be an FQDN the panel has never heard of, and this + # entry is pinned: probing it by name would fail hostname verification + # and report the panel unreachable. Probe the address, register the + # name, verify the name afterwards. + self.host = host + await self._async_adopt_bootstrap_host() try: detection = await detect_api_version( - host, + self._rest_host, port=self._rest_transport.port, httpx_client=self._rest_transport.httpx_client, ssl_context=self._rest_transport.ssl_context, @@ -1041,7 +1166,6 @@ async def async_step_reconfigure( if is_fqdn(host): # New host is FQDN — register it (replaces any existing FQDN on the panel) - self.host = host self.access_token = str(reconfigure_entry.data.get(CONF_ACCESS_TOKEN, "")) self._http_port = http_port self._v2_broker_port = int(reconfigure_entry.data.get(CONF_EBUS_BROKER_PORT, 8883)) @@ -1055,7 +1179,7 @@ async def async_step_reconfigure( access_token = str(reconfigure_entry.data.get(CONF_ACCESS_TOKEN, "")) try: await delete_fqdn( - host, + self._rest_host, access_token, port=self._rest_transport.port, httpx_client=self._rest_transport.httpx_client, diff --git a/custom_components/span_panel/config_flow_validation.py b/custom_components/span_panel/config_flow_validation.py index 589bc0d7..35a1d278 100644 --- a/custom_components/span_panel/config_flow_validation.py +++ b/custom_components/span_panel/config_flow_validation.py @@ -12,7 +12,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.httpx_client import get_async_client -from homeassistant.util.network import is_ipv4_address import httpx from span_panel_api import ( V2AuthResponse, @@ -60,19 +59,19 @@ async def validate_host( async def validate_v2_passphrase( - hass: HomeAssistant, host: str, passphrase: str, - port: int = 80, - transport: PanelRestTransport | None = None, + transport: PanelRestTransport, ) -> V2AuthResponse: """Validate a v2 panel passphrase and return MQTT credentials. - `transport` carries the pinned CA, and every path that reaches this call has - one: the CA step runs before authentication on initial setup, and a reauth - of an entry that arrived unpinned is routed back through that step before - the passphrase is collected. The `port`-only fallback is for a caller that - holds no transport, which the flow itself no longer is. + `transport` is required rather than defaulted, because it is what carries + the pinned CA and this is the call that sends the panel passphrase. A + default would be a plaintext transport, and a caller that forgot to pass one + would get the exchange this pinning exists to prevent without anything + saying so. Every path that reaches here has a transport by construction: the + CA step runs before authentication on initial setup, and a reauth of an + entry that arrived unpinned is routed back through that step first. Raises: SpanPanelAuthError: on invalid passphrase (401/403). @@ -80,32 +79,32 @@ async def validate_v2_passphrase( SpanPanelTimeoutError: on request timeout. """ - resolved = transport or PanelRestTransport( - port=port, ssl_context=None, httpx_client=get_async_client(hass, verify_ssl=False) - ) return await register_v2( host, "Home Assistant", passphrase, - port=resolved.port, - httpx_client=resolved.httpx_client, - ssl_context=resolved.ssl_context, + port=transport.port, + httpx_client=transport.httpx_client, + ssl_context=transport.ssl_context, ) +def is_ip_literal(host: str) -> bool: + """Whether `host` is already an address rather than a name to be resolved.""" + try: + ipaddress.ip_address(host) + except ValueError: + return False + return True + + def is_fqdn(host: str) -> bool: """Determine if host is a Fully Qualified Domain Name (not IP, not mDNS). Returns True for domain names like 'span.home.lan' or 'panel.example.com'. Returns False for IP addresses, mDNS (.local) names, and single-label hostnames. """ - if is_ipv4_address(host): - return False - try: - ipaddress.ip_address(host) - except ValueError: - pass - else: + if is_ip_literal(host): return False if host.endswith((".local", ".local.")): return False @@ -113,29 +112,77 @@ def is_fqdn(host: str) -> bool: async def check_fqdn_tls_ready( - hass: HomeAssistant, fqdn: str, mqtts_port: int, http_port: int = 80 + hass: HomeAssistant, + fqdn: str, + mqtts_port: int, + ca_pem: str | None, + http_port: int = 80, ) -> bool: - """Check if the MQTTS server certificate includes the FQDN in its SAN. - - Downloads the CA certificate from the panel via HTTP, then attempts - a TLS connection to the MQTTS port using the FQDN as server_hostname. - If the TLS handshake succeeds with hostname verification, the panel - has regenerated its certificate to include the FQDN. + """Whether the panel now serves a certificate that names `fqdn`. + + A TLS connection is made to the MQTTS port with the FQDN as + `server_hostname`; a handshake that completes with hostname verification on + means the panel has regenerated its leaf to include the FQDN, which is what + this is polling for. + + `ca_pem` is the anchor the caller already pinned, and passing it is the + point: this used to refetch the CA over plain HTTP on every poll, which + means a fresh trust decision every two seconds against whatever answered, + made against a panel the caller had already anchored. Anything that can + answer that fetch can also serve a leaf signed by the CA it just handed + over, so the poll would have reported the FQDN ready on a certificate the + pinned anchor does not sign. + + `None` is for the one caller that genuinely holds no anchor -- a + reconfigure of an entry whose CA fetch never succeeded -- where the + plaintext fetch is the only channel there is and is no weaker than the + unpinned entry it belongs to. """ - client = get_async_client(hass, verify_ssl=False) - try: - ca_pem = await download_ca_cert(fqdn, port=http_port, httpx_client=client) - except ( - OSError, - SpanPanelAPIError, - SpanPanelConnectionError, - SpanPanelTimeoutError, - ): - return False + if ca_pem is None: + client = get_async_client(hass, verify_ssl=False) + try: + ca_pem = await download_ca_cert(fqdn, port=http_port, httpx_client=client) + except ( + OSError, + SpanPanelAPIError, + SpanPanelConnectionError, + SpanPanelTimeoutError, + ): + return False return await async_leaf_chains_to_ca(fqdn, mqtts_port, ca_pem) +async def async_resolve_host(hass: HomeAssistant, host: str) -> str | None: + """Resolve `host` to the IPv4 address the panel answers on, or None. + + The panel's certificate names the addresses it knows itself by -- its IP and + its mDNS name -- and an FQDN only joins that list once `register_fqdn` has + run. Everything before that has to be dialled by an address the leaf already + names, and this is where that address comes from. + + IPv4 only, and deliberately: the library builds bootstrap URLs by string + concatenation and does not bracket an IPv6 literal, so returning one would + produce a URL that cannot be parsed. `None` means "no answer this code can + use", and the caller falls back to the name it was given rather than + failing -- that is exactly the behaviour that shipped, so a host this + resolver cannot help with is no worse off than before. + """ + if is_ip_literal(host): + return host + + def _resolve() -> str | None: + try: + infos = socket.getaddrinfo(host, None, family=socket.AF_INET, type=socket.SOCK_STREAM) + except OSError: + return None + for *_unused, sockaddr in infos: + return str(sockaddr[0]) + return None + + return await hass.async_add_executor_job(_resolve) + + async def async_fetch_panel_ca(hass: HomeAssistant, host: str, http_port: int = 80) -> str: """Fetch the panel's CA over plain HTTP. @@ -192,10 +239,8 @@ def _check() -> bool: async def validate_v2_proximity( - hass: HomeAssistant, host: str, - port: int = 80, - transport: PanelRestTransport | None = None, + transport: PanelRestTransport, ) -> V2AuthResponse: """Validate v2 panel proximity (door bypass) and return MQTT credentials. @@ -203,21 +248,22 @@ async def validate_v2_proximity( registration. The panel accepts this when the user opens/closes the door 3 times within the proximity window. + `transport` is required for the same reason as in `validate_v2_passphrase`: + no passphrase crosses the wire here, but the broker password comes back, and + a defaulted plaintext transport would return it in the clear. + Raises: SpanPanelAuthError: if proximity was not proven (door not opened). SpanPanelConnectionError: on network/timeout failures. SpanPanelTimeoutError: on request timeout. """ - resolved = transport or PanelRestTransport( - port=port, ssl_context=None, httpx_client=get_async_client(hass, verify_ssl=False) - ) return await register_v2( host, "Home Assistant", - port=resolved.port, - httpx_client=resolved.httpx_client, - ssl_context=resolved.ssl_context, + port=transport.port, + httpx_client=transport.httpx_client, + ssl_context=transport.ssl_context, ) @@ -235,28 +281,47 @@ class PanelRestTransport: Mixing the two is refused by the library rather than guessed at: an explicit `port=80` alongside an `ssl_context` raises, because "HTTPS on port 80" and "a port stored before the CA was pinned" are both plausible readings. + + `ca_pem` is the anchor `ssl_context` was built from, carried alongside it + because a context cannot be read back into a PEM and two callers need the + text: the FQDN readiness poll, which verifies a different port against the + same anchor, and anything that wants to fingerprint what it is pinned to. + It is None exactly when `ssl_context` is. """ port: int ssl_context: ssl.SSLContext | None httpx_client: httpx.AsyncClient | None + ca_pem: str | None -def as_port(value: object, default: int) -> int: - """Read a port out of untyped entry data without trusting its type. +def port_or_none(value: object) -> int | None: + """Read a port out of untyped entry data, or None when it does not hold one. `entry.data` round-trips through JSON and is typed `Any` at the boundary; a - hand-edited `.storage` can put a string there. Anything unusable falls back - to the default rather than raising, because a bad port must not be the reason - an otherwise healthy entry cannot start. + hand-edited `.storage` can put a string there. `None` is the honest answer + for anything unreadable, and it is a different answer from a default: a + caller deciding whether the panel's TLS port is *known* must not be told yes + because a substitute was available. """ if isinstance(value, bool): - return default + return None if isinstance(value, int): return value if isinstance(value, str) and value.isdigit(): return int(value) - return default + return None + + +def as_port(value: object, default: int) -> int: + """Read a port out of untyped entry data, falling back to `default`. + + A bad port must not be the reason an otherwise healthy entry cannot start, + so this never raises. Use `port_or_none` where the difference between a + stored value and a substituted one matters. + """ + parsed = port_or_none(value) + return default if parsed is None else parsed def panel_rest_transport( @@ -281,10 +346,16 @@ def panel_rest_transport( ) else: https_port = as_port(entry_data.get(CONF_HTTPS_PORT), DEFAULT_HTTPS_PORT) - return PanelRestTransport(port=https_port, ssl_context=context, httpx_client=None) + return PanelRestTransport( + port=https_port, + ssl_context=context, + httpx_client=None, + ca_pem=str(pem), + ) return PanelRestTransport( port=as_port(entry_data.get(CONF_HTTP_PORT), 80), ssl_context=None, httpx_client=get_async_client(hass, verify_ssl=False), + ca_pem=None, ) diff --git a/tests/test_config_flow_validation.py b/tests/test_config_flow_validation.py index 443af10d..90ec20c0 100644 --- a/tests/test_config_flow_validation.py +++ b/tests/test_config_flow_validation.py @@ -2,6 +2,7 @@ from __future__ import annotations +import socket import ssl from unittest.mock import AsyncMock, MagicMock, patch @@ -10,9 +11,12 @@ from span_panel_api.exceptions import SpanPanelConnectionError from custom_components.span_panel.config_flow_validation import ( + PanelRestTransport, async_leaf_chains_to_ca, + async_resolve_host, check_fqdn_tls_ready, is_fqdn, + port_or_none, validate_host, validate_v2_passphrase, validate_v2_proximity, @@ -135,57 +139,52 @@ def test_validate_ipv4_and_fqdn_classification() -> None: @pytest.mark.asyncio -async def test_validate_v2_helpers_delegate_register_v2() -> None: - """Passphrase and proximity helpers should delegate to register_v2.""" - hass = MagicMock() - fake_client = MagicMock() - with ( - patch( - "custom_components.span_panel.config_flow_validation.get_async_client", - return_value=fake_client, - ), - patch( - "custom_components.span_panel.config_flow_validation.register_v2", - new=AsyncMock(return_value=MOCK_V2_AUTH), - ) as mock_register, - ): - assert ( - await validate_v2_passphrase( - hass, "panel.example.com", "passphrase", port=8080 - ) - == MOCK_V2_AUTH - ) +async def test_validate_v2_helpers_send_credentials_over_the_given_transport() -> None: + """Both helpers register over the transport they are handed, and only that. + + Neither takes a port or builds a client of its own any more. These are the + two calls that carry the panel passphrase and return the broker password, so + a caller that has nothing pinned cannot reach them at all rather than + silently getting a plaintext one. + """ + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + transport = PanelRestTransport( + port=8443, ssl_context=context, httpx_client=None, ca_pem="-----BEGIN..." + ) + with patch( + "custom_components.span_panel.config_flow_validation.register_v2", + new=AsyncMock(return_value=MOCK_V2_AUTH), + ) as mock_register: assert ( - await validate_v2_proximity(hass, "panel.example.com", port=9090) + await validate_v2_passphrase("panel.example.com", "passphrase", transport) == MOCK_V2_AUTH ) + assert await validate_v2_proximity("panel.example.com", transport) == MOCK_V2_AUTH assert mock_register.await_args_list[0].args == ( "panel.example.com", "Home Assistant", "passphrase", ) - # No transport given, so the plaintext bootstrap shape: shared client, no - # context. Initial registration is the case with nothing pinned yet. assert mock_register.await_args_list[0].kwargs == { - "port": 8080, - "httpx_client": fake_client, - "ssl_context": None, + "port": 8443, + "httpx_client": None, + "ssl_context": context, } assert mock_register.await_args_list[1].args == ( "panel.example.com", "Home Assistant", ) assert mock_register.await_args_list[1].kwargs == { - "port": 9090, - "httpx_client": fake_client, - "ssl_context": None, + "port": 8443, + "httpx_client": None, + "ssl_context": context, } @pytest.mark.asyncio async def test_check_fqdn_tls_ready_returns_false_when_ca_download_fails() -> None: - """TLS readiness should fail fast when the CA certificate cannot be downloaded.""" + """An unpinned caller that cannot fetch a CA has nothing to check against.""" hass = MagicMock() fake_client = MagicMock() with ( @@ -198,7 +197,7 @@ async def test_check_fqdn_tls_ready_returns_false_when_ca_download_fails() -> No side_effect=SpanPanelConnectionError("no cert"), ) as mock_download, ): - assert await check_fqdn_tls_ready(hass, "panel.example.com", 8883) is False + assert await check_fqdn_tls_ready(hass, "panel.example.com", 8883, None) is False mock_download.assert_awaited_once_with( "panel.example.com", port=80, httpx_client=fake_client @@ -207,7 +206,7 @@ async def test_check_fqdn_tls_ready_returns_false_when_ca_download_fails() -> No @pytest.mark.asyncio async def test_check_fqdn_tls_ready_forwards_custom_http_port() -> None: - """TLS readiness should forward the provided HTTP port to CA download.""" + """The unpinned fetch honours the panel's HTTP port.""" hass = MagicMock() fake_client = MagicMock() with ( @@ -221,7 +220,9 @@ async def test_check_fqdn_tls_ready_forwards_custom_http_port() -> None: ) as mock_download, ): assert ( - await check_fqdn_tls_ready(hass, "panel.example.com", 8883, http_port=8080) + await check_fqdn_tls_ready( + hass, "panel.example.com", 8883, None, http_port=8080 + ) is False ) @@ -230,6 +231,59 @@ async def test_check_fqdn_tls_ready_forwards_custom_http_port() -> None: ) +@pytest.mark.asyncio +async def test_check_fqdn_tls_ready_uses_the_pinned_anchor_without_refetching() -> None: + """A caller that already holds the anchor is checked against that anchor. + + Refetching would make a fresh trust decision on every poll, against whatever + answered plain HTTP — and anything that can answer it can also serve a leaf + signed by the CA it just handed over, so the poll would report the FQDN + ready on a certificate the pinned anchor does not sign. + """ + + class FakeLoop: + async def run_in_executor(self, _executor, func): + return func() + + class FakeSocket: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + class FakeSSLContext: + def wrap_socket(self, _sock, server_hostname: str): + assert server_hostname == "panel.example.com" + return FakeSocket() + + hass = MagicMock() + with ( + patch( + "custom_components.span_panel.config_flow_validation.download_ca_cert", + new=AsyncMock(), + ) as mock_download, + patch( + "custom_components.span_panel.config_flow_validation.asyncio.get_running_loop", + return_value=FakeLoop(), + ), + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=FakeSSLContext(), + ) as build_context, + patch( + "custom_components.span_panel.config_flow_validation.socket.create_connection", + return_value=FakeSocket(), + ), + ): + assert ( + await check_fqdn_tls_ready(hass, "panel.example.com", 8883, "pinned-pem") is True + ) + + mock_download.assert_not_awaited() + build_context.assert_called_once_with("pinned-pem") + + @pytest.mark.asyncio async def test_check_fqdn_tls_ready_returns_true_on_success() -> None: """TLS readiness should pass when the handshake succeeds.""" @@ -274,7 +328,7 @@ def wrap_socket(self, _sock, server_hostname: str): return_value=FakeSocket(), ), ): - assert await check_fqdn_tls_ready(hass, "panel.example.com", 8883) is True + assert await check_fqdn_tls_ready(hass, "panel.example.com", 8883, None) is True # The library's builder, not a hand-rolled context: it is the one that clears # VERIFY_X509_STRICT, which the panel's AKI-less CA fails without. @@ -324,7 +378,7 @@ def wrap_socket(self, _sock, server_hostname: str): return_value=FakeSocket(), ), ): - assert await check_fqdn_tls_ready(hass, "panel.example.com", 8883) is False + assert await check_fqdn_tls_ready(hass, "panel.example.com", 8883, None) is False @pytest.mark.asyncio @@ -352,3 +406,69 @@ async def run_in_executor(self, _executor, func): # Nothing is dialled when there is no anchor to dial with. connect.assert_not_called() + + +def test_port_or_none_reports_an_unreadable_value_as_unknown() -> None: + """A substituted default and a stored value are different answers. + + `as_port` exists for callers that just need a number to dial. This one is + for the caller deciding whether the panel's TLS port is *known*, which must + not be told yes because a fallback was available. + """ + assert port_or_none(8443) == 8443 + assert port_or_none("8443") == 8443 + assert port_or_none(None) is None + assert port_or_none("not-a-port") is None + assert port_or_none("") is None + # `True` is an int in Python and would otherwise read as port 1. + assert port_or_none(True) is None + + +@pytest.mark.asyncio +async def test_resolve_host_returns_an_ip_literal_unchanged() -> None: + """An address needs no resolving, and must not be handed to the resolver.""" + hass = MagicMock() + hass.async_add_executor_job = AsyncMock() + assert await async_resolve_host(hass, "192.168.1.50") == "192.168.1.50" + assert await async_resolve_host(hass, "fd00::1") == "fd00::1" + hass.async_add_executor_job.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_resolve_host_returns_the_first_ipv4_answer() -> None: + """Only IPv4, because the library builds URLs without bracketing v6.""" + + async def _run(func): + return func() + + hass = MagicMock() + hass.async_add_executor_job = _run + with patch( + "custom_components.span_panel.config_flow_validation.socket.getaddrinfo", + return_value=[(2, 1, 6, "", ("192.168.1.50", 443))], + ) as resolve: + assert await async_resolve_host(hass, "panel.example.com") == "192.168.1.50" + + assert resolve.call_args.kwargs["family"] == socket.AF_INET + + +@pytest.mark.asyncio +async def test_resolve_host_returns_none_when_the_name_does_not_resolve() -> None: + """The caller falls back to the name it was given, which is what shipped.""" + + async def _run(func): + return func() + + hass = MagicMock() + hass.async_add_executor_job = _run + with patch( + "custom_components.span_panel.config_flow_validation.socket.getaddrinfo", + side_effect=socket.gaierror("no such host"), + ): + assert await async_resolve_host(hass, "panel.example.com") is None + + with patch( + "custom_components.span_panel.config_flow_validation.socket.getaddrinfo", + return_value=[], + ): + assert await async_resolve_host(hass, "panel.example.com") is None diff --git a/tests/test_v2_config_flow.py b/tests/test_v2_config_flow.py index fe046532..41fb8cdd 100644 --- a/tests/test_v2_config_flow.py +++ b/tests/test_v2_config_flow.py @@ -3,6 +3,8 @@ from __future__ import annotations import ipaddress +import logging +import ssl from unittest.mock import AsyncMock, MagicMock, patch from homeassistant import config_entries @@ -82,6 +84,15 @@ def panel_ca_available(): "custom_components.span_panel.config_flow.build_panel_ssl_context", return_value=MagicMock(), ), + # Nothing resolves in a test run — the harness refuses DNS outright — + # so the flow takes its documented fallback and dials the name it was + # given, which is what every assertion in this module was written + # against. The resolved-address bootstrap is exercised for real against + # a live listener in `test_v2_config_flow_tls.py`. + patch( + "custom_components.span_panel.config_flow.async_resolve_host", + new=AsyncMock(return_value=None), + ), ): yield @@ -478,8 +489,9 @@ async def test_the_ca_is_pinned_before_the_passphrase_is_ever_sent( ) # And when it was, it went over the accepted anchor rather than plaintext. - transport = register.call_args.kwargs["transport"] - assert transport is not None + # The transport is the third positional argument and has no default, so + # there is no shape of this call that sends the passphrase unpinned. + transport = register.call_args.args[2] assert transport.ssl_context is not None assert transport.httpx_client is None assert transport.port == 443 @@ -991,6 +1003,68 @@ async def _register(*_args: object, **kwargs: object) -> V2AuthResponse: assert entry.data[CONF_API_VERSION] == "v2" +@pytest.mark.asyncio +async def test_replacing_an_unusable_stored_anchor_says_what_it_is_now_pinned_to( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """A stored PEM this system cannot load is replaced, and that is worth a line. + + Silently swapping what an entry trusts is the one change a user needs a + record of, and the new fingerprint is the value to compare against the one + logged at install — the only trace of it once the old PEM is gone. + """ + entry = MockConfigEntry( + version=CURRENT_CONFIG_VERSION, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "old-token", + CONF_API_VERSION: "v2", + CONF_PANEL_CA_PEM: "not-a-certificate", + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + def _build(pem: str) -> MagicMock: + if pem == "not-a-certificate": + raise ssl.SSLError("not a certificate") + return MagicMock() + + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + side_effect=_build, + ), + patch( + "custom_components.span_panel.config_flow.validate_v2_passphrase", + return_value=MOCK_V2_AUTH, + ), + patch.object(hass.config_entries, "async_reload", return_value=True), + caplog.at_level(logging.WARNING), + ): + menu = await entry.start_reauth_flow(hass) + form = await hass.config_entries.flow.async_configure( + menu["flow_id"], {"next_step_id": "auth_passphrase"} + ) + done = await hass.config_entries.flow.async_configure( + form["flow_id"], {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE} + ) + + assert done["reason"] == "reauth_successful" + assert entry.data[CONF_PANEL_CA_PEM] == FAKE_CA_PEM + assert "Replaced the stored certificate authority" in caplog.text + assert FAKE_CA_FINGERPRINT in caplog.text + + @pytest.mark.asyncio async def test_reauth_refuses_to_send_the_passphrase_when_the_leaf_does_not_chain( hass: HomeAssistant, @@ -1104,6 +1178,78 @@ async def test_reauth_of_a_pinned_entry_does_not_go_back_for_a_ca( fetch.assert_not_awaited() +@pytest.mark.asyncio +async def test_a_full_reauth_leaves_a_pinned_entry_pinned_to_the_same_thing( + hass: HomeAssistant, +) -> None: + """Reauth replaces credentials. It must not quietly move the trust anchor. + + The anchor and the port it was checked against are what a user compared a + fingerprint to and what a CA-changed repair compares against next time. A + reauth that rewrote either would make the repair fire on a panel that never + rotated anything, or hide one that did. + """ + entry = MockConfigEntry( + version=CURRENT_CONFIG_VERSION, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "old-token", + CONF_API_VERSION: "v2", + CONF_EBUS_BROKER_HOST: MOCK_HOST, + CONF_EBUS_BROKER_PORT: 8883, + CONF_EBUS_BROKER_USERNAME: "old-user", + CONF_EBUS_BROKER_PASSWORD: "old-secret", + CONF_PANEL_CA_PEM: FAKE_CA_PEM, + CONF_HTTPS_PORT: 9443, + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + fetch = AsyncMock(return_value="a-different-pem") + + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch("custom_components.span_panel.config_flow.async_fetch_panel_ca", new=fetch), + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + patch( + "custom_components.span_panel.config_flow.validate_v2_passphrase", + return_value=MOCK_V2_AUTH, + ) as register, + patch.object(hass.config_entries, "async_reload", return_value=True), + ): + menu = await entry.start_reauth_flow(hass) + form = await hass.config_entries.flow.async_configure( + menu["flow_id"], {"next_step_id": "auth_passphrase"} + ) + done = await hass.config_entries.flow.async_configure( + form["flow_id"], {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE} + ) + + assert done["reason"] == "reauth_successful" + # Credentials moved on; the anchor and its port did not. + assert entry.data[CONF_ACCESS_TOKEN] == MOCK_V2_AUTH.access_token + assert entry.data[CONF_EBUS_BROKER_PASSWORD] == MOCK_V2_AUTH.ebus_broker_password + assert entry.data[CONF_PANEL_CA_PEM] == FAKE_CA_PEM + assert entry.data[CONF_HTTPS_PORT] == 9443 + # Nothing was fetched to replace it with, and registration ran over the + # stored pin on the stored port. + fetch.assert_not_awaited() + assert register.call_args.args[2].port == 9443 + assert register.call_args.args[2].ca_pem == FAKE_CA_PEM + + @pytest.mark.asyncio async def test_reauth_asks_where_a_moved_panel_serves_tls_and_keeps_the_answer( hass: HomeAssistant, diff --git a/tests/test_v2_config_flow_tls.py b/tests/test_v2_config_flow_tls.py new file mode 100644 index 00000000..d86c8a4b --- /dev/null +++ b/tests/test_v2_config_flow_tls.py @@ -0,0 +1,509 @@ +"""Config flow tests that run against a real TLS handshake. + +The rest of the v2 config-flow suite stubs `async_leaf_chains_to_ca` and +`build_panel_ssl_context`, so hostname verification is never exercised there. +These tests live in their own module precisely so that autouse fixture does not +reach them: a certificate authority and a leaf are generated with +`cryptography`, a small HTTPS listener serves that leaf on loopback, and the +flow's own TLS code runs against it unmodified. + +Everything here is synthetic — a throwaway CA, a self-signed panel leaf, and the +name `panel.home.lan` resolved to 127.0.0.1 for the duration of one test. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterator +import datetime +import http.server +import ipaddress +import json +import socket +import socketserver +import ssl +import threading +from typing import Any +from unittest.mock import AsyncMock, patch + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID +from homeassistant import config_entries +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry +from span_panel_api import DetectionResult, V2AuthResponse, V2StatusInfo + +from custom_components.span_panel.const import ( + CONF_API_VERSION, + CONF_EBUS_BROKER_PORT, + CONF_HOP_PASSPHRASE, + CONF_HTTP_PORT, + CONF_HTTPS_PORT, + CONF_PANEL_CA_PEM, + CONF_REGISTERED_FQDN, + DOMAIN, +) + +PANEL_FQDN = "panel.home.lan" +PANEL_LOOPBACK = "127.0.0.1" +PANEL_SERIAL = "sp3-synthetic-0001" +PASSPHRASE = "hunter2-synthetic" + +# ---------- certificate generation ---------- + + +def _issue_ca() -> tuple[x509.Certificate, rsa.RSAPrivateKey]: + """Issue the throwaway CA that stands in for the one a panel publishes.""" + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "SPAN Panel Test CA")]) + now = datetime.datetime.now(datetime.UTC) + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(subject) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(days=1)) + .not_valid_after(now + datetime.timedelta(days=1)) + .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) + .sign(key, hashes.SHA256()) + ) + return cert, key + + +def _issue_leaf( + ca_cert: x509.Certificate, + ca_key: rsa.RSAPrivateKey, + names: list[x509.GeneralName], +) -> tuple[str, str]: + """Issue a server certificate naming exactly `names`, returning PEM cert and key.""" + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + now = datetime.datetime.now(datetime.UTC) + cert = ( + x509.CertificateBuilder() + .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "SPAN Panel")])) + .issuer_name(ca_cert.subject) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(days=1)) + .not_valid_after(now + datetime.timedelta(days=1)) + .add_extension(x509.SubjectAlternativeName(names), critical=False) + .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) + .sign(ca_key, hashes.SHA256()) + ) + cert_pem = cert.public_bytes(serialization.Encoding.PEM).decode() + key_pem = key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ).decode() + return cert_pem, key_pem + + +def _ca_pem(ca_cert: x509.Certificate) -> str: + return ca_cert.public_bytes(serialization.Encoding.PEM).decode() + + +# ---------- a listener that serves the panel's leaf ---------- + + +class _PanelStatusHandler(http.server.BaseHTTPRequestHandler): + """Answer the one unauthenticated probe `detect_api_version` makes.""" + + protocol_version = "HTTP/1.1" + + def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API + body = json.dumps( + { + "serialNumber": PANEL_SERIAL, + "firmwareVersion": "2.0.0-synthetic", + "proximityProven": True, + } + ).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format: str, *args: Any) -> None: # noqa: A002 + """Keep the test output free of one line per handshake.""" + + +class _PanelTLSListener(socketserver.ThreadingMixIn, http.server.HTTPServer): + """Serve HTTPS on loopback with a certificate the test can swap at will. + + Swappable because that is what the panel does: `register_fqdn` makes it + regenerate its leaf to add the FQDN, and the flow is supposed to notice. + """ + + daemon_threads = True + tls_context: ssl.SSLContext + + def get_request(self) -> tuple[socket.socket, Any]: + sock, addr = super().get_request() + return self.tls_context.wrap_socket(sock, server_side=True), addr + + def handle_error(self, request: Any, client_address: Any) -> None: + """Stay quiet: the leaf check hangs up right after the handshake.""" + + +def _server_context(cert_pem: str, key_pem: str, tmp_path: Any) -> ssl.SSLContext: + """Build a server-side context from PEM text (`load_cert_chain` wants files).""" + cert_file = tmp_path / f"leaf-{abs(hash(cert_pem))}.pem" + key_file = tmp_path / f"key-{abs(hash(cert_pem))}.pem" + cert_file.write_text(cert_pem) + key_file.write_text(key_pem) + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.load_cert_chain(str(cert_file), str(key_file)) + return ctx + + +class _Panel: + """The synthetic panel: a CA, a leaf, and the port it serves TLS on.""" + + def __init__(self, tmp_path: Any) -> None: + self._tmp_path = tmp_path + self._ca_cert, self._ca_key = _issue_ca() + self.ca_pem = _ca_pem(self._ca_cert) + self._listener: _PanelTLSListener | None = None + self.port = 0 + + def present(self, names: list[x509.GeneralName]) -> None: + """Serve a freshly issued leaf naming exactly `names`.""" + cert_pem, key_pem = _issue_leaf(self._ca_cert, self._ca_key, names) + self.present_pem(cert_pem, key_pem) + + def present_pem(self, cert_pem: str, key_pem: str) -> None: + """Serve this certificate, whoever signed it.""" + context = _server_context(cert_pem, key_pem, self._tmp_path) + if self._listener is None: + self._listener = _PanelTLSListener((PANEL_LOOPBACK, 0), _PanelStatusHandler) + self._listener.tls_context = context + self.port = self._listener.server_port + threading.Thread(target=self._listener.serve_forever, daemon=True).start() + else: + self._listener.tls_context = context + + def close(self) -> None: + if self._listener is not None: + self._listener.shutdown() + self._listener.server_close() + self._listener = None + + +@pytest.fixture +def panel(tmp_path: Any) -> Iterator[_Panel]: + """Serve a leaf naming the panel's IP only, as one does before registration.""" + instance = _Panel(tmp_path) + instance.present([x509.IPAddress(ipaddress.ip_address(PANEL_LOOPBACK))]) + yield instance + instance.close() + + +@pytest.fixture +def resolves_to_loopback() -> Iterator[None]: + """Point `panel.home.lan` at the loopback listener, and nothing else. + + The name arrives as `bytes` from anyio's resolver and as `str` from + `socket.create_connection`, so both spellings are matched. + """ + real_getaddrinfo = socket.getaddrinfo + + def _fake(host: Any, port: Any, *args: Any, **kwargs: Any) -> Any: + name = host.decode("ascii") if isinstance(host, bytes) else host + if name == PANEL_FQDN: + return real_getaddrinfo(PANEL_LOOPBACK, port, *args, **kwargs) + return real_getaddrinfo(host, port, *args, **kwargs) + + with patch("socket.getaddrinfo", side_effect=_fake): + yield + + +async def _finish_progress(hass: HomeAssistant, result: dict[str, Any]) -> Any: + """Drive a `async_show_progress` step to whatever it resolves into. + + The registration task does real TLS work, so it is never done on the turn + that starts it — unlike the rest of the suite, where every call inside it is + a mock that completes immediately. + """ + while result["type"] == FlowResultType.SHOW_PROGRESS: + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + if result["type"] == FlowResultType.SHOW_PROGRESS_DONE: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + return result + + +def _auth_response(mqtts_port: int) -> V2AuthResponse: + return V2AuthResponse( + access_token="synthetic-token", + token_type="bearer", + iat_ms=1700000000000, + ebus_broker_host=PANEL_LOOPBACK, + ebus_broker_mqtts_port=mqtts_port, + ebus_broker_ws_port=8080, + ebus_broker_wss_port=8443, + ebus_broker_username="span-user", + ebus_broker_password="mqtt-secret", + hostname="span-panel.local", + serial_number=PANEL_SERIAL, + hop_passphrase=PASSPHRASE, + ) + + +def _registration_adds_the_fqdn(panel: _Panel) -> Callable[..., Any]: + """Return a `register_fqdn` stand-in with the side effect the real panel has.""" + + async def _register(*args: Any, **kwargs: Any) -> None: + panel.present( + [ + x509.IPAddress(ipaddress.ip_address(PANEL_LOOPBACK)), + x509.DNSName(PANEL_FQDN), + ] + ) + + return _register + + +# ---------- Case A: a fresh install named by FQDN ---------- + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_fresh_fqdn_install_pins_and_completes( + hass: HomeAssistant, panel: _Panel +) -> None: + """A fresh install by FQDN must reach entry creation with the CA pinned. + + The panel's leaf names its address, not the FQDN — the FQDN only lands in + the SAN once `register_fqdn` has run, which is after authentication. So the + pre-registration leaf check has to be made over the address the leaf names. + """ + register = AsyncMock(side_effect=_registration_adds_the_fqdn(panel)) + fetch_ca = AsyncMock(return_value=panel.ca_pem) + with ( + patch( + "custom_components.span_panel.config_flow.validate_host", + new=AsyncMock(return_value=True), + ), + patch( + "custom_components.span_panel.config_flow.detect_api_version", + new=AsyncMock( + return_value=DetectionResult( + api_version="v2", + status_info=V2StatusInfo( + serial_number=PANEL_SERIAL, firmware_version="2.0.0-synthetic" + ), + ) + ), + ), + patch("custom_components.span_panel.config_flow.async_fetch_panel_ca", new=fetch_ca), + patch( + "custom_components.span_panel.config_flow.validate_v2_passphrase", + new=AsyncMock(return_value=_auth_response(panel.port)), + ) as validate, + patch("custom_components.span_panel.config_flow.register_fqdn", new=register), + patch("custom_components.span_panel.config_flow.asyncio.sleep", new=AsyncMock()), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + # A non-default HTTP port is what makes the flow ask where TLS lives, + # which is how the listener's ephemeral port reaches it. + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: PANEL_FQDN, CONF_HTTP_PORT: 8080} + ) + assert result["step_id"] == "panel_https_port", result + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HTTPS_PORT: panel.port} + ) + assert result["type"] == FlowResultType.MENU, result + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "auth_passphrase"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOP_PASSPHRASE: PASSPHRASE} + ) + # Registration runs as a background task behind a progress screen, and + # the TLS work in it is real, so the flow is driven on from there. + assert result["type"] == FlowResultType.SHOW_PROGRESS, result + assert result["progress_action"] == "registering_fqdn" + result = await _finish_progress(hass, result) + assert result["step_id"] == "choose_entity_naming_initial", result + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.CREATE_ENTRY, result + assert result["data"][CONF_HOST] == PANEL_FQDN + assert result["data"][CONF_REGISTERED_FQDN] == PANEL_FQDN + assert result["data"][CONF_PANEL_CA_PEM] == panel.ca_pem + # The bootstrap ran over the address the leaf names, and the FQDN is what + # was registered — not the other way round. + assert fetch_ca.await_args.args[1] == PANEL_LOOPBACK + assert validate.await_args.args[0] == PANEL_LOOPBACK + assert register.await_args.args[0] == PANEL_LOOPBACK + assert register.await_args.args[2] == PANEL_FQDN + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_fresh_install_still_refuses_a_leaf_the_ca_does_not_sign( + hass: HomeAssistant, panel: _Panel +) -> None: + """Bootstrapping over the IP must not turn the leaf check off.""" + stranger_cert, stranger_key = _issue_leaf(*_issue_ca(), [x509.DNSName("elsewhere.invalid")]) + panel.present_pem(stranger_cert, stranger_key) + with ( + patch( + "custom_components.span_panel.config_flow.validate_host", + new=AsyncMock(return_value=True), + ), + patch( + "custom_components.span_panel.config_flow.detect_api_version", + new=AsyncMock( + return_value=DetectionResult( + api_version="v2", + status_info=V2StatusInfo( + serial_number=PANEL_SERIAL, firmware_version="2.0.0-synthetic" + ), + ) + ), + ), + patch( + "custom_components.span_panel.config_flow.async_fetch_panel_ca", + new=AsyncMock(return_value=panel.ca_pem), + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: PANEL_FQDN, CONF_HTTP_PORT: 8080} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HTTPS_PORT: panel.port} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "panel_ca" + assert result["errors"] == {"base": "ca_leaf_mismatch"} + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_registration_that_never_lands_does_not_report_success( + hass: HomeAssistant, panel: _Panel +) -> None: + """A panel that accepts the registration but never serves the FQDN must not pass. + + Also pins down where the readiness poll gets its anchor: from the CA this + flow already accepted, not from a fresh plaintext fetch every two seconds. + """ + download = AsyncMock(return_value=panel.ca_pem) + with ( + patch( + "custom_components.span_panel.config_flow.validate_host", + new=AsyncMock(return_value=True), + ), + patch( + "custom_components.span_panel.config_flow.detect_api_version", + new=AsyncMock( + return_value=DetectionResult( + api_version="v2", + status_info=V2StatusInfo( + serial_number=PANEL_SERIAL, firmware_version="2.0.0-synthetic" + ), + ) + ), + ), + patch( + "custom_components.span_panel.config_flow.async_fetch_panel_ca", + new=AsyncMock(return_value=panel.ca_pem), + ), + patch( + "custom_components.span_panel.config_flow.validate_v2_passphrase", + new=AsyncMock(return_value=_auth_response(panel.port)), + ), + # The panel says yes and does nothing: its leaf still names only the IP. + patch("custom_components.span_panel.config_flow.register_fqdn", new=AsyncMock()), + patch("custom_components.span_panel.config_flow.asyncio.sleep", new=AsyncMock()), + patch( + "custom_components.span_panel.config_flow_validation.download_ca_cert", + new=download, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: PANEL_FQDN, CONF_HTTP_PORT: 8080} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HTTPS_PORT: panel.port} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "auth_passphrase"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOP_PASSPHRASE: PASSPHRASE} + ) + result = await _finish_progress(hass, result) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "fqdn_failed" + assert result["errors"] == {"base": "fqdn_registration_failed"} + download.assert_not_awaited() + + +# ---------- Case B: a pinned IP entry reconfigured to an FQDN ---------- + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_reconfigure_pinned_ip_entry_to_fqdn( + hass: HomeAssistant, panel: _Panel +) -> None: + """A pinned entry moved to an FQDN must probe over the address the leaf names.""" + entry = MockConfigEntry( + version=7, + minor_version=1, + domain=DOMAIN, + title="SPAN Panel", + data={ + CONF_HOST: PANEL_LOOPBACK, + CONF_ACCESS_TOKEN: "synthetic-token", + CONF_API_VERSION: "v2", + CONF_EBUS_BROKER_PORT: panel.port, + CONF_HTTPS_PORT: panel.port, + CONF_PANEL_CA_PEM: panel.ca_pem, + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id=PANEL_SERIAL, + ) + entry.add_to_hass(hass) + + register = AsyncMock(side_effect=_registration_adds_the_fqdn(panel)) + with ( + patch("custom_components.span_panel.config_flow.register_fqdn", new=register), + patch("custom_components.span_panel.config_flow.asyncio.sleep", new=AsyncMock()), + ): + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: PANEL_FQDN} + ) + assert result["type"] == FlowResultType.SHOW_PROGRESS, result + result = await _finish_progress(hass, result) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT, result + assert result["reason"] == "reconfigure_successful" + assert entry.data[CONF_HOST] == PANEL_FQDN + assert entry.data[CONF_REGISTERED_FQDN] == PANEL_FQDN + assert entry.data[CONF_PANEL_CA_PEM] == panel.ca_pem + assert register.await_args.args[0] == PANEL_LOOPBACK + assert register.await_args.args[2] == PANEL_FQDN From bef90bd933b221ff8666e96339d8b971adabc3a3 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:21:07 -0700 Subject: [PATCH 162/345] refactor(naming): the base never bypasses entity_id_parts R1 was clarified on 2026-08-26 (evening) in the circuit-numbers spec: The base must reproduce an existing entity's id *when Home Assistant composes it under the entity-id options that match how that install was built*. That is what "never offer a rename the user did not cause" means: the base carries the install's own suffix wording (`consumed_energy` or `energy_consumed`, read back from the id) and the circuit's own name, so two spellings of ours or a relabel never produce an offer. It does **not** mean bypassing Home Assistant's `entity_id_parts` with a hard-coded id: where composition yields a different device part (an EVSE feed sensor on the charger's card, a second panel named "Span Panel 2", an install with the legacy no-prefix option), that is Home Assistant's configuration at work and the offer is legitimate. The "legacy preset" exceptions added during implementation on 2026-08-26 are therefore withdrawn. Nothing moves an id without the user pressing Recreate. Withdrawn accordingly: - naming.py: LEGACY_PRESET_DEVICE_NAME, generated_panel_device_name, legacy_preset_entity_id, _names_another_device, legacy_preset_for_existing, and the now-unused dr / HomeAssistant / DOMAIN / Final imports. The module docstring states the clarified rule. - entity.py: _generated_panel_device_name. - sensor_base.py: _preset_composition_would_move, _id_was_preset_by_this_integration, the `self.entity_id = preset` branch and the USE_DEVICE_PREFIX import. The base is set whenever _object_id_parts returns parts and the entity is not a sub-device. - sensor_circuit.py: both _id_was_preset_by_this_integration class attributes. - switch.py, select.py: the legacy_preset_for_existing call, the preset branch and the USE_DEVICE_PREFIX read. _span_object_id_base is always set. No `self.entity_id = ...` remains in the four circuit platforms; Home Assistant composes every id from the user's entity_id_parts. Kept, because they are base fidelity rather than a bypass: the per-install suffix read-back, the identifier-anchored form test, and the sub-device sensors that supply no base and compose from their label on the charger. Tests: the "offered its own id" cases for a sub-device sensor, a no-prefix install and a generated "Span Panel 2" panel now assert what Home Assistant composes under (DEVICE, ENTITY) parts -- sensor.span_panel_ev_charger_power, sensor.span_panel_refrigerator_power, sensor.span_panel_2_refrigerator_power and the two control equivalents -- and that the live entity_id is unchanged after two reloads (R5). The unit tests of the deleted symbols are gone; every suffix read-back, rename and circuit_object_id_base case is unchanged. README, developer.md and the 2.1.0 changelog describe the base and the read-back only, and say that these three populations may be offered the composed id, their entity-id settings deciding, with nothing moving until they accept. --- CHANGELOG.md | 17 +- README.md | 6 + custom_components/span_panel/entity.py | 22 -- custom_components/span_panel/naming.py | 160 +-------- custom_components/span_panel/select.py | 20 +- custom_components/span_panel/sensor_base.py | 70 +--- .../span_panel/sensor_circuit.py | 4 - custom_components/span_panel/switch.py | 20 +- developer.md | 33 +- tests/test_naming.py | 326 +----------------- tests/test_recreate_entity_ids.py | 159 ++++++--- 11 files changed, 160 insertions(+), 677 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6419969d..7f7fa97a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -109,16 +109,17 @@ working entirely in 2027.8. - **Nothing you already have moves**: an entity keeps the id it has until you press **Recreate entity IDs**, which offers an entity its own id back where nothing changed, and an id reflecting your own configuration where something did — a circuit you renamed, an area you assigned, or the name you gave the panel device. -- **A second panel keeps the ids it has.** Its device is called "Span Panel 2" because this integration named it that, not because you did, so the circuit - entities already on it are still offered their own `span_panel_` ids; only circuits added from this release on are spelled `span_panel_2_`. -- **A panel device you renamed yourself is offered ids carrying that name**, since that is a change you made — the old scheme spelled every circuit id - `span_panel_` no matter what the device was called. +- **Three kinds of installation may be offered a different id when you press Recreate entity IDs**: a sensor on a SPAN Drive's feed circuit (offered the + charger's name, the device it is actually on), a second panel whose device this integration called "Span Panel 2" (offered `span_panel_2_`), and an + installation predating the `use_device_prefix` option whose circuit ids carry no device at all (offered one). Each is your own entity-id settings composing + the id, and no id moves until you accept the offer. +- **A panel device you renamed yourself is offered ids carrying that name**, since Home Assistant composes from the name you gave the device ahead of the one + this integration generated. - **New circuit energy sensors get `consumed_energy`, `produced_energy` and `net_energy`**, the same word order as the panel-level energy sensors; existing sensors keep the spelling they have, whichever of the two it is, and are never offered the other one. -- **Circuit sensors on a SPAN Drive's feed circuit are named for the charger alone**, for sensors created from this release on — `sensor._power`, - matching the charger's other sensors — while the ones you already have go on naming the panel. -- **The legacy `use_device_prefix` option can no longer keep the device out of a _new_ entity's id**, since Home Assistant includes the device unless your own - entity-id settings exclude it; the entities such an installation already has are untouched. +- **Circuit sensors on a SPAN Drive's feed circuit are named for the charger alone** — `sensor._power`, matching the charger's other sensors. +- **The legacy `use_device_prefix` option can no longer keep the device out of a composed entity id**, since Home Assistant includes the device unless your own + entity-id settings exclude it. - **An unnamed circuit in friendly-names mode is now named for the tab it occupies** (`Circuit 7 Power`), so two unnamed circuits no longer collide — the old scheme called every one of them `single_circuit` and let the registry tell them apart with `_2`, `_3`. - **An unnamed circuit you already have may be offered a `circuit_7` id** if you press **Recreate entity IDs**, which is the one place this release proposes a diff --git a/README.md b/README.md index 1287bc06..e0b682da 100644 --- a/README.md +++ b/README.md @@ -610,6 +610,12 @@ The integration supplies only the circuit half of the ID shown above — `Kitche own entity ID settings (`entity_id_parts`, Home Assistant 2026.8 and newer), which decide whether the device name and the area are prefixed; entities you already have keep the IDs they have until you press **Recreate entity IDs**. +For an entity that already exists, the suffix wording in that half is read back from the ID the entity has rather than reworded. Circuit energy IDs have shipped +in two spellings — `consumed_energy` and `energy_consumed` — so an installation is offered whichever one it is already using, and never the other; only new +sensors get the `consumed_energy` word order that the panel-level sensors use. Because your entity ID settings decide the rest of the ID, an installation whose +circuits were named before those settings existed — a SPAN Drive feed circuit, a second panel, or an installation predating the device prefix — may be offered +an ID with a different device half. Nothing moves until you accept the offer. + ### Energy Dip Compensation SPAN panels occasionally report lower energy readings for cumulative energy sensors after firmware updates or resets. Home Assistant's statistics engine diff --git a/custom_components/span_panel/entity.py b/custom_components/span_panel/entity.py index 2b2f4bbd..8e74a41e 100644 --- a/custom_components/span_panel/entity.py +++ b/custom_components/span_panel/entity.py @@ -14,7 +14,6 @@ from .control_gate import CONTROL_CALLER, async_bind_caller from .coordinator import SpanPanelCoordinator from .field_paths import FieldPathDeclarationMixin -from .naming import generated_panel_device_name from .util import snapshot_to_device_info @@ -184,24 +183,3 @@ def _build_device_info( ) host = coordinator.config_entry.data.get(CONF_HOST) return snapshot_to_device_info(snapshot, device_name, host=host) - - @staticmethod - def _generated_panel_device_name( - coordinator: SpanPanelCoordinator, - snapshot: SpanPanelSnapshot, - ) -> str | None: - """Return the panel device's name where this integration generated it. - - The registry's answer, not the config entry's: the rule turns on which - registry field the name sits in, `name` being ours and `name_by_user` - the user's, and a `DeviceInfo` cannot tell the two apart. Read from the - panel's own device even for an entity shown on a sub-device card, since - the ids at stake are the ones the preset builder spelled with the panel. - - Static, and taking the coordinator, so the three circuit platforms can - ask before `super().__init__` has run -- which is where the switch - decides its id, and so where `self.coordinator` does not exist yet. - """ - return generated_panel_device_name( - coordinator.hass, coordinator.config_entry.entry_id, snapshot.serial_number - ) diff --git a/custom_components/span_panel/naming.py b/custom_components/span_panel/naming.py index 81e74018..ebf73e7d 100644 --- a/custom_components/span_panel/naming.py +++ b/custom_components/span_panel/naming.py @@ -6,15 +6,15 @@ or `Kitchen Outlets power` -- and Core assembles the rest from the user's settings. `SpanPanelEntity.suggested_object_id` hands the base to Core. -Two rules bound what the base may be: - -- An existing entity must be proposed its own id when nothing changed. Its - suffix wording is therefore read back from the id it already has, because - this integration has shipped two spellings (`consumed_energy` before it - preset ids, `energy_consumed` after). Recreate must never offer a rename - the user did not cause. -- A new entity gets the noun-last wording, which is what the panel-level ids - (`main_meter_consumed_energy`) and the display labels use. +The base must reproduce an existing entity's id *where Home Assistant composes +it under the entity-id options that match how that install was built*: it +carries the circuit's own name and the install's own suffix wording, read back +from the id the entity already has, because this integration has shipped two +spellings (`consumed_energy` before it preset ids, `energy_consumed` after). It +never bypasses `entity_id_parts` with a hard-coded id -- where composition +yields a different device part, that is the user's configuration at work and +the offer is legitimate. A new entity gets the noun-last wording, which is what +the panel-level ids (`main_meter_consumed_energy`) and the display labels use. The base is *not* the display name. The two are decoupled on purpose, so a label can be reworded without touching a single id. @@ -26,14 +26,10 @@ from __future__ import annotations from collections.abc import Iterable -from typing import Final -from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.util import slugify -from .const import DOMAIN - ENTITY_ID_SUFFIX_FORMS: dict[str, frozenset[str]] = { "power": frozenset({"power", "current_power"}), "energy_produced": frozenset({"produced_energy", "energy_produced"}), @@ -147,142 +143,6 @@ def circuit_object_id_base(identifier: str, suffix: str, existing_entity_id: str return f"{identifier} {words}" -LEGACY_PRESET_DEVICE_NAME: Final = "Span Panel" -"""The device name the preset builder spelled every circuit id with. - -Not the panel's own name. The builder was called without one, so -`snapshot_to_device_info` fell back to this literal on every install -- the -second panel on a system is named "Span Panel 2" and its circuit entity ids -still began `span_panel_`. An id being *kept* has to be spelled the way it -actually is, so this is the string to keep spelling it with. -""" - - -def generated_panel_device_name( - hass: HomeAssistant, entry_id: str, serial_number: str -) -> str | None: - """Return the panel device's registered name, where this integration generated it. - - Nobody types "Span Panel 2": the config flow invents it for the second panel - on a system (`config_flow.get_unique_device_name`) and stores it as the - entry's `device_name`, which is what reaches the device registry. So a name - in `name` is ours and a name in `name_by_user` is the user's, and only the - first is a reason to keep an id from moving -- Home Assistant composes the - device half from `name_by_user or name`, so following a rename the user made - is following the user. - - `None` where the device is not registered yet, which is the first setup of a - panel: entities are built before the platform creates it, and an install - with no device has no existing id to protect either. - - Scoped to the config entry rather than searched across every one, because - identifiers are unique only within an entry -- and a system with two panels - is exactly the case this answers for. - """ - device = dr.async_get(hass).async_get_device_by_identifier((DOMAIN, serial_number), entry_id) - if device is None or device.name_by_user is not None: - return None - return device.name - - -def legacy_preset_entity_id( - platform: str, - device_slug: str | None, - identifier: str, - suffix: str, - existing_entity_id: str, -) -> str: - """Return the id an existing entity keeps, where composing one would move it. - - Three shapes Home Assistant's composition cannot reproduce, for reasons that - belong to this integration rather than to the user: - - - a circuit sensor shown on a **sub-device** card, whose id names the panel - because the preset builder always did, where composition names the charger - -- that being the entity's device; - - any circuit entity on an install with **`USE_DEVICE_PREFIX` off**, whose id - carries no device at all, where `has_entity_name` prefixes one regardless; - - any circuit entity on a panel whose device name **this integration - generated** as something other than "Span Panel" -- the second panel on a - system -- whose id says `span_panel_` all the same, where composition says - `span_panel_2_`. - - R1 forbids offering any of those moves, so those entities go on being preset. - Only those: `device_slug` is `None` for the prefix-less case and everything - else composes, which is the whole point of the release. - - The id is *computed* from current panel data and never read back from the - registry, so a circuit renamed in the SPAN app still refreshes the name half - -- issue #252, which the preset must not undo. Only the suffix half is read - back, by `circuit_object_id_base`, since this integration has shipped two - spellings of it. - """ - object_id = slugify(circuit_object_id_base(identifier, suffix, existing_entity_id)) - if device_slug: - object_id = f"{slugify(device_slug)}_{object_id}" - return f"{platform}.{object_id}" - - -def _names_another_device(device_name: str | None) -> bool: - """Report whether composition would spell the device half some other way. - - Only a name this integration generated reaches here, so a name that is not - "Span Panel" is one no user chose -- and every existing circuit id on that - panel says `span_panel_` regardless, because the preset builder was never - told the panel's name. `None` is the panel whose device is not registered - yet, or one the user renamed, and neither is ours to hold still. - """ - if device_name is None: - return False - return slugify(device_name) != slugify(LEGACY_PRESET_DEVICE_NAME) - - -def legacy_preset_for_existing( - platform: str, - *, - identifier: str, - suffix: str, - existing_entity_id: str | None, - use_device_prefix: bool, - is_sub_device: bool, - device_name: str | None, -) -> str | None: - """Return the id this entity keeps, or None to let Home Assistant compose one. - - The whole R1 exception in one place, asked by all three circuit platforms -- - the sensors, the breaker switch and the priority select. Each used to preset - its id through the same builder, so each faces the same shapes composition - spells differently; a copy of this decision per platform is how three - platforms drift apart on which entities the release moves. - - `device_name` is what `generated_panel_device_name` answers: the panel - device's name where this integration generated it, and `None` where the user - renamed it or it is not registered yet. A generated name that is not "Span - Panel" is the third shape -- the second panel on a system, called "Span - Panel 2" by the config flow, whose circuit ids nonetheless all say - `span_panel_` because the preset builder was never told the panel's name. - Composition would offer that whole panel `sensor.span_panel_2_...`, a move - nobody asked for. - - `None` for anything new: an entity with no id yet has none to protect, and - composing one is the point of the release. `None` too for the ordinary - existing entity, whose id composition reproduces exactly. What is left is - `legacy_preset_entity_id`'s shapes -- see it for why each is kept and for - why the kept id is computed from current panel data rather than read back. - """ - if existing_entity_id is None: - return None - if not (is_sub_device or not use_device_prefix or _names_another_device(device_name)): - return None - return legacy_preset_entity_id( - platform, - LEGACY_PRESET_DEVICE_NAME if use_device_prefix else None, - identifier, - suffix, - existing_entity_id, - ) - - def release_registry_name_written_by_older_release( registry: er.EntityRegistry, entity_id: str, diff --git a/custom_components/span_panel/select.py b/custom_components/span_panel/select.py index ef2c9ea1..ee218eb4 100644 --- a/custom_components/span_panel/select.py +++ b/custom_components/span_panel/select.py @@ -15,7 +15,7 @@ from . import SpanPanelConfigEntry from .adoption import AdoptedSelect, create_adopted_selects -from .const import DOMAIN, USE_CIRCUIT_NUMBERS, USE_DEVICE_PREFIX, CircuitPriority +from .const import DOMAIN, USE_CIRCUIT_NUMBERS, CircuitPriority from .control_gate import ControlMode, outcome_failure_reason, outcome_is_failure from .coordinator import SpanPanelCoordinator from .entity import SpanPanelEntity @@ -29,7 +29,6 @@ ) from .naming import ( circuit_object_id_base, - legacy_preset_for_existing, release_registry_name_written_by_older_release, ) @@ -146,7 +145,6 @@ def __init__( ) use_circuit_numbers = coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) - use_device_prefix: bool = coordinator.config_entry.options.get(USE_DEVICE_PREFIX, True) desc_name = description.name circuit_name = circuit.name or _unnamed_select_fallback(circuit, circuit_id) @@ -168,21 +166,7 @@ def __init__( if use_circuit_numbers else circuit_name ) - preset = legacy_preset_for_existing( - "select", - identifier=identifier, - suffix=suffix, - existing_entity_id=existing_entity_id, - use_device_prefix=use_device_prefix, - is_sub_device=False, - device_name=self._generated_panel_device_name(coordinator, snapshot), - ) - if preset is not None: - self.entity_id = preset - else: - self._span_object_id_base = circuit_object_id_base( - identifier, suffix, existing_entity_id - ) + self._span_object_id_base = circuit_object_id_base(identifier, suffix, existing_entity_id) # Circuit-numbers mode used to deliver the panel's name by writing the # registry's `name`. That field is the user's override, and Home Assistant diff --git a/custom_components/span_panel/sensor_base.py b/custom_components/span_panel/sensor_base.py index 760a4766..fc43dec8 100644 --- a/custom_components/span_panel/sensor_base.py +++ b/custom_components/span_panel/sensor_base.py @@ -22,7 +22,7 @@ from homeassistant.helpers.typing import StateType from span_panel_api import SpanPanelSnapshot -from .const import DOMAIN, ENABLE_ENERGY_DIP_COMPENSATION, USE_DEVICE_PREFIX +from .const import DOMAIN, ENABLE_ENERGY_DIP_COMPENSATION from .coordinator import SpanPanelCoordinator from .energy_dip import ( DipEvent, @@ -41,7 +41,6 @@ ) from .naming import ( circuit_object_id_base, - legacy_preset_for_existing, release_registry_name_written_by_older_release, ) from .options import ENERGY_REPORTING_GRACE_PERIOD @@ -92,19 +91,9 @@ class SpanSensorBase[T: SensorEntityDescription, D](SpanPanelEntity, SensorEntit _is_sub_device: bool = False """True when this entity is shown on a sub-device's card rather than the panel's. - Set by the circuit classes when they are handed a `device_info_override`. It - decides the id policy twice over: Core would compose a *new* one of these - from the sub-device, which is the shape its own sensors have and so the shape - wanted; an *existing* one carries the panel's name and keeps it. - """ - - _id_was_preset_by_this_integration: bool = False - """True for entities whose id this integration used to spell out in full. - - Only those can be moved by handing composition the job, so only those are - kept preset where composition disagrees. The hidden unmapped-tab sensors set - it False deliberately: Home Assistant has always composed their ids, so they - already carry a device prefix that no naming flag ever removed. + Set by the circuit classes when they are handed a `device_info_override`. + Such a sensor supplies no base at all: it composes from its label on the + sub-device's card, which is the shape the sub-device's own sensors have. """ def __init__( @@ -151,21 +140,15 @@ def __init__( # The id itself is Home Assistant's to compose. This entity # supplies only its base; `entity_id` is left unset so Core - # assembles the rest from the user's `entity_id_parts` -- except - # where composing one would move an id this integration spelled - # its own way, which R1 forbids offering. + # assembles the rest from the user's `entity_id_parts`. A + # sub-device sensor supplies no base either, composing from its + # label on the sub-device's card like that device's own sensors. parts = self._object_id_parts(snapshot, description) - if parts is not None: + if parts is not None and not self._is_sub_device: identifier, suffix = parts - preset = self._preset_composition_would_move( - snapshot, identifier, suffix, existing_entity_id + self._span_object_id_base = circuit_object_id_base( + identifier, suffix, existing_entity_id ) - if preset is not None: - self.entity_id = preset - elif not self._is_sub_device: - self._span_object_id_base = circuit_object_id_base( - identifier, suffix, existing_entity_id - ) if existing_entity_id: self._release_synced_registry_name( @@ -235,39 +218,6 @@ def _object_id_parts( """ return None - def _preset_composition_would_move( - self, - snapshot: SpanPanelSnapshot, - identifier: str, - suffix: str, - existing_entity_id: str | None, - ) -> str | None: - """Return the id to go on presetting, or None to let Core compose one. - - The decision itself belongs to `naming.legacy_preset_for_existing`, which - the switch and the select ask the same question of. What is added here is - the one fact only a sensor class knows: whether this integration ever - preset this entity's id at all. The hidden unmapped-tab sensors say no, - and an id Home Assistant has always composed cannot be moved by handing - Home Assistant the job. - - `snapshot` is taken rather than read off the coordinator because it is - the snapshot this entity is being built from, and it carries the serial - the panel's device is registered under. - """ - if not self._id_was_preset_by_this_integration: - return None - use_device_prefix: bool = self.coordinator.config_entry.options.get(USE_DEVICE_PREFIX, True) - return legacy_preset_for_existing( - "sensor", - identifier=identifier, - suffix=suffix, - existing_entity_id=existing_entity_id, - use_device_prefix=use_device_prefix, - is_sub_device=self._is_sub_device, - device_name=self._generated_panel_device_name(self.coordinator, snapshot), - ) - def _generate_panel_name(self, snapshot: SpanPanelSnapshot, description: T) -> str | None: """Generate the displayed name for the sensor, in either naming mode. diff --git a/custom_components/span_panel/sensor_circuit.py b/custom_components/span_panel/sensor_circuit.py index a3ea8d6c..ed4f47ae 100644 --- a/custom_components/span_panel/sensor_circuit.py +++ b/custom_components/span_panel/sensor_circuit.py @@ -92,8 +92,6 @@ class SpanCircuitPowerSensor( ): """Circuit power/current/breaker-rating sensor with extra state attributes.""" - _id_was_preset_by_this_integration = True - # Beyond the value the description declares: `name` and `tabs` build the # entity's identity (~56-83), and `tabs`, `relay_state`, `relay_requester` # and `priority` are republished as state attributes (~231-243). @@ -254,8 +252,6 @@ class SpanCircuitEnergySensor( ): """Circuit energy sensor with grace period tracking.""" - _id_was_preset_by_this_integration = True - # Naming only; this sensor publishes no circuit attributes. _residual_field_paths: ClassVar[tuple[str, ...]] = ("circuit.name", "circuit.tabs") diff --git a/custom_components/span_panel/switch.py b/custom_components/span_panel/switch.py index 2d7d7bda..db459f19 100644 --- a/custom_components/span_panel/switch.py +++ b/custom_components/span_panel/switch.py @@ -15,7 +15,7 @@ from . import SpanPanelConfigEntry from .adoption import AdoptedSwitch, create_adopted_switches -from .const import DOMAIN, USE_CIRCUIT_NUMBERS, USE_DEVICE_PREFIX, CircuitRelayState +from .const import DOMAIN, USE_CIRCUIT_NUMBERS, CircuitRelayState from .control_gate import ( ControlLock, ControlMode, @@ -34,7 +34,6 @@ ) from .naming import ( circuit_object_id_base, - legacy_preset_for_existing, release_registry_name_written_by_older_release, ) from .util import snapshot_to_device_info @@ -101,7 +100,6 @@ def __init__( ) use_circuit_numbers = coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) - use_device_prefix: bool = coordinator.config_entry.options.get(USE_DEVICE_PREFIX, True) circuit_name = circuit.name or _unnamed_switch_fallback(circuit, circuit_id) # One name path, in both naming modes: the panel's name, carried as @@ -120,21 +118,9 @@ def __init__( if use_circuit_numbers else circuit_name ) - preset = legacy_preset_for_existing( - "switch", - identifier=identifier, - suffix="breaker", - existing_entity_id=existing_entity_id, - use_device_prefix=use_device_prefix, - is_sub_device=False, - device_name=self._generated_panel_device_name(coordinator, snapshot), + self._span_object_id_base = circuit_object_id_base( + identifier, "breaker", existing_entity_id ) - if preset is not None: - self.entity_id = preset - else: - self._span_object_id_base = circuit_object_id_base( - identifier, "breaker", existing_entity_id - ) # Circuit-numbers mode used to deliver the panel's name by writing the # registry's `name`. That field is the user's override, and Home Assistant diff --git a/developer.md b/developer.md index 91cffc60..729034fb 100644 --- a/developer.md +++ b/developer.md @@ -390,23 +390,22 @@ parts, and files a preset `entity_id` as `suggested_object_id`, which outranks t base would therefore ship the base as the whole id. The three circuit platforms reach the base by the same route: the sensors through `_object_id_parts`, and the switch and the select directly. -### The legacy preset, and why two populations keep one - -R1 — "Recreate entity IDs" must never propose a change the user did not cause — bounds what may be composed. Two shapes Core cannot reproduce, for reasons that -belong to this integration rather than to the user: - -- a circuit sensor shown on a **sub-device** card, whose id names the panel because the preset builder always did, where composition names the charger, that - being the entity's device; -- any circuit entity on an install with **`USE_DEVICE_PREFIX` off**, whose id carries no device at all, where `has_entity_name` prefixes one regardless. - -`naming.legacy_preset_for_existing` is that whole exception, asked by all three circuit platforms so a copy per platform cannot drift. It answers `None` for -anything new — an entity with no id yet has none to protect — and `None` for the ordinary existing entity, whose id composition reproduces exactly. A third gate -sits in front of it on the sensor side, `_id_was_preset_by_this_integration` (`sensor_base.py`, set to `True` only by the circuit sensor classes): the hidden -unmapped-tab sensors have had their ids composed by Home Assistant all along, and an id Core already composes cannot be moved by handing Core the job, so they -never reach the preset path. Only an _existing_ entity in one of the two shapes goes on being preset, and the kept id is **computed from current panel data** -rather than read back from the registry, so a circuit renamed in the SPAN app still refreshes the name half. That is issue #252, which the preset must not undo. -`LEGACY_PRESET_DEVICE_NAME` is the literal `Span Panel` the old builder spelled every circuit id with — not the panel's own name, which is why a second panel's -circuits are `span_panel_…` too, and why keeping an id means keeping that spelling. +### What R1 bounds, and what it does not + +R1 — "Recreate entity IDs" must never propose a change the user did not cause — bounds the _base_ and nothing else. Read exactly: the base must reproduce an +existing entity's id **where Home Assistant composes it under the entity-id options that match how that install was built**. That is why the suffix wording is +read back and why the name half is anchored on the circuit's own name: two spellings of ours, or a relabel, must never produce an offer. + +It is **not** a licence to bypass `entity_id_parts` with a hard-coded id. Where composition yields a different device half — a SPAN Drive feed sensor, whose +device is the charger and not the panel; a second panel the config flow named "Span Panel 2", whose circuit ids all say `span_panel_` because the old builder +was never told the panel's name; an install with `USE_DEVICE_PREFIX` off, whose ids carry no device at all where `has_entity_name` prefixes one — that is the +user's own configuration at work, and the offer is legitimate. Recreate offers it; nothing moves until the user presses the button (R5). The "legacy preset" +exceptions written for those three shapes were withdrawn by the maintainer on 2026-08-26, and there is no `self.entity_id = …` left in any of the four +platforms. + +One consequence worth naming: a circuit sensor on a sub-device card supplies **no base at all**. Its label there is the bare description name (`Power`), so it +composes as `sensor._power` like the charger's own sensors, and a rename on the panel cannot reach its id. Every other circuit entity sets its base +whenever `_object_id_parts` answers. ### Releasing the registry name diff --git a/tests/test_naming.py b/tests/test_naming.py index 5682dac0..edfbb99f 100644 --- a/tests/test_naming.py +++ b/tests/test_naming.py @@ -174,278 +174,7 @@ def test_the_id_shapes_the_upgrade_scenarios_pinned( assert circuit_object_id_base(identifier, suffix, None) == expected -# --- The id shape kept where composition would move one -------------------- -# -# Two kinds of circuit entity are spelled differently by Home Assistant's -# composition than by the builder that preset their ids: one on a sub-device, -# where the DEVICE part is the charger and not the panel, and one on an install -# that turned the device prefix off, where composition prefixes anyway. Neither -# difference is anything the user did, so those entities keep being preset. - -from custom_components.span_panel.naming import ( - LEGACY_PRESET_DEVICE_NAME, - legacy_preset_entity_id, - legacy_preset_for_existing, -) - - -def test_a_sub_device_sensor_keeps_the_panel_prefixed_id_it_has() -> None: - """Composition would spell this one with the charger's name; its id says the panel.""" - assert ( - legacy_preset_entity_id( - "sensor", - LEGACY_PRESET_DEVICE_NAME, - "Kitchen Outlets", - "power", - "sensor.span_panel_kitchen_outlets_power", - ) - == "sensor.span_panel_kitchen_outlets_power" - ) - - -def test_an_install_without_the_device_prefix_keeps_its_bare_id() -> None: - """`has_entity_name` prefixes the device unconditionally; these ids never did.""" - assert ( - legacy_preset_entity_id( - "sensor", None, "Kitchen Outlets", "power", "sensor.kitchen_outlets_power" - ) - == "sensor.kitchen_outlets_power" - ) - - -def test_a_renamed_circuit_still_refreshes_the_name_half() -> None: - """Issue #252 has to survive the preset: the id is computed, never read back.""" - assert ( - legacy_preset_entity_id( - "sensor", - LEGACY_PRESET_DEVICE_NAME, - "Beer Fridge", - "power", - "sensor.span_panel_kitchen_outlets_power", - ) - == "sensor.span_panel_beer_fridge_power" - ) - assert ( - legacy_preset_entity_id( - "sensor", None, "Beer Fridge", "power", "sensor.kitchen_outlets_power" - ) - == "sensor.beer_fridge_power" - ) - - -@pytest.mark.parametrize( - ("device_slug", "existing", "expected"), - [ - ( - LEGACY_PRESET_DEVICE_NAME, - "sensor.span_panel_kitchen_consumed_energy", - "sensor.span_panel_kitchen_consumed_energy", - ), - ( - LEGACY_PRESET_DEVICE_NAME, - "sensor.span_panel_kitchen_energy_consumed", - "sensor.span_panel_kitchen_energy_consumed", - ), - (None, "sensor.kitchen_consumed_energy", "sensor.kitchen_consumed_energy"), - (None, "sensor.kitchen_energy_consumed", "sensor.kitchen_energy_consumed"), - ], -) -def test_the_suffix_words_come_from_the_id_the_entity_already_has( - device_slug: str | None, existing: str, expected: str -) -> None: - """Both spellings this integration has shipped survive the preset, as they must.""" - assert ( - legacy_preset_entity_id("sensor", device_slug, "Kitchen", "energy_consumed", existing) - == expected - ) - - -def test_the_preset_device_name_is_the_literal_the_old_builder_fell_back_to() -> None: - """Not the panel's own name: the preset builder passed none. - - A second panel on a system is named "Span Panel 2" and its circuit ids still - began `span_panel_`, so the panel's name is the wrong thing to spell an - existing id with. - """ - assert LEGACY_PRESET_DEVICE_NAME == "Span Panel" - - -# --- Who is offered the kept shape, and who composes ------------------------- -# -# The same decision for every platform: the sensors, the breaker switch and the -# priority select all ask this one question rather than each keeping a copy of -# the answer. A copy is how three platforms drift apart. - - -def test_a_new_entity_composes_whatever_the_install_looks_like() -> None: - """There is no id to protect, so nothing is kept and Core composes.""" - assert ( - legacy_preset_for_existing( - "switch", - identifier="Kitchen Outlets", - suffix="breaker", - existing_entity_id=None, - use_device_prefix=False, - is_sub_device=True, - device_name=LEGACY_PRESET_DEVICE_NAME, - ) - is None - ) - - -def test_an_ordinary_existing_entity_composes_too() -> None: - """The device prefix is on and the entity is on the panel: composition agrees.""" - assert ( - legacy_preset_for_existing( - "switch", - identifier="Kitchen Outlets", - suffix="breaker", - existing_entity_id="switch.span_panel_kitchen_outlets_breaker", - use_device_prefix=True, - is_sub_device=False, - device_name=LEGACY_PRESET_DEVICE_NAME, - ) - is None - ) - - -def test_an_existing_entity_on_a_no_prefix_install_keeps_its_bare_id() -> None: - """`has_entity_name` prefixes the device unconditionally; these ids never did.""" - assert ( - legacy_preset_for_existing( - "select", - identifier="Kitchen Outlets", - suffix="circuit_priority", - existing_entity_id="select.kitchen_outlets_circuit_priority", - use_device_prefix=False, - is_sub_device=False, - device_name=LEGACY_PRESET_DEVICE_NAME, - ) - == "select.kitchen_outlets_circuit_priority" - ) - - -def test_an_existing_sub_device_entity_keeps_the_panel_prefix() -> None: - """Composition would name the charger; the id names the panel and goes on doing so.""" - assert ( - legacy_preset_for_existing( - "sensor", - identifier="Kitchen Outlets", - suffix="power", - existing_entity_id="sensor.span_panel_kitchen_outlets_power", - use_device_prefix=True, - is_sub_device=True, - device_name=LEGACY_PRESET_DEVICE_NAME, - ) - == "sensor.span_panel_kitchen_outlets_power" - ) - - -def test_the_kept_id_still_follows_a_circuit_rename() -> None: - """Computed from current panel data, so #252 survives the exception.""" - assert ( - legacy_preset_for_existing( - "switch", - identifier="Beer Fridge", - suffix="breaker", - existing_entity_id="switch.kitchen_outlets_breaker", - use_device_prefix=False, - is_sub_device=False, - device_name=LEGACY_PRESET_DEVICE_NAME, - ) - == "switch.beer_fridge_breaker" - ) - - -# --- A panel device this integration named, rather than the user --------------- -# -# The config flow generates the device name: the second panel on a system is -# called "Span Panel 2" without anyone typing it. Composition spells the DEVICE -# part from that name, while every existing circuit id on that panel says -# `span_panel_` -- the literal the preset builder fell back to. That difference -# is ours, so those ids are kept. A name the *user* gave the device is theirs, -# and an id following it is one they caused. - - -def test_a_generated_panel_device_name_keeps_an_existing_id() -> None: - """Nobody typed "Span Panel 2", so nobody asked for `sensor.span_panel_2_...`.""" - assert ( - legacy_preset_for_existing( - "sensor", - identifier="Kitchen Outlets", - suffix="power", - existing_entity_id="sensor.span_panel_kitchen_outlets_power", - use_device_prefix=True, - is_sub_device=False, - device_name="Span Panel 2", - ) - == "sensor.span_panel_kitchen_outlets_power" - ) - - -def test_the_first_panels_generated_name_composes_because_it_agrees() -> None: - """The first panel is named "Span Panel", which is what its ids already say.""" - assert ( - legacy_preset_for_existing( - "sensor", - identifier="Kitchen Outlets", - suffix="power", - existing_entity_id="sensor.span_panel_kitchen_outlets_power", - use_device_prefix=True, - is_sub_device=False, - device_name=LEGACY_PRESET_DEVICE_NAME, - ) - is None - ) - - -def test_a_device_not_in_the_registry_yet_composes() -> None: - """No device means a first-ever setup, and nothing existing to protect.""" - assert ( - legacy_preset_for_existing( - "sensor", - identifier="Kitchen Outlets", - suffix="power", - existing_entity_id="sensor.span_panel_kitchen_outlets_power", - use_device_prefix=True, - is_sub_device=False, - device_name=None, - ) - is None - ) - - -def test_a_generated_panel_device_name_does_not_reach_a_new_entity() -> None: - """A new entity composes on every install; the exception is for ids that exist.""" - assert ( - legacy_preset_for_existing( - "sensor", - identifier="Kitchen Outlets", - suffix="power", - existing_entity_id=None, - use_device_prefix=True, - is_sub_device=False, - device_name="Span Panel 2", - ) - is None - ) - - -def test_a_generated_name_that_slugifies_the_same_composes() -> None: - """The config entry spells it "SPAN Panel"; the id says `span_panel` either way.""" - assert ( - legacy_preset_for_existing( - "sensor", - identifier="Kitchen Outlets", - suffix="power", - existing_entity_id="sensor.span_panel_kitchen_outlets_power", - use_device_prefix=True, - is_sub_device=False, - device_name="SPAN Panel", - ) - is None - ) - +# --- Releasing the registry name an older release wrote ---------------------- from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -538,56 +267,3 @@ def test_without_a_base_core_gets_the_stock_answer() -> None: """Panel, BESS, EVSE and MID entities keep composing from their name.""" probe = _Probe() assert probe.suggested_object_id == "Display Name" - - -# --- Which panel device names this integration generated ---------------------- - -from homeassistant.helpers import device_registry as dr -from pytest_homeassistant_custom_component.common import MockConfigEntry - -from custom_components.span_panel.naming import generated_panel_device_name - -_DEVICE_SERIAL = "sp3-naming-001" - - -def _register_panel( - hass: HomeAssistant, name: str, *, name_by_user: str | None = None -) -> MockConfigEntry: - """Register a panel device under a config entry, optionally renamed by the user.""" - config_entry = MockConfigEntry(domain=DOMAIN, entry_id="entry-naming", unique_id=_DEVICE_SERIAL) - config_entry.add_to_hass(hass) - device_registry = dr.async_get(hass) - device = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, _DEVICE_SERIAL)}, - name=name, - ) - if name_by_user is not None: - device_registry.async_update_device(device.id, name_by_user=name_by_user) - return config_entry - - -async def test_the_name_the_config_flow_generated_is_reported(hass: HomeAssistant) -> None: - """The config flow invented "Span Panel 2" for the second panel; nobody typed it.""" - config_entry = _register_panel(hass, "Span Panel 2") - - assert ( - generated_panel_device_name(hass, config_entry.entry_id, _DEVICE_SERIAL) == "Span Panel 2" - ) - - -async def test_a_device_the_user_renamed_has_no_generated_name(hass: HomeAssistant) -> None: - """`name_by_user` is the user's field, and Home Assistant composes ids from it first.""" - config_entry = _register_panel(hass, "Span Panel 2", name_by_user="Garage Panel") - - assert generated_panel_device_name(hass, config_entry.entry_id, _DEVICE_SERIAL) is None - - -async def test_a_panel_with_no_device_registered_yet_has_no_name(hass: HomeAssistant) -> None: - """The first-ever setup, where entities are built before the device exists.""" - config_entry = MockConfigEntry( - domain=DOMAIN, entry_id="entry-naming-empty", unique_id=_DEVICE_SERIAL - ) - config_entry.add_to_hass(hass) - - assert generated_panel_device_name(hass, config_entry.entry_id, _DEVICE_SERIAL) is None diff --git a/tests/test_recreate_entity_ids.py b/tests/test_recreate_entity_ids.py index a2fb0957..782027cb 100644 --- a/tests/test_recreate_entity_ids.py +++ b/tests/test_recreate_entity_ids.py @@ -779,14 +779,17 @@ async def test_an_unnamed_circuit_in_friendly_mode_still_gets_a_distinct_id( assert sensor.entity_id == "sensor.span_panel_circuit_15_power" -# --- Entities composition would spell differently than the preset did --------- +# --- Entities composition spells differently than the deleted preset did ------ # -# Two shapes disagree with what Home Assistant composes, for reasons belonging to -# this integration and not to the user: a circuit sensor shown on a sub-device -# card, where the DEVICE part is the charger rather than the panel, and any -# entity on an install that turned the device prefix off, which `has_entity_name` -# prefixes anyway. R1 says Recreate must not offer either move, so these keep -# being preset -- from current panel data, so #252 still holds. +# Two shapes disagree with what the old preset builder wrote: a circuit sensor +# shown on a sub-device card, where the DEVICE part is the charger rather than +# the panel, and any entity on an install that turned the device prefix off, +# which `has_entity_name` prefixes anyway. Neither is bypassed. R1 (clarified) +# asks the base to reproduce an existing id *where Home Assistant composes it +# under the options the install was built with*; where composition yields a +# different device part, that is the user's `entity_id_parts` at work and the +# offer is legitimate. R5 still holds throughout: nothing moves until Recreate +# is pressed. EVSE_DEVICE_INFO = DeviceInfo( identifiers={(DOMAIN, f"{SERIAL}_evse_node-1")}, @@ -810,28 +813,44 @@ def _seed_power_entity(hass: HomeAssistant, entry: MockConfigEntry, object_id: s return seeded.entity_id -async def test_an_existing_sub_device_sensor_is_offered_its_own_id( - hass: HomeAssistant, entry: MockConfigEntry +async def test_an_existing_sub_device_sensor_is_offered_the_composed_id( + hass: HomeAssistant, entry: MockConfigEntry, device_and_entity_parts: None ) -> None: - """An EVSE feed circuit's sensor is spelled with the panel, and stays so.""" + """An EVSE feed circuit's sensor lives on the charger, and composes from it. + + Its id says the panel because the deleted preset builder always did. Under + the user's parts the DEVICE half is the entity's own device -- the charger -- + and the ENTITY half is the label it carries there, which on a sub-device card + is the bare description name. So Recreate offers `sensor._power`, + the shape the charger's own sensors already have. Nothing moves until the + user accepts it (R5). + """ seeded = _seed_power_entity(hass, entry, "span_panel_refrigerator_power") install = _SensorInstall(hass, entry, device_info_override=EVSE_DEVICE_INFO) sensor = await install.load(ORIGINAL_NAME) await install.load(ORIGINAL_NAME) - assert sensor.entity_id == seeded + assert sensor.entity_id == seeded # R5: two reloads, nothing moved registry = er.async_get(hass) registry_entry = registry.async_get(seeded) assert registry_entry is not None - assert registry.async_regenerate_entity_id(registry_entry) == seeded + assert registry_entry.entity_id == seeded + assert ( + registry.async_regenerate_entity_id(registry_entry) == "sensor.span_panel_ev_charger_power" + ) -async def test_an_existing_sub_device_sensor_still_follows_a_rename( - hass: HomeAssistant, entry: MockConfigEntry +async def test_a_rename_does_not_change_what_a_sub_device_sensor_composes( + hass: HomeAssistant, entry: MockConfigEntry, device_and_entity_parts: None ) -> None: - """Keeping the shape must not cost #252: the name half still tracks the panel.""" + """The circuit's name is not in this sensor's label, so #252 cannot reach its id. + + A feed sensor on the charger's card is labelled "Power", not " + Power" -- the card already says which device it belongs to. Renaming the + circuit on the panel therefore leaves both the live id and the offer alone. + """ seeded = _seed_power_entity(hass, entry, "span_panel_refrigerator_power") install = _SensorInstall(hass, entry, device_info_override=EVSE_DEVICE_INFO) @@ -841,13 +860,22 @@ async def test_an_existing_sub_device_sensor_still_follows_a_rename( registry = er.async_get(hass) registry_entry = registry.async_get(seeded) assert registry_entry is not None - assert registry.async_regenerate_entity_id(registry_entry) == RENAMED_ENTITY_ID + assert registry_entry.entity_id == seeded # R5: nothing moved + assert ( + registry.async_regenerate_entity_id(registry_entry) == "sensor.span_panel_ev_charger_power" + ) -async def test_an_existing_sensor_on_a_no_prefix_install_is_offered_its_own_id( - hass: HomeAssistant, entry: MockConfigEntry +async def test_an_existing_sensor_on_a_no_prefix_install_is_offered_the_composed_id( + hass: HomeAssistant, entry: MockConfigEntry, device_and_entity_parts: None ) -> None: - """The pre-1.0.4 shape: no device prefix at all, which composition cannot produce.""" + """The pre-1.0.4 shape: no device prefix at all, which `has_entity_name` re-adds. + + The base still reads the suffix back off the bare id, so the ENTITY half is + the entity's own `refrigerator_power`; the DEVICE half is the user's + `entity_id_parts` asking for one. The offer is theirs to accept, and the live + id does not move on its own (R5). + """ hass.config_entries.async_update_entry(entry, options=dict(LEGACY_NAMES)) seeded = _seed_power_entity(hass, entry, "refrigerator_power") @@ -855,18 +883,19 @@ async def test_an_existing_sensor_on_a_no_prefix_install_is_offered_its_own_id( sensor = await install.load(ORIGINAL_NAME) await install.load(ORIGINAL_NAME) - assert sensor.entity_id == seeded + assert sensor.entity_id == seeded # R5: two reloads, nothing moved registry = er.async_get(hass) registry_entry = registry.async_get(seeded) assert registry_entry is not None - assert registry.async_regenerate_entity_id(registry_entry) == seeded + assert registry_entry.entity_id == seeded + assert registry.async_regenerate_entity_id(registry_entry) == ORIGINAL_ENTITY_ID async def test_a_new_sensor_on_a_no_prefix_install_composes_like_every_other_entity( hass: HomeAssistant, entry: MockConfigEntry ) -> None: - """The preset is for ids that exist. Nothing new inherits the prefix-less shape.""" + """`USE_DEVICE_PREFIX` off cannot take the device out of a composed id.""" hass.config_entries.async_update_entry(entry, options=dict(LEGACY_NAMES)) sensor = await _SensorInstall(hass, entry).load(ORIGINAL_NAME) @@ -877,12 +906,11 @@ async def test_a_new_sensor_on_a_no_prefix_install_composes_like_every_other_ent async def test_an_unmapped_tab_sensor_keeps_its_prefix_on_a_no_prefix_install( hass: HomeAssistant, entry: MockConfigEntry ) -> None: - """The exception is for ids this integration spelled, and it never spelled these. + """The hidden unmapped-tab sensors were always composed, and are unaffected. - The hidden unmapped-tab sensors have always had their ids composed by Home - Assistant from the display name, so they carry a device prefix that the - naming flag never reached. Applying the prefix-less shape to them would - invent a move rather than prevent one. + Home Assistant has always built their ids from the display name, so they + carry a device prefix that the naming flag never reached. Turning the flag + off does not take it away. """ hass.config_entries.async_update_entry(entry, options=dict(LEGACY_NAMES)) @@ -923,10 +951,11 @@ async def test_an_unmapped_tab_sensor_keeps_its_prefix_on_a_no_prefix_install( # The config flow names the device: the second panel on a system becomes "Span # Panel 2" without anyone typing it (`get_unique_device_name`). Composition # spells the DEVICE part from that name, while every circuit id already on that -# panel says `span_panel_` -- the literal the preset builder fell back to on -# every install. Offering `sensor.span_panel_2_...` would be offering a move -# this integration caused, which R1 forbids. A name the *user* gave the device -# is theirs, and an id that follows it is one they asked for. +# panel says `span_panel_` -- the literal the deleted preset builder fell back to +# on every install. Recreate therefore offers `sensor.span_panel_2_...`: that is +# the device the entity is on and the parts the user chose, and the offer stands +# until they accept it. The same holds for a name the user gave the device +# themselves, which Home Assistant composes from ahead of the generated one. SECOND_PANEL_NAME = "Span Panel 2" @@ -946,25 +975,29 @@ def second_panel_entry(hass: HomeAssistant) -> MockConfigEntry: return config_entry -async def test_an_existing_sensor_on_a_generated_second_panel_is_offered_its_own_id( +async def test_an_existing_sensor_on_a_generated_second_panel_is_offered_the_composed_id( hass: HomeAssistant, second_panel_entry: MockConfigEntry, device_and_entity_parts: None ) -> None: - """The whole panel's worth of ids the generated name would otherwise move.""" + """The DEVICE half is the device's name, whoever wrote it.""" seeded = _seed_power_entity(hass, second_panel_entry, "span_panel_refrigerator_power") install = _SensorInstall(hass, second_panel_entry) await install.load(ORIGINAL_NAME) sensor = await install.load(ORIGINAL_NAME) - assert sensor.entity_id == seeded + assert sensor.entity_id == seeded # R5: two reloads, nothing moved registry = er.async_get(hass) registry_entry = registry.async_get(seeded) assert registry_entry is not None - assert registry.async_regenerate_entity_id(registry_entry) == seeded + assert registry_entry.entity_id == seeded + assert ( + registry.async_regenerate_entity_id(registry_entry) + == "sensor.span_panel_2_refrigerator_power" + ) -async def test_the_controls_on_a_generated_second_panel_are_offered_their_own_ids( +async def test_the_controls_on_a_generated_second_panel_are_offered_the_composed_ids( hass: HomeAssistant, second_panel_entry: MockConfigEntry, device_and_entity_parts: None ) -> None: """The breaker switch and the priority select carry the same ids and the same rule.""" @@ -991,22 +1024,29 @@ async def test_the_controls_on_a_generated_second_panel_are_offered_their_own_id await selects.load(ORIGINAL_NAME) select = await selects.load(ORIGINAL_NAME) + # R5: two reloads each, and neither live id moved. assert switch.entity_id == seeded_switch.entity_id assert select.entity_id == seeded_select.entity_id switch_entry = registry.async_get(seeded_switch.entity_id) assert switch_entry is not None - assert registry.async_regenerate_entity_id(switch_entry) == seeded_switch.entity_id + assert ( + registry.async_regenerate_entity_id(switch_entry) + == "switch.span_panel_2_refrigerator_breaker" + ) select_entry = registry.async_get(seeded_select.entity_id) assert select_entry is not None - assert registry.async_regenerate_entity_id(select_entry) == seeded_select.entity_id + assert ( + registry.async_regenerate_entity_id(select_entry) + == "select.span_panel_2_refrigerator_circuit_priority" + ) async def test_a_new_sensor_on_a_generated_second_panel_composes_with_that_name( hass: HomeAssistant, second_panel_entry: MockConfigEntry, device_and_entity_parts: None ) -> None: - """The exception is for ids that exist. A new entity is spelled with its own device.""" + """A new entity is spelled with its own device, as an existing one is offered.""" sensor = await _SensorInstall(hass, second_panel_entry).load(ORIGINAL_NAME) assert sensor.entity_id == "sensor.span_panel_2_refrigerator_power" @@ -1015,11 +1055,11 @@ async def test_a_new_sensor_on_a_generated_second_panel_composes_with_that_name( async def test_a_panel_device_the_user_renamed_is_offered_the_renamed_id( hass: HomeAssistant, second_panel_entry: MockConfigEntry, device_and_entity_parts: None ) -> None: - """R1 cuts both ways: a device the user named is a change the user made. + """A device the user renamed is composed from the name they gave it. - The generated name is still "Span Panel 2" underneath, so this is the case - that separates the two fields. Home Assistant composes from `name_by_user` - ahead of `name`, and so does the offer. + The generated name is still "Span Panel 2" underneath. Home Assistant + composes the DEVICE half from `name_by_user` ahead of `name`, and the offer + follows it -- the integration does not read either field itself. """ seeded = _seed_power_entity(hass, second_panel_entry, "span_panel_refrigerator_power") @@ -1047,8 +1087,7 @@ async def test_a_panel_device_the_user_renamed_is_offered_the_renamed_id( # # The breaker switch and the priority select are the other two circuit entities # whose ids this integration used to spell out in full. They take the same route -# as the sensors: one base, Core composes, and the only exception is the id shape -# composition cannot reproduce. +# as the sensors, without exception: one base, and Core composes the rest. SWITCH_ENTITY_ID = "switch.span_panel_circuit_15_breaker" SELECT_ENTITY_ID = "select.span_panel_circuit_15_circuit_priority" @@ -1139,10 +1178,10 @@ async def test_a_name_the_user_set_on_a_control_is_never_released( assert switch_entry.name == "Beverage Cooling" -async def test_an_existing_switch_on_a_no_prefix_install_is_offered_its_own_id( - hass: HomeAssistant, entry: MockConfigEntry +async def test_an_existing_switch_on_a_no_prefix_install_is_offered_the_composed_id( + hass: HomeAssistant, entry: MockConfigEntry, device_and_entity_parts: None ) -> None: - """The pre-1.0.4 shape reaches the controls too, and composition cannot produce it.""" + """The pre-1.0.4 shape reaches the controls too, and composition re-adds a device.""" hass.config_entries.async_update_entry(entry, options=dict(LEGACY_NAMES)) registry = er.async_get(hass) seeded = registry.async_get_or_create( @@ -1158,17 +1197,21 @@ async def test_an_existing_switch_on_a_no_prefix_install_is_offered_its_own_id( switch = await install.load(ORIGINAL_NAME) await install.load(ORIGINAL_NAME) - assert switch.entity_id == seeded.entity_id + assert switch.entity_id == seeded.entity_id # R5: two reloads, nothing moved registry_entry = registry.async_get(seeded.entity_id) assert registry_entry is not None - assert registry.async_regenerate_entity_id(registry_entry) == seeded.entity_id + assert registry_entry.entity_id == seeded.entity_id + assert ( + registry.async_regenerate_entity_id(registry_entry) + == "switch.span_panel_refrigerator_breaker" + ) -async def test_an_existing_select_on_a_no_prefix_install_is_offered_its_own_id( - hass: HomeAssistant, entry: MockConfigEntry +async def test_an_existing_select_on_a_no_prefix_install_is_offered_the_composed_id( + hass: HomeAssistant, entry: MockConfigEntry, device_and_entity_parts: None ) -> None: - """The same shape, kept for the same reason.""" + """The same shape, composed the same way.""" hass.config_entries.async_update_entry(entry, options=dict(LEGACY_NAMES)) registry = er.async_get(hass) seeded = registry.async_get_or_create( @@ -1184,17 +1227,21 @@ async def test_an_existing_select_on_a_no_prefix_install_is_offered_its_own_id( select = await install.load(ORIGINAL_NAME) await install.load(ORIGINAL_NAME) - assert select.entity_id == seeded.entity_id + assert select.entity_id == seeded.entity_id # R5: two reloads, nothing moved registry_entry = registry.async_get(seeded.entity_id) assert registry_entry is not None - assert registry.async_regenerate_entity_id(registry_entry) == seeded.entity_id + assert registry_entry.entity_id == seeded.entity_id + assert ( + registry.async_regenerate_entity_id(registry_entry) + == "select.span_panel_refrigerator_circuit_priority" + ) async def test_a_new_control_on_a_no_prefix_install_composes_like_every_other_entity( hass: HomeAssistant, entry: MockConfigEntry, device_and_entity_parts: None ) -> None: - """The exception is for ids that exist; nothing new inherits the prefix-less shape.""" + """`USE_DEVICE_PREFIX` off cannot take the device out of a composed id.""" hass.config_entries.async_update_entry(entry, options=dict(LEGACY_NAMES)) switch = await _SwitchInstall(hass, entry).load(ORIGINAL_NAME) From dbbdc426858af87d25e9a47d5c216845c7a09a96 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:28:17 -0700 Subject: [PATCH 163/345] docs: existing EVSE feed sensors compose like any other on Recreate The EVSE naming section still described the withdrawn exemption -- "sensors that already exist keep the IDs they have, which name the panel". Nothing keeps an id any more: a feed sensor's entity id names the charger, and an existing one is offered that id when the user presses Recreate. developer.md now states plainly that the suffix read-back does not reach sub-device sensors. It lives in circuit_object_id_base, which a sub-device sensor never calls -- with no base, Core composes the entity half from the description label. An existing feed energy sensor spelled `..._energy_consumed` is therefore offered `sensor._consumed_energy`, both halves changing, and that is by design: reading a base back for these is the bypass the clarified R1 forbids. README also carried the R5 sentence twice over two paragraphs; it now appears once, in the paragraph that describes the offer. --- README.md | 11 +++++------ developer.md | 7 +++++++ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e0b682da..ece5cd89 100644 --- a/README.md +++ b/README.md @@ -362,9 +362,9 @@ feature. A display suffix differentiates multiple chargers on the same panel: | Circuit numbers | `Main House SPAN Drive (SN-EVSE-001)` | `sensor.main_house_span_drive_sn_evse_001_charger_status` | | No suffix | `Main House SPAN Drive` | `sensor.main_house_span_drive_charger_status` | -The circuit that feeds a charger has its sensors shown on the charger's device. For sensors created from this release on, their entity IDs name the charger -alone — `sensor.main_house_span_drive_garage_power` — matching the charger's other sensors; sensors that already exist keep the IDs they have, which name the -panel. +The circuit that feeds a charger has its sensors shown on the charger's device. Their entity IDs name the charger alone — +`sensor.main_house_span_drive_garage_power` — matching the charger's other sensors. A feed sensor you already have keeps its current id until you press +**Recreate entity IDs**, which offers the charger-named one. #### EVSE Sensors (per charger) @@ -607,14 +607,13 @@ The integration provides flexible entity naming patterns, configured during init - Friendly names still sync from SPAN panel for display The integration supplies only the circuit half of the ID shown above — `Kitchen Outlets Power` or `Circuit 15 Power`. Home Assistant composes the rest from your -own entity ID settings (`entity_id_parts`, Home Assistant 2026.8 and newer), which decide whether the device name and the area are prefixed; entities you -already have keep the IDs they have until you press **Recreate entity IDs**. +own entity ID settings (`entity_id_parts`, Home Assistant 2026.8 and newer), which decide whether the device name and the area are prefixed. For an entity that already exists, the suffix wording in that half is read back from the ID the entity has rather than reworded. Circuit energy IDs have shipped in two spellings — `consumed_energy` and `energy_consumed` — so an installation is offered whichever one it is already using, and never the other; only new sensors get the `consumed_energy` word order that the panel-level sensors use. Because your entity ID settings decide the rest of the ID, an installation whose circuits were named before those settings existed — a SPAN Drive feed circuit, a second panel, or an installation predating the device prefix — may be offered -an ID with a different device half. Nothing moves until you accept the offer. +an ID with a different device half. Entities you already have keep the IDs they have until you press **Recreate entity IDs** and accept the offer. ### Energy Dip Compensation diff --git a/developer.md b/developer.md index 729034fb..e251311f 100644 --- a/developer.md +++ b/developer.md @@ -407,6 +407,13 @@ One consequence worth naming: a circuit sensor on a sub-device card supplies **n composes as `sensor._power` like the charger's own sensors, and a rename on the panel cannot reach its id. Every other circuit entity sets its base whenever `_object_id_parts` answers. +**The suffix read-back therefore does not apply to sub-device (SPAN Drive feed) sensors.** The read-back lives in `circuit_object_id_base`, and a sub-device +sensor never calls it — with no base, Core composes the entity half from the description label, which is the current one. So an existing feed energy sensor +spelled `…_energy_consumed` is offered `sensor._consumed_energy`: the device half changes (panel → charger) and so does the suffix wording +(`energy_consumed` → `consumed_energy`, the label being `Consumed Energy`). **That is by design, not a bug.** These sensors belong to the charger's card, and +the alternative — reading a base back for them — is the bypass R1 was clarified to forbid. As everywhere else, nothing moves until the user presses Recreate and +accepts (R5). + ### Releasing the registry name Circuit-numbers mode used to deliver the panel's circuit name by writing the entity registry's `name` — the _user's_ field, which Core reads ahead of everything From 348728a8c06c07929a25ce510aa961063412a3b9 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:29:41 -0700 Subject: [PATCH 164/345] fix(config-flow): never persist a pinned host the leaf does not name Bootstrapping over the panel's address got FQDN installs working again, but it also let the flow reach entry creation with a host the pinned certificate does not name -- and then write it. An entry like that cannot reach its own panel: every REST call and the MQTT connection dial the recorded host under the stored anchor, and the anchor rejects it. The address was standing in for a name that was never made to work, and nothing downstream could tell the difference. Three ways in. "Continue anyway" after a failed registration recorded the domain the panel had just refused to serve, and recorded CONF_REGISTERED_FQDN with it, because that key was derived from is_fqdn(host) rather than from whether the registration succeeded. A single-label name -- one a search domain resolves, or the hostname an add-on serves under -- is never registered by anything, because is_fqdn is false for it, so it went straight from authentication to entry creation. And a reauth of an unpinned entry whose host is an FQDN acquired the anchor over the address and stored it beside the host, unverified. _bootstrap_host now carries one invariant: it is set only while self.host is a host the pinned leaf does not name. Every route that persists a host reads it and refuses -- ca_leaf_mismatch on install and reauth, on the reconfigure form for a simple host change -- and registration, the one thing that can make the panel start serving a name, clears it when the panel does. "Continue anyway" keeps the panel rather than the name: it records the address that got that far, claims no FQDN registration, and both screens now say so. The domain can be tried again from Reconfigure once whatever blocked it is fixed. The chooser also stops preferring the address over the host it was given. The host is tried first, always, and only a host the leaf does not name falls back to the resolved address. A panel that already names the host in its certificate -- an add-on hostname, an mDNS name, an FQDN a previous install registered -- keeps being reached by it, where preferring the address would have swapped a working host for one the certificate may not name at all. Also: getaddrinfo raises UnicodeEncodeError, not OSError, for a label over 63 characters, because IDNA encoding fails before any lookup is attempted. Both the resolver and the leaf check now catch UnicodeError, so a host typed into a form cannot end the flow in a traceback. --- custom_components/span_panel/config_flow.py | 223 +++++++++--- .../span_panel/config_flow_validation.py | 11 +- custom_components/span_panel/strings.json | 4 +- .../span_panel/translations/en.json | 4 +- .../span_panel/translations/es.json | 4 +- .../span_panel/translations/fr.json | 4 +- .../span_panel/translations/ja.json | 4 +- .../span_panel/translations/pt.json | 4 +- tests/test_config_flow_validation.py | 48 +++ tests/test_v2_config_flow_tls.py | 323 +++++++++++++++++- 10 files changed, 556 insertions(+), 73 deletions(-) diff --git a/custom_components/span_panel/config_flow.py b/custom_components/span_panel/config_flow.py index 9bf6c80a..e02f7504 100644 --- a/custom_components/span_panel/config_flow.py +++ b/custom_components/span_panel/config_flow.py @@ -192,13 +192,23 @@ def __init__(self) -> None: # entry on reauth and reconfigure, where a pin may already exist; left # None on initial setup, which has nothing pinned yet by construction. self._rest_transport: PanelRestTransport | None = None - # The address this flow dials, which is not always the host it is - # setting up. A panel's certificate names the addresses it already knows - # itself by; an FQDN joins that list only once `register_fqdn` has run, - # and everything before that has to be dialled by an address the leaf - # already names. None means "dial `self.host`" -- an IP install, or a - # name that would not resolve. + # The address this flow dials while `self.host` is a name the panel's + # certificate does not yet name -- an FQDN before `register_fqdn` has + # run, which is every FQDN install up to the moment it authenticates. + # + # It carries an invariant the rest of the flow leans on: **it is set + # only while `self.host` is unverified under the pin.** It is None when + # the host is an IP, when the leaf already names the host, and again as + # soon as registration makes the panel serve the name. So anything about + # to persist `self.host` can read it as "this host is one the anchor + # would reject", and refuse rather than write an entry that cannot + # connect to its own panel. self._bootstrap_host: str | None = None + # Whether the panel accepted the FQDN and now serves it. Recorded rather + # than re-derived from `is_fqdn(host)` at entry creation, because that + # question is "does this look like a domain name" and the one that + # matters is "did the registration this flow performed succeed". + self._fqdn_registered: bool = False # Energy dip compensation default for fresh installs self._enable_dip_compensation: bool = True # FQDN registration task (async_show_progress) @@ -223,24 +233,49 @@ def _require_transport(self) -> PanelRestTransport: raise ConfigFlowError("Reached a panel REST call with no transport pinned") return self._rest_transport - async def _async_adopt_bootstrap_host(self) -> None: - """Dial the panel by address while its certificate still names only that. - - A name that does not resolve leaves the host as it was, which is what - shipped: the leaf check then runs against the name and reports what it - finds. Better than refusing to continue over a lookup the user may not - need -- an mDNS name on a host without an mDNS resolver still reaches - the panel through whatever answered the plaintext probe. + async def _async_choose_bootstrap_host(self, ca_pem: str, tls_port: int) -> bool: + """Settle which address the panel is dialled by, and whether it can be at all. + + The host the user gave is tried first, always. A panel's certificate + names the addresses it already knows itself by -- its IP, its mDNS name, + an FQDN registered by a previous install, the hostname an add-on serves + under -- and where the leaf already names the host there is nothing to + work around and no reason to prefer anything else. Only when that fails + is the resolved address tried, which is the case that matters: an FQDN + joins the SAN when `register_fqdn` runs, and that is after + authentication, so a fresh FQDN install has to be bootstrapped over the + address until the panel has been told the name. + + Returns False when neither answers under the published CA, which is a + leaf mismatch and is fatal to the step that called it. A name that will + not resolve simply has one candidate, so it fails exactly as it did + before this address fallback existed. """ self._bootstrap_host = None host = self.host - if not host or is_ip_literal(host): - return - resolved = await async_resolve_host(self.hass, host) - if resolved is None: - _LOGGER.debug("Could not resolve %s to an address; dialling the name", host) - return - self._bootstrap_host = resolved + if not host: + return False + + candidates = [host] + if not is_ip_literal(host): + resolved = await async_resolve_host(self.hass, host) + if resolved is None: + _LOGGER.debug("Could not resolve %s to an address; only the name is tried", host) + elif resolved != host: + candidates.append(resolved) + + for candidate in candidates: + if await async_leaf_chains_to_ca(candidate, tls_port, ca_pem): + self._bootstrap_host = None if candidate == host else candidate + if self._bootstrap_host is not None: + _LOGGER.debug( + "The certificate this panel serves does not name %s; reaching it " + "at %s until it does", + host, + candidate, + ) + return True + return False def ensure_flow_is_set_up(self) -> None: """Ensure the flow is set up.""" @@ -654,14 +689,35 @@ def _store_v2_auth_result(self, result: V2AuthResponse) -> None: self._v2_panel_serial = result.serial_number async def _async_finalize_v2_auth(self) -> ConfigFlowResult: - """Route to appropriate next step after successful v2 auth.""" + """Route to appropriate next step after successful v2 auth. + + Registration is the one thing that can make the panel name a host it + does not name yet, so it is the only route allowed to persist a host + that is currently unverified. Every other route here writes `self.host` + into an entry pinned to this anchor, and writing one the anchor rejects + produces an entry that cannot reach its own panel — a single-label name + that a search domain resolves, or an FQDN this flow bootstrapped over + the address for and is not going on to register. + """ + # If host is an FQDN, register it with the panel for TLS cert SAN inclusion + installing = self.trigger_flow_type != TriggerFlowType.UPDATE_ENTRY + if installing and self.host and is_fqdn(self.host): + return await self.async_step_register_fqdn() + + if self._bootstrap_host is not None: + _LOGGER.warning( + "Panel %s does not name %s in the certificate it serves, and nothing in " + "this flow will ask it to; refusing to pin an entry to a host its own " + "certificate authority rejects", + self._bootstrap_host, + self.host, + ) + return self._async_show_ca_error("ca_leaf_mismatch") + if self.trigger_flow_type == TriggerFlowType.UPDATE_ENTRY: if "entry_id" not in self.context: raise ValueError("Entry ID is missing from context") return self._update_v2_entry(self.context["entry_id"]) - # If host is an FQDN, register it with the panel for TLS cert SAN inclusion - if self.host and is_fqdn(self.host): - return await self.async_step_register_fqdn() return await self.async_step_choose_entity_naming_initial() async def async_step_register_fqdn( @@ -730,13 +786,17 @@ async def _async_register_fqdn_and_wait(self) -> None: self.host, attempt + 1, ) - await self._async_verify_fqdn_over_pin(transport) + await self._async_verify_host_over_pin(transport) + # The panel serves the name now, so the address it was reached + # by while it did not is no longer standing in for anything. + self._bootstrap_host = None + self._fqdn_registered = True return raise ConfigFlowError(f"Timed out waiting for TLS certificate to include FQDN {self.host}") - async def _async_verify_fqdn_over_pin(self, transport: PanelRestTransport) -> None: - """Confirm the pinned anchor validates the panel at the FQDN and REST port. + async def _async_verify_host_over_pin(self, transport: PanelRestTransport) -> None: + """Confirm the pinned anchor validates the panel at `self.host` and the REST port. The readiness poll watches the broker port, which is the port the MQTT client uses. The entry's REST calls go somewhere else -- the HTTPS port @@ -753,15 +813,24 @@ async def _async_verify_fqdn_over_pin(self, transport: PanelRestTransport) -> No return if not await async_leaf_chains_to_ca(self.host or "", transport.port, transport.ca_pem): raise ConfigFlowError( - f"Panel registered FQDN {self.host} but does not serve it on port " + f"Panel registered {self.host} but does not serve it on port " f"{transport.port} under the pinned certificate authority" ) async def async_step_fqdn_failed( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle FQDN registration failure — user may continue without it.""" + """Handle FQDN registration failure — user may continue over the panel's address. + + Continuing keeps the panel, not the name. The certificate does not name + the domain, because getting it named is exactly what failed, so an entry + recording the domain would be pinned to a host its own anchor rejects + and would fail on its first connect. It is set up over the address the + certificate does name instead, and the domain can be tried again from + Reconfigure once whatever blocked the registration is fixed. + """ if user_input is not None: + self._fall_back_to_the_bootstrap_address() return await self.async_step_choose_entity_naming_initial() return self.async_show_form( step_id="fqdn_failed", @@ -769,6 +838,28 @@ async def async_step_fqdn_failed( errors={"base": "fqdn_registration_failed"}, ) + def _fall_back_to_the_bootstrap_address(self) -> None: + """Adopt the address the panel was reached by as the host to record. + + Only ever called where registration has already failed. `_bootstrap_host` + is set exactly when `self.host` is a name the pinned leaf does not name, + so this is the point where an unusable name is traded for the address + that got this far — and the invariant is restored, because the host + being recorded is now one the anchor accepts. + """ + if self._bootstrap_host is None: + return + _LOGGER.warning( + "Could not get panel %s to serve %s, so the entry is set up over %s instead; " + "the certificate the panel serves does not name %s", + self._bootstrap_host, + self.host, + self._bootstrap_host, + self.host, + ) + self.host = self._bootstrap_host + self._bootstrap_host = None + async def async_step_panel_ca_start( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -822,37 +913,26 @@ async def async_step_panel_ca( if not self.host: return self.async_abort(reason="host_not_set") - # Both calls below go to the panel's address rather than to the name the - # user typed. The panel's leaf names the addresses it already knows - # itself by, and an FQDN is not one of them until `register_fqdn` runs -- - # which is after authentication, which is after this. Checking a - # not-yet-registered FQDN against that leaf fails on the hostname, and - # this step is deliberately a dead end, so it would make an FQDN install - # impossible rather than merely unverified. - await self._async_adopt_bootstrap_host() - try: - ca_pem = await async_fetch_panel_ca( - self.hass, self._rest_host, http_port=self._http_port - ) + ca_pem = await async_fetch_panel_ca(self.hass, self.host, http_port=self._http_port) except ( SpanPanelAPIError, SpanPanelConnectionError, SpanPanelTimeoutError, ) as err: - _LOGGER.warning("Could not fetch the CA from panel %s: %s", self._rest_host, err) + _LOGGER.warning("Could not fetch the CA from panel %s: %s", self.host, err) return self._async_show_ca_error("ca_unavailable") # A CA that cannot validate the certificate the panel actually serves is # not the panel's CA. Checked before the fingerprint is shown, so the # user is never asked to accept a value that already failed. Hostname - # verification stays on -- the address it is checked against changed, - # the check did not. - if not await async_leaf_chains_to_ca(self._rest_host, self._https_port, ca_pem): + # verification stays on throughout -- what the chooser varies is the + # address the panel is dialled by, never whether the name is checked. + if not await self._async_choose_bootstrap_host(ca_pem, self._https_port): _LOGGER.warning( "The certificate served by %s on port %s does not chain to the CA the " "panel published; refusing to pin it", - self._rest_host, + self.host, self._https_port, ) return self._async_show_ca_error("ca_leaf_mismatch") @@ -972,7 +1052,11 @@ def create_new_entry( # nonetheless exchanged over TLS — a state nothing else expects. raise ConfigFlowError("Reached entry creation with no pinned panel CA") entry_data[CONF_PANEL_CA_PEM] = self._panel_ca_pem - if is_fqdn(host): + if self._fqdn_registered: + # Recorded from what the registration did, not from what the host + # looks like. `is_fqdn(host)` was true on the path where + # registration had just failed and the user chose to continue, so + # the entry claimed a name the panel had never accepted. entry_data[CONF_REGISTERED_FQDN] = host return self.async_create_entry( @@ -1121,12 +1205,21 @@ async def async_step_reconfigure( # Validate the host is reachable and is a v2 panel http_port = int(reconfigure_entry.data.get(CONF_HTTP_PORT, 80)) self._rest_transport = panel_rest_transport(self.hass, reconfigure_entry.data) - # The new host may be an FQDN the panel has never heard of, and this + # The new host may be a name the panel has never heard of, and this # entry is pinned: probing it by name would fail hostname verification - # and report the panel unreachable. Probe the address, register the - # name, verify the name afterwards. + # and report the panel unreachable. Settle the address first, so the + # probe reaches the panel and `_bootstrap_host` records whether the new + # name is one the pinned certificate already covers. self.host = host - await self._async_adopt_bootstrap_host() + pinned_pem = self._rest_transport.ca_pem + if pinned_pem is not None and not await self._async_choose_bootstrap_host( + pinned_pem, self._rest_transport.port + ): + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), + errors={"base": "ca_leaf_mismatch"}, + ) try: detection = await detect_api_version( self._rest_host, @@ -1171,7 +1264,22 @@ async def async_step_reconfigure( self._v2_broker_port = int(reconfigure_entry.data.get(CONF_EBUS_BROKER_PORT, 8883)) return await self.async_step_reconfigure_register_fqdn() - # New host is not an FQDN — simple update + # New host is not an FQDN — simple update. Nothing on this branch will + # ask the panel to start naming it, so a host the pinned certificate + # does not cover is refused here rather than written into the entry. + if self._bootstrap_host is not None: + _LOGGER.warning( + "Panel %s does not name %s in the certificate it serves; refusing to " + "point a pinned entry at a host its own certificate authority rejects", + self._bootstrap_host, + host, + ) + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), + errors={"base": "ca_leaf_mismatch"}, + ) + data_updates: dict[str, Any] = {CONF_HOST: host} old_fqdn = str(reconfigure_entry.data.get(CONF_REGISTERED_FQDN, "")) if old_fqdn: @@ -1242,9 +1350,16 @@ async def async_step_reconfigure_fqdn_done( async def async_step_reconfigure_fqdn_failed( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle FQDN registration failure during reconfigure.""" + """Handle FQDN registration failure during reconfigure. + + Same trade as on install: the panel is kept and the name is not. The + entry's existing `CONF_REGISTERED_FQDN` is deliberately left alone — + whatever the panel had registered before this attempt, it still has, and + that is what the next reconfigure needs in order to clean it up. + """ if user_input is not None: # User chose to continue anyway — update host without FQDN registration + self._fall_back_to_the_bootstrap_address() reconfigure_entry = self._get_reconfigure_entry() return self.async_update_reload_and_abort( reconfigure_entry, diff --git a/custom_components/span_panel/config_flow_validation.py b/custom_components/span_panel/config_flow_validation.py index 35a1d278..e7624c4b 100644 --- a/custom_components/span_panel/config_flow_validation.py +++ b/custom_components/span_panel/config_flow_validation.py @@ -174,7 +174,11 @@ async def async_resolve_host(hass: HomeAssistant, host: str) -> str | None: def _resolve() -> str | None: try: infos = socket.getaddrinfo(host, None, family=socket.AF_INET, type=socket.SOCK_STREAM) - except OSError: + except (OSError, UnicodeError): + # `UnicodeError` rather than only `OSError`: a label over 63 + # characters fails IDNA encoding before any lookup is attempted, and + # `UnicodeEncodeError` is a `ValueError`. A host the user typed must + # not be able to end a config flow in a traceback. return None for *_unused, sockaddr in infos: return str(sockaddr[0]) @@ -232,7 +236,10 @@ def _check() -> bool: ctx.wrap_socket(sock, server_hostname=host), ): return True - except (ssl.SSLCertVerificationError, ssl.SSLError, OSError, TimeoutError): + except (ssl.SSLCertVerificationError, ssl.SSLError, OSError, TimeoutError, UnicodeError): + # `UnicodeError` for the same reason as in `async_resolve_host`: the + # connect resolves the name, and an over-long label raises out of + # IDNA encoding rather than as a lookup failure. return False return await loop.run_in_executor(None, _check) diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index 2899018b..17aabf2c 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -71,7 +71,7 @@ "title": "Connect to the Span Panel" }, "fqdn_failed": { - "description": "The domain name could not be registered with the panel, or the TLS certificate was not updated in time. You may continue, but the MQTT connection may fail if the panel's certificate does not include your domain name.", + "description": "The domain name could not be registered with the panel, or the TLS certificate was not updated in time. You may continue, and the panel will be set up at the address its certificate does name — the domain name is not recorded, because the panel would reject it. Try the domain name again from Reconfigure once whatever blocked the registration is fixed.", "title": "Domain Registration" }, "reauth_confirm": { @@ -96,7 +96,7 @@ "title": "Reconfiguration Complete" }, "reconfigure_fqdn_failed": { - "description": "The domain name could not be registered with the panel, or the TLS certificate was not updated in time. You may continue, but the MQTT connection may fail if the panel's certificate does not include your domain name.", + "description": "The domain name could not be registered with the panel, or the TLS certificate was not updated in time. You may continue, and the panel will be set up at the address its certificate does name — the domain name is not recorded, because the panel would reject it. Try the domain name again from Reconfigure once whatever blocked the registration is fixed.", "title": "Domain Registration" }, "reconfigure_register_fqdn": { diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index 2899018b..17aabf2c 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -71,7 +71,7 @@ "title": "Connect to the Span Panel" }, "fqdn_failed": { - "description": "The domain name could not be registered with the panel, or the TLS certificate was not updated in time. You may continue, but the MQTT connection may fail if the panel's certificate does not include your domain name.", + "description": "The domain name could not be registered with the panel, or the TLS certificate was not updated in time. You may continue, and the panel will be set up at the address its certificate does name — the domain name is not recorded, because the panel would reject it. Try the domain name again from Reconfigure once whatever blocked the registration is fixed.", "title": "Domain Registration" }, "reauth_confirm": { @@ -96,7 +96,7 @@ "title": "Reconfiguration Complete" }, "reconfigure_fqdn_failed": { - "description": "The domain name could not be registered with the panel, or the TLS certificate was not updated in time. You may continue, but the MQTT connection may fail if the panel's certificate does not include your domain name.", + "description": "The domain name could not be registered with the panel, or the TLS certificate was not updated in time. You may continue, and the panel will be set up at the address its certificate does name — the domain name is not recorded, because the panel would reject it. Try the domain name again from Reconfigure once whatever blocked the registration is fixed.", "title": "Domain Registration" }, "reconfigure_register_fqdn": { diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index af48f590..3a4a39a9 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -71,7 +71,7 @@ "title": "Establecer conexión al Span Panel" }, "fqdn_failed": { - "description": "No se pudo registrar el nombre de dominio en el panel, o el certificado TLS no se actualizó a tiempo. Puede continuar, pero la conexión MQTT puede fallar si el certificado del panel no incluye su nombre de dominio.", + "description": "No se pudo registrar el nombre de dominio en el panel, o el certificado TLS no se actualizó a tiempo. Puede continuar, y el panel se configurará en la dirección que sí figura en su certificado; el nombre de dominio no se guarda, porque el panel lo rechazaría. Vuelva a intentarlo con el nombre de dominio desde Reconfigurar cuando se resuelva lo que impidió el registro.", "title": "Registro de Dominio" }, "reauth_confirm": { @@ -96,7 +96,7 @@ "title": "Reconfiguración Completada" }, "reconfigure_fqdn_failed": { - "description": "No se pudo registrar el nombre de dominio en el panel, o el certificado TLS no se actualizó a tiempo. Puede continuar, pero la conexión MQTT puede fallar si el certificado del panel no incluye su nombre de dominio.", + "description": "No se pudo registrar el nombre de dominio en el panel, o el certificado TLS no se actualizó a tiempo. Puede continuar, y el panel se configurará en la dirección que sí figura en su certificado; el nombre de dominio no se guarda, porque el panel lo rechazaría. Vuelva a intentarlo con el nombre de dominio desde Reconfigurar cuando se resuelva lo que impidió el registro.", "title": "Registro de Dominio" }, "reconfigure_register_fqdn": { diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index f92db59f..e1538116 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -71,7 +71,7 @@ "title": "Connectez-vous au Span Panel" }, "fqdn_failed": { - "description": "Le nom de domaine n'a pas pu être enregistré auprès du panneau, ou le certificat TLS n'a pas été mis à jour à temps. Vous pouvez continuer, mais la connexion MQTT peut échouer si le certificat du panneau n'inclut pas votre nom de domaine.", + "description": "Le nom de domaine n'a pas pu être enregistré auprès du panneau, ou le certificat TLS n'a pas été mis à jour à temps. Vous pouvez continuer : le panneau sera configuré à l'adresse que son certificat désigne réellement, et le nom de domaine n'est pas enregistré, car le panneau le rejetterait. Réessayez avec le nom de domaine depuis Reconfigurer une fois le problème résolu.", "title": "Enregistrement du domaine" }, "reauth_confirm": { @@ -96,7 +96,7 @@ "title": "Reconfiguration terminée" }, "reconfigure_fqdn_failed": { - "description": "Le nom de domaine n'a pas pu être enregistré auprès du panneau, ou le certificat TLS n'a pas été mis à jour à temps. Vous pouvez continuer, mais la connexion MQTT peut échouer si le certificat du panneau n'inclut pas votre nom de domaine.", + "description": "Le nom de domaine n'a pas pu être enregistré auprès du panneau, ou le certificat TLS n'a pas été mis à jour à temps. Vous pouvez continuer : le panneau sera configuré à l'adresse que son certificat désigne réellement, et le nom de domaine n'est pas enregistré, car le panneau le rejetterait. Réessayez avec le nom de domaine depuis Reconfigurer une fois le problème résolu.", "title": "Enregistrement du domaine" }, "reconfigure_register_fqdn": { diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index 0dd04625..bcb12f86 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -71,7 +71,7 @@ "title": "スパンパネルに接続" }, "fqdn_failed": { - "description": "パネルにドメイン名を登録できなかったか、TLS証明書が時間内に更新されませんでした。続行できますが、パネルの証明書にドメイン名が含まれていない場合、MQTT接続が失敗する可能性があります。", + "description": "パネルにドメイン名を登録できなかったか、TLS証明書が時間内に更新されませんでした。続行できますが、パネルは証明書に記載されているアドレスで設定され、ドメイン名は保存されません。パネルがそのドメイン名を拒否するためです。登録を妨げていた問題を解決してから、「再設定」でドメイン名をもう一度お試しください。", "title": "ドメイン登録" }, "reauth_confirm": { @@ -96,7 +96,7 @@ "title": "再設定完了" }, "reconfigure_fqdn_failed": { - "description": "パネルにドメイン名を登録できなかったか、TLS証明書が時間内に更新されませんでした。続行できますが、パネルの証明書にドメイン名が含まれていない場合、MQTT接続が失敗する可能性があります。", + "description": "パネルにドメイン名を登録できなかったか、TLS証明書が時間内に更新されませんでした。続行できますが、パネルは証明書に記載されているアドレスで設定され、ドメイン名は保存されません。パネルがそのドメイン名を拒否するためです。登録を妨げていた問題を解決してから、「再設定」でドメイン名をもう一度お試しください。", "title": "ドメイン登録" }, "reconfigure_register_fqdn": { diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index 26128095..5b1b14b5 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -71,7 +71,7 @@ "title": "Ligar ao Painel Span" }, "fqdn_failed": { - "description": "Não foi possível registar o nome de domínio no painel ou o certificado TLS não foi atualizado a tempo. Pode continuar, mas a conexão MQTT pode falhar se o certificado do painel não incluir o seu nome de domínio.", + "description": "Não foi possível registar o nome de domínio no painel ou o certificado TLS não foi atualizado a tempo. Pode continuar, e o painel será configurado no endereço que o certificado indica; o nome de domínio não é guardado, porque o painel iria rejeitá-lo. Tente novamente o nome de domínio em Reconfigurar assim que resolver o que impediu o registo.", "title": "Registo de Domínio" }, "reauth_confirm": { @@ -96,7 +96,7 @@ "title": "Reconfiguração Concluída" }, "reconfigure_fqdn_failed": { - "description": "Não foi possível registar o nome de domínio no painel ou o certificado TLS não foi atualizado a tempo. Pode continuar, mas a conexão MQTT pode falhar se o certificado do painel não incluir o seu nome de domínio.", + "description": "Não foi possível registar o nome de domínio no painel ou o certificado TLS não foi atualizado a tempo. Pode continuar, e o painel será configurado no endereço que o certificado indica; o nome de domínio não é guardado, porque o painel iria rejeitá-lo. Tente novamente o nome de domínio em Reconfigurar assim que resolver o que impediu o registo.", "title": "Registo de Domínio" }, "reconfigure_register_fqdn": { diff --git a/tests/test_config_flow_validation.py b/tests/test_config_flow_validation.py index 90ec20c0..6cf94cfe 100644 --- a/tests/test_config_flow_validation.py +++ b/tests/test_config_flow_validation.py @@ -472,3 +472,51 @@ async def _run(func): return_value=[], ): assert await async_resolve_host(hass, "panel.example.com") is None + + +@pytest.mark.asyncio +async def test_resolve_host_survives_a_name_that_cannot_be_idna_encoded() -> None: + """A label over 63 characters fails encoding, not lookup, and raises a ValueError. + + `socket.getaddrinfo` raises `UnicodeEncodeError` — a `UnicodeError`, which is + a `ValueError` and not an `OSError` — before it attempts any lookup. A host + the user typed into a form must not be able to end the flow in a traceback. + """ + + async def _run(func): + return func() + + hass = MagicMock() + hass.async_add_executor_job = _run + overlong = "a" * 70 + ".example.com" + with patch( + "custom_components.span_panel.config_flow_validation.socket.getaddrinfo", + side_effect=UnicodeEncodeError("idna", overlong, 0, 70, "label too long"), + ): + assert await async_resolve_host(hass, overlong) is None + + +@pytest.mark.asyncio +async def test_leaf_check_survives_a_name_that_cannot_be_idna_encoded() -> None: + """The leaf check resolves the name too, and fails the same way.""" + + class FakeLoop: + async def run_in_executor(self, _executor, func): + return func() + + overlong = "a" * 70 + ".example.com" + with ( + patch( + "custom_components.span_panel.config_flow_validation.asyncio.get_running_loop", + return_value=FakeLoop(), + ), + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + patch( + "custom_components.span_panel.config_flow_validation.socket.create_connection", + side_effect=UnicodeEncodeError("idna", overlong, 0, 70, "label too long"), + ), + ): + assert await async_leaf_chains_to_ca(overlong, 443, "pem") is False diff --git a/tests/test_v2_config_flow_tls.py b/tests/test_v2_config_flow_tls.py index d86c8a4b..cc79fcab 100644 --- a/tests/test_v2_config_flow_tls.py +++ b/tests/test_v2_config_flow_tls.py @@ -14,6 +14,7 @@ from __future__ import annotations from collections.abc import Callable, Iterator +import contextlib import datetime import http.server import ipaddress @@ -49,10 +50,16 @@ ) PANEL_FQDN = "panel.home.lan" +# A single-label name, as a search domain or an add-on's container hostname +# supplies. `is_fqdn` is False for it, so nothing in the flow ever registers it. +PANEL_SHORTNAME = "spanpanel" PANEL_LOOPBACK = "127.0.0.1" PANEL_SERIAL = "sp3-synthetic-0001" PASSPHRASE = "hunter2-synthetic" +#: Names the `resolves_to_loopback` fixture answers with the test listener. +LOOPBACK_NAMES = frozenset({PANEL_FQDN, PANEL_SHORTNAME}) + # ---------- certificate generation ---------- @@ -207,16 +214,16 @@ def panel(tmp_path: Any) -> Iterator[_Panel]: @pytest.fixture def resolves_to_loopback() -> Iterator[None]: - """Point `panel.home.lan` at the loopback listener, and nothing else. + """Point the panel's names at the loopback listener, and nothing else. - The name arrives as `bytes` from anyio's resolver and as `str` from + A name arrives as `bytes` from anyio's resolver and as `str` from `socket.create_connection`, so both spellings are matched. """ real_getaddrinfo = socket.getaddrinfo def _fake(host: Any, port: Any, *args: Any, **kwargs: Any) -> Any: name = host.decode("ascii") if isinstance(host, bytes) else host - if name == PANEL_FQDN: + if name in LOOPBACK_NAMES: return real_getaddrinfo(PANEL_LOOPBACK, port, *args, **kwargs) return real_getaddrinfo(host, port, *args, **kwargs) @@ -256,6 +263,56 @@ def _auth_response(mqtts_port: int) -> V2AuthResponse: ) +@contextlib.contextmanager +def _plaintext_bootstrap_stubbed(panel: _Panel) -> Iterator[None]: + """Stub everything that is not TLS: the probe, the reachability check, the CA fetch. + + The CA fetch is plain HTTP and the probe answers over it, so neither is what + these tests are about. Everything downstream of them — context building, + hostname verification, the handshake — runs for real against the listener. + """ + with ( + patch( + "custom_components.span_panel.config_flow.validate_host", + new=AsyncMock(return_value=True), + ), + patch( + "custom_components.span_panel.config_flow.detect_api_version", + new=AsyncMock( + return_value=DetectionResult( + api_version="v2", + status_info=V2StatusInfo( + serial_number=PANEL_SERIAL, firmware_version="2.0.0-synthetic" + ), + ) + ), + ), + patch( + "custom_components.span_panel.config_flow.async_fetch_panel_ca", + new=AsyncMock(return_value=panel.ca_pem), + ), + ): + yield + + +async def _reach_the_auth_menu(hass: HomeAssistant, host: str, panel: _Panel) -> Any: + """Submit the host form and the TLS port, landing wherever the CA step lands. + + A non-default HTTP port is what makes the flow ask where TLS lives, which is + how the listener's ephemeral port reaches it. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: host, CONF_HTTP_PORT: 8080} + ) + assert result["step_id"] == "panel_https_port", result + return await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HTTPS_PORT: panel.port} + ) + + def _registration_adds_the_fqdn(panel: _Panel) -> Callable[..., Any]: """Return a `register_fqdn` stand-in with the side effect the real panel has.""" @@ -342,8 +399,10 @@ async def test_fresh_fqdn_install_pins_and_completes( assert result["data"][CONF_REGISTERED_FQDN] == PANEL_FQDN assert result["data"][CONF_PANEL_CA_PEM] == panel.ca_pem # The bootstrap ran over the address the leaf names, and the FQDN is what - # was registered — not the other way round. - assert fetch_ca.await_args.args[1] == PANEL_LOOPBACK + # was registered — not the other way round. The CA fetch is the exception + # and stays on the name: it is plain HTTP, so which address answers it makes + # no difference to what may be trusted. + assert fetch_ca.await_args.args[1] == PANEL_FQDN assert validate.await_args.args[0] == PANEL_LOOPBACK assert register.await_args.args[0] == PANEL_LOOPBACK assert register.await_args.args[2] == PANEL_FQDN @@ -459,6 +518,177 @@ async def test_registration_that_never_lands_does_not_report_success( download.assert_not_awaited() +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_continuing_past_a_failed_registration_uses_the_panels_address( + hass: HomeAssistant, panel: _Panel +) -> None: + """"Continue anyway" must not pin the entry to the name that just failed. + + Registration is what would have made the panel serve the domain, so once it + has failed the domain is a host the pinned anchor rejects — and an entry + recording it could not reach its own panel. The address the panel was + reached by is recorded instead, and no FQDN registration is claimed. + """ + with ( + _plaintext_bootstrap_stubbed(panel), + patch( + "custom_components.span_panel.config_flow.validate_v2_passphrase", + new=AsyncMock(return_value=_auth_response(panel.port)), + ), + # The panel says yes and does nothing: its leaf still names only the IP. + patch("custom_components.span_panel.config_flow.register_fqdn", new=AsyncMock()), + patch("custom_components.span_panel.config_flow.asyncio.sleep", new=AsyncMock()), + ): + result = await _reach_the_auth_menu(hass, PANEL_FQDN, panel) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "auth_passphrase"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOP_PASSPHRASE: PASSPHRASE} + ) + result = await _finish_progress(hass, result) + assert result["step_id"] == "fqdn_failed", result + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["step_id"] == "choose_entity_naming_initial", result + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.CREATE_ENTRY, result + assert result["data"][CONF_HOST] == PANEL_LOOPBACK + assert CONF_REGISTERED_FQDN not in result["data"] + assert result["data"][CONF_PANEL_CA_PEM] == panel.ca_pem + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_a_name_nothing_will_register_is_refused_rather_than_pinned( + hass: HomeAssistant, panel: _Panel +) -> None: + """A single-label host is never registered, so it has to be named already. + + `is_fqdn` is False for it, so `_async_finalize_v2_auth` goes straight to + entry creation with no registration in between. Bootstrapping over the + resolved address gets the flow that far; persisting the name would leave an + entry pinned to a host its own anchor rejects, so it fails closed instead. + """ + with ( + _plaintext_bootstrap_stubbed(panel), + patch( + "custom_components.span_panel.config_flow.validate_v2_passphrase", + new=AsyncMock(return_value=_auth_response(panel.port)), + ), + ): + result = await _reach_the_auth_menu(hass, PANEL_SHORTNAME, panel) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "auth_passphrase"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOP_PASSPHRASE: PASSPHRASE} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "panel_ca" + assert result["errors"] == {"base": "ca_leaf_mismatch"} + assert not hass.config_entries.async_entries(DOMAIN) + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_a_name_the_leaf_already_covers_is_kept_and_never_swapped_for_an_address( + hass: HomeAssistant, panel: _Panel +) -> None: + """The host the user gave is tried first, so a panel that names it keeps it. + + This is the add-on and search-domain case: the panel serves under a + hostname, its certificate says so, and there is nothing to work around. + Preferring the resolved address here would have swapped a working host for + one the certificate may not name at all. + """ + panel.present( + [ + x509.DNSName(PANEL_SHORTNAME), + x509.IPAddress(ipaddress.ip_address(PANEL_LOOPBACK)), + ] + ) + with ( + _plaintext_bootstrap_stubbed(panel), + patch( + "custom_components.span_panel.config_flow.validate_v2_passphrase", + new=AsyncMock(return_value=_auth_response(panel.port)), + ) as validate, + ): + result = await _reach_the_auth_menu(hass, PANEL_SHORTNAME, panel) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "auth_passphrase"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOP_PASSPHRASE: PASSPHRASE} + ) + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.CREATE_ENTRY, result + assert result["data"][CONF_HOST] == PANEL_SHORTNAME + assert result["data"][CONF_PANEL_CA_PEM] == panel.ca_pem + # Registration ran over the name too, not over an address substituted for it. + assert validate.await_args.args[0] == PANEL_SHORTNAME + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_reauth_of_an_unpinned_fqdn_entry_will_not_pin_an_unnamed_host( + hass: HomeAssistant, panel: _Panel +) -> None: + """Reauth acquires the anchor, and the entry's own host has to survive it. + + An entry recorded against an FQDN the panel does not name reaches reauth + unpinned, gets bootstrapped over the address like any other, and would then + have had the anchor written beside a host that anchor rejects. Nothing here + registers the name, so the reauth fails closed and the entry is left as it + was. + """ + entry = MockConfigEntry( + version=7, + minor_version=1, + domain=DOMAIN, + title="SPAN Panel", + data={ + CONF_HOST: PANEL_FQDN, + CONF_ACCESS_TOKEN: "synthetic-token", + CONF_API_VERSION: "v2", + CONF_HTTPS_PORT: panel.port, + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id=PANEL_SERIAL, + ) + entry.add_to_hass(hass) + + with ( + _plaintext_bootstrap_stubbed(panel), + patch( + "custom_components.span_panel.config_flow.validate_v2_passphrase", + new=AsyncMock(return_value=_auth_response(panel.port)), + ), + ): + result = await entry.start_reauth_flow(hass) + assert result["step_id"] == "reauth_confirm", result + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "auth_passphrase"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOP_PASSPHRASE: PASSPHRASE} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "panel_ca" + assert result["errors"] == {"base": "ca_leaf_mismatch"} + # Nothing was written: no anchor beside a host it rejects, no new token. + assert CONF_PANEL_CA_PEM not in entry.data + assert entry.data[CONF_HOST] == PANEL_FQDN + assert entry.data[CONF_ACCESS_TOKEN] == "synthetic-token" + + # ---------- Case B: a pinned IP entry reconfigured to an FQDN ---------- @@ -507,3 +737,86 @@ async def test_reconfigure_pinned_ip_entry_to_fqdn( assert entry.data[CONF_PANEL_CA_PEM] == panel.ca_pem assert register.await_args.args[0] == PANEL_LOOPBACK assert register.await_args.args[2] == PANEL_FQDN + + +def _pinned_entry(hass: HomeAssistant, panel: _Panel) -> MockConfigEntry: + """Add an entry already pinned to this panel and reaching it by address.""" + entry = MockConfigEntry( + version=7, + minor_version=1, + domain=DOMAIN, + title="SPAN Panel", + data={ + CONF_HOST: PANEL_LOOPBACK, + CONF_ACCESS_TOKEN: "synthetic-token", + CONF_API_VERSION: "v2", + CONF_EBUS_BROKER_PORT: panel.port, + CONF_HTTPS_PORT: panel.port, + CONF_PANEL_CA_PEM: panel.ca_pem, + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id=PANEL_SERIAL, + ) + entry.add_to_hass(hass) + return entry + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_continuing_past_a_failed_reconfigure_keeps_the_panels_address( + hass: HomeAssistant, panel: _Panel +) -> None: + """The reconfigure "continue anyway" makes the same trade as the install one. + + An entry that was reaching its panel by address stays that way. Writing the + domain would move a working entry onto a host the anchor it is already + pinned to rejects, which is a worse outcome than the move simply not + happening. + """ + entry = _pinned_entry(hass, panel) + + with ( + # The panel says yes and does nothing: its leaf still names only the IP. + patch("custom_components.span_panel.config_flow.register_fqdn", new=AsyncMock()), + patch("custom_components.span_panel.config_flow.asyncio.sleep", new=AsyncMock()), + ): + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: PANEL_FQDN} + ) + result = await _finish_progress(hass, result) + assert result["step_id"] == "reconfigure_fqdn_failed", result + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT, result + assert result["reason"] == "reconfigure_successful" + assert entry.data[CONF_HOST] == PANEL_LOOPBACK + assert CONF_REGISTERED_FQDN not in entry.data + assert entry.data[CONF_PANEL_CA_PEM] == panel.ca_pem + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_reconfigure_refuses_a_name_the_pinned_certificate_does_not_cover( + hass: HomeAssistant, panel: _Panel +) -> None: + """A move to a single-label name is never registered, so it must already be named. + + The address bootstrap is what lets the probe reach the panel at all; it is + not permission to store a host the pinned certificate rejects, and nothing + on this branch will ask the panel to start serving it. + """ + entry = _pinned_entry(hass, panel) + + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: PANEL_SHORTNAME} + ) + + assert result["type"] == FlowResultType.FORM, result + assert result["step_id"] == "reconfigure" + assert result["errors"] == {"base": "ca_leaf_mismatch"} + assert entry.data[CONF_HOST] == PANEL_LOOPBACK From e1eab502d2d21b1d43c9187a38206cd5c1e24fb1 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:50:43 -0700 Subject: [PATCH 165/345] fix(control): the control lock keeps its state across a reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ControlLock` was a plain object rebuilt by every `async_setup_entry` and `SpanPanelControlLockSwitch` was not a `RestoreEntity`, so every options save, credential rotation, circuit rename and restart silently disarmed the lock and showed the switch off with no notice. It also arrived disarmed on a fresh install, contradicting the option text's promise that the feature keeps the lock armed until someone disarms it. The lock is now constructed armed whenever the feature is on, which also closes the window between the interceptor being installed and the platforms being forwarded, and the switch restores a previous run's disarm into it — into the lock, never alongside it, so the interceptor and the entity cannot disagree. A pending auto-relock is persisted as a wall-clock instant and resumes with the time it had left; restarting the window would let a reload every few minutes hold the lock open indefinitely. The relock also writes state when it falls due instead of only settling on the next read, which nothing performed on a schedule. Also corrects the comment describing a zero timeout, which had the semantics backwards. --- CHANGELOG.md | 2 + custom_components/span_panel/__init__.py | 7 +- custom_components/span_panel/control_gate.py | 115 +++++++- custom_components/span_panel/switch.py | 107 ++++++- tests/test_control_gate.py | 95 ++++++ tests/test_control_lock_state.py | 290 +++++++++++++++++++ tests/test_setup_entry.py | 45 ++- 7 files changed, 647 insertions(+), 14 deletions(-) create mode 100644 tests/test_control_lock_state.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f7fa97a..40f20dcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -164,6 +164,8 @@ working entirely in 2027.8. identical from here. - **Four new options decide who may operate the panel** — administrators only, no commands without a logged-in user, an admin-only control lock, and a relay debounce — every one defaulting to what your panel already does. +- **The control lock arrives armed and stays where you left it**, so turning the option on locks control straight away and a restart or a settings save no + longer quietly unlocks the panel; a pending auto-relock comes back with the time it had left rather than starting its countdown over. - **Every control command is now recorded** in the logbook, on the event bus as `span_panel_control_command`, and at `INFO`, attributed to the automation that issued it rather than to nobody. - **Controls now report what happened to them**, so a command that was refused or never handed to the broker no longer looks like one the panel confirmed. diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py index dbd98c99..ae3e4d8d 100644 --- a/custom_components/span_panel/__init__.py +++ b/custom_components/span_panel/__init__.py @@ -361,7 +361,12 @@ def _on_fatal_transport_error(error: SpanPanelError) -> None: # so no control command can reach the panel ungated — including one # issued during the first refresh. control_policy = ControlPolicy.from_options(entry.options) - control_lock = ControlLock() + # Armed for as long as the feature is on, from the moment the + # interceptor has a lock to consult rather than from the moment the + # switch is added — the first refresh and the whole platform + # forward happen in between. `SpanPanelControlLockSwitch` reopens it + # if that is what the previous run left behind. + control_lock = ControlLock(armed=control_policy.lock_enabled) client.set_control_interceptor(ControlGate(hass, entry, control_policy, control_lock)) entry.async_on_unload(lambda: client.set_control_interceptor(None)) diff --git a/custom_components/span_panel/control_gate.py b/custom_components/span_panel/control_gate.py index 654f9de0..28006a39 100644 --- a/custom_components/span_panel/control_gate.py +++ b/custom_components/span_panel/control_gate.py @@ -27,13 +27,17 @@ from collections.abc import Mapping from contextvars import ContextVar, Token from dataclasses import dataclass +from datetime import datetime from enum import StrEnum import logging import time +from typing import Any, Self from homeassistant.config_entries import ConfigEntry from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.restore_state import ExtraStoredData +from homeassistant.util import dt as dt_util from span_panel_api import ControlCommand, PublishOutcome, PublishState from .const import DOMAIN, EVENT_CONTROL_COMMAND @@ -172,9 +176,11 @@ def from_options(cls, options: Mapping[str, object]) -> ControlPolicy: mode = ControlMode.ALL_USERS raw_timeout = options.get(CONTROL_LOCK_TIMEOUT) - # Absent means the lock feature is off; 0 means armed until manually - # disarmed. The two are different states and a plain float cannot hold - # both, which is why this is nullable. + # Absent (or negative) means the lock feature is off and there is no + # switch; 0 means the feature is on with no auto-relock, so a disarm + # lasts until someone arms it again. "Off" and "on with no countdown" + # are different states and a plain float cannot hold both, which is why + # this is nullable. timeout: float | None = None if isinstance(raw_timeout, int | float) and not isinstance(raw_timeout, bool): timeout = float(raw_timeout) if raw_timeout >= 0 else None @@ -204,24 +210,47 @@ class ControlLock: require admin; making it less safe should. """ - def __init__(self) -> None: - """Start disarmed.""" - self._armed = False + def __init__(self, *, armed: bool = False) -> None: + """Start armed or disarmed, before anything is known about the last run. + + `async_setup_entry` passes the feature's own switch: a lock that exists + as a user-facing thing is a lock that is on. That is what the option + text promises, and it is what fails closed over the window between the + interceptor being installed and the entity being added — the first + refresh and the whole platform forward happen in between. + + `SpanPanelControlLockSwitch.async_added_to_hass` then relaxes it to + whatever the previous run left behind. Relaxing is the only direction a + restore ever moves this, which is why the constructor can decide the + rest by itself. + """ + self._armed = armed self._relock_at: float | None = None @property def armed(self) -> bool: """Whether control is currently locked out. - Auto-relock is evaluated on read rather than on a timer, so it cannot be - missed by a restart or a suspended event loop, and so the lock never - stays open longer than it was asked to. + Auto-relock is settled on read as well as on the entity's timer, so it + cannot be missed by a suspended event loop, and so the lock never stays + open longer than it was asked to even if nothing writes the state. """ if self._relock_at is not None and time.monotonic() >= self._relock_at: self._armed = True self._relock_at = None return self._armed + @property + def relock_in_seconds(self) -> float | None: + """Seconds left of the auto-relock window, or None when none is running. + + None for an armed lock and for a disarm that was asked to last until + someone arms it again — neither has a deadline to report. + """ + if self.armed or self._relock_at is None: + return None + return max(0.0, self._relock_at - time.monotonic()) + def arm(self) -> None: """Lock control out.""" self._armed = True @@ -232,6 +261,74 @@ def disarm(self, timeout_minutes: float) -> None: self._armed = False self._relock_at = None if timeout_minutes <= 0 else time.monotonic() + timeout_minutes * 60 + def resume_disarm(self, relock_in_seconds: float | None) -> None: + """Re-enter the disarmed state the previous run left this entry in. + + The entity calls this and nothing else: a lock for an enabled entry is + constructed armed, so restoring a run that ended armed means leaving it + alone, and the only thing the previous run has to tell this object is + that it had been *opened*. + + `relock_in_seconds` is what was left of that run's auto-relock window, + recomputed by the entity from a wall-clock deadline because `_relock_at` + is a monotonic reading and a monotonic reading does not survive a + restart. None means there was no window. Zero or less means it closed + while Home Assistant was down, and the lock stays armed rather than + granting a grace period nobody asked for. + + Resuming the remainder rather than restarting the window is the point. + A reload happens on every options save, every credential rotation and + every circuit rename; a restarted window would let routine maintenance + hold the lock open indefinitely. + """ + if relock_in_seconds is not None and relock_in_seconds <= 0: + self.arm() + return + self._armed = False + self._relock_at = ( + None if relock_in_seconds is None else time.monotonic() + relock_in_seconds + ) + + +@dataclass(frozen=True, slots=True) +class ControlLockExtraStoredData(ExtraStoredData): + """A pending auto-relock, written down in terms that outlive the process. + + Lives beside `ControlLock` rather than in the switch module because it is + that class's deadline in serialized form: the two have to change together, + and a reader of one should not have to go looking for the other. + + The switch's own `on`/`off` state is not duplicated here — Home Assistant + already restores it — so this holds exactly the one thing the state cannot + express, and holds it as an absolute instant because `_relock_at` is a + monotonic reading with no meaning after a restart. + """ + + relock_at: datetime | None + + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the extra data.""" + return {"relock_at": None if self.relock_at is None else self.relock_at.isoformat()} + + @classmethod + def from_dict(cls, restored: dict[str, Any]) -> Self | None: + """Read back what `as_dict` wrote, or None when it cannot be read. + + None means the record holds something that is not a deadline — edited + by hand, or written in a shape this release does not know. An *absent* + key is not that case: a record written before this release, and one + written by a run with nothing pending, both say "no deadline", and both + read back as one. The caller decides what to do with either; failing + setup over a switch's restore data would not be a proportionate answer. + """ + raw = restored.get("relock_at") + if raw is None: + return cls(relock_at=None) + if not isinstance(raw, str): + return None + parsed = dt_util.parse_datetime(raw) + return None if parsed is None else cls(relock_at=parsed) + class ControlGate: """One veto-and-observe point for every control command on one entry.""" diff --git a/custom_components/span_panel/switch.py b/custom_components/span_panel/switch.py index db459f19..d0cf05df 100644 --- a/custom_components/span_panel/switch.py +++ b/custom_components/span_panel/switch.py @@ -1,15 +1,19 @@ """Control switches.""" from collections.abc import Mapping +from datetime import UTC, datetime, timedelta import logging from typing import Any, ClassVar from homeassistant.components.switch import SwitchEntity -from homeassistant.core import HomeAssistant +from homeassistant.const import STATE_OFF +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.restore_state import RestoreEntity from span_panel_api import SpanCircuitSnapshot, SpanPanelSnapshot from span_panel_api.exceptions import SpanPanelServerError @@ -18,6 +22,7 @@ from .const import DOMAIN, USE_CIRCUIT_NUMBERS, CircuitRelayState from .control_gate import ( ControlLock, + ControlLockExtraStoredData, ControlMode, ControlPolicy, outcome_failure_reason, @@ -347,7 +352,7 @@ def _construct_switch_unique_id( ) -class SpanPanelControlLockSwitch(SpanPanelEntity, SwitchEntity): +class SpanPanelControlLockSwitch(SpanPanelEntity, SwitchEntity, RestoreEntity): """Arms and disarms this panel's control lock. Not a control over the panel — it never publishes anything — so it is @@ -359,6 +364,15 @@ class SpanPanelControlLockSwitch(SpanPanelEntity, SwitchEntity): admin. Disarming requires an administrator, and is refused for a contextless caller regardless of `allow_contextless_control`: an automation that can unlock the panel is not a lock. + + **It is also the lock's memory.** `ControlLock` is a plain object rebuilt by + every `async_setup_entry`, and an entry is reloaded by every options save, + every credential rotation, every circuit rename that asks for one and every + restart. The entity is the only participant Home Assistant persists, so it + restores the armed state on the way in and writes the pending auto-relock + deadline on the way out. The lock stays the single source of truth: the + restored answer is written *into* it, never kept alongside it, so the + interceptor and the switch cannot disagree. """ _attr_entity_category: EntityCategory | None = EntityCategory.CONFIG @@ -376,9 +390,96 @@ def __init__( snapshot: SpanPanelSnapshot = coordinator.data self._lock = lock self._policy = policy + self._relock_timer: CALLBACK_TYPE | None = None self._attr_device_info = snapshot_to_device_info(snapshot, device_name) self._attr_unique_id = f"span_{snapshot.serial_number}_control_lock" + async def async_added_to_hass(self) -> None: + """Reopen the lock if that is where the last run left it. + + Core writes this entity's state immediately after this returns, so the + restored answer reaches the UI without an explicit write here. + """ + await super().async_added_to_hass() + self.async_on_remove(self._async_cancel_relock_timer) + + # The lock arrives armed (`ControlLock.__init__`), so only a previous + # run that had opened it has anything to say. No previous run at all is + # the case the option text describes — "0 keeps the lock armed until + # someone disarms it" is only true if enabling the feature arms it. A + # restored `unknown`/`unavailable` says the last run never got far + # enough to be trusted, and armed is the safe reading of a lock whose + # state is in doubt. + last_state = await self.async_get_last_state() + if last_state is not None and last_state.state == STATE_OFF: + self._lock.resume_disarm(await self._async_restored_relock_seconds()) + self._async_schedule_relock_write() + + async def _async_restored_relock_seconds(self) -> float | None: + """Return what is left of the previous run's auto-relock window. + + None means no window is pending. A value at or below zero means the + window closed while Home Assistant was down; `ControlLock.resume_disarm` + turns that into an armed lock. + """ + extra = await self.async_get_last_extra_data() + stored = None if extra is None else ControlLockExtraStoredData.from_dict(extra.as_dict()) + if stored is None or stored.relock_at is None: + # Either the previous run had no deadline to record, or its record + # is unreadable — a store written before this release, say. Falling + # back to this entry's configured window rather than to "no + # deadline" is what keeps an upgrade from leaving a disarmed lock + # open forever, and it is also the right answer when the user has + # changed the option since: the window they configured is the one + # they asked for. + timeout = self._policy.lock_timeout_minutes + return None if timeout is None or timeout <= 0 else timeout * 60 + return (stored.relock_at - datetime.now(tz=UTC)).total_seconds() + + @property + def extra_restore_state_data(self) -> ControlLockExtraStoredData: + """Record the pending auto-relock as an instant rather than a duration.""" + remaining = self._lock.relock_in_seconds + return ControlLockExtraStoredData( + relock_at=( + None if remaining is None else datetime.now(tz=UTC) + timedelta(seconds=remaining) + ) + ) + + @callback + def _async_cancel_relock_timer(self) -> None: + """Drop any scheduled write, so a reload leaves no timer behind.""" + if self._relock_timer is not None: + self._relock_timer() + self._relock_timer = None + + @callback + def _async_schedule_relock_write(self) -> None: + """Show the auto-relock at the moment it falls due, not at the next read. + + `ControlLock.armed` settles a lapsed deadline lazily, which keeps the + gate correct but leaves the switch reading off until something happens + to look at it — and nothing looks at a switch on a schedule. The panel + was locked and the UI said it was not, for as long as that took. + """ + self._async_cancel_relock_timer() + remaining = self._lock.relock_in_seconds + if remaining is None: + return + self._relock_timer = async_call_later(self.hass, remaining, self._async_relock_reached) + + @callback + def _async_relock_reached(self, _now: datetime) -> None: + """Arm at the deadline and say so. + + Arms explicitly rather than leaving it to the read behind `is_on`: this + timer is the mechanism, and the lazy settle in `ControlLock.armed` is + the backstop for an event loop that was suspended past the deadline. + """ + self._relock_timer = None + self._lock.arm() + self.async_write_ha_state() + @property def is_on(self) -> bool: """Armed reads as on, matching an alarm panel rather than a door.""" @@ -387,6 +488,7 @@ def is_on(self) -> bool: async def async_turn_on(self, **kwargs: Any) -> None: """Arm the lock. Anyone may do this, including an automation.""" self._lock.arm() + self._async_cancel_relock_timer() self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: @@ -410,6 +512,7 @@ async def async_turn_off(self, **kwargs: Any) -> None: timeout = self._policy.lock_timeout_minutes self._lock.disarm(timeout if timeout is not None else 0) + self._async_schedule_relock_write() self.async_write_ha_state() diff --git a/tests/test_control_gate.py b/tests/test_control_gate.py index 952c6c57..5146f83d 100644 --- a/tests/test_control_gate.py +++ b/tests/test_control_gate.py @@ -8,10 +8,12 @@ from __future__ import annotations +from datetime import timedelta from unittest.mock import AsyncMock, MagicMock, patch from homeassistant.core import Context, Event, HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.util import dt as dt_util import pytest from pytest_homeassistant_custom_component.common import MockConfigEntry, MockUser from span_panel_api import ControlCommand, PublishOutcome, PublishState @@ -230,6 +232,99 @@ def test_auto_relock_fires_once_the_window_passes() -> None: assert lock.armed is True +# ---------- restoring the state a previous run left behind ---------- + + +def test_restoring_a_live_window_resumes_the_remainder() -> None: + """Ten minutes left before the reload is ten minutes left after it. + + The entity converts the deadline to and from wall-clock time; this object + only ever sees what is left of it. + """ + lock = ControlLock() + + with patch("custom_components.span_panel.control_gate.time.monotonic", return_value=0.0): + lock.resume_disarm(600.0) + assert lock.armed is False + + with patch("custom_components.span_panel.control_gate.time.monotonic", return_value=599.0): + assert lock.armed is False + + with patch("custom_components.span_panel.control_gate.time.monotonic", return_value=601.0): + assert lock.armed is True + + +def test_restoring_a_window_that_already_closed_arms_the_lock() -> None: + """The lock was asked to re-arm at a moment that has already passed.""" + lock = ControlLock() + + lock.resume_disarm(-30.0) + + assert lock.armed is True + + +def test_restoring_a_disarm_with_no_window_leaves_it_open() -> None: + """`None` is "there was no countdown", not "the countdown has run out".""" + lock = ControlLock() + lock.arm() + + lock.resume_disarm(None) + + assert lock.armed is False + assert lock.relock_in_seconds is None + + +def test_an_armed_lock_reports_no_pending_window() -> None: + """There is nothing to count down to while control is already locked out.""" + lock = ControlLock() + lock.disarm(5) + assert lock.relock_in_seconds == pytest.approx(300, abs=1) + + lock.arm() + + assert lock.relock_in_seconds is None + + +def test_the_stored_deadline_survives_a_round_trip() -> None: + """What `as_dict` writes is what `from_dict` reads, to the microsecond.""" + from custom_components.span_panel.control_gate import ControlLockExtraStoredData + + deadline = dt_util.utcnow() + timedelta(minutes=7) + + restored = ControlLockExtraStoredData.from_dict( + ControlLockExtraStoredData(relock_at=deadline).as_dict() + ) + + assert restored is not None + assert restored.relock_at == deadline + + +@pytest.mark.parametrize("stored", [{"relock_at": 1234}, {"relock_at": "not-a-time"}]) +def test_an_unreadable_stored_deadline_is_reported_rather_than_guessed( + stored: dict[str, object], +) -> None: + """A hand-edited record says nothing usable, and says so. + + Reported as None so the entity can fall back to its configured window; + inventing a deadline here would hide the difference from the one caller + that has to tell them apart. + """ + from custom_components.span_panel.control_gate import ControlLockExtraStoredData + + assert ControlLockExtraStoredData.from_dict(stored) is None + + +@pytest.mark.parametrize("stored", [{}, {"relock_at": None}]) +def test_an_absent_stored_deadline_reads_as_no_countdown(stored: dict[str, object]) -> None: + """A run that ended with nothing pending recorded exactly that.""" + from custom_components.span_panel.control_gate import ControlLockExtraStoredData + + restored = ControlLockExtraStoredData.from_dict(stored) + + assert restored is not None + assert restored.relock_at is None + + # ---------- relay debounce ---------- diff --git a/tests/test_control_lock_state.py b/tests/test_control_lock_state.py new file mode 100644 index 00000000..f83abebb --- /dev/null +++ b/tests/test_control_lock_state.py @@ -0,0 +1,290 @@ +"""The control lock's state across a reload, and what it is before there is one. + +Separate from `test_control_gate.py` because these go through a real config +entry setup and a real `async_reload`. The gate tests construct the switch +against a mock coordinator and never add it to Home Assistant, so they cannot +see the entity lifecycle at all -- and the lifecycle is the whole subject here: +`ControlLock` is rebuilt by every `async_setup_entry`, and the only thing that +knows what the last run left behind is the entity. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +import contextlib +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock, patch + +from homeassistant.const import CONF_HOST, STATE_OFF, STATE_ON, Platform +from homeassistant.core import Context, HomeAssistant, State +from homeassistant.util import dt as dt_util +import pytest +from pytest_homeassistant_custom_component.common import ( + MockConfigEntry, + MockUser, + mock_restore_cache_with_extra_data, +) + +from custom_components.span_panel.const import ( + CONF_API_VERSION, + CONF_EBUS_BROKER_HOST, + CONF_EBUS_BROKER_PASSWORD, + CONF_EBUS_BROKER_PORT, + CONF_EBUS_BROKER_USERNAME, + CONF_HTTP_PORT, + DOMAIN, +) +from custom_components.span_panel.control_gate import ControlLock +from custom_components.span_panel.migrations import CURRENT_CONFIG_VERSION +from custom_components.span_panel.options import CONTROL_LOCK_TIMEOUT + +from .common import async_fire_time_changed +from .factories import SpanPanelSnapshotFactory + +LOCK_ENTITY = "switch.span_panel_control_lock" +SERIAL = "sp3-lock-001" + + +def _entry(timeout: float) -> MockConfigEntry: + """Build a v2 entry with the control-lock feature configured to `timeout`.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_VERSION: "v2", + CONF_HOST: "192.168.1.50", + CONF_EBUS_BROKER_HOST: "span-panel.local", + CONF_EBUS_BROKER_USERNAME: "mqtt-user", + CONF_EBUS_BROKER_PASSWORD: "hunter2-synthetic", + CONF_EBUS_BROKER_PORT: 8883, + CONF_HTTP_PORT: 80, + }, + options={CONTROL_LOCK_TIMEOUT: timeout}, + entry_id="entry-control-lock", + title="SPAN Panel", + unique_id=SERIAL, + version=CURRENT_CONFIG_VERSION, + ) + + +@contextlib.asynccontextmanager +async def _panel(hass: HomeAssistant, *, timeout: float) -> AsyncIterator[MockConfigEntry]: + """Set the entry up for real, with only the switch platform forwarded. + + The transport and the coordinator are stubs -- neither has anything to do + with the lock -- but the entry setup, the platform forward, the entity + registry and the unload path are all the real ones, because a reload is + exactly those four things. + """ + entry = _entry(timeout) + entry.add_to_hass(hass) + + snapshot = SpanPanelSnapshotFactory.create(serial_number=SERIAL, circuits={}) + client = MagicMock() + client.connect = AsyncMock() + client.close = AsyncMock() + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.current_monitor = None + coordinator.async_config_entry_first_refresh = AsyncMock() + coordinator.async_setup_streaming = AsyncMock() + coordinator.async_shutdown = AsyncMock() + + with ( + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + patch("custom_components.span_panel.SpanPanelCoordinator", return_value=coordinator), + patch( + "custom_components.span_panel.ensure_device_registered", + AsyncMock(return_value="panel-device-id"), + ), + patch("custom_components.span_panel.PLATFORMS", [Platform.SWITCH]), + ): + assert await hass.config_entries.async_setup(entry.entry_id) is True + await hass.async_block_till_done() + try: + yield entry + finally: + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +def _lock(entry: MockConfigEntry) -> ControlLock: + """Return the live lock object this entry's gate consults.""" + lock = entry.runtime_data.control_lock + assert isinstance(lock, ControlLock) + return lock + + +async def _reload(hass: HomeAssistant, entry: MockConfigEntry) -> None: + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + +async def _arm(hass: HomeAssistant) -> None: + """Arm through the service, so the entity writes its own state.""" + await hass.services.async_call( + "switch", "turn_on", {"entity_id": LOCK_ENTITY}, blocking=True + ) + + +async def _disarm(hass: HomeAssistant) -> None: + """Disarm through the service as an administrator, the only caller allowed to.""" + admin = MockUser(is_owner=True).add_to_hass(hass) + await hass.services.async_call( + "switch", + "turn_off", + {"entity_id": LOCK_ENTITY}, + blocking=True, + context=Context(user_id=admin.id), + ) + + +# ---------- across a reload ---------- + + +@pytest.mark.asyncio +async def test_an_armed_lock_is_still_armed_after_a_reload(hass: HomeAssistant) -> None: + """A reload is not consent to operate the panel. + + Every options save, every `rotate_credentials`, every circuit rename that + asks for a reload and every Home Assistant restart runs `async_setup_entry` + again. A lock that came back disarmed would have the household's second + factor removed by routine maintenance, with the switch showing off and no + notice that anything had changed. + """ + async with _panel(hass, timeout=0) as entry: + await _arm(hass) + assert _lock(entry).armed is True + + await _reload(hass, entry) + + assert _lock(entry).armed is True + state = hass.states.get(LOCK_ENTITY) + assert state is not None + assert state.state == STATE_ON + + +@pytest.mark.asyncio +async def test_a_disarmed_lock_is_still_disarmed_after_a_reload(hass: HomeAssistant) -> None: + """The restore has to carry both answers, not only the safe one. + + Re-arming on every reload would be its own defect: an options save would + lock a household out of its own circuits mid-task, and the alarm-panel + metaphor only works if the disarmed state is as durable as the armed one. + """ + async with _panel(hass, timeout=0) as entry: + await _disarm(hass) + assert _lock(entry).armed is False + + await _reload(hass, entry) + + assert _lock(entry).armed is False + state = hass.states.get(LOCK_ENTITY) + assert state is not None + assert state.state == STATE_OFF + + +@pytest.mark.asyncio +async def test_a_pending_auto_relock_survives_a_reload(hass: HomeAssistant) -> None: + """A reload must not drop the countdown, nor turn it into an immediate re-arm.""" + async with _panel(hass, timeout=30) as entry: + await _disarm(hass) + assert _lock(entry).relock_in_seconds == pytest.approx(1800, abs=5) + + await _reload(hass, entry) + + assert _lock(entry).armed is False + assert _lock(entry).relock_in_seconds == pytest.approx(1800, abs=5) + + +# ---------- the state before there is one to restore ---------- + + +@pytest.mark.asyncio +async def test_the_lock_starts_armed_when_the_feature_is_first_enabled( + hass: HomeAssistant, +) -> None: + """The option text promises an armed lock, and nothing else can deliver it. + + "0 keeps the lock armed until someone disarms it" is only true if turning + the feature on arms it. A lock that arrives disarmed protects nothing until + the user notices it is off and arms it by hand -- and the user turned the + option on precisely because they expected not to have to. + """ + async with _panel(hass, timeout=0) as entry: + assert _lock(entry).armed is True + state = hass.states.get(LOCK_ENTITY) + assert state is not None + assert state.state == STATE_ON + + +# ---------- what a restart does to a countdown ---------- + + +@pytest.mark.asyncio +async def test_a_restart_resumes_the_remaining_window_rather_than_a_fresh_one( + hass: HomeAssistant, +) -> None: + """Ten minutes left before the restart is ten minutes left after it. + + `_relock_at` is a monotonic reading and cannot outlive the process, so the + entity stores the deadline as a wall-clock instant and the restore converts + it back. Restarting the full window instead would let an entry that saves + its options every few minutes hold the lock open indefinitely, which is the + opposite of what `ControlLock.armed` documents. + """ + deadline = dt_util.utcnow() + timedelta(minutes=10) + mock_restore_cache_with_extra_data( + hass, + ((State(LOCK_ENTITY, STATE_OFF), {"relock_at": deadline.isoformat()}),), + ) + + async with _panel(hass, timeout=30) as entry: + assert _lock(entry).armed is False + assert _lock(entry).relock_in_seconds == pytest.approx(600, abs=5) + + +@pytest.mark.asyncio +async def test_a_window_that_closed_while_home_assistant_was_down_comes_back_armed( + hass: HomeAssistant, +) -> None: + """The lock was asked to re-arm at a moment that has already passed.""" + deadline = dt_util.utcnow() - timedelta(minutes=1) + mock_restore_cache_with_extra_data( + hass, + ((State(LOCK_ENTITY, STATE_OFF), {"relock_at": deadline.isoformat()}),), + ) + + async with _panel(hass, timeout=30) as entry: + assert _lock(entry).armed is True + state = hass.states.get(LOCK_ENTITY) + assert state is not None + assert state.state == STATE_ON + + +# ---------- the auto-relock is visible when it happens ---------- + + +@pytest.mark.asyncio +async def test_the_auto_relock_writes_the_state_when_it_falls_due( + hass: HomeAssistant, +) -> None: + """Otherwise the switch shows off until something else happens to read it. + + Auto-relock was evaluated only on read, and nothing reads a switch on a + schedule. The panel was locked and the UI said it was not -- for hours, if + nothing touched the entity. + """ + async with _panel(hass, timeout=1) as entry: + await _disarm(hass) + state = hass.states.get(LOCK_ENTITY) + assert state is not None + assert state.state == STATE_OFF + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=90)) + await hass.async_block_till_done() + + assert _lock(entry).armed is True + state = hass.states.get(LOCK_ENTITY) + assert state is not None + assert state.state == STATE_ON diff --git a/tests/test_setup_entry.py b/tests/test_setup_entry.py index eb529b85..d672c655 100644 --- a/tests/test_setup_entry.py +++ b/tests/test_setup_entry.py @@ -11,12 +11,11 @@ ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from span_panel_api.exceptions import SpanPanelServerError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.httpx_client import get_async_client import pytest from pytest_homeassistant_custom_component.common import MockConfigEntry -from span_panel_api.exceptions import SpanPanelAuthError +from span_panel_api.exceptions import SpanPanelAuthError, SpanPanelServerError from custom_components.span_panel import SpanPanelRuntimeData, async_setup_entry from custom_components.span_panel.const import ( @@ -28,6 +27,7 @@ CONF_HTTP_PORT, DOMAIN, ) +from custom_components.span_panel.options import CONTROL_LOCK_TIMEOUT from .factories import SpanPanelSnapshotFactory @@ -484,3 +484,44 @@ async def _announce(_hass, _entry) -> None: assert await async_setup_entry(hass, entry) is True assert order == ["forward", "sync", "announce"] + + +async def test_an_enabled_control_lock_is_armed_before_the_platforms_are_forwarded( + hass: HomeAssistant, +) -> None: + """The gate consults the lock long before the switch exists to restore it. + + `set_control_interceptor` happens above the first refresh; the platform + forward is the last thing setup does. A lock built disarmed would leave + that whole window -- a first refresh, a streaming start, a device + registration -- with the feature nominally on and nothing refusing. + + Asserted here rather than through the entity because the entity is exactly + what has not been created yet at the moment this matters. + """ + entry = _create_v2_entry() + entry.add_to_hass(hass) + hass.config_entries.async_update_entry(entry, options={CONTROL_LOCK_TIMEOUT: 0}) + client = MagicMock() + client.connect = AsyncMock() + coordinator = MagicMock() + coordinator.async_config_entry_first_refresh = AsyncMock() + coordinator.async_setup_streaming = AsyncMock() + coordinator.data = SpanPanelSnapshotFactory.create(serial_number="sp3-setup-001") + + with ( + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + patch( + "custom_components.span_panel.SpanPanelCoordinator", return_value=coordinator + ), + patch( + "custom_components.span_panel.ensure_device_registered", + AsyncMock(return_value="panel-device-id"), + ), + patch.object(hass.config_entries, "async_forward_entry_setups", AsyncMock()), + patch.object(hass.config_entries, "async_update_entry"), + ): + assert await async_setup_entry(hass, entry) is True + + assert entry.runtime_data.control_lock.armed is True From 616ccf00992314fc9c56a776eadccb488e619b7c Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:00:32 -0700 Subject: [PATCH 166/345] fix(control): one outcome path for every control, adopted ones included The try/except/outcome block that turns a library refusal and a FAILED `PublishOutcome` into a translated `HomeAssistantError` had been hand-copied into four platforms and left out of the fifth. An adopted device's switch, select or number awaited `set_adopted_property` and discarded what came back: a FAILED outcome -- "broker not connected; refused rather than queued", or a closed transport -- returned normally and refreshed, so a command that was never handed over looked exactly like one the panel confirmed, and a refusal propagated as a raw `SpanPanelServerError` with no translation key. `SpanPanelEntity._async_control` now states both facts once, wrapping the caller binding that `_async_guarded_control` already owned, and every control in the integration goes through it: relay, priority, charge-current limit, GFE override, and any settable property on an adopted device. The curated keys and placeholders are unchanged, so no existing message moves; adopted controls get two of their own, naming the property and the device in the wire vocabulary that is the whole identity such a device has. The select keeps its `ServiceNotFound` branch outside the helper rather than offering it to controls that have never needed it. The library imports Home Assistant nowhere, so nothing on the publish path can raise it today. --- CHANGELOG.md | 3 +- custom_components/span_panel/adoption.py | 38 +++++++++- custom_components/span_panel/button.py | 43 +++-------- custom_components/span_panel/entity.py | 75 ++++++++++++++++++- custom_components/span_panel/number.py | 37 +++------ custom_components/span_panel/select.py | 46 ++++-------- custom_components/span_panel/strings.json | 6 ++ custom_components/span_panel/switch.py | 62 +++++---------- .../span_panel/translations/en.json | 6 ++ .../span_panel/translations/es.json | 6 ++ .../span_panel/translations/fr.json | 6 ++ .../span_panel/translations/ja.json | 6 ++ .../span_panel/translations/pt.json | 6 ++ tests/test_adoption.py | 60 ++++++++++++++- 14 files changed, 258 insertions(+), 142 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40f20dcf..cec265c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -168,7 +168,8 @@ working entirely in 2027.8. longer quietly unlocks the panel; a pending auto-relock comes back with the time it had left rather than starting its countdown over. - **Every control command is now recorded** in the logbook, on the event bus as `span_panel_control_command`, and at `INFO`, attributed to the automation that issued it rather than to nobody. -- **Controls now report what happened to them**, so a command that was refused or never handed to the broker no longer looks like one the panel confirmed. +- **Every control now reports what happened to it**, adopted devices' controls included, so a command that was refused or never handed to the broker no longer + looks like one the panel confirmed. - **A new [Security](README.md#security) section in the README** covers what the integration stores, what rotation costs, and the deployment choices that actually bound the panel's exposure. diff --git a/custom_components/span_panel/adoption.py b/custom_components/span_panel/adoption.py index 80a09a5e..bab1927a 100644 --- a/custom_components/span_panel/adoption.py +++ b/custom_components/span_panel/adoption.py @@ -240,7 +240,7 @@ def adopted_device_info( """ return DeviceInfo( identifiers={(DOMAIN, identifier)}, - name=device.name or humanised(device.device_type.rsplit(".", 1)[-1]), + name=adopted_device_label(device), manufacturer=device.vendor_name or "Unknown", model=device.model or humanised(device.device_type.rsplit(".", 1)[-1]), serial_number=device.serial_number, @@ -250,6 +250,15 @@ def adopted_device_info( ) +def adopted_device_label(device: AdoptedDevice) -> str: + """Return the name this device is known by, on its card and in its errors. + + One function because the two have to agree: an error naming a device the + user cannot find in their device list is an error they cannot act on. + """ + return device.name or humanised(device.device_type.rsplit(".", 1)[-1]) + + def humanised(wire_token: str) -> str: """`backup-generator` -> `Backup Generator`, for a name with no translation. @@ -421,19 +430,40 @@ def __init__( ) self._node_id = declaration.node_id self._property_id = declaration.property_id + self._device_label = adopted_device_label(device) async def _publish(self, value: str) -> None: - """Write one value and refresh, or raise what the library raised. + """Write one value and refresh, reporting both ways it can fail to happen. + + Through the same `_async_control` every curated control uses, and for the + same reason: a refusal (the device left the tree, the property stopped + being settable) never published anything, and a `FAILED` outcome was + never handed to the broker and will not arrive later. Either one leaves + the panel as it was, so returning normally would tell the person who + operated this control that their device changed when it did not. + + The messages name the property and the device rather than the circuit or + the charger the curated keys name, because that is the whole identity an + adopted device has: this integration models nothing about it beyond its + declaration, and the wire vocabulary is what the user will find in the + panel's own tree. No `hasattr` guard, unlike the curated controls. Those ask because a transport may not implement an optional protocol at all; this entity only exists because a v1.0 tree reported an adopted device, and that is the same transport that carries the write. """ - await self._async_guarded_control( + await self._async_control( self.coordinator.client.set_adopted_property( self._device_wire_id, self._node_id, self._property_id, value - ) + ), + command=f"a write to {self._node_id}/{self._property_id} on {self._device_wire_id}", + failed_key="adopted_control_failed", + not_delivered_key="adopted_control_not_delivered", + placeholders={ + "property": f"{self._node_id}/{self._property_id}", + "device": self._device_label, + }, ) await self.coordinator.async_request_refresh() diff --git a/custom_components/span_panel/button.py b/custom_components/span_panel/button.py index f0ccc154..30a4a3f1 100644 --- a/custom_components/span_panel/button.py +++ b/custom_components/span_panel/button.py @@ -5,14 +5,12 @@ from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from span_panel_api import SpanMqttClient, SpanPanelSnapshot -from span_panel_api.exceptions import SpanPanelServerError from . import SpanPanelConfigEntry -from .const import CONF_DEVICE_NAME, DOMAIN -from .control_gate import ControlMode, outcome_failure_reason, outcome_is_failure +from .const import CONF_DEVICE_NAME +from .control_gate import ControlMode from .coordinator import SpanPanelCoordinator from .entity import SpanPanelEntity from .helpers import construct_panel_unique_id_for_entry, has_bess @@ -68,40 +66,21 @@ async def async_press(self) -> None: published, so there is nothing to correct later, and the person who pressed the button is the one who needs to hear about it. A `FAILED` outcome -- handed to nobody, and promised not to arrive later -- is - raised on the same grounds and worded as the different fact it is. + raised on the same grounds and worded as the different fact it is. Both + live in `_async_control`, which every control here shares. """ client = self.coordinator.client if not hasattr(client, "set_dominant_power_source"): _LOGGER.warning("Client does not support GFE override") return - try: - outcome = await self._async_guarded_control( - client.set_dominant_power_source(self._override_value) - ) - except SpanPanelServerError as err: - _LOGGER.warning( - "SPAN panel did not accept a GFE override to %s: %s", self._override_value, err - ) - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="gfe_override_failed", - translation_placeholders={ - "value": self._override_value, - "reason": str(err), - }, - ) from err - - if outcome_is_failure(outcome): - reason = outcome_failure_reason(outcome) - _LOGGER.warning( - "GFE override to %s was not delivered: %s", self._override_value, reason - ) - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="gfe_override_not_delivered", - translation_placeholders={"value": self._override_value, "reason": reason}, - ) + await self._async_control( + client.set_dominant_power_source(self._override_value), + command=f"a GFE override to {self._override_value}", + failed_key="gfe_override_failed", + not_delivered_key="gfe_override_not_delivered", + placeholders={"value": self._override_value}, + ) await self.coordinator.async_request_refresh() diff --git a/custom_components/span_panel/entity.py b/custom_components/span_panel/entity.py index 8e74a41e..58f749f1 100644 --- a/custom_components/span_panel/entity.py +++ b/custom_components/span_panel/entity.py @@ -3,19 +3,29 @@ from __future__ import annotations from collections.abc import Awaitable +import logging from typing import ClassVar from homeassistant.const import CONF_HOST +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from span_panel_api import PublishOutcome, SpanPanelSnapshot - -from .const import CONF_DEVICE_NAME -from .control_gate import CONTROL_CALLER, async_bind_caller +from span_panel_api.exceptions import SpanPanelServerError + +from .const import CONF_DEVICE_NAME, DOMAIN +from .control_gate import ( + CONTROL_CALLER, + async_bind_caller, + outcome_failure_reason, + outcome_is_failure, +) from .coordinator import SpanPanelCoordinator from .field_paths import FieldPathDeclarationMixin from .util import snapshot_to_device_info +_LOGGER = logging.getLogger(__name__) + class SpanPanelEntity(CoordinatorEntity[SpanPanelCoordinator]): """Base entity for all Span Panel platforms.""" @@ -76,6 +86,65 @@ async def _async_guarded_control(self, action: Awaitable[PublishOutcome]) -> Pub finally: CONTROL_CALLER.reset(token) + async def _async_control( + self, + action: Awaitable[PublishOutcome], + *, + command: str, + failed_key: str, + not_delivered_key: str, + placeholders: dict[str, str], + ) -> PublishOutcome: + """Run one control call and raise both ways it can fail to happen. + + Every control this integration offers -- relay, priority, charge-current + limit, GFE override, and any settable property on an adopted device -- + reaches the panel through here, because the two ways a command does not + happen are the same two facts whatever was being commanded, and stating + them once is what keeps a new control from silently reporting success. + + They are reported separately and worded differently, because they are + different facts. A refusal (`SpanPanelServerError`) never resolved an + address: the panel declares the thing non-commandable, or the value has + no representation, and nothing was published. A `FAILED` outcome + resolved one and was never handed over, because the transport was closed + or the broker was disconnected; the library refuses such a write rather + than letting paho queue it, so it is also a promise that nothing fires + later against a panel nobody is watching. Both raise: a control that + silently does nothing is the defect. + + `command` names what was asked in the caller's own words -- "a relay + command for switch.kitchen_outlets" -- and appears only in the log, + where a phrase reading as English is worth more than a key. What the + user sees is `failed_key` / `not_delivered_key` with `placeholders`, + which is why the reason is added here rather than at each call site: it + is the one placeholder every one of these messages carries, and the one + a caller cannot know before the call. + + Returns the outcome for a caller that has something to say about a + delivered command -- `no_op`, say, or the topic it went to. + """ + try: + outcome = await self._async_guarded_control(action) + except SpanPanelServerError as err: + _LOGGER.warning("SPAN panel did not accept %s: %s", command, err) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=failed_key, + translation_placeholders={**placeholders, "reason": str(err)}, + ) from err + + if outcome_is_failure(outcome): + reason = outcome_failure_reason(outcome) + _LOGGER.warning("SPAN panel was never handed %s: %s", command, reason) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=not_delivered_key, + translation_placeholders={**placeholders, "reason": reason}, + ) + + return outcome + async def async_added_to_hass(self) -> None: """Tell the coordinator which snapshot fields this entity reads. diff --git a/custom_components/span_panel/number.py b/custom_components/span_panel/number.py index f8f68115..2be0b9d9 100644 --- a/custom_components/span_panel/number.py +++ b/custom_components/span_panel/number.py @@ -50,12 +50,11 @@ SpanEvseSnapshot, SpanPanelSnapshot, ) -from span_panel_api.exceptions import SpanPanelServerError from . import SpanPanelConfigEntry from .adoption import AdoptedNumber, create_adopted_numbers from .const import CONF_DEVICE_NAME, DOMAIN, USE_CIRCUIT_NUMBERS -from .control_gate import ControlMode, outcome_failure_reason, outcome_is_failure +from .control_gate import ControlMode from .coordinator import SpanPanelCoordinator from .entity import SpanPanelEntity from .field_paths import DerivedReason, FieldPathDeclarationMixin @@ -254,8 +253,9 @@ async def async_set_native_value(self, value: float) -> None: a slower charge, asking for it and getting 17 is a current the user did not request. - Both ways the write does not happen reach the caller. A refusal never - resolved an address; a `FAILED` outcome resolved one and was never + Both ways the write does not happen reach the caller, through the + `_async_control` every control in this integration shares. A refusal + never resolved an address; a `FAILED` outcome resolved one and was never handed over, which the library promises means it will not arrive later. """ client = self.coordinator.client @@ -265,28 +265,13 @@ async def async_set_native_value(self, value: float) -> None: translation_key="evse_charge_limit_unsupported", translation_placeholders={"charger": self._evse_id}, ) - try: - outcome = await self._async_guarded_control( - self._description.set_fn(client, self._evse_id, int(value)) - ) - except SpanPanelServerError as err: - _LOGGER.warning("SPAN panel refused a charge-current limit: %s", err) - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="evse_charge_limit_failed", - translation_placeholders={"charger": self._evse_id, "reason": str(err)}, - ) from err - - if outcome_is_failure(outcome): - reason = outcome_failure_reason(outcome) - _LOGGER.warning( - "Charge-current limit for %s was not delivered: %s", self._evse_id, reason - ) - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="evse_charge_limit_not_delivered", - translation_placeholders={"charger": self._evse_id, "reason": reason}, - ) + await self._async_control( + self._description.set_fn(client, self._evse_id, int(value)), + command=f"a charge-current limit for {self._evse_id}", + failed_key="evse_charge_limit_failed", + not_delivered_key="evse_charge_limit_not_delivered", + placeholders={"charger": self._evse_id}, + ) await self.coordinator.async_request_refresh() diff --git a/custom_components/span_panel/select.py b/custom_components/span_panel/select.py index ee218eb4..a5eb4d57 100644 --- a/custom_components/span_panel/select.py +++ b/custom_components/span_panel/select.py @@ -6,17 +6,16 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceNotFound +from homeassistant.exceptions import ServiceNotFound from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from span_panel_api import SpanCircuitSnapshot, SpanPanelSnapshot -from span_panel_api.exceptions import SpanPanelServerError from . import SpanPanelConfigEntry from .adoption import AdoptedSelect, create_adopted_selects from .const import DOMAIN, USE_CIRCUIT_NUMBERS, CircuitPriority -from .control_gate import ControlMode, outcome_failure_reason, outcome_is_failure +from .control_gate import ControlMode from .coordinator import SpanPanelCoordinator from .entity import SpanPanelEntity from .helpers import ( @@ -215,7 +214,14 @@ async def async_select_option(self, option: str) -> None: A `FAILED` outcome is raised too, and separately worded: that command resolved an address and was never handed to the broker, which the - library promises means it will not arrive later either. + library promises means it will not arrive later either. Both live in + `_async_control`, which every control in this integration shares. + + The `ServiceNotFound` branch is this platform's alone. Nothing on the + publish path calls a Home Assistant service -- the library does not + import Home Assistant at all -- so it survives as a guard against a + transport that one day does, and stays out of the shared helper rather + than being offered to controls that have never needed it. """ _LOGGER.debug("Selecting option: %s", option) client = self.coordinator.client @@ -226,8 +232,12 @@ async def async_select_option(self, option: str) -> None: priority = CircuitPriority(option) try: - outcome = await self._async_guarded_control( - client.set_circuit_priority(self.id, priority.name) + await self._async_control( + client.set_circuit_priority(self.id, priority.name), + command=f"a priority change for {self.entity_id}", + failed_key="circuit_priority_failed", + not_delivered_key="circuit_priority_not_delivered", + placeholders={"circuit": construct_circuit_label(self._get_circuit(), self.id)}, ) except ServiceNotFound as snf: _LOGGER.warning( @@ -242,30 +252,6 @@ async def async_select_option(self, option: str) -> None: notification_id=f"span_panel_service_not_found_{self.id}", ) return - except SpanPanelServerError as err: - _LOGGER.warning( - "SPAN panel did not accept a priority change for %s: %s", self.entity_id, err - ) - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="circuit_priority_failed", - translation_placeholders={ - "circuit": construct_circuit_label(self._get_circuit(), self.id), - "reason": str(err), - }, - ) from err - - if outcome_is_failure(outcome): - reason = outcome_failure_reason(outcome) - _LOGGER.warning("Priority change for %s was not delivered: %s", self.entity_id, reason) - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="circuit_priority_not_delivered", - translation_placeholders={ - "circuit": construct_circuit_label(self._get_circuit(), self.id), - "reason": reason, - }, - ) await self.coordinator.async_request_refresh() diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index 17aabf2c..b3033bf5 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -424,6 +424,12 @@ "admin_only_control": { "message": "Only a Home Assistant administrator may operate this SPAN panel." }, + "adopted_control_failed": { + "message": "The write to {property} on {device} was refused: {reason}. Nothing was sent to the panel, so the value is unchanged." + }, + "adopted_control_not_delivered": { + "message": "The write to {property} on {device} could not be delivered to the panel: {reason}. It was refused rather than queued, so the value is unchanged and will not change later." + }, "circuit_priority_failed": { "message": "The priority change for {circuit} was refused: {reason}. Nothing was sent to the panel, so the priority is unchanged." }, diff --git a/custom_components/span_panel/switch.py b/custom_components/span_panel/switch.py index d0cf05df..5ee28e41 100644 --- a/custom_components/span_panel/switch.py +++ b/custom_components/span_panel/switch.py @@ -8,14 +8,13 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.const import STATE_OFF from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import RestoreEntity from span_panel_api import SpanCircuitSnapshot, SpanPanelSnapshot -from span_panel_api.exceptions import SpanPanelServerError from . import SpanPanelConfigEntry from .adoption import AdoptedSwitch, create_adopted_switches @@ -25,8 +24,6 @@ ControlLockExtraStoredData, ControlMode, ControlPolicy, - outcome_failure_reason, - outcome_is_failure, ) from .coordinator import SpanPanelCoordinator from .entity import SpanPanelEntity @@ -289,53 +286,28 @@ async def _async_set_relay(self, state: str, *, is_on: bool) -> None: relay was already in the requested position. Neither is a failure and neither should discard the requested state. - The two ways a command does not happen are reported the same way and - worded differently, because they are different facts. A refusal never - resolved an address -- the panel declares this relay non-commandable. A - `FAILED` outcome resolved one and never handed it over, because the - transport was closed or the broker was disconnected; the library - refuses such a write rather than letting paho queue it, so it is a - promise that nothing fires later against a panel nobody is watching. - Both raise: a control that silently does nothing is the defect. + Both ways the command can fail to happen -- a refusal that never resolved an + address, and a `FAILED` outcome that resolved one and was never handed + over -- are raised by `_async_control`, which every control in this + integration shares. """ client = self.coordinator.client if not hasattr(client, "set_circuit_relay"): _LOGGER.warning("Client does not support relay control") return - try: - outcome = await self._async_guarded_control( - client.set_circuit_relay(self._circuit_id, state) - ) - except SpanPanelServerError as err: - _LOGGER.warning( - "SPAN panel did not accept a relay command for %s: %s", self.entity_id, err - ) - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="circuit_relay_failed", - translation_placeholders={ - "circuit": construct_circuit_label( - self.coordinator.data.circuits.get(self._circuit_id), - self._circuit_id, - ), - "reason": str(err), - }, - ) from err - if outcome_is_failure(outcome): - reason = outcome_failure_reason(outcome) - _LOGGER.warning("Relay command for %s was not delivered: %s", self.entity_id, reason) - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="circuit_relay_not_delivered", - translation_placeholders={ - "circuit": construct_circuit_label( - self.coordinator.data.circuits.get(self._circuit_id), - self._circuit_id, - ), - "reason": reason, - }, - ) + await self._async_control( + client.set_circuit_relay(self._circuit_id, state), + command=f"a relay command for {self.entity_id}", + failed_key="circuit_relay_failed", + not_delivered_key="circuit_relay_not_delivered", + placeholders={ + "circuit": construct_circuit_label( + self.coordinator.data.circuits.get(self._circuit_id), + self._circuit_id, + ) + }, + ) self._attr_is_on = is_on self.async_write_ha_state() diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index 17aabf2c..b3033bf5 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -424,6 +424,12 @@ "admin_only_control": { "message": "Only a Home Assistant administrator may operate this SPAN panel." }, + "adopted_control_failed": { + "message": "The write to {property} on {device} was refused: {reason}. Nothing was sent to the panel, so the value is unchanged." + }, + "adopted_control_not_delivered": { + "message": "The write to {property} on {device} could not be delivered to the panel: {reason}. It was refused rather than queued, so the value is unchanged and will not change later." + }, "circuit_priority_failed": { "message": "The priority change for {circuit} was refused: {reason}. Nothing was sent to the panel, so the priority is unchanged." }, diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index 3a4a39a9..aa35674f 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -424,6 +424,12 @@ "admin_only_control": { "message": "Solo un administrador de Home Assistant puede manejar este SPAN Panel." }, + "adopted_control_failed": { + "message": "Se rechazó la escritura de {property} en {device}: {reason}. No se envió nada al panel, por lo que el valor no ha cambiado." + }, + "adopted_control_not_delivered": { + "message": "La escritura de {property} en {device} no se pudo entregar al panel: {reason}. Se rechazó en lugar de ponerse en cola, por lo que el valor no ha cambiado ni cambiará más tarde." + }, "circuit_priority_failed": { "message": "Se rechazó el cambio de prioridad para {circuit}: {reason}. No se envió nada al panel, por lo que la prioridad no ha cambiado." }, diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index e1538116..cff160b9 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -424,6 +424,12 @@ "admin_only_control": { "message": "Seul un administrateur Home Assistant peut piloter ce SPAN Panel." }, + "adopted_control_failed": { + "message": "L'écriture de {property} sur {device} a été refusée : {reason}. Rien n'a été envoyé au panneau, la valeur est donc inchangée." + }, + "adopted_control_not_delivered": { + "message": "L'écriture de {property} sur {device} n'a pas pu être transmise au panneau : {reason}. Elle a été refusée plutôt que mise en file d'attente, la valeur est donc inchangée et ne changera pas plus tard." + }, "circuit_priority_failed": { "message": "Le changement de priorité pour {circuit} a été refusé : {reason}. Rien n'a été envoyé au panneau, la priorité est donc inchangée." }, diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index bcb12f86..ce8184df 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -424,6 +424,12 @@ "admin_only_control": { "message": "この SPAN Panel を操作できるのは Home Assistant の管理者だけです。" }, + "adopted_control_failed": { + "message": "{device}の{property}への書き込みは拒否されました: {reason}。パネルには何も送信されていないため、値は変わっていません。" + }, + "adopted_control_not_delivered": { + "message": "{device}の{property}への書き込みをパネルに送信できませんでした: {reason}。キューに入れずに拒否したため、値は変わっておらず、後から変わることもありません。" + }, "circuit_priority_failed": { "message": "{circuit}の優先度変更は拒否されました: {reason}。パネルには何も送信されていないため、優先度は変わっていません。" }, diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index 5b1b14b5..4bdf8f27 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -424,6 +424,12 @@ "admin_only_control": { "message": "Apenas um administrador do Home Assistant pode operar este SPAN Panel." }, + "adopted_control_failed": { + "message": "A escrita de {property} em {device} foi recusada: {reason}. Nada foi enviado ao painel, portanto o valor permanece inalterado." + }, + "adopted_control_not_delivered": { + "message": "A escrita de {property} em {device} não pôde ser entregue ao painel: {reason}. Foi recusada em vez de enfileirada, portanto o valor permanece inalterado e não mudará mais tarde." + }, "circuit_priority_failed": { "message": "A alteração de prioridade para {circuit} foi recusada: {reason}. Nada foi enviado ao painel, portanto a prioridade permanece inalterada." }, diff --git a/tests/test_adoption.py b/tests/test_adoption.py index b5be5d00..b9b14a2f 100644 --- a/tests/test_adoption.py +++ b/tests/test_adoption.py @@ -18,10 +18,12 @@ from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr import pytest from pytest_homeassistant_custom_component.common import MockConfigEntry -from span_panel_api import AdoptedDevice, AdoptedProperty +from span_panel_api import AdoptedDevice, AdoptedProperty, PublishOutcome, PublishState +from span_panel_api.exceptions import SpanPanelServerError from custom_components.span_panel.adoption import ( CONTROL_PLATFORMS, @@ -458,6 +460,62 @@ async def test_a_switch_publishes_the_vocabulary_homie_defines(hass: HomeAssista coordinator.client.set_adopted_property.assert_awaited_once_with("generator-1", "relay", "enabled", "true") +def _adopted_switch(hass: HomeAssistant) -> tuple[MagicMock, object]: + """One adopted switch and the coordinator whose client it writes through.""" + declaration = _property(node_id="relay", property_id="enabled", datatype="boolean", unit=None, settable=True) + snapshot = _snapshot(_device(properties=(declaration,))) + coordinator = MagicMock(data=snapshot) + coordinator.async_request_refresh = AsyncMock() + (entity,) = create_adopted_switches( + coordinator, snapshot, dr.async_get(hass), panel_device_id="panel-device-id" + ) + return coordinator, entity + + +async def test_an_adopted_control_raises_when_the_command_was_not_delivered(hass: HomeAssistant) -> None: + """A `FAILED` outcome is the promise that nothing will happen later. + + The library refuses such a write rather than letting paho queue it, so + returning normally would tell the person who flipped the switch that their + generator changed state when the command never left the process. + """ + coordinator, entity = _adopted_switch(hass) + coordinator.client.set_adopted_property = AsyncMock( + return_value=PublishOutcome( + state=PublishState.FAILED, + topic=None, + value="true", + detail="broker not connected; refused rather than queued", + ) + ) + + with pytest.raises(HomeAssistantError) as raised: + await entity.async_turn_on() + + assert raised.value.translation_key == "adopted_control_not_delivered" + assert raised.value.translation_placeholders is not None + assert raised.value.translation_placeholders["reason"] == "broker not connected; refused rather than queued" + coordinator.async_request_refresh.assert_not_awaited() + + +async def test_an_adopted_control_translates_the_librarys_refusal(hass: HomeAssistant) -> None: + """A refusal reaches the caller in the user's language, like a curated one.""" + coordinator, entity = _adopted_switch(hass) + coordinator.client.set_adopted_property = AsyncMock( + side_effect=SpanPanelServerError("No settable adopted property relay/enabled on device 'generator-1'") + ) + + with pytest.raises(HomeAssistantError) as raised: + await entity.async_turn_on() + + assert raised.value.translation_key == "adopted_control_failed" + assert raised.value.translation_placeholders is not None + assert raised.value.translation_placeholders["reason"] == ( + "No settable adopted property relay/enabled on device 'generator-1'" + ) + coordinator.async_request_refresh.assert_not_awaited() + + async def test_a_number_publishes_an_integer_where_the_declaration_says_integer(hass: HomeAssistant) -> None: """`5`, never `5.0`: a float literal is outside the declared datatype.""" declaration = _property( From 700fa55675da9f62915c6048126ee346c675f896 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:12:31 -0700 Subject: [PATCH 167/345] fix(adoption): a vendor format we cannot parse yields a reading, not a crash `parse_number_format` called `float()` on the declared `$format` with no guard, and `classify` routed any settable numeric with a truthy format to NUMBER. A non-compliant publisher spelling a range `"0-100"` or `"auto"` therefore reached `AdoptedNumber.__init__`, where the ValueError escaped `create_adopted_numbers` into `number.async_setup_entry` -- before its only `async_add_entities` call, so the whole number platform failed and no entity was created at all. That included the curated EVSE charge-current limits: one vendor device on a schema that is explicitly vendor-extensible cost the user a control on hardware this integration models. `parse_number_format` now answers None rather than raising, for anything `float()` refuses and for non-finite bounds, which parse and are still not bounds. `classify` asks it instead of testing the string, so the platform decision and the bounds a number is built from cannot disagree: an unreadable range is no range, and the property surfaces as the reading it can still be. A format Homie permits parses exactly as it always did. `AdoptedNumber` raises on absent bounds rather than defaulting to 0-100, which would offer a control the panel never declared, and `number.async_setup_entry` adds the curated numbers in their own call before adoption is attempted -- ordering rather than a handler, so that any future fault in adoption stops at the adopted entities without five platforms each restating a try/except. --- custom_components/span_panel/adoption.py | 77 ++++++++++--- custom_components/span_panel/number.py | 18 ++- tests/test_adoption.py | 139 ++++++++++++++++++++++- tests/test_evse_charge_limit.py | 15 ++- 4 files changed, 227 insertions(+), 22 deletions(-) diff --git a/custom_components/span_panel/adoption.py b/custom_components/span_panel/adoption.py index bab1927a..0791aaff 100644 --- a/custom_components/span_panel/adoption.py +++ b/custom_components/span_panel/adoption.py @@ -35,6 +35,7 @@ from __future__ import annotations import logging +from math import isfinite from typing import TYPE_CHECKING, Final from homeassistant.components.binary_sensor import BinarySensorEntity @@ -102,7 +103,7 @@ def classify(declaration: AdoptedProperty) -> Platform: | `boolean`, settable | `SWITCH` | | `boolean` | `BINARY_SENSOR` | | `enum`, settable, with a `format` | `SELECT` | - | numeric, settable, with a `format` | `NUMBER` | + | numeric, settable, with a `format` **stating a range** | `NUMBER` | | anything else | `SENSOR` | **A settable property with no usable value domain falls back to a reading, @@ -111,6 +112,15 @@ def classify(declaration: AdoptedProperty) -> Platform: carries the domain, so its absence is the absence of the thing a control needs. + A numeric `format` that *is* present and states no range this integration can + read -- `"0-100"`, `"auto"` -- falls the same way, and for the same reason + rather than for safety: the bounds are what make a number a number, so a + declaration that carries none leaves nothing to build a control out of + except invented bounds. Homie 5 permits no such spelling, so this needs a + non-compliant publisher; what makes it worth a branch is that the parse used + to raise, and it raised inside `async_setup_entry`, where one vendor device + took down every number on the panel including the curated EVSE limits. + Disabled-by-default is what gates a control, not read-only. Enabling an entity is a deliberate act and commanding it is a second one, the panel authorises the write regardless of what is created here, and this @@ -122,7 +132,15 @@ def classify(declaration: AdoptedProperty) -> Platform: if declaration.datatype == ENUM_DATATYPE and settable_with_domain: return Platform.SELECT if declaration.datatype in NUMERIC_DATATYPES and settable_with_domain: - return Platform.NUMBER + if parse_number_format(declaration.format) is not None: + return Platform.NUMBER + _LOGGER.debug( + "Adopted %s/%s declares format %r, which states no range this integration can " + "read; surfacing the property as a reading rather than as a control", + declaration.node_id, + declaration.property_id, + declaration.format, + ) return Platform.SENSOR @@ -532,8 +550,23 @@ def __init__( *, panel_device_id: str, ) -> None: - """Take the bounds from the declaration, which is what makes this a number.""" + """Take the bounds from the declaration, which is what makes this a number. + + A declaration `parse_number_format` reads no range from never reaches + here -- `classify` routes it to a sensor -- so this raises rather than + substituting anything. There is no defensible substitute: bounds + invented here would offer the user a 0-100 control the panel never + declared, and a caller that got past `classify` has a bug worth hearing + about. `number.async_setup_entry` bounds what hearing about it can cost + by adding the curated controls first. + """ bounds = parse_number_format(declaration.format) + if bounds is None: + raise ValueError( + f"{declaration.node_id}/{declaration.property_id} declares format " + f"{declaration.format!r}, which states no range; a numeric property " + "whose format cannot be read is classified as a sensor" + ) super().__init__( coordinator, identifier, device, declaration, panel_device_id=panel_device_id ) @@ -587,18 +620,36 @@ def parse_enum_format(declared: str | None) -> list[str]: return [option.strip() for option in declared.split(",") if option.strip()] -def parse_number_format(declared: str | None) -> tuple[float, float, float]: - """Return the `min:max:step` a Homie numeric `$format` states. +def parse_number_format(declared: str | None) -> tuple[float, float, float] | None: + """Return the `min:max:step` a Homie numeric `$format` states, or None for no range. + + Step defaults to 1 when the declaration gives only a range, and either bound + may be left empty, both of which Homie permits. - Step defaults to 1 when the declaration gives only a range, which Homie - permits. `classify` has already required a format, so the two bounds are - present by the time this is called. + **None is the answer for anything else, and this function never raises.** + It is the one place that decides whether a declared numeric domain is + readable, which is why `classify` asks it rather than testing the string + itself -- the platform decision and the bounds a `NUMBER` is built from then + cannot disagree. It used to raise `ValueError` straight out of `float()`, + which reached `number.async_setup_entry` and failed the whole platform, so a + single non-compliant publisher on a device nobody modelled cost the user + every number on the panel, curated EVSE charge limits included. + + Non-finite bounds are refused alongside the unparseable ones: `float()` + accepts `"nan"` and `"inf"`, and neither is a bound a value can be clamped + to or a figure the frontend can be handed. """ - parts = (declared or "").split(":") - minimum = float(parts[0]) if len(parts) > 0 and parts[0] else 0.0 - maximum = float(parts[1]) if len(parts) > 1 and parts[1] else 100.0 - step = float(parts[2]) if len(parts) > 2 and parts[2] else 1.0 - return minimum, maximum, step + if not declared: + return None + parts = declared.split(":") + try: + minimum = float(parts[0]) if parts[0] else 0.0 + maximum = float(parts[1]) if len(parts) > 1 and parts[1] else 100.0 + step = float(parts[2]) if len(parts) > 2 and parts[2] else 1.0 + except ValueError: + return None + bounds = (minimum, maximum, step) + return bounds if all(isfinite(bound) for bound in bounds) else None def adopted_control_count(snapshot: SpanPanelSnapshot) -> int: diff --git a/custom_components/span_panel/number.py b/custom_components/span_panel/number.py index 2be0b9d9..45c74f65 100644 --- a/custom_components/span_panel/number.py +++ b/custom_components/span_panel/number.py @@ -52,7 +52,7 @@ ) from . import SpanPanelConfigEntry -from .adoption import AdoptedNumber, create_adopted_numbers +from .adoption import create_adopted_numbers from .const import CONF_DEVICE_NAME, DOMAIN, USE_CIRCUIT_NUMBERS from .control_gate import ControlMode from .coordinator import SpanPanelCoordinator @@ -297,7 +297,7 @@ async def async_setup_entry( coordinator = config_entry.runtime_data.coordinator snapshot: SpanPanelSnapshot = coordinator.data - entities: list[SpanEvseNumber | AdoptedNumber] = [ + curated: list[SpanEvseNumber] = [ SpanEvseNumber(coordinator, description, evse_id) for evse_id, evse in snapshot.evse.items() for description in EVSE_NUMBERS @@ -307,11 +307,21 @@ async def async_setup_entry( # declare settable does not. if description.settable_fn(evse) ] + # Added before adoption is attempted, and in their own call, so that the + # controls this integration models cannot be lost to a device it does not. + # Adoption reads a vendor-extensible schema, so its inputs are the ones no + # amount of care here fully constrains -- a malformed numeric `$format` once + # raised out of `AdoptedNumber` and failed this whole coroutine, taking the + # charge-current limits of curated chargers with it. Ordering is what makes + # that structurally impossible; `classify` refusing an unreadable format is + # what makes it not happen. Both, because only one of them is a rule about + # data this integration controls. + async_add_entities(curated) # Settable numerics on devices this integration models nothing for, whose # bounds come from the declaration -- which is what made them numbers rather # than readings in the first place. - entities.extend( + async_add_entities( create_adopted_numbers( coordinator, snapshot, @@ -319,5 +329,3 @@ async def async_setup_entry( panel_device_id=config_entry.runtime_data.panel_device_id, ) ) - - async_add_entities(entities) diff --git a/tests/test_adoption.py b/tests/test_adoption.py index b9b14a2f..e0a63f4e 100644 --- a/tests/test_adoption.py +++ b/tests/test_adoption.py @@ -25,9 +25,11 @@ from span_panel_api import AdoptedDevice, AdoptedProperty, PublishOutcome, PublishState from span_panel_api.exceptions import SpanPanelServerError +from custom_components.span_panel import SpanPanelRuntimeData from custom_components.span_panel.adoption import ( CONTROL_PLATFORMS, DEVICE_CLASS_BY_UNIT, + AdoptedNumber, adopted_control_count, adopted_identifier, adopted_unique_id, @@ -38,17 +40,22 @@ create_adopted_selects, create_adopted_sensors, create_adopted_switches, + parse_number_format, resolve_identifier, ) from custom_components.span_panel.const import DOMAIN from custom_components.span_panel.diagnostics import _adoption from custom_components.span_panel.id_builder import build_panel_unique_id +from custom_components.span_panel.number import ( + SpanEvseNumber, + async_setup_entry as number_async_setup_entry, +) from custom_components.span_panel.util import ( ADOPTED_IDENTIFIER_TOKEN, classify_sub_device_identifier, ) -from .factories import SpanPanelSnapshotFactory +from .factories import SpanEvseSnapshotFactory, SpanPanelSnapshotFactory if TYPE_CHECKING: from span_panel_api import SpanPanelSnapshot @@ -693,3 +700,133 @@ def test_diagnostics_report_whether_a_device_is_proxied_and_not_by_whom() -> Non assert row["proxied"] is True assert "parent" not in row assert "gateway-1" not in json.dumps(block) + + +# -- A numeric format this integration cannot read --------------------------- + +MALFORMED_FORMATS = ["0-100", "auto", "0:banana", "0:100:step", "nan:100", "0:inf", "%", "16 A"] +"""Formats a non-compliant publisher can put on a numeric, none of them a range. + +Homie 5 spells a numeric domain `min:max` with an optional `:step` and permits +nothing else, so every one of these needs a publisher that ignores the +specification -- which a vendor-extensible schema is exactly where to meet. +`nan:100` and `0:inf` are here because `float()` accepts both: they parse +without raising and are still not bounds anything can be clamped to. +""" + + +@pytest.mark.parametrize("fmt", MALFORMED_FORMATS) +def test_a_numeric_format_that_states_no_range_surfaces_as_a_reading(fmt: str) -> None: + """The same rule as a missing format, for a format that says nothing usable. + + An unparseable range is no range, and a number with no bounds is not a safer + control than none -- it is an invented one. The property still has a value, + so it surfaces as the reading it can be. + """ + assert classify(_property(datatype="float", fmt=fmt, settable=True)) is Platform.SENSOR + assert classify(_property(datatype="integer", fmt=fmt, settable=True)) is Platform.SENSOR + + +@pytest.mark.parametrize("fmt", MALFORMED_FORMATS) +def test_parsing_a_format_nothing_can_read_answers_none_rather_than_raising(fmt: str) -> None: + """`float()` raising here reached `async_setup_entry` and took the platform down.""" + assert parse_number_format(fmt) is None + + +@pytest.mark.parametrize( + ("fmt", "expected"), + [ + ("0:100:1", (0.0, 100.0, 1.0)), + ("10:80:5", (10.0, 80.0, 5.0)), + ("10:80", (10.0, 80.0, 1.0)), + (":80", (0.0, 80.0, 1.0)), + ("10:", (10.0, 100.0, 1.0)), + (":", (0.0, 100.0, 1.0)), + ("-5:5:0.5", (-5.0, 5.0, 0.5)), + ], +) +def test_a_format_homie_permits_parses_exactly_as_it_always_did( + fmt: str, expected: tuple[float, float, float] +) -> None: + """Defending against the malformed case may not move the well-formed one. + + Homie leaves either bound omittable, and an omitted one has always taken + Home Assistant's own default -- `":"` included, which states neither and + still yields 0-100. Whether *that* is a range worth building a control from + is a question this change deliberately does not reopen: it is the behaviour + every existing install already has, and it is not what the crash was about. + """ + assert parse_number_format(fmt) == expected + + +def test_an_adopted_number_refuses_to_invent_the_bounds_it_was_not_given() -> None: + """The invariant `classify` establishes, asserted where it is relied on. + + Nothing routes such a property here any more, so this is the guard rather + than the behaviour: a number built without bounds would present a 0-100 + control the panel never declared, and being loud about a broken invariant is + better than shipping that. `number.async_setup_entry` bounds what the noise + can cost by adding the curated controls first. + """ + declaration = _property(node_id="generator", property_id="setpoint", datatype="float", fmt="0-100", settable=True) + with pytest.raises(ValueError, match="states no range"): + AdoptedNumber( + MagicMock(data=_snapshot(_device(properties=(declaration,)))), + adopted_identifier(PANEL_SERIAL, "generator-1"), + _device(properties=(declaration,)), + declaration, + panel_device_id="panel-device-id", + ) + + +def _panel_with_a_charger_and_a_malformed_numeric() -> SpanPanelSnapshot: + """One curated EVSE that declares a settable limit, beside two unreadable formats.""" + declarations = ( + _property(node_id="generator", property_id="setpoint", datatype="float", fmt="0-100", settable=True, unit="%"), + _property(node_id="generator", property_id="ceiling", datatype="integer", fmt="auto", settable=True, unit="A"), + ) + charger = replace( + SpanEvseSnapshotFactory.create(node_id="evse-0", serial_number="SN-EVSE-SYNTH"), + charge_current_limit_a=16, + charge_current_ceiling_a=48, + charge_current_limit_settable=True, + ) + return replace(_snapshot(_device(properties=declarations)), evse={"SN-EVSE-SYNTH": charger}) + + +async def test_a_vendor_format_nothing_can_read_leaves_the_curated_control_standing( + hass: HomeAssistant, +) -> None: + """The blast radius this finding is about, asserted through the platform. + + A `float()` that raised while building an adopted number raised inside + `number.async_setup_entry`, so a non-compliant publisher on some device + nobody modelled cost the user the EVSE charge-current limit -- a curated + control on curated hardware, with nothing on screen to say why. + """ + snapshot = _panel_with_a_charger_and_a_malformed_numeric() + coordinator = MagicMock(data=snapshot) + coordinator.panel_offline = False + coordinator.last_update_success = True + coordinator.unresolved_paths = frozenset() + entry = MockConfigEntry(domain=DOMAIN, data={}, options={}, title="SPAN Panel", unique_id=PANEL_SERIAL) + entry.add_to_hass(hass) + entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator, panel_device_id="panel-device-id") + coordinator.config_entry = entry + added: list[object] = [] + + await number_async_setup_entry(hass, entry, lambda entities, *args, **kwargs: added.extend(entities)) + + assert [type(entity).__name__ for entity in added] == ["SpanEvseNumber"] + assert isinstance(added[0], SpanEvseNumber) + + +def test_the_malformed_numeric_still_reaches_the_user_as_a_reading(hass: HomeAssistant) -> None: + """Routed to a sensor, not dropped: the property has a value, only no domain.""" + snapshot = _panel_with_a_charger_and_a_malformed_numeric() + sensors = create_adopted_sensors( + MagicMock(data=snapshot), snapshot, dr.async_get(hass), panel_device_id="panel-device-id" + ) + + assert sorted(str(entity.name) for entity in sensors) == ["Ceiling", "Setpoint"] + assert adopted_control_count(snapshot) == 0 diff --git a/tests/test_evse_charge_limit.py b/tests/test_evse_charge_limit.py index 8bb65d1d..84286fe1 100644 --- a/tests/test_evse_charge_limit.py +++ b/tests/test_evse_charge_limit.py @@ -210,13 +210,20 @@ def _coordinator(snapshot: SpanPanelSnapshot, client: object | None = None) -> M async def _created( hass: HomeAssistant, snapshot: SpanPanelSnapshot, client: object | None = None ) -> list[SpanEvseNumber]: - """Everything `number.async_setup_entry` creates for one snapshot.""" + """Everything `number.async_setup_entry` creates for one snapshot. + + Every call, not the last one: the platform adds the curated numbers in their + own call before it attempts the adopted ones, so that a device this + integration models nothing for cannot cost the user a control it does model. + """ coordinator = _coordinator(snapshot, client) async_add_entities = MagicMock() await async_setup_entry(hass, coordinator.config_entry, async_add_entities) - added: Sequence[SpanEvseNumber] = async_add_entities.call_args.args[0] + added: Sequence[SpanEvseNumber] = [ + entity for call in async_add_entities.call_args_list for entity in call.args[0] + ] for entity in added: assert isinstance(entity, SpanEvseNumber) return list(added) @@ -387,7 +394,9 @@ async def test_the_control_is_unavailable_while_the_panel_is_offline(hass: HomeA coordinator = _coordinator(schema_one_snapshot(tree)) async_add_entities = MagicMock() await async_setup_entry(hass, coordinator.config_entry, async_add_entities) - entity = _for(async_add_entities.call_args.args[0], tree, EVSE) + entity = _for( + [entity for call in async_add_entities.call_args_list for entity in call.args[0]], tree, EVSE + ) assert entity.available is True coordinator.panel_offline = True From 614c1d24e33be76cbec083a963fa0845c3627dca Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:29:20 -0700 Subject: [PATCH 168/345] security(ca): never pin a CA that does not sign the panel's own certificate The config flow proves the CA it fetched signs the certificate the panel serves before pinning it. The two pins that happen outside the flow did not: the deferred fetch at setup, which settles the flag the v7 migration leaves on an upgraded entry, and the CA-changed repair, which asks a person to accept a new authority. Both stored whatever the plaintext, unauthenticated fetch returned. Anything on the path can answer that fetch. A reverse proxy in front of the panel answers it with an authority of its own, and pinning that is not a recoverable mistake: the broker connection then fails against a certificate the pin rejects, the change diagnosis finds the fetched CA and the pinned one identical and so reports no change, and the entry retries forever with no repair to offer and diagnostics saying it is pinned. Both sites now check the fetched CA against the certificate served on the entry's TLS port, through one helper that carries the config flow's address fallback -- the panel's leaf names the addresses it knows itself by, so a host recorded as a name is also tried by the address it resolves to. A refused deferred pin is treated exactly as a failed fetch: nothing stored, the flag kept for the next setup, and the entry continues unpinned rather than losing its integration. A refused re-pin aborts the fix flow with `ca_leaf_mismatch` and leaves the standing pin alone; the issue stays raised, so the repair can be run again. The user is never shown the fingerprint of a value that already failed. The real-TLS harness moves out of the config-flow test module so the new tests can use it: a throwaway CA, a leaf served from a loopback listener, and the refusal proved by a handshake rather than by a stub. --- custom_components/span_panel/__init__.py | 27 +- .../span_panel/config_flow_validation.py | 35 +++ custom_components/span_panel/repairs.py | 50 +++- custom_components/span_panel/strings.json | 3 +- .../span_panel/translations/en.json | 3 +- .../span_panel/translations/es.json | 3 +- .../span_panel/translations/fr.json | 3 +- .../span_panel/translations/ja.json | 3 +- .../span_panel/translations/pt.json | 3 +- tests/test_panel_ca_pinning.py | 73 +++--- tests/test_panel_ca_pinning_tls.py | 207 +++++++++++++++ tests/test_v2_config_flow_tls.py | 237 +++--------------- tests/tls_panel.py | 222 ++++++++++++++++ 13 files changed, 624 insertions(+), 245 deletions(-) create mode 100644 tests/test_panel_ca_pinning_tls.py create mode 100644 tests/tls_panel.py diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py index ae3e4d8d..e7229ce0 100644 --- a/custom_components/span_panel/__init__.py +++ b/custom_components/span_panel/__init__.py @@ -41,7 +41,7 @@ from .additions import async_announce_new_entities, async_forget_announcements from .adoption import async_register_adopted_devices from .ca_repairs import async_clear_ca_changed, async_raise_ca_changed -from .config_flow_validation import async_fetch_panel_ca +from .config_flow_validation import as_port, async_ca_signs_panel_leaf, async_fetch_panel_ca from .const import ( CONF_API_VERSION, CONF_EBUS_BROKER_HOST, @@ -49,7 +49,9 @@ CONF_EBUS_BROKER_PORT, CONF_EBUS_BROKER_USERNAME, CONF_HTTP_PORT, + CONF_HTTPS_PORT, CONF_PANEL_CA_PEM, + DEFAULT_HTTPS_PORT, DEFAULT_SNAPSHOT_INTERVAL, DOMAIN, ENABLE_CURRENT_MONITORING, @@ -166,6 +168,14 @@ async def _async_pinned_ca( trust-on-first-use on an upgrade path — nobody confirmed this certificate against anything — and the user should be able to find the value afterwards to compare it against another install or the panel's own label. + + Trust on first use is not the same as trust in anything that answers. The + fetch is plaintext and unauthenticated, so what comes back is checked + against the certificate the panel serves on its TLS port before it is + stored; a CA that signs nothing the panel serves is not the panel's. A + refusal is treated exactly as a failed fetch, because it is the same + situation from the entry's point of view: no anchor was acquired, the flag + stays, and the next setup tries again. """ pinned = entry.data.get(CONF_PANEL_CA_PEM) if pinned: @@ -191,6 +201,21 @@ async def _async_pinned_ca( ) return None + https_port = as_port(entry.data.get(CONF_HTTPS_PORT), DEFAULT_HTTPS_PORT) + if not await async_ca_signs_panel_leaf(hass, host, https_port, ca_pem): + _LOGGER.warning( + "The CA published by SPAN panel %s (SHA-256 %s) does not sign the " + "certificate served at %s:%s, so it is not this panel's CA and has not " + "been pinned. Continuing unpinned and retrying on the next setup; check " + "that %s is the panel's TLS port and that nothing is answering for it", + entry.title, + fingerprint, + host, + https_port, + https_port, + ) + return None + data = dict(entry.data) data[CONF_PANEL_CA_PEM] = ca_pem data.pop(PANEL_CA_PENDING, None) diff --git a/custom_components/span_panel/config_flow_validation.py b/custom_components/span_panel/config_flow_validation.py index e7624c4b..6fbb0350 100644 --- a/custom_components/span_panel/config_flow_validation.py +++ b/custom_components/span_panel/config_flow_validation.py @@ -245,6 +245,41 @@ def _check() -> bool: return await loop.run_in_executor(None, _check) +async def async_ca_signs_panel_leaf( + hass: HomeAssistant, host: str, tls_port: int, ca_pem: str +) -> bool: + """Whether `ca_pem` signs the certificate the panel at `host` actually serves. + + The one check that can be made on a CA nobody has confirmed. `ca_pem` comes + off an unauthenticated plaintext fetch, so anything on the path can have + answered it -- a reverse proxy in front of the panel answers it with an + authority of its own -- and pinning that is not a recoverable mistake: the + broker connection then fails against a certificate the pin rejects, the + change diagnosis finds the fetched CA and the pinned one identical and has + no change to report, and the entry retries forever with no repair to offer. + + The address fallback is the config flow's, for the same reason: the panel's + leaf names the addresses it knows itself by, so a host recorded as a name -- + an add-on's container hostname, a search-domain short name, an FQDN the + panel has not been told about -- fails hostname verification against a + perfectly good certificate. The name is tried first, always, because a panel + that already names it has nothing to work around. + + A host that will not resolve simply has one candidate and fails as it would + have anyway. + """ + if not host: + return False + if await async_leaf_chains_to_ca(host, tls_port, ca_pem): + return True + if is_ip_literal(host): + return False + resolved = await async_resolve_host(hass, host) + if resolved is None or resolved == host: + return False + return await async_leaf_chains_to_ca(resolved, tls_port, ca_pem) + + async def validate_v2_proximity( host: str, transport: PanelRestTransport, diff --git a/custom_components/span_panel/repairs.py b/custom_components/span_panel/repairs.py index ffbb4064..a43cf47d 100644 --- a/custom_components/span_panel/repairs.py +++ b/custom_components/span_panel/repairs.py @@ -23,12 +23,29 @@ import voluptuous as vol from .ca_repairs import CA_CHANGED_ISSUE_PREFIX -from .config_flow_validation import async_fetch_panel_ca -from .const import CONF_HTTP_PORT, CONF_PANEL_CA_PEM, DOMAIN, PANEL_CA_PENDING +from .config_flow_validation import as_port, async_ca_signs_panel_leaf, async_fetch_panel_ca +from .const import ( + CONF_HTTP_PORT, + CONF_HTTPS_PORT, + CONF_PANEL_CA_PEM, + DEFAULT_HTTPS_PORT, + DOMAIN, + PANEL_CA_PENDING, +) _LOGGER = logging.getLogger(__name__) +def _fingerprint_or_reason(pem: object) -> str: + """Name a stored PEM for a log line, without letting it raise into one.""" + if not isinstance(pem, str) or not pem: + return "none" + try: + return ca_fingerprint(pem) + except SpanPanelValidationError: + return "unreadable" + + class PanelCAChangedRepairFlow(RepairsFlow): """Re-pin a panel's CA, but only on an explicit human acceptance. @@ -38,6 +55,11 @@ class PanelCAChangedRepairFlow(RepairsFlow): has to be the one that will actually be stored, not a record of an earlier observation. Accepting a fingerprint and pinning a different certificate would make the confirmation meaningless. + + That fresh fetch has to earn its place in the dialog: it is plaintext and + unauthenticated, so it is checked against the certificate the panel serves + before any fingerprint is shown. Everything this flow asks of the user rests + on the value being the panel's own. """ def __init__(self, entry_id: str) -> None: @@ -73,9 +95,12 @@ async def async_step_confirm( return self.async_create_entry(data={}) host = str(entry.data.get(CONF_HOST, "")) + # Cleared before the fetch, not after: nothing accepted on an earlier + # pass through this step may survive into a pass that refuses. + self._ca_pem = None try: ca_pem = await async_fetch_panel_ca( - self.hass, host, http_port=int(entry.data.get(CONF_HTTP_PORT, 80)) + self.hass, host, http_port=as_port(entry.data.get(CONF_HTTP_PORT), 80) ) fingerprint = ca_fingerprint(ca_pem) except ( @@ -87,6 +112,25 @@ async def async_step_confirm( _LOGGER.warning("Could not re-read the CA from panel %s: %s", host, err) return self.async_abort(reason="ca_unreadable") + # The fetch is plaintext and unauthenticated, so whatever answered it + # decides what this dialog would ask the user to trust. A CA that signs + # nothing the panel serves cannot be the panel's, and its fingerprint is + # not one to put in front of a person as the panel's own -- accepting it + # would replace a working pin with one that can never connect. + https_port = as_port(entry.data.get(CONF_HTTPS_PORT), DEFAULT_HTTPS_PORT) + if not await async_ca_signs_panel_leaf(self.hass, host, https_port, ca_pem): + _LOGGER.warning( + "SPAN panel %s published a CA (SHA-256 %s) that does not sign the " + "certificate served at %s:%s; refusing to offer it. The pin in place " + "is SHA-256 %s and has not been touched", + entry.title, + fingerprint, + host, + https_port, + _fingerprint_or_reason(entry.data.get(CONF_PANEL_CA_PEM)), + ) + return self.async_abort(reason="ca_leaf_mismatch") + self._ca_pem = ca_pem return self.async_show_form( step_id="confirm", diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index b3033bf5..55c9145d 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -885,7 +885,8 @@ }, "abort": { "entry_gone": "That SPAN Panel is no longer configured.", - "ca_unreadable": "The panel's certificate authority could not be read. Check that the panel is reachable and try again." + "ca_unreadable": "The panel's certificate authority could not be read. Check that the panel is reachable and try again.", + "ca_leaf_mismatch": "The certificate this panel serves is not signed by the authority it published, so that authority has not been offered for you to accept and nothing has been changed. Check that the HTTPS port is correct and that nothing is intercepting the connection, then try again." } } } diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index b3033bf5..55c9145d 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -885,7 +885,8 @@ }, "abort": { "entry_gone": "That SPAN Panel is no longer configured.", - "ca_unreadable": "The panel's certificate authority could not be read. Check that the panel is reachable and try again." + "ca_unreadable": "The panel's certificate authority could not be read. Check that the panel is reachable and try again.", + "ca_leaf_mismatch": "The certificate this panel serves is not signed by the authority it published, so that authority has not been offered for you to accept and nothing has been changed. Check that the HTTPS port is correct and that nothing is intercepting the connection, then try again." } } } diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index aa35674f..28c7f687 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -885,7 +885,8 @@ }, "abort": { "entry_gone": "Ese SPAN Panel ya no está configurado.", - "ca_unreadable": "No se ha podido leer la autoridad de certificación del panel. Compruebe que el panel es accesible e inténtelo de nuevo." + "ca_unreadable": "No se ha podido leer la autoridad de certificación del panel. Compruebe que el panel es accesible e inténtelo de nuevo.", + "ca_leaf_mismatch": "El certificado que sirve este panel no está firmado por la autoridad que ha publicado, por lo que esa autoridad no se ha ofrecido para su aceptación y no se ha cambiado nada. Compruebe que el puerto HTTPS es correcto y que nada intercepta la conexión, y vuelva a intentarlo." } } } diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index cff160b9..9d610874 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -885,7 +885,8 @@ }, "abort": { "entry_gone": "Ce SPAN Panel n'est plus configuré.", - "ca_unreadable": "L'autorité de certification du panneau n'a pas pu être lue. Vérifiez que le panneau est joignable et réessayez." + "ca_unreadable": "L'autorité de certification du panneau n'a pas pu être lue. Vérifiez que le panneau est joignable et réessayez.", + "ca_leaf_mismatch": "Le certificat présenté par ce panneau n'est pas signé par l'autorité qu'il a publiée ; cette autorité ne vous a donc pas été proposée et rien n'a été modifié. Vérifiez que le port HTTPS est correct et que rien n'intercepte la connexion, puis réessayez." } } } diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index ce8184df..b3174849 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -885,7 +885,8 @@ }, "abort": { "entry_gone": "その SPAN Panel はすでに設定されていません。", - "ca_unreadable": "パネルの認証局を読み取れませんでした。パネルに到達できることを確認して、もう一度お試しください。" + "ca_unreadable": "パネルの認証局を読み取れませんでした。パネルに到達できることを確認して、もう一度お試しください。", + "ca_leaf_mismatch": "このパネルが提供する証明書は、パネルが公開した認証局で署名されていません。そのため、その認証局は承認の対象として提示されず、何も変更されていません。HTTPS ポートが正しいこと、接続が傍受されていないことを確認してから、もう一度お試しください。" } } } diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index 4bdf8f27..696d844b 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -885,7 +885,8 @@ }, "abort": { "entry_gone": "Esse SPAN Panel já não está configurado.", - "ca_unreadable": "Não foi possível ler a autoridade de certificação do painel. Verifique se o painel está acessível e tente novamente." + "ca_unreadable": "Não foi possível ler a autoridade de certificação do painel. Verifique se o painel está acessível e tente novamente.", + "ca_leaf_mismatch": "O certificado que este painel serve não está assinado pela autoridade que publicou, pelo que essa autoridade não foi proposta para aceitação e nada foi alterado. Verifique se a porta HTTPS está correta e se nada está a intercetar a ligação, e tente novamente." } } } diff --git a/tests/test_panel_ca_pinning.py b/tests/test_panel_ca_pinning.py index 71c05261..3697ea91 100644 --- a/tests/test_panel_ca_pinning.py +++ b/tests/test_panel_ca_pinning.py @@ -4,7 +4,12 @@ from unittest.mock import AsyncMock, MagicMock, patch +from homeassistant import config_entries +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry from span_panel_api.exceptions import SpanPanelCAChangedError, SpanPanelConnectionError from custom_components.span_panel import _async_pinned_ca, async_migrate_entry @@ -20,12 +25,6 @@ DOMAIN, PANEL_CA_PENDING, ) -from homeassistant import config_entries -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST -from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir - -from pytest_homeassistant_custom_component.common import MockConfigEntry PEM = "-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n" OTHER_PEM = "-----BEGIN CERTIFICATE-----\nb3RoZXI=\n-----END CERTIFICATE-----\n" @@ -58,9 +57,7 @@ async def test_a_stored_pin_is_used_without_any_fetch(hass: HomeAssistant) -> No """An entry that already carries a CA does not go back to the panel for one.""" entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) - with patch( - "custom_components.span_panel.async_fetch_panel_ca", new=AsyncMock() - ) as fetch: + with patch("custom_components.span_panel.async_fetch_panel_ca", new=AsyncMock()) as fetch: assert await _async_pinned_ca(hass, entry, "192.168.1.100", 80) == PEM fetch.assert_not_awaited() @@ -70,13 +67,23 @@ async def test_a_stored_pin_is_used_without_any_fetch(hass: HomeAssistant) -> No async def test_the_deferred_fetch_stores_the_ca_and_clears_the_flag( hass: HomeAssistant, ) -> None: - """A migrated entry acquires its CA at the first setup that reaches the panel.""" + """A migrated entry acquires its CA at the first setup that reaches the panel. + + The leaf check is stood down here and exercised for real in + `test_panel_ca_pinning_tls`: this is about the bookkeeping either side of it. + """ entry = _entry(hass, **{PANEL_CA_PENDING: True}) - with patch( - "custom_components.span_panel.async_fetch_panel_ca", - new=AsyncMock(return_value=PEM), - ) as fetch: + with ( + patch( + "custom_components.span_panel.async_fetch_panel_ca", + new=AsyncMock(return_value=PEM), + ) as fetch, + patch( + "custom_components.span_panel.async_ca_signs_panel_leaf", + new=AsyncMock(return_value=True), + ), + ): assert await _async_pinned_ca(hass, entry, "192.168.1.100", 8080) == PEM fetch.assert_awaited_once_with(hass, "192.168.1.100", http_port=8080) @@ -109,9 +116,7 @@ async def test_an_entry_with_neither_pin_nor_flag_is_left_alone( """Nothing acquires a CA behind the user's back.""" entry = _entry(hass) - with patch( - "custom_components.span_panel.async_fetch_panel_ca", new=AsyncMock() - ) as fetch: + with patch("custom_components.span_panel.async_fetch_panel_ca", new=AsyncMock()) as fetch: assert await _async_pinned_ca(hass, entry, "192.168.1.100", 80) is None fetch.assert_not_awaited() @@ -146,9 +151,7 @@ async def test_v7_flags_a_v2_entry_for_ca_acquisition(hass: HomeAssistant) -> No @pytest.mark.asyncio @pytest.mark.parametrize("api_version", ["v1", "simulation"]) -async def test_non_v2_entries_are_never_flagged( - hass: HomeAssistant, api_version: str -) -> None: +async def test_non_v2_entries_are_never_flagged(hass: HomeAssistant, api_version: str) -> None: """v1 fails setup before it reaches a panel, and a simulation has none.""" entry = MockConfigEntry( version=6, @@ -197,10 +200,7 @@ async def test_a_clean_connection_clears_a_standing_repair(hass: HomeAssistant) async_clear_ca_changed(hass, entry) - assert ( - ir.async_get(hass).async_get_issue(DOMAIN, ca_changed_issue_id(entry.entry_id)) - is None - ) + assert ir.async_get(hass).async_get_issue(DOMAIN, ca_changed_issue_id(entry.entry_id)) is None @pytest.mark.asyncio @@ -214,10 +214,18 @@ async def test_the_fix_flow_re_pins_only_on_an_explicit_confirmation( flow = PanelCAChangedRepairFlow(entry.entry_id) flow.hass = hass - with patch( - "custom_components.span_panel.repairs.async_fetch_panel_ca", - new=AsyncMock(return_value=OTHER_PEM), - ) as fetch: + with ( + patch( + "custom_components.span_panel.repairs.async_fetch_panel_ca", + new=AsyncMock(return_value=OTHER_PEM), + ) as fetch, + # Stood down here and exercised for real in `test_panel_ca_pinning_tls`; + # this test is about what an explicit confirmation does and does not do. + patch( + "custom_components.span_panel.repairs.async_ca_signs_panel_leaf", + new=AsyncMock(return_value=True), + ), + ): shown = await flow.async_step_init() fetch.assert_awaited_once_with(hass, "192.168.1.100", http_port=8080) @@ -270,9 +278,7 @@ async def test_the_coordinator_takes_entities_unavailable_on_a_ca_change( entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) client = MagicMock() - client.get_snapshot = AsyncMock( - side_effect=SpanPanelCAChangedError("aa" * 32, "bb" * 32) - ) + client.get_snapshot = AsyncMock(side_effect=SpanPanelCAChangedError("aa" * 32, "bb" * 32)) coordinator = SpanPanelCoordinator(hass, client, entry) # A previous good snapshot, which the ordinary offline path would keep serving. coordinator.data = MagicMock() @@ -307,7 +313,7 @@ async def test_an_unpinned_entry_uses_the_plaintext_bootstrap_port( async def test_a_pinned_entry_moves_to_tls_and_drops_the_shared_client( hass: HomeAssistant, ) -> None: - """httpx fixes its trust store at construction, so a pin needs its own client.""" + """A pin needs its own client: httpx fixes its trust store at construction.""" from custom_components.span_panel.config_flow_validation import panel_rest_transport entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM, CONF_HTTP_PORT: 8080}) @@ -373,6 +379,7 @@ async def test_rotation_goes_over_the_pin_when_the_entry_has_one( """Fresh secrets over unverified HTTP would undo the pin where it matters most.""" from homeassistant.config_entries import ConfigEntryState from homeassistant.core import Context + from pytest_homeassistant_custom_component.common import MockUser from custom_components.span_panel import ( SpanPanelRuntimeData, @@ -380,8 +387,6 @@ async def test_rotation_goes_over_the_pin_when_the_entry_has_one( ) from custom_components.span_panel.const import CONF_EBUS_BROKER_PASSWORD - from pytest_homeassistant_custom_component.common import MockUser - entry = _entry( hass, **{CONF_PANEL_CA_PEM: PEM, CONF_EBUS_BROKER_PASSWORD: "old", CONF_HTTP_PORT: 8080}, diff --git a/tests/test_panel_ca_pinning_tls.py b/tests/test_panel_ca_pinning_tls.py new file mode 100644 index 00000000..c96a1a50 --- /dev/null +++ b/tests/test_panel_ca_pinning_tls.py @@ -0,0 +1,207 @@ +"""The two pins that happen outside the config flow, against a real handshake. + +The config flow proves the CA it fetched signs the certificate the panel serves +before it pins anything. Two other places pin: the deferred fetch at setup, for +an entry migrated from a release that predates pinning, and the CA-changed +repair, where a person accepts a new authority. Both took whatever the plaintext +fetch returned. + +Anything on the path can answer that fetch — a reverse proxy in front of the +panel answers it with an authority of its own — and pinning that CA is +unrecoverable: the broker connection then fails against a certificate the pin +rejects, the change diagnosis sees the fetched CA and the pinned one agree, and +the entry retries forever with no repair to offer. So these tests use a listener +serving a genuine leaf rather than the suite's stubs: the refusal is the +verification, and stubbing it out would test nothing. +""" + +from __future__ import annotations + +from collections.abc import Iterator +import ipaddress +from typing import Any +from unittest.mock import AsyncMock, patch + +from cryptography import x509 +from homeassistant import config_entries +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from homeassistant.core import HomeAssistant +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.span_panel import _async_pinned_ca +from custom_components.span_panel.const import ( + CONF_API_VERSION, + CONF_HTTP_PORT, + CONF_HTTPS_PORT, + CONF_PANEL_CA_PEM, + DOMAIN, + PANEL_CA_PENDING, +) +from custom_components.span_panel.repairs import PanelCAChangedRepairFlow + +from .tls_panel import ( + PANEL_LOOPBACK, + PANEL_SERIAL, + PANEL_SHORTNAME, + Panel, + resolving_to_loopback, + unrelated_ca_pem, +) + + +@pytest.fixture +def panel(tmp_path: Any) -> Iterator[Panel]: + """Serve a leaf naming the panel's address, signed by the panel's own CA.""" + instance = Panel(tmp_path) + instance.present([x509.IPAddress(ipaddress.ip_address(PANEL_LOOPBACK))]) + yield instance + instance.close() + + +def _entry(hass: HomeAssistant, panel: Panel, **data: object) -> MockConfigEntry: + """Add an entry pointed at the listener, with TLS on its ephemeral port.""" + entry = MockConfigEntry( + version=7, + minor_version=1, + domain=DOMAIN, + title="SPAN Panel", + data={ + CONF_HOST: PANEL_LOOPBACK, + CONF_ACCESS_TOKEN: "synthetic-token", + CONF_API_VERSION: "v2", + CONF_HTTP_PORT: 8080, + CONF_HTTPS_PORT: panel.port, + **data, + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id=PANEL_SERIAL, + ) + entry.add_to_hass(hass) + return entry + + +# ---------- the deferred pin at setup ---------- + + +@pytest.mark.usefixtures("socket_enabled") +@pytest.mark.asyncio +async def test_the_deferred_pin_refuses_a_ca_that_signs_nothing_the_panel_serves( + hass: HomeAssistant, panel: Panel +) -> None: + """The upgrade pin is trust on first use; it still has to be the panel's CA.""" + entry = _entry(hass, panel, **{PANEL_CA_PENDING: True}) + + with patch( + "custom_components.span_panel.async_fetch_panel_ca", + new=AsyncMock(return_value=unrelated_ca_pem()), + ): + assert await _async_pinned_ca(hass, entry, PANEL_LOOPBACK, 8080) is None + + # Nothing stored, and still pending: the next setup tries again, and until + # then the entry runs unpinned exactly as a failed fetch leaves it. + assert CONF_PANEL_CA_PEM not in entry.data + assert entry.data[PANEL_CA_PENDING] is True + + +@pytest.mark.usefixtures("socket_enabled") +@pytest.mark.asyncio +async def test_the_deferred_pin_still_stores_the_panels_own_ca( + hass: HomeAssistant, panel: Panel +) -> None: + """The check must not cost a healthy upgrade its pin.""" + entry = _entry(hass, panel, **{PANEL_CA_PENDING: True}) + + with patch( + "custom_components.span_panel.async_fetch_panel_ca", + new=AsyncMock(return_value=panel.ca_pem), + ): + assert await _async_pinned_ca(hass, entry, PANEL_LOOPBACK, 8080) == panel.ca_pem + + assert entry.data[CONF_PANEL_CA_PEM] == panel.ca_pem + assert PANEL_CA_PENDING not in entry.data + + +@pytest.mark.usefixtures("socket_enabled") +@pytest.mark.asyncio +async def test_the_deferred_pin_reaches_a_named_host_by_its_address( + hass: HomeAssistant, panel: Panel +) -> None: + """A migrated entry recorded by name is checked over the address the leaf names. + + The panel's leaf names the addresses it knows itself by. An entry configured + with a hostname -- an add-on's container name, a search-domain short name -- + would fail hostname verification against it and never pin, so the address it + resolves to is tried too, exactly as the config flow does. + """ + entry = _entry(hass, panel, **{CONF_HOST: PANEL_SHORTNAME, PANEL_CA_PENDING: True}) + + with ( + resolving_to_loopback(PANEL_SHORTNAME), + patch( + "custom_components.span_panel.async_fetch_panel_ca", + new=AsyncMock(return_value=panel.ca_pem), + ), + ): + pinned = await _async_pinned_ca(hass, entry, PANEL_SHORTNAME, 8080) + + assert pinned == panel.ca_pem + assert entry.data[CONF_PANEL_CA_PEM] == panel.ca_pem + + +# ---------- the re-pin the CA-changed repair performs ---------- + + +@pytest.mark.usefixtures("socket_enabled") +@pytest.mark.asyncio +async def test_the_repair_refuses_a_ca_that_signs_nothing_the_panel_serves( + hass: HomeAssistant, panel: Panel +) -> None: + """The repair asks a person to accept a fingerprint; it must be a real one. + + A CA that does not sign what the panel serves cannot be the panel's, so the + user is never shown its fingerprint and the standing pin is left alone. The + issue stays raised, so the repair can be run again once whatever answered + the fetch is out of the way. + """ + entry = _entry(hass, panel, **{CONF_PANEL_CA_PEM: panel.ca_pem}) + flow = PanelCAChangedRepairFlow(entry.entry_id) + flow.hass = hass + + with patch( + "custom_components.span_panel.repairs.async_fetch_panel_ca", + new=AsyncMock(return_value=unrelated_ca_pem()), + ): + result = await flow.async_step_init() + + assert result["type"] == "abort" + assert result["reason"] == "ca_leaf_mismatch" + assert entry.data[CONF_PANEL_CA_PEM] == panel.ca_pem + + +@pytest.mark.usefixtures("socket_enabled") +@pytest.mark.asyncio +async def test_the_repair_still_offers_a_ca_the_panel_serves_a_leaf_for( + hass: HomeAssistant, panel: Panel +) -> None: + """A panel that legitimately rotated both certificate and authority re-pins.""" + entry = _entry(hass, panel, **{CONF_PANEL_CA_PEM: unrelated_ca_pem()}) + flow = PanelCAChangedRepairFlow(entry.entry_id) + flow.hass = hass + + with patch( + "custom_components.span_panel.repairs.async_fetch_panel_ca", + new=AsyncMock(return_value=panel.ca_pem), + ): + shown = await flow.async_step_init() + assert shown["type"] == "form" + assert shown["description_placeholders"] is not None + assert shown["description_placeholders"]["fingerprint"] + + with patch.object(hass.config_entries, "async_reload", new=AsyncMock()) as reload: + done = await flow.async_step_confirm({}) + + assert done["type"] == "create_entry" + assert entry.data[CONF_PANEL_CA_PEM] == panel.ca_pem + reload.assert_awaited_once_with(entry.entry_id) diff --git a/tests/test_v2_config_flow_tls.py b/tests/test_v2_config_flow_tls.py index cc79fcab..bf96ff71 100644 --- a/tests/test_v2_config_flow_tls.py +++ b/tests/test_v2_config_flow_tls.py @@ -3,33 +3,23 @@ The rest of the v2 config-flow suite stubs `async_leaf_chains_to_ca` and `build_panel_ssl_context`, so hostname verification is never exercised there. These tests live in their own module precisely so that autouse fixture does not -reach them: a certificate authority and a leaf are generated with -`cryptography`, a small HTTPS listener serves that leaf on loopback, and the +reach them: the synthetic panel in `tls_panel` generates a certificate authority +and a leaf, serves that leaf from a small HTTPS listener on loopback, and the flow's own TLS code runs against it unmodified. -Everything here is synthetic — a throwaway CA, a self-signed panel leaf, and the -name `panel.home.lan` resolved to 127.0.0.1 for the duration of one test. +Everything is synthetic — a throwaway CA, a self-signed panel leaf, and the name +`panel.home.lan` resolved to 127.0.0.1 for the duration of one test. """ from __future__ import annotations from collections.abc import Callable, Iterator import contextlib -import datetime -import http.server import ipaddress -import json -import socket -import socketserver -import ssl -import threading from typing import Any from unittest.mock import AsyncMock, patch from cryptography import x509 -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import rsa -from cryptography.x509.oid import NameOID from homeassistant import config_entries from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.core import HomeAssistant @@ -49,164 +39,25 @@ DOMAIN, ) -PANEL_FQDN = "panel.home.lan" -# A single-label name, as a search domain or an add-on's container hostname -# supplies. `is_fqdn` is False for it, so nothing in the flow ever registers it. -PANEL_SHORTNAME = "spanpanel" -PANEL_LOOPBACK = "127.0.0.1" -PANEL_SERIAL = "sp3-synthetic-0001" -PASSPHRASE = "hunter2-synthetic" - -#: Names the `resolves_to_loopback` fixture answers with the test listener. -LOOPBACK_NAMES = frozenset({PANEL_FQDN, PANEL_SHORTNAME}) - -# ---------- certificate generation ---------- - - -def _issue_ca() -> tuple[x509.Certificate, rsa.RSAPrivateKey]: - """Issue the throwaway CA that stands in for the one a panel publishes.""" - key = rsa.generate_private_key(public_exponent=65537, key_size=2048) - subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "SPAN Panel Test CA")]) - now = datetime.datetime.now(datetime.UTC) - cert = ( - x509.CertificateBuilder() - .subject_name(subject) - .issuer_name(subject) - .public_key(key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(now - datetime.timedelta(days=1)) - .not_valid_after(now + datetime.timedelta(days=1)) - .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) - .sign(key, hashes.SHA256()) - ) - return cert, key - - -def _issue_leaf( - ca_cert: x509.Certificate, - ca_key: rsa.RSAPrivateKey, - names: list[x509.GeneralName], -) -> tuple[str, str]: - """Issue a server certificate naming exactly `names`, returning PEM cert and key.""" - key = rsa.generate_private_key(public_exponent=65537, key_size=2048) - now = datetime.datetime.now(datetime.UTC) - cert = ( - x509.CertificateBuilder() - .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "SPAN Panel")])) - .issuer_name(ca_cert.subject) - .public_key(key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(now - datetime.timedelta(days=1)) - .not_valid_after(now + datetime.timedelta(days=1)) - .add_extension(x509.SubjectAlternativeName(names), critical=False) - .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) - .sign(ca_key, hashes.SHA256()) - ) - cert_pem = cert.public_bytes(serialization.Encoding.PEM).decode() - key_pem = key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption(), - ).decode() - return cert_pem, key_pem - - -def _ca_pem(ca_cert: x509.Certificate) -> str: - return ca_cert.public_bytes(serialization.Encoding.PEM).decode() - - -# ---------- a listener that serves the panel's leaf ---------- - - -class _PanelStatusHandler(http.server.BaseHTTPRequestHandler): - """Answer the one unauthenticated probe `detect_api_version` makes.""" - - protocol_version = "HTTP/1.1" - - def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API - body = json.dumps( - { - "serialNumber": PANEL_SERIAL, - "firmwareVersion": "2.0.0-synthetic", - "proximityProven": True, - } - ).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - def log_message(self, format: str, *args: Any) -> None: # noqa: A002 - """Keep the test output free of one line per handshake.""" - - -class _PanelTLSListener(socketserver.ThreadingMixIn, http.server.HTTPServer): - """Serve HTTPS on loopback with a certificate the test can swap at will. - - Swappable because that is what the panel does: `register_fqdn` makes it - regenerate its leaf to add the FQDN, and the flow is supposed to notice. - """ +from .tls_panel import ( + LOOPBACK_NAMES, + PANEL_FQDN, + PANEL_LOOPBACK, + PANEL_SERIAL, + PANEL_SHORTNAME, + Panel, + issue_ca, + issue_leaf, + resolving_to_loopback, +) - daemon_threads = True - tls_context: ssl.SSLContext - - def get_request(self) -> tuple[socket.socket, Any]: - sock, addr = super().get_request() - return self.tls_context.wrap_socket(sock, server_side=True), addr - - def handle_error(self, request: Any, client_address: Any) -> None: - """Stay quiet: the leaf check hangs up right after the handshake.""" - - -def _server_context(cert_pem: str, key_pem: str, tmp_path: Any) -> ssl.SSLContext: - """Build a server-side context from PEM text (`load_cert_chain` wants files).""" - cert_file = tmp_path / f"leaf-{abs(hash(cert_pem))}.pem" - key_file = tmp_path / f"key-{abs(hash(cert_pem))}.pem" - cert_file.write_text(cert_pem) - key_file.write_text(key_pem) - ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - ctx.load_cert_chain(str(cert_file), str(key_file)) - return ctx - - -class _Panel: - """The synthetic panel: a CA, a leaf, and the port it serves TLS on.""" - - def __init__(self, tmp_path: Any) -> None: - self._tmp_path = tmp_path - self._ca_cert, self._ca_key = _issue_ca() - self.ca_pem = _ca_pem(self._ca_cert) - self._listener: _PanelTLSListener | None = None - self.port = 0 - - def present(self, names: list[x509.GeneralName]) -> None: - """Serve a freshly issued leaf naming exactly `names`.""" - cert_pem, key_pem = _issue_leaf(self._ca_cert, self._ca_key, names) - self.present_pem(cert_pem, key_pem) - - def present_pem(self, cert_pem: str, key_pem: str) -> None: - """Serve this certificate, whoever signed it.""" - context = _server_context(cert_pem, key_pem, self._tmp_path) - if self._listener is None: - self._listener = _PanelTLSListener((PANEL_LOOPBACK, 0), _PanelStatusHandler) - self._listener.tls_context = context - self.port = self._listener.server_port - threading.Thread(target=self._listener.serve_forever, daemon=True).start() - else: - self._listener.tls_context = context - - def close(self) -> None: - if self._listener is not None: - self._listener.shutdown() - self._listener.server_close() - self._listener = None +PASSPHRASE = "hunter2-synthetic" @pytest.fixture -def panel(tmp_path: Any) -> Iterator[_Panel]: +def panel(tmp_path: Any) -> Iterator[Panel]: """Serve a leaf naming the panel's IP only, as one does before registration.""" - instance = _Panel(tmp_path) + instance = Panel(tmp_path) instance.present([x509.IPAddress(ipaddress.ip_address(PANEL_LOOPBACK))]) yield instance instance.close() @@ -214,20 +65,8 @@ def panel(tmp_path: Any) -> Iterator[_Panel]: @pytest.fixture def resolves_to_loopback() -> Iterator[None]: - """Point the panel's names at the loopback listener, and nothing else. - - A name arrives as `bytes` from anyio's resolver and as `str` from - `socket.create_connection`, so both spellings are matched. - """ - real_getaddrinfo = socket.getaddrinfo - - def _fake(host: Any, port: Any, *args: Any, **kwargs: Any) -> Any: - name = host.decode("ascii") if isinstance(host, bytes) else host - if name in LOOPBACK_NAMES: - return real_getaddrinfo(PANEL_LOOPBACK, port, *args, **kwargs) - return real_getaddrinfo(host, port, *args, **kwargs) - - with patch("socket.getaddrinfo", side_effect=_fake): + """Point the panel's names at the loopback listener, and nothing else.""" + with resolving_to_loopback(*LOOPBACK_NAMES): yield @@ -264,7 +103,7 @@ def _auth_response(mqtts_port: int) -> V2AuthResponse: @contextlib.contextmanager -def _plaintext_bootstrap_stubbed(panel: _Panel) -> Iterator[None]: +def _plaintext_bootstrap_stubbed(panel: Panel) -> Iterator[None]: """Stub everything that is not TLS: the probe, the reachability check, the CA fetch. The CA fetch is plain HTTP and the probe answers over it, so neither is what @@ -295,7 +134,7 @@ def _plaintext_bootstrap_stubbed(panel: _Panel) -> Iterator[None]: yield -async def _reach_the_auth_menu(hass: HomeAssistant, host: str, panel: _Panel) -> Any: +async def _reach_the_auth_menu(hass: HomeAssistant, host: str, panel: Panel) -> Any: """Submit the host form and the TLS port, landing wherever the CA step lands. A non-default HTTP port is what makes the flow ask where TLS lives, which is @@ -313,7 +152,7 @@ async def _reach_the_auth_menu(hass: HomeAssistant, host: str, panel: _Panel) -> ) -def _registration_adds_the_fqdn(panel: _Panel) -> Callable[..., Any]: +def _registration_adds_the_fqdn(panel: Panel) -> Callable[..., Any]: """Return a `register_fqdn` stand-in with the side effect the real panel has.""" async def _register(*args: Any, **kwargs: Any) -> None: @@ -332,9 +171,7 @@ async def _register(*args: Any, **kwargs: Any) -> None: @pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") @pytest.mark.asyncio -async def test_fresh_fqdn_install_pins_and_completes( - hass: HomeAssistant, panel: _Panel -) -> None: +async def test_fresh_fqdn_install_pins_and_completes(hass: HomeAssistant, panel: Panel) -> None: """A fresh install by FQDN must reach entry creation with the CA pinned. The panel's leaf names its address, not the FQDN — the FQDN only lands in @@ -411,10 +248,10 @@ async def test_fresh_fqdn_install_pins_and_completes( @pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") @pytest.mark.asyncio async def test_fresh_install_still_refuses_a_leaf_the_ca_does_not_sign( - hass: HomeAssistant, panel: _Panel + hass: HomeAssistant, panel: Panel ) -> None: """Bootstrapping over the IP must not turn the leaf check off.""" - stranger_cert, stranger_key = _issue_leaf(*_issue_ca(), [x509.DNSName("elsewhere.invalid")]) + stranger_cert, stranger_key = issue_leaf(*issue_ca(), [x509.DNSName("elsewhere.invalid")]) panel.present_pem(stranger_cert, stranger_key) with ( patch( @@ -455,7 +292,7 @@ async def test_fresh_install_still_refuses_a_leaf_the_ca_does_not_sign( @pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") @pytest.mark.asyncio async def test_registration_that_never_lands_does_not_report_success( - hass: HomeAssistant, panel: _Panel + hass: HomeAssistant, panel: Panel ) -> None: """A panel that accepts the registration but never serves the FQDN must not pass. @@ -521,9 +358,9 @@ async def test_registration_that_never_lands_does_not_report_success( @pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") @pytest.mark.asyncio async def test_continuing_past_a_failed_registration_uses_the_panels_address( - hass: HomeAssistant, panel: _Panel + hass: HomeAssistant, panel: Panel ) -> None: - """"Continue anyway" must not pin the entry to the name that just failed. + """Continuing anyway must not pin the entry to the name that just failed. Registration is what would have made the panel serve the domain, so once it has failed the domain is a host the pinned anchor rejects — and an entry @@ -563,7 +400,7 @@ async def test_continuing_past_a_failed_registration_uses_the_panels_address( @pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") @pytest.mark.asyncio async def test_a_name_nothing_will_register_is_refused_rather_than_pinned( - hass: HomeAssistant, panel: _Panel + hass: HomeAssistant, panel: Panel ) -> None: """A single-label host is never registered, so it has to be named already. @@ -596,7 +433,7 @@ async def test_a_name_nothing_will_register_is_refused_rather_than_pinned( @pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") @pytest.mark.asyncio async def test_a_name_the_leaf_already_covers_is_kept_and_never_swapped_for_an_address( - hass: HomeAssistant, panel: _Panel + hass: HomeAssistant, panel: Panel ) -> None: """The host the user gave is tried first, so a panel that names it keeps it. @@ -637,7 +474,7 @@ async def test_a_name_the_leaf_already_covers_is_kept_and_never_swapped_for_an_a @pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") @pytest.mark.asyncio async def test_reauth_of_an_unpinned_fqdn_entry_will_not_pin_an_unnamed_host( - hass: HomeAssistant, panel: _Panel + hass: HomeAssistant, panel: Panel ) -> None: """Reauth acquires the anchor, and the entry's own host has to survive it. @@ -694,9 +531,7 @@ async def test_reauth_of_an_unpinned_fqdn_entry_will_not_pin_an_unnamed_host( @pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") @pytest.mark.asyncio -async def test_reconfigure_pinned_ip_entry_to_fqdn( - hass: HomeAssistant, panel: _Panel -) -> None: +async def test_reconfigure_pinned_ip_entry_to_fqdn(hass: HomeAssistant, panel: Panel) -> None: """A pinned entry moved to an FQDN must probe over the address the leaf names.""" entry = MockConfigEntry( version=7, @@ -739,7 +574,7 @@ async def test_reconfigure_pinned_ip_entry_to_fqdn( assert register.await_args.args[2] == PANEL_FQDN -def _pinned_entry(hass: HomeAssistant, panel: _Panel) -> MockConfigEntry: +def _pinned_entry(hass: HomeAssistant, panel: Panel) -> MockConfigEntry: """Add an entry already pinned to this panel and reaching it by address.""" entry = MockConfigEntry( version=7, @@ -765,7 +600,7 @@ def _pinned_entry(hass: HomeAssistant, panel: _Panel) -> MockConfigEntry: @pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") @pytest.mark.asyncio async def test_continuing_past_a_failed_reconfigure_keeps_the_panels_address( - hass: HomeAssistant, panel: _Panel + hass: HomeAssistant, panel: Panel ) -> None: """The reconfigure "continue anyway" makes the same trade as the install one. @@ -801,7 +636,7 @@ async def test_continuing_past_a_failed_reconfigure_keeps_the_panels_address( @pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") @pytest.mark.asyncio async def test_reconfigure_refuses_a_name_the_pinned_certificate_does_not_cover( - hass: HomeAssistant, panel: _Panel + hass: HomeAssistant, panel: Panel ) -> None: """A move to a single-label name is never registered, so it must already be named. diff --git a/tests/tls_panel.py b/tests/tls_panel.py new file mode 100644 index 00000000..e554c157 --- /dev/null +++ b/tests/tls_panel.py @@ -0,0 +1,222 @@ +"""A synthetic SPAN panel that serves a real certificate over a real handshake. + +Most of the suite stubs `async_leaf_chains_to_ca` and `build_panel_ssl_context`, +so nothing there ever verifies a certificate. The tests that exist to prove the +integration refuses the wrong anchor cannot use those stubs — the refusal *is* +the verification — so they need a listener with a genuine leaf on it. + +Everything here is synthetic: a throwaway CA and a self-signed panel leaf, +torn down with the test that raised them. This is the machinery only; each +suite wraps it in its own fixture, because a fixture imported into a test +module collides with every parameter that requests it. +""" + +from __future__ import annotations + +from collections.abc import Iterator +import contextlib +import datetime +import http.server +import json +import socket +import socketserver +import ssl +import threading +from typing import Any +from unittest.mock import patch + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID + +PANEL_FQDN = "panel.home.lan" +# A single-label name, as a search domain or an add-on's container hostname +# supplies. `is_fqdn` is False for it, so nothing in the flow ever registers it. +PANEL_SHORTNAME = "spanpanel" +PANEL_LOOPBACK = "127.0.0.1" +PANEL_SERIAL = "sp3-synthetic-0001" + +#: The names a config-flow test expects to reach the listener. +LOOPBACK_NAMES = frozenset({PANEL_FQDN, PANEL_SHORTNAME}) + + +@contextlib.contextmanager +def resolving_to_loopback(*names: str) -> Iterator[None]: + """Answer `names` with the test listener's address, and nothing else. + + A name arrives as `bytes` from anyio's resolver and as `str` from + `socket.create_connection`, so both spellings are matched. What is + replaced is already a patched resolver -- the Home Assistant test plugin + refuses real DNS -- so every other name keeps that refusal. + """ + real_getaddrinfo = socket.getaddrinfo + wanted = frozenset(names) + + def _fake(host: Any, port: Any, *args: Any, **kwargs: Any) -> Any: + name = host.decode("ascii") if isinstance(host, bytes) else host + if name in wanted: + return real_getaddrinfo(PANEL_LOOPBACK, port, *args, **kwargs) + return real_getaddrinfo(host, port, *args, **kwargs) + + with patch("socket.getaddrinfo", side_effect=_fake): + yield + + +# ---------- certificate generation ---------- + + +def issue_ca() -> tuple[x509.Certificate, rsa.RSAPrivateKey]: + """Issue the throwaway CA that stands in for the one a panel publishes.""" + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "SPAN Panel Test CA")]) + now = datetime.datetime.now(datetime.UTC) + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(subject) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(days=1)) + .not_valid_after(now + datetime.timedelta(days=1)) + .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) + .sign(key, hashes.SHA256()) + ) + return cert, key + + +def issue_leaf( + ca_cert: x509.Certificate, + ca_key: rsa.RSAPrivateKey, + names: list[x509.GeneralName], +) -> tuple[str, str]: + """Issue a server certificate naming exactly `names`, returning PEM cert and key.""" + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + now = datetime.datetime.now(datetime.UTC) + cert = ( + x509.CertificateBuilder() + .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "SPAN Panel")])) + .issuer_name(ca_cert.subject) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(days=1)) + .not_valid_after(now + datetime.timedelta(days=1)) + .add_extension(x509.SubjectAlternativeName(names), critical=False) + .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) + .sign(ca_key, hashes.SHA256()) + ) + cert_pem = cert.public_bytes(serialization.Encoding.PEM).decode() + key_pem = key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ).decode() + return cert_pem, key_pem + + +def ca_pem_of(ca_cert: x509.Certificate) -> str: + """Return the PEM text of a certificate, as the panel's CA endpoint serves it.""" + return ca_cert.public_bytes(serialization.Encoding.PEM).decode() + + +def unrelated_ca_pem() -> str: + """Return a CA that signs nothing this panel serves. + + The proxy case, in one call: something on the path answers the plaintext CA + fetch with an authority of its own, and the certificate the panel actually + serves is not signed by it. + """ + ca_cert, _key = issue_ca() + return ca_pem_of(ca_cert) + + +# ---------- a listener that serves the panel's leaf ---------- + + +class PanelStatusHandler(http.server.BaseHTTPRequestHandler): + """Answer the one unauthenticated probe `detect_api_version` makes.""" + + protocol_version = "HTTP/1.1" + + def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API + """Serve the status document, whatever path was asked for.""" + body = json.dumps( + { + "serialNumber": PANEL_SERIAL, + "firmwareVersion": "2.0.0-synthetic", + "proximityProven": True, + } + ).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format: str, *args: Any) -> None: # noqa: A002 + """Keep the test output free of one line per handshake.""" + + +class PanelTLSListener(socketserver.ThreadingMixIn, http.server.HTTPServer): + """Serve HTTPS on loopback with a certificate the test can swap at will. + + Swappable because that is what the panel does: `register_fqdn` makes it + regenerate its leaf to add the FQDN, and the flow is supposed to notice. + """ + + daemon_threads = True + tls_context: ssl.SSLContext + + def get_request(self) -> tuple[socket.socket, Any]: + """Wrap each accepted connection in whatever certificate is current.""" + sock, addr = super().get_request() + return self.tls_context.wrap_socket(sock, server_side=True), addr + + def handle_error(self, request: Any, client_address: Any) -> None: + """Stay quiet: the leaf check hangs up right after the handshake.""" + + +def server_context(cert_pem: str, key_pem: str, tmp_path: Any) -> ssl.SSLContext: + """Build a server-side context from PEM text (`load_cert_chain` wants files).""" + cert_file = tmp_path / f"leaf-{abs(hash(cert_pem))}.pem" + key_file = tmp_path / f"key-{abs(hash(cert_pem))}.pem" + cert_file.write_text(cert_pem) + key_file.write_text(key_pem) + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.load_cert_chain(str(cert_file), str(key_file)) + return ctx + + +class Panel: + """The synthetic panel: a CA, a leaf, and the port it serves TLS on.""" + + def __init__(self, tmp_path: Any) -> None: + """Mint this panel's authority; nothing is served until `present`.""" + self._tmp_path = tmp_path + self._ca_cert, self._ca_key = issue_ca() + self.ca_pem = ca_pem_of(self._ca_cert) + self._listener: PanelTLSListener | None = None + self.port = 0 + + def present(self, names: list[x509.GeneralName]) -> None: + """Serve a freshly issued leaf naming exactly `names`.""" + cert_pem, key_pem = issue_leaf(self._ca_cert, self._ca_key, names) + self.present_pem(cert_pem, key_pem) + + def present_pem(self, cert_pem: str, key_pem: str) -> None: + """Serve this certificate, whoever signed it.""" + context = server_context(cert_pem, key_pem, self._tmp_path) + if self._listener is None: + self._listener = PanelTLSListener((PANEL_LOOPBACK, 0), PanelStatusHandler) + self._listener.tls_context = context + self.port = self._listener.server_port + threading.Thread(target=self._listener.serve_forever, daemon=True).start() + else: + self._listener.tls_context = context + + def close(self) -> None: + """Stop serving and release the port.""" + if self._listener is not None: + self._listener.shutdown() + self._listener.server_close() + self._listener = None From 8fee80435d99f92583b6b6995ebdc5a99543ba7d Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:40:05 -0700 Subject: [PATCH 169/345] security(ca): verify the pin against the port it guards The leaf check added with the deferred pin ran against `https_port`. That key and `panel_ca_pending` arrived in the same commit, so an entry carrying the flag is by definition migrated from before either existed and can never hold one: every deferred check ran against 443. A pre-pinning install whose panel serves TLS somewhere else -- behind a port forward, a NAT rule, a proxy -- was refused on every setup, kept the warning it could not act on, and stayed on the unauthenticated refetch-per-connect path for good. That is the one population the deferred pin exists for, and at base those entries pinned and then connected, because the broker is dialled on `ebus_broker_mqtts_port`. Both sites now check the CA against the broker's port, which is on every v2 entry by construction -- setup refuses one without it before the pin is reached -- and is the connection the anchor is actually used for. The repair moves with it for the same reason: it exists to restore that session, so that is the certificate its offer has to be good for. Also fixes the warning, which named the port twice and described it as the panel's TLS port, and records at the call site the asymmetry that remains: the check accepts a leaf reached at the address the host resolves to, while the bridge dials the host itself, so a certificate naming only the address pins here and fails hostname verification at connect. Pre-existing, and the config flow bootstraps the same way. --- custom_components/span_panel/__init__.py | 44 ++++++++++++++++-------- custom_components/span_panel/const.py | 8 +++++ custom_components/span_panel/repairs.py | 18 ++++++---- tests/test_panel_ca_pinning_tls.py | 43 +++++++++++++++++++++-- 4 files changed, 90 insertions(+), 23 deletions(-) diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py index e7229ce0..8f059969 100644 --- a/custom_components/span_panel/__init__.py +++ b/custom_components/span_panel/__init__.py @@ -49,9 +49,8 @@ CONF_EBUS_BROKER_PORT, CONF_EBUS_BROKER_USERNAME, CONF_HTTP_PORT, - CONF_HTTPS_PORT, CONF_PANEL_CA_PEM, - DEFAULT_HTTPS_PORT, + DEFAULT_MQTTS_PORT, DEFAULT_SNAPSHOT_INTERVAL, DOMAIN, ENABLE_CURRENT_MONITORING, @@ -171,11 +170,11 @@ async def _async_pinned_ca( Trust on first use is not the same as trust in anything that answers. The fetch is plaintext and unauthenticated, so what comes back is checked - against the certificate the panel serves on its TLS port before it is - stored; a CA that signs nothing the panel serves is not the panel's. A - refusal is treated exactly as a failed fetch, because it is the same - situation from the entry's point of view: no anchor was acquired, the flag - stays, and the next setup tries again. + against the certificate the panel's broker serves before it is stored; a CA + that signs nothing the panel serves is not the panel's. A refusal is treated + exactly as a failed fetch, because it is the same situation from the entry's + point of view: no anchor was acquired, the flag stays, and the next setup + tries again. """ pinned = entry.data.get(CONF_PANEL_CA_PEM) if pinned: @@ -201,18 +200,35 @@ async def _async_pinned_ca( ) return None - https_port = as_port(entry.data.get(CONF_HTTPS_PORT), DEFAULT_HTTPS_PORT) - if not await async_ca_signs_panel_leaf(hass, host, https_port, ca_pem): + # The broker's port, not the panel's HTTPS port. This anchor is only ever + # used for one thing -- the MQTTS session the coordinator opens -- so that + # is the certificate it has to be checked against, and the port is on every + # v2 entry by construction: setup refuses one without it a few lines above, + # and the same value is handed to `MqttClientConfig` below. `https_port` was + # the wrong choice for the entries that actually reach here. It arrived in + # the same commit as `panel_ca_pending`, so an entry carrying the flag is by + # definition one migrated from before either existed and can never hold a + # port: every such check ran against 443, and a pre-pinning install whose + # TLS lives elsewhere -- behind NAT, a port forward, a proxy -- would have + # been refused on every setup and left on the unauthenticated refetch path + # for good, which is the one population this deferred pin exists for. + # + # One asymmetry to know about: `async_ca_signs_panel_leaf` accepts a leaf + # reached at the address `host` resolves to, while the bridge dials `host` + # itself. A certificate that names only the address would pin here and then + # fail hostname verification at connect. Pre-existing -- the config flow + # bootstraps the same way -- and not made worse by checking this port. + mqtts_port = as_port(entry.data.get(CONF_EBUS_BROKER_PORT), DEFAULT_MQTTS_PORT) + if not await async_ca_signs_panel_leaf(hass, host, mqtts_port, ca_pem): _LOGGER.warning( "The CA published by SPAN panel %s (SHA-256 %s) does not sign the " - "certificate served at %s:%s, so it is not this panel's CA and has not " - "been pinned. Continuing unpinned and retrying on the next setup; check " - "that %s is the panel's TLS port and that nothing is answering for it", + "certificate served by its broker at %s:%s, so it is not this panel's CA " + "and has not been pinned. Continuing unpinned and retrying on the next " + "setup; check that nothing on the network is answering for the panel", entry.title, fingerprint, host, - https_port, - https_port, + mqtts_port, ) return None diff --git a/custom_components/span_panel/const.py b/custom_components/span_panel/const.py index 4661247b..a0606da6 100644 --- a/custom_components/span_panel/const.py +++ b/custom_components/span_panel/const.py @@ -26,6 +26,14 @@ """Where the panel serves TLS. Configurable for the same reason `http_port` is: a reverse proxy on the panel's VLAN does not have to listen on 443.""" +DEFAULT_MQTTS_PORT = 8883 +"""Where the panel serves MQTT over TLS. + +A fallback and nothing more: the panel publishes this port during registration +and every v2 entry carries it, so a stored value is what is actually used. It is +here for the reader that has to name a port when `entry.data` holds something +unusable, rather than refuse an otherwise healthy entry over it.""" + PANEL_CA_PENDING = "panel_ca_pending" """Set in `entry.data` for a v2 entry that predates CA pinning. diff --git a/custom_components/span_panel/repairs.py b/custom_components/span_panel/repairs.py index a43cf47d..690a22e7 100644 --- a/custom_components/span_panel/repairs.py +++ b/custom_components/span_panel/repairs.py @@ -25,10 +25,10 @@ from .ca_repairs import CA_CHANGED_ISSUE_PREFIX from .config_flow_validation import as_port, async_ca_signs_panel_leaf, async_fetch_panel_ca from .const import ( + CONF_EBUS_BROKER_PORT, CONF_HTTP_PORT, - CONF_HTTPS_PORT, CONF_PANEL_CA_PEM, - DEFAULT_HTTPS_PORT, + DEFAULT_MQTTS_PORT, DOMAIN, PANEL_CA_PENDING, ) @@ -117,16 +117,20 @@ async def async_step_confirm( # nothing the panel serves cannot be the panel's, and its fingerprint is # not one to put in front of a person as the panel's own -- accepting it # would replace a working pin with one that can never connect. - https_port = as_port(entry.data.get(CONF_HTTPS_PORT), DEFAULT_HTTPS_PORT) - if not await async_ca_signs_panel_leaf(self.hass, host, https_port, ca_pem): + # + # Checked on the broker's port, because that is the connection the pin + # anchors and the one this repair exists to restore. Verifying somewhere + # else would be answering a question nobody asked. + mqtts_port = as_port(entry.data.get(CONF_EBUS_BROKER_PORT), DEFAULT_MQTTS_PORT) + if not await async_ca_signs_panel_leaf(self.hass, host, mqtts_port, ca_pem): _LOGGER.warning( "SPAN panel %s published a CA (SHA-256 %s) that does not sign the " - "certificate served at %s:%s; refusing to offer it. The pin in place " - "is SHA-256 %s and has not been touched", + "certificate served by its broker at %s:%s; refusing to offer it. The " + "pin in place is SHA-256 %s and has not been touched", entry.title, fingerprint, host, - https_port, + mqtts_port, _fingerprint_or_reason(entry.data.get(CONF_PANEL_CA_PEM)), ) return self.async_abort(reason="ca_leaf_mismatch") diff --git a/tests/test_panel_ca_pinning_tls.py b/tests/test_panel_ca_pinning_tls.py index c96a1a50..cad235ed 100644 --- a/tests/test_panel_ca_pinning_tls.py +++ b/tests/test_panel_ca_pinning_tls.py @@ -32,6 +32,7 @@ from custom_components.span_panel import _async_pinned_ca from custom_components.span_panel.const import ( CONF_API_VERSION, + CONF_EBUS_BROKER_PORT, CONF_HTTP_PORT, CONF_HTTPS_PORT, CONF_PANEL_CA_PEM, @@ -60,7 +61,13 @@ def panel(tmp_path: Any) -> Iterator[Panel]: def _entry(hass: HomeAssistant, panel: Panel, **data: object) -> MockConfigEntry: - """Add an entry pointed at the listener, with TLS on its ephemeral port.""" + """Add an entry whose broker is the listener, shaped as a v7 migration leaves it. + + No `https_port`: that key and `panel_ca_pending` arrived in the same commit, + so an entry carrying the flag predates both and can never hold one. The + broker port is what the pin is checked against and every v2 entry has it -- + setup refuses one without it before the pin is ever reached. + """ entry = MockConfigEntry( version=7, minor_version=1, @@ -71,7 +78,7 @@ def _entry(hass: HomeAssistant, panel: Panel, **data: object) -> MockConfigEntry CONF_ACCESS_TOKEN: "synthetic-token", CONF_API_VERSION: "v2", CONF_HTTP_PORT: 8080, - CONF_HTTPS_PORT: panel.port, + CONF_EBUS_BROKER_PORT: panel.port, **data, }, source=config_entries.SOURCE_USER, @@ -123,6 +130,38 @@ async def test_the_deferred_pin_still_stores_the_panels_own_ca( assert PANEL_CA_PENDING not in entry.data +@pytest.mark.usefixtures("socket_enabled") +@pytest.mark.asyncio +async def test_the_deferred_pin_checks_the_broker_port_an_upgraded_entry_actually_has( + hass: HomeAssistant, panel: Panel +) -> None: + """The one population this pin exists for has no `https_port` and never will. + + `https_port` and `panel_ca_pending` were added in the same commit, so an + entry reaching the deferred pin is by definition migrated from before either + existed. Checking a port such an entry cannot hold means checking 443 for + all of them, which refuses every pre-pinning install whose TLS is somewhere + else -- behind a port forward, a NAT rule, a proxy -- and leaves exactly + those entries on the unauthenticated refetch path for good. + + The broker's port is on every v2 entry by construction and is the connection + the anchor is used for, so it is what the anchor is checked against. + """ + entry = _entry(hass, panel, **{PANEL_CA_PENDING: True}) + assert CONF_HTTPS_PORT not in entry.data + # Nothing is listening on 443 here, so a check made there could not pass. + assert entry.data[CONF_EBUS_BROKER_PORT] == panel.port + + with patch( + "custom_components.span_panel.async_fetch_panel_ca", + new=AsyncMock(return_value=panel.ca_pem), + ): + assert await _async_pinned_ca(hass, entry, PANEL_LOOPBACK, 8080) == panel.ca_pem + + assert entry.data[CONF_PANEL_CA_PEM] == panel.ca_pem + assert PANEL_CA_PENDING not in entry.data + + @pytest.mark.usefixtures("socket_enabled") @pytest.mark.asyncio async def test_the_deferred_pin_reaches_a_named_host_by_its_address( From fb3ae9b2baa3d3f7ebad89d456672d369989a539 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:42:10 -0700 Subject: [PATCH 170/345] security(discovery): a pinned entry follows a discovered host only when its certificate chains to the pin The abort that says a panel is already configured is also how an entry follows its panel to a new address, and both routes into it -- an mDNS _ebus._tcp announcement, and a user re-adding a panel by hand -- reach it before anything has been authenticated. The serial that matched came from the candidate host's own unauthenticated /api/v2/status, so any device on the LAN could claim a panel's serial and have the entry rewritten to point at itself; MQTT then failed verification against the pinned authority and the CA-changed repair invited the user to accept the impostor's fingerprint. The same rewrite broke honest installs the other way round: re-adding an already-configured panel by an unregistered FQDN or a single-label name stored that host on an entry whose own anchor rejects it, leaving an entry that could not reach its panel and no flow left to correct it. A pinned entry now asks the one question only the panel can answer -- does the candidate serve a certificate this entry's anchor validates, on the port the entry uses -- and carries the host update only when it does, refusing at WARNING otherwise. The host is checked as it would be stored, without the resolved-address fallback the bootstrap uses, because accepting a name on the strength of its address would persist exactly the host that fallback refuses to write. An entry with no anchor has nothing to check against and keeps the behaviour it shipped with. --- custom_components/span_panel/config_flow.py | 93 +++++++++- tests/test_v2_config_flow_tls.py | 177 ++++++++++++++++++++ 2 files changed, 269 insertions(+), 1 deletion(-) diff --git a/custom_components/span_panel/config_flow.py b/custom_components/span_panel/config_flow.py index e02f7504..e7e08183 100644 --- a/custom_components/span_panel/config_flow.py +++ b/custom_components/span_panel/config_flow.py @@ -44,6 +44,7 @@ ) from .config_flow_validation import ( PanelRestTransport, + as_port, async_fetch_panel_ca, async_leaf_chains_to_ca, async_resolve_host, @@ -291,7 +292,97 @@ async def ensure_not_already_configured(self, raise_on_progress: bool = True) -> # User-initiated flows pass raise_on_progress=False so they can # proceed when a zeroconf discovery flow is already running. await self.async_set_unique_id(self.serial_number, raise_on_progress=raise_on_progress) - self._abort_if_unique_id_configured(updates={CONF_HOST: self.host}) + self._abort_if_unique_id_configured(updates=await self._async_host_update()) + + async def _async_host_update(self) -> dict[str, Any] | None: + """Return the host update the already-configured abort should carry, or None. + + The abort that follows is also how an entry follows its panel: a new + DHCP lease, a re-announcement on the new address, and the entry is + rewritten to point at it. Both routes into this reach it before anything + has been authenticated — an `_ebus._tcp` record is whatever the LAN + chose to broadcast, and the serial that matched came from the candidate + host's own unauthenticated `/api/v2/status` — so on its own, "claims to + be this serial" is a claim anything on the network can make. + + A pinned entry has a better question available: does the candidate serve + a certificate this entry's own anchor validates? Nothing but the panel + holding the matching key can answer it, and it is precisely the check + the entry's own connections will apply afterwards. Moving a pinned entry + onto a host that fails it is not a recoverable mistake either way round: + an impostor gets the entry pointed at itself and the user a CA-changed + repair inviting them to accept its fingerprint, while an honest name the + panel simply does not serve — a single-label host, an FQDN never + registered — leaves the entry unable to reach its own panel with no flow + left to correct it. + + Verified as the host would be *stored*, against the port the entry + already uses. The address fallback that `_async_choose_bootstrap_host` + applies is deliberately not repeated: that one exists to dial a panel + while a name is unverified, and accepting a name here because its + address validates would persist exactly the host that fallback refuses + to write. + + An entry with no anchor keeps the behaviour it shipped with. There is no + pin to protect and no check to make, so refusing the move would cost an + unpinned install its panel and buy nothing. + """ + host = self.host + if not host or self.unique_id is None: + return None + entry = self.hass.config_entries.async_entry_for_domain_unique_id( + self.handler, self.unique_id + ) + if entry is None: + return {CONF_HOST: host} + ca_pem = entry.data.get(CONF_PANEL_CA_PEM) + if not ca_pem: + return {CONF_HOST: host} + + tls_port = as_port(entry.data.get(CONF_HTTPS_PORT), DEFAULT_HTTPS_PORT) + try: + chains = await async_leaf_chains_to_ca(host, tls_port, str(ca_pem)) + except Exception: + # `async_leaf_chains_to_ca` answers False for every failure it + # anticipates, so reaching here at all is unexpected. Caught broadly + # rather than left to propagate because the two outcomes are not + # symmetric: an escaped error inside a discovery flow is an + # unhandled traceback, and what it would abandon is the one check + # standing between an unauthenticated claim and a pinned entry's + # host. Anything unrecognised is a refusal. + _LOGGER.exception("Could not verify %s against the pinned authority", host) + chains = False + if chains: + return {CONF_HOST: host} + + _LOGGER.warning( + "Refusing to move the entry for panel %s to %s: the certificate served there on " + "port %s does not chain to the authority this entry is pinned to (SHA-256 %s)", + self.unique_id, + host, + tls_port, + self._pinned_fingerprint(str(ca_pem)), + ) + return None + + @staticmethod + def _pinned_fingerprint(ca_pem: str) -> str: + """Return the fingerprint of the anchor that refused a host, for the log line. + + The anchor's, not the certificate the refused host served. This is the + value the install logged and diagnostics reports, so it is the one a + user can compare something against — and reading the other one would + mean opening a second, deliberately unverified connection to a host this + code has just decided not to trust, to obtain a number with nothing to + compare it to. + + A PEM this system cannot read is not a reason to lose the warning, so it + degrades to a placeholder rather than raising. + """ + try: + return ca_fingerprint(ca_pem) + except SpanPanelValidationError: + return "unreadable" async def async_step_zeroconf(self, discovery_info: ZeroconfServiceInfo) -> ConfigFlowResult: """Handle a flow initiated by zeroconf discovery.""" diff --git a/tests/test_v2_config_flow_tls.py b/tests/test_v2_config_flow_tls.py index bf96ff71..b0064d65 100644 --- a/tests/test_v2_config_flow_tls.py +++ b/tests/test_v2_config_flow_tls.py @@ -24,6 +24,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo import pytest from pytest_homeassistant_custom_component.common import MockConfigEntry from span_panel_api import DetectionResult, V2AuthResponse, V2StatusInfo @@ -49,6 +50,7 @@ issue_ca, issue_leaf, resolving_to_loopback, + unrelated_ca_pem, ) PASSPHRASE = "hunter2-synthetic" @@ -655,3 +657,178 @@ async def test_reconfigure_refuses_a_name_the_pinned_certificate_does_not_cover( assert result["step_id"] == "reconfigure" assert result["errors"] == {"base": "ca_leaf_mismatch"} assert entry.data[CONF_HOST] == PANEL_LOOPBACK + + +# ---------- Case C: an already-configured entry follows a host only under its pin ---------- + +#: Where the pinned entry already reaches its panel. RFC 5737 TEST-NET-1, so +#: nothing routes there: the guard is about the *candidate* host, and the host +#: the entry already holds is never dialled. +PANEL_ADDRESS = "192.0.2.10" + + +def _entry_reached_at_the_address( + hass: HomeAssistant, ca_pem: str | None, tls_port: int +) -> MockConfigEntry: + """Add an entry for this panel at `PANEL_ADDRESS`, pinned to `ca_pem`. + + `None` is the entry that never acquired an anchor. It is what proves the + guard leaves that install exactly as it was rather than freezing its host. + """ + data: dict[str, Any] = { + CONF_HOST: PANEL_ADDRESS, + CONF_ACCESS_TOKEN: "synthetic-token", + CONF_API_VERSION: "v2", + CONF_HTTPS_PORT: tls_port, + } + if ca_pem is not None: + data[CONF_PANEL_CA_PEM] = ca_pem + entry = MockConfigEntry( + version=7, + minor_version=1, + domain=DOMAIN, + title="SPAN Panel", + data=data, + source=config_entries.SOURCE_USER, + options={}, + unique_id=PANEL_SERIAL, + ) + entry.add_to_hass(hass) + return entry + + +async def _announce_from(hass: HomeAssistant, host: str) -> Any: + """Run an `_ebus._tcp` announcement claiming this panel's serial, from `host`.""" + address = ipaddress.IPv4Address(host) + return await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=address, + ip_addresses=[address], + hostname="span-panel.local.", + name="SPAN Panel._ebus._tcp.local.", + port=8883, + properties={}, + type="_ebus._tcp.local.", + ), + ) + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_zeroconf_will_not_move_a_pinned_entry_to_a_host_its_anchor_rejects( + hass: HomeAssistant, panel: Panel +) -> None: + """Anything on the LAN can announce this serial; only the pin decides who gets the entry. + + The listener here is the impostor: it answers on the port the entry uses, + serves a perfectly valid leaf, and the certificate is signed by an authority + the entry is not pinned to. Following it would point the entry at a host its + own anchor rejects and hand the user a CA-changed repair inviting them to + accept the impostor's fingerprint. + """ + entry = _entry_reached_at_the_address(hass, unrelated_ca_pem(), panel.port) + + with _plaintext_bootstrap_stubbed(panel): + result = await _announce_from(hass, PANEL_LOOPBACK) + + assert result["type"] == FlowResultType.ABORT, result + assert result["reason"] == "already_configured" + assert entry.data[CONF_HOST] == PANEL_ADDRESS + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_zeroconf_moves_a_pinned_entry_to_a_host_the_pin_validates( + hass: HomeAssistant, panel: Panel +) -> None: + """A panel that moved address is still the panel, and the entry has to follow it. + + The whole point of the host update: DHCP hands the panel a new lease, mDNS + announces it, and the entry has to end up there. The pin is what tells the + two cases apart, so the check has to pass here or the guard has simply + turned discovery off. + """ + entry = _entry_reached_at_the_address(hass, panel.ca_pem, panel.port) + + with _plaintext_bootstrap_stubbed(panel): + result = await _announce_from(hass, PANEL_LOOPBACK) + + assert result["type"] == FlowResultType.ABORT, result + assert result["reason"] == "already_configured" + assert entry.data[CONF_HOST] == PANEL_LOOPBACK + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_zeroconf_still_moves_an_unpinned_entry(hass: HomeAssistant, panel: Panel) -> None: + """An entry with no anchor has nothing to check against, and keeps what it had. + + There is no pin to protect and no check that could be made, so refusing the + move would only stop an unpinned entry following its panel — a regression + against the behaviour that shipped, bought with no security at all. + """ + entry = _entry_reached_at_the_address(hass, None, panel.port) + + with _plaintext_bootstrap_stubbed(panel): + result = await _announce_from(hass, PANEL_LOOPBACK) + + assert result["type"] == FlowResultType.ABORT, result + assert result["reason"] == "already_configured" + assert entry.data[CONF_HOST] == PANEL_LOOPBACK + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_re_adding_a_pinned_panel_by_an_unserved_name_leaves_its_host_alone( + hass: HomeAssistant, panel: Panel +) -> None: + """Re-adding by hand goes through the same update, and reaches it before the CA step. + + A user who types a single-label name for a panel already set up gets + `already_configured` either way — but the abort used to rewrite the entry's + host on the way out, to a name the panel's certificate does not carry. The + entry then could not connect to its own panel and there was no flow left to + correct it. + """ + entry = _pinned_entry(hass, panel) + + with _plaintext_bootstrap_stubbed(panel): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: PANEL_SHORTNAME, CONF_HTTP_PORT: 8080} + ) + + assert result["type"] == FlowResultType.ABORT, result + assert result["reason"] == "already_configured" + assert entry.data[CONF_HOST] == PANEL_LOOPBACK + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_re_adding_a_pinned_panel_by_a_name_it_serves_moves_the_entry( + hass: HomeAssistant, panel: Panel +) -> None: + """The same path must still let a user correct the host to a name that works.""" + panel.present( + [ + x509.DNSName(PANEL_SHORTNAME), + x509.IPAddress(ipaddress.ip_address(PANEL_LOOPBACK)), + ] + ) + entry = _pinned_entry(hass, panel) + + with _plaintext_bootstrap_stubbed(panel): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: PANEL_SHORTNAME, CONF_HTTP_PORT: 8080} + ) + + assert result["type"] == FlowResultType.ABORT, result + assert result["reason"] == "already_configured" + assert entry.data[CONF_HOST] == PANEL_SHORTNAME From 83ef9a718b5a1e1eaef1d3ef1a754aad76461639 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:52:44 -0700 Subject: [PATCH 171/345] refactor(config-flow): one implementation of which address reaches the panel `_async_choose_bootstrap_host` and `async_ca_signs_panel_leaf` had grown the same ten lines: try the host as given, and only if its certificate does not name it, try the address it resolves to. The flow's copy existed because it needs to know *which* candidate answered, not merely that one did. Give the shared helper that answer. `async_panel_leaf_host` returns the address the panel was reached on, `async_ca_signs_panel_leaf` becomes the boolean reading of it for callers that only want the verdict, and the flow keeps only what is genuinely its own: recording the substitution in `_bootstrap_host`, which is what lets every later step refuse to persist a host the pinned anchor rejects. No behaviour change. The tests that drove the flow's candidate logic move with the seam -- they patched the leaf check inside it, and now patch the helper that performs it; the port they assert on shifts one position because the shared signature takes `hass` first. --- custom_components/span_panel/config_flow.py | 59 +++++++--------- .../span_panel/config_flow_validation.py | 67 +++++++++++++------ tests/test_v2_config_flow.py | 48 ++++++------- 3 files changed, 95 insertions(+), 79 deletions(-) diff --git a/custom_components/span_panel/config_flow.py b/custom_components/span_panel/config_flow.py index e7e08183..01adeba8 100644 --- a/custom_components/span_panel/config_flow.py +++ b/custom_components/span_panel/config_flow.py @@ -47,10 +47,9 @@ as_port, async_fetch_panel_ca, async_leaf_chains_to_ca, - async_resolve_host, + async_panel_leaf_host, check_fqdn_tls_ready, is_fqdn, - is_ip_literal, panel_rest_transport, port_or_none, validate_host, @@ -237,46 +236,34 @@ def _require_transport(self) -> PanelRestTransport: async def _async_choose_bootstrap_host(self, ca_pem: str, tls_port: int) -> bool: """Settle which address the panel is dialled by, and whether it can be at all. - The host the user gave is tried first, always. A panel's certificate - names the addresses it already knows itself by -- its IP, its mDNS name, - an FQDN registered by a previous install, the hostname an add-on serves - under -- and where the leaf already names the host there is nothing to - work around and no reason to prefer anything else. Only when that fails - is the resolved address tried, which is the case that matters: an FQDN - joins the SAN when `register_fqdn` runs, and that is after - authentication, so a fresh FQDN install has to be bootstrapped over the - address until the panel has been told the name. - - Returns False when neither answers under the published CA, which is a - leaf mismatch and is fatal to the step that called it. A name that will - not resolve simply has one candidate, so it fails exactly as it did - before this address fallback existed. + Which addresses are tried, and in what order, is `async_panel_leaf_host` + -- one implementation, shared with the setup-time and repair-time + checks. What is this flow's own is the *consequence* of the answer: a + panel reached at something other than the host the user gave is a panel + whose certificate does not name that host, and `_bootstrap_host` records + exactly that, so every later step about to persist `self.host` can read + it as "the anchor would reject this" and refuse. + + Returns False when nothing answers under the published CA, which is a + leaf mismatch and is fatal to the step that called it. """ self._bootstrap_host = None host = self.host if not host: return False - candidates = [host] - if not is_ip_literal(host): - resolved = await async_resolve_host(self.hass, host) - if resolved is None: - _LOGGER.debug("Could not resolve %s to an address; only the name is tried", host) - elif resolved != host: - candidates.append(resolved) - - for candidate in candidates: - if await async_leaf_chains_to_ca(candidate, tls_port, ca_pem): - self._bootstrap_host = None if candidate == host else candidate - if self._bootstrap_host is not None: - _LOGGER.debug( - "The certificate this panel serves does not name %s; reaching it " - "at %s until it does", - host, - candidate, - ) - return True - return False + reached = await async_panel_leaf_host(self.hass, host, tls_port, ca_pem) + if reached is None: + return False + if reached != host: + self._bootstrap_host = reached + _LOGGER.debug( + "The certificate this panel serves does not name %s; reaching it " + "at %s until it does", + host, + reached, + ) + return True def ensure_flow_is_set_up(self) -> None: """Ensure the flow is set up.""" diff --git a/custom_components/span_panel/config_flow_validation.py b/custom_components/span_panel/config_flow_validation.py index 6fbb0350..680fb58b 100644 --- a/custom_components/span_panel/config_flow_validation.py +++ b/custom_components/span_panel/config_flow_validation.py @@ -245,6 +245,50 @@ def _check() -> bool: return await loop.run_in_executor(None, _check) +async def async_panel_leaf_host( + hass: HomeAssistant, host: str, tls_port: int, ca_pem: str +) -> str | None: + """Return the address that reaches the panel at `host` under `ca_pem`, or None. + + The single implementation of "which address does this panel answer on". + `host` itself when the certificate it serves already names it, the resolved + address when only that works, and None when neither does. + + The name is tried first, always: the panel's leaf names the addresses it + knows itself by, and where it already names the host there is nothing to + work around and no reason to prefer anything else. Only then the resolved + address, which is the case that matters -- a host recorded as a name (an + add-on's container hostname, a search-domain short name, an FQDN the panel + has not been told about) fails hostname verification against a perfectly + good certificate, and an FQDN joins the SAN only once `register_fqdn` has + run, which is after authentication. + + A host that will not resolve simply has one candidate and fails exactly as + it would have without this fallback. + + Callers differ in what they may do with a resolved answer. Dialling one is + always safe; *persisting* one, or persisting the name it stood in for, is + not -- see `SpanPanelConfigFlow._async_choose_bootstrap_host`, which records + the substitution precisely so the rest of the flow can refuse to store a + host the anchor rejects. + """ + if not host: + return None + if await async_leaf_chains_to_ca(host, tls_port, ca_pem): + return host + if is_ip_literal(host): + return None + resolved = await async_resolve_host(hass, host) + if resolved is None: + _LOGGER.debug("Could not resolve %s to an address; only the name is tried", host) + return None + if resolved == host: + return None + if await async_leaf_chains_to_ca(resolved, tls_port, ca_pem): + return resolved + return None + + async def async_ca_signs_panel_leaf( hass: HomeAssistant, host: str, tls_port: int, ca_pem: str ) -> bool: @@ -258,26 +302,11 @@ async def async_ca_signs_panel_leaf( change diagnosis finds the fetched CA and the pinned one identical and has no change to report, and the entry retries forever with no repair to offer. - The address fallback is the config flow's, for the same reason: the panel's - leaf names the addresses it knows itself by, so a host recorded as a name -- - an add-on's container hostname, a search-domain short name, an FQDN the - panel has not been told about -- fails hostname verification against a - perfectly good certificate. The name is tried first, always, because a panel - that already names it has nothing to work around. - - A host that will not resolve simply has one candidate and fails as it would - have anyway. + For callers that only need the verdict; `async_panel_leaf_host` carries the + address the panel was actually reached on, and why there is more than one + candidate. """ - if not host: - return False - if await async_leaf_chains_to_ca(host, tls_port, ca_pem): - return True - if is_ip_literal(host): - return False - resolved = await async_resolve_host(hass, host) - if resolved is None or resolved == host: - return False - return await async_leaf_chains_to_ca(resolved, tls_port, ca_pem) + return await async_panel_leaf_host(hass, host, tls_port, ca_pem) is not None async def validate_v2_proximity( diff --git a/tests/test_v2_config_flow.py b/tests/test_v2_config_flow.py index 41fb8cdd..3810afac 100644 --- a/tests/test_v2_config_flow.py +++ b/tests/test_v2_config_flow.py @@ -84,14 +84,14 @@ def panel_ca_available(): "custom_components.span_panel.config_flow.build_panel_ssl_context", return_value=MagicMock(), ), - # Nothing resolves in a test run — the harness refuses DNS outright — - # so the flow takes its documented fallback and dials the name it was - # given, which is what every assertion in this module was written - # against. The resolved-address bootstrap is exercised for real against - # a live listener in `test_v2_config_flow_tls.py`. + # Every panel here is reached at the host it was given: no DNS (the + # harness refuses it outright) and no address substituted for a name, + # which is what every assertion in this module was written against. The + # resolved-address bootstrap is exercised for real against a live + # listener in `test_v2_config_flow_tls.py`. patch( - "custom_components.span_panel.config_flow.async_resolve_host", - new=AsyncMock(return_value=None), + "custom_components.span_panel.config_flow.async_panel_leaf_host", + new=AsyncMock(side_effect=lambda _hass, host, _port, _pem: host), ), ): yield @@ -509,8 +509,8 @@ async def test_a_ca_that_does_not_sign_what_the_panel_serves_is_not_offered( ), patch("custom_components.span_panel.config_flow.validate_host", return_value=True), patch( - "custom_components.span_panel.config_flow.async_leaf_chains_to_ca", - new=AsyncMock(return_value=False), + "custom_components.span_panel.config_flow.async_panel_leaf_host", + new=AsyncMock(return_value=None), ), ): result = await _reach_the_ca_step(hass) @@ -1080,8 +1080,8 @@ async def test_reauth_refuses_to_send_the_passphrase_when_the_leaf_does_not_chai return_value=MOCK_V2_DETECTION, ), patch( - "custom_components.span_panel.config_flow.async_leaf_chains_to_ca", - new=AsyncMock(return_value=False), + "custom_components.span_panel.config_flow.async_panel_leaf_host", + new=AsyncMock(return_value=None), ), patch( "custom_components.span_panel.config_flow_validation.register_v2", @@ -1257,7 +1257,7 @@ async def test_reauth_asks_where_a_moved_panel_serves_tls_and_keeps_the_answer( """An entry behind a proxy has to say where the leaf is before it can be checked.""" entry = _unpinned_entry(hass, {CONF_API_VERSION: "v2", CONF_HTTP_PORT: 8080}) - leaf_check = AsyncMock(return_value=True) + leaf_host = AsyncMock(side_effect=lambda _hass, host, _port, _pem: host) with ( patch( @@ -1265,8 +1265,8 @@ async def test_reauth_asks_where_a_moved_panel_serves_tls_and_keeps_the_answer( return_value=MOCK_V2_DETECTION, ), patch( - "custom_components.span_panel.config_flow.async_leaf_chains_to_ca", - new=leaf_check, + "custom_components.span_panel.config_flow.async_panel_leaf_host", + new=leaf_host, ), patch( "custom_components.span_panel.config_flow.validate_v2_passphrase", @@ -1292,8 +1292,8 @@ async def test_reauth_asks_where_a_moved_panel_serves_tls_and_keeps_the_answer( assert done["reason"] == "reauth_successful" # Checked on the port the user named, and stored so the pin keeps pointing there. - assert leaf_check.await_args is not None - assert leaf_check.await_args.args[1] == 9443 + assert leaf_host.await_args is not None + assert leaf_host.await_args.args[2] == 9443 assert entry.data[CONF_HTTPS_PORT] == 9443 assert entry.data[CONF_PANEL_CA_PEM] == FAKE_CA_PEM @@ -2322,7 +2322,7 @@ async def test_hassio_published_https_port_is_used_without_asking(hass: HomeAssi for a number they would have to go read out of an add-on log is a question with a worse answer available. """ - leaf_check = AsyncMock(return_value=True) + leaf_host = AsyncMock(side_effect=lambda _hass, host, _port, _pem: host) with ( patch( "custom_components.span_panel.config_flow.detect_api_version", @@ -2333,8 +2333,8 @@ async def test_hassio_published_https_port_is_used_without_asking(hass: HomeAssi return_value=MOCK_V2_AUTH, ), patch( - "custom_components.span_panel.config_flow.async_leaf_chains_to_ca", - new=leaf_check, + "custom_components.span_panel.config_flow.async_panel_leaf_host", + new=leaf_host, ), ): result = await hass.config_entries.flow.async_init( @@ -2349,7 +2349,7 @@ async def test_hassio_published_https_port_is_used_without_asking(hass: HomeAssi assert result2["step_id"] == "choose_v2_auth" # The published port is the one the CA was checked against, not 443. - assert leaf_check.await_args.args[1] == 10090 + assert leaf_host.await_args.args[2] == 10090 result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], {"next_step_id": "auth_passphrase"} @@ -2381,7 +2381,7 @@ async def test_zeroconf_https_port_txt_record_is_used_without_asking( type="_ebus._tcp.local.", ) - leaf_check = AsyncMock(return_value=True) + leaf_host = AsyncMock(side_effect=lambda _hass, host, _port, _pem: host) with ( patch( "custom_components.span_panel.config_flow.detect_api_version", @@ -2392,8 +2392,8 @@ async def test_zeroconf_https_port_txt_record_is_used_without_asking( return_value=True, ), patch( - "custom_components.span_panel.config_flow.async_leaf_chains_to_ca", - new=leaf_check, + "custom_components.span_panel.config_flow.async_panel_leaf_host", + new=leaf_host, ), ): result = await hass.config_entries.flow.async_init( @@ -2405,7 +2405,7 @@ async def test_zeroconf_https_port_txt_record_is_used_without_asking( result2 = await _submit_host_and_pin(hass, result["flow_id"], {}) assert result2["step_id"] == "choose_v2_auth" - assert leaf_check.await_args.args[1] == 9081 + assert leaf_host.await_args.args[2] == 9081 @pytest.mark.asyncio From 115e4777a25b8fac2199f57467eb8471db367eac Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:01:28 -0700 Subject: [PATCH 172/345] fix(services): rotate_credentials names its panel, reports a failed reload, and never downgrades to plaintext Three defects in one service, all of them about a call that invalidates a credential and cannot be taken back. With no `config_entry_id` the handler took the first loaded v2 entry it found. Rotating stops the previous broker password working for every other local client of that panel, so a multi-panel admin who omitted the field broke a panel they did not name. More than one loaded panel is now refused as ambiguous; a single panel stays unambiguous and keeps the field optional. `async_reload` returns whether the entry came back, and its result was dropped: the service logged "Rotated" and reported success even when the integration never reloaded onto the new password. It now raises, and the message says the password was stored so nobody rotates a second time and invalidates the one the panel has already accepted. `panel_rest_transport` falls back to plaintext when a stored PEM will not parse, which is right for a caller whose alternative is not working at all and wrong for this one: the rotation carries the access token out and the new broker password back. The transport helper grows an `allow_plaintext_fallback=False` opt-out that raises `PanelCaUnusableError` instead of downgrading, and the service refuses rather than sending secrets unverified. An entry with no stored CA at all is not pinned in the first place and keeps the plaintext transport it has always used. --- .../span_panel/config_flow_validation.py | 31 ++++++- custom_components/span_panel/services.py | 72 ++++++++++++--- custom_components/span_panel/strings.json | 11 ++- .../span_panel/translations/en.json | 11 ++- .../span_panel/translations/es.json | 11 ++- .../span_panel/translations/fr.json | 11 ++- .../span_panel/translations/ja.json | 11 ++- .../span_panel/translations/pt.json | 11 ++- tests/test_rotate_credentials_service.py | 89 ++++++++++++++++++- 9 files changed, 236 insertions(+), 22 deletions(-) diff --git a/custom_components/span_panel/config_flow_validation.py b/custom_components/span_panel/config_flow_validation.py index 680fb58b..1518e917 100644 --- a/custom_components/span_panel/config_flow_validation.py +++ b/custom_components/span_panel/config_flow_validation.py @@ -395,8 +395,19 @@ def as_port(value: object, default: int) -> int: return default if parsed is None else parsed +class PanelCaUnusableError(Exception): + """An entry stores a panel CA that cannot be turned into a trust anchor. + + Raised only for a caller that passed `allow_plaintext_fallback=False`; the + default path logs and downgrades, as it always has. + """ + + def panel_rest_transport( - hass: HomeAssistant, entry_data: Mapping[str, object] + hass: HomeAssistant, + entry_data: Mapping[str, object], + *, + allow_plaintext_fallback: bool = True, ) -> PanelRestTransport: """Decide how this entry's REST calls should reach the panel. @@ -405,12 +416,28 @@ def panel_rest_transport( somebody edits `.storage` by hand, which is a worse outcome than the transport this entry had before it was pinned — and the CA-changed repair is the path back to a good pin. + + That trade is right for a caller whose alternative is not working at all. It + is wrong for one carrying a secret: a credential rotation sends the access + token out and a fresh broker password back, and doing that unverified undoes + the pin at the one moment it matters most. Such a caller passes + `allow_plaintext_fallback=False` and gets `PanelCaUnusableError` instead of a + quiet downgrade. An entry with no stored PEM at all is not pinned in the first + place and still gets the plaintext transport either way — the flag refuses a + downgrade, it does not invent a pin. + + Raises: + PanelCaUnusableError: `allow_plaintext_fallback` is False and the stored + PEM is not a certificate this system accepts. + """ pem = entry_data.get(CONF_PANEL_CA_PEM) if pem: try: context = build_panel_ssl_context(str(pem)) - except (ssl.SSLError, ValueError): + except (ssl.SSLError, ValueError) as err: + if not allow_plaintext_fallback: + raise PanelCaUnusableError(str(err)) from err _LOGGER.warning( "The stored panel CA is not a certificate this system accepts; " "falling back to plaintext HTTP for REST calls" diff --git a/custom_components/span_panel/services.py b/custom_components/span_panel/services.py index f926c99c..b2c73b55 100644 --- a/custom_components/span_panel/services.py +++ b/custom_components/span_panel/services.py @@ -7,7 +7,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, SupportsResponse -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er from span_panel_api import regenerate_passphrase from span_panel_api.exceptions import ( @@ -18,7 +18,7 @@ ) import voluptuous as vol -from .config_flow_validation import panel_rest_transport +from .config_flow_validation import PanelCaUnusableError, panel_rest_transport from .const import ( CONF_API_VERSION, CONF_EBUS_BROKER_PASSWORD, @@ -693,9 +693,16 @@ def _async_register_credential_services(hass: HomeAssistant) -> None: """Register credential-rotation services.""" def _get_v2_entry(config_entry_id: str | None) -> ConfigEntry: - """Return the loaded v2 entry to rotate, or explain why there isn't one.""" + """Return the loaded v2 entry to rotate, or explain why there isn't one. + + With the id omitted and more than one panel loaded there is no defensible + default: rotating invalidates the broker password every other local client + of that panel is using, so picking one and hoping is worse than asking. + A single loaded panel is unambiguous and the id stays optional there. + """ from . import SpanPanelRuntimeData # pylint: disable=import-outside-toplevel + candidates: list[ConfigEntry] = [] for entry in hass.config_entries.async_loaded_entries(DOMAIN): if config_entry_id is not None and entry.entry_id != config_entry_id: continue @@ -705,13 +712,24 @@ def _get_v2_entry(config_entry_id: str | None) -> ConfigEntry: continue if entry.data.get(CONF_API_VERSION) != "v2": continue - return entry + candidates.append(entry) - raise ServiceValidationError( - "No loaded SPAN panel using the v2 API was found.", - translation_domain=DOMAIN, - translation_key="rotate_credentials_no_entry", - ) + if not candidates: + raise ServiceValidationError( + "No loaded SPAN panel using the v2 API was found.", + translation_domain=DOMAIN, + translation_key="rotate_credentials_no_entry", + ) + + if config_entry_id is None and len(candidates) > 1: + raise ServiceValidationError( + "More than one SPAN panel is loaded. Name the panel to rotate " + "with the config entry field.", + translation_domain=DOMAIN, + translation_key="rotate_credentials_multiple_panels", + ) + + return candidates[0] async def async_handle_rotate_credentials(call: ServiceCall) -> None: await _async_require_admin_caller(hass, call) @@ -728,8 +746,21 @@ async def async_handle_rotate_credentials(call: ServiceCall) -> None: # Over the pinned CA where this entry has one. A credential-rotation # service that delivered fresh secrets over unverified HTTP would undo - # the point of pinning at the one moment it matters most. - transport = panel_rest_transport(hass, entry.data) + # the point of pinning at the one moment it matters most, so an entry + # whose stored CA no longer parses is refused rather than downgraded. + # (An entry that was never pinned is plaintext by design; that is the + # transport it has always used, and this service does not change it.) + try: + transport = panel_rest_transport(hass, entry.data, allow_plaintext_fallback=False) + except PanelCaUnusableError as err: + raise ServiceValidationError( + "The stored certificate authority for this SPAN panel cannot be " + "read, so the rotation would have to travel unencrypted. " + "Nothing was changed.", + translation_domain=DOMAIN, + translation_key="rotate_credentials_ca_unusable", + ) from err + try: new_password = await regenerate_passphrase( host, @@ -764,8 +795,23 @@ async def async_handle_rotate_credentials(call: ServiceCall) -> None: # Awaited rather than fired and forgotten: the running client still # holds the password the panel just invalidated, so the call should not - # report success until the entry is back up on the new one. - await hass.config_entries.async_reload(entry.entry_id) + # report success until the entry is back up on the new one. A reload + # that fails is reported as a failure of the call — the stored password + # stays, because it is the one the panel now accepts and re-rotating + # would only invalidate it again. + if not await hass.config_entries.async_reload(entry.entry_id): + _LOGGER.error( + "Stored a new MQTT broker credential for SPAN panel entry %s, " + "but the entry did not reload onto it", + entry.entry_id, + ) + raise HomeAssistantError( + "The new broker password was stored, but the SPAN panel " + "integration did not come back up on it. Check the logs and " + "reload the entry.", + translation_domain=DOMAIN, + translation_key="rotate_credentials_reload_failed", + ) _LOGGER.info( "Rotated the MQTT broker credential for SPAN panel entry %s", diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index 55c9145d..3b8cb0a4 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -508,15 +508,24 @@ "rotate_credentials_auth_failed": { "message": "The SPAN Panel rejected the stored access token. Reauthenticate the integration before rotating credentials." }, + "rotate_credentials_ca_unusable": { + "message": "The certificate authority stored for this SPAN Panel cannot be read, so the rotation would have to travel unencrypted. Nothing was changed. Repair the panel's certificate authority, then rotate again." + }, "rotate_credentials_failed": { "message": "The SPAN Panel at {host} did not complete the credential rotation. The existing credentials are unchanged." }, "rotate_credentials_no_entry": { "message": "No loaded SPAN panel using the v2 API was found." }, + "rotate_credentials_multiple_panels": { + "message": "More than one SPAN Panel is loaded. Choose the panel to rotate in the config entry field: rotating stops the previous broker password working for every other client of that panel." + }, "rotate_credentials_no_token": { "message": "This SPAN panel has no stored access token. Reauthenticate the integration before rotating credentials." }, + "rotate_credentials_reload_failed": { + "message": "The new broker password was stored, but the SPAN Panel integration did not come back up on it. The panel has already accepted the new password, so do not rotate again: check the logs and reload the integration." + }, "rotate_credentials_requires_admin": { "message": "Only a Home Assistant administrator can rotate SPAN Panel credentials." }, @@ -610,7 +619,7 @@ }, "config_entry_id": { "name": "Config entry", - "description": "Optional config entry id. Only needed when more than one SPAN panel is configured." + "description": "Which panel to rotate. Optional with a single panel; required when more than one SPAN Panel is loaded." } } }, diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index 55c9145d..3b8cb0a4 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -508,15 +508,24 @@ "rotate_credentials_auth_failed": { "message": "The SPAN Panel rejected the stored access token. Reauthenticate the integration before rotating credentials." }, + "rotate_credentials_ca_unusable": { + "message": "The certificate authority stored for this SPAN Panel cannot be read, so the rotation would have to travel unencrypted. Nothing was changed. Repair the panel's certificate authority, then rotate again." + }, "rotate_credentials_failed": { "message": "The SPAN Panel at {host} did not complete the credential rotation. The existing credentials are unchanged." }, "rotate_credentials_no_entry": { "message": "No loaded SPAN panel using the v2 API was found." }, + "rotate_credentials_multiple_panels": { + "message": "More than one SPAN Panel is loaded. Choose the panel to rotate in the config entry field: rotating stops the previous broker password working for every other client of that panel." + }, "rotate_credentials_no_token": { "message": "This SPAN panel has no stored access token. Reauthenticate the integration before rotating credentials." }, + "rotate_credentials_reload_failed": { + "message": "The new broker password was stored, but the SPAN Panel integration did not come back up on it. The panel has already accepted the new password, so do not rotate again: check the logs and reload the integration." + }, "rotate_credentials_requires_admin": { "message": "Only a Home Assistant administrator can rotate SPAN Panel credentials." }, @@ -610,7 +619,7 @@ }, "config_entry_id": { "name": "Config entry", - "description": "Optional config entry id. Only needed when more than one SPAN panel is configured." + "description": "Which panel to rotate. Optional with a single panel; required when more than one SPAN Panel is loaded." } } }, diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index 28c7f687..bc886572 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -508,15 +508,24 @@ "rotate_credentials_auth_failed": { "message": "El SPAN Panel rechazó el token de acceso almacenado. Vuelva a autenticar la integración antes de rotar las credenciales." }, + "rotate_credentials_ca_unusable": { + "message": "No se puede leer la autoridad de certificación almacenada para este SPAN Panel, por lo que la rotación tendría que viajar sin cifrar. No se ha cambiado nada. Repare la autoridad de certificación del panel y vuelva a rotar." + }, "rotate_credentials_failed": { "message": "El SPAN Panel en {host} no completó la rotación de credenciales. Las credenciales existentes no han cambiado." }, + "rotate_credentials_multiple_panels": { + "message": "Hay más de un SPAN Panel cargado. Elija en el campo de entrada de configuración el panel que desea rotar: al rotar, la contraseña anterior del broker deja de funcionar para todos los demás clientes de ese panel." + }, "rotate_credentials_no_entry": { "message": "No se encontró ningún SPAN Panel cargado que use la API v2." }, "rotate_credentials_no_token": { "message": "Este SPAN Panel no tiene un token de acceso almacenado. Vuelva a autenticar la integración antes de rotar las credenciales." }, + "rotate_credentials_reload_failed": { + "message": "Se guardó la nueva contraseña del broker, pero la integración de SPAN Panel no volvió a iniciarse con ella. El panel ya aceptó la nueva contraseña, así que no vuelva a rotar: revise los registros y recargue la integración." + }, "rotate_credentials_requires_admin": { "message": "Solo un administrador de Home Assistant puede rotar las credenciales del SPAN Panel." }, @@ -860,7 +869,7 @@ "fields": { "config_entry_id": { "name": "Entrada de configuración", - "description": "Id de entrada de configuración opcional. Solo es necesario cuando hay más de un SPAN Panel configurado." + "description": "Qué panel rotar. Opcional con un solo panel; obligatorio cuando hay más de un SPAN Panel cargado." } } } diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index 9d610874..08220802 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -508,15 +508,24 @@ "rotate_credentials_auth_failed": { "message": "Le SPAN Panel a rejeté le jeton d'accès enregistré. Réauthentifiez l'intégration avant de renouveler les identifiants." }, + "rotate_credentials_ca_unusable": { + "message": "L'autorité de certification enregistrée pour ce SPAN Panel est illisible ; la rotation devrait donc circuler en clair. Rien n'a été modifié. Réparez l'autorité de certification du panneau, puis relancez la rotation." + }, "rotate_credentials_failed": { "message": "Le SPAN Panel à l'adresse {host} n'a pas terminé le renouvellement des identifiants. Les identifiants existants sont inchangés." }, + "rotate_credentials_multiple_panels": { + "message": "Plusieurs SPAN Panel sont chargés. Choisissez le panneau à faire tourner dans le champ d'entrée de configuration : la rotation empêche l'ancien mot de passe du broker de fonctionner pour tous les autres clients de ce panneau." + }, "rotate_credentials_no_entry": { "message": "Aucun SPAN Panel chargé utilisant l'API v2 n'a été trouvé." }, "rotate_credentials_no_token": { "message": "Ce SPAN Panel n'a aucun jeton d'accès enregistré. Réauthentifiez l'intégration avant de renouveler les identifiants." }, + "rotate_credentials_reload_failed": { + "message": "Le nouveau mot de passe du broker a été enregistré, mais l'intégration SPAN Panel n'est pas revenue en service avec celui-ci. Le panneau a déjà accepté le nouveau mot de passe : ne relancez pas la rotation, consultez les journaux et rechargez l'intégration." + }, "rotate_credentials_requires_admin": { "message": "Seul un administrateur Home Assistant peut renouveler les identifiants du SPAN Panel." }, @@ -860,7 +869,7 @@ "fields": { "config_entry_id": { "name": "Entrée de configuration", - "description": "Id d'entrée de configuration facultatif. Nécessaire uniquement lorsque plusieurs SPAN Panel sont configurés." + "description": "Le panneau à faire tourner. Facultatif avec un seul panneau ; obligatoire lorsque plusieurs SPAN Panel sont chargés." } } } diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index b3174849..62d6d721 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -508,15 +508,24 @@ "rotate_credentials_auth_failed": { "message": "SPAN Panel が保存済みのアクセストークンを拒否しました。認証情報をローテーションする前に、統合を再認証してください。" }, + "rotate_credentials_ca_unusable": { + "message": "この SPAN Panel に保存されている認証局を読み取れないため、認証情報のローテーションは暗号化されない通信になります。変更は行われていません。パネルの認証局を修復してから、もう一度ローテーションしてください。" + }, "rotate_credentials_failed": { "message": "{host} の SPAN Panel が認証情報のローテーションを完了しませんでした。既存の認証情報は変更されていません。" }, + "rotate_credentials_multiple_panels": { + "message": "複数の SPAN Panel が読み込まれています。ローテーションするパネルを設定エントリのフィールドで選択してください。ローテーションすると、そのパネルの他のすべてのクライアントで以前のブローカーパスワードが使用できなくなります。" + }, "rotate_credentials_no_entry": { "message": "v2 API を使用する読み込み済みの SPAN Panel が見つかりません。" }, "rotate_credentials_no_token": { "message": "この SPAN Panel には保存済みのアクセストークンがありません。認証情報をローテーションする前に、統合を再認証してください。" }, + "rotate_credentials_reload_failed": { + "message": "新しいブローカーパスワードは保存されましたが、SPAN Panel 統合はそのパスワードで再起動できませんでした。パネルはすでに新しいパスワードを受け入れているため、再度ローテーションしないでください。ログを確認して統合を再読み込みしてください。" + }, "rotate_credentials_requires_admin": { "message": "SPAN Panel の認証情報をローテーションできるのは Home Assistant の管理者だけです。" }, @@ -860,7 +869,7 @@ "fields": { "config_entry_id": { "name": "設定エントリ", - "description": "任意の設定エントリ id。SPAN Panel を複数設定している場合にのみ必要です。" + "description": "ローテーションするパネル。パネルが 1 台の場合は任意ですが、複数の SPAN Panel が読み込まれている場合は必須です。" } } } diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index 696d844b..dc3008bf 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -508,15 +508,24 @@ "rotate_credentials_auth_failed": { "message": "O SPAN Panel rejeitou o token de acesso guardado. Volte a autenticar a integração antes de rodar as credenciais." }, + "rotate_credentials_ca_unusable": { + "message": "A autoridade de certificação guardada para este SPAN Panel não pode ser lida, pelo que a rotação teria de viajar sem encriptação. Nada foi alterado. Repare a autoridade de certificação do painel e rode novamente." + }, "rotate_credentials_failed": { "message": "O SPAN Panel em {host} não concluiu a rotação de credenciais. As credenciais existentes mantêm-se inalteradas." }, + "rotate_credentials_multiple_panels": { + "message": "Está carregado mais do que um SPAN Panel. Escolha o painel a rodar no campo da entrada de configuração: a rotação faz com que a palavra-passe anterior do broker deixe de funcionar para todos os outros clientes desse painel." + }, "rotate_credentials_no_entry": { "message": "Não foi encontrado nenhum SPAN Panel carregado que utilize a API v2." }, "rotate_credentials_no_token": { "message": "Este SPAN Panel não tem um token de acesso guardado. Volte a autenticar a integração antes de rodar as credenciais." }, + "rotate_credentials_reload_failed": { + "message": "A nova palavra-passe do broker foi guardada, mas a integração SPAN Panel não voltou a arrancar com ela. O painel já aceitou a nova palavra-passe, por isso não rode novamente: consulte os registos e recarregue a integração." + }, "rotate_credentials_requires_admin": { "message": "Apenas um administrador do Home Assistant pode rodar as credenciais do SPAN Panel." }, @@ -860,7 +869,7 @@ "fields": { "config_entry_id": { "name": "Entrada de configuração", - "description": "Id da entrada de configuração, opcional. Apenas necessário quando existe mais do que um SPAN Panel configurado." + "description": "Que painel rodar. Opcional com um único painel; obrigatório quando está carregado mais do que um SPAN Panel." } } } diff --git a/tests/test_rotate_credentials_service.py b/tests/test_rotate_credentials_service.py index a39894e3..69bdbd80 100644 --- a/tests/test_rotate_credentials_service.py +++ b/tests/test_rotate_credentials_service.py @@ -17,12 +17,13 @@ CONF_EBUS_BROKER_PASSWORD, CONF_EBUS_BROKER_PORT, CONF_EBUS_BROKER_USERNAME, + CONF_PANEL_CA_PEM, DOMAIN, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.core import Context, HomeAssistant -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from pytest_homeassistant_custom_component.common import MockConfigEntry, MockUser @@ -288,3 +289,89 @@ async def test_entry_without_runtime_data_is_skipped(hass: HomeAssistant) -> Non await _call_rotate(hass, _admin_context(hass)) assert err.value.translation_key == "rotate_credentials_no_entry" + + +@pytest.mark.asyncio +async def test_two_panels_and_no_id_refuses_rather_than_picking_one( + hass: HomeAssistant, +) -> None: + """With two panels loaded, an omitted id is ambiguous, not a default.""" + first = _add_v2_entry(hass) + second = MockConfigEntry( + domain=DOMAIN, + version=7, + data=dict(first.data) | {CONF_HOST: "192.168.1.101"}, + entry_id="span_entry_two", + unique_id="sp3-test-004", + ) + second.add_to_hass(hass) + second.mock_state(hass, ConfigEntryState.LOADED) + second.runtime_data = SpanPanelRuntimeData( + coordinator=MagicMock(), + panel_device_id="panel-device-id-two", + ) + _async_register_credential_services(hass) + + with ( + patch( + "custom_components.span_panel.services.regenerate_passphrase", + AsyncMock(return_value=NEW_BROKER_PASSWORD), + ) as rotate, + patch.object(hass.config_entries, "async_reload", AsyncMock(return_value=True)), + pytest.raises(ServiceValidationError) as err, + ): + await _call_rotate(hass, _admin_context(hass)) + + assert err.value.translation_key == "rotate_credentials_multiple_panels" + rotate.assert_not_awaited() + assert first.data[CONF_EBUS_BROKER_PASSWORD] == OLD_BROKER_PASSWORD + assert second.data[CONF_EBUS_BROKER_PASSWORD] == OLD_BROKER_PASSWORD + + +@pytest.mark.asyncio +async def test_a_reload_that_fails_is_reported_with_the_password_already_stored( + hass: HomeAssistant, +) -> None: + """The panel did not come back on the new password; the caller must hear it.""" + entry = _add_v2_entry(hass) + _async_register_credential_services(hass) + + with ( + patch( + "custom_components.span_panel.services.regenerate_passphrase", + AsyncMock(return_value=NEW_BROKER_PASSWORD), + ), + patch.object(hass.config_entries, "async_reload", AsyncMock(return_value=False)), + pytest.raises(HomeAssistantError) as err, + ): + await _call_rotate(hass, _admin_context(hass)) + + assert err.value.translation_key == "rotate_credentials_reload_failed" + # The panel has already issued it, so the entry must keep the new one. + assert entry.data[CONF_EBUS_BROKER_PASSWORD] == NEW_BROKER_PASSWORD + + +@pytest.mark.asyncio +async def test_an_unusable_stored_ca_refuses_rather_than_rotating_in_the_clear( + hass: HomeAssistant, +) -> None: + """A rotation carries the token out and the new password back; never unpinned.""" + entry = _add_v2_entry(hass) + hass.config_entries.async_update_entry( + entry, data=dict(entry.data) | {CONF_PANEL_CA_PEM: "not a certificate"} + ) + _async_register_credential_services(hass) + + with ( + patch( + "custom_components.span_panel.services.regenerate_passphrase", + AsyncMock(return_value=NEW_BROKER_PASSWORD), + ) as rotate, + patch.object(hass.config_entries, "async_reload", AsyncMock(return_value=True)), + pytest.raises(ServiceValidationError) as err, + ): + await _call_rotate(hass, _admin_context(hass)) + + assert err.value.translation_key == "rotate_credentials_ca_unusable" + rotate.assert_not_awaited() + assert entry.data[CONF_EBUS_BROKER_PASSWORD] == OLD_BROKER_PASSWORD From 2a3ac790604b1ed13e5bd999a319caccaae88bb3 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:10:00 -0700 Subject: [PATCH 173/345] test(services): rotate_credentials tests pass ruff, and two wording nits The test file was excluded from the repo's own ruff hooks and had drifted: one unsorted import block and six nested `with` statements. Sorted and folded into parenthesised context managers, including the five that predate this task's tests. No behaviour change; the same 13 tests pass. The in-code fallback message for `rotate_credentials_ca_unusable` said what went wrong but not what to do about it, while the translated string carried the remedy. Anyone reading the untranslated fallback got the worse half. `PanelCaUnusableError` subclasses `ValueError`: an unparseable stored value is what it is, and `build_panel_ssl_context` already raises `ValueError` for the same condition, so a caller catching that around either function keeps catching this one. --- .../span_panel/config_flow_validation.py | 7 +++- custom_components/span_panel/services.py | 3 +- tests/test_rotate_credentials_service.py | 35 ++++++++----------- 3 files changed, 23 insertions(+), 22 deletions(-) diff --git a/custom_components/span_panel/config_flow_validation.py b/custom_components/span_panel/config_flow_validation.py index 1518e917..4339e402 100644 --- a/custom_components/span_panel/config_flow_validation.py +++ b/custom_components/span_panel/config_flow_validation.py @@ -395,11 +395,16 @@ def as_port(value: object, default: int) -> int: return default if parsed is None else parsed -class PanelCaUnusableError(Exception): +class PanelCaUnusableError(ValueError): """An entry stores a panel CA that cannot be turned into a trust anchor. Raised only for a caller that passed `allow_plaintext_fallback=False`; the default path logs and downgrades, as it always has. + + A `ValueError`, because that is what a stored value that cannot be parsed + is, and because it is one of the two exceptions `build_panel_ssl_context` + already raises for the same condition -- a caller that catches `ValueError` + around either function keeps catching this. """ diff --git a/custom_components/span_panel/services.py b/custom_components/span_panel/services.py index b2c73b55..c4b93191 100644 --- a/custom_components/span_panel/services.py +++ b/custom_components/span_panel/services.py @@ -756,7 +756,8 @@ async def async_handle_rotate_credentials(call: ServiceCall) -> None: raise ServiceValidationError( "The stored certificate authority for this SPAN panel cannot be " "read, so the rotation would have to travel unencrypted. " - "Nothing was changed.", + "Nothing was changed. Repair the panel's certificate authority, " + "then rotate again.", translation_domain=DOMAIN, translation_key="rotate_credentials_ca_unusable", ) from err diff --git a/tests/test_rotate_credentials_service.py b/tests/test_rotate_credentials_service.py index 69bdbd80..2c510db8 100644 --- a/tests/test_rotate_credentials_service.py +++ b/tests/test_rotate_credentials_service.py @@ -4,7 +4,12 @@ from unittest.mock import AsyncMock, MagicMock, patch +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry, MockUser from span_panel_api.exceptions import SpanPanelAuthError, SpanPanelConnectionError from custom_components.span_panel import ( @@ -20,12 +25,6 @@ CONF_PANEL_CA_PEM, DOMAIN, ) -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST -from homeassistant.core import Context, HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError - -from pytest_homeassistant_custom_component.common import MockConfigEntry, MockUser OLD_BROKER_PASSWORD = "old-broker-password" NEW_BROKER_PASSWORD = "new-broker-password" @@ -114,9 +113,8 @@ async def test_non_admin_is_refused(hass: HomeAssistant) -> None: with patch( "custom_components.span_panel.services.regenerate_passphrase", AsyncMock(return_value=NEW_BROKER_PASSWORD), - ) as rotate: - with pytest.raises(ServiceValidationError) as err: - await _call_rotate(hass, _non_admin_context(hass)) + ) as rotate, pytest.raises(ServiceValidationError) as err: + await _call_rotate(hass, _non_admin_context(hass)) assert err.value.translation_key == "rotate_credentials_requires_admin" rotate.assert_not_awaited() @@ -132,9 +130,8 @@ async def test_contextless_call_is_refused(hass: HomeAssistant) -> None: with patch( "custom_components.span_panel.services.regenerate_passphrase", AsyncMock(return_value=NEW_BROKER_PASSWORD), - ) as rotate: - with pytest.raises(ServiceValidationError) as err: - await _call_rotate(hass, None) + ) as rotate, pytest.raises(ServiceValidationError) as err: + await _call_rotate(hass, None) assert err.value.translation_key == "rotate_credentials_requires_user" rotate.assert_not_awaited() @@ -168,9 +165,9 @@ async def test_connection_failure_leaves_the_old_password_in_place( AsyncMock(side_effect=SpanPanelConnectionError("unreachable")), ), patch.object(hass.config_entries, "async_reload", reload_mock), + pytest.raises(ServiceValidationError) as err, ): - with pytest.raises(ServiceValidationError) as err: - await _call_rotate(hass, _admin_context(hass)) + await _call_rotate(hass, _admin_context(hass)) assert err.value.translation_key == "rotate_credentials_failed" assert entry.data[CONF_EBUS_BROKER_PASSWORD] == OLD_BROKER_PASSWORD @@ -186,9 +183,8 @@ async def test_rejected_token_asks_for_reauthentication(hass: HomeAssistant) -> with patch( "custom_components.span_panel.services.regenerate_passphrase", AsyncMock(side_effect=SpanPanelAuthError("401")), - ): - with pytest.raises(ServiceValidationError) as err: - await _call_rotate(hass, _admin_context(hass)) + ), pytest.raises(ServiceValidationError) as err: + await _call_rotate(hass, _admin_context(hass)) assert err.value.translation_key == "rotate_credentials_auth_failed" assert entry.data[CONF_EBUS_BROKER_PASSWORD] == OLD_BROKER_PASSWORD @@ -208,9 +204,8 @@ async def test_missing_access_token_is_reported_before_any_call( with patch( "custom_components.span_panel.services.regenerate_passphrase", AsyncMock(return_value=NEW_BROKER_PASSWORD), - ) as rotate: - with pytest.raises(ServiceValidationError) as err: - await _call_rotate(hass, _admin_context(hass)) + ) as rotate, pytest.raises(ServiceValidationError) as err: + await _call_rotate(hass, _admin_context(hass)) assert err.value.translation_key == "rotate_credentials_no_token" rotate.assert_not_awaited() From 2cc502d9d597d4ac6e97473d4159a91af961e89a Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:11:32 -0700 Subject: [PATCH 174/345] fix(coordinator): a dead transport makes entities unavailable, not zero A CA change mid-session marked the panel offline before raising UpdateFailed, and offline is the flag that tells entities to keep answering: `available` returns True on that branch ahead of `last_update_success`, so every sensor stayed available, POWER sensors reported 0.0 and energy sensors held their grace values -- indefinitely, since nothing retries and re-pinning requires a person. A dashboard cannot tell that from a live reading of a panel drawing no power. Offline now means only what its consumers assume: a gap that closes. `transport_dead` is the separate fact -- set from the library's fatal-error channel and from the CA branch, cleared by a connection edge back to connected -- and every platform's `available` consults one helper on `SpanPanelEntity` ahead of whatever it does about offline, so the six copies of that decision cannot drift apart again. `panel_status` is the single exception, because it reports reachability and so has to survive the condition it reports; it reads the flag directly and goes off rather than holding a stale on. --- custom_components/span_panel/binary_sensor.py | 20 +- custom_components/span_panel/button.py | 2 + custom_components/span_panel/coordinator.py | 97 +++++++++- custom_components/span_panel/entity.py | 25 +++ custom_components/span_panel/number.py | 2 + custom_components/span_panel/select.py | 2 + custom_components/span_panel/sensor_base.py | 8 + custom_components/span_panel/switch.py | 2 + tests/test_adoption.py | 1 + tests/test_bess_telemetry.py | 1 + tests/test_binary_sensor_platform.py | 1 + tests/test_button.py | 1 + tests/test_control_lock_state.py | 1 + tests/test_der_link_health.py | 1 + tests/test_diagnostics.py | 2 + tests/test_dps_and_bess.py | 1 + tests/test_energy_dip_compensation.py | 1 + tests/test_evse_charge_limit.py | 1 + tests/test_metadata_sweep.py | 1 + tests/test_panel_ca_pinning.py | 176 +++++++++++++++++- tests/test_pcs.py | 1 + tests/test_pv_device.py | 1 + tests/test_recreate_entity_ids.py | 1 + tests/test_schema_discovery.py | 1 + tests/test_sensor_entities.py | 1 + tests/test_shed_forecast.py | 1 + 26 files changed, 340 insertions(+), 12 deletions(-) diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py index 16cb1633..b20e1f0c 100644 --- a/custom_components/span_panel/binary_sensor.py +++ b/custom_components/span_panel/binary_sensor.py @@ -274,6 +274,14 @@ def available(self) -> bool: `door_state`, `eth0_link` and `wlan_link` reporting a field the adapter cannot resolve. `panel_status` and the derived sensors declare no `field_path`, so the probe never fires for them. + + `panel_status` is the one entity a dead transport does not take with + it, and the exception is the same one that already exempts it from the + offline branch: it reports reachability, so it has to survive the + condition it exists to report. It reads `off` rather than holding a + stale `on` -- see `_handle_coordinator_update`. Every other binary + sensor here reads a snapshot field, and holding one from before the + transport died is exactly what the transport probe is for. """ if self._reads_an_unresolved_field: return False @@ -282,6 +290,9 @@ def available(self) -> bool: if hasattr(self.entity_description, "key") and self.entity_description.key == PANEL_STATUS: return True + if not self._transport_available: + return False + # Hardware status sensors should remain available when offline to show Unknown hardware_status_sensors = { SYSTEM_DOOR_STATE, @@ -305,7 +316,14 @@ def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" # Special handling for panel_status sensor if hasattr(self.entity_description, "key") and self.entity_description.key == PANEL_STATUS: - self._attr_is_on = not self.coordinator.panel_offline + # Both flags, because both mean "no panel at the other end". A dead + # transport is deliberately not marked offline -- that flag makes + # the other entities hold their last reading -- and reading it here + # is what stops the one connectivity sensor in the integration from + # reporting connected while the connection is gone for good. + self._attr_is_on = not ( + self.coordinator.panel_offline or self.coordinator.transport_dead + ) self._attr_available = True super()._handle_coordinator_update() return diff --git a/custom_components/span_panel/button.py b/custom_components/span_panel/button.py index 30a4a3f1..36fc3e0c 100644 --- a/custom_components/span_panel/button.py +++ b/custom_components/span_panel/button.py @@ -103,6 +103,8 @@ def available(self) -> bool: One useful consequence: `dsm_state` folds in the user's own assertion, so once a press takes effect the button disables itself rather than inviting a second. """ + if not self._transport_available: + return False if getattr(self.coordinator, "panel_offline", False): return False if not super().available: diff --git a/custom_components/span_panel/coordinator.py b/custom_components/span_panel/coordinator.py index 3583166e..5585bb5e 100644 --- a/custom_components/span_panel/coordinator.py +++ b/custom_components/span_panel/coordinator.py @@ -26,6 +26,7 @@ from span_panel_api.exceptions import ( SpanPanelAuthError, SpanPanelCAChangedError, + SpanPanelError, SpanPanelStaleDataError, ) @@ -114,11 +115,15 @@ def __init__( self._reload_requested = False # Flag to track if panel is offline/unreachable self._panel_offline = False + # Flag to track a transport that has stopped for good. Distinct from + # offline on purpose; see `transport_dead`. + self._transport_dead = False # Streaming state self._unregister_streaming: Callable[[], None] | None = None self._unregister_connection: Callable[[], None] | None = None self._unregister_schema_change: Callable[[], None] | None = None + self._unregister_fatal_error: Callable[[], None] | None = None # Hardware capability tracking — detect when BESS/PV are commissioned # and trigger a reload so the factory creates the appropriate sensors. @@ -182,6 +187,34 @@ def panel_offline(self) -> bool: """Return True if the panel is currently offline/unreachable.""" return self._panel_offline + @property + def transport_dead(self) -> bool: + """True once the transport has stopped in a way waiting cannot fix. + + Deliberately not `panel_offline`. Offline means "no data right now", + and every consumer of it is written for a gap that closes: sensors + hold their last reading through the grace period, POWER sensors read + 0.0, the panel-status binary sensor says so, and all of them stay + available because that is the right answer for a broker that drops for + thirty seconds. + + None of it is the right answer for a transport that is not coming + back. The last snapshot was read before the failure and is indefinitely + old; a dashboard cannot tell a held reading from a live one, and 0 W is + not a measurement at all. Entities that read this go unavailable, which + is the one honest state -- it says the value is not knowable rather + than substituting a plausible one. + + Set by the library's fatal-error channel and by the CA branch of the + update path -- today the same condition reached two ways, since a + changed CA is the only failure the library declares terminal. Cleared + by a connection edge back to connected, which is the only evidence that + disproves it; the library refuses to reconnect after a CA change, so in + that case the edge arrives only after a person re-pins and the entry + reloads. + """ + return self._transport_dead + def request_reload(self) -> None: """Request a reload of the integration.""" self._reload_requested = True @@ -208,6 +241,30 @@ def _mark_panel_offline(self, reason: Exception | str) -> None: ) self._panel_offline = True + def _mark_transport_dead(self, reason: Exception | str) -> None: + """Record that the transport has failed terminally and tell the entities. + + Logged at ERROR, once, because unlike an outage this one needs a + person: nothing here retries, and the Repair raised alongside it is the + only way back. + + The fan-out matters on the fatal-callback path. That path is not an + update cycle -- `last_update_success` is still True and no listener + would otherwise be notified -- so without it every entity would keep + rendering its last value until the fallback poll came round a minute + later and raised `UpdateFailed`. Guarded on the edge so the second + route into the same condition does not re-render everything. + """ + if self._transport_dead: + return + _LOGGER.error( + "%s transport has stopped and will not recover on its own: %s", + self.config_entry.title or "SPAN Panel", + reason, + ) + self._transport_dead = True + self.async_update_listeners() + # --- Energy dip compensation --- def report_energy_dip(self, entity_id: str, delta: float, cumulative_offset: float) -> None: @@ -272,6 +329,15 @@ async def async_setup_streaming(self) -> None: self._unregister_schema_change = self._client.register_schema_change_callback( self._on_schema_generation_change ) + # Subscribed here rather than only in `async_setup_entry`, which takes + # the same channel for the Repair, because the two consumers want + # different things from it and neither is the other's business: the + # entry raises something a person can act on, and the coordinator marks + # the transport dead so the entities stop answering. The library fans + # out to every subscriber. + self._unregister_fatal_error = self._client.register_fatal_error_callback( + self._on_fatal_transport_error + ) self._unregister_streaming = self._client.register_snapshot_callback(self._on_snapshot_push) await self._client.start_streaming() _LOGGER.info("MQTT push streaming started") @@ -381,13 +447,30 @@ def _on_connection_change(self, connected: bool) -> None: trigger spurious entity re-renders. """ was_offline = self._panel_offline + was_dead = self._transport_dead if connected: self._mark_panel_online() + # The one thing that disproves a dead transport: it connected. Not + # folded into `_mark_panel_online`, which a successful snapshot also + # calls -- a read that came back cannot say the failure the library + # declared terminal has been resolved, only that this read worked. + self._transport_dead = False else: self._mark_panel_offline("MQTT broker disconnected") - if self._panel_offline != was_offline: + if self._panel_offline != was_offline or self._transport_dead != was_dead: self.async_update_listeners() + @callback + def _on_fatal_transport_error(self, error: SpanPanelError) -> None: + """Take the entities down when the library gives up on the transport. + + The reconnect loop runs fire-and-forget, so a mid-session failure has + no call stack to surface on. Waiting for the next fallback poll to + re-raise it would leave a minute of entities reporting values read + before the transport died. + """ + self._mark_transport_dead(error) + async def _on_snapshot_push(self, snapshot: SpanPanelSnapshot) -> None: """Handle a pushed snapshot from MQTT streaming.""" self._mark_panel_online() @@ -405,6 +488,10 @@ async def async_shutdown(self) -> None: self._unregister_schema_change() self._unregister_schema_change = None + if self._unregister_fatal_error is not None: + self._unregister_fatal_error() + self._unregister_fatal_error = None + if self._unregister_streaming is not None: self._unregister_streaming() self._unregister_streaming = None @@ -721,17 +808,19 @@ async def _async_update_data(self) -> SpanPanelSnapshot: await self._run_post_update_tasks(snapshot) except SpanPanelCAChangedError as err: - # Not an outage, and must not be handled as one. The offline branch + # Not an outage, and must not be marked as one. The offline branch # below keeps serving the last snapshot so a brief broker drop does # not blank the dashboard, which is right for a transport that will # come back and wrong for one that has stopped for good: it would # leave every entity showing a plausible value read before the pin - # broke. `UpdateFailed` takes them unavailable instead. + # broke. Marking it offline used to do exactly that -- `available` + # returns True on the offline shortcut, ahead of `UpdateFailed`, so + # the sensors stayed available and the POWER ones read 0 W. # # Nothing is retried or torn down here. The library refuses to # reconnect, the Repair is raised from the fatal-error channel in # `async_setup_entry`, and re-pinning requires a person. - self._mark_panel_offline(err) + self._mark_transport_dead(err) raise UpdateFailed(str(err)) from err except SpanPanelAuthError as err: diff --git a/custom_components/span_panel/entity.py b/custom_components/span_panel/entity.py index 58f749f1..4df21efe 100644 --- a/custom_components/span_panel/entity.py +++ b/custom_components/span_panel/entity.py @@ -224,6 +224,24 @@ def _reads_an_unresolved_field(self) -> bool: source = self._source_field_path() return source is not None and source in self.coordinator.unresolved_paths + @property + def _transport_available(self) -> bool: + """False once the panel's transport has stopped for good. + + The single copy of this check. Every platform's `available` consults it + *ahead of* whatever that platform does about `panel_offline`, because + each of those offline branches answers a question this one has already + settled: the sensors and the hardware-status binary sensors return True + to keep showing a held reading, and the controls return False for a + reason -- unreachable -- that understates a transport nobody can reach + again. Both are answers about a gap that closes. A dead transport is + not one, so it is decided first and decided once. + + See `SpanPanelCoordinator.transport_dead` for what makes it dead and + what brings it back. + """ + return not self.coordinator.transport_dead + @property def available(self) -> bool: """False when this entity's snapshot field could not be resolved. @@ -236,9 +254,16 @@ def available(self) -> bool: This covers the resolution-failure case only. A field the adapter resolves but the device stops publishing still reaches HA as a parsed default; that needs the snapshot model to admit None. + + Also the availability of every entity that does not override this -- + the adopted and extension entities, the EVSE binary sensors and the + control-lock switch -- which is where the transport check earns its + place on the base class. """ if self._reads_an_unresolved_field: return False + if not self._transport_available: + return False return super().available @staticmethod diff --git a/custom_components/span_panel/number.py b/custom_components/span_panel/number.py index 45c74f65..7f497f27 100644 --- a/custom_components/span_panel/number.py +++ b/custom_components/span_panel/number.py @@ -218,6 +218,8 @@ def available(self) -> bool: panel has not told us what the charger is rated for, which is the true statement; the entity comes back when the ceiling does. """ + if not self._transport_available: + return False if self.coordinator.panel_offline: return False if self._description.maximum_fn(self._evse()) is None: diff --git a/custom_components/span_panel/select.py b/custom_components/span_panel/select.py index a5eb4d57..39d03d8f 100644 --- a/custom_components/span_panel/select.py +++ b/custom_components/span_panel/select.py @@ -266,6 +266,8 @@ def available(self) -> bool: Selects become unavailable when panel is offline since they can't change settings. """ + if not self._transport_available: + return False if getattr(self.coordinator, "panel_offline", False): return False return super().available diff --git a/custom_components/span_panel/sensor_base.py b/custom_components/span_panel/sensor_base.py index fc43dec8..0fa8c86c 100644 --- a/custom_components/span_panel/sensor_base.py +++ b/custom_components/span_panel/sensor_base.py @@ -340,9 +340,17 @@ def available(self) -> bool: The unresolved-field probe runs first: the grace-period branch below returns True unconditionally, so probing after it would let every offline sensor keep reporting a field the adapter cannot resolve. + + The transport probe runs for the same reason and answers the harder + case: a grace period is a bet that the reading will be true again + shortly, and a transport that has stopped for good makes that bet + unpayable. `_handle_offline_state` gives POWER sensors 0.0 while it + runs, which is a reading nobody took. """ if self._reads_an_unresolved_field: return False + if not self._transport_available: + return False try: if getattr(self.coordinator, "panel_offline", False): return True diff --git a/custom_components/span_panel/switch.py b/custom_components/span_panel/switch.py index 5ee28e41..8ea7eb2b 100644 --- a/custom_components/span_panel/switch.py +++ b/custom_components/span_panel/switch.py @@ -200,6 +200,8 @@ def available(self) -> bool: Switches become unavailable when panel is offline since they can't control circuits. """ + if not self._transport_available: + return False if getattr(self.coordinator, "panel_offline", False): return False return super().available diff --git a/tests/test_adoption.py b/tests/test_adoption.py index e0a63f4e..fab512a9 100644 --- a/tests/test_adoption.py +++ b/tests/test_adoption.py @@ -807,6 +807,7 @@ async def test_a_vendor_format_nothing_can_read_leaves_the_curated_control_stand snapshot = _panel_with_a_charger_and_a_malformed_numeric() coordinator = MagicMock(data=snapshot) coordinator.panel_offline = False + coordinator.transport_dead = False coordinator.last_update_success = True coordinator.unresolved_paths = frozenset() entry = MockConfigEntry(domain=DOMAIN, data={}, options={}, title="SPAN Panel", unique_id=PANEL_SERIAL) diff --git a/tests/test_bess_telemetry.py b/tests/test_bess_telemetry.py index eeac77b2..542dde12 100644 --- a/tests/test_bess_telemetry.py +++ b/tests/test_bess_telemetry.py @@ -108,6 +108,7 @@ def _coordinator(snapshot: SpanPanelSnapshot) -> MagicMock: coordinator.data = snapshot coordinator.hass = MagicMock() coordinator.panel_offline = False + coordinator.transport_dead = False coordinator.unresolved_paths = frozenset() coordinator.config_entry = MockConfigEntry( domain="span_panel", diff --git a/tests/test_binary_sensor_platform.py b/tests/test_binary_sensor_platform.py index 69445d71..5e900f66 100644 --- a/tests/test_binary_sensor_platform.py +++ b/tests/test_binary_sensor_platform.py @@ -36,6 +36,7 @@ def _make_coordinator(snapshot) -> MagicMock: coordinator = MagicMock() coordinator.data = snapshot coordinator.panel_offline = False + coordinator.transport_dead = False coordinator.last_update_success = True coordinator.config_entry = MockConfigEntry( domain=DOMAIN, diff --git a/tests/test_button.py b/tests/test_button.py index 855a4101..484da6cb 100644 --- a/tests/test_button.py +++ b/tests/test_button.py @@ -28,6 +28,7 @@ def _make_button_coordinator(snapshot) -> MagicMock: coordinator = MagicMock() coordinator.data = snapshot coordinator.panel_offline = False + coordinator.transport_dead = False coordinator.config_entry = MockConfigEntry( domain=DOMAIN, data={}, diff --git a/tests/test_control_lock_state.py b/tests/test_control_lock_state.py index f83abebb..50627f20 100644 --- a/tests/test_control_lock_state.py +++ b/tests/test_control_lock_state.py @@ -84,6 +84,7 @@ async def _panel(hass: HomeAssistant, *, timeout: float) -> AsyncIterator[MockCo client.close = AsyncMock() coordinator = MagicMock() coordinator.data = snapshot + coordinator.transport_dead = False coordinator.current_monitor = None coordinator.async_config_entry_first_refresh = AsyncMock() coordinator.async_setup_streaming = AsyncMock() diff --git a/tests/test_der_link_health.py b/tests/test_der_link_health.py index 8d6f49f8..f7d6bf2d 100644 --- a/tests/test_der_link_health.py +++ b/tests/test_der_link_health.py @@ -163,6 +163,7 @@ def _coordinator(snapshot: SpanPanelSnapshot) -> MagicMock: coordinator = MagicMock() coordinator.data = snapshot coordinator.panel_offline = False + coordinator.transport_dead = False coordinator.last_update_success = True coordinator.unresolved_paths = frozenset() coordinator.config_entry = MockConfigEntry( diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 812840d3..48b6ab7e 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -64,6 +64,7 @@ async def test_config_entry_diagnostics_includes_redacted_runtime_data( coordinator = MagicMock() coordinator.data = snapshot coordinator.panel_offline = False + coordinator.transport_dead = False coordinator.last_update_success = True # Explicit: a MagicMock answers `len()` and iteration happily, so leaving # this unset would let the discovery block render as an empty report rather @@ -231,6 +232,7 @@ async def test_diagnostics_reports_the_entity_registry(hass: HomeAssistant) -> N coordinator = MagicMock() coordinator.data = SpanPanelSnapshotFactory.create(serial_number="sp3-diag-003") coordinator.panel_offline = False + coordinator.transport_dead = False coordinator.last_update_success = True coordinator.schema_findings = None entry.runtime_data = SimpleNamespace(coordinator=coordinator) diff --git a/tests/test_dps_and_bess.py b/tests/test_dps_and_bess.py index 0bb3f160..7b21ed84 100644 --- a/tests/test_dps_and_bess.py +++ b/tests/test_dps_and_bess.py @@ -86,6 +86,7 @@ def _make_gfe_coordinator( coordinator = MagicMock() coordinator.data = snapshot + coordinator.transport_dead = False coordinator.config_entry = MagicMock() coordinator.config_entry.title = "SPAN Panel" coordinator.config_entry.data = {} diff --git a/tests/test_energy_dip_compensation.py b/tests/test_energy_dip_compensation.py index 30c34887..b06f6e88 100644 --- a/tests/test_energy_dip_compensation.py +++ b/tests/test_energy_dip_compensation.py @@ -27,6 +27,7 @@ def __init__( # pylint: disable=super-init-not-called """Bypass parent __init__ to avoid full HA dependencies.""" self.coordinator = SimpleNamespace( panel_offline=False, + transport_dead=False, config_entry=SimpleNamespace( options={ ENERGY_REPORTING_GRACE_PERIOD: 15, diff --git a/tests/test_evse_charge_limit.py b/tests/test_evse_charge_limit.py index 84286fe1..de9a6166 100644 --- a/tests/test_evse_charge_limit.py +++ b/tests/test_evse_charge_limit.py @@ -190,6 +190,7 @@ def _coordinator(snapshot: SpanPanelSnapshot, client: object | None = None) -> M coordinator = MagicMock() coordinator.data = snapshot coordinator.panel_offline = False + coordinator.transport_dead = False coordinator.last_update_success = True coordinator.unresolved_paths = frozenset() coordinator.client = MagicMock() if client is None else client diff --git a/tests/test_metadata_sweep.py b/tests/test_metadata_sweep.py index 4e540481..b77ba5a5 100644 --- a/tests/test_metadata_sweep.py +++ b/tests/test_metadata_sweep.py @@ -133,6 +133,7 @@ def _coordinator(snapshot: SpanPanelSnapshot) -> MagicMock: coordinator.data = snapshot coordinator.hass = MagicMock() coordinator.panel_offline = False + coordinator.transport_dead = False coordinator.unresolved_paths = frozenset() coordinator.config_entry = MockConfigEntry( domain=DOMAIN, diff --git a/tests/test_panel_ca_pinning.py b/tests/test_panel_ca_pinning.py index 3697ea91..66ef9bb3 100644 --- a/tests/test_panel_ca_pinning.py +++ b/tests/test_panel_ca_pinning.py @@ -8,6 +8,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.update_coordinator import UpdateFailed import pytest from pytest_homeassistant_custom_component.common import MockConfigEntry from span_panel_api.exceptions import SpanPanelCAChangedError, SpanPanelConnectionError @@ -24,7 +25,13 @@ CONF_PANEL_CA_PEM, DOMAIN, PANEL_CA_PENDING, + PANEL_STATUS, ) +from custom_components.span_panel.coordinator import SpanPanelCoordinator +from custom_components.span_panel.sensor_circuit import SpanCircuitPowerSensor +from custom_components.span_panel.sensor_definitions import CIRCUIT_SENSORS + +from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory PEM = "-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n" OTHER_PEM = "-----BEGIN CERTIFICATE-----\nb3RoZXI=\n-----END CERTIFICATE-----\n" @@ -267,26 +274,181 @@ async def test_the_fix_flow_aborts_when_the_panel_cannot_be_read( assert entry.data[CONF_PANEL_CA_PEM] == PEM -@pytest.mark.asyncio -async def test_the_coordinator_takes_entities_unavailable_on_a_ca_change( +# ---------- a transport that has stopped for good ---------- + + +def _panel_reading_1200_watts( hass: HomeAssistant, -) -> None: - """A dead transport must not keep serving the snapshot read before it died.""" - from homeassistant.helpers.update_coordinator import UpdateFailed +) -> tuple[SpanPanelCoordinator, SpanCircuitPowerSensor]: + """Build a live entry whose next read finds the panel behind a different CA. - from custom_components.span_panel.coordinator import SpanPanelCoordinator + The sensor is a real one on a real coordinator: the defect was in how the + two of them agreed on availability, so a mock of either proves nothing. + """ + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="c1", name="Kitchen Outlets", instant_power_w=1200.0 + ) + snapshot = SpanPanelSnapshotFactory.create(circuits={"c1": circuit}) entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) client = MagicMock() client.get_snapshot = AsyncMock(side_effect=SpanPanelCAChangedError("aa" * 32, "bb" * 32)) coordinator = SpanPanelCoordinator(hass, client, entry) # A previous good snapshot, which the ordinary offline path would keep serving. - coordinator.data = MagicMock() + coordinator.data = snapshot + + power = next(desc for desc in CIRCUIT_SENSORS if desc.key == "circuit_power") + return coordinator, SpanCircuitPowerSensor(coordinator, power, snapshot, "c1") + + +@pytest.mark.asyncio +async def test_the_coordinator_takes_entities_unavailable_on_a_ca_change( + hass: HomeAssistant, +) -> None: + """A dead transport must not keep serving the snapshot read before it died.""" + coordinator, _ = _panel_reading_1200_watts(hass) with pytest.raises(UpdateFailed): await coordinator._async_update_data() + # Dead, not merely offline: the offline flag is what keeps sensors serving + # the last snapshot, and a transport that is not coming back must not. + assert coordinator.transport_dead is True + assert coordinator.panel_offline is False + + +@pytest.mark.asyncio +async def test_a_power_sensor_goes_unavailable_rather_than_reporting_zero( + hass: HomeAssistant, +) -> None: + """The defect: a CA change left every POWER sensor 'available' at 0 W.""" + coordinator, sensor = _panel_reading_1200_watts(hass) + + sensor._update_native_value() + assert sensor.available is True + assert sensor.native_value == 1200.0 + + with pytest.raises(UpdateFailed): + await coordinator._async_update_data() + sensor._update_native_value() + + assert sensor.available is False + assert sensor.native_value != 0.0 + + +@pytest.mark.asyncio +async def test_the_fatal_channel_takes_the_entities_down_without_waiting_for_a_poll( + hass: HomeAssistant, +) -> None: + """The mid-session route in: no exception reaches anybody's call stack. + + The reconnect loop is fire-and-forget, so the only notification is this + callback. Without it the entities keep rendering the last snapshot until + the fallback poll comes round. + """ + coordinator, sensor = _panel_reading_1200_watts(hass) + coordinator.client.start_streaming = AsyncMock() + await coordinator.async_setup_streaming() + on_fatal = coordinator.client.register_fatal_error_callback.call_args.args[0] + + rendered: list[None] = [] + # Listening is also what starts the coordinator's refresh timer, hence the + # unsubscribe below. + unsubscribe = coordinator.async_add_listener(lambda: rendered.append(None)) + + on_fatal(SpanPanelCAChangedError("aa" * 32, "bb" * 32)) + + assert coordinator.transport_dead is True + assert sensor.available is False + # Told at once, rather than a minute later: nothing else fires here. + assert rendered == [None] + unsubscribe() + + +@pytest.mark.asyncio +async def test_an_ordinary_disconnect_still_holds_the_last_reading( + hass: HomeAssistant, +) -> None: + """The grace period is for outages, and this fix must not have taken it.""" + coordinator, sensor = _panel_reading_1200_watts(hass) + + coordinator._on_connection_change(False) + assert coordinator.panel_offline is True + assert coordinator.transport_dead is False + assert sensor.available is True + + +@pytest.mark.asyncio +async def test_a_reconnection_brings_the_entities_back(hass: HomeAssistant) -> None: + """Dead is not permanent -- it is 'until something connects again'.""" + coordinator, sensor = _panel_reading_1200_watts(hass) + with pytest.raises(UpdateFailed): + await coordinator._async_update_data() + assert sensor.available is False + + coordinator._on_connection_change(True) + + assert coordinator.transport_dead is False + assert sensor.available is True + + +@pytest.mark.asyncio +async def test_a_held_hardware_reading_and_a_control_go_with_the_transport( + hass: HomeAssistant, +) -> None: + """The other two shapes of availability, both wrong for a dead transport. + + `door_state` returns True on the offline branch so it can render Unknown + through an outage, and the switch returns False there because a control + that cannot reach the panel is not a control. Neither branch is consulted + now: the transport probe settles both ahead of them. + """ + from custom_components.span_panel.binary_sensor import BINARY_SENSORS, SpanPanelBinarySensor + from custom_components.span_panel.const import SYSTEM_DOOR_STATE + from custom_components.span_panel.switch import SpanPanelCircuitsSwitch + + coordinator, _ = _panel_reading_1200_watts(hass) + door = SpanPanelBinarySensor( + coordinator, next(desc for desc in BINARY_SENSORS if desc.key == SYSTEM_DOOR_STATE) + ) + relay = SpanPanelCircuitsSwitch(coordinator, "c1", "SPAN Panel") + assert door.available is True + assert relay.available is True + + with pytest.raises(UpdateFailed): + await coordinator._async_update_data() + + assert door.available is False + assert relay.available is False + + +@pytest.mark.asyncio +async def test_the_connectivity_sensor_reports_disconnected_rather_than_vanishing( + hass: HomeAssistant, +) -> None: + """The one entity that must survive the condition it reports. + + `panel_status` is exempt from the transport probe, so it has to read the + flag itself -- otherwise it would sit at 'connected' describing a transport + that has stopped for good. + """ + from custom_components.span_panel.binary_sensor import BINARY_SENSORS, SpanPanelBinarySensor + + coordinator, _ = _panel_reading_1200_watts(hass) + description = next(desc for desc in BINARY_SENSORS if desc.key == PANEL_STATUS) + status = SpanPanelBinarySensor(coordinator, description) + status.async_write_ha_state = MagicMock() + + status._handle_coordinator_update() + assert status.is_on is True + + with pytest.raises(UpdateFailed): + await coordinator._async_update_data() + status._handle_coordinator_update() + + assert status.available is True + assert status.is_on is False # ---------- the transport those credentials travel over ---------- diff --git a/tests/test_pcs.py b/tests/test_pcs.py index d4500660..5c2cbe01 100644 --- a/tests/test_pcs.py +++ b/tests/test_pcs.py @@ -152,6 +152,7 @@ def _coordinator(snapshot: SpanPanelSnapshot) -> MagicMock: coordinator.data = snapshot coordinator.hass = MagicMock() coordinator.panel_offline = False + coordinator.transport_dead = False coordinator.unresolved_paths = frozenset() coordinator.config_entry = MockConfigEntry( domain="span_panel", diff --git a/tests/test_pv_device.py b/tests/test_pv_device.py index 49e19fba..a909aaed 100644 --- a/tests/test_pv_device.py +++ b/tests/test_pv_device.py @@ -198,6 +198,7 @@ def _coordinator( coordinator.data = snapshot coordinator.hass = hass coordinator.panel_offline = False + coordinator.transport_dead = False coordinator.last_update_success = True coordinator.unresolved_paths = frozenset() coordinator.config_entry = entry diff --git a/tests/test_recreate_entity_ids.py b/tests/test_recreate_entity_ids.py index 782027cb..7504affd 100644 --- a/tests/test_recreate_entity_ids.py +++ b/tests/test_recreate_entity_ids.py @@ -93,6 +93,7 @@ def _coordinator( coordinator.hass = hass coordinator.data = snapshot coordinator.panel_offline = False + coordinator.transport_dead = False coordinator.config_entry = entry coordinator.request_reload = MagicMock() coordinator.register_circuit_energy_sensor = MagicMock() diff --git a/tests/test_schema_discovery.py b/tests/test_schema_discovery.py index cf1ea8ae..c8a6f0eb 100644 --- a/tests/test_schema_discovery.py +++ b/tests/test_schema_discovery.py @@ -182,6 +182,7 @@ def _entry(findings: SchemaFindings | None) -> MockConfigEntry: coordinator = MagicMock() coordinator.data = _snapshot() coordinator.panel_offline = False + coordinator.transport_dead = False coordinator.last_update_success = True coordinator.schema_findings = findings entry = MockConfigEntry(domain=DOMAIN, title="SPAN Panel", unique_id="example-40t-001") diff --git a/tests/test_sensor_entities.py b/tests/test_sensor_entities.py index 0e1556ca..13feeeb8 100644 --- a/tests/test_sensor_entities.py +++ b/tests/test_sensor_entities.py @@ -83,6 +83,7 @@ def _make_coordinator(snapshot, *, options: dict | None = None) -> MagicMock: coordinator.data = snapshot coordinator.hass = MagicMock() coordinator.panel_offline = False + coordinator.transport_dead = False coordinator.config_entry = MockConfigEntry( domain="span_panel", data={CONF_HOST: "192.168.1.50"}, diff --git a/tests/test_shed_forecast.py b/tests/test_shed_forecast.py index a6bfb083..6e8221e2 100644 --- a/tests/test_shed_forecast.py +++ b/tests/test_shed_forecast.py @@ -65,6 +65,7 @@ def _coordinator(snapshot: SpanPanelSnapshot) -> MagicMock: coordinator.data = snapshot coordinator.hass = MagicMock() coordinator.panel_offline = False + coordinator.transport_dead = False coordinator.unresolved_paths = frozenset() coordinator.config_entry = MockConfigEntry( domain="span_panel", From b9c35bbe6d858a23f19cbb3572bd9ebab3092f17 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:14:51 -0700 Subject: [PATCH 175/345] fix(dip): a confirmed dip that then returns to its baseline is still retracted Confirmation cleared the pending record, so the offset became permanent on one tick of apparent progress. A debounced snapshot burst can deliver two stale samples, the later one higher, which is indistinguishable from a counter climbing away from a fresh base: 1000 -> 0 -> 5 -> 1001 booked the whole lifetime counter and left a +1000 Wh step in long-term statistics, where undoing it is a manual per-hour adjustment. Confirming now keeps the record for three further readings, during which a return to the baseline retracts everything; when the window is spent the record is dropped and the offset is final. A further fall inside the window accumulates against the original baseline and spends a reading rather than reopening it. The remaining window is persisted alongside the record, so a restart neither loses the retraction nor extends it; stored data written before the field existed restores as an unsettled dip, which is what it was. --- custom_components/span_panel/energy_dip.py | 92 +++++++++++++-- custom_components/span_panel/grace_period.py | 7 ++ custom_components/span_panel/sensor_base.py | 7 +- tests/test_energy_dip_compensation.py | 92 ++++++++++++--- tests/test_energy_dip_retraction.py | 114 ++++++++++++++++++- tests/test_grace_period_restoration.py | 2 + 6 files changed, 290 insertions(+), 24 deletions(-) diff --git a/custom_components/span_panel/energy_dip.py b/custom_components/span_panel/energy_dip.py index 1e9746f8..2389ec0a 100644 --- a/custom_components/span_panel/energy_dip.py +++ b/custom_components/span_panel/energy_dip.py @@ -11,6 +11,14 @@ irreversible: the offset lands in Home Assistant's long-term statistics, where undoing it is a manual statistics adjustment per affected hour. +Confirmation is evidence rather than proof, so it is not the end of the record. +A burst of stale samples can deliver a low reading and then a slightly higher +low reading before the true value arrives, which looks exactly like a counter +starting to climb from a fresh base. The record is therefore kept for +`CONFIRMED_RETRACTION_WINDOW` further readings, during which a return to the +baseline still retracts everything; after that it is dropped and the offset is +final. + See `process_energy_dip` for the rule and `tests/test_energy_dip_retraction.py` for the sequences it is written against. """ @@ -28,17 +36,50 @@ """ +CONFIRMED_RETRACTION_WINDOW = 3 +"""How many readings a confirmed dip stays retractable for. + +Three is the shortest window that covers the failure it exists for: a debounced +snapshot burst replaying two or three stale samples before the true reading +lands. What it costs is a genuine reset that, within its next three readings, +climbs the whole way back to the counter's old lifetime total — which would mean +a circuit consuming its entire recorded history in a few polling intervals, so +the exchange is a physically implausible mis-retraction for a plausible +mis-confirmation. Longer would be safer against replays and costlier here; the +bound exists so the offset does become final rather than staying open forever. +""" + + class PendingDip(NamedTuple): - """A booked but unsettled dip. + """A booked dip that is still, in some degree, provisional. `baseline` is what the counter read before any of it — the high-water mark a recovery is judged against, which does not move if the counter falls again. `delta` is how much has been added to the offset provisionally, and is what gets removed if the dip turns out not to have happened. + + `confirmed_ticks_left` is `None` while the dip is unsettled, waiting + open-endedly for evidence either way. Once a reading corroborates it the + field holds how many further readings may still retract it; when that runs + out the record is dropped and the offset is final. """ baseline: float delta: float + confirmed_ticks_left: int | None = None + + +def _count_down(record: PendingDip) -> PendingDip | None: + """Spend one reading of a confirmed record's retraction window. + + An unsettled record has no window and comes back unchanged. A confirmed one + whose window runs out here is dropped, which is what makes its offset final. + """ + if record.confirmed_ticks_left is None: + return record + + remaining = record.confirmed_ticks_left - 1 + return record._replace(confirmed_ticks_left=remaining) if remaining > 0 else None class DipEvent(StrEnum): @@ -48,7 +89,11 @@ class DipEvent(StrEnum): """A drop was seen and compensated. Not yet believed.""" CONFIRMED = "confirmed" - """The counter counted up from the lower base, so the reset was real.""" + """The counter counted up from the lower base, so the reset is believed. + + Believed, not closed: the record stays retractable for + `CONFIRMED_RETRACTION_WINDOW` more readings. + """ RETRACTED = "retracted" """The counter came back, so no reset happened and the offset is removed.""" @@ -91,6 +136,10 @@ def process_energy_dip( confirms it: the counter is demonstrably counting up from the lower base, which only a reset produces. - **A repeat of the dipped reading** settles nothing and is held. + - **A return to the baseline in the `CONFIRMED_RETRACTION_WINDOW` readings + after a confirmation** retracts it after all, because a replayed burst can + produce corroborating progress out of two stale samples. The record is + dropped once the window is spent, and only then is the offset final. That last case is why confirmation needs *progress* rather than just a second low reading. Snapshots are dispatched on a debounce, so one replay @@ -98,9 +147,11 @@ def process_energy_dip( reading as corroboration would be satisfied by a second copy of the very sample it exists to reject. - A counter that falls again before anything is settled accumulates into the - same pending record and keeps the original baseline, because a recovery has - to be judged against what the counter read before any of the fall. + A counter that falls again before the record is dropped accumulates into it + and keeps the original baseline, because a recovery has to be judged against + what the counter read before any of the fall. A fall is not a return, so it + also spends one reading of an open confirmation window rather than reopening + it. The cost is that a genuine reset on a circuit drawing nothing stays unconfirmed until it next moves, deferring only the notification — @@ -111,7 +162,8 @@ def process_energy_dip( raw_value: The current raw energy reading from the panel. last_panel_reading: The previous raw reading, or None if this is the first. current_offset: The cumulative compensation offset so far. - pending: An unsettled dip from an earlier reading, if there is one. + pending: A dip from an earlier reading that is still provisional — + unsettled, or confirmed with window left — if there is one. Returns: A `DipOutcome`. `event` names what happened, if anything; `delta` is the @@ -123,10 +175,17 @@ def process_energy_dip( offset = current_offset + dip # The baseline is the high-water mark, so a second fall extends the # provisional amount without lowering the mark it will be judged against. + # A record whose window runs out on this tick is final, so what follows + # it is a new dip judged against the reading it fell from. + carried = _count_down(pending) if pending is not None else None booked = ( PendingDip(baseline=last_panel_reading, delta=dip) - if pending is None - else PendingDip(baseline=pending.baseline, delta=pending.delta + dip) + if carried is None + else PendingDip( + baseline=carried.baseline, + delta=carried.delta + dip, + confirmed_ticks_left=carried.confirmed_ticks_left, + ) ) return DipOutcome(offset, booked, raw_value + offset, DipEvent.BOOKED, dip) @@ -135,10 +194,25 @@ def process_energy_dip( offset = current_offset - pending.delta return DipOutcome(offset, None, raw_value + offset, DipEvent.RETRACTED, pending.delta) - if last_panel_reading is not None and raw_value > last_panel_reading: + if pending.confirmed_ticks_left is not None: + # Already believed; this reading only spends the window it is + # believed under. return DipOutcome( current_offset, + _count_down(pending), + raw_value + current_offset, + None, None, + ) + + if last_panel_reading is not None and raw_value > last_panel_reading: + return DipOutcome( + current_offset, + PendingDip( + baseline=pending.baseline, + delta=pending.delta, + confirmed_ticks_left=CONFIRMED_RETRACTION_WINDOW, + ), raw_value + current_offset, DipEvent.CONFIRMED, pending.delta, diff --git a/custom_components/span_panel/grace_period.py b/custom_components/span_panel/grace_period.py index 6b52453b..ce08fdff 100644 --- a/custom_components/span_panel/grace_period.py +++ b/custom_components/span_panel/grace_period.py @@ -71,6 +71,11 @@ class SpanEnergyExtraStoredData(ExtraStoredData): # forgetting it was ever provisional. pending_dip_baseline: float | None = None pending_dip_delta: float | None = None + # How much of a confirmed dip's retraction window is left, or None for a dip + # that is not confirmed at all. Stored data written before the window + # existed has no key, and restores as None — an unsettled dip, which is what + # those records were. + pending_dip_confirmed_ticks_left: int | None = None def as_dict(self) -> dict[str, Any]: """Return a dict representation of the extra data.""" @@ -84,6 +89,7 @@ def as_dict(self) -> dict[str, Any]: "last_dip_delta": self.last_dip_delta, "pending_dip_baseline": self.pending_dip_baseline, "pending_dip_delta": self.pending_dip_delta, + "pending_dip_confirmed_ticks_left": self.pending_dip_confirmed_ticks_left, } @classmethod @@ -108,6 +114,7 @@ def from_dict(cls, restored: dict[str, Any]) -> Self | None: last_dip_delta=restored.get("last_dip_delta"), pending_dip_baseline=restored.get("pending_dip_baseline"), pending_dip_delta=restored.get("pending_dip_delta"), + pending_dip_confirmed_ticks_left=restored.get("pending_dip_confirmed_ticks_left"), ) except (AttributeError, KeyError, TypeError): return None diff --git a/custom_components/span_panel/sensor_base.py b/custom_components/span_panel/sensor_base.py index 0fa8c86c..7b0ae03c 100644 --- a/custom_components/span_panel/sensor_base.py +++ b/custom_components/span_panel/sensor_base.py @@ -644,7 +644,9 @@ async def async_added_to_hass(self) -> None: self._last_dip_delta = restored.last_dip_delta if restored.pending_dip_baseline is not None and restored.pending_dip_delta is not None: self._pending_dip = PendingDip( - baseline=restored.pending_dip_baseline, delta=restored.pending_dip_delta + baseline=restored.pending_dip_baseline, + delta=restored.pending_dip_delta, + confirmed_ticks_left=restored.pending_dip_confirmed_ticks_left, ) _LOGGER.debug( "Restored energy dip compensation for %s: offset=%s, last_reading=%s, last_dip=%s", @@ -742,6 +744,9 @@ def extra_restore_state_data(self) -> SensorExtraStoredData: last_dip_delta=self._last_dip_delta, pending_dip_baseline=self._pending_dip.baseline if self._pending_dip else None, pending_dip_delta=self._pending_dip.delta if self._pending_dip else None, + pending_dip_confirmed_ticks_left=( + self._pending_dip.confirmed_ticks_left if self._pending_dip else None + ), ), ) diff --git a/tests/test_energy_dip_compensation.py b/tests/test_energy_dip_compensation.py index b06f6e88..fd74f56c 100644 --- a/tests/test_energy_dip_compensation.py +++ b/tests/test_energy_dip_compensation.py @@ -8,7 +8,7 @@ from homeassistant.components.sensor import SensorStateClass from custom_components.span_panel.const import ENABLE_ENERGY_DIP_COMPENSATION -from custom_components.span_panel.energy_dip import PendingDip +from custom_components.span_panel.energy_dip import CONFIRMED_RETRACTION_WINDOW, PendingDip from custom_components.span_panel.options import ENERGY_REPORTING_GRACE_PERIOD from custom_components.span_panel.sensor_base import ( SpanEnergyExtraStoredData, @@ -69,6 +69,28 @@ def get_data_source(self, snapshot): return "dummy_data" +def _restart(before: DummyDipSensor) -> DummyDipSensor: + """Persist a sensor's dip state and restore it into a fresh one. + + Mirrors what `SpanEnergySensorBase.async_added_to_hass` does with the + record `extra_restore_state_data` wrote, without an HA instance to store it. + """ + restored = SpanEnergyExtraStoredData.from_dict(before.extra_restore_state_data.as_dict()) + assert restored is not None + + after = DummyDipSensor(dip_enabled=True) + after._energy_offset = restored.energy_offset or 0.0 + after._last_panel_reading = restored.last_panel_reading + after._last_dip_delta = restored.last_dip_delta + if restored.pending_dip_baseline is not None and restored.pending_dip_delta is not None: + after._pending_dip = PendingDip( + baseline=restored.pending_dip_baseline, + delta=restored.pending_dip_delta, + confirmed_ticks_left=restored.pending_dip_confirmed_ticks_left, + ) + return after + + # ============================================================================= # SpanEnergyExtraStoredData round-trip with new fields # ============================================================================= @@ -108,6 +130,9 @@ def test_from_dict_restores_dip_fields(self): assert result is not None assert result.energy_offset == 10.0 assert result.last_panel_reading == 190.0 + # Written before the confirmation window existed, so the dip it records + # is an unsettled one -- which is what those records always were. + assert result.pending_dip_confirmed_ticks_left is None assert result.last_dip_delta == 10.0 def test_backward_compat_missing_dip_fields(self): @@ -546,19 +571,9 @@ def test_a_restart_mid_dip_still_retracts_when_the_counter_returns(self): before._update_native_value() before._mock_panel_value = 0.0 before._update_native_value() - stored = before.extra_restore_state_data.as_dict() - after = DummyDipSensor(dip_enabled=True) - restored = SpanEnergyExtraStoredData.from_dict(stored) - assert restored is not None - after._energy_offset = restored.energy_offset or 0.0 - after._last_panel_reading = restored.last_panel_reading - after._last_dip_delta = restored.last_dip_delta - assert restored.pending_dip_baseline is not None - assert restored.pending_dip_delta is not None - after._pending_dip = PendingDip( - baseline=restored.pending_dip_baseline, delta=restored.pending_dip_delta - ) + after = _restart(before) + assert after._pending_dip is not None after._mock_panel_value = 1007.6 after._update_native_value() @@ -568,6 +583,57 @@ def test_a_restart_mid_dip_still_retracts_when_the_counter_returns(self): after.coordinator.report_energy_dip.assert_not_called() +class TestTheConfirmationWindowSurvivesARestart: + """A confirmed dip is still provisional for a few readings, restart or not. + + Both halves of that matter across a restart. Dropping the window would make + a mis-confirmed offset permanent; restoring the record as though it had + never been confirmed would leave it retractable indefinitely, so a counter + that genuinely reset would hand its offset back on the day it climbed past + its old lifetime total. + """ + + @staticmethod + def _confirmed() -> DummyDipSensor: + """Build a sensor that has booked a dip and just had it confirmed.""" + sensor = DummyDipSensor(dip_enabled=True) + for value in (1000.0, 0.0, 5.0): + sensor._mock_panel_value = value + sensor._update_native_value() + return sensor + + def test_the_remaining_window_is_persisted(self): + stored = self._confirmed().extra_restore_state_data.as_dict() + + assert stored["pending_dip_baseline"] == 1000.0 + assert stored["pending_dip_confirmed_ticks_left"] == CONFIRMED_RETRACTION_WINDOW + + def test_a_return_after_the_restart_still_retracts(self): + after = _restart(self._confirmed()) + + after._mock_panel_value = 1001.0 + after._update_native_value() + + assert after._energy_offset == 0.0 + assert after._attr_native_value == 1001.0 + + def test_the_window_keeps_running_out_across_the_restart(self): + """Restarting does not buy the record more readings.""" + after = _restart(self._confirmed()) + + for value in (6.0, 7.0, 8.0): + after._mock_panel_value = value + after._update_native_value() + + assert after._pending_dip is None + + after._mock_panel_value = 1001.0 + after._update_native_value() + + assert after._energy_offset == 1000.0 + assert after._attr_native_value == 2001.0 + + class TestAnOutageDoesNotSettleADip: """Grace period and dip compensation share a stored record but not a decision. diff --git a/tests/test_energy_dip_retraction.py b/tests/test_energy_dip_retraction.py index 1811d32f..035c161b 100644 --- a/tests/test_energy_dip_retraction.py +++ b/tests/test_energy_dip_retraction.py @@ -20,6 +20,16 @@ twice. That is the failure this rule exists to catch, so it must not be the failure that satisfies it. +**And corroboration is evidence rather than proof.** The same burst that +replays one stale sample can replay two, the second slightly higher than the +first, which is indistinguishable on the tick from a counter climbing away from +a fresh base. So confirming does not close the record: for +`CONFIRMED_RETRACTION_WINDOW` more readings a return to the baseline still +retracts everything. Once the window is spent the record is dropped and the +offset is final, because a compensation that could be undone at any future +reading would be undone by the ordinary growth of a counter that really did +reset. + The cost is that a genuine reset on a circuit drawing nothing stays unconfirmed until it next moves. Compensation is applied throughout — only the notification waits — so the sensor reads correctly while the evidence is outstanding. @@ -30,7 +40,9 @@ import pytest from custom_components.span_panel.energy_dip import ( + CONFIRMED_RETRACTION_WINDOW, DipEvent, + DipOutcome, PendingDip, process_energy_dip, ) @@ -48,6 +60,25 @@ def _book(raw: float = 0.0, offset: float = 0.0) -> tuple[float, PendingDip]: return outcome.offset, outcome.pending +def _feed(readings: list[float], *, after: float) -> list[DipOutcome]: + """Run a run of readings through the rule from a standing start. + + `after` is what the counter read before the first of them, so the caller + writes the panel's sequence and gets one outcome per reading back. + """ + outcomes: list[DipOutcome] = [] + previous = after + offset = 0.0 + pending: PendingDip | None = None + + for raw in readings: + outcome = process_energy_dip(raw, previous, offset, pending) + outcomes.append(outcome) + offset, pending, previous = outcome.offset, outcome.pending, raw + + return outcomes + + class TestBooking: """The tick on which the counter first reads low.""" @@ -153,8 +184,11 @@ def test_progress_from_the_dipped_reading_confirms(self) -> None: assert outcome.event is DipEvent.CONFIRMED assert outcome.delta == pytest.approx(BASELINE) - assert outcome.pending is None assert outcome.offset == pytest.approx(BASELINE) + # Believed, but kept: see `TestConfirmThenReturn`. + assert outcome.pending == PendingDip( + baseline=BASELINE, delta=BASELINE, confirmed_ticks_left=CONFIRMED_RETRACTION_WINDOW + ) def test_the_offset_stays_applied_through_confirmation(self) -> None: """Confirming settles the belief, it does not change the arithmetic.""" @@ -204,6 +238,84 @@ def test_a_second_phantom_zero_then_recovery_still_retracts(self) -> None: assert recovered.compensated == pytest.approx(BASELINE + 7.6) +class TestConfirmThenReturn: + """A dip corroborated by one tick of progress, and then undone. + + The confirming reading is evidence, not proof: a replay burst can deliver a + low sample and then a slightly higher low sample before the real reading + arrives. If confirmation closed the record for good, that shape would leave + the counter's whole lifetime total in the offset permanently. + """ + + def test_a_return_to_the_baseline_after_confirmation_still_retracts(self) -> None: + """The review's sequence: 1000 -> 0 (booked) -> 5 (confirmed) -> 1001.""" + offset, pending = _book() + + confirmed = process_energy_dip(5.0, 0.0, offset, pending) + returned = process_energy_dip(BASELINE + 1.0, 5.0, confirmed.offset, confirmed.pending) + + assert returned.event is DipEvent.RETRACTED + assert returned.offset == pytest.approx(0.0) + assert returned.compensated == pytest.approx(BASELINE + 1.0) + + def test_the_last_reading_of_the_window_can_still_retract(self) -> None: + """The window is three readings, and the third of them is one of them.""" + outcomes = _feed([0.0, 5.0, 6.0, 7.0, BASELINE + 1.0], after=BASELINE) + + assert [outcome.event for outcome in outcomes[1:]] == [ + DipEvent.CONFIRMED, + None, + None, + DipEvent.RETRACTED, + ] + assert outcomes[-1].offset == pytest.approx(0.0) + assert outcomes[-1].compensated == pytest.approx(BASELINE + 1.0) + + def test_the_window_runs_out_and_the_offset_becomes_final(self) -> None: + """A fourth reading is past it, so a return then is just consumption. + + Which is the cost of the window: a counter that genuinely reset and then + climbed its whole lifetime total back within three readings would be + mis-retracted. It is the offset staying put that matters here. + """ + outcomes = _feed([0.0, 5.0, 6.0, 7.0, 8.0, BASELINE + 1.0], after=BASELINE) + + assert outcomes[-2].pending is None + assert outcomes[-1].event is None + assert outcomes[-1].offset == pytest.approx(BASELINE) + assert outcomes[-1].compensated == pytest.approx(2 * BASELINE + 1.0) + + def test_a_genuine_reset_that_keeps_counting_drops_its_record(self) -> None: + """Nothing is provisional forever: the record is not carried indefinitely.""" + outcomes = _feed([0.0, 5.0, 6.0, 7.0, 8.0], after=BASELINE) + + assert outcomes[1].pending is not None + assert outcomes[1].pending.confirmed_ticks_left == CONFIRMED_RETRACTION_WINDOW + assert [outcome.pending is None for outcome in outcomes[2:]] == [False, False, True] + + def test_a_further_fall_inside_the_window_accumulates_and_still_retracts(self) -> None: + """A burst can deliver more than one stale sample, in any order. + + The second fall extends the provisional amount against the original + baseline, so the return gives all of it back at once. + """ + outcomes = _feed([0.0, 5.0, 2.0, BASELINE + 1.0], after=BASELINE) + + assert outcomes[2].event is DipEvent.BOOKED + assert outcomes[2].offset == pytest.approx(BASELINE + 3.0) + assert outcomes[-1].event is DipEvent.RETRACTED + assert outcomes[-1].delta == pytest.approx(BASELINE + 3.0) + assert outcomes[-1].offset == pytest.approx(0.0) + + def test_a_fall_inside_the_window_does_not_reopen_it(self) -> None: + """A fall is not a return, so it spends a reading like any other.""" + outcomes = _feed([0.0, 5.0, 2.0, 3.0, 4.0, BASELINE + 1.0], after=BASELINE) + + assert outcomes[-2].pending is None + assert outcomes[-1].event is None + assert outcomes[-1].offset == pytest.approx(BASELINE + 3.0) + + class TestASecondDropWhileUnconfirmed: """A counter that keeps falling before anything is settled.""" diff --git a/tests/test_grace_period_restoration.py b/tests/test_grace_period_restoration.py index 9add39ee..502b3882 100644 --- a/tests/test_grace_period_restoration.py +++ b/tests/test_grace_period_restoration.py @@ -40,6 +40,7 @@ def test_as_dict_with_all_values(self): "last_dip_delta": None, "pending_dip_baseline": None, "pending_dip_delta": None, + "pending_dip_confirmed_ticks_left": None, } def test_as_dict_with_none_values(self): @@ -63,6 +64,7 @@ def test_as_dict_with_none_values(self): "last_dip_delta": None, "pending_dip_baseline": None, "pending_dip_delta": None, + "pending_dip_confirmed_ticks_left": None, } def test_from_dict_with_all_values(self): From 95ecab0475df53ba45cb4cd27e1afcbbe9d3565f Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:22:28 -0700 Subject: [PATCH 176/345] fix(sensor): do not present a default as the at_service_entrance reading The grid power sensor published `at_service_entrance` from `SpanPanelSnapshot.lugs_at_service_entrance`, which is a plain `bool` defaulting to True. Only the parent/child adapter ever writes it -- from the upstream lugs' own `connection/fed-by-device-id` -- and the flat adapter does not reference the field at all. A flat panel therefore reported True whatever its topology, including the BESS-ahead-of-the-lugs case the attribute exists to report, and reported it as a reading. The attribute is now published only where the running adapter resolved it. Omission rather than a third value: nothing renders a missing attribute, so a flat panel looks as it did before the attribute existed, whereas an `unknown` string would be a new row meaning "ignore me". The test is where the value came from, never what it is, so a panel that genuinely is at the service entrance still reports True. Asked of the adapter rather than of the field metadata, which is how every other "did the adapter produce this" question here is settled: a metadata row states a reading's unit and datatype, and this field's backing property is topology, so schema_1 lists it in `_CONSUMED_WITHOUT_A_ROW` and neither adapter emits a row. The map is silent for the panel that resolved the field and for the panel that never looked, which is exactly the distinction needed. This is a workaround for a library type and is meant to be deleted: once span-panel-api makes the field `bool | None`, the honest test is `is not None` on the value. Tests drive the real adapters rather than the snapshot factory, so the claims are about the adapters: a new `schema_zero_snapshot()` fixture builds a flat snapshot, and the vendored parent/child capture already carries `lugs-upstream/connection/fed-by-device-id = bess`. --- custom_components/span_panel/sensor_panel.py | 58 +++++++++++- tests/adapter_fixtures.py | 39 +++++++- tests/test_sensor_entities.py | 98 +++++++++++++++++++- 3 files changed, 191 insertions(+), 4 deletions(-) diff --git a/custom_components/span_panel/sensor_panel.py b/custom_components/span_panel/sensor_panel.py index 280b3c37..b4047e07 100644 --- a/custom_components/span_panel/sensor_panel.py +++ b/custom_components/span_panel/sensor_panel.py @@ -413,6 +413,56 @@ def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanBatterySnapshot: the grid one reads a meter whose meaning depends on where the panel sits. """ +_SERVICE_ENTRANCE_ADAPTER = "schema_1" +"""The adapter key whose mapper resolves `lugs_at_service_entrance`. + +`SpanMqttClient.schema_major` reports the running adapter's key, and only the +parent/child one answers this question: it reads the upstream lugs' own +`connection/fed-by-device-id` and sets the field from whether that property is +there. The flat adapter contains no reference to the field at all. + +**Why this is asked of the adapter rather than of the field metadata**, which is +how every other "did the adapter produce this" question in this integration is +settled (`schema_validation`, `SpanPanelEntity._reads_an_unresolved_field`). +That machinery rests on the adapter classifying absence, and here it cannot: a +metadata row states a *reading's* unit and datatype, and the property behind +this field is topology rather than a reading, so schema_1 lists it in +`_CONSUMED_WITHOUT_A_ROW` and emits no row for it. Neither adapter publishes +`panel.lugs_at_service_entrance`, so the metadata map is silent for both the +panel that resolved the field and the panel that never looked -- exactly the +distinction that has to be drawn. + +**This is a workaround for a library type, and it is meant to be deleted.** +`SpanPanelSnapshot.lugs_at_service_entrance` is a plain `bool` defaulting to +True, alone among the snapshot's conditional members in having no `None` to mean +"not answered". Once span-panel-api makes it `bool | None` (wanted for 3.1.1), +the honest test is `is not None` on the value itself, this constant goes, and the +integration stops needing to know which adapter is running -- which it knows +nowhere else, deliberately. +""" + + +def _service_entrance_was_read(coordinator: SpanPanelCoordinator) -> bool: + """Whether `lugs_at_service_entrance` is a reading rather than a default. + + The attribute exists to tell a topology from a fault, so publishing it where + the value came from a dataclass default answers a question the panel was + never asked -- and answers it `True`, which is exactly the "your two grid + figures agree" reading a user would act on. A flat panel with a BESS ahead + of its lugs is the changelog's own example of the topology this attribute is + for, and it is the one install that would be told the opposite. + + Omission rather than a third value: nothing renders a missing attribute, so + a flat panel looks exactly as it did before the attribute existed, while an + `unknown` string would be a new row on a dashboard meaning "ignore me". + + The test is where the value came from and never what it is. Suppressing + `True` as "probably a default" would silence the real reading a panel at the + service entrance publishes, which is the same mistake in the other + direction. + """ + return coordinator.client.schema_major == _SERVICE_ENTRANCE_ADAPTER + class SpanPanelPowerSensor(SpanSensorBase[SpanPanelDataSensorEntityDescription, SpanPanelSnapshot]): """Panel power sensor with calculated amperage attribute. @@ -428,6 +478,10 @@ class SpanPanelPowerSensor(SpanSensorBase[SpanPanelDataSensorEntityDescription, figures differ has no way to tell a topology from a fault, and the answer now sits on the sensor they are already looking at. + Published only where it is a reading -- see `_service_entrance_was_read`. An + answer to a question the panel was never asked is worse here than no answer, + because the value a consumer would get is the reassuring one. + An attribute rather than an entity, deliberately. Topology is static -- a panel's position in a chain does not change without an electrician -- so a binary sensor would be a permanent row recording one unchanging boolean into @@ -489,7 +543,9 @@ def extra_state_attributes(self) -> dict[str, Any] | None: else: attributes["amperage"] = 0.0 - if self._description_key == _GRID_POWER_KEY: + if self._description_key == _GRID_POWER_KEY and _service_entrance_was_read( + self.coordinator + ): attributes["at_service_entrance"] = self.coordinator.data.lugs_at_service_entrance return attributes diff --git a/tests/adapter_fixtures.py b/tests/adapter_fixtures.py index 6548a9a3..72649abd 100644 --- a/tests/adapter_fixtures.py +++ b/tests/adapter_fixtures.py @@ -18,7 +18,7 @@ from typing import NamedTuple from ebus_sdk.homie import DiscoveredDevice -from span_panel_api.models import FieldMetadata, SpanPanelSnapshot +from span_panel_api.models import FieldMetadata, SpanPanelSnapshot, V2HomieSchema from custom_components.span_panel.schema_validation import DiscoveredProperty @@ -196,6 +196,43 @@ def schema_zero_metadata() -> dict[str, FieldMetadata]: return _curated(build_field_metadata(raw["types"])) +SCHEMA_ZERO_SERIAL = "sp3-synthetic-0001" +"""Serial the flat fixture adapter is built for. Synthetic, like every id here.""" + +SCHEMA_ZERO_FIRMWARE = "r202612" +"""A release in the flat window (r202603-r202627), which is what makes it flat. + +`V2HomieSchema.data_model_version` is left at its default `None` for the same +reason: absent is the flat discriminator, and the dispatcher reads it. +""" + + +def schema_zero_snapshot() -> SpanPanelSnapshot: + """A snapshot as the real flat adapter builds it, before any topic arrives. + + The counterpart to `schema_one_snapshot`, and it exists for the fields the + flat adapter never writes. Retained topics only ever *add* to this: a field + the adapter does not reference at all keeps whatever value the snapshot + dataclass gives it, and no message can move it. + + `lugs_at_service_entrance` is such a field. It is a plain `bool` defaulting + to True, and `span_panel_api_schema_0` contains no reference to the name, so + on flat firmware the value a consumer reads is the library's default rather + than anything the panel said. Driving the adapter rather than the snapshot + factory is what makes that a fact about the adapter instead of a fact the + test wrote down itself. + """ + from span_panel_api_schema_0.adapter import SchemaZeroAdapter + + raw = json.loads((_FIXTURES / "schema_zero_types.json").read_text()) + schema = V2HomieSchema( + firmware_version=SCHEMA_ZERO_FIRMWARE, + types_schema_hash="0" * 16, + types=raw["types"], + ) + return SchemaZeroAdapter(SCHEMA_ZERO_SERIAL, schema).build_snapshot() + + def _curated(metadata: dict[str, FieldMetadata]) -> dict[str, FieldMetadata]: """The half of an adapter's map that names snapshot fields we curate. diff --git a/tests/test_sensor_entities.py b/tests/test_sensor_entities.py index 13feeeb8..fbddf6db 100644 --- a/tests/test_sensor_entities.py +++ b/tests/test_sensor_entities.py @@ -57,6 +57,7 @@ SpanPVMetadataSensor, ) +from .adapter_fixtures import schema_one_snapshot, schema_one_tree, schema_zero_snapshot from .factories import ( SpanBatterySnapshotFactory, SpanCircuitSnapshotFactory, @@ -77,10 +78,20 @@ def _mock_entity_registry(): yield registry -def _make_coordinator(snapshot, *, options: dict | None = None) -> MagicMock: - """Create a coordinator-like mock for direct sensor tests.""" +def _make_coordinator( + snapshot, *, options: dict | None = None, schema_major: str | None = "schema_1" +) -> MagicMock: + """Create a coordinator-like mock for direct sensor tests. + + `schema_major` is the running adapter's key, as `SpanMqttClient.schema_major` + reports it. Defaulted to the parent/child adapter because a `MagicMock` + attribute answers every comparison with an object that is equal to nothing, + and a sensor asking which adapter produced a field would then read every + coordinator in this module as an adapter it has never heard of. + """ coordinator = MagicMock() coordinator.data = snapshot + coordinator.client.schema_major = schema_major coordinator.hass = MagicMock() coordinator.panel_offline = False coordinator.transport_dead = False @@ -176,6 +187,89 @@ def test_only_the_grid_sensor_carries_the_topology_attribute() -> None: assert "at_service_entrance" not in (sensor.extra_state_attributes or {}) +def test_flat_firmware_publishes_no_topology_it_was_never_told() -> None: + """The flat adapter never writes the field, so its value is a library default. + + `lugs_at_service_entrance` is a plain `bool` defaulting to True and + `span_panel_api_schema_0` does not reference it, so a flat panel with a BESS + ahead of its main lugs -- the very topology the attribute exists to report -- + would publish `at_service_entrance: True`. That is not a wrong reading, which + a user could at least argue with; it is the absence of one wearing a + reading's clothes. + + Driven through the real flat adapter rather than the snapshot factory: the + factory takes whatever a test hands it, and the claim under test is about + what the adapter does. + """ + flat = schema_zero_snapshot() + assert flat.lugs_at_service_entrance is True, ( + "the library default moved; this test's premise is stale" + ) + + coordinator = _make_coordinator( + replace(flat, instant_grid_power_w=480.0), schema_major="schema_0" + ) + description = next(desc for desc in PANEL_POWER_SENSORS if desc.key == "instantGridPowerW") + + sensor = SpanPanelPowerSensor(coordinator, description, flat) + sensor._update_native_value() + + assert sensor.native_value == 480.0 + assert "at_service_entrance" not in (sensor.extra_state_attributes or {}) + + +def test_the_topology_attribute_reports_what_the_parent_child_panel_published() -> None: + """schema_1 resolves the field, so the attribute is a reading and is published. + + The vendored capture's upstream lugs carry `connection/fed-by-device-id = + bess`: a real panel with a battery ahead of the main lugs, which is the case + the flat adapter cannot see and this one can. + """ + snapshot = schema_one_snapshot() + 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 False + + +def test_the_topology_attribute_still_says_true_when_true_is_a_reading() -> None: + """Omission must turn on where the value came from, never on what it is. + + Republishing the capture without the upstream lugs' `fed-by-device-id` makes + a panel that *is* at the service entrance, and schema_1 reads True off it. + Suppressing True as "probably a default" would lose that. + """ + tree = schema_one_tree() + del tree["lugs-upstream"]["connection/fed-by-device-id"] + snapshot = schema_one_snapshot(tree) + 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_before_an_adapter_is_known() -> None: + """`schema_major` is None until the client has dispatched one. + + Unknown is not "flat" and not "at the service entrance"; it is unknown, and + the attribute's whole job is to be a reading. + """ + snapshot = SpanPanelSnapshotFactory.create(instant_grid_power_w=480.0) + coordinator = _make_coordinator(snapshot, schema_major=None) + description = next(desc for desc in PANEL_POWER_SENSORS if desc.key == "instantGridPowerW") + + sensor = SpanPanelPowerSensor(coordinator, description, snapshot) + sensor._update_native_value() + + assert "at_service_entrance" not in (sensor.extra_state_attributes or {}) + + def test_a_panel_sensor_with_a_translation_key_falls_back_to_the_neutral_label() -> None: """One shared answer replaces the per-class fallbacks, and it is name-only. From 60994dbea8d74555b1ab013046011af4b86ef3f7 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:29:52 -0700 Subject: [PATCH 177/345] fix(dip): a dip inside a confirmed window is judged against its own baseline, and the reset is reported when it settles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folding a dip that arrives inside a corroborated dip's retraction window into that dip's record measured the newcomer against the older high-water mark. A 3 Wh replay booked while a 1000 Wh dip was still open could then only be retracted by the counter returning all the way to 1000, which it cannot do inside the window: `1000 → 0 → 5 → 2 → 6 → 7` left the 3 Wh compensated for good. The two dips are now kept apart, each judged against its own baseline, so that sequence gives the 3 Wh back and keeps the 1000. A fall does not buy the older dip's window more readings, and corroborating the newer dip settles the older one rather than merging it, so the bound stays three readings from the most recent corroboration. The notification also moves from corroboration to settling. Corroboration is evidence, and the readings just after it can still retract the dip, but a persistent notification cannot be retracted — so a dip that was reported and then undone left the user with a notice for an event that did not happen, which is what reporting on sight did in SpanPanel/span#259. `DipEvent.SETTLED` marks the reading that closes the window, and `DipOutcome.settled` carries the amount separately from `event` because a dip can settle on the same reading that books or retracts another one. Both records persist across a restart with the window's remaining readings, so a restart neither loses the retraction nor renews it, and the report still arrives when the dip settles afterwards. --- custom_components/span_panel/energy_dip.py | 264 ++++++++++++------- custom_components/span_panel/grace_period.py | 21 +- custom_components/span_panel/sensor_base.py | 71 +++-- tests/test_energy_dip_compensation.py | 80 ++++-- tests/test_energy_dip_retraction.py | 130 ++++++--- tests/test_grace_period_restoration.py | 8 +- 6 files changed, 403 insertions(+), 171 deletions(-) diff --git a/custom_components/span_panel/energy_dip.py b/custom_components/span_panel/energy_dip.py index 2389ec0a..69b85d9f 100644 --- a/custom_components/span_panel/energy_dip.py +++ b/custom_components/span_panel/energy_dip.py @@ -11,13 +11,19 @@ irreversible: the offset lands in Home Assistant's long-term statistics, where undoing it is a manual statistics adjustment per affected hour. -Confirmation is evidence rather than proof, so it is not the end of the record. -A burst of stale samples can deliver a low reading and then a slightly higher -low reading before the true value arrives, which looks exactly like a counter -starting to climb from a fresh base. The record is therefore kept for -`CONFIRMED_RETRACTION_WINDOW` further readings, during which a return to the -baseline still retracts everything; after that it is dropped and the offset is -final. +Corroboration is evidence rather than proof, so it is not the end of the record +either. A burst of stale samples can deliver a low reading and then a slightly +higher low reading before the true value arrives, which looks exactly like a +counter starting to climb from a fresh base. A corroborated dip is therefore +kept for `CONFIRMED_RETRACTION_WINDOW` further readings, during which a return +to its baseline still retracts it. When the window closes the dip *settles*: +only then is the offset final, and only then is the user told a reset happened. + +The two records are kept apart. A dip that occurs inside another dip's window is +its own dip, with its own baseline — the reading it fell from, not the older +high-water mark — because that is what it has to return to in order to be +undone. Folding them together would leave a small artefact judged against a +baseline it can never reach, and so compensated for good. See `process_energy_dip` for the rule and `tests/test_energy_dip_retraction.py` for the sequences it is written against. @@ -37,7 +43,7 @@ CONFIRMED_RETRACTION_WINDOW = 3 -"""How many readings a confirmed dip stays retractable for. +"""How many readings a corroborated dip stays retractable for. Three is the shortest window that covers the failure it exists for: a debounced snapshot burst replaying two or three stale samples before the true reading @@ -53,15 +59,15 @@ class PendingDip(NamedTuple): """A booked dip that is still, in some degree, provisional. - `baseline` is what the counter read before any of it — the high-water mark - a recovery is judged against, which does not move if the counter falls - again. `delta` is how much has been added to the offset provisionally, and - is what gets removed if the dip turns out not to have happened. + `baseline` is what the counter read before it — the mark a recovery is + judged against, which does not move if the counter falls again. `delta` is + how much has been added to the offset provisionally, and is what gets + removed if the dip turns out not to have happened. - `confirmed_ticks_left` is `None` while the dip is unsettled, waiting - open-endedly for evidence either way. Once a reading corroborates it the - field holds how many further readings may still retract it; when that runs - out the record is dropped and the offset is final. + `confirmed_ticks_left` is `None` for an unsettled dip, which waits + open-endedly for evidence either way. On a corroborated dip it holds how + many further readings may still retract it; when that runs out the dip + settles and its offset is final. """ baseline: float @@ -69,21 +75,32 @@ class PendingDip(NamedTuple): confirmed_ticks_left: int | None = None -def _count_down(record: PendingDip) -> PendingDip | None: - """Spend one reading of a confirmed record's retraction window. +def _count_down(record: PendingDip | None) -> PendingDip | None: + """Spend one reading of a corroborated record's retraction window. - An unsettled record has no window and comes back unchanged. A confirmed one - whose window runs out here is dropped, which is what makes its offset final. + Nothing, and an unsettled record, come back as they are — neither has a + window to spend. A corroborated record whose window runs out here is + dropped, which is what settling is. """ - if record.confirmed_ticks_left is None: + if record is None or record.confirmed_ticks_left is None: return record remaining = record.confirmed_ticks_left - 1 return record._replace(confirmed_ticks_left=remaining) if remaining > 0 else None +def _settled_by(before: PendingDip | None, after: PendingDip | None) -> float | None: + """Return what a record made final by running out of window, if it did.""" + return before.delta if before is not None and after is None else None + + +def _has_returned(raw_value: float, record: PendingDip) -> bool: + """Whether a reading is back at a record's own baseline, within tolerance.""" + return raw_value >= record.baseline - DIP_THRESHOLD_WH + + class DipEvent(StrEnum): - """What a reading did to the pending state, when it did anything.""" + """What a reading did to the provisional state, when it did anything.""" BOOKED = "booked" """A drop was seen and compensated. Not yet believed.""" @@ -91,13 +108,21 @@ class DipEvent(StrEnum): CONFIRMED = "confirmed" """The counter counted up from the lower base, so the reset is believed. - Believed, not closed: the record stays retractable for - `CONFIRMED_RETRACTION_WINDOW` more readings. + Believed, not closed: the dip stays retractable for + `CONFIRMED_RETRACTION_WINDOW` more readings, and is reported when it + settles rather than here. """ RETRACTED = "retracted" """The counter came back, so no reset happened and the offset is removed.""" + SETTLED = "settled" + """A corroborated dip ran out of window, so its offset is now final. + + This is the point at which the reset is worth telling the user about, + because it is the first point at which nothing can take it back. + """ + class DipOutcome(NamedTuple): """The result of feeding one raw reading through the rule.""" @@ -109,12 +134,58 @@ class DipOutcome(NamedTuple): delta: float | None """The amount the event concerns, or None when nothing happened.""" + recently_confirmed: PendingDip | None = None + """A corroborated dip still inside its retraction window, if there is one.""" + + settled: float | None = None + """What settled on this reading, if anything. + + Carried separately from `event` because a dip can settle on the same + reading that books or retracts another one, and the caller must report it + either way. + """ + + +def _book( + raw_value: float, + last_panel_reading: float, + current_offset: float, + pending: PendingDip | None, + recently_confirmed: PendingDip | None, +) -> DipOutcome: + """Compensate a fall, and age whatever was already provisional. + + A fall cannot also be a return: every reading since either record was booked + has been below that record's baseline, and this one is below the last of + them. So a corroborated dip only ages here, and the fall is a dip in its own + right — booked against the reading it fell from, or accumulated into an + unsettled dip that is still waiting on the same question. + """ + dip = last_panel_reading - raw_value + offset = current_offset + dip + booked = ( + PendingDip(baseline=last_panel_reading, delta=dip) + if pending is None + else PendingDip(baseline=pending.baseline, delta=pending.delta + dip) + ) + aged = _count_down(recently_confirmed) + return DipOutcome( + offset, + booked, + raw_value + offset, + DipEvent.BOOKED, + dip, + aged, + _settled_by(recently_confirmed, aged), + ) + def process_energy_dip( raw_value: float, last_panel_reading: float | None, current_offset: float, pending: PendingDip | None, + recently_confirmed: PendingDip | None = None, ) -> DipOutcome: """Advance dip compensation by one reading. @@ -133,28 +204,37 @@ def process_energy_dip( - **A later reading back at the baseline** (within the same tolerance) retracts it: the counter never reset, so the offset is removed. - **A later reading above the dipped one but still below the baseline** - confirms it: the counter is demonstrably counting up from the lower base, - which only a reset produces. + corroborates it: the counter is demonstrably counting up from the lower + base, which only a reset produces. - **A repeat of the dipped reading** settles nothing and is held. - **A return to the baseline in the `CONFIRMED_RETRACTION_WINDOW` readings - after a confirmation** retracts it after all, because a replayed burst can - produce corroborating progress out of two stale samples. The record is - dropped once the window is spent, and only then is the offset final. + after that** retracts it after all, because a replayed burst can produce + corroborating progress out of two stale samples. + - **The reading that closes the window** settles the dip. The offset is + final and the reset is reported. - That last case is why confirmation needs *progress* rather than just a + That fourth case is why corroboration needs *progress* rather than just a second low reading. Snapshots are dispatched on a debounce, so one replay can deliver the same incomplete state twice; a rule that accepted any low reading as corroboration would be satisfied by a second copy of the very - sample it exists to reject. - - A counter that falls again before the record is dropped accumulates into it - and keeps the original baseline, because a recovery has to be judged against - what the counter read before any of the fall. A fall is not a return, so it - also spends one reading of an open confirmation window rather than reopening - it. + sample it exists to reject. The window exists because the same burst can + just as easily deliver two *different* stale samples, the later one higher, + which is corroboration by coincidence. + + A counter that falls again while a dip is unsettled accumulates into it and + keeps that dip's baseline, because a recovery has to be judged against what + the counter read before any of the fall. A fall inside a *corroborated* + dip's window is a separate dip instead, judged against the reading it fell + from: the two are undone by different readings, and merging them would leave + a small artefact needing the counter's whole lifetime total back before it + could ever be retracted. The older dip ages either way — a fall is not a + return, so it does not buy a window more readings — and if an unsettled dip + is corroborated while an older one is still open, the older one settles then + and there rather than merging, so the bound stays three readings from the + most recent corroboration. The cost is that a genuine reset on a circuit drawing nothing stays - unconfirmed until it next moves, deferring only the notification — + unsettled until it next moves, deferring only the notification — compensation is applied throughout, so the sensor reads correctly while the evidence is outstanding. @@ -162,63 +242,71 @@ def process_energy_dip( raw_value: The current raw energy reading from the panel. last_panel_reading: The previous raw reading, or None if this is the first. current_offset: The cumulative compensation offset so far. - pending: A dip from an earlier reading that is still provisional — - unsettled, or confirmed with window left — if there is one. + pending: An unsettled dip from an earlier reading, if there is one. + recently_confirmed: A corroborated dip still inside its retraction + window, if there is one. Returns: A `DipOutcome`. `event` names what happened, if anything; `delta` is the - amount it concerns; `pending` is the state to carry into the next call. + amount it concerns; `settled` is what became final on this reading; + `pending` and `recently_confirmed` are the state to carry into the next + call. """ if last_panel_reading is not None and last_panel_reading - raw_value >= DIP_THRESHOLD_WH: - dip = last_panel_reading - raw_value - offset = current_offset + dip - # The baseline is the high-water mark, so a second fall extends the - # provisional amount without lowering the mark it will be judged against. - # A record whose window runs out on this tick is final, so what follows - # it is a new dip judged against the reading it fell from. - carried = _count_down(pending) if pending is not None else None - booked = ( - PendingDip(baseline=last_panel_reading, delta=dip) - if carried is None - else PendingDip( - baseline=carried.baseline, - delta=carried.delta + dip, - confirmed_ticks_left=carried.confirmed_ticks_left, - ) + return _book(raw_value, last_panel_reading, current_offset, pending, recently_confirmed) + + confirmed = recently_confirmed + settled: float | None = None + removed = 0.0 + + if confirmed is not None: + if _has_returned(raw_value, confirmed): + removed = confirmed.delta + confirmed = None + else: + aged = _count_down(confirmed) + settled = _settled_by(confirmed, aged) + confirmed = aged + + if pending is not None and _has_returned(raw_value, pending): + # A corroborated dip's baseline is the higher of the two, so a reading + # that reached it has reached this one as well: both come off together. + removed += pending.delta + pending = None + + if removed: + offset = current_offset - removed + return DipOutcome( + offset, pending, raw_value + offset, DipEvent.RETRACTED, removed, confirmed, settled ) - return DipOutcome(offset, booked, raw_value + offset, DipEvent.BOOKED, dip) - - if pending is not None: - if raw_value >= pending.baseline - DIP_THRESHOLD_WH: - offset = current_offset - pending.delta - return DipOutcome(offset, None, raw_value + offset, DipEvent.RETRACTED, pending.delta) - - if pending.confirmed_ticks_left is not None: - # Already believed; this reading only spends the window it is - # believed under. - return DipOutcome( - current_offset, - _count_down(pending), - raw_value + current_offset, - None, - None, - ) - - if last_panel_reading is not None and raw_value > last_panel_reading: - return DipOutcome( - current_offset, - PendingDip( - baseline=pending.baseline, - delta=pending.delta, - confirmed_ticks_left=CONFIRMED_RETRACTION_WINDOW, - ), - raw_value + current_offset, - DipEvent.CONFIRMED, - pending.delta, - ) - - return DipOutcome(current_offset, pending, raw_value + current_offset, None, None) + + if pending is not None and last_panel_reading is not None and raw_value > last_panel_reading: + return DipOutcome( + current_offset, + None, + raw_value + current_offset, + DipEvent.CONFIRMED, + pending.delta, + PendingDip( + baseline=pending.baseline, + delta=pending.delta, + confirmed_ticks_left=CONFIRMED_RETRACTION_WINDOW, + ), + # An older corroborated dip is superseded rather than merged, so it + # is final as of now. + confirmed.delta if confirmed is not None else settled, + ) + + return DipOutcome( + current_offset, + pending, + raw_value + current_offset, + DipEvent.SETTLED if settled is not None else None, + settled, + confirmed, + settled, + ) def build_dip_attributes( diff --git a/custom_components/span_panel/grace_period.py b/custom_components/span_panel/grace_period.py index ce08fdff..61e74bf6 100644 --- a/custom_components/span_panel/grace_period.py +++ b/custom_components/span_panel/grace_period.py @@ -71,11 +71,14 @@ class SpanEnergyExtraStoredData(ExtraStoredData): # forgetting it was ever provisional. pending_dip_baseline: float | None = None pending_dip_delta: float | None = None - # How much of a confirmed dip's retraction window is left, or None for a dip - # that is not confirmed at all. Stored data written before the window - # existed has no key, and restores as None — an unsettled dip, which is what - # those records were. - pending_dip_confirmed_ticks_left: int | None = None + # A corroborated dip that has not settled yet, carried for the same reason + # and with the readings left in its window, so a restart neither loses the + # ability to retract it nor buys it more readings. Stored data written + # before these existed has no such keys and restores them as None, which is + # the state those records were in. + confirmed_dip_baseline: float | None = None + confirmed_dip_delta: float | None = None + confirmed_dip_ticks_left: int | None = None def as_dict(self) -> dict[str, Any]: """Return a dict representation of the extra data.""" @@ -89,7 +92,9 @@ def as_dict(self) -> dict[str, Any]: "last_dip_delta": self.last_dip_delta, "pending_dip_baseline": self.pending_dip_baseline, "pending_dip_delta": self.pending_dip_delta, - "pending_dip_confirmed_ticks_left": self.pending_dip_confirmed_ticks_left, + "confirmed_dip_baseline": self.confirmed_dip_baseline, + "confirmed_dip_delta": self.confirmed_dip_delta, + "confirmed_dip_ticks_left": self.confirmed_dip_ticks_left, } @classmethod @@ -114,7 +119,9 @@ def from_dict(cls, restored: dict[str, Any]) -> Self | None: last_dip_delta=restored.get("last_dip_delta"), pending_dip_baseline=restored.get("pending_dip_baseline"), pending_dip_delta=restored.get("pending_dip_delta"), - pending_dip_confirmed_ticks_left=restored.get("pending_dip_confirmed_ticks_left"), + confirmed_dip_baseline=restored.get("confirmed_dip_baseline"), + confirmed_dip_delta=restored.get("confirmed_dip_delta"), + confirmed_dip_ticks_left=restored.get("confirmed_dip_ticks_left"), ) except (AttributeError, KeyError, TypeError): return None diff --git a/custom_components/span_panel/sensor_base.py b/custom_components/span_panel/sensor_base.py index 7b0ae03c..4cf0faf5 100644 --- a/custom_components/span_panel/sensor_base.py +++ b/custom_components/span_panel/sensor_base.py @@ -544,9 +544,12 @@ def __init__( self._last_panel_reading: float | None = None self._last_dip_delta: float | None = None # A dip that has been compensated but not yet settled — see - # `process_energy_dip`. Non-None means part of `_energy_offset` is - # provisional and may still be taken back. + # `process_energy_dip`. Either being non-None means part of + # `_energy_offset` is provisional and may still be taken back: the + # first is waiting on evidence, the second has it and is waiting out + # the window in which that evidence could still be an artefact. self._pending_dip: PendingDip | None = None + self._recently_confirmed_dip: PendingDip | None = None self._is_total_increasing: bool = ( getattr(description, "state_class", None) == SensorStateClass.TOTAL_INCREASING ) @@ -568,24 +571,33 @@ def _process_raw_value(self, raw_value: float | str | None) -> None: ): raw_float = float(raw_value) outcome = process_energy_dip( - raw_float, self._last_panel_reading, self._energy_offset, self._pending_dip + raw_float, + self._last_panel_reading, + self._energy_offset, + self._pending_dip, + self._recently_confirmed_dip, ) self._apply_dip_event(outcome) self._energy_offset = outcome.offset self._pending_dip = outcome.pending + self._recently_confirmed_dip = outcome.recently_confirmed self._last_panel_reading = raw_float super()._process_raw_value(outcome.compensated) else: super()._process_raw_value(raw_value) def _apply_dip_event(self, outcome: DipOutcome) -> None: - """Record the diagnostic, and tell the coordinator once a dip is believed. - - The notification moves to confirmation rather than firing when the dip - is first seen. Reporting on sight is what made `SpanPanel/span#259` so - loud — a notification naming essentially every circuit on the panel, - for an event that had not happened. A dip that gets retracted now - produces no notification at all, because nothing happened. + """Record the diagnostic, and tell the coordinator once a dip is final. + + The notification waits for the dip to *settle* rather than firing when + it is first seen, or even when it is first corroborated. Reporting on + sight is what made `SpanPanel/span#259` so loud — a notification naming + essentially every circuit on the panel, for an event that had not + happened. Reporting on corroboration would be quieter but still + premature, because the reading after it can take the offset back; a + persistent notification cannot be taken back, so it is held until + nothing can undo what it describes. A dip that gets retracted produces + no notification at all, because nothing happened. `last_dip_delta` is set when the dip is booked, since it describes what the counter did and the compensation is applied from that moment, and @@ -597,16 +609,24 @@ def _apply_dip_event(self, outcome: DipOutcome) -> None: elif outcome.event is DipEvent.RETRACTED: self._last_dip_delta = None _LOGGER.debug( - "Energy dip retracted for %s: %s Wh returned to %s, offset back to %s", + "Energy dip retracted for %s: %s Wh given back, offset back to %s", self.entity_id or self._attr_unique_id, outcome.delta, - self._pending_dip.baseline if self._pending_dip else None, outcome.offset, ) - elif outcome.event is DipEvent.CONFIRMED and outcome.delta is not None: + elif outcome.event is DipEvent.CONFIRMED: + _LOGGER.debug( + "Energy dip corroborated for %s: %s Wh, reported if it settles", + self.entity_id or self._attr_unique_id, + outcome.delta, + ) + + # Checked separately from `event`: a dip can settle on the same reading + # that books or retracts another one. + if outcome.settled is not None: self.coordinator.report_energy_dip( self.entity_id or self._attr_unique_id or "unknown", - outcome.delta, + outcome.settled, outcome.offset, ) @@ -646,7 +666,16 @@ async def async_added_to_hass(self) -> None: self._pending_dip = PendingDip( baseline=restored.pending_dip_baseline, delta=restored.pending_dip_delta, - confirmed_ticks_left=restored.pending_dip_confirmed_ticks_left, + ) + if ( + restored.confirmed_dip_baseline is not None + and restored.confirmed_dip_delta is not None + and restored.confirmed_dip_ticks_left is not None + ): + self._recently_confirmed_dip = PendingDip( + baseline=restored.confirmed_dip_baseline, + delta=restored.confirmed_dip_delta, + confirmed_ticks_left=restored.confirmed_dip_ticks_left, ) _LOGGER.debug( "Restored energy dip compensation for %s: offset=%s, last_reading=%s, last_dip=%s", @@ -744,8 +773,16 @@ def extra_restore_state_data(self) -> SensorExtraStoredData: last_dip_delta=self._last_dip_delta, pending_dip_baseline=self._pending_dip.baseline if self._pending_dip else None, pending_dip_delta=self._pending_dip.delta if self._pending_dip else None, - pending_dip_confirmed_ticks_left=( - self._pending_dip.confirmed_ticks_left if self._pending_dip else None + confirmed_dip_baseline=( + self._recently_confirmed_dip.baseline if self._recently_confirmed_dip else None + ), + confirmed_dip_delta=( + self._recently_confirmed_dip.delta if self._recently_confirmed_dip else None + ), + confirmed_dip_ticks_left=( + self._recently_confirmed_dip.confirmed_ticks_left + if self._recently_confirmed_dip + else None ), ), ) diff --git a/tests/test_energy_dip_compensation.py b/tests/test_energy_dip_compensation.py index fd74f56c..2bc521dc 100644 --- a/tests/test_energy_dip_compensation.py +++ b/tests/test_energy_dip_compensation.py @@ -59,6 +59,7 @@ def __init__( # pylint: disable=super-init-not-called self._last_panel_reading: float | None = None self._last_dip_delta: float | None = None self._pending_dip: PendingDip | None = None + self._recently_confirmed_dip: PendingDip | None = None self._is_total_increasing: bool = state_class == SensorStateClass.TOTAL_INCREASING self._dip_compensation_enabled: bool = dip_enabled @@ -86,7 +87,16 @@ def _restart(before: DummyDipSensor) -> DummyDipSensor: after._pending_dip = PendingDip( baseline=restored.pending_dip_baseline, delta=restored.pending_dip_delta, - confirmed_ticks_left=restored.pending_dip_confirmed_ticks_left, + ) + if ( + restored.confirmed_dip_baseline is not None + and restored.confirmed_dip_delta is not None + and restored.confirmed_dip_ticks_left is not None + ): + after._recently_confirmed_dip = PendingDip( + baseline=restored.confirmed_dip_baseline, + delta=restored.confirmed_dip_delta, + confirmed_ticks_left=restored.confirmed_dip_ticks_left, ) return after @@ -130,9 +140,10 @@ def test_from_dict_restores_dip_fields(self): assert result is not None assert result.energy_offset == 10.0 assert result.last_panel_reading == 190.0 - # Written before the confirmation window existed, so the dip it records - # is an unsettled one -- which is what those records always were. - assert result.pending_dip_confirmed_ticks_left is None + # Written before the corroborated slot existed, so it records only an + # unsettled dip -- which is the state those records were always in. + assert result.confirmed_dip_baseline is None + assert result.confirmed_dip_ticks_left is None assert result.last_dip_delta == 10.0 def test_backward_compat_missing_dip_fields(self): @@ -340,31 +351,43 @@ def test_non_total_increasing_passthrough(self): assert sensor._attr_native_value == 950.0 assert sensor._energy_offset == 0.0 - def test_dip_reports_to_coordinator_once_confirmed(self): - """Confirmation calls coordinator.report_energy_dip, not detection. + def test_dip_reports_to_coordinator_once_it_settles(self): + """The notification waits until nothing can take the offset back. - The notification waits for the counter to corroborate the drop. Firing - on sight is what produced the `SpanPanel/span#259` notification naming - essentially every circuit on the panel for an event that had not - happened. + Not on detection: firing on sight is what produced the + `SpanPanel/span#259` notification naming essentially every circuit on + the panel for an event that had not happened. And not on corroboration + either, because the readings just after it can still retract the dip, + while a persistent notification cannot be retracted at all. """ sensor = DummyDipSensor(dip_enabled=True) sensor.entity_id = "sensor.test_energy" - sensor._mock_panel_value = 1000.0 - sensor._update_native_value() - - sensor._mock_panel_value = 950.0 - sensor._update_native_value() - sensor.coordinator.report_energy_dip.assert_not_called() + for value in (1000.0, 950.0, 955.0, 956.0, 957.0): + sensor._mock_panel_value = value + sensor._update_native_value() + sensor.coordinator.report_energy_dip.assert_not_called() - sensor._mock_panel_value = 955.0 + sensor._mock_panel_value = 958.0 sensor._update_native_value() sensor.coordinator.report_energy_dip.assert_called_once_with( "sensor.test_energy", 50.0, 50.0 ) + def test_a_dip_retracted_inside_its_window_is_never_reported(self): + """A notification that cannot be taken back is not sent speculatively.""" + sensor = DummyDipSensor(dip_enabled=True) + sensor.entity_id = "sensor.test_energy" + + for value in (1000.0, 950.0, 955.0, 1001.0, 1002.0, 1003.0, 1004.0): + sensor._mock_panel_value = value + sensor._update_native_value() + + assert sensor._energy_offset == 0.0 + assert sensor._attr_native_value == 1004.0 + sensor.coordinator.report_energy_dip.assert_not_called() + def test_no_report_when_no_dip(self): """Normal increases don't trigger coordinator notification.""" sensor = DummyDipSensor(dip_enabled=True) @@ -605,8 +628,11 @@ def _confirmed() -> DummyDipSensor: def test_the_remaining_window_is_persisted(self): stored = self._confirmed().extra_restore_state_data.as_dict() - assert stored["pending_dip_baseline"] == 1000.0 - assert stored["pending_dip_confirmed_ticks_left"] == CONFIRMED_RETRACTION_WINDOW + assert stored["confirmed_dip_baseline"] == 1000.0 + assert stored["confirmed_dip_delta"] == 1000.0 + assert stored["confirmed_dip_ticks_left"] == CONFIRMED_RETRACTION_WINDOW + # The corroborated dip left the unsettled slot when it was corroborated. + assert stored["pending_dip_baseline"] is None def test_a_return_after_the_restart_still_retracts(self): after = _restart(self._confirmed()) @@ -616,6 +642,7 @@ def test_a_return_after_the_restart_still_retracts(self): assert after._energy_offset == 0.0 assert after._attr_native_value == 1001.0 + after.coordinator.report_energy_dip.assert_not_called() def test_the_window_keeps_running_out_across_the_restart(self): """Restarting does not buy the record more readings.""" @@ -625,7 +652,7 @@ def test_the_window_keeps_running_out_across_the_restart(self): after._mock_panel_value = value after._update_native_value() - assert after._pending_dip is None + assert after._recently_confirmed_dip is None after._mock_panel_value = 1001.0 after._update_native_value() @@ -633,6 +660,19 @@ def test_the_window_keeps_running_out_across_the_restart(self): assert after._energy_offset == 1000.0 assert after._attr_native_value == 2001.0 + def test_the_dip_is_reported_when_it_settles_after_the_restart(self): + """A restart delays the notification with the window, it does not lose it.""" + after = _restart(self._confirmed()) + after.entity_id = "sensor.test_energy" + + for value in (6.0, 7.0, 8.0): + after._mock_panel_value = value + after._update_native_value() + + after.coordinator.report_energy_dip.assert_called_once_with( + "sensor.test_energy", 1000.0, 1000.0 + ) + class TestAnOutageDoesNotSettleADip: """Grace period and dip compensation share a stored record but not a decision. diff --git a/tests/test_energy_dip_retraction.py b/tests/test_energy_dip_retraction.py index 035c161b..4787ff0b 100644 --- a/tests/test_energy_dip_retraction.py +++ b/tests/test_energy_dip_retraction.py @@ -23,12 +23,18 @@ **And corroboration is evidence rather than proof.** The same burst that replays one stale sample can replay two, the second slightly higher than the first, which is indistinguishable on the tick from a counter climbing away from -a fresh base. So confirming does not close the record: for +a fresh base. So corroborating does not close the record: for `CONFIRMED_RETRACTION_WINDOW` more readings a return to the baseline still -retracts everything. Once the window is spent the record is dropped and the -offset is final, because a compensation that could be undone at any future -reading would be undone by the ordinary growth of a counter that really did -reset. +retracts it. The reading that closes the window *settles* the dip — the offset +is final and the user is told — because a compensation that could be undone at +any future reading would be undone by the ordinary growth of a counter that +really did reset. + +**A dip inside that window is a dip of its own**, kept apart from the older one +and judged against the reading it fell from. A 3 Wh artefact folded into a +1000 Wh dip's record would be measured against a baseline the counter cannot +reach again inside the window, so it could never be retracted — a small replay +made permanent by a large one. The cost is that a genuine reset on a circuit drawing nothing stays unconfirmed until it next moves. Compensation is applied throughout — only the notification @@ -70,11 +76,13 @@ def _feed(readings: list[float], *, after: float) -> list[DipOutcome]: previous = after offset = 0.0 pending: PendingDip | None = None + confirmed: PendingDip | None = None for raw in readings: - outcome = process_energy_dip(raw, previous, offset, pending) + outcome = process_energy_dip(raw, previous, offset, pending, confirmed) outcomes.append(outcome) offset, pending, previous = outcome.offset, outcome.pending, raw + confirmed = outcome.recently_confirmed return outcomes @@ -174,7 +182,10 @@ def test_a_reading_just_under_the_baseline_still_retracts(self) -> None: class TestConfirmation: - """The counter counts up from the lower base, so the reset was real.""" + """The counter counts up from the lower base, which only a reset produces. + + Believed, not closed: the dip is corroborated here and settles later. + """ def test_progress_from_the_dipped_reading_confirms(self) -> None: """Counting up from the lower base is what a reset looks like.""" @@ -185,10 +196,9 @@ def test_progress_from_the_dipped_reading_confirms(self) -> None: assert outcome.event is DipEvent.CONFIRMED assert outcome.delta == pytest.approx(BASELINE) assert outcome.offset == pytest.approx(BASELINE) - # Believed, but kept: see `TestConfirmThenReturn`. - assert outcome.pending == PendingDip( - baseline=BASELINE, delta=BASELINE, confirmed_ticks_left=CONFIRMED_RETRACTION_WINDOW - ) + # Nothing is unsettled any more; the dip moves to the corroborated slot + # and waits out its window there. See `TestConfirmThenReturn`. + assert outcome.pending is None def test_the_offset_stays_applied_through_confirmation(self) -> None: """Confirming settles the belief, it does not change the arithmetic.""" @@ -248,15 +258,22 @@ class TestConfirmThenReturn: """ def test_a_return_to_the_baseline_after_confirmation_still_retracts(self) -> None: - """The review's sequence: 1000 -> 0 (booked) -> 5 (confirmed) -> 1001.""" - offset, pending = _book() + """The review's sequence: 1000 -> 0 (booked) -> 5 (corroborated) -> 1001.""" + outcomes = _feed([0.0, 5.0, BASELINE + 1.0], after=BASELINE) - confirmed = process_energy_dip(5.0, 0.0, offset, pending) - returned = process_energy_dip(BASELINE + 1.0, 5.0, confirmed.offset, confirmed.pending) + assert outcomes[-1].event is DipEvent.RETRACTED + assert outcomes[-1].offset == pytest.approx(0.0) + assert outcomes[-1].compensated == pytest.approx(BASELINE + 1.0) + + def test_the_corroborated_dip_is_kept_with_a_window(self) -> None: + """Believed, but not closed, and held apart from anything unsettled.""" + outcomes = _feed([0.0, 5.0], after=BASELINE) - assert returned.event is DipEvent.RETRACTED - assert returned.offset == pytest.approx(0.0) - assert returned.compensated == pytest.approx(BASELINE + 1.0) + assert outcomes[-1].event is DipEvent.CONFIRMED + assert outcomes[-1].pending is None + assert outcomes[-1].recently_confirmed == PendingDip( + baseline=BASELINE, delta=BASELINE, confirmed_ticks_left=CONFIRMED_RETRACTION_WINDOW + ) def test_the_last_reading_of_the_window_can_still_retract(self) -> None: """The window is three readings, and the third of them is one of them.""" @@ -280,40 +297,79 @@ def test_the_window_runs_out_and_the_offset_becomes_final(self) -> None: """ outcomes = _feed([0.0, 5.0, 6.0, 7.0, 8.0, BASELINE + 1.0], after=BASELINE) - assert outcomes[-2].pending is None + assert outcomes[-2].event is DipEvent.SETTLED + assert outcomes[-2].recently_confirmed is None assert outcomes[-1].event is None assert outcomes[-1].offset == pytest.approx(BASELINE) assert outcomes[-1].compensated == pytest.approx(2 * BASELINE + 1.0) - def test_a_genuine_reset_that_keeps_counting_drops_its_record(self) -> None: - """Nothing is provisional forever: the record is not carried indefinitely.""" + def test_a_genuine_reset_settles_on_the_reading_that_closes_the_window(self) -> None: + """Nothing stays provisional forever, and settling names the amount. + + Which is what the notification reports, because it is the first moment + nothing can take the offset back. + """ outcomes = _feed([0.0, 5.0, 6.0, 7.0, 8.0], after=BASELINE) - assert outcomes[1].pending is not None - assert outcomes[1].pending.confirmed_ticks_left == CONFIRMED_RETRACTION_WINDOW - assert [outcome.pending is None for outcome in outcomes[2:]] == [False, False, True] + assert [outcome.settled for outcome in outcomes] == [None, None, None, None, BASELINE] + assert [outcome.recently_confirmed is None for outcome in outcomes[1:]] == [ + False, + False, + False, + True, + ] - def test_a_further_fall_inside_the_window_accumulates_and_still_retracts(self) -> None: - """A burst can deliver more than one stale sample, in any order. - The second fall extends the provisional amount against the original - baseline, so the return gives all of it back at once. - """ - outcomes = _feed([0.0, 5.0, 2.0, BASELINE + 1.0], after=BASELINE) +class TestADipInsideAConfirmedWindow: + """A fall while an older dip is still provisional is its own dip. + + Its baseline is the reading it fell from, not the older dip's high-water + mark, because that is what the counter has to come back to for *this* fall + to be undone. Folding the two together would judge a 3 Wh artefact against a + counter's whole lifetime total — a mark it can never reach again inside the + window, so the artefact would be compensated for good. + """ + + def test_the_smaller_dip_retracts_against_its_own_baseline(self) -> None: + """`1000 -> 0 -> 5 -> 2 -> 6`: the 3 Wh comes back, the 1000 Wh stays.""" + outcomes = _feed([0.0, 5.0, 2.0, 6.0], after=BASELINE) assert outcomes[2].event is DipEvent.BOOKED - assert outcomes[2].offset == pytest.approx(BASELINE + 3.0) + assert outcomes[2].pending == PendingDip(baseline=5.0, delta=3.0) + assert outcomes[-1].event is DipEvent.RETRACTED + assert outcomes[-1].delta == pytest.approx(3.0) + assert outcomes[-1].offset == pytest.approx(BASELINE) + + def test_a_return_to_the_older_baseline_retracts_both(self) -> None: + """`1000 -> 0 -> 5 -> 2 -> 1001`: the counter is where it started.""" + outcomes = _feed([0.0, 5.0, 2.0, BASELINE + 1.0], after=BASELINE) + assert outcomes[-1].event is DipEvent.RETRACTED assert outcomes[-1].delta == pytest.approx(BASELINE + 3.0) assert outcomes[-1].offset == pytest.approx(0.0) + assert outcomes[-1].compensated == pytest.approx(BASELINE + 1.0) - def test_a_fall_inside_the_window_does_not_reopen_it(self) -> None: - """A fall is not a return, so it spends a reading like any other.""" - outcomes = _feed([0.0, 5.0, 2.0, 3.0, 4.0, BASELINE + 1.0], after=BASELINE) + def test_the_older_dip_keeps_ageing_through_the_newer_one(self) -> None: + """A fall is not a return, so it does not buy the window more readings.""" + outcomes = _feed([0.0, 5.0, 2.0, 6.0, 7.0], after=BASELINE) - assert outcomes[-2].pending is None - assert outcomes[-1].event is None - assert outcomes[-1].offset == pytest.approx(BASELINE + 3.0) + assert outcomes[-1].event is DipEvent.SETTLED + assert outcomes[-1].settled == pytest.approx(BASELINE) + assert outcomes[-1].offset == pytest.approx(BASELINE) + + def test_corroborating_the_newer_dip_settles_the_older_one(self) -> None: + """The bound stays three readings from the most recent corroboration. + + So the older dip is made final rather than merged into the newer one's + window, which would let a chain of falls keep it provisional forever. + """ + outcomes = _feed([0.0, 5.0, 2.0, 3.0], after=BASELINE) + + assert outcomes[-1].event is DipEvent.CONFIRMED + assert outcomes[-1].settled == pytest.approx(BASELINE) + assert outcomes[-1].recently_confirmed == PendingDip( + baseline=5.0, delta=3.0, confirmed_ticks_left=CONFIRMED_RETRACTION_WINDOW + ) class TestASecondDropWhileUnconfirmed: diff --git a/tests/test_grace_period_restoration.py b/tests/test_grace_period_restoration.py index 502b3882..e282670c 100644 --- a/tests/test_grace_period_restoration.py +++ b/tests/test_grace_period_restoration.py @@ -40,7 +40,9 @@ def test_as_dict_with_all_values(self): "last_dip_delta": None, "pending_dip_baseline": None, "pending_dip_delta": None, - "pending_dip_confirmed_ticks_left": None, + "confirmed_dip_baseline": None, + "confirmed_dip_delta": None, + "confirmed_dip_ticks_left": None, } def test_as_dict_with_none_values(self): @@ -64,7 +66,9 @@ def test_as_dict_with_none_values(self): "last_dip_delta": None, "pending_dip_baseline": None, "pending_dip_delta": None, - "pending_dip_confirmed_ticks_left": None, + "confirmed_dip_baseline": None, + "confirmed_dip_delta": None, + "confirmed_dip_ticks_left": None, } def test_from_dict_with_all_values(self): From 62720919af67990f3cd0dcc919b0c5ca17df3ea9 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:32:53 -0700 Subject: [PATCH 178/345] test(sensor): pin the at_service_entrance adapter gate to the installed adapter keys `_SERVICE_ENTRANCE_ADAPTER` names the adapter whose mapper resolves `lugs_at_service_entrance`, and nothing held that name against the adapters. A renamed key would have matched no panel, made every install look like an adapter that does not resolve the field, and dropped the `at_service_entrance` attribute with no error, no unavailable entity and nothing in the log. The assertion goes in the conformance module because that is what the module is for: its docstring says a statement about which adapters produce a field is checked against the adapters rather than left as prose, and this constant is the one such statement made outside `RESIDUAL_EXEMPT_PATHS`. Read off the adapter classes, since `SpanMqttClient.schema_major` returns the class attribute and a class needs no connection. Both halves are load-bearing. The `==` catches a renamed key silently dropping the attribute; the `!=` catches the constant naming the flat key, which would publish the library's `True` default on every flat panel -- the defect the gate was added to fix. Also makes the new `schema_zero_snapshot` docstring imperative, which D401 asks for and its sibling `schema_one_snapshot` already is. --- tests/adapter_fixtures.py | 2 +- tests/test_field_path_conformance.py | 35 +++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/tests/adapter_fixtures.py b/tests/adapter_fixtures.py index 72649abd..53273f5c 100644 --- a/tests/adapter_fixtures.py +++ b/tests/adapter_fixtures.py @@ -208,7 +208,7 @@ def schema_zero_metadata() -> dict[str, FieldMetadata]: def schema_zero_snapshot() -> SpanPanelSnapshot: - """A snapshot as the real flat adapter builds it, before any topic arrives. + """Build a snapshot as the real flat adapter builds it, before any topic arrives. The counterpart to `schema_one_snapshot`, and it exists for the fields the flat adapter never writes. Retained topics only ever *add* to this: a field diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py index f018dcaa..a894e076 100644 --- a/tests/test_field_path_conformance.py +++ b/tests/test_field_path_conformance.py @@ -17,8 +17,10 @@ import pytest from span_panel_api.models import FieldMetadata +from span_panel_api_schema_0.adapter import SchemaZeroAdapter +from span_panel_api_schema_1.adapter import SchemaOneAdapter -from custom_components.span_panel import field_paths as field_paths_module +from custom_components.span_panel import field_paths as field_paths_module, sensor_panel from custom_components.span_panel.field_paths import ( RESIDUAL_EXEMPT_PATHS, Producibility, @@ -443,3 +445,34 @@ def test_every_exempt_path_still_has_a_reader() -> None: f"exempt paths no longer read anywhere in the package: {unread}. The " "reader was removed; drop the exemption with it." ) + + +def test_the_service_entrance_gate_names_the_installed_adapter_keys() -> None: + """The one adapter fact this integration states outside `RESIDUAL_EXEMPT_PATHS`. + + `sensor_panel._SERVICE_ENTRANCE_ADAPTER` says which adapter resolves + `lugs_at_service_entrance`, and the grid power sensor publishes its + `at_service_entrance` attribute only when that adapter is the one running. + It is here for the reason the exemption annotations are: a statement about + what an adapter produces belongs against the adapters, not in prose. + + Without this the failure is silent and one-directional. A renamed adapter key + matches nothing, every panel looks like an adapter that does not resolve the + field, and the attribute simply stops appearing -- no error, no unavailable + entity, nothing in the log. The `!=` half matters just as much: the constant + naming the *flat* key would publish the library's `True` default on every + flat panel, which is the defect this gate was added to fix. + + Read off the adapter classes rather than off a live client because the class + attribute is what a client reports (`SpanMqttClient.schema_major` returns + `self._adapter.schema_major`), and a class needs no MQTT connection. + """ + assert SchemaOneAdapter.schema_major == sensor_panel._SERVICE_ENTRANCE_ADAPTER, ( + f"the parent/child adapter's key is {SchemaOneAdapter.schema_major!r} but the " + f"grid sensor gates on {sensor_panel._SERVICE_ENTRANCE_ADAPTER!r}; " + "at_service_entrance would silently stop being published" + ) + assert SchemaZeroAdapter.schema_major != sensor_panel._SERVICE_ENTRANCE_ADAPTER, ( + "the grid sensor gates on the flat adapter's key, which would publish the " + "library's default as a reading on every flat panel" + ) From 86b074597a537d84438a02a551fe380fcf5e8244 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:33:22 -0700 Subject: [PATCH 179/345] fix(control): debounce only delivered commands, refuse under DISABLED, follow settability changes The relay debounce stamped its window in `before_publish`, so a command the broker never took still refused the user's immediate retry with "that circuit's relay was operated less than 2 seconds ago" -- false, and unactionable at exactly the moment a person presses the button again. The stamp moves to `after_publish` and is recorded only for an outcome that is not FAILED, which is the one state promising the command will never arrive; the synchronous check stays in `before_publish` where the veto has to happen. `ControlMode.DISABLED` was enforced only by not creating entities. That is what a user sees, not a choke point, and the module opens by promising one. The gate now refuses under `disabled` with a translated `control_disabled` and a logbook phrase. No entity path changes: all four control platforms already create nothing under `disabled`, and no service publishes a control. `is_user_controllable` and `is_never_backup` were read once, at setup, so an entity could outlive its own settability -- the case the library's changelog names -- and go on offering a control the panel refuses every use of. Each platform's creation rule is now one predicate, read by `async_setup_entry` and by the entity, and the entity asks for a reload when the answer moves. The opposite edge, a circuit that becomes settable, still needs a reader that sees every circuit; the coordinator is where that belongs. Also, from the reviews of the two preceding control commits: a restored disarm whose pending window cannot be read now arms the lock rather than granting a fresh one, since a restart is not consent to operate the panel; the runtime data's default lock is derived from the default policy instead of being a bare disarmed object; and the `ServiceNotFound` branch in the priority select is removed with its notification and its test, the MQTT client having no way to raise it. --- CHANGELOG.md | 6 + custom_components/span_panel/__init__.py | 18 ++- custom_components/span_panel/control_gate.py | 60 ++++++-- custom_components/span_panel/logbook.py | 1 + custom_components/span_panel/select.py | 119 ++++++++++------ custom_components/span_panel/strings.json | 3 + custom_components/span_panel/switch.py | 89 +++++++++--- .../span_panel/translations/en.json | 3 + .../span_panel/translations/es.json | 3 + .../span_panel/translations/fr.json | 3 + .../span_panel/translations/ja.json | 3 + .../span_panel/translations/pt.json | 3 + tests/test_circuit_control.py | 90 ++++++++++++ tests/test_control_gate.py | 130 +++++++++++++++++- tests/test_control_lock_state.py | 43 ++++++ tests/test_select.py | 120 ++++++++++++---- 16 files changed, 590 insertions(+), 104 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cec265c5..672bdc20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -166,6 +166,12 @@ working entirely in 2027.8. debounce — every one defaulting to what your panel already does. - **The control lock arrives armed and stays where you left it**, so turning the option on locks control straight away and a restart or a settings save no longer quietly unlocks the panel; a pending auto-relock comes back with the time it had left rather than starting its countdown over. +- **A control lock whose pending countdown cannot be read comes back armed** rather than opening a fresh window nobody asked for — which is what a record + written by an older release looks like on the first restart after upgrading. +- **The relay debounce counts only commands the panel was actually sent**, so a breaker command the broker never took no longer refuses your immediate retry + with "operated less than 2 seconds ago". +- **A circuit the panel stops letting you operate loses its breaker switch and its priority control** on the next update rather than at the next restart, + instead of leaving you a control the panel refuses every press of. - **Every control command is now recorded** in the logbook, on the event bus as `span_panel_control_command`, and at `INFO`, attributed to the automation that issued it rather than to nobody. - **Every control now reports what happened to it**, adopted devices' controls included, so a command that was refused or never handed to the broker no longer diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py index 8f059969..9a3bf8ff 100644 --- a/custom_components/span_panel/__init__.py +++ b/custom_components/span_panel/__init__.py @@ -95,6 +95,18 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +def _default_control_lock() -> ControlLock: + """Return the lock that belongs to the default control policy. + + Derived from the policy rather than written down again, so the two cannot + say different things about whether the feature is on. `async_setup_entry` + builds the pair the same way (`ControlLock(armed=policy.lock_enabled)`): a + lock that exists as a user-facing thing starts armed, and one the entry has + not enabled starts disarmed because there is no switch to open it with. + """ + return ControlLock(armed=ControlPolicy.default().lock_enabled) + + @dataclass class SpanPanelRuntimeData: """Runtime data for a Span Panel config entry.""" @@ -112,8 +124,10 @@ class SpanPanelRuntimeData: # with no control options has, and that is most of them. control_policy: ControlPolicy = field(default_factory=ControlPolicy.default) # Shared with the gate, and mutated by the lock entity. One object per entry: - # the gate reads it on every publish and the entity writes it. - control_lock: ControlLock = field(default_factory=ControlLock) + # the gate reads it on every publish and the entity writes it. The default + # is the default policy's lock rather than a bare `ControlLock()`, whose + # disarmed state would be a promise this class is in no position to make. + control_lock: ControlLock = field(default_factory=_default_control_lock) type SpanPanelConfigEntry = ConfigEntry[SpanPanelRuntimeData] diff --git a/custom_components/span_panel/control_gate.py b/custom_components/span_panel/control_gate.py index 28006a39..3c79f37b 100644 --- a/custom_components/span_panel/control_gate.py +++ b/custom_components/span_panel/control_gate.py @@ -345,9 +345,10 @@ def __init__( self._entry = entry self._policy = policy self._lock = lock - # Monotonic timestamp of the last relay command allowed through, per - # circuit. Keyed on the address the adapter produced rather than on an - # entity id, so it holds for a caller that never went through an entity. + # Monotonic timestamp of the last relay command that was handed to the + # broker, per circuit -- not of the last one that was attempted. Keyed on + # the address the adapter produced rather than on an entity id, so it + # holds for a caller that never went through an entity. self._last_relay: dict[tuple[str, str], float] = {} # -- ControlInterceptor ------------------------------------------------- @@ -363,6 +364,19 @@ async def before_publish(self, command: ControlCommand) -> None: context = caller.context if caller is not None else None user_id = context.user_id if context is not None else None + # First, because it is the entry-wide answer and the most useful thing + # to be told. Not creating the entities is what a user sees, but it is + # not a choke point: a command arriving by any other route -- a service + # this integration grows later, a code path that forgets the helper -- + # would otherwise be published by an entry whose owner switched control + # off. That is the promise this module opens with. + if self._policy.mode is ControlMode.DISABLED: + self._refuse( + "control_disabled", + "Control of this SPAN panel is switched off in its options. " + "Nothing was sent to the panel.", + ) + if self._lock.armed: self._refuse( "control_locked", @@ -385,10 +399,13 @@ async def before_publish(self, command: ControlCommand) -> None: ) if command.property_id == RELAY_PROPERTY: - self._check_relay_debounce(command) + self._refuse_a_debounced_relay(command) async def after_publish(self, command: ControlCommand, outcome: PublishOutcome) -> None: """Record what happened to every command, refusals included.""" + if command.property_id == RELAY_PROPERTY and outcome.state is not PublishState.FAILED: + self._open_relay_debounce_window(command) + caller = CONTROL_CALLER.get() context = caller.context if caller is not None else None reason = _REFUSAL_REASON.get() @@ -421,27 +438,50 @@ async def after_publish(self, command: ControlCommand, outcome: PublishOutcome) # -- internals ---------------------------------------------------------- - def _check_relay_debounce(self, command: ControlCommand) -> None: + def _refuse_a_debounced_relay(self, command: ControlCommand) -> None: """Refuse a relay command that follows too closely on the last one. Refused, never queued. A queued relay command firing seconds later against a changed panel state is worse than a refusal the automation author can see and fix. + + Only the check lives on this side. Opening the window is + `_open_relay_debounce_window`'s job and happens once the outcome is + known. """ window = self._policy.relay_debounce_seconds if window <= 0: return - circuit = (command.device_id, command.node_id) - now = time.monotonic() - last = self._last_relay.get(circuit) - if last is not None and now - last < window: + last = self._last_relay.get((command.device_id, command.node_id)) + if last is not None and time.monotonic() - last < window: self._refuse( "relay_debounced", f"That circuit's relay was operated less than {window:g} seconds ago. " "The command was refused rather than queued.", ) - self._last_relay[circuit] = now + + def _open_relay_debounce_window(self, command: ControlCommand) -> None: + """Start the window for a relay command that was actually handed over. + + Stamped from the outcome rather than from the attempt. A command that + was never delivered moved no contactor, and there is nothing to protect + from a second one -- while a bridge that is down is exactly the moment a + person presses the button again, so debouncing that retry with "operated + less than 2 seconds ago" is both false and unactionable. + + `FAILED` is the one state that promises the command will not arrive + later, and it covers every refusal too, this gate's own vetoes included. + `ACCEPTED` and `UNCONFIRMED` were handed to the broker and may already + have moved the relay, so both open the window. + + The library fires `after_publish` as a task rather than awaiting it, so + the stamp lands a tick after the publish returns. That is late only for + a caller issuing two relay commands without yielding to the event loop + in between, which a service call never does. + """ + if self._policy.relay_debounce_seconds > 0: + self._last_relay[(command.device_id, command.node_id)] = time.monotonic() def _refuse(self, reason: str, message: str, parent_id: str | None = None) -> None: """Record the reason for `after_publish`, then veto.""" diff --git a/custom_components/span_panel/logbook.py b/custom_components/span_panel/logbook.py index 590c6079..9d25e2e8 100644 --- a/custom_components/span_panel/logbook.py +++ b/custom_components/span_panel/logbook.py @@ -41,6 +41,7 @@ } _REFUSAL_PHRASES: dict[str, str] = { + "control_disabled": "but control of this panel is switched off", "control_locked": "but the control lock is armed", "contextless_control_refused": "but commands without a logged-in user are refused", "admin_only_control": "but only administrators may operate this panel", diff --git a/custom_components/span_panel/select.py b/custom_components/span_panel/select.py index 39d03d8f..b756e188 100644 --- a/custom_components/span_panel/select.py +++ b/custom_components/span_panel/select.py @@ -6,7 +6,6 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceNotFound from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -19,7 +18,6 @@ from .coordinator import SpanPanelCoordinator from .entity import SpanPanelEntity from .helpers import ( - async_create_span_notification, build_select_unique_id_for_entry, construct_circuit_identifier_from_tabs, construct_circuit_label, @@ -42,6 +40,27 @@ def _unnamed_select_fallback(circuit: SpanCircuitSnapshot, circuit_id: str) -> s return construct_circuit_identifier_from_tabs(circuit.tabs, circuit_id) +def _circuit_has_a_priority_select(circuit: SpanCircuitSnapshot) -> bool: + """Whether this circuit gets a shed-priority select at all. + + One predicate, read by `async_setup_entry` when it decides what to create + and by the entity itself when it decides whether it should still exist. A + second copy would drift, and the drift would be invisible: a select left on + the dashboard offering a choice the panel refuses. + + Priority settability is its own commissioning flag, not the relay's. v1.0 + expresses never-backup as the absence of `$settable` on + `load-shed/priority`, which the adapter carries here. + """ + if not circuit.is_user_controllable: + return False + if circuit.is_never_backup: + return False + # PV/EVSE circuits only get selects if they have a physical breaker + # (relative_position == "DOWNSTREAM" means connected at a breaker slot). + return not (circuit.device_type in ("pv", "evse") and circuit.relative_position != "DOWNSTREAM") + + _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 1 @@ -182,6 +201,13 @@ def __init__( self._attr_options = description.options_fn(circuit) self._attr_current_option = description.current_option_fn(circuit) + # What the panel said about this circuit when the entity was created. + # `async_setup_entry` read the same predicate to decide this select + # should exist; the library's own changelog names the case where the + # answer changes under a live entity, so the entity carries the answer + # forward and watches for it to move. See `_handle_coordinator_update`. + self._was_settable = _circuit_has_a_priority_select(circuit) + # Store initial circuit name for change detection in auto-sync if not existing_entity_id: self._previous_circuit_name: str | None | object = _NAME_UNSET @@ -216,12 +242,6 @@ async def async_select_option(self, option: str) -> None: resolved an address and was never handed to the broker, which the library promises means it will not arrive later either. Both live in `_async_control`, which every control in this integration shares. - - The `ServiceNotFound` branch is this platform's alone. Nothing on the - publish path calls a Home Assistant service -- the library does not - import Home Assistant at all -- so it survives as a guard against a - transport that one day does, and stays out of the shared helper rather - than being offered to controls that have never needed it. """ _LOGGER.debug("Selecting option: %s", option) client = self.coordinator.client @@ -231,27 +251,13 @@ async def async_select_option(self, option: str) -> None: priority = CircuitPriority(option) - try: - await self._async_control( - client.set_circuit_priority(self.id, priority.name), - command=f"a priority change for {self.entity_id}", - failed_key="circuit_priority_failed", - not_delivered_key="circuit_priority_not_delivered", - placeholders={"circuit": construct_circuit_label(self._get_circuit(), self.id)}, - ) - except ServiceNotFound as snf: - _LOGGER.warning( - "Service not found when setting priority: %s.%s", - snf.domain, - snf.service, - ) - await async_create_span_notification( - self.hass, - message="The requested service is not available in the SPAN API.", - title="Service Not Found", - notification_id=f"span_panel_service_not_found_{self.id}", - ) - return + await self._async_control( + client.set_circuit_priority(self.id, priority.name), + command=f"a priority change for {self.entity_id}", + failed_key="circuit_priority_failed", + not_delivered_key="circuit_priority_not_delivered", + placeholders={"circuit": construct_circuit_label(self._get_circuit(), self.id)}, + ) await self.coordinator.async_request_refresh() @@ -298,10 +304,17 @@ def extra_state_attributes(self) -> Mapping[str, Any] | None: return attributes or None def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + """Handle updated data from the coordinator. + + Two reload-worthy changes are watched on this one path: the circuit's + name, which the entity carries as `original_name` and can only refresh + by being rebuilt, and whether the panel still lets this circuit's shed + priority be set at all, which decided that this entity exists. + """ snapshot: SpanPanelSnapshot = self.coordinator.data circuit = snapshot.circuits.get(self.id) if circuit: + self._watch_settability(circuit) current_circuit_name = circuit.name # One path for both modes: the name is carried by `original_name`, @@ -348,6 +361,37 @@ def _handle_coordinator_update(self) -> None: self._attr_current_option = self.description_wrapper.current_option_fn(circuit) super()._handle_coordinator_update() + def _watch_settability(self, circuit: SpanCircuitSnapshot) -> None: + """Ask for a reload when the panel changes its mind about this circuit. + + An entity can outlive its own settability: `is_user_controllable` and + `is_never_backup` are read once, at setup, and a circuit the panel later + commissions never-backup keeps a select that refuses every choice made + in it. + + The opposite edge -- a circuit that *becomes* settable -- is picked up + only when the reload this asks for happens for some other reason, since + a circuit with no select has nothing here to notice it. Watching for + that one needs a reader that sees every circuit rather than its own, and + the coordinator is where that belongs. + + A reload rather than a quiet availability change, because the entity + should not exist at all under the new answer, and creating and removing + entities is what `async_setup_entry` is for. The edge is what triggers + it: comparing against the answer this entity was built with means a + steady state costs nothing on every push. + """ + settable = _circuit_has_a_priority_select(circuit) + if settable == self._was_settable: + return + _LOGGER.info( + "Circuit %s shed priority is %s settable, requesting integration reload", + self.id, + "now" if settable else "no longer", + ) + self._was_settable = settable + self.coordinator.request_reload() + def _construct_select_unique_id( self, coordinator: SpanPanelCoordinator, @@ -381,20 +425,7 @@ async def async_setup_entry( entities: list[SpanPanelCircuitsSelect | AdoptedSelect] = [] for circuit_id, circuit_data in snapshot.circuits.items(): - if not circuit_data.is_user_controllable: - continue - # Priority settability is its own commissioning flag, not the relay's. - # v1.0 expresses never-backup as the absence of `$settable` on - # `load-shed/priority`, which the adapter carries here; offering the - # control without checking it means offering a write the panel refuses. - if circuit_data.is_never_backup: - continue - # PV/EVSE circuits only get selects if they have a physical breaker - # (relative_position == "DOWNSTREAM" means connected at a breaker slot) - if ( - circuit_data.device_type in ("pv", "evse") - and circuit_data.relative_position != "DOWNSTREAM" - ): + if not _circuit_has_a_priority_select(circuit_data): continue entities.append( SpanPanelCircuitsSelect( diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index 3b8cb0a4..291b4a61 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -445,6 +445,9 @@ "contextless_control_refused": { "message": "This SPAN panel refuses control commands that do not come from a logged-in user." }, + "control_disabled": { + "message": "Control of this SPAN panel is switched off in its options. Nothing was sent to the panel." + }, "control_lock_disarm_requires_admin": { "message": "Only a Home Assistant administrator can disarm the SPAN panel control lock." }, diff --git a/custom_components/span_panel/switch.py b/custom_components/span_panel/switch.py index 8ea7eb2b..9ae7f94b 100644 --- a/custom_components/span_panel/switch.py +++ b/custom_components/span_panel/switch.py @@ -58,6 +58,21 @@ def _unnamed_switch_fallback(circuit: SpanCircuitSnapshot, circuit_id: str) -> s return construct_circuit_identifier_from_tabs(circuit.tabs, circuit_id) +def _circuit_has_a_breaker_switch(circuit: SpanCircuitSnapshot) -> bool: + """Whether this circuit gets a breaker switch at all. + + One predicate, read by `async_setup_entry` when it decides what to create + and by the entity itself when it decides whether it should still exist. A + second copy would drift, and the drift would be invisible: a switch left on + the dashboard offering a control the panel refuses. + """ + if not circuit.is_user_controllable: + return False + # PV/EVSE circuits only get switches if they have a physical breaker + # (relative_position == "DOWNSTREAM" means connected at a breaker slot). + return not (circuit.device_type in ("pv", "evse") and circuit.relative_position != "DOWNSTREAM") + + class SpanPanelCircuitsSwitch(SpanPanelEntity, SwitchEntity): """Represent a switch entity.""" @@ -140,6 +155,13 @@ def __init__( self._update_is_on() + # What the panel said about this circuit when the entity was created. + # `async_setup_entry` read the same predicate to decide this switch + # should exist; the library's own changelog names the case where the + # answer changes under a live entity, so the entity carries the answer + # forward and watches for it to move. See `_handle_coordinator_update`. + self._was_controllable = _circuit_has_a_breaker_switch(circuit) + # Store initial circuit name for change detection in auto-sync if not existing_entity_id: self._previous_circuit_name: str | None | object = _NAME_UNSET @@ -156,10 +178,17 @@ async def async_will_remove_from_hass(self) -> None: await super().async_will_remove_from_hass() def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + """Handle updated data from the coordinator. + + Two reload-worthy changes are watched on this one path: the circuit's + name, which the entity carries as `original_name` and can only refresh + by being rebuilt, and whether the panel still lets this circuit be + operated at all, which decided that this entity exists. + """ snapshot: SpanPanelSnapshot = self.coordinator.data circuit = snapshot.circuits.get(self._circuit_id) if circuit: + self._watch_settability(circuit) current_circuit_name = circuit.name # One path for both modes: the name is carried by `original_name`, @@ -194,6 +223,36 @@ def _handle_coordinator_update(self) -> None: self._update_is_on() super()._handle_coordinator_update() + def _watch_settability(self, circuit: SpanCircuitSnapshot) -> None: + """Ask for a reload when the panel changes its mind about this circuit. + + An entity can outlive its own controllability: `is_user_controllable` is + read once, at setup, and a circuit the panel later declares + non-commandable keeps a switch that refuses every press. + + The opposite edge -- a circuit that *becomes* commandable -- is picked up + only when the reload this asks for happens for some other reason, since + a circuit with no switch has nothing here to notice it. Watching for + that one needs a reader that sees every circuit rather than its own, and + the coordinator is where that belongs. + + A reload rather than a quiet availability change, because the entity + should not exist at all under the new answer, and creating and removing + entities is what `async_setup_entry` is for. The edge is what triggers + it: comparing against the answer this entity was built with means a + steady state costs nothing on every push. + """ + controllable = _circuit_has_a_breaker_switch(circuit) + if controllable == self._was_controllable: + return + _LOGGER.info( + "Circuit %s is %s user-controllable, requesting integration reload", + self._circuit_id, + "now" if controllable else "no longer", + ) + self._was_controllable = controllable + self.coordinator.request_reload() + @property def available(self) -> bool: """Return entity availability. @@ -400,14 +459,19 @@ async def _async_restored_relock_seconds(self) -> float | None: stored = None if extra is None else ControlLockExtraStoredData.from_dict(extra.as_dict()) if stored is None or stored.relock_at is None: # Either the previous run had no deadline to record, or its record - # is unreadable — a store written before this release, say. Falling - # back to this entry's configured window rather than to "no - # deadline" is what keeps an upgrade from leaving a disarmed lock - # open forever, and it is also the right answer when the user has - # changed the option since: the window they configured is the one - # they asked for. + # is unreadable — a store written before this release, say. timeout = self._policy.lock_timeout_minutes - return None if timeout is None or timeout <= 0 else timeout * 60 + if timeout is None or timeout <= 0: + # The entry asks for no countdown at all, so a disarm lasts + # until someone arms it. That is what the option promises and + # the restore has nothing to add to it. + return None + # This entry does auto-relock, and how much of the window was left + # is exactly what could not be read. Granting a fresh one would hand + # back more open time than the previous run had, on evidence that + # says nothing — and a restart is not consent to operate the panel. + # Treated as closed, which arms the lock and costs a user one click. + return 0.0 return (stored.relock_at - datetime.now(tz=UTC)).total_seconds() @property @@ -526,14 +590,7 @@ async def async_setup_entry( return for circuit_id, circuit_data in snapshot.circuits.items(): - if not circuit_data.is_user_controllable: - continue - # PV/EVSE circuits only get switches if they have a physical breaker - # (relative_position == "DOWNSTREAM" means connected at a breaker slot) - if ( - circuit_data.device_type in ("pv", "evse") - and circuit_data.relative_position != "DOWNSTREAM" - ): + if not _circuit_has_a_breaker_switch(circuit_data): continue entities.append(SpanPanelCircuitsSwitch(coordinator, circuit_id, _device_name)) diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index 3b8cb0a4..291b4a61 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -445,6 +445,9 @@ "contextless_control_refused": { "message": "This SPAN panel refuses control commands that do not come from a logged-in user." }, + "control_disabled": { + "message": "Control of this SPAN panel is switched off in its options. Nothing was sent to the panel." + }, "control_lock_disarm_requires_admin": { "message": "Only a Home Assistant administrator can disarm the SPAN panel control lock." }, diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index bc886572..3efc2c41 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -445,6 +445,9 @@ "contextless_control_refused": { "message": "Este SPAN Panel rechaza las órdenes de control que no proceden de un usuario con sesión iniciada." }, + "control_disabled": { + "message": "El control de este SPAN Panel está desactivado en sus opciones. No se envió nada al panel." + }, "control_lock_disarm_requires_admin": { "message": "Solo un administrador de Home Assistant puede desactivar el bloqueo de control del SPAN Panel." }, diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index 08220802..26ddcf52 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -445,6 +445,9 @@ "contextless_control_refused": { "message": "Ce SPAN Panel refuse les commandes de contrôle qui ne proviennent pas d'un utilisateur connecté." }, + "control_disabled": { + "message": "Le contrôle de ce SPAN Panel est désactivé dans ses options. Rien n'a été envoyé au panneau." + }, "control_lock_disarm_requires_admin": { "message": "Seul un administrateur Home Assistant peut désarmer le verrou de contrôle du SPAN Panel." }, diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index 62d6d721..d645dc31 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -445,6 +445,9 @@ "contextless_control_refused": { "message": "この SPAN Panel は、ログインユーザーからではない操作コマンドを拒否します。" }, + "control_disabled": { + "message": "この SPAN Panel の制御はオプションで無効になっています。パネルには何も送信されませんでした。" + }, "control_lock_disarm_requires_admin": { "message": "SPAN Panel のコントロールロックを解除できるのは Home Assistant の管理者だけです。" }, diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index dc3008bf..185a0a62 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -445,6 +445,9 @@ "contextless_control_refused": { "message": "Este SPAN Panel recusa comandos de controlo que não venham de um utilizador com sessão iniciada." }, + "control_disabled": { + "message": "O controlo deste SPAN Panel está desativado nas suas opções. Nada foi enviado ao painel." + }, "control_lock_disarm_requires_admin": { "message": "Apenas um administrador do Home Assistant pode desarmar o bloqueio de controlo do SPAN Panel." }, diff --git a/tests/test_circuit_control.py b/tests/test_circuit_control.py index d7393171..1e9066a8 100644 --- a/tests/test_circuit_control.py +++ b/tests/test_circuit_control.py @@ -245,6 +245,96 @@ def test_circuit_name_change_triggers_reload_request(hass: HomeAssistant) -> Non coordinator.request_reload.assert_called_once() +def test_a_circuit_that_stops_being_controllable_requests_a_reload( + hass: HomeAssistant, +) -> None: + """An entity can outlive its own controllability, and must not. + + `is_user_controllable` is read once, at setup, to decide whether this switch + exists at all. When the panel withdraws it mid-session the switch stays on + the dashboard and every press is refused by the panel, which is a worse + experience than the entity going away on the reload the flag change earns. + """ + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", + name="Kitchen Outlets", + is_user_controllable=True, + ) + coordinator = _make_coordinator({"1": circuit}) + coordinator.hass = hass + + with pytest.MonkeyPatch.context() as mp: + init_registry = MagicMock() + init_registry.async_get_entity_id.return_value = "switch.kitchen_outlets_breaker" + mp.setattr( + "custom_components.span_panel.switch.er.async_get", + lambda _hass: init_registry, + ) + switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") + + switch.hass = hass + switch.entity_id = "switch.kitchen_outlets_breaker" + switch.async_write_ha_state = MagicMock() + + # Same name, so nothing but the settability flag can explain a reload. + coordinator.data = SpanPanelSnapshotFactory.create( + circuits={"1": replace(circuit, is_user_controllable=False)} + ) + + with pytest.MonkeyPatch.context() as mp: + runtime_registry = MagicMock() + runtime_registry.async_get.return_value = None + mp.setattr( + "custom_components.span_panel.switch.er.async_get", + lambda _hass: runtime_registry, + ) + switch._handle_coordinator_update() + + coordinator.request_reload.assert_called_once() + + +def test_a_circuit_that_stays_controllable_requests_no_reload( + hass: HomeAssistant, +) -> None: + """The watch has to be an edge, or every push would reload the entry.""" + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", + name="Kitchen Outlets", + is_user_controllable=True, + relay_state="CLOSED", + ) + coordinator = _make_coordinator({"1": circuit}) + coordinator.hass = hass + + with pytest.MonkeyPatch.context() as mp: + init_registry = MagicMock() + init_registry.async_get_entity_id.return_value = "switch.kitchen_outlets_breaker" + mp.setattr( + "custom_components.span_panel.switch.er.async_get", + lambda _hass: init_registry, + ) + switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") + + switch.hass = hass + switch.entity_id = "switch.kitchen_outlets_breaker" + switch.async_write_ha_state = MagicMock() + + coordinator.data = SpanPanelSnapshotFactory.create( + circuits={"1": replace(circuit, relay_state="OPEN")} + ) + + with pytest.MonkeyPatch.context() as mp: + runtime_registry = MagicMock() + runtime_registry.async_get.return_value = None + mp.setattr( + "custom_components.span_panel.switch.er.async_get", + lambda _hass: runtime_registry, + ) + switch._handle_coordinator_update() + + coordinator.request_reload.assert_not_called() + + def test_switch_unavailable_when_panel_offline() -> None: """Switches become unavailable when the panel is offline.""" circuit = SpanCircuitSnapshotFactory.create(circuit_id="1") diff --git a/tests/test_control_gate.py b/tests/test_control_gate.py index 5146f83d..9ddce5eb 100644 --- a/tests/test_control_gate.py +++ b/tests/test_control_gate.py @@ -305,9 +305,9 @@ def test_an_unreadable_stored_deadline_is_reported_rather_than_guessed( ) -> None: """A hand-edited record says nothing usable, and says so. - Reported as None so the entity can fall back to its configured window; - inventing a deadline here would hide the difference from the one caller - that has to tell them apart. + Reported as None rather than as a guessed deadline, because the entity has + to be able to tell "no window was pending" from "the window is unreadable" + — it re-arms for the second and not the first. """ from custom_components.span_panel.control_gate import ControlLockExtraStoredData @@ -339,6 +339,7 @@ async def test_a_second_relay_command_inside_the_window_is_refused( "custom_components.span_panel.control_gate.time.monotonic", return_value=100.0 ): await gate.before_publish(RELAY) + await gate.after_publish(RELAY, CONFIRMED) with patch( "custom_components.span_panel.control_gate.time.monotonic", return_value=101.0 @@ -364,7 +365,9 @@ async def test_the_debounce_is_per_circuit(hass: HomeAssistant) -> None: "custom_components.span_panel.control_gate.time.monotonic", return_value=100.0 ): await gate.before_publish(RELAY) + await gate.after_publish(RELAY, CONFIRMED) await gate.before_publish(other) + await gate.after_publish(other, CONFIRMED) @pytest.mark.asyncio @@ -372,10 +375,15 @@ async def test_the_debounce_does_not_touch_other_controls(hass: HomeAssistant) - """A priority write is not a relay actuation and has no contactor to protect.""" gate = _gate(hass, debounce=2.0) + priority_confirmed = PublishOutcome( + state=PublishState.CONFIRMED, topic=PRIORITY.topic, value="OFF_GRID" + ) + with patch( "custom_components.span_panel.control_gate.time.monotonic", return_value=100.0 ): await gate.before_publish(PRIORITY) + await gate.after_publish(PRIORITY, priority_confirmed) await gate.before_publish(PRIORITY) @@ -385,9 +393,108 @@ async def test_a_zero_debounce_disables_it(hass: HomeAssistant) -> None: gate = _gate(hass, debounce=0.0) await gate.before_publish(RELAY) + await gate.after_publish(RELAY, CONFIRMED) await gate.before_publish(RELAY) +@pytest.mark.asyncio +async def test_a_command_that_was_never_delivered_does_not_debounce_the_retry( + hass: HomeAssistant, +) -> None: + """The debounce protects a contactor, and a FAILED command never reached one. + + The bridge being down is exactly when a person presses the button again, and + telling them the breaker "was operated less than 2 seconds ago" is both false + and unactionable. + """ + gate = _gate(hass, debounce=2.0) + never_sent = PublishOutcome( + state=PublishState.FAILED, + topic=RELAY.topic, + value="CLOSED", + detail="broker not connected; refused rather than queued", + ) + + with patch("custom_components.span_panel.control_gate.time.monotonic", return_value=100.0): + await gate.before_publish(RELAY) + await gate.after_publish(RELAY, never_sent) + + with patch("custom_components.span_panel.control_gate.time.monotonic", return_value=100.5): + await gate.before_publish(RELAY) + + +@pytest.mark.asyncio +async def test_a_delivered_command_debounces_the_next_one(hass: HomeAssistant) -> None: + """The window opens when the panel was actually told to move the relay.""" + gate = _gate(hass, debounce=2.0) + + with patch("custom_components.span_panel.control_gate.time.monotonic", return_value=100.0): + await gate.before_publish(RELAY) + await gate.after_publish(RELAY, CONFIRMED) + + with ( + patch("custom_components.span_panel.control_gate.time.monotonic", return_value=101.0), + pytest.raises(ServiceValidationError) as err, + ): + await gate.before_publish(RELAY) + + assert err.value.translation_key == "relay_debounced" + + +@pytest.mark.asyncio +async def test_a_refusal_does_not_open_a_debounce_window(hass: HomeAssistant) -> None: + """A vetoed command is a FAILED outcome too, and moved nothing.""" + lock = ControlLock(armed=True) + gate = _gate(hass, debounce=2.0, lock=lock) + + with patch("custom_components.span_panel.control_gate.time.monotonic", return_value=100.0): + with pytest.raises(ServiceValidationError): + await gate.before_publish(RELAY) + await gate.after_publish(RELAY, VETOED) + + lock.disarm(0) + with patch("custom_components.span_panel.control_gate.time.monotonic", return_value=100.5): + await gate.before_publish(RELAY) + + +# ---------- disabled ---------- + + +@pytest.mark.asyncio +async def test_a_disabled_entry_refuses_a_command_that_reaches_the_gate( + hass: HomeAssistant, +) -> None: + """`disabled` is a policy, not merely an absence of entities. + + Not creating the entities is what a user sees; it is not a choke point. A + command that arrives by any other route -- a service this integration grows + later, a caller that forgets the helper -- has to be refused here, which is + what the module promises. + """ + gate = _gate(hass, mode=ControlMode.DISABLED) + + with pytest.raises(ServiceValidationError) as err: + await gate.before_publish(RELAY) + + assert err.value.translation_key == "control_disabled" + + +@pytest.mark.asyncio +async def test_a_disabled_entry_refuses_an_administrator_too(hass: HomeAssistant) -> None: + """Nobody operates the panel through Home Assistant while control is off.""" + gate = _gate(hass, mode=ControlMode.DISABLED) + admin = MockUser(is_owner=True).add_to_hass(hass) + + token = async_bind_caller(Context(user_id=admin.id), "switch.span_panel_kitchen") + try: + with pytest.raises(ServiceValidationError) as err: + await gate.before_publish(RELAY) + finally: + CONTROL_CALLER.reset(token) + + assert err.value.translation_key == "control_disabled" + + # ---------- the audit ---------- @@ -890,6 +997,23 @@ async def test_the_logbook_explains_a_refusal(hass: HomeAssistant) -> None: assert "operated moments ago" in described["message"] +@pytest.mark.asyncio +async def test_the_logbook_explains_a_refusal_under_disabled(hass: HomeAssistant) -> None: + """Every reason the gate can refuse for has a sentence, or the audit is a code.""" + described = _describe( + hass, + { + "user_id": None, + "parent_id": None, + "command": "circuit-1/relay", + "value": "CLOSED", + "outcome": "refused:control_disabled", + }, + ) + + assert "switched off" in described["message"] + + # ---------- `disabled` keeps the entity_ids it stops creating ---------- diff --git a/tests/test_control_lock_state.py b/tests/test_control_lock_state.py index 50627f20..74081bf1 100644 --- a/tests/test_control_lock_state.py +++ b/tests/test_control_lock_state.py @@ -263,6 +263,49 @@ async def test_a_window_that_closed_while_home_assistant_was_down_comes_back_arm assert state.state == STATE_ON +@pytest.mark.asyncio +async def test_a_disarm_with_no_readable_deadline_comes_back_armed( + hass: HomeAssistant, +) -> None: + """An entry that auto-relocks cannot restore a window it cannot read. + + A record written before this release, or edited by hand, says the lock was + open and says nothing about how much of its window was left. Granting a + fresh one would hand back more open time than the previous run had, on no + evidence at all — and a restart is not consent to operate the panel. + """ + mock_restore_cache_with_extra_data( + hass, + ((State(LOCK_ENTITY, STATE_OFF), {"relock_at": "not-a-time"}),), + ) + + async with _panel(hass, timeout=30) as entry: + assert _lock(entry).armed is True + state = hass.states.get(LOCK_ENTITY) + assert state is not None + assert state.state == STATE_ON + + +@pytest.mark.asyncio +async def test_a_disarm_with_no_countdown_configured_stays_open( + hass: HomeAssistant, +) -> None: + """A zero timeout has no window to lose, so there is nothing to fail closed on. + + "0 keeps the lock disarmed until someone arms it" is the option's own + promise, and re-arming across a restart would break it for every entry that + never asked for a countdown. + """ + mock_restore_cache_with_extra_data( + hass, + ((State(LOCK_ENTITY, STATE_OFF), {}),), + ) + + async with _panel(hass, timeout=0) as entry: + assert _lock(entry).armed is False + assert _lock(entry).relock_in_seconds is None + + # ---------- the auto-relock is visible when it happens ---------- diff --git a/tests/test_select.py b/tests/test_select.py index e8226df3..9661c77d 100644 --- a/tests/test_select.py +++ b/tests/test_select.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceNotFound +from homeassistant.exceptions import HomeAssistantError import pytest from span_panel_api import PublishOutcome, PublishState from span_panel_api.exceptions import SpanPanelServerError @@ -65,34 +65,6 @@ def test_select_init_missing_circuit() -> None: SpanPanelCircuitsSelect(coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "bad_id", "Test Device") -@pytest.mark.asyncio -async def test_async_select_option_service_not_found() -> None: - """Test that ServiceNotFound triggers a notification.""" - coordinator = _make_coordinator_with_circuit() - circuit = coordinator.data.circuits["id"] - - with patch( - "custom_components.span_panel.select.async_create_span_notification", - new_callable=AsyncMock, - ) as mock_notification: - select = SpanPanelCircuitsSelect( - coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "Test Device" - ) - select.coordinator = coordinator - select.hass = MagicMock() - - # Make the client's set_circuit_priority raise ServiceNotFound - coordinator.client = AsyncMock() - coordinator.client.set_circuit_priority = AsyncMock( - side_effect=ServiceNotFound("test_domain", "test_service") - ) - - select._get_circuit = MagicMock(return_value=circuit) - await select.async_select_option(CircuitPriority.SOC_THRESHOLD.value) - - mock_notification.assert_called_once() - - @pytest.mark.asyncio async def test_async_select_option_refusal_is_raised_at_the_caller() -> None: """A refused priority reaches the person who chose it, carrying the reason. @@ -418,6 +390,96 @@ def test_handle_coordinator_update_requests_reload_on_name_change() -> None: assert select._previous_circuit_name == "Renamed Kitchen" +def test_a_circuit_commissioned_never_backup_requests_a_reload() -> None: + """Priority settability is its own flag, and it decides whether this exists. + + `is_never_backup` is read once, at setup. A circuit the panel later + commissions never-backup keeps a priority select that refuses every choice + the user makes in it. + """ + coordinator = _make_coordinator_with_circuit(circuit_name="Kitchen") + coordinator.hass = MagicMock() + with patch("custom_components.span_panel.select.er.async_get") as mock_async_get: + registry = MagicMock() + registry.async_get_entity_id.return_value = "select.kitchen_circuit_priority" + mock_async_get.return_value = registry + + select = SpanPanelCircuitsSelect( + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "SPAN Panel" + ) + + # Same name, so nothing but the settability flag can explain a reload. + updated_circuit = replace(coordinator.data.circuits["id"], is_never_backup=True) + coordinator.data = SpanPanelSnapshotFactory.create(circuits={"id": updated_circuit}) + select.hass = MagicMock() + select.async_write_ha_state = MagicMock() + select.entity_id = "select.kitchen_circuit_priority" + + with patch("custom_components.span_panel.select.er.async_get") as mock_async_get: + runtime_registry = MagicMock() + runtime_registry.async_get.return_value = None + mock_async_get.return_value = runtime_registry + select._handle_coordinator_update() + + coordinator.request_reload.assert_called_once() + + +def test_a_circuit_that_stops_being_controllable_requests_a_select_reload() -> None: + """The other half of the same gate: a circuit that stops being commandable.""" + coordinator = _make_coordinator_with_circuit(circuit_name="Kitchen") + coordinator.hass = MagicMock() + with patch("custom_components.span_panel.select.er.async_get") as mock_async_get: + registry = MagicMock() + registry.async_get_entity_id.return_value = "select.kitchen_circuit_priority" + mock_async_get.return_value = registry + + select = SpanPanelCircuitsSelect( + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "SPAN Panel" + ) + + updated_circuit = replace(coordinator.data.circuits["id"], is_user_controllable=False) + coordinator.data = SpanPanelSnapshotFactory.create(circuits={"id": updated_circuit}) + select.hass = MagicMock() + select.async_write_ha_state = MagicMock() + select.entity_id = "select.kitchen_circuit_priority" + + with patch("custom_components.span_panel.select.er.async_get") as mock_async_get: + runtime_registry = MagicMock() + runtime_registry.async_get.return_value = None + mock_async_get.return_value = runtime_registry + select._handle_coordinator_update() + + coordinator.request_reload.assert_called_once() + + +def test_a_circuit_whose_settability_is_unchanged_requests_no_select_reload() -> None: + """The watch has to be an edge, or every push would reload the entry.""" + coordinator = _make_coordinator_with_circuit(circuit_name="Kitchen") + coordinator.hass = MagicMock() + with patch("custom_components.span_panel.select.er.async_get") as mock_async_get: + registry = MagicMock() + registry.async_get_entity_id.return_value = "select.kitchen_circuit_priority" + mock_async_get.return_value = registry + + select = SpanPanelCircuitsSelect( + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "SPAN Panel" + ) + + updated_circuit = replace(coordinator.data.circuits["id"], priority="OFF_GRID") + coordinator.data = SpanPanelSnapshotFactory.create(circuits={"id": updated_circuit}) + select.hass = MagicMock() + select.async_write_ha_state = MagicMock() + select.entity_id = "select.kitchen_circuit_priority" + + with patch("custom_components.span_panel.select.er.async_get") as mock_async_get: + runtime_registry = MagicMock() + runtime_registry.async_get.return_value = None + mock_async_get.return_value = runtime_registry + select._handle_coordinator_update() + + coordinator.request_reload.assert_not_called() + + def test_handle_coordinator_update_skips_when_circuit_missing_from_snapshot() -> None: """Select entity should not crash when its circuit is temporarily absent from a snapshot.""" coordinator = _make_coordinator_with_circuit(circuit_name="Kitchen") From 5904201fbe780a0bc8aa6dc9f7de12d06a8d18f7 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:35:51 -0700 Subject: [PATCH 180/345] fix(adoption,extension,additions): five robustness defects from the release review Adopted unique_ids are not injective: flattening the hyphens collapses `battery-2/cell-temperature` and `battery/2-cell-temperature` onto one id, and Home Assistant drops the loser permanently with only a core log line naming an id the user cannot map back to the wire. The encoding does not change -- an adopted unique_id is as permanent as a curated one, so the injective form would move every id an install already holds -- so `_create` claims one id per platform, keeps the first property to reach it, and skips the second with a WARNING naming both wire paths. The injective encoding is recorded as a follow-on for the next identity namespace. Adoption lacked the 255-character state clamp `9567cb9` added to extension, so an unbounded vendor string reached the state machine verbatim: core substitutes `unknown` and logs an error on every update, which loses the reading entirely. One `clamp_state` now serves both halves of vendor extensibility. The additions store did no shape narrowing, and it is read from inside `async_setup_entry`: a wrong-shaped file raised out of it, shut the coordinator down and failed the entry with no retry. It is now narrowed exactly as `notices._load` is, and a bad shape re-seeds with a WARNING. The extension-overflow notice was raised through `async_raise` on a condition re-derived at every setup, so it returned after every dismissal -- un-dismissable in practice, and the opposite of what `notices.py` promises. `async_raise_on_change` records what was announced in the notices store, and the notice returns only when the declined set changes. The record deliberately outlives dismissal. Adopted devices and extension properties were absent from `detect_capabilities`, and nothing adds those entities dynamically, so one appearing after setup reached the user only after a manual restart. They are carried as one token per device id and per subject/path, which keeps the existing expansion-only reload trigger correct on removal as well as on arrival. Also folds in a deferred minor from the Task 5 review: `parse_number_format` treats a reversed range or a non-positive step as unparseable, so a `NumberEntity` is never built with bounds that admit no value or a step the frontend divides by. Caveat: an existing `number.*` entity whose format reversed its bounds -- which never worked -- moves to the sensor domain. Its unique_id is unchanged. --- custom_components/span_panel/additions.py | 72 +++++++++-- custom_components/span_panel/adoption.py | 129 +++++++++++++++++-- custom_components/span_panel/extension.py | 67 ++++++---- custom_components/span_panel/helpers.py | 40 +++++- custom_components/span_panel/notices.py | 121 +++++++++++++++++- developer.md | 25 ++++ tests/test_adoption.py | 147 +++++++++++++++++++++- tests/test_coordinator.py | 116 +++++++++++++++++ tests/test_extension_entities.py | 106 +++++++++++++++- tests/test_new_entity_announcement.py | 66 ++++++++++ 10 files changed, 840 insertions(+), 49 deletions(-) diff --git a/custom_components/span_panel/additions.py b/custom_components/span_panel/additions.py index dce1ffbf..35e79f3d 100644 --- a/custom_components/span_panel/additions.py +++ b/custom_components/span_panel/additions.py @@ -28,7 +28,7 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any, Final +from typing import TYPE_CHECKING, Final, TypedDict from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.storage import Store @@ -87,7 +87,18 @@ """ -def _store(hass: HomeAssistant, entry: ConfigEntry) -> Store[dict[str, Any]]: +class StoredAnnouncements(TypedDict): + """The unique_ids this entry has already announced. + + Typed rather than `dict[str, Any]` so the one shape this file writes is + stated once. It is a compile-time claim only -- see `_load` for what the disk + is actually allowed to hold. + """ + + announced_unique_ids: list[str] + + +def _store(hass: HomeAssistant, entry: ConfigEntry) -> Store[StoredAnnouncements]: """Return the record of what has already been announced for this entry. Per entry rather than per domain: two panels add entities independently, and @@ -96,6 +107,50 @@ def _store(hass: HomeAssistant, entry: ConfigEntry) -> Store[dict[str, Any]]: return Store(hass, _STORE_VERSION, f"{DOMAIN}.announced.{entry.entry_id}") +def _load(stored: object, entry: ConfigEntry) -> frozenset[str] | None: + """Read the announced set off disk, or `None` when the file is not what we wrote. + + `notices._load` does this and has three tests; this file had neither, and the + consequence here is the worse of the two. This coroutine is awaited from + inside `async_setup_entry`, so a wrong-shaped file raised out of it, the + coordinator was shut down and the entry went to SETUP_ERROR -- which is not + retried. A panel would stay down until somebody found and deleted a file + about *notifications*. + + On-disk data violates the TypedDict freely: a hand edit, a partially restored + backup, a file written by a later version and read after a rollback. Home + Assistant already renames undecodable JSON and raises a core repair; the gap + is valid JSON of the wrong shape, which it hands back intact. + + `None` means "no usable record", which the caller treats exactly as a first + install: re-seed from what is registered now and announce nothing. That + silently swallows one pass of genuine additions, which is the same trade the + first install already makes and is worth strictly less than the entry. + + An `announced_unique_ids` holding something that is not a list of strings is + refused whole rather than filtered down to the strings in it. A partial read + would announce every id the bad row displaced as though it were new, which is + the flood the record exists to prevent -- and unlike a notice, an id here + carries no meaning of its own to salvage. + """ + if stored is None: + return None + announced = stored.get(_ANNOUNCED) if isinstance(stored, dict) else None + if not isinstance(announced, list) or not all( + isinstance(unique_id, str) for unique_id in announced + ): + _LOGGER.warning( + "Ignoring the announcement record for %s: expected an object with an " + "'%s' list of ids, found %s. Entities added by this setup are recorded " + "as known rather than announced; the next addition is announced normally.", + entry.entry_id, + _ANNOUNCED, + type(announced if isinstance(stored, dict) else stored).__name__, + ) + return None + return frozenset(announced) + + async def async_announce_new_entities(hass: HomeAssistant, entry: ConfigEntry) -> None: """Tell the user which entities this setup added, and which need enabling. @@ -108,13 +163,17 @@ async def async_announce_new_entities(hass: HomeAssistant, entry: ConfigEntry) - predates the record has entities that were never announced but are not new either, so the first pass adopts them as already-known rather than announcing a release's worth of history. + + Silent, once, after a record that could not be read -- the same seeding path, + reached from `_load` returning `None`. Nothing about a notification is worth + failing setup for. """ store = _store(hass, entry) - stored = await store.async_load() + announced = _load(await store.async_load(), entry) registered = _registered(hass, entry) - if stored is None: - await store.async_save({_ANNOUNCED: sorted(registered)}) + if announced is None: + await store.async_save(StoredAnnouncements(announced_unique_ids=sorted(registered))) _LOGGER.debug( "Seeded the announcement record for %s with %d entities; nothing announced", entry.entry_id, @@ -122,7 +181,6 @@ async def async_announce_new_entities(hass: HomeAssistant, entry: ConfigEntry) - ) return - announced = frozenset(stored.get(_ANNOUNCED, ())) added = [ registry_entry for registry_entry in _entries(hass, entry) @@ -139,7 +197,7 @@ async def async_announce_new_entities(hass: HomeAssistant, entry: ConfigEntry) - title=_text(text, "title"), message=_message(hass, added, text), ) - await store.async_save({_ANNOUNCED: sorted(announced | registered)}) + await store.async_save(StoredAnnouncements(announced_unique_ids=sorted(announced | registered))) _LOGGER.debug("Announced %d new entities for %s", len(added), entry.entry_id) diff --git a/custom_components/span_panel/adoption.py b/custom_components/span_panel/adoption.py index 0791aaff..40e085bf 100644 --- a/custom_components/span_panel/adoption.py +++ b/custom_components/span_panel/adoption.py @@ -65,6 +65,49 @@ ENUM_DATATYPE: Final = "enum" NUMERIC_DATATYPES: Final = frozenset({"float", "integer"}) +MAX_STATE_LENGTH: Final = 255 +"""Home Assistant's hard limit on a state string. + +The same number core spells `MAX_LENGTH_STATE_STATE`, and the same number its own +error message quotes back at you. + +Lives in this module rather than in `extension.py` because both halves of vendor +extensibility need it and this is the half the other imports from. It arrived in +extension only, which is exactly how adoption came to be missing it. +""" + + +def clamp_state(published: str, wire_path: str) -> str: + """Return a published string short enough for Home Assistant to store as a state. + + **Truncated rather than passed through, and truncated rather than refused.** + A vendor string is unbounded on the wire and core refuses one over + `MAX_STATE_LENGTH`: `hass.states.async_set` raises `InvalidStateError`, and + the entity platform's own setter logs an error and substitutes `unknown`. So + passing it through does not deliver a long reading, it delivers *no* reading + plus one error line per update, forever, for a value nobody chose to enable. + The first 255 characters are strictly more than that. + + Applied only where the value reaches the state machine as text. A declared + numeric is parsed to a float first and cannot be long, and this must not + touch it: clamping the digits of a number would silently change its value + rather than shorten a label. + + One function for both halves of vendor extensibility. `9567cb9` added this to + `extension.py` and left `adoption.py` -- the module with the *unbounded* + device vocabulary -- writing raw strings. + """ + if len(published) <= MAX_STATE_LENGTH: + return published + _LOGGER.debug( + "%s published %d characters; truncated to %d", + wire_path, + len(published), + MAX_STATE_LENGTH, + ) + return published[:MAX_STATE_LENGTH] + + DEVICE_CLASS_BY_UNIT: dict[str, SensorDeviceClass] = { "W": SensorDeviceClass.POWER, "kW": SensorDeviceClass.POWER, @@ -234,6 +277,19 @@ def adopted_unique_id(identifier: str, declaration: AdoptedProperty) -> str: becomes a human-chosen description key rather than a wire address. Those are the change itself, not a formatting difference, which is why promotion needs to take over the existing id rather than expecting to reproduce it. + + **This grammar is not injective, deliberately and permanently.** Flattening + the hyphens is what makes the suffix read like a curated one, and it is also + what collapses `battery-2` + `cell-temperature` and `battery` + + `2-cell-temperature` onto one id. `extension_unique_id` carries the wire path + verbatim and is injective by construction, and that is the encoding a *new* + identity namespace should use -- but adopting it here would move every + adopted id an install already holds, which strands the entities keyed on them + with nothing to migrate them back. So the collision is handled where the + entities are built (`_create`) rather than removed here: the first property + to claim an id keeps it and the second is skipped with both wire paths named. + Changing the encoding is a follow-on for whatever namespace comes next, and + it needs a maintainer's ruling rather than a fix. """ wire_path = f"{declaration.node_id}.{declaration.property_id}".replace("-", "_") return f"span_{identifier.lower()}_{get_user_friendly_suffix(wire_path)}" @@ -400,12 +456,16 @@ def native_value(self) -> str | float | None: than as its raw text: the entity has a unit and a device class, and putting a string behind those would be a worse lie than reporting nothing. + + An undeclared one is text, and text off a vendor device is unbounded, so + it goes through `clamp_state` -- see there for why truncating beats + letting core refuse it. """ raw = self._published() if raw is None: return None if self.entity_description.native_unit_of_measurement is None: - return raw + return clamp_state(raw, f"Adopted {self._declaration_path}") try: return float(raw) except ValueError: @@ -638,6 +698,15 @@ def parse_number_format(declared: str | None) -> tuple[float, float, float] | No Non-finite bounds are refused alongside the unparseable ones: `float()` accepts `"nan"` and `"inf"`, and neither is a bound a value can be clamped to or a figure the frontend can be handed. + + So are a reversed range and a step of zero or less, for the same reason and + not for a different one: they parse, and they are still not a domain. A + `NumberEntity` with `min > max` admits no value at all, and a step of `0` + divides by zero in the frontend's own slider arithmetic while a negative one + counts the wrong way. The rule is `minimum <= maximum and step > 0`; + `minimum == maximum` is left alone, because a single-valued domain is a + degenerate control rather than a broken one and refusing it would move a + property nobody has complained about. """ if not declared: return None @@ -649,7 +718,9 @@ def parse_number_format(declared: str | None) -> tuple[float, float, float] | No except ValueError: return None bounds = (minimum, maximum, step) - return bounds if all(isfinite(bound) for bound in bounds) else None + if not all(isfinite(bound) for bound in bounds): + return None + return bounds if minimum <= maximum and step > 0 else None def adopted_control_count(snapshot: SpanPanelSnapshot) -> int: @@ -770,18 +841,58 @@ def _create[AdoptedT: AdoptedEntity]( *, panel_device_id: str, ) -> list[AdoptedT]: - """Build one platform's share of the adopted properties. + """Build one platform's share of the adopted properties, one entity per id. One partition function rather than five bodies, so `classify` stays the only place a property's platform is decided. Five bodies would each restate the predicate, and a property could then reach two platforms or none. + + **The first property to claim an id keeps it.** `adopted_unique_id` is not + injective -- see its docstring for why that cannot be fixed by changing the + encoding -- so two wire addresses can arrive at one id. Handing both to Home + Assistant registers the first and drops the second *permanently*, with one + core log line naming a `unique_id` the user cannot map back to anything on + the wire. Skipping it here costs the same entity and names both addresses, so + the case is reportable by whoever meets it. + + Claimed per platform, which is the scope the registry keys on: an entity is + unique by (domain, integration, unique_id), so the same id under `sensor` and + under `binary_sensor` is not a collision and must not be treated as one. + + Arrival order decides the winner, and the order tracks the wire. That is a + real weakness -- a firmware update reordering a description could hand the id + to the other property -- and it is bounded by how unlikely the collision is + in the first place: it needs one device publishing two addresses that differ + only in which side of a hyphen a segment falls. The injective namespace is + the real answer; this is the guard until there is one. """ - return [ - entity_class(coordinator, identifier, device, declaration, panel_device_id=panel_device_id) - for device, identifier in _adopted(snapshot, registry) - for declaration in device.properties - if classify(declaration) is platform - ] + built: list[AdoptedT] = [] + claimed: dict[str, str] = {} + for device, identifier in _adopted(snapshot, registry): + for declaration in device.properties: + if classify(declaration) is not platform: + continue + unique_id = adopted_unique_id(identifier, declaration) + first_claim = claimed.get(unique_id) + if first_claim is not None: + _LOGGER.warning( + "Adopted %s on %s resolves to the same entity id as %s, which was seen " + "first; %s is not surfaced. Both wire addresses flatten to %s. Attach " + "this integration's diagnostics to an issue if you need the second one", + declaration.path, + device.device_id, + first_claim, + declaration.path, + unique_id, + ) + continue + claimed[unique_id] = declaration.path + built.append( + entity_class( + coordinator, identifier, device, declaration, panel_device_id=panel_device_id + ) + ) + return built def _adopted( diff --git a/custom_components/span_panel/extension.py b/custom_components/span_panel/extension.py index d25cfb48..92f12729 100644 --- a/custom_components/span_panel/extension.py +++ b/custom_components/span_panel/extension.py @@ -37,11 +37,17 @@ from homeassistant.helpers.entity_registry import EntityRegistry from span_panel_api import ExtensionProperty, ExtensionSubject, SpanPanelSnapshot -from .adoption import BOOLEAN_DATATYPE, DEVICE_CLASS_BY_UNIT, homie_boolean, humanised +from .adoption import ( + BOOLEAN_DATATYPE, + DEVICE_CLASS_BY_UNIT, + clamp_state, + homie_boolean, + humanised, +) from .const import DOMAIN from .coordinator import SpanPanelCoordinator from .entity import SpanPanelEntity -from .notices import async_raise, read_translations +from .notices import async_raise_on_change, read_translations from .util import ( ADOPTED_IDENTIFIER_TOKEN, SUB_DEVICE_BESS, @@ -54,9 +60,6 @@ SCOPE_PANEL: Final = "panel" -MAX_STATE_LENGTH: Final = 255 -"""Home Assistant's hard limit on a state string; a longer one raises on write.""" - HINT_READING: Final = "reading" HINT_DETAIL: Final = "detail" @@ -401,24 +404,17 @@ def __init__( @property def native_value(self) -> str | float | None: - """Return the published value, parsed to a number only where one is declared.""" + """Return the published value, parsed to a number only where one is declared. + + An undeclared one is text, and a vendor string is unbounded on the wire, + so it goes through the clamp `adoption` holds for both halves of vendor + extensibility. + """ raw = self._published() if raw is None: return None if self.entity_description.native_unit_of_measurement is None: - # Truncated rather than passed through: Home Assistant refuses a - # state over 255 characters, and a vendor string is unbounded on the - # wire. Raising once per update for a value nobody chose is worse - # than showing the first 255 characters of it. - if len(raw) > MAX_STATE_LENGTH: - _LOGGER.debug( - "Extension %s published %d characters; truncated to %d", - self._declaration_path, - len(raw), - MAX_STATE_LENGTH, - ) - return raw[:MAX_STATE_LENGTH] - return raw + return clamp_state(raw, f"Extension {self._declaration_path}") try: return float(raw) except ValueError: @@ -618,26 +614,34 @@ async def async_notice_declined_extensions( device_registry: DeviceRegistry, entity_registry: EntityRegistry, ) -> None: - """Tell the user when the cap left vendor readings out, or say nothing. + """Tell the user once when the cap left vendor readings out, or say nothing. A durable notice rather than a log line, because the alternative is a truncation the user cannot see: an entity list showing sixty of a device's eighty readings looks exactly like a device with sixty readings. Raised once at setup rather than from `adoptable`, which both platforms call. + + **Once, not once per setup.** The overflow is re-derived from the same wire + every time this runs, so raising it through `async_raise` put the notice back + on screen after every restart and reload -- for as long as the publisher kept + publishing, which is forever. `notices.py` says in its own opening paragraphs + that a notice stands until it is dismissed; one that ignores the dismissal is + not a notice, and this integration would have taught its users to ignore the + category. + + The remembered fingerprint is the rendered device/count list rather than the + message, so a *worse* truncation later -- another device overflowing, or more + readings declined on the same one -- is news the user is told about, while a + translation change is not. """ declined = declined_extensions(snapshot, device_registry, entity_registry) if not declined: return rendered = ", ".join(f"{key} ({count})" for key, count in sorted(declined.items())) - _LOGGER.warning( - "Vendor readings beyond the per-device limit of %d were not adopted: %s", - MAX_PER_DEVICE, - rendered, - ) text = await hass.async_add_executor_job( read_translations, hass.config.language, _OVERFLOW_NOTICE ) - async_raise( + raised = async_raise_on_change( hass, entry, _OVERFLOW_NOTICE, @@ -645,4 +649,15 @@ async def async_notice_declined_extensions( message=(text.get("body") or _OVERFLOW_FALLBACK["body"]).format( limit=MAX_PER_DEVICE, devices=rendered ), + fingerprint=rendered, + ) + # Logged on the same decision as the notice, for the same reason. A WARNING + # per setup for a condition the user has already answered is the log's + # version of an un-dismissable notice, and it is the line a maintainer would + # read as "this just happened". + _LOGGER.log( + logging.WARNING if raised else logging.DEBUG, + "Vendor readings beyond the per-device limit of %d were not adopted: %s", + MAX_PER_DEVICE, + rendered, ) diff --git a/custom_components/span_panel/helpers.py b/custom_components/span_panel/helpers.py index de595552..10ef4921 100644 --- a/custom_components/span_panel/helpers.py +++ b/custom_components/span_panel/helpers.py @@ -55,6 +55,7 @@ "CIRCUIT_SUFFIX_MAPPING", "PANEL_ENTITY_SUFFIX_MAPPING", "PANEL_SUFFIX_MAPPING", + "adopted_capability_tokens", "async_create_span_notification", "build_bess_unique_id", "build_mid_unique_id", @@ -415,6 +416,35 @@ def has_der_link_health(snapshot: SpanPanelSnapshot) -> bool: ) +def adopted_capability_tokens(snapshot: SpanPanelSnapshot) -> frozenset[str]: + """One token per adopted device and per vendor extension property. + + Vendor extensibility is the one part of the snapshot whose vocabulary this + integration cannot enumerate in advance, so it cannot be reduced to a named + flag the way `bess` or `pcs` are. It reaches the reload trigger as the + identities themselves. + + **One token each rather than one hash of the set.** The trigger fires on set + *expansion* (`current - known`), and a hash of a shrinking set is as "new" as + a hash of a growing one — so a single token would reload the integration when + a vendor device left the tree, and flap it on a device that came and went. + Per-identity tokens make the expansion-only rule hold for free: a device that + arrives adds a token, a device that leaves removes one, and only the first is + a reload. + + Keyed on the wire identity rather than on the value: a reading changing is + every refresh, and the question here is only whether an entity that could not + be created at setup now can. + """ + return frozenset( + [f"adopted:{device.device_id}" for device in snapshot.adopted_devices] + + [ + f"extension:{row.subject.kind}:{row.subject.instance_key or ''}:{row.path}" + for row in snapshot.extension_properties + ] + ) + + def detect_capabilities(snapshot: SpanPanelSnapshot) -> frozenset[str]: """Derive the set of optional capabilities present in the snapshot. @@ -423,8 +453,16 @@ def detect_capabilities(snapshot: SpanPanelSnapshot) -> frozenset[str]: sensors are created. A capability is not hardware, but it reaches this the same way — the panel starts publishing a node it did not publish before — and the consequence is identical: entities that could not be created at setup now can. + + Adopted devices and vendor extension properties are in the set for exactly + that reason. `adoption.py` opens by calling a device type nobody modelled an + *expected* event rather than a hypothetical one, and nothing adds those + entities dynamically — so with them excluded, a vendor device that appeared an + hour after setup produced no device, no entity and no reload, and the user saw + nothing at all until they restarted Home Assistant. See + `adopted_capability_tokens` for why they are carried as identities. """ - caps: set[str] = set() + caps: set[str] = set(adopted_capability_tokens(snapshot)) if has_bess(snapshot): caps.add("bess") if has_pv(snapshot): diff --git a/custom_components/span_panel/notices.py b/custom_components/span_panel/notices.py index 15b3545d..66e08033 100644 --- a/custom_components/span_panel/notices.py +++ b/custom_components/span_panel/notices.py @@ -24,6 +24,12 @@ from live state on every refresh does not belong here; it belongs in `schema_repairs`, which reconciles instead, and where being a defect is the point. + +There is a middle case, and `async_raise_on_change` is for it: a condition that +*is* re-derived at every setup, but whose news value is spent the first time it +is reported. Raised through `async_raise` such a notice reappears after every +dismissal, which makes it un-dismissable in practice. That function remembers +what was announced, so the notice returns only when what it says changes. """ from __future__ import annotations @@ -80,9 +86,10 @@ class StandingNotice(TypedDict): class StoredNotices(TypedDict): - """One config entry's undismissed notices, by notice id.""" + """One config entry's undismissed notices, and what it has already been told.""" standing: dict[str, StandingNotice] + announced: dict[str, str] @dataclass(slots=True) @@ -92,10 +99,17 @@ class _Notices: The store is held rather than rebuilt per call because `async_delay_save` schedules on the instance: two `Store` objects over the same key would each hold a pending write and race to be last. + + `standing` and `announced` answer different questions and are deliberately + not one dict. `standing` is "what is on screen", and dismissal empties it. + `announced` is "what the user has already been told", and *nothing* empties + it -- that is the whole point of it, because a notice whose record died with + its dismissal would be raised again by the next setup. """ store: Store[StoredNotices] standing: dict[str, StandingNotice] + announced: dict[str, str] _DATA: HassKey[dict[str, _Notices]] = HassKey(f"{DOMAIN}_standing_notices") @@ -137,7 +151,12 @@ async def async_restore(hass: HomeAssistant, entry: ConfigEntry) -> None: store: Store[StoredNotices] = Store( hass, _STORE_VERSION, f"{DOMAIN}.notices.{entry.entry_id}" ) - notices = _Notices(store=store, standing=_load(await store.async_load(), entry)) + stored = await store.async_load() + notices = _Notices( + store=store, + standing=_load(stored, entry), + announced=_load_announced(stored, entry), + ) known[entry.entry_id] = notices for notice_id, notice in notices.standing.items(): @@ -200,6 +219,43 @@ def _load(stored: object, entry: ConfigEntry) -> dict[str, StandingNotice]: return kept +def _load_announced(stored: object, entry: ConfigEntry) -> dict[str, str]: + """Read the announced fingerprints off disk, tolerating anything else. + + Same trade as `_load`, and read separately rather than as part of it because + the two fall back differently in one way that matters: a lost `standing` set + shows a notice again, which is mildly annoying, while a lost `announced` set + shows a notice the user already dismissed, which is the exact defect the + record exists to prevent. Neither is worth failing setup over, so both fall + back to empty and the next raise rewrites the file. + + Absent entirely on a record written before this field existed, which is the + ordinary upgrade path rather than an error: the first setup after the upgrade + raises once more and records it. + + A row whose fingerprint is not a string is dropped without a line of its own. + Unlike a malformed *notice*, which loses text nothing can re-derive, a + dropped fingerprint costs exactly one extra raise of a notice that is + re-derived anyway. + """ + announced = stored.get("announced") if isinstance(stored, dict) else None + if announced is None: + return {} + if not isinstance(announced, dict): + _LOGGER.warning( + "Ignoring the announcement record for %s: expected a mapping, found %s. A " + "notice already dismissed may be shown once more.", + entry.entry_id, + type(announced).__name__, + ) + return {} + return { + str(notice_id): fingerprint + for notice_id, fingerprint in announced.items() + if isinstance(fingerprint, str) + } + + @callback def async_raise( hass: HomeAssistant, entry: ConfigEntry, notice_id: str, *, title: str, message: str @@ -230,6 +286,60 @@ def async_raise( _persist(notices) +@callback +def async_raise_on_change( + hass: HomeAssistant, + entry: ConfigEntry, + notice_id: str, + *, + title: str, + message: str, + fingerprint: str, +) -> bool: + """Raise a notice only when what it reports differs from what was last reported. + + For the notices whose *cause* is re-derived from live state at every setup + rather than happening once. Those cannot use `async_raise` directly: it + re-raises unconditionally, so the cause being still true would put the notice + back on screen after every restart and reload, and a notice that returns + however often it is dismissed is not a notice, it is a nag with no off + switch. The module's own rule (see the top of this file) says a re-derivable + condition belongs in `schema_repairs`; this is the middle case, where the + condition is re-derivable but the *news* is not -- being told once that a + vendor device declares more readings than will be added is useful, and being + told again every Tuesday is not. + + `fingerprint` is what was said, not that something was said, so a *different* + truncation later is news again and reaches the user. The caller chooses it; + it wants to be the rendered facts rather than the rendered prose, or a + translation update would re-announce. + + Returns whether it raised, so a caller can scope its logging to the same + decision rather than duplicating it. + + Dismissal does not clear the record -- that is the point -- so a user who + wants to be told again has to remove the entry. That is the correct trade for + a condition the diagnostics download reports in full anyway. + + The record is written *before* the raise rather than after, so `async_raise`'s + own `_persist` carries both halves and this stays one queued write per + action. Two would be correct on disk and worse in every other way: a `Store` + keeps one pending write, so the second merely resets the first's timer. + """ + notices = hass.data.get(_DATA, {}).get(entry.entry_id) + if notices is not None: + if notices.announced.get(notice_id) == fingerprint: + _LOGGER.debug( + "Notice %s already announced for %s with the same content; not raised again", + notice_id, + entry.entry_id, + ) + return False + notices.announced[notice_id] = fingerprint + async_raise(hass, entry, notice_id, title=title, message=message) + return True + + @callback def _on_change( hass: HomeAssistant, @@ -247,6 +357,11 @@ def _on_change( Filtered to this entry's own ids because the dispatcher delivers every notification change in the system, most of which belong to other integrations. + + Only the standing set is touched. The announcement record deliberately + outlives dismissal -- clearing it here would let the next setup raise the + notice the user just dismissed, which is the defect `async_raise_on_change` + exists to close. """ if change is not UpdateType.REMOVED: return @@ -283,7 +398,7 @@ def _persist(notices: _Notices) -> None: property it buys -- what is queued is what was true when it was queued -- does not then depend on every future mutation site remembering to re-persist. """ - written = StoredNotices(standing=dict(notices.standing)) + written = StoredNotices(standing=dict(notices.standing), announced=dict(notices.announced)) notices.store.async_delay_save(lambda: written, _SAVE_DELAY) diff --git a/developer.md b/developer.md index e251311f..5d1f70e7 100644 --- a/developer.md +++ b/developer.md @@ -537,6 +537,26 @@ node rule, creates no entity, and so had nothing to call `async_get_or_create` f Running it before the platforms also makes the identity freeze single-valued: `resolve_identifier` runs once, at registration, so every entity created afterwards resolves against a device that already exists and cannot disagree. +### The device-level grammar is not injective, and the collision is caught + +`adopted_unique_id` spells `span_{serial}_adopted_{anchor}_{suffix}` and builds the suffix through `get_user_friendly_suffix`, which flattens the hyphens. That +is what makes it read like a curated suffix, and it is also what collapses `battery-2` + `cell-temperature` and `battery` + `2-cell-temperature` onto one id — +exactly the collision the extension grammar above avoids by carrying the path verbatim. + +The encoding does not change. An adopted `unique_id` is as permanent as a curated one, so adopting the injective form would move every id an install already +holds and strand the entities keyed on them, with nothing to migrate them back. The collision is handled where the entities are built instead: `_create` claims +one id per platform, the first property to reach an id keeps it, and the second is skipped with a WARNING naming both wire paths. Handing both to Home Assistant +registers the first and drops the second permanently, with one core log line naming an id the user cannot map back to anything on the wire. + +The injective encoding belongs to whatever identity namespace comes next, and adopting it there is a maintainer's ruling rather than a fix. + +### A device or a property that arrives after setup + +Nothing here adds entities dynamically, so a vendor device or extension property that appears an hour after setup reaches the user through the capability reload +and nothing else. `detect_capabilities` therefore carries one token per adopted device id and one per extension subject/path alongside the named flags, and +`_check_capability_change` fires on set _expansion_ exactly as it does for `bess` or `pcs`. One token each rather than one hash of the set: a hash of a shrinking +set is as "new" as a hash of a growing one, so a single token would reload the integration when a device left the tree and flap it on one that came and went. + ### The proxy link is recorded, and the nesting is not built `AdoptedDevice` carries `parent` (the device id it declares as its parent) and `proxied` (whether that parent is a peer rather than the tree root). Adoption @@ -754,6 +774,11 @@ panel's card, so counting per card would pool thirty-five devices against one al misbehaving publisher anywhere. Overflow raises a durable notice (`async_notice_declined_extensions`, once at setup rather than once per platform), because a truncation the user cannot see is the one thing worse than the truncation: sixty of a device's eighty readings looks exactly like a device with sixty. +**Told once, not once per setup.** The overflow is re-derived from the same wire at every setup, so raising it through `async_raise` put it back on screen after +every restart and reload — un-dismissable in practice, which is the opposite of what `notices.py` promises. It goes through `async_raise_on_change` instead, +which records what was announced in the notices store and raises again only when that changes. The fingerprint is the rendered device/count list rather than the +message, so another device overflowing is news and a translation update is not; dismissal does not clear the record, because clearing it there is the defect. + **An id the registry already holds is never displaced.** The cap admits in adapter emission order, which tracks the wire, so a firmware update declaring a new property earlier shifts everything after it. Capping on arrival order alone would let a new property evict a standing entity — whose registry row is permanent, and for which nothing would ever build an entity again, leaving it unavailable forever with a stranger in its slot and no migration path by design. `adoptable` diff --git a/tests/test_adoption.py b/tests/test_adoption.py index fab512a9..9244cb6c 100644 --- a/tests/test_adoption.py +++ b/tests/test_adoption.py @@ -18,7 +18,7 @@ from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, InvalidStateError from homeassistant.helpers import device_registry as dr import pytest from pytest_homeassistant_custom_component.common import MockConfigEntry @@ -29,6 +29,7 @@ from custom_components.span_panel.adoption import ( CONTROL_PLATFORMS, DEVICE_CLASS_BY_UNIT, + MAX_STATE_LENGTH, AdoptedNumber, adopted_control_count, adopted_identifier, @@ -682,6 +683,126 @@ def test_curation_changes_two_of_the_three_segments(hass: HomeAssistant) -> None assert ADOPTED_IDENTIFIER_TOKEN not in curated_if_modelled +# -- The grammar is not injective, and the collision is caught ---------------- + + +def _colliding_pair() -> tuple[AdoptedProperty, AdoptedProperty]: + """Two wire addresses that differ only in which side of the hyphen the `2` falls. + + The review's own example, and a realistic one: a battery vendor hanging a + second pack off the device publishes `battery-2/cell-temperature`, and a + vendor that numbers the property rather than the node publishes + `battery/2-cell-temperature`. + """ + first = _property(node_id="battery-2", property_id="cell-temperature", unit="°C", value="31.4") + second = _property(node_id="battery", property_id="2-cell-temperature", unit="°C", value="99.9") + return first, second + + +def test_the_adopted_grammar_is_not_injective() -> None: + """Stated as an assertion because it is a permanent property of these ids. + + `{node}.{property}` with the hyphens flattened cannot distinguish where the + hyphen was, so two distinct wire addresses reach one id. The id form is + frozen -- an adopted `unique_id` is as permanent as a curated one, and + moving it would strand every entity already keyed on it -- so the collision + is *handled* rather than removed. `extension.py`'s grammar carries the wire + path verbatim and is injective by construction; that is the encoding a future + identity namespace would adopt, and this test records why it cannot be + adopted here. + """ + identifier = adopted_identifier(PANEL_SERIAL, "battery-pack-1") + first, second = _colliding_pair() + + assert adopted_unique_id(identifier, first) == adopted_unique_id(identifier, second) + + +def test_the_second_of_two_colliding_properties_is_skipped_rather_than_built( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Home Assistant drops the loser of a unique_id collision permanently. + + Two entities added under one id leaves the second unregistered with only a + core log line naming a `unique_id` the user cannot map back to a wire + address. Keeping the first and saying so names both addresses, which is what + makes the case reportable. + """ + first, second = _colliding_pair() + snapshot = _snapshot(_device(properties=(first, second))) + + sensors = create_adopted_sensors( + MagicMock(data=snapshot), snapshot, dr.async_get(hass), panel_device_id="panel-device-id" + ) + + assert len(sensors) == 1 + assert sensors[0].native_value == 31.4 + assert "battery-2/cell-temperature" in caplog.text + assert "battery/2-cell-temperature" in caplog.text + + +def test_the_same_address_on_two_devices_is_not_a_collision(hass: HomeAssistant) -> None: + """The anchor is part of the id, so the guard must not pool devices together. + + Two identical vendor devices publishing the same property is the ordinary + case, not the pathological one. + """ + declaration = _property(node_id="meter", property_id="active-power") + snapshot = _snapshot( + _device("generator-1", properties=(declaration,)), + _device("generator-2", properties=(declaration,)), + ) + + sensors = create_adopted_sensors( + MagicMock(data=snapshot), snapshot, dr.async_get(hass), panel_device_id="panel-device-id" + ) + + assert len({str(sensor.unique_id) for sensor in sensors}) == 2 + + +# -- A vendor string longer than a Home Assistant state ----------------------- + + +def test_a_string_over_the_state_limit_is_clamped_rather_than_written( + hass: HomeAssistant, +) -> None: + """`9567cb9` fixed this for extension properties and left adoption behind. + + Home Assistant refuses a state over 255 characters -- the assertion below is + core's own rule, not this integration's. A vendor string is unbounded on the + wire, and an adopted `string` property with no unit reaches the state machine + verbatim: the entity platform's own setter does not raise, it logs an ERROR + and substitutes `unknown`, so the reading was lost on every update and the + log filled up saying so. + """ + with pytest.raises(InvalidStateError): + hass.states.async_set("sensor.synthetic_adopted_notes", "x" * 300) + + declaration = _property( + node_id="diagnostics", property_id="notes", datatype="string", unit=None, value="x" * 300 + ) + snapshot = _snapshot(_device(properties=(declaration,))) + + (sensor,) = create_adopted_sensors( + MagicMock(data=snapshot), snapshot, dr.async_get(hass), panel_device_id="panel-device-id" + ) + + assert sensor.native_value == "x" * MAX_STATE_LENGTH + + +def test_a_string_within_the_limit_is_passed_through_untouched(hass: HomeAssistant) -> None: + """Clamping the pathological case may not truncate the ordinary one.""" + declaration = _property( + node_id="diagnostics", property_id="notes", datatype="string", unit=None, value="ok" + ) + snapshot = _snapshot(_device(properties=(declaration,))) + + (sensor,) = create_adopted_sensors( + MagicMock(data=snapshot), snapshot, dr.async_get(hass), panel_device_id="panel-device-id" + ) + + assert sensor.native_value == "ok" + + # -- Diagnostics report the proxy relationship, never the parent's id --------- @@ -704,7 +825,19 @@ def test_diagnostics_report_whether_a_device_is_proxied_and_not_by_whom() -> Non # -- A numeric format this integration cannot read --------------------------- -MALFORMED_FORMATS = ["0-100", "auto", "0:banana", "0:100:step", "nan:100", "0:inf", "%", "16 A"] +MALFORMED_FORMATS = [ + "0-100", + "auto", + "0:banana", + "0:100:step", + "nan:100", + "0:inf", + "%", + "16 A", + "80:10", + "0:100:0", + "0:100:-1", +] """Formats a non-compliant publisher can put on a numeric, none of them a range. Homie 5 spells a numeric domain `min:max` with an optional `:step` and permits @@ -712,6 +845,11 @@ def test_diagnostics_report_whether_a_device_is_proxied_and_not_by_whom() -> Non specification -- which a vendor-extensible schema is exactly where to meet. `nan:100` and `0:inf` are here because `float()` accepts both: they parse without raising and are still not bounds anything can be clamped to. + +The last three are the same class one step further in. `80:10` reverses the +range, so the control admits no value at all; `0:100:0` and `0:100:-1` give a +step that the frontend's own slider arithmetic divides by or counts backwards +with. Each parses cleanly and none of them is a domain. """ @@ -743,6 +881,11 @@ def test_parsing_a_format_nothing_can_read_answers_none_rather_than_raising(fmt: ("10:", (10.0, 100.0, 1.0)), (":", (0.0, 100.0, 1.0)), ("-5:5:0.5", (-5.0, 5.0, 0.5)), + # Equal bounds are degenerate, not broken: a single-valued domain still + # admits a value, so refusing it would move a property nobody has + # complained about. The boundary is asserted from both sides -- `80:10` + # is in MALFORMED_FORMATS. + ("5:5:1", (5.0, 5.0, 1.0)), ], ) def test_a_format_homie_permits_parses_exactly_as_it_always_did( diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index b449bb95..94c36083 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -2,11 +2,18 @@ from __future__ import annotations +from dataclasses import replace import logging from typing import cast from unittest.mock import AsyncMock, MagicMock, patch import pytest +from span_panel_api import ( + AdoptedDevice, + AdoptedProperty, + ExtensionProperty, + ExtensionSubject, +) from span_panel_api.exceptions import ( SpanPanelAPIError, SpanPanelAuthError, @@ -16,6 +23,7 @@ ) from custom_components.span_panel.coordinator import SpanPanelCoordinator +from custom_components.span_panel.helpers import detect_capabilities from homeassistant.core import HomeAssistant from span_panel_api import SpanMqttClient from span_panel_api.models import FieldMetadata @@ -720,3 +728,111 @@ def test_the_reload_trigger_sees_every_capability_the_platforms_gate_on() -> Non assert {"shed_forecast", "pcs", "bess_telemetry", "der_link_health"} <= ( detect_capabilities(snapshot) ) + + +# --- a device or a property that arrives after setup ------------------------- + + +def _adopted_device(device_id: str = "generator-1") -> AdoptedDevice: + return AdoptedDevice( + device_id=device_id, + device_type="energy.ebus.device.generator", + name="Backup Generator", + properties=( + AdoptedProperty( + node_id="meter", property_id="active-power", datatype="float", unit="W" + ), + ), + ) + + +def _extension_row(property_id: str = "cell-temperature") -> ExtensionProperty: + return ExtensionProperty( + subject=ExtensionSubject(kind="battery", instance_key=None), + node_id="battery-2", + property_id=property_id, + datatype="float", + unit="°C", + ) + + +async def test_an_adopted_device_arriving_after_setup_asks_for_a_reload( + hass: HomeAssistant, +) -> None: + """Adoption's module docstring calls a device nobody modelled an *expected* event. + + Nothing adds these entities dynamically, so the only way one reaches the user + without a manual restart is the capability reload -- and the capability set + used to be blind to them, so a vendor device that appeared an hour after + setup produced no device, no entity and no reload until somebody restarted + Home Assistant. + """ + coordinator = _create_coordinator(hass) + baseline = SpanPanelSnapshotFactory.create() + arrived = replace(baseline, adopted_devices=(_adopted_device(),)) + + assert detect_capabilities(arrived) != detect_capabilities(baseline) + + coordinator._check_capability_change(baseline) + coordinator._check_capability_change(arrived) + + assert coordinator._reload_requested is True + + +async def test_an_extension_property_arriving_after_setup_asks_for_a_reload( + hass: HomeAssistant, +) -> None: + """The other half of vendor extensibility, and the same silence.""" + coordinator = _create_coordinator(hass) + baseline = SpanPanelSnapshotFactory.create() + arrived = replace(baseline, extension_properties=(_extension_row(),)) + + assert detect_capabilities(arrived) != detect_capabilities(baseline) + + coordinator._check_capability_change(baseline) + coordinator._check_capability_change(arrived) + + assert coordinator._reload_requested is True + + +async def test_a_device_that_leaves_the_tree_asks_for_nothing(hass: HomeAssistant) -> None: + """Expansion-only, exactly like every other capability flag. + + A device that stops publishing costs nothing to leave alone: its entities + read unknown and nothing here removes a registry row, so a reload would + rebuild the identical set. Reloading on a shrink would also make a flapping + publisher reload the integration on a loop. + """ + coordinator = _create_coordinator(hass) + present = replace( + SpanPanelSnapshotFactory.create(), + adopted_devices=(_adopted_device(), _adopted_device("generator-2")), + ) + gone = replace(present, adopted_devices=(_adopted_device(),)) + + coordinator._check_capability_change(present) + coordinator._check_capability_change(gone) + + assert coordinator._reload_requested is False + + +async def test_the_same_devices_and_properties_ask_for_nothing(hass: HomeAssistant) -> None: + """The fingerprint has to be stable, or every refresh would reload the integration.""" + coordinator = _create_coordinator(hass) + snapshot = replace( + SpanPanelSnapshotFactory.create(), + adopted_devices=(_adopted_device(),), + extension_properties=(_extension_row(), _extension_row("pack-voltage")), + ) + + coordinator._check_capability_change(snapshot) + for _ in range(5): + coordinator._check_capability_change( + replace( + SpanPanelSnapshotFactory.create(), + adopted_devices=(_adopted_device(),), + extension_properties=(_extension_row("pack-voltage"), _extension_row()), + ) + ) + + assert coordinator._reload_requested is False diff --git a/tests/test_extension_entities.py b/tests/test_extension_entities.py index 44ad55a6..bf943c20 100644 --- a/tests/test_extension_entities.py +++ b/tests/test_extension_entities.py @@ -13,7 +13,8 @@ from typing import TYPE_CHECKING from unittest.mock import MagicMock -from homeassistant.const import EntityCategory, Platform +from homeassistant.components.persistent_notification import async_dismiss +from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE, EntityCategory, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er import pytest @@ -28,6 +29,7 @@ ExtensionBinarySensor, ExtensionSensor, adoptable, + async_notice_declined_extensions, classify_extension, create_extension_binary_sensors, create_extension_sensors, @@ -37,6 +39,7 @@ resolve_platform, subject_key, ) +from custom_components.span_panel.notices import _DATA, async_restore from custom_components.span_panel.util import SUB_DEVICE_BESS from .factories import SpanPanelSnapshotFactory @@ -406,3 +409,104 @@ def test_the_hint_is_carried_on_the_entity_for_curation_triage( )[0] assert sensor._attr_extra_state_attributes["prominence_hint"] == HINT_READING assert sensor._attr_extra_state_attributes["wire_path"] == "battery-2/cell-temperature" + + +# --- the overflow notice is told once, not at every setup -------------------- + + +def _overflowing(count: int = MAX_PER_DEVICE + 3) -> SpanPanelSnapshot: + """One battery declaring more vendor properties than the cap will admit.""" + return _snapshot(*(_row(property_id=f"cell-{index}") for index in range(count))) + + +def _notification_id(entry: MockConfigEntry) -> str: + return f"{DOMAIN}_extension_overflow_{entry.entry_id}" + + +async def _notice(hass: HomeAssistant, entry: MockConfigEntry, snapshot: SpanPanelSnapshot) -> None: + await async_notice_declined_extensions( + hass, entry, snapshot, dr.async_get(hass), er.async_get(hass) + ) + + +async def test_the_overflow_notice_is_raised_when_the_cap_declines_something( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """A silent truncation reads as "that is everything the vendor publishes".""" + entry = hass.config_entries.async_get_entry(registered_panel[0]) + assert entry is not None + await async_restore(hass, entry) + + await _notice(hass, entry, _overflowing()) + + assert _notification_id(entry) in dict(hass.data.get("persistent_notification", {})) + + +async def test_a_dismissed_overflow_notice_is_not_raised_again_next_setup( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """`notices.py:21-26` states the rule this violated: dismissal is the acknowledgement. + + The cap is re-derived from the same wire on every setup, so re-raising made + the notice un-dismissable -- it came back on every restart and every reload + for as long as the publisher kept publishing, which is forever. + """ + entry = hass.config_entries.async_get_entry(registered_panel[0]) + assert entry is not None + await async_restore(hass, entry) + snapshot = _overflowing() + await _notice(hass, entry, snapshot) + + async_dismiss(hass, _notification_id(entry)) + await hass.async_block_till_done() + await _notice(hass, entry, snapshot) + + assert _notification_id(entry) not in dict(hass.data.get("persistent_notification", {})) + + +async def test_a_dismissed_overflow_notice_returns_when_more_is_declined( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """Remembering the announcement may not silence a *different* announcement. + + A second vendor device overflowing later is news the first notice never + carried, so the record is of what was said rather than of having said + something. + """ + entry = hass.config_entries.async_get_entry(registered_panel[0]) + assert entry is not None + await async_restore(hass, entry) + await _notice(hass, entry, _overflowing()) + async_dismiss(hass, _notification_id(entry)) + await hass.async_block_till_done() + + await _notice(hass, entry, _overflowing(MAX_PER_DEVICE + 9)) + + assert _notification_id(entry) in dict(hass.data.get("persistent_notification", {})) + + +async def test_the_overflow_record_survives_a_restart( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """The in-memory view dies with the process; the reason it must not come back does not.""" + entry = hass.config_entries.async_get_entry(registered_panel[0]) + assert entry is not None + await async_restore(hass, entry) + snapshot = _overflowing() + await _notice(hass, entry, snapshot) + async_dismiss(hass, _notification_id(entry)) + await hass.async_block_till_done() + + # Flushed through core's own final-write event rather than by advancing the + # clock. A dismissal and a raise each queue a delayed save, and a `Store` + # that has been re-armed will reschedule its timer rather than write when a + # test fires it early -- so the clock trick reports "nothing was recorded" + # for a record that was. This is the path Home Assistant takes at shutdown. + hass.bus.async_fire(EVENT_HOMEASSISTANT_FINAL_WRITE) + await hass.async_block_till_done() + hass.data.get(_DATA, {}).pop(entry.entry_id, None) + hass.data.get("persistent_notification", {}).clear() + await async_restore(hass, entry) + await _notice(hass, entry, snapshot) + + assert _notification_id(entry) not in dict(hass.data.get("persistent_notification", {})) diff --git a/tests/test_new_entity_announcement.py b/tests/test_new_entity_announcement.py index fe14b70d..7066f8fe 100644 --- a/tests/test_new_entity_announcement.py +++ b/tests/test_new_entity_announcement.py @@ -26,7 +26,9 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.span_panel.additions import ( + _ANNOUNCED, _SECTION, + _STORE_VERSION, COLLAPSE_ABOVE, async_announce_new_entities, async_forget_announcements, @@ -557,3 +559,67 @@ async def test_one_past_the_threshold_collapses( message = _text(_announcement(hass, entry)) assert f"Span Panel ({COLLAPSE_ABOVE + 1} entities)" in message assert "Acme Reading 0" not in message + + +# -- A store file that is not what we wrote ------------------------------------ +# +# `notices` narrows its own store for this reason and has three tests for it; +# this one had none. The consequence here is worse, because this coroutine is +# awaited from inside `async_setup_entry`: a wrong-shaped file raised out of it, +# the coordinator was shut down, and the entry failed with no retry -- so a panel +# stayed dead until somebody found and deleted a file about *notifications*. + + +def _seed(hass_storage: dict[str, Any], entry: MockConfigEntry, data: object) -> None: + key = f"{DOMAIN}.announced.{entry.entry_id}" + hass_storage[key] = {"version": _STORE_VERSION, "key": key, "data": data} + + +@pytest.mark.parametrize( + ("shape", "description"), + [ + ({_ANNOUNCED: 7}, "a number where the list should be"), + ({_ANNOUNCED: {"a": 1}}, "a mapping where the list should be"), + ({_ANNOUNCED: ["fine", 3]}, "a list holding something that is not an id"), + ({_ANNOUNCED: None}, "an explicit null"), + ({}, "an object with no announced key"), + (["announced_unique_ids"], "a list at the top level"), + ("nonsense", "a bare string"), + ], +) +async def test_a_malformed_store_does_not_take_the_integration_down( + hass: HomeAssistant, + hass_storage: dict[str, Any], + entry: MockConfigEntry, + shape: object, + description: str, +) -> None: + """Setup must survive every one of these; the record is recoverable, the entry is not.""" + _seed(hass_storage, entry, shape) + _register(hass, entry, _PART_NUMBER, name="Part Number") + + await async_announce_new_entities(hass, entry) + + assert _announcement(hass, entry) is None + + +async def test_a_malformed_store_is_re_seeded_rather_than_left_broken( + hass: HomeAssistant, hass_storage: dict[str, Any], entry: MockConfigEntry +) -> None: + """Self-healing is what makes re-seeding the right trade. + + The bad file is replaced by a valid one holding what is registered now, so + the *next* addition is announced normally and the user never has to find the + file. The cost is one pass of silence, which is the same trade a first + install already makes. + """ + _seed(hass_storage, entry, {_ANNOUNCED: 7}) + _register(hass, entry, _PART_NUMBER, name="Part Number") + await async_announce_new_entities(hass, entry) + + _register(hass, entry, "sp3-001_grid_state", name="Grid State", disabled=False) + await async_announce_new_entities(hass, entry) + + message = _text(_announcement(hass, entry)) + assert "Grid State" in message + assert "Part Number" not in message From 38d2e3c49f22d833c4e8bea67fe515402a700054 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:47:32 -0700 Subject: [PATCH 181/345] fix(adoption): deterministic collision winner, digested capability tokens The adopted unique_id collision guard picked its survivor by adapter emission order, which tracks the wire. A firmware update that declared the two colliding properties in the other order would then have changed what a standing entity reads -- silently, since the unique_id does not move and nothing here migrates it. `_create` sorts each device's properties by wire path, so the survivor is a function of the declarations alone. The test runs both declaration orders. Registry preference, which is how the extension cap resolves its equivalent problem, is not available here: both properties resolve to the same unique_id, so the registry row cannot say which of them put it there. The new capability tokens carried adopted device ids and extension instance keys verbatim, and `_check_capability_change` logs the capability set at INFO whenever it expands. `diagnostics.AdoptedDeviceRow` states the rule those tokens were breaking: a device id can embed a serial, because producers derive a DER's id preferring a serial over a default slug, which is why that block reports `proxied` rather than `parent`. Both identity segments are now a truncated sha256. The reload trigger only compares tokens for equality, so a stable digest is the identity as far as it is concerned; the subject kind and wire path stay in the clear because they are vendor vocabulary and name no install. `additions._load` reported "found NoneType" for a record with no `announced_unique_ids` key -- the type of what `get` returned rather than of anything on disk, which reads as a null somebody wrote and sends the reader to the wrong part of the file. `_describe` now says "missing" for an absent key, names the offending element type for a bad list, and keeps NoneType only where a null was written. Also clears the three ruff errors that predate this branch in tests/test_coordinator.py, which this task's diff touches: the import block it edited (I001) and two nested `with` blocks (SIM117). --- custom_components/span_panel/additions.py | 22 +++++- custom_components/span_panel/adoption.py | 42 ++++++----- custom_components/span_panel/helpers.py | 37 ++++++++-- tests/test_adoption.py | 38 +++++++--- tests/test_coordinator.py | 87 ++++++++++++++++++----- tests/test_new_entity_announcement.py | 33 +++++++++ 6 files changed, 210 insertions(+), 49 deletions(-) diff --git a/custom_components/span_panel/additions.py b/custom_components/span_panel/additions.py index 35e79f3d..d1349109 100644 --- a/custom_components/span_panel/additions.py +++ b/custom_components/span_panel/additions.py @@ -145,12 +145,32 @@ def _load(stored: object, entry: ConfigEntry) -> frozenset[str] | None: "as known rather than announced; the next addition is announced normally.", entry.entry_id, _ANNOUNCED, - type(announced if isinstance(stored, dict) else stored).__name__, + _describe(stored), ) return None return frozenset(announced) +def _describe(stored: object) -> str: + """Say what was on disk where the announced list should have been. + + A separate function because the honest answer has three cases and only one of + them is a type name. Reporting `type(stored.get(...))` for all three says + "found NoneType" for a record that simply has no such key, which reads as a + null somebody wrote and sends whoever is looking at the wrong part of the + file. + """ + if not isinstance(stored, dict): + return type(stored).__name__ + if _ANNOUNCED not in stored: + return "missing" + announced = stored[_ANNOUNCED] + if isinstance(announced, list): + offending = next((item for item in announced if not isinstance(item, str)), None) + return f"a list holding {type(offending).__name__}" + return type(announced).__name__ + + async def async_announce_new_entities(hass: HomeAssistant, entry: ConfigEntry) -> None: """Tell the user which entities this setup added, and which need enabling. diff --git a/custom_components/span_panel/adoption.py b/custom_components/span_panel/adoption.py index 40e085bf..6247484c 100644 --- a/custom_components/span_panel/adoption.py +++ b/custom_components/span_panel/adoption.py @@ -286,8 +286,8 @@ def adopted_unique_id(identifier: str, declaration: AdoptedProperty) -> str: identity namespace should use -- but adopting it here would move every adopted id an install already holds, which strands the entities keyed on them with nothing to migrate them back. So the collision is handled where the - entities are built (`_create`) rather than removed here: the first property - to claim an id keeps it and the second is skipped with both wire paths named. + entities are built (`_create`) rather than removed here: the lexically first + wire path keeps the id and the other is skipped with both paths named. Changing the encoding is a follow-on for whatever namespace comes next, and it needs a maintainer's ruling rather than a fix. """ @@ -847,36 +847,44 @@ def _create[AdoptedT: AdoptedEntity]( place a property's platform is decided. Five bodies would each restate the predicate, and a property could then reach two platforms or none. - **The first property to claim an id keeps it.** `adopted_unique_id` is not - injective -- see its docstring for why that cannot be fixed by changing the - encoding -- so two wire addresses can arrive at one id. Handing both to Home - Assistant registers the first and drops the second *permanently*, with one - core log line naming a `unique_id` the user cannot map back to anything on - the wire. Skipping it here costs the same entity and names both addresses, so - the case is reportable by whoever meets it. + **One id is claimed by one property, and which one does not depend on the + wire.** `adopted_unique_id` is not injective -- see its docstring for why that + cannot be fixed by changing the encoding -- so two wire addresses can arrive + at one id. Handing both to Home Assistant registers the first and drops the + second *permanently*, with one core log line naming a `unique_id` the user + cannot map back to anything on the wire. Skipping it here costs the same + entity and names both addresses, so the case is reportable by whoever meets + it. Claimed per platform, which is the scope the registry keys on: an entity is unique by (domain, integration, unique_id), so the same id under `sensor` and under `binary_sensor` is not a collision and must not be treated as one. - Arrival order decides the winner, and the order tracks the wire. That is a - real weakness -- a firmware update reordering a description could hand the id - to the other property -- and it is bounded by how unlikely the collision is - in the first place: it needs one device publishing two addresses that differ - only in which side of a hyphen a segment falls. The injective namespace is - the real answer; this is the guard until there is one. + **The winner is the lexically first wire path, not the first one published.** + Adapter emission order tracks the wire, so a firmware update that declared + the properties in the other order would have moved the id from one property + to the other -- which is a *standing entity changing what it reads* with no + id change to make it visible, and nothing here to migrate it back. Sorting on + `path` makes the survivor a function of the declarations alone. Registry + preference, which is how the extension cap resolves its equivalent problem, + is not available: both properties resolve to the same `unique_id`, so the + registry row cannot say which of them put it there. + + The sort covers a whole device rather than only the colliding pair, because a + rule that only applied on collision would still depend on arrival order to + decide which pair collided first. """ built: list[AdoptedT] = [] claimed: dict[str, str] = {} for device, identifier in _adopted(snapshot, registry): - for declaration in device.properties: + for declaration in sorted(device.properties, key=lambda row: row.path): if classify(declaration) is not platform: continue unique_id = adopted_unique_id(identifier, declaration) first_claim = claimed.get(unique_id) if first_claim is not None: _LOGGER.warning( - "Adopted %s on %s resolves to the same entity id as %s, which was seen " + "Adopted %s on %s resolves to the same entity id as %s, which sorts " "first; %s is not surfaced. Both wire addresses flatten to %s. Attach " "this integration's diagnostics to an issue if you need the second one", declaration.path, diff --git a/custom_components/span_panel/helpers.py b/custom_components/span_panel/helpers.py index 10ef4921..e057b474 100644 --- a/custom_components/span_panel/helpers.py +++ b/custom_components/span_panel/helpers.py @@ -2,6 +2,7 @@ from __future__ import annotations +from hashlib import sha256 import logging from homeassistant.components.persistent_notification import async_create @@ -416,13 +417,34 @@ def has_der_link_health(snapshot: SpanPanelSnapshot) -> bool: ) +def _digest(identity: str) -> str: + """Return a short, stable digest of one wire identity. + + Not a security boundary and not trying to be one — this is the same rule + `diagnostics.AdoptedDeviceRow` states and applies to `parent`: a device id can + embed a serial, because producers derive a DER's id preferring a serial over a + default slug, which is why this repository holds PV's `info/serial-number` + unvalued. The tokens below are the only capability names that carry wire + identity, and the capability set is *logged at INFO* by + `_check_capability_change` whenever it expands. A serial does not belong in + a line that ends up pasted into an issue. + + Everything the reload trigger needs survives the digest: it compares tokens for + equality and never reads one, so a stable digest is the identity as far as + that comparison is concerned. What is lost is only a maintainer's ability to + read *which* device appeared out of the log line, and the diagnostics download + already answers that with the device type and counts. + """ + return sha256(identity.encode("utf-8")).hexdigest()[:12] + + def adopted_capability_tokens(snapshot: SpanPanelSnapshot) -> frozenset[str]: """One token per adopted device and per vendor extension property. Vendor extensibility is the one part of the snapshot whose vocabulary this integration cannot enumerate in advance, so it cannot be reduced to a named - flag the way `bess` or `pcs` are. It reaches the reload trigger as the - identities themselves. + flag the way `bess` or `pcs` are. It reaches the reload trigger as its + identities, digested — see `_digest` for why the raw ids must not appear here. **One token each rather than one hash of the set.** The trigger fires on set *expansion* (`current - known`), and a hash of a shrinking set is as "new" as @@ -432,14 +454,21 @@ def adopted_capability_tokens(snapshot: SpanPanelSnapshot) -> frozenset[str]: arrives adds a token, a device that leaves removes one, and only the first is a reload. + The extension token digests its **instance key** and keeps its subject kind + and wire path in the clear. The key is an identity — an EVSE's node id, a + circuit's uuid — and can carry the same serial a device id can; `battery` and + `meter/cell-temperature` are vendor vocabulary that names no install. + Keyed on the wire identity rather than on the value: a reading changing is every refresh, and the question here is only whether an entity that could not be created at setup now can. """ return frozenset( - [f"adopted:{device.device_id}" for device in snapshot.adopted_devices] + [f"adopted:{_digest(device.device_id)}" for device in snapshot.adopted_devices] + [ - f"extension:{row.subject.kind}:{row.subject.instance_key or ''}:{row.path}" + f"extension:{row.subject.kind}:" + f"{'' if row.subject.instance_key is None else _digest(row.subject.instance_key)}:" + f"{row.path}" for row in snapshot.extension_properties ] ) diff --git a/tests/test_adoption.py b/tests/test_adoption.py index 9244cb6c..043ba004 100644 --- a/tests/test_adoption.py +++ b/tests/test_adoption.py @@ -694,9 +694,17 @@ def _colliding_pair() -> tuple[AdoptedProperty, AdoptedProperty]: vendor that numbers the property rather than the node publishes `battery/2-cell-temperature`. """ - first = _property(node_id="battery-2", property_id="cell-temperature", unit="°C", value="31.4") - second = _property(node_id="battery", property_id="2-cell-temperature", unit="°C", value="99.9") - return first, second + sorts_first = _property( + node_id="battery-2", property_id="cell-temperature", unit="°C", value="31.4" + ) + sorts_second = _property( + node_id="battery", property_id="2-cell-temperature", unit="°C", value="99.9" + ) + # `-` (0x2D) sorts before `/` (0x2F), so `battery-2/cell-temperature` is the + # lexically first of the two paths. The values differ so the survivor is + # identifiable by what it reads. + assert sorts_first.path < sorts_second.path + return sorts_first, sorts_second def test_the_adopted_grammar_is_not_injective() -> None: @@ -717,18 +725,29 @@ def test_the_adopted_grammar_is_not_injective() -> None: assert adopted_unique_id(identifier, first) == adopted_unique_id(identifier, second) -def test_the_second_of_two_colliding_properties_is_skipped_rather_than_built( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture +@pytest.mark.parametrize("reversed_order", [False, True], ids=["as-declared", "reversed"]) +def test_the_lexically_first_of_two_colliding_properties_wins_either_way( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, reversed_order: bool ) -> None: """Home Assistant drops the loser of a unique_id collision permanently. Two entities added under one id leaves the second unregistered with only a core log line naming a `unique_id` the user cannot map back to a wire - address. Keeping the first and saying so names both addresses, which is what - makes the case reportable. + address. Keeping one and saying so names both addresses, which is what makes + the case reportable. + + **Run in both declaration orders, and that is the point of the test.** + Adapter emission order tracks the wire, so a winner picked by arrival order + would let a firmware update that reordered a description change what a + standing entity reads -- silently, since the `unique_id` does not move and + nothing here would migrate it. The survivor is the lexically first wire path + in both runs. Registry preference cannot resolve this the way the extension + cap's does: both properties resolve to the same id, so the registry row + cannot say which of them put it there. """ - first, second = _colliding_pair() - snapshot = _snapshot(_device(properties=(first, second))) + sorts_first, sorts_second = _colliding_pair() + declared = (sorts_second, sorts_first) if reversed_order else (sorts_first, sorts_second) + snapshot = _snapshot(_device(properties=declared)) sensors = create_adopted_sensors( MagicMock(data=snapshot), snapshot, dr.async_get(hass), panel_device_id="panel-device-id" @@ -736,6 +755,7 @@ def test_the_second_of_two_colliding_properties_is_skipped_rather_than_built( assert len(sensors) == 1 assert sensors[0].native_value == 31.4 + assert str(sensors[0].name) == "Cell Temperature" assert "battery-2/cell-temperature" in caplog.text assert "battery/2-cell-temperature" in caplog.text diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 94c36083..87f3563a 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -7,12 +7,20 @@ from typing import cast from unittest.mock import AsyncMock, MagicMock, patch +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, +) import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry from span_panel_api import ( AdoptedDevice, AdoptedProperty, ExtensionProperty, ExtensionSubject, + SpanMqttClient, ) from span_panel_api.exceptions import ( SpanPanelAPIError, @@ -21,18 +29,11 @@ SpanPanelServerError, SpanPanelTimeoutError, ) - -from custom_components.span_panel.coordinator import SpanPanelCoordinator -from custom_components.span_panel.helpers import detect_capabilities -from homeassistant.core import HomeAssistant -from span_panel_api import SpanMqttClient from span_panel_api.models import FieldMetadata from span_panel_api.protocol import SpanPanelClientProtocol -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - HomeAssistantError, -) + +from custom_components.span_panel.coordinator import SpanPanelCoordinator +from custom_components.span_panel.helpers import adopted_capability_tokens, detect_capabilities from .adapter_fixtures import schema_one_snapshot from .factories import ( @@ -41,8 +42,6 @@ SpanPanelSnapshotFactory, ) -from pytest_homeassistant_custom_component.common import MockConfigEntry - def _create_coordinator( hass: HomeAssistant, @@ -453,9 +452,11 @@ async def test_on_connection_change_false_flips_offline_and_notifies_listeners( coordinator = _create_coordinator(hass) assert coordinator.panel_offline is False - with patch.object(coordinator, "async_update_listeners") as notify: - with caplog.at_level(logging.INFO): - coordinator._on_connection_change(False) + with ( + patch.object(coordinator, "async_update_listeners") as notify, + caplog.at_level(logging.INFO), + ): + coordinator._on_connection_change(False) assert coordinator.panel_offline is True notify.assert_called_once_with() @@ -472,9 +473,11 @@ async def test_on_connection_change_true_clears_offline_and_notifies_listeners( coordinator = _create_coordinator(hass) coordinator._panel_offline = True - with patch.object(coordinator, "async_update_listeners") as notify: - with caplog.at_level(logging.INFO): - coordinator._on_connection_change(True) + with ( + patch.object(coordinator, "async_update_listeners") as notify, + caplog.at_level(logging.INFO), + ): + coordinator._on_connection_change(True) assert coordinator.panel_offline is False notify.assert_called_once_with() @@ -836,3 +839,51 @@ async def test_the_same_devices_and_properties_ask_for_nothing(hass: HomeAssista ) assert coordinator._reload_requested is False + + +async def test_no_wire_identity_reaches_the_capability_set_in_the_clear() -> None: + """The capability set is logged at INFO whenever it expands, so it may not carry a serial. + + `diagnostics.AdoptedDeviceRow` states the repo rule: a device id can embed a + serial, because producers derive a DER's id preferring a serial over a + default slug -- which is why this repository holds PV's `info/serial-number` + unvalued and why that block reports `proxied` rather than `parent`. A + capability token naming the id verbatim would put the same value in a log + line users paste into issues. + + The subject kind and the wire path stay in the clear on purpose: they are + vendor vocabulary and name no install. + """ + snapshot = replace( + SpanPanelSnapshotFactory.create(), + adopted_devices=(_adopted_device("panel-EX-0000-0001"),), + extension_properties=( + replace( + _extension_row(), + subject=ExtensionSubject(kind="evse", instance_key="evse-EX-0000-0002"), + ), + ), + ) + + tokens = adopted_capability_tokens(snapshot) + + assert len(tokens) == 2 + rendered = " ".join(sorted(tokens)) + assert "EX-0000-0001" not in rendered + assert "EX-0000-0002" not in rendered + assert "panel-" not in rendered + # What is kept is what a maintainer can act on without an identity. + assert "extension:evse:" in rendered + assert "battery-2/cell-temperature" in rendered + + +async def test_the_digest_is_stable_across_calls_and_distinguishes_devices() -> None: + """A digest that moved between snapshots would reload the integration on every refresh.""" + first = replace(SpanPanelSnapshotFactory.create(), adopted_devices=(_adopted_device(),)) + again = replace(SpanPanelSnapshotFactory.create(), adopted_devices=(_adopted_device(),)) + other = replace( + SpanPanelSnapshotFactory.create(), adopted_devices=(_adopted_device("generator-2"),) + ) + + assert adopted_capability_tokens(first) == adopted_capability_tokens(again) + assert adopted_capability_tokens(first) != adopted_capability_tokens(other) diff --git a/tests/test_new_entity_announcement.py b/tests/test_new_entity_announcement.py index 7066f8fe..8b4ff0bb 100644 --- a/tests/test_new_entity_announcement.py +++ b/tests/test_new_entity_announcement.py @@ -623,3 +623,36 @@ async def test_a_malformed_store_is_re_seeded_rather_than_left_broken( message = _text(_announcement(hass, entry)) assert "Grid State" in message assert "Part Number" not in message + + +@pytest.mark.parametrize( + ("shape", "expected"), + [ + ({}, "found missing"), + ({_ANNOUNCED: 7}, "found int"), + ({_ANNOUNCED: None}, "found NoneType"), + ({_ANNOUNCED: ["fine", 3]}, "found a list holding int"), + ("nonsense", "found str"), + ], +) +async def test_the_warning_says_what_was_actually_on_disk( + hass: HomeAssistant, + hass_storage: dict[str, Any], + entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, + shape: object, + expected: str, +) -> None: + """The line is the only thing anybody has to go on, so it may not mislead. + + A record with no `announced_unique_ids` key at all reported "found NoneType" + -- the type of what `get` returned rather than of anything on disk -- which + reads as a null somebody wrote and sends the reader to the wrong part of the + file. `None` written *explicitly* still reports NoneType, because there it is + the truth. + """ + _seed(hass_storage, entry, shape) + + await async_announce_new_entities(hass, entry) + + assert expected in caplog.text From 6c3c686f6f29147bf5058144d36524ca52bc68ec Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:53:39 -0700 Subject: [PATCH 182/345] refactor: runtime-data types in a leaf module, one guard, no casts `SpanPanelRuntimeData` and `SpanPanelConfigEntry` lived in `__init__.py`, which imports every platform. Naming the entry type therefore meant importing the whole integration, so seven modules imported it from the package root and five more reached for it from inside functions -- `services.py` had five copies of the same runtime-data guard, each behind its own deferred import. pylint counted 21 cyclic-import chains, 10 of them routed through the package root. The two types now live in `runtime.py`, whose only runtime dependency inside this package is `control_gate` (itself a leaf); the coordinator is needed there as an annotation only. The package root re-exports both names, so `from custom_components.span_panel import SpanPanelRuntimeData` keeps working for the 22 test modules that use it. Every deferred import of them is now a top-level one, and the five guards are one `_loaded_runtime_data()` that still answers with `isinstance` (AGENTS.md point 6) -- core deletes `runtime_data` on unload, so both halves of the check are real. 21 cycles -> 16, root-routed 10 -> 3. The nine `cast(ServiceResponse, ...)` in `services.py` go with them. `ServiceResponse` is `dict[str, JsonValueType] | None`, and `dict` is invariant, so the responses are built at that type instead of asserted into it; the two that were only ever redundant are simply returned. The tenth cast was `sensor_base.extra_restore_state_data` asserting a `SpanEnergyExtraStoredData` into the `SensorExtraStoredData` that `RestoreSensor` declares. It restated that class's two fields rather than inheriting them; it now subclasses it and the assertion is unnecessary. The stored keys are unchanged, so existing records restore identically. `SpanPanelEntity.coordinator` is annotated explicitly. The generic base binds correctly, but the attribute is only created by `BaseCoordinatorEntity.__init__`'s unannotated `self.coordinator = coordinator`; mypy does not run inference over installed packages, so it read as `Any` and every `self.coordinator.X` in this integration was unchecked. It resolves to `SpanPanelCoordinator` now, with no new casts needed anywhere. --- custom_components/span_panel/__init__.py | 48 ++----- custom_components/span_panel/binary_sensor.py | 2 +- custom_components/span_panel/button.py | 2 +- custom_components/span_panel/config_flow.py | 2 +- custom_components/span_panel/coordinator.py | 2 +- custom_components/span_panel/diagnostics.py | 2 +- custom_components/span_panel/entity.py | 15 +++ custom_components/span_panel/field_paths.py | 5 +- custom_components/span_panel/grace_period.py | 30 ++++- custom_components/span_panel/migrations.py | 2 +- custom_components/span_panel/number.py | 2 +- custom_components/span_panel/runtime.py | 65 +++++++++ custom_components/span_panel/select.py | 2 +- custom_components/span_panel/sensor.py | 2 +- custom_components/span_panel/sensor_base.py | 57 ++++---- custom_components/span_panel/services.py | 127 +++++++++++------- custom_components/span_panel/switch.py | 2 +- custom_components/span_panel/websocket.py | 2 +- 18 files changed, 231 insertions(+), 138 deletions(-) create mode 100644 custom_components/span_panel/runtime.py diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py index 9a3bf8ff..9fa83b0f 100644 --- a/custom_components/span_panel/__init__.py +++ b/custom_components/span_panel/__init__.py @@ -3,12 +3,10 @@ from __future__ import annotations import asyncio -from dataclasses import dataclass, field import logging from homeassistant.components.frontend import async_remove_panel as async_remove_panel from homeassistant.components.panel_custom import async_register_panel as async_register_panel -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import CoreState, HomeAssistant, callback from homeassistant.exceptions import ( @@ -72,6 +70,15 @@ from .migrations import CURRENT_CONFIG_VERSION, async_migrate_entry # noqa: F401 from .notices import async_forget, async_restore from .options import SNAPSHOT_UPDATE_INTERVAL + +# Re-exported: the types themselves live in a leaf module (see `runtime.py`), but +# `custom_components.span_panel.SpanPanelRuntimeData` is the name the tests and +# the rest of the integration have always used, so the package root keeps +# answering to it. +from .runtime import ( + SpanPanelConfigEntry as SpanPanelConfigEntry, + SpanPanelRuntimeData as SpanPanelRuntimeData, +) from .schema_repairs import ( async_clear_retired_new_entity_notices, async_clear_retired_upgrade_notice, @@ -95,43 +102,6 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -def _default_control_lock() -> ControlLock: - """Return the lock that belongs to the default control policy. - - Derived from the policy rather than written down again, so the two cannot - say different things about whether the feature is on. `async_setup_entry` - builds the pair the same way (`ControlLock(armed=policy.lock_enabled)`): a - lock that exists as a user-facing thing starts armed, and one the entry has - not enabled starts disarmed because there is no switch to open it with. - """ - return ControlLock(armed=ControlPolicy.default().lock_enabled) - - -@dataclass -class SpanPanelRuntimeData: - """Runtime data for a Span Panel config entry.""" - - coordinator: SpanPanelCoordinator - # Registry id of the panel device, which every sub-device (BESS, MID, EVSE) - # hangs off with `via_device_id`. Resolved once during setup rather than - # per platform: sub-devices are only ever built after the panel device - # exists, so an id looked up here is one no caller has to handle the absence - # of. See `ensure_device_registered`. - panel_device_id: str - # Resolved once at setup and read by every control platform, so a single - # answer decides which entities exist and which callers may operate them. - # Defaulted rather than required because the default *is* the policy an entry - # with no control options has, and that is most of them. - control_policy: ControlPolicy = field(default_factory=ControlPolicy.default) - # Shared with the gate, and mutated by the lock entity. One object per entry: - # the gate reads it on every publish and the entity writes it. The default - # is the default policy's lock rather than a bare `ControlLock()`, whose - # disarmed state would be a promise this class is in no position to make. - control_lock: ControlLock = field(default_factory=_default_control_lock) - - -type SpanPanelConfigEntry = ConfigEntry[SpanPanelRuntimeData] - PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.BUTTON, diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py index b20e1f0c..cfdf84ca 100644 --- a/custom_components/span_panel/binary_sensor.py +++ b/custom_components/span_panel/binary_sensor.py @@ -20,7 +20,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from span_panel_api import SpanEvseSnapshot, SpanPanelSnapshot -from . import SpanPanelConfigEntry from .adoption import create_adopted_binary_sensors from .const import ( CONF_DEVICE_NAME, @@ -44,6 +43,7 @@ has_pcs, resolve_evse_display_suffix, ) +from .runtime import SpanPanelConfigEntry from .util import bess_device_info, evse_device_info, pv_device_info # pylint: disable=invalid-overridden-method diff --git a/custom_components/span_panel/button.py b/custom_components/span_panel/button.py index 36fc3e0c..8b21e09e 100644 --- a/custom_components/span_panel/button.py +++ b/custom_components/span_panel/button.py @@ -8,12 +8,12 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from span_panel_api import SpanMqttClient, SpanPanelSnapshot -from . import SpanPanelConfigEntry from .const import CONF_DEVICE_NAME from .control_gate import ControlMode from .coordinator import SpanPanelCoordinator from .entity import SpanPanelEntity from .helpers import construct_panel_unique_id_for_entry, has_bess +from .runtime import SpanPanelConfigEntry _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/span_panel/config_flow.py b/custom_components/span_panel/config_flow.py index 01adeba8..ab4f6a99 100644 --- a/custom_components/span_panel/config_flow.py +++ b/custom_components/span_panel/config_flow.py @@ -83,7 +83,7 @@ ) if TYPE_CHECKING: - from . import SpanPanelConfigEntry + from .runtime import SpanPanelConfigEntry _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/span_panel/coordinator.py b/custom_components/span_panel/coordinator.py index 5585bb5e..bc6c179d 100644 --- a/custom_components/span_panel/coordinator.py +++ b/custom_components/span_panel/coordinator.py @@ -9,9 +9,9 @@ from typing import TYPE_CHECKING, Final, Protocol if TYPE_CHECKING: - from . import SpanPanelConfigEntry from .current_monitor import CurrentMonitor from .graph_horizon import GraphHorizonManager + from .runtime import SpanPanelConfigEntry from homeassistant.components.persistent_notification import async_create from homeassistant.core import HomeAssistant, callback diff --git a/custom_components/span_panel/diagnostics.py b/custom_components/span_panel/diagnostics.py index 06302877..8af0a0ec 100644 --- a/custom_components/span_panel/diagnostics.py +++ b/custom_components/span_panel/diagnostics.py @@ -11,7 +11,6 @@ from span_panel_api import SpanPanelSnapshot, ca_fingerprint from span_panel_api.exceptions import SpanPanelValidationError -from . import SpanPanelConfigEntry from .adoption import adopted_control_count, classify from .const import ( CONF_EBUS_BROKER_PASSWORD, @@ -20,6 +19,7 @@ CONF_PANEL_CA_PEM, PANEL_CA_PENDING, ) +from .runtime import SpanPanelConfigEntry from .schema_validation import SchemaFindings TO_REDACT = { diff --git a/custom_components/span_panel/entity.py b/custom_components/span_panel/entity.py index 4df21efe..22ffd5d4 100644 --- a/custom_components/span_panel/entity.py +++ b/custom_components/span_panel/entity.py @@ -30,6 +30,21 @@ class SpanPanelEntity(CoordinatorEntity[SpanPanelCoordinator]): """Base entity for all Span Panel platforms.""" + coordinator: SpanPanelCoordinator + """Restated so that reading it is typed, which the generic base does not give us. + + `CoordinatorEntity[SpanPanelCoordinator]` binds the type parameter correctly + -- `__init__` is checked against `SpanPanelCoordinator` either way -- but the + attribute itself is only ever created by `BaseCoordinatorEntity.__init__`'s + unannotated `self.coordinator = coordinator`. mypy does not run its inference + pass over installed packages, so an attribute whose type is inferred from a + parameter there comes back as `Any` no matter what the type argument says, + and every `self.coordinator.…` read in this integration was unchecked. (An + attribute the same `__init__` assigns a literal to, like + `last_update_success`, is unaffected; the difference is inference, not the + generic.) One line here types every subclass's reads. + """ + _attr_has_entity_name = True _span_object_id_base: str | None = None diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py index 0c8135d5..ef01bccc 100644 --- a/custom_components/span_panel/field_paths.py +++ b/custom_components/span_panel/field_paths.py @@ -508,8 +508,9 @@ def platform_descriptions() -> tuple[EntityDescription, ...]: not the other. """ # Deferred: the platform modules import `FieldPathDeclarationMixin` from - # here, and `binary_sensor` reaches the package root for its config-entry - # type. Importing them at module scope would close both loops. + # here, so importing them at module scope would close that loop. (They no + # longer reach the package root for the config-entry type -- that lives in + # the leaf `runtime` module -- but this loop is the module's own and stays.) from .binary_sensor import ( # pylint: disable=import-outside-toplevel BESS_CONNECTED_SENSOR, BINARY_SENSORS, diff --git a/custom_components/span_panel/grace_period.py b/custom_components/span_panel/grace_period.py index 61e74bf6..836de298 100644 --- a/custom_components/span_panel/grace_period.py +++ b/custom_components/span_panel/grace_period.py @@ -8,9 +8,9 @@ import logging from typing import Any, Self +from homeassistant.components.sensor import SensorExtraStoredData from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import State -from homeassistant.helpers.restore_state import ExtraStoredData # Matches SensorEntity.native_value: the broadest concrete union that the # HA sensor platform can hand us. We accept it verbatim so sensor_base can @@ -49,16 +49,26 @@ def _parse_numeric_state(state: State | None) -> tuple[float | None, datetime | @dataclass -class SpanEnergyExtraStoredData(ExtraStoredData): +class SpanEnergyExtraStoredData(SensorExtraStoredData): """Extra stored data for Span energy sensors with grace period tracking. This data is persisted across Home Assistant restarts to maintain grace period state for energy sensors, preventing statistics spikes when the panel is offline at startup. + + It extends the sensor platform's own stored data rather than + `ExtraStoredData` directly, because that is what it is: `native_value` and + `native_unit_of_measurement` were restated copies of + `SensorExtraStoredData`'s two fields, which is what made + `RestoreSensor.extra_restore_state_data` -- declared to return a + `SensorExtraStoredData` -- need a `cast` to return one of these. Inheriting + them says the relationship instead of asserting it. + + The stored keys are unchanged by that: the same twelve names are written + and read as before, so records written by earlier versions restore + identically. See `as_dict` for why it does not delegate to the base. """ - native_value: float | None - native_unit_of_measurement: str | None last_valid_state: float | None last_valid_changed: str | None # ISO format datetime string energy_offset: float | None = None @@ -81,7 +91,17 @@ class SpanEnergyExtraStoredData(ExtraStoredData): confirmed_dip_ticks_left: int | None = None def as_dict(self) -> dict[str, Any]: - """Return a dict representation of the extra data.""" + """Return a dict representation of the extra data. + + Every key is written here, the two inherited ones included, rather than + starting from `super().as_dict()`. The base encodes a `date`, `datetime` + or `Decimal` native value as a tagged dict that its own `from_dict` + decodes again, and `from_dict` below does not decode -- it reads each + key back as it was written. Since `extra_restore_state_data` coerces + this sensor's value to `float | None` before it ever gets here, writing + the value plainly keeps the pair symmetric instead of adding a decoder + for a shape these sensors cannot produce. + """ return { "native_value": self.native_value, "native_unit_of_measurement": self.native_unit_of_measurement, diff --git a/custom_components/span_panel/migrations.py b/custom_components/span_panel/migrations.py index e2007b8e..1bf7271c 100644 --- a/custom_components/span_panel/migrations.py +++ b/custom_components/span_panel/migrations.py @@ -11,7 +11,7 @@ from .const import CONF_API_VERSION, CONF_HOP_PASSPHRASE, PANEL_CA_PENDING if TYPE_CHECKING: - from . import SpanPanelConfigEntry + from .runtime import SpanPanelConfigEntry _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/span_panel/number.py b/custom_components/span_panel/number.py index 7f497f27..5e103540 100644 --- a/custom_components/span_panel/number.py +++ b/custom_components/span_panel/number.py @@ -51,7 +51,6 @@ SpanPanelSnapshot, ) -from . import SpanPanelConfigEntry from .adoption import create_adopted_numbers from .const import CONF_DEVICE_NAME, DOMAIN, USE_CIRCUIT_NUMBERS from .control_gate import ControlMode @@ -59,6 +58,7 @@ from .entity import SpanPanelEntity from .field_paths import DerivedReason, FieldPathDeclarationMixin from .helpers import build_evse_unique_id_for_entry, resolve_evse_display_suffix +from .runtime import SpanPanelConfigEntry from .util import evse_device_info _LOGGER: logging.Logger = logging.getLogger(__name__) diff --git a/custom_components/span_panel/runtime.py b/custom_components/span_panel/runtime.py new file mode 100644 index 00000000..cf703d37 --- /dev/null +++ b/custom_components/span_panel/runtime.py @@ -0,0 +1,65 @@ +"""The types that describe a Span Panel config entry's runtime data. + +A leaf module on purpose. `SpanPanelConfigEntry` is the annotation every +platform, the coordinator, the migrations and the services need, and it used to +live in the package root -- which imports every platform. Naming the entry type +therefore meant importing the whole integration, so the modules that needed only +the annotation reached for it from inside functions instead, and `services.py` +grew five copies of the same runtime-data guard around five deferred imports. + +Nothing here imports anything from this package at runtime except +`control_gate`, which is itself a leaf. `SpanPanelCoordinator` is needed only as +an annotation, so it is imported under `TYPE_CHECKING`: a module that wants to +say "this is a SPAN entry" should not have to pull in the coordinator and, +through it, the schema and sensor machinery. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from homeassistant.config_entries import ConfigEntry + +from .control_gate import ControlLock, ControlPolicy + +if TYPE_CHECKING: + from .coordinator import SpanPanelCoordinator + + +def _default_control_lock() -> ControlLock: + """Return the lock that belongs to the default control policy. + + Derived from the policy rather than written down again, so the two cannot + say different things about whether the feature is on. `async_setup_entry` + builds the pair the same way (`ControlLock(armed=policy.lock_enabled)`): a + lock that exists as a user-facing thing starts armed, and one the entry has + not enabled starts disarmed because there is no switch to open it with. + """ + return ControlLock(armed=ControlPolicy.default().lock_enabled) + + +@dataclass +class SpanPanelRuntimeData: + """Runtime data for a Span Panel config entry.""" + + coordinator: SpanPanelCoordinator + # Registry id of the panel device, which every sub-device (BESS, MID, EVSE) + # hangs off with `via_device_id`. Resolved once during setup rather than + # per platform: sub-devices are only ever built after the panel device + # exists, so an id looked up here is one no caller has to handle the absence + # of. See `ensure_device_registered`. + panel_device_id: str + # Resolved once at setup and read by every control platform, so a single + # answer decides which entities exist and which callers may operate them. + # Defaulted rather than required because the default *is* the policy an entry + # with no control options has, and that is most of them. + control_policy: ControlPolicy = field(default_factory=ControlPolicy.default) + # Shared with the gate, and mutated by the lock entity. One object per entry: + # the gate reads it on every publish and the entity writes it. The default + # is the default policy's lock rather than a bare `ControlLock()`, whose + # disarmed state would be a promise this class is in no position to make. + control_lock: ControlLock = field(default_factory=_default_control_lock) + + +type SpanPanelConfigEntry = ConfigEntry[SpanPanelRuntimeData] diff --git a/custom_components/span_panel/select.py b/custom_components/span_panel/select.py index b756e188..2b0e62b6 100644 --- a/custom_components/span_panel/select.py +++ b/custom_components/span_panel/select.py @@ -11,7 +11,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from span_panel_api import SpanCircuitSnapshot, SpanPanelSnapshot -from . import SpanPanelConfigEntry from .adoption import AdoptedSelect, create_adopted_selects from .const import DOMAIN, USE_CIRCUIT_NUMBERS, CircuitPriority from .control_gate import ControlMode @@ -28,6 +27,7 @@ circuit_object_id_base, release_registry_name_written_by_older_release, ) +from .runtime import SpanPanelConfigEntry # Device types that use "Solar" as the fallback identifier when unnamed. _SOLAR_DEVICE_TYPES: frozenset[str] = frozenset({"pv"}) diff --git a/custom_components/span_panel/sensor.py b/custom_components/span_panel/sensor.py index fefbcb36..98c65120 100644 --- a/custom_components/span_panel/sensor.py +++ b/custom_components/span_panel/sensor.py @@ -11,7 +11,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from span_panel_api import SpanPanelSnapshot -from . import SpanPanelConfigEntry from .adoption import create_adopted_sensors from .const import ( CONF_DEVICE_NAME, @@ -33,6 +32,7 @@ has_shed_forecast, resolve_evse_display_suffix, ) +from .runtime import SpanPanelConfigEntry from .sensor_base import SpanEnergySensorBase, SpanSensorBase from .sensor_circuit import ( SpanCircuitEnergySensor, diff --git a/custom_components/span_panel/sensor_base.py b/custom_components/span_panel/sensor_base.py index 4cf0faf5..4734eed2 100644 --- a/custom_components/span_panel/sensor_base.py +++ b/custom_components/span_panel/sensor_base.py @@ -7,7 +7,7 @@ from datetime import UTC, date, datetime, timedelta from decimal import Decimal import logging -from typing import Any, cast +from typing import Any from homeassistant.components.sensor import ( RestoreSensor, @@ -755,35 +755,32 @@ def extra_restore_state_data(self) -> SensorExtraStoredData: integration is unloaded or HA shuts down, and restored when the entity is added back to HA. """ - return cast( - SensorExtraStoredData, - SpanEnergyExtraStoredData( - native_value=( - float(self._attr_native_value) - if isinstance(self._attr_native_value, int | float) - else None - ), - native_unit_of_measurement=self.native_unit_of_measurement, - last_valid_state=self._last_valid_state, - last_valid_changed=( - self._last_valid_changed.isoformat() if self._last_valid_changed else None - ), - energy_offset=self._energy_offset or None, - last_panel_reading=self._last_panel_reading, - last_dip_delta=self._last_dip_delta, - pending_dip_baseline=self._pending_dip.baseline if self._pending_dip else None, - pending_dip_delta=self._pending_dip.delta if self._pending_dip else None, - confirmed_dip_baseline=( - self._recently_confirmed_dip.baseline if self._recently_confirmed_dip else None - ), - confirmed_dip_delta=( - self._recently_confirmed_dip.delta if self._recently_confirmed_dip else None - ), - confirmed_dip_ticks_left=( - self._recently_confirmed_dip.confirmed_ticks_left - if self._recently_confirmed_dip - else None - ), + return SpanEnergyExtraStoredData( + native_value=( + float(self._attr_native_value) + if isinstance(self._attr_native_value, int | float) + else None + ), + native_unit_of_measurement=self.native_unit_of_measurement, + last_valid_state=self._last_valid_state, + last_valid_changed=( + self._last_valid_changed.isoformat() if self._last_valid_changed else None + ), + energy_offset=self._energy_offset or None, + last_panel_reading=self._last_panel_reading, + last_dip_delta=self._last_dip_delta, + pending_dip_baseline=self._pending_dip.baseline if self._pending_dip else None, + pending_dip_delta=self._pending_dip.delta if self._pending_dip else None, + confirmed_dip_baseline=( + self._recently_confirmed_dip.baseline if self._recently_confirmed_dip else None + ), + confirmed_dip_delta=( + self._recently_confirmed_dip.delta if self._recently_confirmed_dip else None + ), + confirmed_dip_ticks_left=( + self._recently_confirmed_dip.confirmed_ticks_left + if self._recently_confirmed_dip + else None ), ) diff --git a/custom_components/span_panel/services.py b/custom_components/span_panel/services.py index c4b93191..bfa56d0e 100644 --- a/custom_components/span_panel/services.py +++ b/custom_components/span_panel/services.py @@ -3,12 +3,13 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, cast +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, SupportsResponse from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util.json import JsonObjectType, JsonValueType from span_panel_api import regenerate_passphrase from span_panel_api.exceptions import ( SpanPanelAPIError, @@ -36,11 +37,7 @@ SPIKE_THRESHOLD_PCT, WINDOW_DURATION_M, ) - -if TYPE_CHECKING: - from homeassistant.config_entries import ConfigEntry - - from . import SpanPanelRuntimeData +from .runtime import SpanPanelRuntimeData _LOGGER = logging.getLogger(__name__) @@ -48,6 +45,28 @@ _DEVICE_TYPE_MAP: dict[str, str] = {"bess": "battery"} +def _loaded_runtime_data(entry: ConfigEntry) -> SpanPanelRuntimeData | None: + """Return this entry's runtime data, or None if it is not one of ours. + + Every service here is registered domain-wide and walks + `async_loaded_entries(DOMAIN)`, so each of them has to answer the same + question before touching an entry: does this entry carry runtime data of + ours? Both halves of that are real. `ConfigEntry.runtime_data` is a bare + annotation that core deletes on unload (`config_entries.py:1044-1045`), so + the attribute is genuinely absent on an entry that has not finished setting + up -- hence `getattr` with a default rather than an attribute read. And what + is there is whatever the owning integration put there, so `isinstance` is + what says it is ours; a test double may put anything at all in it. + + One helper, one answer: this used to be five copies of the same two lines, + each preceded by its own deferred import of the runtime-data type. + """ + runtime_data = getattr(entry, "runtime_data", None) + if isinstance(runtime_data, SpanPanelRuntimeData): + return runtime_data + return None + + def _async_register_services(hass: HomeAssistant) -> None: """Register domain-level services (called once per HA instance).""" @@ -55,8 +74,6 @@ async def async_handle_export_manifest( _call: ServiceCall, ) -> ServiceResponse: """Export circuit topology manifest for all configured SPAN panels.""" - from . import SpanPanelRuntimeData # pylint: disable=import-outside-toplevel - if not hass.config_entries.async_loaded_entries(DOMAIN): raise ServiceValidationError( "No SPAN panel configuration entries are loaded. " @@ -66,19 +83,18 @@ async def async_handle_export_manifest( ) entity_reg = er.async_get(hass) - panels = [] + panels: list[JsonValueType] = [] for entry in hass.config_entries.async_loaded_entries(DOMAIN): - if not hasattr(entry, "runtime_data") or not isinstance( - entry.runtime_data, SpanPanelRuntimeData - ): + runtime_data = _loaded_runtime_data(entry) + if runtime_data is None: continue - snapshot = entry.runtime_data.coordinator.data + snapshot = runtime_data.coordinator.data if snapshot is None: continue serial = snapshot.serial_number - circuits = [] + circuits: list[JsonValueType] = [] for circuit_id, circuit in snapshot.circuits.items(): if circuit_id.startswith("unmapped_tab_"): @@ -113,7 +129,7 @@ async def async_handle_export_manifest( } ) - return cast(ServiceResponse, {"panels": panels}) + return {"panels": panels} hass.services.async_register( DOMAIN, @@ -205,15 +221,12 @@ def _get_runtime_data( When config_entry_id is provided, returns that specific entry. Otherwise falls back to the first loaded entry. """ - from . import SpanPanelRuntimeData # pylint: disable=import-outside-toplevel - for entry in hass.config_entries.async_loaded_entries(DOMAIN): - if not hasattr(entry, "runtime_data") or not isinstance( - entry.runtime_data, SpanPanelRuntimeData - ): + runtime_data = _loaded_runtime_data(entry) + if runtime_data is None: continue if config_entry_id is None or entry.entry_id == config_entry_id: - return entry.runtime_data, entry + return runtime_data, entry return None def _get_monitor( @@ -292,15 +305,20 @@ async def async_handle_get_monitoring_status( entry_id = call.data.get("config_entry_id") result = _get_runtime_data(entry_id) if result is None: - return cast(ServiceResponse, {"enabled": False}) + return {"enabled": False} runtime_data, _entry = result monitor = runtime_data.coordinator.current_monitor if monitor is None: - return cast(ServiceResponse, {"enabled": False}) - status = monitor.get_monitoring_status() + return {"enabled": False} + status: JsonObjectType = monitor.get_monitoring_status() status["enabled"] = True - status["global_settings"] = monitor.get_global_settings() - return cast(ServiceResponse, status) + # `MonitoringSettings` is `dict[str, int | bool | str]`, which `dict` is + # invariant in, so it needs widening rather than casting to sit in a + # JSON response. `get_global_settings` builds a fresh dict per call, so + # the copy costs nothing. + global_settings: JsonObjectType = dict(monitor.get_global_settings()) + status["global_settings"] = global_settings + return status hass.services.async_register( DOMAIN, @@ -385,16 +403,13 @@ def _get_horizon_manager( call: ServiceCall, ) -> GraphHorizonManager: """Find the GraphHorizonManager for the given entry.""" - from . import SpanPanelRuntimeData # pylint: disable=import-outside-toplevel - entry_id = call.data.get("config_entry_id") for entry in hass.config_entries.async_loaded_entries(DOMAIN): - if not hasattr(entry, "runtime_data") or not isinstance( - entry.runtime_data, SpanPanelRuntimeData - ): + runtime_data = _loaded_runtime_data(entry) + if runtime_data is None: continue if entry_id is None or entry.entry_id == entry_id: - mgr = entry.runtime_data.coordinator.graph_horizon_manager + mgr = runtime_data.coordinator.graph_horizon_manager if mgr is not None: return mgr raise ServiceValidationError( @@ -433,19 +448,17 @@ async def async_handle_clear_subdevice_graph_horizon(call: ServiceCall) -> None: async def async_handle_get_graph_settings( call: ServiceCall, ) -> ServiceResponse: - from . import SpanPanelRuntimeData # pylint: disable=import-outside-toplevel - entry_id = call.data.get("config_entry_id") for entry in hass.config_entries.async_loaded_entries(DOMAIN): - if not hasattr(entry, "runtime_data") or not isinstance( - entry.runtime_data, SpanPanelRuntimeData - ): + runtime_data = _loaded_runtime_data(entry) + if runtime_data is None: continue if entry_id is None or entry.entry_id == entry_id: - mgr = entry.runtime_data.coordinator.graph_horizon_manager + mgr = runtime_data.coordinator.graph_horizon_manager if mgr is not None: - return cast(ServiceResponse, mgr.get_all_settings()) - return cast(ServiceResponse, {"global_horizon": DEFAULT_GRAPH_HORIZON, "circuits": {}}) + settings: JsonObjectType = mgr.get_all_settings() + return settings + return {"global_horizon": DEFAULT_GRAPH_HORIZON, "circuits": {}} hass.services.async_register( DOMAIN, @@ -619,21 +632,37 @@ def _resolve_entity_to_favorite_target(entity_id: str) -> tuple[str, FavoriteKin translation_placeholders={"entity_id": entity_id}, ) + def _favorites_response(favorites: dict[str, dict[str, list[str]]]) -> ServiceResponse: + """Wrap the favorites map in the shape all three favorites services return. + + The map is rebuilt rather than handed over as-is because `dict` and + `list` are invariant: the storage type says exactly `list[str]`, and a + JSON response value says `list[JsonValueType]`, so the two are not the + same type however identical the contents. Rebuilding also means the + response cannot alias the caller's map. + """ + payload: JsonObjectType = { + panel_device_id: {kind: list(target_ids) for kind, target_ids in kinds.items()} + for panel_device_id, kinds in favorites.items() + } + return {"favorites": payload} + async def async_handle_get_favorites(_call: ServiceCall) -> ServiceResponse: - favorites = await async_get_favorites(hass) - return cast(ServiceResponse, {"favorites": favorites}) + return _favorites_response(await async_get_favorites(hass)) async def async_handle_add_favorite(call: ServiceCall) -> ServiceResponse: entity_id = call.data["entity_id"] panel_device_id, kind, target_id = _resolve_entity_to_favorite_target(entity_id) - favorites = await async_set_favorite(hass, panel_device_id, kind, target_id, True) - return cast(ServiceResponse, {"favorites": favorites}) + return _favorites_response( + await async_set_favorite(hass, panel_device_id, kind, target_id, True) + ) async def async_handle_remove_favorite(call: ServiceCall) -> ServiceResponse: entity_id = call.data["entity_id"] panel_device_id, kind, target_id = _resolve_entity_to_favorite_target(entity_id) - favorites = await async_set_favorite(hass, panel_device_id, kind, target_id, False) - return cast(ServiceResponse, {"favorites": favorites}) + return _favorites_response( + await async_set_favorite(hass, panel_device_id, kind, target_id, False) + ) _favorite_mutation_schema = vol.Schema({vol.Required("entity_id"): str}) @@ -700,15 +729,11 @@ def _get_v2_entry(config_entry_id: str | None) -> ConfigEntry: of that panel is using, so picking one and hoping is worse than asking. A single loaded panel is unambiguous and the id stays optional there. """ - from . import SpanPanelRuntimeData # pylint: disable=import-outside-toplevel - candidates: list[ConfigEntry] = [] for entry in hass.config_entries.async_loaded_entries(DOMAIN): if config_entry_id is not None and entry.entry_id != config_entry_id: continue - if not hasattr(entry, "runtime_data") or not isinstance( - entry.runtime_data, SpanPanelRuntimeData - ): + if _loaded_runtime_data(entry) is None: continue if entry.data.get(CONF_API_VERSION) != "v2": continue diff --git a/custom_components/span_panel/switch.py b/custom_components/span_panel/switch.py index 9ae7f94b..057928ce 100644 --- a/custom_components/span_panel/switch.py +++ b/custom_components/span_panel/switch.py @@ -16,7 +16,6 @@ from homeassistant.helpers.restore_state import RestoreEntity from span_panel_api import SpanCircuitSnapshot, SpanPanelSnapshot -from . import SpanPanelConfigEntry from .adoption import AdoptedSwitch, create_adopted_switches from .const import DOMAIN, USE_CIRCUIT_NUMBERS, CircuitRelayState from .control_gate import ( @@ -38,6 +37,7 @@ circuit_object_id_base, release_registry_name_written_by_older_release, ) +from .runtime import SpanPanelConfigEntry from .util import snapshot_to_device_info _LOGGER: logging.Logger = logging.getLogger(__name__) diff --git a/custom_components/span_panel/websocket.py b/custom_components/span_panel/websocket.py index dfbc59c8..bbd55651 100644 --- a/custom_components/span_panel/websocket.py +++ b/custom_components/span_panel/websocket.py @@ -16,7 +16,7 @@ from .util import classify_sub_device_identifier if TYPE_CHECKING: - from . import SpanPanelRuntimeData + from .runtime import SpanPanelRuntimeData # Mapping from unique_id suffixes to semantic role names for circuit sensors. # Mapping from unique_id suffixes (as generated by build_circuit_unique_id via From 39491ffa07d3b845ed4539300e58468483697234 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:06:02 -0700 Subject: [PATCH 183/345] fix(coordinator): follow settability changes in both directions A circuit's controllability was watched by the switch and the select themselves, each comparing the gating predicate against the answer it was constructed with. That sees only half of it. A circuit the panel declares non-commandable gets neither entity, so when the panel later commissions it there is nothing on that circuit to notice: the switch and the select it has earned appear at the next reload that happens for some other reason, or at the next restart. The reader that can see it is the one that reads the whole snapshot. This moves the watch to `_check_settability_change` on the coordinator, beside `_check_capability_change` and modelled on it: one pass over every circuit answering both gating predicates, a baseline taken from the first snapshot, and one reload requested on any change since -- gained or lost. The baseline moves on the pass that asks, so a panel holding its new answer does not reload again on every push. Only circuits present in both readings are judged: circuits drop out of a snapshot and come back, which both platforms already guard for, and reading an absence as a change would turn a flap into a reload loop. The two predicates move to `helpers`, which is where the coordinator can read them from -- `switch.py` and `select.py` import the coordinator, so the predicate they share with it has to sit below all three. They stay single copies, gating creation and the reload trigger on the same answer. Also drops `async_create_span_notification`, dead since the select stopped calling it, and pins `SpanPanelRuntimeData`'s default lock to the default policy's `lock_enabled` so the two cannot drift apart. --- custom_components/span_panel/coordinator.py | 79 ++++++- custom_components/span_panel/helpers.py | 69 +++--- custom_components/span_panel/select.py | 74 +------ custom_components/span_panel/switch.py | 66 +----- tests/test_circuit_control.py | 90 -------- tests/test_coordinator.py | 223 +++++++++++++++++++- tests/test_helpers.py | 25 --- tests/test_select.py | 90 -------- tests/test_setup_entry.py | 14 ++ 9 files changed, 367 insertions(+), 363 deletions(-) diff --git a/custom_components/span_panel/coordinator.py b/custom_components/span_panel/coordinator.py index bc6c179d..1e7afe16 100644 --- a/custom_components/span_panel/coordinator.py +++ b/custom_components/span_panel/coordinator.py @@ -31,7 +31,11 @@ ) from .const import DOMAIN -from .helpers import detect_capabilities +from .helpers import ( + circuit_has_a_breaker_switch, + circuit_has_a_priority_select, + detect_capabilities, +) from .id_builder import build_circuit_unique_id from .notices import async_raise, read_translations from .schema_repairs import async_sync_schema_issues @@ -129,6 +133,11 @@ def __init__( # and trigger a reload so the factory creates the appropriate sensors. self._known_capabilities: frozenset[str] | None = None + # Per-circuit control settability — detect when the panel changes its + # mind about whether a circuit may be operated, in either direction. + # None until the first snapshot, which is the baseline. + self._known_settability: dict[str, tuple[bool, bool]] | None = None + # Schema validation — runs once SUCCESSFULLY; a pass that finds no # metadata yet leaves the flag unset so a later one can still answer. self._schema_validated = False @@ -475,6 +484,7 @@ async def _on_snapshot_push(self, snapshot: SpanPanelSnapshot) -> None: """Handle a pushed snapshot from MQTT streaming.""" self._mark_panel_online() self._check_capability_change(snapshot) + self._check_settability_change(snapshot) self.async_set_updated_data(snapshot) await self._run_post_update_tasks(snapshot) @@ -638,6 +648,68 @@ def _check_capability_change(self, snapshot: SpanPanelSnapshot) -> None: self._known_capabilities = current self.request_reload() + # --- Per-circuit control settability --- + + @staticmethod + def _read_settability(snapshot: SpanPanelSnapshot) -> dict[str, tuple[bool, bool]]: + """Answer, for every circuit, which control entities it would get. + + The same two predicates `switch.async_setup_entry` and + `select.async_setup_entry` gate creation on, read here over the whole + snapshot rather than restated. A second copy would drift, and the drift + would be silent: the platforms and the reload trigger disagreeing about + what the panel allows. + """ + return { + circuit_id: ( + circuit_has_a_breaker_switch(circuit), + circuit_has_a_priority_select(circuit), + ) + for circuit_id, circuit in snapshot.circuits.items() + } + + def _check_settability_change(self, snapshot: SpanPanelSnapshot) -> None: + """Request a reload when the panel changes its mind about a circuit. + + Both directions, which is why this lives here rather than on the + entities. A circuit that stops being commandable keeps a switch that + refuses every press -- an entity can see that about itself. A circuit + that *becomes* commandable has no switch and no select at all, so there + is nothing on it to notice; only a reader over every circuit can. + + A reload rather than a quiet availability change, because the entity + should not exist -- or should exist and does not -- under the new + answer, and creating and removing entities is what `async_setup_entry` + is for. The baseline is updated on the same pass that asks for the + reload, so a settled panel costs one dict comprehension per push and a + changed one asks exactly once. + + Only circuits present in *both* readings are judged. A circuit can drop + out of a snapshot and come back -- the platforms guard for exactly that + -- and counting an absence as a settability change would turn a flap + into a reload loop. Membership is still carried forward, so a circuit + that leaves and returns with a different answer is caught on its return. + """ + current = self._read_settability(snapshot) + if self._known_settability is None: + # First snapshot — record baseline + self._known_settability = current + return + + known = self._known_settability + changed = sorted( + circuit_id + for circuit_id, answer in current.items() + if circuit_id in known and known[circuit_id] != answer + ) + self._known_settability = current + if changed: + _LOGGER.info( + "Panel changed which controls it allows on circuit(s) %s — requesting reload", + ", ".join(changed), + ) + self.request_reload() + # --- Solar entity migration (v1 → v2) --- _SOLAR_SUFFIX_TO_DESCRIPTION_KEY: dict[str, str] = { @@ -805,6 +877,11 @@ async def _async_update_data(self) -> SpanPanelSnapshot: # Check for new hardware capabilities (BESS, PV, power-flows) self._check_capability_change(snapshot) + # Check whether the panel changed which circuits it will let a user + # operate. Both transports run it: the fallback poll is the only + # path a panel with no live MQTT stream has. + self._check_settability_change(snapshot) + await self._run_post_update_tasks(snapshot) except SpanPanelCAChangedError as err: diff --git a/custom_components/span_panel/helpers.py b/custom_components/span_panel/helpers.py index e057b474..0270f89a 100644 --- a/custom_components/span_panel/helpers.py +++ b/custom_components/span_panel/helpers.py @@ -3,10 +3,7 @@ from __future__ import annotations from hashlib import sha256 -import logging -from homeassistant.components.persistent_notification import async_create -from homeassistant.core import HomeAssistant from homeassistant.helpers import ( entity_registry as er, # noqa: F401 — re-exported for patch compatibility ) @@ -57,7 +54,6 @@ "PANEL_ENTITY_SUFFIX_MAPPING", "PANEL_SUFFIX_MAPPING", "adopted_capability_tokens", - "async_create_span_notification", "build_bess_unique_id", "build_mid_unique_id", "build_bess_unique_id_for_entry", @@ -72,6 +68,8 @@ "build_select_unique_id_for_entry", "build_switch_unique_id", "build_switch_unique_id_for_entry", + "circuit_has_a_breaker_switch", + "circuit_has_a_priority_select", "construct_binary_sensor_unique_id", "construct_circuit_identifier_from_tabs", "construct_circuit_label", @@ -102,40 +100,45 @@ "resolve_evse_display_suffix", ] -_LOGGER = logging.getLogger(__name__) +def circuit_has_a_breaker_switch(circuit: SpanCircuitSnapshot) -> bool: + """Whether this circuit gets a breaker switch at all. -async def async_create_span_notification( - hass: HomeAssistant, - message: str, - title: str, - notification_id: str, - level: str = "warning", -) -> None: - """Create a persistent notification for SPAN Panel issues. - - Args: - hass: Home Assistant instance - message: Notification message content - title: Notification title - notification_id: Unique identifier for the notification - level: Severity level (info, warning, error) + One predicate, read by `switch.async_setup_entry` when it decides what to + create and by the coordinator when it decides whether that answer has moved + since setup. A second copy would drift, and the drift would be invisible: a + switch left on the dashboard offering a control the panel refuses, or a + circuit the panel has commissioned that never grows the switch it earned. + It lives here rather than in `switch.py` because the coordinator is the only + reader that sees every circuit, and `switch.py` imports the coordinator -- + so the predicate has to sit below both of them. """ - _LOGGER.log( - getattr(logging, level.upper(), logging.WARNING), - "SPAN Panel %s: %s - %s", - level, - title, - message, - ) + if not circuit.is_user_controllable: + return False + # PV/EVSE circuits only get switches if they have a physical breaker + # (relative_position == "DOWNSTREAM" means connected at a breaker slot). + return not (circuit.device_type in ("pv", "evse") and circuit.relative_position != "DOWNSTREAM") - async_create( - hass, - message=message, - title=title, - notification_id=notification_id, - ) + +def circuit_has_a_priority_select(circuit: SpanCircuitSnapshot) -> bool: + """Whether this circuit gets a shed-priority select at all. + + The companion of `circuit_has_a_breaker_switch`, and read by the same two + callers for the same reason: `select.async_setup_entry` to decide what to + create, the coordinator to notice the answer changing under a live entry. + + Priority settability is its own commissioning flag, not the relay's. v1.0 + expresses never-backup as the absence of `$settable` on + `load-shed/priority`, which the adapter carries here. + """ + if not circuit.is_user_controllable: + return False + if circuit.is_never_backup: + return False + # PV/EVSE circuits only get selects if they have a physical breaker + # (relative_position == "DOWNSTREAM" means connected at a breaker slot). + return not (circuit.device_type in ("pv", "evse") and circuit.relative_position != "DOWNSTREAM") def construct_circuit_identifier_from_tabs(tabs: list[int], circuit_id: str = "") -> str: diff --git a/custom_components/span_panel/select.py b/custom_components/span_panel/select.py index 2b0e62b6..133de719 100644 --- a/custom_components/span_panel/select.py +++ b/custom_components/span_panel/select.py @@ -18,6 +18,7 @@ from .entity import SpanPanelEntity from .helpers import ( build_select_unique_id_for_entry, + circuit_has_a_priority_select, construct_circuit_identifier_from_tabs, construct_circuit_label, construct_tabs_attribute, @@ -40,27 +41,6 @@ def _unnamed_select_fallback(circuit: SpanCircuitSnapshot, circuit_id: str) -> s return construct_circuit_identifier_from_tabs(circuit.tabs, circuit_id) -def _circuit_has_a_priority_select(circuit: SpanCircuitSnapshot) -> bool: - """Whether this circuit gets a shed-priority select at all. - - One predicate, read by `async_setup_entry` when it decides what to create - and by the entity itself when it decides whether it should still exist. A - second copy would drift, and the drift would be invisible: a select left on - the dashboard offering a choice the panel refuses. - - Priority settability is its own commissioning flag, not the relay's. v1.0 - expresses never-backup as the absence of `$settable` on - `load-shed/priority`, which the adapter carries here. - """ - if not circuit.is_user_controllable: - return False - if circuit.is_never_backup: - return False - # PV/EVSE circuits only get selects if they have a physical breaker - # (relative_position == "DOWNSTREAM" means connected at a breaker slot). - return not (circuit.device_type in ("pv", "evse") and circuit.relative_position != "DOWNSTREAM") - - _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 1 @@ -201,13 +181,6 @@ def __init__( self._attr_options = description.options_fn(circuit) self._attr_current_option = description.current_option_fn(circuit) - # What the panel said about this circuit when the entity was created. - # `async_setup_entry` read the same predicate to decide this select - # should exist; the library's own changelog names the case where the - # answer changes under a live entity, so the entity carries the answer - # forward and watches for it to move. See `_handle_coordinator_update`. - self._was_settable = _circuit_has_a_priority_select(circuit) - # Store initial circuit name for change detection in auto-sync if not existing_entity_id: self._previous_circuit_name: str | None | object = _NAME_UNSET @@ -306,15 +279,17 @@ def extra_state_attributes(self) -> Mapping[str, Any] | None: def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator. - Two reload-worthy changes are watched on this one path: the circuit's - name, which the entity carries as `original_name` and can only refresh - by being rebuilt, and whether the panel still lets this circuit's shed - priority be set at all, which decided that this entity exists. + The circuit's name is watched here: the entity carries it as + `original_name`, which can only be refreshed by being rebuilt, so a + rename earns a reload. Whether the panel still lets this circuit's shed + priority be set -- the answer that decided this entity exists -- is + watched by `SpanPanelCoordinator._check_settability_change` instead, + because the opposite edge happens on circuits that have no entity here + to see it. """ snapshot: SpanPanelSnapshot = self.coordinator.data circuit = snapshot.circuits.get(self.id) if circuit: - self._watch_settability(circuit) current_circuit_name = circuit.name # One path for both modes: the name is carried by `original_name`, @@ -361,37 +336,6 @@ def _handle_coordinator_update(self) -> None: self._attr_current_option = self.description_wrapper.current_option_fn(circuit) super()._handle_coordinator_update() - def _watch_settability(self, circuit: SpanCircuitSnapshot) -> None: - """Ask for a reload when the panel changes its mind about this circuit. - - An entity can outlive its own settability: `is_user_controllable` and - `is_never_backup` are read once, at setup, and a circuit the panel later - commissions never-backup keeps a select that refuses every choice made - in it. - - The opposite edge -- a circuit that *becomes* settable -- is picked up - only when the reload this asks for happens for some other reason, since - a circuit with no select has nothing here to notice it. Watching for - that one needs a reader that sees every circuit rather than its own, and - the coordinator is where that belongs. - - A reload rather than a quiet availability change, because the entity - should not exist at all under the new answer, and creating and removing - entities is what `async_setup_entry` is for. The edge is what triggers - it: comparing against the answer this entity was built with means a - steady state costs nothing on every push. - """ - settable = _circuit_has_a_priority_select(circuit) - if settable == self._was_settable: - return - _LOGGER.info( - "Circuit %s shed priority is %s settable, requesting integration reload", - self.id, - "now" if settable else "no longer", - ) - self._was_settable = settable - self.coordinator.request_reload() - def _construct_select_unique_id( self, coordinator: SpanPanelCoordinator, @@ -425,7 +369,7 @@ async def async_setup_entry( entities: list[SpanPanelCircuitsSelect | AdoptedSelect] = [] for circuit_id, circuit_data in snapshot.circuits.items(): - if not _circuit_has_a_priority_select(circuit_data): + if not circuit_has_a_priority_select(circuit_data): continue entities.append( SpanPanelCircuitsSelect( diff --git a/custom_components/span_panel/switch.py b/custom_components/span_panel/switch.py index 057928ce..ab892d97 100644 --- a/custom_components/span_panel/switch.py +++ b/custom_components/span_panel/switch.py @@ -28,6 +28,7 @@ from .entity import SpanPanelEntity from .helpers import ( build_switch_unique_id_for_entry, + circuit_has_a_breaker_switch, construct_circuit_identifier_from_tabs, construct_circuit_label, construct_tabs_attribute, @@ -58,21 +59,6 @@ def _unnamed_switch_fallback(circuit: SpanCircuitSnapshot, circuit_id: str) -> s return construct_circuit_identifier_from_tabs(circuit.tabs, circuit_id) -def _circuit_has_a_breaker_switch(circuit: SpanCircuitSnapshot) -> bool: - """Whether this circuit gets a breaker switch at all. - - One predicate, read by `async_setup_entry` when it decides what to create - and by the entity itself when it decides whether it should still exist. A - second copy would drift, and the drift would be invisible: a switch left on - the dashboard offering a control the panel refuses. - """ - if not circuit.is_user_controllable: - return False - # PV/EVSE circuits only get switches if they have a physical breaker - # (relative_position == "DOWNSTREAM" means connected at a breaker slot). - return not (circuit.device_type in ("pv", "evse") and circuit.relative_position != "DOWNSTREAM") - - class SpanPanelCircuitsSwitch(SpanPanelEntity, SwitchEntity): """Represent a switch entity.""" @@ -155,13 +141,6 @@ def __init__( self._update_is_on() - # What the panel said about this circuit when the entity was created. - # `async_setup_entry` read the same predicate to decide this switch - # should exist; the library's own changelog names the case where the - # answer changes under a live entity, so the entity carries the answer - # forward and watches for it to move. See `_handle_coordinator_update`. - self._was_controllable = _circuit_has_a_breaker_switch(circuit) - # Store initial circuit name for change detection in auto-sync if not existing_entity_id: self._previous_circuit_name: str | None | object = _NAME_UNSET @@ -180,15 +159,16 @@ async def async_will_remove_from_hass(self) -> None: def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator. - Two reload-worthy changes are watched on this one path: the circuit's - name, which the entity carries as `original_name` and can only refresh - by being rebuilt, and whether the panel still lets this circuit be - operated at all, which decided that this entity exists. + The circuit's name is watched here: the entity carries it as + `original_name`, which can only be refreshed by being rebuilt, so a + rename earns a reload. Whether the panel still lets this circuit be + operated -- the answer that decided this entity exists -- is watched by + `SpanPanelCoordinator._check_settability_change` instead, because the + opposite edge happens on circuits that have no entity here to see it. """ snapshot: SpanPanelSnapshot = self.coordinator.data circuit = snapshot.circuits.get(self._circuit_id) if circuit: - self._watch_settability(circuit) current_circuit_name = circuit.name # One path for both modes: the name is carried by `original_name`, @@ -223,36 +203,6 @@ def _handle_coordinator_update(self) -> None: self._update_is_on() super()._handle_coordinator_update() - def _watch_settability(self, circuit: SpanCircuitSnapshot) -> None: - """Ask for a reload when the panel changes its mind about this circuit. - - An entity can outlive its own controllability: `is_user_controllable` is - read once, at setup, and a circuit the panel later declares - non-commandable keeps a switch that refuses every press. - - The opposite edge -- a circuit that *becomes* commandable -- is picked up - only when the reload this asks for happens for some other reason, since - a circuit with no switch has nothing here to notice it. Watching for - that one needs a reader that sees every circuit rather than its own, and - the coordinator is where that belongs. - - A reload rather than a quiet availability change, because the entity - should not exist at all under the new answer, and creating and removing - entities is what `async_setup_entry` is for. The edge is what triggers - it: comparing against the answer this entity was built with means a - steady state costs nothing on every push. - """ - controllable = _circuit_has_a_breaker_switch(circuit) - if controllable == self._was_controllable: - return - _LOGGER.info( - "Circuit %s is %s user-controllable, requesting integration reload", - self._circuit_id, - "now" if controllable else "no longer", - ) - self._was_controllable = controllable - self.coordinator.request_reload() - @property def available(self) -> bool: """Return entity availability. @@ -590,7 +540,7 @@ async def async_setup_entry( return for circuit_id, circuit_data in snapshot.circuits.items(): - if not _circuit_has_a_breaker_switch(circuit_data): + if not circuit_has_a_breaker_switch(circuit_data): continue entities.append(SpanPanelCircuitsSwitch(coordinator, circuit_id, _device_name)) diff --git a/tests/test_circuit_control.py b/tests/test_circuit_control.py index 1e9066a8..d7393171 100644 --- a/tests/test_circuit_control.py +++ b/tests/test_circuit_control.py @@ -245,96 +245,6 @@ def test_circuit_name_change_triggers_reload_request(hass: HomeAssistant) -> Non coordinator.request_reload.assert_called_once() -def test_a_circuit_that_stops_being_controllable_requests_a_reload( - hass: HomeAssistant, -) -> None: - """An entity can outlive its own controllability, and must not. - - `is_user_controllable` is read once, at setup, to decide whether this switch - exists at all. When the panel withdraws it mid-session the switch stays on - the dashboard and every press is refused by the panel, which is a worse - experience than the entity going away on the reload the flag change earns. - """ - circuit = SpanCircuitSnapshotFactory.create( - circuit_id="1", - name="Kitchen Outlets", - is_user_controllable=True, - ) - coordinator = _make_coordinator({"1": circuit}) - coordinator.hass = hass - - with pytest.MonkeyPatch.context() as mp: - init_registry = MagicMock() - init_registry.async_get_entity_id.return_value = "switch.kitchen_outlets_breaker" - mp.setattr( - "custom_components.span_panel.switch.er.async_get", - lambda _hass: init_registry, - ) - switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") - - switch.hass = hass - switch.entity_id = "switch.kitchen_outlets_breaker" - switch.async_write_ha_state = MagicMock() - - # Same name, so nothing but the settability flag can explain a reload. - coordinator.data = SpanPanelSnapshotFactory.create( - circuits={"1": replace(circuit, is_user_controllable=False)} - ) - - with pytest.MonkeyPatch.context() as mp: - runtime_registry = MagicMock() - runtime_registry.async_get.return_value = None - mp.setattr( - "custom_components.span_panel.switch.er.async_get", - lambda _hass: runtime_registry, - ) - switch._handle_coordinator_update() - - coordinator.request_reload.assert_called_once() - - -def test_a_circuit_that_stays_controllable_requests_no_reload( - hass: HomeAssistant, -) -> None: - """The watch has to be an edge, or every push would reload the entry.""" - circuit = SpanCircuitSnapshotFactory.create( - circuit_id="1", - name="Kitchen Outlets", - is_user_controllable=True, - relay_state="CLOSED", - ) - coordinator = _make_coordinator({"1": circuit}) - coordinator.hass = hass - - with pytest.MonkeyPatch.context() as mp: - init_registry = MagicMock() - init_registry.async_get_entity_id.return_value = "switch.kitchen_outlets_breaker" - mp.setattr( - "custom_components.span_panel.switch.er.async_get", - lambda _hass: init_registry, - ) - switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") - - switch.hass = hass - switch.entity_id = "switch.kitchen_outlets_breaker" - switch.async_write_ha_state = MagicMock() - - coordinator.data = SpanPanelSnapshotFactory.create( - circuits={"1": replace(circuit, relay_state="OPEN")} - ) - - with pytest.MonkeyPatch.context() as mp: - runtime_registry = MagicMock() - runtime_registry.async_get.return_value = None - mp.setattr( - "custom_components.span_panel.switch.er.async_get", - lambda _hass: runtime_registry, - ) - switch._handle_coordinator_update() - - coordinator.request_reload.assert_not_called() - - def test_switch_unavailable_when_panel_offline() -> None: """Switches become unavailable when the panel is offline.""" circuit = SpanCircuitSnapshotFactory.create(circuit_id="1") diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 87f3563a..38d5fb43 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -33,11 +33,17 @@ from span_panel_api.protocol import SpanPanelClientProtocol from custom_components.span_panel.coordinator import SpanPanelCoordinator -from custom_components.span_panel.helpers import adopted_capability_tokens, detect_capabilities +from custom_components.span_panel.helpers import ( + adopted_capability_tokens, + circuit_has_a_breaker_switch, + circuit_has_a_priority_select, + detect_capabilities, +) from .adapter_fixtures import schema_one_snapshot from .factories import ( SpanBatterySnapshotFactory, + SpanCircuitSnapshotFactory, SpanEvseSnapshotFactory, SpanPanelSnapshotFactory, ) @@ -887,3 +893,218 @@ async def test_the_digest_is_stable_across_calls_and_distinguishes_devices() -> assert adopted_capability_tokens(first) == adopted_capability_tokens(again) assert adopted_capability_tokens(first) != adopted_capability_tokens(other) +async def test_a_circuit_that_becomes_controllable_requests_a_reload( + hass: HomeAssistant, +) -> None: + """The direction no entity can see: a circuit that gains a control. + + A circuit the panel declares non-commandable gets no switch and no select, + so there is no entity on that circuit to notice the panel changing its mind + later. Only a reader that sees every circuit can, and the coordinator is + the one thing that reads the whole snapshot on every push. + """ + client = MagicMock(spec=SpanPanelClientProtocol) + client.field_metadata = None + coordinator = _create_coordinator(hass, client=client) + + locked = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen Outlets", is_user_controllable=False + ) + baseline = SpanPanelSnapshotFactory.create(circuits={"1": locked}) + commissioned = SpanPanelSnapshotFactory.create( + circuits={"1": replace(locked, is_user_controllable=True)} + ) + + with patch.object(coordinator, "request_reload") as request_reload: + await coordinator._on_snapshot_push(baseline) + request_reload.assert_not_called() + + await coordinator._on_snapshot_push(commissioned) + request_reload.assert_called_once() + + +async def test_a_circuit_that_stops_being_controllable_requests_a_reload( + hass: HomeAssistant, +) -> None: + """A control entity can outlive its own controllability, and must not. + + `is_user_controllable` decided at setup that this circuit gets a switch and + a select. When the panel withdraws it mid-session both stay on the + dashboard and every press and every choice is refused, which is a worse + experience than the entities going away on the reload the change earns. + """ + coordinator = _create_coordinator(hass) + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen Outlets", is_user_controllable=True + ) + + coordinator._check_settability_change(SpanPanelSnapshotFactory.create(circuits={"1": circuit})) + assert coordinator._known_settability == {"1": (True, True)} + assert coordinator._reload_requested is False + + coordinator._check_settability_change( + SpanPanelSnapshotFactory.create( + circuits={"1": replace(circuit, is_user_controllable=False)} + ) + ) + + assert coordinator._known_settability == {"1": (False, False)} + assert coordinator._reload_requested is True + + +async def test_a_circuit_commissioned_never_backup_requests_a_reload( + hass: HomeAssistant, +) -> None: + """Priority settability is its own flag, and it decides whether a select exists. + + A circuit the panel commissions never-backup keeps a relay it can still + operate, so the switch is unaffected -- but its priority select now refuses + every choice made in it. + """ + coordinator = _create_coordinator(hass) + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen Outlets", is_user_controllable=True + ) + + coordinator._check_settability_change(SpanPanelSnapshotFactory.create(circuits={"1": circuit})) + coordinator._check_settability_change( + SpanPanelSnapshotFactory.create(circuits={"1": replace(circuit, is_never_backup=True)}) + ) + + assert coordinator._known_settability == {"1": (True, False)} + assert coordinator._reload_requested is True + + +async def test_a_circuit_whose_settability_is_unchanged_requests_no_reload( + hass: HomeAssistant, +) -> None: + """The watch has to be an edge, or every push would reload the entry.""" + coordinator = _create_coordinator(hass) + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen Outlets", is_user_controllable=True, relay_state="CLOSED" + ) + + coordinator._check_settability_change(SpanPanelSnapshotFactory.create(circuits={"1": circuit})) + coordinator._check_settability_change( + SpanPanelSnapshotFactory.create(circuits={"1": replace(circuit, relay_state="OPEN")}) + ) + + assert coordinator._reload_requested is False + + +async def test_a_settability_change_asks_for_one_reload_and_not_a_storm( + hass: HomeAssistant, +) -> None: + """The baseline moves on the pass that asks, exactly like the capability check. + + Without that, every push after a withdrawn control would re-request a + reload, and the entry would reload for as long as the panel held its new + answer. + """ + coordinator = _create_coordinator(hass) + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen Outlets", is_user_controllable=True + ) + withdrawn = SpanPanelSnapshotFactory.create( + circuits={"1": replace(circuit, is_user_controllable=False)} + ) + + coordinator._check_settability_change(SpanPanelSnapshotFactory.create(circuits={"1": circuit})) + + with patch.object(coordinator, "request_reload") as request_reload: + coordinator._check_settability_change(withdrawn) + coordinator._check_settability_change(withdrawn) + coordinator._check_settability_change(withdrawn) + + request_reload.assert_called_once() + + +async def test_a_circuit_dropping_out_of_a_snapshot_requests_no_reload( + hass: HomeAssistant, +) -> None: + """Absence is not an answer about settability, and must not be read as one. + + Circuits go missing from a snapshot and come back -- both control platforms + guard for exactly that. Counting the gap as a change would turn a flapping + circuit into a reload loop, so only circuits present in both readings are + judged. The one that returns is judged again on its return. + """ + coordinator = _create_coordinator(hass) + kitchen = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen Outlets", is_user_controllable=True + ) + garage = SpanCircuitSnapshotFactory.create( + circuit_id="2", name="Garage Outlets", is_user_controllable=True + ) + + coordinator._check_settability_change( + SpanPanelSnapshotFactory.create(circuits={"1": kitchen, "2": garage}) + ) + coordinator._check_settability_change(SpanPanelSnapshotFactory.create(circuits={"1": kitchen})) + assert coordinator._reload_requested is False + + coordinator._check_settability_change( + SpanPanelSnapshotFactory.create(circuits={"1": kitchen, "2": garage}) + ) + assert coordinator._reload_requested is False + + coordinator._check_settability_change( + SpanPanelSnapshotFactory.create( + circuits={"1": kitchen, "2": replace(garage, is_user_controllable=False)} + ) + ) + assert coordinator._reload_requested is True + + +async def test_the_settability_reader_gates_on_what_the_platforms_gate_on( + hass: HomeAssistant, +) -> None: + """The coordinator must not derive its own, narrower notion of settable. + + A second copy of either predicate would drift, and the drift would be + silent: the platforms and the reload trigger disagreeing about what the + panel allows. Both read `helpers`, and this pins the pair by driving a case + only the shared predicate answers -- a PV circuit with no physical breaker, + which is controllable and still gets neither entity. + """ + upstream_pv = SpanCircuitSnapshotFactory.create( + circuit_id="9", + name="Solar", + device_type="pv", + relative_position="UPSTREAM", + is_user_controllable=True, + ) + coordinator = _create_coordinator(hass) + + coordinator._check_settability_change( + SpanPanelSnapshotFactory.create(circuits={"9": upstream_pv}) + ) + + assert coordinator._known_settability == {"9": (False, False)} + assert circuit_has_a_breaker_switch(upstream_pv) is False + assert circuit_has_a_priority_select(upstream_pv) is False + + +async def test_the_polling_path_watches_settability_too(hass: HomeAssistant) -> None: + """A panel with no live MQTT stream has only the fallback poll to notice on.""" + client = MagicMock(spec=SpanPanelClientProtocol) + client.field_metadata = None + coordinator = _create_coordinator(hass, client=client) + + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen Outlets", is_user_controllable=False + ) + client.get_snapshot = AsyncMock( + return_value=SpanPanelSnapshotFactory.create(circuits={"1": circuit}) + ) + await coordinator._async_update_data() + + client.get_snapshot = AsyncMock( + return_value=SpanPanelSnapshotFactory.create( + circuits={"1": replace(circuit, is_user_controllable=True)} + ) + ) + with patch.object(coordinator, "request_reload") as request_reload: + await coordinator._async_update_data() + + request_reload.assert_called_once() diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 9e5c87a0..33ba14af 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -2,13 +2,9 @@ # pylint: disable=reimported -from unittest.mock import MagicMock, patch - from homeassistant.util import slugify -import pytest from custom_components.span_panel.helpers import ( - async_create_span_notification, construct_circuit_identifier_from_tabs, detect_capabilities, get_suffix_from_sensor_key, @@ -121,27 +117,6 @@ def test_construct_circuit_identifier_from_tabs(self): assert construct_circuit_identifier_from_tabs([7], "c1") == "Circuit 7" assert construct_circuit_identifier_from_tabs([], "fallback") == "Circuit fallback" - @patch("custom_components.span_panel.helpers.async_create") - @pytest.mark.asyncio - async def test_async_create_span_notification_logs_and_forwards(self, mock_create): - """Test notification helper forwarding.""" - hass = MagicMock() - - await async_create_span_notification( - hass, - "Panel connection lost", - "SPAN Alert", - "notif-1", - level="error", - ) - - mock_create.assert_called_once_with( - hass, - message="Panel connection lost", - title="SPAN Alert", - notification_id="notif-1", - ) - def test_detect_capabilities_helper(self): """Test capability detection from a populated snapshot.""" snapshot = SpanPanelSnapshotFactory.create( diff --git a/tests/test_select.py b/tests/test_select.py index 9661c77d..87e8c3b5 100644 --- a/tests/test_select.py +++ b/tests/test_select.py @@ -390,96 +390,6 @@ def test_handle_coordinator_update_requests_reload_on_name_change() -> None: assert select._previous_circuit_name == "Renamed Kitchen" -def test_a_circuit_commissioned_never_backup_requests_a_reload() -> None: - """Priority settability is its own flag, and it decides whether this exists. - - `is_never_backup` is read once, at setup. A circuit the panel later - commissions never-backup keeps a priority select that refuses every choice - the user makes in it. - """ - coordinator = _make_coordinator_with_circuit(circuit_name="Kitchen") - coordinator.hass = MagicMock() - with patch("custom_components.span_panel.select.er.async_get") as mock_async_get: - registry = MagicMock() - registry.async_get_entity_id.return_value = "select.kitchen_circuit_priority" - mock_async_get.return_value = registry - - select = SpanPanelCircuitsSelect( - coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "SPAN Panel" - ) - - # Same name, so nothing but the settability flag can explain a reload. - updated_circuit = replace(coordinator.data.circuits["id"], is_never_backup=True) - coordinator.data = SpanPanelSnapshotFactory.create(circuits={"id": updated_circuit}) - select.hass = MagicMock() - select.async_write_ha_state = MagicMock() - select.entity_id = "select.kitchen_circuit_priority" - - with patch("custom_components.span_panel.select.er.async_get") as mock_async_get: - runtime_registry = MagicMock() - runtime_registry.async_get.return_value = None - mock_async_get.return_value = runtime_registry - select._handle_coordinator_update() - - coordinator.request_reload.assert_called_once() - - -def test_a_circuit_that_stops_being_controllable_requests_a_select_reload() -> None: - """The other half of the same gate: a circuit that stops being commandable.""" - coordinator = _make_coordinator_with_circuit(circuit_name="Kitchen") - coordinator.hass = MagicMock() - with patch("custom_components.span_panel.select.er.async_get") as mock_async_get: - registry = MagicMock() - registry.async_get_entity_id.return_value = "select.kitchen_circuit_priority" - mock_async_get.return_value = registry - - select = SpanPanelCircuitsSelect( - coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "SPAN Panel" - ) - - updated_circuit = replace(coordinator.data.circuits["id"], is_user_controllable=False) - coordinator.data = SpanPanelSnapshotFactory.create(circuits={"id": updated_circuit}) - select.hass = MagicMock() - select.async_write_ha_state = MagicMock() - select.entity_id = "select.kitchen_circuit_priority" - - with patch("custom_components.span_panel.select.er.async_get") as mock_async_get: - runtime_registry = MagicMock() - runtime_registry.async_get.return_value = None - mock_async_get.return_value = runtime_registry - select._handle_coordinator_update() - - coordinator.request_reload.assert_called_once() - - -def test_a_circuit_whose_settability_is_unchanged_requests_no_select_reload() -> None: - """The watch has to be an edge, or every push would reload the entry.""" - coordinator = _make_coordinator_with_circuit(circuit_name="Kitchen") - coordinator.hass = MagicMock() - with patch("custom_components.span_panel.select.er.async_get") as mock_async_get: - registry = MagicMock() - registry.async_get_entity_id.return_value = "select.kitchen_circuit_priority" - mock_async_get.return_value = registry - - select = SpanPanelCircuitsSelect( - coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "SPAN Panel" - ) - - updated_circuit = replace(coordinator.data.circuits["id"], priority="OFF_GRID") - coordinator.data = SpanPanelSnapshotFactory.create(circuits={"id": updated_circuit}) - select.hass = MagicMock() - select.async_write_ha_state = MagicMock() - select.entity_id = "select.kitchen_circuit_priority" - - with patch("custom_components.span_panel.select.er.async_get") as mock_async_get: - runtime_registry = MagicMock() - runtime_registry.async_get.return_value = None - mock_async_get.return_value = runtime_registry - select._handle_coordinator_update() - - coordinator.request_reload.assert_not_called() - - def test_handle_coordinator_update_skips_when_circuit_missing_from_snapshot() -> None: """Select entity should not crash when its circuit is temporarily absent from a snapshot.""" coordinator = _make_coordinator_with_circuit(circuit_name="Kitchen") diff --git a/tests/test_setup_entry.py b/tests/test_setup_entry.py index d672c655..6de75b3e 100644 --- a/tests/test_setup_entry.py +++ b/tests/test_setup_entry.py @@ -27,6 +27,7 @@ CONF_HTTP_PORT, DOMAIN, ) +from custom_components.span_panel.control_gate import ControlPolicy from custom_components.span_panel.options import CONTROL_LOCK_TIMEOUT from .factories import SpanPanelSnapshotFactory @@ -525,3 +526,16 @@ async def test_an_enabled_control_lock_is_armed_before_the_platforms_are_forward assert await async_setup_entry(hass, entry) is True assert entry.runtime_data.control_lock.armed is True +def test_runtime_data_defaults_its_lock_to_the_default_policys_answer() -> None: + """The dataclass default may not contradict the policy it stands in for. + + `control_lock` is defaulted rather than required, so an entry built without + one gets whatever this class decides -- and a bare `ControlLock()` would be + disarmed, a promise about a feature the class has no way to check. Deriving + it from `ControlPolicy.default()` is what keeps it honest, and this pins the + two together so a change to the default policy cannot silently unlock the + entries that never named a lock. + """ + runtime_data = SpanPanelRuntimeData(coordinator=MagicMock(), panel_device_id="panel-device-id") + + assert runtime_data.control_lock.armed == ControlPolicy.default().lock_enabled From 152da89f6d5eb8dc4ab1047ba877aa123f36ba59 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:12:16 -0700 Subject: [PATCH 184/345] docs: the security release, its limits, and two stale pins The 2.1.0 changelog described the release as it stood fourteen commits ago. Every user-visible change from the review fixes now has one bullet under the heading it belongs to, and the two bullets that had gone stale are corrected rather than left standing. Security gains what the pinning work actually did beyond the first pin: a pinned entry never left holding an address its own certificate does not name, "continue anyway" keeping the panel rather than the unregistered domain, the deferred pin and the CA-changed repair both checking the fetched authority against the certificate the broker serves before storing it, and a discovered or re-added host moving a pinned entry only when it chains to that entry's anchor. `rotate_credentials` gains its three: naming the panel when more than one is loaded, reporting a reload that did not come back, and refusing to travel unverified. Two Security bullets were wrong rather than missing. The settability one claimed one direction and "the next update"; it is both directions and a reload the integration asks for. And `Administrators only` is now stated not to cover an automation, which Home Assistant hands a fresh context with no user on it -- the option for those callers is the contextless one beside it. Adoption's qualifications go under Added, because adoption ships in this release and a fix to something nobody has run is not a fix: a number box only where the declared range reads, the lexically first wire path keeping a colliding id, a device arriving after setup reaching the user on its own, the 60-per-device cap and its once-per-change notice, and the announced-entities store re-seeding rather than failing setup. Fixed gains the dip's post-corroboration window, the notification moving to settling, and a dead transport making entities unavailable instead of reading 0 W. `at_service_entrance` is stated as present only on the parent/child data model. A new Known issues section records the four things the release does not close: a single-label name cannot complete a pinned install, an upgraded entry whose broker port serves someone else's certificate stays unpinned with no repair, a refused host move points at Reconfigure, and Supervisor discovery is deliberately outside the address check. README's Security section follows: reauth acquiring an anchor, how a hostname install is verified, what moves a pinned entry and what does not, the Supervisor exception, and rotation's three refusals. Its dip section described a notification that now fires three readings later. developer.md's settability section said nothing re-reads it, which stopped being true at 86b0745 and 39491ff; it now describes the coordinator's reader and why both edges live there. The adopted collision rule says lexically first, `runtime.py` and `_loaded_runtime_data` are documented, and the unguarded Supervisor path is written down where the next reader of config_flow.py will find it. AGENTS.md point 6 names the helper instead of the pattern. pyproject was still on 2.1.0b6 against a b16 manifest, and prek pinned ruff v0.15.1 against the v0.15.12 in the dependency group. --- AGENTS.md | 4 ++- CHANGELOG.md | 62 +++++++++++++++++++++++++++++++++-- README.md | 71 ++++++++++++++++++++++++++++++++++------ developer.md | 89 +++++++++++++++++++++++++++++++++++--------------- prek.toml | 2 +- pyproject.toml | 2 +- 6 files changed, 189 insertions(+), 41 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 18441d56..421ace68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,7 +91,9 @@ forget action). Services that perform actions without returning data omit `suppo ### 6. Runtime Data Guards Handlers that access `entry.runtime_data` MUST verify the entry is loaded and its runtime data is the expected type before accessing coordinator or other -runtime objects. Use `hasattr` and `isinstance` checks — never assume runtime data is present. +runtime objects. Call `services._loaded_runtime_data(entry)`, which answers `SpanPanelRuntimeData | None` — never inline the check and never assume runtime data +is present. It reads the attribute with a default, because core deletes `runtime_data` on unload, and then narrows it with `isinstance`, because what is there +is whatever the owning integration put there. ### 7. Domain-Level Registration diff --git a/CHANGELOG.md b/CHANGELOG.md index 672bdc20..4962cc67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,8 @@ working entirely in 2027.8. never reported before. - **Grid Power now says whether it is really measuring the grid**, through a new `at_service_entrance` attribute: false where a battery or another panel sits between the utility and your main lugs, which is when it and **Grid Power Flow** legitimately disagree. +- **The attribute is present only where your panel publishes the topology it is read from** — the parent/child data model — so a panel on the flat one has no + `at_service_entrance` row at all rather than a `true` that nothing measured. - **Your solar inverter gets a device of its own** rather than rendering as diagnostic sensors on the panel's card; the five PV entities keep the entity ids, unique ids and history they have today. @@ -65,6 +67,13 @@ working entirely in 2027.8. up at any time; one now gets a card of its own hanging off the panel, with whatever it publishes as entities beneath it, all disabled and diagnostic. - **A property an adopted device accepts writes to becomes a control**: a boolean becomes a switch, an enum a select, a bounded number a number entity, constrained to what the device declared and nothing invented. +- **A number box appears only where the declared range can actually be read** — a range this integration cannot parse, one whose bounds are the wrong way round, + or one with a zero step surfaces as a plain reading instead of a control admitting no value; if you ran a 2.1.0 beta and a reversed range gave you a `number.` + entity that never worked, you get a sensor in its place. +- **Two vendor readings that would land on the same entity id no longer silently cost you one of them**: the lexically first wire path keeps the id and the + other is named in a warning in the log, rather than being dropped by Home Assistant under an id you cannot map back to anything. +- **A vendor device or reading that turns up hours after setup reaches you on its own**, through the same reload a new battery or charger triggers, instead of + waiting for the next restart. - **Nothing adopted enters long-term statistics** — `state_class` is not declared on the wire and a wrong guess writes corrupt statistics; wrap an adopted reading in a template sensor or utility meter if you want them. - **A new reading a vendor adds to a device you already have now appears too**, on the card that device already has — the battery, the Microgrid Interconnect @@ -76,6 +85,8 @@ working entirely in 2027.8. which is which. - **Deleting one hides it until the next reload while your panel is still publishing it, and removes it for good once your panel stops.** There is no setting to suppress one, because the delete button already does both jobs, decided by what your panel is actually sending. +- **At most 60 vendor readings are adopted per device**, a backstop against a misbehaving publisher rather than a policy on a real one, and the notification + naming what was declined is now raised once and stays dismissed until that set changes. - **Your panel's own card shows what the panel says it is** — manufacturer, model and hardware revision read from the enclosure rather than assumed, once your panel publishes them. @@ -100,6 +111,8 @@ working entirely in 2027.8. setting only when an entity is first created. - **Your other panel readings are unaffected**, checked against a capture from a live upgraded panel rather than assumed. - **New entities are now announced in a notification** that splits what arrived into ready to use and switched off and names each one. +- **A damaged record of what has already been announced is re-seeded rather than failing setup**, so a truncated or hand-edited store costs you one repeated + notification instead of an integration that will not start. - **The Wi-Fi network name moved to the Wi-Fi Link sensor** as a `wifi_ssid` attribute, absent rather than blank on a panel that publishes no SSID. - **It is no longer an attribute of the Software Version sensor**, so a template reading `state_attr('sensor.span_panel_software_version', 'wifi_ssid')` should point at the Wi-Fi Link binary sensor instead. @@ -128,11 +141,20 @@ working entirely in 2027.8. — so a template or a voice alias matching the old exact string needs updating. - **A name you set in an entity's own settings still outranks the panel's**, and this integration no longer writes that field, clearing only the value an older release of its own put there. +- **Renaming a circuit in the SPAN app now reaches the displayed name through a reload**, which the integration requests for itself when it sees the new name — + the name travels as the entity's original name, which is written when the entity is created, so a rebuild is what refreshes it. ### Fixed - **Energy dip compensation no longer books a dip that never happened** (#259), where a partial reading at startup looked like a counter reset and every restart added each circuit's whole lifetime counter to its offset. +- **A dip that comes back to where it started is still taken back after it looked corroborated**, because one reading climbing from the new low base is no + longer final: the compensation stays provisional for three further readings, so a debounced burst of stale samples cannot leave a permanent step in your + energy statistics. +- **The notification about a compensated dip now arrives when the dip settles** rather than the moment it looks corroborated, so you are no longer told about a + counter reset that the next reading undoes — a persistent notification cannot be taken back the way the offset can. +- **A panel this integration can no longer reach makes its entities unavailable instead of reporting 0 W** — a certificate authority that changes mid-session + used to leave every power sensor reading zero and every energy sensor frozen, indefinitely and indistinguishably from a panel drawing no power. - **Recreate entity IDs proposes the ids your panel would produce now** (#252), in your installation's naming style, leaving unique ids and statistics untouched. - **The registry `Name` an older release wrote on circuit-numbers installations is handed back to you** at the first start after upgrading, for every label @@ -160,18 +182,40 @@ working entirely in 2027.8. connection and keeps the authority afterwards. - **Setting up or moving a panel to a hostname works with pinning on**, because the panel is set up over the address its certificate already names and the hostname is verified after the panel has added it. +- **A pinned entry is never left holding an address its own certificate does not name**, which would have produced an entry unable to reach the panel it was + just set up against, with no flow left to correct it. +- **Continuing past a failed domain registration now keeps the panel rather than the name**, recording the address that got that far and claiming no registered + domain, so the entry works and the domain can be tried again from **Reconfigure** once whatever blocked it is fixed. +- **An entry upgraded from before pinning checks the authority it fetched against the certificate the panel serves on the broker port before storing it**, so a + reverse proxy or anything else answering that fetch cannot become the authority the integration then trusts forever. +- **The same check now guards the repair that asks you to accept a changed authority**, and a value that fails it is refused rather than offered to you as a + fingerprint to approve. +- **An mDNS announcement or a re-added panel only moves a pinned entry's address when the new address serves a certificate that entry's authority validates** — + previously any device on your network could claim your panel's serial and have the entry pointed at itself, after which the changed-authority repair invited + you to accept the impostor's fingerprint. - **A changed authority stops the integration and raises a repair** carrying both fingerprints, because a legitimate rotation and an impersonated panel look identical from here. +- **`span_panel.rotate_credentials` asks which panel when more than one is loaded** instead of rotating the first it finds, since the previous broker password + stops working for every other local client of whichever panel it picked. +- **It reports a reload that did not come back** rather than logging success, and says the new password was already stored so nobody rotates a second time and + invalidates the one the panel has accepted. +- **It refuses to run at all when the stored authority cannot be read**, rather than carrying the access token out and the new broker password back over an + unverified connection; repair the authority first. - **Four new options decide who may operate the panel** — administrators only, no commands without a logged-in user, an admin-only control lock, and a relay debounce — every one defaulting to what your panel already does. +- **`Administrators only` does not cover an automation a non-admin triggered**, because Home Assistant gives an automation a fresh context with no user on it + and there is then nothing to hold to the admin test; switch off **Allow control without a logged-in user** if you want those refused too. +- **Choosing `Nobody` now refuses control commands as well as creating no controls**, so the option is enforced where commands are published rather than only by + the absence of entities to press. - **The control lock arrives armed and stays where you left it**, so turning the option on locks control straight away and a restart or a settings save no longer quietly unlocks the panel; a pending auto-relock comes back with the time it had left rather than starting its countdown over. - **A control lock whose pending countdown cannot be read comes back armed** rather than opening a fresh window nobody asked for — which is what a record written by an older release looks like on the first restart after upgrading. - **The relay debounce counts only commands the panel was actually sent**, so a breaker command the broker never took no longer refuses your immediate retry with "operated less than 2 seconds ago". -- **A circuit the panel stops letting you operate loses its breaker switch and its priority control** on the next update rather than at the next restart, - instead of leaving you a control the panel refuses every press of. +- **A circuit the panel starts or stops letting you operate gains or loses its breaker switch and its priority control on the next reload**, which the + integration asks for as soon as it notices — both directions, rather than leaving you a control the panel refuses every press of or no control where the panel + now allows one. - **Every control command is now recorded** in the logbook, on the event bus as `span_panel_control_command`, and at `INFO`, attributed to the automation that issued it rather than to nobody. - **Every control now reports what happened to it**, adopted devices' controls included, so a command that was refused or never handed to the broker no longer @@ -179,6 +223,20 @@ working entirely in 2027.8. - **A new [Security](README.md#security) section in the README** covers what the integration stores, what rotation costs, and the deployment choices that actually bound the panel's exposure. +### Known issues + +- **A panel reachable only by a single-label name cannot complete a pinned setup by that name** — the SPAN simulator add-on and anything else whose certificate + names only its IP address — because nothing registers a name that is not a fully qualified domain, so the certificate never comes to name it; set the panel up + by its IP address instead. +- **An upgraded entry whose broker port does not serve a certificate the panel's own authority signs stays unpinned** — a proxy terminating TLS with a + certificate of its own, say — logging a warning at every start and raising no repair; reconfigure the entry so it names the panel directly, which pins it + through the setup flow. +- **A discovered address this entry's authority rejects is refused with a log warning and nothing else** — if the panel really has moved and its certificate + really has changed, use **Reconfigure** on the entry to set the new address up under a fresh check. +- **A panel discovered through the Supervisor is deliberately not held to the address check** the other discovery routes are, because a Home Assistant add-on + arrives over the authenticated Supervisor API and legitimately reallocates its own ports; an add-on with Supervisor privileges can therefore move a pinned + entry. + ## [2.0.8] - 5/2026 ### Fixed diff --git a/README.md b/README.md index ece5cd89..d925b4a2 100644 --- a/README.md +++ b/README.md @@ -595,8 +595,8 @@ The integration provides flexible entity naming patterns, configured during init - Entity IDs use descriptive circuit names from your SPAN panel - Example: `sensor.span_panel_kitchen_outlets_power` - - Renaming a circuit in the SPAN app updates the displayed name automatically; the entity ID changes only if you accept the offer from **Recreate entity - IDs** + - Renaming a circuit in the SPAN app updates the displayed name automatically — the integration reloads itself to pick the new name up, since it travels as + the entity's original name and only a rebuild refreshes it; the entity ID changes only if you accept the offer from **Recreate entity IDs** - More intuitive for automations and scripts 2. **Circuit Numbers** (Stable entity IDs) @@ -632,10 +632,18 @@ A dip is compensated as soon as it is seen, but not believed straight away. A co so a reading that drops and then returns to where it was is a transport artifact rather than a reset, and its offset is taken back. The offset stays provisional until a later reading either disproves it (the counter comes back) or corroborates it (the counter climbs from the new, lower base). -**The persistent notification therefore lists a dip once it is corroborated, one reading after it is seen, and a dip that is disproved produces no notification -at all** — the sensor was compensated the whole time and nothing needs your attention. Seeing no notification after a momentary dip is the feature working, not +**Corroboration is evidence, not a verdict.** A debounced burst can deliver two stale samples with the later one higher, which looks exactly like a counter +climbing away from a fresh base, so a corroborated dip stays provisional for three further readings; a return to the original baseline inside that window still +takes the whole offset back. Once the window is spent the offset is final. A second dip arriving inside another's window is judged against its own baseline +rather than folded into the older one, so each is retracted or kept on its own evidence. + +**The persistent notification therefore lists a dip once it settles — when that window closes — and a dip that is disproved produces no notification at all** — +the sensor was compensated the whole time and nothing needs your attention. Reporting on corroboration alone left a notice standing for an event the next +reading undid, which a persistent notification cannot take back the way the offset can. Seeing no notification after a momentary dip is the feature working, not failing. +Both the provisional record and its remaining window survive a restart, so restarting neither loses a retraction nor renews the window it had left. + **Diagnostic attributes** (visible when compensation is active): | Attribute | Description | @@ -701,15 +709,40 @@ to compare against. See the certificate-authority-changed entry in Troubleshooti shows an error and stops. Submitting the form again retries the fetch, which is what a panel that was briefly unreachable needs. An opt-out would quietly restore the plaintext credential exchange this ordering exists to remove, at the moment you are least likely to weigh it. +**Re-authenticating an entry that has no anchor acquires one first.** Reauth is a registration, so it carries the passphrase out and the broker password back — +the same exchange setup performs. An entry that arrived from before pinning, or one whose stored authority will no longer load, goes through the +certificate-authority step before either sign-in method is offered, and keeps the anchor afterwards instead of falling back to a plaintext fetch on every +connection. + +**Setting a panel up by hostname works, and the hostname is verified.** A panel's certificate names the addresses it already knows itself by; a domain joins +that list only when the integration asks the panel to regenerate its certificate, which happens after you have authenticated. Everything before that runs +against the address the certificate already names, hostname verification never relaxed, and the domain is checked once the panel reports the new certificate. +Only a fully qualified domain is registered, so a single-label name — the hostname an add-on or the simulator is reached by — never joins the certificate, and a +name the certificate does not already carry cannot be stored on a pinned entry; set that panel up by its address instead. + +**A pinned entry follows its panel to a new address only when the new address proves itself.** An entry moves when your panel announces itself over mDNS at a +new address, and when you add the same panel again by hand. Both used to be decided by a serial read from an unauthenticated endpoint, which anything on your +network can claim. The entry now asks whether the candidate serves a certificate its own anchor validates, on the port the entry uses, and refuses the move — +logging at `WARNING` — otherwise. If the panel really has moved and its certificate really has changed, use **Reconfigure** on the entry. + +A panel that arrives through the **Supervisor** — a Home Assistant add-on — is the one exception to that check, and deliberately so: it announces itself over +the authenticated Supervisor API and legitimately reallocates its own ports, so holding it to the stored address would freeze the entry. An add-on already +holding Supervisor privileges can therefore move a pinned entry. + **Panels configured before this release are pinned differently, and are not risk-free until they are.** They are pinned on the first startup that reaches the -panel, logged at `WARNING` with the fingerprint so you can find the value afterwards. Until that succeeds, the connection to the broker still authenticates with -your panel's password while the authority is re-fetched over plaintext HTTP on every connection and whatever answers is trusted — the substitution pinning -exists to close. +panel, logged at `WARNING` with the fingerprint so you can find the value afterwards. What was fetched is checked against the certificate the panel serves on +the broker port — the connection the anchor is actually used for — and an authority that signs nothing the panel serves is not stored. Until a pin succeeds, the +connection to the broker still authenticates with your panel's password while the authority is re-fetched over plaintext HTTP on every connection and whatever +answers is trusted — the substitution pinning exists to close. If the panel is unreachable the integration starts anyway and retries on the next startup. That is deliberate. The exposure in the meantime is exactly the one the entry already had before this release, and refusing to start would not remove it — it would only remove the integration, leaving the credential no safer while guaranteeing an outage. Retrying closes it at the first opportunity instead. +The same is true of an entry whose broker port does not serve a certificate the panel's own authority signs — a proxy terminating TLS with a certificate of its +own, say. It stays unpinned and logs that warning at every start. Reconfiguring the entry so it names the panel directly is what pins it, because the setup flow +performs the check with somewhere to send you when it fails. + Diagnostics report the fingerprint under `panel_ca`. The certificate itself is not included — it is public, but multi-KB, and the fingerprint is the part worth reading. @@ -731,15 +764,22 @@ nothing until you choose otherwise. **Why `Administrators only` is worth setting.** Home Assistant's default user policy grants every non-admin user control of every entity. Until you change this, a dashboard-only household member can open any breaker in the house. +**`Administrators only` does not reach an automation a non-admin triggered.** It checks the user a command arrived with, and Home Assistant gives an automation +a fresh context with no user on it — the triggering context is recorded as the parent, not carried through — so an automation a non-admin set off publishes with +nothing for the admin test to hold. (A script is different: run by a person it keeps that person's user, and run from an automation it inherits the automation's +userless one.) The option that covers those callers is **Allow control without a logged-in user**; the two are separate settings because most households want +their automations to keep working. Set both if you want control limited to administrators acting in person. + **What none of this defends against.** These options constrain callers arriving through Home Assistant. They do not constrain anything that already holds the broker credential — a second Home Assistant instance, a script you wrote, or a malicious custom integration running inside this same Home Assistant process, which reads the credential straight out of memory. For that, see [Recommended deployment](#recommended-deployment) below; network topology and a locked enclosure are the real boundary. **Nothing is deleted when you choose `Nobody`.** The control entities stop being created and read as unavailable; their registry entries, names, areas and -customizations are kept, so turning the option back on restores exactly the entities you had. Dashboards and automations referencing them will show them -unavailable in the meantime — including the shipped SPAN Panel card, whose toggles call `switch.turn_on` and `select.select_option` directly and have no -card-side message for a refusal. A non-admin using that card under `Administrators only` gets a refusal with no explanation on the card. +customizations are kept, so turning the option back on restores exactly the entities you had. The option is also enforced where commands are published, not only +by the absence of entities, so a command reaching the panel by any other route is refused rather than sent. Dashboards and automations referencing them will +show them unavailable in the meantime — including the shipped SPAN Panel card, whose toggles call `switch.turn_on` and `select.select_option` directly and have +no card-side message for a refusal. A non-admin using that card under `Administrators only` gets a refusal with no explanation on the card. ### The record of what was commanded @@ -768,6 +808,10 @@ data: {} Run it after a contractor visit, a suspected credential exposure, or any event that put someone else in front of the panel. +**Name the panel when you have more than one.** The `config_entry_id` field is optional with a single panel loaded and required beyond that: rotating stops the +previous broker password working for every other local client of whichever panel it ran against, so the action refuses an ambiguous call rather than picking +one. + **Know the blast radius before you run it.** The previous broker password stops working the moment the panel issues the new one, so every other local client using it — a second Home Assistant instance, a script, third-party tooling — must be re-provisioned from the panel before it will reconnect. This integration re-provisions itself automatically; nothing else does. The panel access token and the panel passphrase are not changed. @@ -776,6 +820,13 @@ Only a Home Assistant administrator can run it, and it cannot be called from an outright. If the panel rejects the stored access token, reauthenticate the integration first — the existing credentials are left untouched on every failure path. +**It will not run over an unverified connection.** The rotation carries the access token out and the new broker password back, so an entry whose stored +authority cannot be read is refused with nothing changed rather than falling back to plaintext; repair the panel's certificate authority and run it again. An +entry that was never pinned has no anchor to fall back from and keeps the connection it has always used. + +**If the reload afterwards fails, do not rotate again.** The new password is stored and the panel has already accepted it, so a second rotation would invalidate +the one that works. The action reports the failure and says so; check the log and reload the integration. + ### Recommended deployment The panel credential is a single all-or-nothing secret, and anyone standing at the panel can mint a fresh one with three presses of the door switch. Network diff --git a/developer.md b/developer.md index 5d1f70e7..ff4eb85c 100644 --- a/developer.md +++ b/developer.md @@ -444,35 +444,41 @@ This closes the question of whether to go verbatim everywhere. The migration mec `entity_id`s — `sensor.span_panel_kitchen_instantPowerW`, breaking every user reference — or a decoupling of the two, which throws away the consistency the helper exists to provide. Closing the mapping gets the whole benefit for none of that. -## Settability is read at setup, and deliberately not re-read +## Settability is re-read, and the reader is the coordinator -Which curated control entities exist is decided once, in each platform's `async_setup_entry`, from what the panel declares about each circuit at that moment. -`switch` gates on `is_user_controllable`; `select` gates on that and on `is_never_backup`, which is a separate commissioning flag — a never-backup circuit has a -working relay and a priority the panel pins. +Which curated control entities exist is decided in each platform's `async_setup_entry`, from what the panel declares about each circuit. `switch` gates on +`circuit_has_a_breaker_switch`; `select` gates on `circuit_has_a_priority_select`, which additionally excludes `is_never_backup` — a separate commissioning +flag, since a never-backup circuit has a working relay and a priority the panel pins. -SPAN documents that re-commissioning a circuit in place cycles that child device's `$state` and republishes its `$description` with a new `$settable`. So a -circuit's settability can change while the integration is running, in either direction, and the setup-time gate cannot see it: +Both predicates live in `helpers.py` rather than in their platforms, because two callers read them and a second copy would drift invisibly: a switch left on the +dashboard offering a control the panel refuses, or a circuit the panel has commissioned that never grows the switch it earned. `helpers.py` is also the only +place that sits below all three: `switch.py` and `select.py` import the coordinator, so the predicate they share with it cannot live in either of them. -- **A circuit that stops being controllable keeps its entity.** The entity outlives its own controllability and every write it attempts now fails. -- **A circuit that becomes controllable gets no entity.** Nothing is watching it, because no entity exists to watch, so this direction cannot be noticed from an - entity at all. +SPAN documents that re-commissioning a circuit in place cycles that child device's `$state` and republishes its `$description` with a new `$settable`, so a +circuit's settability can change while the integration is running, in either direction. `SpanPanelCoordinator._check_settability_change` watches both from one +place, on every push: -Both are safe, and the first is now well behaved. `span-panel-api` refuses a circuit relay or priority command for a circuit the panel declares non-settable, -raising rather than publishing, and each control reports that refusal as a translated error on the control the user touched. Nothing is silently published and -nothing is silently swallowed. The remedy for both directions is a reload. +- **A circuit that stops being controllable** can be seen from its own entity — which is where this used to be watched, and it is only one of the two edges. +- **A circuit that becomes controllable has no entity to see it with**, so only a reader over every circuit can catch that edge at all. That is the coordinator, + and putting both edges there keeps one answer rather than two half-answers. -**Nothing re-evaluates settability at runtime, and the reason is not the one to expect.** Permanent entity IDs are not the obstacle: the entity registry maps -`unique_id` to `entity_id`, so a reload gives every entity back the ID it had, both platforms already leave the registry entries of controls they do not create -in place, and `coordinator.request_reload()` is already used when a circuit is renamed. The mechanism is available and precedented. +`_read_settability` builds `{circuit_id: (switch?, select?)}` from the same two predicates the platforms gate on, and a difference against the previous reading +calls `request_reload()`. A reload rather than a quiet availability change, because under the new answer the entity should not exist, or should exist and does +not, and creating and removing entities is what `async_setup_entry` is for. Permanent entity IDs are not an obstacle: the registry maps `unique_id` to +`entity_id`, so a reload gives every entity back the ID it had, and both platforms already leave the registry entries of controls they do not create in place. -The obstacle is the signal. SPAN reports a firmware defect in which the `$settable` re-toggle on the runtime re-commissioning path is skipped until the service -restarts — which is why the library refuses when _either_ the declaration or the `relay-controllable` value says no. Driving an entry-wide reload off a flag the -publisher warns can be stale would tear down every entity in the entry, and with them the grace-period, energy-dip and current-monitor state, on a value that -may not be true yet. An entity that exists and cleanly explains why it cannot act is a better answer than one that vanishes and reappears. +Two details worth keeping: -**If this is ever built, build it as a Repair rather than as a reload.** `schema_repairs` is the module for something re-derived from live state on every -refresh: raise a Repair naming the circuit that changed, touch no entity, and let the user choose when to reload. Waiting is cheap, and worth doing until a -panel is actually observed changing a circuit's settability in the field — so far that behaviour is documented by SPAN rather than seen here. +- **Only circuits present in _both_ readings are judged.** A circuit can drop out of a snapshot and come back — the platforms guard for exactly that — and + counting an absence as a settability change would turn a flap into a reload loop. Membership is still carried forward, so a circuit that leaves and returns + with a different answer is caught on its return. +- **The baseline is updated on the same pass that asks for the reload**, so a settled panel costs one dict comprehension per push and a changed one asks exactly + once. + +The library is the backstop under all of this: `span-panel-api` refuses a circuit relay or priority command for a circuit the panel declares non-settable, +raising rather than publishing, and each control reports that refusal as a translated error on the control the user touched. SPAN also reports a firmware defect +in which the `$settable` re-toggle on the runtime re-commissioning path is skipped until the service restarts, which is why the library refuses when _either_ +the declaration or the `relay-controllable` value says no. A stale flag therefore costs a reload rather than a wrong control. ## Adopting a device this integration models nothing for @@ -545,8 +551,14 @@ exactly the collision the extension grammar above avoids by carrying the path ve The encoding does not change. An adopted `unique_id` is as permanent as a curated one, so adopting the injective form would move every id an install already holds and strand the entities keyed on them, with nothing to migrate them back. The collision is handled where the entities are built instead: `_create` claims -one id per platform, the first property to reach an id keeps it, and the second is skipped with a WARNING naming both wire paths. Handing both to Home Assistant -registers the first and drops the second permanently, with one core log line naming an id the user cannot map back to anything on the wire. +one id per platform, the **lexically first wire path** keeps it, and the other is skipped with a WARNING naming both. Handing both to Home Assistant registers +one and drops the other permanently, with one core log line naming an id the user cannot map back to anything on the wire. + +**Lexically first, not first to arrive.** `_create` sorts each device's properties by wire path, so the survivor is a function of the declarations alone. +Picking by adapter emission order would have tracked the wire: a firmware update declaring the two colliding properties the other way round would then have +changed what a standing entity reads, silently, since the `unique_id` does not move and nothing here migrates it. Registry preference — how the extension cap +resolves its equivalent problem — is not available, because both properties resolve to the _same_ `unique_id` and the registry row cannot say which of them put +it there. The injective encoding belongs to whatever identity namespace comes next, and adopting it there is a maintainer's ruling rather than a fix. @@ -554,8 +566,9 @@ The injective encoding belongs to whatever identity namespace comes next, and ad Nothing here adds entities dynamically, so a vendor device or extension property that appears an hour after setup reaches the user through the capability reload and nothing else. `detect_capabilities` therefore carries one token per adopted device id and one per extension subject/path alongside the named flags, and -`_check_capability_change` fires on set _expansion_ exactly as it does for `bess` or `pcs`. One token each rather than one hash of the set: a hash of a shrinking -set is as "new" as a hash of a growing one, so a single token would reload the integration when a device left the tree and flap it on one that came and went. +`_check_capability_change` fires on set _expansion_ exactly as it does for `bess` or `pcs`. One token each rather than one hash of the set: a hash of a +shrinking set is as "new" as a hash of a growing one, so a single token would reload the integration when a device left the tree and flap it on one that came +and went. ### The proxy link is recorded, and the nesting is not built @@ -802,6 +815,30 @@ confidence, and the ranking is the argument: The real fix is upstream: a declared `role` on the property, proposed in `SpanPanel_Docs/span/docs/dev/ebus-property-role-proposal.md`. Until then the ranking is the shipping plan, and `entity_category` being free to revise is what makes a conservative default cheap. +## Runtime data lives in `runtime.py`, and one helper answers for it + +`SpanPanelRuntimeData` and `SpanPanelConfigEntry` used to live in `__init__.py`, which imports every platform — so naming the entry type meant importing the +whole integration. Seven modules imported them from the package root and five more reached for them from inside functions to break the cycle. They now live in +`runtime.py`, whose only runtime dependency inside this package is `control_gate` (itself a leaf); the coordinator is needed there as an annotation only. The +package root re-exports both names, so `from custom_components.span_panel import SpanPanelRuntimeData` keeps working, and every deferred import of them is a +top-level one again. + +**Use `services._loaded_runtime_data(entry)` rather than an inline `hasattr` / `isinstance` pair.** Every service here is registered domain-wide and walks +`async_loaded_entries(DOMAIN)`, so each one has to ask the same question before touching an entry, and this used to be five copies of it. Both halves of the +check are real, which is why the helper is written the way it is: `ConfigEntry.runtime_data` is a bare annotation that core _deletes_ on unload, so the +attribute is genuinely absent on an entry that has not finished setting up (hence `getattr` with a default), and what is there is whatever the owning +integration put there, so `isinstance` is what says it is ours. AGENTS.md service point 6 names this helper. + +## The Supervisor discovery path is deliberately unguarded + +A pinned entry only follows a discovered host when that host serves a certificate the entry's anchor validates — `async_step_zeroconf` and the by-hand re-add +both go through that check, because the serial they match on comes from an unauthenticated endpoint anything on the LAN can answer. + +`async_step_hassio` deliberately does not. A Supervisor discovery arrives over the authenticated Supervisor API from an add-on the user installed, and add-ons +legitimately reallocate their own ports, so applying the stored-address guard would freeze the entry against its own add-on. The cost is stated rather than +hidden: an add-on that already holds Supervisor privileges can move a pinned entry. If that trade is ever revisited, the guard is the same helper the other two +routes call, and README's Security section carries the user-facing note. + ## Linting and Type Checking ```bash diff --git a/prek.toml b/prek.toml index 1b734798..90994f8c 100644 --- a/prek.toml +++ b/prek.toml @@ -91,7 +91,7 @@ hooks = [ # Ruff for linting, import sorting, and primary formatting [[repos]] repo = "https://github.com/astral-sh/ruff-pre-commit" -rev = "v0.15.1" +rev = "v0.15.12" hooks = [ { id = "ruff-format", exclude = '^tests/.*|scripts/.*|\.\S*_cache/.*|dist/.*|venv/.*' }, { id = "ruff-check", args = ["--fix"], exclude = '^tests/.*|scripts/.*|\.\S*_cache/.*|dist/.*|venv/.*' }, diff --git a/pyproject.toml b/pyproject.toml index a4a364bc..504ef00b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span" -version = "2.1.0b6" +version = "2.1.0b16" description = "Span Panel Custom Integration for Home Assistant" authors = [{name = "SpanPanel"}] license = {text = "MIT"} From 0dd4e824c7d90f727d685a52899edad053ed83f0 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:22:48 -0700 Subject: [PATCH 185/345] docs: reauthenticate is the on-screen route to a pin The README and the changelog both told an upgraded entry that never pinned to use Reconfigure, "because the setup flow performs the check with somewhere to send you when it fails". It does not. An entry with no stored PEM skips the CA chooser at config_flow.py:1293 -- the leaf check there is gated on a pin the entry does not have -- and the non-FQDN branch writes the host and reloads. What pins it is `_async_pinned_ca` during that reload, silently, announced only by the "Pinned the CA advertised by..." WARNING. Reauthenticate is the route that does it on screen: an unpinned v2 entry goes to `async_step_panel_ca_start`, which asks for the TLS port where the HTTP port has been moved -- the proxy population this limitation is about -- and refuses with an error where the authority does not sign what the panel serves. Both places now say that, and say the reload is the quiet alternative rather than the documented one. Also, from the same review: the two dip bullets become one statement of what ships, since a bullet describing what an earlier beta did is beta-vs-beta narration; the reversed-range bullet drops its beta clause for the same reason; and "Known issues" becomes "Known limitations", which is what those four entries are. uv.lock carries the version line the dependency sync wrote after the pyproject bump in 152da89. --- CHANGELOG.md | 23 +++++++++++------------ README.md | 8 ++++++-- uv.lock | 2 +- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4962cc67..0ba9ac2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,8 +68,7 @@ working entirely in 2027.8. - **A property an adopted device accepts writes to becomes a control**: a boolean becomes a switch, an enum a select, a bounded number a number entity, constrained to what the device declared and nothing invented. - **A number box appears only where the declared range can actually be read** — a range this integration cannot parse, one whose bounds are the wrong way round, - or one with a zero step surfaces as a plain reading instead of a control admitting no value; if you ran a 2.1.0 beta and a reversed range gave you a `number.` - entity that never worked, you get a sensor in its place. + or one with a zero step surfaces as a plain reading instead of a control admitting no value. - **Two vendor readings that would land on the same entity id no longer silently cost you one of them**: the lexically first wire path keeps the id and the other is named in a warning in the log, rather than being dropped by Home Assistant under an id you cannot map back to anything. - **A vendor device or reading that turns up hours after setup reaches you on its own**, through the same reload a new battery or charger triggers, instead of @@ -148,11 +147,10 @@ working entirely in 2027.8. - **Energy dip compensation no longer books a dip that never happened** (#259), where a partial reading at startup looked like a counter reset and every restart added each circuit's whole lifetime counter to its offset. -- **A dip that comes back to where it started is still taken back after it looked corroborated**, because one reading climbing from the new low base is no - longer final: the compensation stays provisional for three further readings, so a debounced burst of stale samples cannot leave a permanent step in your - energy statistics. -- **The notification about a compensated dip now arrives when the dip settles** rather than the moment it looks corroborated, so you are no longer told about a - counter reset that the next reading undoes — a persistent notification cannot be taken back the way the offset can. +- **A compensated dip stays provisional until it settles, and is only reported then**: it is compensated the moment it is seen, corroborated by a reading that + climbs from the new low base, held for three further readings during which the counter returning to where it started takes the whole offset back, and named in + a notification once that window closes — so a burst of stale samples cannot leave a permanent step in your energy statistics or a notice for an event that did + not happen. - **A panel this integration can no longer reach makes its entities unavailable instead of reporting 0 W** — a certificate authority that changes mid-session used to leave every power sensor reading zero and every energy sensor frozen, indefinitely and indistinguishably from a panel drawing no power. - **Recreate entity IDs proposes the ids your panel would produce now** (#252), in your installation's naming style, leaving unique ids and statistics @@ -223,16 +221,17 @@ working entirely in 2027.8. - **A new [Security](README.md#security) section in the README** covers what the integration stores, what rotation costs, and the deployment choices that actually bound the panel's exposure. -### Known issues +### Known limitations - **A panel reachable only by a single-label name cannot complete a pinned setup by that name** — the SPAN simulator add-on and anything else whose certificate names only its IP address — because nothing registers a name that is not a fully qualified domain, so the certificate never comes to name it; set the panel up by its IP address instead. - **An upgraded entry whose broker port does not serve a certificate the panel's own authority signs stays unpinned** — a proxy terminating TLS with a - certificate of its own, say — logging a warning at every start and raising no repair; reconfigure the entry so it names the panel directly, which pins it - through the setup flow. -- **A discovered address this entry's authority rejects is refused with a log warning and nothing else** — if the panel really has moved and its certificate - really has changed, use **Reconfigure** on the entry to set the new address up under a fresh check. + certificate of its own, say — logging a warning at every start and raising no repair. **Reauthenticate** is the route that does the check on screen: it asks + for the TLS port and refuses with an error if the authority does not sign what the panel serves. Reconfiguring the entry to the panel's own address instead + leaves the pin to the reload that follows, which happens silently — watch the log for "Pinned the CA advertised by…". +- **A discovered address this entry's authority rejects is refused with a log warning and nothing else** — if the panel really has moved, use **Reconfigure** on + the entry, which checks the new address against the standing pin before storing it. - **A panel discovered through the Supervisor is deliberately not held to the address check** the other discovery routes are, because a Home Assistant add-on arrives over the authenticated Supervisor API and legitimately reallocates its own ports; an add-on with Supervisor privileges can therefore move a pinned entry. diff --git a/README.md b/README.md index d925b4a2..f2902d44 100644 --- a/README.md +++ b/README.md @@ -740,8 +740,12 @@ the entry already had before this release, and refusing to start would not remov while guaranteeing an outage. Retrying closes it at the first opportunity instead. The same is true of an entry whose broker port does not serve a certificate the panel's own authority signs — a proxy terminating TLS with a certificate of its -own, say. It stays unpinned and logs that warning at every start. Reconfiguring the entry so it names the panel directly is what pins it, because the setup flow -performs the check with somewhere to send you when it fails. +own, say. It stays unpinned and logs that warning at every start, and no repair is raised for it. + +**Reauthenticate is the route that does this on screen.** An unpinned entry sent through **Reauthenticate** goes to the certificate-authority step first, which +asks for the TLS port where the HTTP port has been moved — the install most likely to be behind a proxy — and refuses with an error if the authority does not +sign what the panel serves. Reconfiguring the entry to the panel's own address is the other way through, but the pin then happens during the reload that +follows, silently: the only announcement is a `WARNING` reading "Pinned the CA advertised by SPAN panel …" with the fingerprint. Diagnostics report the fingerprint under `panel_ca`. The certificate itself is not included — it is public, but multi-KB, and the fingerprint is the part worth reading. diff --git a/uv.lock b/uv.lock index cd19b282..26d82e37 100644 --- a/uv.lock +++ b/uv.lock @@ -2491,7 +2491,7 @@ wheels = [ [[package]] name = "span" -version = "2.1.0b6" +version = "2.1.0b16" source = { virtual = "." } dependencies = [ { name = "homeassistant" }, From 4da0b7fcace6831c0143b2b0874ab308e1ea98b8 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:42:18 -0700 Subject: [PATCH 186/345] =?UTF-8?q?fix:=20close=20the=20final-review=20ite?= =?UTF-8?q?ms=20=E2=80=94=20reconfigure=20fails=20closed,=20the=20pin's=20?= =?UTF-8?q?two=20uses,=20and=20the=20sweep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment above the deferred pin claimed the anchor is used for one thing. It is used for two: the MQTTS session and, once stored, every REST call `panel_rest_transport` builds — reauth detection, reconfigure, FQDN registration and rotate_credentials, all on the HTTPS port. Rewrite it to say which port is verified and why, and state the cost of verifying one port while using the anchor on two: a proxy terminating only 443 pins at reload and then fails every REST path. Say the same in README's Security section and in the changelog's matching Known-limitations bullet. Reconfigure took the default plaintext fallback, so an entry whose stored PEM no longer parses was silently downgraded — `delete_fqdn` and `register_fqdn` then carried the access token over HTTP and `_async_verify_host_over_pin` returned early with nothing to verify against. It now fails closed the way rotate_credentials does: `allow_plaintext_fallback=False`, a new `ca_unusable` form error, and no REST call and no host write. Covered by a test using a genuinely unloadable PEM. Sweep: - `8883` literals in config_flow become DEFAULT_MQTTS_PORT. - `int(...)` on stored ports in `__init__` becomes `as_port`, which substitutes the documented default instead of raising out of setup on garbage. - The refused-host-move warning now says how to move the entry deliberately. - `async_step_hassio` carries a comment marking it the deliberately unguarded Supervisor path, mirroring developer.md. - `_check_settability_change` records the partial-retained-replay false positive: one spurious reload per library client rebuild, a 3.1.1 item. - `_pinned_fingerprint` and `repairs._fingerprint_or_reason` were the same function; they become `pem_fingerprint_or_reason` in config_flow_validation. - The two inline `hasattr(entry, "runtime_data")` pairs in the package root call the shared guard, which moves from `services` to `runtime` — a leaf that already owns the type, where `services` is inside an existing import cycle. - `_mark_transport_dead` logs at WARNING; the library and the CA-changed repair already report the same condition at ERROR. - The control-lock switch stays available on a dead transport. Arming is local, and core skips an unavailable entity in `switch.turn_on`, so inheriting the base class's transport probe left the lock unshuttable at the moment someone would most want it shut. --- AGENTS.md | 2 +- CHANGELOG.md | 11 ++- README.md | 4 + custom_components/span_panel/__init__.py | 60 ++++++++++----- custom_components/span_panel/config_flow.py | 73 ++++++++++++------- .../span_panel/config_flow_validation.py | 27 +++++++ custom_components/span_panel/coordinator.py | 22 +++++- custom_components/span_panel/entity.py | 7 +- custom_components/span_panel/repairs.py | 19 ++--- custom_components/span_panel/runtime.py | 26 +++++++ custom_components/span_panel/services.py | 34 ++------- custom_components/span_panel/strings.json | 1 + custom_components/span_panel/switch.py | 27 +++++++ .../span_panel/translations/en.json | 1 + .../span_panel/translations/es.json | 1 + .../span_panel/translations/fr.json | 1 + .../span_panel/translations/ja.json | 1 + .../span_panel/translations/pt.json | 1 + developer.md | 11 +-- tests/test_control_lock_state.py | 33 +++++++++ tests/test_schema_availability.py | 10 ++- tests/test_v2_config_flow.py | 59 +++++++++++++++ 22 files changed, 326 insertions(+), 105 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 421ace68..36cc115c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,7 +91,7 @@ forget action). Services that perform actions without returning data omit `suppo ### 6. Runtime Data Guards Handlers that access `entry.runtime_data` MUST verify the entry is loaded and its runtime data is the expected type before accessing coordinator or other -runtime objects. Call `services._loaded_runtime_data(entry)`, which answers `SpanPanelRuntimeData | None` — never inline the check and never assume runtime data +runtime objects. Call `runtime.loaded_runtime_data(entry)`, which answers `SpanPanelRuntimeData | None` — never inline the check and never assume runtime data is present. It reads the attribute with a default, because core deletes `runtime_data` on unload, and then narrows it with `isinstance`, because what is there is whatever the owning integration put there. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ba9ac2f..ccbaa07a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -148,9 +148,9 @@ working entirely in 2027.8. - **Energy dip compensation no longer books a dip that never happened** (#259), where a partial reading at startup looked like a counter reset and every restart added each circuit's whole lifetime counter to its offset. - **A compensated dip stays provisional until it settles, and is only reported then**: it is compensated the moment it is seen, corroborated by a reading that - climbs from the new low base, held for three further readings during which the counter returning to where it started takes the whole offset back, and named in - a notification once that window closes — so a burst of stale samples cannot leave a permanent step in your energy statistics or a notice for an event that did - not happen. + climbs from the new low base, held for three further readings during which the counter returning to where it started takes that dip's whole compensation back, + and named in a notification once that window closes — so a burst of stale samples cannot leave a permanent step in your energy statistics or a notice for an + event that did not happen. - **A panel this integration can no longer reach makes its entities unavailable instead of reporting 0 W** — a certificate authority that changes mid-session used to leave every power sensor reading zero and every energy sensor frozen, indefinitely and indistinguishably from a panel drawing no power. - **Recreate entity IDs proposes the ids your panel would produce now** (#252), in your installation's naming style, leaving unique ids and statistics @@ -229,7 +229,10 @@ working entirely in 2027.8. - **An upgraded entry whose broker port does not serve a certificate the panel's own authority signs stays unpinned** — a proxy terminating TLS with a certificate of its own, say — logging a warning at every start and raising no repair. **Reauthenticate** is the route that does the check on screen: it asks for the TLS port and refuses with an error if the authority does not sign what the panel serves. Reconfiguring the entry to the panel's own address instead - leaves the pin to the reload that follows, which happens silently — watch the log for "Pinned the CA advertised by…". + leaves the pin to the reload that follows, which happens silently — watch the log for "Pinned the CA advertised by…". The reverse arrangement — a proxy + terminating only port 443, so that the panel's own authority signs the broker's certificate but not the one served on the HTTPS port — does pin at that + reload, and every path that then talks to the panel over HTTPS (**Reauthenticate**, **Reconfigure**, **Rotate credentials**) refuses rather than sending your + token unencrypted, until port 443 also serves the panel's own certificate. - **A discovered address this entry's authority rejects is refused with a log warning and nothing else** — if the panel really has moved, use **Reconfigure** on the entry, which checks the new address against the standing pin before storing it. - **A panel discovered through the Supervisor is deliberately not held to the address check** the other discovery routes are, because a Home Assistant add-on diff --git a/README.md b/README.md index f2902d44..e831ef06 100644 --- a/README.md +++ b/README.md @@ -742,6 +742,10 @@ while guaranteeing an outage. Retrying closes it at the first opportunity instea The same is true of an entry whose broker port does not serve a certificate the panel's own authority signs — a proxy terminating TLS with a certificate of its own, say. It stays unpinned and logs that warning at every start, and no repair is raised for it. +The reverse arrangement has the opposite cost. Where a proxy terminates only port 443, the panel's own authority still signs the broker's certificate, so the +entry pins at the reload — and the anchor is then also what every REST call to the panel is verified against. **Reauthenticate**, **Reconfigure** and **Rotate +credentials** all refuse, rather than send your access token unencrypted, until port 443 serves the panel's own certificate too. + **Reauthenticate is the route that does this on screen.** An unpinned entry sent through **Reauthenticate** goes to the certificate-authority step first, which asks for the TLS port where the HTTP port has been moved — the install most likely to be behind a proxy — and refuses with an error if the authority does not sign what the panel serves. Reconfiguring the entry to the panel's own address is the other way through, but the pin then happens during the reload that diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py index 9fa83b0f..6b4c2c7c 100644 --- a/custom_components/span_panel/__init__.py +++ b/custom_components/span_panel/__init__.py @@ -78,6 +78,7 @@ from .runtime import ( SpanPanelConfigEntry as SpanPanelConfigEntry, SpanPanelRuntimeData as SpanPanelRuntimeData, + loaded_runtime_data, ) from .schema_repairs import ( async_clear_retired_new_entity_notices, @@ -184,18 +185,30 @@ async def _async_pinned_ca( ) return None - # The broker's port, not the panel's HTTPS port. This anchor is only ever - # used for one thing -- the MQTTS session the coordinator opens -- so that - # is the certificate it has to be checked against, and the port is on every - # v2 entry by construction: setup refuses one without it a few lines above, - # and the same value is handed to `MqttClientConfig` below. `https_port` was - # the wrong choice for the entries that actually reach here. It arrived in - # the same commit as `panel_ca_pending`, so an entry carrying the flag is by - # definition one migrated from before either existed and can never hold a - # port: every such check ran against 443, and a pre-pinning install whose - # TLS lives elsewhere -- behind NAT, a port forward, a proxy -- would have - # been refused on every setup and left on the unauthenticated refetch path - # for good, which is the one population this deferred pin exists for. + # The broker's port, not the panel's HTTPS port. The anchor has two uses, + # and this checks it against one of them: the MQTTS session the coordinator + # opens below, and -- once it is stored -- the REST transport that + # `panel_rest_transport` builds on the HTTPS port for reauth detection, + # reconfigure, FQDN registration and `rotate_credentials`. The broker's is + # the connection this pin was introduced for, and the only one every v2 + # entry carries a port for: setup refuses an entry without + # `CONF_EBUS_BROKER_PORT` a few lines above, and the same value is handed to + # `MqttClientConfig` below. + # + # `https_port` was the wrong port to verify on for the entries that actually + # reach here. It arrived in the same commit as `panel_ca_pending`, so an + # entry carrying the flag is by definition one migrated from before either + # existed and can never hold a port: every such check ran against 443, and a + # pre-pinning install whose TLS lives elsewhere -- behind NAT, a port + # forward, a proxy -- would have been refused on every setup and left on the + # unauthenticated refetch path for good, which is the one population this + # deferred pin exists for. + # + # Verifying one port and using the anchor on two has a cost, and it is + # stated rather than hidden: a CA that signs the broker's leaf but not + # whatever answers on 443 -- a proxy terminating only the HTTPS port -- + # pins here and then fails every REST path, which the callers that carry a + # secret refuse rather than downgrade. README and the changelog say so. # # One asymmetry to know about: `async_ca_signs_panel_leaf` accepts a leaf # reached at the address `host` resolves to, while the bridge dials `host` @@ -294,7 +307,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> host, ) - panel_http_port = int(config.get(CONF_HTTP_PORT, 80)) + # `as_port` rather than `int()`: `entry.data` round-trips through + # JSON and is typed `Any` at this boundary, so a hand-edited + # `.storage` can put anything here, and `int("http")` would raise + # out of setup where the pin site a few lines above quietly falls + # back to the documented default. Same treatment, same answer. + panel_http_port = as_port(config.get(CONF_HTTP_PORT), 80) # Before the client is built, because the pin is a constructor # argument: supplying it is what stops the bridge fetching a CA over @@ -305,7 +323,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> broker_host=host, username=config[CONF_EBUS_BROKER_USERNAME], password=config[CONF_EBUS_BROKER_PASSWORD], - mqtts_port=int(config[CONF_EBUS_BROKER_PORT]), + mqtts_port=as_port(config[CONF_EBUS_BROKER_PORT], DEFAULT_MQTTS_PORT), ca_pem=ca_pem, ) @@ -506,10 +524,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) - if not unload_ok: return False - if hasattr(entry, "runtime_data") and entry.runtime_data is not None: - if entry.runtime_data.coordinator.current_monitor is not None: - entry.runtime_data.coordinator.current_monitor.async_stop() - await entry.runtime_data.coordinator.async_shutdown() + runtime_data = loaded_runtime_data(entry) + if runtime_data is not None: + if runtime_data.coordinator.current_monitor is not None: + runtime_data.coordinator.current_monitor.async_stop() + await runtime_data.coordinator.async_shutdown() return True @@ -541,10 +560,11 @@ async def async_remove_config_entry_device( The main panel device cannot be removed — only sub-devices (like EVSE chargers) that are no longer present can be removed by the user. """ - if not hasattr(config_entry, "runtime_data") or config_entry.runtime_data is None: + runtime_data = loaded_runtime_data(config_entry) + if runtime_data is None: return True - coordinator = config_entry.runtime_data.coordinator + coordinator = runtime_data.coordinator snapshot = coordinator.data # Identify the main panel device identifier diff --git a/custom_components/span_panel/config_flow.py b/custom_components/span_panel/config_flow.py index ab4f6a99..41227c62 100644 --- a/custom_components/span_panel/config_flow.py +++ b/custom_components/span_panel/config_flow.py @@ -43,6 +43,7 @@ process_general_options_input, ) from .config_flow_validation import ( + PanelCaUnusableError, PanelRestTransport, as_port, async_fetch_panel_ca, @@ -51,6 +52,7 @@ check_fqdn_tls_ready, is_fqdn, panel_rest_transport, + pem_fingerprint_or_reason, port_or_none, validate_host, validate_v2_passphrase, @@ -69,6 +71,7 @@ CONF_PANEL_SERIAL, CONF_REGISTERED_FQDN, DEFAULT_HTTPS_PORT, + DEFAULT_MQTTS_PORT, DOMAIN, ENABLE_ENERGY_DIP_COMPENSATION, ENTITY_NAMING_PATTERN, @@ -344,33 +347,15 @@ async def _async_host_update(self) -> dict[str, Any] | None: _LOGGER.warning( "Refusing to move the entry for panel %s to %s: the certificate served there on " - "port %s does not chain to the authority this entry is pinned to (SHA-256 %s)", + "port %s does not chain to the authority this entry is pinned to (SHA-256 %s). " + "If the panel really has moved, use Reconfigure to move this entry", self.unique_id, host, tls_port, - self._pinned_fingerprint(str(ca_pem)), + pem_fingerprint_or_reason(ca_pem), ) return None - @staticmethod - def _pinned_fingerprint(ca_pem: str) -> str: - """Return the fingerprint of the anchor that refused a host, for the log line. - - The anchor's, not the certificate the refused host served. This is the - value the install logged and diagnostics reports, so it is the one a - user can compare something against — and reading the other one would - mean opening a second, deliberately unverified connection to a host this - code has just decided not to trust, to obtain a number with nothing to - compare it to. - - A PEM this system cannot read is not a reason to lose the warning, so it - degrades to a placeholder rather than raising. - """ - try: - return ca_fingerprint(ca_pem) - except SpanPanelValidationError: - return "unreadable" - async def async_step_zeroconf(self, discovery_info: ZeroconfServiceInfo) -> ConfigFlowResult: """Handle a flow initiated by zeroconf discovery.""" # Do not probe device if the host is already configured @@ -441,6 +426,19 @@ async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> ConfigFl on different HTTP ports (for example the SPAN Panel Simulator add-on). Deduplicate by panel serial, not by host, so each panel gets its own config entry. + + **This route is deliberately not held to the pinned-address check** that + `async_step_zeroconf` and the by-hand re-add both go through, in + `_async_host_update`. A Supervisor discovery arrives over the + authenticated Supervisor API from an add-on the user installed, and + add-ons legitimately reallocate their own ports, so applying that guard + would freeze the entry against its own add-on -- which is why the ports + go straight into the update below. The cost is stated rather than + hidden, here and in README's Security section: an add-on that already + holds Supervisor privileges can move a pinned entry. If the trade is + ever revisited, the guard is the same helper the other two routes call. + See developer.md, "The Supervisor discovery path is deliberately + unguarded". """ config = discovery_info.config host = str(config.get("host", "")) @@ -848,7 +846,7 @@ async def _async_register_fqdn_and_wait(self) -> None: ssl_context=transport.ssl_context, ) - mqtts_port = self._v2_broker_port or 8883 + mqtts_port = self._v2_broker_port or DEFAULT_MQTTS_PORT max_attempts = 30 for attempt in range(max_attempts): await asyncio.sleep(2) @@ -1281,8 +1279,31 @@ async def async_step_reconfigure( ) # Validate the host is reachable and is a v2 panel - http_port = int(reconfigure_entry.data.get(CONF_HTTP_PORT, 80)) - self._rest_transport = panel_rest_transport(self.hass, reconfigure_entry.data) + http_port = as_port(reconfigure_entry.data.get(CONF_HTTP_PORT), 80) + # Fail closed rather than downgrade, as `rotate_credentials` does and + # for the same reason: this flow carries the entry's access token to the + # panel -- `delete_fqdn` on one branch, `register_fqdn` on the other -- + # and `_async_verify_host_over_pin` has nothing to check the new name + # against without an anchor. A stored PEM that cannot be read would + # otherwise turn a pinned entry's reconfigure into a plaintext one that + # also skips its own host check, which is the pin undone twice over. The + # CA-changed repair and Reauthenticate are the routes back. + try: + self._rest_transport = panel_rest_transport( + self.hass, reconfigure_entry.data, allow_plaintext_fallback=False + ) + except PanelCaUnusableError as err: + _LOGGER.warning( + "The certificate authority stored for panel %s cannot be read (%s); " + "refusing to reconfigure it over an unverified connection", + reconfigure_entry.title, + err, + ) + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), + errors={"base": "ca_unusable"}, + ) # The new host may be a name the panel has never heard of, and this # entry is pinned: probing it by name would fail hostname verification # and report the panel unreachable. Settle the address first, so the @@ -1339,7 +1360,9 @@ async def async_step_reconfigure( # New host is FQDN — register it (replaces any existing FQDN on the panel) self.access_token = str(reconfigure_entry.data.get(CONF_ACCESS_TOKEN, "")) self._http_port = http_port - self._v2_broker_port = int(reconfigure_entry.data.get(CONF_EBUS_BROKER_PORT, 8883)) + self._v2_broker_port = as_port( + reconfigure_entry.data.get(CONF_EBUS_BROKER_PORT), DEFAULT_MQTTS_PORT + ) return await self.async_step_reconfigure_register_fqdn() # New host is not an FQDN — simple update. Nothing on this branch will diff --git a/custom_components/span_panel/config_flow_validation.py b/custom_components/span_panel/config_flow_validation.py index 4339e402..1f3e9df6 100644 --- a/custom_components/span_panel/config_flow_validation.py +++ b/custom_components/span_panel/config_flow_validation.py @@ -16,6 +16,7 @@ from span_panel_api import ( V2AuthResponse, build_panel_ssl_context, + ca_fingerprint, detect_api_version, download_ca_cert, register_v2, @@ -24,6 +25,7 @@ SpanPanelAPIError, SpanPanelConnectionError, SpanPanelTimeoutError, + SpanPanelValidationError, ) from .const import ( @@ -395,6 +397,31 @@ def as_port(value: object, default: int) -> int: return default if parsed is None else parsed +def pem_fingerprint_or_reason(pem: object) -> str: + """Name a stored anchor for a log line, without letting it raise into one. + + Every caller is a log line about a refusal, and each of them is naming the + anchor *already stored on the entry* — the value the install logged when it + pinned and the value diagnostics reports, so the one a user can compare + something against. Never the certificate the refused host served: reading + that would mean opening a second, deliberately unverified connection to a + host this code has just decided not to trust, for a number with nothing to + compare it to. + + `object` rather than `str` because that is what `entry.data` hands out. The + two ways a stored anchor can fail to name itself are answered rather than + raised, because a log line that cannot be written is worse than an imprecise + one: "none" for an entry that stores no anchor, "unreadable" for one whose + PEM this system cannot parse. + """ + if not isinstance(pem, str) or not pem: + return "none" + try: + return ca_fingerprint(pem) + except SpanPanelValidationError: + return "unreadable" + + class PanelCaUnusableError(ValueError): """An entry stores a panel CA that cannot be turned into a trust anchor. diff --git a/custom_components/span_panel/coordinator.py b/custom_components/span_panel/coordinator.py index 1e7afe16..437deeb3 100644 --- a/custom_components/span_panel/coordinator.py +++ b/custom_components/span_panel/coordinator.py @@ -253,9 +253,14 @@ def _mark_panel_offline(self, reason: Exception | str) -> None: def _mark_transport_dead(self, reason: Exception | str) -> None: """Record that the transport has failed terminally and tell the entities. - Logged at ERROR, once, because unlike an outage this one needs a - person: nothing here retries, and the Repair raised alongside it is the - only way back. + Logged at WARNING, once. This condition does need a person -- nothing + here retries, and the Repair raised alongside it is the only way back -- + but it is already reported at ERROR by the library that gave up on the + transport and again by `ca_repairs.async_raise_ca_changed`, which logs + at ERROR and raises an `IssueSeverity.ERROR` Repair. A third copy at + that level says nothing the first two did not. This line's job is + narrower: it records the coordinator's own transition, for whoever is + reading the log around those two. The fan-out matters on the fatal-callback path. That path is not an update cycle -- `last_update_success` is still True and no listener @@ -266,7 +271,7 @@ def _mark_transport_dead(self, reason: Exception | str) -> None: """ if self._transport_dead: return - _LOGGER.error( + _LOGGER.warning( "%s transport has stopped and will not recover on its own: %s", self.config_entry.title or "SPAN Panel", reason, @@ -689,6 +694,15 @@ def _check_settability_change(self, snapshot: SpanPanelSnapshot) -> None: -- and counting an absence as a settability change would turn a flap into a reload loop. Membership is still carried forward, so a circuit that leaves and returns with a different answer is caught on its return. + + One known false positive, upstream of here: a library client rebuilt + mid-session replays retained MQTT topics, and a replay that arrives + partial can present a circuit whose `relay-controllable` has not been + redelivered yet. The absent field reads as True for that one dispatch, + which looks like a settability change and costs one spurious reload per + rebuild. It is a library item (3.1.1), not one this reader can settle -- + a partial snapshot is indistinguishable here from a real change -- and + the reload debounce is what keeps the cost to one. """ current = self._read_settability(snapshot) if self._known_settability is None: diff --git a/custom_components/span_panel/entity.py b/custom_components/span_panel/entity.py index 22ffd5d4..ed8d9c4d 100644 --- a/custom_components/span_panel/entity.py +++ b/custom_components/span_panel/entity.py @@ -271,9 +271,10 @@ def available(self) -> bool: default; that needs the snapshot model to admit None. Also the availability of every entity that does not override this -- - the adopted and extension entities, the EVSE binary sensors and the - control-lock switch -- which is where the transport check earns its - place on the base class. + the adopted and extension entities and the EVSE binary sensors -- which + is where the transport check earns its place on the base class. The + control-lock switch is the one control that does override it, because + arming is local and stays answerable with no panel at the other end. """ if self._reads_an_unresolved_field: return False diff --git a/custom_components/span_panel/repairs.py b/custom_components/span_panel/repairs.py index 690a22e7..06467be7 100644 --- a/custom_components/span_panel/repairs.py +++ b/custom_components/span_panel/repairs.py @@ -23,7 +23,12 @@ import voluptuous as vol from .ca_repairs import CA_CHANGED_ISSUE_PREFIX -from .config_flow_validation import as_port, async_ca_signs_panel_leaf, async_fetch_panel_ca +from .config_flow_validation import ( + as_port, + async_ca_signs_panel_leaf, + async_fetch_panel_ca, + pem_fingerprint_or_reason, +) from .const import ( CONF_EBUS_BROKER_PORT, CONF_HTTP_PORT, @@ -36,16 +41,6 @@ _LOGGER = logging.getLogger(__name__) -def _fingerprint_or_reason(pem: object) -> str: - """Name a stored PEM for a log line, without letting it raise into one.""" - if not isinstance(pem, str) or not pem: - return "none" - try: - return ca_fingerprint(pem) - except SpanPanelValidationError: - return "unreadable" - - class PanelCAChangedRepairFlow(RepairsFlow): """Re-pin a panel's CA, but only on an explicit human acceptance. @@ -131,7 +126,7 @@ async def async_step_confirm( fingerprint, host, mqtts_port, - _fingerprint_or_reason(entry.data.get(CONF_PANEL_CA_PEM)), + pem_fingerprint_or_reason(entry.data.get(CONF_PANEL_CA_PEM)), ) return self.async_abort(reason="ca_leaf_mismatch") diff --git a/custom_components/span_panel/runtime.py b/custom_components/span_panel/runtime.py index cf703d37..a6a8942a 100644 --- a/custom_components/span_panel/runtime.py +++ b/custom_components/span_panel/runtime.py @@ -63,3 +63,29 @@ class SpanPanelRuntimeData: type SpanPanelConfigEntry = ConfigEntry[SpanPanelRuntimeData] + + +def loaded_runtime_data(entry: ConfigEntry) -> SpanPanelRuntimeData | None: + """Return this entry's runtime data, or None if it is not one of ours. + + Every service is registered domain-wide and walks + `async_loaded_entries(DOMAIN)`, so each of them has to answer this question + before touching an entry -- and so does anything handed an entry by core: + `async_unload_entry` after the platforms have gone, and + `async_remove_config_entry_device`. + + Both halves of the check are real. `ConfigEntry.runtime_data` is a bare + annotation that core deletes on unload (`config_entries.py:1044-1045`), so + the attribute is genuinely absent on an entry that has not finished setting + up -- hence `getattr` with a default rather than an attribute read. And what + is there is whatever the owning integration put there, so `isinstance` is + what says it is ours; a test double may put anything at all in it. + + One helper, one answer. It lives here rather than in `services` because + `services` is not a leaf and half the callers are not services: this module + already owns the type the check is against, and importing it costs nothing. + """ + runtime_data = getattr(entry, "runtime_data", None) + if isinstance(runtime_data, SpanPanelRuntimeData): + return runtime_data + return None diff --git a/custom_components/span_panel/services.py b/custom_components/span_panel/services.py index bfa56d0e..de1761ed 100644 --- a/custom_components/span_panel/services.py +++ b/custom_components/span_panel/services.py @@ -37,7 +37,7 @@ SPIKE_THRESHOLD_PCT, WINDOW_DURATION_M, ) -from .runtime import SpanPanelRuntimeData +from .runtime import SpanPanelRuntimeData, loaded_runtime_data _LOGGER = logging.getLogger(__name__) @@ -45,28 +45,6 @@ _DEVICE_TYPE_MAP: dict[str, str] = {"bess": "battery"} -def _loaded_runtime_data(entry: ConfigEntry) -> SpanPanelRuntimeData | None: - """Return this entry's runtime data, or None if it is not one of ours. - - Every service here is registered domain-wide and walks - `async_loaded_entries(DOMAIN)`, so each of them has to answer the same - question before touching an entry: does this entry carry runtime data of - ours? Both halves of that are real. `ConfigEntry.runtime_data` is a bare - annotation that core deletes on unload (`config_entries.py:1044-1045`), so - the attribute is genuinely absent on an entry that has not finished setting - up -- hence `getattr` with a default rather than an attribute read. And what - is there is whatever the owning integration put there, so `isinstance` is - what says it is ours; a test double may put anything at all in it. - - One helper, one answer: this used to be five copies of the same two lines, - each preceded by its own deferred import of the runtime-data type. - """ - runtime_data = getattr(entry, "runtime_data", None) - if isinstance(runtime_data, SpanPanelRuntimeData): - return runtime_data - return None - - def _async_register_services(hass: HomeAssistant) -> None: """Register domain-level services (called once per HA instance).""" @@ -86,7 +64,7 @@ async def async_handle_export_manifest( panels: list[JsonValueType] = [] for entry in hass.config_entries.async_loaded_entries(DOMAIN): - runtime_data = _loaded_runtime_data(entry) + runtime_data = loaded_runtime_data(entry) if runtime_data is None: continue @@ -222,7 +200,7 @@ def _get_runtime_data( Otherwise falls back to the first loaded entry. """ for entry in hass.config_entries.async_loaded_entries(DOMAIN): - runtime_data = _loaded_runtime_data(entry) + runtime_data = loaded_runtime_data(entry) if runtime_data is None: continue if config_entry_id is None or entry.entry_id == config_entry_id: @@ -405,7 +383,7 @@ def _get_horizon_manager( """Find the GraphHorizonManager for the given entry.""" entry_id = call.data.get("config_entry_id") for entry in hass.config_entries.async_loaded_entries(DOMAIN): - runtime_data = _loaded_runtime_data(entry) + runtime_data = loaded_runtime_data(entry) if runtime_data is None: continue if entry_id is None or entry.entry_id == entry_id: @@ -450,7 +428,7 @@ async def async_handle_get_graph_settings( ) -> ServiceResponse: entry_id = call.data.get("config_entry_id") for entry in hass.config_entries.async_loaded_entries(DOMAIN): - runtime_data = _loaded_runtime_data(entry) + runtime_data = loaded_runtime_data(entry) if runtime_data is None: continue if entry_id is None or entry.entry_id == entry_id: @@ -733,7 +711,7 @@ def _get_v2_entry(config_entry_id: str | None) -> ConfigEntry: for entry in hass.config_entries.async_loaded_entries(DOMAIN): if config_entry_id is not None and entry.entry_id != config_entry_id: continue - if _loaded_runtime_data(entry) is None: + if loaded_runtime_data(entry) is None: continue if entry.data.get(CONF_API_VERSION) != "v2": continue diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index 291b4a61..b6b3ca60 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -15,6 +15,7 @@ "error": { "ca_leaf_mismatch": "The certificate this panel serves is not signed by the authority it published. Check that the HTTPS port is correct and that nothing is intercepting the connection, then try again.", "ca_unavailable": "The panel's certificate authority could not be read. Check that the panel is reachable and powered on, then try again. Setup cannot continue without it, because your passphrase would otherwise be sent in the clear.", + "ca_unusable": "The certificate authority stored for this panel cannot be read, so this change would have to be sent unencrypted. Nothing has been changed. Reauthenticate the panel to store a usable authority, then try again.", "cannot_connect": "Failed to connect to Span Panel", "fqdn_registration_failed": "Could not register the domain name with the panel or the TLS certificate was not updated in time.", "host_required": "Host is required", diff --git a/custom_components/span_panel/switch.py b/custom_components/span_panel/switch.py index ab892d97..46ea5016 100644 --- a/custom_components/span_panel/switch.py +++ b/custom_components/span_panel/switch.py @@ -468,6 +468,33 @@ def _async_relock_reached(self, _now: datetime) -> None: self._lock.arm() self.async_write_ha_state() + @property + def available(self) -> bool: + """A dead transport does not take this switch with it. + + The base class turns every entity unavailable once the transport is + gone, and it is right to: those entities all report something read from + the panel, and holding a value from before the transport died is exactly + what that probe is for. This one reports nothing from the panel. Arming + is local — it writes to `ControlLock`, which the interceptor consults + before it publishes anything — so it is answerable whether or not the + panel is reachable, and it is worth answering: the moment a transport + dies is a plausible moment to want the lock shut before it comes back. + + The asymmetry only runs one way, and safely. Arming works with no panel + at all; disarming works too, and buys nothing, because every control it + would open is unavailable anyway and the gate is not the thing that is + broken. + + Unconditional, so nothing in `super().available` is consulted, and each + of the three things it would have asked is answered here: the transport + probe is the one this overrides; the unresolved-field probe never fires + on an entity that declares no `field_path`, as this one does not; and + `CoordinatorEntity`'s `last_update_success` is a fact about the last + snapshot fetch, which this entity does not read and does not need. + """ + return True + @property def is_on(self) -> bool: """Armed reads as on, matching an alarm panel rather than a door.""" diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index 291b4a61..b6b3ca60 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -15,6 +15,7 @@ "error": { "ca_leaf_mismatch": "The certificate this panel serves is not signed by the authority it published. Check that the HTTPS port is correct and that nothing is intercepting the connection, then try again.", "ca_unavailable": "The panel's certificate authority could not be read. Check that the panel is reachable and powered on, then try again. Setup cannot continue without it, because your passphrase would otherwise be sent in the clear.", + "ca_unusable": "The certificate authority stored for this panel cannot be read, so this change would have to be sent unencrypted. Nothing has been changed. Reauthenticate the panel to store a usable authority, then try again.", "cannot_connect": "Failed to connect to Span Panel", "fqdn_registration_failed": "Could not register the domain name with the panel or the TLS certificate was not updated in time.", "host_required": "Host is required", diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index 3efc2c41..4caf9775 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -15,6 +15,7 @@ "error": { "ca_leaf_mismatch": "El certificado que sirve este panel no está firmado por la autoridad que ha publicado. Compruebe que el puerto HTTPS es correcto y que nada intercepta la conexión, y vuelva a intentarlo.", "ca_unavailable": "No se ha podido leer la autoridad de certificación del panel. Compruebe que el panel es accesible y está encendido, y vuelva a intentarlo. La configuración no puede continuar sin ella, porque de lo contrario su contraseña se enviaría en claro.", + "ca_unusable": "La autoridad de certificación almacenada para este panel no se puede leer, por lo que este cambio tendría que enviarse sin cifrar. No se ha cambiado nada. Vuelva a autenticar el panel para almacenar una autoridad utilizable y vuelva a intentarlo.", "cannot_connect": "No se logró establecer conexión con Span Panel", "fqdn_registration_failed": "No se pudo registrar el nombre de dominio en el panel o el certificado TLS no se actualizó a tiempo.", "host_required": "Se requiere host", diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index 26ddcf52..43c6c967 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -15,6 +15,7 @@ "error": { "ca_leaf_mismatch": "Le certificat présenté par ce panneau n'est pas signé par l'autorité qu'il a publiée. Vérifiez que le port HTTPS est correct et que rien n'intercepte la connexion, puis réessayez.", "ca_unavailable": "L'autorité de certification du panneau n'a pas pu être lue. Vérifiez que le panneau est joignable et sous tension, puis réessayez. La configuration ne peut pas continuer sans elle, car votre mot de passe serait sinon envoyé en clair.", + "ca_unusable": "L'autorité de certification enregistrée pour ce panneau ne peut pas être lue, ce changement devrait donc être envoyé sans chiffrement. Rien n'a été modifié. Réauthentifiez le panneau pour enregistrer une autorité utilisable, puis réessayez.", "cannot_connect": "Échec de la connexion au Span Panel", "fqdn_registration_failed": "Impossible d'enregistrer le nom de domaine auprès du panneau ou le certificat TLS n'a pas été mis à jour à temps.", "host_required": "L'hôte est requis", diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index d645dc31..8aca6e4c 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -15,6 +15,7 @@ "error": { "ca_leaf_mismatch": "このパネルが提供する証明書は、パネルが公開した認証局で署名されていません。HTTPS ポートが正しいこと、接続が傍受されていないことを確認してから、もう一度お試しください。", "ca_unavailable": "パネルの認証局を読み取れませんでした。パネルに到達でき、電源が入っていることを確認してから、もう一度お試しください。これがないとセットアップは続行できません。続行するとパスフレーズが平文で送信されてしまうためです。", + "ca_unusable": "このパネルに保存されている認証局を読み取れないため、この変更は暗号化されずに送信されることになります。何も変更されていません。パネルを再認証して使用可能な認証局を保存してから、もう一度お試しください。", "cannot_connect": "スパンパネルへの接続に失敗しました", "fqdn_registration_failed": "パネルにドメイン名を登録できなかったか、TLS証明書が時間内に更新されませんでした。", "host_required": "ホストが必要です", diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index 185a0a62..c0cc74aa 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -15,6 +15,7 @@ "error": { "ca_leaf_mismatch": "O certificado que este painel serve não está assinado pela autoridade que publicou. Verifique se a porta HTTPS está correta e se nada está a intercetar a ligação, e tente novamente.", "ca_unavailable": "Não foi possível ler a autoridade de certificação do painel. Verifique se o painel está acessível e ligado e tente novamente. A configuração não pode continuar sem ela, porque de outro modo a sua frase-passe seria enviada em claro.", + "ca_unusable": "A autoridade de certificação armazenada para este painel não pode ser lida, pelo que esta alteração teria de ser enviada sem cifra. Nada foi alterado. Volte a autenticar o painel para armazenar uma autoridade utilizável e tente novamente.", "cannot_connect": "Falha ao ligar ao Painel Span", "fqdn_registration_failed": "Não foi possível registar o nome de domínio no painel ou o certificado TLS não foi atualizado a tempo.", "host_required": "O anfitrião é necessário", diff --git a/developer.md b/developer.md index ff4eb85c..8845ea22 100644 --- a/developer.md +++ b/developer.md @@ -823,11 +823,12 @@ whole integration. Seven modules imported them from the package root and five mo package root re-exports both names, so `from custom_components.span_panel import SpanPanelRuntimeData` keeps working, and every deferred import of them is a top-level one again. -**Use `services._loaded_runtime_data(entry)` rather than an inline `hasattr` / `isinstance` pair.** Every service here is registered domain-wide and walks -`async_loaded_entries(DOMAIN)`, so each one has to ask the same question before touching an entry, and this used to be five copies of it. Both halves of the -check are real, which is why the helper is written the way it is: `ConfigEntry.runtime_data` is a bare annotation that core _deletes_ on unload, so the -attribute is genuinely absent on an entry that has not finished setting up (hence `getattr` with a default), and what is there is whatever the owning -integration put there, so `isinstance` is what says it is ours. AGENTS.md service point 6 names this helper. +**Use `runtime.loaded_runtime_data(entry)` rather than an inline `hasattr` / `isinstance` pair.** Every service is registered domain-wide and walks +`async_loaded_entries(DOMAIN)`, so each one has to ask the same question before touching an entry, and this used to be five copies of it — plus two more in the +package root, in `async_unload_entry` and `async_remove_config_entry_device`, which is why the helper lives in `runtime` (a leaf that already owns the type) +rather than in `services`. Both halves of the check are real, which is why the helper is written the way it is: `ConfigEntry.runtime_data` is a bare annotation +that core _deletes_ on unload, so the attribute is genuinely absent on an entry that has not finished setting up (hence `getattr` with a default), and what is +there is whatever the owning integration put there, so `isinstance` is what says it is ours. AGENTS.md service point 6 names this helper. ## The Supervisor discovery path is deliberately unguarded diff --git a/tests/test_control_lock_state.py b/tests/test_control_lock_state.py index 74081bf1..73e90c1b 100644 --- a/tests/test_control_lock_state.py +++ b/tests/test_control_lock_state.py @@ -332,3 +332,36 @@ async def test_the_auto_relock_writes_the_state_when_it_falls_due( state = hass.states.get(LOCK_ENTITY) assert state is not None assert state.state == STATE_ON + + +# ---------- a dead transport does not take the lock with it ---------- + + +@pytest.mark.asyncio +async def test_the_lock_stays_operable_when_the_transport_dies(hass: HomeAssistant) -> None: + """Arming is local, so it is answerable with no panel at the other end. + + Every other control goes unavailable once the transport is gone, and should: + they report something read from the panel. This one reports the state of an + in-process object the interceptor consults, and the moment a transport dies + is a plausible moment to want the lock shut before it comes back. + + Inheriting the base class's transport probe cost more than a greyed-out + tile: core's entity-service helper skips an unavailable entity outright, so + `switch.turn_on` was dropped without an error and the lock stayed open. That + is what this asserts — the arm lands, not merely that the state renders. + """ + async with _panel(hass, timeout=0) as entry: + await _disarm(hass) + state = hass.states.get(LOCK_ENTITY) + assert state is not None + assert state.state == STATE_OFF + + entry.runtime_data.coordinator.transport_dead = True + + await _arm(hass) + + state = hass.states.get(LOCK_ENTITY) + assert state is not None + assert state.state == STATE_ON + assert _lock(entry).armed is True diff --git a/tests/test_schema_availability.py b/tests/test_schema_availability.py index 9a69c347..04ab7953 100644 --- a/tests/test_schema_availability.py +++ b/tests/test_schema_availability.py @@ -286,10 +286,13 @@ def test_every_available_override_is_accounted_for() -> None: `SpanSensorBase` and `SpanPanelBinarySensor` return True on their own before delegating, so each carries the probe ahead of that branch. The - switch, select and button return False when the panel is offline and + circuit switch, select and button return False when the panel is offline and otherwise delegate, so the override on `SpanPanelEntity` covers them. - Anything new in this list has to answer the same question, and this - assertion is what forces it to be asked. + `SpanPanelControlLockSwitch` answers the question a third way — it never + delegates, because it reads no panel field and arming it is local — and is + the one control a dead transport deliberately leaves alone. Anything new in + this list has to answer the same question, and this assertion is what forces + it to be asked. """ overriders = { cls.__name__ @@ -305,6 +308,7 @@ def test_every_available_override_is_accounted_for() -> None: "SpanSensorBase", "SpanPanelBinarySensor", "SpanPanelCircuitsSwitch", + "SpanPanelControlLockSwitch", "SpanPanelGFEOverrideButton", "SpanPanelCircuitsSelect", } diff --git a/tests/test_v2_config_flow.py b/tests/test_v2_config_flow.py index 3810afac..adb25989 100644 --- a/tests/test_v2_config_flow.py +++ b/tests/test_v2_config_flow.py @@ -56,6 +56,13 @@ FAKE_CA_PEM = "-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n" FAKE_CA_FINGERPRINT = "0" * 64 +#: An anchor already stored on an entry that `ssl` will not load. Named apart +#: from `FAKE_CA_PEM` — which happens to be unreadable too, hence the autouse +#: fixture — because a test about an unusable pin should say which property of +#: the PEM it is about. `build_panel_ssl_context` is left unpatched wherever +#: this is used, so the refusal comes from the real one. +UNREADABLE_CA_PEM = "-----BEGIN CERTIFICATE-----\nbm90LWEtY2VydA==\n-----END CERTIFICATE-----\n" + @pytest.fixture(autouse=True) def panel_ca_available(): @@ -2950,3 +2957,55 @@ async def test_reconfigure_switch_from_fqdn_to_ip_clears_registration( ) assert entry.data[CONF_HOST] == "192.168.1.201" assert entry.data[CONF_REGISTERED_FQDN] == "" + + +@pytest.mark.asyncio +async def test_reconfigure_refuses_to_downgrade_an_unreadable_pin( + hass: HomeAssistant, +) -> None: + """A pin that cannot be read is not permission to reconfigure over plaintext. + + This flow carries the entry's access token to the panel — `delete_fqdn` on + this branch, `register_fqdn` on the other — and checks the new host against + the anchor before writing it. With no usable anchor it can do neither, so it + refuses instead of falling back to the transport the entry had before it was + pinned. Nothing reaches the network and nothing is written. + """ + entry = MockConfigEntry( + version=7, + minor_version=1, + domain=DOMAIN, + title="SPAN Panel", + data={ + CONF_HOST: "panel.example.com", + CONF_REGISTERED_FQDN: "panel.example.com", + CONF_ACCESS_TOKEN: "token", + CONF_API_VERSION: "v2", + CONF_PANEL_CA_PEM: UNREADABLE_CA_PEM, + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + detect = AsyncMock(return_value=MOCK_V2_DETECTION) + delete = AsyncMock() + with ( + patch("custom_components.span_panel.config_flow.detect_api_version", new=detect), + patch("custom_components.span_panel.config_flow.delete_fqdn", new=delete), + ): + result = await entry.start_reconfigure_flow(hass) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.201"}, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "reconfigure" + assert result2["errors"] == {"base": "ca_unusable"} + detect.assert_not_awaited() + delete.assert_not_awaited() + assert entry.data[CONF_HOST] == "panel.example.com" + assert entry.data[CONF_REGISTERED_FQDN] == "panel.example.com" + assert entry.data[CONF_PANEL_CA_PEM] == UNREADABLE_CA_PEM From dbdfa3b4b5a7591982cc9e873acfe0e1e428e9df Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:26:35 -0700 Subject: [PATCH 187/345] chore: 2.1.0b17 Carries the fixes from the 2.1.0 release review: CA pinning on reauth and FQDN installs, a control lock that survives reload, one outcome path for every control, the dead-transport availability fix, dip retraction after corroboration, and the naming route that lets Home Assistant compose circuit entity ids from the user's own settings. --- custom_components/span_panel/manifest.json | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 80e4e972..0cbed153 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -27,7 +27,7 @@ "span-panel-api-schema-0==1.1.0", "span-panel-api-schema-1==1.1.0" ], - "version": "2.1.0b16", + "version": "2.1.0b17", "zeroconf": [ { "type": "_span._tcp.local." diff --git a/pyproject.toml b/pyproject.toml index 504ef00b..710d1694 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span" -version = "2.1.0b16" +version = "2.1.0b17" description = "Span Panel Custom Integration for Home Assistant" authors = [{name = "SpanPanel"}] license = {text = "MIT"} diff --git a/uv.lock b/uv.lock index 26d82e37..379bcf1f 100644 --- a/uv.lock +++ b/uv.lock @@ -2491,7 +2491,7 @@ wheels = [ [[package]] name = "span" -version = "2.1.0b16" +version = "2.1.0b17" source = { virtual = "." } dependencies = [ { name = "homeassistant" }, From 9e82bde46bec5ddd50c1b9d8050a535c6ce33444 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:13:59 -0700 Subject: [PATCH 188/345] docs: entity id and name handling rules --- developer_entity_id_and_name_handling.md | 256 +++++++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 developer_entity_id_and_name_handling.md diff --git a/developer_entity_id_and_name_handling.md b/developer_entity_id_and_name_handling.md new file mode 100644 index 00000000..f9020652 --- /dev/null +++ b/developer_entity_id_and_name_handling.md @@ -0,0 +1,256 @@ +# Entity IDs and Names — the rules + +This document is the permanent statement of how this integration names entities and composes their entity IDs. It exists because the question has been +re-derived from scratch more than once. The rules below are the maintainer's intent; they outrank any single design document, review note or older comment. A +change to naming is checked against them first, and a change that cannot satisfy them is not made. + +Every claim here is grounded in the code as of 2.1.0 (`naming.py`, `entity.py`, `sensor_base.py`, `sensor_circuit.py`, `switch.py`, `select.py`) and in Home +Assistant Core 2026.8 (`helpers/entity_registry.py`, `helpers/entity_platform.py`). When the code and this document disagree, one of them is wrong — fix it, do +not paper over it. + +## 1. The five rules + +| # | Rule | +| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **R1** | **Recreate never offers an uncaused rename.** The base this integration supplies must reproduce an existing entity's ID when Home Assistant composes it under the entity-ID options matching how that install was built. Our own doing — two suffix spellings, a reworded label, a registry `name` an older release wrote — never produces an offer. An offer that reflects the user's own configuration (an area they assigned, a device they renamed, a circuit they renamed on the panel, `entity_id_parts` they chose) is legitimate. | +| **R2** | **The integration supplies only the flag-driven base.** `Circuit 15 power` or `Kitchen Outlets power`. Device and area prefixes are Core's, by the user's global `entity_id_parts` choice, and this integration does not exempt itself from that choice. No `entity_id` is preset, ever. | +| **R3** | **Circuit-numbers mode promises the circuit-number token stays stable across circuit renames** — not the surrounding prefix text, which is Core's. | +| **R4** | **The display name is the panel's, delivered as `original_name`.** The registry `name` field is the user's and is never written by this integration. The one write it makes to that field is to _clear_ a value a pre-2.1.0 release wrote there (§6). | +| **R5** | **Existing entity IDs never move.** Nothing moves an ID without the user pressing **Recreate entity IDs** and accepting the offer. Unique IDs never change with the naming style. | + +A consequence worth stating outright: R1 does **not** mean bypassing `entity_id_parts` with a hard-coded ID. Where composition yields a different device or area +part than the entity has today, that is the user's configuration at work and the offer stands. The one-time attempt to add "legacy preset" exceptions for those +cases was withdrawn. + +## 2. Three fields, three owners + +Every entity has three naming-relevant values. Keeping them apart is the whole design. + +| Value | Owner | What it is | Where it lives | +| ------------------- | --------------- | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| **Display name** | The panel | `"{circuit name} {label}"` — `Kitchen Outlets Power`, `Bathroom Lights Breaker` | `_attr_name` → registry `original_name`. Same string in both naming modes. | +| **Object-ID base** | The naming flag | `"{identifier} {suffix words}"` — `Kitchen Outlets power` or `Circuit 15 power` | `SpanPanelEntity._span_object_id_base` → `suggested_object_id` property → registry `object_id_base`. | +| **Registry `name`** | The user | Whatever they typed in the entity's settings. Outranks everything in both display and ID composition. | Registry `name`. Read to detect an override; never written (except the §6 release). | + +The display name and the base are decoupled **on purpose**: a label can be reworded without touching a single ID, and an ID can carry an old spelling while the +label is current. + +## 3. How Home Assistant composes an ID (Core 2026.8) + +This is Core's behaviour, cited so nobody has to re-read Core to trust the rest of the document. + +### 3.1 Priority + +`EntityRegistry._async_generate_entity_id` (`helpers/entity_registry.py`): + +```text +name > suggested_object_id > object_id_base +``` + +- `name` (the user's registry name) — always prefixed with the device name. +- `suggested_object_id` — used **verbatim, not prefixed** with device or area. This is where a preset `entity_id` lands. +- `object_id_base` — prefixed with device (and area, if the user's settings include it) when `has_entity_name` is True. All of ours have it True. + +The prefixing follows `self.settings.entity_id_parts`, defaulting to `(AREA, DEVICE, ENTITY)`. Device name is `device.name_by_user or device.name`; the area is +the entity's, falling back to the device's. + +### 3.2 What the platform hands Core + +`entity_platform._async_derive_object_ids`: + +- If the entity preset `entity_id` (`internal_integration_suggested_object_id`), that becomes `suggested_object_id`. **We never do this.** +- Otherwise `entity.suggested_object_id` becomes `object_id_base`. The stock property returns the display name; ours returns the base (§4). + +### 3.3 The stored row is refreshed on every add + +`async_get_or_create` on an entity that already exists calls `_async_update_entity(..., object_id_base=..., suggested_object_id=...)`, and the update applies +any value that is not `UNDEFINED` and differs from the stored one — **including `None`**. So every reload rewrites the row's `object_id_base` from the current +base and clears a stale `suggested_object_id`. The row is never frozen at creation. + +### 3.4 Recreate entity IDs regenerates from the stored row + +`async_regenerate_entity_id(entry)` composes from `entry.name`, `entry.suggested_object_id`, `entry.object_id_base`, `entry.device_id`, `entry.area_id` — the +**stored row**, not the live entity. Two corollaries: + +- A row whose `suggested_object_id` still holds a 2.0.8 preset reproduces that preset forever, whatever the entity now wants. That is exactly why 2.0.8 could + not follow a rename (#252), and why a 2.0.8 install that _appears_ to run 2.1.0 but has not actually loaded it shows "No renamable entity IDs". +- A row whose `name` is set composes from that, device-prefixed, in both display and ID. That is why a 2.0.8 circuit-numbers install got friendly-name offers + (§6). + +## 4. What this integration does — one path, both modes + +### 4.1 The identifier (the naming flag's half) + +`sensor_circuit._resolve_circuit_identifier`, and the same branch inline in `switch.py` / `select.py`: + +| Mode | Identifier | Source | +| --------------------------------------- | --------------------------------------------------------- | --------------------------------------------------------------------------------- | +| Circuit numbers (`use_circuit_numbers`) | `Circuit 15`, `Circuit 30 32` (every tab, sorted) | `helpers.construct_circuit_identifier_from_tabs(circuit.tabs, circuit_id)` | +| Friendly names, circuit has a name | the panel's name, verbatim | `circuit.name` | +| Friendly names, circuit unnamed | `Solar` (PV), `EV Charger` (EVSE), else `Circuit 7` (tab) | `_unnamed_circuit_fallback` — never `None`, so two unnamed circuits never collide | +| Unmapped tab (either mode) | `Unmapped Tab 32` | `SpanUnmappedCircuitSensor._object_id_parts` | + +The identifier **always answers**. A `None` identifier lets Core compose from the label alone, which gives every unnamed circuit the same ID and leaves the +registry to disambiguate with `_2`, `_3` — the pre-2.1.0 behaviour that is now gone. + +### 4.2 The suffix + +Each entity names its canonical suffix: + +- Circuit sensors: `id_builder.get_user_friendly_suffix(description key)` → `power`, `energy_consumed`, `energy_produced`, `energy_net`, `current`, + `breaker_rating`. +- Switch: `"breaker"`, named outright (`get_user_friendly_suffix` has no switch entry). +- Select: the description key `"circuit_priority"`, verbatim (`get_user_friendly_suffix` would map it to `priority`, which is the unique-ID's spelling, not the + entity-ID's). + +`CIRCUIT_SUFFIX_MAPPING` in `id_builder` builds **unique IDs** and is closed. The tables in `naming.py` govern **entity IDs only**. Do not cross them. + +### 4.3 The base — `naming.circuit_object_id_base(identifier, suffix, existing_entity_id)` + +Returns `"{identifier} {suffix words}"`, where the words are decided in this order: + +1. **The existing ID still names this circuit** (`..._{identifier_slug}_{form}` or `..._{identifier_slug}`): what follows the identifier settles both the form + the ID carries and whether the preset builder omitted the suffix. They are one question, answered together — answering them separately produced + `..._current_current_power` for a circuit named "Current" and `..._solar_power` for one named "Solar Power", both uncaused renames (R1). +2. **The existing ID no longer names this circuit** (renamed on the panel, #252): the form is read back from the _end_ of the ID by itself + (`_existing_suffix_form`), and the omission is decided from the _new_ name. The name half follows the panel; the suffix half does not. +3. **No existing ID**: the noun-last wording from `NEW_ENTITY_ID_SUFFIX_WORDS` (`consumed energy`, matching the panel-level `main_meter_consumed_energy`). + +`ENTITY_ID_SUFFIX_FORMS` lists every suffix form ever shipped (`consumed_energy` before the preset era, `energy_consumed` after; `current_power` and `power`). +**An entry is added only when another form is discovered to have shipped, never to introduce one.** An existing entity is offered whichever spelling it already +carries and never the other (R1). + +Worked examples (friendly mode, `(DEVICE, ENTITY)` parts, device "Span Panel"): + +| Circuit name (panel) | Existing ID | Suffix | Base | Composed / offered | +| ----------------------- | --------------------------------------------------- | ----------------- | --------------------------------- | --------------------------------------------------------------- | +| Kitchen Outlets | none | `energy_consumed` | `Kitchen Outlets consumed energy` | `sensor.span_panel_kitchen_outlets_consumed_energy` | +| Kitchen Outlets | `sensor.span_panel_kitchen_outlets_energy_consumed` | `energy_consumed` | `Kitchen Outlets energy consumed` | its own ID — no offer | +| Bathroom Lights Renamed | `switch.span_panel_bathroom_lights_breaker` | `breaker` | `Bathroom Lights Renamed breaker` | `switch.span_panel_bathroom_lights_renamed_breaker` | +| Solar Power | `sensor.span_panel_solar_power_power` | `power` | `Solar Power power` | its own ID | +| Solar Power | `sensor.span_panel_solar_power` | `power` | `Solar Power` | its own ID | +| Power | `sensor.span_panel_power_power` | `power` | `Power power` | its own ID (a name that _is_ the suffix word keeps both halves) | + +Circuit-numbers mode is the same table with `Circuit 15` in the identifier column — and a rename on the panel changes nothing in it (R3). + +### 4.4 Delivery to Core + +`SpanPanelEntity` (`entity.py`) carries `_span_object_id_base: str | None = None` and overrides the `suggested_object_id` property to return it when set, or +`super().suggested_object_id` (the display name) when not. Core's `type.__getattribute__` guard covers the `name` property only, so this override is supported. +Circuit sensors, the switch and the select set the base at construction. Every other entity leaves it `None` and composes from its label as it always has. + +`entity_id` is **never** preset. Nothing in this integration assigns `self.entity_id` or `_attr_entity_id`. + +### 4.5 Who composes without a base + +- **Sub-device circuit sensors** (an EVSE feed circuit's sensors, `_is_sub_device`): no base. Core composes from the label on the charger's device, so the ID + names the charger alone — `sensor._power` — like the charger's other sensors. A feed sensor created before this route keeps its old ID until Recreate + is pressed, and is then offered the charger-named one (a legitimate offer: the device part is Core's). +- **Everything not a circuit entity** (panel, BESS, MID, EVSE, PV, binary sensors, adopted properties): stock behaviour, label-composed. + +### 4.6 Legacy `use_device_prefix` + +Stored, preserved by the options flow, and read by `get_current_naming_pattern`, but it **cannot keep the device out of a composed ID** — Core includes the +device unless the user's own `entity_id_parts` excludes it. An install predating the option, whose circuit IDs carry no device half, is offered one on Recreate. +That is Core composing under the user's settings, and it is documented in the README and CHANGELOG rather than special-cased. + +## 5. Name sync — what "phase 1" and "phase 2" mean, and what they mean now + +The terms come from the pre-2.1.0 design and are still used in conversation, so here is what each was and what it is today. + +### 5.1 Before 2.1.0 (the two-phase scheme, for context only) + +- **Phase 1 — construction.** `_attr_name` was set from the _naming flag_ (`Circuit 15 Power` in circuit-numbers mode) so that Core would derive a flag-shaped + `object_id_base`, and `entity_id` was **preset** by a builder, which Core stored as `suggested_object_id`. A new entity displayed the flag name until phase 2. +- **Phase 2 — first coordinator update, and every rename after.** Circuit-numbers mode **wrote the registry `name`** with the panel's name to fix the display. + That put the panel name in the user's field, which outranks everything in ID generation — so Recreate proposed a friendly-name ID for every circuit-numbered + entity, and a rename in the SPAN app could never reach the preset `suggested_object_id` (#252). Friendly mode's phase 2 was idempotent (same string) and wrote + nothing. + +### 5.2 In 2.1.0 the phases collapse to one + +There is one name path, in both modes: + +1. **Construction** (`sensor_base.py`, `switch.py`, `select.py`): `_attr_name = "{circuit name} {label}"` — the _panel's_ name, unconditionally, existing entity + or not. It reaches the registry as `original_name`. The base (§4.3) is computed at the same time and handed to Core through `suggested_object_id`. If an + existing row carries a `name` a pre-2.1.0 release wrote, it is released (§6). +2. **Every coordinator update** (`_handle_coordinator_update` / `_sync_circuit_name`): the entity compares the panel's current circuit name with the one it was + built from. + - If the registry `name` is set, the user has overridden the name: the panel's rename is **noted and ignored** — theirs outranks it, and a reload would + change nothing on screen. + - If the entity was **not** in the registry at construction (`_previous_circuit_name is _NAME_UNSET`), the first update requests one reload. This is the + surviving trace of "phase 2": a freshly created entity's registry row is settled by that reload. + - If the name **changed**, the entity requests a reload (`coordinator.request_reload()`). +3. **Reload** (`coordinator.py`, end of the update cycle): the entry is torn down and rebuilt. `original_name` is only written when an entity is added, so a + rebuild is the only way to refresh it — that is why a rename costs a reload rather than a registry write. The rebuild also recomputes the base with the new + circuit name and, per §3.3, rewrites the row's `object_id_base`. + +The rename therefore reaches: + +| What | Friendly names | Circuit numbers | +| ------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| Display name | new name, after the reload | new name, after the reload (`Span Panel Kitchen Outlets Power`, device-prefixed like friendly mode) | +| Entity ID | unchanged (R5) | unchanged (R5) | +| Row `object_id_base` | `Kitchen Outlets Renamed power` | `Circuit 15 power` — unchanged | +| Recreate entity IDs offer | `sensor.span_panel_kitchen_outlets_renamed_power` | its own ID — no offer (R3) | +| Registry `name` | untouched | untouched | + +Settability changes (a circuit becoming or ceasing to be controllable) are **not** watched here; `SpanPanelCoordinator._check_settability_change` does that, +because the "gains a control" edge happens on circuits that have no entity to see it. + +## 6. Releasing the registry `name` a pre-2.1.0 release wrote + +`naming.release_registry_name_written_by_older_release(registry, entity_id, circuit_name, description_names)` runs once per existing circuit entity at +construction. It clears the registry `name` **only** if it equals `"{circuit_name} {label}"` for the description's current label _or any label it has carried +before_ (`SpanPanelCircuitsSensorEntityDescription.legacy_names`: `Energy Consumed` / `Energy Produced` / `Energy Net` beside the current `Consumed Energy` +etc.). Any other value is the user's and is left exactly where it is. The switch releases `"{name} Breaker"`, the select `"{name} {description name}"`. + +This is the one write this integration ever makes to the user's field, and it is a hand-back, not a claim. Miss a release site and that platform's Recreate +stays poisoned: under composition a stale `name` composes _with the area_, so a 2.0.8 name on a panel in "Basement" would propose +`sensor.basement_span_panel_kitchen_outlets_consumed_energy`. + +## 7. Offers that are legitimate — and the one that is not fully avoidable + +Recreate offers a different ID only where the user's own configuration composes one. Each of these is documented for users in README and CHANGELOG: + +| Situation | Offer | Cause | +| ---------------------------------------------------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------- | +| Circuit renamed on the panel, friendly mode | the new name's ID | user renamed it | +| Panel device in an area, `entity_id_parts` includes AREA (the default) | area-prefixed ID | user assigned the area | +| Panel device renamed by the user (`name_by_user`) | ID carrying that name | user renamed the device | +| Sensor on a SPAN Drive feed circuit | charger-named ID | device part is Core's | +| Second panel whose generated device name is "Span Panel 2" | `span_panel_2_…` | device part is Core's | +| Install predating `use_device_prefix`, circuit IDs with no device half | device-prefixed ID | device part is Core's | +| Unnamed circuit in friendly mode whose ID carries `single_circuit`/`power_2` | `circuit_7…` | **the one uncaused offer** — no computed identifier reproduces both eras; accepted and documented | + +Anything not in this table that Recreate offers on an unchanged install is a bug against R1. + +## 8. Things that must stay true (checklist for any change touching naming) + +- [ ] No entity presets `entity_id`. `Grep` for `entity_id =` and `_attr_entity_id` in platform code finds nothing. +- [ ] The registry `name` is never assigned a value; the only `async_update_entity(..., name=...)` call is the `None` in + `release_registry_name_written_by_older_release`. +- [ ] `_attr_name` is the panel's name in both modes; nothing branches on `use_circuit_numbers` to pick a display name. +- [ ] The base is computed from `(identifier, canonical suffix, existing_entity_id)` and nothing else — never from the display label. +- [ ] `ENTITY_ID_SUFFIX_FORMS` gains an entry only for a form found to have shipped; `NEW_ENTITY_ID_SUFFIX_WORDS` and it stay in one-to-one correspondence + (`test_every_form_table_entry_has_a_new_wording_and_vice_versa`). +- [ ] A reworded description label adds the old label to `legacy_names` so §6 still recognises what 2.0.8 wrote. +- [ ] Unique IDs are untouched by any of this; `CIRCUIT_SUFFIX_MAPPING` is closed. +- [ ] `tests/test_naming.py` and `tests/test_recreate_entity_ids.py` pass. The latter drives a real `EntityPlatform` with `entity_id_parts` fixed, so a + composition regression fails there rather than in a unit test of a builder. +- [ ] Run the `entity-id-auditor` agent after editing `id_builder.py`, `entity_resolver.py`, `naming.py` or any circuit platform file. + +## 9. Diagnosing "Recreate offered nothing" on a live install + +1. Confirm the running code: `manifest.json` version and the presence of `custom_components/span_panel/naming.py`. A 2.0.8 install with 2.1.0 selected in HACS + but not yet loaded shows exactly this symptom, because its rows still hold the 2.0.8 preset in `suggested_object_id` (§3.4). +2. Read the entity's row in `.storage/core.entity_registry`: on 2.1.0 after at least one reload, `suggested_object_id` is `null` and `object_id_base` reads the + base (`Bathroom Lights Renamed breaker`). A non-null `suggested_object_id` means the entity has not been re-added by 2.1.0 code. +3. Check `name` on the row: a non-null value is a user override (or an unreleased 2.0.8 write — compare against §6's patterns) and outranks the base. +4. Confirm the rename-triggered reload happened: the log carries `Auto-sync detected circuit name change from '…' to '…'` followed by the entry reload. +5. Only then look at `circuit_object_id_base` with the row's exact `entity_id`, identifier and suffix — `tests/test_naming.py` shows how to call it directly. + +## 10. Where the history lives + +The design trail (issue #252 write-up, the 2026-08-22 spec and plan, the 2026-08-26 composition-route review) lives in the `SpanPanel_Docs` workspace, not in +this repository. It records how the rules were arrived at and the routes that were rejected. It is history; this document is the rule. From af2c1d40359a56075e4776d5485e59f306e606da Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:15:10 -0700 Subject: [PATCH 189/345] remove extraneous test files --- test_migration.py | 0 test_migration_comprehensive.py | 0 test_migration_simple.py | 0 3 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 test_migration.py delete mode 100644 test_migration_comprehensive.py delete mode 100644 test_migration_simple.py diff --git a/test_migration.py b/test_migration.py deleted file mode 100644 index e69de29b..00000000 diff --git a/test_migration_comprehensive.py b/test_migration_comprehensive.py deleted file mode 100644 index e69de29b..00000000 diff --git a/test_migration_simple.py b/test_migration_simple.py deleted file mode 100644 index e69de29b..00000000 From b2115a2539174c9ca4d184a262a31f897f69e8c4 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:49:49 -0700 Subject: [PATCH 190/345] chore(tests): drop two empty test modules and the README documenting one test_coordinator_migration.py and test_entity_bulk_naming_patterns.py were both zero bytes. entity_id_renaming_README.md documented the second one in full -- every class it names (TestYamlFixtureValidation, TestBulkEntityIdNamingPatterns, TestBulkModificationEdgeCases) and every fixture it points at are gone, and nothing links to it, so it goes with them rather than leaving a doc describing a suite that no longer exists. --- tests/entity_id_renaming_README.md | 64 ----------------------- tests/test_coordinator_migration.py | 0 tests/test_entity_bulk_naming_patterns.py | 0 3 files changed, 64 deletions(-) delete mode 100644 tests/entity_id_renaming_README.md delete mode 100644 tests/test_coordinator_migration.py delete mode 100644 tests/test_entity_bulk_naming_patterns.py diff --git a/tests/entity_id_renaming_README.md b/tests/entity_id_renaming_README.md deleted file mode 100644 index 4a424b3b..00000000 --- a/tests/entity_id_renaming_README.md +++ /dev/null @@ -1,64 +0,0 @@ -# Bulk Entity ID Naming Pattern Tests Summary - -## Overview - -This test suite validates bulk entity ID naming pattern changes with user customization preservation. It demonstrates how the integration handles system-wide -transformations while preserving all user modifications. - -## Test Structure - -### 1. YAML Fixture Validation (`TestYamlFixtureValidation`) - -- **`test_all_yaml_fixtures_are_valid`**: Validates all YAML fixtures have proper structure and required fields -- **`test_fixture_naming_patterns_are_consistent`**: Ensures naming patterns follow expected conventions across fixtures -- **`test_fixture_user_customizations_are_preserved`**: Verifies user customizations are identical across before/after fixtures - -### 2. Bulk Naming Pattern Tests (`TestBulkEntityIdNamingPatterns`) - -- **`test_crud_then_bulk_naming_pattern_change_preserves_customizations`**: Main workflow test demonstrating CRUD operations followed by bulk naming changes -- **`test_naming_pattern_combinations`**: Tests all four naming pattern combinations (device prefix + circuit numbers) - -### 3. Edge Case Tests (`TestBulkModificationEdgeCases`) - -- **`test_empty_sensor_set_handling`**: Validates graceful handling of empty sensor sets -- **`test_unknown_sensor_types_preserved`**: Ensures unknown sensor types are preserved during bulk modifications - -## YAML Fixtures - -### `/tests/fixtures/circuit_numbers.yaml` - -### `/tests/fixtures/device_prefix.yaml` - -### `/tests/fixtures/device_prefix.yaml` - -Demonstrates solar sensors with friendly naming pattern: - -- `entity_id: "sensor.span_panel_solar_inverter_power"` -- `entity_id: "sensor.span_panel_solar_inverter_energy_produced"` -- Contains user customizations (custom attributes, modified names) - -### `/tests/fixtures/solar_sensors_circuit_numbers.yaml` - -Shows the same sensors with circuit number naming pattern: - -- `entity_id: "sensor.span_panel_circuit_30_32_power"` -- `entity_id: "sensor.span_panel_circuit_30_32_energy_produced"` -- **All user customizations preserved** - only entity_ids change - -## Key Testing Principles - -1. **Real YAML Fixtures**: Tests use actual YAML files instead of embedded strings -2. **Fixture Validation**: Dedicated tests ensure all fixtures are valid and consistent -3. **User Customization Preservation**: Validates that bulk operations preserve all user modifications -4. **Pattern Consistency**: Tests verify naming patterns follow expected conventions -5. **Edge Case Coverage**: Handles empty sets and unknown sensor types - -## Test Benefits - -- **Comprehensive Validation**: All fixtures are validated together -- **Real-world Scenarios**: Tests actual YAML structures users would create -- **Pattern Verification**: Ensures naming patterns are consistent and predictable -- **Customization Safety**: Proves user modifications survive bulk changes -- **Maintainability**: Easy to add new fixtures and validate them automatically - -This test suite provides confidence that bulk entity ID naming pattern changes work correctly while preserving all user customizations. diff --git a/tests/test_coordinator_migration.py b/tests/test_coordinator_migration.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_entity_bulk_naming_patterns.py b/tests/test_entity_bulk_naming_patterns.py deleted file mode 100644 index e69de29b..00000000 From dd24bc5d9a153fb430c47b8e6b34f0ce1025e258 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:51:54 -0700 Subject: [PATCH 191/345] refactor(evse): one empty-charger sentinel, in util beside the builder that reads it binary_sensor, number and sensor_evse each defined an identical SpanEvseSnapshot(node_id="", feed_circuit_id="") as the fallback for snapshot.evse.get(). The value is shared, so the definition is too: EMPTY_EVSE now lives in util.py next to evse_device_info, the builder every one of those call sites feeds it into. Safe as a single module-level instance because SpanEvseSnapshot is frozen. --- custom_components/span_panel/binary_sensor.py | 9 +++------ custom_components/span_panel/number.py | 11 ++++------- custom_components/span_panel/sensor_evse.py | 9 +++------ custom_components/span_panel/util.py | 12 ++++++++++++ 4 files changed, 22 insertions(+), 19 deletions(-) diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py index cfdf84ca..a65976d3 100644 --- a/custom_components/span_panel/binary_sensor.py +++ b/custom_components/span_panel/binary_sensor.py @@ -44,7 +44,7 @@ resolve_evse_display_suffix, ) from .runtime import SpanPanelConfigEntry -from .util import bess_device_info, evse_device_info, pv_device_info +from .util import EMPTY_EVSE, bess_device_info, evse_device_info, pv_device_info # pylint: disable=invalid-overridden-method @@ -522,9 +522,6 @@ class SpanEvseBinarySensorEntityDescription( `bess_connected` and `pcs_active` rather than its two neighbours. """ -# Fallback EVSE snapshot used when the EVSE disappears mid-session -_EMPTY_EVSE = SpanEvseSnapshot(node_id="", feed_circuit_id="") - class SpanEvseBinarySensor(SpanPanelEntity, BinarySensorEntity): """EVSE (EV charger) binary sensor entity.""" @@ -552,7 +549,7 @@ def __init__( ) panel_identifier = snapshot.serial_number - evse = snapshot.evse.get(evse_id, _EMPTY_EVSE) + evse = snapshot.evse.get(evse_id, EMPTY_EVSE) use_circuit_numbers = data_coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) display_suffix = resolve_evse_display_suffix(evse, snapshot, use_circuit_numbers) self._attr_device_info = evse_device_info( @@ -578,7 +575,7 @@ def _handle_coordinator_update(self) -> None: return snapshot = self.coordinator.data - evse = snapshot.evse.get(self._evse_id, _EMPTY_EVSE) + evse = snapshot.evse.get(self._evse_id, EMPTY_EVSE) self._attr_is_on = self._value_fn(evse) super()._handle_coordinator_update() diff --git a/custom_components/span_panel/number.py b/custom_components/span_panel/number.py index 5e103540..4e3bafac 100644 --- a/custom_components/span_panel/number.py +++ b/custom_components/span_panel/number.py @@ -59,15 +59,12 @@ from .field_paths import DerivedReason, FieldPathDeclarationMixin from .helpers import build_evse_unique_id_for_entry, resolve_evse_display_suffix from .runtime import SpanPanelConfigEntry -from .util import evse_device_info +from .util import EMPTY_EVSE, evse_device_info _LOGGER: logging.Logger = logging.getLogger(__name__) PARALLEL_UPDATES = 1 -_EMPTY_EVSE = SpanEvseSnapshot(node_id="", feed_circuit_id="") -"""Fallback for a charger that disappears mid-session, as the other EVSE platforms use.""" - @dataclass(frozen=True) class SpanEvseNumberRequiredKeysMixin(FieldPathDeclarationMixin): @@ -163,7 +160,7 @@ def __init__( ) or "Span Panel" ) - evse = snapshot.evse.get(evse_id, _EMPTY_EVSE) + evse = snapshot.evse.get(evse_id, EMPTY_EVSE) use_circuit_numbers = data_coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) self._attr_device_info = evse_device_info( snapshot.serial_number, @@ -186,8 +183,8 @@ def __init__( def _evse(self) -> SpanEvseSnapshot: snapshot: SpanPanelSnapshot | None = self.coordinator.data if snapshot is None: - return _EMPTY_EVSE - return snapshot.evse.get(self._evse_id, _EMPTY_EVSE) + return EMPTY_EVSE + return snapshot.evse.get(self._evse_id, EMPTY_EVSE) def _apply(self, evse: SpanEvseSnapshot) -> None: """Take the reading and the bound the panel currently publishes. diff --git a/custom_components/span_panel/sensor_evse.py b/custom_components/span_panel/sensor_evse.py index 7d6f4219..7907f552 100644 --- a/custom_components/span_panel/sensor_evse.py +++ b/custom_components/span_panel/sensor_evse.py @@ -11,13 +11,10 @@ from .helpers import build_evse_unique_id_for_entry, resolve_evse_display_suffix from .sensor_base import SpanSensorBase from .sensor_definitions import SpanEvseSensorEntityDescription -from .util import evse_device_info +from .util import EMPTY_EVSE, evse_device_info _LOGGER: logging.Logger = logging.getLogger(__name__) -# Fallback EVSE snapshot used when the EVSE disappears mid-session -_EMPTY_EVSE = SpanEvseSnapshot(node_id="", feed_circuit_id="") - class SpanEvseSensor(SpanSensorBase[SpanEvseSensorEntityDescription, SpanEvseSnapshot]): """EVSE (EV charger) sensor entity.""" @@ -42,7 +39,7 @@ def __init__( ) panel_identifier = snapshot.serial_number - evse = snapshot.evse.get(evse_id, _EMPTY_EVSE) + evse = snapshot.evse.get(evse_id, EMPTY_EVSE) use_circuit_numbers = data_coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) display_suffix = resolve_evse_display_suffix(evse, snapshot, use_circuit_numbers) self._attr_device_info = evse_device_info( @@ -67,4 +64,4 @@ def _generate_unique_id( def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanEvseSnapshot: """Get the EVSE snapshot for this sensor's charger.""" - return snapshot.evse.get(self._evse_id, _EMPTY_EVSE) + return snapshot.evse.get(self._evse_id, EMPTY_EVSE) diff --git a/custom_components/span_panel/util.py b/custom_components/span_panel/util.py index 1e0230ad..99dd7fb5 100644 --- a/custom_components/span_panel/util.py +++ b/custom_components/span_panel/util.py @@ -188,6 +188,18 @@ def mid_device_info( ) +EMPTY_EVSE: Final = SpanEvseSnapshot(node_id="", feed_circuit_id="") +"""Stand-in for a charger that has left the snapshot mid-session. + +One instance rather than one per platform: the binary sensor, the number and the +sensor each carried their own, and a fallback that differs between platforms is +one charger rendering three ways. Safe to share because `SpanEvseSnapshot` is +frozen -- every field below the two ids is the `None` the library gives an +unpublished property, so a charger that has gone away reads exactly like one that +never published, which is what the builder below already handles. +""" + + def evse_device_info( panel_identifier: str, evse: SpanEvseSnapshot, From cc5881cf4c71341643263eea94b064f48583972a Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:53:10 -0700 Subject: [PATCH 192/345] refactor(binary_sensor): name the offline-visible sensor set once available() and _handle_coordinator_update() each built the same three-element set inline. They have to agree -- a sensor added to one and not the other would report unknown while offline but drop out of availability, or the reverse -- so the set is now a module-level frozenset both read, following _EV_CONNECTED_STATUSES. --- custom_components/span_panel/binary_sensor.py | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py index a65976d3..c0a5c5e6 100644 --- a/custom_components/span_panel/binary_sensor.py +++ b/custom_components/span_panel/binary_sensor.py @@ -228,6 +228,18 @@ def _grid_islandable(snapshot: SpanPanelSnapshot) -> bool | None: """ +_HARDWARE_STATUS_SENSORS: frozenset[str] = frozenset( + {SYSTEM_DOOR_STATE, SYSTEM_ETHERNET_LINK, SYSTEM_WIFI_LINK} +) +"""The sensors that stay available while the panel is offline, reading unknown. + +Read by `available` and by `_handle_coordinator_update`, which is why it is named +once here: the two have to agree, and a set restated in each is a set that can be +extended in one and not the other -- leaving a sensor that reports unknown while +offline but drops out of the registry's availability, or the reverse. +""" + + class SpanPanelBinarySensor[T: SpanPanelBinarySensorEntityDescription]( SpanPanelEntity, BinarySensorEntity ): @@ -294,15 +306,9 @@ def available(self) -> bool: return False # Hardware status sensors should remain available when offline to show Unknown - hardware_status_sensors = { - SYSTEM_DOOR_STATE, - SYSTEM_ETHERNET_LINK, - SYSTEM_WIFI_LINK, - } - if ( hasattr(self.entity_description, "key") - and self.entity_description.key in hardware_status_sensors + and self.entity_description.key in _HARDWARE_STATUS_SENSORS ): if getattr(self.coordinator, "panel_offline", False): return True @@ -330,15 +336,9 @@ def _handle_coordinator_update(self) -> None: # Check for panel offline status first to prevent accessing None data if self.coordinator.panel_offline or self.coordinator.data is None: - hardware_status_sensors = { - SYSTEM_DOOR_STATE, - SYSTEM_ETHERNET_LINK, - SYSTEM_WIFI_LINK, - } - if ( hasattr(self.entity_description, "key") - and self.entity_description.key in hardware_status_sensors + and self.entity_description.key in _HARDWARE_STATUS_SENSORS ): self._attr_is_on = None self._attr_available = True From 66d0db2ee39b8239730858e26872fe4d1c258cb4 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:57:49 -0700 Subject: [PATCH 193/345] refactor(controls): drop the client capability checks that cannot fail coordinator.client is typed SpanMqttClient, which defines set_circuit_relay, set_circuit_priority, set_dominant_power_source and set_evse_charge_limit, so every guard in front of them was statically unreachable: - switch.async_turn_on/off, select.async_select_option and button.async_press each did hasattr() and logged "Client does not support ..." before returning. - number.async_set_native_value did isinstance(client, EvseControlProtocol) and raised evse_charge_limit_unsupported. SpanMqttClient satisfies that protocol structurally, so the error was unreachable; its strings.json entry and the five locale copies go with it. - button.async_setup_entry gated the override on isinstance(coordinator.client, SpanMqttClient), which is the declared type. Only the has_bess() half of that condition ever decided anything. The single pattern is now no runtime check at all -- the type is the guarantee. EvseControlProtocol stays as the set_fn parameter type, which is what keeps the description free of a transport class. Tests that only drove the removed branches are gone; the button setup test keeps its has_bess coverage without the isinstance scaffolding. --- custom_components/span_panel/button.py | 11 +---- custom_components/span_panel/number.py | 9 +--- custom_components/span_panel/select.py | 4 -- custom_components/span_panel/strings.json | 3 -- custom_components/span_panel/switch.py | 4 -- .../span_panel/translations/en.json | 3 -- .../span_panel/translations/es.json | 3 -- .../span_panel/translations/fr.json | 3 -- .../span_panel/translations/ja.json | 3 -- .../span_panel/translations/pt.json | 3 -- tests/test_button.py | 41 ++----------------- tests/test_circuit_control.py | 30 -------------- tests/test_dps_and_bess.py | 19 +-------- tests/test_evse_charge_limit.py | 17 -------- tests/test_select.py | 29 ------------- 15 files changed, 8 insertions(+), 174 deletions(-) diff --git a/custom_components/span_panel/button.py b/custom_components/span_panel/button.py index 8b21e09e..613b7aee 100644 --- a/custom_components/span_panel/button.py +++ b/custom_components/span_panel/button.py @@ -1,12 +1,11 @@ """Button entities for the Span Panel.""" -import logging from typing import Final from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from span_panel_api import SpanMqttClient, SpanPanelSnapshot +from span_panel_api import SpanPanelSnapshot from .const import CONF_DEVICE_NAME from .control_gate import ControlMode @@ -15,8 +14,6 @@ from .helpers import construct_panel_unique_id_for_entry, has_bess from .runtime import SpanPanelConfigEntry -_LOGGER = logging.getLogger(__name__) - PARALLEL_UPDATES = 1 @@ -70,10 +67,6 @@ async def async_press(self) -> None: live in `_async_control`, which every control here shares. """ client = self.coordinator.client - if not hasattr(client, "set_dominant_power_source"): - _LOGGER.warning("Client does not support GFE override") - return - await self._async_control( client.set_dominant_power_source(self._override_value), command=f"a GFE override to {self._override_value}", @@ -134,7 +127,7 @@ async def async_setup_entry( entities: list[SpanPanelGFEOverrideButton] = [] snapshot: SpanPanelSnapshot = coordinator.data - if isinstance(coordinator.client, SpanMqttClient) and has_bess(snapshot): + if has_bess(snapshot): entities.append(SpanPanelGFEOverrideButton(coordinator, GFE_OVERRIDE_DESCRIPTION, "GRID")) async_add_entities(entities) diff --git a/custom_components/span_panel/number.py b/custom_components/span_panel/number.py index 4e3bafac..6bdd477c 100644 --- a/custom_components/span_panel/number.py +++ b/custom_components/span_panel/number.py @@ -40,7 +40,6 @@ ) from homeassistant.const import UnitOfElectricCurrent from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -52,7 +51,7 @@ ) from .adoption import create_adopted_numbers -from .const import CONF_DEVICE_NAME, DOMAIN, USE_CIRCUIT_NUMBERS +from .const import CONF_DEVICE_NAME, USE_CIRCUIT_NUMBERS from .control_gate import ControlMode from .coordinator import SpanPanelCoordinator from .entity import SpanPanelEntity @@ -258,12 +257,6 @@ async def async_set_native_value(self, value: float) -> None: handed over, which the library promises means it will not arrive later. """ client = self.coordinator.client - if not isinstance(client, EvseControlProtocol): - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="evse_charge_limit_unsupported", - translation_placeholders={"charger": self._evse_id}, - ) await self._async_control( self._description.set_fn(client, self._evse_id, int(value)), command=f"a charge-current limit for {self._evse_id}", diff --git a/custom_components/span_panel/select.py b/custom_components/span_panel/select.py index 133de719..16a7750d 100644 --- a/custom_components/span_panel/select.py +++ b/custom_components/span_panel/select.py @@ -218,10 +218,6 @@ async def async_select_option(self, option: str) -> None: """ _LOGGER.debug("Selecting option: %s", option) client = self.coordinator.client - if not hasattr(client, "set_circuit_priority"): - _LOGGER.warning("Client does not support priority control") - return - priority = CircuitPriority(option) await self._async_control( diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index b6b3ca60..7a022294 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -464,9 +464,6 @@ "evse_charge_limit_not_delivered": { "message": "The charge-current limit for charger {charger} could not be delivered to the panel: {reason}. It was refused rather than queued, so the limit is unchanged." }, - "evse_charge_limit_unsupported": { - "message": "This SPAN panel offers no charge-current control for charger {charger}." - }, "export_manifest_no_entries": { "message": "No SPAN panel configuration entries are loaded. Add and configure a SPAN panel before calling this service." }, diff --git a/custom_components/span_panel/switch.py b/custom_components/span_panel/switch.py index 46ea5016..7cff2a69 100644 --- a/custom_components/span_panel/switch.py +++ b/custom_components/span_panel/switch.py @@ -303,10 +303,6 @@ async def _async_set_relay(self, state: str, *, is_on: bool) -> None: integration shares. """ client = self.coordinator.client - if not hasattr(client, "set_circuit_relay"): - _LOGGER.warning("Client does not support relay control") - return - await self._async_control( client.set_circuit_relay(self._circuit_id, state), command=f"a relay command for {self.entity_id}", diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index b6b3ca60..7a022294 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -464,9 +464,6 @@ "evse_charge_limit_not_delivered": { "message": "The charge-current limit for charger {charger} could not be delivered to the panel: {reason}. It was refused rather than queued, so the limit is unchanged." }, - "evse_charge_limit_unsupported": { - "message": "This SPAN panel offers no charge-current control for charger {charger}." - }, "export_manifest_no_entries": { "message": "No SPAN panel configuration entries are loaded. Add and configure a SPAN panel before calling this service." }, diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index 4caf9775..9e27eede 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -464,9 +464,6 @@ "evse_charge_limit_not_delivered": { "message": "El límite de corriente de carga del cargador {charger} no se pudo entregar al panel: {reason}. Se rechazó en lugar de ponerse en cola, por lo que el límite no ha cambiado." }, - "evse_charge_limit_unsupported": { - "message": "Este panel SPAN no ofrece control de corriente de carga para el cargador {charger}." - }, "export_manifest_no_entries": { "message": "No hay entradas de configuración de SPAN Panel cargadas. Agregue y configure un SPAN Panel antes de llamar a este servicio." }, diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index 43c6c967..897335e2 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -464,9 +464,6 @@ "evse_charge_limit_not_delivered": { "message": "La limite de courant de charge du chargeur {charger} n'a pas pu être transmise au panneau : {reason}. Elle a été refusée plutôt que mise en file d'attente, la limite est donc inchangée." }, - "evse_charge_limit_unsupported": { - "message": "Ce panneau SPAN n'offre aucun contrôle du courant de charge pour le chargeur {charger}." - }, "export_manifest_no_entries": { "message": "Aucune entrée de configuration SPAN Panel n'est chargée. Ajoutez et configurez un panneau SPAN avant d'appeler ce service." }, diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index 8aca6e4c..c11ae3a3 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -464,9 +464,6 @@ "evse_charge_limit_not_delivered": { "message": "充電器 {charger} の充電電流上限をパネルに送信できませんでした: {reason}。キューに入れずに拒否したため、上限は変わっていません。" }, - "evse_charge_limit_unsupported": { - "message": "このSPANパネルは充電器 {charger} の充電電流制御に対応していません。" - }, "export_manifest_no_entries": { "message": "SPANパネルの設定エントリが読み込まれていません。このサービスを呼び出す前に、SPANパネルを追加して設定してください。" }, diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index c0cc74aa..b0529486 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -464,9 +464,6 @@ "evse_charge_limit_not_delivered": { "message": "O limite de corrente de carga do carregador {charger} não pôde ser entregue ao painel: {reason}. Foi recusado em vez de enfileirado, portanto o limite permanece inalterado." }, - "evse_charge_limit_unsupported": { - "message": "Este painel SPAN não oferece controlo da corrente de carga para o carregador {charger}." - }, "export_manifest_no_entries": { "message": "Nenhuma entrada de configuração do painel SPAN está carregada. Adicione e configure um painel SPAN antes de chamar este serviço." }, diff --git a/tests/test_button.py b/tests/test_button.py index 484da6cb..1141ebe0 100644 --- a/tests/test_button.py +++ b/tests/test_button.py @@ -2,7 +2,7 @@ from __future__ import annotations -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock import pytest from span_panel_api import PublishOutcome, PublishState @@ -58,27 +58,6 @@ async def test_gfe_override_button_success_refreshes_coordinator() -> None: coordinator.async_request_refresh.assert_awaited_once() -@pytest.mark.asyncio -async def test_gfe_override_button_logs_when_client_lacks_support( - caplog: pytest.LogCaptureFixture, -) -> None: - """Buttons should return early when the client cannot publish overrides.""" - snapshot = SpanPanelSnapshotFactory.create( - battery=SpanBatterySnapshotFactory.create(connected=False), - dominant_power_source="BATTERY", - ) - coordinator = _make_button_coordinator(snapshot) - coordinator.client = object() - button = SpanPanelGFEOverrideButton(coordinator, GFE_OVERRIDE_DESCRIPTION, "GRID") - - caplog.set_level("WARNING") - - await button.async_press() - - assert "Client does not support GFE override" in caplog.text - coordinator.async_request_refresh.assert_not_awaited() - - @pytest.mark.asyncio async def test_gfe_override_button_refusal_is_raised_at_the_caller() -> None: """A refused override reaches the person who pressed the button.""" @@ -171,28 +150,20 @@ def test_gfe_override_button_available_only_when_override_is_relevant() -> None: @pytest.mark.asyncio -async def test_button_async_setup_entry_only_adds_button_for_mqtt_with_bess( +async def test_button_async_setup_entry_only_adds_button_when_the_panel_has_bess( hass: HomeAssistant, ) -> None: """Button platform should only expose the override when it can work.""" - - class FakeSpanMqttClient: - """Minimal client type used for isinstance checks.""" - snapshot = SpanPanelSnapshotFactory.create( battery=SpanBatterySnapshotFactory.create(connected=False), dominant_power_source="BATTERY", ) coordinator = _make_button_coordinator(snapshot) - coordinator.client = FakeSpanMqttClient() config_entry = MockConfigEntry(domain=DOMAIN, data={}, title="SPAN Panel") config_entry.runtime_data = MagicMock(control_policy=ControlPolicy.default(), coordinator=coordinator) async_add_entities = MagicMock() - with patch( - "custom_components.span_panel.button.SpanMqttClient", FakeSpanMqttClient - ): - await async_setup_entry(hass, config_entry, async_add_entities) + await async_setup_entry(hass, config_entry, async_add_entities) entities = async_add_entities.call_args.args[0] assert len(entities) == 1 @@ -203,13 +174,9 @@ class FakeSpanMqttClient: battery=SpanBatterySnapshotFactory.create(soe_percentage=None) ) ) - coordinator_no_bess.client = FakeSpanMqttClient() config_entry.runtime_data = MagicMock(control_policy=ControlPolicy.default(), coordinator=coordinator_no_bess) async_add_entities = MagicMock() - with patch( - "custom_components.span_panel.button.SpanMqttClient", FakeSpanMqttClient - ): - await async_setup_entry(hass, config_entry, async_add_entities) + await async_setup_entry(hass, config_entry, async_add_entities) assert async_add_entities.call_args.args[0] == [] diff --git a/tests/test_circuit_control.py b/tests/test_circuit_control.py index d7393171..97561271 100644 --- a/tests/test_circuit_control.py +++ b/tests/test_circuit_control.py @@ -263,21 +263,6 @@ def test_switch_extra_state_attributes_include_tabs_and_voltage() -> None: assert switch.extra_state_attributes == {"tabs": "tabs [1]", "voltage": 120} -@pytest.mark.asyncio -async def test_switch_turn_on_without_relay_support_logs_and_returns( - caplog: pytest.LogCaptureFixture, -) -> None: - """Switches should no-op when the client lacks relay control.""" - circuit = SpanCircuitSnapshotFactory.create(circuit_id="1", relay_state="OPEN") - coordinator = _make_coordinator({"1": circuit}) - coordinator.client = MagicMock(spec=[]) - - switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") - await switch.async_turn_on() - - assert "Client does not support relay control" in caplog.text - - def test_switch_relay_state_target_shows_pending_state() -> None: """Switch should show the target state while a relay command is pending.""" # Panel reports OPEN but has a pending target of CLOSED @@ -555,21 +540,6 @@ def test_switch_update_is_on_clears_state_when_circuit_disappears( assert switch.is_on is None -@pytest.mark.asyncio -async def test_switch_turn_off_without_relay_support_logs_and_returns( - caplog: pytest.LogCaptureFixture, -) -> None: - """Turning off should no-op when the client lacks relay control.""" - circuit = SpanCircuitSnapshotFactory.create(circuit_id="1", relay_state="CLOSED") - coordinator = _make_coordinator({"1": circuit}) - coordinator.client = MagicMock(spec=[]) - - switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") - await switch.async_turn_off() - - assert "Client does not support relay control" in caplog.text - - def test_switch_turn_on_off_schedule_async_tasks(hass: HomeAssistant) -> None: """Sync turn_on/turn_off wrappers should schedule async tasks.""" circuit = SpanCircuitSnapshotFactory.create( diff --git a/tests/test_dps_and_bess.py b/tests/test_dps_and_bess.py index 7b21ed84..8bc536bc 100644 --- a/tests/test_dps_and_bess.py +++ b/tests/test_dps_and_bess.py @@ -1,7 +1,7 @@ """Tests for GFE override buttons and BESS connected binary sensor.""" from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock import pytest from span_panel_api import SpanBatterySnapshot @@ -124,23 +124,6 @@ async def test_grid_button_press_publishes_grid(self) -> None: coordinator.client.set_dominant_power_source.assert_called_once_with("GRID") coordinator.async_request_refresh.assert_called_once() - @pytest.mark.asyncio - async def test_button_missing_control_method(self) -> None: - """Client without set_dominant_power_source method logs warning.""" - coordinator = _make_gfe_coordinator() - button = SpanPanelGFEOverrideButton( - coordinator, GFE_OVERRIDE_DESCRIPTION, "GRID" - ) - button.hass = MagicMock() - - # Client without set_dominant_power_source - coordinator.client = MagicMock(spec=[]) - - await button.async_press() - - # Should not raise, just log - coordinator.async_request_refresh.assert_not_called() - @pytest.mark.asyncio async def test_button_server_error(self) -> None: """SpanPanelServerError surfaces as a translated Home Assistant error.""" diff --git a/tests/test_evse_charge_limit.py b/tests/test_evse_charge_limit.py index de9a6166..6a37401f 100644 --- a/tests/test_evse_charge_limit.py +++ b/tests/test_evse_charge_limit.py @@ -633,23 +633,6 @@ async def test_the_range_home_assistant_checks_is_the_commissioned_one(hass: Hom assert entity.native_min_value == 0 -async def test_a_client_with_no_evse_control_is_reported_rather_than_ignored( - hass: HomeAssistant, -) -> None: - """A transport that does not implement the control at all — a flat panel's.""" - - class _NoEvseControl: - """Everything but `set_evse_charge_limit`.""" - - tree = schema_one_tree() - created = await _created(hass, schema_one_snapshot(tree), _NoEvseControl()) - - with pytest.raises(HomeAssistantError) as raised: - await _for(created, tree, EVSE).async_set_native_value(16.0) - - assert raised.value.translation_key == "evse_charge_limit_unsupported" - - # --------------------------------------------------------------------------- # The declarations this entity makes about itself # --------------------------------------------------------------------------- diff --git a/tests/test_select.py b/tests/test_select.py index 87e8c3b5..62021bfb 100644 --- a/tests/test_select.py +++ b/tests/test_select.py @@ -156,35 +156,6 @@ async def test_async_select_option_success_refreshes_coordinator() -> None: coordinator.async_request_refresh.assert_awaited_once() -@pytest.mark.asyncio -async def test_async_select_option_without_priority_support_returns_early( - caplog: pytest.LogCaptureFixture, -) -> None: - """Clients without priority support should log and return without refresh.""" - coordinator = _make_coordinator_with_circuit() - coordinator.hass = MagicMock() - coordinator.async_request_refresh = AsyncMock() - - with patch( - "custom_components.span_panel.select.er.async_get" - ) as mock_async_get: - registry = MagicMock() - registry.async_get_entity_id.return_value = None - mock_async_get.return_value = registry - select = SpanPanelCircuitsSelect( - coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "SPAN Panel" - ) - - coordinator.client = object() - select.hass = MagicMock() - caplog.set_level("WARNING") - - await select.async_select_option(CircuitPriority.SOC_THRESHOLD.value) - - assert "Client does not support priority control" in caplog.text - coordinator.async_request_refresh.assert_not_awaited() - - def test_select_uses_circuit_numbers_for_the_base_when_the_option_is_enabled() -> None: """The option decides the id base; the displayed name is the panel's regardless. From 560769a4f699dbe340f0d779239d4b7f1a191591 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:01:18 -0700 Subject: [PATCH 194/345] refactor(diagnostics): read device_type and tabs straight off the circuit SpanCircuitSnapshot is frozen and declares both fields -- tabs is required, device_type defaults to "circuit" -- so the hasattr() guards could never be false against a real snapshot. Both keys move into the dict literal with the rest of the circuit's fields. The one thing the guards did serve was a test that built its circuit as a SimpleNamespace omitting those two fields. That double now uses SpanCircuitSnapshotFactory, like the test beside it already did, so it cannot drift from the model again. --- custom_components/span_panel/diagnostics.py | 6 ++---- tests/test_diagnostics.py | 16 +++++++++++----- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/custom_components/span_panel/diagnostics.py b/custom_components/span_panel/diagnostics.py index 8af0a0ec..14df690f 100644 --- a/custom_components/span_panel/diagnostics.py +++ b/custom_components/span_panel/diagnostics.py @@ -298,11 +298,9 @@ async def async_get_config_entry_diagnostics( "instant_power_w": circuit.instant_power_w, "produced_energy_wh": circuit.produced_energy_wh, "consumed_energy_wh": circuit.consumed_energy_wh, + "device_type": circuit.device_type, + "tabs": circuit.tabs, } - if hasattr(circuit, "device_type"): - circuit_data[circuit_id]["device_type"] = circuit.device_type - if hasattr(circuit, "tabs"): - circuit_data[circuit_id]["tabs"] = circuit.tabs evse_data: dict[str, dict[str, Any]] = {} if snapshot.evse: diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 48b6ab7e..a866ac4d 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -148,16 +148,20 @@ async def test_config_entry_diagnostics_omits_optional_sections_when_unavailable eth0_link=None, wlan_link=None, circuits={ - "uuid_minimal": SimpleNamespace( - name=None, + # The real model, not a namespace: every field the diagnostics dump + # reads off a circuit is present on `SpanCircuitSnapshot`, and a hand + # -rolled double that omitted two of them is what kept a pair of + # unreachable `hasattr` guards alive in the dump. + "uuid_minimal": SpanCircuitSnapshotFactory.create( + circuit_id="uuid_minimal", + name="", relay_state="OPEN", - relay_state_target=None, priority="NEVER", - priority_target=None, is_user_controllable=False, instant_power_w=0.0, produced_energy_wh=0.0, consumed_energy_wh=0.0, + tabs=[], ) }, evse={}, @@ -187,7 +191,7 @@ async def test_config_entry_diagnostics_omits_optional_sections_when_unavailable "power_flow_grid": None, } assert result["circuits"]["uuid_minimal"] == { - "name": None, + "name": "", "relay_state": "OPEN", "relay_state_target": None, "priority": "NEVER", @@ -196,6 +200,8 @@ async def test_config_entry_diagnostics_omits_optional_sections_when_unavailable "instant_power_w": 0.0, "produced_energy_wh": 0.0, "consumed_energy_wh": 0.0, + "device_type": "circuit", + "tabs": [], } assert result["evse"] == {} assert result["battery"] == {} From d6d3ae764a1d11d4253ff020ad435929bc31b905 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:03:21 -0700 Subject: [PATCH 195/345] docs(extension): describe the fall-through the hint actually takes; freeze the unit map prominence_hint's third rank named node_has_curated_siblings, in both the docstring and developer.md. No such function exists and nothing is recorded -- the branch is a plain fall-through to detail, and the node a property hangs off is deliberately never consulted. Both places now say that. DEVICE_CLASS_BY_UNIT was a bare dict, so a lookup table two modules read could be mutated by either. It is now Mapping + MappingProxyType, matching RESIDUAL_EXEMPT_PATHS in field_paths.py and _PANEL_BINARY_SENSOR_CLASSES in binary_sensor.py. --- custom_components/span_panel/adoption.py | 32 +++++++++++++---------- custom_components/span_panel/extension.py | 9 ++++--- developer.md | 4 +-- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/custom_components/span_panel/adoption.py b/custom_components/span_panel/adoption.py index 6247484c..ad4c8af3 100644 --- a/custom_components/span_panel/adoption.py +++ b/custom_components/span_panel/adoption.py @@ -34,8 +34,10 @@ from __future__ import annotations +from collections.abc import Mapping import logging from math import isfinite +from types import MappingProxyType from typing import TYPE_CHECKING, Final from homeassistant.components.binary_sensor import BinarySensorEntity @@ -108,20 +110,22 @@ def clamp_state(published: str, wire_path: str) -> str: return published[:MAX_STATE_LENGTH] -DEVICE_CLASS_BY_UNIT: dict[str, SensorDeviceClass] = { - "W": SensorDeviceClass.POWER, - "kW": SensorDeviceClass.POWER, - "Wh": SensorDeviceClass.ENERGY, - "kWh": SensorDeviceClass.ENERGY, - "V": SensorDeviceClass.VOLTAGE, - "A": SensorDeviceClass.CURRENT, - "Hz": SensorDeviceClass.FREQUENCY, - "VA": SensorDeviceClass.APPARENT_POWER, - "var": SensorDeviceClass.REACTIVE_POWER, - "°C": SensorDeviceClass.TEMPERATURE, - "°F": SensorDeviceClass.TEMPERATURE, - "s": SensorDeviceClass.DURATION, -} +DEVICE_CLASS_BY_UNIT: Mapping[str, SensorDeviceClass] = MappingProxyType( + { + "W": SensorDeviceClass.POWER, + "kW": SensorDeviceClass.POWER, + "Wh": SensorDeviceClass.ENERGY, + "kWh": SensorDeviceClass.ENERGY, + "V": SensorDeviceClass.VOLTAGE, + "A": SensorDeviceClass.CURRENT, + "Hz": SensorDeviceClass.FREQUENCY, + "VA": SensorDeviceClass.APPARENT_POWER, + "var": SensorDeviceClass.REACTIVE_POWER, + "°C": SensorDeviceClass.TEMPERATURE, + "°F": SensorDeviceClass.TEMPERATURE, + "s": SensorDeviceClass.DURATION, + } +) """Units this integration is willing to claim a device class for, enumerated. Enumerated rather than inferred, and the omissions are the point. `%` is absent diff --git a/custom_components/span_panel/extension.py b/custom_components/span_panel/extension.py index 92f12729..4e0eca55 100644 --- a/custom_components/span_panel/extension.py +++ b/custom_components/span_panel/extension.py @@ -280,10 +280,11 @@ def prominence_hint(row: ExtensionProperty) -> str: a `%` state of charge, and `%` is deliberately absent from `DEVICE_CLASS_BY_UNIT` because it is equally a confidence or a duty cycle. So this signal may promote and never demote. - 3. **Everything else → `detail`**, with `node_has_curated_siblings` recorded - beside it as corroboration rather than as a decision. Homie nodes are - organisational rather than editorial: vendors hang configuration knobs off - `meter` because that is where the code was. + 3. **Everything else → `detail`**, by fall-through rather than by a third + signal. The node a property hangs off is the obvious candidate for one and + is deliberately not consulted: Homie nodes are organisational rather than + editorial, and vendors hang configuration knobs off `meter` because that + is where the code was. The upstream `role` declaration proposed alongside this design would retire all three for compliant publishers, which is why it is worth asking for. diff --git a/developer.md b/developer.md index 8845ea22..77ca3bf4 100644 --- a/developer.md +++ b/developer.md @@ -809,8 +809,8 @@ confidence, and the ranking is the argument: firmware build is device description. 2. **A unit in `DEVICE_CLASS_BY_UNIT` → reading.** Moderate, and it may promote but never demote, because it fails systematically in one direction: the most headline-worthy number a battery publishes is a `%` state of charge, and `%` is absent from that map on purpose, being equally a confidence or a duty cycle. -3. **Everything else → detail**, with `node_has_curated_siblings` recorded as corroboration rather than as a decision. Homie nodes are organisational, not - editorial. +3. **Everything else → detail**, by fall-through rather than a third signal. The node a property hangs off is the obvious candidate for one and is deliberately + not consulted: Homie nodes are organisational, not editorial. The real fix is upstream: a declared `role` on the property, proposed in `SpanPanel_Docs/span/docs/dev/ebus-property-role-proposal.md`. Until then the ranking is the shipping plan, and `entity_category` being free to revise is what makes a conservative default cheap. From 181dc114a56451bd9dd2cc4fca374bcf5c5a47f4 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:29:45 -0700 Subject: [PATCH 196/345] chore(tests): drop two more empty test files test_power_debug.py and yaml_factories.py are both zero bytes and nothing imports or references either. --- tests/test_power_debug.py | 0 tests/yaml_factories.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 tests/test_power_debug.py delete mode 100644 tests/yaml_factories.py diff --git a/tests/test_power_debug.py b/tests/test_power_debug.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/yaml_factories.py b/tests/yaml_factories.py deleted file mode 100644 index e69de29b..00000000 From 51b0abc38d654346ae01b71db053635b48ebd93b Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:33:26 -0700 Subject: [PATCH 197/345] refactor(sensor_circuit): one circuit-sensor base, one API key map SpanCircuitPowerSensor and SpanCircuitEnergySensor restated the same identity logic: a byte-identical __init__ description-copy block, a _generate_unique_id and an _object_id_parts differing only in which _API_KEY_MAP they read, and an identical get_data_source. The two maps were never duplicates -- their key sets are disjoint, and sensor.py hands each class only its own three keys -- so they collapse into one module-level MappingProxyType that neither can shadow. The rest moves to SpanCircuitSensorBase, which the power sensor extends directly and the energy sensor mixes with SpanEnergySensorBase. Cooperative super() keeps the grace-period init, _process_raw_value and extra_state_attributes reaching the energy base as before. Each subclass now holds only what genuinely differs: its residual field paths, its displayed name, and its state attributes. The description copy is now dataclasses.replace. The hand-written field list had already drifted -- the energy class omitted entity_category -- and only circuit_breaker_rating sets one, which is a power-sensor key, so no entity changes. Verified field-by-field against all five circuit descriptions. _ENERGY_TYPE_MAP is frozen the same way while here. No unique id, entity id, name or object-id base changes: test_naming.py, test_recreate_entity_ids.py and test_unique_id_generation.py pass unchanged. --- .../span_panel/sensor_circuit.py | 238 ++++++++---------- 1 file changed, 98 insertions(+), 140 deletions(-) diff --git a/custom_components/span_panel/sensor_circuit.py b/custom_components/span_panel/sensor_circuit.py index ed4f47ae..71838f4d 100644 --- a/custom_components/span_panel/sensor_circuit.py +++ b/custom_components/span_panel/sensor_circuit.py @@ -3,6 +3,8 @@ from __future__ import annotations from collections.abc import Mapping +from dataclasses import replace +from types import MappingProxyType from typing import Any, ClassVar from homeassistant.helpers.device_registry import DeviceInfo @@ -87,21 +89,47 @@ def _resolve_circuit_identifier_for_sync(circuit: SpanCircuitSnapshot, circuit_i return _unnamed_circuit_fallback(circuit, circuit_id) -class SpanCircuitPowerSensor( +_API_KEY_MAP: Mapping[str, str] = MappingProxyType( + { + # Instantaneous readings. + "circuit_power": "instantPowerW", + "circuit_current": "current", + "circuit_breaker_rating": "breaker_rating", + # Totals, under the original API names migration normalized them from. + "circuit_energy_produced": "producedEnergyWh", + "circuit_energy_consumed": "consumedEnergyWh", + "circuit_energy_net": "netEnergyWh", + } +) +"""Catalog key to the wire key a circuit sensor's identity is built from. + +Both the unique id and the entity-id suffix key on this rather than on the +circuit id, because the description handed to Core is rebuilt around the circuit +id and no longer remembers which sensor it is. + +One table for both families, which are disjoint by construction: `sensor.py` +hands the first three keys only to `SpanCircuitPowerSensor` and the last three +only to `SpanCircuitEnergySensor`, so neither can shadow the other. It used to +be two class attributes of the same name, which is how they were mistaken for a +duplicate of each other. +""" + + +class SpanCircuitSensorBase( SpanSensorBase[SpanPanelCircuitsSensorEntityDescription, SpanCircuitSnapshot] ): - """Circuit power/current/breaker-rating sensor with extra state attributes.""" + """What a circuit's power and energy sensors share: how they identify themselves. - # Beyond the value the description declares: `name` and `tabs` build the - # entity's identity (~56-83), and `tabs`, `relay_state`, `relay_requester` - # and `priority` are republished as state attributes (~231-243). - _residual_field_paths: ClassVar[tuple[str, ...]] = ( - "circuit.name", - "circuit.tabs", - "circuit.relay_state", - "circuit.relay_requester", - "circuit.priority", - ) + Both are handed a catalog description and a circuit id, rebuild the + description around that id so the base class looks the reading up by it, and + key their unique id and entity-id suffix on `_API_KEY_MAP`. What genuinely + differs stays on the two subclasses: the residual field paths, the displayed + name, and the state attributes. + + Cooperative `super()` throughout, because the energy sensor mixes this with + `SpanEnergySensorBase`: the `__init__` below reaches the grace-period base on + that class and `SpanSensorBase` directly on the power one. + """ def __init__( self, @@ -111,50 +139,72 @@ def __init__( circuit_id: str, device_info_override: DeviceInfo | None = None, ) -> None: - """Initialize the enhanced circuit power sensor.""" + """Initialize a circuit sensor bound to one circuit.""" + # Set before `super().__init__`, which calls the three methods below + # while it composes the entity's identity. self.circuit_id = circuit_id self.original_key = description.key self._is_sub_device = device_info_override is not None - # Override the description key to use the circuit_id for data lookup - description_with_circuit = SpanPanelCircuitsSensorEntityDescription( - key=circuit_id, - name=description.name, - native_unit_of_measurement=description.native_unit_of_measurement, - state_class=description.state_class, - suggested_display_precision=description.suggested_display_precision, - device_class=description.device_class, - value_fn=description.value_fn, - field_path=description.field_path, - derived=description.derived, - entity_registry_enabled_default=description.entity_registry_enabled_default, - entity_registry_visible_default=description.entity_registry_visible_default, - entity_category=description.entity_category, - legacy_names=description.legacy_names, - ) - - super().__init__(data_coordinator, description_with_circuit, snapshot) + # The key becomes the circuit id because the base class looks its data + # up by it; `original_key` keeps what the catalog called this sensor. + # + # `replace` rather than the field-by-field copy this replaced: that copy + # was restated in both classes and had already drifted -- the energy one + # omitted `entity_category` -- and any field added to the description + # later would have been dropped by both. + super().__init__(data_coordinator, replace(description, key=circuit_id), snapshot) if device_info_override is not None: self._attr_device_info = device_info_override - # Map original description keys to API keys for unique ID generation - _API_KEY_MAP: dict[str, str] = { - "circuit_power": "instantPowerW", - "circuit_current": "current", - "circuit_breaker_rating": "breaker_rating", - } + @property + def _api_key(self) -> str: + """The wire key this sensor's unique id and entity-id suffix are built from.""" + return _API_KEY_MAP.get(self.original_key, self.original_key) def _generate_unique_id( self, snapshot: SpanPanelSnapshot, description: SpanPanelCircuitsSensorEntityDescription, ) -> str: - """Generate unique ID for circuit power sensors.""" - api_key = self._API_KEY_MAP.get(self.original_key, self.original_key) + """Generate unique ID for circuit sensors.""" return construct_circuit_unique_id_for_entry( - self.coordinator, snapshot, self.circuit_id, api_key, self._device_name + self.coordinator, snapshot, self.circuit_id, self._api_key, self._device_name + ) + + def _object_id_parts( + self, + snapshot: SpanPanelSnapshot, + description: SpanPanelCircuitsSensorEntityDescription, + ) -> tuple[str, str] | None: + """Return the circuit identifier and the suffix this sensor's id ends with.""" + circuit = snapshot.circuits.get(self.circuit_id) + if not circuit: + return None + identifier = _resolve_circuit_identifier( + circuit, self.circuit_id, self.coordinator.config_entry.options ) + return identifier, get_user_friendly_suffix(self._api_key) + + def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanCircuitSnapshot: + """Get the circuit this sensor reads.""" + return _get_circuit_data_source(self.circuit_id, snapshot) + + +class SpanCircuitPowerSensor(SpanCircuitSensorBase): + """Circuit power/current/breaker-rating sensor with extra state attributes.""" + + # Beyond the value the description declares: `name` and `tabs` build the + # entity's identity (~56-83), and `tabs`, `relay_state`, `relay_requester` + # and `priority` are republished as state attributes (~231-243). + _residual_field_paths: ClassVar[tuple[str, ...]] = ( + "circuit.name", + "circuit.tabs", + "circuit.relay_state", + "circuit.relay_requester", + "circuit.priority", + ) def _generate_panel_name( self, @@ -174,27 +224,6 @@ def _generate_panel_name( circuit_identifier = _resolve_circuit_identifier_for_sync(circuit, self.circuit_id) return f"{circuit_identifier} {description.name or 'Sensor'}" - def _object_id_parts( - self, - snapshot: SpanPanelSnapshot, - description: SpanPanelCircuitsSensorEntityDescription, - ) -> tuple[str, str] | None: - """Return the circuit identifier and the suffix this sensor's id ends with.""" - circuit = snapshot.circuits.get(self.circuit_id) - if not circuit: - return None - identifier = _resolve_circuit_identifier( - circuit, self.circuit_id, self.coordinator.config_entry.options - ) - suffix = get_user_friendly_suffix( - self._API_KEY_MAP.get(self.original_key, self.original_key) - ) - return identifier, suffix - - def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanCircuitSnapshot: - """Get the data source for the circuit power sensor.""" - return _get_circuit_data_source(self.circuit_id, snapshot) - @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return additional state attributes.""" @@ -248,55 +277,14 @@ def extra_state_attributes(self) -> dict[str, Any] | None: class SpanCircuitEnergySensor( - SpanEnergySensorBase[SpanPanelCircuitsSensorEntityDescription, SpanCircuitSnapshot] + SpanCircuitSensorBase, + SpanEnergySensorBase[SpanPanelCircuitsSensorEntityDescription, SpanCircuitSnapshot], ): """Circuit energy sensor with grace period tracking.""" # Naming only; this sensor publishes no circuit attributes. _residual_field_paths: ClassVar[tuple[str, ...]] = ("circuit.name", "circuit.tabs") - # Map new description keys to the original API keys that migration - # normalized from; both the unique id and the entity-id suffix key on them. - _API_KEY_MAP: dict[str, str] = { - "circuit_energy_produced": "producedEnergyWh", - "circuit_energy_consumed": "consumedEnergyWh", - "circuit_energy_net": "netEnergyWh", - } - - def __init__( - self, - data_coordinator: SpanPanelCoordinator, - description: SpanPanelCircuitsSensorEntityDescription, - snapshot: SpanPanelSnapshot, - circuit_id: str, - device_info_override: DeviceInfo | None = None, - ) -> None: - """Initialize the circuit energy sensor.""" - self.circuit_id = circuit_id - self.original_key = description.key - self._is_sub_device = device_info_override is not None - - # Override the description key to use the circuit_id for data lookup - description_with_circuit = SpanPanelCircuitsSensorEntityDescription( - key=circuit_id, - name=description.name, - native_unit_of_measurement=description.native_unit_of_measurement, - state_class=description.state_class, - suggested_display_precision=description.suggested_display_precision, - device_class=description.device_class, - value_fn=description.value_fn, - field_path=description.field_path, - derived=description.derived, - entity_registry_enabled_default=description.entity_registry_enabled_default, - entity_registry_visible_default=description.entity_registry_visible_default, - legacy_names=description.legacy_names, - ) - - super().__init__(data_coordinator, description_with_circuit, snapshot) - - if device_info_override is not None: - self._attr_device_info = device_info_override - async def async_added_to_hass(self) -> None: """Register consumed/produced sensors on the coordinator for net energy lookup.""" await super().async_added_to_hass() @@ -304,17 +292,6 @@ async def async_added_to_hass(self) -> None: if energy_type: self.coordinator.register_circuit_energy_sensor(self.circuit_id, energy_type, self) - def _generate_unique_id( - self, - snapshot: SpanPanelSnapshot, - description: SpanPanelCircuitsSensorEntityDescription, - ) -> str: - """Generate unique ID for circuit energy sensors.""" - api_key = self._API_KEY_MAP.get(self.original_key, self.original_key) - return construct_circuit_unique_id_for_entry( - self.coordinator, snapshot, self.circuit_id, api_key, self._device_name - ) - def _generate_panel_name( self, snapshot: SpanPanelSnapshot, @@ -331,28 +308,13 @@ def _generate_panel_name( circuit_identifier = _resolve_circuit_identifier_for_sync(circuit, self.circuit_id) return f"{circuit_identifier} {description.name}" - def _object_id_parts( - self, - snapshot: SpanPanelSnapshot, - description: SpanPanelCircuitsSensorEntityDescription, - ) -> tuple[str, str] | None: - """Return the circuit identifier and the suffix this sensor's id ends with.""" - circuit = snapshot.circuits.get(self.circuit_id) - if not circuit: - return None - identifier = _resolve_circuit_identifier( - circuit, self.circuit_id, self.coordinator.config_entry.options - ) - suffix = get_user_friendly_suffix( - self._API_KEY_MAP.get(self.original_key, self.original_key) - ) - return identifier, suffix - # Map original_key to the energy type used for coordinator dip offset tracking - _ENERGY_TYPE_MAP: dict[str, str] = { - "circuit_energy_consumed": "consumed", - "circuit_energy_produced": "produced", - } + _ENERGY_TYPE_MAP: ClassVar[Mapping[str, str]] = MappingProxyType( + { + "circuit_energy_consumed": "consumed", + "circuit_energy_produced": "produced", + } + ) def _process_raw_value(self, raw_value: float | str | None) -> None: """Process raw value, adjusting net energy for dip compensation consistency. @@ -370,10 +332,6 @@ def _process_raw_value(self, raw_value: float | str | None) -> None: if net_adjustment: self._attr_native_value += net_adjustment - def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanCircuitSnapshot: - """Get the data source for the circuit energy sensor.""" - return _get_circuit_data_source(self.circuit_id, snapshot) - @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return additional state attributes including grace period and circuit info.""" From c95841d34f12a885b014d0fe5aab8dec8977b38e Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:35:23 -0700 Subject: [PATCH 198/345] refactor: remove the remaining guards that cannot fail Same class as the control-capability checks, in five more places: - coordinator._run_schema_validation gated field_metadata on isinstance(self._client, SpanPanelClientProtocol). _client is SpanMqttClient, which carries all three protocol members, so the None arm was unreachable -- though field_metadata itself still returns None during a rebuild, so the check below it stays. The docstring no longer claims a protocol read that is gone. - binary_sensor tested hasattr(self.entity_description, "key") four times. key is a required field on EntityDescription. - getattr(self.coordinator, "panel_offline", False) in binary_sensor, button, select, switch and sensor_base. SpanPanelEntity annotates coordinator: SpanPanelCoordinator, which declares the property. - sensor_base.available wrapped that getattr in try/except AttributeError plus a bare except. panel_offline returns a bool field and cannot raise, so the whole block collapses to the read. - sensor_circuit._unnamed_circuit_fallback read device_type through getattr with a "circuit" default, which is the field's own declared default. No test drove any of the removed branches. --- custom_components/span_panel/binary_sensor.py | 16 +++++----------- custom_components/span_panel/button.py | 2 +- custom_components/span_panel/coordinator.py | 12 +++--------- custom_components/span_panel/select.py | 2 +- custom_components/span_panel/sensor_base.py | 13 +++---------- custom_components/span_panel/sensor_circuit.py | 5 ++--- custom_components/span_panel/switch.py | 2 +- 7 files changed, 16 insertions(+), 36 deletions(-) diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py index c0a5c5e6..6d6268eb 100644 --- a/custom_components/span_panel/binary_sensor.py +++ b/custom_components/span_panel/binary_sensor.py @@ -299,18 +299,15 @@ def available(self) -> bool: return False # Panel status sensor should always be available to show online/offline state - if hasattr(self.entity_description, "key") and self.entity_description.key == PANEL_STATUS: + if self.entity_description.key == PANEL_STATUS: return True if not self._transport_available: return False # Hardware status sensors should remain available when offline to show Unknown - if ( - hasattr(self.entity_description, "key") - and self.entity_description.key in _HARDWARE_STATUS_SENSORS - ): - if getattr(self.coordinator, "panel_offline", False): + if self.entity_description.key in _HARDWARE_STATUS_SENSORS: + if self.coordinator.panel_offline: return True if getattr(self, "_attr_available", True) is False: @@ -321,7 +318,7 @@ def available(self) -> bool: def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" # Special handling for panel_status sensor - if hasattr(self.entity_description, "key") and self.entity_description.key == PANEL_STATUS: + if self.entity_description.key == PANEL_STATUS: # Both flags, because both mean "no panel at the other end". A dead # transport is deliberately not marked offline -- that flag makes # the other entities hold their last reading -- and reading it here @@ -336,10 +333,7 @@ def _handle_coordinator_update(self) -> None: # Check for panel offline status first to prevent accessing None data if self.coordinator.panel_offline or self.coordinator.data is None: - if ( - hasattr(self.entity_description, "key") - and self.entity_description.key in _HARDWARE_STATUS_SENSORS - ): + if self.entity_description.key in _HARDWARE_STATUS_SENSORS: self._attr_is_on = None self._attr_available = True _LOGGER.debug( diff --git a/custom_components/span_panel/button.py b/custom_components/span_panel/button.py index 613b7aee..ea62478e 100644 --- a/custom_components/span_panel/button.py +++ b/custom_components/span_panel/button.py @@ -98,7 +98,7 @@ def available(self) -> bool: """ if not self._transport_available: return False - if getattr(self.coordinator, "panel_offline", False): + if self.coordinator.panel_offline: return False if not super().available: return False diff --git a/custom_components/span_panel/coordinator.py b/custom_components/span_panel/coordinator.py index 437deeb3..1a0241da 100644 --- a/custom_components/span_panel/coordinator.py +++ b/custom_components/span_panel/coordinator.py @@ -22,7 +22,7 @@ ) from homeassistant.helpers import entity_registry as er from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from span_panel_api import SpanMqttClient, SpanPanelClientProtocol, SpanPanelSnapshot +from span_panel_api import SpanMqttClient, SpanPanelSnapshot from span_panel_api.exceptions import ( SpanPanelAuthError, SpanPanelCAChangedError, @@ -521,15 +521,9 @@ async def async_shutdown(self) -> None: def _run_schema_validation(self) -> None: """Classify the adapter's field metadata once at startup. - Reads the metadata through ``SpanPanelClientProtocol`` so this module - never names a transport class, and stores the result for the platforms - and the Repairs reconciler to read. + Stores the result for the platforms and the Repairs reconciler to read. """ - field_metadata = ( - self._client.field_metadata - if isinstance(self._client, SpanPanelClientProtocol) - else None - ) + field_metadata = self._client.field_metadata if field_metadata is None: # "Unknown", NOT "nothing is wrong". `field_metadata` is None for the diff --git a/custom_components/span_panel/select.py b/custom_components/span_panel/select.py index 16a7750d..7a7c848b 100644 --- a/custom_components/span_panel/select.py +++ b/custom_components/span_panel/select.py @@ -243,7 +243,7 @@ def available(self) -> bool: """ if not self._transport_available: return False - if getattr(self.coordinator, "panel_offline", False): + if self.coordinator.panel_offline: return False return super().available diff --git a/custom_components/span_panel/sensor_base.py b/custom_components/span_panel/sensor_base.py index 4734eed2..536f1689 100644 --- a/custom_components/span_panel/sensor_base.py +++ b/custom_components/span_panel/sensor_base.py @@ -351,15 +351,8 @@ def available(self) -> bool: return False if not self._transport_available: return False - try: - if getattr(self.coordinator, "panel_offline", False): - return True - except AttributeError as err: - # If coordinator is missing expected attribute, log and fall back - _LOGGER.debug("Availability check: missing coordinator attribute: %s", err) - except Exception as err: # noqa: BLE001 # pragma: no cover - defensive - # Any unexpected error shouldn't crash the availability check - _LOGGER.debug("Availability check: unexpected error: %s", err) + if self.coordinator.panel_offline: + return True return super().available @property @@ -856,7 +849,7 @@ def extra_state_attributes(self) -> dict[str, Any] | None: attributes["grace_period_remaining"] = str(remaining_minutes) # Indicate if we're currently using grace period - panel_offline = getattr(self.coordinator, "panel_offline", False) + panel_offline = self.coordinator.panel_offline if panel_offline and remaining_seconds > 0: attributes["using_grace_period"] = "True" diff --git a/custom_components/span_panel/sensor_circuit.py b/custom_components/span_panel/sensor_circuit.py index 71838f4d..74aa0f8e 100644 --- a/custom_components/span_panel/sensor_circuit.py +++ b/custom_components/span_panel/sensor_circuit.py @@ -46,10 +46,9 @@ def _unnamed_circuit_fallback(circuit: SpanCircuitSnapshot, circuit_id: str) -> PV circuits use "Solar" (matching v1 naming), EVSE circuits use "EV Charger", all others use tab-based naming. """ - device_type = getattr(circuit, "device_type", "circuit") - if device_type in _SOLAR_DEVICE_TYPES: + if circuit.device_type in _SOLAR_DEVICE_TYPES: return "Solar" - if device_type in _EVSE_DEVICE_TYPES: + if circuit.device_type in _EVSE_DEVICE_TYPES: return "EV Charger" return construct_circuit_identifier_from_tabs(circuit.tabs, circuit_id) diff --git a/custom_components/span_panel/switch.py b/custom_components/span_panel/switch.py index 7cff2a69..3024d6c4 100644 --- a/custom_components/span_panel/switch.py +++ b/custom_components/span_panel/switch.py @@ -211,7 +211,7 @@ def available(self) -> bool: """ if not self._transport_available: return False - if getattr(self.coordinator, "panel_offline", False): + if self.coordinator.panel_offline: return False return super().available From 61f5e3f03e466e21aca355458019b8016d8f9f19 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:35:35 -0700 Subject: [PATCH 199/345] style(tests): organize imports in the three files this sweep edited ruff I001, from imports removed earlier in the sweep. tests/ is excluded from the ruff hooks, so this is run explicitly. --- tests/test_button.py | 7 +++---- tests/test_diagnostics.py | 10 +++++----- tests/test_dps_and_bess.py | 2 +- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/tests/test_button.py b/tests/test_button.py index 1141ebe0..c5a3eddd 100644 --- a/tests/test_button.py +++ b/tests/test_button.py @@ -4,7 +4,10 @@ from unittest.mock import AsyncMock, MagicMock +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry from span_panel_api import PublishOutcome, PublishState from span_panel_api.exceptions import SpanPanelServerError @@ -15,13 +18,9 @@ ) from custom_components.span_panel.const import DOMAIN from custom_components.span_panel.control_gate import ControlPolicy -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from .factories import SpanBatterySnapshotFactory, SpanPanelSnapshotFactory -from pytest_homeassistant_custom_component.common import MockConfigEntry - def _make_button_coordinator(snapshot) -> MagicMock: """Create a coordinator-like mock for button tests.""" diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index a866ac4d..9e135ae6 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -6,6 +6,11 @@ from unittest.mock import MagicMock from homeassistant.components.diagnostics import REDACTED +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from pytest_homeassistant_custom_component.common import MockConfigEntry + from custom_components.span_panel import SpanPanelRuntimeData from custom_components.span_panel.const import ( CONF_EBUS_BROKER_PASSWORD, @@ -16,9 +21,6 @@ from custom_components.span_panel.diagnostics import ( async_get_config_entry_diagnostics, ) -from homeassistant.const import CONF_ACCESS_TOKEN -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er from .factories import ( SpanBatterySnapshotFactory, @@ -27,8 +29,6 @@ SpanPanelSnapshotFactory, ) -from pytest_homeassistant_custom_component.common import MockConfigEntry - async def test_config_entry_diagnostics_includes_redacted_runtime_data( hass: HomeAssistant, diff --git a/tests/test_dps_and_bess.py b/tests/test_dps_and_bess.py index 8bc536bc..9193c352 100644 --- a/tests/test_dps_and_bess.py +++ b/tests/test_dps_and_bess.py @@ -3,6 +3,7 @@ from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock +from homeassistant.exceptions import HomeAssistantError import pytest from span_panel_api import SpanBatterySnapshot from span_panel_api.exceptions import SpanPanelServerError @@ -15,7 +16,6 @@ from custom_components.span_panel.helpers import has_bess from custom_components.span_panel.sensor_definitions import BESS_METADATA_SENSORS from custom_components.span_panel.sensor_panel import _grid_forming_device_name -from homeassistant.exceptions import HomeAssistantError from .factories import SpanPanelSnapshotFactory From 662e94524ab519edfb248da0e009a83ba523c3f0 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:39:12 -0700 Subject: [PATCH 200/345] fix(number): name the charger the user sees when a charge-limit write fails The charger placeholder carried self._evse_id -- the serial-harmonised snapshot key, which is neither the node id the wire addresses nor anything shown on a dashboard. Both failure messages reach a person, so both now carry the name on the charger's device card. evse_device_info built that name inline. It moves to util.evse_display_name, which the builder now calls, so the card and any message about the charger cannot drift apart. Also sorts the imports in the touched test file (pre-existing ruff I001; tests/ is outside the ruff hooks). --- custom_components/span_panel/number.py | 15 +++++++++++---- custom_components/span_panel/util.py | 23 +++++++++++++++++++---- tests/test_evse_charge_limit.py | 25 +++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/custom_components/span_panel/number.py b/custom_components/span_panel/number.py index 6bdd477c..4eb10249 100644 --- a/custom_components/span_panel/number.py +++ b/custom_components/span_panel/number.py @@ -58,7 +58,7 @@ from .field_paths import DerivedReason, FieldPathDeclarationMixin from .helpers import build_evse_unique_id_for_entry, resolve_evse_display_suffix from .runtime import SpanPanelConfigEntry -from .util import EMPTY_EVSE, evse_device_info +from .util import EMPTY_EVSE, evse_device_info, evse_display_name _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -161,11 +161,18 @@ def __init__( ) evse = snapshot.evse.get(evse_id, EMPTY_EVSE) use_circuit_numbers = data_coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) + display_suffix = resolve_evse_display_suffix(evse, snapshot, use_circuit_numbers) + + # The charger's own name, kept because both ways this control can fail + # are reported to a person. `_evse_id` is the node id the wire uses; it + # names nothing the user can see on their dashboard. + self._charger_name = evse_display_name(evse, panel_name, display_suffix) + self._attr_device_info = evse_device_info( snapshot.serial_number, evse, panel_name, - resolve_evse_display_suffix(evse, snapshot, use_circuit_numbers), + display_suffix, panel_device_id=data_coordinator.config_entry.runtime_data.panel_device_id, ) self._attr_unique_id = build_evse_unique_id_for_entry( @@ -259,10 +266,10 @@ async def async_set_native_value(self, value: float) -> None: client = self.coordinator.client await self._async_control( self._description.set_fn(client, self._evse_id, int(value)), - command=f"a charge-current limit for {self._evse_id}", + command=f"a charge-current limit for {self._charger_name}", failed_key="evse_charge_limit_failed", not_delivered_key="evse_charge_limit_not_delivered", - placeholders={"charger": self._evse_id}, + placeholders={"charger": self._charger_name}, ) await self.coordinator.async_request_refresh() diff --git a/custom_components/span_panel/util.py b/custom_components/span_panel/util.py index 99dd7fb5..6243005f 100644 --- a/custom_components/span_panel/util.py +++ b/custom_components/span_panel/util.py @@ -200,6 +200,24 @@ def mid_device_info( """ +def evse_display_name( + evse: SpanEvseSnapshot, + panel_name: str, + display_suffix: str | None = None, +) -> str: + """Return what this charger is called wherever a person reads about it. + + Separate from the builder below because a charger is named in two places + that have to agree: its device card, and any message about it. The wire + calls a charger by its node id, which is what the panel needs and what + nobody reading a notification can act on -- so anything user-facing asks + here instead. + """ + base_name = evse.model or "EV Charger" + name = f"{base_name} ({display_suffix})" if display_suffix else base_name + return f"{panel_name} {name}" + + def evse_device_info( panel_identifier: str, evse: SpanEvseSnapshot, @@ -209,12 +227,9 @@ def evse_device_info( panel_device_id: str, ) -> DeviceInfo: """Create DeviceInfo for an EVSE sub-device linked to the parent panel.""" - base_name = evse.model or "EV Charger" - name = f"{base_name} ({display_suffix})" if display_suffix else base_name - name = f"{panel_name} {name}" return DeviceInfo( identifiers={(DOMAIN, f"{panel_identifier}_{SUB_DEVICE_EVSE}_{evse.node_id}")}, - name=name, + name=evse_display_name(evse, panel_name, display_suffix), manufacturer=evse.vendor_name or "SPAN", model=evse.model or "SPAN Drive", serial_number=evse.serial_number, diff --git a/tests/test_evse_charge_limit.py b/tests/test_evse_charge_limit.py index 6a37401f..55fd6dd3 100644 --- a/tests/test_evse_charge_limit.py +++ b/tests/test_evse_charge_limit.py @@ -615,6 +615,31 @@ async def test_a_command_the_transport_never_handed_over_is_reported( entity.coordinator.async_request_refresh.assert_not_awaited() +async def test_a_failure_names_the_charger_the_user_sees(hass: HomeAssistant) -> None: + """`charger` reaches a person, so it carries the name on the device card. + + The wire addresses a charger by node id and the snapshot keys it by a + serial-harmonised id. Neither string appears anywhere the user looks, so a + notification carrying one names a charger they cannot find. + """ + tree = schema_one_tree() + client, bridge = _live_client() + bridge.publish.return_value = None + created = await _created(hass, schema_one_snapshot(tree), client) + entity = _for(created, tree, EVSE) + asked = _published(tree, EVSE, CEILING_TOPIC) - 8 + + with pytest.raises(HomeAssistantError) as raised: + await entity.async_set_native_value(float(asked)) + + placeholders = raised.value.translation_placeholders + assert placeholders is not None + device_info = entity._attr_device_info + assert device_info is not None + assert placeholders["charger"] == device_info["name"] + assert placeholders["charger"] != entity._evse_id + + async def test_the_range_home_assistant_checks_is_the_commissioned_one(hass: HomeAssistant) -> None: """The first of the two checks, asserted through the range the entity reports. From 9d24aec7ec87ebf40b92373e6eea09842264a939 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:45:17 -0700 Subject: [PATCH 201/345] perf(config_flow): fetch the CA once per FQDN readiness wait, not once per poll An entry that pins no CA had check_fqdn_tls_ready fetch one over plain HTTP whenever ca_pem was None -- so the thirty-poll wait made thirty plaintext trust decisions two seconds apart, each against whatever answered. Acquiring an anchor and testing a leaf against one are different jobs on different schedules: the leaf changes while the panel regenerates it, the anchor does not, and only the caller knows it is polling. The fetch moves out to async_download_ca_or_none and check_fqdn_tls_ready now requires an anchor, which also removes its unreachable None arm. The wait holds the first successful fetch for the rest of the run, and still retries if the panel is not answering yet. The two reconfigure tests patched the old seam and now patch the new one; the four direct tests split along the same line. --- custom_components/span_panel/config_flow.py | 22 +++++-- .../span_panel/config_flow_validation.py | 65 ++++++++++--------- tests/test_config_flow_validation.py | 43 +++--------- tests/test_v2_config_flow.py | 53 ++++++++++++++- 4 files changed, 109 insertions(+), 74 deletions(-) diff --git a/custom_components/span_panel/config_flow.py b/custom_components/span_panel/config_flow.py index 41227c62..cb61cff8 100644 --- a/custom_components/span_panel/config_flow.py +++ b/custom_components/span_panel/config_flow.py @@ -46,6 +46,7 @@ PanelCaUnusableError, PanelRestTransport, as_port, + async_download_ca_or_none, async_fetch_panel_ca, async_leaf_chains_to_ca, async_panel_leaf_host, @@ -848,15 +849,22 @@ async def _async_register_fqdn_and_wait(self) -> None: mqtts_port = self._v2_broker_port or DEFAULT_MQTTS_PORT max_attempts = 30 + + # The leaf is what changes during this wait; the anchor is not. An entry + # that holds no CA of its own therefore fetches one once and keeps it, + # rather than making a fresh plaintext trust decision on each of thirty + # polls. Kept inside the loop so a fetch that fails while the panel is + # still coming back is retried rather than ending the wait. + ca_pem = transport.ca_pem for attempt in range(max_attempts): await asyncio.sleep(2) - if await check_fqdn_tls_ready( - self.hass, - self.host, - mqtts_port, - transport.ca_pem, - http_port=self._http_port, - ): + if ca_pem is None: + ca_pem = await async_download_ca_or_none( + self.hass, self.host, http_port=self._http_port + ) + if ca_pem is None: + continue + if await check_fqdn_tls_ready(self.host, mqtts_port, ca_pem): _LOGGER.debug( "FQDN %s found in TLS cert SAN after %d attempts", self.host, diff --git a/custom_components/span_panel/config_flow_validation.py b/custom_components/span_panel/config_flow_validation.py index 1f3e9df6..3337a5fc 100644 --- a/custom_components/span_panel/config_flow_validation.py +++ b/custom_components/span_panel/config_flow_validation.py @@ -113,13 +113,33 @@ def is_fqdn(host: str) -> bool: return "." in host -async def check_fqdn_tls_ready( - hass: HomeAssistant, - fqdn: str, - mqtts_port: int, - ca_pem: str | None, - http_port: int = 80, -) -> bool: +async def async_download_ca_or_none( + hass: HomeAssistant, fqdn: str, http_port: int = 80 +) -> str | None: + """Fetch the panel's CA over plain HTTP, or None when it cannot be had. + + Plaintext, and only for the caller that genuinely holds no anchor -- a + reconfigure of an entry whose CA fetch never succeeded -- where this is the + only channel there is and is no weaker than the unpinned entry it belongs to. + + Separate from the check below because acquiring an anchor and testing a leaf + against one are different jobs on different schedules: the leaf changes while + the panel regenerates it, the anchor does not. Only a caller knows whether it + is polling, so only a caller can decide how often to pay for this. + """ + client = get_async_client(hass, verify_ssl=False) + try: + return await download_ca_cert(fqdn, port=http_port, httpx_client=client) + except ( + OSError, + SpanPanelAPIError, + SpanPanelConnectionError, + SpanPanelTimeoutError, + ): + return None + + +async def check_fqdn_tls_ready(fqdn: str, mqtts_port: int, ca_pem: str) -> bool: """Whether the panel now serves a certificate that names `fqdn`. A TLS connection is made to the MQTTS port with the FQDN as @@ -127,31 +147,14 @@ async def check_fqdn_tls_ready( means the panel has regenerated its leaf to include the FQDN, which is what this is polling for. - `ca_pem` is the anchor the caller already pinned, and passing it is the - point: this used to refetch the CA over plain HTTP on every poll, which - means a fresh trust decision every two seconds against whatever answered, - made against a panel the caller had already anchored. Anything that can - answer that fetch can also serve a leaf signed by the CA it just handed - over, so the poll would have reported the FQDN ready on a certificate the - pinned anchor does not sign. - - `None` is for the one caller that genuinely holds no anchor -- a - reconfigure of an entry whose CA fetch never succeeded -- where the - plaintext fetch is the only channel there is and is no weaker than the - unpinned entry it belongs to. + `ca_pem` is the anchor the caller already holds, and requiring it is the + point: this used to refetch the CA over plain HTTP whenever it was not + given, which means a fresh trust decision every two seconds against whatever + answered. Anything that can answer that fetch can also serve a leaf signed by + the CA it just handed over, so the poll would have reported the FQDN ready on + a certificate the real anchor does not sign. A caller with no anchor of its + own gets one from `async_download_ca_or_none`, once. """ - if ca_pem is None: - client = get_async_client(hass, verify_ssl=False) - try: - ca_pem = await download_ca_cert(fqdn, port=http_port, httpx_client=client) - except ( - OSError, - SpanPanelAPIError, - SpanPanelConnectionError, - SpanPanelTimeoutError, - ): - return False - return await async_leaf_chains_to_ca(fqdn, mqtts_port, ca_pem) diff --git a/tests/test_config_flow_validation.py b/tests/test_config_flow_validation.py index 6cf94cfe..477401ff 100644 --- a/tests/test_config_flow_validation.py +++ b/tests/test_config_flow_validation.py @@ -12,6 +12,7 @@ from custom_components.span_panel.config_flow_validation import ( PanelRestTransport, + async_download_ca_or_none, async_leaf_chains_to_ca, async_resolve_host, check_fqdn_tls_ready, @@ -183,7 +184,7 @@ async def test_validate_v2_helpers_send_credentials_over_the_given_transport() - @pytest.mark.asyncio -async def test_check_fqdn_tls_ready_returns_false_when_ca_download_fails() -> None: +async def test_the_ca_fetch_returns_none_when_the_panel_will_not_serve_one() -> None: """An unpinned caller that cannot fetch a CA has nothing to check against.""" hass = MagicMock() fake_client = MagicMock() @@ -197,7 +198,7 @@ async def test_check_fqdn_tls_ready_returns_false_when_ca_download_fails() -> No side_effect=SpanPanelConnectionError("no cert"), ) as mock_download, ): - assert await check_fqdn_tls_ready(hass, "panel.example.com", 8883, None) is False + assert await async_download_ca_or_none(hass, "panel.example.com") is None mock_download.assert_awaited_once_with( "panel.example.com", port=80, httpx_client=fake_client @@ -205,7 +206,7 @@ async def test_check_fqdn_tls_ready_returns_false_when_ca_download_fails() -> No @pytest.mark.asyncio -async def test_check_fqdn_tls_ready_forwards_custom_http_port() -> None: +async def test_the_ca_fetch_forwards_a_custom_http_port() -> None: """The unpinned fetch honours the panel's HTTP port.""" hass = MagicMock() fake_client = MagicMock() @@ -219,12 +220,7 @@ async def test_check_fqdn_tls_ready_forwards_custom_http_port() -> None: side_effect=SpanPanelConnectionError("no cert"), ) as mock_download, ): - assert ( - await check_fqdn_tls_ready( - hass, "panel.example.com", 8883, None, http_port=8080 - ) - is False - ) + assert await async_download_ca_or_none(hass, "panel.example.com", http_port=8080) is None mock_download.assert_awaited_once_with( "panel.example.com", port=8080, httpx_client=fake_client @@ -257,7 +253,6 @@ def wrap_socket(self, _sock, server_hostname: str): assert server_hostname == "panel.example.com" return FakeSocket() - hass = MagicMock() with ( patch( "custom_components.span_panel.config_flow_validation.download_ca_cert", @@ -276,9 +271,7 @@ def wrap_socket(self, _sock, server_hostname: str): return_value=FakeSocket(), ), ): - assert ( - await check_fqdn_tls_ready(hass, "panel.example.com", 8883, "pinned-pem") is True - ) + assert await check_fqdn_tls_ready("panel.example.com", 8883, "pinned-pem") is True mock_download.assert_not_awaited() build_context.assert_called_once_with("pinned-pem") @@ -304,17 +297,7 @@ def wrap_socket(self, _sock, server_hostname: str): assert server_hostname == "panel.example.com" return FakeSocket() - hass = MagicMock() - fake_client = MagicMock() with ( - patch( - "custom_components.span_panel.config_flow_validation.get_async_client", - return_value=fake_client, - ), - patch( - "custom_components.span_panel.config_flow_validation.download_ca_cert", - new=AsyncMock(return_value="pem-data"), - ), patch( "custom_components.span_panel.config_flow_validation.asyncio.get_running_loop", return_value=FakeLoop(), @@ -328,7 +311,7 @@ def wrap_socket(self, _sock, server_hostname: str): return_value=FakeSocket(), ), ): - assert await check_fqdn_tls_ready(hass, "panel.example.com", 8883, None) is True + assert await check_fqdn_tls_ready("panel.example.com", 8883, "pem-data") is True # The library's builder, not a hand-rolled context: it is the one that clears # VERIFY_X509_STRICT, which the panel's AKI-less CA fails without. @@ -354,17 +337,7 @@ class FakeSSLContext: def wrap_socket(self, _sock, server_hostname: str): raise ssl.SSLError(f"bad cert for {server_hostname}") - hass = MagicMock() - fake_client = MagicMock() with ( - patch( - "custom_components.span_panel.config_flow_validation.get_async_client", - return_value=fake_client, - ), - patch( - "custom_components.span_panel.config_flow_validation.download_ca_cert", - new=AsyncMock(return_value="pem-data"), - ), patch( "custom_components.span_panel.config_flow_validation.asyncio.get_running_loop", return_value=FakeLoop(), @@ -378,7 +351,7 @@ def wrap_socket(self, _sock, server_hostname: str): return_value=FakeSocket(), ), ): - assert await check_fqdn_tls_ready(hass, "panel.example.com", 8883, None) is False + assert await check_fqdn_tls_ready("panel.example.com", 8883, "pem-data") is False @pytest.mark.asyncio diff --git a/tests/test_v2_config_flow.py b/tests/test_v2_config_flow.py index adb25989..ed2e4671 100644 --- a/tests/test_v2_config_flow.py +++ b/tests/test_v2_config_flow.py @@ -7,13 +7,13 @@ import ssl from unittest.mock import AsyncMock, MagicMock, patch +import pytest from homeassistant import config_entries from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -import pytest from pytest_homeassistant_custom_component.common import MockConfigEntry from span_panel_api import DetectionResult, V2AuthResponse, V2StatusInfo from span_panel_api.exceptions import SpanPanelAuthError, SpanPanelConnectionError @@ -26,6 +26,7 @@ SpanPanelConfigFlow, TriggerFlowType, ) +from custom_components.span_panel.config_flow_validation import PanelRestTransport from custom_components.span_panel.const import ( CONF_API_VERSION, CONF_EBUS_BROKER_HOST, @@ -2655,6 +2656,46 @@ async def test_user_flow_fqdn_registration_progress_then_naming( assert result3["data"][CONF_PANEL_CA_PEM] == FAKE_CA_PEM +@pytest.mark.asyncio +async def test_an_anchorless_readiness_wait_fetches_the_ca_once( + hass: HomeAssistant, +) -> None: + """The leaf changes while the panel regenerates it; the anchor does not. + + An entry whose CA fetch never succeeded has no anchor of its own, so the + wait has to fetch one. It used to fetch a fresh one on every poll -- thirty + plaintext trust decisions, two seconds apart, each against whatever + answered. One fetch, held for the rest of the wait. + """ + flow = SpanPanelConfigFlow() + flow.hass = hass + flow.host = "panel.example.com" + flow.access_token = "token" + flow._http_port = 80 + flow._rest_transport = PanelRestTransport( + port=80, ssl_context=None, httpx_client=None, ca_pem=None + ) + + # False twice, then ready: the poll runs more than once, which is the only + # way a per-poll fetch would show up. + ready = AsyncMock(side_effect=[False, False, True]) + download = AsyncMock(return_value="fetched-pem") + + with ( + patch("custom_components.span_panel.config_flow.register_fqdn", new=AsyncMock()), + patch("custom_components.span_panel.config_flow.async_download_ca_or_none", new=download), + patch("custom_components.span_panel.config_flow.check_fqdn_tls_ready", new=ready), + patch("custom_components.span_panel.config_flow.asyncio.sleep", new=AsyncMock()), + patch.object(flow, "_async_verify_host_over_pin", new=AsyncMock()), + ): + await flow._async_register_fqdn_and_wait() + + assert ready.await_count == 3 + download.assert_awaited_once() + # Every poll checked against the anchor that single fetch produced. + assert [call.args[2] for call in ready.await_args_list] == ["fetched-pem"] * 3 + + @pytest.mark.usefixtures("socket_enabled") @pytest.mark.asyncio async def test_user_flow_fqdn_registration_failure_can_continue( @@ -2829,6 +2870,11 @@ async def test_reconfigure_to_fqdn_registers_and_updates_registered_fqdn( "custom_components.span_panel.config_flow.register_fqdn", new=AsyncMock(), ), + # This entry pins no CA, so the readiness wait fetches one itself. + patch( + "custom_components.span_panel.config_flow.async_download_ca_or_none", + new=AsyncMock(return_value=FAKE_CA_PEM), + ), patch( "custom_components.span_panel.config_flow.check_fqdn_tls_ready", new=AsyncMock(return_value=True), @@ -2882,6 +2928,11 @@ async def test_reconfigure_fqdn_failure_can_continue_without_registration( "custom_components.span_panel.config_flow.register_fqdn", new=AsyncMock(), ), + # This entry pins no CA, so the readiness wait fetches one itself. + patch( + "custom_components.span_panel.config_flow.async_download_ca_or_none", + new=AsyncMock(return_value=FAKE_CA_PEM), + ), patch( "custom_components.span_panel.config_flow.check_fqdn_tls_ready", new=AsyncMock(return_value=False), From 839e707b94083f66f071a949150a695a05a01ac6 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:51:15 -0700 Subject: [PATCH 202/345] chore: pin span-panel-api 3.1.1 and schema-0 1.1.1 --- custom_components/span_panel/manifest.json | 4 ++-- pyproject.toml | 4 ++-- requirements_test.txt | 4 ++-- uv.lock | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 0cbed153..581e05bd 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -23,8 +23,8 @@ ], "quality_scale": "gold", "requirements": [ - "span-panel-api==3.1.0", - "span-panel-api-schema-0==1.1.0", + "span-panel-api==3.1.1", + "span-panel-api-schema-0==1.1.1", "span-panel-api-schema-1==1.1.0" ], "version": "2.1.0b17", diff --git a/pyproject.toml b/pyproject.toml index 710d1694..e69ff075 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,12 +8,12 @@ readme = "README.md" requires-python = ">=3.14.3,<3.15" dependencies = [ "homeassistant==2026.8.0", - "span-panel-api==3.1.0", + "span-panel-api==3.1.1", # 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.1.0", + "span-panel-api-schema-0==1.1.1", "span-panel-api-schema-1==1.1.0", ] diff --git a/requirements_test.txt b/requirements_test.txt index 9f9926e2..2183dcc2 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -3,6 +3,6 @@ pytest-asyncio>=1.3.0 setuptools>=65.7.0 pytest-homeassistant-custom-component==0.13.354 homeassistant==2026.8.0 -span-panel-api==3.1.0 -span-panel-api-schema-0==1.1.0 +span-panel-api==3.1.1 +span-panel-api-schema-0==1.1.1 span-panel-api-schema-1==1.1.0 diff --git a/uv.lock b/uv.lock index 379bcf1f..517838f0 100644 --- a/uv.lock +++ b/uv.lock @@ -2550,7 +2550,7 @@ dev = [ [[package]] name = "span-panel-api" -version = "3.1.0" +version = "3.1.1" source = { editable = "../../span/span-panel-api" } dependencies = [ { name = "httpx" }, @@ -2591,7 +2591,7 @@ dev = [ [[package]] name = "span-panel-api-schema-0" -version = "1.1.0" +version = "1.1.1" source = { editable = "../../span/span-panel-api/packages/schema-0" } dependencies = [ { name = "span-panel-api" }, From 42ac5fde869907fee8bd664b9c53ff395880b00e Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:53:19 -0700 Subject: [PATCH 203/345] chore: 2.1.0b18 --- custom_components/span_panel/manifest.json | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 581e05bd..414c3f24 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -27,7 +27,7 @@ "span-panel-api-schema-0==1.1.1", "span-panel-api-schema-1==1.1.0" ], - "version": "2.1.0b17", + "version": "2.1.0b18", "zeroconf": [ { "type": "_span._tcp.local." diff --git a/pyproject.toml b/pyproject.toml index e69ff075..e29c651f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span" -version = "2.1.0b17" +version = "2.1.0b18" description = "Span Panel Custom Integration for Home Assistant" authors = [{name = "SpanPanel"}] license = {text = "MIT"} diff --git a/uv.lock b/uv.lock index 517838f0..155a24a8 100644 --- a/uv.lock +++ b/uv.lock @@ -2491,7 +2491,7 @@ wheels = [ [[package]] name = "span" -version = "2.1.0b17" +version = "2.1.0b18" source = { virtual = "." } dependencies = [ { name = "homeassistant" }, From 5c3f155d3e47a2d3dd0b220aed01886db22ac1cd Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:15:22 -0700 Subject: [PATCH 204/345] chore: pin span-panel-api-schema-1 1.1.1 The vendored schema-1 capture is byte-identical between 1.1.0 and 1.1.1; only its provenance record moves. tests/fixtures/README.md's source path for it named the package directory the capture left in 48b64a4. --- custom_components/span_panel/manifest.json | 2 +- pyproject.toml | 2 +- requirements_test.txt | 2 +- tests/fixtures/README.md | 4 ++-- tests/fixtures/schema_one_tree.source | 2 +- uv.lock | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 414c3f24..2f9185cd 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -25,7 +25,7 @@ "requirements": [ "span-panel-api==3.1.1", "span-panel-api-schema-0==1.1.1", - "span-panel-api-schema-1==1.1.0" + "span-panel-api-schema-1==1.1.1" ], "version": "2.1.0b18", "zeroconf": [ diff --git a/pyproject.toml b/pyproject.toml index e29c651f..1b65b9e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ # 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.1.1", - "span-panel-api-schema-1==1.1.0", + "span-panel-api-schema-1==1.1.1", ] [dependency-groups] diff --git a/requirements_test.txt b/requirements_test.txt index 2183dcc2..6f1f3df2 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -5,4 +5,4 @@ pytest-homeassistant-custom-component==0.13.354 homeassistant==2026.8.0 span-panel-api==3.1.1 span-panel-api-schema-0==1.1.1 -span-panel-api-schema-1==1.1.0 +span-panel-api-schema-1==1.1.1 diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index 551f501c..3717338b 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -28,7 +28,7 @@ Both payloads shipped as package data until recently, and this repository import | File here | Path before `span-panel-api#162` | | ------------------------ | ----------------------------------------------------------------------------------------- | | `schema_zero_types.json` | `src/span_panel_api/reference_payloads/homie_schema.json` | -| `schema_one_tree.json` | `packages/schema-1/src/span_panel_api_schema_1/reference_payloads/parent_child_tree.json` | +| `schema_one_tree.json` | `tests/reference_payloads/parent_child_tree.json` | `span-panel-api#162` takes both back out of the wheels and moves them to `tests/reference_payloads/`, so the paths in the first table are the ones to copy from now. The byte comparison below checks both locations for the tree, newest first, so it works against a checkout on either side of that merge; the older path can @@ -55,7 +55,7 @@ They catch different failures, and neither one substitutes for the other. `schema_one_tree.source` records the release the capture was copied from, as a pinned requirement: ```text -span-panel-api-schema-1==1.1.0 +span-panel-api-schema-1==1.1.1 ``` `tests/test_fixture_provenance.py` holds that against `importlib.metadata.version("span-panel-api-schema-1")` — the release actually installed. When the pin in diff --git a/tests/fixtures/schema_one_tree.source b/tests/fixtures/schema_one_tree.source index 64b9da88..e70e5bd0 100644 --- a/tests/fixtures/schema_one_tree.source +++ b/tests/fixtures/schema_one_tree.source @@ -1 +1 @@ -span-panel-api-schema-1==1.1.0 +span-panel-api-schema-1==1.1.1 diff --git a/uv.lock b/uv.lock index 155a24a8..3ba692ea 100644 --- a/uv.lock +++ b/uv.lock @@ -2602,7 +2602,7 @@ requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-ap [[package]] name = "span-panel-api-schema-1" -version = "1.1.0" +version = "1.1.1" source = { editable = "../../span/span-panel-api/packages/schema-1" } dependencies = [ { name = "ebus-sdk" }, From 5679858e32d9f19bcd8f11bfbcd695ad08f186bd Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:16:47 -0700 Subject: [PATCH 205/345] chore: 2.1.0b19 --- custom_components/span_panel/manifest.json | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 2f9185cd..26c54705 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -27,7 +27,7 @@ "span-panel-api-schema-0==1.1.1", "span-panel-api-schema-1==1.1.1" ], - "version": "2.1.0b18", + "version": "2.1.0b19", "zeroconf": [ { "type": "_span._tcp.local." diff --git a/pyproject.toml b/pyproject.toml index 1b65b9e4..3555cdc7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span" -version = "2.1.0b18" +version = "2.1.0b19" description = "Span Panel Custom Integration for Home Assistant" authors = [{name = "SpanPanel"}] license = {text = "MIT"} diff --git a/uv.lock b/uv.lock index 3ba692ea..92fc6517 100644 --- a/uv.lock +++ b/uv.lock @@ -2491,7 +2491,7 @@ wheels = [ [[package]] name = "span" -version = "2.1.0b18" +version = "2.1.0b19" source = { virtual = "." } dependencies = [ { name = "homeassistant" }, From f10aea3493aa74dea02fc4801b4229f3c8c1f4ab Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:02:20 -0700 Subject: [PATCH 206/345] chore: pin span-panel-api-schema-1 1.1.2 and follow its reference capture, which now carries a never-backup circuit --- custom_components/span_panel/manifest.json | 2 +- pyproject.toml | 2 +- requirements_test.txt | 2 +- tests/fixtures/schema_one_tree.json | 66 ++++++++++++++-------- tests/fixtures/schema_one_tree.source | 2 +- tests/test_der_link_health.py | 6 +- uv.lock | 2 +- 7 files changed, 50 insertions(+), 32 deletions(-) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 26c54705..9ef22ab5 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -25,7 +25,7 @@ "requirements": [ "span-panel-api==3.1.1", "span-panel-api-schema-0==1.1.1", - "span-panel-api-schema-1==1.1.1" + "span-panel-api-schema-1==1.1.2" ], "version": "2.1.0b19", "zeroconf": [ diff --git a/pyproject.toml b/pyproject.toml index 3555cdc7..2faa5079 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ # 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.1.1", - "span-panel-api-schema-1==1.1.1", + "span-panel-api-schema-1==1.1.2", ] [dependency-groups] diff --git a/requirements_test.txt b/requirements_test.txt index 6f1f3df2..0c7e5f70 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -5,4 +5,4 @@ pytest-homeassistant-custom-component==0.13.354 homeassistant==2026.8.0 span-panel-api==3.1.1 span-panel-api-schema-0==1.1.1 -span-panel-api-schema-1==1.1.1 +span-panel-api-schema-1==1.1.2 diff --git a/tests/fixtures/schema_one_tree.json b/tests/fixtures/schema_one_tree.json index c6700deb..42a5e793 100644 --- a/tests/fixtures/schema_one_tree.json +++ b/tests/fixtures/schema_one_tree.json @@ -1,6 +1,6 @@ { "0ab966b95f92a6a51ec548485aa85f54": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787706163063, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787869298458, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", @@ -18,7 +18,7 @@ "switch/relay-requester": "NONE" }, "573066aaddd7b75114c4563ce3af18c4": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787706163064, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Solar Inverter\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787869298459, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Solar Inverter\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "30", @@ -33,13 +33,13 @@ "meter/exported-energy": "0.0", "meter/imported-energy": "182.16666666666666", "pcs/managed": "false", - "pcs/priority": "5", + "pcs/priority": "6", "switch/relay": "CLOSED", "switch/relay-controllable": "false", "switch/relay-requester": "CONFIGURATION" }, "62d0e03897b337b57101aae82f1e9ba2": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787706163063, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787869298458, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "50", @@ -59,8 +59,26 @@ "switch/relay-controllable": "true", "switch/relay-requester": "NONE" }, + "acf35888f35522c721501d35e66503e6": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787869298459, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Pool Pump\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\"}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "20", + "info/name": "Pool Pump", + "info/spaces": "3", + "load-shed/priority": "OFF_GRID", + "meter/active-power": "-1230.0", + "meter/current": "10.25", + "meter/exported-energy": "41.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "5", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, "bess": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787706163064, \"type\": \"energy.ebus.device.bess\", \"name\": \"Battery\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"soc\": {\"name\": \"soc\", \"type\": \"energy.ebus.capability.soc\", \"properties\": {\"soc\": {\"name\": \"State of charge\", \"datatype\": \"float\", \"unit\": \"%\"}, \"soe\": {\"name\": \"State of energy\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"communication-state\": {\"name\": \"Communication state\", \"datatype\": \"enum\", \"format\": \"OK,DEGRADED,LOST,UNKNOWN\"}}}}, \"children\": [\"bess-mid\"], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787869298460, \"type\": \"energy.ebus.device.bess\", \"name\": \"Battery\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"soc\": {\"name\": \"soc\", \"type\": \"energy.ebus.capability.soc\", \"properties\": {\"soc\": {\"name\": \"State of charge\", \"datatype\": \"float\", \"unit\": \"%\"}, \"soe\": {\"name\": \"State of energy\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"communication-state\": {\"name\": \"Communication state\", \"datatype\": \"enum\", \"format\": \"OK,DEGRADED,LOST,UNKNOWN\"}}}}, \"children\": [\"bess-mid\"], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", "$state": "ready", "info/firmware-version": "example-bess/v0.1.0", "info/model": "Example BESS", @@ -74,7 +92,7 @@ "status/communication-state": "OK" }, "bess-mid": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787706163064, \"type\": \"energy.ebus.device.mid\", \"name\": \"Microgrid Interconnect Device\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}}}, \"grid\": {\"name\": \"grid\", \"type\": \"energy.ebus.capability.grid\", \"properties\": {\"islanding-state\": {\"name\": \"Islanding state of the BESS-integrated grid-forming device\", \"datatype\": \"enum\", \"format\": \"ON_GRID,OFF_GRID,UNKNOWN\"}, \"grid-state\": {\"name\": \"Sensed grid condition\", \"datatype\": \"enum\", \"format\": \"UP,DOWN,DEGRADED,UNKNOWN\"}, \"grid-forming-entity\": {\"name\": \"Identity of the currently grid-forming entity\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"bess\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787869298460, \"type\": \"energy.ebus.device.mid\", \"name\": \"Microgrid Interconnect Device\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}}}, \"grid\": {\"name\": \"grid\", \"type\": \"energy.ebus.capability.grid\", \"properties\": {\"islanding-state\": {\"name\": \"Islanding state of the BESS-integrated grid-forming device\", \"datatype\": \"enum\", \"format\": \"ON_GRID,OFF_GRID,UNKNOWN\"}, \"grid-state\": {\"name\": \"Sensed grid condition\", \"datatype\": \"enum\", \"format\": \"UP,DOWN,DEGRADED,UNKNOWN\"}, \"grid-forming-entity\": {\"name\": \"Identity of the currently grid-forming entity\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"bess\", \"extensions\": []}", "$state": "ready", "grid/grid-forming-entity": "GRID", "grid/grid-state": "UP", @@ -86,7 +104,7 @@ "info/vendor-name": "Span" }, "d3724e0d660ba506aa79c1cafe5d1181": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787706163063, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garage Outlet\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787869298458, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garage Outlet\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", @@ -104,7 +122,7 @@ "switch/relay-requester": "NONE" }, "evse": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787706163064, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787869298459, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", "$state": "ready", "config/max-charge-current": "32", "config/user-max-charge-current": "32", @@ -118,7 +136,7 @@ "switch/lock-state": "LOCKED" }, "evse-2": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787706163064, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787869298459, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", "$state": "ready", "config/max-charge-current": "32", "config/user-max-charge-current": "32", @@ -132,7 +150,7 @@ "switch/lock-state": "UNLOCKED" }, "example-40t-001": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787706163064, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Example 40-tab Panel\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"enum\", \"format\": \"MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"data-model-version\": {\"name\": \"eBus data-model version (parent/child schema discriminator)\", \"datatype\": \"string\"}}}, \"door\": {\"name\": \"door\", \"type\": \"energy.ebus.capability.door\", \"properties\": {\"state\": {\"name\": \"Door state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"voltage-a\": {\"name\": \"L1 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}, \"voltage-b\": {\"name\": \"L2 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Main breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"enabled\": {\"name\": \"PCS system enabled\", \"datatype\": \"boolean\"}, \"active\": {\"name\": \"PCS system actively controlling one (or more) loads\", \"datatype\": \"boolean\"}, \"import-limit\": {\"name\": \"The power import limit currently being managed to\", \"datatype\": \"float\", \"unit\": \"A\"}, \"binding-constraint\": {\"name\": \"Which constraint class currently sets the import limit\", \"datatype\": \"enum\", \"format\": \"FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN\"}, \"feed-import-limit\": {\"name\": \"Limit of maximum power feeding the distribution enclosure\", \"datatype\": \"float\", \"unit\": \"A\"}, \"feed-import-limit-enablement\": {\"name\": \"Enablement status of the feed-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"feed-import-limit-active\": {\"name\": \"Is feed-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"operator-import-limit\": {\"name\": \"Operator-imposed maximum import limit\", \"datatype\": \"float\", \"unit\": \"A\"}, \"operator-import-limit-enablement\": {\"name\": \"Enablement status of the operator-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"operator-import-limit-active\": {\"name\": \"Is operator-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"off-grid-import-limit\": {\"name\": \"Off-Grid limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"off-grid-import-limit-enablement\": {\"name\": \"Enablement status of the off-grid-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"off-grid-import-limit-active\": {\"name\": \"Is off-grid-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"requested-import-limit\": {\"name\": \"Requested limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"requested-import-limit-enablement\": {\"name\": \"Enablement status of the requested-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"requested-import-limit-active\": {\"name\": \"Is requested-import-limit currently being enforced?\", \"datatype\": \"boolean\"}}}, \"shed-forecast\": {\"name\": \"shed-forecast\", \"type\": \"energy.ebus.capability.shed-forecast\", \"properties\": {\"total-time-remaining\": {\"name\": \"Estimated total time before all sheddable circuits are shed (off-grid runtime)\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"time-to-priority-shed\": {\"name\": \"Estimated time before the next priority tier is shed\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-total-time-remaining\": {\"name\": \"Estimated total time assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-time-to-priority-shed\": {\"name\": \"Estimated time to next priority shed assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"confidence\": {\"name\": \"Confidence of the shed-forecast estimate\", \"datatype\": \"enum\", \"format\": \"LOW,MEDIUM,HIGH\"}}}, \"shed\": {\"name\": \"shed\", \"type\": \"energy.ebus.capability.shed\", \"properties\": {\"asserted-islanding-state\": {\"name\": \"Consumer-asserted islanding-state (grid-state override during MID/BESS comm-loss)\", \"datatype\": \"enum\", \"format\": \"NONE,ON_GRID,OFF_GRID\", \"settable\": true}, \"policy\": {\"name\": \"Shed policy (algorithm and parameters)\", \"datatype\": \"json\", \"format\": \"{\\\"$id\\\":\\\"soc-priority.v1\\\",\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"algorithm\\\",\\\"parameters\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"algorithm\\\":{\\\"const\\\":\\\"soc-priority.v1\\\"},\\\"parameters\\\":{\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"soc-threshold-shed\\\",\\\"soc-threshold-release\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"soc-threshold-shed\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent below which SOC_THRESHOLD circuits shed\\\"},\\\"soc-threshold-release\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent above which shed SOC_THRESHOLD circuits restore\\\"}}}}}\"}}}, \"power-flows\": {\"name\": \"power-flows\", \"type\": \"energy.ebus.capability.power-flows\", \"properties\": {\"pv\": {\"name\": \"PV power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"battery\": {\"name\": \"Battery/BESS power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"grid\": {\"name\": \"Grid power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"site\": {\"name\": \"Site power flow\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"relay\": {\"name\": \"Main relay\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"ethernet\": {\"name\": \"Is Ethernet network interface operational?\", \"datatype\": \"boolean\"}, \"wifi\": {\"name\": \"Is Wi-Fi network interface operational?\", \"datatype\": \"boolean\"}, \"wifi-ssid\": {\"name\": \"SSID to which Wi-Fi network interface is connected\", \"datatype\": \"string\"}, \"cloud-connection\": {\"name\": \"Device connected to vendor cloud?\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,UNCONNECTED,CONNECTED\"}, \"postal-code\": {\"name\": \"Postal (Zip) code\", \"datatype\": \"string\"}, \"time-zone\": {\"name\": \"Time zone\", \"datatype\": \"string\"}}}}, \"children\": [\"bess\", \"0ab966b95f92a6a51ec548485aa85f54\", \"d3724e0d660ba506aa79c1cafe5d1181\", \"62d0e03897b337b57101aae82f1e9ba2\", \"fe8b85c15bc9610c1b8b4ebc6f82488d\", \"573066aaddd7b75114c4563ce3af18c4\", \"evse\", \"evse-2\", \"lugs-upstream\", \"lugs-downstream\", \"pv\"], \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787869298460, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Example 40-tab Panel\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"enum\", \"format\": \"MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"data-model-version\": {\"name\": \"eBus data-model version (parent/child schema discriminator)\", \"datatype\": \"string\"}}}, \"door\": {\"name\": \"door\", \"type\": \"energy.ebus.capability.door\", \"properties\": {\"state\": {\"name\": \"Door state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"voltage-a\": {\"name\": \"L1 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}, \"voltage-b\": {\"name\": \"L2 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Main breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"enabled\": {\"name\": \"PCS system enabled\", \"datatype\": \"boolean\"}, \"active\": {\"name\": \"PCS system actively controlling one (or more) loads\", \"datatype\": \"boolean\"}, \"import-limit\": {\"name\": \"The power import limit currently being managed to\", \"datatype\": \"float\", \"unit\": \"A\"}, \"binding-constraint\": {\"name\": \"Which constraint class currently sets the import limit\", \"datatype\": \"enum\", \"format\": \"FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN\"}, \"feed-import-limit\": {\"name\": \"Limit of maximum power feeding the distribution enclosure\", \"datatype\": \"float\", \"unit\": \"A\"}, \"feed-import-limit-enablement\": {\"name\": \"Enablement status of the feed-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"feed-import-limit-active\": {\"name\": \"Is feed-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"operator-import-limit\": {\"name\": \"Operator-imposed maximum import limit\", \"datatype\": \"float\", \"unit\": \"A\"}, \"operator-import-limit-enablement\": {\"name\": \"Enablement status of the operator-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"operator-import-limit-active\": {\"name\": \"Is operator-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"off-grid-import-limit\": {\"name\": \"Off-Grid limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"off-grid-import-limit-enablement\": {\"name\": \"Enablement status of the off-grid-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"off-grid-import-limit-active\": {\"name\": \"Is off-grid-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"requested-import-limit\": {\"name\": \"Requested limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"requested-import-limit-enablement\": {\"name\": \"Enablement status of the requested-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"requested-import-limit-active\": {\"name\": \"Is requested-import-limit currently being enforced?\", \"datatype\": \"boolean\"}}}, \"shed-forecast\": {\"name\": \"shed-forecast\", \"type\": \"energy.ebus.capability.shed-forecast\", \"properties\": {\"total-time-remaining\": {\"name\": \"Estimated total time before all sheddable circuits are shed (off-grid runtime)\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"time-to-priority-shed\": {\"name\": \"Estimated time before the next priority tier is shed\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-total-time-remaining\": {\"name\": \"Estimated total time assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-time-to-priority-shed\": {\"name\": \"Estimated time to next priority shed assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"confidence\": {\"name\": \"Confidence of the shed-forecast estimate\", \"datatype\": \"enum\", \"format\": \"LOW,MEDIUM,HIGH\"}}}, \"shed\": {\"name\": \"shed\", \"type\": \"energy.ebus.capability.shed\", \"properties\": {\"asserted-islanding-state\": {\"name\": \"Consumer-asserted islanding-state (grid-state override during MID/BESS comm-loss)\", \"datatype\": \"enum\", \"format\": \"NONE,ON_GRID,OFF_GRID\", \"settable\": true}, \"policy\": {\"name\": \"Shed policy (algorithm and parameters)\", \"datatype\": \"json\", \"format\": \"{\\\"$id\\\":\\\"soc-priority.v1\\\",\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"algorithm\\\",\\\"parameters\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"algorithm\\\":{\\\"const\\\":\\\"soc-priority.v1\\\"},\\\"parameters\\\":{\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"soc-threshold-shed\\\",\\\"soc-threshold-release\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"soc-threshold-shed\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent below which SOC_THRESHOLD circuits shed\\\"},\\\"soc-threshold-release\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent above which shed SOC_THRESHOLD circuits restore\\\"}}}}}\"}}}, \"power-flows\": {\"name\": \"power-flows\", \"type\": \"energy.ebus.capability.power-flows\", \"properties\": {\"pv\": {\"name\": \"PV power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"battery\": {\"name\": \"Battery/BESS power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"grid\": {\"name\": \"Grid power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"site\": {\"name\": \"Site power flow\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"relay\": {\"name\": \"Main relay\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"ethernet\": {\"name\": \"Is Ethernet network interface operational?\", \"datatype\": \"boolean\"}, \"wifi\": {\"name\": \"Is Wi-Fi network interface operational?\", \"datatype\": \"boolean\"}, \"wifi-ssid\": {\"name\": \"SSID to which Wi-Fi network interface is connected\", \"datatype\": \"string\"}, \"cloud-connection\": {\"name\": \"Device connected to vendor cloud?\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,UNCONNECTED,CONNECTED\"}, \"postal-code\": {\"name\": \"Postal (Zip) code\", \"datatype\": \"string\"}, \"time-zone\": {\"name\": \"Time zone\", \"datatype\": \"string\"}}}}, \"children\": [\"bess\", \"0ab966b95f92a6a51ec548485aa85f54\", \"d3724e0d660ba506aa79c1cafe5d1181\", \"62d0e03897b337b57101aae82f1e9ba2\", \"fe8b85c15bc9610c1b8b4ebc6f82488d\", \"acf35888f35522c721501d35e66503e6\", \"573066aaddd7b75114c4563ce3af18c4\", \"evse\", \"evse-2\", \"lugs-upstream\", \"lugs-downstream\", \"pv\"], \"extensions\": []}", "$state": "ready", "breaker/rating": "200", "door/state": "CLOSED", @@ -161,9 +179,9 @@ "pcs/requested-import-limit-active": "false", "pcs/requested-import-limit-enablement": "UNCONFIGURED", "power-flows/battery": "3500.0", - "power-flows/grid": "2347.0", + "power-flows/grid": "1117.0", "power-flows/pv": "-8500.0", - "power-flows/site": "2653.0", + "power-flows/site": "3883.0", "shed-forecast/confidence": "HIGH", "shed-forecast/full-charge-time-to-priority-shed": "3038", "shed-forecast/full-charge-total-time-remaining": "4320", @@ -180,7 +198,7 @@ "status/wifi-ssid": "example-wifi" }, "fe8b85c15bc9610c1b8b4ebc6f82488d": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787706163063, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787869298458, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "50", @@ -201,30 +219,30 @@ "switch/relay-requester": "NONE" }, "lugs-downstream": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787706163064, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Downstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787869298460, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Downstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", "$state": "ready", "info/direction": "DOWNSTREAM", - "meter/active-power": "-5847.0", - "meter/current-a": "46.46666666666666", + "meter/active-power": "-4617.0", + "meter/current-a": "56.71666666666666", "meter/current-b": "46.474999999999994", - "meter/exported-energy": "97.45", - "meter/imported-energy": "44.05" + "meter/exported-energy": "76.95", + "meter/imported-energy": "64.55" }, "lugs-upstream": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787706163064, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Upstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787869298459, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Upstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", "$state": "ready", "connection/fed-by-device-id": "bess", "connection/fed-by-device-status": "OK", "connection/fed-by-device-type": "energy.ebus.device.bess", "info/direction": "UPSTREAM", - "meter/active-power": "-5847.0", - "meter/current-a": "46.46666666666666", + "meter/active-power": "-4617.0", + "meter/current-a": "56.71666666666666", "meter/current-b": "46.474999999999994", - "meter/exported-energy": "97.45", - "meter/imported-energy": "44.05" + "meter/exported-energy": "76.95", + "meter/imported-energy": "64.55" }, "pv": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787706163064, \"type\": \"energy.ebus.device.pv\", \"name\": \"Solar\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nominal-power\": {\"name\": \"Nominal power\", \"datatype\": \"float\", \"unit\": \"W\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787869298460, \"type\": \"energy.ebus.device.pv\", \"name\": \"Solar\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nominal-power\": {\"name\": \"Nominal power\", \"datatype\": \"float\", \"unit\": \"W\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", "$state": "ready", "info/firmware-version": "example-pv/v0.1.0", "info/model": "IQ8PLUS-72-2-US", diff --git a/tests/fixtures/schema_one_tree.source b/tests/fixtures/schema_one_tree.source index e70e5bd0..1737c0f3 100644 --- a/tests/fixtures/schema_one_tree.source +++ b/tests/fixtures/schema_one_tree.source @@ -1 +1 @@ -span-panel-api-schema-1==1.1.1 +span-panel-api-schema-1==1.1.2 diff --git a/tests/test_der_link_health.py b/tests/test_der_link_health.py index f7d6bf2d..31a5b307 100644 --- a/tests/test_der_link_health.py +++ b/tests/test_der_link_health.py @@ -244,7 +244,7 @@ def _evse_links_by_feed( # --------------------------------------------------------------------------- -def test_the_capture_publishes_a_link_record_for_three_ders_and_two_bare_circuits() -> None: +def test_the_capture_publishes_a_link_record_for_three_ders_and_three_bare_circuits() -> None: """Guard every expectation below, and the negative case with them. Three DER-feeding circuits publish the record; the remaining circuits @@ -264,7 +264,7 @@ def test_the_capture_publishes_a_link_record_for_three_ders_and_two_bare_circuit assert tree[circuit][FEEDS_STATUS_TOPIC] in _status_options() bare = circuits - feeding - assert len(bare) == 2, f"expected two circuits feeding no DER, found {len(bare)}" + assert len(bare) == 3, f"expected three circuits feeding no DER, found {len(bare)}" for circuit in bare: declared = json.loads(tree[circuit]["$description"])["nodes"] assert CONNECTION_NODE in declared, ( @@ -388,7 +388,7 @@ async def test_a_charger_whose_circuit_publishes_no_record_gets_no_entity( async def test_the_circuits_that_feed_no_der_create_nothing(hass: HomeAssistant) -> None: - """The capture's two bare circuits, asserted as producing no entity. + """The capture's three bare circuits, asserted as producing no entity. The `feeds-*` triple is absent from a mixed-load circuit by design, so the count of link sensors must equal the count of DER the capture claims — three diff --git a/uv.lock b/uv.lock index 92fc6517..fd304ffb 100644 --- a/uv.lock +++ b/uv.lock @@ -2602,7 +2602,7 @@ requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-ap [[package]] name = "span-panel-api-schema-1" -version = "1.1.1" +version = "1.1.2" source = { editable = "../../span/span-panel-api/packages/schema-1" } dependencies = [ { name = "ebus-sdk" }, From e2b495d2adc5f37a26584993d32b36e436cd001c Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:27:53 -0700 Subject: [PATCH 207/345] chore: 2.1.0b20 --- custom_components/span_panel/manifest.json | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 9ef22ab5..2b16d4b9 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -27,7 +27,7 @@ "span-panel-api-schema-0==1.1.1", "span-panel-api-schema-1==1.1.2" ], - "version": "2.1.0b19", + "version": "2.1.0b20", "zeroconf": [ { "type": "_span._tcp.local." diff --git a/pyproject.toml b/pyproject.toml index 2faa5079..b2a69bcd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span" -version = "2.1.0b19" +version = "2.1.0b20" description = "Span Panel Custom Integration for Home Assistant" authors = [{name = "SpanPanel"}] license = {text = "MIT"} diff --git a/uv.lock b/uv.lock index fd304ffb..1871c0e9 100644 --- a/uv.lock +++ b/uv.lock @@ -2491,7 +2491,7 @@ wheels = [ [[package]] name = "span" -version = "2.1.0b19" +version = "2.1.0b20" source = { virtual = "." } dependencies = [ { name = "homeassistant" }, From 254398b7d79464ac64f290edbff1316bb02def78 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:41:20 -0700 Subject: [PATCH 208/345] chore: read the adapter reference payloads from the pinned wheels; the vendored copies and their guard leave --- .env.example | 31 +- .github/workflows/ci.yml | 48 -- .prettierignore | 17 - custom_components/span_panel/diagnostics.py | 2 +- custom_components/span_panel/field_paths.py | 2 +- custom_components/span_panel/manifest.json | 4 +- .../span_panel/schema_validation.py | 4 +- developer.md | 67 +-- prek.toml | 36 +- pyproject.toml | 23 +- requirements_test.txt | 4 +- scripts/refresh-vendored-capture.py | 323 -------------- tests/adapter_fixtures.py | 120 ++--- tests/conftest.py | 44 -- tests/fixtures/README.md | 217 ++------- tests/fixtures/schema_one_tree.json | 252 ----------- tests/fixtures/schema_one_tree.source | 1 - tests/fixtures/schema_zero_types.json | 420 ------------------ tests/test_bess_telemetry.py | 4 +- tests/test_field_path_conformance.py | 2 +- tests/test_fixture_provenance.py | 354 --------------- tests/test_library_path_hook.py | 19 +- tests/test_library_resolution.py | 22 +- tests/test_metadata_sweep.py | 2 +- tests/test_pv_device.py | 2 +- tests/test_schema_discovery.py | 4 +- tests/test_sensor_entities.py | 2 +- tests/test_shed_forecast.py | 2 +- uv.lock | 5 +- 29 files changed, 143 insertions(+), 1890 deletions(-) delete mode 100755 scripts/refresh-vendored-capture.py delete mode 100644 tests/fixtures/schema_one_tree.json delete mode 100644 tests/fixtures/schema_one_tree.source delete mode 100644 tests/fixtures/schema_zero_types.json delete mode 100644 tests/test_fixture_provenance.py diff --git a/.env.example b/.env.example index 88dde549..0d8bf8d9 100644 --- a/.env.example +++ b/.env.example @@ -1,33 +1,8 @@ # Copy this file to .env and adjust paths for your local setup. # -# `tests/conftest.py` reads this file directly, so no direnv or dotenv package is -# needed for the test suite. A value already exported in your shell wins over -# anything here -- the file supplies defaults, it does not override an -# intentional choice. - -# Path to span-panel-api repo. Names the *checkout*, not a worktree: to test -# against unreleased library work, install the worktree into the venv instead of -# naming it in any committed file. See developer.md, "Working against unreleased -# library code" -- `pyproject.toml` names the same checkout for the same reason, -# and a prek hook holds it there. -# -# Used for the editable pip install; read by -# tests/test_fixture_provenance.py, which byte-compares the vendored capture -# `tests/fixtures/schema_one_tree.json` against the copy in that checkout; and -# read by scripts/refresh-vendored-capture.py, which is how that capture is -# refreshed when the comparison fails. -# -# Optional here: without it that one comparison skips and the rest of the suite -# runs in full. It skips again if the checkout is not on the release -# `tests/fixtures/schema_one_tree.source` records, because comparing against -# another release reports drift rather than corruption -- position the checkout -# on that release's tag before believing a failure. -# -# CI is not optional: it clones span-panel-api at that release and this check -# fails there rather than skipping. A skip reads in a summary line exactly like a -# pass, and that is how a stale vendored capture went unnoticed for nine days in -# span-panel-api (its DEVELOPMENT.md, "A skip here is not a pass"). -export SPAN_PANEL_API_DIR=../span-panel-api +# Consumed by the shell scripts under `scripts/`, which source it. Nothing in the +# test suite reads it: the library the suite resolves is named by +# `[tool.uv.sources]` in `pyproject.toml`, not by an environment variable. # Path to span-card frontend repo (for build-frontend.sh) export SPAN_CARD_DIR=../cards/span-card diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19c1f08b..ebecee83 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,54 +13,6 @@ jobs: - name: Checkout code uses: actions/checkout@v7 - - name: Check out span-panel-api at the release the vendored capture names - run: | - set -euo pipefail - # The tag is derived, never written down twice. `schema_one_tree.source` - # is the one place that records which release the vendored capture was - # copied from, and this clones that release so the byte comparison is - # against the source the copy claims -- not against whatever `main` - # happens to hold. A hardcoded tag here could disagree with that file, - # which would be a new instance of the staleness this check exists to - # remove. - # - # `span-panel-api-schema-1==1.1.0` -> tag `schema-1-v1.1.0`: the - # monorepo tags a per-package release as `-v`, and the - # package directory is the distribution name with the `span-panel-api-` - # prefix dropped (see RELEASE.md there). The bootstrap distribution is - # tagged plain `v`, which is why this derives from the adapter - # pin rather than from the requirement list generally. - # - # `git clone` rather than actions/checkout because the ref has to be - # computed from a file in the workspace first, and splitting that across - # a step output would put the derivation in two places. The repository - # is public, so no token beyond the default is involved. - pin="$(tr -d '[:space:]' < tests/fixtures/schema_one_tree.source)" - case "$pin" in - *==*) ;; - *) - echo "schema_one_tree.source must hold one pinned requirement, got '$pin'" >&2 - exit 1 - ;; - esac - distribution="${pin%%==*}" - release="${pin##*==}" - tag="${distribution#span-panel-api-}-v${release}" - echo "Vendored capture records $pin; cloning SpanPanel/span-panel-api at $tag" - # Under RUNNER_TEMP rather than in the workspace: a second checkout - # inside it would be collected by pytest and walked by every - # --all-files linter. - checkout="$RUNNER_TEMP/span-panel-api" - git clone --depth 1 --branch "$tag" \ - https://github.com/SpanPanel/span-panel-api.git "$checkout" - # Exported here rather than as a job-level `env:` because the `runner` - # context is not available there, and because two later steps run the - # suite -- the prek hooks (`pytest-cov-summary` always runs) and the - # coverage step. Either one running without it would fail the byte - # comparison for want of a checkout, which is exactly the "wiring came - # undone" state that check refuses to skip. - echo "SPAN_PANEL_API_DIR=$checkout" >> "$GITHUB_ENV" - - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 with: diff --git a/.prettierignore b/.prettierignore index 0bcdde13..2c66719a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -40,21 +40,4 @@ poetry.lock # Test files (optional - remove if you want to format test files) # tests/ -# Vendored adapter captures: byte copies of files span-panel-api owns, held to -# their source byte for byte by tests/test_fixture_provenance.py. A formatter and -# a byte-identical vendored fixture cannot both be unconditional, and it is the -# formatter that has to yield -- the bytes are the artifact. -# -# PRECAUTIONARY. Prettier has never touched these files and did not cause any -# drift we have seen: nothing here runs it over JSON (scripts/fix-markdown.sh -# passes only *.md globs, and prek.toml has no Prettier hook). The drift the byte -# guard actually caught was a stale source checkout, not a formatter. -# -# It is not idle, though. `schema_zero_types.json` carries its producer's 4-space -# indentation, and Prettier under .prettierrc rewrites it from 14675 to 11779 -# bytes -- and that file has no byte guard, so nothing would report it. The day a -# JSON glob is added, this line is what stands between a captured payload and a -# silent 3 KB rewrite. -tests/fixtures/schema_*.json - # Keep docs directory included for formatting diff --git a/custom_components/span_panel/diagnostics.py b/custom_components/span_panel/diagnostics.py index 14df690f..9b86bb9f 100644 --- a/custom_components/span_panel/diagnostics.py +++ b/custom_components/span_panel/diagnostics.py @@ -131,7 +131,7 @@ def _discovery(findings: SchemaFindings | None) -> DiscoveryBlock: diagnostics payload leaves the house into issues and forum posts, and `TO_REDACT` above is key-based over the config entry — it could not protect a wire value put here, so no wire value is put here. `test_diagnostics` asserts - that against the vendored capture rather than leaving it to review. + that against the adapter's reference capture rather than leaving it to review. `available` is False while the adapter has not reported metadata yet, which is a real state on a reconnect. It is not the same as an empty report: an diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py index ef01bccc..995ef3c0 100644 --- a/custom_components/span_panel/field_paths.py +++ b/custom_components/span_panel/field_paths.py @@ -379,7 +379,7 @@ class FieldPathDeclarationMixin: that field just is not on both schemas. The annotations are not documentation. `tests/test_field_path_conformance.py` -builds both adapters' metadata from the vendored fixtures and asserts every +builds both adapters' metadata from their reference payloads and asserts every entry's annotation against what those adapters actually produce, so a stale reason fails the build instead of misleading a reader. A path that becomes producible by both fails there too, demanding promotion to a declaration. diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 2b16d4b9..3557ca6c 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -24,8 +24,8 @@ "quality_scale": "gold", "requirements": [ "span-panel-api==3.1.1", - "span-panel-api-schema-0==1.1.1", - "span-panel-api-schema-1==1.1.2" + "span-panel-api-schema-0==1.1.2", + "span-panel-api-schema-1==1.1.3" ], "version": "2.1.0b20", "zeroconf": [ diff --git a/custom_components/span_panel/schema_validation.py b/custom_components/span_panel/schema_validation.py index a4c77573..0f9d3969 100644 --- a/custom_components/span_panel/schema_validation.py +++ b/custom_components/span_panel/schema_validation.py @@ -73,8 +73,8 @@ class DiscoveredProperty: """A property the panel declares that the running adapter reads nothing from. The runtime half of `tests/test_declared_but_unread`, which asks the same - question of a vendored capture and therefore cannot see a panel that starts - publishing something in the field. + question of the adapter's reference capture and therefore cannot see a panel + that starts publishing something in the field. Maintainer-facing only. Nothing creates an entity, a Repair or a notification from one of these — it is carried in diagnostics so that diff --git a/developer.md b/developer.md index 77ca3bf4..d409fd3e 100644 --- a/developer.md +++ b/developer.md @@ -60,9 +60,6 @@ cp .env.example .env The defaults assume the standard workspace layout: ```dotenv -# Path to span-panel-api repo (for editable pip install) -export SPAN_PANEL_API_DIR=../span-panel-api - # Path to span-card frontend repo (for build-frontend.sh) export SPAN_CARD_DIR=../cards/span-card @@ -137,8 +134,8 @@ fails everywhere; that one is never intentional. ### Why the version alone is not enough `3cbf02a` pointed both `[tool.uv.sources]` and `[tool.pyright].extraPaths` at a scratch worktree and committed it. It survived several commits and hours of -other work. While it stood, a test fixture was vendored through that resolution and captured a producer defect the library had already fixed — and every -version-based check passed, because the stale worktree declared the same version number as the corrected code. Only a byte comparison caught it. +other work. While it stood, the conformance tests ran against a producer defect the library had already fixed — and every version-based check passed, because +the stale worktree declared the same version number as the corrected code. **A version string does not identify content. Only a filesystem location does.** That is what both guards check and why neither of them checks a version on its own. @@ -227,54 +224,20 @@ python -m pytest tests/test_current_monitor.py -q python -m pytest tests/ --cov=custom_components/span_panel --cov-report=term-missing ``` -## The vendored adapter captures - -`tests/fixtures/schema_one_tree.json` and `tests/fixtures/schema_zero_types.json` are byte copies of payloads that live in `span-panel-api`. Committing them is -what keeps this suite free of a cross-repo dependency: the tests run with no sibling checkout, and no runtime wheel has to keep shipping test data. - -### Refreshing the parent/child capture - -```bash -# SPAN_PANEL_API_DIR names the checkout; see .env.example -uv run python scripts/refresh-vendored-capture.py -``` - -The script copies the capture byte for byte and writes the release it came from into `tests/fixtures/schema_one_tree.source`, in one action. **That is the -reason it exists.** Those two facts — the bytes, and the claim about which release they are — were previously updated by two different actions, so doing one and -not the other was a single missed keystroke; doing both from one command makes them incapable of disagreeing. - -It refuses more than it accepts, deliberately. No `SPAN_PANEL_API_DIR`, a path that does not resolve, a directory that is not a checkout, a checkout whose -schema-1 release is not the one installed, a checkout that has the file at neither known location, or a checkout that is not standing on the release's tag — all -of these stop before anything is written. The last one matters most: the refresh that went wrong copied from a worktree that _declared_ the installed release -while sitting on unreleased work behind `main`, so the declaration and the pin agreed while the bytes were a release old. A version in a working tree is intent; -a tag is a fact. Copying from unreleased work is still possible with `--allow-unreleased`, because it is occasionally correct — it is how the capture was -corrected while the fix was only on `main` — but it has to be said out loud rather than warned about and ignored. - -Commit both files together. They are one fact in two files. - -### Which guard tells you to run it - -| Guard | Compares | Catches | Needs a checkout | -| ----------------------------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------ | ---------------- | -| `test_the_vendored_capture_matches_the_installed_adapter` | `schema_one_tree.source` against the installed distribution | a pin that moved while the copy stayed put | no | -| `test_the_vendored_capture_is_byte_identical_to_its_source` | the capture against the copy in that release | a copy refreshed **wrongly** — reformatted, edited, or taken from the wrong checkout | yes | - -The first fails everywhere, including CI, the moment `manifest.json` moves and nobody refreshes. The second needs `SPAN_PANEL_API_DIR`, so it **skips** locally -when no checkout is configured or the configured one is on another release, and **fails** under `CI`, where the workflow clones `span-panel-api` at the release -the pin names. That asymmetry is deliberate and should not be "fixed": `span-panel-api` paid for it and records the reason in its `DEVELOPMENT.md`, under "A -skip here is not a pass" — _"A skip reads in a summary line exactly like a pass, and that is how a stale vendored capture went unnoticed for nine days."_ - -Either failure names the refresh. Run the script. +## The adapter reference payloads -### Why the capture is not copied at test time +The conformance tests replay two real schema-adapter inputs: a full parent/child device tree and a flat `GET /api/v2/homie/schema` response. Neither is +committed here. Both are package data of the adapter distributions — `span_panel_api_schema_1/reference/parent_child_tree.json` and +`span_panel_api_schema_0/reference/homie_schema.json` — and `tests/adapter_fixtures.py` reads them through `importlib.resources`, exactly as the library's own +suite does. -Someone will eventually propose having the suite copy the fixture from `SPAN_PANEL_API_DIR` on every run, so it can never be stale. It would undo both things -vendoring bought. +So the payload the suite replays is whichever one the pinned wheel ships, and the pin in `custom_components/span_panel/manifest.json` is the record of which +that is. **Moving to a newer capture is a pin bump and nothing else**: bump the adapter version there, let `scripts/sync-dependencies.py` carry it into +`pyproject.toml` and `requirements_test.txt`, then `uv sync`. There is no copy to refresh and nothing to keep honest — a claim about which release the bytes +came from cannot disagree with the bytes when the bytes are the release's. -It puts the cross-repo dependency back — every test run needing a `span-panel-api` checkout, which is exactly what committing the bytes removed. And it makes -the byte comparison vacuous: a copy compared against the thing it was just copied from can only ever pass. **Committed bytes that are allowed to disagree with -their source are the whole mechanism.** That freedom to disagree is what let the comparison find a capture vendored from a stale path override, which nothing -else in either repository could see. A fixture that silently re-syncs itself cannot report anything. +`tests/test_library_resolution.py` is what makes the pin mean something: it checks that the `span_panel_api` actually imported is the one the pins name, rather +than a worktree an override is pointing at. ## Knowing what the panel publishes that nothing reads @@ -314,8 +277,8 @@ opt-in disabled entity does only if the user asks for it. ### What it does not cover -The gate reads the **vendored fixture**, so it answers "what does our capture declare that we do not read". It cannot see a property a real panel starts -publishing in the field. The runtime half below is what answers that. +The gate reads the **adapter's reference capture**, so it answers "what does that capture declare that we do not read". It cannot see a property a real panel +starts publishing in the field. The runtime half below is what answers that. Note also that a property can be _read on one device and not another_ and the gate will not see it — it asks whether anything moved, not whether everything did. `snapshot.pv` keeps the first `energy.ebus.device.pv` child and discards the rest, so a second inverter is invisible while the property still counts as read. diff --git a/prek.toml b/prek.toml index 90994f8c..71f3719d 100644 --- a/prek.toml +++ b/prek.toml @@ -1,29 +1,23 @@ default_stages = ["pre-commit"] -# Two kinds of vendored byte copy live under tests/fixtures/, and neither is -# source this repository owns: -# -# schema_*.json captures of files span-panel-api owns, held to their source -# byte for byte by tests/test_fixture_provenance.py -# pyproject_*.toml this file's own pyproject.toml at the commits where the -# library path override went wrong, held to those commits by -# tests/test_library_path_hook.py -- they are what proves the -# library-path gate fires, so they have to stay the real files -# rather than approximations of them +# `tests/fixtures/pyproject_*.toml` are byte copies of this file's own pyproject.toml +# at the commits where the library path override went wrong, held to those commits +# by tests/test_library_path_hook.py -- they are what proves the library-path gate +# fires, so they have to stay the real files rather than approximations of them. # # The three hooks below that *rewrite* files -- trailing-whitespace, -# end-of-file-fixer and mixed-line-ending -- are excluded from both, because a -# formatter and a byte-identical vendored fixture cannot both be unconditional and -# it is the formatter that has to yield: the bytes are the artifact. +# end-of-file-fixer and mixed-line-ending -- are excluded from them, because a +# formatter and a byte-identical copy cannot both be unconditional and it is the +# formatter that has to yield: the bytes are the artifact. # # Not hypothetical, merely not yet triggered. Those three cover tests/ today, and -# they leave the current copies alone only because all of them happen to be +# they leave the current copies alone only because both happen to be # newline-terminated with LF endings and no trailing whitespace. The first one -# vendored without a final newline would be rewritten on commit, and the guard -# would then fail against a source the copy had genuinely matched when it was made. +# taken without a final newline would be rewritten on commit, and the guard would +# then fail against a commit the copy had genuinely matched when it was made. # -# The read-only hooks keep covering them. check-json and check-toml in particular -# should: a copy that does not parse is worth hearing about wherever it came from. +# The read-only hooks keep covering them. check-toml in particular should: a copy +# that does not parse is worth hearing about wherever it came from. [[repos]] repo = "https://github.com/pre-commit/pre-commit-hooks" rev = "v5.0.0" @@ -34,9 +28,9 @@ hooks = [ { id = "check-added-large-files" }, { id = "check-merge-conflict", exclude = '\.\S*_cache/.*|dist/.*|venv/.*' }, { id = "debug-statements", exclude = '^tests/.*|\.\S*_cache/.*|dist/.*|venv/.*', language_version = "python3.14" }, - { id = "trailing-whitespace", exclude = '^tests/fixtures/(schema_.*\.json|pyproject_.*\.toml)$|\.\S*_cache/.*|dist/.*|venv/.*' }, - { id = "end-of-file-fixer", exclude = '^tests/fixtures/(schema_.*\.json|pyproject_.*\.toml)$|\.\S*_cache/.*|dist/.*|venv/.*' }, - { id = "mixed-line-ending", args = ["--fix=lf"], exclude = '^tests/fixtures/(schema_.*\.json|pyproject_.*\.toml)$|\.\S*_cache/.*|dist/.*|venv/.*' }, + { id = "trailing-whitespace", exclude = '^tests/fixtures/pyproject_.*\.toml$|\.\S*_cache/.*|dist/.*|venv/.*' }, + { id = "end-of-file-fixer", exclude = '^tests/fixtures/pyproject_.*\.toml$|\.\S*_cache/.*|dist/.*|venv/.*' }, + { id = "mixed-line-ending", args = ["--fix=lf"], exclude = '^tests/fixtures/pyproject_.*\.toml$|\.\S*_cache/.*|dist/.*|venv/.*' }, ] # The library path override in pyproject.toml must name the checkout, not a diff --git a/pyproject.toml b/pyproject.toml index b2a69bcd..4757befd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,8 +13,8 @@ dependencies = [ # 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.1.1", - "span-panel-api-schema-1==1.1.2", + "span-panel-api-schema-0==1.1.2", + "span-panel-api-schema-1==1.1.3", ] [dependency-groups] @@ -45,24 +45,25 @@ dev = [ package = false [tool.uv.sources] -# The primary checkout, which is where 3.1.0 and both 1.1.0 adapters now live. +# The primary checkout, which is where the pinned releases are developed. # # These pointed at a `-security` worktree while that work was unmerged. That # outlived its reason and became actively harmful: the worktree never received -# span-panel-api#161, so `span_panel_api_schema_1.reference_payloads` resolved to -# a parent/child capture that still recorded the emitter's `$settable` defect on -# a locked relay, and `tests/fixtures/schema_one_tree.json` was vendored from it. -# A path override is a redirection of what every import in the suite resolves to; -# leaving one aimed at a stale checkout silently tests against a producer that no -# longer exists. Point these at a checkout that tracks `main`, and move them only -# for as long as an unmerged branch is actually under test. +# span-panel-api#161, so the adapters' reference payloads resolved to a +# parent/child capture that still recorded the emitter's `$settable` defect on a +# locked relay, and the conformance tests asserted against it. A path override is +# a redirection of what every import in the suite resolves to -- including the +# reference payloads the tests replay -- so leaving one aimed at a stale checkout +# silently tests against a producer that no longer exists. Point these at a +# checkout that tracks `main`, and move them only for as long as an unmerged +# branch is actually under test. # # A path that does not exist is not a soft failure: `uv run` cannot build the # environment at all, so nothing in this repo runs until it is corrected. # # CI never reads this block -- it deletes it and re-locks from PyPI (.github/ # workflows/ci.yml) -- so a stale path here breaks only local work. It does mean -# CI cannot pass until 3.1.0 and both 1.1.0 adapters are on PyPI. +# CI cannot pass until the pinned releases are on PyPI. span-panel-api = { path = "../../span/span-panel-api", editable = true } span-panel-api-schema-0 = { path = "../../span/span-panel-api/packages/schema-0", editable = true } span-panel-api-schema-1 = { path = "../../span/span-panel-api/packages/schema-1", editable = true } diff --git a/requirements_test.txt b/requirements_test.txt index 0c7e5f70..3cfc4e1f 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -4,5 +4,5 @@ setuptools>=65.7.0 pytest-homeassistant-custom-component==0.13.354 homeassistant==2026.8.0 span-panel-api==3.1.1 -span-panel-api-schema-0==1.1.1 -span-panel-api-schema-1==1.1.2 +span-panel-api-schema-0==1.1.2 +span-panel-api-schema-1==1.1.3 diff --git a/scripts/refresh-vendored-capture.py b/scripts/refresh-vendored-capture.py deleted file mode 100755 index 17365ceb..00000000 --- a/scripts/refresh-vendored-capture.py +++ /dev/null @@ -1,323 +0,0 @@ -#!/usr/bin/env python3 -"""Refresh the vendored schema-1 capture and the release it records, in one action. - -`tests/fixtures/schema_one_tree.json` is a byte copy of a payload that lives in -`span-panel-api`, and `tests/fixtures/schema_one_tree.source` records which -release it was copied from. Two files, two facts, and **that is the whole reason -this script exists**: until now they were updated by two different actions -- a -copy and an edit -- so doing one and not the other was a single missed keystroke, -and the result was a capture whose claim about itself was wrong. Performing both -from one command makes them incapable of disagreeing, which is a stronger -guarantee than any amount of care with a two-step README. - -It is not hypothetical. The capture was once refreshed from a checkout that sat -behind the recorded release, producing a copy that was faithful to *a* release -and mislabelled as another; it recorded a producer defect that upstream had -already fixed, and every conformance test kept passing against it. So this script -refuses to copy from a checkout whose schema-1 release is not the one installed, -and refuses again unless that checkout is standing on the release's *tag* -- -because the stale worktree declared the right version while sitting on the wrong -commit, and only a tag can be checked. Copying from unreleased work is still -possible, with `--allow-unreleased`, because it is occasionally correct; it just -has to be said out loud. Making the wrong refresh hard is more of the point than -making the right one convenient. - -**The capture is deliberately not copied at test time.** Doing that would undo -both things vendoring bought. It would put the cross-repo dependency back -- -every test run needing a `span-panel-api` checkout, which is exactly what -committing the bytes removed -- and it would make the byte comparison in -`tests/test_fixture_provenance.py` vacuous, since a copy compared against the -thing it was just copied from can only ever pass. Committed bytes that are -*allowed to disagree with their source* are what let that comparison find the -stale checkout. A refresh is a deliberate act, taken here and reviewed in a diff. - -Usage, from the repository root: - - uv run python scripts/refresh-vendored-capture.py - -`SPAN_PANEL_API_DIR` names the checkout; see `.env.example`. A correct run when -nothing has moved upstream is a no-op and says so. -""" - -from __future__ import annotations - -from importlib.metadata import PackageNotFoundError, version -import os -from pathlib import Path -import re -import shutil -import subprocess -import sys - -REPO = Path(__file__).resolve().parent.parent - -sys.path.insert(0, str(REPO)) - -from tests.adapter_fixtures import ( # noqa: E402 - CHECKOUT_VARIABLE, - SCHEMA_ONE_DISTRIBUTION, - SCHEMA_ONE_RELEASE_DECLARATION, - SCHEMA_ONE_SOURCE_PATHS, - SCHEMA_ONE_TREE, - SCHEMA_ONE_TREE_SOURCE, -) - -DOTENV = REPO / ".env" - -_VERSION = re.compile(r'^version = "([^"]+)"', re.MULTILINE) - - -class RefreshError(Exception): - """Something about the environment or the checkout makes a refresh unsafe.""" - - -def load_dotenv() -> None: - """Populate the environment from `.env`, without overriding what is set. - - Read directly rather than through python-dotenv, for the reason - `tests/conftest.py` gives: parsing a handful of `export KEY=value` lines does - not justify a dependency. This is a second small implementation rather than a - shared one because `conftest.py` must stay free of heavy imports at - collection time, and anything this script could import it from is not. - """ - if not DOTENV.is_file(): - return - for raw in DOTENV.read_text(encoding="utf-8").splitlines(): - line = raw.strip() - if not line or line.startswith("#"): - continue - if line.startswith("export "): - line = line[len("export ") :].strip() - if "=" not in line: - continue - key, _, value = line.partition("=") - os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) - - -def installed_release() -> str: - """Return the release of the adapter this environment actually has. - - Read rather than accepted as an argument, because a version typed on a - command line is a third place for the claim to be wrong. The pin has one - home in `manifest.json`, reaches the environment through `pyproject.toml`, - and this is that same value read back out. - """ - try: - return version(SCHEMA_ONE_DISTRIBUTION) - except PackageNotFoundError as exc: - raise RefreshError( - f"{SCHEMA_ONE_DISTRIBUTION} is not installed, so there is no release to " - f"record. Run this through the project environment: " - f"`uv run python scripts/{Path(__file__).name}`." - ) from exc - - -def checkout() -> Path: - """Resolve the `span-panel-api` checkout named by the environment. - - A relative value is resolved against the repository root rather than the - working directory, because that is what `.env.example`'s `../span-panel-api` - means and this can be run from anywhere. - """ - configured = os.environ.get(CHECKOUT_VARIABLE) - if not configured: - raise RefreshError( - f"{CHECKOUT_VARIABLE} is not set. Point it at a span-panel-api checkout -- " - f"copy `.env.example` to `.env` and edit it, or export it for this run. " - f"There is no default: a refresh has to name the checkout it trusts." - ) - path = Path(configured) - if not path.is_absolute(): - path = REPO / path - if not path.is_dir(): - raise RefreshError( - f"{CHECKOUT_VARIABLE}={configured} does not exist. Point it at a " - f"span-panel-api checkout." - ) - if not (path / SCHEMA_ONE_RELEASE_DECLARATION).is_file(): - raise RefreshError( - f"{CHECKOUT_VARIABLE}={configured} has no {SCHEMA_ONE_RELEASE_DECLARATION}, " - f"so it is not a span-panel-api checkout (or not a complete one). Re-clone it." - ) - return path - - -def declared_release(source: Path) -> str: - """Read the release a checkout declares, from the package it would build.""" - declaration = (source / SCHEMA_ONE_RELEASE_DECLARATION).read_text(encoding="utf-8") - found = _VERSION.search(declaration) - if found is None: - raise RefreshError( - f"{source / SCHEMA_ONE_RELEASE_DECLARATION} declares no version, so there is " - f"no way to tell which release its capture belongs to." - ) - return found.group(1) - - -def _git(source: Path, *arguments: str) -> str | None: - """Run a read-only git command in the checkout, or None if git cannot answer. - - None covers a checkout that is not a git repository at all -- an unpacked - sdist, a copied directory -- which is a legitimate way to have the files and - simply means the position check cannot be made. - """ - try: - finished = subprocess.run( # noqa: S603 - ["git", "-C", str(source), *arguments], - capture_output=True, - text=True, - check=False, - ) - except OSError: - return None - if finished.returncode != 0: - return None - return finished.stdout.strip() - - -def require_position_at_the_release(source: Path, release: str, allow_unreleased: bool) -> None: - """Refuse a checkout that is not sitting on the release it claims. - - **This is the check that would have caught the refresh that went wrong, and - the version comparison above is not.** The stale worktree the capture came - from *declared* the installed release while sitting on unreleased work behind - `main`, so the declaration and the pin agreed while the bytes were a release - old. A version string in a working tree is a statement of intent; a tag is a - fact, and only the tag can be checked. - - Three outcomes, because they call for different actions: - - * Positioned at the tag -- nothing to say, this is the ordinary case. - * Positioned elsewhere while the tag exists -- refuse. A release is available - and there is no reason to copy from anything else. - * Tag absent -- refuse unless `--allow-unreleased` was passed. The release is - not cut, so unreleased work is the only thing there is to copy from, and - that is sometimes the right call: it is how this capture was corrected when - the fix was on `main` and 1.1.0 was not yet tagged. But it is a decision, not - a detail, and it must be made deliberately. A warning on stderr is not good - enough -- this repository already learned that a message which does not stop - anything reads exactly like success. - """ - tag = f"schema-1-v{release}" - tagged = _git(source, "rev-list", "-n", "1", tag) - - if tagged is None: - if not allow_unreleased: - raise RefreshError( - f"{source} declares {release}, but {tag} does not exist there (or it is " - f"not a git checkout), so these bytes are unreleased work that nothing " - f"can verify against a release. That is occasionally the right thing to " - f"vendor -- it is how this capture was corrected while the fix was only " - f"on `main` -- but it has to be deliberate. Fetch the tag if you expected " - f"one (`git -C {source} fetch --tags`), or re-run with --allow-unreleased " - f"and say in the commit message why." - ) - print( - f"warning: {tag} does not exist in {source}, so this vendors unreleased " - f"work. Nothing can verify these bytes against a release until {tag} is " - f"cut; refresh again once it is.", - file=sys.stderr, - ) - return - - head = _git(source, "rev-parse", "HEAD") - if head is not None and head != tagged: - raise RefreshError( - f"{source} is on {head[:12]}, not on {tag} ({tagged[:12]}), even though " - f"{tag} exists. Copy from the release, not from a working tree that happens " - f"to declare it -- that difference is exactly how this capture went wrong " - f"before. `git -C {source} checkout {tag}` and run this again." - ) - - -def capture_in(source: Path, release: str) -> Path: - """Locate the capture inside a checkout, at whichever of the two paths holds it.""" - for relative in SCHEMA_ONE_SOURCE_PATHS: - candidate = source / relative - if candidate.is_file(): - return candidate - locations = " nor ".join(str(relative) for relative in SCHEMA_ONE_SOURCE_PATHS) - raise RefreshError( - f"{source} is {SCHEMA_ONE_DISTRIBUTION} {release} but holds the capture at " - f"neither {locations}. Either the checkout is incomplete, or that release " - f"moved the file again and SCHEMA_ONE_SOURCE_PATHS needs the new location." - ) - - -def refresh(allow_unreleased: bool) -> int: - """Copy the capture and write the release it came from. Return an exit code.""" - load_dotenv() - - installed = installed_release() - source = checkout() - declared = declared_release(source) - - if declared != installed: - raise RefreshError( - f"{CHECKOUT_VARIABLE} names a checkout on {SCHEMA_ONE_DISTRIBUTION} " - f"{declared}, and this environment has {installed} installed. Refreshing " - f"from it would vendor bytes from one release and record another -- which " - f"is how the capture came to carry a producer defect that upstream had " - f"already fixed. Move the checkout to {installed} (its tag is " - f"schema-1-v{installed}), or change the pin in manifest.json and re-sync." - ) - - require_position_at_the_release(source, installed, allow_unreleased) - capture = capture_in(source, declared) - - # `shutil.copyfile`, never a json.load/json.dump round trip. Re-serialising is - # what turns a copy into a reformatting: it rewrites indentation, key order and - # float spelling, and the byte comparison in tests/test_fixture_provenance.py - # exists precisely to reject the result. - unchanged = SCHEMA_ONE_TREE.is_file() and SCHEMA_ONE_TREE.read_bytes() == capture.read_bytes() - shutil.copyfile(capture, SCHEMA_ONE_TREE) - - record = f"{SCHEMA_ONE_DISTRIBUTION}=={installed}\n" - record_unchanged = ( - SCHEMA_ONE_TREE_SOURCE.is_file() - and SCHEMA_ONE_TREE_SOURCE.read_text(encoding="utf-8") == record - ) - SCHEMA_ONE_TREE_SOURCE.write_text(record, encoding="utf-8") - - print(f"source: {capture}") - print(f"release: {SCHEMA_ONE_DISTRIBUTION}=={installed}") - if unchanged and record_unchanged: - print("Already current; nothing changed.") - return 0 - print(f"updated: {SCHEMA_ONE_TREE.relative_to(REPO)}") - print(f"updated: {SCHEMA_ONE_TREE_SOURCE.relative_to(REPO)}") - print( - "Review the diff before committing, and commit both files together -- " - "they are one fact in two files." - ) - return 0 - - -ALLOW_UNRELEASED = "--allow-unreleased" - - -def main(argv: list[str]) -> int: - """Run the refresh, reporting a bad environment as prose rather than a traceback. - - Arguments are checked by hand rather than with argparse: there is one flag, - and an unrecognised one has to be an error instead of being ignored -- a - misspelled `--allow-unreleased` that silently did nothing would turn the - deliberate decision back into an accident. - """ - unknown = [argument for argument in argv if argument != ALLOW_UNRELEASED] - if unknown: - print( - f"refresh-vendored-capture: unrecognised argument(s) {' '.join(unknown)}. " - f"The only option is {ALLOW_UNRELEASED}.", - file=sys.stderr, - ) - return 2 - try: - return refresh(allow_unreleased=ALLOW_UNRELEASED in argv) - except RefreshError as exc: - print(f"refresh-vendored-capture: {exc}", file=sys.stderr) - return 1 - - -if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) diff --git a/tests/adapter_fixtures.py b/tests/adapter_fixtures.py index 53273f5c..665735fc 100644 --- a/tests/adapter_fixtures.py +++ b/tests/adapter_fixtures.py @@ -1,9 +1,15 @@ -"""Build real adapter field metadata from vendored fixtures. +"""Build real adapter field metadata from the adapters' own reference payloads. The library's own harness compares wire-level deltas between schemas; it cannot know what this integration declares it reads. These helpers give the integration's tests real adapter output to check declarations against. +The payloads are package data of the two adapter distributions, read through +`importlib.resources` exactly as the library's own suite reads them, so the bytes +replayed here are the bytes shipped by the wheel `manifest.json` pins. The pin is +the provenance: bumping it is what moves the capture, and nothing has to keep a +copy of it honest. + Uses the real `ebus_sdk.DiscoveredDevice` rather than a stand-in. A description-only stand-in is not sufficient: `_downstream_lugs_metadata` -> `find_lugs` reads property *values* via `device.get_property(...)`, so a stand-in @@ -13,92 +19,32 @@ from __future__ import annotations +from importlib.resources import files import json -import pathlib -from typing import NamedTuple from ebus_sdk.homie import DiscoveredDevice -from span_panel_api.models import FieldMetadata, SpanPanelSnapshot, V2HomieSchema +from span_panel_api.models import FieldMetadata, HomieSchemaTypes, SpanPanelSnapshot, V2HomieSchema from custom_components.span_panel.schema_validation import DiscoveredProperty -_FIXTURES = pathlib.Path(__file__).parent / "fixtures" - -SCHEMA_ONE_TREE = _FIXTURES / "schema_one_tree.json" -"""The vendored parent/child capture. See `tests/fixtures/README.md` for provenance.""" - -SCHEMA_ONE_TREE_SOURCE = _FIXTURES / "schema_one_tree.source" -"""The release `SCHEMA_ONE_TREE` was copied from, checked by `test_fixture_provenance.py`.""" - -SCHEMA_ONE_DISTRIBUTION = "span-panel-api-schema-1" -"""The distribution that publishes the capture, and whose installed version the claim names.""" - -CHECKOUT_VARIABLE = "SPAN_PANEL_API_DIR" -"""Names a `span-panel-api` checkout. Already in `.env.example` for editable installs.""" - -SCHEMA_ONE_SOURCE_PATHS = ( - pathlib.Path("tests") / "reference_payloads" / "parent_child_tree.json", - pathlib.Path("packages") - / "schema-1" - / "src" - / "span_panel_api_schema_1" - / "reference_payloads" - / "parent_child_tree.json", -) -"""Where the capture lives inside a `span-panel-api` checkout, newest location first. - -Two of them because the file is moving. `span-panel-api#162` takes the reference -payloads out of the schema_1 wheel and makes them ordinary test fixtures -- which -is the whole reason this repository vendors a copy rather than importing one -- -but that change is unmerged, so a checkout on `main` today still holds the file -under `packages/schema-1/`. Checking the new path first follows the file rather -than the merge, and works against a checkout on either side of it. - -Delete the second entry once #162 is merged **and** no release this repository can -pin still predates it: the CI clone and `scripts/refresh-vendored-capture.py` both -position themselves at a recorded release, so the old path stays reachable for as -long as that release can be a pre-#162 one. - -Here rather than in `test_fixture_provenance.py` because the refresh script needs -the same two paths. A second copy of this list is how the fallback silently -outlives the merge in one place and not the other. -""" - -SCHEMA_ONE_RELEASE_DECLARATION = pathlib.Path("packages") / "schema-1" / "pyproject.toml" -"""Where a checkout declares which schema-1 release it is.""" - +SCHEMA_ONE_TREE = files("span_panel_api_schema_1") / "reference" / "parent_child_tree.json" +"""The parent/child capture, as `span-panel-api-schema-1` ships it.""" -class VendoredSource(NamedTuple): - """The distribution and release a vendored fixture was copied from.""" +SCHEMA_ZERO_SCHEMA = files("span_panel_api_schema_0") / "reference" / "homie_schema.json" +"""The `GET /api/v2/homie/schema` capture, as `span-panel-api-schema-0` ships it.""" - distribution: str - version: str +def _schema_zero_types() -> HomieSchemaTypes: + """The flat capture's `types` map, which is the shape a schema_0 build takes. -def schema_one_source() -> VendoredSource: - """Read the release recorded beside the vendored capture. - - Recorded as a pinned requirement -- `span-panel-api-schema-1==1.1.0` -- rather - than a bare version, for two reasons. It names *which* distribution the claim - is about, and this repository pins three of them; and it is written in the - same vocabulary as the `manifest.json` requirement it has to agree with, so - the two can be compared by eye during a bump as well as by - `test_fixture_provenance.py`. - - It lives in its own file rather than inside the payload because the refresh - procedure is a byte-for-byte copy: a key added to the JSON would be - overwritten by every refresh, and a payload that differs from its source is - exactly what the README forbids. + The whole `GET /api/v2/homie/schema` response is what the adapter ships; + `types` is the half both flat fixtures need, so it is unwrapped once here + rather than at each of them. """ - recorded = SCHEMA_ONE_TREE_SOURCE.read_text().strip() - distribution, separator, version = recorded.partition("==") - if not (separator and distribution and version): - raise ValueError( - f"{SCHEMA_ONE_TREE_SOURCE} must hold one pinned requirement naming the " - f"release the vendored capture was copied from, such as " - f"'span-panel-api-schema-1==1.1.0'. It holds {recorded!r}." - ) - return VendoredSource(distribution, version) + document: dict[str, HomieSchemaTypes] = json.loads( + SCHEMA_ZERO_SCHEMA.read_text(encoding="utf-8") + ) + return document["types"] SCHEMA_ONE_PANEL = "example-40t-001" @@ -137,13 +83,11 @@ def _devices_from(tree: dict[str, dict[str, str]]) -> list[DiscoveredDevice]: def schema_one_tree(without: str | None = None) -> dict[str, dict[str, str]]: """A mutable copy of the parent/child capture, ready to be rewritten. - **Vendored here, not read from the library's package data.** The capture is - test data; carrying it in the runtime wheel put 56 KB of it on every user's - disk, and reading it by import made this suite depend on a distribution - continuing to ship files nothing at runtime reads. The objection to a copy was - that it goes stale in silence -- answered by `schema_one_source()` and - `test_fixture_provenance.py`, which hold the recorded release against the one - actually installed, so a moved pin with an unrefreshed copy fails CI by name. + **Read from the library's package data, not copied into this repository.** + `span-panel-api-schema-1` publishes the capture beside the adapter that parses + it, so the pinned wheel is both the payload and the record of which release it + came from; a copy here would need a guard to keep it honest, and the pin needs + none. Copied per call, and one level deep, which is as deep as a topic goes: a test proves a reading came off the wire by republishing it and asserting the entity @@ -154,7 +98,7 @@ def schema_one_tree(without: str | None = None) -> dict[str, dict[str, str]]: from the base by construction -- the only difference each ever had was the one missing device. """ - capture: dict[str, dict[str, str]] = json.loads(SCHEMA_ONE_TREE.read_text()) + capture: dict[str, dict[str, str]] = json.loads(SCHEMA_ONE_TREE.read_text(encoding="utf-8")) tree = {device_id: dict(topics) for device_id, topics in capture.items()} if without is not None: assert without in tree, f"{without!r} is not in the capture; nothing to drop" @@ -192,8 +136,7 @@ def schema_zero_metadata() -> dict[str, FieldMetadata]: """ from span_panel_api_schema_0.field_metadata import build_field_metadata - raw = json.loads((_FIXTURES / "schema_zero_types.json").read_text()) - return _curated(build_field_metadata(raw["types"])) + return _curated(build_field_metadata(_schema_zero_types())) SCHEMA_ZERO_SERIAL = "sp3-synthetic-0001" @@ -224,11 +167,10 @@ def schema_zero_snapshot() -> SpanPanelSnapshot: """ from span_panel_api_schema_0.adapter import SchemaZeroAdapter - raw = json.loads((_FIXTURES / "schema_zero_types.json").read_text()) schema = V2HomieSchema( firmware_version=SCHEMA_ZERO_FIRMWARE, types_schema_hash="0" * 16, - types=raw["types"], + types=_schema_zero_types(), ) return SchemaZeroAdapter(SCHEMA_ZERO_SERIAL, schema).build_snapshot() @@ -272,7 +214,7 @@ def schema_one_metadata_raw() -> dict[str, FieldMetadata]: def schema_one_discovery() -> tuple[DiscoveredProperty, ...]: - """What schema_1 declares in the vendored tree that it reads nothing from. + """What schema_1 declares in the reference tree that it reads nothing from. The other half of the same map. Held apart from `schema_one_metadata` so a test has to ask for it by name — a discovered path arriving unannounced in a diff --git a/tests/conftest.py b/tests/conftest.py index 9e0463d1..9499daaa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,6 @@ """Configure test framework.""" import logging -import os from pathlib import Path import sys import types @@ -15,49 +14,6 @@ _PROJECT_ROOT = str(Path(__file__).resolve().parent.parent) _CC_DIR = str(Path(_PROJECT_ROOT) / "custom_components") -_DOTENV = Path(_PROJECT_ROOT) / ".env" - - -def _load_dotenv() -> None: - """Populate the environment from `.env`, without overriding what is set. - - Read directly rather than through python-dotenv. Until now `.env` was only - ever consumed by shell scripts, which source it; the fixture provenance - comparison in `test_fixture_provenance.py` is the first thing inside the - suite that needs `SPAN_PANEL_API_DIR`, and taking a dependency to parse a - handful of `export KEY=value` lines would put a package on the test path to - save nothing. `span-panel-api` reads its own `.env` the same way and for the - same reason. - - `setdefault`, never assignment. An exported value is a deliberate choice for - this run -- pointing at a different checkout to reproduce something -- and a - file silently winning over it is the kind of surprise that costs an - afternoon. - - The `export ` prefix is stripped because this repository's `.env.example` - writes it, so a loader that took the line verbatim would define a variable - named `export SPAN_PANEL_API_DIR` and the checks that read it would go on - skipping, which is the failure mode they exist to remove. - - Absence is fine; the checks that need a checkout skip themselves. See - `.env.example`. - """ - if not _DOTENV.is_file(): - return - for raw in _DOTENV.read_text(encoding="utf-8").splitlines(): - line = raw.strip() - if not line or line.startswith("#"): - continue - if line.startswith("export "): - line = line[len("export ") :].strip() - if "=" not in line: - continue - key, _, value = line.partition("=") - os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) - - -_load_dotenv() - def _ensure_span_panel_importable() -> None: """Ensure custom_components.span_panel can be imported. diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index 3717338b..da8f9f0e 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -1,148 +1,11 @@ # Test fixtures -Two kinds of vendored byte copy live here, on the same terms: committed rather than generated, held to their source by a guard, and exempt from the formatters -because in both cases the bytes are the artifact. +The files here are committed source this repository owns, with one exception: the **historical `pyproject.toml` copies** (`pyproject_*.toml`) are this +repository's own file at the two commits where the library path override went wrong, held to those commits by a guard. -**Adapter captures** (`schema_*.json`) are real schema-adapter inputs, used by the field-path conformance tests via `tests/adapter_fixtures.py`. They are -committed so the test suite has no cross-repo dependency and no runtime wheel has to keep shipping test data for this repository's benefit. The cost of a copy -is that it can go stale, which for `schema_one_tree.json` is answered by the two guards below. Everything from here to "Why the flat capture has no byte guard" -is about them. - -**Historical `pyproject.toml` copies** (`pyproject_*.toml`) are this repository's own file at the two commits where the library path override went wrong. See -"[The historical pyproject copies](#the-historical-pyproject-copies)". - -## Provenance - -Both are byte-identical copies from the `span-panel-api` repository: - -| File here | Source in `$SPAN_PANEL_API_DIR` | Guarded by | -| ------------------------ | ------------------------------------------------- | ------------------------ | -| `schema_zero_types.json` | `tests/reference_payloads/homie_schema.json` | nothing | -| `schema_one_tree.json` | `tests/reference_payloads/parent_child_tree.json` | `schema_one_tree.source` | - -Refresh by copying them again, and keep `schema_one_tree.json` byte-identical to its source: the library pins what that capture leaves unvalued against -panelbench's own baseline (`tests/test_reference_tree_values.py` there), so a copy that has drifted puts these tests on a wire no producer sends. - -Both payloads shipped as package data until recently, and this repository imported them from the installed wheels rather than copying them: - -| File here | Path before `span-panel-api#162` | -| ------------------------ | ----------------------------------------------------------------------------------------- | -| `schema_zero_types.json` | `src/span_panel_api/reference_payloads/homie_schema.json` | -| `schema_one_tree.json` | `tests/reference_payloads/parent_child_tree.json` | - -`span-panel-api#162` takes both back out of the wheels and moves them to `tests/reference_payloads/`, so the paths in the first table are the ones to copy from -now. The byte comparison below checks both locations for the tree, newest first, so it works against a checkout on either side of that merge; the older path can -be dropped once no release this repository can pin still predates it. - -`schema_zero_types.json` has been verified byte-identical to `homie_schema.json` at `schema-1-v1.0.0`, `v3.0.1`, `main`, and the post-#162 location — the -bootstrap payload has not moved a byte across any of them. It has no automated guard: see "Why the flat capture has no byte guard" below. - -If a copy changes shape rather than content, the loader in `tests/adapter_fixtures.py` is what needs updating — note that `schema_one_tree.json` is a **dict -keyed by device id**, whose `$description` value is a **JSON string**, not a parsed object. `tests/test_fixture_provenance.py` pins that shape, so a capture -that arrives pre-parsed fails there rather than somewhere far from the fixture. - -## The two guards - -They catch different failures, and neither one substitutes for the other. - -| Guard | Compares | Catches | Needs a checkout | -| --------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------- | -| Version claim | `schema_one_tree.source` against the installed distribution | a pin that moved while the copy stayed put | no | -| Byte comparison | `schema_one_tree.json` against the capture in that release | a copy that was refreshed **wrongly** — reformatted, edited, or taken from a working tree ahead of the release it names | yes | - -### The version claim - -`schema_one_tree.source` records the release the capture was copied from, as a pinned requirement: - -```text -span-panel-api-schema-1==1.1.1 -``` - -`tests/test_fixture_provenance.py` holds that against `importlib.metadata.version("span-panel-api-schema-1")` — the release actually installed. When the pin in -`manifest.json` moves and nobody refreshes the capture, that test fails and names the refresh, so staleness is loud rather than silent. It needs no checkout of -the library and no network call, which is why it runs everywhere and why a copy is safe to keep here at all. - -It is a separate file rather than a key inside the payload because a refresh is a byte-for-byte copy: anything added to the JSON would be overwritten by the -next one. It names the distribution as well as the version because this repository pins three of them. - -What it cannot see is the bytes. A capture edited in place, reindented on the way in, or copied from a working tree that is ahead of the release it names -satisfies this guard completely — the claim is a string, and the string is correct. - -### The byte comparison - -`test_the_vendored_capture_is_byte_identical_to_its_source` closes exactly that hole. It reads `SPAN_PANEL_API_DIR` (see `.env.example`), confirms the checkout -is on the release `schema_one_tree.source` names, and compares the vendored copy against the capture there byte for byte, with no reformatting tolerance. - -The release check is not decoration. Written without it, the comparison passed against a checkout sitting one release behind the recorded one, whose bytes -happened to be the ones vendored here — proving only that the copy matched _something_, and "something" includes the working tree it was mistakenly taken from. - -**A skip here is not a pass.** Without a checkout, or with one on another release, the comparison skips: not every developer keeps a sibling checkout, and -failing them for it is what gets a check deleted rather than configured. Under `CI` it fails instead, because the workflow clones `span-panel-api` at the -release the pin names, so an unset or misplaced path there means the wiring has come undone rather than that the check is unavailable. `span-panel-api` paid for -this asymmetry and states the reason plainly in its `DEVELOPMENT.md`, under "A skip here is not a pass": _"A skip reads in a summary line exactly like a pass, -and that is how a stale vendored capture went unnoticed for nine days."_ Do not make the skip unconditional. - -CI derives the tag from `schema_one_tree.source` rather than hardcoding it — `span-panel-api-schema-1==1.1.0` becomes `schema-1-v1.1.0` — so there is no second -copy of the pin that could disagree with the first. - -### What it caught the first time it ran - -Worth recording, because it is the failure mode the guard was built for and it had already happened. - -`schema_one_tree.json` was vendored from a **stale sibling checkout**. `pyproject.toml` pointed its `[tool.uv.sources]` path overrides at a `span-panel-api` -worktree that predated span-panel-api#161, so `span_panel_api_schema_1.reference_payloads` resolved there, and the copy inherited a capture that still recorded -the emitter's `$settable` defect on a locked relay together with `load-shed/priority` values (`UNKNOWN`) that no producer publishes. The copy itself was -faithful — byte-identical to the `schema-1-v1.0.0` release artifact — and `schema_one_tree.source` claimed 1.1.0. Both halves looked right in isolation; only -holding the bytes against the release the claim names showed the mismatch. - -Nothing else in either repository could see it. The version guard compared a string to a string and passed. The conformance tests passed, because they were -asserting against the capture. The integration was testing its locked-circuit and priority-settability work against a producer defect that upstream had already -fixed. - -The fix was to repoint the path overrides at a checkout that tracks `main` and re-vendor. A path override redirects what every import in the suite resolves to, -so one aimed at a stale checkout quietly tests against a producer that no longer exists — worth checking before believing any fixture taken through one. - -### Refreshing - -Either guard failing names the refresh, and the refresh is one command: - -```bash -# SPAN_PANEL_API_DIR names the checkout; see .env.example -uv run python scripts/refresh-vendored-capture.py -``` - -Commit both files it touches together — they are one fact in two files. - -What it does, for anyone reading this without the checkout to hand: - -1. Copies `tests/reference_payloads/parent_child_tree.json` from `$SPAN_PANEL_API_DIR` over `schema_one_tree.json`, byte for byte — `shutil.copyfile`, never a - `json.load`/`json.dump` round trip, which would rewrite indentation, key order and float spelling and produce exactly what the byte comparison rejects. It - checks the pre-#162 package-data path as a fallback, so it works against a checkout on either side of that merge. -2. Writes `schema_one_tree.source` from the **installed** distribution's version, rather than from anything typed in. - -**Doing both from one command is the point, not the convenience.** The bytes and the recorded release used to be updated by two different actions, so doing one -and not the other was a single missed keystroke — and the result was a capture whose claim about itself was wrong, which every conformance test then passed -against. One command cannot do half of it. - -It refuses rather than guesses: no `SPAN_PANEL_API_DIR`, a path that does not resolve, a directory that is not a checkout, a checkout whose schema-1 release is -not the one installed, a checkout with the file at neither location, or a checkout that is not standing on the release's tag. That last one is the one that -matters: the refresh that went wrong copied from a worktree that _declared_ the installed release while sitting on unreleased work behind `main`, so the -declaration and the pin agreed while the bytes were a release old. A version in a working tree is intent; a tag is a fact, and only a tag can be checked. - -Copying from unreleased work is still possible with `--allow-unreleased`, because it is occasionally correct — the current copy is exactly that case, taken from -`main` because `schema-1-v1.1.0` is not cut and the alternative was to keep shipping a capture that records a producer defect. It verifies against a `main` -checkout today and will verify against the tag once 1.1.0 is released from these bytes; if `main` moves the capture again first, the guard says so, which is the -whole point. What is never acceptable is a capture taken from a checkout _behind_ the recorded release — that is how this one went wrong. - -### Why the capture is not copied at test time - -Someone will eventually propose having the suite copy the fixture from `$SPAN_PANEL_API_DIR` on every run, so it can never be stale. It would undo both things -vendoring bought. - -It puts the cross-repo dependency back — every test run needing a `span-panel-api` checkout, which is exactly what committing the bytes removed. And it makes -the byte comparison vacuous: a copy compared against the thing it was just copied from can only ever pass. **Committed bytes that are allowed to disagree with -their source are the whole mechanism.** That freedom to disagree is what let the comparison find a capture vendored from a stale path override, which nothing -else in either repository could see. A fixture that silently re-syncs itself cannot report anything. +The schema-adapter payloads the conformance tests replay are **not** here. They are package data of `span-panel-api-schema-0` and `span-panel-api-schema-1`, +read out of the installed wheels by `tests/adapter_fixtures.py`; the pin in `custom_components/span_panel/manifest.json` is what says which capture the suite +replays, and bumping the pin is what moves it. ## The historical pyproject copies @@ -160,64 +23,38 @@ defect is written by somebody who already knows what the rule checks, so it exer first: it is the interval between the two corrections, several commits during which the repository looked fixed, and a hook covering only `[tool.uv.sources]` calls it clean. -Unlike the adapter captures, these cannot go stale. A commit is content-addressed, so `3cbf02a:pyproject.toml` cannot become different bytes; the only failure -the comparison can report is a copy that was wrong when it was vendored. That is why its guard **skips** when the object is unreachable — a shallow clone has no -history to compare against, and failing there would report the clone rather than the fixture. This is the one place in this directory where a skip is not hiding -a moving target; "A skip here is not a pass" above is the case where it would be, and that distinction is the whole reason this one is allowed. +These cannot go stale. A commit is content-addressed, so `3cbf02a:pyproject.toml` cannot become different bytes; the only failure the comparison can report is a +copy that was wrong when it was taken. That is why its guard **skips** when the object is unreachable — a shallow clone has no history to compare against, and +failing there would report the clone rather than the fixture. A skip is safe here precisely because the target cannot move, which is not true of a guard over +anything that can. Re-vendor either with `git show :pyproject.toml > tests/fixtures/.toml`, and only to correct a copy — never to make a failing case pass. -## These files are exempt from formatting - -`tests/fixtures/schema_*.json` and `tests/fixtures/pyproject_*.toml` are excluded from every hook that rewrites files — `trailing-whitespace`, -`end-of-file-fixer` and `mixed-line-ending` in `prek.toml`, and `tests/fixtures/schema_*.json` in `.prettierignore` (Prettier cannot format TOML, so the -historical copies need no line there). - -The reason is the whole point of the byte comparison: **these are captured bytes, not source we own.** A vendored fixture held byte-identical to its source and -an unconditional formatter cannot both exist, and it is the formatter that has to yield. A capture reindented on the way in is one nothing upstream can hold to -its own baseline any more, and the resulting failure names the fixture rather than the hook that broke it — so the person debugging it starts in the wrong -repository. - -It is not hypothetical, only untriggered so far. Those three hooks cover `tests/` and left the captures alone only because both happen to be newline-terminated -with LF endings and no trailing whitespace. Stripping the final newline from `schema_one_tree.json` and running `prek run end-of-file-fixer --all-files` reports -`Fixing tests/fixtures/schema_one_tree.json` without the exclusion, and leaves it untouched with it. The first capture vendored without a final newline would -have been rewritten on commit, and the guard would then have failed against a source the copy genuinely matched when it was made. - -Prettier is excluded as a **precaution, not a fix.** It has never touched these files and caused none of the drift we have seen — nothing here runs it over JSON -(`scripts/fix-markdown.sh` passes only `*.md` globs, `prek.toml` has no Prettier hook), and the drift the byte guard actually caught was a stale source -checkout. The line still earns its place: `schema_zero_types.json` carries its producer's 4-space indentation, and Prettier under `.prettierrc` rewrites it from -14675 to 11779 bytes. That file has no byte guard, so nothing would report it. - -The scope is two prefixes rather than the whole directory, because only the vendored byte copies have this property. `tests/fixtures/README.md` is prose this -repository owns and should keep being formatted; the migration YAMLs are hand-written source; and `unread_declarations_baseline.json`, despite being a -mechanically-checked inventory, is hand-maintained — its values are one-line human explanations. Each prefix is a vocabulary something already uses — `schema_*` -is `tests/adapter_fixtures.py`'s for adapter captures, `pyproject_*` is `tests/test_library_path_hook.py`'s for historical copies of this repository's own -`pyproject.toml` — so the next one vendored under either is covered without anyone remembering to widen the rule. - -The read-only hooks still cover these files, `check-json` and `check-toml` in particular. A copy that does not parse is worth hearing about wherever it came -from. +## The historical copies are exempt from formatting -## Why the flat capture has no byte guard +`tests/fixtures/pyproject_*.toml` is excluded from every hook that rewrites files — `trailing-whitespace`, `end-of-file-fixer` and `mixed-line-ending` in +`prek.toml`. Prettier cannot format TOML, so `.prettierignore` needs no line for them. -`schema_zero_types.json` is vendored on the same terms as the tree but has no `.source` file and no byte comparison, and that is a deliberate gap rather than an -oversight. +The reason is the whole point of the comparison: **these are captured bytes, not source we own.** A copy held byte-identical to a commit and an unconditional +formatter cannot both exist, and it is the formatter that has to yield. A copy reindented on the way in fails against the commit it genuinely matched when it +was made, and the resulting failure names the fixture rather than the hook that broke it — so the person debugging it starts in the wrong place. -It comes from the **bootstrap** distribution, `span-panel-api`, which is released on its own tag line (`v3.1.0`), while the tree comes from -`span-panel-api-schema-1` (`schema-1-v1.1.0`). CI clones the library once, at one revision. A checkout positioned at the schema-1 tag holds the bootstrap -sources at whatever state they happened to be in at that commit, which is not the same thing as a bootstrap _release_ — so a byte comparison there would be -against an arbitrary revision, which is exactly the "proves only that the copy matches something" failure the tree's release check exists to prevent. +It is not hypothetical, only untriggered so far. Those three hooks cover `tests/`, and they leave the copies alone only because both happen to be +newline-terminated with LF endings and no trailing whitespace. The first one vendored without a final newline would be rewritten on commit. -Guarding it properly means either a second checkout at the bootstrap tag or a second job, and neither is free. Against that: the payload has been byte-stable -upstream across every revision checked. The gap is recorded here so the next person weighs it rather than rediscovering it. +The scope is one prefix rather than the whole directory, because only these copies have this property. `tests/fixtures/README.md` is prose this repository owns +and should keep being formatted; the migration YAMLs are hand-written source; and `unread_declarations_baseline.json`, despite being a mechanically-checked +inventory, is hand-maintained — its values are one-line human explanations. `pyproject_*` is `tests/test_library_path_hook.py`'s own vocabulary, so the next +copy taken under it is covered without anyone remembering to widen the rule. -## Derived variants +The read-only hooks still cover these files, `check-toml` in particular. A copy that does not parse is worth hearing about wherever it came from. -The batteryless and PV-less trees are **derived in memory**, not committed: `adapter_fixtures.schema_one_tree(without="bess")` and `without="pv"` return the -capture with that one device dropped. They were separate files once; deriving them means they cannot drift from the base, since the only difference either ever -had was the one missing device. Each drops exactly one device (13 -> 12) and retains the panel and both lugs devices — a variant that removed more would make -the conformance tests pass for the wrong reason. Note `bess-mid` is typed `energy.ebus.device.mid` and is not the BESS. +## Derived variants of the parent/child capture -## Why these exist +The batteryless and PV-less trees are **derived in memory**: `adapter_fixtures.schema_one_tree(without="bess")` and `without="pv"` return the adapter's capture +with that one device dropped. They were separate files once; deriving them means they cannot drift from the base, since the only difference either ever had was +the one missing device. Each drops exactly one device (14 -> 13) and retains the panel and both lugs devices — a variant that removed more would make the +conformance tests pass for the wrong reason. Note `bess-mid` is typed `energy.ebus.device.mid` and is not the BESS. The batteryless tree proves a panel with no BESS produces **no** `battery.*` entries — hardware absence, not degradation. The PV-less tree proves the same for a panel that has power-flows telemetry but no PV device, which is the case telemetry-based capability detection gets wrong. diff --git a/tests/fixtures/schema_one_tree.json b/tests/fixtures/schema_one_tree.json deleted file mode 100644 index 42a5e793..00000000 --- a/tests/fixtures/schema_one_tree.json +++ /dev/null @@ -1,252 +0,0 @@ -{ - "0ab966b95f92a6a51ec548485aa85f54": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787869298458, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "breaker/poles": "1", - "breaker/rating": "15", - "info/name": "Kitchen Lights", - "info/spaces": "1", - "load-shed/priority": "SOC_THRESHOLD", - "meter/active-power": "-121.0", - "meter/current": "1.0083333333333333", - "meter/exported-energy": "4.033333333333333", - "meter/imported-energy": "0.0", - "pcs/managed": "true", - "pcs/priority": "1", - "switch/relay": "CLOSED", - "switch/relay-controllable": "true", - "switch/relay-requester": "NONE" - }, - "573066aaddd7b75114c4563ce3af18c4": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787869298459, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Solar Inverter\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "breaker/poles": "2", - "breaker/rating": "30", - "connection/feeds-device-id": "pv", - "connection/feeds-device-status": "OK", - "connection/feeds-device-type": "energy.ebus.device.pv", - "info/name": "Solar Inverter", - "info/spaces": "36,38", - "load-shed/priority": "NEVER", - "meter/active-power": "8500.0", - "meter/current": "35.416666666666664", - "meter/exported-energy": "0.0", - "meter/imported-energy": "182.16666666666666", - "pcs/managed": "false", - "pcs/priority": "6", - "switch/relay": "CLOSED", - "switch/relay-controllable": "false", - "switch/relay-requester": "CONFIGURATION" - }, - "62d0e03897b337b57101aae82f1e9ba2": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787869298458, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "breaker/poles": "2", - "breaker/rating": "50", - "connection/feeds-device-id": "evse", - "connection/feeds-device-status": "OK", - "connection/feeds-device-type": "energy.ebus.device.evse", - "info/name": "SPAN Drive - Garage", - "info/spaces": "32,34", - "load-shed/priority": "OFF_GRID", - "meter/active-power": "-2410.0", - "meter/current": "10.041666666666666", - "meter/exported-energy": "80.33333333333333", - "meter/imported-energy": "0.0", - "pcs/managed": "true", - "pcs/priority": "3", - "switch/relay": "CLOSED", - "switch/relay-controllable": "true", - "switch/relay-requester": "NONE" - }, - "acf35888f35522c721501d35e66503e6": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787869298459, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Pool Pump\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\"}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "breaker/poles": "1", - "breaker/rating": "20", - "info/name": "Pool Pump", - "info/spaces": "3", - "load-shed/priority": "OFF_GRID", - "meter/active-power": "-1230.0", - "meter/current": "10.25", - "meter/exported-energy": "41.0", - "meter/imported-energy": "0.0", - "pcs/managed": "true", - "pcs/priority": "5", - "switch/relay": "CLOSED", - "switch/relay-controllable": "true", - "switch/relay-requester": "NONE" - }, - "bess": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787869298460, \"type\": \"energy.ebus.device.bess\", \"name\": \"Battery\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"soc\": {\"name\": \"soc\", \"type\": \"energy.ebus.capability.soc\", \"properties\": {\"soc\": {\"name\": \"State of charge\", \"datatype\": \"float\", \"unit\": \"%\"}, \"soe\": {\"name\": \"State of energy\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"communication-state\": {\"name\": \"Communication state\", \"datatype\": \"enum\", \"format\": \"OK,DEGRADED,LOST,UNKNOWN\"}}}}, \"children\": [\"bess-mid\"], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "info/firmware-version": "example-bess/v0.1.0", - "info/model": "Example BESS", - "info/nameplate-capacity": "13.5", - "info/part-number": "SPN-BESS-001", - "info/serial-number": "EXAMPLE-BESS-40T-001", - "info/vendor-name": "Span", - "meter/active-power": "3500.0", - "soc/soc": "50.410493827160494", - "soc/soe": "6.805416666666667", - "status/communication-state": "OK" - }, - "bess-mid": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787869298460, \"type\": \"energy.ebus.device.mid\", \"name\": \"Microgrid Interconnect Device\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}}}, \"grid\": {\"name\": \"grid\", \"type\": \"energy.ebus.capability.grid\", \"properties\": {\"islanding-state\": {\"name\": \"Islanding state of the BESS-integrated grid-forming device\", \"datatype\": \"enum\", \"format\": \"ON_GRID,OFF_GRID,UNKNOWN\"}, \"grid-state\": {\"name\": \"Sensed grid condition\", \"datatype\": \"enum\", \"format\": \"UP,DOWN,DEGRADED,UNKNOWN\"}, \"grid-forming-entity\": {\"name\": \"Identity of the currently grid-forming entity\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"bess\", \"extensions\": []}", - "$state": "ready", - "grid/grid-forming-entity": "GRID", - "grid/grid-state": "UP", - "grid/islanding-state": "ON_GRID", - "info/firmware-version": "example-mid/v0.1.0", - "info/hardware-version": "rev1", - "info/model": "SPAN MID", - "info/serial-number": "EXAMPLE-BESS-40T-001-mid", - "info/vendor-name": "Span" - }, - "d3724e0d660ba506aa79c1cafe5d1181": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787869298458, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garage Outlet\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "breaker/poles": "1", - "breaker/rating": "20", - "info/name": "Garage Outlet", - "info/spaces": "2", - "load-shed/priority": "SOC_THRESHOLD", - "meter/active-power": "-122.0", - "meter/current": "1.0166666666666666", - "meter/exported-energy": "4.066666666666666", - "meter/imported-energy": "0.0", - "pcs/managed": "true", - "pcs/priority": "2", - "switch/relay": "CLOSED", - "switch/relay-controllable": "true", - "switch/relay-requester": "NONE" - }, - "evse": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787869298459, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "config/max-charge-current": "32", - "config/user-max-charge-current": "32", - "info/firmware-version": "example/v0.1.0", - "info/model": "SPAN Drive", - "info/part-number": "SPN-DRV-001", - "info/serial-number": "SIM-EVSE-example-40t-001", - "info/vendor-name": "SPAN", - "meter/advertised-current": "32.0", - "status/status": "CHARGING", - "switch/lock-state": "LOCKED" - }, - "evse-2": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787869298459, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "config/max-charge-current": "32", - "config/user-max-charge-current": "32", - "info/firmware-version": "example/v0.1.0", - "info/model": "SPAN Drive", - "info/part-number": "SPN-DRV-001", - "info/serial-number": "SIM-EVSE-example-40t-001-2", - "info/vendor-name": "SPAN", - "meter/advertised-current": "32.0", - "status/status": "AVAILABLE", - "switch/lock-state": "UNLOCKED" - }, - "example-40t-001": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787869298460, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Example 40-tab Panel\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"enum\", \"format\": \"MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"data-model-version\": {\"name\": \"eBus data-model version (parent/child schema discriminator)\", \"datatype\": \"string\"}}}, \"door\": {\"name\": \"door\", \"type\": \"energy.ebus.capability.door\", \"properties\": {\"state\": {\"name\": \"Door state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"voltage-a\": {\"name\": \"L1 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}, \"voltage-b\": {\"name\": \"L2 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Main breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"enabled\": {\"name\": \"PCS system enabled\", \"datatype\": \"boolean\"}, \"active\": {\"name\": \"PCS system actively controlling one (or more) loads\", \"datatype\": \"boolean\"}, \"import-limit\": {\"name\": \"The power import limit currently being managed to\", \"datatype\": \"float\", \"unit\": \"A\"}, \"binding-constraint\": {\"name\": \"Which constraint class currently sets the import limit\", \"datatype\": \"enum\", \"format\": \"FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN\"}, \"feed-import-limit\": {\"name\": \"Limit of maximum power feeding the distribution enclosure\", \"datatype\": \"float\", \"unit\": \"A\"}, \"feed-import-limit-enablement\": {\"name\": \"Enablement status of the feed-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"feed-import-limit-active\": {\"name\": \"Is feed-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"operator-import-limit\": {\"name\": \"Operator-imposed maximum import limit\", \"datatype\": \"float\", \"unit\": \"A\"}, \"operator-import-limit-enablement\": {\"name\": \"Enablement status of the operator-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"operator-import-limit-active\": {\"name\": \"Is operator-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"off-grid-import-limit\": {\"name\": \"Off-Grid limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"off-grid-import-limit-enablement\": {\"name\": \"Enablement status of the off-grid-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"off-grid-import-limit-active\": {\"name\": \"Is off-grid-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"requested-import-limit\": {\"name\": \"Requested limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"requested-import-limit-enablement\": {\"name\": \"Enablement status of the requested-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"requested-import-limit-active\": {\"name\": \"Is requested-import-limit currently being enforced?\", \"datatype\": \"boolean\"}}}, \"shed-forecast\": {\"name\": \"shed-forecast\", \"type\": \"energy.ebus.capability.shed-forecast\", \"properties\": {\"total-time-remaining\": {\"name\": \"Estimated total time before all sheddable circuits are shed (off-grid runtime)\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"time-to-priority-shed\": {\"name\": \"Estimated time before the next priority tier is shed\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-total-time-remaining\": {\"name\": \"Estimated total time assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-time-to-priority-shed\": {\"name\": \"Estimated time to next priority shed assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"confidence\": {\"name\": \"Confidence of the shed-forecast estimate\", \"datatype\": \"enum\", \"format\": \"LOW,MEDIUM,HIGH\"}}}, \"shed\": {\"name\": \"shed\", \"type\": \"energy.ebus.capability.shed\", \"properties\": {\"asserted-islanding-state\": {\"name\": \"Consumer-asserted islanding-state (grid-state override during MID/BESS comm-loss)\", \"datatype\": \"enum\", \"format\": \"NONE,ON_GRID,OFF_GRID\", \"settable\": true}, \"policy\": {\"name\": \"Shed policy (algorithm and parameters)\", \"datatype\": \"json\", \"format\": \"{\\\"$id\\\":\\\"soc-priority.v1\\\",\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"algorithm\\\",\\\"parameters\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"algorithm\\\":{\\\"const\\\":\\\"soc-priority.v1\\\"},\\\"parameters\\\":{\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"soc-threshold-shed\\\",\\\"soc-threshold-release\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"soc-threshold-shed\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent below which SOC_THRESHOLD circuits shed\\\"},\\\"soc-threshold-release\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent above which shed SOC_THRESHOLD circuits restore\\\"}}}}}\"}}}, \"power-flows\": {\"name\": \"power-flows\", \"type\": \"energy.ebus.capability.power-flows\", \"properties\": {\"pv\": {\"name\": \"PV power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"battery\": {\"name\": \"Battery/BESS power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"grid\": {\"name\": \"Grid power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"site\": {\"name\": \"Site power flow\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"relay\": {\"name\": \"Main relay\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"ethernet\": {\"name\": \"Is Ethernet network interface operational?\", \"datatype\": \"boolean\"}, \"wifi\": {\"name\": \"Is Wi-Fi network interface operational?\", \"datatype\": \"boolean\"}, \"wifi-ssid\": {\"name\": \"SSID to which Wi-Fi network interface is connected\", \"datatype\": \"string\"}, \"cloud-connection\": {\"name\": \"Device connected to vendor cloud?\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,UNCONNECTED,CONNECTED\"}, \"postal-code\": {\"name\": \"Postal (Zip) code\", \"datatype\": \"string\"}, \"time-zone\": {\"name\": \"Time zone\", \"datatype\": \"string\"}}}}, \"children\": [\"bess\", \"0ab966b95f92a6a51ec548485aa85f54\", \"d3724e0d660ba506aa79c1cafe5d1181\", \"62d0e03897b337b57101aae82f1e9ba2\", \"fe8b85c15bc9610c1b8b4ebc6f82488d\", \"acf35888f35522c721501d35e66503e6\", \"573066aaddd7b75114c4563ce3af18c4\", \"evse\", \"evse-2\", \"lugs-upstream\", \"lugs-downstream\", \"pv\"], \"extensions\": []}", - "$state": "ready", - "breaker/rating": "200", - "door/state": "CLOSED", - "info/data-model-version": "1.0", - "info/firmware-version": "example/v0.1.0", - "info/hardware-version": "rev2", - "info/model": "MAIN_40", - "info/serial-number": "example-40t-001", - "info/vendor-name": "Span", - "meter/voltage-a": "120.0", - "meter/voltage-b": "120.0", - "pcs/active": "false", - "pcs/binding-constraint": "NONE", - "pcs/enabled": "false", - "pcs/feed-import-limit": "0.0", - "pcs/feed-import-limit-active": "false", - "pcs/feed-import-limit-enablement": "UNCONFIGURED", - "pcs/import-limit": "0.0", - "pcs/off-grid-import-limit": "0.0", - "pcs/off-grid-import-limit-active": "false", - "pcs/off-grid-import-limit-enablement": "UNCONFIGURED", - "pcs/operator-import-limit": "0.0", - "pcs/operator-import-limit-active": "false", - "pcs/operator-import-limit-enablement": "UNCONFIGURED", - "pcs/requested-import-limit": "0.0", - "pcs/requested-import-limit-active": "false", - "pcs/requested-import-limit-enablement": "UNCONFIGURED", - "power-flows/battery": "3500.0", - "power-flows/grid": "1117.0", - "power-flows/pv": "-8500.0", - "power-flows/site": "3883.0", - "shed-forecast/confidence": "HIGH", - "shed-forecast/full-charge-time-to-priority-shed": "3038", - "shed-forecast/full-charge-total-time-remaining": "4320", - "shed-forecast/time-to-priority-shed": "3037", - "shed-forecast/total-time-remaining": "4320", - "shed/asserted-islanding-state": "NONE", - "shed/policy": "{\"algorithm\": \"soc-priority.v1\", \"parameters\": {\"soc-threshold-shed\": 20, \"soc-threshold-release\": 30}}", - "status/cloud-connection": "CONNECTED", - "status/ethernet": "true", - "status/postal-code": "94103", - "status/relay": "CLOSED", - "status/time-zone": "America/Los_Angeles", - "status/wifi": "true", - "status/wifi-ssid": "example-wifi" - }, - "fe8b85c15bc9610c1b8b4ebc6f82488d": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787869298458, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "breaker/poles": "2", - "breaker/rating": "50", - "connection/feeds-device-id": "evse-2", - "connection/feeds-device-status": "OK", - "connection/feeds-device-type": "energy.ebus.device.evse", - "info/name": "SPAN Drive - Driveway", - "info/spaces": "35,37", - "load-shed/priority": "OFF_GRID", - "meter/active-power": "0.0", - "meter/current": "0.0", - "meter/exported-energy": "40.333333333333336", - "meter/imported-energy": "0.0", - "pcs/managed": "true", - "pcs/priority": "4", - "switch/relay": "CLOSED", - "switch/relay-controllable": "true", - "switch/relay-requester": "NONE" - }, - "lugs-downstream": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787869298460, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Downstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "info/direction": "DOWNSTREAM", - "meter/active-power": "-4617.0", - "meter/current-a": "56.71666666666666", - "meter/current-b": "46.474999999999994", - "meter/exported-energy": "76.95", - "meter/imported-energy": "64.55" - }, - "lugs-upstream": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787869298459, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Upstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "connection/fed-by-device-id": "bess", - "connection/fed-by-device-status": "OK", - "connection/fed-by-device-type": "energy.ebus.device.bess", - "info/direction": "UPSTREAM", - "meter/active-power": "-4617.0", - "meter/current-a": "56.71666666666666", - "meter/current-b": "46.474999999999994", - "meter/exported-energy": "76.95", - "meter/imported-energy": "64.55" - }, - "pv": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787869298460, \"type\": \"energy.ebus.device.pv\", \"name\": \"Solar\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nominal-power\": {\"name\": \"Nominal power\", \"datatype\": \"float\", \"unit\": \"W\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "info/firmware-version": "example-pv/v0.1.0", - "info/model": "IQ8PLUS-72-2-US", - "info/nominal-power": "10000.0", - "info/vendor-name": "Enphase" - } -} diff --git a/tests/fixtures/schema_one_tree.source b/tests/fixtures/schema_one_tree.source deleted file mode 100644 index 1737c0f3..00000000 --- a/tests/fixtures/schema_one_tree.source +++ /dev/null @@ -1 +0,0 @@ -span-panel-api-schema-1==1.1.2 diff --git a/tests/fixtures/schema_zero_types.json b/tests/fixtures/schema_zero_types.json deleted file mode 100644 index b7a338d0..00000000 --- a/tests/fixtures/schema_zero_types.json +++ /dev/null @@ -1,420 +0,0 @@ -{ - "firmwareVersion": "spanos2/r202603/05", - "homieDomain": "ebus", - "homieVersion": 5, - "types": { - "energy.ebus.device.distribution-enclosure.core": { - "vendor-name": { - "name": "Vendor name", - "datatype": "string" - }, - "serial-number": { - "name": "Serial number", - "datatype": "string" - }, - "hardware-version": { - "name": "Hardware version", - "datatype": "string" - }, - "software-version": { - "name": "Software version", - "datatype": "string" - }, - "door": { - "name": "Door state", - "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED" - }, - "grid-islandable": { - "name": "Capable of operating with power while disconnected from the grid", - "datatype": "boolean" - }, - "dominant-power-source": { - "name": "Current dominant power source, load-shedding trigger", - "datatype": "enum", - "format": "GRID,BATTERY,PV,GENERATOR,NONE,UNKNOWN", - "settable": true - }, - "relay": { - "name": "Main relay", - "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED" - }, - "l1-voltage": { - "name": "L1 voltage", - "datatype": "float", - "unit": "V" - }, - "l2-voltage": { - "name": "L2 voltage", - "datatype": "float", - "unit": "V" - }, - "breaker-rating": { - "name": "Main breaker rating", - "datatype": "integer", - "unit": "A" - }, - "ethernet": { - "name": "Is Ethernet network interface operational?", - "datatype": "boolean" - }, - "wifi": { - "name": "Is Wi-Fi network interface operational?", - "datatype": "boolean" - }, - "wifi-ssid": { - "name": "SSID to which Wi-Fi network interface is connected", - "datatype": "string" - }, - "vendor-cloud": { - "name": "Device connected to vendor cloud?", - "datatype": "enum", - "format": "UNKNOWN,UNCONNECTED,CONNECTED" - }, - "postal-code": { - "name": "Postal (Zip) code", - "datatype": "string" - }, - "time-zone": { - "name": "Time zone", - "datatype": "string" - } - }, - "energy.ebus.device.lugs": { - "direction": { - "name": "Lugs feed direction: upstream or downstream", - "datatype": "enum", - "format": "UPSTREAM,DOWNSTREAM" - }, - "feed": { - "name": "Device the lugs are connected to, if known", - "datatype": "string" - }, - "l1-current": { - "name": "L1 current", - "datatype": "float", - "unit": "A" - }, - "l2-current": { - "name": "L2 current", - "datatype": "float", - "unit": "A" - }, - "active-power": { - "name": "Active power", - "datatype": "float", - "unit": "W" - }, - "imported-energy": { - "name": "Imported energy", - "datatype": "float", - "unit": "Wh" - }, - "exported-energy": { - "name": "Exported energy", - "datatype": "float", - "unit": "Wh" - } - }, - "energy.ebus.device.circuit": { - "name": { - "name": "Circuit name", - "datatype": "string" - }, - "relay": { - "name": "Circuit relay state", - "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED", - "settable": true - }, - "relay-requester": { - "name": "Actor requesting the relay state", - "datatype": "enum", - "format": "UNKNOWN,NONE,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT" - }, - "breaker-rating": { - "name": "Circuit breaker rating", - "datatype": "integer", - "unit": "A" - }, - "current": { - "name": "Measured current", - "datatype": "float", - "unit": "A" - }, - "active-power": { - "name": "Measured active power", - "datatype": "float", - "unit": "kW" - }, - "imported-energy": { - "name": "Measured energy imported", - "datatype": "float", - "unit": "Wh" - }, - "exported-energy": { - "name": "Measured energy exported", - "datatype": "float", - "unit": "Wh" - }, - "space": { - "name": "Circuit breaker space number within load center", - "datatype": "integer", - "format": "1:32:1" - }, - "dipole": { - "name": "Does circuit land on a two-pole breaker?", - "datatype": "boolean" - }, - "shed-priority": { - "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", - "datatype": "enum", - "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", - "settable": true - }, - "pcs-managed": { - "name": "Is circuit managed by PCS?", - "datatype": "boolean" - }, - "pcs-priority": { - "name": "Circuit PCS priority ranking", - "datatype": "integer" - }, - "sheddable": { - "name": "Is circuit configured to be sheddable?", - "datatype": "boolean" - }, - "never-backup": { - "name": "Is circuit configured to be never-backup?", - "datatype": "boolean" - }, - "always-on": { - "name": "Is circuit configured to be always on?", - "datatype": "boolean" - } - }, - "energy.ebus.device.bess": { - "vendor-name": { - "name": "Vendor name", - "datatype": "string" - }, - "product-name": { - "name": "Product name", - "datatype": "string" - }, - "model": { - "name": "Model", - "datatype": "string" - }, - "serial-number": { - "name": "Serial number", - "datatype": "string" - }, - "software-version": { - "name": "Software version", - "datatype": "string" - }, - "nameplate-capacity": { - "name": "Nameplate capacity", - "datatype": "float", - "unit": "kWh" - }, - "relative-position": { - "name": "Relative position of the commissioned backup system WRT the distribution enclosure", - "datatype": "enum", - "format": "UPSTREAM,DOWNSTREAM,IN_PANEL" - }, - "feed": { - "name": "Circuit ID upon which the commissioned backup system is landed", - "datatype": "enum" - }, - "soc": { - "name": "State of charge", - "datatype": "float", - "unit": "%" - }, - "soe": { - "name": "State of energy", - "datatype": "float", - "unit": "kWh" - }, - "connected": { - "name": "Connected to backup system?", - "datatype": "boolean" - }, - "grid-state": { - "name": "Grid connection state", - "datatype": "enum", - "format": "UNKNOWN,ON_GRID,OFF_GRID" - } - }, - "energy.ebus.device.pv": { - "vendor-name": { - "name": "Vendor name", - "datatype": "string" - }, - "product-name": { - "name": "Product name", - "datatype": "string" - }, - "serial-number": { - "name": "Serial number", - "datatype": "string" - }, - "software-version": { - "name": "Software version", - "datatype": "string" - }, - "nameplate-capacity": { - "name": "Nameplate capacity", - "datatype": "float", - "unit": "W" - }, - "relative-position": { - "name": "Relative position of the commissioned PV system WRT the distribution enclosure", - "datatype": "enum", - "format": "UPSTREAM,DOWNSTREAM,IN_PANEL" - }, - "feed": { - "name": "Circuit ID upon which the commissioned PV system is landed", - "datatype": "enum" - } - }, - "energy.ebus.device.evse": { - "vendor-name": { - "name": "Vendor name", - "datatype": "string" - }, - "product-name": { - "name": "Product name", - "datatype": "string" - }, - "part-number": { - "name": "Part number", - "datatype": "string" - }, - "serial-number": { - "name": "Serial number", - "datatype": "string" - }, - "software-version": { - "name": "Software version", - "datatype": "string" - }, - "feed": { - "name": "Circuit ID upon which the commissioned EVSE is landed", - "datatype": "enum" - }, - "lock-state": { - "name": "Lock state", - "datatype": "enum", - "format": "UNKNOWN,LOCKED,UNLOCKED" - }, - "status": { - "name": "Status", - "datatype": "enum", - "format": "UNKNOWN,AVAILABLE,PREPARING,CHARGING,SUSPENDED_EV,SUSPENDED_EVSE,FINISHING,RESERVED,FAULTED,UNAVAILABLE" - }, - "advertised-current": { - "name": "Current EVSE is advertising to the EV", - "datatype": "float", - "unit": "A" - } - }, - "energy.ebus.device.pcs": { - "enabled": { - "name": "PCS system enabled", - "datatype": "boolean" - }, - "active": { - "name": "PCS system actively controlling one (or more) loads", - "datatype": "boolean" - }, - "import-limit": { - "name": "The power import limit currently being managed to", - "datatype": "float", - "unit": "A" - }, - "feed-import-limit": { - "name": "Limit of maximum power feeding the distribution enclosure", - "datatype": "float", - "unit": "A" - }, - "feed-import-limit-enablement": { - "name": "Enablement status of the feed-import-limit", - "datatype": "enum", - "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED" - }, - "feed-import-limit-active": { - "name": "Is feed-import-limit currently being enforced?", - "datatype": "boolean" - }, - "grid-import-limit": { - "name": "Grid limit maximum import power", - "datatype": "float", - "unit": "A" - }, - "grid-import-limit-enablement": { - "name": "Enablement status of the grid-import-limit", - "datatype": "enum", - "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED" - }, - "grid-import-limit-active": { - "name": "Is grid-import-limit currently being enforced?", - "datatype": "boolean" - }, - "off-grid-import-limit": { - "name": "Off-Grid limit maximum import power", - "datatype": "float", - "unit": "A" - }, - "off-grid-import-limit-enablement": { - "name": "Enablement status of the off-grid-import-limit", - "datatype": "enum", - "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED" - }, - "off-grid-import-limit-active": { - "name": "Is off-grid-import-limit currently being enforced?", - "datatype": "boolean" - }, - "requested-import-limit": { - "name": "Requested limit maximum import power", - "datatype": "float", - "unit": "A" - }, - "requested-import-limit-enablement": { - "name": "Enablement status of the requested-import-limit", - "datatype": "enum", - "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED" - }, - "requested-import-limit-active": { - "name": "Is requested-import-limit currently being enforced?", - "datatype": "boolean" - } - }, - "energy.ebus.device.power-flows": { - "pv": { - "name": "PV power flow", - "datatype": "float", - "unit": "W" - }, - "battery": { - "name": "Battery/BESS power flow", - "datatype": "float", - "unit": "W" - }, - "grid": { - "name": "Grid power flow", - "datatype": "float", - "unit": "W" - }, - "site": { - "name": "Site power flow", - "datatype": "float", - "unit": "W" - } - } - }, - "typesSchemaHash": "sha256:d347556a07d98f40" -} diff --git a/tests/test_bess_telemetry.py b/tests/test_bess_telemetry.py index 542dde12..7907ec4b 100644 --- a/tests/test_bess_telemetry.py +++ b/tests/test_bess_telemetry.py @@ -5,7 +5,7 @@ of its link, as opposed to the enclosure's view of both. Every assertion runs against a real snapshot built by the real schema_1 adapter -over the vendored capture, and every expected value is read out of that capture +over the reference capture, and every expected value is read out of that capture rather than written as a literal — a test that pins the same constant the code pins passes whether or not the wire is ever read. Each reading is proved by republishing it, deleting it, or dropping the node that carries it. @@ -29,7 +29,7 @@ `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. -Which way *is* charging is read off the vendored capture, and the capture now +Which way *is* charging is read off the reference capture, and the capture now carries the post-`distribution-enclosure-simulator#39` frame: `power-flows` 0.3 defines the node balance as every term positive when power flows into the thing it names, so `pv` is negative while producing and the four terms sum to zero. diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py index a894e076..a081f689 100644 --- a/tests/test_field_path_conformance.py +++ b/tests/test_field_path_conformance.py @@ -265,7 +265,7 @@ def test_gate_is_one_directional( def _adapter_paths() -> tuple[frozenset[str], frozenset[str]]: - """Return the paths each adapter actually produces, from the vendored fixtures.""" + """Return the paths each adapter actually produces, from the reference payloads.""" return frozenset(schema_zero_metadata()), frozenset(schema_one_metadata()) diff --git a/tests/test_fixture_provenance.py b/tests/test_fixture_provenance.py deleted file mode 100644 index 053209da..00000000 --- a/tests/test_fixture_provenance.py +++ /dev/null @@ -1,354 +0,0 @@ -"""The vendored capture must name the release it came from, and be right about it. - -`tests/fixtures/schema_one_tree.json` is a byte-identical copy of a payload that -lives in the `span-panel-api` repository. Committing it is what keeps this suite -free of a cross-repo dependency, and free of the expectation that a runtime wheel -keeps shipping test data. The price of a copy is that it can go stale, and a -stale capture is the worst kind of green: the conformance tests keep passing, -against a wire no panel sends. - -Detecting that does not need the file, only the version claim. `schema_one_tree.source` -records the release the copy was taken from, and `importlib.metadata` reports the -release actually installed -- with no checkout, no network call and no second copy -to compare against. The moment the pin moves and the capture is not refreshed, -this test fails and says what to do about it. - -That is half the problem. A version claim catches a copy that was never -refreshed; it cannot catch one that was refreshed *wrongly* -- reformatted, -hand-edited, or taken from a working tree ahead of the release it names -- because -those leave the claim intact and change only the bytes. So the second guard here -compares the bytes, against the capture in a `span-panel-api` checkout named by -`SPAN_PANEL_API_DIR`. It needs something the version guard deliberately does not, -and therefore skips when no checkout is configured -- but **fails** rather than -skips under `CI`, where the workflow clones one. The library states the reason -plainly, having paid for it: "A skip reads in a summary line exactly like a pass, -and that is how a stale vendored capture went unnoticed for nine days" -(`span-panel-api`, DEVELOPMENT.md, "A skip here is not a pass"). Anyone tempted to -make the skip unconditional should read that first. - -This is one test rather than a check inside `adapter_fixtures.schema_one_tree()` -deliberately. Stale provenance is a maintenance fact, not a broken payload: the -capture still parses and still drives every adapter. A loader-level assertion -would turn one actionable failure into several hundred identical ones, burying -whatever else the run had to say -- and it would make the refresh itself -undebuggable, since the bump and the copy cannot land in the same keystroke. -""" - -from __future__ import annotations - -from importlib.metadata import PackageNotFoundError, version -import json -import os -from pathlib import Path -import re -from typing import NoReturn - -import pytest - -from .adapter_fixtures import ( - CHECKOUT_VARIABLE, - SCHEMA_ONE_RELEASE_DECLARATION as _RELEASE_DECLARATION, - SCHEMA_ONE_SOURCE_PATHS as _SOURCE_PATHS, - SCHEMA_ONE_TREE, - SCHEMA_ONE_TREE_SOURCE, - schema_one_source, -) - -_REPO_ROOT = Path(__file__).resolve().parent.parent - -_VERSION = re.compile(r'^version = "([^"]+)"', re.MULTILINE) - - -def _refresh(distribution: str, installed: str) -> str: - """Say what to do about a stale copy, to someone who has never refreshed one.""" - return ( - f"Copy `parent_child_tree.json` from {distribution} at {installed} over " - f"{SCHEMA_ONE_TREE.name} -- byte for byte, no reformatting -- and set " - f"{SCHEMA_ONE_TREE_SOURCE.name} to '{distribution}=={installed}'. " - f"tests/fixtures/README.md has the source path." - ) - - -def test_the_vendored_capture_records_where_it_came_from() -> None: - """A copy with no provenance cannot be checked, which is how it goes stale.""" - source = schema_one_source() - - assert source.distribution == "span-panel-api-schema-1" - assert source.version, "the recorded release must not be empty" - - -def test_the_vendored_capture_matches_the_installed_adapter() -> None: - """The pin moved and the copy did not: the failure this whole arrangement exists for.""" - source = schema_one_source() - - try: - installed = version(source.distribution) - except PackageNotFoundError: - pytest.fail( - f"{SCHEMA_ONE_TREE_SOURCE.name} records {source.distribution!r}, which is not " - f"installed. The vendored capture cannot be checked against a distribution " - f"this environment does not have; correct the name or install it." - ) - - assert installed == source.version, ( - f"The vendored capture {SCHEMA_ONE_TREE.name} was copied from " - f"{source.distribution} {source.version}, but {installed} is installed. " - f"The adapter is not broken -- the copy is out of date. " - f"{_refresh(source.distribution, installed)}" - ) - - -def test_the_vendored_capture_is_the_shape_the_loader_expects() -> None: - """A dict keyed by device id, whose `$description` is a JSON string, not an object. - - Pinned here because a refresh is a manual copy, and the one thing a copy can - get wrong that the version claim cannot see is a change of shape. The loader - reads `$description` with `update_description`, which parses; a capture that - had been helpfully pre-parsed would fail far from here. - """ - capture: dict[str, dict[str, str]] = json.loads(SCHEMA_ONE_TREE.read_text()) - - assert capture, "the capture is empty" - for device_id, topics in capture.items(): - description = topics["$description"] - assert isinstance(description, str), f"{device_id}: $description must be a JSON string" - assert isinstance(json.loads(description), dict), f"{device_id}: $description must parse" - - -def _unconfigured(reason: str) -> NoReturn: - """Not configured: skip on a developer machine, fail in CI. - - Locally, skipping is right. Not every developer keeps a `span-panel-api` - checkout beside this one, and a provenance check is not what they are running - the suite for. Failing on them is what makes a developer delete the check - rather than configure it. - - In CI it is the opposite. The workflow clones the library at the release - `schema_one_tree.source` names and exports `SPAN_PANEL_API_DIR`, so an unset - or unusable path there does not mean "unavailable", it means the wiring that - makes this check run has come undone. Skipping on that reads in the summary - line exactly like passing -- which is how the equivalent check in - `span-panel-api` stayed silent for the nine days it took a vendored capture to - go stale (DEVELOPMENT.md, "A skip here is not a pass"). A check an environment - can switch off by omitting a variable is a check nobody can rely on. - - `CI` rather than a variable of our own, because it is what GitHub Actions and - every other runner already set: an environment that stops supplying a path has - to opt *out* of being an environment, which is not something a workflow edit - does by accident. - """ - if os.environ.get("CI"): - pytest.fail( - f"{reason}. CI clones span-panel-api and exports {CHECKOUT_VARIABLE}, so this " - f"is the provenance wiring being broken rather than a check that is " - f"unavailable -- and a skip here is indistinguishable from a pass." - ) - pytest.skip(reason) - - -def _checkout() -> Path: - """Return the `span-panel-api` checkout named by the environment, or take the unconfigured exit. - - A variable that is unset, one pointing at a directory that is gone, and one - pointing at a directory that is not a checkout of this library are all the - same situation -- the source is not available to compare against -- and all - take the same exit. Letting a bad path through instead produces a - `FileNotFoundError` from inside a comparison, which reads as a broken test - rather than an unconfigured one. - - The third state is not hypothetical: `SPAN_PANEL_API_DIR` predates this check - and exists to point `pip install -e` at a library checkout, so it is exactly - the kind of variable someone has already set to something almost right. - `packages/schema-1/pyproject.toml` is what distinguishes a real checkout, and - it is also where a failure reads the release the checkout is on. - - A relative value is resolved against the repository root rather than the - working directory, because that is what `.env.example`'s `../span-panel-api` - means -- and pytest can be run from anywhere. - """ - configured = os.environ.get(CHECKOUT_VARIABLE) - if not configured: - _unconfigured( - f"set {CHECKOUT_VARIABLE} to a span-panel-api checkout to compare the " - f"vendored capture against its source" - ) - path = Path(configured) - if not path.is_absolute(): - path = _REPO_ROOT / path - if not path.is_dir(): - _unconfigured(f"{CHECKOUT_VARIABLE}={configured} does not exist; point it at a checkout") - if not (path / _RELEASE_DECLARATION).is_file(): - _unconfigured( - f"{CHECKOUT_VARIABLE}={configured} has no {_RELEASE_DECLARATION}, so it is not a " - f"span-panel-api checkout (or not a complete one); re-clone it" - ) - return path - - -def _checkout_release(checkout: Path) -> str: - """Read the release a checkout declares, from the package it would build.""" - declared = _VERSION.search((checkout / _RELEASE_DECLARATION).read_text(encoding="utf-8")) - return declared.group(1) if declared else "an undeclared release" - - -def _source_capture(checkout: Path, recorded: str) -> Path: - """Return the capture inside a checkout on the release that `recorded` names. - - The position check is not decoration, and this is not a hypothetical: written - without it, this comparison passed against a checkout sitting on the previous - release, whose bytes happened to be the ones vendored here. A byte comparison - that will accept whichever revision it is handed proves only that the copy - matches *something* -- and "something" includes the working tree the copy was - mistakenly taken from, which is the exact defect it is here to find. - - Being on the wrong release takes the same exit as having no checkout at all, - rather than failing outright, because it is the same situation: the release - this capture claims to come from is not available to compare against. Locally - that is ordinary -- `SPAN_PANEL_API_DIR` predates this check and points at - whatever a developer is working on -- and failing them for it is what makes a - developer delete the check rather than move a checkout. In CI it cannot happen - innocently: the workflow clones at the tag `schema_one_tree.source` names, so a - checkout on any other release means the derivation has come undone, and - `_unconfigured` fails there for that reason. - """ - declared = _checkout_release(checkout) - if declared != recorded: - _unconfigured( - f"{CHECKOUT_VARIABLE} names a checkout on span-panel-api-schema-1 {declared}, and " - f"{SCHEMA_ONE_TREE_SOURCE.name} records the capture as a copy of {recorded}. Move it " - f"to the schema-1-v{recorded} tag; comparing against another release reports drift " - f"rather than corruption" - ) - - for relative in _SOURCE_PATHS: - source = checkout / relative - if source.is_file(): - return source - pytest.fail( - f"{checkout} is span-panel-api-schema-1 {declared} as recorded, but holds the capture at " - f"neither {_SOURCE_PATHS[0]} nor {_SOURCE_PATHS[1]}. Either the checkout is incomplete, " - f"or that release moved the file again and this list needs the new path." - ) - - -def test_the_vendored_capture_is_byte_identical_to_its_source() -> None: - """The bytes, against the release the copy says it came from. - - The check the version guard cannot make. `schema_one_tree.source` agreeing - with the installed distribution proves only that somebody bumped a string; it - says nothing about whether the accompanying copy was faithful, and a capture - that was reformatted on the way in, or taken from a working tree ahead of the - release it names, satisfies the version guard completely. - - Byte for byte, with no reformatting tolerance, because the two repositories - have to be able to compare these files trivially and because the library pins - what this capture leaves unvalued against its producer's own baseline. A copy - that has been re-serialised -- reindented, reordered, floats rounded -- is one - nothing upstream can hold to that baseline any more, and it puts these tests - on a wire no panel sends. - """ - recorded = schema_one_source() - source = _source_capture(_checkout(), recorded.version) - - assert source.read_bytes() == SCHEMA_ONE_TREE.read_bytes(), ( - f"{SCHEMA_ONE_TREE.name} is not a faithful copy of {source}, which is " - f"{recorded.distribution} {recorded.version} -- the release " - f"{SCHEMA_ONE_TREE_SOURCE.name} records it as a copy of. The version claim is right and " - f"the bytes are not, so the copy was edited, reformatted, or taken from a working tree " - f"rather than from that release. {_refresh(recorded.distribution, recorded.version)}" - ) - - -def _stub_checkout(root: Path, release: str) -> Path: - """Build the smallest thing `_checkout` accepts: a tree declaring a schema-1 release.""" - declaration = root / _RELEASE_DECLARATION - declaration.parent.mkdir(parents=True, exist_ok=True) - declaration.write_text( - f'[project]\nname = "span-panel-api-schema-1"\nversion = "{release}"\n', encoding="utf-8" - ) - return root - - -def test_a_checkout_on_another_release_fails_in_ci_and_skips_locally( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - """A present checkout on the wrong release is unavailable, not acceptable. - - Written as its own test because it is the state that actually occurred: a - real, complete, perfectly healthy checkout, sitting one release behind the - one `schema_one_tree.source` names, holding bytes that matched. Without the - position check the comparison passed and reported provenance it had not - verified -- a green that is worse than a skip, because a skip at least says - it did not look. - """ - recorded = schema_one_source() - elsewhere = "0.0.0-not-the-recorded-release" - assert elsewhere != recorded.version, "the stub must not accidentally be the recorded release" - checkout = _stub_checkout(tmp_path / "span-panel-api", elsewhere) - - monkeypatch.delenv("CI", raising=False) - with pytest.raises((pytest.fail.Exception, pytest.skip.Exception), match=elsewhere) as local: - _source_capture(checkout, recorded.version) - assert local.type is pytest.skip.Exception, ( - f"off CI a checkout on another release must skip, got {local.typename}. A developer's " - f"checkout is wherever their work is, and failing them for it is what gets the check " - f"deleted" - ) - - monkeypatch.setenv("CI", "true") - with pytest.raises((pytest.fail.Exception, pytest.skip.Exception)) as in_ci: - _source_capture(checkout, recorded.version) - assert in_ci.type is pytest.fail.Exception, ( - f"under CI a checkout on another release must fail rather than {in_ci.typename.lower()}: " - f"the workflow clones at the tag the recorded release names, so this can only mean the " - f"derivation has come undone" - ) - - -def test_an_unavailable_checkout_fails_in_ci_and_skips_locally( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """The guard on the guard. - - The byte comparison is worth exactly as much as the thing that decides whether - it runs, and that thing is one `if`. In `span-panel-api` it went wrong in the - quiet direction -- the variable named a directory that did not exist, every - provenance check skipped, and nine days of drift accumulated behind a summary - line that read like a pass. - - So both halves are asserted, in both environments, for all three of the states - `_checkout` distinguishes. Asserting only the CI half would leave the local - half free to become a failure, which is the change that makes a developer - without a sibling checkout delete the check instead of configuring it. - - `_checkout` is exercised through its public behaviour -- the exception it - raises -- rather than by inspecting `_unconfigured`, so this keeps holding if - the branch moves into the caller. - """ - outcomes = (pytest.fail.Exception, pytest.skip.Exception) - missing = "/nonexistent/span-panel-api" - - monkeypatch.delenv("CI", raising=False) - monkeypatch.setenv(CHECKOUT_VARIABLE, missing) - with pytest.raises(outcomes, match="does not exist") as local: - _checkout() - assert local.type is pytest.skip.Exception, ( - f"off CI an unavailable checkout must skip, got {local.typename}. Failing instead is " - f"what makes a developer without a span-panel-api checkout delete the check rather " - f"than configure it" - ) - - monkeypatch.setenv("CI", "true") - for value, why in ( - ("", "unset"), - (missing, "a path that is gone"), - (str(_REPO_ROOT), "a directory that is not a span-panel-api checkout"), - ): - monkeypatch.setenv(CHECKOUT_VARIABLE, value) - with pytest.raises(outcomes) as raised: - _checkout() - assert raised.type is pytest.fail.Exception, ( - f"under CI, {why} must fail rather than {raised.typename.lower()}: a provenance " - f"check that skips is one an environment can switch off, and the summary line " - f"cannot tell the difference between that and a pass" - ) diff --git a/tests/test_library_path_hook.py b/tests/test_library_path_hook.py index 399dafe0..4ead5f1b 100644 --- a/tests/test_library_path_hook.py +++ b/tests/test_library_path_hook.py @@ -265,16 +265,15 @@ def test_the_vendored_copies_are_the_commits_they_name(name: str) -> None: obvious way to make a failing case pass is to edit the fixture. **A skip here is genuinely not the pass that a skip usually hides**, and the - difference is worth stating because this repository's other provenance guard - refuses exactly this exit. That one tracks a moving target -- a library - release that can be re-cut, re-tagged, or drift -- so skipping means not - looking at something that changes. A commit is content-addressed: the bytes at - `3cbf02a:pyproject.toml` cannot become different bytes. The only way this - comparison can fail is a copy that was wrong when it was vendored, and that is - caught wherever the history is reachable, which is every developer machine and - any CI checkout with depth. A shallow clone does not have the object to - compare against, and asserting against an object that is not there would fail - on the clone rather than on the fixture. + difference is worth stating because a guard over anything that can move must + refuse exactly this exit: skipping there means not looking at something that + changes. A commit is content-addressed: the bytes at `3cbf02a:pyproject.toml` + cannot become different bytes. The only way this comparison can fail is a copy + that was wrong when it was taken, and that is caught wherever the history is + reachable, which is every developer machine and any CI checkout with depth. A + shallow clone does not have the object to compare against, and asserting + against an object that is not there would fail on the clone rather than on the + fixture. """ recorded = _recorded_commit(name) blob = subprocess.run( diff --git a/tests/test_library_resolution.py b/tests/test_library_resolution.py index 9532b9a3..5191a93e 100644 --- a/tests/test_library_resolution.py +++ b/tests/test_library_resolution.py @@ -1,19 +1,20 @@ """The `span_panel_api` under test must be the one the pins name. Every other guard in this suite reads a version. `manifest.json` pins one, -`scripts/sync-dependencies.py` holds the copies to it, `test_fixture_provenance.py` -compares the vendored capture against it. All of that assumes the version string -identifies the code, and it does not: a checkout sitting on unreleased work +`scripts/sync-dependencies.py` holds the copies to it, and the reference payloads +the conformance tests replay are whatever the pinned wheels ship. All of that +assumes the version string identifies the code, and it does not: a checkout +sitting on unreleased work declares the same version as the release it is ahead of, and a scratch worktree declares the same version as the checkout it was branched from. A version is a claim; a filesystem location is a fact. That gap is not hypothetical here. `3cbf02a` pointed `[tool.uv.sources]` at a scratch worktree and committed it, so every import in the suite resolved through -a checkout that had never received span-panel-api#161. A fixture was vendored -through it while it stood, capturing an emitter defect that had already been -fixed. Every version check in both repositories passed. Only a byte comparison -saw it, and only because that comparison had a real second copy to look at. +a checkout that had never received span-panel-api#161. The conformance tests ran +against an emitter defect that had already been fixed, for as long as it stood. +Every version check in both repositories passed, because the stale worktree +declared the same version number as the corrected code. So this file checks the one thing no version can: **where the module actually came from**. It reads `span_panel_api.__file__`, not metadata, because those two can @@ -33,10 +34,9 @@ protecting gets deleted. Under `CI` there is no such thing as a legitimate override: the workflow deletes `[tool.uv.sources]` and resolves from PyPI, so anything but the pinned distribution in the environment's own site-packages means -the wiring has come undone. The asymmetry, and the reason a skip is not a pass, -are `test_fixture_provenance.py`'s -- "A skip reads in a summary line exactly like -a pass, and that is how a stale vendored capture went unnoticed for nine days" -(`span-panel-api`, DEVELOPMENT.md). +the wiring has come undone. The reason a skip is not a pass is `span-panel-api`'s, +stated in its DEVELOPMENT.md -- "A skip reads in a summary line exactly like a +pass, and that is how a stale vendored capture went unnoticed for nine days". """ from __future__ import annotations diff --git a/tests/test_metadata_sweep.py b/tests/test_metadata_sweep.py index b77ba5a5..c349adbd 100644 --- a/tests/test_metadata_sweep.py +++ b/tests/test_metadata_sweep.py @@ -5,7 +5,7 @@ the panel has published all along that nothing rendered, so a test asserting a constant the code also holds would pass whether or not the wire is ever read. -Every expectation below is therefore read out of the vendored capture, and every +Every expectation below is therefore read out of the reference capture, and every reading is proved by republishing it, unpublishing it, or both. The device-card assertions go through the real device registry after a real registration rather than through the `DeviceInfo` dict, because the dict is what the code returns and diff --git a/tests/test_pv_device.py b/tests/test_pv_device.py index a909aaed..e1a23548 100644 --- a/tests/test_pv_device.py +++ b/tests/test_pv_device.py @@ -38,7 +38,7 @@ `test_a_fresh_install_and_a_migrated_one_differ_only_in_entity_id` is the record of the decision. -Every expectation about what the card shows is read out of the vendored capture +Every expectation about what the card shows is read out of the reference capture and proved by republishing or unpublishing the property, never by a literal. The registry-shape expectations *are* literals, on purpose: they record what a released installation carries, and deriving them from the code under test would diff --git a/tests/test_schema_discovery.py b/tests/test_schema_discovery.py index c8a6f0eb..0a3e5e29 100644 --- a/tests/test_schema_discovery.py +++ b/tests/test_schema_discovery.py @@ -1,6 +1,6 @@ """What the panel declares that nothing here reads, surfaced to a maintainer. -`test_declared_but_unread` asks this question of the vendored capture and +`test_declared_but_unread` asks this question of the reference capture and answers it by experiment. It is the right check and it is fixture-bound: a real panel that starts publishing a property fails nothing until somebody recaptures. The adapter answers the same question at runtime, for the panel in front of the @@ -80,7 +80,7 @@ def test_the_adapter_emits_discovered_rows_at_all() -> None: raw = schema_one_metadata_raw() namespaced = {path for path in raw if is_discovery_path(path)} assert namespaced, ( - "schema_1 emitted no discovered rows for the vendored tree, so nothing " + "schema_1 emitted no discovered rows for the reference tree, so nothing " "below is being tested. If that is real, the adapter stopped emitting them." ) assert all(isinstance(raw[path], DiscoveredMetadata) for path in namespaced) diff --git a/tests/test_sensor_entities.py b/tests/test_sensor_entities.py index fbddf6db..3c8b3d18 100644 --- a/tests/test_sensor_entities.py +++ b/tests/test_sensor_entities.py @@ -221,7 +221,7 @@ def test_flat_firmware_publishes_no_topology_it_was_never_told() -> None: def test_the_topology_attribute_reports_what_the_parent_child_panel_published() -> None: """schema_1 resolves the field, so the attribute is a reading and is published. - The vendored capture's upstream lugs carry `connection/fed-by-device-id = + The reference capture's upstream lugs carry `connection/fed-by-device-id = bess`: a real panel with a battery ahead of the main lugs, which is the case the flat adapter cannot see and this one can. """ diff --git a/tests/test_shed_forecast.py b/tests/test_shed_forecast.py index 6e8221e2..8597ff1b 100644 --- a/tests/test_shed_forecast.py +++ b/tests/test_shed_forecast.py @@ -1,7 +1,7 @@ """The backup-planning forecast, surfaced as two sensors and three attributes. Every assertion here runs against a real snapshot built by the real schema_1 -adapter over the vendored capture, and every expected value is read out of that +adapter over the reference capture, and every expected value is read out of that capture rather than written as a literal. That is deliberate and it is the whole design of this module: a test that pins the same constant the code pins passes whether or not the wire is ever read, so each reading is proved by republishing diff --git a/uv.lock b/uv.lock index 1871c0e9..eb46faa0 100644 --- a/uv.lock +++ b/uv.lock @@ -2574,6 +2574,7 @@ dev = [ { name = "black" }, { name = "coverage" }, { name = "cryptography", specifier = ">=50.0.0" }, + { name = "ebus-panel-sim", specifier = "==0.8.0" }, { name = "mypy" }, { name = "pre-commit" }, { name = "pylint" }, @@ -2591,7 +2592,7 @@ dev = [ [[package]] name = "span-panel-api-schema-0" -version = "1.1.1" +version = "1.1.2" source = { editable = "../../span/span-panel-api/packages/schema-0" } dependencies = [ { name = "span-panel-api" }, @@ -2602,7 +2603,7 @@ requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-ap [[package]] name = "span-panel-api-schema-1" -version = "1.1.2" +version = "1.1.3" source = { editable = "../../span/span-panel-api/packages/schema-1" } dependencies = [ { name = "ebus-sdk" }, From 47b34e0aede5b2365a072073b0890f7dfe178342 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:52:29 -0700 Subject: [PATCH 209/345] chore: 2.1.0b21 --- CHANGELOG.md | 30 +++++----------------- custom_components/span_panel/manifest.json | 2 +- prek.toml | 4 +-- pyproject.toml | 2 +- scripts/check-library-path.py | 9 +++---- tests/adapter_fixtures.py | 17 +++++++----- uv.lock | 2 +- 7 files changed, 27 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccbaa07a..e38cca1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,29 +63,13 @@ working entirely in 2027.8. now. Filed under Diagnostics with the panel's other electrical characteristics. Import Limit carries the whole arbitration as attributes. - **Every circuit's power sensor gains `pcs_managed` and `pcs_priority`** where the circuit reports them — the shed order when an import limit binds, which is not the backup tier the existing `shed_priority` names. -- **New kinds of device your panel gains no longer wait for a release.** SPAN's data model is vendor-extensible, so a device type nobody has modelled can turn - up at any time; one now gets a card of its own hanging off the panel, with whatever it publishes as entities beneath it, all disabled and diagnostic. -- **A property an adopted device accepts writes to becomes a control**: a boolean becomes a switch, an enum a select, a bounded number a number entity, - constrained to what the device declared and nothing invented. -- **A number box appears only where the declared range can actually be read** — a range this integration cannot parse, one whose bounds are the wrong way round, - or one with a zero step surfaces as a plain reading instead of a control admitting no value. -- **Two vendor readings that would land on the same entity id no longer silently cost you one of them**: the lexically first wire path keeps the id and the - other is named in a warning in the log, rather than being dropped by Home Assistant under an id you cannot map back to anything. -- **A vendor device or reading that turns up hours after setup reaches you on its own**, through the same reload a new battery or charger triggers, instead of - waiting for the next restart. -- **Nothing adopted enters long-term statistics** — `state_class` is not declared on the wire and a wrong guess writes corrupt statistics; wrap an adopted - reading in a template sensor or utility meter if you want them. -- **A new reading a vendor adds to a device you already have now appears too**, on the card that device already has — the battery, the Microgrid Interconnect - Device, a SPAN Drive or the solar inverter, with circuit and lugs readings landing on the panel's own card. Previously only whole new _devices_ were picked - up, so a battery vendor adding a field reached you nowhere. -- These new readings arrive switched off and filed as diagnostics, like everything else adopted, and they are readings only — never switches or number boxes, - because a control here would sit beside the curated ones without their limits and translations. -- **They keep the panel's own wording** (`Battery 2 Cell Temperature`), which is deliberately plainer than a curated entity's name so you can tell at a glance - which is which. -- **Deleting one hides it until the next reload while your panel is still publishing it, and removes it for good once your panel stops.** There is no setting to - suppress one, because the delete button already does both jobs, decided by what your panel is actually sending. -- **At most 60 vendor readings are adopted per device**, a backstop against a misbehaving publisher rather than a policy on a real one, and the notification - naming what was declined is now raised once and stays dismissed until that set changes. + +- **Devices and readings your panel gains no longer wait for a release.** SPAN's data model is vendor-extensible, so a device type nobody has modelled gets a + card of its own hanging off the panel, and a new reading on a device you already have — the battery, the Microgrid Interconnect Device, a SPAN Drive, the + solar inverter, a circuit or the lugs — lands on the card that device already has, both picked up on a reload rather than at the next restart. They arrive + switched off, filed as diagnostics and under the panel's own plainer wording, and a property the device accepts writes to becomes a switch, a select or a + number box constrained to what the device declared and nothing invented. Nothing adopted enters long-term statistics because `state_class` is not published on + the wire, and at most 60 readings are adopted per device as a backstop against a misbehaving publisher. - **Your panel's own card shows what the panel says it is** — manufacturer, model and hardware revision read from the enclosure rather than assumed, once your panel publishes them. diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 3557ca6c..4deef033 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -27,7 +27,7 @@ "span-panel-api-schema-0==1.1.2", "span-panel-api-schema-1==1.1.3" ], - "version": "2.1.0b20", + "version": "2.1.0b21", "zeroconf": [ { "type": "_span._tcp.local." diff --git a/prek.toml b/prek.toml index 71f3719d..5586aed6 100644 --- a/prek.toml +++ b/prek.toml @@ -36,8 +36,8 @@ hooks = [ # The library path override in pyproject.toml must name the checkout, not a # worktree. `3cbf02a` committed a scratch worktree into `[tool.uv.sources]` and # `[tool.pyright].extraPaths`; it redirected every import in the suite for -# several commits, and a fixture vendored through it captured a producer defect -# the library had already fixed. Runs early -- it is a read of one small file, and +# several commits, and the conformance tests ran against a producer defect the +# library had already fixed. Runs early -- it is a read of one small file, and # a run that resolves the wrong library has nothing useful to say afterwards. [[repos]] repo = "local" diff --git a/pyproject.toml b/pyproject.toml index 4757befd..26a2aa0c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span" -version = "2.1.0b20" +version = "2.1.0b21" description = "Span Panel Custom Integration for Home Assistant" authors = [{name = "SpanPanel"}] license = {text = "MIT"} diff --git a/scripts/check-library-path.py b/scripts/check-library-path.py index 41c116c2..923eee13 100644 --- a/scripts/check-library-path.py +++ b/scripts/check-library-path.py @@ -11,11 +11,10 @@ for a while, and the obvious way to do that is to edit them. That is what happened in `3cbf02a`: both blocks were pointed at a scratch worktree and committed. It stood for several commits and a few hours of other people's work, -and while it stood a test fixture was vendored through it from a checkout that -still recorded a producer defect the library had already fixed. Every -version-based check passed -- the stale worktree declared the same version number -as the corrected code -- and only a byte comparison caught it. A version string -does not identify content; only a filesystem location does. +and while it stood the conformance tests ran against a producer defect the +library had already fixed. Every version-based check passed -- the stale worktree +declared the same version number as the corrected code. A version string does +not identify content; only a filesystem location does. So the redirection does not belong in a committed file at all. It belongs in the virtual environment, where it affects one developer's next command and nothing diff --git a/tests/adapter_fixtures.py b/tests/adapter_fixtures.py index 665735fc..b70997ca 100644 --- a/tests/adapter_fixtures.py +++ b/tests/adapter_fixtures.py @@ -35,16 +35,21 @@ def _schema_zero_types() -> HomieSchemaTypes: - """The flat capture's `types` map, which is the shape a schema_0 build takes. + """Unwrap the flat capture's `types` map, the shape a schema_0 build takes. The whole `GET /api/v2/homie/schema` response is what the adapter ships; `types` is the half both flat fixtures need, so it is unwrapped once here - rather than at each of them. + rather than at each of them. Narrowed rather than annotated: the document's + other top-level values are strings, and `json.loads` would let a wrong + annotation stand. """ - document: dict[str, HomieSchemaTypes] = json.loads( - SCHEMA_ZERO_SCHEMA.read_text(encoding="utf-8") - ) - return document["types"] + document: object = json.loads(SCHEMA_ZERO_SCHEMA.read_text(encoding="utf-8")) + if not isinstance(document, dict): + raise TypeError(f"{SCHEMA_ZERO_SCHEMA.name} is not a JSON object") + types = document["types"] + if not isinstance(types, dict): + raise TypeError(f"{SCHEMA_ZERO_SCHEMA.name} has no `types` object") + return types SCHEMA_ONE_PANEL = "example-40t-001" diff --git a/uv.lock b/uv.lock index eb46faa0..5ce9a6a1 100644 --- a/uv.lock +++ b/uv.lock @@ -2491,7 +2491,7 @@ wheels = [ [[package]] name = "span" -version = "2.1.0b20" +version = "2.1.0b21" source = { virtual = "." } dependencies = [ { name = "homeassistant" }, From fa2800ef62de27f80a40e3482105c666760c5857 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:18:33 -0700 Subject: [PATCH 210/345] docs: lead 2.1.0 with a summary, collapse the adopted-entity entries, and correct the release notes --- CHANGELOG.md | 86 +++++++++++++++++++++++----------------------------- 1 file changed, 38 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e38cca1c..d19eac28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,45 +4,46 @@ All notable changes to this project will be documented in this file. ## [2.1.0] - 8/2026 -### You will need this release before SPAN updates your panel - -SPAN firmware `r202633` replaces the way the panel publishes its data — the wire model every release up to 2.0.8 is retired in the same update that introduces -the new one. There is no wire overlap and no setting to keep the old behaviour. **2.0.8 cannot read a panel on `r202633`**: it stays connected, reports every -circuit as missing, and shows nothing useful. +### In short + +- **Install this before SPAN pushes firmware `r202633`** — it replaces the panel's wire model, and 2.0.8 cannot read a panel that has taken it. Requires Home + Assistant 2026.8. +- **The Microgrid Interconnect Device and the solar inverter become devices of their own**, carrying grid health and whether Grid Power is really measuring the + grid. +- **EVSE Charge Current Limit** is the first control here that changes something on a SPAN Drive rather than on the panel. +- **The battery says when things run out** — Time to Priority Shed and Backup Time Remaining, beside its own power and link state. +- **Vendor extensions are adopted on a reload**, so a device type or reading nobody has modelled no longer waits for a release. +- **Your passphrase is no longer stored and the panel's certificate is pinned before credentials cross the network**, with control lockable or admin-only. +- **Nothing you have moves** — entity ids, unique ids and history survive the upgrade. -**The transition itself is seamless.** Install 2.1.0 early and your only outage is the firmware update itself. A log line and a one-time notification tell you -exactly what happened, so take a screenshot. +### You will need this release before SPAN updates your panel -**A panel that is still rebooting is waited out, for as long as your panel takes.** Taking the firmware upgrade drops the panel's connection for several minutes -— four or more is not uncommon once the panel receives its upgrade file. If you experience an extended delay (over 5 minutes), reload the integration (reload is -a menu item, not a reinstallation). +SPAN firmware `r202633` replaces the wire model and introduces the new one on reboot. **2.0.8 cannot read a panel on `r202633`.** ### Requires Home Assistant 2026.8.0 or newer -This release raises the minimum from 2026.5.4. Home Assistant 2026.8 replaced the two device-registry calls this integration relies on — the old forms stop -working entirely in 2027.8. +This release requires Home Assistant 2026.8 to avoid a deprecated API — the old API stops working entirely in 2027.8. -### Added +**The transition is uneventful** — the integration boots with the right wire library based on which firmware it sees. Your devices and entities are intact. + +### Added (Subject to Firmware Support) -- **Your panel's Microgrid Interconnect Device appears as its own device**, carrying **Grid State** — the health of the utility supply itself, which your panel +- **The panel's Microgrid Interconnect Device appears as its own device**, carrying **Grid State** — the health of the utility supply itself, which your panel never reported before. -- **Grid Power now says whether it is really measuring the grid**, through a new `at_service_entrance` attribute: false where a battery or another panel sits +- **Grid Power now reports whether it sits at the service entrance**, through an `at_service_entrance` attribute: false where a battery or another panel sits between the utility and your main lugs, which is when it and **Grid Power Flow** legitimately disagree. -- **The attribute is present only where your panel publishes the topology it is read from** — the parent/child data model — so a panel on the flat one has no - `at_service_entrance` row at all rather than a `true` that nothing measured. +- **The attribute is present only where your panel publishes the topology it is read from.** -- **Your solar inverter gets a device of its own** rather than rendering as diagnostic sensors on the panel's card; the five PV entities keep the entity ids, +- **The solar inverter gets a device of its own** rather than rendering as diagnostic sensors on the panel's card; the five PV entities keep the entity ids, unique ids and history they have today. - **Assign the new Solar device to an area** — those five no longer inherit the panel's area, which quietly drops them from area-scoped dashboards, automations and voice commands until you do. - New installations get `sensor.span_panel_solar_pv_vendor` and friends where existing ones keep `sensor.span_panel_pv_vendor`; both are correct and neither will change again. -- **EVSE Charge Current Limit** — a settable ceiling on each commissioned SPAN Drive, the first control this integration has that changes something on a charger - rather than on the panel. -- The maximum is the one your installer commissioned, read from the panel: a higher value is refused rather than quietly rounded down, and the control is - unavailable rather than invented where the rating is not published. -- The control appears only where the panel says the limit can be changed, and reports a change still in flight as a `charge_current_limit_target` attribute. +- **EVSE Charge Current Limit** — a settable ceiling on each commissioned SPAN Drive. +- The maximum is the one your installer commissioned; the control is unavailable where the rating is not published. +- The control appears only where the panel says the limit can be changed and reports a change still in flight as a `charge_current_limit_target` attribute. - **PV Panel Link and EVSE Panel Link** — the panel's own report of the link to your inverter and to each charger, which is the fact **BESS Connected** has always shown for the battery. @@ -60,19 +61,14 @@ working entirely in 2027.8. - Each forecast carries its full-charge equivalent and the panel's own `forecast_confidence` as attributes, rather than as two more near-constant entities. - **Import Limit, Binding Constraint and PCS Active** — the current limit your panel enforces, which rule set it, and whether anything is being throttled right - now. Filed under Diagnostics with the panel's other electrical characteristics. Import Limit carries the whole arbitration as attributes. + now. Filed under Diagnostics. Import Limit carries the arbitration as attributes. - **Every circuit's power sensor gains `pcs_managed` and `pcs_priority`** where the circuit reports them — the shed order when an import limit binds, which is not the backup tier the existing `shed_priority` names. -- **Devices and readings your panel gains no longer wait for a release.** SPAN's data model is vendor-extensible, so a device type nobody has modelled gets a - card of its own hanging off the panel, and a new reading on a device you already have — the battery, the Microgrid Interconnect Device, a SPAN Drive, the - solar inverter, a circuit or the lugs — lands on the card that device already has, both picked up on a reload rather than at the next restart. They arrive - switched off, filed as diagnostics and under the panel's own plainer wording, and a property the device accepts writes to becomes a switch, a select or a - number box constrained to what the device declared and nothing invented. Nothing adopted enters long-term statistics because `state_class` is not published on - the wire, and at most 60 readings are adopted per device as a backstop against a misbehaving publisher. +- **Vendor extensions** — new device types and readings are picked up on a reload. They arrive disabled and filed as diagnostics. Nothing adopted enters + long-term statistics, because `state_class` is not published on the wire. -- **Your panel's own card shows what the panel says it is** — manufacturer, model and hardware revision read from the enclosure rather than assumed, once your - panel publishes them. +- **Your panel's own card shows metadata** — manufacturer, model and hardware revision read from the enclosure once your panel publishes them. - **Part Number** diagnostic sensor on every SPAN Drive, matching the one the battery already has. Off by default. - **Circuit Priority's shed policy is readable**: `dsm_state` gains `shed_algorithm` and the two state-of-charge thresholds that decide when circuits shed and when they come back. @@ -80,7 +76,7 @@ working entirely in 2027.8. - **Diagnostics include your entity registry** — every entity this integration owns, its unique id, and what disabled it, which Home Assistant will not otherwise tell you. -### Changed +### Changed (Subject to Firmware Update) - **`DSM Grid State` is now more trustworthy**, reading the islanding state the Microgrid Interconnect Device actually senses rather than inferring it from the battery and the dominant power source, and keeping its entity id and its history. @@ -93,7 +89,7 @@ working entirely in 2027.8. - **Existing installations keep those five exactly where they are**, with their history and entity ids, because Home Assistant consults the enabled-by-default setting only when an entity is first created. - **Your other panel readings are unaffected**, checked against a capture from a live upgraded panel rather than assumed. -- **New entities are now announced in a notification** that splits what arrived into ready to use and switched off and names each one. +- **New entities are now announced in a notification** that splits what arrived into ready to use and switched off, and names each one. - **A damaged record of what has already been announced is re-seeded rather than failing setup**, so a truncated or hand-edited store costs you one repeated notification instead of an integration that will not start. - **The Wi-Fi network name moved to the Wi-Fi Link sensor** as a `wifi_ssid` attribute, absent rather than blank on a panel that publishes no SSID. @@ -106,26 +102,20 @@ working entirely in 2027.8. nothing changed, and an id reflecting your own configuration where something did — a circuit you renamed, an area you assigned, or the name you gave the panel device. - **Three kinds of installation may be offered a different id when you press Recreate entity IDs**: a sensor on a SPAN Drive's feed circuit (offered the - charger's name, the device it is actually on), a second panel whose device this integration called "Span Panel 2" (offered `span_panel_2_`), and an - installation predating the `use_device_prefix` option whose circuit ids carry no device at all (offered one). Each is your own entity-id settings composing - the id, and no id moves until you accept the offer. + charger's name, the device it is actually on), a second panel whose device this integration called "Span Panel 2" (offered `span_panel_2_`), and an older + installation whose circuit ids carry no device name at all (offered one). Each is your own entity-id settings composing the id, and no id moves until you + accept the offer. - **A panel device you renamed yourself is offered ids carrying that name**, since Home Assistant composes from the name you gave the device ahead of the one this integration generated. -- **New circuit energy sensors get `consumed_energy`, `produced_energy` and `net_energy`**, the same word order as the panel-level energy sensors; existing - sensors keep the spelling they have, whichever of the two it is, and are never offered the other one. -- **Circuit sensors on a SPAN Drive's feed circuit are named for the charger alone** — `sensor._power`, matching the charger's other sensors. -- **The legacy `use_device_prefix` option can no longer keep the device out of a composed entity id**, since Home Assistant includes the device unless your own - entity-id settings exclude it. +- **New installations provide uniform circuit energy sensor ids — `consumed_energy`, `produced_energy` and `net_energy`**, in line with the panel-level energy + sensors. +- **A newly commissioned SPAN Drive's feed-circuit sensors are named for the charger** — `sensor._power`, matching the charger's other sensors; the + ones you already have keep their ids. - **An unnamed circuit in friendly-names mode is now named for the tab it occupies** (`Circuit 7 Power`), so two unnamed circuits no longer collide — the old scheme called every one of them `single_circuit` and let the registry tell them apart with `_2`, `_3`. -- **An unnamed circuit you already have may be offered a `circuit_7` id** if you press **Recreate entity IDs**, which is the one place this release proposes a - new id for a circuit you did not rename. Its current id keeps working until you accept. +- **If you recreate entity IDs, an unnamed circuit you already have may be offered a `circuit_7` id.** Its current id keeps working if you decline. - **Circuit-numbers installations now show the device in the displayed name** — `Span Panel Kitchen Outlets Power`, as friendly-names installations always have — so a template or a voice alias matching the old exact string needs updating. -- **A name you set in an entity's own settings still outranks the panel's**, and this integration no longer writes that field, clearing only the value an older - release of its own put there. -- **Renaming a circuit in the SPAN app now reaches the displayed name through a reload**, which the integration requests for itself when it sees the new name — - the name travels as the entity's original name, which is written when the entity is created, so a rebuild is what refreshes it. ### Fixed From b9a8a3f2ec04bebb23fddba290a6a944c2796214 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 28 Aug 2026 00:28:53 -0700 Subject: [PATCH 211/345] docs: trim the security notes in the changelog and README --- CHANGELOG.md | 4 ---- README.md | 43 ++++++------------------------------------- 2 files changed, 6 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d19eac28..35084bfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -144,16 +144,12 @@ This release requires Home Assistant 2026.8 to avoid a deprecated API — the ol ### Security -- **The panel passphrase is no longer stored** — upgrading migrates the config entry to version 7 and removes it, after which an older build of the integration - will refuse to load that entry, so roll back from a backup rather than by reinstalling. - **New admin-only action `span_panel.rotate_credentials`** replaces the eBus MQTT broker password on demand, which immediately breaks any other local client using the old one. - **The panel's certificate authority is pinned before your passphrase is ever sent**, so the registration exchange that carries it — and everything after — no longer crosses your network in the clear. - **Re-authenticating an entry that was never pinned pins it first**, so an entry that arrived from an older release sends its new credentials over a verified connection and keeps the authority afterwards. -- **Setting up or moving a panel to a hostname works with pinning on**, because the panel is set up over the address its certificate already names and the - hostname is verified after the panel has added it. - **A pinned entry is never left holding an address its own certificate does not name**, which would have produced an entry unable to reach the panel it was just set up against, with no flow left to correct it. - **Continuing past a failed domain registration now keeps the panel rather than the name**, recording the address that got that far and claiming no registered diff --git a/README.md b/README.md index e831ef06..7132fbfe 100644 --- a/README.md +++ b/README.md @@ -663,51 +663,22 @@ and select your preferred precision from the "Display Precision" menu. ## Security -### What the integration stores - -Setting up a v2 panel exchanges your panel passphrase for two long-lived secrets, which are kept in the config entry (`.storage/core.config_entries`): - -- the **eBus MQTT broker password**, which grants control of every relay, circuit priority, islanding state and EVSE limit on the panel and its sub-devices, and -- the **REST access token**, which grants panel management — client registration, FQDN registration and credential rotation. - -The **panel passphrase itself is no longer stored.** It is an input to registration and nothing afterwards reads it, but a stored copy could mint fresh -credentials at any time. Upgrading migrates the config entry to version 7 and removes it; nothing else in the entry changes and no entity is affected. If you -have not yet upgraded, reauthenticating also removes it. - -> **Downgrade note.** Once an entry has reached version 7, installing an older build of the integration will fail to set it up — Home Assistant refuses to load -> a config entry whose version is newer than the installed integration supports. Restore a backup taken before the upgrade if you need to roll back. - These are the limits of what the integration can enforce. Anything that already holds the broker password — including another integration running in the same Home Assistant process — talks to the panel directly and is not subject to Home Assistant's permission model at all. -### The panel's certificate authority - -The panel issues its own certificate authority and signs its TLS certificate with it. **Before you are asked for your passphrase**, setup fetches that -authority, checks that the certificate the panel actually serves is signed by it, and pins it. Everything after that — registration itself, and every later -connection — runs over that authority, and the integration stops rather than accepting a different one. - -The ordering is the point. Registration is the exchange that sends your passphrase and returns both the access token and the broker password, so it is the -single most valuable message on your network. It now travels over a verified connection instead of in the clear. - **What that does and does not buy you.** The authority is fetched over your local network on a connection that has nothing to verify itself against — it is the anchor everything else is checked against. So: -- Anyone merely **listening** on your network can no longer read your passphrase or the credentials the panel returns for it. That is the common case, and it is - closed. +- Anyone merely **listening** on your network cannot read your passphrase or the credentials the panel returns for it. - A device **actively standing between** Home Assistant and your panel at that first fetch could answer with an authority of its own, sign a certificate with it, and still see them. Pinning cannot detect that on its own. -Comparing the fingerprint against another source is what closes the second case. Setup does not stop to ask you to do that, because at first contact there is -nothing to compare against: SPAN does not publish the value, so the question could only be answered by pressing Submit. The fingerprint is put where it can -actually be used instead — diagnostics report it under `panel_ca`, it is logged at setup, and another install of this integration on the same panel reports the -same value. - -After the first pin, nothing can change the authority without stopping and asking you — and _that_ is a question you can answer, because there is a prior value -to compare against. See the certificate-authority-changed entry in Troubleshooting. +Comparing the fingerprint against another source is what closes the second case. At first contact there is nothing to compare against: SPAN does not publish the +value, so the question could only be answered by pressing Submit. The fingerprint is put where it can actually be used instead — diagnostics report it under +`panel_ca`, it is logged at setup, and another install of this integration on the same panel reports the same value. -**There is no "continue without pinning" for a new panel.** If the authority cannot be read, or the certificate the panel serves is not signed by it, setup -shows an error and stops. Submitting the form again retries the fetch, which is what a panel that was briefly unreachable needs. An opt-out would quietly -restore the plaintext credential exchange this ordering exists to remove, at the moment you are least likely to weigh it. +After the first pin, nothing can change the authority without stopping and asking you — there is a prior value to compare against. See the +certificate-authority-changed entry in Troubleshooting. **Re-authenticating an entry that has no anchor acquires one first.** Reauth is a registration, so it carries the passphrase out and the broker password back — the same exchange setup performs. An entry that arrived from before pinning, or one whose stored authority will no longer load, goes through the @@ -717,8 +688,6 @@ connection. **Setting a panel up by hostname works, and the hostname is verified.** A panel's certificate names the addresses it already knows itself by; a domain joins that list only when the integration asks the panel to regenerate its certificate, which happens after you have authenticated. Everything before that runs against the address the certificate already names, hostname verification never relaxed, and the domain is checked once the panel reports the new certificate. -Only a fully qualified domain is registered, so a single-label name — the hostname an add-on or the simulator is reached by — never joins the certificate, and a -name the certificate does not already carry cannot be stored on a pinned entry; set that panel up by its address instead. **A pinned entry follows its panel to a new address only when the new address proves itself.** An entry moves when your panel announces itself over mDNS at a new address, and when you add the same panel again by hand. Both used to be decided by a serial read from an unauthenticated endpoint, which anything on your From 04549a566e5ebdee78b36aa62172924dc39937d0 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 28 Aug 2026 00:44:32 -0700 Subject: [PATCH 212/345] docs: let the security section's two lead-ins stand on their own --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7132fbfe..09ff8333 100644 --- a/README.md +++ b/README.md @@ -663,11 +663,11 @@ and select your preferred precision from the "Display Precision" menu. ## Security -These are the limits of what the integration can enforce. Anything that already holds the broker password — including another integration running in the same -Home Assistant process — talks to the panel directly and is not subject to Home Assistant's permission model at all. +There are limits to what the integration can enforce. Anything that already holds the eBus MQTT broker password — including another integration running in the +same Home Assistant process — talks to the panel directly and is not subject to Home Assistant's permission model at all. -**What that does and does not buy you.** The authority is fetched over your local network on a connection that has nothing to verify itself against — it is the -anchor everything else is checked against. So: +**What pinning the panel's certificate authority does and does not buy you.** The authority is fetched over your local network on a connection that has nothing +to verify itself against — it is the anchor everything else is checked against. So: - Anyone merely **listening** on your network cannot read your passphrase or the credentials the panel returns for it. - A device **actively standing between** Home Assistant and your panel at that first fetch could answer with an authority of its own, sign a certificate with From 65a17f0dadf632daa275afd886c16198251ce43e Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:31:21 -0700 Subject: [PATCH 213/345] Clean up change log --- CHANGELOG.md | 107 ++++++++------------------------------------------- 1 file changed, 16 insertions(+), 91 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35084bfb..4bccfe8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,8 @@ All notable changes to this project will be documented in this file. ### You will need this release before SPAN updates your panel -SPAN firmware `r202633` replaces the wire model and introduces the new one on reboot. **2.0.8 cannot read a panel on `r202633`.** +SPAN firmware `r202633` retires the wire model every release up to 2.0.8 reads, and serves its replacement from the reboot onwards. **2.0.8 cannot read a panel +on `r202633`.** ### Requires Home Assistant 2026.8.0 or newer @@ -26,7 +27,7 @@ This release requires Home Assistant 2026.8 to avoid a deprecated API — the ol **The transition is uneventful** — the integration boots with the right wire library based on which firmware it sees. Your devices and entities are intact. -### Added (Subject to Firmware Support) +### Added (subject to firmware support) - **The panel's Microgrid Interconnect Device appears as its own device**, carrying **Grid State** — the health of the utility supply itself, which your panel never reported before. @@ -38,8 +39,7 @@ This release requires Home Assistant 2026.8 to avoid a deprecated API — the ol unique ids and history they have today. - **Assign the new Solar device to an area** — those five no longer inherit the panel's area, which quietly drops them from area-scoped dashboards, automations and voice commands until you do. -- New installations get `sensor.span_panel_solar_pv_vendor` and friends where existing ones keep `sensor.span_panel_pv_vendor`; both are correct and neither - will change again. +- New installations get `sensor.span_panel_solar_pv_vendor` and friends where existing ones keep `sensor.span_panel_pv_vendor`; both are correct. - **EVSE Charge Current Limit** — a settable ceiling on each commissioned SPAN Drive. - The maximum is the one your installer commissioned; the control is unavailable where the rating is not published. @@ -57,8 +57,8 @@ This release requires Home Assistant 2026.8 to avoid a deprecated API — the ol Power Flow importing. - **Time to Priority Shed and Backup Time Remaining** — how long before the panel starts shedding circuits and how long before the battery is spent. Enabled by - default. -- Each forecast carries its full-charge equivalent and the panel's own `forecast_confidence` as attributes, rather than as two more near-constant entities. + default. Each forecast carries its full-charge equivalent and the panel's own `forecast_confidence` as attributes, rather than as two more near-constant + entities. - **Import Limit, Binding Constraint and PCS Active** — the current limit your panel enforces, which rule set it, and whether anything is being throttled right now. Filed under Diagnostics. Import Limit carries the arbitration as attributes. @@ -73,10 +73,10 @@ This release requires Home Assistant 2026.8 to avoid a deprecated API — the ol - **Circuit Priority's shed policy is readable**: `dsm_state` gains `shed_algorithm` and the two state-of-charge thresholds that decide when circuits shed and when they come back. - **Grid-forming device name** as an attribute on the Grid Forming Entity sensor. -- **Diagnostics include your entity registry** — every entity this integration owns, its unique id, and what disabled it, which Home Assistant will not - otherwise tell you. +- **The diagnostics download includes your entity registry** — every entity this integration owns, its unique id, and what disabled it, which Home Assistant + will not otherwise tell you. -### Changed (Subject to Firmware Update) +### Changed (subject to firmware support) - **`DSM Grid State` is now more trustworthy**, reading the islanding state the Microgrid Interconnect Device actually senses rather than inferring it from the battery and the dominant power source, and keeping its entity id and its history. @@ -86,128 +86,53 @@ This release requires Home Assistant 2026.8 to avoid a deprecated API — the ol sides of the upgrade so it lands once at this release. - **Five panel sensors are switched off for new installations** (#234) because of three SPAN-documented defects predating firmware r202633: **Feedthrough Produced Energy**, **Feedthrough Consumed Energy**, **Feedthrough Power**, and the two **Downstream** current sensors. -- **Existing installations keep those five exactly where they are**, with their history and entity ids, because Home Assistant consults the enabled-by-default - setting only when an entity is first created. -- **Your other panel readings are unaffected**, checked against a capture from a live upgraded panel rather than assumed. - **New entities are now announced in a notification** that splits what arrived into ready to use and switched off, and names each one. -- **A damaged record of what has already been announced is re-seeded rather than failing setup**, so a truncated or hand-edited store costs you one repeated - notification instead of an integration that will not start. -- **The Wi-Fi network name moved to the Wi-Fi Link sensor** as a `wifi_ssid` attribute, absent rather than blank on a panel that publishes no SSID. -- **It is no longer an attribute of the Software Version sensor**, so a template reading `state_attr('sensor.span_panel_software_version', 'wifi_ssid')` should - point at the Wi-Fi Link binary sensor instead. -- **Circuit entity ids are now composed by Home Assistant from your own entity-id settings**, the way every other entity this integration creates already was: - the integration supplies the circuit half — `Circuit 15 Power` or `Kitchen Outlets Power`, as your naming option says — and Home Assistant adds the device - name, and the area as well if your entity-id settings (`entity_id_parts`) include it. -- **Nothing you already have moves**: an entity keeps the id it has until you press **Recreate entity IDs**, which offers an entity its own id back where - nothing changed, and an id reflecting your own configuration where something did — a circuit you renamed, an area you assigned, or the name you gave the panel - device. -- **Three kinds of installation may be offered a different id when you press Recreate entity IDs**: a sensor on a SPAN Drive's feed circuit (offered the - charger's name, the device it is actually on), a second panel whose device this integration called "Span Panel 2" (offered `span_panel_2_`), and an older - installation whose circuit ids carry no device name at all (offered one). Each is your own entity-id settings composing the id, and no id moves until you - accept the offer. +- **The Wi-Fi network name moved to the Wi-Fi Link sensor** as a `wifi_ssid` attribute, absent rather than blank on a panel that publishes no SSID. It is no + longer an attribute of the Software Version sensor, so a template reading `state_attr('sensor.span_panel_software_version', 'wifi_ssid')` should point at the + Wi-Fi Link binary sensor instead. - **A panel device you renamed yourself is offered ids carrying that name**, since Home Assistant composes from the name you gave the device ahead of the one this integration generated. - **New installations provide uniform circuit energy sensor ids — `consumed_energy`, `produced_energy` and `net_energy`**, in line with the panel-level energy sensors. - **A newly commissioned SPAN Drive's feed-circuit sensors are named for the charger** — `sensor._power`, matching the charger's other sensors; the ones you already have keep their ids. -- **An unnamed circuit in friendly-names mode is now named for the tab it occupies** (`Circuit 7 Power`), so two unnamed circuits no longer collide — the old - scheme called every one of them `single_circuit` and let the registry tell them apart with `_2`, `_3`. -- **If you recreate entity IDs, an unnamed circuit you already have may be offered a `circuit_7` id.** Its current id keeps working if you decline. -- **Circuit-numbers installations now show the device in the displayed name** — `Span Panel Kitchen Outlets Power`, as friendly-names installations always have - — so a template or a voice alias matching the old exact string needs updating. ### Fixed - **Energy dip compensation no longer books a dip that never happened** (#259), where a partial reading at startup looked like a counter reset and every restart added each circuit's whole lifetime counter to its offset. -- **A compensated dip stays provisional until it settles, and is only reported then**: it is compensated the moment it is seen, corroborated by a reading that - climbs from the new low base, held for three further readings during which the counter returning to where it started takes that dip's whole compensation back, - and named in a notification once that window closes — so a burst of stale samples cannot leave a permanent step in your energy statistics or a notice for an - event that did not happen. - **A panel this integration can no longer reach makes its entities unavailable instead of reporting 0 W** — a certificate authority that changes mid-session used to leave every power sensor reading zero and every energy sensor frozen, indefinitely and indistinguishably from a panel drawing no power. - **Recreate entity IDs proposes the ids your panel would produce now** (#252), in your installation's naming style, leaving unique ids and statistics untouched. -- **The registry `Name` an older release wrote on circuit-numbers installations is handed back to you** at the first start after upgrading, for every label - those sensors have ever carried, so that field is yours again and no longer decides what Recreate proposes. -- **Enum sensors advertise the states they can actually report**, where nine sensors declared only `unknown` and `DSM Grid State` sitting at `On Grid` showed - "Possible states: Unknown". - **The README described Battery Power's sign backwards**, since the sensor has always reported discharging as positive (#184) and only the documentation was ever wrong. - **A breaker your panel refuses to operate now says so**, reporting the panel's own reason where you pressed it instead of a traceback in the log and a toggle that quietly springs back. - **A control command that never reached your panel now says so**, reported where you issued it rather than only in the log and stating plainly that nothing was queued. -- **A refused circuit priority no longer blames your firmware**, carrying the panel's own reason on the control you used rather than as a notification you - dismiss by hand. ### Security -- **New admin-only action `span_panel.rotate_credentials`** replaces the eBus MQTT broker password on demand, which immediately breaks any other local client - using the old one. - **The panel's certificate authority is pinned before your passphrase is ever sent**, so the registration exchange that carries it — and everything after — no longer crosses your network in the clear. -- **Re-authenticating an entry that was never pinned pins it first**, so an entry that arrived from an older release sends its new credentials over a verified - connection and keeps the authority afterwards. -- **A pinned entry is never left holding an address its own certificate does not name**, which would have produced an entry unable to reach the panel it was - just set up against, with no flow left to correct it. -- **Continuing past a failed domain registration now keeps the panel rather than the name**, recording the address that got that far and claiming no registered - domain, so the entry works and the domain can be tried again from **Reconfigure** once whatever blocked it is fixed. -- **An entry upgraded from before pinning checks the authority it fetched against the certificate the panel serves on the broker port before storing it**, so a - reverse proxy or anything else answering that fetch cannot become the authority the integration then trusts forever. -- **The same check now guards the repair that asks you to accept a changed authority**, and a value that fails it is refused rather than offered to you as a - fingerprint to approve. -- **An mDNS announcement or a re-added panel only moves a pinned entry's address when the new address serves a certificate that entry's authority validates** — - previously any device on your network could claim your panel's serial and have the entry pointed at itself, after which the changed-authority repair invited - you to accept the impostor's fingerprint. - **A changed authority stops the integration and raises a repair** carrying both fingerprints, because a legitimate rotation and an impersonated panel look identical from here. -- **`span_panel.rotate_credentials` asks which panel when more than one is loaded** instead of rotating the first it finds, since the previous broker password - stops working for every other local client of whichever panel it picked. -- **It reports a reload that did not come back** rather than logging success, and says the new password was already stored so nobody rotates a second time and - invalidates the one the panel has accepted. -- **It refuses to run at all when the stored authority cannot be read**, rather than carrying the access token out and the new broker password back over an - unverified connection; repair the authority first. - **Four new options decide who may operate the panel** — administrators only, no commands without a logged-in user, an admin-only control lock, and a relay debounce — every one defaulting to what your panel already does. - **`Administrators only` does not cover an automation a non-admin triggered**, because Home Assistant gives an automation a fresh context with no user on it and there is then nothing to hold to the admin test; switch off **Allow control without a logged-in user** if you want those refused too. -- **Choosing `Nobody` now refuses control commands as well as creating no controls**, so the option is enforced where commands are published rather than only by - the absence of entities to press. -- **The control lock arrives armed and stays where you left it**, so turning the option on locks control straight away and a restart or a settings save no - longer quietly unlocks the panel; a pending auto-relock comes back with the time it had left rather than starting its countdown over. -- **A control lock whose pending countdown cannot be read comes back armed** rather than opening a fresh window nobody asked for — which is what a record - written by an older release looks like on the first restart after upgrading. -- **The relay debounce counts only commands the panel was actually sent**, so a breaker command the broker never took no longer refuses your immediate retry - with "operated less than 2 seconds ago". -- **A circuit the panel starts or stops letting you operate gains or loses its breaker switch and its priority control on the next reload**, which the - integration asks for as soon as it notices — both directions, rather than leaving you a control the panel refuses every press of or no control where the panel - now allows one. - **Every control command is now recorded** in the logbook, on the event bus as `span_panel_control_command`, and at `INFO`, attributed to the automation that issued it rather than to nobody. -- **Every control now reports what happened to it**, adopted devices' controls included, so a command that was refused or never handed to the broker no longer - looks like one the panel confirmed. -- **A new [Security](README.md#security) section in the README** covers what the integration stores, what rotation costs, and the deployment choices that - actually bound the panel's exposure. +- **A new [Security](README.md#security) section in the README** covers what rotation costs and the deployment choices that actually bound the panel's exposure. ### Known limitations -- **A panel reachable only by a single-label name cannot complete a pinned setup by that name** — the SPAN simulator add-on and anything else whose certificate - names only its IP address — because nothing registers a name that is not a fully qualified domain, so the certificate never comes to name it; set the panel up - by its IP address instead. -- **An upgraded entry whose broker port does not serve a certificate the panel's own authority signs stays unpinned** — a proxy terminating TLS with a - certificate of its own, say — logging a warning at every start and raising no repair. **Reauthenticate** is the route that does the check on screen: it asks - for the TLS port and refuses with an error if the authority does not sign what the panel serves. Reconfiguring the entry to the panel's own address instead - leaves the pin to the reload that follows, which happens silently — watch the log for "Pinned the CA advertised by…". The reverse arrangement — a proxy - terminating only port 443, so that the panel's own authority signs the broker's certificate but not the one served on the HTTPS port — does pin at that - reload, and every path that then talks to the panel over HTTPS (**Reauthenticate**, **Reconfigure**, **Rotate credentials**) refuses rather than sending your - token unencrypted, until port 443 also serves the panel's own certificate. +- **A panel reachable only by a single-label name — one with no dot and no domain — cannot complete a pinned setup by that name**, because nothing registers a + name that is not a fully qualified domain, so the certificate never comes to name it. Set that panel up by its IP address, or by the `.local` name shown in + the mobile app. - **A discovered address this entry's authority rejects is refused with a log warning and nothing else** — if the panel really has moved, use **Reconfigure** on the entry, which checks the new address against the standing pin before storing it. -- **A panel discovered through the Supervisor is deliberately not held to the address check** the other discovery routes are, because a Home Assistant add-on - arrives over the authenticated Supervisor API and legitimately reallocates its own ports; an add-on with Supervisor privileges can therefore move a pinned - entry. ## [2.0.8] - 5/2026 From c7071461231cbccdb8b08e6d4614560ed60fc67a Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:53:40 -0700 Subject: [PATCH 214/345] =?UTF-8?q?ci(deps):=20take=20the=20pending=20depe?= =?UTF-8?q?ndabot=20bumps=20=E2=80=94=20setup-python=207,=20mypy=202.1.0,?= =?UTF-8?q?=20ruff=200.15.18,=20pyright,=20pylint,=20prek,=20pytest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 2 +- pyproject.toml | 10 +- requirements_test.txt | 2 +- uv.lock | 263 ++++++++++++++++++++++++--------------- 4 files changed, 173 insertions(+), 104 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebecee83..04b16469 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} allow-prereleases: true diff --git a/pyproject.toml b/pyproject.toml index 26a2aa0c..08666a4f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,16 +22,16 @@ dev = [ "homeassistant-stubs==2026.8.0", "types-requests", "types-PyYAML", - "mypy==1.20.2", - "pyright==1.1.409", - "ruff==0.15.12", + "mypy==2.1.0", + "pyright==1.1.410", + "ruff==0.15.18", "bandit[toml]==1.9.4", - "prek>=0.3.11", + "prek>=0.4.5", "voluptuous-stubs", "python-direnv", "prettier", "radon==6.0.1", - "pylint==4.0.5", + "pylint==4.0.6", # Pinned, not floored: each release pins one exact Home Assistant, so this # and the `homeassistant` pin above are a single choice rather than two. # 0.13.354 is the release that pins 2026.8.0, the version this integration diff --git a/requirements_test.txt b/requirements_test.txt index 3cfc4e1f..a87d993e 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,5 +1,5 @@ pytest>=9.0.3 -pytest-asyncio>=1.3.0 +pytest-asyncio>=1.4.0 setuptools>=65.7.0 pytest-homeassistant-custom-component==0.13.354 homeassistant==2026.8.0 diff --git a/uv.lock b/uv.lock index 5ce9a6a1..c8514504 100644 --- a/uv.lock +++ b/uv.lock @@ -242,6 +242,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, ] +[[package]] +name = "ast-serialize" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/a9/11851c3e02a3fea2ddc9932d1fdc7d2edaeecc0d2e11bc5f2a7fde2b0934/ast_serialize-0.8.0.tar.gz", hash = "sha256:6c37c43e4004dfb42d321ddedc569dc17ff4259296f3af577c9ea46a809bc010", size = 845638, upload-time = "2026-08-07T11:29:02.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/16/6e520b57cd8c75914b38c670ad4593d13c22911e4306cc7165dab8b0789b/ast_serialize-0.8.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3d822605fa7bb326ef868d25fafced7fc660fa46d9b90c02ea86d5e2f5d325f7", size = 863924, upload-time = "2026-08-07T11:27:34.579Z" }, + { url = "https://files.pythonhosted.org/packages/03/e1/48802de9b22a2bcad42ec80601a17e3f69172fe4f590e6311bcc2b323aeb/ast_serialize-0.8.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:2efa40b068197d5efb62655b43baadb842ed71c4958cccd3e8b86a35726f0119", size = 1177662, upload-time = "2026-08-07T11:27:36.196Z" }, + { url = "https://files.pythonhosted.org/packages/38/d4/323438db76bded3a1f3523a3167b8325916b2ddceb2107a330c6ec9fcf4d/ast_serialize-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:db1b957291bca08c7e72f43a12357b2948e20775d970e3fc3dac0aa3160ab725", size = 1167072, upload-time = "2026-08-07T11:27:37.646Z" }, + { url = "https://files.pythonhosted.org/packages/77/82/53c5400b54144b56de8ed7f957fd1ccd97e42482009292ab46121d15f8dd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdc0d5b18ff8fb364e87923e47c0a91d0d69dbcaeaa274591f7fd26892cc3a3a", size = 1225497, upload-time = "2026-08-07T11:27:39.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/5f/36c07327a8b91303fbf1382c7c3e8a2902072dbe1b9546138a5288e75ff0/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9da7330f3e235bf7da89b8d39205c6350fc0c08a85379743f2df9fff87d6d980", size = 1227101, upload-time = "2026-08-07T11:27:40.799Z" }, + { url = "https://files.pythonhosted.org/packages/9d/48/5adf5c67addc7ddb328122208c6d375a84cf154984f412b4087330a157bd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3186969ee66a9863b00acc6523ace44c56974eecb348a7ea4b228d9f0b80e19", size = 1424001, upload-time = "2026-08-07T11:27:42.708Z" }, + { url = "https://files.pythonhosted.org/packages/38/a1/70074dd3869d2b0e934f91891d8d6b734361cd3b80f85ca7ece2e668ecdd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40a57b73731be45da4fa41430c4d5dc94a24b3a4faba7b9e069978c0402064ea", size = 1245545, upload-time = "2026-08-07T11:27:44.4Z" }, + { url = "https://files.pythonhosted.org/packages/e3/be/53b9c0a8a6399950c2e3546bdfab96d2b299d5b114b47eb94fd3c49c4054/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5075b9da3ef807eda752502446dfecea3b381c4900b7e27a5d5f4f899eb39951", size = 1248961, upload-time = "2026-08-07T11:27:45.781Z" }, + { url = "https://files.pythonhosted.org/packages/eb/13/3651d3812548a2bda15e26e5dd51aadb48cf682d0865370255fcf0e367dd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:293cc1c5bfa741f8e3fbe8175b9c07beee487c9a6fdbb25a5acad9f1df2d30a9", size = 1243877, upload-time = "2026-08-07T11:27:47.325Z" }, + { url = "https://files.pythonhosted.org/packages/21/a0/521f0bf000f675e9312a4aae2c8ba7a992405d072a85c485e08fd59433b9/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0910c3442a75216dde0f102d854ba2aaa71d2482e0ee213630b9bf29584fba3", size = 1293903, upload-time = "2026-08-07T11:27:49.264Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7e/402fc902568aa2ee65865a3e151f000db0153da8ce6b1be4c9c349025f8d/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43dd6d596879bb1cb8a12cc9dae7bb10090a39a35883026c24f82488a195619a", size = 1401070, upload-time = "2026-08-07T11:27:50.947Z" }, + { url = "https://files.pythonhosted.org/packages/ff/7c/97d4b66c057f1706fc8be6dd532cc77c988794357c8f4ffdb6adabb39562/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8c9d537f59e936392cfd3597789d1390304dd659efc3c486ce7f40fb6b8a9f53", size = 1502602, upload-time = "2026-08-07T11:27:52.364Z" }, + { url = "https://files.pythonhosted.org/packages/89/6f/72cc3b71562001bba46e898ccfbf1844f7939b3e28912736206102f2e5a8/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f0190a33d7f97c65e9069f7a7f40499eea6b5cbe260c558378109caf20ce934b", size = 1495848, upload-time = "2026-08-07T11:27:53.803Z" }, + { url = "https://files.pythonhosted.org/packages/a0/53/d6f629d1e49308b2f363dae028baa213ec222c9106fa1f7f0d1f7b41499a/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:77308ae6c5cf5264cc0f01a7c556ec77a9e68eb1f61b093534d698139fdc3b14", size = 1556556, upload-time = "2026-08-07T11:27:55.342Z" }, + { url = "https://files.pythonhosted.org/packages/ee/22/340f35dd8dfc6d412d53dc20699ca014b8d228db923e8ed4759c512b162c/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8d53a23f27e1ed3a36b2d26fd2a1a6228c8e85a1ed62ff7cdb44bd610769f20a", size = 1417822, upload-time = "2026-08-07T11:27:56.712Z" }, + { url = "https://files.pythonhosted.org/packages/11/29/6dde5c13fbebc051d3a6df4ec0a6fd1d5359333cc1193f7f609f3410b4d8/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ffa5e7cb08f96fed9121f77b224151e41caf88feab9d652bb46c78202b6fbeda", size = 1445153, upload-time = "2026-08-07T11:27:58.275Z" }, + { url = "https://files.pythonhosted.org/packages/62/c5/f473a8ed030f7a0ca24b9849cca184677a50c053867a7b808c2e1289bbd3/ast_serialize-0.8.0-cp314-cp314t-win32.whl", hash = "sha256:fa70ed4dea0bb18b30a1789c77baa701d0ef30c474f2ccabdea61e25623a8827", size = 1063711, upload-time = "2026-08-07T11:27:59.793Z" }, + { url = "https://files.pythonhosted.org/packages/23/63/39e171fcd38ca057c2e1979d5ee81ac7a3502784abe3d83df7454f7a0978/ast_serialize-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d8b3c8eee4c1baef9d4e84d2a59a805501617127be42615cb48970b15b0892b6", size = 1103740, upload-time = "2026-08-07T11:28:01.405Z" }, + { url = "https://files.pythonhosted.org/packages/21/1c/d00762b399e7726d68d0a088cc946e3a4c60f1c6176f557608f672f627f3/ast_serialize-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:ac4f0a83c55a9b782f79ad55a5247b7db123c1db405959791c2ef886e9710c9f", size = 1076021, upload-time = "2026-08-07T11:28:02.947Z" }, + { url = "https://files.pythonhosted.org/packages/4c/11/911210c3c78923273a9211a2b6cfc4c8aa723b30dab3e1c8d19afb983b40/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:86b8a1e6d90467345356098b040150e82fbc26d24a7a202224b13dc1f6264ca0", size = 1177715, upload-time = "2026-08-07T11:28:04.654Z" }, + { url = "https://files.pythonhosted.org/packages/77/89/6282881c8587606638db153cbe21e1e0c4d1f3970dee1aa0610a1c62a026/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:39e92ff8e8cb45947fe9007174b2950e1fb098e6abd00266a13cd3bcf6675068", size = 1169347, upload-time = "2026-08-07T11:28:06.1Z" }, + { url = "https://files.pythonhosted.org/packages/97/78/a9f846a03a340ff3728c915f23338ca742742f3292700559cdb3ad999b1e/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c85d8d18db5b2dfcb3b7e38a4d600ca35504c0ed8a6f75cd1c811e4ffe248a15", size = 1225916, upload-time = "2026-08-07T11:28:07.654Z" }, + { url = "https://files.pythonhosted.org/packages/c0/15/aba6ef8a988a6eceb6f0359589aac509e29ae2dba67fd9bfd5af0c3f13e7/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9830ff7e764f74d9eefb01170c61a9f0fd2c027dac5fcb72e064decd57d56371", size = 1227135, upload-time = "2026-08-07T11:28:09.504Z" }, + { url = "https://files.pythonhosted.org/packages/94/29/3f63d696ea7c5b8abadcecc3505be51bd900daaccc522ed8322fa5b05a93/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6479d9722a4cd21b578f5478074c41e6169f04811996ec881655560f703a5bba", size = 1425040, upload-time = "2026-08-07T11:28:11.044Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5d/0aac338604ff59df5774d4304307898982252f325ff7cafe31d52fedcb65/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a63bed264e818cd83eec11feed0f50aa162542b91132ef58afebc857182763a5", size = 1246278, upload-time = "2026-08-07T11:28:12.519Z" }, + { url = "https://files.pythonhosted.org/packages/23/ca/9f1ef795bb724719532bd86dbec11e5b66857d3fbe9b6772baec0191a6ed/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d187197d234aa45d6cfa2b096be5f666e8cc2e7eb3722d0ab8926293cf5720c", size = 1250029, upload-time = "2026-08-07T11:28:13.896Z" }, + { url = "https://files.pythonhosted.org/packages/dc/25/5e061372d2ed953b9ba3b9c4f73de3b8e9234cda3f6c088db4686801d0e1/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:2d39a56282cfcc0d8eeea37267c754be59c98d48505c23b1dae5c6011f3813dd", size = 1243575, upload-time = "2026-08-07T11:28:15.37Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c1/ae7da218053120635a4ca802366c69f707203641af95372eeb83f70dfd52/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f7cc5f10386994c0f4844f1e6d6a97127e9b478660eb6dec2b257644f0acab64", size = 1294396, upload-time = "2026-08-07T11:28:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/2e/89/271d1f49c5269fcddcc789ea3f25be401f6723fc1138aeda539f4d05516d/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6102f2f985c2e542be85cd857678ec9356fefa792b93cadfadd31139f5696f27", size = 1401987, upload-time = "2026-08-07T11:28:18.333Z" }, + { url = "https://files.pythonhosted.org/packages/55/be/4e7d77fcf571ac7cb5cf7115a20c36642bd7d29473b45dfaaefeb9618f90/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:3a8660fe66667b76a6e9dccd1d33e66b229fde3b308db991c041609226c005b6", size = 1502904, upload-time = "2026-08-07T11:28:20.039Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ae/ed1de2db7e019d4236fbc164ffa5ef9a6022a300a342bbf142d21b7c141e/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:e7266307e5fba39836edb79def8608887af48820508bff3c5f2941e1e04d1534", size = 1496967, upload-time = "2026-08-07T11:28:21.734Z" }, + { url = "https://files.pythonhosted.org/packages/92/89/5fea507fae5c5f18b7dc7f95e5c00956574b8c717b8fd2049c504fab0b18/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca7e6fd1ad845d1cc649dc2ecd499db2f8f46af5bf8da7b70dd858774cc038b", size = 1559041, upload-time = "2026-08-07T11:28:23.194Z" }, + { url = "https://files.pythonhosted.org/packages/42/71/478d69df21b64e064554a68134c94be304270316ca676a94e63c389a636a/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:2880350b13d3eae69a0d70bc1fb6c9bfaca4dbd0e20ba8cd1aa483080b56ff06", size = 1417367, upload-time = "2026-08-07T11:28:24.601Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2d/8962dc8d5b3a9dc27b36f9db199afa25264c741505469d9ec10ffbfd2ba7/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:ab0f9a59f7d63d0d441b56b9a818b273705264352d5115cfee12e940e816d958", size = 1446178, upload-time = "2026-08-07T11:28:26.152Z" }, + { url = "https://files.pythonhosted.org/packages/4f/22/14d2ad4fd1d1bcd0dc687ca268e0630069f45162496260c0efb70ee0ea72/ast_serialize-0.8.0-cp315-abi3.abi3t-win32.whl", hash = "sha256:0485a25ef519c62e749ee3c1ad8070e591b380d67226349eb5a70b228dc1ac4a", size = 1063811, upload-time = "2026-08-07T11:28:27.864Z" }, + { url = "https://files.pythonhosted.org/packages/18/1d/84a327c0202a41aa5fdba3ade33904d6d8f3b9e6806fa83568d835395850/ast_serialize-0.8.0-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:bd84d60bca7079e741be4ac5dbe237751a59d7f6f9f0126b11880d63822cbe16", size = 1105518, upload-time = "2026-08-07T11:28:29.691Z" }, + { url = "https://files.pythonhosted.org/packages/8c/92/74556dec52fde85a2ad84ed159991b916241043788609c15d8b77e14570b/ast_serialize-0.8.0-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:057769b5921336eb2d9124f2a731b42ed05ffdac559b840dbdf6f3937cf153dc", size = 1076319, upload-time = "2026-08-07T11:28:31.282Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e3/6142e920fec6ef7bccabd8c24ed8ed99f8bdc6cb8b065e1df7c6a3b2d667/ast_serialize-0.8.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e1bd223df0f6c96b396975fa604cb33bce53d9b4a0185490be4c4a289f7c9c87", size = 1184007, upload-time = "2026-08-07T11:28:34.654Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e9/6e8be8df02b35d85e2b8809f7f1cfa290bdf5882b55127a539d049482db0/ast_serialize-0.8.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ddd3b61f45c132da66c5476b281891e08c1fd87fbdabe8a6973e1622efc85f06", size = 1177588, upload-time = "2026-08-07T11:28:36.318Z" }, + { url = "https://files.pythonhosted.org/packages/8c/80/7e0fd2e2e2aba257820db4a8657c4c356844d36b914b20a4af294bcfb902/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f9caa63fad8241257ae401b5ff0a64026c6adb36b8e86cbe8782d9ea505daf6", size = 1234575, upload-time = "2026-08-07T11:28:37.772Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/3bae0af06f9b1bae3001c44d64215f5b567877e7aae9ffd45db11c3a7647/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3926fa117b5e65019853a2969966d11c7175af377a3425991f3fe73784412405", size = 1236015, upload-time = "2026-08-07T11:28:39.14Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c4/ce2d41a1bc22508e82618901f7e10f2a5e2f9556553fea90624daf9875e2/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:485f1113af805e9e170b95ef993ca3fbd4f89c04bab25c58b4fc632d854801ab", size = 1432808, upload-time = "2026-08-07T11:28:40.664Z" }, + { url = "https://files.pythonhosted.org/packages/1a/90/f5058f209756dd70e958b7538aaa82d25d24944baf9ec8ae6f27b06fcacc/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccebbed24f1281062d5852353c72c47502955926cfcb8345ffb3a44d87ff3d3", size = 1256251, upload-time = "2026-08-07T11:28:42.223Z" }, + { url = "https://files.pythonhosted.org/packages/bf/32/7f77ea87fa0836daab706ed5cb7f903bb25fa26a77439011aee626af11d8/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:252f883290d1cdb728eb7fe1d9a7221b88af5a329aae0bc91ddee4dafb820331", size = 1258574, upload-time = "2026-08-07T11:28:43.751Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5a/75b82ad2725b5e8e8c742732f9e76c6738a292d0709e1f60d10a973730b4/ast_serialize-0.8.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:96abc072ad29db8d02194afd47d68987322622787daceae82398d7b69f3ba2e6", size = 1254075, upload-time = "2026-08-07T11:28:45.28Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/8c20ed4eea805516a3fd23dd4a721ce28c64f50f0e4b359969f60a8c97a6/ast_serialize-0.8.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9118ad3e369727060b2696fc4078f250ecffca4248ba87f537f55cea9f9dce06", size = 1301018, upload-time = "2026-08-07T11:28:46.851Z" }, + { url = "https://files.pythonhosted.org/packages/cb/5b/9f14430f12fe830b656fb38f8e2e05ee13b02a88967660bef46af0ab22a8/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f359df4bd921918af8bebd142a376c77511d7151cc8ba852760b587b5a4a54f3", size = 1409951, upload-time = "2026-08-07T11:28:48.312Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3d/084882eca93c842bd4262591a071ec7f825340644035e51501208cc5a8d4/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e94f9121d13fa36cbf21314783c77d05ae3a0868decd18cf5233fdcc6de49ac8", size = 1509544, upload-time = "2026-08-07T11:28:49.847Z" }, + { url = "https://files.pythonhosted.org/packages/ce/73/ea84852096c2036c61cc0b2f97b90242207419f534dc671060ee1c8e05cb/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:54f95b486018d262bcb387a9afd96f0da74508b442762b80c769454a6fbb3ee3", size = 1505671, upload-time = "2026-08-07T11:28:51.239Z" }, + { url = "https://files.pythonhosted.org/packages/cb/88/287b9a5300c1f2f651d259f670931b63110adc265b7613c885b44c5bc53d/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c38b915511e32bc718c49dbce98ff9af36bac0ad6a604f58000cd5e3aecdba7", size = 1563685, upload-time = "2026-08-07T11:28:53.112Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/1bc3a79afcf0c2a8d2c37182d0d659d1545a9d7f7f6dc9cf3e63d6c17135/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9a2ef9cf12f2de4f1028c42c1dd7d775255e0fb3e5bb48896c97e35ef52366fe", size = 1427977, upload-time = "2026-08-07T11:28:54.418Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cd/440c798957e14e31776bfeb024d8fafe0bb1d5b89c51c2f067e69938f7b0/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f18048fe9f6dd266bd577cdec48bdcecb74faaa01fe941324435483b013ed2a", size = 1454335, upload-time = "2026-08-07T11:28:55.968Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4a/587eb36dcc240a54c8660f599464516b469ecad96f0dbdb6bccbedb50745/ast_serialize-0.8.0-cp39-abi3-win32.whl", hash = "sha256:31883542dd6c94d178f5db3d32fbd69c5eb88b3a7c018e7ac8cc0c45195ddbed", size = 1068858, upload-time = "2026-08-07T11:28:57.541Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a4/3e887bbd92164e183cb6e412c6a3e9198ddd446d7fe405958293ef5ef49c/ast_serialize-0.8.0-cp39-abi3-win_amd64.whl", hash = "sha256:861794565b06337005c1447ef23103a3d5a627d08bdc827870d00d0b28ef5f51", size = 1111839, upload-time = "2026-08-07T11:28:59Z" }, + { url = "https://files.pythonhosted.org/packages/25/6c/b400476d3ceba681ab929787edc9554f6d88fcc69435eb681b00fc0457a5/ast_serialize-0.8.0-cp39-abi3-win_arm64.whl", hash = "sha256:b2a5978662fd4db463dfb4b974d2b10ac6430b98f5333aabc7051909df3561d0", size = 1083655, upload-time = "2026-08-07T11:29:00.349Z" }, +] + [[package]] name = "astral" version = "2.2" @@ -1234,36 +1297,41 @@ wheels = [ [[package]] name = "librt" -version = "0.8.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, - { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, - { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" }, - { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" }, - { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" }, - { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" }, - { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" }, - { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" }, - { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" }, - { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" }, - { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" }, - { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" }, - { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" }, - { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" }, - { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" }, - { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" }, - { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" }, - { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" }, - { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" }, - { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" }, - { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" }, - { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" }, - { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" }, - { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" }, + { url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" }, + { url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" }, + { url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" }, + { url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" }, + { url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" }, + { url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" }, + { url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" }, + { url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" }, + { url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" }, + { url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" }, + { url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" }, + { url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" }, ] [[package]] @@ -1448,31 +1516,32 @@ wheels = [ [[package]] name = "mypy" -version = "1.20.2" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "ast-serialize" }, { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/d1/b4ec96b0ecc620a4443570c6e95c867903428cfcde4206518eafdd5880c3/mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30", size = 14524561, upload-time = "2026-04-21T17:06:27.325Z" }, - { url = "https://files.pythonhosted.org/packages/3a/63/d2c2ff4fa66bc49477d32dfa26e8a167ba803ea6a69c5efb416036909d30/mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924", size = 13363883, upload-time = "2026-04-21T17:11:11.239Z" }, - { url = "https://files.pythonhosted.org/packages/2a/56/983916806bf4eddeaaa2c9230903c3669c6718552a921154e1c5182c701f/mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb", size = 13742945, upload-time = "2026-04-21T17:08:34.181Z" }, - { url = "https://files.pythonhosted.org/packages/19/65/0cd9285ab010ee8214c83d67c6b49417c40d86ce46f1aa109457b5a9b8d7/mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc", size = 14706163, upload-time = "2026-04-21T17:05:15.51Z" }, - { url = "https://files.pythonhosted.org/packages/94/97/48ff3b297cafcc94d185243a9190836fb1b01c1b0918fff64e941e973cc9/mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558", size = 14938677, upload-time = "2026-04-21T17:05:39.562Z" }, - { url = "https://files.pythonhosted.org/packages/fd/a1/1b4233d255bdd0b38a1f284feeb1c143ca508c19184964e22f8d837ec851/mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8", size = 11089322, upload-time = "2026-04-21T17:06:44.29Z" }, - { url = "https://files.pythonhosted.org/packages/78/c2/ce7ee2ba36aeb954ba50f18fa25d9c1188578654b97d02a66a15b6f09531/mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3", size = 10017775, upload-time = "2026-04-21T17:07:20.732Z" }, - { url = "https://files.pythonhosted.org/packages/4e/a1/9d93a7d0b5859af0ead82b4888b46df6c8797e1bc5e1e262a08518c6d48e/mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609", size = 15549002, upload-time = "2026-04-21T17:08:23.107Z" }, - { url = "https://files.pythonhosted.org/packages/00/d2/09a6a10ee1bf0008f6c144d9676f2ca6a12512151b4e0ad0ff6c4fac5337/mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2", size = 14401942, upload-time = "2026-04-21T17:07:31.837Z" }, - { url = "https://files.pythonhosted.org/packages/57/da/9594b75c3c019e805250bed3583bdf4443ff9e6ef08f97e39ae308cb06f2/mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c", size = 15041649, upload-time = "2026-04-21T17:09:34.653Z" }, - { url = "https://files.pythonhosted.org/packages/97/77/f75a65c278e6e8eba2071f7f5a90481891053ecc39878cc444634d892abe/mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744", size = 15864588, upload-time = "2026-04-21T17:11:44.936Z" }, - { url = "https://files.pythonhosted.org/packages/d7/46/1a4e1c66e96c1a3246ddf5403d122ac9b0a8d2b7e65730b9d6533ba7a6d3/mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6", size = 16093956, upload-time = "2026-04-21T17:10:17.683Z" }, - { url = "https://files.pythonhosted.org/packages/5a/2c/78a8851264dec38cd736ca5b8bc9380674df0dd0be7792f538916157716c/mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec", size = 12568661, upload-time = "2026-04-21T17:11:54.473Z" }, - { url = "https://files.pythonhosted.org/packages/83/01/cd7318aa03493322ce275a0e14f4f52b8896335e4e79d4fb8153a7ad2b77/mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382", size = 10389240, upload-time = "2026-04-21T17:09:42.719Z" }, - { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" }, + { url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" }, + { url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" }, + { url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" }, + { url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, ] [[package]] @@ -1644,26 +1713,26 @@ wheels = [ [[package]] name = "prek" -version = "0.4.0" +version = "0.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/68/00050a4184f038a622855b1989b013d0ac5bfc0a29bf3cdbd1ed823595d8/prek-0.4.0.tar.gz", hash = "sha256:47f42477c8453c7440e4e656e5ab0c2a1e4c25daa5ed441a9ac1a2b7634abc12", size = 446399, upload-time = "2026-05-14T10:50:35.194Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/c8/ad3efcaf7007e4796f9a7482b4a3d84402c1535109695cba76fcb87cb03d/prek-0.5.0.tar.gz", hash = "sha256:8df015db60c1e9a30b4a266e65fa14c4d41d2b2b3b90879d76a5d8970dcce64d", size = 544033, upload-time = "2026-08-27T03:50:33.079Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/83/e4f5f574b8b3a80305a638e0cb2d46e3aa9596ac2b1e2dd6c910fedea376/prek-0.4.0-py3-none-linux_armv6l.whl", hash = "sha256:f4cba2132e038349b4b0a00a73b300e4192bae9f78fff8df0365c00bd19140e7", size = 5509604, upload-time = "2026-05-14T10:50:39.306Z" }, - { url = "https://files.pythonhosted.org/packages/05/de/ee9b8648944d44a8403ca49172d60936f5476196a7baf38c86df4aec7243/prek-0.4.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:944670565dfb3800465355f299effa31572822566d10c6210e6f76bf399ddf53", size = 5877244, upload-time = "2026-05-14T10:50:54.213Z" }, - { url = "https://files.pythonhosted.org/packages/33/f8/2e6021993332a249667102de0960160aa942880b0634d3e8920d062ebdb4/prek-0.4.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a8272f32e698eae514556086ad73d02026ec00bbd4d26c420f761ea857cfd795", size = 5435063, upload-time = "2026-05-14T10:50:31.75Z" }, - { url = "https://files.pythonhosted.org/packages/2a/8b/98fef34684f52c7f5b7b56bce14aec2b24b7abbcd7b6523cc83a63f74c58/prek-0.4.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:a860fda0f27f872622689358a583e5f2a5771241331848233274f4cfeb8ae9bb", size = 5690323, upload-time = "2026-05-14T10:50:42.583Z" }, - { url = "https://files.pythonhosted.org/packages/00/18/bf18cf0d3ab5b5eddadc6ade754ba6269ae857b32a4605036397ec56ccc1/prek-0.4.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:473ce0262e36b8e77b327941ad6d5747ac451b4c441cdc86dab95640b68fff72", size = 5423877, upload-time = "2026-05-14T10:50:36.434Z" }, - { url = "https://files.pythonhosted.org/packages/ce/1e/881f18b3cbf6fdca4ec8eb62d2c2e0cb9458fd4cebd136f5e0f76c7aa8d2/prek-0.4.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e46277b577991ccdc7b13e2cfa7d260ae13e77f5af543e50e188799f649f0ad8", size = 5830461, upload-time = "2026-05-14T10:50:45.885Z" }, - { url = "https://files.pythonhosted.org/packages/91/ea/1623391bdf1e103d7959cc7d41caedcd1ab982412c7db9f9f9ee5c0e09f4/prek-0.4.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b60bd4fdc323896a5bada2708734d16ff59ee5e32b8c390ae2ebe328426964a3", size = 6717949, upload-time = "2026-05-14T10:50:51.133Z" }, - { url = "https://files.pythonhosted.org/packages/da/56/59d73f20d3bf5ec1d1e88f24473cf77fb485acefd285c1eb79d2f102e8dc/prek-0.4.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7203bff21e622a5482e956384c43990b0092faab02f72118275839a77a45e939", size = 6104294, upload-time = "2026-05-14T10:50:57.251Z" }, - { url = "https://files.pythonhosted.org/packages/b7/7f/c03e17c06069b96805c3579c00f830bad0e700f30aa3fed08c78958be77a/prek-0.4.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:44e3716a3e2983add2c094775d564536342011e84f770132389819c965513d6d", size = 5703788, upload-time = "2026-05-14T10:50:55.717Z" }, - { url = "https://files.pythonhosted.org/packages/93/6f/f5664a1712ba214f67a0e93411dc62893a02165b5f04f3182e2c188d0623/prek-0.4.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6e3a0e43d7f345a5e32962736c335b5fc466d499f2556fb6f24028dd08108618", size = 5538311, upload-time = "2026-05-14T10:50:33.859Z" }, - { url = "https://files.pythonhosted.org/packages/d9/af/8a2aa0e02363e96d05a21ca73b4ea862459535bf6cf293710594396df450/prek-0.4.0-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:0d19efff4537e4a68b5bd61dcbf5ccb2dd8343df3ea9482aabe37d241ff18ddf", size = 5398617, upload-time = "2026-05-14T10:50:37.828Z" }, - { url = "https://files.pythonhosted.org/packages/6e/06/2b175e2ef812a3ab6ebab2dcd7aa0d03aea24852b73c6c4a30882800a01c/prek-0.4.0-py3-none-musllinux_1_1_i686.whl", hash = "sha256:0d9483404e5b8bf65111ee6b821e6cf7a5ba9c3a2065e7a0b7a39a17daaffcc9", size = 5685656, upload-time = "2026-05-14T10:50:40.781Z" }, - { url = "https://files.pythonhosted.org/packages/39/00/aee25867a6e88bb5cadee41dd83745876a273d3c149612424cf068239a8e/prek-0.4.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:01d69684306c67917ed69497d0f523216f13f2306723b62e1ace454209477b45", size = 6217097, upload-time = "2026-05-14T10:50:52.903Z" }, - { url = "https://files.pythonhosted.org/packages/f2/d2/e0cca68af94b7639badd3967fc37ae6159979baf6cc3c7f1d68f0a966bdf/prek-0.4.0-py3-none-win32.whl", hash = "sha256:ce2a8feb5dc1f1e748879d0e14c2145353059b830ac5e3f64d92127ab16efc78", size = 5204721, upload-time = "2026-05-14T10:50:49.269Z" }, - { url = "https://files.pythonhosted.org/packages/2d/5b/462c907502e7b9f634c73de4e6d36fa44b5d652a52a9ddd51811b2030ca7/prek-0.4.0-py3-none-win_amd64.whl", hash = "sha256:b218d92ad5d2ff0b59240d7d837fa9edc61849894958acb3a225efff7a2faa65", size = 5595119, upload-time = "2026-05-14T10:50:47.482Z" }, - { url = "https://files.pythonhosted.org/packages/0d/a0/a0be2f73cbc8fd65a5e1de576aa5a5324339c7cc9f7dd328009a5e1a3ae1/prek-0.4.0-py3-none-win_arm64.whl", hash = "sha256:ff1f1fa311023418d40a443bca6928a9f5ce073d0b9130b641954183aa31736d", size = 5423774, upload-time = "2026-05-14T10:50:44.19Z" }, + { url = "https://files.pythonhosted.org/packages/89/83/a5b36db04d5ed8c461b9fec216193a52eee5be37170a9387022f832312b6/prek-0.5.0-py3-none-linux_armv6l.whl", hash = "sha256:df794a883e347ef78cb74522a143dbd8cc2a66e765ef328f48b615f37098015e", size = 5593469, upload-time = "2026-08-27T03:50:00.402Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b5/cd6cab203693a1c45d5a3bae3b8eb56174d595da8af6d6e1595c795f909a/prek-0.5.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:19d6fd892e112cf7dc300735a7ac8e596813ff1c11649ba2d2b9dd6fdfd6c96e", size = 5980270, upload-time = "2026-08-27T03:50:02.675Z" }, + { url = "https://files.pythonhosted.org/packages/9d/b6/bbf5134c7b1360aad3f62c0f0391a7c6403b3500d16df25848089ad957c3/prek-0.5.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:313e2535483c37bba884ab034f478c174de7d3b9627081f2eedd16ac2c03bcc3", size = 5521835, upload-time = "2026-08-27T03:50:04.609Z" }, + { url = "https://files.pythonhosted.org/packages/78/72/7c79e9de5603fdc54d2101f0982f1af5e5ffa1a45c0bcbdbe5f985ed6b90/prek-0.5.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:1e9b34caeb465f1fa7cbb5d872d4611560eaff877162c4ad80a608838b0819a3", size = 5822039, upload-time = "2026-08-27T03:50:07.281Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d2/68c21ec26f773b2370b132d69fe325589db08865fbe8b3df7d8ea8d8fc49/prek-0.5.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:561c2d028c7679fe8969937aa9ab74d96d6f7c748b8abf98edf6dc031dfbbdc9", size = 5510802, upload-time = "2026-08-27T03:50:09.2Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/ce6db8d998cb3e48bf747b7390e1ec87bb817ec500cfa5791af3a0d2eded/prek-0.5.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:310579ceb4acbc5646df9d663ec38a57b47258a903780089cfacfbf0dc9d49f1", size = 5969141, upload-time = "2026-08-27T03:50:11.211Z" }, + { url = "https://files.pythonhosted.org/packages/5a/10/35e4c8fc4534d3b98c1bef2bfd559ba2aeedc9e540fcd4091fd4d91543b6/prek-0.5.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:da38e9c1d227773728c05d2f644704f3d3ace67f6ea58e41d406ebb596c3a135", size = 6731634, upload-time = "2026-08-27T03:50:13.244Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fc/433b55d10e928de8ab2a9b83dfdbe8cba936fafc6e0a5c65b85f4007a8ea/prek-0.5.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b845f25f481a7df56f39dddcba35576a10d78aa71e09e4f88bcccce0729ebfb2", size = 6203980, upload-time = "2026-08-27T03:50:15.158Z" }, + { url = "https://files.pythonhosted.org/packages/7b/b8/b5cdd27d6da0d754abcf9cc06a092f476bd9242f0e8e19e3dfd1a2d1b315/prek-0.5.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:93b4086a1b69c24a967b5c80608cfc4a111cc2b0f69bc1d1f278eb4c23ad430c", size = 5827384, upload-time = "2026-08-27T03:50:16.898Z" }, + { url = "https://files.pythonhosted.org/packages/18/e5/1d8fd614dfe04aa9378b33fb23f7285da582ca090c321973da8466a754d5/prek-0.5.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3299eca7269582665255f4c8f3ab38bc64e4d16ab2e9ce5e3d20cf65bc9f40ee", size = 5589195, upload-time = "2026-08-27T03:50:18.857Z" }, + { url = "https://files.pythonhosted.org/packages/6c/89/e758cefe423cd1422e882325baef65af2bf2b6a903c32d657323a993d394/prek-0.5.0-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:005c20b3df988f240bfa50518777d5babeab50cf774a8917dba1705567e1f561", size = 5491009, upload-time = "2026-08-27T03:50:20.836Z" }, + { url = "https://files.pythonhosted.org/packages/ea/90/6b30d898a2610113378b78b82f1d700bcdb79993ba92b85f492a02859f53/prek-0.5.0-py3-none-musllinux_1_1_i686.whl", hash = "sha256:a1a46d8de94d7c7c58b027a3622763861f1c76b01bafe9dc6d8b408713ad3946", size = 5826007, upload-time = "2026-08-27T03:50:23.1Z" }, + { url = "https://files.pythonhosted.org/packages/48/23/3fd00ff6d844b1756b95fc913730c6ec2f772f36558693ff3c4cfb2d3071/prek-0.5.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:e2aad66e0111cab24a03af2f67fca737c3d8f7b1188cf9b8994bbbf7b52f53b8", size = 6319092, upload-time = "2026-08-27T03:50:25.41Z" }, + { url = "https://files.pythonhosted.org/packages/ca/10/5d99bdfc77b51ab427a139e0b9f2dd5eacc74fe02fd1228c7be1a8ede6ad/prek-0.5.0-py3-none-win32.whl", hash = "sha256:d4970878d5032ad101b4ec5662f3848a70f80c0295ed5a1fea0aed82979c1251", size = 5340535, upload-time = "2026-08-27T03:50:27.452Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2a/835019081b3a09acefc6e28a6dd561a45c8270f8a84e85fa00d6f2be0300/prek-0.5.0-py3-none-win_amd64.whl", hash = "sha256:7bf2df923ba48ec72af7dc69033d613f4775eb301cdf975e19e749ecda2a28c4", size = 5725188, upload-time = "2026-08-27T03:50:29.492Z" }, + { url = "https://files.pythonhosted.org/packages/55/dc/a634d4c5951bd899526c6d8f2a4776bc42af1df4b01951e4f90412909cfc/prek-0.5.0-py3-none-win_arm64.whl", hash = "sha256:43e9d2d727435b0be28fe2195a6a22b0e5e0d937270ae0c88edfc42b71db8fab", size = 5492540, upload-time = "2026-08-27T03:50:31.49Z" }, ] [[package]] @@ -1908,7 +1977,7 @@ crypto = [ [[package]] name = "pylint" -version = "4.0.5" +version = "4.0.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "astroid" }, @@ -1919,9 +1988,9 @@ dependencies = [ { name = "platformdirs" }, { name = "tomlkit" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e4/b6/74d9a8a68b8067efce8d07707fe6a236324ee1e7808d2eb3646ec8517c7d/pylint-4.0.5.tar.gz", hash = "sha256:8cd6a618df75deb013bd7eb98327a95f02a6fb839205a6bbf5456ef96afb317c", size = 1572474, upload-time = "2026-02-20T09:07:33.621Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/1d/3bb57f303701549550d74bf7ced2b07412be97125c167a0c9d216aa9f762/pylint-4.0.6.tar.gz", hash = "sha256:52f19191bee08bf103f9705ad1a0ece4aa5a0a4ef2bdcbd969375a1e6f6579d5", size = 1585588, upload-time = "2026-06-14T14:43:26.772Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/6f/9ac2548e290764781f9e7e2aaf0685b086379dabfb29ca38536985471eaf/pylint-4.0.5-py3-none-any.whl", hash = "sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2", size = 536694, upload-time = "2026-02-20T09:07:31.028Z" }, + { url = "https://files.pythonhosted.org/packages/ab/da/acb2e7d4dbd2dfb792d38c0d850481f29ad7049b356d23f56c687d35203b/pylint-4.0.6-py3-none-any.whl", hash = "sha256:d11a0e1fdb7b1cd46ec5d6fc78fee8b95f28695b2d6140e5809925f61e32ea54", size = 538389, upload-time = "2026-06-14T14:43:24.873Z" }, ] [[package]] @@ -2039,15 +2108,15 @@ sdist = { url = "https://files.pythonhosted.org/packages/08/64/a99f27d3b4347486c [[package]] name = "pyright" -version = "1.1.409" +version = "1.1.410" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodeenv" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/51/4e/3aa27f74211522dba7e9cbc3e74de779c6d4b654c54e50a4840623be8014/pyright-1.1.409.tar.gz", hash = "sha256:986ee05beca9e077c165758ad123667c679e050059a2546aa02473930394bc93", size = 4430434, upload-time = "2026-04-23T11:02:03.799Z" } +sdist = { url = "https://files.pythonhosted.org/packages/10/53/e4d8ea1391bd4355231be6f91bf239479aa0014260ed3fb5526eeb12a1f2/pyright-1.1.410.tar.gz", hash = "sha256:07a073b8ba6749826773c1269773efa11b93440d9a6aa60419d9a3172d6dc488", size = 4062013, upload-time = "2026-06-01T17:35:48.894Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/6b/330d8ebae582b30c2959a1ef4c3bc344ebde48c2ff0c3f113c4710735e11/pyright-1.1.409-py3-none-any.whl", hash = "sha256:aa3ea228cab90c845c7a60d28db7a844c04315356392aa09fafcee98c8c22fb3", size = 6438161, upload-time = "2026-04-23T11:02:01.309Z" }, + { url = "https://files.pythonhosted.org/packages/d7/33/288b5868fa00846dacf249633719d747893e54aebd196b9968ac1878a5d3/pyright-1.1.410-py3-none-any.whl", hash = "sha256:5e961bed37cacf96b3f7cd7b1da39b350a9239aa2e69138d0e88f728cfaf296c", size = 6082448, upload-time = "2026-06-01T17:35:46.387Z" }, ] [[package]] @@ -2407,27 +2476,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, - { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, - { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, - { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, - { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, - { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, - { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, - { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, - { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, - { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, - { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, - { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, - { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, +version = "0.15.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/98/1295ad5a5aa9bc85bdcdfa5d82fe7b49c61af5657df4f227637ff9de0da6/ruff-0.15.18.tar.gz", hash = "sha256:2698a964c70e8bf402dcb99c8810472d270d141e7aa8c4e13599fd52033a2f33", size = 4761437, upload-time = "2026-06-18T18:25:39.224Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/d0/686e984941269621e2be72612d5c1e461f8f7b38415a2a7d7a81c8ae6715/ruff-0.15.18-py3-none-linux_armv6l.whl", hash = "sha256:8b6850172348c8381b8b3084c5915a4393c2373b9b54cd5b5e1ea15812bc10df", size = 10887308, upload-time = "2026-06-18T18:25:03.062Z" }, + { url = "https://files.pythonhosted.org/packages/ed/21/bc4123e3f5515ee99f8ce1eb93a14a0628fe4d1678663cd08f933ac16931/ruff-0.15.18-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3fccc153a85417dcd976883160cacce486997b0a0058dd18f54b8aaaac7d1ce2", size = 11281305, upload-time = "2026-06-18T18:25:30.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/93/4769464c25cf7ab2acb3c7dda9cad3d867eb41c59565b3e2a9d17249c90c/ruff-0.15.18-py3-none-macosx_11_0_arm64.whl", hash = "sha256:08d4c86a68f2c3ec2c9d56380a71fb4a4f65373055cbb8caabd645e9102f38d4", size = 10641215, upload-time = "2026-06-18T18:25:15.802Z" }, + { url = "https://files.pythonhosted.org/packages/6c/42/56926d17120db2c208d76bf60a1a019644dd9e91dc27f0f95c9caddb1366/ruff-0.15.18-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37e5108745c2c0705da916d7d4de533ddf547051ef45f62888c31bae73f66318", size = 10957224, upload-time = "2026-06-18T18:25:36.955Z" }, + { url = "https://files.pythonhosted.org/packages/22/4f/d43fab8d8189afde803103022d000a8ef9f230616d436d52a8b2b8d63b50/ruff-0.15.18-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56949a6ce8b3abde54c0bcb22cebfe57e8771cadc84b407ae8b8eaf67ebdcd43", size = 10699024, upload-time = "2026-06-18T18:25:05.707Z" }, + { url = "https://files.pythonhosted.org/packages/63/42/1e3e4c68bd408b9768cf3e439acbe2c78245225faef253f7028a0cdb63e0/ruff-0.15.18-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01a754cd6a1b630d3f97e33eb452cf7a98040482318e870f8bc52a5a30e62657", size = 11491458, upload-time = "2026-06-18T18:25:20.275Z" }, + { url = "https://files.pythonhosted.org/packages/20/77/47a3484bea8521e14a203d98c389c5c97846675e4f02734672da4a69b52a/ruff-0.15.18-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ba7a07e03a44dbf10bb086ee06705b173625014ec99f73a7e6836a5e5590a0c", size = 12383752, upload-time = "2026-06-18T18:25:22.535Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ca/054159590787023d83b658a1a1819c4c8910114e7015069340b71c0961cb/ruff-0.15.18-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a2c40a41a4cadbcf5897b548ab29dfe248b20c540961c0247d98a3973c70403", size = 11577923, upload-time = "2026-06-18T18:25:10.702Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/d353d6b7bbd73cc0ec37f4463d7540e45e894338abdd9964eee0de332708/ruff-0.15.18-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f0480ce690cbb6c4db6e5d08f19fce98e10ba131a8b60c1bcdac42771e3ae2d", size = 11583925, upload-time = "2026-06-18T18:25:32.391Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4a/891f89b9c296ed3e5f3ece1a5629badc989d9a8fdaa30431aaf4774bc1c2/ruff-0.15.18-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2330215f1f393fa8733f55edce04fcf94c36a2c460fcde31f78cc84e4951e9b1", size = 11582834, upload-time = "2026-06-18T18:25:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/32/a3/ed9e370154bf85de360b93c03026157f02d4943b2d01ff4945f4429f8e8a/ruff-0.15.18-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6aa6a3d979e48ae617578183674bf264fbe7d0114a796a26bd678d67963c7ff", size = 10927328, upload-time = "2026-06-18T18:25:34.676Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d1/5cf5909329fedb5d39d555ee818ba5cf4638e1a301b89785d34f2905bfcb/ruff-0.15.18-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a81beadbbff2c9c245561ae3f77b16709d87f35eec650d0501679239d3449b22", size = 10693187, upload-time = "2026-06-18T18:25:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/fd/44/ff6c635cf2c4f4e7b618b6640da057376baa36014695487d88aed4794268/ruff-0.15.18-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2186d9e940ae332ab293623a75b5f4fe49565f449954d50a72a046683aa6b809", size = 11208721, upload-time = "2026-06-18T18:25:41.327Z" }, + { url = "https://files.pythonhosted.org/packages/88/d9/5baa2a30861adfb7022cf33c1e35b2fc18085b08c16f83eff4c7b99a5f48/ruff-0.15.18-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5c2abf140438032bc77b2284a6c9944ecd8a19e5f1c7b52b1b8e4a0a80d19a7a", size = 11678599, upload-time = "2026-06-18T18:25:13.607Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1a/0725a7cfdc32ff769efb96ee782bec882e16448c5d9e3be947ec4c04ce27/ruff-0.15.18-py3-none-win32.whl", hash = "sha256:02299e6e9fa5b297a3f6d5d10d7bcd655c925b028bb8b9d4588214549c6b9ec4", size = 10901903, upload-time = "2026-06-18T18:25:24.755Z" }, + { url = "https://files.pythonhosted.org/packages/f3/51/805d9f6fb7970505c3504794a5ec350f605361b807fef4dcf214ebd35e72/ruff-0.15.18-py3-none-win_amd64.whl", hash = "sha256:dac80dc8d26b2257dbefabed62f5d255c3937b4ccb122da1fc634794fa3578b3", size = 12041189, upload-time = "2026-06-18T18:25:17.915Z" }, + { url = "https://files.pythonhosted.org/packages/29/4c/67bb45e41609eb4726f1bfeb59e083cf91d14c696d4bd14c234a980be93d/ruff-0.15.18-py3-none-win_arm64.whl", hash = "sha256:b2c9257fcbd4a3e5b977a1904e6facca016bafe2edc17df24db67cfaee03b4e4", size = 11329958, upload-time = "2026-06-18T18:25:43.686Z" }, ] [[package]] @@ -2533,15 +2602,15 @@ dev = [ { name = "bandit", extras = ["toml"], specifier = "==1.9.4" }, { name = "homeassistant-stubs", specifier = "==2026.8.0" }, { name = "isort" }, - { name = "mypy", specifier = "==1.20.2" }, - { name = "prek", specifier = ">=0.3.11" }, + { name = "mypy", specifier = "==2.1.0" }, + { name = "prek", specifier = ">=0.4.5" }, { name = "prettier" }, - { name = "pylint", specifier = "==4.0.5" }, - { name = "pyright", specifier = "==1.1.409" }, + { name = "pylint", specifier = "==4.0.6" }, + { name = "pyright", specifier = "==1.1.410" }, { name = "pytest-homeassistant-custom-component", specifier = "==0.13.354" }, { name = "python-direnv" }, { name = "radon", specifier = "==6.0.1" }, - { name = "ruff", specifier = "==0.15.12" }, + { name = "ruff", specifier = "==0.15.18" }, { name = "types-pyyaml" }, { name = "types-requests" }, { name = "voluptuous-stubs" }, From 54454cec47dd4d527a83f7987f84cdd13b4af3d3 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:56:50 -0700 Subject: [PATCH 215/345] chore: 2.1.0 --- custom_components/span_panel/manifest.json | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 4deef033..9ad216bb 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -27,7 +27,7 @@ "span-panel-api-schema-0==1.1.2", "span-panel-api-schema-1==1.1.3" ], - "version": "2.1.0b21", + "version": "2.1.0", "zeroconf": [ { "type": "_span._tcp.local." diff --git a/pyproject.toml b/pyproject.toml index 08666a4f..09edb043 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span" -version = "2.1.0b21" +version = "2.1.0" description = "Span Panel Custom Integration for Home Assistant" authors = [{name = "SpanPanel"}] license = {text = "MIT"} diff --git a/uv.lock b/uv.lock index c8514504..49879d9f 100644 --- a/uv.lock +++ b/uv.lock @@ -2560,7 +2560,7 @@ wheels = [ [[package]] name = "span" -version = "2.1.0b21" +version = "2.1.0" source = { virtual = "." } dependencies = [ { name = "homeassistant" }, From e4cce6962d572a4fe0e46192a11c6a34c275bca4 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:39:07 -0700 Subject: [PATCH 216/345] fix(config_flow): tell a moved panel apart from an impersonated one A pinned entry whose panel changed DHCP lease had no way back. The panel serves a perfectly good certificate that no longer names where it answers, and `async_leaf_chains_to_ca` returned one boolean for "does not chain" and "does not name this host" alike, so: - reconfigure refused with "not signed by the authority it published", which was false -- it was signed, by exactly that authority; - the mDNS move-guard refused on the same check; - reauth reads the stored host and offers no field to change it; - the CA-changed repair never fired, because the CA had not changed. Deleting and re-adding the entry was the only route left, for a panel that had done nothing but take a new address. `async_leaf_verdict` answers the three questions separately -- TRUSTED, NAME_MISMATCH, UNTRUSTED -- plus UNREACHABLE, which had been folded into the same value, so an unplugged cable was reported to users as an unsigned certificate. `async_leaf_chains_to_ca` stays as a wrapper for TRUSTED, so every existing caller keeps the meaning it had. Classification alone would have fixed nothing: `detect_api_version` and `register_fqdn` run over the entry's pinned transport, and both would fail the same hostname check before registration could repair anything. So on NAME_MISMATCH the reconfigure flow rebuilds its own transport with the pin kept and only the name binding dropped. It probes, registers the FQDN, and the panel regenerates its certificate around the new name. That transport never leaves the flow and is never stored. A bare IP the certificate does not name is still refused, because this flow can relax the name binding for its own probe and the coordinator and broker cannot -- storing it would point the entry at an address every later connection rejects. The new `ca_name_mismatch` error says so and names both ways through: an FQDN, or the panel's .local name. The relaxed context and the SAN matcher come from span-panel-api 3.2.0 rather than being written here. `ssl.match_hostname` was removed in 3.12, so the matcher is hand-written, and a hand-written security primitive with two implementations is the drift `_ssl`'s docstring warns about. --- CHANGELOG.md | 4 +- README.md | 13 +- custom_components/span_panel/config_flow.py | 92 ++++++++--- .../span_panel/config_flow_validation.py | 116 ++++++++++--- custom_components/span_panel/manifest.json | 4 +- custom_components/span_panel/strings.json | 1 + .../span_panel/translations/en.json | 1 + .../span_panel/translations/es.json | 1 + .../span_panel/translations/fr.json | 1 + .../span_panel/translations/ja.json | 1 + .../span_panel/translations/pt.json | 1 + pyproject.toml | 2 +- requirements_test.txt | 2 +- tests/test_config_flow_validation.py | 19 ++- tests/test_v2_config_flow.py | 4 +- tests/test_v2_config_flow_tls.py | 153 +++++++++++++++++- uv.lock | 2 +- 17 files changed, 365 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bccfe8f..a1593f7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -132,7 +132,9 @@ This release requires Home Assistant 2026.8 to avoid a deprecated API — the ol name that is not a fully qualified domain, so the certificate never comes to name it. Set that panel up by its IP address, or by the `.local` name shown in the mobile app. - **A discovered address this entry's authority rejects is refused with a log warning and nothing else** — if the panel really has moved, use **Reconfigure** on - the entry, which checks the new address against the standing pin before storing it. + the entry, which checks the new address against the standing pin before storing it. A panel whose certificate does not yet name where it now answers is moved + by reconfiguring to a fully qualified domain name, which asks the panel to regenerate its certificate around it, or to the `.local` name its certificate + already covers; a bare new IP address the certificate does not name is refused, because every connection after the flow would reject it too. ## [2.0.8] - 5/2026 diff --git a/README.md b/README.md index 09ff8333..aefe18ac 100644 --- a/README.md +++ b/README.md @@ -692,11 +692,14 @@ against the address the certificate already names, hostname verification never r **A pinned entry follows its panel to a new address only when the new address proves itself.** An entry moves when your panel announces itself over mDNS at a new address, and when you add the same panel again by hand. Both used to be decided by a serial read from an unauthenticated endpoint, which anything on your network can claim. The entry now asks whether the candidate serves a certificate its own anchor validates, on the port the entry uses, and refuses the move — -logging at `WARNING` — otherwise. If the panel really has moved and its certificate really has changed, use **Reconfigure** on the entry. - -A panel that arrives through the **Supervisor** — a Home Assistant add-on — is the one exception to that check, and deliberately so: it announces itself over -the authenticated Supervisor API and legitimately reallocates its own ports, so holding it to the stored address would freeze the entry. An add-on already -holding Supervisor privileges can therefore move a pinned entry. +logging at `WARNING` — otherwise. If the panel really has moved, use **Reconfigure** on the entry. + +**Reconfigure tells a moved panel apart from an impersonated one.** A panel that changed address serves a perfectly good certificate that does not yet name +where it now answers, and something impersonating your panel serves one that chains to nothing — the same failure, until you ask which. Reconfigure asks. A host +that does not chain to the pinned authority is refused as before, and one that does not answer at all is now reported as unreachable rather than as an unsigned +certificate. A host that chains but is not named is your panel, and the way through is to reconfigure to a **fully qualified domain name**, which asks the panel +to regenerate its certificate around that name, or to the panel's **`.local` name**, which its certificate already covers. Reconfiguring to a bare new IP +address the certificate does not name is refused, because the entry would then be pointed at an address every later connection rejects. **Panels configured before this release are pinned differently, and are not risk-free until they are.** They are pinned on the first startup that reaches the panel, logged at `WARNING` with the fingerprint so you can find the value afterwards. What was fetched is checked against the certificate the panel serves on diff --git a/custom_components/span_panel/config_flow.py b/custom_components/span_panel/config_flow.py index cb61cff8..e69e4f8c 100644 --- a/custom_components/span_panel/config_flow.py +++ b/custom_components/span_panel/config_flow.py @@ -43,12 +43,14 @@ process_general_options_input, ) from .config_flow_validation import ( + LeafVerdict, PanelCaUnusableError, PanelRestTransport, as_port, async_download_ca_or_none, async_fetch_panel_ca, async_leaf_chains_to_ca, + async_leaf_verdict, async_panel_leaf_host, check_fqdn_tls_ready, is_fqdn, @@ -208,6 +210,10 @@ def __init__(self) -> None: # would reject", and refuse rather than write an entry that cannot # connect to its own panel. self._bootstrap_host: str | None = None + # Whether the host this flow is about to store is one the pinned + # certificate names. Only reconfigure sets it false, and only to + # refuse -- see `async_step_reconfigure`. + self._leaf_names_host: bool = True # Whether the panel accepted the FQDN and now serves it. Recorded rather # than re-derived from `is_fqdn(host)` at entry creation, because that # question is "does this look like a domain name" and the one that @@ -1312,21 +1318,62 @@ async def async_step_reconfigure( data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), errors={"base": "ca_unusable"}, ) - # The new host may be a name the panel has never heard of, and this - # entry is pinned: probing it by name would fail hostname verification - # and report the panel unreachable. Settle the address first, so the - # probe reaches the panel and `_bootstrap_host` records whether the new - # name is one the pinned certificate already covers. + # The new host may be one the panel's certificate does not name -- a new + # DHCP lease, or an FQDN it has not been told about yet -- and this entry + # is pinned. Ask what is actually true of it before deciding, because + # "not your panel" and "your panel, under a name it has not caught up + # with" have opposite remedies and used to be the same answer. self.host = host + self._leaf_names_host = True pinned_pem = self._rest_transport.ca_pem - if pinned_pem is not None and not await self._async_choose_bootstrap_host( - pinned_pem, self._rest_transport.port - ): - return self.async_show_form( - step_id="reconfigure", - data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), - errors={"base": "ca_leaf_mismatch"}, - ) + if pinned_pem is not None: + if await self._async_choose_bootstrap_host(pinned_pem, self._rest_transport.port): + # Something under the pin both validates and names itself. The + # host is storable only when that something was the host: a + # substituted address reaches the panel but says nothing about + # the name the user asked to store. + self._leaf_names_host = self._bootstrap_host is None + else: + # Nothing the anchor both validates and names, which used to end + # the flow here. Three different things produce that, and only + # one of them is the panel being absent. + verdict = await async_leaf_verdict(host, self._rest_transport.port, pinned_pem) + if verdict is LeafVerdict.UNREACHABLE: + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), + errors={"base": "cannot_connect"}, + ) + if verdict is not LeafVerdict.NAME_MISMATCH: + _LOGGER.warning( + "Refusing to reconfigure panel %s to %s: the certificate served there on " + "port %s does not chain to the authority this entry is pinned to " + "(SHA-256 %s)", + reconfigure_entry.title, + host, + self._rest_transport.port, + pem_fingerprint_or_reason(pinned_pem), + ) + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), + errors={"base": "ca_leaf_mismatch"}, + ) + # The panel is there -- only it holds a key the pinned anchor + # signed -- but its certificate names neither this address nor + # anything this flow could reach it by. Every call over the + # strict transport would fail and report it unreachable, so this + # flow's own calls keep the pin and drop only the name binding. + # That is what lets the FQDN branch below ask the panel to + # regenerate the certificate that fixes this. The relaxed + # transport never leaves this flow and is never stored. + self._leaf_names_host = False + self._rest_transport = panel_rest_transport( + self.hass, + reconfigure_entry.data, + allow_plaintext_fallback=False, + verify_hostname=False, + ) try: detection = await detect_api_version( self._rest_host, @@ -1376,17 +1423,26 @@ async def async_step_reconfigure( # New host is not an FQDN — simple update. Nothing on this branch will # ask the panel to start naming it, so a host the pinned certificate # does not cover is refused here rather than written into the entry. - if self._bootstrap_host is not None: + # + # Refused even though the panel demonstrably answered here, because + # storing it would not help: this flow can relax the name binding for + # its own probe, but the coordinator and the broker cannot, so every + # connection after the flow ends would fail hostname verification + # against the same certificate. The entry would be pointed at an address + # it cannot use. The remedies are real ones and the message names them. + if not self._leaf_names_host: _LOGGER.warning( - "Panel %s does not name %s in the certificate it serves; refusing to " - "point a pinned entry at a host its own certificate authority rejects", - self._bootstrap_host, + "Panel %s answered at %s but does not name it in the certificate it serves; " + "refusing to store an address every later connection would reject. Reconfigure " + "to an FQDN, which asks the panel to regenerate its certificate, or to the " + "panel's .local name, which its certificate already covers", + reconfigure_entry.title, host, ) return self.async_show_form( step_id="reconfigure", data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), - errors={"base": "ca_leaf_mismatch"}, + errors={"base": "ca_name_mismatch"}, ) data_updates: dict[str, Any] = {CONF_HOST: host} diff --git a/custom_components/span_panel/config_flow_validation.py b/custom_components/span_panel/config_flow_validation.py index 3337a5fc..a035dbcb 100644 --- a/custom_components/span_panel/config_flow_validation.py +++ b/custom_components/span_panel/config_flow_validation.py @@ -5,6 +5,7 @@ import asyncio from collections.abc import Mapping from dataclasses import dataclass +from enum import StrEnum import ipaddress import logging import socket @@ -19,6 +20,7 @@ ca_fingerprint, detect_api_version, download_ca_cert, + leaf_names_host, register_v2, ) from span_panel_api.exceptions import ( @@ -211,45 +213,104 @@ async def async_fetch_panel_ca(hass: HomeAssistant, host: str, http_port: int = ) -async def async_leaf_chains_to_ca(host: str, tls_port: int, ca_pem: str) -> bool: - """Whether the certificate served on `tls_port` validates against `ca_pem` alone. +class LeafVerdict(StrEnum): + """What the certificate on a host says about that host. + + One boolean used to answer three different questions, and the answers call + for opposite responses. Splitting them is the whole point: + + - `TRUSTED` — the leaf chains to the anchor *and* names this host. The only + verdict that authorises storing the host or carrying traffic to it. + - `NAME_MISMATCH` — the leaf chains to the anchor but does not name this + host. Only the panel can produce this, because only the panel holds a key + the anchor signed; what it means is that the panel's certificate has not + caught up with the address it is being reached at, which is what a DHCP + move looks like from here. + - `UNTRUSTED` — something answered and its certificate does not chain to the + anchor. The interception case, and the only one worth alarming a user + about. + - `UNREACHABLE` — nothing answered at all. Distinct from `UNTRUSTED` + because it had been collapsed into it, so an unplugged cable was reported + to users as "the certificate this panel serves is not signed by the + authority it published" — an accusation of interception for a network + timeout. + """ + + TRUSTED = "trusted" + NAME_MISMATCH = "name_mismatch" + UNTRUSTED = "untrusted" + UNREACHABLE = "unreachable" + + +async def async_leaf_verdict(host: str, tls_port: int, ca_pem: str) -> LeafVerdict: + """Classify the certificate served on `tls_port` against `ca_pem`. - A CA that cannot validate the certificate the panel actually serves is not - the panel's CA. That does not make a fetched PEM trustworthy — an attacker - who can substitute the CA can serve a leaf signed by it — but it does catch - the fetch that returned something unrelated, and it is the only check - available without a fingerprint from another channel. + One handshake, with hostname checking off, which establishes the chain; the + name binding is then evaluated separately from the certificate the + handshake returned. Both halves come from the library rather than being + written here, for the reason its `_ssl` module gives about the fingerprint: + a security primitive with two implementations drifts, and a hand-written + hostname matcher is the most error-prone of the three now that + `ssl.match_hostname` no longer exists to defer to. - Hostname verification stays on, so this also fails when the panel's - certificate does not name the address being used, which is the condition - `check_fqdn_tls_ready` is polling for. + Relaxing the hostname check does not relax trust. The chain, the signature + and the expiry are all still verified against the pinned anchor, so a peer + without a key that anchor signed fails here exactly as it did before; what + changes is only that failing the *name* is now reported as a different + thing from failing the *chain*. """ loop = asyncio.get_running_loop() - def _check() -> bool: + def _check() -> LeafVerdict: try: # The library's builder, not a hand-rolled context: the panel's CA # omits the Authority Key Identifier extension, which Python's # default-on VERIFY_X509_STRICT rejects outright. A context built # here without clearing that flag fails against a healthy panel. - ctx = build_panel_ssl_context(ca_pem) + ctx = build_panel_ssl_context(ca_pem, check_hostname=False) except (ssl.SSLError, ValueError): - return False + # An anchor that will not load cannot validate anything. Nothing was + # reached, so this is not an accusation against the host. + return LeafVerdict.UNTRUSTED try: with ( socket.create_connection((host, tls_port), timeout=5) as sock, - ctx.wrap_socket(sock, server_hostname=host), + ctx.wrap_socket(sock, server_hostname=host) as tls, ): - return True - except (ssl.SSLCertVerificationError, ssl.SSLError, OSError, TimeoutError, UnicodeError): + peer = tls.getpeercert() + except ssl.SSLCertVerificationError: + return LeafVerdict.UNTRUSTED + except (OSError, TimeoutError, UnicodeError): # `UnicodeError` for the same reason as in `async_resolve_host`: the # connect resolves the name, and an over-long label raises out of - # IDNA encoding rather than as a lookup failure. - return False + # IDNA encoding rather than as a lookup failure. `ssl.SSLError` is a + # subclass of `OSError` and lands here too -- a handshake that broke + # without a verification failure says nothing about the peer's + # certificate, so it is a failure to reach, not a failure to trust. + return LeafVerdict.UNREACHABLE + + # The handshake completed under the pinned anchor, so the peer holds a + # key that anchor signed. Only the name is left in question. + if peer and leaf_names_host(peer, host): + return LeafVerdict.TRUSTED + return LeafVerdict.NAME_MISMATCH return await loop.run_in_executor(None, _check) +async def async_leaf_chains_to_ca(host: str, tls_port: int, ca_pem: str) -> bool: + """Whether the panel at `host` serves a certificate that chains *and* names it. + + The question every existing caller was already asking, kept as its own name + so that the answer does not quietly widen underneath them. A caller that + wants to act on *why* the answer is no asks `async_leaf_verdict` instead; + the two must not be confused, because a `NAME_MISMATCH` host is one this + function has always refused and must keep refusing -- storing it would + strand every later connection, all of which verify the hostname. + """ + return await async_leaf_verdict(host, tls_port, ca_pem) is LeafVerdict.TRUSTED + + async def async_panel_leaf_host( hass: HomeAssistant, host: str, tls_port: int, ca_pem: str ) -> str | None: @@ -443,9 +504,26 @@ def panel_rest_transport( entry_data: Mapping[str, object], *, allow_plaintext_fallback: bool = True, + verify_hostname: bool = True, ) -> PanelRestTransport: """Decide how this entry's REST calls should reach the panel. + `verify_hostname=False` keeps the pin and drops only the name binding, for + the single case that needs it: a reconfigure of a pinned entry whose panel + is answering at an address its certificate does not yet name. Such a panel + has proven it holds a key the pinned anchor signed -- nothing else can + complete the handshake -- but every call to it still fails hostname + verification, so without this the flow cannot probe the panel, cannot ask + it to regenerate its certificate, and cannot repair the situation it + exists to repair. + + It is for a flow deciding *which* host to talk to, and for nothing else. It + must never reach a transport that gets stored on the entry or used at + runtime: the name binding is what stops a validated certificate being + replayed by a host it was not issued to, and a coordinator carrying data + over a relaxed context would give that up permanently rather than for the + length of one flow. + A malformed stored PEM falls back to plaintext rather than raising. The alternative is a config entry that cannot make a single REST call until somebody edits `.storage` by hand, which is a worse outcome than the @@ -469,7 +547,7 @@ def panel_rest_transport( pem = entry_data.get(CONF_PANEL_CA_PEM) if pem: try: - context = build_panel_ssl_context(str(pem)) + context = build_panel_ssl_context(str(pem), check_hostname=verify_hostname) except (ssl.SSLError, ValueError) as err: if not allow_plaintext_fallback: raise PanelCaUnusableError(str(err)) from err diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 9ad216bb..7154cdc6 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -23,11 +23,11 @@ ], "quality_scale": "gold", "requirements": [ - "span-panel-api==3.1.1", + "span-panel-api==3.2.0", "span-panel-api-schema-0==1.1.2", "span-panel-api-schema-1==1.1.3" ], - "version": "2.1.0", + "version": "2.1.0b22", "zeroconf": [ { "type": "_span._tcp.local." diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index 7a022294..9adc0d95 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -14,6 +14,7 @@ }, "error": { "ca_leaf_mismatch": "The certificate this panel serves is not signed by the authority it published. Check that the HTTPS port is correct and that nothing is intercepting the connection, then try again.", + "ca_name_mismatch": "The panel answered at this address but its certificate does not name it, so every later connection would reject it. Reconfigure to a fully qualified domain name, which asks the panel to regenerate its certificate to include it, or to the panel's .local name, which its certificate already covers.", "ca_unavailable": "The panel's certificate authority could not be read. Check that the panel is reachable and powered on, then try again. Setup cannot continue without it, because your passphrase would otherwise be sent in the clear.", "ca_unusable": "The certificate authority stored for this panel cannot be read, so this change would have to be sent unencrypted. Nothing has been changed. Reauthenticate the panel to store a usable authority, then try again.", "cannot_connect": "Failed to connect to Span Panel", diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index 7a022294..9adc0d95 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -14,6 +14,7 @@ }, "error": { "ca_leaf_mismatch": "The certificate this panel serves is not signed by the authority it published. Check that the HTTPS port is correct and that nothing is intercepting the connection, then try again.", + "ca_name_mismatch": "The panel answered at this address but its certificate does not name it, so every later connection would reject it. Reconfigure to a fully qualified domain name, which asks the panel to regenerate its certificate to include it, or to the panel's .local name, which its certificate already covers.", "ca_unavailable": "The panel's certificate authority could not be read. Check that the panel is reachable and powered on, then try again. Setup cannot continue without it, because your passphrase would otherwise be sent in the clear.", "ca_unusable": "The certificate authority stored for this panel cannot be read, so this change would have to be sent unencrypted. Nothing has been changed. Reauthenticate the panel to store a usable authority, then try again.", "cannot_connect": "Failed to connect to Span Panel", diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index 9e27eede..aed70164 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -14,6 +14,7 @@ }, "error": { "ca_leaf_mismatch": "El certificado que sirve este panel no está firmado por la autoridad que ha publicado. Compruebe que el puerto HTTPS es correcto y que nada intercepta la conexión, y vuelva a intentarlo.", + "ca_name_mismatch": "El panel respondió en esta dirección pero su certificado no la nombra, por lo que cualquier conexión posterior la rechazaría. Reconfigure con un nombre de dominio completo, lo que pide al panel que regenere su certificado para incluirlo, o con el nombre .local del panel, que su certificado ya cubre.", "ca_unavailable": "No se ha podido leer la autoridad de certificación del panel. Compruebe que el panel es accesible y está encendido, y vuelva a intentarlo. La configuración no puede continuar sin ella, porque de lo contrario su contraseña se enviaría en claro.", "ca_unusable": "La autoridad de certificación almacenada para este panel no se puede leer, por lo que este cambio tendría que enviarse sin cifrar. No se ha cambiado nada. Vuelva a autenticar el panel para almacenar una autoridad utilizable y vuelva a intentarlo.", "cannot_connect": "No se logró establecer conexión con Span Panel", diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index 897335e2..1c5b90de 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -14,6 +14,7 @@ }, "error": { "ca_leaf_mismatch": "Le certificat présenté par ce panneau n'est pas signé par l'autorité qu'il a publiée. Vérifiez que le port HTTPS est correct et que rien n'intercepte la connexion, puis réessayez.", + "ca_name_mismatch": "Le panneau a répondu à cette adresse mais son certificat ne la nomme pas, de sorte que toute connexion ultérieure la rejetterait. Reconfigurez avec un nom de domaine pleinement qualifié, ce qui demande au panneau de régénérer son certificat pour l'inclure, ou avec le nom .local du panneau, que son certificat couvre déjà.", "ca_unavailable": "L'autorité de certification du panneau n'a pas pu être lue. Vérifiez que le panneau est joignable et sous tension, puis réessayez. La configuration ne peut pas continuer sans elle, car votre mot de passe serait sinon envoyé en clair.", "ca_unusable": "L'autorité de certification enregistrée pour ce panneau ne peut pas être lue, ce changement devrait donc être envoyé sans chiffrement. Rien n'a été modifié. Réauthentifiez le panneau pour enregistrer une autorité utilisable, puis réessayez.", "cannot_connect": "Échec de la connexion au Span Panel", diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index c11ae3a3..eb12e4f7 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -14,6 +14,7 @@ }, "error": { "ca_leaf_mismatch": "このパネルが提供する証明書は、パネルが公開した認証局で署名されていません。HTTPS ポートが正しいこと、接続が傍受されていないことを確認してから、もう一度お試しください。", + "ca_name_mismatch": "パネルはこのアドレスで応答しましたが、証明書がこのアドレスを名前として含んでいないため、以降の接続はすべて拒否されます。完全修飾ドメイン名で再構成すると、パネルに証明書の再生成を求めてこのアドレスを含めます。または、証明書が既に対象としているパネルの .local 名で再構成してください。", "ca_unavailable": "パネルの認証局を読み取れませんでした。パネルに到達でき、電源が入っていることを確認してから、もう一度お試しください。これがないとセットアップは続行できません。続行するとパスフレーズが平文で送信されてしまうためです。", "ca_unusable": "このパネルに保存されている認証局を読み取れないため、この変更は暗号化されずに送信されることになります。何も変更されていません。パネルを再認証して使用可能な認証局を保存してから、もう一度お試しください。", "cannot_connect": "スパンパネルへの接続に失敗しました", diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index b0529486..8e14fca3 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -14,6 +14,7 @@ }, "error": { "ca_leaf_mismatch": "O certificado que este painel serve não está assinado pela autoridade que publicou. Verifique se a porta HTTPS está correta e se nada está a intercetar a ligação, e tente novamente.", + "ca_name_mismatch": "O painel respondeu neste endereço mas o seu certificado não o nomeia, pelo que qualquer ligação posterior o rejeitaria. Reconfigure com um nome de domínio totalmente qualificado, o que pede ao painel que regenere o seu certificado para o incluir, ou com o nome .local do painel, que o seu certificado já cobre.", "ca_unavailable": "Não foi possível ler a autoridade de certificação do painel. Verifique se o painel está acessível e ligado e tente novamente. A configuração não pode continuar sem ela, porque de outro modo a sua frase-passe seria enviada em claro.", "ca_unusable": "A autoridade de certificação armazenada para este painel não pode ser lida, pelo que esta alteração teria de ser enviada sem cifra. Nada foi alterado. Volte a autenticar o painel para armazenar uma autoridade utilizável e tente novamente.", "cannot_connect": "Falha ao ligar ao Painel Span", diff --git a/pyproject.toml b/pyproject.toml index 09edb043..1003054a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ readme = "README.md" requires-python = ">=3.14.3,<3.15" dependencies = [ "homeassistant==2026.8.0", - "span-panel-api==3.1.1", + "span-panel-api==3.2.0", # 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, diff --git a/requirements_test.txt b/requirements_test.txt index a87d993e..12589e57 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -3,6 +3,6 @@ pytest-asyncio>=1.4.0 setuptools>=65.7.0 pytest-homeassistant-custom-component==0.13.354 homeassistant==2026.8.0 -span-panel-api==3.1.1 +span-panel-api==3.2.0 span-panel-api-schema-0==1.1.2 span-panel-api-schema-1==1.1.3 diff --git a/tests/test_config_flow_validation.py b/tests/test_config_flow_validation.py index 477401ff..28aeb38a 100644 --- a/tests/test_config_flow_validation.py +++ b/tests/test_config_flow_validation.py @@ -248,6 +248,11 @@ def __enter__(self): def __exit__(self, exc_type, exc, tb): return False + def getpeercert(self): + # The handshake now establishes only the chain; the name binding is + # judged separately from the certificate it hands back. + return {"subjectAltName": (("DNS", "panel.example.com"),)} + class FakeSSLContext: def wrap_socket(self, _sock, server_hostname: str): assert server_hostname == "panel.example.com" @@ -274,7 +279,7 @@ def wrap_socket(self, _sock, server_hostname: str): assert await check_fqdn_tls_ready("panel.example.com", 8883, "pinned-pem") is True mock_download.assert_not_awaited() - build_context.assert_called_once_with("pinned-pem") + build_context.assert_called_once_with("pinned-pem", check_hostname=False) @pytest.mark.asyncio @@ -292,6 +297,11 @@ def __enter__(self): def __exit__(self, exc_type, exc, tb): return False + def getpeercert(self): + # The handshake now establishes only the chain; the name binding is + # judged separately from the certificate it hands back. + return {"subjectAltName": (("DNS", "panel.example.com"),)} + class FakeSSLContext: def wrap_socket(self, _sock, server_hostname: str): assert server_hostname == "panel.example.com" @@ -315,7 +325,7 @@ def wrap_socket(self, _sock, server_hostname: str): # The library's builder, not a hand-rolled context: it is the one that clears # VERIFY_X509_STRICT, which the panel's AKI-less CA fails without. - build_context.assert_called_once_with("pem-data") + build_context.assert_called_once_with("pem-data", check_hostname=False) @pytest.mark.asyncio @@ -333,6 +343,11 @@ def __enter__(self): def __exit__(self, exc_type, exc, tb): return False + def getpeercert(self): + # The handshake now establishes only the chain; the name binding is + # judged separately from the certificate it hands back. + return {"subjectAltName": (("DNS", "panel.example.com"),)} + class FakeSSLContext: def wrap_socket(self, _sock, server_hostname: str): raise ssl.SSLError(f"bad cert for {server_hostname}") diff --git a/tests/test_v2_config_flow.py b/tests/test_v2_config_flow.py index ed2e4671..8ec2ca96 100644 --- a/tests/test_v2_config_flow.py +++ b/tests/test_v2_config_flow.py @@ -1038,7 +1038,9 @@ async def test_replacing_an_unusable_stored_anchor_says_what_it_is_now_pinned_to ) entry.add_to_hass(hass) - def _build(pem: str) -> MagicMock: + def _build(pem: str, *, check_hostname: bool = True) -> MagicMock: + # `check_hostname` is accepted and ignored: an anchor that will not load + # fails the same way whichever question the caller was going to ask of it. if pem == "not-a-certificate": raise ssl.SSLError("not a certificate") return MagicMock() diff --git a/tests/test_v2_config_flow_tls.py b/tests/test_v2_config_flow_tls.py index b0064d65..1951f325 100644 --- a/tests/test_v2_config_flow_tls.py +++ b/tests/test_v2_config_flow_tls.py @@ -645,6 +645,13 @@ async def test_reconfigure_refuses_a_name_the_pinned_certificate_does_not_cover( The address bootstrap is what lets the probe reach the panel at all; it is not permission to store a host the pinned certificate rejects, and nothing on this branch will ask the panel to start serving it. + + Refused as a *naming* failure, not a signing one. The panel answered and its + certificate chains to the pinned anchor -- nothing else holds a key that + anchor signed -- so telling the user it "is not signed by the authority it + published" would accuse the network of interception over a name the panel + simply has not been told about. The remedies differ too, and only the + naming message can name them. """ entry = _pinned_entry(hass, panel) @@ -655,7 +662,7 @@ async def test_reconfigure_refuses_a_name_the_pinned_certificate_does_not_cover( assert result["type"] == FlowResultType.FORM, result assert result["step_id"] == "reconfigure" - assert result["errors"] == {"base": "ca_leaf_mismatch"} + assert result["errors"] == {"base": "ca_name_mismatch"} assert entry.data[CONF_HOST] == PANEL_LOOPBACK @@ -832,3 +839,147 @@ async def test_re_adding_a_pinned_panel_by_a_name_it_serves_moves_the_entry( assert result["type"] == FlowResultType.ABORT, result assert result["reason"] == "already_configured" assert entry.data[CONF_HOST] == PANEL_SHORTNAME + + +@pytest.fixture +def moved_panel(tmp_path: Any) -> Iterator[Panel]: + """Serve a leaf naming an address the panel no longer has, as a moved lease does. + + The listener is on loopback, so the flow reaches it while its certificate + names 10.0.0.99 and nothing else. That is what a DHCP move looks like from + the integration's side, and it is chain-valid throughout -- the panel is + still the panel, and still the only holder of a key the pinned anchor + signed. + """ + instance = Panel(tmp_path) + instance.present([x509.IPAddress(ipaddress.ip_address("10.0.0.99"))]) + yield instance + instance.close() + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_a_moved_panel_can_be_reconfigured_to_an_fqdn( + hass: HomeAssistant, moved_panel: Panel +) -> None: + """The case that had no way out before this. + + The panel's certificate names neither the address it now answers on nor the + name being moved to, so every probe over the pinned transport failed + hostname verification and the flow reported it unreachable. Reconfigure was + the documented remedy and refused for the same reason, the mDNS move-guard + refused, and reauth offers no host field -- deleting the entry was the only + route left. + + Registration is what repairs it: the flow probes over a transport that keeps + the pin and drops only the name binding, asks the panel to regenerate its + certificate around the FQDN, and stores the name once the panel serves it. + """ + entry = _pinned_entry(hass, moved_panel) + + register = AsyncMock(side_effect=_registration_adds_the_fqdn(moved_panel)) + with ( + patch("custom_components.span_panel.config_flow.register_fqdn", new=register), + patch("custom_components.span_panel.config_flow.asyncio.sleep", new=AsyncMock()), + ): + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: PANEL_FQDN} + ) + result = await _finish_progress(hass, result) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT, result + assert result["reason"] == "reconfigure_successful" + assert entry.data[CONF_HOST] == PANEL_FQDN + assert entry.data[CONF_REGISTERED_FQDN] == PANEL_FQDN + # The anchor is untouched throughout: nothing here is a CA change. + assert entry.data[CONF_PANEL_CA_PEM] == moved_panel.ca_pem + assert register.await_args.args[2] == PANEL_FQDN + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_a_moved_panel_is_still_refused_at_a_bare_address( + hass: HomeAssistant, moved_panel: Panel +) -> None: + """Reaching the panel is not permission to store the address it was reached at. + + Nothing on this branch asks the panel to start naming the address, so the + entry would be stored pointing somewhere the coordinator and the broker both + reject -- they verify the hostname and cannot be relaxed the way one flow's + own probe can. + """ + entry = _pinned_entry(hass, moved_panel) + + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: PANEL_LOOPBACK} + ) + + assert result["type"] == FlowResultType.FORM, result + assert result["errors"] == {"base": "ca_name_mismatch"} + assert entry.data[CONF_HOST] == PANEL_LOOPBACK + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_an_impostor_is_still_refused_as_a_signing_failure( + hass: HomeAssistant, panel: Panel +) -> None: + """The guarantee the relaxed probe must not weaken. + + Relaxing the name binding leaves the chain, the signature and the expiry + verified against the pinned anchor, so a host without a key that anchor + signed fails exactly as it did before -- and is reported as what it is, + rather than as a naming problem. + """ + entry = _pinned_entry(hass, panel) + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_PANEL_CA_PEM: unrelated_ca_pem()} + ) + + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: PANEL_LOOPBACK} + ) + + assert result["type"] == FlowResultType.FORM, result + assert result["errors"] == {"base": "ca_leaf_mismatch"} + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_an_unreachable_host_is_not_accused_of_interception( + hass: HomeAssistant, panel: Panel +) -> None: + """An unplugged cable used to be reported as an unsigned certificate. + + `async_leaf_chains_to_ca` answered False for a timeout and a verification + failure alike, so a host that never answered produced "the certificate this + panel serves is not signed by the authority it published" -- an accusation + about a certificate nothing ever presented. + """ + entry = _pinned_entry(hass, panel) + + # A port on loopback with nothing listening: connection refused, no TLS. + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_HTTPS_PORT: _closed_port()} + ) + + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: PANEL_LOOPBACK} + ) + + assert result["type"] == FlowResultType.FORM, result + assert result["errors"] == {"base": "cannot_connect"} + + +def _closed_port() -> int: + """Return a port nothing is listening on, by binding and immediately closing.""" + import socket as _socket + + with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) diff --git a/uv.lock b/uv.lock index 49879d9f..d615e95a 100644 --- a/uv.lock +++ b/uv.lock @@ -2619,7 +2619,7 @@ dev = [ [[package]] name = "span-panel-api" -version = "3.1.1" +version = "3.2.0" source = { editable = "../../span/span-panel-api" } dependencies = [ { name = "httpx" }, From a2674c94f898d51c24c166352d9d17caf8fb5603 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:51:59 -0700 Subject: [PATCH 217/345] fix(config_flow): a moved panel with no address to fall back to is refused "Continue anyway" after a failed FQDN registration stored the name and reported success. On every path but one that is the right trade: the flow falls back to the address it reached the panel by, which the anchor verified, so the entry lands somewhere usable. The moved-panel path has no such address. Its certificate names neither the FQDN nor anything reachable -- that is why the flow relaxed the name binding for its own probe -- so `_fall_back_to_the_bootstrap_address` had nothing to adopt and returned having changed nothing, and the flow then wrote the unserved name behind a `reconfigure_successful` banner. Every runtime connection would fail the hostname check the coordinator and the broker apply and cannot relax: the stranded entry this branch refuses for bare IP addresses, delivered as a success. It now re-shows the form with `ca_name_mismatch` and leaves the entry untouched, and the fallback's docstring no longer claims an invariant it cannot restore without an address. Also: an empty `getpeercert()` after a completed handshake now classifies as UNTRUSTED rather than NAME_MISMATCH. `CERT_REQUIRED` should make it unreachable, but NAME_MISMATCH is the verdict that unlocks the relaxed transport, and "no validated certificate in hand" is not evidence that anything holds a key the anchor signed. --- custom_components/span_panel/config_flow.py | 28 ++++++++++++ .../span_panel/config_flow_validation.py | 9 +++- tests/test_v2_config_flow_tls.py | 44 +++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/custom_components/span_panel/config_flow.py b/custom_components/span_panel/config_flow.py index e69e4f8c..03b32a4d 100644 --- a/custom_components/span_panel/config_flow.py +++ b/custom_components/span_panel/config_flow.py @@ -936,6 +936,13 @@ def _fall_back_to_the_bootstrap_address(self) -> None: so this is the point where an unusable name is traded for the address that got this far — and the invariant is restored, because the host being recorded is now one the anchor accepts. + + That restoration depends on there being an address to trade for. On the + moved-panel path there is not: the leaf names nothing this flow could + reach the panel by, so `_bootstrap_host` is None and this returns having + changed nothing. A caller must not read that as permission to store + `self.host` — see `async_step_reconfigure_fqdn_failed`, which refuses + rather than record a name the panel does not serve. """ if self._bootstrap_host is None: return @@ -1523,6 +1530,27 @@ async def async_step_reconfigure_fqdn_failed( that is what the next reconfigure needs in order to clean it up. """ if user_input is not None: + if not self._leaf_names_host and self._bootstrap_host is None: + # There is nothing to continue to. This is the moved-panel path: + # the certificate names neither the name asked for nor any + # address this flow could reach the panel by, and registration — + # the one thing that would have made the panel serve the name — + # is what just failed. Falling back has no address to fall back + # to, so continuing would store the unserved name and report + # success, stranding every later connection on the hostname + # check this flow relaxed only for itself. + _LOGGER.warning( + "Registration failed and panel %s serves a certificate naming neither %s " + "nor an address this flow reached it by, so there is no host to store. " + "Try the panel's .local name, which its certificate already covers", + self._get_reconfigure_entry().title, + self.host, + ) + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema({vol.Required(CONF_HOST, default=self.host or ""): str}), + errors={"base": "ca_name_mismatch"}, + ) # User chose to continue anyway — update host without FQDN registration self._fall_back_to_the_bootstrap_address() reconfigure_entry = self._get_reconfigure_entry() diff --git a/custom_components/span_panel/config_flow_validation.py b/custom_components/span_panel/config_flow_validation.py index a035dbcb..b421cee2 100644 --- a/custom_components/span_panel/config_flow_validation.py +++ b/custom_components/span_panel/config_flow_validation.py @@ -291,7 +291,14 @@ def _check() -> LeafVerdict: # The handshake completed under the pinned anchor, so the peer holds a # key that anchor signed. Only the name is left in question. - if peer and leaf_names_host(peer, host): + if not peer: + # `CERT_REQUIRED` should make this unreachable. It fails to + # UNTRUSTED rather than to NAME_MISMATCH because NAME_MISMATCH is + # the verdict that unlocks the relaxed transport, and "the handshake + # completed but no validated certificate came back" is not evidence + # that anything holds a key the anchor signed. + return LeafVerdict.UNTRUSTED + if leaf_names_host(peer, host): return LeafVerdict.TRUSTED return LeafVerdict.NAME_MISMATCH diff --git a/tests/test_v2_config_flow_tls.py b/tests/test_v2_config_flow_tls.py index 1951f325..72d9d289 100644 --- a/tests/test_v2_config_flow_tls.py +++ b/tests/test_v2_config_flow_tls.py @@ -983,3 +983,47 @@ def _closed_port() -> int: with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as sock: sock.bind(("127.0.0.1", 0)) return int(sock.getsockname()[1]) + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_a_moved_panel_whose_registration_fails_has_nothing_to_continue_to( + hass: HomeAssistant, moved_panel: Panel +) -> None: + """"Continue anyway" must not store a name the panel never started serving. + + On every other path there is a verified address to fall back to -- the one + the flow reached the panel by. On this one there is not: the certificate + names neither the FQDN nor anything reachable, which is why the flow relaxed + the name binding for its own probe in the first place, and registration was + the single thing that would have fixed that. It failed. + + Falling back therefore has nowhere to fall back to, and continuing would + write the unserved name and report success -- stranding every runtime + connection on the hostname check, which the coordinator and the broker apply + and cannot relax. The entry is left exactly as it was. + """ + entry = _pinned_entry(hass, moved_panel) + + with ( + # The panel accepts the call and regenerates nothing, so its leaf still + # names the address it no longer has. + patch("custom_components.span_panel.config_flow.register_fqdn", new=AsyncMock()), + patch("custom_components.span_panel.config_flow.asyncio.sleep", new=AsyncMock()), + ): + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: PANEL_FQDN} + ) + result = await _finish_progress(hass, result) + assert result["step_id"] == "reconfigure_fqdn_failed", result + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM, result + assert result["errors"] == {"base": "ca_name_mismatch"} + # Untouched: the entry still points where it did, and is still pinned. + assert entry.data[CONF_HOST] == PANEL_LOOPBACK + assert CONF_REGISTERED_FQDN not in entry.data + assert entry.data[CONF_PANEL_CA_PEM] == moved_panel.ca_pem From cf23c30bfd0e7b8ddf71de5b299d09c9d3a00585 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:55:46 -0700 Subject: [PATCH 218/345] test(config_flow): prove the refusal form is not a dead end The Finding A fix returns a `reconfigure` form from inside `async_step_reconfigure_fqdn_failed`, which is only correct if submitting it re-enters `async_step_reconfigure` and gets judged again. Asserted rather than assumed: the test now submits the form it lands on and confirms the flow is still live and the entry still untouched. --- tests/test_v2_config_flow_tls.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_v2_config_flow_tls.py b/tests/test_v2_config_flow_tls.py index 72d9d289..06b3e046 100644 --- a/tests/test_v2_config_flow_tls.py +++ b/tests/test_v2_config_flow_tls.py @@ -1022,8 +1022,19 @@ async def test_a_moved_panel_whose_registration_fails_has_nothing_to_continue_to await hass.async_block_till_done() assert result["type"] == FlowResultType.FORM, result + assert result["step_id"] == "reconfigure" assert result["errors"] == {"base": "ca_name_mismatch"} # Untouched: the entry still points where it did, and is still pinned. assert entry.data[CONF_HOST] == PANEL_LOOPBACK assert CONF_REGISTERED_FQDN not in entry.data assert entry.data[CONF_PANEL_CA_PEM] == moved_panel.ca_pem + + # And the form it lands on is live, not a dead end: submitting it re-enters + # the reconfigure step and is judged again, rather than wedging the flow. + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: PANEL_LOOPBACK} + ) + assert result["type"] == FlowResultType.FORM, result + assert result["step_id"] == "reconfigure" + assert result["errors"] == {"base": "ca_name_mismatch"} + assert entry.data[CONF_HOST] == PANEL_LOOPBACK From 4e805e72849be059d62443bfe945068d7c0888f6 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:01:32 -0700 Subject: [PATCH 219/345] docs: make the security section guidance, not narrative The section had grown to explain its own reasoning inline, so a reader looking for what to do worked through why each choice was made to find it. The four situation-and-remedy paragraphs -- hostname setup, the mDNS move guard, Reconfigure, and an entry with no anchor -- are a table now, which leaves nowhere to put a justifying clause. 68 lines to 30. The reasoning is kept, in developer.md under "Why the CA pinning behaves as it does": why no fingerprint is offered at first contact, why diagnostics carry the fingerprint and not the certificate, why an unreachable panel does not stop setup, why reauth keeps the anchor it acquires, the difference between pinning on screen and pinning during a reload, and why a registered domain is checked when the panel reports it. Corrections along the way: - The deployment bullet named `tcp/80` for the REST bootstrap. A pinned entry uses `tcp/443`; `tcp/80` is the plaintext CA fetch, and REST only until the entry pins. Following it literally would have blocked every REST call the moment pinning succeeded. - "The changeover is designed to be seamless.**" had a closing bold marker with no opener, so it rendered literally. - The `r202633` sentence was never closed and "after the firmware hits" dangled. - "differ in two:" introduced four bullets, one of which is not a difference. - The prerequisite read as a floor; 2.0.8 is a required stepping stone. - Install step 8 said "IP address or .local address". - One `behavior` against the document's British spelling. The adopted-device and adopted-vendor-reading sections move below the entity tables. They had been sitting between the Microgrid Interconnect Device and Power Sensor Attributes, so a reader working down the tables hit fifty lines of vendor-extensibility prose and landed back in tables. developer.md's Supervisor section pointed at a README paragraph that no longer exists; the reference is dropped. --- README.md | 247 +++++++++++++++++++++------------------------------ developer.md | 32 ++++++- 2 files changed, 131 insertions(+), 148 deletions(-) diff --git a/README.md b/README.md index aefe18ac..2b895663 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ The integration includes a built-in dashboard accessible from the Home Assistant monitoring with configurable alerts, and circuit settings for relays and load shedding. See [Frontend Dashboard](frontend.md) for details. You can optionally use the [span-card](https://github.com/SpanPanel/span-card) Lovelace card for visualization and switch control. -The [SPAN Panel Simulator](https://github.com/SpanPanel/simulator) HA App lets you clone your panel's circuit layout for testing, or model an upgrade to +The [SPAN Panel PanelBench](https://github.com/SpanPanel/panelbench) HA App lets you clone your panel's circuit layout for testing, or model an upgrade to evaluate firmware or integration changes in a sandbox before applying them to your real panel. This integration communicates with the SPAN Panel over your local network using SPAN's official @@ -46,23 +46,23 @@ real-time state updates without polling. ## ⚠️ Backup and Upgrade to v2.1.x before your panel's firmware updates, or the integration will stop working (upgrade only from v2.0.8!) -**SPAN firmware `r202633` changes the API in a non-compatible way after the firmware hits** When your panel takes that update, 2.0.8 stops being able to read -it. The integration still connects, still shows as loaded, and reports every circuit as missing — sensors go unavailable, automations stop firing, dashboards go -blank. It does not fail loudly. It goes quiet. +**SPAN firmware `r202633` changes the API in a way that is not backward compatible.** When your panel takes that update, 2.0.8 stops being able to read it. The +integration still connects, still shows as loaded, and reports every circuit as missing — sensors go unavailable, automations stop firing, dashboards go blank. +It does not fail loudly. It goes quiet. **Nobody outside SPAN knows when your panel will update, and you cannot defer it from Home Assistant.** Panels update on SPAN's timing. There is no schedule to -plan around, which is why the safe move is to be on 2.1.x already rather than to wait for a signal that is less appealing. +plan around, which is why the safe move is to be on 2.1.x already rather than to wait for the signal, which is your panel going quiet. **Upgrading first costs you nothing.** On your current firmware, 2.1.x reads your panel exactly as 2.0.8 does. -The changeover is designed to be seamless.** The integration detects the new format on the wire, reloads itself, and carries on: +**The changeover is designed to be seamless.** The integration detects the new format on the wire, reloads itself, and carries on: -- no re-pairing, no re-authentication. +- No re-pairing, no re-authentication. - Entity ids, unique ids and long-term statistics survive. Dashboards, automations and history follow. - New entities appear because the new firmware genuinely publishes more. Nothing you already had is removed. Upgrade afterwards instead and you reach the same place — after however long it takes you to notice, and to work out that a firmware update is the reason your -panel went silent. Worst case should be a reload. +panel went silent. The worst case is a reload. Take another backup after the upgrade. @@ -81,7 +81,7 @@ The old format is retired in the same update that introduces the new one — the - [Home Assistant](https://www.home-assistant.io/) installed - [HACS](https://hacs.xyz/) installed - SPAN Panel with firmware `spanos2/r202603/05` or later -- SPAN Panel integration v1.3.0 or later +- SPAN Panel integration v2.0.8 specifically, if you are upgrading to 2.1.x - Panel passphrase (found via the SPAN app) **or** physical access to the panel door ## Installation @@ -93,11 +93,11 @@ The old format is retired in the same update that introduces the new one — the 5. Restart Home Assistant (you will be prompted by a repair notification) 6. Go to `Settings` > `Devices & Services` 7. Click `+ Add Integration` and search for "Span" -8. Enter the IP address of your SPAN Panel +8. Pick your panel from the discovered list, or enter its IP address or `.local` name (both shown in the mobile app) 9. The integration detects the panel as v2 and presents an authentication choice: - **Enter Panel Passphrase** — type the passphrase found in the SPAN mobile app under On-premise settings - **Proof of Proximity** — open and close the panel door 3 times, then click Submit -10. Choose your entity naming pattern +10. Choose your entity ID naming pattern (see naming patterns below) 11. Optionally adjust the snapshot update interval — 0 is real-time, up to 15 seconds based on CPU ### Upgrade Process @@ -258,57 +258,6 @@ can island — so **Grid Islandable** reads `Off` and **Grid Forming Entity** re `DSM Grid State` keeps its entity id and history but is no longer inferred from the battery or the dominant power source; it now reads the islanding state the MID actually senses. -### Adopted Devices - -The eBus schema is vendor-extensible, so your panel can publish a device type this integration has never modelled. Rather than ignoring it, the integration -gives it a card of its own hanging off the panel, carrying whatever identity it publishes, with its readings as entities beneath it. - -Everything adopted arrives **disabled and diagnostic**, so nothing reaches a dashboard uninvited, and the new-entity notification names the device so you can -find it. A property the device accepts writes to becomes a control rather than a reading — a `boolean` becomes a switch, an enumeration becomes a select, a -number becomes a number entity — and those arrive switched off too. - -Two things worth knowing before you build on one: - -- **Nothing adopted enters long-term statistics.** No adopted entity carries a `state_class`, because the correct one is not published on the wire and guessing - wrong writes corrupt statistics that fixing the panel afterwards does not repair. If you want statistics from an adopted reading, wrap it in a template - sensor, a Riemann-sum integration or a utility meter — a deliberate choice on an entity you enabled. -- **A new property on a device this integration already models is adopted too**, but as a reading on that device's existing card rather than as a device of its - own. See [Adopted Vendor Readings](#adopted-vendor-readings) below. - -### Adopted Vendor Readings - -The other half of vendor extensibility. A publisher can add a property to a device this integration already models — the battery, a charger, the solar inverter, -a circuit or the panel itself — and until 2.1.x that reading went nowhere: it appeared in the diagnostics download and in no entity list. It now becomes an -entity on that device's own card. The wire already says which device and node it belongs to, so it has a home; what it does not say is how important it is. - -They behave like adopted devices in the ways that matter, and differ in two: - -- **They arrive switched off and filed as diagnostics**, so nothing lands on a dashboard uninvited. The new-entity notification names each one, up to five per - device; beyond that it gives the device and a count instead, because fifteen new vendor readings at once would otherwise cost you the curated additions in the - same message. -- **They are readings only — never switches, selects or number boxes**, even where the panel says the property accepts writes. These sit beside curated controls - that do real work, such as the charge limit that refuses a value above what your charger was commissioned for, and a generic control would sit there with none - of that. If a control is worth having, it arrives curated in a release. -- **They keep the panel's own wording**, so a vendor property on the battery reads `Battery 2 Cell Temperature` rather than something tidied up. Deliberately - plainer than a curated entity's name: it is how you tell at a glance which entities this integration designed and which it is passing through. -- **Nothing adopted enters long-term statistics**, exactly as for adopted devices, and here it buys something extra: with no statistics behind them, a later - release can correct one of these entities' units, device class or category with nothing to repair. - -**What the delete button does**, since it is not quite what you would expect: - -- Delete one while your panel is still publishing that property and it comes back — switched off — at the next reload. There is no setting to suppress it, - because leaving it switched off is already that. -- Delete one after your panel has stopped publishing it and it stays gone, because nothing exists to recreate it from. - -So deletion means "hide it until next time" for a live reading and "clear it out" for a dead one, and your panel decides which. A property your panel stops -publishing is left in place reading unknown rather than removed: silence on the wire does not distinguish a property that is gone from one that has not arrived -yet, and deleting your entity on a guess is not something an upgrade should do. - -**These entities are permanent in id, not in identity.** If one of these readings is later curated properly, the curated entity is a new entity with its own id -and its own history — the adopted one is not renamed into it. That is the trade for surfacing a reading the moment it appears rather than waiting for a release -to model it, and it is why a vendor reading you have come to depend on is worth mentioning in an issue: curation is what turns it into something with a real -name, a proper category and statistics. - ### Power Sensor Attributes Applies to Current Power, Feed Through Power, Battery Power, PV Power, Grid Power, and Site Power sensors. @@ -505,6 +454,54 @@ Applies to Main Meter and Feed Through energy sensors. | ---------------------------- | ------ | ----------------------------------------------------------------- | | GFE Override: Grid Connected | Button | Tell the panel the grid is up when BESS communication interrupted | +### Adopted Devices + +The eBus schema is vendor-extensible, so your panel can publish a device type this integration has never modelled. Rather than ignoring it, the integration +gives it a card of its own hanging off the panel, carrying whatever identity it publishes, with its readings as entities beneath it. + +Everything adopted arrives **disabled and diagnostic**, so nothing reaches a dashboard uninvited, and the new-entity notification names the device so you can +find it. A property the device accepts writes to becomes a control rather than a reading — a `boolean` becomes a switch, an enumeration becomes a select, a +number becomes a number entity — and those arrive switched off too. + +Two things worth knowing before you build on one: + +- **Nothing adopted enters long-term statistics.** No adopted entity carries a `state_class`, because the correct one is not published on the wire and guessing + wrong writes corrupt statistics that fixing the panel afterwards does not repair. If you want statistics from an adopted reading, wrap it in a template + sensor, a Riemann-sum integration or a utility meter — a deliberate choice on an entity you enabled. +- **A new property on a device this integration already models is adopted too**, but as a reading on that device's existing card rather than as a device of its + own. See [Adopted Vendor Readings](#adopted-vendor-readings) below. + +### Adopted Vendor Readings + +The other half of vendor extensibility. A publisher can add a property to a device this integration already models — the battery, a charger, the solar inverter, +a circuit or the panel itself — and until 2.1.x that reading went nowhere: it appeared in the diagnostics download and in no entity list. It now becomes an +entity on that device's own card. The wire already says which device and node it belongs to, so it has a home; what it does not say is how important it is. + +They behave like adopted devices in the ways that matter, and differ as follows: + +- **They arrive switched off and filed as diagnostics**, so nothing lands on a dashboard uninvited. The new-entity notification names each one, up to five per + device; beyond that it gives the device and a count instead, because fifteen new vendor readings at once would otherwise cost you the curated additions in the + same message. +- **They are readings only — never switches, selects or number boxes**, even where the panel says the property accepts writes. These sit beside curated controls + that do real work, such as the charge limit that refuses a value above what your charger was commissioned for, and a generic control would sit there with none + of that. If a control is worth having, it arrives curated in a release. +- **They keep the panel's own wording**, so a vendor property on the battery reads `Battery 2 Cell Temperature` rather than something tidied up. Deliberately + plainer than a curated entity's name: it is how you tell at a glance which entities this integration designed and which it is passing through. +- **What the delete button does**, since it is not quite what you would expect: + + - Delete one while your panel is still publishing that property and it comes back — switched off — at the next reload. There is no setting to suppress it, + because leaving it switched off is already that. + - Delete one after your panel has stopped publishing it and it stays gone, because nothing exists to recreate it from. + +So deletion means "hide it until next time" for a live reading and "clear it out" for a dead one, and your panel decides which. A property your panel stops +publishing is left in place reading unknown rather than removed: silence on the wire does not distinguish a property that is gone from one that has not arrived +yet, and deleting your entity on a guess is not something an upgrade should do. + +**These entities are permanent in id, not in identity.** If one of these readings is later curated properly (delivered as an official part of the integration), +the curated entity is a new entity with its own id and its own history — the adopted one is not renamed into it. That is the trade for surfacing a reading the +moment it appears rather than waiting for a release to model it, and it is why a vendor reading you have come to depend on is worth raising in an issue: +curation is what turns it into something with a real name, a proper category and statistics. + ### BESS & Grid Management This section explains how the SPAN panel manages power sources and load shedding when a Battery Energy Storage System (BESS) is installed, and what the @@ -569,7 +566,7 @@ even after the grid is restored. Manual confirmation or an external sensor is re When `bess_connected` returns to on, no action is needed — firmware resumes normal GFE management automatically. -For a detailed discussion of failure scenarios, the MID topology, generator and non-integrated BESS behavior, and `/set` risk analysis, see +For a detailed discussion of failure scenarios, the MID topology, generator and non-integrated BESS behaviour, and `/set` risk analysis, see [BESS & Grid Management Deep Dive](bess-grid-management.md). ## Configuration Options @@ -632,18 +629,12 @@ A dip is compensated as soon as it is seen, but not believed straight away. A co so a reading that drops and then returns to where it was is a transport artifact rather than a reset, and its offset is taken back. The offset stays provisional until a later reading either disproves it (the counter comes back) or corroborates it (the counter climbs from the new, lower base). -**Corroboration is evidence, not a verdict.** A debounced burst can deliver two stale samples with the later one higher, which looks exactly like a counter -climbing away from a fresh base, so a corroborated dip stays provisional for three further readings; a return to the original baseline inside that window still -takes the whole offset back. Once the window is spent the offset is final. A second dip arriving inside another's window is judged against its own baseline -rather than folded into the older one, so each is retracted or kept on its own evidence. - **The persistent notification therefore lists a dip once it settles — when that window closes — and a dip that is disproved produces no notification at all** — + the sensor was compensated the whole time and nothing needs your attention. Reporting on corroboration alone left a notice standing for an event the next reading undid, which a persistent notification cannot take back the way the offset can. Seeing no notification after a momentary dip is the feature working, not failing. -Both the provisional record and its remaining window survive a restart, so restarting neither loses a retraction nor renews the window it had left. - **Diagnostic attributes** (visible when compensation is active): | Attribute | Description | @@ -663,71 +654,33 @@ and select your preferred precision from the "Display Precision" menu. ## Security -There are limits to what the integration can enforce. Anything that already holds the eBus MQTT broker password — including another integration running in the -same Home Assistant process — talks to the panel directly and is not subject to Home Assistant's permission model at all. - -**What pinning the panel's certificate authority does and does not buy you.** The authority is fetched over your local network on a connection that has nothing -to verify itself against — it is the anchor everything else is checked against. So: - -- Anyone merely **listening** on your network cannot read your passphrase or the credentials the panel returns for it. -- A device **actively standing between** Home Assistant and your panel at that first fetch could answer with an authority of its own, sign a certificate with - it, and still see them. Pinning cannot detect that on its own. - -Comparing the fingerprint against another source is what closes the second case. At first contact there is nothing to compare against: SPAN does not publish the -value, so the question could only be answered by pressing Submit. The fingerprint is put where it can actually be used instead — diagnostics report it under -`panel_ca`, it is logged at setup, and another install of this integration on the same panel reports the same value. - -After the first pin, nothing can change the authority without stopping and asking you — there is a prior value to compare against. See the -certificate-authority-changed entry in Troubleshooting. - -**Re-authenticating an entry that has no anchor acquires one first.** Reauth is a registration, so it carries the passphrase out and the broker password back — -the same exchange setup performs. An entry that arrived from before pinning, or one whose stored authority will no longer load, goes through the -certificate-authority step before either sign-in method is offered, and keeps the anchor afterwards instead of falling back to a plaintext fetch on every -connection. - -**Setting a panel up by hostname works, and the hostname is verified.** A panel's certificate names the addresses it already knows itself by; a domain joins -that list only when the integration asks the panel to regenerate its certificate, which happens after you have authenticated. Everything before that runs -against the address the certificate already names, hostname verification never relaxed, and the domain is checked once the panel reports the new certificate. - -**A pinned entry follows its panel to a new address only when the new address proves itself.** An entry moves when your panel announces itself over mDNS at a -new address, and when you add the same panel again by hand. Both used to be decided by a serial read from an unauthenticated endpoint, which anything on your -network can claim. The entry now asks whether the candidate serves a certificate its own anchor validates, on the port the entry uses, and refuses the move — -logging at `WARNING` — otherwise. If the panel really has moved, use **Reconfigure** on the entry. - -**Reconfigure tells a moved panel apart from an impersonated one.** A panel that changed address serves a perfectly good certificate that does not yet name -where it now answers, and something impersonating your panel serves one that chains to nothing — the same failure, until you ask which. Reconfigure asks. A host -that does not chain to the pinned authority is refused as before, and one that does not answer at all is now reported as unreachable rather than as an unsigned -certificate. A host that chains but is not named is your panel, and the way through is to reconfigure to a **fully qualified domain name**, which asks the panel -to regenerate its certificate around that name, or to the panel's **`.local` name**, which its certificate already covers. Reconfiguring to a bare new IP -address the certificate does not name is refused, because the entry would then be pointed at an address every later connection rejects. - -**Panels configured before this release are pinned differently, and are not risk-free until they are.** They are pinned on the first startup that reaches the -panel, logged at `WARNING` with the fingerprint so you can find the value afterwards. What was fetched is checked against the certificate the panel serves on -the broker port — the connection the anchor is actually used for — and an authority that signs nothing the panel serves is not stored. Until a pin succeeds, the -connection to the broker still authenticates with your panel's password while the authority is re-fetched over plaintext HTTP on every connection and whatever -answers is trusted — the substitution pinning exists to close. +**The integration cannot enforce much on its own.** Anything holding the eBus broker password — including another integration in the same Home Assistant process +— talks to the panel directly, outside Home Assistant's permission model. -If the panel is unreachable the integration starts anyway and retries on the next startup. That is deliberate. The exposure in the meantime is exactly the one -the entry already had before this release, and refusing to start would not remove it — it would only remove the integration, leaving the credential no safer -while guaranteeing an outage. Retrying closes it at the first opportunity instead. +**What pinning the panel's certificate authority buys.** The authority is fetched over your network on a connection with nothing to verify itself against, +because it _is_ the anchor everything else is checked against: -The same is true of an entry whose broker port does not serve a certificate the panel's own authority signs — a proxy terminating TLS with a certificate of its -own, say. It stays unpinned and logs that warning at every start, and no repair is raised for it. +- A **listener** cannot read your passphrase or the credentials the panel returns for it. +- A device **actively in the path at that first fetch** can answer with an authority of its own and read both. Pinning alone cannot detect this. -The reverse arrangement has the opposite cost. Where a proxy terminates only port 443, the panel's own authority still signs the broker's certificate, so the -entry pins at the reload — and the anchor is then also what every REST call to the panel is verified against. **Reauthenticate**, **Reconfigure** and **Rotate -credentials** all refuse, rather than send your access token unencrypted, until port 443 serves the panel's own certificate too. +Comparing the fingerprint against another source closes the second case. It is in diagnostics under `panel_ca`, in the setup log, and reported identically by +another install of this integration on the same panel. After the first pin, any change stops the integration and raises a repair — see Troubleshooting. -**Reauthenticate is the route that does this on screen.** An unpinned entry sent through **Reauthenticate** goes to the certificate-authority step first, which -asks for the TLS port where the HTTP port has been moved — the install most likely to be behind a proxy — and refuses with an error if the authority does not -sign what the panel serves. Reconfiguring the entry to the panel's own address is the other way through, but the pin then happens during the reload that -follows, silently: the only announcement is a `WARNING` reading "Pinned the CA advertised by SPAN panel …" with the fingerprint. +| Situation | What happens | +| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Setting up by hostname | Verified, never relaxed. A domain joins the certificate's SAN only after you authenticate; everything before that runs against an address the certificate already names. | +| Panel announces a new address, or you re-add it | The entry moves only if the candidate serves a certificate its own anchor validates. Otherwise the move is refused and logged at `WARNING`. | +| The panel really has moved | Use **Reconfigure**. A host that does not chain is refused, one that does not answer is reported unreachable, and one that chains but is not named is your panel — move it to an **FQDN** (the panel regenerates its certificate around that name) or the panel's **`.local` name** (already covered). A bare new IP the certificate does not name is refused. | +| The entry has no anchor, or a stored one won't load | **Reauthenticate** acquires one before either sign-in method is offered. | -Diagnostics report the fingerprint under `panel_ca`. The certificate itself is not included — it is public, but multi-KB, and the fingerprint is the part worth -reading. +**Entries from before pinning** pin at the first startup that reaches the panel, logged at `WARNING` with the fingerprint. Until that succeeds the authority is +re-fetched over plaintext on every connection and whatever answers is trusted. If the panel is unreachable the integration starts anyway and retries, because +refusing to start would remove the integration without making the credential any safer. -If your panel serves TLS somewhere other than port 443 — behind a reverse proxy, say — the setup flow asks for the port, but only when you have already changed -the HTTP port from 80. +**Proxies cut both ways.** One terminating the broker port with a certificate of its own leaves the entry unpinned, warning at every start with no repair +raised. One terminating only port 443 lets the entry pin, but **Reauthenticate**, **Reconfigure** and **Rotate credentials** then refuse rather than send your +access token unencrypted, until port 443 serves the panel's own certificate too. If your panel serves TLS on another port the setup flow asks — but only once +you have moved the HTTP port off 80. ### Restricting who can operate the panel @@ -769,13 +722,13 @@ Every command is also logged at `INFO`. Commands report one of four outcomes, and the distinctions matter: -| Outcome | Meaning | -| ------------- | ---------------------------------------------------------------------------------------- | -| `confirmed` | The panel reported the value you asked for. | -| `accepted` | The broker acknowledged the message and the panel did not report a change. | -| `unconfirmed` | Nothing came back within the deadline. **Not an error** — see the troubleshooting entry. | -| `failed` | The command was never handed to the broker and will not be delivered. | -| `refused:…` | This integration refused it, for the named reason. | +| Outcome | Meaning | +| ------------- | --------------------------------------------------------------------------------------- | +| `confirmed` | The panel reported the value you asked for. | +| `accepted` | The broker acknowledged the message and the panel did not report a change. | +| `unconfirmed` | Nothing came back within the deadline.**Not an error** — see the troubleshooting entry. | +| `failed` | The command was never handed to the broker and will not be delivered. | +| `refused:…` | This integration refused it, for the named reason. | ### Rotating panel credentials @@ -812,15 +765,15 @@ the one that works. The action reports the failure and says so; check the log an The panel credential is a single all-or-nothing secret, and anyone standing at the panel can mint a fresh one with three presses of the door switch. Network topology and physical control of the panel are the real boundary; everything above is defense in depth behind it. -- **Put the panel on its own VLAN**, with default-deny between VLANs, and allow only the Home Assistant host to reach it. The integration currently uses - `tcp/80` for the REST bootstrap and `tcp/8883` for MQTTS. Deny `tcp/9001` and `tcp/9002` (plaintext and WebSocket MQTT) from every source unless you are +- **Put the panel on a trusted VLAN**, with default-deny between VLANs, and allow only the Home Assistant host to reach it. Open `tcp/443` (REST), `tcp/8883` + (MQTTS) and `tcp/80` (the plaintext certificate-authority fetch, and REST too until the entry pins), and deny `tcp/9001` and `tcp/9002` unless you are actively using the SPAN Home on-premise UI. -- **Use the IP address or an FQDN, not the `.local` name.** mDNS does not cross VLAN boundaries. The panel's IP is in its certificate SAN, so hostname - verification still works; for an FQDN, the integration registers it with the panel so the panel adds it to the SAN. -- **If Home Assistant cannot be on the panel's VLAN**, put a reverse proxy on the panel's VLAN and restrict its inbound to the Home Assistant host. The proxy - holds no panel credential; it only relays. Note that MQTTS is a TCP stream, not HTTP — a plain HTTP reverse proxy covers the REST port only, and `tcp/8883` - needs a stream proxy (HAProxy, nginx `stream`, or Caddy's `layer4` plugin). -- **Lock the panel enclosure.** The three-press proximity bypass hands out full credentials to anyone who can open the door; it is the equivalent of a printed +- **Use the IP address or an FQDN, not the `.local` name**, because mDNS does not cross VLAN boundaries. The panel's IP is already in its certificate SAN, and + for an FQDN the integration registers it so the panel adds it. +- **If Home Assistant cannot be on the panel's VLAN**, put a reverse proxy there and restrict its inbound to the Home Assistant host; it holds no panel + credential and only relays. MQTTS is a TCP stream rather than HTTP, so `tcp/8883` needs a stream proxy (HAProxy, nginx `stream`, or Caddy's `layer4` plugin) + while a plain HTTP proxy covers the REST port alone. +- **Secure the panel enclosure**, because the three-press proximity bypass hands full credentials to anyone who can open the door — the equivalent of a printed root password. This outranks every software control on this page. - **Put dashboard-only household members in Home Assistant's read-only group.** Home Assistant's default user policy grants every non-admin user control of every entity, which includes this integration's circuit switches and priority selects. @@ -844,11 +797,11 @@ See [WebSocket API Reference](websocket-api.md) for the full schema, response fo | **Replaced sub-device shows the old serial number** | After replacing a SPAN sub-device (Drive / EVSE, BESS, PV inverter), the device entry in Home Assistant keeps showing the previous hardware's serial number. The integration keys entities off the panel-assigned node identity, which is intentionally stable across hardware swaps so long-term history (e.g. lifetime charging kWh for a Drive) is preserved. The device-registry serial number, however, does not auto-refresh. | In**Settings → Devices & Services → Span Panel**, open the affected sub-device and delete it, then reload the integration (or restart Home Assistant). The device re-registers with the new serial number. Entity IDs and their recorded history are preserved. | | **Door sensor unavailable** | The SPAN API returns UNKNOWN if the cabinet door has not been operated recently. This is a defect in the SPAN API. | The integration reports the sensor as unavailable until a proper value arrives. Opening or closing the door publishes the correct state. The door is classified as a tamper sensor (`Detected` / `Clear`) to differentiate it from a normal entry door. | | **No switch on a circuit** | A circuit has no switch entity exposed in Home Assistant. | The circuit is configured in the SPAN App as one of the "Always on Circuits". The API does not permit user control of those circuits, so no switch is created. | -| **Reinstalling to change the entity ID style gives back the old entity IDs** | The naming style is chosen at install and cannot be changed from the options, so reinstalling looks like the way to switch. It is not: every entity returns with the entity ID it had before. | Home Assistant remembers a removed entity for **30 days**, keyed on its unique ID, and restores that record's entity ID — along with its name, area, labels and icon — as soon as an entity with the same unique ID appears again. This integration's unique IDs do not change with the naming style, so the remembered ID wins over the one the new style asks for. Either clear the leftover registry entries between removing and reinstalling, or wait out the 30 days and let Home Assistant discard them. A tool such as [ha-registry-clean](https://github.com/LegoTypes/ha-registry-clean) can do the clearing; it is a separate project, not part of this integration. Clearing also discards the names, areas and labels you had assigned. | +| **Reinstalling to change the entity ID style gives back the old entity IDs** | The naming style is chosen at install and cannot be changed from the options, so reinstalling looks like the way to switch. It is not: every entity returns with the entity ID it had before. | Home Assistant remembers a removed entity for**30 days**, keyed on its unique ID, and restores that record's entity ID — along with its name, area, labels and icon — as soon as an entity with the same unique ID appears again. This integration's unique IDs do not change with the naming style, so the remembered ID wins over the one the new style asks for. Either clear the leftover registry entries between removing and reinstalling, or wait out the 30 days and let Home Assistant discard them. A tool such as [ha-registry-clean](https://github.com/LegoTypes/ha-registry-clean) can do the clearing; it is a separate project, not part of this integration. Clearing also discards the names, areas and labels you had assigned. | | **Setup fails after downgrading the integration** | After installing an older release, the SPAN Panel config entry fails to set up and Home Assistant reports an unsupported configuration version. | The release that stopped storing the panel passphrase migrated the config entry to version 7. Home Assistant refuses to load a config entry whose version is newer than the installed integration understands, and there is no automatic downgrade. Reinstall the newer release, or restore a backup taken before the upgrade. Removing and re-adding the integration also works and preserves entity IDs, but needs the panel passphrase or physical access to the door again. | -| **"SPAN Panel certificate authority changed" repair** | The integration has stopped connecting and a repair reports two fingerprints: the one it pinned and the one the panel now advertises. Entities are unavailable. | The panel is presenting a different certificate authority than the one you accepted at setup. Two things look identical from here and only you can tell them apart: a firmware upgrade or a factory reset rotates the authority legitimately, and so does a device on your network standing in for your panel. **If you know why it changed**, open the repair, compare the new fingerprint, and accept it — that re-pins and reconnects. **If nothing should have changed**, do not accept. Check what else is on the panel's network segment first. The integration will not reconnect on its own and will not re-pin on its own, deliberately: retrying would mean waiting to succeed against whatever is answering. | +| **"SPAN Panel certificate authority changed" repair** | The integration has stopped connecting and a repair reports two fingerprints: the one it pinned and the one the panel now advertises. Entities are unavailable. | The panel is presenting a different certificate authority than the one you accepted at setup. Two things look identical from here and only you can tell them apart: a firmware upgrade or a factory reset rotates the authority legitimately, and so does a device on your network standing in for your panel.**If you know why it changed**, open the repair, compare the new fingerprint, and accept it — that re-pins and reconnects. **If nothing should have changed**, do not accept. Check what else is on the panel's network segment first. The integration will not reconnect on its own and will not re-pin on its own, deliberately: retrying would mean waiting to succeed against whatever is answering. | | **A circuit re-commissioned in the SPAN App has the wrong controls** | A circuit whose configuration changed in the SPAN App — made controllable, locked, or set to never back up — still has the controls it had before. A newly controllable circuit has no Breaker switch. A newly locked one still shows its switch, and operating it reports that the command was refused. | Reload the integration (**Settings → Devices & Services → Span Panel → ⋮ → Reload**). Which control entities exist is decided when the integration starts, from what the panel declares about each circuit at that moment; a change made afterwards is picked up at the next reload or restart. Reloading is safe — entity IDs, history, names and areas are preserved, because they are keyed on identifiers that do not change. Until you reload, a control the panel no longer accepts is refused rather than published: the command is not queued and the breaker does not move. | -| **A control reports `unconfirmed`** | The logbook or the `span_panel_control_command` event says a command was `unconfirmed`. Nothing appears broken. | **This is not an error.** It means the panel took the command and did not report a change within the deadline, and the most common reason by far is that there was no change to report — the relay was already open, the priority was already that value. It is also indistinguishable from a silent rejection by the panel, because SPAN's firmware sends no reason code; the integration reports what it observed rather than guessing. The two outcomes that do mean something went wrong are `failed`, which means the command was never sent, and `refused:…`, which means this integration refused it and names why. | +| **A control reports `unconfirmed`** | The logbook or the`span_panel_control_command` event says a command was `unconfirmed`. Nothing appears broken. | **This is not an error.** It means the panel took the command and did not report a change within the deadline, and the most common reason by far is that there was no change to report — the relay was already open, the priority was already that value. It is also indistinguishable from a silent rejection by the panel, because SPAN's firmware sends no reason code; the integration reports what it observed rather than guessing. The two outcomes that do mean something went wrong are `failed`, which means the command was never sent, and `refused:…`, which means this integration refused it and names why. | ## Development diff --git a/developer.md b/developer.md index d409fd3e..1e9bdb3f 100644 --- a/developer.md +++ b/developer.md @@ -793,6 +793,36 @@ rather than in `services`. Both halves of the check are real, which is why the h that core _deletes_ on unload, so the attribute is genuinely absent on an entry that has not finished setting up (hence `getattr` with a default), and what is there is whatever the owning integration put there, so `isinstance` is what says it is ours. AGENTS.md service point 6 names this helper. +## Why the CA pinning behaves as it does + +The README says what the pinning does; this is why each of those choices was made rather than the obvious alternative. All of it was written for the README and +moved here, because it answers "why is it like that" rather than "what do I do". + +**The fingerprint is not shown at first contact, on purpose.** Comparing it against another source is what closes the active-in-path case, and at first contact +there is nothing to compare against — SPAN does not publish the value, so the question could only ever be answered by pressing Submit. A dialog that asks a +question the user cannot answer trains them to dismiss it, which costs the one time it matters. So the value is put where it can actually be used instead: +diagnostics under `panel_ca`, the setup log, and any other install of this integration on the same panel. + +**Diagnostics carry the fingerprint and not the certificate.** The certificate is public, so withholding it buys no secrecy — it is omitted because it is +multi-KB and the fingerprint is the part anyone reads. + +**An unreachable panel does not stop setup.** The integration starts anyway and retries on the next startup. The exposure in the meantime is exactly the one the +entry already had before pinning existed, and refusing to start would not remove it — it would remove the integration, leaving the credential no safer while +guaranteeing an outage. Retrying closes the window at the first opportunity instead. + +**Reauth keeps the anchor it acquires.** An entry that predates pinning, or whose stored authority no longer loads, goes through the certificate-authority step +before either sign-in method is offered, and keeps the anchor afterwards rather than falling back to a plaintext fetch on every connection. Reauth is a +registration — it carries the passphrase out and the broker password back, the same exchange setup performs — so it is the one flow where acquiring an anchor +first is worth a step in front of the user. + +**Reauthenticate is the route that pins on screen; Reconfigure pins silently.** The certificate-authority step asks for the TLS port where the HTTP port has +been moved — the install most likely to be behind a proxy — and errors if the authority does not sign what the panel serves. Reconfiguring the entry to the +panel's own address reaches the same place, but the pin then happens during the reload that follows, announced only by a `WARNING` reading "Pinned the CA +advertised by SPAN panel …" with the fingerprint. + +**A registered domain is checked once the panel reports the new certificate**, not when registration is requested. The panel regenerates its certificate around +the FQDN asynchronously, so the flow polls for the name to appear in the SAN rather than assuming the call took effect. + ## The Supervisor discovery path is deliberately unguarded A pinned entry only follows a discovered host when that host serves a certificate the entry's anchor validates — `async_step_zeroconf` and the by-hand re-add @@ -801,7 +831,7 @@ both go through that check, because the serial they match on comes from an unaut `async_step_hassio` deliberately does not. A Supervisor discovery arrives over the authenticated Supervisor API from an add-on the user installed, and add-ons legitimately reallocate their own ports, so applying the stored-address guard would freeze the entry against its own add-on. The cost is stated rather than hidden: an add-on that already holds Supervisor privileges can move a pinned entry. If that trade is ever revisited, the guard is the same helper the other two -routes call, and README's Security section carries the user-facing note. +routes call. ## Linting and Type Checking From 4243ec3fd70862e8235e353f8935b24e982cf690 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:46:59 -0700 Subject: [PATCH 220/345] fix(repairs): tell the user when the panel is not at the configured address A pinned CA that still validates the panel's certificate, on a handshake that fails anyway, means one of two things the library used to conflate: an expired leaf, which fixes itself, and a certificate that does not name the configured host, which never will. 3.3.0 tells the two apart and reports the second on a callback of its own; this subscribes to it and puts the remedy in front of a person. `leaf_repairs.py` follows `ca_repairs` in shape and the `schema_repairs` reconciliation model in substance: not persistent, because the transport is alive and re-derives the condition on every attempt; WARNING, because nothing was intercepted and nothing refused; not fixable, because Reconfigure is the remedy, already refuses a host the leaf does not name, and a fix flow cannot hand off to a config flow. Subscribed before `connect()` rather than after it, unlike every other callback here. The library runs the diagnosis inside the first connect and a mismatch makes it raise, so setup raises ConfigEntryNotReady and the retry builds a new client -- a subscription after a successful connect would never see the case it exists for. Cleared on the coordinator's connection edge, which is the exact event the library re-arms its own once-per-outage signal on, and at setup beside the CA one. A CA change on the same entry supersedes it: only that finding carries a security decision, and pointing somebody at an address served by a certificate just refused is the wrong instruction. Two translation keys rather than one. A placeholder is substituted verbatim, so a certificate naming no address at all gets a description of its own instead of an English phrase passed through {leaf_names}. Requires span-panel-api 3.3.0, which is not yet on PyPI. --- CHANGELOG.md | 2 + custom_components/span_panel/__init__.py | 29 +- custom_components/span_panel/ca_repairs.py | 9 + custom_components/span_panel/coordinator.py | 10 + custom_components/span_panel/leaf_repairs.py | 138 +++++++ custom_components/span_panel/manifest.json | 2 +- custom_components/span_panel/strings.json | 8 + .../span_panel/translations/en.json | 8 + .../span_panel/translations/es.json | 8 + .../span_panel/translations/fr.json | 8 + .../span_panel/translations/ja.json | 8 + .../span_panel/translations/pt.json | 8 + pyproject.toml | 2 +- requirements_test.txt | 2 +- tests/test_leaf_name_mismatch_repair.py | 391 ++++++++++++++++++ uv.lock | 2 +- 16 files changed, 629 insertions(+), 6 deletions(-) create mode 100644 custom_components/span_panel/leaf_repairs.py create mode 100644 tests/test_leaf_name_mismatch_repair.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a1593f7f..b3eb08e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,6 +103,8 @@ This release requires Home Assistant 2026.8 to avoid a deprecated API — the ol added each circuit's whole lifetime counter to its offset. - **A panel this integration can no longer reach makes its entities unavailable instead of reporting 0 W** — a certificate authority that changes mid-session used to leave every power sensor reading zero and every energy sensor frozen, indefinitely and indistinguishably from a panel drawing no power. +- **A panel that has moved to another address now says so in Repairs**, naming the addresses its certificate does carry so you can point the entry at one with + Reconfigure, instead of retrying silently with every entity unavailable and one line a minute in the log. - **Recreate entity IDs proposes the ids your panel would produce now** (#252), in your installation's naming style, leaving unique ids and statistics untouched. - **The README described Battery Power's sign backwards**, since the sensor has always reported discharging as positive (#184) and only the documentation was diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py index 6b4c2c7c..a1085e86 100644 --- a/custom_components/span_panel/__init__.py +++ b/custom_components/span_panel/__init__.py @@ -21,7 +21,12 @@ ) from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.typing import ConfigType -from span_panel_api import SpanMqttClient, SpanPanelSnapshot, ca_fingerprint +from span_panel_api import ( + LeafNameMismatch, + SpanMqttClient, + SpanPanelSnapshot, + ca_fingerprint, +) from span_panel_api.exceptions import ( SpanPanelAPIError, SpanPanelAuthError, @@ -67,6 +72,7 @@ async_save_panel_settings as async_save_panel_settings, ) from .graph_horizon import GraphHorizonManager +from .leaf_repairs import async_clear_leaf_name_mismatch, async_raise_leaf_name_mismatch from .migrations import CURRENT_CONFIG_VERSION, async_migrate_entry # noqa: F401 from .notices import async_forget, async_restore from .options import SNAPSHOT_UPDATE_INTERVAL @@ -350,6 +356,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> # the one every other integration already shares. httpx_client=get_async_client(hass), ) + + # Before `connect()`, unlike every other subscription here, because + # the library runs the diagnosis this reports *inside* the first + # connect. Registered afterwards it would see nothing on the path it + # exists for: a mismatch makes `connect()` raise + # `SpanPanelConnectionError`, setup raises `ConfigEntryNotReady`, and + # the retry builds a new client -- so every attempt would fire the + # signal into a client with no subscriber and the user would keep + # getting the log line and nothing else. + @callback + def _on_leaf_name_mismatch(mismatch: LeafNameMismatch) -> None: + async_raise_leaf_name_mismatch(hass, entry, mismatch) + + entry.async_on_unload(client.register_leaf_mismatch_callback(_on_leaf_name_mismatch)) + try: await client.connect() except SpanPanelCAChangedError as err: @@ -384,8 +405,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> raise ConfigEntryNotReady(f"SPAN panel is not ready yet: {err}") from err # The connection got as far as a handshake under the current pin, so - # any standing Repair describes a state that no longer holds. + # any standing Repair describes a state that no longer holds. That + # covers the name mismatch too: the handshake that just succeeded is + # the one whose failure the mismatch explains, and it is the same + # event the library re-arms its own signal on. async_clear_ca_changed(hass, entry) + async_clear_leaf_name_mismatch(hass, entry) # The other half of the same condition: the reconnect loop runs # fire-and-forget, so a CA that changes mid-session cannot surface as diff --git a/custom_components/span_panel/ca_repairs.py b/custom_components/span_panel/ca_repairs.py index 5fe4a14e..7a8c37c6 100644 --- a/custom_components/span_panel/ca_repairs.py +++ b/custom_components/span_panel/ca_repairs.py @@ -25,6 +25,7 @@ from homeassistant.helpers import issue_registry as ir from .const import DOMAIN +from .leaf_repairs import async_clear_leaf_name_mismatch _LOGGER = logging.getLogger(__name__) @@ -52,7 +53,15 @@ def async_raise_ca_changed( `is_persistent` because the transport this describes is already dead. A non-persistent issue reloads as a tombstone, and this one has no live state to re-assert it from — the client that would have noticed is gone. + + Supersedes a standing name mismatch on the same entry, and does it here + rather than at each call site so no future one can forget. The two findings + come out of the same failed handshake and its diagnosis, so both can stand + against one panel; only this one carries a decision, and telling somebody to + re-point their configuration at an address served by a certificate this + integration has just refused to trust is the wrong instruction. """ + async_clear_leaf_name_mismatch(hass, entry) _LOGGER.error( "SPAN panel %s is advertising CA %s where %s was pinned. Not reconnecting: a " "rotated CA and an intercepted connection look identical from here. Confirm the " diff --git a/custom_components/span_panel/coordinator.py b/custom_components/span_panel/coordinator.py index 1a0241da..41461d79 100644 --- a/custom_components/span_panel/coordinator.py +++ b/custom_components/span_panel/coordinator.py @@ -37,6 +37,7 @@ detect_capabilities, ) from .id_builder import build_circuit_unique_id +from .leaf_repairs import async_clear_leaf_name_mismatch from .notices import async_raise, read_translations from .schema_repairs import async_sync_schema_issues from .schema_validation import SchemaFindings, evaluate_field_metadata @@ -459,10 +460,19 @@ def _on_connection_change(self, connected: bool) -> None: Listener fan-out is guarded by a real state change so a misbehaving or future-version library that re-emits the same edge does not trigger spurious entity re-renders. + + A connect also drops any standing name-mismatch Repair, and this edge is + the right place for it rather than a snapshot arriving: it is the exact + event the library re-arms its own once-per-outage signal on, so the + notice the user sees and the library's idea of whether it has reported + anything cannot drift apart. Raising is deliberately not done here -- + `async_setup_entry` holds the one subscription that raises, so a mismatch + is reported once however many things are listening. """ was_offline = self._panel_offline was_dead = self._transport_dead if connected: + async_clear_leaf_name_mismatch(self.hass, self.config_entry) self._mark_panel_online() # The one thing that disproves a dead transport: it connected. Not # folded into `_mark_panel_online`, which a successful snapshot also diff --git a/custom_components/span_panel/leaf_repairs.py b/custom_components/span_panel/leaf_repairs.py new file mode 100644 index 00000000..24180f19 --- /dev/null +++ b/custom_components/span_panel/leaf_repairs.py @@ -0,0 +1,138 @@ +"""The Repair raised when the panel's certificate does not name the configured host. + +The third outcome of the library's failed-handshake diagnosis, and the only one +that used to reach nobody. A pinned CA that still validates the panel's +certificate says the peer is the panel; a certificate that does not carry the +configured address says the panel is not where the entry claims it is. Most often +a new DHCP lease, sometimes an entry recorded under a name the panel does not +know itself by. + +Separate from `ca_repairs` because the two are reconciled on opposite terms, and +this one is the `schema_repairs` model rather than the `ca_repairs` one: + +- **Not persistent.** The transport is alive and still retrying. The library + re-derives this on every attempt and re-arms the signal on the next successful + connect, so there is live state to re-assert it from and a restart may sweep it + away. +- **Severity `WARNING`, not `ERROR`.** Nothing is being intercepted and nothing + has been refused -- the chain verified under the pin. The panel is simply + somewhere other than where the entry says. +- **Not fixable.** The remedy is Reconfigure, which already exists, already + refuses a host the leaf does not name, and already carries the FQDN + registration path. A Repairs fix flow cannot hand off to a config flow, so a + fixable Repair would have to re-implement a subset of Reconfigure -- host + entry, verdict, store -- and `async_panel_leaf_host` warns in as many words + that persisting a resolved address is where that subset goes wrong. One + implementation of "change the host" is the point; this Repair's job is to make + the condition visible and name the address to use. + +Two translation keys rather than one, because a certificate that names nothing +at all is a different sentence and not a different value. Home Assistant +substitutes `translation_placeholders` into the description verbatim, so a phrase +passed as a placeholder would reach every user in English no matter what language +they read the rest of the notice in; the empty case therefore gets a description +of its own, and the phrase is translated with everything else. It also reads as +what it is -- a fault in the panel rather than in the address the user chose. +""" + +from __future__ import annotations + +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir +from span_panel_api import LeafNameMismatch + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +LEAF_NAME_MISMATCH_ISSUE_PREFIX = "panel_leaf_name_mismatch_" + +_NAMED_TRANSLATION_KEY = "panel_leaf_name_mismatch" +_UNNAMED_TRANSLATION_KEY = "panel_leaf_name_mismatch_no_names" + + +def leaf_name_mismatch_issue_id(entry_id: str) -> str: + """Issue id for one entry's name mismatch.""" + return f"{LEAF_NAME_MISMATCH_ISSUE_PREFIX}{entry_id}" + + +@callback +def async_raise_leaf_name_mismatch( + hass: HomeAssistant, + entry: ConfigEntry, + mismatch: LeafNameMismatch, +) -> None: + """Raise the Repair, carrying the configured host and the names actually served. + + Both, because "the address is wrong" without saying which address is right + leaves the user exactly where the log line left them. The names come from the + certificate's SAN entries in certificate order, and one of them is what + Reconfigure wants. + + `is_persistent=False` because the condition is derived from a transport that + is still running: the library re-diagnoses it on every reconnect attempt and + re-arms the signal on the next successful connect, so a restart that resolves + nothing raises it again and a restart after the panel came back does not. + """ + issue_id = leaf_name_mismatch_issue_id(entry.entry_id) + if mismatch.leaf_names: + leaf_names = ", ".join(mismatch.leaf_names) + _LOGGER.warning( + "SPAN panel %s is configured as %s, but the certificate it serves names only " + "%s. The panel has probably moved. Use Reconfigure on the SPAN Panel entry to " + "point it at one of those names; the integration keeps retrying meanwhile", + entry.title, + mismatch.host, + leaf_names, + ) + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key=_NAMED_TRANSLATION_KEY, + translation_placeholders={ + "panel": entry.title, + "host": mismatch.host, + "leaf_names": leaf_names, + }, + ) + return + + _LOGGER.warning( + "SPAN panel %s is configured as %s, but the certificate it serves names no address " + "at all, so there is no address to re-point the entry at. The panel's certificate " + "needs regenerating; the integration keeps retrying meanwhile", + entry.title, + mismatch.host, + ) + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key=_UNNAMED_TRANSLATION_KEY, + translation_placeholders={ + "panel": entry.title, + "host": mismatch.host, + }, + ) + + +@callback +def async_clear_leaf_name_mismatch(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Drop the Repair. + + Cleared on any connection that succeeded under the current configuration -- + which is exactly when the library re-arms the signal, so the two never + disagree about whether the condition still holds -- and when a CA change + takes over the same entry, because that finding supersedes this one. + """ + ir.async_delete_issue(hass, DOMAIN, leaf_name_mismatch_issue_id(entry.entry_id)) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 7154cdc6..ea428a30 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -23,7 +23,7 @@ ], "quality_scale": "gold", "requirements": [ - "span-panel-api==3.2.0", + "span-panel-api==3.3.0", "span-panel-api-schema-0==1.1.2", "span-panel-api-schema-1==1.1.3" ], diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index 9adc0d95..645df518 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -900,6 +900,14 @@ "ca_leaf_mismatch": "The certificate this panel serves is not signed by the authority it published, so that authority has not been offered for you to accept and nothing has been changed. Check that the HTTPS port is correct and that nothing is intercepting the connection, then try again." } } + }, + "panel_leaf_name_mismatch": { + "title": "SPAN Panel is not at the configured address", + "description": "{panel} is configured as **{host}**, but the certificate it serves names only: **{leaf_names}**. The panel has probably moved, or the address was stored under a name the panel does not know itself by.\n\nUse *Reconfigure* on the SPAN Panel entry to point it at one of the names above. The integration keeps retrying meanwhile and will recover on its own if the panel returns to {host}." + }, + "panel_leaf_name_mismatch_no_names": { + "title": "SPAN Panel is not at the configured address", + "description": "{panel} is configured as **{host}**, but the certificate it serves names no address at all, so there is no address to point the entry at. That is a fault in the panel's certificate rather than in the address you configured, and it usually clears after the panel regenerates it — a restart of the panel is the usual way.\n\nIf it persists, use *Reconfigure* on the SPAN Panel entry to re-establish the connection. The integration keeps retrying meanwhile and will recover on its own if the panel starts naming {host} again." } } } diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index 9adc0d95..645df518 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -900,6 +900,14 @@ "ca_leaf_mismatch": "The certificate this panel serves is not signed by the authority it published, so that authority has not been offered for you to accept and nothing has been changed. Check that the HTTPS port is correct and that nothing is intercepting the connection, then try again." } } + }, + "panel_leaf_name_mismatch": { + "title": "SPAN Panel is not at the configured address", + "description": "{panel} is configured as **{host}**, but the certificate it serves names only: **{leaf_names}**. The panel has probably moved, or the address was stored under a name the panel does not know itself by.\n\nUse *Reconfigure* on the SPAN Panel entry to point it at one of the names above. The integration keeps retrying meanwhile and will recover on its own if the panel returns to {host}." + }, + "panel_leaf_name_mismatch_no_names": { + "title": "SPAN Panel is not at the configured address", + "description": "{panel} is configured as **{host}**, but the certificate it serves names no address at all, so there is no address to point the entry at. That is a fault in the panel's certificate rather than in the address you configured, and it usually clears after the panel regenerates it — a restart of the panel is the usual way.\n\nIf it persists, use *Reconfigure* on the SPAN Panel entry to re-establish the connection. The integration keeps retrying meanwhile and will recover on its own if the panel starts naming {host} again." } } } diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index aed70164..90d5e826 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -900,6 +900,14 @@ "ca_leaf_mismatch": "El certificado que sirve este panel no está firmado por la autoridad que ha publicado, por lo que esa autoridad no se ha ofrecido para su aceptación y no se ha cambiado nada. Compruebe que el puerto HTTPS es correcto y que nada intercepta la conexión, y vuelva a intentarlo." } } + }, + "panel_leaf_name_mismatch": { + "title": "El SPAN Panel no está en la dirección configurada", + "description": "{panel} está configurado como **{host}**, pero el certificado que sirve solo nombra: **{leaf_names}**. Es probable que el panel se haya movido, o que la dirección se guardara con un nombre por el que el panel no se conoce a sí mismo.\n\nUse *Reconfigurar* en la entrada del SPAN Panel para apuntarla a uno de los nombres anteriores. Mientras tanto, la integración sigue reintentando y se recuperará por sí sola si el panel vuelve a {host}." + }, + "panel_leaf_name_mismatch_no_names": { + "title": "El SPAN Panel no está en la dirección configurada", + "description": "{panel} está configurado como **{host}**, pero el certificado que sirve no nombra ninguna dirección, por lo que no hay ninguna dirección a la que apuntar la entrada. Se trata de un fallo del certificado del panel y no de la dirección que usted configuró, y suele resolverse cuando el panel lo regenera: lo habitual es reiniciar el panel.\n\nSi persiste, use *Reconfigurar* en la entrada del SPAN Panel para restablecer la conexión. Mientras tanto, la integración sigue reintentando y se recuperará por sí sola si el panel vuelve a nombrar {host}." } } } diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index 1c5b90de..488f01ff 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -900,6 +900,14 @@ "ca_leaf_mismatch": "Le certificat présenté par ce panneau n'est pas signé par l'autorité qu'il a publiée ; cette autorité ne vous a donc pas été proposée et rien n'a été modifié. Vérifiez que le port HTTPS est correct et que rien n'intercepte la connexion, puis réessayez." } } + }, + "panel_leaf_name_mismatch": { + "title": "Le SPAN Panel n'est pas à l'adresse configurée", + "description": "{panel} est configuré comme **{host}**, mais le certificat qu'il présente ne nomme que : **{leaf_names}**. Le panneau a probablement changé d'adresse, ou l'adresse a été enregistrée sous un nom que le panneau ne se connaît pas.\n\nUtilisez *Reconfigurer* sur l'entrée SPAN Panel pour la pointer vers l'un des noms ci-dessus. L'intégration continue de réessayer entre-temps et se rétablira d'elle-même si le panneau revient à {host}." + }, + "panel_leaf_name_mismatch_no_names": { + "title": "Le SPAN Panel n'est pas à l'adresse configurée", + "description": "{panel} est configuré comme **{host}**, mais le certificat qu'il présente ne nomme aucune adresse ; il n'y a donc aucune adresse vers laquelle pointer l'entrée. C'est un défaut du certificat du panneau et non de l'adresse que vous avez configurée, et cela se résout généralement lorsque le panneau le régénère — un redémarrage du panneau est la manière habituelle.\n\nSi cela persiste, utilisez *Reconfigurer* sur l'entrée SPAN Panel pour rétablir la connexion. L'intégration continue de réessayer entre-temps et se rétablira d'elle-même si le panneau nomme à nouveau {host}." } } } diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index eb12e4f7..670fbf55 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -900,6 +900,14 @@ "ca_leaf_mismatch": "このパネルが提供する証明書は、パネルが公開した認証局で署名されていません。そのため、その認証局は承認の対象として提示されず、何も変更されていません。HTTPS ポートが正しいこと、接続が傍受されていないことを確認してから、もう一度お試しください。" } } + }, + "panel_leaf_name_mismatch": { + "title": "SPAN Panel が設定されたアドレスにありません", + "description": "{panel} は **{host}** として設定されていますが、パネルが提供する証明書は **{leaf_names}** しか名前として持っていません。パネルのアドレスが変わったか、パネル自身が認識していない名前でアドレスが保存された可能性があります。\n\nSPAN Panel のエントリで *再構成* を使い、上記のいずれかの名前を指すように変更してください。その間も統合は再試行を続け、パネルが {host} に戻れば自動的に復旧します。" + }, + "panel_leaf_name_mismatch_no_names": { + "title": "SPAN Panel が設定されたアドレスにありません", + "description": "{panel} は **{host}** として設定されていますが、パネルが提供する証明書はアドレスをまったく名前として持っていないため、エントリを指し直す先がありません。これは設定したアドレスではなくパネルの証明書側の問題で、パネルが証明書を再生成すれば通常は解消します。パネルの再起動が一般的な方法です。\n\n解消しない場合は、SPAN Panel のエントリで *再構成* を使って接続をやり直してください。その間も統合は再試行を続け、パネルが再び {host} を名前として持つようになれば自動的に復旧します。" } } } diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index 8e14fca3..d97042ab 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -900,6 +900,14 @@ "ca_leaf_mismatch": "O certificado que este painel serve não está assinado pela autoridade que publicou, pelo que essa autoridade não foi proposta para aceitação e nada foi alterado. Verifique se a porta HTTPS está correta e se nada está a intercetar a ligação, e tente novamente." } } + }, + "panel_leaf_name_mismatch": { + "title": "O SPAN Panel não está no endereço configurado", + "description": "{panel} está configurado como **{host}**, mas o certificado que serve nomeia apenas: **{leaf_names}**. É provável que o painel tenha mudado de endereço, ou que o endereço tenha sido guardado com um nome pelo qual o painel não se conhece a si próprio.\n\nUse *Reconfigurar* na entrada do SPAN Panel para a apontar para um dos nomes acima. Entretanto, a integração continua a tentar e recupera sozinha se o painel voltar a {host}." + }, + "panel_leaf_name_mismatch_no_names": { + "title": "O SPAN Panel não está no endereço configurado", + "description": "{panel} está configurado como **{host}**, mas o certificado que serve não nomeia qualquer endereço, pelo que não há endereço nenhum para onde apontar a entrada. É uma falha do certificado do painel e não do endereço que configurou, e costuma resolver-se quando o painel o regenera — reiniciar o painel é a forma habitual.\n\nSe persistir, use *Reconfigurar* na entrada do SPAN Panel para restabelecer a ligação. Entretanto, a integração continua a tentar e recupera sozinha se o painel voltar a nomear {host}." } } } diff --git a/pyproject.toml b/pyproject.toml index 1003054a..51ad93b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ readme = "README.md" requires-python = ">=3.14.3,<3.15" dependencies = [ "homeassistant==2026.8.0", - "span-panel-api==3.2.0", + "span-panel-api==3.3.0", # 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, diff --git a/requirements_test.txt b/requirements_test.txt index 12589e57..c1b255d6 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -3,6 +3,6 @@ pytest-asyncio>=1.4.0 setuptools>=65.7.0 pytest-homeassistant-custom-component==0.13.354 homeassistant==2026.8.0 -span-panel-api==3.2.0 +span-panel-api==3.3.0 span-panel-api-schema-0==1.1.2 span-panel-api-schema-1==1.1.3 diff --git a/tests/test_leaf_name_mismatch_repair.py b/tests/test_leaf_name_mismatch_repair.py new file mode 100644 index 00000000..f3586302 --- /dev/null +++ b/tests/test_leaf_name_mismatch_repair.py @@ -0,0 +1,391 @@ +"""Tests for the Repair raised when the panel's certificate does not name the host. + +The condition this covers is the one the library used to report to nobody: a +handshake that fails against a pin the panel still advertises, because the +certificate underneath names an address the entry does not use. The user was left +with a minute-by-minute log warning, every entity unavailable, and nothing in the +UI at all. + +The wiring is the substance of it, so most of these tests are about *where* the +subscription happens rather than what it does with the signal. The library runs +the diagnosis inside `connect()`, and a mismatch makes `connect()` raise, so a +subscription placed after it -- the natural place, and where the fatal-error one +lives -- would never see the case it exists for. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +from homeassistant import config_entries +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import issue_registry as ir +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry +from span_panel_api import LeafNameMismatch +from span_panel_api.exceptions import SpanPanelConnectionError + +from custom_components.span_panel import async_setup_entry +from custom_components.span_panel.ca_repairs import async_raise_ca_changed, ca_changed_issue_id +from custom_components.span_panel.const import ( + CONF_API_VERSION, + CONF_EBUS_BROKER_HOST, + CONF_EBUS_BROKER_PASSWORD, + CONF_EBUS_BROKER_PORT, + CONF_EBUS_BROKER_USERNAME, + CONF_HTTP_PORT, + CONF_PANEL_CA_PEM, + DOMAIN, +) +from custom_components.span_panel.coordinator import SpanPanelCoordinator +from custom_components.span_panel.leaf_repairs import ( + async_clear_leaf_name_mismatch, + async_raise_leaf_name_mismatch, + leaf_name_mismatch_issue_id, +) + +from .factories import SpanPanelSnapshotFactory + +PEM = "-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n" + +_STRINGS = ( + Path(__file__).resolve().parent.parent / "custom_components" / "span_panel" / "strings.json" +) + +MOVED = LeafNameMismatch(host="192.168.1.100", leaf_names=("span-panel.local", "192.168.1.187")) +NAMELESS = LeafNameMismatch(host="192.168.1.100", leaf_names=()) + + +def _entry(hass: HomeAssistant) -> MockConfigEntry: + """Add a pinned v2 entry, the only shape this condition can arise on.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=7, + title="Span Panel", + data={ + CONF_HOST: "192.168.1.100", + CONF_ACCESS_TOKEN: "token", + CONF_API_VERSION: "v2", + CONF_PANEL_CA_PEM: PEM, + }, + source=config_entries.SOURCE_USER, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + return entry + + +def _setup_entry(hass: HomeAssistant) -> MockConfigEntry: + """Add an entry carrying everything `async_setup_entry` needs of a v2 panel.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_VERSION: "v2", + CONF_HOST: "192.168.1.100", + CONF_EBUS_BROKER_HOST: "span-panel.local", + CONF_EBUS_BROKER_USERNAME: "mqtt-user", + CONF_EBUS_BROKER_PASSWORD: "mqtt-pass", + CONF_EBUS_BROKER_PORT: 8883, + CONF_HTTP_PORT: 80, + }, + entry_id="entry-leaf", + title="sp3-leaf-001", + unique_id="sp3-leaf-001", + ) + entry.add_to_hass(hass) + return entry + + +def _issue(hass: HomeAssistant, entry: MockConfigEntry) -> ir.IssueEntry | None: + return ir.async_get(hass).async_get_issue(DOMAIN, leaf_name_mismatch_issue_id(entry.entry_id)) + + +# ---------- what the notice says ---------- + + +async def test_a_mismatch_names_the_addresses_the_panel_does_answer_to( + hass: HomeAssistant, +) -> None: + """Saying the address is wrong without saying which is right is the log line again.""" + entry = _entry(hass) + + async_raise_leaf_name_mismatch(hass, entry, MOVED) + + issue = _issue(hass, entry) + assert issue is not None + assert issue.translation_key == "panel_leaf_name_mismatch" + assert issue.translation_placeholders == { + "panel": "Span Panel", + "host": "192.168.1.100", + "leaf_names": "span-panel.local, 192.168.1.187", + } + + +async def test_the_notice_is_a_warning_the_user_cannot_click_through( + hass: HomeAssistant, +) -> None: + """Not fixable, because Reconfigure is the remedy and a fix flow cannot start one. + + Not persistent either, and not an error: the transport is alive and still + retrying, the chain verified under the pin, and nothing was refused. + """ + entry = _entry(hass) + + async_raise_leaf_name_mismatch(hass, entry, MOVED) + + issue = _issue(hass, entry) + assert issue is not None + assert issue.is_fixable is False + assert issue.is_persistent is False + assert issue.severity == ir.IssueSeverity.WARNING + + +async def test_a_certificate_naming_nothing_gets_its_own_description( + hass: HomeAssistant, +) -> None: + """The empty case is a different sentence, not an empty value. + + A placeholder is substituted verbatim, so a phrase passed as one would reach + a Spanish reader in English. It is also a different fault -- the panel's + certificate rather than the user's address -- and "names only: " followed by + nothing is not a sentence in any language. + """ + entry = _entry(hass) + + async_raise_leaf_name_mismatch(hass, entry, NAMELESS) + + issue = _issue(hass, entry) + assert issue is not None + assert issue.translation_key == "panel_leaf_name_mismatch_no_names" + assert issue.translation_placeholders == { + "panel": "Span Panel", + "host": "192.168.1.100", + } + + +async def test_both_descriptions_exist_and_use_only_the_placeholders_supplied( + hass: HomeAssistant, +) -> None: + """`strings.json` is the source of truth, and it has to match what is passed. + + A description referencing a placeholder nothing supplies renders the literal + `{leaf_names}` to the user, which is how the empty case would fail if it were + ever pointed at the other key. + """ + strings = json.loads(_STRINGS.read_text(encoding="utf-8")) + issues = strings["issues"] + entry = _entry(hass) + + for mismatch in (MOVED, NAMELESS): + async_raise_leaf_name_mismatch(hass, entry, mismatch) + issue = _issue(hass, entry) + assert issue is not None + assert issue.translation_placeholders is not None + block = issues[issue.translation_key] + assert block["title"] + block["description"].format(**issue.translation_placeholders) + + +# ---------- when it goes away ---------- + + +async def test_a_connection_clears_a_standing_notice(hass: HomeAssistant) -> None: + """The same edge the library re-arms its once-per-outage signal on. + + Cleared on the connection callback rather than on a snapshot so the two + cannot drift: if the notice outlived the re-arm, the next mismatch would fire + against an issue that is already standing and tell the user nothing new. + """ + entry = _entry(hass) + async_raise_leaf_name_mismatch(hass, entry, MOVED) + coordinator = SpanPanelCoordinator(hass, MagicMock(), entry) + + coordinator._on_connection_change(True) + + assert _issue(hass, entry) is None + + +async def test_a_disconnect_leaves_the_notice_standing(hass: HomeAssistant) -> None: + """Losing the connection is the condition, not its resolution.""" + entry = _entry(hass) + async_raise_leaf_name_mismatch(hass, entry, MOVED) + coordinator = SpanPanelCoordinator(hass, MagicMock(), entry) + + coordinator._on_connection_change(False) + + assert _issue(hass, entry) is not None + + +async def test_a_ca_change_takes_the_notice_over(hass: HomeAssistant) -> None: + """Two findings out of one handshake, and only one of them carries a decision. + + Telling somebody to re-point their configuration at an address served by a + certificate the integration has just refused to trust is the wrong + instruction, so the CA Repair does not stand beside this one. + """ + entry = _entry(hass) + async_raise_leaf_name_mismatch(hass, entry, MOVED) + + async_raise_ca_changed(hass, entry, "aa" * 32, "bb" * 32) + + assert _issue(hass, entry) is None + assert ( + ir.async_get(hass).async_get_issue(DOMAIN, ca_changed_issue_id(entry.entry_id)) is not None + ) + + +async def test_clearing_a_notice_that_was_never_raised_is_not_an_error( + hass: HomeAssistant, +) -> None: + """Setup and every connect call this, and almost every one has nothing to drop.""" + entry = _entry(hass) + + async_clear_leaf_name_mismatch(hass, entry) + + assert _issue(hass, entry) is None + + +# ---------- the wiring ---------- + + +def _diagnosing_client(mismatch: LeafNameMismatch | None) -> tuple[MagicMock, list[str]]: + """Build a client that diagnoses inside `connect()` and then raises, as the library does. + + The signal is fired from within `connect`, so a callback registered after it + returns -- or after it raises -- never sees it. That is the whole point of + the ordering under test, and a mock that fired it from anywhere else would + prove nothing. + + Returns the calls it saw, in order, so the ordering can be asserted directly + as well as through its consequence. + """ + client = MagicMock() + subscribers: list[Any] = [] + order: list[str] = [] + + def register(callback: Any) -> Any: + order.append("register") + subscribers.append(callback) + return MagicMock(name="unregister") + + async def connect() -> None: + order.append("connect") + for callback in subscribers: + if mismatch is not None: + callback(mismatch) + raise SpanPanelConnectionError("TLS handshake failed") + + client.register_leaf_mismatch_callback = MagicMock(side_effect=register) + client.connect = AsyncMock(side_effect=connect) + client.close = AsyncMock() + return client, order + + +async def test_a_mismatch_on_the_very_first_connect_still_reaches_the_user( + hass: HomeAssistant, +) -> None: + """The regression this ordering exists for. + + A mismatch makes `connect()` raise, setup raises `ConfigEntryNotReady`, and + the retry builds a new client -- so a subscription registered after a + successful connect would be registered on a client that never has one. Every + setup attempt would fire the signal into nothing. + """ + entry = _setup_entry(hass) + client, _order = _diagnosing_client(MOVED) + + with ( + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + pytest.raises(ConfigEntryNotReady), + ): + await async_setup_entry(hass, entry) + + issue = _issue(hass, entry) + assert issue is not None + assert issue.translation_placeholders is not None + assert issue.translation_placeholders["leaf_names"] == "span-panel.local, 192.168.1.187" + + +async def test_the_subscription_is_registered_before_the_connect_attempt( + hass: HomeAssistant, +) -> None: + """Stated as an ordering too, not only through its consequence. + + The test above would keep passing on a subscription moved back after + `connect()` if the library ever stopped raising on a mismatch, which is a + change to the library rather than to this decision. + """ + entry = _setup_entry(hass) + client, order = _diagnosing_client(None) + + with ( + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + pytest.raises(ConfigEntryNotReady), + ): + await async_setup_entry(hass, entry) + + assert order == ["register", "connect"] + + +async def test_a_successful_setup_clears_a_notice_left_from_a_previous_run( + hass: HomeAssistant, +) -> None: + """A handshake that succeeded is the same disproof a reconnect is.""" + entry = _setup_entry(hass) + async_raise_leaf_name_mismatch(hass, entry, MOVED) + + snapshot = SpanPanelSnapshotFactory.create(serial_number="sp3-leaf-001") + client = MagicMock() + client.connect = AsyncMock() + coordinator = MagicMock() + coordinator.async_config_entry_first_refresh = AsyncMock() + coordinator.async_setup_streaming = AsyncMock() + coordinator.data = snapshot + + with ( + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + patch("custom_components.span_panel.SpanPanelCoordinator", return_value=coordinator), + patch("custom_components.span_panel.ensure_device_registered", AsyncMock()), + patch.object(hass.config_entries, "async_forward_entry_setups", AsyncMock()), + patch.object(hass.config_entries, "async_update_entry"), + ): + assert await async_setup_entry(hass, entry) is True + + assert _issue(hass, entry) is None + + +async def test_unloading_the_entry_takes_the_subscription_with_it( + hass: HomeAssistant, +) -> None: + """The client goes when the entry does, and so must the callback holding `hass`.""" + entry = _setup_entry(hass) + unregister = MagicMock(name="unregister") + client = MagicMock() + client.connect = AsyncMock() + client.register_leaf_mismatch_callback = MagicMock(return_value=unregister) + coordinator = MagicMock() + coordinator.async_config_entry_first_refresh = AsyncMock() + coordinator.async_setup_streaming = AsyncMock() + coordinator.data = SpanPanelSnapshotFactory.create(serial_number="sp3-leaf-001") + + with ( + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + patch("custom_components.span_panel.SpanPanelCoordinator", return_value=coordinator), + patch("custom_components.span_panel.ensure_device_registered", AsyncMock()), + patch.object(hass.config_entries, "async_forward_entry_setups", AsyncMock()), + patch.object(hass.config_entries, "async_update_entry"), + ): + assert await async_setup_entry(hass, entry) is True + + unregister.assert_not_called() + await entry._async_process_on_unload(hass) + unregister.assert_called_once_with() diff --git a/uv.lock b/uv.lock index d615e95a..6a92320c 100644 --- a/uv.lock +++ b/uv.lock @@ -2619,7 +2619,7 @@ dev = [ [[package]] name = "span-panel-api" -version = "3.2.0" +version = "3.3.0" source = { editable = "../../span/span-panel-api" } dependencies = [ { name = "httpx" }, From 0a03afa0d5c593ddb0c4bf0a4aadedb59460dde2 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:55:50 -0700 Subject: [PATCH 221/345] fix(config_flow): a Supervisor discovery reallocates ports, not the host `async_step_hassio` put the add-on's published host straight into the already-configured update on every add-on start. Observed on ha-test: an entry configured at an address that reached the panel and that the panel's certificate names was overwritten with the add-on's container hostname, a name the panel does not serve, 56 seconds after the entry was created. Every connection afterwards failed verification against the entry's own pin. The update also moved `host` without `ebus_broker_host`, leaving the entry naming two different machines. The docstring's argument for being unguarded -- add-ons legitimately reallocate their own ports -- covers the ports and does not reach the host. An add-on that moved a port has not moved the panel. So the ports still go straight in, and `_async_hassio_host_update` decides the host on the evidence the entry already has: probe the *configured* host on the *newly discovered* port, which is precisely the reallocated-port case, and keep the stored host when a v2 answer there carries this serial. Only a configured host that has stopped answering for this panel is replaced, and then `CONF_HOST` and `CONF_EBUS_BROKER_HOST` move together. Deliberately not `_async_host_update`'s pinned-address check. That one decides whether an unauthenticated claim may move an entry; this one decides whether there is anything to move at all. The Supervisor route's documented trade is unchanged for a host that has genuinely gone away. Bumps the integration to 2.1.0b23 and re-mirrors pyproject's version onto the manifest's, which had drifted at 2.1.0. --- CHANGELOG.md | 2 + README.md | 1 + custom_components/span_panel/config_flow.py | 94 ++++++++- custom_components/span_panel/manifest.json | 2 +- developer.md | 18 +- pyproject.toml | 2 +- tests/test_v2_config_flow.py | 204 ++++++++++++++------ uv.lock | 2 +- 8 files changed, 252 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3eb08e4..b6a8951c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,6 +105,8 @@ This release requires Home Assistant 2026.8 to avoid a deprecated API — the ol used to leave every power sensor reading zero and every energy sensor frozen, indefinitely and indistinguishably from a panel drawing no power. - **A panel that has moved to another address now says so in Repairs**, naming the addresses its certificate does carry so you can point the entry at one with Reconfigure, instead of retrying silently with every entity unavailable and one line a minute in the log. +- **An add-on restart no longer overwrites the address you configured** — a panel announced by an add-on keeps the host you gave it for as long as that host + still answers, and only the add-on's own ports are taken as published. - **Recreate entity IDs proposes the ids your panel would produce now** (#252), in your installation's naming style, leaving unique ids and statistics untouched. - **The README described Battery Power's sign backwards**, since the sensor has always reported discharging as positive (#184) and only the documentation was diff --git a/README.md b/README.md index 2b895663..99a45103 100644 --- a/README.md +++ b/README.md @@ -671,6 +671,7 @@ another install of this integration on the same panel. After the first pin, any | Setting up by hostname | Verified, never relaxed. A domain joins the certificate's SAN only after you authenticate; everything before that runs against an address the certificate already names. | | Panel announces a new address, or you re-add it | The entry moves only if the candidate serves a certificate its own anchor validates. Otherwise the move is refused and logged at `WARNING`. | | The panel really has moved | Use **Reconfigure**. A host that does not chain is refused, one that does not answer is reported unreachable, and one that chains but is not named is your panel — move it to an **FQDN** (the panel regenerates its certificate around that name) or the panel's **`.local` name** (already covered). A bare new IP the certificate does not name is refused. | +| A panel announced by an **add-on** | The add-on's ports are taken as published, because add-ons reallocate their own. The address you configured is kept while it still answers for that panel, and replaced only when it has stopped answering. | | The entry has no anchor, or a stored one won't load | **Reauthenticate** acquires one before either sign-in method is offered. | **Entries from before pinning** pin at the first startup that reaches the panel, logged at `WARNING` with the fingerprint. Until that succeeds the authority is diff --git a/custom_components/span_panel/config_flow.py b/custom_components/span_panel/config_flow.py index 03b32a4d..5a30a6af 100644 --- a/custom_components/span_panel/config_flow.py +++ b/custom_components/span_panel/config_flow.py @@ -363,6 +363,75 @@ async def _async_host_update(self) -> dict[str, Any] | None: ) return None + async def _async_hassio_host_update(self, host: str, port: int, serial: str) -> dict[str, Any]: + """Return the host keys a Supervisor discovery should carry, usually none. + + An add-on restart republishes the container hostname it answers on, + which is not a claim that the panel has moved and is not the address the + user configured. Overwriting the stored host with it broke a working + entry seconds after creation: the panel's certificate names the address + the user typed and does not name the add-on's hostname, so every + connection afterwards failed verification against the entry's own pin. + + The question that decides it is the same one the entry itself asks on + every setup -- does this address still reach this panel -- so it is + asked directly rather than by proxy: probe the configured host on the + *newly discovered* port, because a reallocated port on the same machine + is exactly the case the ports being unguarded exists for. A v2 answer + carrying this serial means the entry is fine where it is and is left + alone. + + Deliberately not `_async_host_update`'s pinned-address check. That one + decides whether an unauthenticated claim may move an entry, and answers + "no" by refusing the move outright; this one decides whether there is + anything to move at all, and its evidence is the panel the entry already + has rather than the candidate. An add-on that has genuinely taken the + panel's place still moves the entry, which is the Supervisor route's + documented trade. + + `CONF_EBUS_BROKER_HOST` moves with `CONF_HOST` or not at all. The two + are the same address for the same panel -- `async_setup_entry` dials the + configured host and only logs the broker's own advertisement -- and + moving one without the other left the entry describing two different + machines. + """ + entry = self.hass.config_entries.async_entry_for_domain_unique_id(self.handler, serial) + if entry is None: + return {CONF_HOST: host} + + configured = str(entry.data.get(CONF_HOST, "")) + if not configured or configured == host: + return {CONF_HOST: host} + + detection = await detect_api_version( + configured, port=port, httpx_client=get_async_client(self.hass, verify_ssl=False) + ) + reached = ( + detection.api_version == "v2" + and detection.status_info is not None + and detection.status_info.serial_number == serial + ) + if reached: + _LOGGER.debug( + "Supervisor discovery published host %s, but panel %s still answers at its " + "configured host %s on port %s. Taking the ports and leaving the host alone", + host, + serial, + configured, + port, + ) + return {} + + _LOGGER.info( + "Moving the entry for panel %s from %s to %s: the configured host no longer " + "answers for this panel on port %s", + serial, + configured, + host, + port, + ) + return {CONF_HOST: host, CONF_EBUS_BROKER_HOST: host} + async def async_step_zeroconf(self, discovery_info: ZeroconfServiceInfo) -> ConfigFlowResult: """Handle a flow initiated by zeroconf discovery.""" # Do not probe device if the host is already configured @@ -434,18 +503,22 @@ async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> ConfigFl Deduplicate by panel serial, not by host, so each panel gets its own config entry. - **This route is deliberately not held to the pinned-address check** that + **The ports are deliberately not held to the pinned-address check** that `async_step_zeroconf` and the by-hand re-add both go through, in `_async_host_update`. A Supervisor discovery arrives over the authenticated Supervisor API from an add-on the user installed, and - add-ons legitimately reallocate their own ports, so applying that guard - would freeze the entry against its own add-on -- which is why the ports - go straight into the update below. The cost is stated rather than - hidden, here and in README's Security section: an add-on that already - holds Supervisor privileges can move a pinned entry. If the trade is - ever revisited, the guard is the same helper the other two routes call. - See developer.md, "The Supervisor discovery path is deliberately - unguarded". + add-ons legitimately reallocate their own ports across restarts, so + holding the ports to the stored values would freeze the entry against + its own add-on. They go straight into the update below. + + The *host* is a different question and is not carried by that argument. + An add-on reallocating a port has not moved, and the address the user + configured is the one their panel's certificate names; overwriting it + with the add-on's own container hostname broke a working entry seconds + after it was created, because that hostname is not a name the panel + serves. So the host moves only when the configured one has stopped + answering for this panel -- see `_async_hassio_host_update`. See + developer.md, "The Supervisor discovery path is unguarded on ports". """ config = discovery_info.config host = str(config.get("host", "")) @@ -483,9 +556,10 @@ async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> ConfigFl # restarts: an entry left pointing at last run's ports is an entry that # cannot reach its panel. await self.async_set_unique_id(panel_serial) - updates: dict[str, Any] = {CONF_HOST: host, CONF_HTTP_PORT: port} + updates: dict[str, Any] = {CONF_HTTP_PORT: port} if self._https_port_known: updates[CONF_HTTPS_PORT] = self._https_port + updates.update(await self._async_hassio_host_update(host, port, panel_serial)) self._abort_if_unique_id_configured(updates=updates) # Set up flow — same path as v2 zeroconf discovery diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index ea428a30..22f9bb58 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -27,7 +27,7 @@ "span-panel-api-schema-0==1.1.2", "span-panel-api-schema-1==1.1.3" ], - "version": "2.1.0b22", + "version": "2.1.0b23", "zeroconf": [ { "type": "_span._tcp.local." diff --git a/developer.md b/developer.md index 1e9bdb3f..34229cdb 100644 --- a/developer.md +++ b/developer.md @@ -823,15 +823,23 @@ advertised by SPAN panel …" with the fingerprint. **A registered domain is checked once the panel reports the new certificate**, not when registration is requested. The panel regenerates its certificate around the FQDN asynchronously, so the flow polls for the name to appear in the SAN rather than assuming the call took effect. -## The Supervisor discovery path is deliberately unguarded +## The Supervisor discovery path is unguarded on ports A pinned entry only follows a discovered host when that host serves a certificate the entry's anchor validates — `async_step_zeroconf` and the by-hand re-add both go through that check, because the serial they match on comes from an unauthenticated endpoint anything on the LAN can answer. -`async_step_hassio` deliberately does not. A Supervisor discovery arrives over the authenticated Supervisor API from an add-on the user installed, and add-ons -legitimately reallocate their own ports, so applying the stored-address guard would freeze the entry against its own add-on. The cost is stated rather than -hidden: an add-on that already holds Supervisor privileges can move a pinned entry. If that trade is ever revisited, the guard is the same helper the other two -routes call. +`async_step_hassio` deliberately does not hold its **ports** to that check. A Supervisor discovery arrives over the authenticated Supervisor API from an add-on +the user installed, and add-ons legitimately reallocate their own ports across restarts, so holding the ports to the stored values would freeze the entry +against its own add-on. + +**The host is not covered by that argument, and is not taken on the add-on's word.** An add-on republishing its container hostname has not moved the panel, and +that hostname is generally not a name the panel's certificate carries — writing it over a working host broke an entry seconds after it was created. So +`_async_hassio_host_update` probes the _configured_ host on the _newly discovered_ port, which is precisely the reallocated-port case, and keeps the stored host +whenever a v2 answer there carries this panel's serial. Only a configured host that has stopped answering for this panel is replaced, and then `CONF_HOST` and +`CONF_EBUS_BROKER_HOST` move together — moving one without the other left the entry naming two different machines. + +That is a narrower check than the other two routes make, and the residual cost is stated rather than hidden: an add-on that already holds Supervisor privileges +can still move an entry whose configured host has genuinely gone away. If that trade is ever revisited, the guard is the same helper the other two routes call. ## Linting and Type Checking diff --git a/pyproject.toml b/pyproject.toml index 51ad93b3..f648785c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span" -version = "2.1.0" +version = "2.1.0b23" description = "Span Panel Custom Integration for Home Assistant" authors = [{name = "SpanPanel"}] license = {text = "MIT"} diff --git a/tests/test_v2_config_flow.py b/tests/test_v2_config_flow.py index 8ec2ca96..a1d9ddff 100644 --- a/tests/test_v2_config_flow.py +++ b/tests/test_v2_config_flow.py @@ -2,18 +2,19 @@ from __future__ import annotations +from collections.abc import Callable import ipaddress import logging import ssl from unittest.mock import AsyncMock, MagicMock, patch -import pytest from homeassistant import config_entries from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo +import pytest from pytest_homeassistant_custom_component.common import MockConfigEntry from span_panel_api import DetectionResult, V2AuthResponse, V2StatusInfo from span_panel_api.exceptions import SpanPanelAuthError, SpanPanelConnectionError @@ -190,9 +191,7 @@ async def test_user_flow_detects_v2_and_shows_auth_choice(hass: HomeAssistant) - assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" - result2 = await _submit_host_and_pin( - hass, result["flow_id"], {CONF_HOST: MOCK_HOST} - ) + result2 = await _submit_host_and_pin(hass, result["flow_id"], {CONF_HOST: MOCK_HOST}) assert result2["type"] == FlowResultType.MENU assert result2["step_id"] == "choose_v2_auth" @@ -282,9 +281,7 @@ async def test_passphrase_auth_success(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await _submit_host_and_pin( - hass, result["flow_id"], {CONF_HOST: MOCK_HOST} - ) + result2 = await _submit_host_and_pin(hass, result["flow_id"], {CONF_HOST: MOCK_HOST}) assert result2["step_id"] == "choose_v2_auth" # Select passphrase auth from the menu @@ -324,9 +321,7 @@ async def test_passphrase_auth_bad_passphrase(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await _submit_host_and_pin( - hass, result["flow_id"], {CONF_HOST: MOCK_HOST} - ) + result2 = await _submit_host_and_pin(hass, result["flow_id"], {CONF_HOST: MOCK_HOST}) # Select passphrase auth from the menu result2b = await hass.config_entries.flow.async_configure( @@ -365,9 +360,7 @@ async def test_passphrase_auth_connection_error(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await _submit_host_and_pin( - hass, result["flow_id"], {CONF_HOST: MOCK_HOST} - ) + result2 = await _submit_host_and_pin(hass, result["flow_id"], {CONF_HOST: MOCK_HOST}) # Select passphrase auth from the menu result2b = await hass.config_entries.flow.async_configure( @@ -411,9 +404,7 @@ async def test_v2_entry_contains_mqtt_credentials(hass: HomeAssistant) -> None: ) # Step 1: submit host - result2 = await _submit_host_and_pin( - hass, result["flow_id"], {CONF_HOST: MOCK_HOST} - ) + result2 = await _submit_host_and_pin(hass, result["flow_id"], {CONF_HOST: MOCK_HOST}) # Step 2: choose auth method (passphrase) result2b = await hass.config_entries.flow.async_configure( @@ -456,9 +447,7 @@ async def _reach_the_ca_step(hass: HomeAssistant): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - return await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_HOST: MOCK_HOST} - ) + return await hass.config_entries.flow.async_configure(result["flow_id"], {CONF_HOST: MOCK_HOST}) @pytest.mark.asyncio @@ -584,9 +573,7 @@ async def test_a_recovered_panel_can_be_retried_into_a_successful_pin( patch("custom_components.span_panel.config_flow.validate_host", return_value=True), patch( "custom_components.span_panel.config_flow.async_fetch_panel_ca", - new=AsyncMock( - side_effect=[SpanPanelConnectionError("unreachable"), FAKE_CA_PEM] - ), + new=AsyncMock(side_effect=[SpanPanelConnectionError("unreachable"), FAKE_CA_PEM]), ), ): failed = await _reach_the_ca_step(hass) @@ -1408,9 +1395,7 @@ async def test_user_flow_recovery_after_bad_host(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "cannot_connect"} # Second attempt succeeds - result3 = await _submit_host_and_pin( - hass, result2["flow_id"], {CONF_HOST: MOCK_HOST} - ) + result3 = await _submit_host_and_pin(hass, result2["flow_id"], {CONF_HOST: MOCK_HOST}) assert result3["type"] == FlowResultType.MENU assert result3["step_id"] == "choose_v2_auth" @@ -1435,9 +1420,7 @@ async def test_passphrase_auth_empty_passphrase(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await _submit_host_and_pin( - hass, result["flow_id"], {CONF_HOST: MOCK_HOST} - ) + result2 = await _submit_host_and_pin(hass, result["flow_id"], {CONF_HOST: MOCK_HOST}) result2b = await hass.config_entries.flow.async_configure( result2["flow_id"], @@ -1475,9 +1458,7 @@ async def test_passphrase_auth_recovery_after_error(hass: HomeAssistant) -> None DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await _submit_host_and_pin( - hass, result["flow_id"], {CONF_HOST: MOCK_HOST} - ) + result2 = await _submit_host_and_pin(hass, result["flow_id"], {CONF_HOST: MOCK_HOST}) result2b = await hass.config_entries.flow.async_configure( result2["flow_id"], @@ -1524,9 +1505,7 @@ async def test_proximity_auth_success(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await _submit_host_and_pin( - hass, result["flow_id"], {CONF_HOST: MOCK_HOST} - ) + result2 = await _submit_host_and_pin(hass, result["flow_id"], {CONF_HOST: MOCK_HOST}) assert result2["step_id"] == "choose_v2_auth" result2b = await hass.config_entries.flow.async_configure( @@ -1562,9 +1541,7 @@ async def test_proximity_not_proven_returns_to_menu(hass: HomeAssistant) -> None DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await _submit_host_and_pin( - hass, result["flow_id"], {CONF_HOST: MOCK_HOST} - ) + result2 = await _submit_host_and_pin(hass, result["flow_id"], {CONF_HOST: MOCK_HOST}) result2b = await hass.config_entries.flow.async_configure( result2["flow_id"], @@ -1599,9 +1576,7 @@ async def test_proximity_switch_to_passphrase(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await _submit_host_and_pin( - hass, result["flow_id"], {CONF_HOST: MOCK_HOST} - ) + result2 = await _submit_host_and_pin(hass, result["flow_id"], {CONF_HOST: MOCK_HOST}) result2b = await hass.config_entries.flow.async_configure( result2["flow_id"], @@ -2228,28 +2203,53 @@ async def test_hassio_discovery_routes_to_confirm(hass: HomeAssistant) -> None: @pytest.mark.asyncio -async def test_hassio_dedup_by_serial(hass: HomeAssistant) -> None: - """Hassio discovery of an already-configured serial should abort and update host/port.""" - existing = MockConfigEntry( +def _hassio_configured_entry(hass: HomeAssistant, **data: object) -> MockConfigEntry: + """Add an entry for the simulator's serial, configured at a host of its own.""" + entry = MockConfigEntry( version=3, minor_version=1, domain=DOMAIN, title="Span Panel", data={ CONF_HOST: "192.168.1.40", + CONF_EBUS_BROKER_HOST: "192.168.1.40", CONF_ACCESS_TOKEN: "existing-token", CONF_API_VERSION: "v2", CONF_HTTP_PORT: 80, + **data, }, source=config_entries.SOURCE_USER, options={}, unique_id="SPAN-SIM-001", ) - existing.add_to_hass(hass) + entry.add_to_hass(hass) + return entry + + +def _detection_by_host(*answering: str) -> Callable[..., DetectionResult]: + """Answer as the simulator for the named hosts and as nothing for the rest. + + Keyed by host because that is the whole question here: the discovered host + and the configured one are two different addresses, and which of them + answers is what decides whether the entry moves. + """ + + def detect(host: str, **_kwargs: object) -> DetectionResult: + if host in answering: + return MOCK_V2_DETECTION_SIM + return DetectionResult(api_version="v1", probe_failed=True) + + return detect + + +@pytest.mark.asyncio +async def test_hassio_dedup_by_serial(hass: HomeAssistant) -> None: + """A discovery of an already-configured serial aborts rather than adding a second entry.""" + existing = _hassio_configured_entry(hass) with patch( "custom_components.span_panel.config_flow.detect_api_version", - return_value=MOCK_V2_DETECTION_SIM, + side_effect=_detection_by_host("192.168.1.50", "192.168.1.40"), ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -2259,11 +2259,111 @@ async def test_hassio_dedup_by_serial(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" - # Host and port should be updated to the new values + assert existing.data[CONF_HTTP_PORT] == 9090 + + +@pytest.mark.asyncio +async def test_hassio_keeps_a_configured_host_that_still_answers(hass: HomeAssistant) -> None: + """The defect: an add-on restart overwrote a working host with its own hostname. + + The add-on republishes the container hostname it answers on, which is not a + claim that the panel moved and is generally not a name the panel's + certificate carries. Writing it over the configured host broke the entry + seconds after it was created — every connection afterwards failed + verification against the entry's own pin. + """ + existing = _hassio_configured_entry(hass) + + with patch( + "custom_components.span_panel.config_flow.detect_api_version", + side_effect=_detection_by_host("192.168.1.50", "192.168.1.40"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=_hassio_service_info(MOCK_HASSIO_CONFIG), + ) + + assert result["reason"] == "already_configured" + assert existing.data[CONF_HOST] == "192.168.1.40" + assert existing.data[CONF_EBUS_BROKER_HOST] == "192.168.1.40" + # The ports are the add-on's own and are taken as published either way. + assert existing.data[CONF_HTTP_PORT] == 9090 + + +@pytest.mark.asyncio +async def test_hassio_probes_the_configured_host_on_the_newly_published_port( + hass: HomeAssistant, +) -> None: + """A reallocated port on the same machine is the case the ports are unguarded for. + + Probing the configured host on its *stored* port would report it dead every + time the add-on moved, which is the move this is meant to prevent. + """ + _hassio_configured_entry(hass) + probe = AsyncMock(side_effect=_detection_by_host("192.168.1.50", "192.168.1.40")) + + with patch("custom_components.span_panel.config_flow.detect_api_version", probe): + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=_hassio_service_info(MOCK_HASSIO_CONFIG), + ) + + probed = [(call.args[0], call.kwargs["port"]) for call in probe.call_args_list] + assert ("192.168.1.40", 9090) in probed + + +@pytest.mark.asyncio +async def test_hassio_moves_host_and_broker_host_together_when_the_old_one_is_dead( + hass: HomeAssistant, +) -> None: + """A configured host that has stopped answering is a panel that really moved. + + Both keys move or neither does: they are the same address for the same + panel, and moving one without the other left the entry naming two different + machines. + """ + existing = _hassio_configured_entry(hass) + + with patch( + "custom_components.span_panel.config_flow.detect_api_version", + side_effect=_detection_by_host("192.168.1.50"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=_hassio_service_info(MOCK_HASSIO_CONFIG), + ) + + assert result["reason"] == "already_configured" assert existing.data[CONF_HOST] == "192.168.1.50" + assert existing.data[CONF_EBUS_BROKER_HOST] == "192.168.1.50" assert existing.data[CONF_HTTP_PORT] == 9090 +@pytest.mark.asyncio +async def test_hassio_takes_both_ports_whichever_way_the_host_goes( + hass: HomeAssistant, +) -> None: + """The published ports are the add-on's own and are never held to stored values.""" + kept = _hassio_configured_entry(hass, **{CONF_HTTPS_PORT: 443}) + + with patch( + "custom_components.span_panel.config_flow.detect_api_version", + side_effect=_detection_by_host("192.168.1.50", "192.168.1.40"), + ): + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=_hassio_service_info({**MOCK_HASSIO_CONFIG, "https_port": 10090}), + ) + + assert kept.data[CONF_HOST] == "192.168.1.40" + assert kept.data[CONF_HTTP_PORT] == 9090 + assert kept.data[CONF_HTTPS_PORT] == 10090 + + @pytest.mark.usefixtures("socket_enabled") @pytest.mark.asyncio async def test_hassio_end_to_end_entry_creation(hass: HomeAssistant) -> None: @@ -2290,9 +2390,7 @@ async def test_hassio_end_to_end_entry_creation(hass: HomeAssistant) -> None: # Step 2: confirm -> HTTPS port (this panel moved its HTTP port) -> CA port_step = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert port_step["step_id"] == "panel_https_port" - result2 = await _submit_host_and_pin( - hass, port_step["flow_id"], {CONF_HTTPS_PORT: 9443} - ) + result2 = await _submit_host_and_pin(hass, port_step["flow_id"], {CONF_HTTPS_PORT: 9443}) assert result2["type"] == FlowResultType.MENU assert result2["step_id"] == "choose_v2_auth" @@ -2599,9 +2697,7 @@ async def test_zeroconf_invalid_http_port_defaults_to_80(hass: HomeAssistant) -> assert result["type"] == FlowResultType.FORM assert result["step_id"] == "confirm_discovery" - mock_detect.assert_awaited_once_with( - "192.168.1.200", port=80, httpx_client=fake_client - ) + mock_detect.assert_awaited_once_with("192.168.1.200", port=80, httpx_client=fake_client) @pytest.mark.usefixtures("socket_enabled") @@ -2801,9 +2897,7 @@ async def test_fqdn_entry_creation_sets_registered_fqdn_and_unique_title( ) assert port_step["step_id"] == "panel_https_port" # Left at the default, so it is not written to the entry. - result2 = await _submit_host_and_pin( - hass, port_step["flow_id"], {CONF_HTTPS_PORT: 443} - ) + result2 = await _submit_host_and_pin(hass, port_step["flow_id"], {CONF_HTTPS_PORT: 443}) result2b = await hass.config_entries.flow.async_configure( result2["flow_id"], {"next_step_id": "auth_passphrase"}, diff --git a/uv.lock b/uv.lock index 6a92320c..fb4249ba 100644 --- a/uv.lock +++ b/uv.lock @@ -2560,7 +2560,7 @@ wheels = [ [[package]] name = "span" -version = "2.1.0" +version = "2.1.0b23" source = { virtual = "." } dependencies = [ { name = "homeassistant" }, From b1a134f0ecf0b60e2f9256b2ef50529117a73312 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:05:27 -0700 Subject: [PATCH 222/345] chore(release): 2.1.0 --- custom_components/span_panel/manifest.json | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 22f9bb58..49c0c6fa 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -27,7 +27,7 @@ "span-panel-api-schema-0==1.1.2", "span-panel-api-schema-1==1.1.3" ], - "version": "2.1.0b23", + "version": "2.1.0", "zeroconf": [ { "type": "_span._tcp.local." diff --git a/pyproject.toml b/pyproject.toml index f648785c..51ad93b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span" -version = "2.1.0b23" +version = "2.1.0" description = "Span Panel Custom Integration for Home Assistant" authors = [{name = "SpanPanel"}] license = {text = "MIT"} diff --git a/uv.lock b/uv.lock index fb4249ba..6a92320c 100644 --- a/uv.lock +++ b/uv.lock @@ -2560,7 +2560,7 @@ wheels = [ [[package]] name = "span" -version = "2.1.0b23" +version = "2.1.0" source = { virtual = "." } dependencies = [ { name = "homeassistant" }, From 4deec35580a1c84982b7ce5e169f83f8ba81e774 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:09:28 +0000 Subject: [PATCH 223/345] deps(deps): bump the ha-core group with 3 updates Bumps the ha-core group with 3 updates: [homeassistant](https://github.com/home-assistant/core), [homeassistant-stubs](https://github.com/KapJI/homeassistant-stubs) and [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component). Updates `homeassistant` from 2026.8.0 to 2026.8.3 - [Release notes](https://github.com/home-assistant/core/releases) - [Commits](https://github.com/home-assistant/core/compare/2026.8.0...2026.8.3) Updates `homeassistant-stubs` from 2026.8.0 to 2026.8.3 - [Release notes](https://github.com/KapJI/homeassistant-stubs/releases) - [Commits](https://github.com/KapJI/homeassistant-stubs/compare/2026.8.0...2026.8.3) Updates `pytest-homeassistant-custom-component` from 0.13.354 to 0.13.358 - [Release notes](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/releases) - [Changelog](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/CHANGELOG.md) - [Commits](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/compare/0.13.354...0.13.358) --- updated-dependencies: - dependency-name: homeassistant dependency-version: 2026.8.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: ha-core - dependency-name: homeassistant-stubs dependency-version: 2026.8.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: ha-core - dependency-name: pytest-homeassistant-custom-component dependency-version: 0.13.358 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: ha-core ... Signed-off-by: dependabot[bot] --- pyproject.toml | 6 +++--- requirements_test.txt | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 51ad93b3..71b70784 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ license = {text = "MIT"} readme = "README.md" requires-python = ">=3.14.3,<3.15" dependencies = [ - "homeassistant==2026.8.0", + "homeassistant==2026.8.3", "span-panel-api==3.3.0", # Separate distributions, not dependencies of the bootstrap: adapters are found # through the `span_panel_api.schema_adapters` entry-point group, so each one has @@ -19,7 +19,7 @@ dependencies = [ [dependency-groups] dev = [ - "homeassistant-stubs==2026.8.0", + "homeassistant-stubs==2026.8.3", "types-requests", "types-PyYAML", "mypy==2.1.0", @@ -36,7 +36,7 @@ dev = [ # and the `homeassistant` pin above are a single choice rather than two. # 0.13.354 is the release that pins 2026.8.0, the version this integration # declares as its floor and therefore the one worth testing against. - "pytest-homeassistant-custom-component==0.13.354", + "pytest-homeassistant-custom-component==0.13.358", "isort", "vulture>=2.14", ] diff --git a/requirements_test.txt b/requirements_test.txt index c1b255d6..6d01ab3f 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,8 +1,8 @@ pytest>=9.0.3 pytest-asyncio>=1.4.0 setuptools>=65.7.0 -pytest-homeassistant-custom-component==0.13.354 -homeassistant==2026.8.0 +pytest-homeassistant-custom-component==0.13.358 +homeassistant==2026.8.3 span-panel-api==3.3.0 span-panel-api-schema-0==1.1.2 span-panel-api-schema-1==1.1.3 From 710e9a447d6010ea9642e12ce056e29c9894a8b0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:10:14 +0000 Subject: [PATCH 224/345] deps(deps): bump the dev-dependencies group with 6 updates Updates the requirements on [mypy](https://github.com/python/mypy), [pyright](https://github.com/RobertCraigie/pyright-python), [ruff](https://github.com/astral-sh/ruff), [prek](https://github.com/j178/prek), [pylint](https://github.com/pylint-dev/pylint) and [pytest](https://github.com/pytest-dev/pytest) to permit the latest version. Updates `mypy` from 2.1.0 to 2.3.1 - [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md) - [Commits](https://github.com/python/mypy/compare/v2.1.0...v2.3.1) Updates `pyright` from 1.1.410 to 1.1.411 - [Release notes](https://github.com/RobertCraigie/pyright-python/releases) - [Commits](https://github.com/RobertCraigie/pyright-python/compare/v1.1.410...v1.1.411) Updates `ruff` from 0.15.18 to 0.16.5 - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.15.18...0.16.5) Updates `prek` to 0.5.0 - [Release notes](https://github.com/j178/prek/releases) - [Changelog](https://github.com/j178/prek/blob/master/CHANGELOG.md) - [Commits](https://github.com/j178/prek/compare/v0.4.5...v0.5.0) Updates `pylint` from 4.0.6 to 4.0.7 - [Release notes](https://github.com/pylint-dev/pylint/releases) - [Commits](https://github.com/pylint-dev/pylint/compare/v4.0.6...v4.0.7) Updates `pytest` to 9.1.1 - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/9.0.3...9.1.1) --- updated-dependencies: - dependency-name: mypy dependency-version: 2.3.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-dependencies - dependency-name: pyright dependency-version: 1.1.411 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-dependencies - dependency-name: ruff dependency-version: 0.16.5 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-dependencies - dependency-name: prek dependency-version: 0.5.0 dependency-type: direct:development dependency-group: dev-dependencies - dependency-name: pylint dependency-version: 4.0.7 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-dependencies - dependency-name: pytest dependency-version: 9.1.1 dependency-type: direct:production dependency-group: dev-dependencies ... Signed-off-by: dependabot[bot] --- pyproject.toml | 10 +++++----- requirements_test.txt | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 51ad93b3..04780a24 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,16 +22,16 @@ dev = [ "homeassistant-stubs==2026.8.0", "types-requests", "types-PyYAML", - "mypy==2.1.0", - "pyright==1.1.410", - "ruff==0.15.18", + "mypy==2.3.1", + "pyright==1.1.411", + "ruff==0.16.5", "bandit[toml]==1.9.4", - "prek>=0.4.5", + "prek>=0.5.0", "voluptuous-stubs", "python-direnv", "prettier", "radon==6.0.1", - "pylint==4.0.6", + "pylint==4.0.7", # Pinned, not floored: each release pins one exact Home Assistant, so this # and the `homeassistant` pin above are a single choice rather than two. # 0.13.354 is the release that pins 2026.8.0, the version this integration diff --git a/requirements_test.txt b/requirements_test.txt index c1b255d6..0352f1d4 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,4 +1,4 @@ -pytest>=9.0.3 +pytest>=9.1.1 pytest-asyncio>=1.4.0 setuptools>=65.7.0 pytest-homeassistant-custom-component==0.13.354 From f0f0b743b8c8352ed9653bd1db3583da9fcb0543 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:18:55 -0700 Subject: [PATCH 225/345] feat(curation): record type and validation for user-asserted adopted metadata --- custom_components/span_panel/adoption.py | 11 +- custom_components/span_panel/curation.py | 211 ++++++++++++++++++++++ custom_components/span_panel/extension.py | 2 +- custom_components/span_panel/util.py | 13 ++ tests/test_curation.py | 110 +++++++++++ 5 files changed, 341 insertions(+), 6 deletions(-) create mode 100644 custom_components/span_panel/curation.py create mode 100644 tests/test_curation.py diff --git a/custom_components/span_panel/adoption.py b/custom_components/span_panel/adoption.py index ad4c8af3..b8ce72d2 100644 --- a/custom_components/span_panel/adoption.py +++ b/custom_components/span_panel/adoption.py @@ -53,7 +53,12 @@ from .const import DOMAIN from .entity import SpanPanelEntity from .id_builder import get_user_friendly_suffix -from .util import ADOPTED_IDENTIFIER_TOKEN +from .util import ( + ADOPTED_IDENTIFIER_TOKEN, + BOOLEAN_DATATYPE, + ENUM_DATATYPE, + NUMERIC_DATATYPES, +) if TYPE_CHECKING: from homeassistant.core import HomeAssistant @@ -63,10 +68,6 @@ _LOGGER = logging.getLogger(__name__) -BOOLEAN_DATATYPE: Final = "boolean" -ENUM_DATATYPE: Final = "enum" -NUMERIC_DATATYPES: Final = frozenset({"float", "integer"}) - MAX_STATE_LENGTH: Final = 255 """Home Assistant's hard limit on a state string. diff --git a/custom_components/span_panel/curation.py b/custom_components/span_panel/curation.py new file mode 100644 index 00000000..a6d48c91 --- /dev/null +++ b/custom_components/span_panel/curation.py @@ -0,0 +1,211 @@ +"""User-supplied metadata for adopted entities. + +Adopted entities arrive with deliberately minimal metadata because the +integration refuses to guess. A user who owns the vendor device is not +guessing: this module stores their assertions -- `state_class`, +`device_class`, and prominence -- keyed by a scope-prefixed wire address, +validates them against what the wire actually declares, and hands the +platforms fully-formed entity descriptions at construction. + +**This is the one module allowed to spell `state_class`.** `adoption.py` and +`extension.py` carry AST guards asserting the token never appears there; the +description helpers below are how a curated record becomes an entity +description without either module naming the thing it must not set. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +import logging +from typing import Final + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass + +# `homeassistant.components.sensor` re-exports this at runtime but leaves it out +# of its `__all__`, so the package-level import is an `attr-defined` error under +# mypy. `.const` is where it is actually defined and is the path that type-checks. +from homeassistant.components.sensor.const import DEVICE_CLASS_UNITS +from homeassistant.const import Platform + +from .util import NUMERIC_DATATYPES + +_LOGGER = logging.getLogger(__name__) + +PROMOTED: Final = "none" +"""The one storable `entity_category` value: promoted out of diagnostics.""" + + +@dataclass(frozen=True, slots=True) +class RowContext: + """What one adopted row declares, as far as validation needs it.""" + + platform: Platform + datatype: str + unit: str | None + + +@dataclass(frozen=True, slots=True) +class CurationRecord: + """One row's user-asserted metadata. Absence of a field means default.""" + + state_class: SensorStateClass | None = None + device_class: str | None = None + promote: bool = False + + +class CurationError(Exception): + """A record that cannot be stored, with a websocket-ready error code.""" + + def __init__(self, code: str, message: str) -> None: + """Carry a stable code beside the message, because the two have different readers. + + The message says what is wrong in English and is for a log; the code is + what the websocket hands the frontend, so it has to survive rewording. + """ + super().__init__(message) + self.code = code + + +def _validate_state_class(value: object, context: RowContext) -> SensorStateClass: + if context.platform is not Platform.SENSOR or context.datatype not in NUMERIC_DATATYPES: + raise CurationError( + "invalid_state_class", + f"a state class needs a numeric sensor row, not {context.platform.value}" + f"/{context.datatype}", + ) + try: + return SensorStateClass(str(value)) + except ValueError as err: + raise CurationError("invalid_state_class", f"unknown state class {value!r}") from err + + +def _validate_device_class(value: object, context: RowContext) -> str: + if context.platform is Platform.BINARY_SENSOR: + try: + return BinarySensorDeviceClass(str(value)).value + except ValueError as err: + raise CurationError( + "invalid_device_class", f"unknown binary sensor device class {value!r}" + ) from err + if context.platform is not Platform.SENSOR: + raise CurationError( + "invalid_field_for_platform", + "a control row accepts prominence only, not a device class", + ) + try: + device_class = SensorDeviceClass(str(value)) + except ValueError as err: + raise CurationError("invalid_device_class", f"unknown device class {value!r}") from err + constrained = DEVICE_CLASS_UNITS.get(device_class) + if constrained is not None and context.unit not in constrained: + raise CurationError( + "incompatible_device_class", + f"{device_class.value} does not admit the declared unit {context.unit!r}", + ) + return device_class.value + + +def validate_record(raw: Mapping[str, object], context: RowContext) -> CurationRecord: + """Turn a websocket record payload into a `CurationRecord`, or refuse it. + + Refuse rather than warn: a stored record is applied unattended at every + future setup. Cross-field checks only -- field shape and enum membership + are already constrained in the websocket schema, and are re-checked here + because the same validator also re-runs at construction (see `sanitise`). + """ + state_class: SensorStateClass | None = None + device_class: str | None = None + promote = False + if "state_class" in raw: + if context.platform not in (Platform.SENSOR, Platform.BINARY_SENSOR): + raise CurationError( + "invalid_field_for_platform", "a control row accepts prominence only" + ) + state_class = _validate_state_class(raw["state_class"], context) + if "device_class" in raw: + device_class = _validate_device_class(raw["device_class"], context) + if "entity_category" in raw: + if raw["entity_category"] != PROMOTED: + raise CurationError("invalid_entity_category", f"only {PROMOTED!r} is storable") + promote = True + return CurationRecord(state_class=state_class, device_class=device_class, promote=promote) + + +def record_as_dict(record: CurationRecord) -> dict[str, str]: + """Return the storable/wire form of a record: present fields only.""" + raw: dict[str, str] = {} + if record.state_class is not None: + raw["state_class"] = record.state_class.value + if record.device_class is not None: + raw["device_class"] = record.device_class + if record.promote: + raw["entity_category"] = PROMOTED + return raw + + +def parse_record(raw: object) -> CurationRecord | None: + """Read one stored record, or `None` when the disk holds something else. + + Shape-only: staleness against the wire is `sanitise`'s job, because the + wire is not available at load time and may change between loads. + """ + if not isinstance(raw, Mapping): + return None + known = {"state_class", "device_class", "entity_category"} + if not raw or not set(raw) <= known: + return None + state_class: SensorStateClass | None = None + if "state_class" in raw: + try: + state_class = SensorStateClass(str(raw["state_class"])) + except ValueError: + return None + device_class = str(raw["device_class"]) if "device_class" in raw else None + if "entity_category" in raw and raw["entity_category"] != PROMOTED: + return None + return CurationRecord( + state_class=state_class, device_class=device_class, promote="entity_category" in raw + ) + + +def sanitise(record: CurationRecord, context: RowContext) -> tuple[CurationRecord, tuple[str, ...]]: + """Drop the fields of a record the current declaration no longer supports. + + A record can go stale between save and a later setup -- the vendor may + change a row's unit or datatype. Each field is re-validated independently + and a failing one is dropped rather than failing the record: the wire may + revert, and the user's other assertions are still good. + """ + kept: dict[str, object] = {} + dropped: list[str] = [] + for field, value in record_as_dict(record).items(): + try: + validate_record({field: value}, context) + except CurationError: + dropped.append(field) + else: + kept[field] = value + return validate_record(kept, context) if kept else CurationRecord(), tuple(dropped) + + +def allowed_state_classes(context: RowContext) -> list[str]: + """Return the state classes a row of this shape may assert. Empty off numeric sensors.""" + if context.platform is not Platform.SENSOR or context.datatype not in NUMERIC_DATATYPES: + return [] + return [cls.value for cls in SensorStateClass] + + +def allowed_device_classes(context: RowContext) -> list[str]: + """Return the device classes compatible with this row's platform and declared unit.""" + if context.platform is Platform.BINARY_SENSOR: + return [cls.value for cls in BinarySensorDeviceClass] + if context.platform is not Platform.SENSOR: + return [] + allowed: list[str] = [] + for device_class in SensorDeviceClass: + constrained = DEVICE_CLASS_UNITS.get(device_class) + if constrained is None or context.unit in constrained: + allowed.append(device_class.value) + return allowed diff --git a/custom_components/span_panel/extension.py b/custom_components/span_panel/extension.py index 4e0eca55..7ab58890 100644 --- a/custom_components/span_panel/extension.py +++ b/custom_components/span_panel/extension.py @@ -38,7 +38,6 @@ from span_panel_api import ExtensionProperty, ExtensionSubject, SpanPanelSnapshot from .adoption import ( - BOOLEAN_DATATYPE, DEVICE_CLASS_BY_UNIT, clamp_state, homie_boolean, @@ -50,6 +49,7 @@ from .notices import async_raise_on_change, read_translations from .util import ( ADOPTED_IDENTIFIER_TOKEN, + BOOLEAN_DATATYPE, SUB_DEVICE_BESS, SUB_DEVICE_EVSE, SUB_DEVICE_MID, diff --git a/custom_components/span_panel/util.py b/custom_components/span_panel/util.py index 6243005f..210795bc 100644 --- a/custom_components/span_panel/util.py +++ b/custom_components/span_panel/util.py @@ -38,6 +38,19 @@ SUB_DEVICE_EVSE: Final = "evse" SUB_DEVICE_PV: Final = "pv" +BOOLEAN_DATATYPE: Final = "boolean" +ENUM_DATATYPE: Final = "enum" +NUMERIC_DATATYPES: Final = frozenset({"float", "integer"}) +"""The wire datatypes an eBus `$datatype` declares, as far as this integration reads them. + +Here rather than in `adoption.py`, where they were first written, because three +modules now decide something from a declared datatype and only one of them is +about adopting a device: `extension.py` picks a platform from it, and +`curation.py` refuses a state class off a row that is not numeric. `util.py` is +the module all three already import from, so this is the one home that does not +make a reader of one feature import the other. +""" + ADOPTED_IDENTIFIER_TOKEN: Final = "adopted" """The infix marking a sub-device identifier as adopted rather than curated. diff --git a/tests/test_curation.py b/tests/test_curation.py new file mode 100644 index 00000000..1b1a51f6 --- /dev/null +++ b/tests/test_curation.py @@ -0,0 +1,110 @@ +"""Curation records: parsing, validation, sanitisation, and the description helpers.""" + +import pytest + +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import Platform + +from custom_components.span_panel.curation import ( + CurationError, + CurationRecord, + RowContext, + allowed_device_classes, + allowed_state_classes, + parse_record, + record_as_dict, + sanitise, + validate_record, +) + +SENSOR_FLOAT_V = RowContext(platform=Platform.SENSOR, datatype="float", unit="V") +SENSOR_STRING = RowContext(platform=Platform.SENSOR, datatype="string", unit=None) +BINARY = RowContext(platform=Platform.BINARY_SENSOR, datatype="boolean", unit=None) +SWITCH = RowContext(platform=Platform.SWITCH, datatype="boolean", unit=None) + + +def test_a_numeric_sensor_row_accepts_a_state_class() -> None: + record = validate_record({"state_class": "measurement"}, SENSOR_FLOAT_V) + assert record.state_class is SensorStateClass.MEASUREMENT + + +def test_a_string_sensor_row_refuses_a_state_class() -> None: + with pytest.raises(CurationError) as err: + validate_record({"state_class": "measurement"}, SENSOR_STRING) + assert err.value.code == "invalid_state_class" + + +def test_a_binary_row_refuses_a_state_class() -> None: + with pytest.raises(CurationError) as err: + validate_record({"state_class": "measurement"}, BINARY) + assert err.value.code == "invalid_state_class" + + +def test_a_control_row_accepts_only_promotion() -> None: + record = validate_record({"entity_category": "none"}, SWITCH) + assert record.promote is True + with pytest.raises(CurationError) as err: + validate_record({"device_class": "power", "entity_category": "none"}, SWITCH) + assert err.value.code == "invalid_field_for_platform" + + +def test_a_device_class_incompatible_with_the_wire_unit_is_refused() -> None: + with pytest.raises(CurationError) as err: + validate_record({"device_class": "temperature"}, SENSOR_FLOAT_V) + assert err.value.code == "incompatible_device_class" + + +def test_a_device_class_compatible_with_the_wire_unit_is_accepted() -> None: + record = validate_record({"device_class": "voltage"}, SENSOR_FLOAT_V) + assert record.device_class == "voltage" + + +def test_an_unknown_device_class_is_refused() -> None: + with pytest.raises(CurationError) as err: + validate_record({"device_class": "not-a-class"}, SENSOR_FLOAT_V) + assert err.value.code == "invalid_device_class" + + +def test_a_binary_row_takes_binary_device_classes_only() -> None: + assert validate_record({"device_class": "problem"}, BINARY).device_class == "problem" + with pytest.raises(CurationError): + validate_record({"device_class": "voltage"}, BINARY) + + +def test_an_entity_category_other_than_none_is_refused() -> None: + with pytest.raises(CurationError) as err: + validate_record({"entity_category": "config"}, SENSOR_FLOAT_V) + assert err.value.code == "invalid_entity_category" + + +def test_sanitise_drops_a_field_the_wire_no_longer_supports_and_keeps_the_rest() -> None: + record = CurationRecord( + state_class=SensorStateClass.MEASUREMENT, device_class="voltage", promote=True + ) + sanitised, dropped = sanitise(record, SENSOR_STRING) + assert sanitised.state_class is None + assert sanitised.device_class is None + assert sanitised.promote is True + assert set(dropped) == {"state_class", "device_class"} + + +def test_allowed_state_classes_are_empty_off_numeric_sensor_rows() -> None: + assert allowed_state_classes(SENSOR_FLOAT_V) == [cls.value for cls in SensorStateClass] + assert allowed_state_classes(SENSOR_STRING) == [] + assert allowed_state_classes(SWITCH) == [] + + +def test_allowed_device_classes_respect_the_wire_unit() -> None: + allowed = allowed_device_classes(SENSOR_FLOAT_V) + assert "voltage" in allowed + assert "temperature" not in allowed + assert allowed_device_classes(SWITCH) == [] + + +def test_record_round_trips_through_its_dict_form() -> None: + record = CurationRecord( + state_class=SensorStateClass.TOTAL_INCREASING, device_class="energy", promote=True + ) + assert parse_record(record_as_dict(record)) == record + assert parse_record({"unknown": "shape"}) is None + assert parse_record("not a mapping") is None From 0d9ee30c1bbf76a236a2a37f993a0dfbd23ff290 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:21:29 -0700 Subject: [PATCH 226/345] test(curation): cover the refusal paths the validation core is built on --- tests/test_curation.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_curation.py b/tests/test_curation.py index 1b1a51f6..b8662abc 100644 --- a/tests/test_curation.py +++ b/tests/test_curation.py @@ -2,6 +2,7 @@ import pytest +from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import Platform @@ -108,3 +109,25 @@ def test_record_round_trips_through_its_dict_form() -> None: assert parse_record(record_as_dict(record)) == record assert parse_record({"unknown": "shape"}) is None assert parse_record("not a mapping") is None + + +def test_an_unknown_state_class_is_refused_on_a_row_that_could_carry_one() -> None: + with pytest.raises(CurationError) as err: + validate_record({"state_class": "not-a-state-class"}, SENSOR_FLOAT_V) + assert err.value.code == "invalid_state_class" + + +def test_a_control_row_refuses_a_state_class_before_reading_it() -> None: + with pytest.raises(CurationError) as err: + validate_record({"state_class": "measurement"}, SWITCH) + assert err.value.code == "invalid_field_for_platform" + + +def test_a_stored_record_the_wire_vocabulary_has_outgrown_reads_as_absent() -> None: + assert parse_record({"state_class": "no-longer-a-state-class"}) is None + assert parse_record({"entity_category": "diagnostic"}) is None + assert parse_record({}) is None + + +def test_a_binary_row_is_offered_every_binary_device_class() -> None: + assert allowed_device_classes(BINARY) == [cls.value for cls in BinarySensorDeviceClass] From 0447aad984f63014827288ef2fdfe5e102031efa Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:33:00 -0700 Subject: [PATCH 227/345] feat(curation): per-entry storage overlay with stale-record sanitisation --- custom_components/span_panel/curation.py | 107 ++++++++++++++++++++++- tests/test_curation.py | 89 +++++++++++++++++++ 2 files changed, 195 insertions(+), 1 deletion(-) diff --git a/custom_components/span_panel/curation.py b/custom_components/span_panel/curation.py index a6d48c91..50b31a0d 100644 --- a/custom_components/span_panel/curation.py +++ b/custom_components/span_panel/curation.py @@ -18,7 +18,7 @@ from collections.abc import Mapping from dataclasses import dataclass import logging -from typing import Final +from typing import TYPE_CHECKING, Final, TypedDict from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass @@ -28,9 +28,15 @@ # mypy. `.const` is where it is actually defined and is the path that type-checks. from homeassistant.components.sensor.const import DEVICE_CLASS_UNITS from homeassistant.const import Platform +from homeassistant.helpers.storage import Store +from .const import DOMAIN from .util import NUMERIC_DATATYPES +if TYPE_CHECKING: + from homeassistant.config_entries import ConfigEntry + from homeassistant.core import HomeAssistant + _LOGGER = logging.getLogger(__name__) PROMOTED: Final = "none" @@ -209,3 +215,102 @@ def allowed_device_classes(context: RowContext) -> list[str]: if constrained is None or context.unit in constrained: allowed.append(device_class.value) return allowed + + +_STORE_VERSION: Final = 1 + + +class StoredCuration(TypedDict): + """The one shape this module writes to disk.""" + + records: dict[str, dict[str, str]] + + +def _store(hass: HomeAssistant, entry: ConfigEntry) -> Store[StoredCuration]: + """Per entry, exactly as `additions._store` is: two panels curate independently.""" + return Store(hass, _STORE_VERSION, f"{DOMAIN}.curation.{entry.entry_id}") + + +class CurationOverlay: + """Every stored record for one entry, resolved once per setup.""" + + def __init__(self, records: Mapping[str, CurationRecord]) -> None: + """Take a copy: the overlay is read once at setup and never re-reads the disk.""" + self._records = dict(records) + + @classmethod + def empty(cls) -> CurationOverlay: + """Return the overlay for an entry that has curated nothing.""" + return cls({}) + + def record_for(self, key: str) -> CurationRecord | None: + """Return the record as stored, unmeasured against any row -- see `for_row`.""" + return self._records.get(key) + + def for_row(self, key: str, context: RowContext) -> CurationRecord | None: + """Return the record as it applies to the row's *current* declaration. + + Curation must never block setup: a field the wire no longer supports + is dropped with one warning, never raised. + """ + record = self._records.get(key) + if record is None: + return None + sanitised, dropped = sanitise(record, context) + if dropped: + _LOGGER.warning( + "Curation for %s no longer fits the published declaration; ignoring %s", + key, + ", ".join(dropped), + ) + return sanitised + + def stale_fields(self, key: str, context: RowContext) -> tuple[str, ...]: + """Name the fields `for_row` would drop, so the editor can say which are stale.""" + record = self._records.get(key) + if record is None: + return () + return sanitise(record, context)[1] + + def as_dicts(self) -> dict[str, dict[str, str]]: + """Enum values and keys only -- safe for diagnostics and the list command.""" + return {key: record_as_dict(record) for key, record in sorted(self._records.items())} + + +async def async_load_curation(hass: HomeAssistant, entry: ConfigEntry) -> CurationOverlay: + """Read the overlay off disk. A store this module did not write loads as empty. + + Awaited from `async_setup_entry`, so nothing here may raise for bad disk + content -- see `additions._load` for the incident that rule comes from. + """ + stored = await _store(hass, entry).async_load() + records: dict[str, CurationRecord] = {} + raw_records = stored.get("records") if isinstance(stored, dict) else None + if isinstance(raw_records, dict): + for key, raw in raw_records.items(): + record = parse_record(raw) + if isinstance(key, str) and record is not None: + records[key] = record + else: + _LOGGER.warning("Discarding unreadable curation record under %r", key) + elif stored is not None: + _LOGGER.warning("Curation store is not the shape this integration writes; starting empty") + return CurationOverlay(records) + + +async def async_save_record( + hass: HomeAssistant, entry: ConfigEntry, key: str, record: CurationRecord | None +) -> None: + """Write one record (or clear one) and leave every other key untouched.""" + store = _store(hass, entry) + stored = await store.async_load() + raw_records: dict[str, dict[str, str]] = {} + if isinstance(stored, dict) and isinstance(stored.get("records"), dict): + for existing_key, raw in stored["records"].items(): + if isinstance(existing_key, str) and parse_record(raw) is not None: + raw_records[existing_key] = dict(raw) + if record is None: + raw_records.pop(key, None) + else: + raw_records[key] = record_as_dict(record) + await store.async_save({"records": raw_records}) diff --git a/tests/test_curation.py b/tests/test_curation.py index b8662abc..bbc852ca 100644 --- a/tests/test_curation.py +++ b/tests/test_curation.py @@ -1,17 +1,23 @@ """Curation records: parsing, validation, sanitisation, and the description helpers.""" +from unittest.mock import MagicMock + import pytest from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import Platform +from homeassistant.core import HomeAssistant from custom_components.span_panel.curation import ( CurationError, + CurationOverlay, CurationRecord, RowContext, allowed_device_classes, allowed_state_classes, + async_load_curation, + async_save_record, parse_record, record_as_dict, sanitise, @@ -131,3 +137,86 @@ def test_a_stored_record_the_wire_vocabulary_has_outgrown_reads_as_absent() -> N def test_a_binary_row_is_offered_every_binary_device_class() -> None: assert allowed_device_classes(BINARY) == [cls.value for cls in BinarySensorDeviceClass] + + +# The store. Loading is awaited from `async_setup_entry`, so every shape the disk +# is allowed to hold has to end in an overlay rather than an exception -- these +# are the shapes, not a survey of them. + + +def _entry() -> MagicMock: + entry = MagicMock() + entry.entry_id = "test-entry" + return entry + + +async def test_save_load_round_trip(hass: HomeAssistant) -> None: + entry = _entry() + record = CurationRecord(state_class=SensorStateClass.MEASUREMENT, device_class="voltage") + await async_save_record(hass, entry, "bess/battery-2/cell-voltage", record) + overlay = await async_load_curation(hass, entry) + assert overlay.record_for("bess/battery-2/cell-voltage") == record + assert overlay.as_dicts() == { + "bess/battery-2/cell-voltage": {"state_class": "measurement", "device_class": "voltage"} + } + + +async def test_saving_none_clears_the_record(hass: HomeAssistant) -> None: + entry = _entry() + await async_save_record(hass, entry, "bess/b/p", CurationRecord(promote=True)) + await async_save_record(hass, entry, "bess/b/p", None) + overlay = await async_load_curation(hass, entry) + assert overlay.record_for("bess/b/p") is None + + +async def test_a_wrong_shaped_store_loads_as_empty( + hass: HomeAssistant, hass_storage: dict[str, object] +) -> None: + hass_storage["span_panel.curation.test-entry"] = { + "version": 1, + "data": {"records": "not a mapping"}, + } + overlay = await async_load_curation(hass, _entry()) + assert overlay.as_dicts() == {} + + +async def test_an_unreadable_record_is_skipped_not_fatal( + hass: HomeAssistant, hass_storage: dict[str, object] +) -> None: + hass_storage["span_panel.curation.test-entry"] = { + "version": 1, + "data": {"records": {"good/b/p": {"entity_category": "none"}, "bad/b/p": {"x": 1}}}, + } + overlay = await async_load_curation(hass, _entry()) + assert overlay.record_for("good/b/p") == CurationRecord(promote=True) + assert overlay.record_for("bad/b/p") is None + + +def test_for_row_sanitises_and_stale_fields_reports() -> None: + overlay = CurationOverlay( + {"k": CurationRecord(state_class=SensorStateClass.MEASUREMENT, promote=True)} + ) + sanitised = overlay.for_row("k", SENSOR_STRING) + assert sanitised == CurationRecord(promote=True) + assert overlay.stale_fields("k", SENSOR_STRING) == ("state_class",) + assert overlay.for_row("missing", SENSOR_STRING) is None + assert overlay.stale_fields("missing", SENSOR_STRING) == () + + +def test_an_empty_overlay_answers_for_a_row_it_has_never_heard_of() -> None: + """What a setup that stored nothing runs against, and every row goes through it.""" + overlay = CurationOverlay.empty() + assert overlay.as_dicts() == {} + assert overlay.for_row("bess/b/p", SENSOR_STRING) is None + + +def test_a_dropped_field_is_named_in_the_warning_it_logs( + caplog: pytest.LogCaptureFixture, +) -> None: + """The line is all the user gets: their assertion stops applying and nothing else says so.""" + overlay = CurationOverlay( + {"bess/battery-2/cell-voltage": CurationRecord(device_class="voltage")} + ) + assert overlay.for_row("bess/battery-2/cell-voltage", SENSOR_STRING) == CurationRecord() + assert "bess/battery-2/cell-voltage" in caplog.text + assert "device_class" in caplog.text From 6451a839f786e7343e2fe44a1f371e36605bbd64 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:39:21 -0700 Subject: [PATCH 228/345] fix(curation): a record asserting nothing clears the key instead of storing an unreadable one --- custom_components/span_panel/curation.py | 17 +++++++++++++---- tests/test_curation.py | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/custom_components/span_panel/curation.py b/custom_components/span_panel/curation.py index 50b31a0d..bd07251b 100644 --- a/custom_components/span_panel/curation.py +++ b/custom_components/span_panel/curation.py @@ -301,7 +301,15 @@ async def async_load_curation(hass: HomeAssistant, entry: ConfigEntry) -> Curati async def async_save_record( hass: HomeAssistant, entry: ConfigEntry, key: str, record: CurationRecord | None ) -> None: - """Write one record (or clear one) and leave every other key untouched.""" + """Write one record (or clear one) and leave every other key untouched. + + A record asserting nothing clears the key rather than being stored, because + its stored form is `{}` and `parse_record` refuses that. Writing it would + leave a record on disk that the next load reports as unreadable -- the + warning meant for a damaged or hand-edited store -- and the save after that + would delete, over a value this signature accepts. Save may not write what + load rejects. + """ store = _store(hass, entry) stored = await store.async_load() raw_records: dict[str, dict[str, str]] = {} @@ -309,8 +317,9 @@ async def async_save_record( for existing_key, raw in stored["records"].items(): if isinstance(existing_key, str) and parse_record(raw) is not None: raw_records[existing_key] = dict(raw) - if record is None: - raw_records.pop(key, None) + fields = record_as_dict(record) if record is not None else {} + if fields: + raw_records[key] = fields else: - raw_records[key] = record_as_dict(record) + raw_records.pop(key, None) await store.async_save({"records": raw_records}) diff --git a/tests/test_curation.py b/tests/test_curation.py index bbc852ca..88a39356 100644 --- a/tests/test_curation.py +++ b/tests/test_curation.py @@ -169,6 +169,23 @@ async def test_saving_none_clears_the_record(hass: HomeAssistant) -> None: assert overlay.record_for("bess/b/p") is None +async def test_a_record_that_asserts_nothing_clears_rather_than_being_written( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Saving `CurationRecord()` may not leave the one shape the loader calls damaged. + + Its stored form is `{}`, which `parse_record` refuses -- so writing it would + put a record on disk that the next load reports as unreadable and the save + after that silently deletes, over a value the signature accepts. + """ + entry = _entry() + await async_save_record(hass, entry, "bess/b/p", CurationRecord(promote=True)) + await async_save_record(hass, entry, "bess/b/p", CurationRecord()) + overlay = await async_load_curation(hass, entry) + assert overlay.as_dicts() == {} + assert "unreadable" not in caplog.text + + async def test_a_wrong_shaped_store_loads_as_empty( hass: HomeAssistant, hass_storage: dict[str, object] ) -> None: From 7da69d33de1b3cc1b9bac3f60927a1443170d67c Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:45:53 -0700 Subject: [PATCH 229/345] feat(curation): description helpers; AST guard added for extension.py --- custom_components/span_panel/curation.py | 46 ++++++++++++++++++++++-- tests/test_curation.py | 40 +++++++++++++++++++-- tests/test_extension_entities.py | 23 ++++++++++++ 3 files changed, 104 insertions(+), 5 deletions(-) diff --git a/custom_components/span_panel/curation.py b/custom_components/span_panel/curation.py index bd07251b..1b6e5859 100644 --- a/custom_components/span_panel/curation.py +++ b/custom_components/span_panel/curation.py @@ -21,13 +21,17 @@ from typing import TYPE_CHECKING, Final, TypedDict from homeassistant.components.binary_sensor import BinarySensorDeviceClass -from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntityDescription, + SensorStateClass, +) # `homeassistant.components.sensor` re-exports this at runtime but leaves it out # of its `__all__`, so the package-level import is an `attr-defined` error under # mypy. `.const` is where it is actually defined and is the path that type-checks. from homeassistant.components.sensor.const import DEVICE_CLASS_UNITS -from homeassistant.const import Platform +from homeassistant.const import EntityCategory, Platform from homeassistant.helpers.storage import Store from .const import DOMAIN @@ -323,3 +327,41 @@ async def async_save_record( else: raw_records.pop(key, None) await store.async_save({"records": raw_records}) + + +def sensor_description( + path: str, + unit: str | None, + default_device_class: SensorDeviceClass | None, + record: CurationRecord | None, +) -> SensorEntityDescription: + """Return the description a (possibly curated) adopted sensor is built from. + + The one place in the integration where a `state_class` reaches an adopted + entity. `adoption.py` and `extension.py` call this rather than building + their own description, which is what keeps their AST guards true. + """ + device_class = default_device_class + state_class: SensorStateClass | None = None + if record is not None: + if record.device_class is not None: + device_class = SensorDeviceClass(record.device_class) + state_class = record.state_class + return SensorEntityDescription( + key=path, + device_class=device_class, + native_unit_of_measurement=unit, + state_class=state_class, + ) + + +def binary_sensor_device_class(record: CurationRecord | None) -> BinarySensorDeviceClass | None: + """Return a curated binary device class, or none -- there is no unit map to default from.""" + if record is None or record.device_class is None: + return None + return BinarySensorDeviceClass(record.device_class) + + +def entity_category_for(record: CurationRecord | None) -> EntityCategory | None: + """Return DIAGNOSTIC unless the user explicitly promoted this row.""" + return None if record is not None and record.promote else EntityCategory.DIAGNOSTIC diff --git a/tests/test_curation.py b/tests/test_curation.py index 88a39356..3180e5cf 100644 --- a/tests/test_curation.py +++ b/tests/test_curation.py @@ -2,12 +2,11 @@ from unittest.mock import MagicMock -import pytest - from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass -from homeassistant.const import Platform +from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant +import pytest from custom_components.span_panel.curation import ( CurationError, @@ -18,9 +17,12 @@ allowed_state_classes, async_load_curation, async_save_record, + binary_sensor_device_class, + entity_category_for, parse_record, record_as_dict, sanitise, + sensor_description, validate_record, ) @@ -237,3 +239,35 @@ def test_a_dropped_field_is_named_in_the_warning_it_logs( assert overlay.for_row("bess/battery-2/cell-voltage", SENSOR_STRING) == CurationRecord() assert "bess/battery-2/cell-voltage" in caplog.text assert "device_class" in caplog.text + + +# The description helpers. Every curated value reaches an entity through one of +# these three, which is what lets `adoption.py` and `extension.py` stay free of +# the tokens their AST guards forbid. + + +def test_sensor_description_without_a_record_matches_todays_behaviour() -> None: + description = sensor_description("b/p", "V", SensorDeviceClass.VOLTAGE, None) + assert description.state_class is None + assert description.device_class is SensorDeviceClass.VOLTAGE + assert description.native_unit_of_measurement == "V" + + +def test_a_record_supplies_state_class_and_overrides_the_default_device_class() -> None: + record = CurationRecord(state_class=SensorStateClass.MEASUREMENT, device_class="energy") + description = sensor_description("b/p", "Wh", SensorDeviceClass.ENERGY, record) + assert description.state_class is SensorStateClass.MEASUREMENT + assert description.device_class is SensorDeviceClass.ENERGY + + +def test_entity_category_promotes_only_on_an_explicit_record() -> None: + assert entity_category_for(None) is EntityCategory.DIAGNOSTIC + assert entity_category_for(CurationRecord()) is EntityCategory.DIAGNOSTIC + assert entity_category_for(CurationRecord(promote=True)) is None + + +def test_binary_sensor_device_class_reads_the_record_only() -> None: + assert binary_sensor_device_class(None) is None + assert binary_sensor_device_class(CurationRecord(device_class="problem")) is ( + BinarySensorDeviceClass.PROBLEM + ) diff --git a/tests/test_extension_entities.py b/tests/test_extension_entities.py index bf943c20..ff9c7182 100644 --- a/tests/test_extension_entities.py +++ b/tests/test_extension_entities.py @@ -9,7 +9,9 @@ from __future__ import annotations +import ast from dataclasses import replace +from pathlib import Path from typing import TYPE_CHECKING from unittest.mock import MagicMock @@ -510,3 +512,24 @@ async def test_the_overflow_record_survives_a_restart( await _notice(hass, entry, snapshot) assert _notification_id(entry) not in dict(hass.data.get("persistent_notification", {})) + + +def test_no_state_class_is_set_anywhere_in_the_extension_module() -> None: + """Assert the no-statistics rule against the syntax, not against an instance. + + This closes the gap the adoption module's guard left: `extension.py` had only + per-instance coverage, so a future branch setting a state class on a platform + no test instantiates would pass everything above. The one module allowed to + spell `state_class` is `curation.py`, where every value comes from a + validated user record. + """ + from custom_components.span_panel import extension + + tree = ast.parse(Path(extension.__file__).read_text(encoding="utf-8")) + keywords = [node.arg for node in ast.walk(tree) if isinstance(node, ast.keyword)] + targets = [node.attr for node in ast.walk(tree) if isinstance(node, ast.Attribute)] + assert "state_class" not in keywords + assert "_attr_state_class" not in targets + assert not [ + node.id for node in ast.walk(tree) if isinstance(node, ast.Name) and "StateClass" in node.id + ] From f40c013c56dd78a3bf074851ad4616472e7fd1cf Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:01:01 -0700 Subject: [PATCH 230/345] feat(curation): load the overlay at setup, ahead of the platforms Read the stored overlay into runtime data before the platforms are forwarded, so every adopted entity is born with its curated metadata rather than having it applied after its first state is written. The field is required rather than defaulted: a setup path that forgets the load has to fail loudly, because an empty overlay is indistinguishable from a user who has curated nothing. Removing the entry forgets the store, as it already forgets the announcement record. The keys are wire addresses rather than registry ids, so a store left behind is one the next entry for the same panel would load and apply. --- custom_components/span_panel/__init__.py | 8 +++ custom_components/span_panel/curation.py | 10 ++++ custom_components/span_panel/runtime.py | 19 +++++-- tests/test_adoption.py | 7 ++- tests/test_bess_telemetry.py | 5 +- tests/test_binary_sensor_platform.py | 5 +- tests/test_circuit_manifest_service.py | 11 ++++ tests/test_curation.py | 32 +++++++++++ tests/test_der_link_health.py | 5 +- tests/test_diagnostics.py | 13 ++++- tests/test_evse_charge_limit.py | 5 +- tests/test_init_helpers.py | 19 +++++-- tests/test_metadata_sweep.py | 5 +- tests/test_panel_ca_pinning.py | 5 +- tests/test_pcs.py | 5 +- tests/test_pv_device.py | 5 +- tests/test_recreate_entity_ids.py | 9 +++- tests/test_rotate_credentials_service.py | 5 ++ tests/test_schema_availability.py | 5 +- tests/test_schema_discovery.py | 5 +- tests/test_schema_repairs.py | 2 + tests/test_sensor_entities.py | 13 +++-- tests/test_sensor_platform.py | 25 ++++++--- tests/test_setup_entry.py | 67 +++++++++++++++++++++++- tests/test_shed_forecast.py | 5 +- tests/test_websocket.py | 11 ++++ 26 files changed, 273 insertions(+), 33 deletions(-) diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py index a1085e86..624604bb 100644 --- a/custom_components/span_panel/__init__.py +++ b/custom_components/span_panel/__init__.py @@ -61,6 +61,7 @@ ) from .control_gate import ControlGate, ControlLock, ControlPolicy from .coordinator import SpanPanelCoordinator +from .curation import async_forget_curation, async_load_curation from .current_monitor import CurrentMonitor from .extension import async_notice_declined_extensions from .frontend import ( @@ -496,6 +497,7 @@ def _on_fatal_transport_error(error: SpanPanelError) -> None: panel_device_id=await ensure_device_registered( hass, entry, snapshot, smart_device_name ), + curation=await async_load_curation(hass, entry), ) # Before the forward, because a sub-device's `via_device_id` has to name a @@ -569,9 +571,15 @@ async def async_remove_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) - The announcement record goes with them, and for a sharper reason: it outliving the entry would mean re-adding the same panel announces none of the entities it recreates, because every one of them is already recorded as announced. + + The curation overlay goes for the same reason. Its keys are wire addresses + rather than registry ids, so a store left behind is one the next entry for + the same panel would load and apply, re-asserting metadata the user is no + longer here to have asked for. """ async_clear_schema_issues(hass, entry) await async_forget_announcements(hass, entry) + await async_forget_curation(hass, entry) await async_forget(hass, entry) diff --git a/custom_components/span_panel/curation.py b/custom_components/span_panel/curation.py index 1b6e5859..34e91cb9 100644 --- a/custom_components/span_panel/curation.py +++ b/custom_components/span_panel/curation.py @@ -329,6 +329,16 @@ async def async_save_record( await store.async_save({"records": raw_records}) +async def async_forget_curation(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Drop the curated records when the entry is removed. + + The keys are wire addresses, not registry ids, so a store left behind would + be picked back up by whatever entry is next added for the same panel -- and + silently re-assert metadata the user removed the panel to be rid of. + """ + await _store(hass, entry).async_remove() + + def sensor_description( path: str, unit: str | None, diff --git a/custom_components/span_panel/runtime.py b/custom_components/span_panel/runtime.py index a6a8942a..d6e7533d 100644 --- a/custom_components/span_panel/runtime.py +++ b/custom_components/span_panel/runtime.py @@ -8,10 +8,12 @@ grew five copies of the same runtime-data guard around five deferred imports. Nothing here imports anything from this package at runtime except -`control_gate`, which is itself a leaf. `SpanPanelCoordinator` is needed only as -an annotation, so it is imported under `TYPE_CHECKING`: a module that wants to -say "this is a SPAN entry" should not have to pull in the coordinator and, -through it, the schema and sensor machinery. +`control_gate` and `curation`, and neither reaches back into the platforms: +`control_gate` sees `const` and `options`, `curation` sees `const` and `util`. +`SpanPanelCoordinator` is needed only as an annotation, so it is imported under +`TYPE_CHECKING`: a module that wants to say "this is a SPAN entry" should not +have to pull in the coordinator and, through it, the schema and sensor +machinery. """ from __future__ import annotations @@ -22,6 +24,7 @@ from homeassistant.config_entries import ConfigEntry from .control_gate import ControlLock, ControlPolicy +from .curation import CurationOverlay if TYPE_CHECKING: from .coordinator import SpanPanelCoordinator @@ -50,6 +53,14 @@ class SpanPanelRuntimeData: # exists, so an id looked up here is one no caller has to handle the absence # of. See `ensure_device_registered`. panel_device_id: str + # The user's curation overlay for adopted entities, loaded from .storage + # before the platforms are forwarded so every adopted entity is *born* with + # its curated metadata -- a state class that arrives after the first state is + # written is a statistics reset rather than a metadata change. Required + # rather than defaulted: a setup path that forgets the load has to fail here, + # because an empty overlay is indistinguishable from a user who has curated + # nothing, and the user's records would still be sitting on disk. + curation: CurationOverlay # Resolved once at setup and read by every control platform, so a single # answer decides which entities exist and which callers may operate them. # Defaulted rather than required because the default *is* the policy an entry diff --git a/tests/test_adoption.py b/tests/test_adoption.py index 043ba004..883931e1 100644 --- a/tests/test_adoption.py +++ b/tests/test_adoption.py @@ -45,6 +45,7 @@ resolve_identifier, ) from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.curation import CurationOverlay from custom_components.span_panel.diagnostics import _adoption from custom_components.span_panel.id_builder import build_panel_unique_id from custom_components.span_panel.number import ( @@ -975,7 +976,11 @@ async def test_a_vendor_format_nothing_can_read_leaves_the_curated_control_stand coordinator.unresolved_paths = frozenset() entry = MockConfigEntry(domain=DOMAIN, data={}, options={}, title="SPAN Panel", unique_id=PANEL_SERIAL) entry.add_to_hass(hass) - entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator, panel_device_id="panel-device-id") + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + ) coordinator.config_entry = entry added: list[object] = [] diff --git a/tests/test_bess_telemetry.py b/tests/test_bess_telemetry.py index 7907ec4b..a52937e8 100644 --- a/tests/test_bess_telemetry.py +++ b/tests/test_bess_telemetry.py @@ -53,6 +53,7 @@ from span_panel_api import SpanPanelSnapshot from custom_components.span_panel import SpanPanelRuntimeData +from custom_components.span_panel.curation import CurationOverlay from custom_components.span_panel.field_paths import ( RESIDUAL_EXEMPT_PATHS, DerivedReason, @@ -118,7 +119,9 @@ def _coordinator(snapshot: SpanPanelSnapshot) -> MagicMock: unique_id=snapshot.serial_number, ) coordinator.config_entry.runtime_data = SpanPanelRuntimeData( - coordinator=coordinator, panel_device_id="panel-device-id" + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) return coordinator diff --git a/tests/test_binary_sensor_platform.py b/tests/test_binary_sensor_platform.py index 5e900f66..c8e5b08c 100644 --- a/tests/test_binary_sensor_platform.py +++ b/tests/test_binary_sensor_platform.py @@ -20,6 +20,7 @@ PANEL_STATUS, SYSTEM_DOOR_STATE, ) +from custom_components.span_panel.curation import CurationOverlay from homeassistant.core import HomeAssistant from .factories import ( @@ -46,7 +47,9 @@ def _make_coordinator(snapshot) -> MagicMock: unique_id=snapshot.serial_number, ) coordinator.config_entry.runtime_data = SpanPanelRuntimeData( - coordinator=coordinator, panel_device_id="panel-device-id" + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) coordinator.async_request_refresh = AsyncMock() return coordinator diff --git a/tests/test_circuit_manifest_service.py b/tests/test_circuit_manifest_service.py index b79da009..58df147c 100644 --- a/tests/test_circuit_manifest_service.py +++ b/tests/test_circuit_manifest_service.py @@ -11,6 +11,7 @@ _async_register_services, ) from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.curation import CurationOverlay from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant @@ -92,6 +93,7 @@ async def test_basic_manifest(self, hass: HomeAssistant): entry.runtime_data = SpanPanelRuntimeData( coordinator=_make_coordinator(snapshot), panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) _register_power_entity( @@ -169,10 +171,12 @@ async def test_multiple_panels(self, hass: HomeAssistant): entry_a.runtime_data = SpanPanelRuntimeData( coordinator=_make_coordinator(snapshot_a), panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) entry_b.runtime_data = SpanPanelRuntimeData( coordinator=_make_coordinator(snapshot_b), panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) _register_power_entity( @@ -216,6 +220,7 @@ async def test_unmapped_tabs_excluded(self, hass: HomeAssistant): entry.runtime_data = SpanPanelRuntimeData( coordinator=_make_coordinator(snapshot), panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) _register_power_entity( @@ -260,6 +265,7 @@ async def test_circuit_without_entity_excluded(self, hass: HomeAssistant): entry.runtime_data = SpanPanelRuntimeData( coordinator=_make_coordinator(snapshot), panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) # Only register entity for one circuit @@ -322,6 +328,7 @@ async def test_all_device_types_included(self, hass: HomeAssistant): entry.runtime_data = SpanPanelRuntimeData( coordinator=_make_coordinator(snapshot), panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) _register_power_entity( @@ -369,6 +376,7 @@ async def test_bess_device_type_mapped_to_battery(self, hass: HomeAssistant): entry.runtime_data = SpanPanelRuntimeData( coordinator=_make_coordinator(snapshot), panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) _register_power_entity( @@ -404,6 +412,7 @@ async def test_panel_with_no_resolvable_circuits_omitted(self, hass: HomeAssista entry.runtime_data = SpanPanelRuntimeData( coordinator=_make_coordinator(snapshot), panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) # No entities registered @@ -436,6 +445,7 @@ async def test_template_uses_min_tab(self, hass: HomeAssistant): entry.runtime_data = SpanPanelRuntimeData( coordinator=_make_coordinator(snapshot), panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) _register_power_entity( @@ -469,6 +479,7 @@ async def test_host_included_from_config_entry(self, hass: HomeAssistant): entry.runtime_data = SpanPanelRuntimeData( coordinator=_make_coordinator(snapshot), panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) _register_power_entity( diff --git a/tests/test_curation.py b/tests/test_curation.py index 3180e5cf..606aba11 100644 --- a/tests/test_curation.py +++ b/tests/test_curation.py @@ -7,7 +7,10 @@ from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry +from custom_components.span_panel import async_remove_entry +from custom_components.span_panel.const import DOMAIN from custom_components.span_panel.curation import ( CurationError, CurationOverlay, @@ -15,6 +18,7 @@ RowContext, allowed_device_classes, allowed_state_classes, + async_forget_curation, async_load_curation, async_save_record, binary_sensor_device_class, @@ -211,6 +215,34 @@ async def test_an_unreadable_record_is_skipped_not_fatal( assert overlay.record_for("bad/b/p") is None +async def test_removing_the_entry_forgets_the_curated_records( + hass: HomeAssistant, hass_storage: dict[str, object] +) -> None: + """The keys are wire addresses, not registry ids. + + A store left behind is one the next entry added for the same panel would + load and apply, re-asserting metadata for a panel the user removed. + """ + entry = _entry() + await async_save_record(hass, entry, "bess/b/p", CurationRecord(promote=True)) + + await async_forget_curation(hass, entry) + + assert "span_panel.curation.test-entry" not in hass_storage + assert (await async_load_curation(hass, entry)).as_dicts() == {} + + +async def test_removing_the_config_entry_is_what_calls_the_forget(hass: HomeAssistant) -> None: + """The store outliving the entry is only prevented if the removal hook says so.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, entry_id="removed-entry", unique_id="sp3-001") + entry.add_to_hass(hass) + await async_save_record(hass, entry, "bess/b/p", CurationRecord(promote=True)) + + await async_remove_entry(hass, entry) + + assert (await async_load_curation(hass, entry)).as_dicts() == {} + + def test_for_row_sanitises_and_stale_fields_reports() -> None: overlay = CurationOverlay( {"k": CurationRecord(state_class=SensorStateClass.MEASUREMENT, promote=True)} diff --git a/tests/test_der_link_health.py b/tests/test_der_link_health.py index 31a5b307..c08f6f7d 100644 --- a/tests/test_der_link_health.py +++ b/tests/test_der_link_health.py @@ -51,6 +51,7 @@ async_setup_entry, ) from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.curation import CurationOverlay from custom_components.span_panel.field_paths import ( RESIDUAL_EXEMPT_PATHS, DerivedReason, @@ -174,7 +175,9 @@ def _coordinator(snapshot: SpanPanelSnapshot) -> MagicMock: unique_id=snapshot.serial_number, ) coordinator.config_entry.runtime_data = SpanPanelRuntimeData( - coordinator=coordinator, panel_device_id="panel-device-id" + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) coordinator.async_request_refresh = AsyncMock() return coordinator diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 9e135ae6..34af0a2e 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -18,6 +18,7 @@ CONF_HOP_PASSPHRASE, DOMAIN, ) +from custom_components.span_panel.curation import CurationOverlay from custom_components.span_panel.diagnostics import ( async_get_config_entry_diagnostics, ) @@ -85,7 +86,11 @@ async def test_config_entry_diagnostics_includes_redacted_runtime_data( title="SPAN Panel", unique_id="sp3-diag-001", ) - entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator, panel_device_id="panel-device-id") + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + ) result = await async_get_config_entry_diagnostics(hass, entry) @@ -178,7 +183,11 @@ async def test_config_entry_diagnostics_omits_optional_sections_when_unavailable coordinator.schema_findings = None entry = MockConfigEntry(domain=DOMAIN, data={}, title="SPAN Panel") - entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator, panel_device_id="panel-device-id") + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + ) result = await async_get_config_entry_diagnostics(hass, entry) diff --git a/tests/test_evse_charge_limit.py b/tests/test_evse_charge_limit.py index 55fd6dd3..802e771e 100644 --- a/tests/test_evse_charge_limit.py +++ b/tests/test_evse_charge_limit.py @@ -39,6 +39,7 @@ from custom_components.span_panel import PLATFORMS, SpanPanelRuntimeData from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.curation import CurationOverlay from custom_components.span_panel.field_paths import ( RESIDUAL_EXEMPT_PATHS, DerivedReason, @@ -202,7 +203,9 @@ def _coordinator(snapshot: SpanPanelSnapshot, client: object | None = None) -> M unique_id=snapshot.serial_number, ) coordinator.config_entry.runtime_data = SpanPanelRuntimeData( - coordinator=coordinator, panel_device_id="panel-device-id" + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) coordinator.async_request_refresh = AsyncMock() return coordinator diff --git a/tests/test_init_helpers.py b/tests/test_init_helpers.py index 327420a2..841f1006 100644 --- a/tests/test_init_helpers.py +++ b/tests/test_init_helpers.py @@ -15,6 +15,7 @@ update_listener, ) from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.curation import CurationOverlay from homeassistant.const import CONF_HOST from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import device_registry as dr @@ -42,7 +43,11 @@ async def test_async_remove_config_entry_device_rejects_main_panel_device( coordinator = MagicMock() coordinator.data = snapshot entry = MockConfigEntry(domain=DOMAIN, data={}) - entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator, panel_device_id="panel-device-id") + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + ) device = MagicMock() device.identifiers = {(DOMAIN, "sp3-main-001")} @@ -57,7 +62,11 @@ async def test_async_remove_config_entry_device_allows_subdevice_removal( coordinator = MagicMock() coordinator.data = snapshot entry = MockConfigEntry(domain=DOMAIN, data={}) - entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator, panel_device_id="panel-device-id") + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + ) device = MagicMock() device.identifiers = {(DOMAIN, "sp3-main-001_evse")} @@ -176,7 +185,11 @@ async def test_async_unload_entry_shuts_down_runtime_data( coordinator = MagicMock() coordinator.async_shutdown = AsyncMock() entry = MockConfigEntry(domain=DOMAIN, data={}, entry_id="entry-789") - entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator, panel_device_id="panel-device-id") + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + ) with patch.object( hass.config_entries, "async_unload_platforms", AsyncMock(return_value=True) diff --git a/tests/test_metadata_sweep.py b/tests/test_metadata_sweep.py index c349adbd..9d6abf58 100644 --- a/tests/test_metadata_sweep.py +++ b/tests/test_metadata_sweep.py @@ -42,6 +42,7 @@ async_setup_entry as binary_sensor_async_setup_entry, ) from custom_components.span_panel.const import DOMAIN, SYSTEM_DOOR_STATE, SYSTEM_WIFI_LINK +from custom_components.span_panel.curation import CurationOverlay from custom_components.span_panel.field_paths import ( RESIDUAL_EXEMPT_PATHS, declared_field_paths, @@ -143,7 +144,9 @@ def _coordinator(snapshot: SpanPanelSnapshot) -> MagicMock: unique_id=snapshot.serial_number, ) coordinator.config_entry.runtime_data = SpanPanelRuntimeData( - coordinator=coordinator, panel_device_id="panel-device-id" + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) return coordinator diff --git a/tests/test_panel_ca_pinning.py b/tests/test_panel_ca_pinning.py index 66ef9bb3..46c6f539 100644 --- a/tests/test_panel_ca_pinning.py +++ b/tests/test_panel_ca_pinning.py @@ -28,6 +28,7 @@ PANEL_STATUS, ) from custom_components.span_panel.coordinator import SpanPanelCoordinator +from custom_components.span_panel.curation import CurationOverlay from custom_components.span_panel.sensor_circuit import SpanCircuitPowerSensor from custom_components.span_panel.sensor_definitions import CIRCUIT_SENSORS @@ -555,7 +556,9 @@ async def test_rotation_goes_over_the_pin_when_the_entry_has_one( ) entry.mock_state(hass, ConfigEntryState.LOADED) entry.runtime_data = SpanPanelRuntimeData( - coordinator=MagicMock(), panel_device_id="panel-device-id" + coordinator=MagicMock(), + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) _async_register_credential_services(hass) diff --git a/tests/test_pcs.py b/tests/test_pcs.py index 5c2cbe01..cad7436b 100644 --- a/tests/test_pcs.py +++ b/tests/test_pcs.py @@ -40,6 +40,7 @@ SpanPanelBinarySensor, SpanPanelBinarySensorEntityDescription, ) +from custom_components.span_panel.curation import CurationOverlay from custom_components.span_panel.field_paths import ( RESIDUAL_EXEMPT_PATHS, DerivedReason, @@ -162,7 +163,9 @@ def _coordinator(snapshot: SpanPanelSnapshot) -> MagicMock: unique_id=snapshot.serial_number, ) coordinator.config_entry.runtime_data = SpanPanelRuntimeData( - coordinator=coordinator, panel_device_id="panel-device-id" + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) return coordinator diff --git a/tests/test_pv_device.py b/tests/test_pv_device.py index e1a23548..cee417be 100644 --- a/tests/test_pv_device.py +++ b/tests/test_pv_device.py @@ -63,6 +63,7 @@ async_setup_entry as binary_sensor_setup_entry, ) from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.curation import CurationOverlay from custom_components.span_panel.field_paths import ( RESIDUAL_EXEMPT_PATHS, Producibility, @@ -248,7 +249,9 @@ async def _install( coordinator = _coordinator(hass, entry, snapshot) entry.runtime_data = SpanPanelRuntimeData( - coordinator=coordinator, panel_device_id=panel_device_id + coordinator=coordinator, + panel_device_id=panel_device_id, + curation=CurationOverlay.empty(), ) await _register(hass, entry, "sensor", sensor_setup_entry) await _register(hass, entry, "binary_sensor", binary_sensor_setup_entry) diff --git a/tests/test_recreate_entity_ids.py b/tests/test_recreate_entity_ids.py index 7504affd..e52ab18f 100644 --- a/tests/test_recreate_entity_ids.py +++ b/tests/test_recreate_entity_ids.py @@ -41,6 +41,7 @@ USE_CIRCUIT_NUMBERS, USE_DEVICE_PREFIX, ) +from custom_components.span_panel.curation import CurationOverlay from custom_components.span_panel.entity import SpanPanelEntity from custom_components.span_panel.id_builder import ( build_circuit_unique_id, @@ -165,7 +166,9 @@ async def load(self, circuit_name: str) -> E: coordinator = _coordinator(self._hass, snapshot, self._entry) self._coordinator = coordinator self._entry.runtime_data = SpanPanelRuntimeData( - coordinator=coordinator, panel_device_id="panel-device-id" + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) self._platform = MockEntityPlatform( @@ -931,7 +934,9 @@ async def test_an_unmapped_tab_sensor_keeps_its_prefix_on_a_no_prefix_install( ) coordinator = _coordinator(hass, snapshot, entry) entry.runtime_data = SpanPanelRuntimeData( - coordinator=coordinator, panel_device_id="panel-device-id" + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) platform = MockEntityPlatform(hass, domain="sensor", platform_name=DOMAIN) diff --git a/tests/test_rotate_credentials_service.py b/tests/test_rotate_credentials_service.py index 2c510db8..b7792261 100644 --- a/tests/test_rotate_credentials_service.py +++ b/tests/test_rotate_credentials_service.py @@ -25,6 +25,7 @@ CONF_PANEL_CA_PEM, DOMAIN, ) +from custom_components.span_panel.curation import CurationOverlay OLD_BROKER_PASSWORD = "old-broker-password" NEW_BROKER_PASSWORD = "new-broker-password" @@ -52,6 +53,7 @@ def _add_v2_entry(hass: HomeAssistant) -> MockConfigEntry: entry.runtime_data = SpanPanelRuntimeData( coordinator=MagicMock(), panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) return entry @@ -226,6 +228,7 @@ async def test_no_v2_entry_is_reported(hass: HomeAssistant) -> None: entry.runtime_data = SpanPanelRuntimeData( coordinator=MagicMock(), panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) _async_register_credential_services(hass) @@ -251,6 +254,7 @@ async def test_config_entry_id_selects_the_named_panel(hass: HomeAssistant) -> N second.runtime_data = SpanPanelRuntimeData( coordinator=MagicMock(), panel_device_id="panel-device-id-two", + curation=CurationOverlay.empty(), ) _async_register_credential_services(hass) @@ -304,6 +308,7 @@ async def test_two_panels_and_no_id_refuses_rather_than_picking_one( second.runtime_data = SpanPanelRuntimeData( coordinator=MagicMock(), panel_device_id="panel-device-id-two", + curation=CurationOverlay.empty(), ) _async_register_credential_services(hass) diff --git a/tests/test_schema_availability.py b/tests/test_schema_availability.py index 04ab7953..e65b1173 100644 --- a/tests/test_schema_availability.py +++ b/tests/test_schema_availability.py @@ -41,6 +41,7 @@ SYSTEM_ETHERNET_LINK, ) from custom_components.span_panel.coordinator import SpanPanelCoordinator +from custom_components.span_panel.curation import CurationOverlay from custom_components.span_panel.entity import SpanPanelEntity from custom_components.span_panel.field_paths import DerivedReason from custom_components.span_panel.schema_validation import SchemaFindings @@ -78,7 +79,9 @@ def _make_coordinator(hass: HomeAssistant) -> SpanPanelCoordinator: coordinator = SpanPanelCoordinator(hass, cast(SpanMqttClient, MagicMock()), entry) coordinator.data = snapshot entry.runtime_data = SpanPanelRuntimeData( - coordinator=coordinator, panel_device_id="panel-device-id" + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) return coordinator diff --git a/tests/test_schema_discovery.py b/tests/test_schema_discovery.py index 0a3e5e29..b4db4c5b 100644 --- a/tests/test_schema_discovery.py +++ b/tests/test_schema_discovery.py @@ -41,6 +41,7 @@ from custom_components.span_panel import SpanPanelRuntimeData from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.curation import CurationOverlay from custom_components.span_panel.diagnostics import async_get_config_entry_diagnostics from custom_components.span_panel.field_paths import ( RESIDUAL_EXEMPT_PATHS, @@ -187,7 +188,9 @@ def _entry(findings: SchemaFindings | None) -> MockConfigEntry: coordinator.schema_findings = findings entry = MockConfigEntry(domain=DOMAIN, title="SPAN Panel", unique_id="example-40t-001") entry.runtime_data = SpanPanelRuntimeData( - coordinator=coordinator, panel_device_id="panel-device-id" + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) return entry diff --git a/tests/test_schema_repairs.py b/tests/test_schema_repairs.py index 82e6d229..6b1ec6e1 100644 --- a/tests/test_schema_repairs.py +++ b/tests/test_schema_repairs.py @@ -7,6 +7,7 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.span_panel.const import DOMAIN, EVENT_SCHEMA_ISSUE +from custom_components.span_panel.curation import CurationOverlay from custom_components.span_panel.schema_repairs import ( async_clear_schema_issues, async_sync_schema_issues, @@ -540,6 +541,7 @@ async def _entities_by_declared_path(hass): config_entry.runtime_data = SpanPanelRuntimeData( coordinator=coordinator, panel_device_id=await ensure_device_registered(hass, config_entry, snapshot, "SPAN Panel"), + curation=CurationOverlay.empty(), ) grouped: dict[str, dict[str, list[object]]] = {} diff --git a/tests/test_sensor_entities.py b/tests/test_sensor_entities.py index 3c8b3d18..e01a9e6f 100644 --- a/tests/test_sensor_entities.py +++ b/tests/test_sensor_entities.py @@ -19,6 +19,7 @@ ENABLE_ENERGY_DIP_COMPENSATION, USE_CIRCUIT_NUMBERS, ) +from custom_components.span_panel.curation import CurationOverlay from custom_components.span_panel.options import ENERGY_REPORTING_GRACE_PERIOD from custom_components.span_panel.sensor_base import ( SpanEnergyExtraStoredData, @@ -103,7 +104,9 @@ def _make_coordinator( unique_id=snapshot.serial_number, ) coordinator.config_entry.runtime_data = SpanPanelRuntimeData( - coordinator=coordinator, panel_device_id="panel-device-id" + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) coordinator.request_reload = MagicMock() coordinator.register_circuit_energy_sensor = MagicMock() @@ -738,7 +741,9 @@ def test_energy_sensor_coerces_invalid_grace_period_value() -> None: unique_id=snapshot.serial_number, ) coordinator.config_entry.runtime_data = SpanPanelRuntimeData( - coordinator=coordinator, panel_device_id="panel-device-id" + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) description = next( desc for desc in PANEL_ENERGY_SENSORS if desc.key == "mainMeterEnergyConsumedWh" @@ -810,7 +815,9 @@ def test_evse_sensor_uses_evse_subdevice_info_and_name() -> None: unique_id=snapshot.serial_number, ) coordinator.config_entry.runtime_data = SpanPanelRuntimeData( - coordinator=coordinator, panel_device_id="panel-device-id" + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) description = next(desc for desc in EVSE_SENSORS if desc.key == "evse_status") diff --git a/tests/test_sensor_platform.py b/tests/test_sensor_platform.py index 7bf84535..ab0075c8 100644 --- a/tests/test_sensor_platform.py +++ b/tests/test_sensor_platform.py @@ -16,6 +16,7 @@ ENABLE_UNMAPPED_CIRCUIT_SENSORS, USE_CIRCUIT_NUMBERS, ) +from custom_components.span_panel.curation import CurationOverlay from custom_components.span_panel.sensor import ( _build_evse_device_info_map, async_setup_entry, @@ -240,7 +241,9 @@ def test_build_evse_device_info_map_uses_feed_circuit_and_display_suffix() -> No options={USE_CIRCUIT_NUMBERS: False}, ) coordinator.config_entry.runtime_data = SpanPanelRuntimeData( - coordinator=coordinator, panel_device_id="panel-device-id" + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) mapping = _build_evse_device_info_map(coordinator, snapshot) @@ -281,7 +284,9 @@ def test_create_circuit_sensors_skips_unmapped_and_optional_net_sensors() -> Non ) coordinator.config_entry = entry entry.runtime_data = SpanPanelRuntimeData( - coordinator=coordinator, panel_device_id="panel-device-id" + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) entities = create_circuit_sensors(coordinator, snapshot, entry) @@ -316,7 +321,9 @@ def test_create_unmapped_circuit_sensors_only_creates_unmapped_entities() -> Non domain=DOMAIN, data={}, title="SPAN Panel" ) coordinator.config_entry.runtime_data = SpanPanelRuntimeData( - coordinator=coordinator, panel_device_id="panel-device-id" + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) entities = create_unmapped_circuit_sensors(coordinator, snapshot) @@ -341,7 +348,9 @@ def test_create_battery_sensors_returns_expected_entities_when_bess_present() -> title="SPAN Panel", ) coordinator.config_entry.runtime_data = SpanPanelRuntimeData( - coordinator=coordinator, panel_device_id="panel-device-id" + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) entities = create_battery_sensors(coordinator, snapshot) @@ -369,7 +378,9 @@ def test_create_power_flow_sensors_gate_pv_and_site_flow() -> None: domain=DOMAIN, data={}, title="SPAN Panel" ) coordinator.config_entry.runtime_data = SpanPanelRuntimeData( - coordinator=coordinator, panel_device_id="panel-device-id" + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) entities = create_power_flow_sensors(coordinator, snapshot) @@ -396,7 +407,9 @@ def test_create_evse_sensors_creates_all_descriptions_for_each_charger() -> None domain=DOMAIN, data={}, title="SPAN Panel" ) coordinator.config_entry.runtime_data = SpanPanelRuntimeData( - coordinator=coordinator, panel_device_id="panel-device-id" + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) entities = create_evse_sensors(coordinator, snapshot) diff --git a/tests/test_setup_entry.py b/tests/test_setup_entry.py index 6de75b3e..b0736b40 100644 --- a/tests/test_setup_entry.py +++ b/tests/test_setup_entry.py @@ -28,6 +28,7 @@ DOMAIN, ) from custom_components.span_panel.control_gate import ControlPolicy +from custom_components.span_panel.curation import CurationOverlay, CurationRecord from custom_components.span_panel.options import CONTROL_LOCK_TIMEOUT from .factories import SpanPanelSnapshotFactory @@ -526,6 +527,8 @@ async def test_an_enabled_control_lock_is_armed_before_the_platforms_are_forward assert await async_setup_entry(hass, entry) is True assert entry.runtime_data.control_lock.armed is True + + def test_runtime_data_defaults_its_lock_to_the_default_policys_answer() -> None: """The dataclass default may not contradict the policy it stands in for. @@ -536,6 +539,68 @@ def test_runtime_data_defaults_its_lock_to_the_default_policys_answer() -> None: two together so a change to the default policy cannot silently unlock the entries that never named a lock. """ - runtime_data = SpanPanelRuntimeData(coordinator=MagicMock(), panel_device_id="panel-device-id") + runtime_data = SpanPanelRuntimeData( + coordinator=MagicMock(), + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + ) assert runtime_data.control_lock.armed == ControlPolicy.default().lock_enabled + + +def test_runtime_data_refuses_to_be_built_without_an_overlay() -> None: + """`curation` is required so a setup path that forgets the load fails loudly. + + Defaulting it to an empty overlay would make a missed load indistinguishable + from a user who has curated nothing -- every adopted entity born bare, with + the user's stored assertions still on disk and nothing saying why they stopped + being applied. + """ + with pytest.raises(TypeError, match="curation"): + SpanPanelRuntimeData(coordinator=MagicMock(), panel_device_id="panel-device-id") + + +async def test_setup_hands_the_platforms_the_overlay_that_was_on_disk( + hass: HomeAssistant, hass_storage: dict[str, object] +) -> None: + """Loaded before the platforms are forwarded, so adopted entities are born curated. + + Applying it afterwards would mean every adopted entity exists uncurated for + the length of a setup, and a `state_class` that arrives after the first state + is written is a statistics reset rather than a metadata change. + """ + hass_storage["span_panel.curation.entry-setup"] = { + "version": 1, + "data": {"records": {"bess/battery-2/cell-voltage": {"device_class": "voltage"}}}, + } + entry = _create_v2_entry() + entry.add_to_hass(hass) + client = MagicMock() + client.connect = AsyncMock() + coordinator = MagicMock() + coordinator.async_config_entry_first_refresh = AsyncMock() + coordinator.async_setup_streaming = AsyncMock() + coordinator.data = SpanPanelSnapshotFactory.create(serial_number="sp3-setup-001") + forwarded_overlay: list[CurationOverlay] = [] + + async def _capture(*args: object, **kwargs: object) -> None: + forwarded_overlay.append(entry.runtime_data.curation) + + with ( + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + patch( + "custom_components.span_panel.SpanPanelCoordinator", return_value=coordinator + ), + patch( + "custom_components.span_panel.ensure_device_registered", + AsyncMock(return_value="panel-device-id"), + ), + patch.object(hass.config_entries, "async_forward_entry_setups", _capture), + patch.object(hass.config_entries, "async_update_entry"), + ): + assert await async_setup_entry(hass, entry) is True + + assert forwarded_overlay[0].record_for("bess/battery-2/cell-voltage") == CurationRecord( + device_class="voltage" + ) diff --git a/tests/test_shed_forecast.py b/tests/test_shed_forecast.py index 8597ff1b..4a4edbbd 100644 --- a/tests/test_shed_forecast.py +++ b/tests/test_shed_forecast.py @@ -18,6 +18,7 @@ from span_panel_api import SpanPanelSnapshot from custom_components.span_panel import SpanPanelRuntimeData +from custom_components.span_panel.curation import CurationOverlay from custom_components.span_panel.field_paths import ( RESIDUAL_EXEMPT_PATHS, Producibility, @@ -75,7 +76,9 @@ def _coordinator(snapshot: SpanPanelSnapshot) -> MagicMock: unique_id=snapshot.serial_number, ) coordinator.config_entry.runtime_data = SpanPanelRuntimeData( - coordinator=coordinator, panel_device_id="panel-device-id" + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) return coordinator diff --git a/tests/test_websocket.py b/tests/test_websocket.py index c9fc6bb5..c7aef1f3 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -9,6 +9,7 @@ from custom_components.span_panel import SpanPanelRuntimeData from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.curation import CurationOverlay from custom_components.span_panel.websocket import ( _build_circuit_entity_map, _classify_sensor_role, @@ -400,6 +401,7 @@ async def test_sub_device_id_rejected(self, hass: HomeAssistant): entry.runtime_data = SpanPanelRuntimeData( coordinator=_make_coordinator(SpanPanelSnapshotFactory.create()), panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) panel_device = _register_panel_device( @@ -488,6 +490,7 @@ async def test_successful_topology_basic(self, hass: HomeAssistant): entry.runtime_data = SpanPanelRuntimeData( coordinator=_make_coordinator(snapshot), panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) device = _register_panel_device(hass, "span_entry", serial="sp3-test-001") @@ -552,6 +555,7 @@ async def test_unmapped_circuits_excluded(self, hass: HomeAssistant): entry.runtime_data = SpanPanelRuntimeData( coordinator=_make_coordinator(snapshot), panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) device = _register_panel_device(hass, "span_entry") @@ -582,6 +586,7 @@ async def test_sub_devices_included(self, hass: HomeAssistant): entry.runtime_data = SpanPanelRuntimeData( coordinator=_make_coordinator(snapshot), panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) panel_device = _register_panel_device(hass, "span_entry", serial="sp3-sub-001") @@ -661,6 +666,7 @@ async def test_evse_feed_circuit_entities_found(self, hass: HomeAssistant): entry.runtime_data = SpanPanelRuntimeData( coordinator=_make_coordinator(snapshot), panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) panel_device = _register_panel_device(hass, "span_entry", serial="sp3-evse-001") @@ -735,6 +741,7 @@ async def test_topology_includes_always_on_and_priority(self, hass: HomeAssistan entry.runtime_data = SpanPanelRuntimeData( coordinator=_make_coordinator(snapshot), panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) device = _register_panel_device(hass, "span_entry", serial="sp3-prio-001") @@ -787,6 +794,7 @@ async def test_topology_circuit_record_carries_the_documented_keys(self, hass: H entry.runtime_data = SpanPanelRuntimeData( coordinator=_make_coordinator(snapshot), panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) device = _register_panel_device(hass, "span_entry", serial="sp3-contract-001") @@ -859,6 +867,7 @@ async def test_topology_reports_priority_settability_apart_from_the_relay( entry.runtime_data = SpanPanelRuntimeData( coordinator=_make_coordinator(snapshot), panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) device = _register_panel_device(hass, "span_entry", serial="sp3-backup-001") @@ -895,6 +904,7 @@ async def test_topology_includes_panel_status_entity(self, hass: HomeAssistant): entry.runtime_data = SpanPanelRuntimeData( coordinator=_make_coordinator(snapshot), panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) panel_device = _register_panel_device(hass, "span_entry", serial="sp3-242424-001") @@ -935,6 +945,7 @@ async def test_topology_omits_panel_status_when_entity_missing(self, hass: HomeA entry.runtime_data = SpanPanelRuntimeData( coordinator=_make_coordinator(snapshot), panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), ) panel_device = _register_panel_device(hass, "span_entry", serial="sp3-242424-001") From 78e7b60a73c196f6c7bc3628aa3ea2ec8af3e752 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:15:38 -0700 Subject: [PATCH 231/345] feat(curation): adopted-device entities are born with curated metadata --- custom_components/span_panel/adoption.py | 167 ++++++++++++-- custom_components/span_panel/binary_sensor.py | 1 + custom_components/span_panel/number.py | 1 + custom_components/span_panel/select.py | 1 + custom_components/span_panel/sensor.py | 1 + custom_components/span_panel/switch.py | 1 + tests/test_adoption.py | 205 ++++++++++++++++-- 7 files changed, 339 insertions(+), 38 deletions(-) diff --git a/custom_components/span_panel/adoption.py b/custom_components/span_panel/adoption.py index b8ce72d2..594b93df 100644 --- a/custom_components/span_panel/adoption.py +++ b/custom_components/span_panel/adoption.py @@ -14,16 +14,23 @@ controls do real work, so `extension.py` mints a terminal identity in plain wire vocabulary and surfaces even a settable property as a reading. -**Nothing adopted enters long-term statistics.** No adopted entity carries a -`state_class`, ever. Three reasons, and the third is the one that shapes the +**This module never decides that something enters long-term statistics.** It +spells no `state_class` anywhere -- an AST guard in `tests/test_adoption.py` +asserts the token is absent from the syntax, not merely from the paths a test +happens to construct. Three reasons, and the third is the one that shapes the module: `state_class` is not declared on the wire and is not derivable from one (`feedthroughEnergyProducedWh` is `TOTAL` beside `mainMeterEnergyProducedWh` as `TOTAL_INCREASING` -- same unit, same device class); a wrong one writes corrupt statistics that fixing the producer does not repair; and enrolling a property nobody asked for into long-term statistics is a permanent write to every -install's recorder database. A user who wants statistics from an adopted reading -can wrap it in a template sensor, a Riemann sum or a utility meter, which is -their call to make on an entity they chose to enable. +install's recorder database. + +**The owner of the device may still assert one, and that is a different act.** +A user curating a row is not guessing about their own hardware, and their +assertion arrives here as a `CurationRecord` that `curation.sensor_description` +turns into a description -- which is how a curated row gets a state class +without this module naming the thing it must not infer. Nothing is asserted by +default: an uncurated row is exactly the entity it was before curation existed. **These entities declare no field paths.** `snapshot.adopted_devices` is outside the curated field-path vocabulary by construction: it carries no metadata row, so @@ -43,14 +50,22 @@ from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.components.number import NumberEntity from homeassistant.components.select import SelectEntity -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.components.switch import SwitchEntity -from homeassistant.const import EntityCategory, Platform +from homeassistant.const import Platform from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from span_panel_api import AdoptedDevice, AdoptedProperty, SpanPanelSnapshot from .const import DOMAIN +from .curation import ( + CurationOverlay, + CurationRecord, + RowContext, + binary_sensor_device_class, + entity_category_for, + sensor_description, +) from .entity import SpanPanelEntity from .id_builder import get_user_friendly_suffix from .util import ( @@ -300,6 +315,17 @@ def adopted_unique_id(identifier: str, declaration: AdoptedProperty) -> str: return f"span_{identifier.lower()}_{get_user_friendly_suffix(wire_path)}" +def adopted_curation_key(identifier: str, declaration: AdoptedProperty) -> str: + """Return the curation-store key for one adopted property. + + The frozen registry identifier plus the `{node}/{property}` path, hyphens + preserved -- injective where `adopted_unique_id` deliberately is not, + because nothing here flattens. Scope-prefixed because `path` alone is + unique only within one device. + """ + return f"{identifier}/{declaration.path}" + + def adopted_device_info( identifier: str, device: AdoptedDevice, @@ -387,13 +413,15 @@ def async_register_adopted_devices( class AdoptedEntity(SpanPanelEntity): """Base for an entity built from a declaration rather than from a description. - Disabled and diagnostic without exception. Adoption's job is to make a device - reachable, not to put it on somebody's dashboard: the user decides what is - worth enabling, having seen the device exists. + Disabled without exception, and diagnostic unless the owner of the device + said otherwise. Adoption's job is to make a device reachable, not to put it + on somebody's dashboard: the user decides what is worth enabling, having + seen the device exists. A curated row is that decision already made, so + `entity_category_for` lets it out of diagnostics -- and leaves it disabled + all the same, because enabling is still their act. """ _attr_entity_registry_enabled_default = False - _attr_entity_category: EntityCategory | None = EntityCategory.DIAGNOSTIC def __init__( self, @@ -403,6 +431,7 @@ def __init__( declaration: AdoptedProperty, *, panel_device_id: str, + record: CurationRecord | None = None, ) -> None: """Bind this entity to one property of one adopted device.""" super().__init__(coordinator) @@ -413,6 +442,7 @@ def __init__( self._attr_device_info = adopted_device_info( identifier, device, panel_device_id=panel_device_id ) + self._attr_entity_category = entity_category_for(record) def _published(self) -> str | None: """Return this property's current value, or None when the panel publishes none. @@ -432,7 +462,7 @@ def _published(self) -> str | None: class AdoptedSensor(AdoptedEntity, SensorEntity): - """A reading from an adopted device, with no `state_class`.""" + """A reading from an adopted device, described by its declaration and its record.""" def __init__( self, @@ -442,15 +472,27 @@ def __init__( declaration: AdoptedProperty, *, panel_device_id: str, + record: CurationRecord | None = None, ) -> None: - """Take the unit and device class from what the panel declared.""" + """Take the unit and device class from what the panel declared. + + The description is built in `curation` rather than here, which is what + lets a curated row carry the one piece of metadata this module may not + name and still leaves an uncurated row with exactly what it had. + """ super().__init__( - coordinator, identifier, device, declaration, panel_device_id=panel_device_id + coordinator, + identifier, + device, + declaration, + panel_device_id=panel_device_id, + record=record, ) - self.entity_description = SensorEntityDescription( - key=declaration.path, - device_class=DEVICE_CLASS_BY_UNIT.get(declaration.unit or ""), - native_unit_of_measurement=declaration.unit, + self.entity_description = sensor_description( + declaration.path, + declaration.unit, + DEVICE_CLASS_BY_UNIT.get(declaration.unit or ""), + record, ) @property @@ -483,6 +525,34 @@ def native_value(self) -> str | float | None: class AdoptedBinarySensor(AdoptedEntity, BinarySensorEntity): """A declared `boolean` from an adopted device that the panel does not accept writes to.""" + def __init__( + self, + coordinator: SpanPanelCoordinator, + identifier: str, + device: AdoptedDevice, + declaration: AdoptedProperty, + *, + panel_device_id: str, + record: CurationRecord | None = None, + ) -> None: + """Take the device class from the record, because there is nothing to default from. + + A sensor's device class can be read off the declared unit; a boolean + declares no unit, so `door` and `problem` and `running` are + indistinguishable on the wire. An uncurated binary sensor therefore has + no device class at all, and the user's assertion is the only one there + can be. + """ + super().__init__( + coordinator, + identifier, + device, + declaration, + panel_device_id=panel_device_id, + record=record, + ) + self._attr_device_class = binary_sensor_device_class(record) + @property def is_on(self) -> bool | None: """Homie spells a boolean `true`/`false`; anything else is not an answer.""" @@ -506,10 +576,22 @@ def __init__( declaration: AdoptedProperty, *, panel_device_id: str, + record: CurationRecord | None = None, ) -> None: - """Remember the wire address this control publishes to.""" + """Remember the wire address this control publishes to. + + The record reaches a control carrying prominence and nothing else -- + `sanitise` refuses a state class or a device class on a row that is not + a sensor -- so it is passed straight through rather than filtered again + here. + """ super().__init__( - coordinator, identifier, device, declaration, panel_device_id=panel_device_id + coordinator, + identifier, + device, + declaration, + panel_device_id=panel_device_id, + record=record, ) self._node_id = declaration.node_id self._property_id = declaration.property_id @@ -579,10 +661,16 @@ def __init__( declaration: AdoptedProperty, *, panel_device_id: str, + record: CurationRecord | None = None, ) -> None: """Take the option list from the declaration, which is the whole domain.""" super().__init__( - coordinator, identifier, device, declaration, panel_device_id=panel_device_id + coordinator, + identifier, + device, + declaration, + panel_device_id=panel_device_id, + record=record, ) self._attr_options = parse_enum_format(declaration.format) @@ -614,6 +702,7 @@ def __init__( declaration: AdoptedProperty, *, panel_device_id: str, + record: CurationRecord | None = None, ) -> None: """Take the bounds from the declaration, which is what makes this a number. @@ -633,7 +722,12 @@ def __init__( "whose format cannot be read is classified as a sensor" ) super().__init__( - coordinator, identifier, device, declaration, panel_device_id=panel_device_id + coordinator, + identifier, + device, + declaration, + panel_device_id=panel_device_id, + record=record, ) self._attr_native_min_value, self._attr_native_max_value, self._attr_native_step = bounds self._attr_native_unit_of_measurement = declaration.unit @@ -749,6 +843,7 @@ def create_adopted_sensors( registry: DeviceRegistry, *, panel_device_id: str, + overlay: CurationOverlay, ) -> list[AdoptedSensor]: """Every adopted property that is not a declared boolean. @@ -762,6 +857,7 @@ def create_adopted_sensors( registry, Platform.SENSOR, panel_device_id=panel_device_id, + overlay=overlay, ) @@ -771,6 +867,7 @@ def create_adopted_binary_sensors( registry: DeviceRegistry, *, panel_device_id: str, + overlay: CurationOverlay, ) -> list[AdoptedBinarySensor]: """Every adopted property declared `boolean` that the panel accepts no write to.""" return _create( @@ -780,6 +877,7 @@ def create_adopted_binary_sensors( registry, Platform.BINARY_SENSOR, panel_device_id=panel_device_id, + overlay=overlay, ) @@ -789,6 +887,7 @@ def create_adopted_switches( registry: DeviceRegistry, *, panel_device_id: str, + overlay: CurationOverlay, ) -> list[AdoptedSwitch]: """Every adopted property declared `boolean` and settable.""" return _create( @@ -798,6 +897,7 @@ def create_adopted_switches( registry, Platform.SWITCH, panel_device_id=panel_device_id, + overlay=overlay, ) @@ -807,6 +907,7 @@ def create_adopted_selects( registry: DeviceRegistry, *, panel_device_id: str, + overlay: CurationOverlay, ) -> list[AdoptedSelect]: """Every adopted `enum` that is settable and declares its option list.""" return _create( @@ -816,6 +917,7 @@ def create_adopted_selects( registry, Platform.SELECT, panel_device_id=panel_device_id, + overlay=overlay, ) @@ -825,6 +927,7 @@ def create_adopted_numbers( registry: DeviceRegistry, *, panel_device_id: str, + overlay: CurationOverlay, ) -> list[AdoptedNumber]: """Every adopted numeric that is settable and declares its bounds.""" return _create( @@ -834,6 +937,7 @@ def create_adopted_numbers( registry, Platform.NUMBER, panel_device_id=panel_device_id, + overlay=overlay, ) @@ -845,6 +949,7 @@ def _create[AdoptedT: AdoptedEntity]( platform: Platform, *, panel_device_id: str, + overlay: CurationOverlay, ) -> list[AdoptedT]: """Build one platform's share of the adopted properties, one entity per id. @@ -878,6 +983,13 @@ def _create[AdoptedT: AdoptedEntity]( The sort covers a whole device rather than only the colliding pair, because a rule that only applied on collision would still depend on arrival order to decide which pair collided first. + + **The curated record is read through `for_row`, never off the overlay.** The + store keeps whatever the user asserted when they asserted it, and the + declaration it was asserted against can have moved since. `for_row` measures + the record against the declaration in hand and drops what no longer fits; + the raw record would reach `SensorDeviceClass(...)` unchecked and raise + inside `async_setup_entry`, which is the whole platform for one stale row. """ built: list[AdoptedT] = [] claimed: dict[str, str] = {} @@ -900,9 +1012,18 @@ def _create[AdoptedT: AdoptedEntity]( ) continue claimed[unique_id] = declaration.path + record = overlay.for_row( + adopted_curation_key(identifier, declaration), + RowContext(platform=platform, datatype=declaration.datatype, unit=declaration.unit), + ) built.append( entity_class( - coordinator, identifier, device, declaration, panel_device_id=panel_device_id + coordinator, + identifier, + device, + declaration, + panel_device_id=panel_device_id, + record=record, ) ) return built diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py index 6d6268eb..d108eccb 100644 --- a/custom_components/span_panel/binary_sensor.py +++ b/custom_components/span_panel/binary_sensor.py @@ -691,6 +691,7 @@ async def async_setup_entry( snapshot, dr.async_get(hass), panel_device_id=config_entry.runtime_data.panel_device_id, + overlay=config_entry.runtime_data.curation, ), # Vendor extensions on devices this integration *does* model. A # separate inventory from adoption's for the same reason adoption is diff --git a/custom_components/span_panel/number.py b/custom_components/span_panel/number.py index 4eb10249..c56deead 100644 --- a/custom_components/span_panel/number.py +++ b/custom_components/span_panel/number.py @@ -326,5 +326,6 @@ async def async_setup_entry( snapshot, dr.async_get(hass), panel_device_id=config_entry.runtime_data.panel_device_id, + overlay=config_entry.runtime_data.curation, ) ) diff --git a/custom_components/span_panel/select.py b/custom_components/span_panel/select.py index 7a7c848b..dbc49ca3 100644 --- a/custom_components/span_panel/select.py +++ b/custom_components/span_panel/select.py @@ -385,6 +385,7 @@ async def async_setup_entry( coordinator.data, dr.async_get(hass), panel_device_id=config_entry.runtime_data.panel_device_id, + overlay=config_entry.runtime_data.curation, ) ) diff --git a/custom_components/span_panel/sensor.py b/custom_components/span_panel/sensor.py index 98c65120..5a088892 100644 --- a/custom_components/span_panel/sensor.py +++ b/custom_components/span_panel/sensor.py @@ -129,6 +129,7 @@ async def async_setup_entry( snapshot, dr.async_get(hass), panel_device_id=config_entry.runtime_data.panel_device_id, + overlay=config_entry.runtime_data.curation, ) # Vendor extensions on devices this integration *does* model, which diff --git a/custom_components/span_panel/switch.py b/custom_components/span_panel/switch.py index 3024d6c4..8eafaa6f 100644 --- a/custom_components/span_panel/switch.py +++ b/custom_components/span_panel/switch.py @@ -576,6 +576,7 @@ async def async_setup_entry( coordinator.data, dr.async_get(hass), panel_device_id=config_entry.runtime_data.panel_device_id, + overlay=config_entry.runtime_data.curation, ) ) diff --git a/tests/test_adoption.py b/tests/test_adoption.py index 883931e1..9fcad6b3 100644 --- a/tests/test_adoption.py +++ b/tests/test_adoption.py @@ -15,7 +15,8 @@ from typing import TYPE_CHECKING from unittest.mock import AsyncMock, MagicMock -from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, InvalidStateError @@ -45,7 +46,7 @@ resolve_identifier, ) from custom_components.span_panel.const import DOMAIN -from custom_components.span_panel.curation import CurationOverlay +from custom_components.span_panel.curation import CurationOverlay, CurationRecord from custom_components.span_panel.diagnostics import _adoption from custom_components.span_panel.id_builder import build_panel_unique_id from custom_components.span_panel.number import ( @@ -191,6 +192,7 @@ def test_no_adopted_sensor_carries_a_state_class(hass: HomeAssistant) -> None: _snapshot(_device(properties=declarations)), dr.async_get(hass), panel_device_id="panel-device-id", + overlay=CurationOverlay.empty(), ) assert len(entities) == 3 @@ -225,6 +227,7 @@ def test_a_declared_unit_this_integration_knows_gets_a_device_class(hass: HomeAs _snapshot(_device(properties=declarations)), dr.async_get(hass), panel_device_id="panel-device-id", + overlay=CurationOverlay.empty(), ) assert entity.device_class is SensorDeviceClass.POWER @@ -243,6 +246,7 @@ def test_a_unit_outside_the_map_gets_no_device_class(hass: HomeAssistant, unit: _snapshot(_device(properties=declarations)), dr.async_get(hass), panel_device_id="panel-device-id", + overlay=CurationOverlay.empty(), ) assert entity.device_class is None assert unit not in DEVICE_CLASS_BY_UNIT @@ -257,10 +261,15 @@ def test_every_adopted_entity_is_disabled_and_diagnostic(hass: HomeAssistant) -> snapshot = _snapshot(_device(properties=declarations)) coordinator = MagicMock(data=snapshot) registry = dr.async_get(hass) + uncurated = CurationOverlay.empty() entities = [ - *create_adopted_sensors(coordinator, snapshot, registry, panel_device_id="panel-device-id"), - *create_adopted_binary_sensors(coordinator, snapshot, registry, panel_device_id="panel-device-id"), + *create_adopted_sensors( + coordinator, snapshot, registry, panel_device_id="panel-device-id", overlay=uncurated + ), + *create_adopted_binary_sensors( + coordinator, snapshot, registry, panel_device_id="panel-device-id", overlay=uncurated + ), ] assert len(entities) == 2 @@ -274,9 +283,12 @@ def test_a_declared_boolean_becomes_a_binary_sensor_and_not_a_sensor(hass: HomeA snapshot = _snapshot(_device(properties=declarations)) coordinator = MagicMock(data=snapshot) registry = dr.async_get(hass) + uncurated = CurationOverlay.empty() - assert create_adopted_sensors(coordinator, snapshot, registry, panel_device_id="p") == [] - (entity,) = create_adopted_binary_sensors(coordinator, snapshot, registry, panel_device_id="p") + assert create_adopted_sensors(coordinator, snapshot, registry, panel_device_id="p", overlay=uncurated) == [] + (entity,) = create_adopted_binary_sensors( + coordinator, snapshot, registry, panel_device_id="p", overlay=uncurated + ) assert entity.is_on is True @@ -392,7 +404,7 @@ def _built(hass: HomeAssistant, *declarations: AdoptedProperty) -> dict[Platform snapshot = _snapshot(_device(properties=declarations)) coordinator = MagicMock(data=snapshot) registry = dr.async_get(hass) - kwargs = {"panel_device_id": "panel-device-id"} + kwargs = {"panel_device_id": "panel-device-id", "overlay": CurationOverlay.empty()} return { Platform.SENSOR: list(create_adopted_sensors(coordinator, snapshot, registry, **kwargs)), Platform.BINARY_SENSOR: list(create_adopted_binary_sensors(coordinator, snapshot, registry, **kwargs)), @@ -462,7 +474,11 @@ async def test_a_switch_publishes_the_vocabulary_homie_defines(hass: HomeAssista coordinator.async_request_refresh = AsyncMock() (entity,) = create_adopted_switches( - coordinator, snapshot, dr.async_get(hass), panel_device_id="panel-device-id" + coordinator, + snapshot, + dr.async_get(hass), + panel_device_id="panel-device-id", + overlay=CurationOverlay.empty(), ) await entity.async_turn_on() @@ -476,7 +492,11 @@ def _adopted_switch(hass: HomeAssistant) -> tuple[MagicMock, object]: coordinator = MagicMock(data=snapshot) coordinator.async_request_refresh = AsyncMock() (entity,) = create_adopted_switches( - coordinator, snapshot, dr.async_get(hass), panel_device_id="panel-device-id" + coordinator, + snapshot, + dr.async_get(hass), + panel_device_id="panel-device-id", + overlay=CurationOverlay.empty(), ) return coordinator, entity @@ -535,7 +555,13 @@ async def test_a_number_publishes_an_integer_where_the_declaration_says_integer( coordinator.client.set_adopted_property = AsyncMock() coordinator.async_request_refresh = AsyncMock() - (entity,) = create_adopted_numbers(coordinator, snapshot, dr.async_get(hass), panel_device_id="panel-device-id") + (entity,) = create_adopted_numbers( + coordinator, + snapshot, + dr.async_get(hass), + panel_device_id="panel-device-id", + overlay=CurationOverlay.empty(), + ) await entity.async_set_native_value(45.0) coordinator.client.set_adopted_property.assert_awaited_once_with("generator-1", "generator", "setpoint", "45") @@ -751,7 +777,11 @@ def test_the_lexically_first_of_two_colliding_properties_wins_either_way( snapshot = _snapshot(_device(properties=declared)) sensors = create_adopted_sensors( - MagicMock(data=snapshot), snapshot, dr.async_get(hass), panel_device_id="panel-device-id" + MagicMock(data=snapshot), + snapshot, + dr.async_get(hass), + panel_device_id="panel-device-id", + overlay=CurationOverlay.empty(), ) assert len(sensors) == 1 @@ -774,7 +804,11 @@ def test_the_same_address_on_two_devices_is_not_a_collision(hass: HomeAssistant) ) sensors = create_adopted_sensors( - MagicMock(data=snapshot), snapshot, dr.async_get(hass), panel_device_id="panel-device-id" + MagicMock(data=snapshot), + snapshot, + dr.async_get(hass), + panel_device_id="panel-device-id", + overlay=CurationOverlay.empty(), ) assert len({str(sensor.unique_id) for sensor in sensors}) == 2 @@ -804,7 +838,11 @@ def test_a_string_over_the_state_limit_is_clamped_rather_than_written( snapshot = _snapshot(_device(properties=(declaration,))) (sensor,) = create_adopted_sensors( - MagicMock(data=snapshot), snapshot, dr.async_get(hass), panel_device_id="panel-device-id" + MagicMock(data=snapshot), + snapshot, + dr.async_get(hass), + panel_device_id="panel-device-id", + overlay=CurationOverlay.empty(), ) assert sensor.native_value == "x" * MAX_STATE_LENGTH @@ -818,7 +856,11 @@ def test_a_string_within_the_limit_is_passed_through_untouched(hass: HomeAssista snapshot = _snapshot(_device(properties=(declaration,))) (sensor,) = create_adopted_sensors( - MagicMock(data=snapshot), snapshot, dr.async_get(hass), panel_device_id="panel-device-id" + MagicMock(data=snapshot), + snapshot, + dr.async_get(hass), + panel_device_id="panel-device-id", + overlay=CurationOverlay.empty(), ) assert sensor.native_value == "ok" @@ -994,8 +1036,141 @@ def test_the_malformed_numeric_still_reaches_the_user_as_a_reading(hass: HomeAss """Routed to a sensor, not dropped: the property has a value, only no domain.""" snapshot = _panel_with_a_charger_and_a_malformed_numeric() sensors = create_adopted_sensors( - MagicMock(data=snapshot), snapshot, dr.async_get(hass), panel_device_id="panel-device-id" + MagicMock(data=snapshot), + snapshot, + dr.async_get(hass), + panel_device_id="panel-device-id", + overlay=CurationOverlay.empty(), ) assert sorted(str(entity.name) for entity in sensors) == ["Ceiling", "Setpoint"] assert adopted_control_count(snapshot) == 0 + + +# -- A curated record reaches the entity at construction ---------------------- + + +def _overlay_for(declaration_path: str, record: CurationRecord, identifier: str) -> CurationOverlay: + """One overlay holding one record, under the key `_create` will look up.""" + return CurationOverlay({f"{identifier}/{declaration_path}": record}) + + +def test_a_curated_record_shapes_the_sensor_at_construction(hass: HomeAssistant) -> None: + """What the user asserted is what the entity is born with. + + Applied at construction rather than patched onto a live entity: a + `state_class` decides whether the recorder writes long-term statistics, and + that decision is read when the entity is added. + """ + declarations = (_property(unit="V", datatype="float"),) + snapshot = _snapshot(_device(properties=declarations)) + identifier = resolve_identifier( + dr.async_get(hass), snapshot.serial_number, snapshot.adopted_devices[0] + ) + record = CurationRecord( + state_class=SensorStateClass.MEASUREMENT, device_class="voltage", promote=True + ) + (entity,) = create_adopted_sensors( + MagicMock(data=snapshot), + snapshot, + dr.async_get(hass), + panel_device_id="panel-device-id", + overlay=_overlay_for(declarations[0].path, record, identifier), + ) + assert entity.state_class is SensorStateClass.MEASUREMENT + assert entity.device_class is SensorDeviceClass.VOLTAGE + assert entity.entity_category is None + assert entity.entity_registry_enabled_default is False + + +def test_an_uncurated_row_is_exactly_todays_entity(hass: HomeAssistant) -> None: + """Curation is opt-in per row: an empty overlay changes nothing at all.""" + declarations = (_property(unit="V", datatype="float"),) + snapshot = _snapshot(_device(properties=declarations)) + (entity,) = create_adopted_sensors( + MagicMock(data=snapshot), + snapshot, + dr.async_get(hass), + panel_device_id="panel-device-id", + overlay=CurationOverlay.empty(), + ) + assert entity.state_class is None + assert entity.entity_category is EntityCategory.DIAGNOSTIC + + +def test_a_stale_record_field_is_skipped_and_the_rest_applied(hass: HomeAssistant) -> None: + """A record can outlive the declaration it was written against. + + The vendor moved this row to `string`, so the asserted `state_class` no + longer has a numeric sensor to sit on -- and a state class on a text + reading is exactly the corrupt statistics adoption refuses to write. The + field is dropped; the prominence the user also asked for still applies. + """ + declarations = (_property(unit=None, datatype="string"),) + snapshot = _snapshot(_device(properties=declarations)) + identifier = resolve_identifier( + dr.async_get(hass), snapshot.serial_number, snapshot.adopted_devices[0] + ) + record = CurationRecord(state_class=SensorStateClass.MEASUREMENT, promote=True) + (entity,) = create_adopted_sensors( + MagicMock(data=snapshot), + snapshot, + dr.async_get(hass), + panel_device_id="panel-device-id", + overlay=_overlay_for(declarations[0].path, record, identifier), + ) + assert entity.state_class is None + assert entity.entity_category is None + + +def test_a_curated_binary_sensor_takes_the_device_class_it_was_given(hass: HomeAssistant) -> None: + """The record is the only device class a binary sensor can have. + + A sensor's comes off the declared unit; a boolean declares no unit, so + `door` and `problem` and `running` are indistinguishable on the wire and + there is nothing for `DEVICE_CLASS_BY_UNIT` to answer with. + """ + declarations = (_property(node_id="relay", property_id="closed", datatype="boolean", unit=None),) + snapshot = _snapshot(_device(properties=declarations)) + identifier = resolve_identifier( + dr.async_get(hass), snapshot.serial_number, snapshot.adopted_devices[0] + ) + record = CurationRecord(device_class=BinarySensorDeviceClass.DOOR.value) + (entity,) = create_adopted_binary_sensors( + MagicMock(data=snapshot), + snapshot, + dr.async_get(hass), + panel_device_id="panel-device-id", + overlay=_overlay_for(declarations[0].path, record, identifier), + ) + assert entity.device_class is BinarySensorDeviceClass.DOOR + assert entity.entity_category is EntityCategory.DIAGNOSTIC + + +def test_a_control_takes_prominence_and_nothing_else(hass: HomeAssistant) -> None: + """A control row carries one field, and the entity is what proves it. + + `sanitise` refuses a device class on a row that is not a sensor, so nothing + downstream re-filters the record -- which is only safe if the refusal + actually holds at the entity. Asserted here rather than only against + `sanitise` so that a control growing its own description later cannot start + honouring a field the editor never offered. + """ + declarations = ( + _property(node_id="relay", property_id="enabled", datatype="boolean", unit=None, settable=True), + ) + snapshot = _snapshot(_device(properties=declarations)) + identifier = resolve_identifier( + dr.async_get(hass), snapshot.serial_number, snapshot.adopted_devices[0] + ) + record = CurationRecord(device_class="voltage", promote=True) + (entity,) = create_adopted_switches( + MagicMock(data=snapshot), + snapshot, + dr.async_get(hass), + panel_device_id="panel-device-id", + overlay=_overlay_for(declarations[0].path, record, identifier), + ) + assert entity.entity_category is None + assert entity.device_class is None + assert entity.entity_registry_enabled_default is False From 6114eb04fe18d32cde93ecefda8379175f79f0b8 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:28:31 -0700 Subject: [PATCH 232/345] feat(curation): vendor readings on modelled devices honour the overlay --- custom_components/span_panel/binary_sensor.py | 1 + custom_components/span_panel/extension.py | 159 +++++++++++++--- custom_components/span_panel/sensor.py | 1 + tests/test_extension_entities.py | 174 +++++++++++++++++- 4 files changed, 301 insertions(+), 34 deletions(-) diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py index d108eccb..f260c689 100644 --- a/custom_components/span_panel/binary_sensor.py +++ b/custom_components/span_panel/binary_sensor.py @@ -702,6 +702,7 @@ async def async_setup_entry( snapshot, dr.async_get(hass), er.async_get(hass), + overlay=config_entry.runtime_data.curation, ), ] ) diff --git a/custom_components/span_panel/extension.py b/custom_components/span_panel/extension.py index 7ab58890..f252834a 100644 --- a/custom_components/span_panel/extension.py +++ b/custom_components/span_panel/extension.py @@ -13,6 +13,15 @@ one existing -- an entity's `unique_id` and `entity_id` are permanent, its *identity* carries no expectation of permanence beyond that. +**Terminal is not unimprovable, and the owner of the device is not guessing.** +A user who curates a row asserts what this module refuses to infer, and their +record arrives as metadata `curation.py` composes into the description built +here -- which is how a curated reading carries a state class without this +module naming the thing an AST guard forbids it to spell. It changes what an +entity *says*, never what it *is*: the id, the card and the platform are +untouched, and an uncurated row is exactly the entity it was before curation +existed. + **Nothing is ever removed by this integration.** A row the user deletes is recreated -- disabled, as it arrives -- for as long as the property is still published, so deletion is not suppression and no suppression feature is needed. @@ -29,9 +38,9 @@ from typing import Final from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo, DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry @@ -45,6 +54,14 @@ ) from .const import DOMAIN from .coordinator import SpanPanelCoordinator +from .curation import ( + CurationOverlay, + CurationRecord, + RowContext, + binary_sensor_device_class, + entity_category_for, + sensor_description, +) from .entity import SpanPanelEntity from .notices import async_raise_on_change, read_translations from .util import ( @@ -192,6 +209,18 @@ def is_extension_unique_id(unique_id: str) -> bool: return f"_{ADOPTED_IDENTIFIER_TOKEN}_" in unique_id and "/" in unique_id +def extension_curation_key(subject: ExtensionSubject, path: str) -> str | None: + """Return the curation-store key for one extension property: `{scope}/{node}/{property}`. + + Scope-prefixed because `path` is unique only within one wire device; the + scope segment is exactly the one the unique_id carries, so the key is + injective for the same reason the id is. `None` mirrors + `extension_unique_id`: no scope, nothing to curate. + """ + scope = extension_scope(subject) + return None if scope is None else f"{scope}/{path}" + + def extension_device_identifier(panel_identifier: str, subject: ExtensionSubject) -> str | None: """Return the registry identifier of the curated device this property belongs on. @@ -247,10 +276,14 @@ def resolve_platform(registry: EntityRegistry, unique_id: str, datatype: str) -> would strand it and mint a second entity beside it. Metadata may reshape everything else about a standing entity -- category, - device class, unit, name, icon -- and safely, because these entities carry no - `state_class` and so have no statistics for a unit change to corrupt. The - platform is the exception, and the exception is enforced here rather than - remembered: whatever domain the id is already registered under wins. + device class, unit, name, icon. For an uncurated entity that is free, + because it writes no long-term statistics and so has nothing for a unit + change to corrupt. A curated one carries what its owner asserted, and + `curation.sanitise` re-measures that assertion against each new + declaration, so a reshaping the assertion no longer fits drops it rather + than applying it over a wire that has moved. The platform is the exception + either way, and the exception is enforced here rather than remembered: + whatever domain the id is already registered under wins. """ for platform in (Platform.SENSOR, Platform.BINARY_SENSOR): if registry.async_get_entity_id(platform.value, DOMAIN, unique_id) is not None: @@ -262,10 +295,11 @@ def prominence_hint(row: ExtensionProperty) -> str: """Return an advisory ranking for one extension property. Advisory, and only advisory: every extension entity arrives DIAGNOSTIC - whatever this says. `entity_category` is the one attribute that is free to - revise later -- no id change, no statistics consequence -- so the conservative - default costs a line in a future release, while the mistakes that are *not* - free are simply never made here. + whatever this says, until its owner curates it out -- which is the act this + hint exists to rank rather than to perform. `entity_category` is the one + attribute that is free to revise later -- no id change, no statistics + consequence -- so the conservative default costs a line in a future release, + while the mistakes that are *not* free are simply never made here. Ranked by confidence, and each signal's failure mode is why it sits where it does: @@ -316,13 +350,15 @@ def prominence_hint(row: ExtensionProperty) -> str: class ExtensionEntity(SpanPanelEntity): """Base for an entity built from a vendor extension on a curated device. - Disabled and diagnostic without exception, exactly as `AdoptedEntity` is: the - integration's job here is to make the reading reachable, not to put it on - somebody's dashboard. + Disabled without exception, and diagnostic unless the owner of the device + said otherwise, exactly as `AdoptedEntity` is: the integration's job here is + to make the reading reachable, not to put it on somebody's dashboard. A + curated row is that decision already made, so `entity_category_for` lets it + out of diagnostics -- and leaves it disabled all the same, because enabling + is still their act. """ _attr_entity_registry_enabled_default = False - _attr_entity_category: EntityCategory | None = EntityCategory.DIAGNOSTIC def __init__( self, @@ -331,6 +367,7 @@ def __init__( row: ExtensionProperty, *, device_identifier: str, + record: CurationRecord | None = None, ) -> None: """Bind this entity to one extension property of one curated device.""" super().__init__(coordinator) @@ -353,6 +390,7 @@ def __init__( "prominence_hint": prominence_hint(row), "wire_path": row.path, } + self._attr_entity_category = entity_category_for(record) def _row(self) -> ExtensionProperty | None: """Return this property's current row, or None when it has left the tree. @@ -380,11 +418,16 @@ def _published(self) -> str | None: class ExtensionSensor(ExtensionEntity, SensorEntity): - """A vendor reading on a curated device, with no `state_class`. - - No `state_class`, ever, and that is what makes the rest of this safe: an - entity writing no long-term statistics has nothing for a later unit or - device-class change to corrupt, so metadata may reshape it freely. + """A vendor reading on a curated device, described by the wire and by its record. + + This module infers nothing that would enrol a reading in long-term + statistics, and spells no such attribute anywhere -- an AST guard in + `tests/test_extension_entities.py` asserts the token is absent from the + syntax rather than from the paths a test happens to construct. So an + uncurated reading has nothing for a later unit or device-class change to + corrupt, and metadata may reshape it freely. The owner of the vendor device + may assert one, and their assertion reaches the entity through + `curation.sensor_description` rather than from anything guessed here. """ def __init__( @@ -394,13 +437,19 @@ def __init__( row: ExtensionProperty, *, device_identifier: str, + record: CurationRecord | None = None, ) -> None: - """Take the unit and device class from what the publisher declared.""" - super().__init__(coordinator, unique_id, row, device_identifier=device_identifier) - self.entity_description = SensorEntityDescription( - key=row.path, - device_class=DEVICE_CLASS_BY_UNIT.get(row.unit or ""), - native_unit_of_measurement=row.unit, + """Take the unit and device class from what the publisher declared. + + The description is built in `curation` rather than here, which is what + lets a curated row carry the one piece of metadata this module may not + name and still leaves an uncurated row with exactly what it had. + """ + super().__init__( + coordinator, unique_id, row, device_identifier=device_identifier, record=record + ) + self.entity_description = sensor_description( + row.path, row.unit, DEVICE_CLASS_BY_UNIT.get(row.unit or ""), record ) @property @@ -428,6 +477,28 @@ def native_value(self) -> str | float | None: class ExtensionBinarySensor(ExtensionEntity, BinarySensorEntity): """A declared `boolean` vendor extension on a curated device.""" + def __init__( + self, + coordinator: SpanPanelCoordinator, + unique_id: str, + row: ExtensionProperty, + *, + device_identifier: str, + record: CurationRecord | None = None, + ) -> None: + """Take the device class from the record, because there is nothing to default from. + + A sensor's device class can be read off the declared unit; a boolean + declares no unit, so `door` and `problem` and `running` are + indistinguishable on the wire. An uncurated binary sensor therefore has + no device class at all, and the user's assertion is the only one there + can be. + """ + super().__init__( + coordinator, unique_id, row, device_identifier=device_identifier, record=record + ) + self._attr_device_class = binary_sensor_device_class(record) + @property def is_on(self) -> bool | None: """Homie spells a boolean `true`/`false`; anything else is not an answer.""" @@ -439,10 +510,18 @@ def create_extension_sensors( snapshot: SpanPanelSnapshot, device_registry: DeviceRegistry, entity_registry: EntityRegistry, + *, + overlay: CurationOverlay, ) -> list[ExtensionSensor]: """Every extension property that is not a declared boolean.""" return _create( - ExtensionSensor, coordinator, snapshot, device_registry, entity_registry, Platform.SENSOR + ExtensionSensor, + coordinator, + snapshot, + device_registry, + entity_registry, + Platform.SENSOR, + overlay=overlay, ) @@ -451,6 +530,8 @@ def create_extension_binary_sensors( snapshot: SpanPanelSnapshot, device_registry: DeviceRegistry, entity_registry: EntityRegistry, + *, + overlay: CurationOverlay, ) -> list[ExtensionBinarySensor]: """Every extension property declared `boolean`.""" return _create( @@ -460,6 +541,7 @@ def create_extension_binary_sensors( device_registry, entity_registry, Platform.BINARY_SENSOR, + overlay=overlay, ) @@ -470,6 +552,8 @@ def _create[ExtensionT: ExtensionEntity]( device_registry: DeviceRegistry, entity_registry: EntityRegistry, platform: Platform, + *, + overlay: CurationOverlay, ) -> list[ExtensionT]: """Build one platform's share of the extension properties. @@ -477,12 +561,33 @@ def _create[ExtensionT: ExtensionEntity]( the only place a property's platform is decided -- two bodies would each restate the predicate, and a property could then reach both platforms or neither. + + **The curated record is read through `for_row`, never off the overlay.** The + store keeps whatever the user asserted when they asserted it, and the + declaration it was asserted against can have moved since. `for_row` measures + the record against the declaration in hand and drops what no longer fits; + the raw record would reach the device-class constructor unchecked and raise + inside `async_setup_entry`, which is the whole platform for one stale row. + The context is built from `platform` rather than from the datatype, because + `resolve_platform` has already ruled on which platform this row is on and a + second derivation could disagree with the registry. + + `extension_curation_key` declines exactly the subjects `extension_unique_id` + declines, so every row reaching here has a key. The `None` branch is the + type system holding the two to one contract, not a case that occurs. """ built: list[ExtensionT] = [] for row, unique_id, device_identifier in adoptable(snapshot, device_registry, entity_registry): if resolve_platform(entity_registry, unique_id, row.datatype) is not platform: continue - built.append(entity_class(coordinator, unique_id, row, device_identifier=device_identifier)) + key = extension_curation_key(row.subject, row.path) + context = RowContext(platform=platform, datatype=row.datatype, unit=row.unit) + record = None if key is None else overlay.for_row(key, context) + built.append( + entity_class( + coordinator, unique_id, row, device_identifier=device_identifier, record=record + ) + ) return built diff --git a/custom_components/span_panel/sensor.py b/custom_components/span_panel/sensor.py index 5a088892..c79f0525 100644 --- a/custom_components/span_panel/sensor.py +++ b/custom_components/span_panel/sensor.py @@ -139,6 +139,7 @@ async def async_setup_entry( snapshot, dr.async_get(hass), er.async_get(hass), + overlay=config_entry.runtime_data.curation, ) # Add all native sensor entities diff --git a/tests/test_extension_entities.py b/tests/test_extension_entities.py index ff9c7182..713ac696 100644 --- a/tests/test_extension_entities.py +++ b/tests/test_extension_entities.py @@ -15,7 +15,9 @@ from typing import TYPE_CHECKING from unittest.mock import MagicMock +from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.persistent_notification import async_dismiss +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE, EntityCategory, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -24,6 +26,7 @@ from span_panel_api import ExtensionProperty, ExtensionSubject from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.curation import CurationOverlay, CurationRecord from custom_components.span_panel.extension import ( HINT_DETAIL, HINT_READING, @@ -35,7 +38,9 @@ classify_extension, create_extension_binary_sensors, create_extension_sensors, + extension_curation_key, extension_device_identifier, + extension_scope, extension_unique_id, prominence_hint, resolve_platform, @@ -310,7 +315,11 @@ def test_a_sensor_arrives_disabled_diagnostic_and_without_statistics( """ snapshot = _snapshot(_row()) sensors = create_extension_sensors( - _coordinator(snapshot), snapshot, dr.async_get(hass), er.async_get(hass) + _coordinator(snapshot), + snapshot, + dr.async_get(hass), + er.async_get(hass), + overlay=CurationOverlay.empty(), ) assert len(sensors) == 1 sensor = sensors[0] @@ -328,7 +337,11 @@ def test_a_name_carries_the_node_so_it_cannot_collide_with_a_curated_one( """Curated names on these cards carry no wire vocabulary, so prefixing avoids collisions.""" snapshot = _snapshot(_row()) sensor = create_extension_sensors( - _coordinator(snapshot), snapshot, dr.async_get(hass), er.async_get(hass) + _coordinator(snapshot), + snapshot, + dr.async_get(hass), + er.async_get(hass), + overlay=CurationOverlay.empty(), )[0] assert sensor._attr_name == "Battery 2 Cell Temperature" @@ -340,7 +353,11 @@ def test_a_declared_boolean_becomes_a_binary_sensor( _row(property_id="pack-enabled", datatype="boolean", unit=None, value="true") ) binary = create_extension_binary_sensors( - _coordinator(snapshot), snapshot, dr.async_get(hass), er.async_get(hass) + _coordinator(snapshot), + snapshot, + dr.async_get(hass), + er.async_get(hass), + overlay=CurationOverlay.empty(), ) assert len(binary) == 1 assert isinstance(binary[0], ExtensionBinarySensor) @@ -348,7 +365,11 @@ def test_a_declared_boolean_becomes_a_binary_sensor( # And it is not also a sensor: one property, one platform. assert ( create_extension_sensors( - _coordinator(snapshot), snapshot, dr.async_get(hass), er.async_get(hass) + _coordinator(snapshot), + snapshot, + dr.async_get(hass), + er.async_get(hass), + overlay=CurationOverlay.empty(), ) == [] ) @@ -360,7 +381,11 @@ def test_a_property_that_stops_being_published_reads_unknown_rather_than_vanishi """Absence on the wire is ambiguous, so the entity stays and reports nothing.""" snapshot = _snapshot(_row()) sensor = create_extension_sensors( - _coordinator(snapshot), snapshot, dr.async_get(hass), er.async_get(hass) + _coordinator(snapshot), + snapshot, + dr.async_get(hass), + er.async_get(hass), + overlay=CurationOverlay.empty(), )[0] sensor.coordinator.data = _snapshot() @@ -373,11 +398,142 @@ def test_an_unparseable_number_is_reported_as_nothing_rather_than_as_text( """A string behind a unit and a device class is a worse lie than no reading.""" snapshot = _snapshot(_row(value="not-a-number")) sensor = create_extension_sensors( - _coordinator(snapshot), snapshot, dr.async_get(hass), er.async_get(hass) + _coordinator(snapshot), + snapshot, + dr.async_get(hass), + er.async_get(hass), + overlay=CurationOverlay.empty(), )[0] assert sensor.native_value is None +# --- what the owner of the device is allowed to assert ---------------------- + + +def _overlay_keyed(row: ExtensionProperty, record: CurationRecord) -> CurationOverlay: + """Key a record the way `_create` looks one up: the scope segment, then the wire path.""" + return CurationOverlay({f"{extension_scope(row.subject)}/{row.path}": record}) + + +def test_the_curation_key_is_scoped_because_a_wire_path_alone_is_not_unique() -> None: + """Three wire devices publishing one path are three rows, so they are three keys. + + `path` is unique only within one wire device, so keying on it bare would let + a record asserted for one circuit reshape the identically-named property on + every other circuit -- and on the battery. The scope segment is exactly the + one `extension_unique_id` carries, so the key is injective for the reason + the id is. + """ + rows = ( + _row(), + _row(kind="circuit", instance_key="circuit-a"), + _row(kind="circuit", instance_key="circuit-b"), + ) + assert {extension_curation_key(row.subject, row.path) for row in rows} == { + "bess/battery-2/cell-temperature", + "circuit_circuit-a/battery-2/cell-temperature", + "circuit_circuit-b/battery-2/cell-temperature", + } + + +def test_a_subject_that_names_no_card_has_nothing_to_curate() -> None: + """`None` mirrors `extension_unique_id`: no card, no entity, and so no key.""" + subject = ExtensionSubject(kind="thermostat", instance_key=None) + assert extension_curation_key(subject, "acme/setpoint") is None + + +def test_a_curated_record_shapes_the_extension_sensor( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """Every field the owner of the device may assert reaches the entity. + + Including the `state_class` this module may not infer: the entity is built + from a description `curation` composed, so a user's assertion arrives + without `extension.py` ever naming the thing its AST guard forbids. + """ + row = _row(datatype="float", unit="V") + snapshot = _snapshot(row) + record = CurationRecord( + state_class=SensorStateClass.MEASUREMENT, device_class="voltage", promote=True + ) + (entity,) = create_extension_sensors( + _coordinator(snapshot), + snapshot, + dr.async_get(hass), + er.async_get(hass), + overlay=_overlay_keyed(row, record), + ) + assert entity.state_class is SensorStateClass.MEASUREMENT + assert entity.device_class is SensorDeviceClass.VOLTAGE + assert entity.entity_category is None + # Promotion is not enablement. Enabling stays the user's separate act. + assert entity.entity_registry_enabled_default is False + + +def test_an_uncurated_extension_row_is_exactly_todays_entity( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """Curation adds a path; it moves nothing that nobody curated.""" + row = _row(datatype="float", unit="V") + snapshot = _snapshot(row) + (entity,) = create_extension_sensors( + _coordinator(snapshot), + snapshot, + dr.async_get(hass), + er.async_get(hass), + overlay=CurationOverlay.empty(), + ) + assert entity.state_class is None + assert entity.entity_category is EntityCategory.DIAGNOSTIC + # The unit map still supplies what the wire declared. + assert entity.device_class is SensorDeviceClass.VOLTAGE + + +def test_a_stale_extension_record_field_is_skipped_and_the_rest_applied( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """A record outlives the declaration it was asserted against, and must not fail setup. + + The publisher has relabelled a numeric property as a string, so the stored + state class no longer fits. It is dropped -- the row is read through + `for_row` rather than off the overlay -- and the prominence the same user + asserted is honoured all the same. + """ + row = _row(datatype="string", unit=None) + snapshot = _snapshot(row) + record = CurationRecord(state_class=SensorStateClass.MEASUREMENT, promote=True) + (entity,) = create_extension_sensors( + _coordinator(snapshot), + snapshot, + dr.async_get(hass), + er.async_get(hass), + overlay=_overlay_keyed(row, record), + ) + assert entity.state_class is None + assert entity.entity_category is None + + +def test_a_curated_binary_sensor_gets_the_only_device_class_there_can_be( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """A boolean declares no unit, so `door` and `problem` are indistinguishable on the wire. + + There is nothing to default from, which makes the user's assertion the only + device class a vendor boolean can ever carry. + """ + row = _row(property_id="pack-fault", datatype="boolean", unit=None, value="true") + snapshot = _snapshot(row) + (entity,) = create_extension_binary_sensors( + _coordinator(snapshot), + snapshot, + dr.async_get(hass), + er.async_get(hass), + overlay=_overlay_keyed(row, CurationRecord(device_class="problem")), + ) + assert entity.device_class == BinarySensorDeviceClass.PROBLEM + assert entity.entity_category is EntityCategory.DIAGNOSTIC + + # --- the prominence hint ---------------------------------------------------- @@ -407,7 +563,11 @@ def test_the_hint_is_carried_on_the_entity_for_curation_triage( ) -> None: snapshot = _snapshot(_row()) sensor = create_extension_sensors( - _coordinator(snapshot), snapshot, dr.async_get(hass), er.async_get(hass) + _coordinator(snapshot), + snapshot, + dr.async_get(hass), + er.async_get(hass), + overlay=CurationOverlay.empty(), )[0] assert sensor._attr_extra_state_attributes["prominence_hint"] == HINT_READING assert sensor._attr_extra_state_attributes["wire_path"] == "battery-2/cell-temperature" From 7d73cdda1f00666bd97834980e5944b26e6d69a7 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:49:00 -0700 Subject: [PATCH 233/345] feat(curation): adopted/list reports every curatable row and its choices --- custom_components/span_panel/websocket.py | 10 +- .../span_panel/websocket_adopted.py | 360 ++++++++++ tests/test_websocket_adopted.py | 639 ++++++++++++++++++ websocket-api.md | 99 +++ 4 files changed, 1107 insertions(+), 1 deletion(-) create mode 100644 custom_components/span_panel/websocket_adopted.py create mode 100644 tests/test_websocket_adopted.py diff --git a/custom_components/span_panel/websocket.py b/custom_components/span_panel/websocket.py index bbd55651..ba60dd6e 100644 --- a/custom_components/span_panel/websocket.py +++ b/custom_components/span_panel/websocket.py @@ -14,6 +14,7 @@ from .helpers import build_panel_unique_id, construct_voltage_attribute from .id_builder import build_binary_sensor_unique_id from .util import classify_sub_device_identifier +from .websocket_adopted import handle_adopted_list if TYPE_CHECKING: from .runtime import SpanPanelRuntimeData @@ -55,8 +56,15 @@ def async_register_commands(hass: HomeAssistant) -> None: - """Register WebSocket commands for the Span Panel integration.""" + """Register WebSocket commands for the Span Panel integration. + + Every command this integration answers is named here, including the adopted + ones defined in `websocket_adopted`. The dependency runs one way -- that + module never imports this one -- so registration stays a single list rather + than something each module does for itself. + """ websocket_api.async_register_command(hass, handle_panel_topology) + websocket_api.async_register_command(hass, handle_adopted_list) @websocket_api.websocket_command( diff --git a/custom_components/span_panel/websocket_adopted.py b/custom_components/span_panel/websocket_adopted.py new file mode 100644 index 00000000..6014d506 --- /dev/null +++ b/custom_components/span_panel/websocket_adopted.py @@ -0,0 +1,360 @@ +"""WebSocket commands for curating adopted entities. + +Separate from `websocket.py` because the two answer different questions about +the same panel. That module reports the *curated* topology -- circuits, tabs, +sub-devices, the entity ids a dashboard renders from -- and its readers are +dashboards. This one reports what the panel publishes that nobody has modelled +yet, and its reader is an editor: every row carries the choices the user may +assert, computed from the wire declaration through Core's own maps, so the card +never offers an option the curate command would refuse. + +**Nothing here writes registry state, and that is a boundary rather than an +omission.** Enabling, naming, icons, areas and display units are registry acts +the user makes through Core's own websocket commands, which already ask for +admin and already carry the undo. This module owns exactly the metadata Core has +nowhere to put -- a state class, a device class and prominence for an entity +built from a vendor declaration -- and `curation.py` owns whether an assertion +is admissible. + +This module must not import `websocket.py`: registration runs the other way, so +the dependency has one direction and no cycle can appear as further commands +join the ones here. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from homeassistant.components import websocket_api +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +import voluptuous as vol + +from .adoption import ( + adopted_curation_key, + adopted_device_label, + adopted_unique_id, + classify, + humanised, + resolve_identifier, +) +from .const import DOMAIN +from .curation import ( + CurationOverlay, + RowContext, + allowed_device_classes, + allowed_state_classes, + record_as_dict, +) +from .extension import adoptable, extension_curation_key, resolve_platform +from .runtime import SpanPanelRuntimeData, loaded_runtime_data + +if TYPE_CHECKING: + from span_panel_api import SpanPanelSnapshot + + +@dataclass(frozen=True, slots=True) +class _AdoptableRow: + """One curatable row: what it is on the wire, and where it renders. + + One derivation of what is curatable, rather than one per command. The key a + row carries is what the store is keyed on and what the editor hands back + when the user asserts something, so a second derivation of the same set + would let the editor offer a row the store cannot resolve. + """ + + key: str + """The curation-store key -- scope-prefixed and injective, per `curation.py`.""" + + path: str + """The `{node}/{property}` wire address, as the capability catalogs spell it.""" + + context: RowContext + """The declaration, as far as validation and the allowed-choice helpers need it.""" + + unique_id: str + """The id this row's entity carries, whether or not that entity exists yet.""" + + device_identifier: str + """The registry identifier of the card this row renders on. + + The grouping key rather than `device_registry_id`, because the registry id is + absent for a device adopted since the last setup and two such devices must + not collapse into one group. + """ + + device_registry_id: str | None + """The card's registry id, or None while the card is still to be created.""" + + device_label: str + """What that card is called, for a group heading the user can recognise.""" + + name: str + """The entity's own name, in the same wire vocabulary the entity carries.""" + + settable: bool + """Whether the panel accepts a write. Declaration fact, reported for triage.""" + + adopted_device: bool + """Whether the card is one adoption minted, rather than a curated device.""" + + +@websocket_api.websocket_command( + { + vol.Required("type"): "span_panel/adopted/list", + vol.Required("device_id"): str, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def handle_adopted_list( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Return every curatable row on this panel, grouped by the device it renders on. + + Admin users pass the HA device registry ID for the **main SPAN panel**, the + same contract `handle_panel_topology` has: one panel is one entry, and the + rows come from that entry's snapshot and overlay together. + + Read-only. The response is the editor's whole input -- the stored record, the + admissible choices, and the names of any stored fields the current + declaration no longer supports. + """ + resolved = _resolve_panel_entry(hass, connection, msg) + if resolved is None: + return + _entry, runtime_data, snapshot = resolved + + entity_registry = er.async_get(hass) + devices: list[dict[str, Any]] = [] + # Each device's row list, held here as the same object its group carries, so + # one pass both opens the group and fills it. + grouped: dict[str, list[dict[str, Any]]] = {} + for row in _rows(hass, snapshot): + rows = grouped.get(row.device_identifier) + if rows is None: + rows = [] + grouped[row.device_identifier] = rows + devices.append( + { + "device_id": row.device_registry_id, + "name": row.device_label, + "adopted_device": row.adopted_device, + "rows": rows, + } + ) + rows.append(_row_payload(row, runtime_data.curation, entity_registry)) + + connection.send_result(msg["id"], {"devices": devices}) + + +def _row_payload( + row: _AdoptableRow, + overlay: CurationOverlay, + entity_registry: er.EntityRegistry, +) -> dict[str, Any]: + """Return the wire record for one row: its declaration, its record, its choices. + + **The record is reported as stored, not as it would be applied.** Entity + construction reads the same record through `for_row`, which drops what the + current declaration no longer supports -- that is right for an entity and + wrong for an editor, because a silently sanitised record shows the user an + assertion they never made and hides that theirs was dropped. So the stored + fields go out verbatim, beside `stale_fields` naming the ones the wire has + outgrown. + """ + record = overlay.record_for(row.key) + return { + "key": row.key, + "path": row.path, + "platform": row.context.platform.value, + "entity_id": entity_registry.async_get_entity_id( + row.context.platform.value, DOMAIN, row.unique_id + ), + "datatype": row.context.datatype, + "unit": row.context.unit, + "settable": row.settable, + "name": row.name, + "curation": {} if record is None else record_as_dict(record), + "allowed_device_classes": allowed_device_classes(row.context), + "allowed_state_classes": allowed_state_classes(row.context), + "stale_fields": list(overlay.stale_fields(row.key, row.context)), + } + + +def _rows(hass: HomeAssistant, snapshot: SpanPanelSnapshot) -> list[_AdoptableRow]: + """Every row on this panel a user may curate, in a deterministic order. + + Both halves of vendor extensibility, resolved through the same functions the + entity builders use rather than beside them: `resolve_identifier` and + `classify` for a device nobody modelled, `adoptable` and `resolve_platform` + for a vendor property on a device this integration does model. A second + derivation here would let the editor disagree with the entities it edits -- + offering a state class for a row that is really a control, or a key the + curate command cannot resolve. + + `adoptable` is what decides which extension rows exist at all, so the cap and + the wait-for-the-card deferral apply here exactly as they do to the entities: + a row it declines has no entity, no card to group under and no name to show. + + Adopted declarations are sorted by `path` for the same reason `_create` + sorts them -- adapter emission order tracks the wire, so an order derived + from it moves when a firmware update declares a property earlier. Extension + rows are sorted for a weaker version of the same reason: `adoptable` returns + the already-registered rows first, so an unsorted list would reshuffle the + card the moment a new row's entity appeared. + """ + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + rows: list[_AdoptableRow] = [] + + for device in snapshot.adopted_devices: + identifier = resolve_identifier(device_registry, snapshot.serial_number, device) + card = device_registry.async_get_device(identifiers={(DOMAIN, identifier)}) + for declaration in sorted(device.properties, key=lambda row: row.path): + rows.append( + _AdoptableRow( + key=adopted_curation_key(identifier, declaration), + path=declaration.path, + context=RowContext( + platform=classify(declaration), + datatype=declaration.datatype, + unit=declaration.unit, + ), + unique_id=adopted_unique_id(identifier, declaration), + device_identifier=identifier, + device_registry_id=None if card is None else card.id, + device_label=_device_label(card, adopted_device_label(device)), + name=humanised(declaration.property_id), + settable=declaration.settable, + adopted_device=True, + ) + ) + + for extension, unique_id, identifier in sorted( + adoptable(snapshot, device_registry, entity_registry), + key=lambda adopted: (adopted[2], adopted[0].path), + ): + key = extension_curation_key(extension.subject, extension.path) + card = device_registry.async_get_device(identifiers={(DOMAIN, identifier)}) + if key is None or card is None: + # Neither happens: `adoptable` declines a subject with no scope, which + # is exactly what `extension_curation_key` declines, and it declines a + # card the registry does not hold. Both branches are the type system + # holding those contracts to one answer rather than cases to handle. + continue + rows.append( + _AdoptableRow( + key=key, + path=extension.path, + context=RowContext( + platform=resolve_platform(entity_registry, unique_id, extension.datatype), + datatype=extension.datatype, + unit=extension.unit, + ), + unique_id=unique_id, + device_identifier=identifier, + device_registry_id=card.id, + device_label=_device_label(card, identifier), + name=f"{humanised(extension.node_id)} {humanised(extension.property_id)}", + settable=extension.settable, + adopted_device=False, + ) + ) + + return rows + + +def _device_label(card: dr.DeviceEntry | None, fallback: str) -> str: + """Return what this card is called, preferring what the user renamed it to. + + A group heading has to be the name the user sees in their device list, or the + editor is grouping rows under a device they cannot find. The fallback is for + a card that does not exist yet -- an adopted device that arrived since the + last setup -- where the wire's own label is the only name there is. + """ + if card is None: + return fallback + return card.name_by_user or card.name or fallback + + +def _resolve_panel_entry( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> tuple[ConfigEntry, SpanPanelRuntimeData, SpanPanelSnapshot] | None: + """Resolve the panel device id in a request to the entry, its runtime data and its snapshot. + + Sends the refusal itself and answers None, so a handler's first line is the + whole of its validation. The checks and their codes mirror + `handle_panel_topology`, because the two commands take the same handle and a + consumer that learned one set of codes must not meet a second. + + Runtime state is reached through `loaded_runtime_data`, per AGENTS.md's + runtime-data guard: core deletes `runtime_data` on unload, and what is there + on a loaded entry is whatever the owning integration put there. + + One divergence from topology, in an unreachable case: a device carrying a + SPAN identifier whose entry the registry no longer holds is refused as + `not_span_panel` rather than `not_loaded`. Resolving the entry and checking + its domain is one step here, and a device whose SPAN entry is gone is not a + SPAN panel any more. + """ + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(msg["device_id"]) + + if device_entry is None: + connection.send_error(msg["id"], "device_not_found", "Device not found") + return None + + if not any(domain == DOMAIN for domain, _ in device_entry.identifiers): + connection.send_error(msg["id"], "not_span_panel", "Device is not a SPAN Panel device") + return None + + # Every sub-device registers with via_device_id pointing at the panel. + if device_entry.via_device_id is not None: + connection.send_error( + msg["id"], + "not_panel_device", + "Use the SPAN panel device registry ID, not a sub-device.", + ) + return None + + entry = _config_entry(hass, device_entry) + if entry is None: + connection.send_error(msg["id"], "not_span_panel", "Device is not a SPAN Panel device") + return None + + if entry.state is not ConfigEntryState.LOADED: + connection.send_error(msg["id"], "not_loaded", "SPAN Panel integration is not loaded") + return None + + runtime_data = loaded_runtime_data(entry) + if runtime_data is None: + connection.send_error(msg["id"], "not_loaded", "SPAN Panel integration is not loaded") + return None + + snapshot = runtime_data.coordinator.data + if snapshot is None: + connection.send_error(msg["id"], "no_data", "SPAN Panel has not yet provided any data") + return None + + return entry, runtime_data, snapshot + + +def _config_entry(hass: HomeAssistant, device_entry: dr.DeviceEntry) -> ConfigEntry | None: + """Return the SPAN Panel entry this device belongs to, if one still does. + + The entry's domain is checked rather than assumed: a device row may carry + entries from more than one integration, and the first one is not necessarily + ours. + """ + for entry_id in device_entry.config_entries: + entry = hass.config_entries.async_get_entry(entry_id) + if entry is not None and entry.domain == DOMAIN: + return entry + return None diff --git a/tests/test_websocket_adopted.py b/tests/test_websocket_adopted.py new file mode 100644 index 00000000..61d164ca --- /dev/null +++ b/tests/test_websocket_adopted.py @@ -0,0 +1,639 @@ +"""The adopted/list command reports every curatable row, grouped by the device it renders on. + +Three things carry this surface and each fails loudly here if it stops holding: +a row's key is the one the curate command will be handed back, the allowed +choices are computed from the wire rather than offered blind, and a stored record +that no longer fits its declaration is *shown* rather than silently sanitised -- +the editor is where a user finds out their assertion went stale. +""" + +from __future__ import annotations + +from dataclasses import replace +from typing import TYPE_CHECKING, Any +from unittest.mock import MagicMock + +from homeassistant.components.sensor import SensorStateClass +from homeassistant.config_entries import ConfigEntryState +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, MockUser +from pytest_homeassistant_custom_component.typing import WebSocketGenerator +from span_panel_api import AdoptedDevice, AdoptedProperty, ExtensionProperty, ExtensionSubject + +from custom_components.span_panel import SpanPanelRuntimeData +from custom_components.span_panel.adoption import ( + adopted_curation_key, + adopted_identifier, + adopted_unique_id, + async_register_adopted_devices, +) +from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.curation import CurationOverlay, CurationRecord +from custom_components.span_panel.extension import extension_curation_key, extension_unique_id +from custom_components.span_panel.util import SUB_DEVICE_BESS +from custom_components.span_panel.websocket import async_register_commands + +from .factories import SpanPanelSnapshotFactory + +if TYPE_CHECKING: + from span_panel_api import SpanPanelSnapshot + +PANEL_SERIAL = "sp3-242424-001" +ENTRY_ID = "span_entry" +ADOPTED_ANCHOR = "generator-1" +ADOPTED_IDENTIFIER = adopted_identifier(PANEL_SERIAL, ADOPTED_ANCHOR) +BESS_IDENTIFIER = f"{PANEL_SERIAL}_{SUB_DEVICE_BESS}" + +# One reading, one control and one string, so the three shapes the allowed-choice +# helpers answer differently are all on one device. +POWER = AdoptedProperty( + node_id="meter", property_id="active-power", datatype="float", unit="W", value="2400" +) +SETPOINT = AdoptedProperty( + node_id="control", + property_id="power-setpoint", + datatype="float", + unit="W", + format="0:5000:100", + settable=True, + value="1000", +) +LABEL = AdoptedProperty( + node_id="status", property_id="mode-label", datatype="string", value="idle" +) + +GENERATOR = AdoptedDevice( + device_id=ADOPTED_ANCHOR, + device_type="energy.ebus.device.generator", + name="Backup Generator", + model="GEN-9000", + properties=(POWER, SETPOINT, LABEL), +) + +CELL_TEMPERATURE = ExtensionProperty( + subject=ExtensionSubject(kind="battery"), + node_id="battery-2", + property_id="cell-temperature", + datatype="float", + unit="°C", + value="31.4", +) + +POWER_KEY = adopted_curation_key(ADOPTED_IDENTIFIER, POWER) +LABEL_KEY = adopted_curation_key(ADOPTED_IDENTIFIER, LABEL) +SETPOINT_KEY = adopted_curation_key(ADOPTED_IDENTIFIER, SETPOINT) +CELL_TEMPERATURE_KEY = extension_curation_key(CELL_TEMPERATURE.subject, CELL_TEMPERATURE.path) + + +def _snapshot( + *, + devices: tuple[AdoptedDevice, ...] = (GENERATOR,), + rows: tuple[ExtensionProperty, ...] = (CELL_TEMPERATURE,), +) -> SpanPanelSnapshot: + """Return a curated snapshot carrying the given adopted devices and extension rows.""" + return replace( + SpanPanelSnapshotFactory.create_complete(serial_number=PANEL_SERIAL), + adopted_devices=devices, + extension_properties=rows, + ) + + +def _register_cards(hass: HomeAssistant) -> dr.DeviceEntry: + """Register the panel and its BESS card, as setup leaves them, and return the panel.""" + registry = dr.async_get(hass) + panel = registry.async_get_or_create( + config_entry_id=ENTRY_ID, + identifiers={(DOMAIN, PANEL_SERIAL)}, + name="Span Panel", + ) + registry.async_get_or_create( + config_entry_id=ENTRY_ID, + identifiers={(DOMAIN, BESS_IDENTIFIER)}, + name="Span Panel Battery", + via_device_id=panel.id, + ) + return panel + + +def _setup( + hass: HomeAssistant, + snapshot: SpanPanelSnapshot | None = None, + *, + overlay: CurationOverlay | None = None, + register_adopted: bool = True, +) -> dr.DeviceEntry: + """Bring an entry up the way setup does, and return the panel's device entry. + + In setup's own order: the panel card, then the entry's runtime data, then the + adopted devices -- `async_register_adopted_devices` is called rather than + hand-registering, so the identifiers under test are the ones production mints. + """ + entry = MockConfigEntry(domain=DOMAIN, data={}, entry_id=ENTRY_ID, unique_id=PANEL_SERIAL) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + panel = _register_cards(hass) + coordinator = MagicMock() + coordinator.data = snapshot if snapshot is not None else _snapshot() + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id=panel.id, + curation=overlay if overlay is not None else CurationOverlay.empty(), + ) + if register_adopted: + async_register_adopted_devices( + hass, ENTRY_ID, coordinator.data, panel_device_id=panel.id + ) + return panel + + +async def _list( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, device_id: str +) -> dict[str, Any]: + """Send one adopted/list request over a real websocket and return the reply.""" + async_register_commands(hass) + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "span_panel/adopted/list", "device_id": device_id}) + reply: dict[str, Any] = await client.receive_json() + return reply + + +def _group(reply: dict[str, Any], name: str) -> dict[str, Any]: + """Return the one device group with this name, failing the test if it is absent.""" + matched = [group for group in reply["result"]["devices"] if group["name"] == name] + assert len(matched) == 1, f"expected exactly one {name!r} group in {reply['result']}" + return matched[0] + + +def _row(group: dict[str, Any], key: str) -> dict[str, Any]: + """Return the one row in this group carrying this curation key.""" + matched = [row for row in group["rows"] if row["key"] == key] + assert len(matched) == 1, f"expected exactly one row keyed {key!r} in {group}" + return matched[0] + + +# --- grouping --------------------------------------------------------------- + + +async def test_rows_are_grouped_by_the_device_they_render_on( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """An adopted device and a curated card are two groups, each flagged for what it is. + + The flag is what the editor decides from: an adopted device is a card this + integration minted from the wire, a curated one is a card the user already + knows, and their rows are curated identically but presented differently. + """ + panel = _setup(hass) + + reply = await _list(hass, hass_ws_client, panel.id) + + assert reply["success"] is True + generator = _group(reply, "Backup Generator") + battery = _group(reply, "Span Panel Battery") + + assert generator["adopted_device"] is True + assert battery["adopted_device"] is False + + registry = dr.async_get(hass) + adopted_card = registry.async_get_device(identifiers={(DOMAIN, ADOPTED_IDENTIFIER)}) + assert adopted_card is not None + assert generator["device_id"] == adopted_card.id + bess_card = registry.async_get_device(identifiers={(DOMAIN, BESS_IDENTIFIER)}) + assert bess_card is not None + assert battery["device_id"] == bess_card.id + + assert [row["key"] for row in generator["rows"]] == [SETPOINT_KEY, POWER_KEY, LABEL_KEY] + assert [row["key"] for row in battery["rows"]] == [CELL_TEMPERATURE_KEY] + + +async def test_a_row_carries_the_documented_keys( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """The row record's key set is a contract with a card in another repository. + + Asserted exactly rather than by presence, exactly as the topology command's + circuit record is: a deliberate change updates this list, an accidental one + fails here rather than in a renderer no test in this repository can reach. + """ + panel = _setup(hass) + + reply = await _list(hass, hass_ws_client, panel.id) + + assert set(_row(_group(reply, "Backup Generator"), POWER_KEY)) == { + "key", + "path", + "platform", + "entity_id", + "datatype", + "unit", + "settable", + "name", + "curation", + "allowed_device_classes", + "allowed_state_classes", + "stale_fields", + } + + +async def test_a_row_reports_the_declaration_it_is_curated_against( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Path, platform, datatype, unit, settability and name, from the wire.""" + panel = _setup(hass) + + reply = await _list(hass, hass_ws_client, panel.id) + generator = _group(reply, "Backup Generator") + + power = _row(generator, POWER_KEY) + assert power["path"] == "meter/active-power" + assert power["platform"] == "sensor" + assert power["datatype"] == "float" + assert power["unit"] == "W" + assert power["settable"] is False + assert power["name"] == "Active Power" + + setpoint = _row(generator, SETPOINT_KEY) + assert setpoint["platform"] == "number" + assert setpoint["settable"] is True + + temperature = _row(_group(reply, "Span Panel Battery"), CELL_TEMPERATURE_KEY) + assert temperature["path"] == "battery-2/cell-temperature" + assert temperature["platform"] == "sensor" + assert temperature["unit"] == "°C" + # Node-prefixed, exactly as the entity is: a vendor reading sits beside + # curated ones on the same card and has to disambiguate itself. + assert temperature["name"] == "Battery 2 Cell Temperature" + + +# --- the allowed choices ---------------------------------------------------- + + +async def test_a_string_row_admits_no_state_class( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """A state class needs a numeric sensor, so a string row offers none. + + Computed server-side so the card never renders an option the curate command + would refuse. + """ + panel = _setup(hass) + + reply = await _list(hass, hass_ws_client, panel.id) + generator = _group(reply, "Backup Generator") + + assert _row(generator, LABEL_KEY)["allowed_state_classes"] == [] + assert _row(generator, POWER_KEY)["allowed_state_classes"] == [ + state_class.value for state_class in SensorStateClass + ] + + +async def test_a_control_row_admits_neither_class( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """A settable numeric surfaces as a control, and a control carries prominence only.""" + panel = _setup(hass) + + reply = await _list(hass, hass_ws_client, panel.id) + setpoint = _row(_group(reply, "Backup Generator"), SETPOINT_KEY) + + assert setpoint["allowed_state_classes"] == [] + assert setpoint["allowed_device_classes"] == [] + + +async def test_the_declared_unit_constrains_the_device_classes( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Core's own unit map decides, so a watt row offers power and never temperature.""" + panel = _setup(hass) + + reply = await _list(hass, hass_ws_client, panel.id) + + power = _row(_group(reply, "Backup Generator"), POWER_KEY) + assert "power" in power["allowed_device_classes"] + assert "temperature" not in power["allowed_device_classes"] + + temperature = _row(_group(reply, "Span Panel Battery"), CELL_TEMPERATURE_KEY) + assert "temperature" in temperature["allowed_device_classes"] + assert "power" not in temperature["allowed_device_classes"] + + +# --- what the store holds --------------------------------------------------- + + +async def test_a_stored_record_is_reported_as_stored( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """The editor opens on what the user asserted, field for field.""" + panel = _setup( + hass, + overlay=CurationOverlay( + { + POWER_KEY: CurationRecord( + state_class=SensorStateClass.MEASUREMENT, + device_class="power", + promote=True, + ) + } + ), + ) + + reply = await _list(hass, hass_ws_client, panel.id) + generator = _group(reply, "Backup Generator") + + assert _row(generator, POWER_KEY)["curation"] == { + "state_class": "measurement", + "device_class": "power", + "entity_category": "none", + } + assert _row(generator, POWER_KEY)["stale_fields"] == [] + # A row nobody has curated carries an empty record rather than a null. + assert _row(generator, LABEL_KEY)["curation"] == {} + + +async def test_a_record_the_wire_outgrew_is_shown_and_named_stale( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Shown as stored *and* marked, because the editor is where the user finds out. + + Entity construction reads the same record through `for_row`, which drops what + no longer fits. The editor must not: a silently sanitised record would show + the user an assertion they never made, with no way to see that theirs was + dropped. So the stored fields are reported verbatim beside the names of the + ones the current declaration refuses. + """ + panel = _setup( + hass, + overlay=CurationOverlay( + {LABEL_KEY: CurationRecord(state_class=SensorStateClass.TOTAL_INCREASING)} + ), + ) + + reply = await _list(hass, hass_ws_client, panel.id) + label = _row(_group(reply, "Backup Generator"), LABEL_KEY) + + assert label["curation"] == {"state_class": "total_increasing"} + assert label["stale_fields"] == ["state_class"] + + +# --- the registry side ------------------------------------------------------ + + +async def test_entity_id_is_the_registry_row_or_null( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """A row not yet in the registry reports null rather than a guessed id. + + Which is an ordinary state for both halves: an adopted entity is created + disabled and a vendor extension arrives on the setup after its card does, so + a row the user can curate now may have no entity until the next reload. + """ + panel = _setup(hass) + er.async_get(hass).async_get_or_create( + "sensor", + DOMAIN, + adopted_unique_id(ADOPTED_IDENTIFIER, POWER), + suggested_object_id="backup_generator_active_power", + ) + + reply = await _list(hass, hass_ws_client, panel.id) + generator = _group(reply, "Backup Generator") + + assert _row(generator, POWER_KEY)["entity_id"] == "sensor.backup_generator_active_power" + assert _row(generator, LABEL_KEY)["entity_id"] is None + battery = _group(reply, "Span Panel Battery") + assert _row(battery, CELL_TEMPERATURE_KEY)["entity_id"] is None + + +async def test_the_platform_a_row_already_holds_decides_its_entity( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """An extension row resolves its platform through the registry, never the datatype alone. + + The domain is baked into `entity_id`, so a row born a sensor stays one however + the publisher relabels it -- and the list has to report the platform the + entity actually has, or the editor offers choices for a row that is not there. + """ + unique_id = extension_unique_id( + PANEL_SERIAL, + CELL_TEMPERATURE.subject, + CELL_TEMPERATURE.node_id, + CELL_TEMPERATURE.property_id, + ) + assert unique_id is not None + panel = _setup( + hass, + _snapshot(rows=(replace(CELL_TEMPERATURE, datatype="boolean", unit=None),)), + ) + er.async_get(hass).async_get_or_create( + "sensor", DOMAIN, unique_id, suggested_object_id="battery_2_cell_temperature" + ) + + reply = await _list(hass, hass_ws_client, panel.id) + temperature = _row(_group(reply, "Span Panel Battery"), CELL_TEMPERATURE_KEY) + + assert temperature["platform"] == "sensor" + assert temperature["entity_id"] == "sensor.battery_2_cell_temperature" + + +async def test_a_device_with_no_card_yet_is_grouped_under_a_null_id( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """A device that arrived after setup has no card until the next reload, and still lists. + + Its rows are curatable now -- the store is keyed on the wire address, not on a + registry id -- so hiding them would make the user wait a reload to assert + something the store would happily hold. + """ + panel = _setup(hass, register_adopted=False) + + reply = await _list(hass, hass_ws_client, panel.id) + generator = _group(reply, "Backup Generator") + + assert generator["device_id"] is None + assert generator["adopted_device"] is True + assert [row["key"] for row in generator["rows"]] == [SETPOINT_KEY, POWER_KEY, LABEL_KEY] + + +async def test_an_extension_row_waits_for_the_card_it_belongs_on( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """No card, no group -- the same deferral the entity builders make. + + An extension property hangs off a device this integration models, so a + subject whose card is not registered yet has nowhere to render and no name to + group under. It appears at the next reload, exactly as its entity does. + """ + panel = _setup( + hass, + _snapshot(rows=(replace(CELL_TEMPERATURE, subject=ExtensionSubject(kind="pv")),)), + ) + + reply = await _list(hass, hass_ws_client, panel.id) + + assert [group["name"] for group in reply["result"]["devices"]] == ["Backup Generator"] + + +# --- refusals --------------------------------------------------------------- + + +async def test_requires_admin( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_admin_user: MockUser, +) -> None: + """Curation is an admin act, refused before any device is resolved.""" + hass_admin_user.groups = [] + async_register_commands(hass) + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + {"type": "span_panel/adopted/list", "device_id": "any-device-id"} + ) + + reply = await client.receive_json() + assert reply["success"] is False + assert reply["error"]["code"] == "unauthorized" + + +async def test_device_not_found( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """An id the registry does not hold is refused by that name.""" + _setup(hass) + + reply = await _list(hass, hass_ws_client, "nonexistent") + + assert reply["error"]["code"] == "device_not_found" + + +async def test_not_span_panel(hass: HomeAssistant, hass_ws_client: WebSocketGenerator) -> None: + """Another integration's device is refused before its entry is touched.""" + other = MockConfigEntry(domain="other_domain", data={}, entry_id="other_entry") + other.add_to_hass(hass) + device = dr.async_get(hass).async_get_or_create( + config_entry_id="other_entry", + identifiers={("other_domain", "other_serial")}, + ) + + reply = await _list(hass, hass_ws_client, device.id) + + assert reply["error"]["code"] == "not_span_panel" + + +async def test_a_span_identifier_no_span_entry_owns( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """A device row that outlived its entry is refused rather than followed. + + The entry's domain is checked rather than assumed, because a device row may + carry entries from more than one integration and the first is not necessarily + ours. Topology answers `not_loaded` here; this command answers + `not_span_panel`, because resolving the entry and checking its domain is one + step and a device whose SPAN entry is gone is not a SPAN panel any more. + """ + other = MockConfigEntry(domain="other_domain", data={}, entry_id="other_entry") + other.add_to_hass(hass) + device = dr.async_get(hass).async_get_or_create( + config_entry_id="other_entry", + identifiers={(DOMAIN, PANEL_SERIAL)}, + ) + + reply = await _list(hass, hass_ws_client, device.id) + + assert reply["error"]["code"] == "not_span_panel" + + +async def test_not_panel_device(hass: HomeAssistant, hass_ws_client: WebSocketGenerator) -> None: + """A sub-device id is refused: the panel is the handle for the whole entry. + + Sub-devices are exactly what this command *reports*, so passing one is the + ordinary mistake, and it earns its own code rather than an empty list. + """ + _setup(hass) + bess = dr.async_get(hass).async_get_device(identifiers={(DOMAIN, BESS_IDENTIFIER)}) + assert bess is not None + + reply = await _list(hass, hass_ws_client, bess.id) + + assert reply["error"]["code"] == "not_panel_device" + + +async def test_not_loaded(hass: HomeAssistant, hass_ws_client: WebSocketGenerator) -> None: + """An entry that is not set up has no overlay and no snapshot to report.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, entry_id=ENTRY_ID, unique_id=PANEL_SERIAL) + entry.add_to_hass(hass) + panel = _register_cards(hass) + + reply = await _list(hass, hass_ws_client, panel.id) + + assert reply["error"]["code"] == "not_loaded" + + +async def test_runtime_data_is_not_ours( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """A LOADED entry whose runtime data is not ours answers not_loaded, not an attribute error. + + `loaded_runtime_data` is the one place that decides this, per AGENTS.md's + runtime-data guard: core deletes the attribute on unload, and what is there + is whatever put it there. + """ + entry = MockConfigEntry(domain=DOMAIN, data={}, entry_id=ENTRY_ID, unique_id=PANEL_SERIAL) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = object() + panel = _register_cards(hass) + + reply = await _list(hass, hass_ws_client, panel.id) + + assert reply["error"]["code"] == "not_loaded" + + +async def test_no_data(hass: HomeAssistant, hass_ws_client: WebSocketGenerator) -> None: + """A panel that has not answered yet has nothing to list.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, entry_id=ENTRY_ID, unique_id=PANEL_SERIAL) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + panel = _register_cards(hass) + coordinator = MagicMock() + coordinator.data = None + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id=panel.id, + curation=CurationOverlay.empty(), + ) + + reply = await _list(hass, hass_ws_client, panel.id) + + assert reply["error"]["code"] == "no_data" + + +async def test_a_panel_with_nothing_adopted_lists_nothing( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """An empty list, not a refusal: most panels publish neither kind of row.""" + panel = _setup(hass, _snapshot(devices=(), rows=())) + + reply = await _list(hass, hass_ws_client, panel.id) + + assert reply["success"] is True + assert reply["result"] == {"devices": []} + + +async def test_device_id_is_required_by_the_schema( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Constrained at the schema rather than checked in the handler. + + A missing device id is refused by the websocket layer, so the handler never + runs and never has to answer for a request shape voluptuous can reject. + """ + async_register_commands(hass) + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "span_panel/adopted/list"}) + + reply = await client.receive_json() + assert reply["success"] is False + assert reply["error"]["code"] == "invalid_format" diff --git a/websocket-api.md b/websocket-api.md index 2eeb6f8f..1419fda2 100644 --- a/websocket-api.md +++ b/websocket-api.md @@ -166,6 +166,7 @@ current, `switch` is absent for always-on circuits). | ---------------- | --------------------------------------------- | | device_not_found | The device_id does not exist in HA | | not_span_panel | The device is not a SPAN Panel device | +| not_panel_device | The device_id is a sub-device, not the panel | | not_loaded | The integration or config entry is not loaded | | no_data | The coordinator has no panel data yet | @@ -189,3 +190,101 @@ for (const [circuitId, circuit] of Object.entries(topology.circuits)) { Each panel is a separate config entry with its own device ID. To render multiple panels, call `span_panel/panel_topology` once per panel device ID. The response is scoped to a single panel — circuits, sub-devices, and entity mappings from other panels are never included. + +## `span_panel/adopted/list` + +Returns every adopted row on a panel, grouped by the device card it renders on. An adopted row is a property the panel publishes that this integration models no +field for — a whole device nobody has modelled, or a vendor extension on a device it does model — surfaced as a disabled diagnostic entity in plain wire +vocabulary. + +Those entities carry deliberately minimal metadata, because a state class is not declared on the wire and is not derivable from one, and a device class guessed +off a unit mislabels as often as it helps. The owner of the vendor device is not guessing, so this command is the input to an editor where they can say what the +integration refuses to infer. Each row therefore carries not only what the wire declares but the choices Core's own maps admit for that declaration, computed +server-side so a card never offers an option that would be refused on save. + +Admin only, like every command here. + +### Request + +```json +{ + "type": "span_panel/adopted/list", + "device_id": "" +} +``` + +| Field | Type | Description | +| ----------- | ------ | ------------------------------------------------------------------------------------------ | +| `device_id` | string | The device registry ID for the **main SPAN panel**, the same handle `panel_topology` takes | + +### Response + +```json +{ + "devices": [ + { + "device_id": "abc123def456", + "name": "Backup Generator", + "adopted_device": true, + "rows": [ + { + "key": "nj-2316-005k6_adopted_generator-1/meter/active-power", + "path": "meter/active-power", + "platform": "sensor", + "entity_id": "sensor.backup_generator_active_power", + "datatype": "float", + "unit": "W", + "settable": false, + "name": "Active Power", + "curation": { "state_class": "measurement", "device_class": "power" }, + "allowed_device_classes": ["power"], + "allowed_state_classes": ["measurement", "total", "total_increasing"], + "stale_fields": [] + } + ] + } + ] +} +``` + +#### Device Object + +| Field | Type | Description | +| ---------------- | ----------- | ---------------------------------------------------------------------------------- | +| `device_id` | string/null | HA device registry ID, or null for an adopted device whose card is not created yet | +| `name` | string | The card's display name, or the wire label when there is no card yet | +| `adopted_device` | bool | Whether the card is one adoption minted, rather than a curated SPAN device | +| `rows` | object[] | The curatable rows on that card (see below) | + +#### Row Object + +| Field | Type | Description | +| ------------------------ | ----------- | ----------------------------------------------------------------------------- | +| `key` | string | The curation key for this row — what a save is keyed on | +| `path` | string | The `{node}/{property}` wire address | +| `platform` | string | `sensor`, `binary_sensor`, `switch`, `select`, or `number` | +| `entity_id` | string/null | Null when the entity is not in the registry yet | +| `datatype` | string | The declared Homie datatype | +| `unit` | string/null | The declared unit, verbatim | +| `settable` | bool | Whether the panel accepts a write to this property | +| `name` | string | The entity's name in wire vocabulary | +| `curation` | object | The stored record, as stored; `{}` when the row has never been curated | +| `allowed_device_classes` | string[] | Device classes admissible for this platform and unit; empty for a control row | +| `allowed_state_classes` | string[] | State classes admissible for this row; empty off a numeric sensor | +| `stale_fields` | string[] | Stored fields the current declaration no longer supports | + +`curation` reports what is stored rather than what would be applied. A field named in `stale_fields` is one the wire has outgrown since it was asserted — the +entity is built without it, and the editor shows it so the user can see their assertion was dropped rather than silently losing it. + +A row is listed whether or not its entity exists yet: adopted entities are created disabled, and a vendor extension appears on the setup after its device card +does. Curation is keyed on the wire address rather than on a registry ID, so a row can be curated before its entity exists. + +### Errors + +| Code | Description | +| ---------------- | --------------------------------------------- | +| device_not_found | The device_id does not exist in HA | +| not_span_panel | The device is not a SPAN Panel device | +| not_panel_device | The device_id is a sub-device, not the panel | +| not_loaded | The integration or config entry is not loaded | +| no_data | The coordinator has no panel data yet | From a851212812788c2f1eea1a791731f3afc61708b6 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:57:49 -0700 Subject: [PATCH 234/345] fix(curation): adopted/list skips the loser of a flattened-id collision --- .../span_panel/websocket_adopted.py | 37 ++++++-- tests/test_websocket_adopted.py | 95 +++++++++++++++++++ 2 files changed, 125 insertions(+), 7 deletions(-) diff --git a/custom_components/span_panel/websocket_adopted.py b/custom_components/span_panel/websocket_adopted.py index 6014d506..90c694f8 100644 --- a/custom_components/span_panel/websocket_adopted.py +++ b/custom_components/span_panel/websocket_adopted.py @@ -28,6 +28,7 @@ from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er import voluptuous as vol @@ -202,30 +203,52 @@ def _rows(hass: HomeAssistant, snapshot: SpanPanelSnapshot) -> list[_AdoptableRo a row it declines has no entity, no card to group under and no name to show. Adopted declarations are sorted by `path` for the same reason `_create` - sorts them -- adapter emission order tracks the wire, so an order derived - from it moves when a firmware update declares a property earlier. Extension - rows are sorted for a weaker version of the same reason: `adoptable` returns - the already-registered rows first, so an unsorted list would reshuffle the - card the moment a new row's entity appeared. + sorts them, and the sort does the same job here: `adopted_unique_id` is + deliberately non-injective, so two wire addresses can flatten onto one id, + and the lexically first path claims it. **The other is skipped rather than + listed**, mirroring `_create`, because a listed loser is not merely a row + with no entity: `entity_id` resolves by (platform, unique_id), so it would + report the *winner's* entity beside its own curation key -- inviting a record + saved against an entity that will never read it, under a live entity_id + saying it will. The skip is silent; `_create` already warns, naming both + addresses, and a second line per list request would say nothing new. + + Claimed per platform, which is the scope the registry keys on: an entity is + unique by (domain, integration, unique_id), so the same id under `sensor` and + under `switch` is two entities and not a collision. `_create` runs once per + platform and gets that scoping for free; one pass over every platform has to + say so. + + Extension rows are sorted for a weaker version of the ordering reason: + `adoptable` returns the already-registered rows first, so an unsorted list + would reshuffle the card the moment a new row's entity appeared. They need no + claim -- `extension_unique_id` carries the wire path verbatim and is + injective by construction. """ device_registry = dr.async_get(hass) entity_registry = er.async_get(hass) rows: list[_AdoptableRow] = [] + claimed: set[tuple[Platform, str]] = set() for device in snapshot.adopted_devices: identifier = resolve_identifier(device_registry, snapshot.serial_number, device) card = device_registry.async_get_device(identifiers={(DOMAIN, identifier)}) for declaration in sorted(device.properties, key=lambda row: row.path): + platform = classify(declaration) + unique_id = adopted_unique_id(identifier, declaration) + if (platform, unique_id) in claimed: + continue + claimed.add((platform, unique_id)) rows.append( _AdoptableRow( key=adopted_curation_key(identifier, declaration), path=declaration.path, context=RowContext( - platform=classify(declaration), + platform=platform, datatype=declaration.datatype, unit=declaration.unit, ), - unique_id=adopted_unique_id(identifier, declaration), + unique_id=unique_id, device_identifier=identifier, device_registry_id=None if card is None else card.id, device_label=_device_label(card, adopted_device_label(device)), diff --git a/tests/test_websocket_adopted.py b/tests/test_websocket_adopted.py index 61d164ca..8ce09ff2 100644 --- a/tests/test_websocket_adopted.py +++ b/tests/test_websocket_adopted.py @@ -71,6 +71,31 @@ properties=(POWER, SETPOINT, LABEL), ) +# Two wire addresses that flatten to one adopted unique_id -- the collision +# `adopted_unique_id` documents as permanent, spelled out. `battery-2/...` sorts +# first because `-` precedes `/`, which is what makes it the claim. +FLATTENS_FIRST = AdoptedProperty( + node_id="battery-2", + property_id="cell-temperature", + datatype="float", + unit="°C", + value="31.4", +) +FLATTENS_SECOND = AdoptedProperty( + node_id="battery", + property_id="2-cell-temperature", + datatype="float", + unit="°C", + value="31.5", +) +FLATTENS_SECOND_AS_SWITCH = AdoptedProperty( + node_id="battery", + property_id="2-cell-temperature", + datatype="boolean", + settable=True, + value="true", +) + CELL_TEMPERATURE = ExtensionProperty( subject=ExtensionSubject(kind="battery"), node_id="battery-2", @@ -207,6 +232,76 @@ async def test_rows_are_grouped_by_the_device_they_render_on( assert [row["key"] for row in battery["rows"]] == [CELL_TEMPERATURE_KEY] +async def test_one_declaration_claims_a_flattened_id_and_the_other_is_not_listed( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """The row that cannot become an entity is not offered as one. + + `adopted_unique_id` is deliberately non-injective, so two wire addresses can + flatten onto one id; `adoption._create` gives it to the lexically first path + and skips the other. Listing both would be worse than cosmetic: the editor + resolves `entity_id` by (platform, unique_id), so the skipped row would + report the *winner's* entity while carrying its own curation key -- a record + the user saves against an entity that will never read it, beside a live + entity_id saying it will. + + The declarations are handed over in the other order, so what decides is the + sort rather than the order the publisher happened to emit. + """ + unique_id = adopted_unique_id(ADOPTED_IDENTIFIER, FLATTENS_FIRST) + assert unique_id == adopted_unique_id(ADOPTED_IDENTIFIER, FLATTENS_SECOND) + panel = _setup( + hass, + _snapshot( + devices=(replace(GENERATOR, properties=(FLATTENS_SECOND, FLATTENS_FIRST)),), + rows=(), + ), + ) + er.async_get(hass).async_get_or_create( + "sensor", + DOMAIN, + unique_id, + suggested_object_id="backup_generator_battery_2_cell_temperature", + ) + + reply = await _list(hass, hass_ws_client, panel.id) + rows = _group(reply, "Backup Generator")["rows"] + + assert [row["key"] for row in rows] == [ + adopted_curation_key(ADOPTED_IDENTIFIER, FLATTENS_FIRST) + ] + assert rows[0]["path"] == "battery-2/cell-temperature" + assert rows[0]["entity_id"] == "sensor.backup_generator_battery_2_cell_temperature" + + +async def test_one_id_on_two_platforms_is_two_entities_and_two_rows( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """The claim is per platform, because the registry's own uniqueness is. + + An entity is unique by (domain, integration, unique_id), so the same id under + `sensor` and under `switch` is two entities rather than a collision -- + `adoption._create` claims per platform for exactly that reason. A claim + scoped to the id alone would drop a row that really does become an entity. + """ + panel = _setup( + hass, + _snapshot( + devices=(replace(GENERATOR, properties=(FLATTENS_FIRST, FLATTENS_SECOND_AS_SWITCH)),), + rows=(), + ), + ) + + reply = await _list(hass, hass_ws_client, panel.id) + rows = _group(reply, "Backup Generator")["rows"] + + assert [row["platform"] for row in rows] == ["sensor", "switch"] + assert [row["key"] for row in rows] == [ + adopted_curation_key(ADOPTED_IDENTIFIER, FLATTENS_FIRST), + adopted_curation_key(ADOPTED_IDENTIFIER, FLATTENS_SECOND_AS_SWITCH), + ] + + async def test_a_row_carries_the_documented_keys( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: From 566bf1c192210246da8c4249c03ad4708df6fa85 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:06:04 -0700 Subject: [PATCH 235/345] security(rest): ride the pinned CA at runtime, and fail closed with repairs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #264. Every REST call site adopted `panel_rest_transport` when the pin landed — config flow, reauth, repairs, rotate_credentials — except the one that runs on every startup: the schema fetch inside connect() stayed on plaintext HTTP with the pin sitting unused in entry.data, which is the condition the library's transport warning named. The runtime client now takes the pinned context and the HTTPS port (span-panel-api 3.4.0), so a pinned entry makes zero plaintext bootstrap calls in steady state. Failing closed follows the library's own escalation rule: a matching fingerprint never convicts. A rotated CA — diagnosed by re-reading the advertised CA, plaintext deliberately — is the one terminal outcome, taking the existing guided re-pin repair. Under an unchanged CA the leaf probe splits the rest: a moved panel raises the existing address repair and keeps its promise to retry; a leaf the pin does not validate — a middlebox, or a certificate a clock reset pushed outside its window — raises its own non-persistent repair and retries, so the clock case heals itself and plaintext is never fallen back to; missing evidence just retries. The two same-fingerprint repairs supersede each other, since a panel cannot be both moved and intercepted. A stored anchor that cannot be read stops setup with a fixable repair that reuses the CA-change flow, instead of quietly downgrading the one call that runs unattended on every boot. A stored HTTPS port of 80 — which the library refuses under a context — ends in a clear error naming Reconfigure, and both port forms now refuse it at input. For the population the deferred pin serves — TLS behind NAT, a port forward, a proxy, never asked for a port by the gated CA step — Reconfigure now offers the HTTPS port on any pinned entry, probing against and persisting the submitted value, and the retry message for an unreachable pinned panel names the expected port and the remedy. Entry removal clears the whole CA repair family, including two pre-existing leakers. --- CHANGELOG.md | 18 + README.md | 7 +- custom_components/span_panel/__init__.py | 222 ++++++- custom_components/span_panel/ca_repairs.py | 121 ++++ custom_components/span_panel/config_flow.py | 168 ++++-- .../span_panel/config_flow_validation.py | 48 +- custom_components/span_panel/manifest.json | 2 +- custom_components/span_panel/repairs.py | 10 +- custom_components/span_panel/strings.json | 27 +- .../span_panel/translations/en.json | 27 +- .../span_panel/translations/es.json | 27 +- .../span_panel/translations/fr.json | 27 +- .../span_panel/translations/ja.json | 27 +- .../span_panel/translations/pt.json | 27 +- pyproject.toml | 2 +- requirements_test.txt | 2 +- tests/test_runtime_rest_transport.py | 571 ++++++++++++++++++ tests/test_v2_config_flow.py | 194 ++++++ 18 files changed, 1420 insertions(+), 107 deletions(-) create mode 100644 tests/test_runtime_rest_transport.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b6a8951c..83686417 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to this project will be documented in this file. +## [2.1.1] + +### Fixed + +- **The startup warning that panel traffic is unencrypted is resolved, not silenced** — a pinned entry now reads the panel's schema over HTTPS verified against its pinned certificate authority, instead of plaintext HTTP (#264). + +### Added + +- **A repair when the HTTPS port serves a certificate the pin does not validate** — a wrong HTTPS port, a proxy terminating TLS in front of the panel, or a panel whose clock reset in an outage; the integration keeps retrying without connecting and never falls back to plaintext, so the clock case heals on its own. +- **A repair when the stored certificate authority can no longer be read**, guiding you through re-acquiring and confirming the panel's certificate authority. +- **Reconfigure now offers the HTTPS port on any panel with a pinned certificate authority**, for installs whose TLS lives behind a proxy or port forward and were never asked. + +### Changed + +- **A panel whose certificate authority rotated is now also detected at startup's first read**, raising the same guided re-pin repair it already had. +- **A moved panel detected at startup's first read raises the existing address repair and keeps retrying**, exactly as it promises. +- **When a pinned panel cannot be reached at all, the retry message names the HTTPS port and the Reconfigure remedy** instead of only reporting the panel as not ready. + ## [2.1.0] - 8/2026 ### In short diff --git a/README.md b/README.md index 99a45103..6350117a 100644 --- a/README.md +++ b/README.md @@ -679,9 +679,10 @@ re-fetched over plaintext on every connection and whatever answers is trusted. I refusing to start would remove the integration without making the credential any safer. **Proxies cut both ways.** One terminating the broker port with a certificate of its own leaves the entry unpinned, warning at every start with no repair -raised. One terminating only port 443 lets the entry pin, but **Reauthenticate**, **Reconfigure** and **Rotate credentials** then refuse rather than send your -access token unencrypted, until port 443 serves the panel's own certificate too. If your panel serves TLS on another port the setup flow asks — but only once -you have moved the HTTP port off 80. +raised. One terminating only port 443 lets the entry pin, but startup then refuses to connect and keeps retrying, with a repair naming the port — every REST +call a pinned entry makes, the schema read at startup included, verifies against the pin, and a certificate the pin does not validate is refused rather than +downgraded to plaintext — until the port serves the panel's own certificate. If your panel serves TLS on another port, the setup flow asks once you have moved +the HTTP port off 80, and **Reconfigure** offers the HTTPS port to any pinned entry. ### Restricting who can operate the panel diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py index a1085e86..bc8a96a0 100644 --- a/custom_components/span_panel/__init__.py +++ b/custom_components/span_panel/__init__.py @@ -35,6 +35,7 @@ SpanPanelError, SpanPanelServerError, SpanPanelTimeoutError, + SpanPanelTLSVerificationError, SpanPanelValidationError, ) from span_panel_api.mqtt.models import MqttClientConfig @@ -43,8 +44,23 @@ from . import config_flow # noqa: F401 from .additions import async_announce_new_entities, async_forget_announcements from .adoption import async_register_adopted_devices -from .ca_repairs import async_clear_ca_changed, async_raise_ca_changed -from .config_flow_validation import as_port, async_ca_signs_panel_leaf, async_fetch_panel_ca +from .ca_repairs import ( + async_clear_ca_changed, + async_clear_ca_unusable, + async_clear_rest_tls_untrusted, + async_raise_ca_changed, + async_raise_ca_unusable, + async_raise_rest_tls_untrusted, +) +from .config_flow_validation import ( + LeafVerdict, + PanelCaUnusableError, + as_port, + async_ca_signs_panel_leaf, + async_fetch_panel_ca, + async_leaf_probe, + panel_rest_transport, +) from .const import ( CONF_API_VERSION, CONF_EBUS_BROKER_HOST, @@ -213,8 +229,14 @@ async def _async_pinned_ca( # Verifying one port and using the anchor on two has a cost, and it is # stated rather than hidden: a CA that signs the broker's leaf but not # whatever answers on 443 -- a proxy terminating only the HTTPS port -- - # pins here and then fails every REST path, which the callers that carry a - # secret refuse rather than downgrade. README and the changelog say so. + # pins here and then fails every REST path. Since the runtime schema fetch + # moved onto the pin (issue #264), that failure is no longer quiet: setup + # retries under `ConfigEntryNotReady` with the `panel_rest_tls_untrusted` + # Repair naming the port when something answers it badly, and with the + # retry message naming the port and Reconfigure when nothing answers it at + # all -- Reconfigure offers the HTTPS port to a pinned entry for exactly + # this population. Never plaintext either way. README and the changelog + # say so. # # One asymmetry to know about: `async_ca_signs_panel_leaf` accepts a leaf # reached at the address `host` resolves to, while the bridge dials `host` @@ -249,6 +271,88 @@ async def _async_pinned_ca( return ca_pem +async def _async_rest_tls_verdict( + hass: HomeAssistant, + entry: SpanPanelConfigEntry, + host: str, + http_port: int, + https_port: int, + pinned_pem: str, + err: SpanPanelTLSVerificationError, +) -> ConfigEntryError | ConfigEntryNotReady: + """Say what a REST certificate-verification failure was, and how setup ends. + + The same question the library asks when the MQTT handshake fails under a + pin, answered the same way, because the failure itself carries no evidence: + re-read the CA the panel advertises — plaintext deliberately, since a fetch + verified against the old pin would fail and tell us nothing — and compare + fingerprints. The fetch is diagnostic only and never re-anchors anything. + + A different fingerprint is the CA-changed Repair, with its guided re-pin — + a firmware reset rotating the CA is the common legitimate cause of this + failure, and it already has a flow. It is also the only terminal outcome, + which mirrors the library's own `_diagnose_verification_failure`: a + matching fingerprint never escalates, because `SSLCertVerificationError` + under an unchanged CA is three different conditions and two of them are + transient. The leaf probe splits them. A leaf that names somewhere else is + a moved panel — the leaf-mismatch Repair, which promises retrying, and + setup keeps that promise. A leaf the pin does not validate at all is either + something terminating TLS in front of the panel or a certificate a clock + reset pushed outside its validity window — one Repair naming both, retried + so the clock case heals itself and the proxy case stands visibly refused, + never downgraded to plaintext. And missing evidence — the CA unreadable, + the TLS port unreachable — retries with no verdict at all, because a panel + mid-reboot must not be convicted of anything. + """ + expected = ca_fingerprint(pinned_pem) + try: + advertised = await async_fetch_panel_ca(hass, host, http_port=http_port) + observed = ca_fingerprint(advertised) + except ( + SpanPanelAPIError, + SpanPanelConnectionError, + SpanPanelTimeoutError, + SpanPanelValidationError, + ) as fetch_err: + _LOGGER.warning( + "TLS verification failed for SPAN panel %s's REST API and the panel's CA " + "could not be re-read to say why (%s). Treating this as transient and retrying", + entry.title, + fetch_err, + ) + return ConfigEntryNotReady(f"SPAN panel REST TLS failure is undiagnosed yet: {err}") + if observed != expected: + async_raise_ca_changed(hass, entry, expected, observed) + return ConfigEntryError( + f"SPAN panel {entry.title} is advertising CA {observed} where {expected} was pinned" + ) + probe = await async_leaf_probe(host, https_port, pinned_pem) + if probe.verdict is LeafVerdict.NAME_MISMATCH: + # The other half of the mutual supersede: `async_raise_rest_tls_untrusted` + # clears the leaf repair itself, but `leaf_repairs` cannot import + # `ca_repairs` back without a cycle, so this direction lives with the + # one caller that can produce both verdicts. A panel cannot be both + # merely moved and untrusted; only the current verdict may stand. + async_clear_rest_tls_untrusted(hass, entry) + async_raise_leaf_name_mismatch( + hass, entry, LeafNameMismatch(host=host, leaf_names=probe.leaf_names) + ) + return ConfigEntryNotReady( + f"SPAN panel {entry.title}'s certificate does not name {host}; " + "see the Repair, and retrying meanwhile" + ) + if probe.verdict is LeafVerdict.UNTRUSTED: + async_raise_rest_tls_untrusted(hass, entry, host, https_port, expected) + return ConfigEntryNotReady( + f"{host}:{https_port} answered with a certificate SPAN panel {entry.title}'s " + "pinned CA does not currently validate; see the Repair, and retrying meanwhile" + ) + return ConfigEntryNotReady( + f"SPAN panel {entry.title}'s REST TLS failure did not reproduce under diagnosis " + f"({probe.verdict}); retrying: {err}" + ) + + async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> bool: """Set up Span Panel from a config entry.""" _LOGGER.debug("Setting up entry %s (version %s)", entry.entry_id, entry.version) @@ -325,6 +429,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> # plaintext HTTP on every connect and trusting whatever answers. ca_pem = await _async_pinned_ca(hass, entry, host, panel_http_port) + # From `entry.data` rather than `config`, because the deferred pin + # above may have just written the anchor there. Fail closed on a + # stored PEM that cannot be read: the fallback the other callers get + # would put this entry's schema fetch — the call that runs + # unattended on every boot — back on plaintext, which is the + # downgrade the pin exists to prevent (issue #264). The transport's + # own client is not used here; see the constructor comment below. + try: + transport = panel_rest_transport( + hass, entry.data, allow_plaintext_fallback=False + ) + except PanelCaUnusableError as err: + async_raise_ca_unusable(hass, entry, str(err)) + raise ConfigEntryError( # noqa: TRY301 + f"The stored CA for SPAN panel {entry.title} cannot be read; " + "re-acquire it in Settings > Repairs" + ) from err + broker_config = MqttClientConfig( broker_host=host, username=config[CONF_EBUS_BROKER_USERNAME], @@ -341,19 +463,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> serial_number, broker_config, snapshot_interval=snapshot_interval, + # Both ports, because the client runs two transports with + # opposite security properties. The plaintext port carries the + # bridge's CA fetches, which never follow the pin — they read + # the very anchor everything else is checked against. The + # schema fetch rides the pinned transport on the HTTPS port + # whenever the entry holds an anchor, which is every entry the + # config flow has created since pinning landed; passing the + # HTTPS port without a context is refused by the library, hence + # the pairing (issue #264). panel_http_port=panel_http_port, + panel_https_port=transport.port if transport.ssl_context is not None else None, + ssl_context=transport.ssl_context, # Home Assistant's shared client, which it owns and closes at - # shutdown. Without this the library built one per schema read -- - # once at connect, and once per retry while a panel finishes - # rebooting after a firmware upgrade. `quality_scale.yaml` claims - # `inject-websession: done`, and until now that was true of the - # config flow and of nothing that ran afterwards. - # - # Verification is not the reason for the default client rather - # than the config flow's `verify_ssl=False` one: the library's - # bootstrap URLs are plain `http://`, so TLS never happens on - # this path either way. It is the default client because that is - # the one every other integration already shares. + # shutdown, and which serves only the unpinned plaintext path -- + # under a context the library builds a dedicated client per + # call, because httpx fixes its trust store at construction. + # `transport.httpx_client` is deliberately not used: it is the + # config flow's `verify_ssl=False` variant, and asking for that + # here would stand up a second connection pool for a flag that + # is inert on plaintext. The default client is the one every + # other integration already shares. httpx_client=get_async_client(hass), ) @@ -388,6 +518,42 @@ def _on_leaf_name_mismatch(mismatch: LeafNameMismatch) -> None: except SpanPanelAuthError as err: await client.close() raise ConfigEntryAuthFailed(f"MQTT authentication failed: {err}") from err + except SpanPanelValidationError as err: + # The library refusing a stored combination it will not guess + # about — an HTTPS port of 80 under a pin is the one that + # reaches here, written by a config flow that used to accept + # it. A stored value does not fix itself, so no retry; the + # message names the remedy. + await client.close() + raise ConfigEntryError( # noqa: TRY301 + f"SPAN panel {entry.title}'s stored HTTPS port is not usable under " + f"its pinned CA ({err}); correct it with Reconfigure" + ) from err + except SpanPanelTLSVerificationError as err: + # Before its parent `SpanPanelConnectionError`, which would + # retry it with no diagnosis. Something answered the REST HTTPS + # port with a certificate the pin rejects; which Repair that + # raises — and whether it is terminal — is decided by the + # diagnosis below, the only party holding the evidence. + await client.close() + pinned_pem = transport.ca_pem + if pinned_pem is None: + # Not reachable: the library raises this only under an + # ssl_context, and the transport carries a PEM exactly when + # it carries a context. Stated as a guard rather than + # laundered through `str()`, so if the pairing is ever + # broken the failure names itself instead of fingerprinting + # the string "None". + raise + raise await _async_rest_tls_verdict( + hass, + entry, + host, + panel_http_port, + transport.port, + pinned_pem, + err, + ) from err except ( SpanPanelConnectionError, SpanPanelTimeoutError, @@ -402,7 +568,19 @@ def _on_leaf_name_mismatch(mismatch: LeafNameMismatch) -> None: SpanPanelServerError, ) as err: await client.close() - raise ConfigEntryNotReady(f"SPAN panel is not ready yet: {err}") from err + message = f"SPAN panel is not ready yet: {err}" + if transport.ssl_context is not None: + # For the population the deferred pin serves — TLS behind + # NAT, a port forward, a proxy — nothing may answer the + # default HTTPS port at all, and that failure is a plain + # refused connection indistinguishable from a reboot. The + # retry message is the one channel that reaches them, so it + # carries the remedy instead of blaming the panel. + message += ( + f" (its REST API is expected on HTTPS port {transport.port}; if the " + "panel's TLS is served elsewhere, set the HTTPS port via Reconfigure)" + ) + raise ConfigEntryNotReady(message) from err # The connection got as far as a handshake under the current pin, so # any standing Repair describes a state that no longer holds. That @@ -411,6 +589,11 @@ def _on_leaf_name_mismatch(mismatch: LeafNameMismatch) -> None: # event the library re-arms its own signal on. async_clear_ca_changed(hass, entry) async_clear_leaf_name_mismatch(hass, entry) + # Same reconciliation: the REST half of the handshake succeeded + # under the current pin too — the schema fetch inside connect() is + # what these two describe failing. + async_clear_rest_tls_untrusted(hass, entry) + async_clear_ca_unusable(hass, entry) # The other half of the same condition: the reconnect loop runs # fire-and-forget, so a CA that changes mid-session cannot surface as @@ -571,6 +754,13 @@ async def async_remove_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) - it recreates, because every one of them is already recorded as announced. """ async_clear_schema_issues(hass, entry) + # The CA family too, for the same reason — and the two persistent ones most + # of all, since nothing else can ever clear an issue whose entry is gone: + # the fixable flow aborts with `entry_gone`, and the rest have no flow. + async_clear_ca_changed(hass, entry) + async_clear_ca_unusable(hass, entry) + async_clear_rest_tls_untrusted(hass, entry) + async_clear_leaf_name_mismatch(hass, entry) await async_forget_announcements(hass, entry) await async_forget(hass, entry) diff --git a/custom_components/span_panel/ca_repairs.py b/custom_components/span_panel/ca_repairs.py index 7a8c37c6..c2f98055 100644 --- a/custom_components/span_panel/ca_repairs.py +++ b/custom_components/span_panel/ca_repairs.py @@ -30,6 +30,8 @@ _LOGGER = logging.getLogger(__name__) CA_CHANGED_ISSUE_PREFIX = "panel_ca_changed_" +CA_UNUSABLE_ISSUE_PREFIX = "panel_ca_unusable_" +REST_TLS_UNTRUSTED_ISSUE_PREFIX = "panel_rest_tls_untrusted_" def ca_changed_issue_id(entry_id: str) -> str: @@ -37,6 +39,16 @@ def ca_changed_issue_id(entry_id: str) -> str: return f"{CA_CHANGED_ISSUE_PREFIX}{entry_id}" +def ca_unusable_issue_id(entry_id: str) -> str: + """Issue id for one entry's unreadable stored CA.""" + return f"{CA_UNUSABLE_ISSUE_PREFIX}{entry_id}" + + +def rest_tls_untrusted_issue_id(entry_id: str) -> str: + """Issue id for one entry's REST TLS port serving something the pin rejects.""" + return f"{REST_TLS_UNTRUSTED_ISSUE_PREFIX}{entry_id}" + + @callback def async_raise_ca_changed( hass: HomeAssistant, @@ -94,3 +106,112 @@ def async_raise_ca_changed( def async_clear_ca_changed(hass: HomeAssistant, entry: ConfigEntry) -> None: """Drop the Repair, on a setup that reached the panel under the current pin.""" ir.async_delete_issue(hass, DOMAIN, ca_changed_issue_id(entry.entry_id)) + + +@callback +def async_raise_ca_unusable(hass: HomeAssistant, entry: ConfigEntry, reason: str) -> None: + """Raise the Repair for a stored anchor this system cannot read. + + Setup fails closed on this instead of downgrading to plaintext, because the + downgrade would run unattended on every boot at exactly the call the pin + exists to protect. The other REST callers that refuse this state — a + credential rotation — could point at reauth; setup cannot fix a stored + value, so the fix is the same flow the CA-change uses: fetch what the panel + advertises, verify it signs the panel's own certificate, and ask a person to + accept the fingerprint. + + Fixable and persistent for the CA-changed Repair's reasons: the fix *is* the + resolution, and there is no live transport left to re-derive the finding + from. + """ + _LOGGER.error( + "The stored certificate authority for SPAN panel %s cannot be read (%s), so its " + "REST calls cannot be verified and setup has stopped rather than falling back to " + "plaintext. Re-acquire the panel's certificate authority in Settings > Repairs", + entry.title, + reason, + ) + ir.async_create_issue( + hass, + DOMAIN, + ca_unusable_issue_id(entry.entry_id), + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.ERROR, + translation_key="panel_ca_unusable", + translation_placeholders={"panel": entry.title}, + data={"entry_id": entry.entry_id}, + ) + + +@callback +def async_clear_ca_unusable(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Drop the Repair, on a setup whose stored anchor built a working transport.""" + ir.async_delete_issue(hass, DOMAIN, ca_unusable_issue_id(entry.entry_id)) + + +@callback +def async_raise_rest_tls_untrusted( + hass: HomeAssistant, entry: ConfigEntry, host: str, https_port: int, fingerprint: str +) -> None: + """Raise the Repair for a REST TLS port answering with a certificate the pin rejects. + + Raised only after the diagnosis has ruled the alternatives out: the panel + still advertises the pinned CA (a rotated CA takes the CA-changed Repair + and its guided re-pin), and the leaf probe found a certificate the pin does + not currently validate rather than one that merely names somewhere else (a + moved panel takes the leaf-mismatch Repair). What is left is two conditions + the probe cannot tell apart, and the text names both: something terminating + TLS in front of the panel with a certificate of its own, or the panel's own + certificate outside its validity window after a clock reset. + + Not fixable, because none of the remedies are this integration's to apply: + the port is corrected by Reconfigure, the middlebox by whoever put it + there, and the clock by the panel getting time. Retried rather than + terminal — the clock case clears itself, plaintext is never fallen back + to either way, and a matching fingerprint never escalates, which is the + same stance the library's own diagnosis takes. `is_persistent=False` for + the leaf-mismatch Repair's reason: re-derived on every retry, so a restart + that resolves it must not resurrect it. + + Supersedes a standing leaf-name mismatch on the same entry, for the + CA-changed Repair's reason: the two verdicts come out of the same probe and + contradict each other — one promises recovery at a returning address, this + one says what answers there is not trusted — so only the current one may + stand. The reverse supersede lives at the verdict call site in `__init__`, + because `leaf_repairs` cannot import this module back without a cycle. + """ + async_clear_leaf_name_mismatch(hass, entry) + _LOGGER.error( + "Something at %s:%s is answering SPAN panel %s's HTTPS port with a certificate " + "its pinned CA (SHA-256 %s) does not currently validate, while the panel still " + "advertises that same CA. Retrying without connecting: check the entry's HTTPS " + "port and whether anything terminates TLS between Home Assistant and the panel; " + "a panel whose clock reset clears this on its own once it has time again", + host, + https_port, + entry.title, + fingerprint, + ) + ir.async_create_issue( + hass, + DOMAIN, + rest_tls_untrusted_issue_id(entry.entry_id), + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.ERROR, + translation_key="panel_rest_tls_untrusted", + translation_placeholders={ + "panel": entry.title, + "host": host, + "https_port": str(https_port), + "fingerprint": fingerprint, + }, + data={"entry_id": entry.entry_id}, + ) + + +@callback +def async_clear_rest_tls_untrusted(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Drop the Repair, on a setup that reached the panel under the current pin.""" + ir.async_delete_issue(hass, DOMAIN, rest_tls_untrusted_issue_id(entry.entry_id)) diff --git a/custom_components/span_panel/config_flow.py b/custom_components/span_panel/config_flow.py index 5a30a6af..75c76ec0 100644 --- a/custom_components/span_panel/config_flow.py +++ b/custom_components/span_panel/config_flow.py @@ -4,6 +4,7 @@ import asyncio from collections.abc import Mapping +from dataclasses import replace import enum import logging import ssl @@ -184,6 +185,10 @@ def __init__(self) -> None: self._v2_panel_serial: str | None = None self._http_port: int = 80 self._https_port: int = DEFAULT_HTTPS_PORT + # Reconfigure's own copy, kept apart from `_https_port` because that one + # belongs to the CA-acquisition steps and reauth. None until the user + # submits the reconfigure form of a pinned entry. + self._reconfigure_https_port: int | None = None # Whether something authoritative already told this flow where the panel # serves TLS, rather than the value above being a default. Discovery is # one such source: a panel that publishes the port knows where it @@ -1053,17 +1058,30 @@ async def async_step_panel_ca_start( async def async_step_panel_https_port( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Collect the port the panel serves TLS on.""" + """Collect the port the panel serves TLS on. + + 80 is refused here, at the form, because the library refuses it at + every call it would later anchor — a stored plaintext port under a pin + fails each setup with nothing the user can do but reconfigure. Refuse + it while the person who typed it is present. + """ + schema = vol.Schema( + { + vol.Required(CONF_HTTPS_PORT, default=DEFAULT_HTTPS_PORT): vol.All( + vol.Coerce(int), vol.Range(min=1, max=65535) + ), + } + ) if user_input is None: + return self.async_show_form(step_id="panel_https_port", data_schema=schema) + port = int(user_input[CONF_HTTPS_PORT]) + if port == 80: return self.async_show_form( step_id="panel_https_port", - data_schema=vol.Schema( - { - vol.Required(CONF_HTTPS_PORT, default=DEFAULT_HTTPS_PORT): int, - } - ), + data_schema=schema, + errors={"base": "https_port_is_plaintext"}, ) - self._https_port = int(user_input[CONF_HTTPS_PORT]) + self._https_port = port return await self.async_step_panel_ca() async def async_step_panel_ca( @@ -1352,6 +1370,40 @@ async def async_step_choose_entity_naming_initial( raise ConfigFlowError("Missing required parameters during entry creation") return self.create_new_entry(self.host, self.serial_number, self.access_token) + def _reconfigure_form( + self, entry: config_entries.ConfigEntry, host: str, error: str | None = None + ) -> ConfigFlowResult: + """Show the reconfigure form, with the HTTPS port on it exactly when the entry is pinned. + + A pinned entry's REST calls ride the HTTPS port, and one population was + never asked for it: TLS behind a port forward with the HTTP port still + on 80, which the CA step's own gating skips. Reconfigure is the remedy + every refusal points at, so this is where the port has to be settable. + An unpinned entry keeps the host-only form — it has no TLS transport + for the answer to configure. + """ + schema: dict[Any, Any] = {vol.Required(CONF_HOST, default=host): str} + if entry.data.get(CONF_PANEL_CA_PEM): + default = ( + self._reconfigure_https_port + if self._reconfigure_https_port is not None + else as_port(entry.data.get(CONF_HTTPS_PORT), DEFAULT_HTTPS_PORT) + ) + schema[vol.Required(CONF_HTTPS_PORT, default=default)] = vol.All( + vol.Coerce(int), vol.Range(min=1, max=65535) + ) + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema(schema), + errors={"base": error} if error else None, + ) + + def _reconfigured_transport(self, transport: PanelRestTransport) -> PanelRestTransport: + """Apply the submitted HTTPS port to a transport built from stored data.""" + if self._reconfigure_https_port is None or transport.ssl_context is None: + return transport + return replace(transport, port=self._reconfigure_https_port) + async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -1360,18 +1412,23 @@ async def async_step_reconfigure( if user_input is None: current_host = reconfigure_entry.data.get(CONF_HOST, "") - return self.async_show_form( - step_id="reconfigure", - data_schema=vol.Schema({vol.Required(CONF_HOST, default=current_host): str}), - ) + return self._reconfigure_form(reconfigure_entry, str(current_host)) host = user_input[CONF_HOST].strip() if not host: - return self.async_show_form( - step_id="reconfigure", - data_schema=vol.Schema({vol.Required(CONF_HOST, default=""): str}), - errors={"base": "host_required"}, - ) + return self._reconfigure_form(reconfigure_entry, "", "host_required") + + if reconfigure_entry.data.get(CONF_PANEL_CA_PEM): + submitted = port_or_none(user_input.get(CONF_HTTPS_PORT)) + if submitted is None: + submitted = as_port(reconfigure_entry.data.get(CONF_HTTPS_PORT), DEFAULT_HTTPS_PORT) + if submitted == 80: + # The plaintext default. The library refuses this combination at + # every call, so storing it would fail every later setup; + # refused here instead, where the person who typed it is + # present. See `_build_url` in span-panel-api. + return self._reconfigure_form(reconfigure_entry, host, "https_port_is_plaintext") + self._reconfigure_https_port = submitted # Validate the host is reachable and is a v2 panel http_port = as_port(reconfigure_entry.data.get(CONF_HTTP_PORT), 80) @@ -1394,11 +1451,8 @@ async def async_step_reconfigure( reconfigure_entry.title, err, ) - return self.async_show_form( - step_id="reconfigure", - data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), - errors={"base": "ca_unusable"}, - ) + return self._reconfigure_form(reconfigure_entry, host, "ca_unusable") + self._rest_transport = self._reconfigured_transport(self._rest_transport) # The new host may be one the panel's certificate does not name -- a new # DHCP lease, or an FQDN it has not been told about yet -- and this entry # is pinned. Ask what is actually true of it before deciding, because @@ -1420,11 +1474,7 @@ async def async_step_reconfigure( # one of them is the panel being absent. verdict = await async_leaf_verdict(host, self._rest_transport.port, pinned_pem) if verdict is LeafVerdict.UNREACHABLE: - return self.async_show_form( - step_id="reconfigure", - data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), - errors={"base": "cannot_connect"}, - ) + return self._reconfigure_form(reconfigure_entry, host, "cannot_connect") if verdict is not LeafVerdict.NAME_MISMATCH: _LOGGER.warning( "Refusing to reconfigure panel %s to %s: the certificate served there on " @@ -1435,11 +1485,7 @@ async def async_step_reconfigure( self._rest_transport.port, pem_fingerprint_or_reason(pinned_pem), ) - return self.async_show_form( - step_id="reconfigure", - data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), - errors={"base": "ca_leaf_mismatch"}, - ) + return self._reconfigure_form(reconfigure_entry, host, "ca_leaf_mismatch") # The panel is there -- only it holds a key the pinned anchor # signed -- but its certificate names neither this address nor # anything this flow could reach it by. Every call over the @@ -1449,11 +1495,13 @@ async def async_step_reconfigure( # regenerate the certificate that fixes this. The relaxed # transport never leaves this flow and is never stored. self._leaf_names_host = False - self._rest_transport = panel_rest_transport( - self.hass, - reconfigure_entry.data, - allow_plaintext_fallback=False, - verify_hostname=False, + self._rest_transport = self._reconfigured_transport( + panel_rest_transport( + self.hass, + reconfigure_entry.data, + allow_plaintext_fallback=False, + verify_hostname=False, + ) ) try: detection = await detect_api_version( @@ -1468,25 +1516,13 @@ async def async_step_reconfigure( SpanPanelTimeoutError, SpanPanelAPIError, ): - return self.async_show_form( - step_id="reconfigure", - data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), - errors={"base": "cannot_connect"}, - ) + return self._reconfigure_form(reconfigure_entry, host, "cannot_connect") if detection.probe_failed: - return self.async_show_form( - step_id="reconfigure", - data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), - errors={"base": "cannot_connect"}, - ) + return self._reconfigure_form(reconfigure_entry, host, "cannot_connect") if detection.api_version != "v2" or detection.status_info is None: - return self.async_show_form( - step_id="reconfigure", - data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), - errors={"base": "cannot_connect"}, - ) + return self._reconfigure_form(reconfigure_entry, host, "cannot_connect") # Ensure the serial number matches — prevent switching to a different panel await self.async_set_unique_id(detection.status_info.serial_number) @@ -1520,13 +1556,11 @@ async def async_step_reconfigure( reconfigure_entry.title, host, ) - return self.async_show_form( - step_id="reconfigure", - data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), - errors={"base": "ca_name_mismatch"}, - ) + return self._reconfigure_form(reconfigure_entry, host, "ca_name_mismatch") data_updates: dict[str, Any] = {CONF_HOST: host} + if self._reconfigure_https_port is not None: + data_updates[CONF_HTTPS_PORT] = self._reconfigure_https_port old_fqdn = str(reconfigure_entry.data.get(CONF_REGISTERED_FQDN, "")) if old_fqdn: # Switching from FQDN to IP — clean up old registration @@ -1585,12 +1619,15 @@ async def async_step_reconfigure_fqdn_done( ) -> ConfigFlowResult: """Complete reconfiguration after successful FQDN registration.""" reconfigure_entry = self._get_reconfigure_entry() + data_updates: dict[str, Any] = { + CONF_HOST: self.host or "", + CONF_REGISTERED_FQDN: self.host or "", + } + if self._reconfigure_https_port is not None: + data_updates[CONF_HTTPS_PORT] = self._reconfigure_https_port return self.async_update_reload_and_abort( reconfigure_entry, - data_updates={ - CONF_HOST: self.host or "", - CONF_REGISTERED_FQDN: self.host or "", - }, + data_updates=data_updates, ) async def async_step_reconfigure_fqdn_failed( @@ -1620,17 +1657,18 @@ async def async_step_reconfigure_fqdn_failed( self._get_reconfigure_entry().title, self.host, ) - return self.async_show_form( - step_id="reconfigure", - data_schema=vol.Schema({vol.Required(CONF_HOST, default=self.host or ""): str}), - errors={"base": "ca_name_mismatch"}, + return self._reconfigure_form( + self._get_reconfigure_entry(), self.host or "", "ca_name_mismatch" ) # User chose to continue anyway — update host without FQDN registration self._fall_back_to_the_bootstrap_address() reconfigure_entry = self._get_reconfigure_entry() + data_updates: dict[str, Any] = {CONF_HOST: self.host or ""} + if self._reconfigure_https_port is not None: + data_updates[CONF_HTTPS_PORT] = self._reconfigure_https_port return self.async_update_reload_and_abort( reconfigure_entry, - data_updates={CONF_HOST: self.host or ""}, + data_updates=data_updates, ) return self.async_show_form( step_id="reconfigure_fqdn_failed", diff --git a/custom_components/span_panel/config_flow_validation.py b/custom_components/span_panel/config_flow_validation.py index b421cee2..0fa9053b 100644 --- a/custom_components/span_panel/config_flow_validation.py +++ b/custom_components/span_panel/config_flow_validation.py @@ -242,9 +242,35 @@ class LeafVerdict(StrEnum): UNREACHABLE = "unreachable" +@dataclass(frozen=True, slots=True) +class LeafProbeResult: + """One leaf probe's verdict, with the names the certificate actually carries. + + `leaf_names` exists for exactly one verdict: a `NAME_MISMATCH` is only + actionable when the user can be told which names *would* work, and the + probe is the one place that has the validated certificate in hand. SAN + ``DNS`` and ``IP Address`` entries in certificate order — the same reading + the library's `LeafNameMismatch` documents — and empty for every other + verdict, where no validated certificate came back or the names answered + nothing. + """ + + verdict: LeafVerdict + leaf_names: tuple[str, ...] + + async def async_leaf_verdict(host: str, tls_port: int, ca_pem: str) -> LeafVerdict: """Classify the certificate served on `tls_port` against `ca_pem`. + The verdict half of `async_leaf_probe`, kept as its own name for the + callers that act only on the classification. + """ + return (await async_leaf_probe(host, tls_port, ca_pem)).verdict + + +async def async_leaf_probe(host: str, tls_port: int, ca_pem: str) -> LeafProbeResult: + """Classify the certificate served on `tls_port` against `ca_pem`. + One handshake, with hostname checking off, which establishes the chain; the name binding is then evaluated separately from the certificate the handshake returned. Both halves come from the library rather than being @@ -261,7 +287,7 @@ async def async_leaf_verdict(host: str, tls_port: int, ca_pem: str) -> LeafVerdi """ loop = asyncio.get_running_loop() - def _check() -> LeafVerdict: + def _check() -> LeafProbeResult: try: # The library's builder, not a hand-rolled context: the panel's CA # omits the Authority Key Identifier extension, which Python's @@ -271,7 +297,7 @@ def _check() -> LeafVerdict: except (ssl.SSLError, ValueError): # An anchor that will not load cannot validate anything. Nothing was # reached, so this is not an accusation against the host. - return LeafVerdict.UNTRUSTED + return LeafProbeResult(LeafVerdict.UNTRUSTED, ()) try: with ( socket.create_connection((host, tls_port), timeout=5) as sock, @@ -279,7 +305,7 @@ def _check() -> LeafVerdict: ): peer = tls.getpeercert() except ssl.SSLCertVerificationError: - return LeafVerdict.UNTRUSTED + return LeafProbeResult(LeafVerdict.UNTRUSTED, ()) except (OSError, TimeoutError, UnicodeError): # `UnicodeError` for the same reason as in `async_resolve_host`: the # connect resolves the name, and an over-long label raises out of @@ -287,7 +313,7 @@ def _check() -> LeafVerdict: # subclass of `OSError` and lands here too -- a handshake that broke # without a verification failure says nothing about the peer's # certificate, so it is a failure to reach, not a failure to trust. - return LeafVerdict.UNREACHABLE + return LeafProbeResult(LeafVerdict.UNREACHABLE, ()) # The handshake completed under the pinned anchor, so the peer holds a # key that anchor signed. Only the name is left in question. @@ -297,10 +323,18 @@ def _check() -> LeafVerdict: # the verdict that unlocks the relaxed transport, and "the handshake # completed but no validated certificate came back" is not evidence # that anything holds a key the anchor signed. - return LeafVerdict.UNTRUSTED + return LeafProbeResult(LeafVerdict.UNTRUSTED, ()) if leaf_names_host(peer, host): - return LeafVerdict.TRUSTED - return LeafVerdict.NAME_MISMATCH + return LeafProbeResult(LeafVerdict.TRUSTED, ()) + names: list[str] = [] + for san_entry in peer.get("subjectAltName", ()): + # Typeshed leaves SAN entries loosely typed; only the two-string + # shape carries an address, which is all the repair can point at. + if isinstance(san_entry, tuple) and len(san_entry) == 2: + kind, value = san_entry + if kind in ("DNS", "IP Address") and isinstance(value, str): + names.append(value) + return LeafProbeResult(LeafVerdict.NAME_MISMATCH, tuple(names)) return await loop.run_in_executor(None, _check) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 49c0c6fa..5b834a34 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -23,7 +23,7 @@ ], "quality_scale": "gold", "requirements": [ - "span-panel-api==3.3.0", + "span-panel-api==3.4.0", "span-panel-api-schema-0==1.1.2", "span-panel-api-schema-1==1.1.3" ], diff --git a/custom_components/span_panel/repairs.py b/custom_components/span_panel/repairs.py index 06467be7..1693d9af 100644 --- a/custom_components/span_panel/repairs.py +++ b/custom_components/span_panel/repairs.py @@ -22,7 +22,7 @@ ) import voluptuous as vol -from .ca_repairs import CA_CHANGED_ISSUE_PREFIX +from .ca_repairs import CA_CHANGED_ISSUE_PREFIX, CA_UNUSABLE_ISSUE_PREFIX from .config_flow_validation import ( as_port, async_ca_signs_panel_leaf, @@ -150,4 +150,12 @@ async def async_create_fix_flow( if issue_id.startswith(CA_CHANGED_ISSUE_PREFIX): entry_id = issue_id.removeprefix(CA_CHANGED_ISSUE_PREFIX) return PanelCAChangedRepairFlow(entry_id) + if issue_id.startswith(CA_UNUSABLE_ISSUE_PREFIX): + # The same flow on purpose: an unreadable stored anchor and a rotated CA + # end in the same place — fetch what the panel advertises, check it + # signs the panel's own certificate, and pin it only on a person's + # explicit acceptance of the fingerprint. Only the story the issue tells + # differs, and that lives in its own translation. + entry_id = issue_id.removeprefix(CA_UNUSABLE_ISSUE_PREFIX) + return PanelCAChangedRepairFlow(entry_id) raise ValueError(f"{DOMAIN} raised no fixable issue with id {issue_id}") diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index 645df518..da6fe33e 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -19,6 +19,7 @@ "ca_unusable": "The certificate authority stored for this panel cannot be read, so this change would have to be sent unencrypted. Nothing has been changed. Reauthenticate the panel to store a usable authority, then try again.", "cannot_connect": "Failed to connect to Span Panel", "fqdn_registration_failed": "Could not register the domain name with the panel or the TLS certificate was not updated in time.", + "https_port_is_plaintext": "Port 80 is the plaintext HTTP port. Enter the port the panel serves TLS on — 443 unless something moved it.", "host_required": "Host is required", "invalid_auth": "Invalid authentication", "proximity_failed": "Proximity not proven. Please open and close the panel door 3 times and try again.", @@ -86,10 +87,12 @@ }, "reconfigure": { "data": { - "host": "Host" + "host": "Host", + "https_port": "HTTPS port" }, "data_description": { - "host": "IP address or hostname of the SPAN Panel" + "host": "IP address or hostname of the SPAN Panel", + "https_port": "Shown for a panel with a pinned certificate authority. Leave at 443 unless a proxy or a port mapping moved the panel's TLS." }, "description": "Update the host address for this SPAN Panel. The panel serial number must match.", "title": "Reconfigure SPAN Panel" @@ -901,6 +904,26 @@ } } }, + "panel_ca_unusable": { + "title": "SPAN Panel stored certificate authority is unreadable", + "fix_flow": { + "step": { + "confirm": { + "title": "Trust the panel's certificate authority?", + "description": "The certificate authority stored for {panel} can no longer be read, so its connection cannot be verified and the integration has stopped rather than continue unencrypted.\n\nThe panel is advertising this certificate authority:\n\n**{fingerprint}**\n\nAccepting pins it and reconnects. Compare the fingerprint against another install or the panel itself if you can — a device on your network standing in for your panel would present a fingerprint of its own." + } + }, + "abort": { + "entry_gone": "That SPAN Panel is no longer configured.", + "ca_unreadable": "The panel's certificate authority could not be read. Check that the panel is reachable and try again.", + "ca_leaf_mismatch": "The certificate this panel serves is not signed by the authority it published, so that authority has not been offered for you to accept and nothing has been changed. Check that the HTTPS port is correct and that nothing is intercepting the connection, then try again." + } + } + }, + "panel_rest_tls_untrusted": { + "title": "SPAN Panel HTTPS port is serving an untrusted certificate", + "description": "Whatever answers at **{host}:{https_port}** presented a certificate that {panel}'s pinned certificate authority (SHA-256 {fingerprint}) does not currently validate — while the panel itself still advertises that same authority, so the authority has not changed.\n\nTwo things produce this. Something between Home Assistant and the panel — a reverse proxy, a port forward, a wrong HTTPS port on the entry — is terminating TLS with a certificate of its own. Or the panel's clock reset in an outage and its certificate is outside its validity window, which clears on its own once the panel has time again.\n\nThe integration keeps retrying without connecting, and never falls back to unencrypted HTTP. Check the HTTPS port with *Reconfigure* on the SPAN Panel entry, or remove whatever is intercepting the connection; if the panel just lost power, give it a little while." + }, "panel_leaf_name_mismatch": { "title": "SPAN Panel is not at the configured address", "description": "{panel} is configured as **{host}**, but the certificate it serves names only: **{leaf_names}**. The panel has probably moved, or the address was stored under a name the panel does not know itself by.\n\nUse *Reconfigure* on the SPAN Panel entry to point it at one of the names above. The integration keeps retrying meanwhile and will recover on its own if the panel returns to {host}." diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index 645df518..da6fe33e 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -19,6 +19,7 @@ "ca_unusable": "The certificate authority stored for this panel cannot be read, so this change would have to be sent unencrypted. Nothing has been changed. Reauthenticate the panel to store a usable authority, then try again.", "cannot_connect": "Failed to connect to Span Panel", "fqdn_registration_failed": "Could not register the domain name with the panel or the TLS certificate was not updated in time.", + "https_port_is_plaintext": "Port 80 is the plaintext HTTP port. Enter the port the panel serves TLS on — 443 unless something moved it.", "host_required": "Host is required", "invalid_auth": "Invalid authentication", "proximity_failed": "Proximity not proven. Please open and close the panel door 3 times and try again.", @@ -86,10 +87,12 @@ }, "reconfigure": { "data": { - "host": "Host" + "host": "Host", + "https_port": "HTTPS port" }, "data_description": { - "host": "IP address or hostname of the SPAN Panel" + "host": "IP address or hostname of the SPAN Panel", + "https_port": "Shown for a panel with a pinned certificate authority. Leave at 443 unless a proxy or a port mapping moved the panel's TLS." }, "description": "Update the host address for this SPAN Panel. The panel serial number must match.", "title": "Reconfigure SPAN Panel" @@ -901,6 +904,26 @@ } } }, + "panel_ca_unusable": { + "title": "SPAN Panel stored certificate authority is unreadable", + "fix_flow": { + "step": { + "confirm": { + "title": "Trust the panel's certificate authority?", + "description": "The certificate authority stored for {panel} can no longer be read, so its connection cannot be verified and the integration has stopped rather than continue unencrypted.\n\nThe panel is advertising this certificate authority:\n\n**{fingerprint}**\n\nAccepting pins it and reconnects. Compare the fingerprint against another install or the panel itself if you can — a device on your network standing in for your panel would present a fingerprint of its own." + } + }, + "abort": { + "entry_gone": "That SPAN Panel is no longer configured.", + "ca_unreadable": "The panel's certificate authority could not be read. Check that the panel is reachable and try again.", + "ca_leaf_mismatch": "The certificate this panel serves is not signed by the authority it published, so that authority has not been offered for you to accept and nothing has been changed. Check that the HTTPS port is correct and that nothing is intercepting the connection, then try again." + } + } + }, + "panel_rest_tls_untrusted": { + "title": "SPAN Panel HTTPS port is serving an untrusted certificate", + "description": "Whatever answers at **{host}:{https_port}** presented a certificate that {panel}'s pinned certificate authority (SHA-256 {fingerprint}) does not currently validate — while the panel itself still advertises that same authority, so the authority has not changed.\n\nTwo things produce this. Something between Home Assistant and the panel — a reverse proxy, a port forward, a wrong HTTPS port on the entry — is terminating TLS with a certificate of its own. Or the panel's clock reset in an outage and its certificate is outside its validity window, which clears on its own once the panel has time again.\n\nThe integration keeps retrying without connecting, and never falls back to unencrypted HTTP. Check the HTTPS port with *Reconfigure* on the SPAN Panel entry, or remove whatever is intercepting the connection; if the panel just lost power, give it a little while." + }, "panel_leaf_name_mismatch": { "title": "SPAN Panel is not at the configured address", "description": "{panel} is configured as **{host}**, but the certificate it serves names only: **{leaf_names}**. The panel has probably moved, or the address was stored under a name the panel does not know itself by.\n\nUse *Reconfigure* on the SPAN Panel entry to point it at one of the names above. The integration keeps retrying meanwhile and will recover on its own if the panel returns to {host}." diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index 90d5e826..9307040f 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -20,6 +20,7 @@ "cannot_connect": "No se logró establecer conexión con Span Panel", "fqdn_registration_failed": "No se pudo registrar el nombre de dominio en el panel o el certificado TLS no se actualizó a tiempo.", "host_required": "Se requiere host", + "https_port_is_plaintext": "El puerto 80 es el puerto HTTP sin cifrar. Introduzca el puerto en el que el panel sirve TLS — 443 salvo que algo lo haya movido.", "invalid_auth": "Autenticación invalida", "proximity_failed": "Proximidad no comprobada. Por favor abra y cierre la puerta del panel 3 veces e intente de nuevo.", "unknown": "Error inesperado" @@ -86,10 +87,12 @@ }, "reconfigure": { "data": { - "host": "Host" + "host": "Host", + "https_port": "Puerto HTTPS" }, "data_description": { - "host": "Dirección IP o nombre de host del SPAN Panel" + "host": "Dirección IP o nombre de host del SPAN Panel", + "https_port": "Se muestra para un panel con autoridad de certificación fijada. Deje 443 salvo que un proxy o una redirección de puertos haya movido el TLS del panel." }, "description": "Actualice la dirección de host para este SPAN Panel. El número de serie del panel debe coincidir.", "title": "Reconfigurar SPAN Panel" @@ -901,6 +904,26 @@ } } }, + "panel_ca_unusable": { + "title": "La autoridad de certificación almacenada del SPAN Panel es ilegible", + "fix_flow": { + "step": { + "confirm": { + "title": "¿Confiar en la autoridad de certificación del panel?", + "description": "La autoridad de certificación almacenada para {panel} ya no se puede leer, por lo que su conexión no puede verificarse y la integración se ha detenido en lugar de continuar sin cifrado.\n\nEl panel está anunciando esta autoridad de certificación:\n\n**{fingerprint}**\n\nAceptar la fija y reconecta. Compare la huella con otra instalación o con el propio panel si puede — un dispositivo de su red que suplante al panel presentaría una huella propia." + } + }, + "abort": { + "entry_gone": "Ese SPAN Panel ya no está configurado.", + "ca_unreadable": "No se ha podido leer la autoridad de certificación del panel. Compruebe que el panel es accesible e inténtelo de nuevo.", + "ca_leaf_mismatch": "El certificado que sirve este panel no está firmado por la autoridad que publicó, por lo que esa autoridad no se le ha ofrecido para aceptar y no se ha cambiado nada. Compruebe que el puerto HTTPS es correcto y que nada intercepta la conexión, e inténtelo de nuevo." + } + } + }, + "panel_rest_tls_untrusted": { + "title": "El puerto HTTPS del SPAN Panel sirve un certificado no fiable", + "description": "Lo que responde en **{host}:{https_port}** presentó un certificado que la autoridad de certificación fijada de {panel} (SHA-256 {fingerprint}) no valida actualmente — mientras el propio panel sigue anunciando esa misma autoridad, así que la autoridad no ha cambiado.\n\nDos cosas producen esto. Algo entre Home Assistant y el panel — un proxy inverso, una redirección de puertos, un puerto HTTPS incorrecto en la entrada — está terminando TLS con un certificado propio. O el reloj del panel se reinició en un apagón y su certificado está fuera de su período de validez, lo que se resuelve solo cuando el panel vuelve a tener hora.\n\nLa integración sigue reintentando sin conectarse, y nunca recurre a HTTP sin cifrar. Compruebe el puerto HTTPS con *Reconfigurar* en la entrada del SPAN Panel, o retire lo que esté interceptando la conexión; si el panel acaba de perder la alimentación, dele un tiempo." + }, "panel_leaf_name_mismatch": { "title": "El SPAN Panel no está en la dirección configurada", "description": "{panel} está configurado como **{host}**, pero el certificado que sirve solo nombra: **{leaf_names}**. Es probable que el panel se haya movido, o que la dirección se guardara con un nombre por el que el panel no se conoce a sí mismo.\n\nUse *Reconfigurar* en la entrada del SPAN Panel para apuntarla a uno de los nombres anteriores. Mientras tanto, la integración sigue reintentando y se recuperará por sí sola si el panel vuelve a {host}." diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index 488f01ff..35b2b8bf 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -20,6 +20,7 @@ "cannot_connect": "Échec de la connexion au Span Panel", "fqdn_registration_failed": "Impossible d'enregistrer le nom de domaine auprès du panneau ou le certificat TLS n'a pas été mis à jour à temps.", "host_required": "L'hôte est requis", + "https_port_is_plaintext": "Le port 80 est le port HTTP en clair. Saisissez le port sur lequel le panneau sert TLS — 443 sauf si quelque chose l'a déplacé.", "invalid_auth": "Authentification invalide", "proximity_failed": "Proximité non prouvée. Veuillez ouvrir et fermer la porte du panneau 3 fois et réessayer.", "unknown": "Erreur inattendue" @@ -86,10 +87,12 @@ }, "reconfigure": { "data": { - "host": "Hôte" + "host": "Hôte", + "https_port": "Port HTTPS" }, "data_description": { - "host": "Adresse IP ou nom d'hôte du SPAN Panel" + "host": "Adresse IP ou nom d'hôte du SPAN Panel", + "https_port": "Affiché pour un panneau dont l'autorité de certification est épinglée. Laissez 443 sauf si un proxy ou une redirection de port a déplacé le TLS du panneau." }, "description": "Mettez à jour l'adresse de l'hôte pour ce SPAN Panel. Le numéro de série du panneau doit correspondre.", "title": "Reconfigurer le SPAN Panel" @@ -901,6 +904,26 @@ } } }, + "panel_ca_unusable": { + "title": "L'autorité de certification enregistrée du SPAN Panel est illisible", + "fix_flow": { + "step": { + "confirm": { + "title": "Faire confiance à l'autorité de certification du panneau ?", + "description": "L'autorité de certification enregistrée pour {panel} ne peut plus être lue, sa connexion ne peut donc plus être vérifiée et l'intégration s'est arrêtée plutôt que de continuer sans chiffrement.\n\nLe panneau annonce cette autorité de certification :\n\n**{fingerprint}**\n\nAccepter l'épingle et reconnecte. Comparez l'empreinte avec une autre installation ou avec le panneau lui-même si possible — un appareil de votre réseau se faisant passer pour le panneau présenterait sa propre empreinte." + } + }, + "abort": { + "entry_gone": "Ce SPAN Panel n'est plus configuré.", + "ca_unreadable": "L'autorité de certification du panneau n'a pas pu être lue. Vérifiez que le panneau est joignable et réessayez.", + "ca_leaf_mismatch": "Le certificat servi par ce panneau n'est pas signé par l'autorité qu'il a publiée ; cette autorité ne vous a donc pas été proposée et rien n'a été modifié. Vérifiez que le port HTTPS est correct et que rien n'intercepte la connexion, puis réessayez." + } + } + }, + "panel_rest_tls_untrusted": { + "title": "Le port HTTPS du SPAN Panel sert un certificat non fiable", + "description": "Ce qui répond sur **{host}:{https_port}** a présenté un certificat que l'autorité de certification épinglée de {panel} (SHA-256 {fingerprint}) ne valide pas actuellement — alors que le panneau lui-même annonce toujours cette même autorité, qui n'a donc pas changé.\n\nDeux choses produisent cela. Quelque chose entre Home Assistant et le panneau — un proxy inverse, une redirection de port, un port HTTPS erroné sur l'entrée — termine TLS avec son propre certificat. Ou l'horloge du panneau s'est réinitialisée pendant une coupure et son certificat est hors de sa période de validité, ce qui se résout de lui-même dès que le panneau retrouve l'heure.\n\nL'intégration continue de réessayer sans se connecter, et ne revient jamais au HTTP non chiffré. Vérifiez le port HTTPS via *Reconfigurer* sur l'entrée SPAN Panel, ou retirez ce qui intercepte la connexion ; si le panneau vient de perdre l'alimentation, laissez-lui un peu de temps." + }, "panel_leaf_name_mismatch": { "title": "Le SPAN Panel n'est pas à l'adresse configurée", "description": "{panel} est configuré comme **{host}**, mais le certificat qu'il présente ne nomme que : **{leaf_names}**. Le panneau a probablement changé d'adresse, ou l'adresse a été enregistrée sous un nom que le panneau ne se connaît pas.\n\nUtilisez *Reconfigurer* sur l'entrée SPAN Panel pour la pointer vers l'un des noms ci-dessus. L'intégration continue de réessayer entre-temps et se rétablira d'elle-même si le panneau revient à {host}." diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index 670fbf55..29eee67d 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -20,6 +20,7 @@ "cannot_connect": "スパンパネルへの接続に失敗しました", "fqdn_registration_failed": "パネルにドメイン名を登録できなかったか、TLS証明書が時間内に更新されませんでした。", "host_required": "ホストが必要です", + "https_port_is_plaintext": "ポート 80 は平文 HTTP のポートです。パネルが TLS を提供しているポートを入力してください — 移動されていなければ 443 です。", "invalid_auth": "認証が無効です", "proximity_failed": "近接が証明されませんでした。パネルのドアを3回開閉してから、もう一度お試しください。", "unknown": "予期しないエラー" @@ -86,10 +87,12 @@ }, "reconfigure": { "data": { - "host": "ホスト" + "host": "ホスト", + "https_port": "HTTPS ポート" }, "data_description": { - "host": "SPAN PanelのIPアドレスまたはホスト名" + "host": "SPAN PanelのIPアドレスまたはホスト名", + "https_port": "認証局が固定されているパネルで表示されます。プロキシやポート転送でパネルの TLS が移動していない限り、443 のままにしてください。" }, "description": "このSPAN Panelのホストアドレスを更新します。パネルのシリアル番号が一致する必要があります。", "title": "SPAN Panelの再設定" @@ -901,6 +904,26 @@ } } }, + "panel_ca_unusable": { + "title": "SPAN Panel の保存済み認証局が読み取れません", + "fix_flow": { + "step": { + "confirm": { + "title": "パネルの認証局を信頼しますか?", + "description": "{panel} に保存されている認証局が読み取れなくなったため、接続を検証できず、暗号化なしで続行する代わりに統合を停止しました。\n\nパネルは次の認証局を公開しています:\n\n**{fingerprint}**\n\n承認するとこの認証局が固定され、再接続します。可能であれば、フィンガープリントを別のインストールまたはパネル本体と比較してください — ネットワーク上でパネルになりすます機器は、独自のフィンガープリントを提示します。" + } + }, + "abort": { + "entry_gone": "その SPAN Panel はもう設定されていません。", + "ca_unreadable": "パネルの認証局を読み取れませんでした。パネルに到達できることを確認して、もう一度お試しください。", + "ca_leaf_mismatch": "このパネルが提示する証明書は、パネルが公開した認証局によって署名されていないため、その認証局は承認対象として提示されず、何も変更されていません。HTTPS ポートが正しいこと、接続を傍受するものがないことを確認して、もう一度お試しください。" + } + } + }, + "panel_rest_tls_untrusted": { + "title": "SPAN Panel の HTTPS ポートが信頼できない証明書を提示しています", + "description": "**{host}:{https_port}** で応答しているものは、{panel} の固定された認証局(SHA-256 {fingerprint})が現在検証できない証明書を提示しました — 一方でパネル本体は同じ認証局を公開し続けているため、認証局は変更されていません。\n\n原因は二つ考えられます。Home Assistant とパネルの間の何か — リバースプロキシ、ポートフォワード、エントリの誤った HTTPS ポート — が独自の証明書で TLS を終端している場合。または、停電でパネルの時計がリセットされ、証明書が有効期間外になっている場合で、こちらはパネルが時刻を取得し直せば自然に解消します。\n\n統合は接続せずに再試行を続け、暗号化されていない HTTP には決して戻りません。SPAN Panel エントリの *再設定* で HTTPS ポートを確認するか、接続を傍受しているものを取り除いてください。パネルが停電直後の場合は、しばらく待ってください。" + }, "panel_leaf_name_mismatch": { "title": "SPAN Panel が設定されたアドレスにありません", "description": "{panel} は **{host}** として設定されていますが、パネルが提供する証明書は **{leaf_names}** しか名前として持っていません。パネルのアドレスが変わったか、パネル自身が認識していない名前でアドレスが保存された可能性があります。\n\nSPAN Panel のエントリで *再構成* を使い、上記のいずれかの名前を指すように変更してください。その間も統合は再試行を続け、パネルが {host} に戻れば自動的に復旧します。" diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index d97042ab..bfe126c5 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -20,6 +20,7 @@ "cannot_connect": "Falha ao ligar ao Painel Span", "fqdn_registration_failed": "Não foi possível registar o nome de domínio no painel ou o certificado TLS não foi atualizado a tempo.", "host_required": "O anfitrião é necessário", + "https_port_is_plaintext": "A porta 80 é a porta HTTP sem cifra. Introduza a porta em que o painel serve TLS — 443 a menos que algo a tenha movido.", "invalid_auth": "Autenticação inválida", "proximity_failed": "Proximidade não comprovada. Por favor, abra e feche a porta do painel 3 vezes e tente novamente.", "unknown": "Erro inesperado" @@ -86,10 +87,12 @@ }, "reconfigure": { "data": { - "host": "Anfitrião" + "host": "Anfitrião", + "https_port": "Porta HTTPS" }, "data_description": { - "host": "Endereço IP ou nome do host do SPAN Panel" + "host": "Endereço IP ou nome do host do SPAN Panel", + "https_port": "Mostrado para um painel com autoridade de certificação fixada. Deixe 443 a menos que um proxy ou um reencaminhamento de porta tenha movido o TLS do painel." }, "description": "Atualize o endereço do anfitrião para este SPAN Panel. O número de série do painel deve coincidir.", "title": "Reconfigurar SPAN Panel" @@ -901,6 +904,26 @@ } } }, + "panel_ca_unusable": { + "title": "A autoridade de certificação armazenada do SPAN Panel está ilegível", + "fix_flow": { + "step": { + "confirm": { + "title": "Confiar na autoridade de certificação do painel?", + "description": "A autoridade de certificação armazenada para {panel} já não pode ser lida, pelo que a sua ligação não pode ser verificada e a integração parou em vez de continuar sem cifragem.\n\nO painel está a anunciar esta autoridade de certificação:\n\n**{fingerprint}**\n\nAceitar fixa-a e reconecta. Compare a impressão digital com outra instalação ou com o próprio painel se puder — um dispositivo na sua rede a fazer-se passar pelo painel apresentaria uma impressão digital própria." + } + }, + "abort": { + "entry_gone": "Esse SPAN Panel já não está configurado.", + "ca_unreadable": "Não foi possível ler a autoridade de certificação do painel. Verifique se o painel está acessível e tente novamente.", + "ca_leaf_mismatch": "O certificado que este painel serve não está assinado pela autoridade que publicou, pelo que essa autoridade não lhe foi oferecida para aceitar e nada foi alterado. Verifique se a porta HTTPS está correta e se nada está a intercetar a ligação, e tente novamente." + } + } + }, + "panel_rest_tls_untrusted": { + "title": "A porta HTTPS do SPAN Panel está a servir um certificado não confiável", + "description": "O que responde em **{host}:{https_port}** apresentou um certificado que a autoridade de certificação fixada de {panel} (SHA-256 {fingerprint}) não valida atualmente — enquanto o próprio painel continua a anunciar essa mesma autoridade, pelo que a autoridade não mudou.\n\nDuas coisas produzem isto. Algo entre o Home Assistant e o painel — um proxy inverso, um reencaminhamento de porta, uma porta HTTPS errada na entrada — está a terminar TLS com um certificado próprio. Ou o relógio do painel foi reposto numa falha de energia e o seu certificado está fora do período de validade, o que se resolve sozinho assim que o painel voltar a ter horas.\n\nA integração continua a tentar sem se ligar, e nunca recorre a HTTP sem cifra. Verifique a porta HTTPS com *Reconfigurar* na entrada do SPAN Panel, ou remova o que está a intercetar a ligação; se o painel acabou de perder energia, dê-lhe algum tempo." + }, "panel_leaf_name_mismatch": { "title": "O SPAN Panel não está no endereço configurado", "description": "{panel} está configurado como **{host}**, mas o certificado que serve nomeia apenas: **{leaf_names}**. É provável que o painel tenha mudado de endereço, ou que o endereço tenha sido guardado com um nome pelo qual o painel não se conhece a si próprio.\n\nUse *Reconfigurar* na entrada do SPAN Panel para a apontar para um dos nomes acima. Entretanto, a integração continua a tentar e recupera sozinha se o painel voltar a {host}." diff --git a/pyproject.toml b/pyproject.toml index 71b70784..e51bc710 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ readme = "README.md" requires-python = ">=3.14.3,<3.15" dependencies = [ "homeassistant==2026.8.3", - "span-panel-api==3.3.0", + "span-panel-api==3.4.0", # 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, diff --git a/requirements_test.txt b/requirements_test.txt index 6d01ab3f..b6b87133 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -3,6 +3,6 @@ pytest-asyncio>=1.4.0 setuptools>=65.7.0 pytest-homeassistant-custom-component==0.13.358 homeassistant==2026.8.3 -span-panel-api==3.3.0 +span-panel-api==3.4.0 span-panel-api-schema-0==1.1.2 span-panel-api-schema-1==1.1.3 diff --git a/tests/test_runtime_rest_transport.py b/tests/test_runtime_rest_transport.py new file mode 100644 index 00000000..3eeb889d --- /dev/null +++ b/tests/test_runtime_rest_transport.py @@ -0,0 +1,571 @@ +"""The runtime client rides the pinned transport, and refuses to ride without it. + +Issue #264: every other REST call site adopted `panel_rest_transport` when the +pin landed — config flow, reauth, repairs, `rotate_credentials` — and the one +that runs on every startup did not. The schema fetch inside `connect()` stayed +on plaintext HTTP with the pin sitting unused in `entry.data`, which is exactly +the condition the library's transport warning names. + +Fail closed, both ways it can fail. A stored anchor that cannot be read is not +downgraded to plaintext at the one call that runs unattended on every boot; a +certificate the pin rejects is not retried into submission. Each gets a Repair, +because both need a person: the first needs a new anchor accepted, the second is +either a legitimately rotated CA (accept it) or something standing in front of +the panel's TLS port (investigate it) — and which of those it is gets diagnosed +the same way the library diagnoses the MQTT side, by re-reading the advertised +CA and comparing fingerprints. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +from unittest.mock import AsyncMock, MagicMock, patch + +from homeassistant.config_entries import ConfigEntryError, ConfigEntryNotReady +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.httpx_client import get_async_client +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry +from span_panel_api.exceptions import ( + SpanPanelConnectionError, + SpanPanelTLSVerificationError, + SpanPanelValidationError, +) + +from custom_components.span_panel import async_remove_entry, async_setup_entry +from custom_components.span_panel.ca_repairs import ( + ca_changed_issue_id, + ca_unusable_issue_id, + rest_tls_untrusted_issue_id, +) +from custom_components.span_panel.config_flow_validation import LeafProbeResult, LeafVerdict +from custom_components.span_panel.const import ( + CONF_API_VERSION, + CONF_EBUS_BROKER_HOST, + CONF_EBUS_BROKER_PASSWORD, + CONF_EBUS_BROKER_PORT, + CONF_EBUS_BROKER_USERNAME, + CONF_HTTP_PORT, + CONF_HTTPS_PORT, + CONF_PANEL_CA_PEM, + DOMAIN, +) +from custom_components.span_panel.leaf_repairs import leaf_name_mismatch_issue_id + +from .factories import SpanPanelSnapshotFactory + +PEM = "-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n" +OTHER_PEM = "-----BEGIN CERTIFICATE-----\nb3RoZXI=\n-----END CERTIFICATE-----\n" +SERIAL = "sp3-rest-001" + + +def _entry(hass: HomeAssistant, **data_overrides: object) -> MockConfigEntry: + data: dict[str, object] = { + CONF_API_VERSION: "v2", + CONF_HOST: "192.168.1.50", + CONF_EBUS_BROKER_HOST: "span-panel.local", + CONF_EBUS_BROKER_USERNAME: "mqtt-user", + CONF_EBUS_BROKER_PASSWORD: "mqtt-pass", + CONF_EBUS_BROKER_PORT: 8883, + CONF_HTTP_PORT: 80, + } + data.update(data_overrides) + entry = MockConfigEntry( + domain=DOMAIN, data=data, entry_id="entry-rest", title=SERIAL, unique_id=SERIAL + ) + entry.add_to_hass(hass) + return entry + + +def _happy_client() -> MagicMock: + client = MagicMock() + client.connect = AsyncMock() + client.close = AsyncMock() + return client + + +def _happy_coordinator() -> MagicMock: + coordinator = MagicMock() + coordinator.async_config_entry_first_refresh = AsyncMock() + coordinator.async_setup_streaming = AsyncMock() + coordinator.data = SpanPanelSnapshotFactory.create(serial_number=SERIAL) + return coordinator + + +@contextmanager +def _full_setup(client: MagicMock, hass: HomeAssistant) -> Iterator[MagicMock]: + """Everything a successful setup needs mocked, yielding the client class.""" + with ( + patch("custom_components.span_panel.async_register_commands"), + patch( + "custom_components.span_panel.SpanMqttClient", return_value=client + ) as mock_client_cls, + patch( + "custom_components.span_panel.SpanPanelCoordinator", + return_value=_happy_coordinator(), + ), + patch( + "custom_components.span_panel.ensure_device_registered", + AsyncMock(return_value="panel-device-id"), + ), + patch.object(hass.config_entries, "async_forward_entry_setups", AsyncMock()), + patch.object(hass.config_entries, "async_update_entry"), + ): + yield mock_client_cls + + +class TestThePinReachesTheRuntimeClient: + """The transport decision follows the entry's pin, port by port.""" + + async def test_a_pinned_entry_hands_its_anchor_to_the_runtime_client( + self, hass: HomeAssistant + ) -> None: + """The pin stored on the entry anchors the schema fetch at connect.""" + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + client = _happy_client() + context = MagicMock(name="pinned-context") + + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=context, + ), + _full_setup(client, hass) as mock_client_cls, + ): + assert await async_setup_entry(hass, entry) is True + + kwargs = mock_client_cls.call_args.kwargs + assert kwargs["ssl_context"] is context + # 443 by default — the stored plaintext port is the bridge's, not TLS's. + assert kwargs["panel_https_port"] == 443 + assert kwargs["panel_http_port"] == 80 + # The shared client stays: the library ignores it under a context, and + # it is what the unpinned plaintext path uses. + assert kwargs["httpx_client"] is get_async_client(hass) + + async def test_a_pinned_entry_honours_a_configured_https_port( + self, hass: HomeAssistant + ) -> None: + """A stored HTTPS port names where the panel's TLS actually lives.""" + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM, CONF_HTTPS_PORT: 8443}) + client = _happy_client() + + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + _full_setup(client, hass) as mock_client_cls, + ): + assert await async_setup_entry(hass, entry) is True + + assert mock_client_cls.call_args.kwargs["panel_https_port"] == 8443 + + async def test_an_unpinned_entry_keeps_the_plaintext_transport( + self, hass: HomeAssistant + ) -> None: + """No pin is not a failure — it is the TOFU path, plaintext as designed.""" + entry = _entry(hass) + client = _happy_client() + + with _full_setup(client, hass) as mock_client_cls: + assert await async_setup_entry(hass, entry) is True + + kwargs = mock_client_cls.call_args.kwargs + assert kwargs["ssl_context"] is None + assert kwargs["panel_https_port"] is None + assert kwargs["panel_http_port"] == 80 + + +class TestFailingClosed: + """Both ways the pinned transport can fail end with a person, not a downgrade.""" + + async def test_an_unusable_stored_anchor_fails_setup_with_a_repair( + self, hass: HomeAssistant + ) -> None: + """A pin that cannot be read must not quietly become no pin at all.""" + entry = _entry(hass, **{CONF_PANEL_CA_PEM: "not a certificate"}) + + with ( + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient") as mock_client_cls, + pytest.raises(ConfigEntryError), + ): + await async_setup_entry(hass, entry) + + mock_client_cls.assert_not_called() + issue = ir.async_get(hass).async_get_issue(DOMAIN, ca_unusable_issue_id(entry.entry_id)) + assert issue is not None + assert issue.is_fixable + + async def test_a_rotated_ca_behind_a_rest_failure_raises_the_ca_changed_repair( + self, hass: HomeAssistant + ) -> None: + """The common legitimate cause, given the guided re-pin it already has.""" + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + client = _happy_client() + client.connect = AsyncMock( + side_effect=SpanPanelTLSVerificationError("certificate verify failed") + ) + + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + patch( + "custom_components.span_panel.async_fetch_panel_ca", + new=AsyncMock(return_value=OTHER_PEM), + ), + pytest.raises(ConfigEntryError), + ): + await async_setup_entry(hass, entry) + + client.close.assert_awaited_once() + issue = ir.async_get(hass).async_get_issue(DOMAIN, ca_changed_issue_id(entry.entry_id)) + assert issue is not None + + async def test_an_untrusted_leaf_behind_an_unchanged_ca_retries_with_a_repair( + self, hass: HomeAssistant + ) -> None: + """The CA did not rotate and the leaf does not chain to it. + + A proxy terminating the TLS port with its own certificate, or a leaf an + outage's clock reset left outside its validity window — the probe cannot + tell them apart, so the repair names both and the entry retries: the + clock case heals itself, and never plaintext either way. + """ + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + client = _happy_client() + client.connect = AsyncMock( + side_effect=SpanPanelTLSVerificationError("certificate verify failed") + ) + + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + patch( + "custom_components.span_panel.async_fetch_panel_ca", + new=AsyncMock(return_value=PEM), + ), + patch( + "custom_components.span_panel.async_leaf_probe", + new=AsyncMock(return_value=LeafProbeResult(LeafVerdict.UNTRUSTED, ())), + ), + pytest.raises(ConfigEntryNotReady), + ): + await async_setup_entry(hass, entry) + + client.close.assert_awaited_once() + issue = ir.async_get(hass).async_get_issue( + DOMAIN, rest_tls_untrusted_issue_id(entry.entry_id) + ) + assert issue is not None + assert not issue.is_fixable + # Re-derived on every retry, so it must not outlive a restart that fixes it. + assert not issue.is_persistent + + async def test_a_moved_panel_behind_an_unchanged_ca_raises_the_leaf_repair( + self, hass: HomeAssistant + ) -> None: + """The panel is who it says it is, and not where the entry says it is. + + The leaf-mismatch repair already promises "the integration keeps + retrying", and setup keeps that promise with NotReady rather than + contradicting it with a terminal accusation of interception. + """ + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + client = _happy_client() + client.connect = AsyncMock( + side_effect=SpanPanelTLSVerificationError("certificate verify failed") + ) + + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + patch( + "custom_components.span_panel.async_fetch_panel_ca", + new=AsyncMock(return_value=PEM), + ), + patch( + "custom_components.span_panel.async_leaf_probe", + new=AsyncMock( + return_value=LeafProbeResult( + LeafVerdict.NAME_MISMATCH, ("span-panel.local", "10.0.0.7") + ) + ), + ), + pytest.raises(ConfigEntryNotReady), + ): + await async_setup_entry(hass, entry) + + client.close.assert_awaited_once() + issue = ir.async_get(hass).async_get_issue( + DOMAIN, leaf_name_mismatch_issue_id(entry.entry_id) + ) + assert issue is not None + assert ( + ir.async_get(hass).async_get_issue(DOMAIN, rest_tls_untrusted_issue_id(entry.entry_id)) + is None + ) + + async def test_the_two_same_fingerprint_repairs_supersede_each_other( + self, hass: HomeAssistant + ) -> None: + """A panel cannot be both intercepted and merely moved; only the current verdict stands. + + Without the supersede, a panel that first probed UNTRUSTED and then + moved (or the reverse) showed both repairs at once — one saying "keep + retrying, it will recover", the other implying interception — for the + rest of the session. + """ + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + client = _happy_client() + client.connect = AsyncMock( + side_effect=SpanPanelTLSVerificationError("certificate verify failed") + ) + registry = ir.async_get(hass) + + async def _setup_with_verdict(result: LeafProbeResult) -> None: + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + patch( + "custom_components.span_panel.async_fetch_panel_ca", + new=AsyncMock(return_value=PEM), + ), + patch( + "custom_components.span_panel.async_leaf_probe", + new=AsyncMock(return_value=result), + ), + pytest.raises(ConfigEntryNotReady), + ): + await async_setup_entry(hass, entry) + + await _setup_with_verdict(LeafProbeResult(LeafVerdict.UNTRUSTED, ())) + await _setup_with_verdict( + LeafProbeResult(LeafVerdict.NAME_MISMATCH, ("span-panel.local",)) + ) + assert registry.async_get_issue(DOMAIN, leaf_name_mismatch_issue_id(entry.entry_id)) + assert ( + registry.async_get_issue(DOMAIN, rest_tls_untrusted_issue_id(entry.entry_id)) is None + ) + + await _setup_with_verdict(LeafProbeResult(LeafVerdict.UNTRUSTED, ())) + assert registry.async_get_issue(DOMAIN, rest_tls_untrusted_issue_id(entry.entry_id)) + assert ( + registry.async_get_issue(DOMAIN, leaf_name_mismatch_issue_id(entry.entry_id)) is None + ) + + async def test_an_unreachable_tls_port_behind_an_unchanged_ca_just_retries( + self, hass: HomeAssistant + ) -> None: + """The probe reached nothing, which is a panel mid-reboot, not a verdict.""" + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + client = _happy_client() + client.connect = AsyncMock( + side_effect=SpanPanelTLSVerificationError("certificate verify failed") + ) + + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + patch( + "custom_components.span_panel.async_fetch_panel_ca", + new=AsyncMock(return_value=PEM), + ), + patch( + "custom_components.span_panel.async_leaf_probe", + new=AsyncMock(return_value=LeafProbeResult(LeafVerdict.UNREACHABLE, ())), + ), + pytest.raises(ConfigEntryNotReady), + ): + await async_setup_entry(hass, entry) + + client.close.assert_awaited_once() + registry = ir.async_get(hass) + assert registry.async_get_issue(DOMAIN, rest_tls_untrusted_issue_id(entry.entry_id)) is None + assert registry.async_get_issue(DOMAIN, leaf_name_mismatch_issue_id(entry.entry_id)) is None + + async def test_a_pinned_entry_that_cannot_connect_names_the_https_port( + self, hass: HomeAssistant + ) -> None: + """Nothing answering the TLS port must not read as 'panel is down'. + + The deferred-pin population can have TLS living somewhere 443 is not — + behind NAT, a port forward, a proxy — and was never asked for the port. + Their failure is a plain refused connection, indistinguishable from a + reboot, so the retry message is where the remedy has to travel. + """ + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + client = _happy_client() + client.connect = AsyncMock(side_effect=SpanPanelConnectionError("connection refused")) + + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + pytest.raises(ConfigEntryNotReady, match="HTTPS port 443"), + ): + await async_setup_entry(hass, entry) + + client.close.assert_awaited_once() + + async def test_a_stored_https_port_of_80_fails_setup_cleanly( + self, hass: HomeAssistant + ) -> None: + """The library refuses the plaintext port under a context; setup must not crash. + + `SpanPanelValidationError` out of connect() used to escape as a raw + traceback with the client left open. It is a stored-configuration + problem, so it ends as a clear terminal error naming the remedy. + """ + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM, CONF_HTTPS_PORT: 80}) + client = _happy_client() + client.connect = AsyncMock( + side_effect=SpanPanelValidationError("port=80 was passed together with an ssl_context") + ) + + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + pytest.raises(ConfigEntryError), + ): + await async_setup_entry(hass, entry) + + client.close.assert_awaited_once() + + async def test_missing_evidence_retries_rather_than_escalating( + self, hass: HomeAssistant + ) -> None: + """A panel unreachable on its HTTP port is a panel mid-reboot, not a verdict.""" + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + client = _happy_client() + client.connect = AsyncMock( + side_effect=SpanPanelTLSVerificationError("certificate verify failed") + ) + + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + patch( + "custom_components.span_panel.async_fetch_panel_ca", + new=AsyncMock(side_effect=SpanPanelConnectionError("unreachable")), + ), + pytest.raises(ConfigEntryNotReady), + ): + await async_setup_entry(hass, entry) + + client.close.assert_awaited_once() + registry = ir.async_get(hass) + assert registry.async_get_issue(DOMAIN, ca_changed_issue_id(entry.entry_id)) is None + assert ( + registry.async_get_issue(DOMAIN, rest_tls_untrusted_issue_id(entry.entry_id)) is None + ) + + async def test_a_clean_connect_clears_the_standing_repairs( + self, hass: HomeAssistant + ) -> None: + """A handshake under the current pin refutes what the issues describe.""" + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + registry = ir.async_get(hass) + registry.async_get_or_create( + DOMAIN, + rest_tls_untrusted_issue_id(entry.entry_id), + is_fixable=False, + is_persistent=True, + severity=ir.IssueSeverity.ERROR, + translation_key="panel_rest_tls_untrusted", + ) + registry.async_get_or_create( + DOMAIN, + ca_unusable_issue_id(entry.entry_id), + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.ERROR, + translation_key="panel_ca_unusable", + ) + client = _happy_client() + + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + _full_setup(client, hass), + ): + assert await async_setup_entry(hass, entry) is True + + assert ( + registry.async_get_issue(DOMAIN, rest_tls_untrusted_issue_id(entry.entry_id)) is None + ) + assert registry.async_get_issue(DOMAIN, ca_unusable_issue_id(entry.entry_id)) is None + + +class TestRemovalLeavesNothingBehind: + """Core deletes no issues when an entry is removed; the CA family goes here.""" + + async def test_removing_the_entry_clears_its_ca_repairs(self, hass: HomeAssistant) -> None: + """Persistent issues especially: nothing else can clear one whose entry is gone.""" + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + registry = ir.async_get(hass) + for issue_id, fixable in ( + (ca_changed_issue_id(entry.entry_id), True), + (ca_unusable_issue_id(entry.entry_id), True), + (rest_tls_untrusted_issue_id(entry.entry_id), False), + (leaf_name_mismatch_issue_id(entry.entry_id), False), + ): + registry.async_get_or_create( + DOMAIN, + issue_id, + is_fixable=fixable, + is_persistent=True, + severity=ir.IssueSeverity.ERROR, + translation_key="panel_ca_changed", + ) + + with ( + patch("custom_components.span_panel.async_forget_announcements", AsyncMock()), + patch("custom_components.span_panel.async_forget", AsyncMock()), + ): + await async_remove_entry(hass, entry) + + for issue_id in ( + ca_changed_issue_id(entry.entry_id), + ca_unusable_issue_id(entry.entry_id), + rest_tls_untrusted_issue_id(entry.entry_id), + leaf_name_mismatch_issue_id(entry.entry_id), + ): + assert registry.async_get_issue(DOMAIN, issue_id) is None, issue_id diff --git a/tests/test_v2_config_flow.py b/tests/test_v2_config_flow.py index a1d9ddff..570ab7ab 100644 --- a/tests/test_v2_config_flow.py +++ b/tests/test_v2_config_flow.py @@ -3156,3 +3156,197 @@ async def test_reconfigure_refuses_to_downgrade_an_unreadable_pin( assert entry.data[CONF_HOST] == "panel.example.com" assert entry.data[CONF_REGISTERED_FQDN] == "panel.example.com" assert entry.data[CONF_PANEL_CA_PEM] == UNREADABLE_CA_PEM + + +@pytest.mark.asyncio +async def test_reconfigure_offers_the_https_port_to_a_pinned_entry( + hass: HomeAssistant, +) -> None: + """A pinned entry's REST rides the HTTPS port, so reconfigure must let it be set. + + The population this exists for was never asked: TLS behind a port forward + with the HTTP port still on 80, which the CA step's gating skips. Before + this field, such an entry was enforced against port 443 with no in-product + way to say otherwise. + """ + entry = MockConfigEntry( + version=7, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "token", + CONF_API_VERSION: "v2", + CONF_PANEL_CA_PEM: FAKE_CA_PEM, + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert CONF_HTTPS_PORT in result["data_schema"].schema + + +@pytest.mark.asyncio +async def test_reconfigure_keeps_the_host_only_form_for_an_unpinned_entry( + hass: HomeAssistant, +) -> None: + """No pin, no HTTPS transport — a TLS-port question would answer nothing.""" + entry = MockConfigEntry( + version=7, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "token", + CONF_API_VERSION: "v2", + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] == FlowResultType.FORM + assert CONF_HTTPS_PORT not in result["data_schema"].schema + + +@pytest.mark.asyncio +async def test_reconfigure_rejects_the_plaintext_port_as_https( + hass: HomeAssistant, +) -> None: + """80 is the plaintext default; storing it as the TLS port bricks every setup.""" + entry = MockConfigEntry( + version=7, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "token", + CONF_API_VERSION: "v2", + CONF_PANEL_CA_PEM: FAKE_CA_PEM, + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + detect = AsyncMock(return_value=MOCK_V2_DETECTION) + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + patch("custom_components.span_panel.config_flow.detect_api_version", new=detect), + ): + result = await entry.start_reconfigure_flow(hass) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST, CONF_HTTPS_PORT: 80}, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "https_port_is_plaintext"} + detect.assert_not_awaited() + assert CONF_HTTPS_PORT not in entry.data + + +@pytest.mark.asyncio +async def test_reconfigure_stores_a_corrected_https_port(hass: HomeAssistant) -> None: + """The submitted port is both probed against and persisted.""" + entry = MockConfigEntry( + version=7, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "token", + CONF_API_VERSION: "v2", + CONF_PANEL_CA_PEM: FAKE_CA_PEM, + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + leaf_host = AsyncMock(side_effect=lambda _hass, host, _port, _pem: host) + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + patch( + "custom_components.span_panel.config_flow.async_panel_leaf_host", + new=leaf_host, + ), + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + ): + result = await entry.start_reconfigure_flow(hass) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST, CONF_HTTPS_PORT: 8443}, + ) + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + assert entry.data[CONF_HTTPS_PORT] == 8443 + # The probe ran against the port the user submitted, not the stored default. + assert leaf_host.await_args.args[2] == 8443 + + +@pytest.mark.asyncio +async def test_the_https_port_step_refuses_the_plaintext_port( + hass: HomeAssistant, +) -> None: + """80 stored as the TLS port fails every later setup; refuse it at the form.""" + discovery_info = ZeroconfServiceInfo( + ip_address=ipaddress.IPv4Address("192.168.1.200"), + ip_addresses=[ipaddress.IPv4Address("192.168.1.200")], + hostname="span-panel.local.", + name="SPAN Panel._ebus._tcp.local.", + port=8883, + properties={"httpPort": "8080"}, + type="_ebus._tcp.local.", + ) + + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow.is_ipv4_address", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=discovery_info, + ) + port_step = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert port_step["step_id"] == "panel_https_port" + + refused = await hass.config_entries.flow.async_configure( + port_step["flow_id"], {CONF_HTTPS_PORT: 80} + ) + + assert refused["type"] == FlowResultType.FORM + assert refused["step_id"] == "panel_https_port" + assert refused["errors"] == {"base": "https_port_is_plaintext"} From 235632392d69ae3f63c3995879da3ffbe9a4ebbf Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:12:52 -0700 Subject: [PATCH 236/345] fix(deps): pair pytest-homeassistant-custom-component with the pinned Home Assistant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each release of the test harness pins one exact Home Assistant, so its pin and the homeassistant pin are a single choice — the pyproject says so. The bump to 0.13.358 paired with 2026.9.0b0 against the pinned 2026.8.3, an unresolvable pair that CI's re-lock from PyPI reports as a sqlalchemy split (2.0.52 vs 2.0.51) while a stale local venv resolves nothing and notices nothing. 0.13.357 is the release that pins 2026.8.3. --- pyproject.toml | 7 ++++--- requirements_test.txt | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e51bc710..5c9fbfc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,9 +34,10 @@ dev = [ "pylint==4.0.6", # Pinned, not floored: each release pins one exact Home Assistant, so this # and the `homeassistant` pin above are a single choice rather than two. - # 0.13.354 is the release that pins 2026.8.0, the version this integration - # declares as its floor and therefore the one worth testing against. - "pytest-homeassistant-custom-component==0.13.358", + # 0.13.357 is the release that pins 2026.8.3 — the version pinned above; a + # bump to 0.13.358 pinned 2026.9.0b0 and made the pair unresolvable, which + # CI's re-lock from PyPI catches and a stale local venv does not. + "pytest-homeassistant-custom-component==0.13.357", "isort", "vulture>=2.14", ] diff --git a/requirements_test.txt b/requirements_test.txt index b6b87133..136aa655 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,7 +1,7 @@ pytest>=9.0.3 pytest-asyncio>=1.4.0 setuptools>=65.7.0 -pytest-homeassistant-custom-component==0.13.358 +pytest-homeassistant-custom-component==0.13.357 homeassistant==2026.8.3 span-panel-api==3.4.0 span-panel-api-schema-0==1.1.2 From dfd354506899db42dcb47a0816837b8484953f7a Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:14:27 -0700 Subject: [PATCH 237/345] =?UTF-8?q?feat(curation):=20adopted/curate=20webs?= =?UTF-8?q?ocket=20command=20=E2=80=94=20validate,=20save,=20schedule=20re?= =?UTF-8?q?load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- custom_components/span_panel/websocket.py | 3 +- .../span_panel/websocket_adopted.py | 128 ++++- tests/test_websocket_adopted.py | 488 +++++++++++++++++- websocket-api.md | 95 ++++ 4 files changed, 703 insertions(+), 11 deletions(-) diff --git a/custom_components/span_panel/websocket.py b/custom_components/span_panel/websocket.py index ba60dd6e..cb944aa1 100644 --- a/custom_components/span_panel/websocket.py +++ b/custom_components/span_panel/websocket.py @@ -14,7 +14,7 @@ from .helpers import build_panel_unique_id, construct_voltage_attribute from .id_builder import build_binary_sensor_unique_id from .util import classify_sub_device_identifier -from .websocket_adopted import handle_adopted_list +from .websocket_adopted import handle_adopted_curate, handle_adopted_list if TYPE_CHECKING: from .runtime import SpanPanelRuntimeData @@ -65,6 +65,7 @@ def async_register_commands(hass: HomeAssistant) -> None: """ websocket_api.async_register_command(hass, handle_panel_topology) websocket_api.async_register_command(hass, handle_adopted_list) + websocket_api.async_register_command(hass, handle_adopted_curate) @websocket_api.websocket_command( diff --git a/custom_components/span_panel/websocket_adopted.py b/custom_components/span_panel/websocket_adopted.py index 90c694f8..603a2968 100644 --- a/custom_components/span_panel/websocket_adopted.py +++ b/custom_components/span_panel/websocket_adopted.py @@ -24,9 +24,11 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Final from homeassistant.components import websocket_api +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -43,11 +45,16 @@ ) from .const import DOMAIN from .curation import ( + PROMOTED, + CurationError, CurationOverlay, + CurationRecord, RowContext, allowed_device_classes, allowed_state_classes, + async_save_record, record_as_dict, + validate_record, ) from .extension import adoptable, extension_curation_key, resolve_platform from .runtime import SpanPanelRuntimeData, loaded_runtime_data @@ -102,6 +109,25 @@ class _AdoptableRow: """Whether the card is one adoption minted, rather than a curated device.""" +_STATE_CLASS_VALUES: Final = [cls.value for cls in SensorStateClass] +"""Every state class, without regard to a row -- the schema has no row to regard. + +Which choices a *particular* row admits is `allowed_state_classes`' answer and +`validate_record`'s to enforce, because both need the declaration. This is only +the alphabet, so a value Core has never heard of never reaches either. +""" + +_DEVICE_CLASS_VALUES: Final = sorted( + {cls.value for cls in SensorDeviceClass} | {cls.value for cls in BinarySensorDeviceClass} +) +"""Both platforms' device classes, unioned for the same reason: no row here yet. + +A binary row's classes and a sensor row's are disjoint vocabularies, and which +one applies is decided from `RowContext.platform` in `curation`. Sorted so the +schema's own error message names the values in a stable order. +""" + + @websocket_api.websocket_command( { vol.Required("type"): "span_panel/adopted/list", @@ -187,6 +213,106 @@ def _row_payload( } +@websocket_api.websocket_command( + { + vol.Required("type"): "span_panel/adopted/curate", + vol.Required("device_id"): str, + vol.Required("key"): vol.All( + str, vol.Length(min=1, max=256), vol.Match(r"^[A-Za-z0-9_./-]+$") + ), + vol.Required("record"): vol.Schema( + { + vol.Optional("state_class"): vol.In(_STATE_CLASS_VALUES), + vol.Optional("device_class"): vol.In(_DEVICE_CLASS_VALUES), + vol.Optional("entity_category"): PROMOTED, + } + ), + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def handle_adopted_curate( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Store one row's asserted metadata, or clear it, and rebuild the entity that reads it. + + Admin users pass the panel's device registry id -- the same handle + `adopted/list` takes -- and a `key` that command reported. The rows are + derived again here rather than the key being trusted: the store is keyed on + wire addresses, so a key nothing publishes would be held forever, read by no + entity and shown on no list. + + An empty `record` clears the row. Anything else is validated against the + row's *current* declaration, and a refusal carries `curation`'s own code + unchanged, so the editor renders the refusal it can explain rather than a + generic one. What the schema can decide without the declaration -- enum + membership, the one storable category, the key's shape -- is decided there, + so only questions that need the wire reach this far. + + **A save's side effects are exactly three: the store, a reload, the reply.** + Nothing here writes registry state, per this module's boundary. The reload is + the half that is easy to miss: an entity description is fixed at + construction, so a record reaches its entity only by that entity being built + again -- and being built *with* it, because a state class that arrives after + the first state is written is a statistics reset rather than a metadata + change. + """ + resolved = _resolve_panel_entry(hass, connection, msg) + if resolved is None: + return + entry, runtime_data, snapshot = resolved + + key: str = msg["key"] + row = {candidate.key: candidate for candidate in _rows(hass, snapshot)}.get(key) + if row is None: + connection.send_error(msg["id"], "unknown_key", f"No curatable row is keyed {key!r}") + return + + previous = runtime_data.curation.record_for(key) + record: CurationRecord | None = None + if msg["record"]: + try: + record = validate_record(msg["record"], row.context) + except CurationError as err: + connection.send_error(msg["id"], err.code, str(err)) + return + + await async_save_record(hass, entry, key, record) + hass.config_entries.async_schedule_reload(entry.entry_id) + connection.send_result( + msg["id"], + { + "record": {} if record is None else record_as_dict(record), + "warnings": _warnings(record, previous), + }, + ) + + +def _warnings(record: CurationRecord | None, previous: CurationRecord | None) -> list[str]: + """Name the consequences of a save that the saved record does not show on its face. + + Advisory rather than refusals -- the write has happened and the user asked + for it -- and both are about the recorder rather than the entity, which is + exactly why the record cannot show them. + + Clearing a state class stops long-term statistics being compiled for the + entity, and core raises its own `state_class_removed` repair against the + statistics already collected (`sensor/recorder.py`). Asserting + `total_increasing` reinterprets the reading rather than describing it: the + recorder reads a drop of more than a tenth as a meter reset and starts a new + cycle, so a reading that legitimately falls manufactures consumption. + """ + if record is None: + if previous is not None and previous.state_class is not None: + return ["statistics_removed"] + return [] + if record.state_class is SensorStateClass.TOTAL_INCREASING: + return ["total_increasing"] + return [] + + def _rows(hass: HomeAssistant, snapshot: SpanPanelSnapshot) -> list[_AdoptableRow]: """Every row on this panel a user may curate, in a deterministic order. diff --git a/tests/test_websocket_adopted.py b/tests/test_websocket_adopted.py index 8ce09ff2..c687bf80 100644 --- a/tests/test_websocket_adopted.py +++ b/tests/test_websocket_adopted.py @@ -1,17 +1,24 @@ -"""The adopted/list command reports every curatable row, grouped by the device it renders on. - -Three things carry this surface and each fails loudly here if it stops holding: -a row's key is the one the curate command will be handed back, the allowed -choices are computed from the wire rather than offered blind, and a stored record -that no longer fits its declaration is *shown* rather than silently sanitised -- -the editor is where a user finds out their assertion went stale. +"""The adopted commands: list reports every curatable row, curate stores one. + +Three things carry the list surface and each fails loudly here if it stops +holding: a row's key is the one the curate command will be handed back, the +allowed choices are computed from the wire rather than offered blind, and a +stored record that no longer fits its declaration is *shown* rather than silently +sanitised -- the editor is where a user finds out their assertion went stale. + +Curate is tested against the same fixtures for the reason that matters most about +it: the keys it accepts and the choices it admits are the ones list offered, and +a second derivation of either would let the editor save something the entities +never read. Its refusals are asserted by code, its side effects are asserted to +be exactly three -- the store, the reload, the reply -- and the registry is +asserted to be untouched. """ from __future__ import annotations from dataclasses import replace from typing import TYPE_CHECKING, Any -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from homeassistant.components.sensor import SensorStateClass from homeassistant.config_entries import ConfigEntryState @@ -29,8 +36,13 @@ async_register_adopted_devices, ) from custom_components.span_panel.const import DOMAIN -from custom_components.span_panel.curation import CurationOverlay, CurationRecord +from custom_components.span_panel.curation import ( + CurationOverlay, + CurationRecord, + async_load_curation, +) from custom_components.span_panel.extension import extension_curation_key, extension_unique_id +from custom_components.span_panel.runtime import loaded_runtime_data from custom_components.span_panel.util import SUB_DEVICE_BESS from custom_components.span_panel.websocket import async_register_commands @@ -183,6 +195,56 @@ async def _list( return reply +async def _curate( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_id: str, + key: str, + record: dict[str, Any], + *, + scheduled_reload: MagicMock | None = None, +) -> dict[str, Any]: + """Send one adopted/curate request over a real websocket and return the reply. + + The reload a successful save schedules is patched out in every call. Letting + it run would set the integration up for real against a panel no test has, and + what the handler owes is that it *asked* for one -- which the `scheduled_reload` + a test passes in is how that is asserted. + """ + async_register_commands(hass) + client = await hass_ws_client(hass) + with patch.object( + hass.config_entries, + "async_schedule_reload", + MagicMock() if scheduled_reload is None else scheduled_reload, + ): + await client.send_json_auto_id( + { + "type": "span_panel/adopted/curate", + "device_id": device_id, + "key": key, + "record": record, + } + ) + reply: dict[str, Any] = await client.receive_json() + return reply + + +async def _reload_overlay(hass: HomeAssistant) -> None: + """Re-resolve the entry's overlay from disk, standing in for the patched-out reload. + + A save writes the store and schedules the reload; the overlay every read goes + through is resolved once per setup and never re-reads the disk. Doing that one + step by hand is what keeps a follow-up assertion about what reached the store + rather than about what a handler happened to leave in memory. + """ + entry = hass.config_entries.async_get_entry(ENTRY_ID) + assert entry is not None + runtime_data = loaded_runtime_data(entry) + assert runtime_data is not None + runtime_data.curation = await async_load_curation(hass, entry) + + def _group(reply: dict[str, Any], name: str) -> dict[str, Any]: """Return the one device group with this name, failing the test if it is absent.""" matched = [group for group in reply["result"]["devices"] if group["name"] == name] @@ -732,3 +794,411 @@ async def test_device_id_is_required_by_the_schema( reply = await client.receive_json() assert reply["success"] is False assert reply["error"]["code"] == "invalid_format" + + +# --- curate: what a save leaves behind -------------------------------------- + + +async def test_a_saved_record_is_stored_under_the_key_the_list_offered( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """The key round-trips: what list offered is what curate resolves and the store holds. + + The whole editor rests on the two commands deriving the same rows, so the + assertion is deliberately end-to-end -- save through the websocket, re-read + the overlay off disk, and find the record on the row list reports it against. + """ + panel = _setup(hass) + + reply = await _curate( + hass, + hass_ws_client, + panel.id, + POWER_KEY, + {"state_class": "measurement", "device_class": "power", "entity_category": "none"}, + ) + + assert reply["success"] is True + assert reply["result"] == { + "record": { + "state_class": "measurement", + "device_class": "power", + "entity_category": "none", + }, + "warnings": [], + } + + await _reload_overlay(hass) + listed = await _list(hass, hass_ws_client, panel.id) + assert _row(_group(listed, "Backup Generator"), POWER_KEY)["curation"] == { + "state_class": "measurement", + "device_class": "power", + "entity_category": "none", + } + + +async def test_a_vendor_row_on_a_modelled_device_is_curated_the_same_way( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Both halves of adoption are one command, because they are one row derivation. + + An extension property hangs off a device this integration models and is keyed + by subject rather than by an adopted identifier, so a lookup built from only + the adopted half would refuse a row the editor is showing. + """ + panel = _setup(hass) + assert CELL_TEMPERATURE_KEY is not None + + reply = await _curate( + hass, hass_ws_client, panel.id, CELL_TEMPERATURE_KEY, {"device_class": "temperature"} + ) + + assert reply["result"] == {"record": {"device_class": "temperature"}, "warnings": []} + + await _reload_overlay(hass) + listed = await _list(hass, hass_ws_client, panel.id) + assert _row(_group(listed, "Span Panel Battery"), CELL_TEMPERATURE_KEY)["curation"] == { + "device_class": "temperature" + } + + +async def test_a_save_schedules_exactly_one_reload( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Curated metadata reaches an entity by rebuilding it, so a save has to ask for that. + + An entity description is fixed at construction, so the record the user just + stored does not reach the entity until the entry is set up again. One reload + per save: the handler neither skips it nor asks twice for one write. + """ + panel = _setup(hass) + scheduled = MagicMock() + + reply = await _curate( + hass, + hass_ws_client, + panel.id, + POWER_KEY, + {"device_class": "power"}, + scheduled_reload=scheduled, + ) + + assert reply["success"] is True + scheduled.assert_called_once_with(ENTRY_ID) + + +async def test_a_refused_record_schedules_no_reload( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Nothing was written, so there is nothing for a reload to pick up.""" + panel = _setup(hass) + scheduled = MagicMock() + + reply = await _curate( + hass, + hass_ws_client, + panel.id, + POWER_KEY, + {"device_class": "temperature"}, + scheduled_reload=scheduled, + ) + + assert reply["error"]["code"] == "incompatible_device_class" + scheduled.assert_not_called() + + +async def test_a_save_writes_no_registry_state( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """The boundary this module is built on, asserted rather than described. + + A device class and a promotion out of diagnostics both *look* like registry + acts, and writing them there is how this command would quietly become a + second, weaker version of Core's own entity-registry update -- one with no + undo and no user override. The registry hands back the very object it holds, + and any update replaces that object, so identity is the exact assertion. + """ + panel = _setup(hass) + registry = er.async_get(hass) + entity = registry.async_get_or_create( + "sensor", + DOMAIN, + adopted_unique_id(ADOPTED_IDENTIFIER, POWER), + suggested_object_id="backup_generator_active_power", + ) + before = registry.async_get(entity.entity_id) + + reply = await _curate( + hass, + hass_ws_client, + panel.id, + POWER_KEY, + {"state_class": "measurement", "device_class": "power", "entity_category": "none"}, + ) + + assert reply["success"] is True + assert registry.async_get(entity.entity_id) is before + + +# --- curate: the warnings ---------------------------------------------------- + + +async def test_clearing_a_record_that_carried_a_state_class_warns_the_statistics_go( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Statistics are compiled off the state class, so clearing one stops them. + + Advisory rather than a refusal -- the save has already happened and the user + asked for it -- but it is a consequence the record does not show on its face, + and core will raise its own `state_class_removed` repair against the + statistics already collected, so the reply says so first. + """ + panel = _setup(hass) + await _curate( + hass, hass_ws_client, panel.id, POWER_KEY, {"state_class": "total", "device_class": "power"} + ) + await _reload_overlay(hass) + + reply = await _curate(hass, hass_ws_client, panel.id, POWER_KEY, {}) + + assert reply["result"] == {"record": {}, "warnings": ["statistics_removed"]} + + await _reload_overlay(hass) + listed = await _list(hass, hass_ws_client, panel.id) + assert _row(_group(listed, "Backup Generator"), POWER_KEY)["curation"] == {} + + +async def test_clearing_a_record_that_never_carried_one_warns_nothing( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """No state class was in force, so no statistics existed to lose.""" + panel = _setup(hass) + await _curate(hass, hass_ws_client, panel.id, POWER_KEY, {"device_class": "power"}) + await _reload_overlay(hass) + + reply = await _curate(hass, hass_ws_client, panel.id, POWER_KEY, {}) + + assert reply["result"] == {"record": {}, "warnings": []} + + +async def test_clearing_a_row_nobody_curated_is_accepted_and_warns_nothing( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Clearing what was never set is a no-op rather than a refusal. + + An editor that opens on an uncurated row and saves it unchanged is the + ordinary case, and it must not be told it did something wrong. + """ + panel = _setup(hass) + + reply = await _curate(hass, hass_ws_client, panel.id, LABEL_KEY, {}) + + assert reply["result"] == {"record": {}, "warnings": []} + + +async def test_asserting_total_increasing_is_warned_about( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """The one state class that reinterprets the reading rather than describing it. + + `total_increasing` has the recorder read a drop of more than a tenth as a + meter reset and start a new cycle, so asserting it on a reading that + legitimately falls manufactures consumption. Saved as asked, and flagged. + """ + panel = _setup(hass) + + reply = await _curate( + hass, hass_ws_client, panel.id, POWER_KEY, {"state_class": "total_increasing"} + ) + + assert reply["result"] == { + "record": {"state_class": "total_increasing"}, + "warnings": ["total_increasing"], + } + + +# --- curate: refusals -------------------------------------------------------- + + +async def test_a_key_the_panel_does_not_publish_is_refused( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """A well-formed key for a row that does not exist earns its own code. + + The store would hold anything -- its keys are wire addresses, not registry + ids -- so nothing but this check stops a typo becoming a record no entity + will ever read and no list will ever show. + """ + panel = _setup(hass) + + reply = await _curate( + hass, + hass_ws_client, + panel.id, + adopted_curation_key(ADOPTED_IDENTIFIER, FLATTENS_FIRST), + {"device_class": "temperature"}, + ) + + assert reply["success"] is False + assert reply["error"]["code"] == "unknown_key" + + +async def test_the_row_the_list_skipped_cannot_be_curated( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """One row derivation, so a row list declines to offer is one curate declines to save. + + The loser of a flattened-id collision never becomes an entity. A record saved + against it would sit on disk unread forever, which is worse than a refusal + because the editor would report it back as an assertion in force. + """ + panel = _setup( + hass, + _snapshot( + devices=(replace(GENERATOR, properties=(FLATTENS_SECOND, FLATTENS_FIRST)),), rows=() + ), + ) + + reply = await _curate( + hass, + hass_ws_client, + panel.id, + adopted_curation_key(ADOPTED_IDENTIFIER, FLATTENS_SECOND), + {"device_class": "temperature"}, + ) + + assert reply["error"]["code"] == "unknown_key" + + +async def test_a_cross_field_refusal_surfaces_the_validators_own_code( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """A device class the declared unit does not admit is refused, by name. + + Membership in the enum is all the schema can know; whether `temperature` + admits watts is a fact about this row, so `curation` decides it and its code + reaches the editor unchanged -- the card renders the refusal it can explain, + not a generic one. + """ + panel = _setup(hass) + + reply = await _curate( + hass, hass_ws_client, panel.id, POWER_KEY, {"device_class": "temperature"} + ) + + assert reply["success"] is False + assert reply["error"]["code"] == "incompatible_device_class" + + +async def test_the_schemas_alphabet_is_wider_than_any_one_row_admits( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """What the schema cannot know without the row is what the handler answers for. + + The schema takes both platforms' device classes because it has no row to + narrow them with, and every state class because whether a row is a numeric + sensor is a fact about the wire. So a binary-only class on a sensor row and a + state class on a string row both pass the schema and are refused here, each + by its own code. + """ + panel = _setup(hass) + + binary_only = await _curate( + hass, hass_ws_client, panel.id, POWER_KEY, {"device_class": "motion"} + ) + off_a_string = await _curate( + hass, hass_ws_client, panel.id, LABEL_KEY, {"state_class": "measurement"} + ) + + assert binary_only["error"]["code"] == "invalid_device_class" + assert off_a_string["error"]["code"] == "invalid_state_class" + + +async def test_a_control_row_accepts_prominence_only( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """What list reports as an empty choice list, curate refuses -- the same answer twice. + + A settable numeric surfaces as a control, which carries neither class. The + editor is told so by the empty `allowed_*` lists; a card that ignored them + must still be refused rather than storing metadata the entity cannot hold. + """ + panel = _setup(hass) + + refused = await _curate(hass, hass_ws_client, panel.id, SETPOINT_KEY, {"device_class": "power"}) + accepted = await _curate( + hass, hass_ws_client, panel.id, SETPOINT_KEY, {"entity_category": "none"} + ) + + assert refused["error"]["code"] == "invalid_field_for_platform" + assert accepted["result"] == {"record": {"entity_category": "none"}, "warnings": []} + + +async def test_the_schema_refuses_what_it_can_decide_without_the_row( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Enum membership, the one storable category, and unknown fields never reach the handler. + + Everything statically expressible is constrained at the schema, so the + handler answers only for what needs the row's declaration. All three come + back as the websocket layer's own `invalid_format`. + """ + panel = _setup(hass) + + for record in ( + {"state_class": "invented"}, + {"device_class": "invented"}, + {"entity_category": "diagnostic"}, + {"nonsense": "1"}, + ): + reply = await _curate(hass, hass_ws_client, panel.id, POWER_KEY, record) + assert reply["success"] is False, record + assert reply["error"]["code"] == "invalid_format", record + + +async def test_the_schema_refuses_a_key_no_curation_scheme_mints( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Keys are wire addresses and become storage keys, so their shape is constrained. + + Both minting schemes produce identifier and path segments only. A key outside + that alphabet, or an unbounded one, is refused before the handler sees it. + """ + panel = _setup(hass) + + for key in ("has spaces", "has\\backslash", "x" * 257, ""): + reply = await _curate(hass, hass_ws_client, panel.id, key, {}) + assert reply["success"] is False, key + assert reply["error"]["code"] == "invalid_format", key + + +async def test_curate_requires_admin( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_admin_user: MockUser, +) -> None: + """A write is an admin act, refused before any device or key is resolved.""" + hass_admin_user.groups = [] + _setup(hass) + + reply = await _curate(hass, hass_ws_client, "any-device-id", POWER_KEY, {}) + + assert reply["success"] is False + assert reply["error"]["code"] == "unauthorized" + + +async def test_curate_takes_the_panel_handle( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """The same handle and the same refusals as list, because they are one resolution. + + A consumer that learned list's codes must not meet a second set on the + command it calls next, so curate resolves the panel through the same helper. + """ + _setup(hass) + bess = dr.async_get(hass).async_get_device(identifiers={(DOMAIN, BESS_IDENTIFIER)}) + assert bess is not None + + reply = await _curate(hass, hass_ws_client, bess.id, POWER_KEY, {}) + + assert reply["error"]["code"] == "not_panel_device" diff --git a/websocket-api.md b/websocket-api.md index 1419fda2..193ba0ed 100644 --- a/websocket-api.md +++ b/websocket-api.md @@ -288,3 +288,98 @@ does. Curation is keyed on the wire address rather than on a registry ID, so a r | not_panel_device | The device_id is a sub-device, not the panel | | not_loaded | The integration or config entry is not loaded | | no_data | The coordinator has no panel data yet | + +## `span_panel/adopted/curate` + +Stores the metadata a user asserts for one adopted row, or clears it. The `key` is one `adopted/list` reported: the rows this command accepts are derived the +same way and from the same snapshot, so a key that command did not offer is refused rather than stored. + +**This command writes no registry state.** Enabling an entity, renaming it, giving it an icon or an area, and choosing a display unit are all Core's own +websocket commands, which already ask for admin and already carry the undo. What is here is only what Core has nowhere to put — a state class, a device class, +and prominence for an entity built from a vendor declaration. + +A successful save has three effects and no others: the record is written to the integration's own store, the config entry is scheduled for reload, and the +result is returned. The reload is not incidental. An entity description is fixed when the entity is constructed, so a record reaches its entity only by that +entity being built again — and being built _with_ it, since a state class that first appears after states have been recorded is a statistics reset rather than a +metadata change. + +Admin only, like every command here. + +### Request + +```json +{ + "type": "span_panel/adopted/curate", + "device_id": "", + "key": "nj-2316-005k6_adopted_generator-1/meter/active-power", + "record": { + "state_class": "measurement", + "device_class": "power", + "entity_category": "none" + } +} +``` + +| Field | Type | Description | +| ----------- | ------ | ------------------------------------------------------------------------------------------ | +| `device_id` | string | The device registry ID for the **main SPAN panel**, the same handle `panel_topology` takes | +| `key` | string | The `key` of the row being curated, exactly as `adopted/list` reported it | +| `record` | object | The full record to store; an empty object clears the row | + +#### Record Object + +| Field | Type | Description | +| ----------------- | ------ | -------------------------------------------------------------------------------------- | +| `state_class` | string | `measurement`, `total`, or `total_increasing` — numeric sensor rows only | +| `device_class` | string | A sensor or binary-sensor device class the row's platform and declared unit admit | +| `entity_category` | string | `none`, the one storable value — it promotes the entity out of the diagnostic category | + +`record` replaces the stored record rather than merging into it: a field left out is a field cleared. The values admissible for a given row are exactly the +`allowed_state_classes` and `allowed_device_classes` that `adopted/list` reported for it, so a card built from that response never offers a value this command +refuses. + +### Response + +```json +{ + "record": { + "state_class": "measurement", + "device_class": "power", + "entity_category": "none" + }, + "warnings": [] +} +``` + +| Field | Type | Description | +| ---------- | -------- | ------------------------------------------------------------- | +| `record` | object | The record now stored; `{}` when the row was cleared | +| `warnings` | string[] | Advisory consequences of the save, which has already happened | + +#### Warnings + +| Code | Description | +| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `statistics_removed` | The cleared record carried a state class, so long-term statistics stop being compiled and HA raises its own `state_class_removed` repair | +| `total_increasing` | The recorder reads a drop of more than a tenth as a meter reset and starts a new cycle, so a reading that legitimately falls manufactures consumption | + +Warnings are never refusals. They name effects the stored record does not show on its face, because both are about the recorder rather than about the entity. + +### Errors + +| Code | Description | +| -------------------------- | ------------------------------------------------------------------------------------- | +| device_not_found | The device_id does not exist in HA | +| not_span_panel | The device is not a SPAN Panel device | +| not_panel_device | The device_id is a sub-device, not the panel | +| not_loaded | The integration or config entry is not loaded | +| no_data | The coordinator has no panel data yet | +| unknown_key | No curatable row on this panel carries that key | +| invalid_state_class | A state class was asserted on a row that is not a numeric sensor | +| invalid_device_class | The value is not a device class for this row's platform | +| incompatible_device_class | The device class does not admit the unit the row declares | +| invalid_field_for_platform | A control row accepts prominence only, not a state class or a device class | +| invalid_format | The request failed the command schema — an unknown value or field, or a malformed key | + +The first five are the codes `adopted/list` answers, from the same resolution: a consumer that learned them for one command does not meet a second set on the +next. From ce9711dc1401812b12d13048a41001258fd15de7 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:15:20 -0700 Subject: [PATCH 238/345] style: fold in the formatter fix the first commit left unstaged --- custom_components/span_panel/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py index bc8a96a0..cf4a03c7 100644 --- a/custom_components/span_panel/__init__.py +++ b/custom_components/span_panel/__init__.py @@ -437,9 +437,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> # downgrade the pin exists to prevent (issue #264). The transport's # own client is not used here; see the constructor comment below. try: - transport = panel_rest_transport( - hass, entry.data, allow_plaintext_fallback=False - ) + transport = panel_rest_transport(hass, entry.data, allow_plaintext_fallback=False) except PanelCaUnusableError as err: async_raise_ca_unusable(hass, entry, str(err)) raise ConfigEntryError( # noqa: TRY301 From fc5ddf1ef59d2ff99e7416241760caa11dfc901a Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:18:23 -0700 Subject: [PATCH 239/345] docs: wrap the 2.1.1 changelog to the markdown line limit --- CHANGELOG.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83686417..b5f83664 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,19 +6,23 @@ All notable changes to this project will be documented in this file. ### Fixed -- **The startup warning that panel traffic is unencrypted is resolved, not silenced** — a pinned entry now reads the panel's schema over HTTPS verified against its pinned certificate authority, instead of plaintext HTTP (#264). +- **The startup warning that panel traffic is unencrypted is resolved, not silenced** — a pinned entry now reads the panel's schema over HTTPS verified against + its pinned certificate authority, instead of plaintext HTTP (#264). ### Added -- **A repair when the HTTPS port serves a certificate the pin does not validate** — a wrong HTTPS port, a proxy terminating TLS in front of the panel, or a panel whose clock reset in an outage; the integration keeps retrying without connecting and never falls back to plaintext, so the clock case heals on its own. +- **A repair when the HTTPS port serves a certificate the pin does not validate** — a wrong HTTPS port, a proxy terminating TLS in front of the panel, or a + panel whose clock reset in an outage; the integration keeps retrying without connecting and never falls back to plaintext, so the clock case heals on its own. - **A repair when the stored certificate authority can no longer be read**, guiding you through re-acquiring and confirming the panel's certificate authority. -- **Reconfigure now offers the HTTPS port on any panel with a pinned certificate authority**, for installs whose TLS lives behind a proxy or port forward and were never asked. +- **Reconfigure now offers the HTTPS port on any panel with a pinned certificate authority**, for installs whose TLS lives behind a proxy or port forward and + were never asked. ### Changed - **A panel whose certificate authority rotated is now also detected at startup's first read**, raising the same guided re-pin repair it already had. - **A moved panel detected at startup's first read raises the existing address repair and keeps retrying**, exactly as it promises. -- **When a pinned panel cannot be reached at all, the retry message names the HTTPS port and the Reconfigure remedy** instead of only reporting the panel as not ready. +- **When a pinned panel cannot be reached at all, the retry message names the HTTPS port and the Reconfigure remedy** instead of only reporting the panel as not + ready. ## [2.1.0] - 8/2026 From bc6d7d011c5d1317da316b608444ff88c21304af Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:20:04 -0700 Subject: [PATCH 240/345] fix(curation): warn statistics_removed whenever a save leaves no state class --- .../span_panel/websocket_adopted.py | 21 ++++++++---- tests/test_websocket_adopted.py | 34 +++++++++++++++++++ websocket-api.md | 8 ++--- 3 files changed, 52 insertions(+), 11 deletions(-) diff --git a/custom_components/span_panel/websocket_adopted.py b/custom_components/span_panel/websocket_adopted.py index 603a2968..07eed3b6 100644 --- a/custom_components/span_panel/websocket_adopted.py +++ b/custom_components/span_panel/websocket_adopted.py @@ -297,14 +297,21 @@ def _warnings(record: CurationRecord | None, previous: CurationRecord | None) -> for it -- and both are about the recorder rather than the entity, which is exactly why the record cannot show them. - Clearing a state class stops long-term statistics being compiled for the - entity, and core raises its own `state_class_removed` repair against the - statistics already collected (`sensor/recorder.py`). Asserting - `total_increasing` reinterprets the reading rather than describing it: the - recorder reads a drop of more than a tenth as a meter reset and starts a new - cycle, so a reading that legitimately falls manufactures consumption. + The first fires on what a save *leaves*, not on how it was spelled. A record + narrowed to its other fields drops the state class exactly as clearing the + whole record does, so a warning scoped to the clear would let the identical + consequence go unsaid on the route a user is more likely to take. Losing a + state class stops long-term statistics being compiled for the entity, and + core raises its own `state_class_removed` repair against the ones already + collected (`sensor/recorder.py`). + + Asserting `total_increasing` reinterprets the reading rather than describing + it: the recorder reads a drop of more than a tenth as a meter reset and + starts a new cycle, so a reading that legitimately falls manufactures + consumption. It cannot co-fire with the first, which requires the save to + have left no state class at all. """ - if record is None: + if record is None or record.state_class is None: if previous is not None and previous.state_class is not None: return ["statistics_removed"] return [] diff --git a/tests/test_websocket_adopted.py b/tests/test_websocket_adopted.py index c687bf80..d6c1792f 100644 --- a/tests/test_websocket_adopted.py +++ b/tests/test_websocket_adopted.py @@ -968,6 +968,40 @@ async def test_clearing_a_record_that_carried_a_state_class_warns_the_statistics assert _row(_group(listed, "Backup Generator"), POWER_KEY)["curation"] == {} +async def test_narrowing_a_record_off_its_state_class_warns_the_same_way( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """The warning is about what the save leaves, not about how it was spelled. + + A record narrowed to its other fields drops the state class exactly as + clearing the whole record does -- the same stopped statistics and the same + repair -- and it is the likelier route, because a user editing one field of a + record still sends the whole record back. + """ + panel = _setup(hass) + await _curate( + hass, + hass_ws_client, + panel.id, + POWER_KEY, + {"state_class": "measurement", "device_class": "power"}, + ) + await _reload_overlay(hass) + + reply = await _curate(hass, hass_ws_client, panel.id, POWER_KEY, {"device_class": "power"}) + + assert reply["result"] == { + "record": {"device_class": "power"}, + "warnings": ["statistics_removed"], + } + + await _reload_overlay(hass) + listed = await _list(hass, hass_ws_client, panel.id) + assert _row(_group(listed, "Backup Generator"), POWER_KEY)["curation"] == { + "device_class": "power" + } + + async def test_clearing_a_record_that_never_carried_one_warns_nothing( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: diff --git a/websocket-api.md b/websocket-api.md index 193ba0ed..30b277da 100644 --- a/websocket-api.md +++ b/websocket-api.md @@ -358,10 +358,10 @@ refuses. #### Warnings -| Code | Description | -| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| `statistics_removed` | The cleared record carried a state class, so long-term statistics stop being compiled and HA raises its own `state_class_removed` repair | -| `total_increasing` | The recorder reads a drop of more than a tenth as a meter reset and starts a new cycle, so a reading that legitimately falls manufactures consumption | +| Code | Description | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `statistics_removed` | The save leaves the row without a state class it previously had — cleared outright or narrowed to the other fields — so long-term statistics stop being compiled and HA raises its own `state_class_removed` repair | +| `total_increasing` | The recorder reads a drop of more than a tenth as a meter reset and starts a new cycle, so a reading that legitimately falls manufactures consumption | Warnings are never refusals. They name effects the stored record does not show on its face, because both are about the recorder rather than about the entity. From f0c544bd9dc1c30a50c3130afb5f3fe7aab218be Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:27:14 -0700 Subject: [PATCH 241/345] feat(curation): report stored curation in diagnostics --- custom_components/span_panel/diagnostics.py | 4 ++ tests/test_diagnostics.py | 67 ++++++++++++++++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/custom_components/span_panel/diagnostics.py b/custom_components/span_panel/diagnostics.py index 9b86bb9f..565e194d 100644 --- a/custom_components/span_panel/diagnostics.py +++ b/custom_components/span_panel/diagnostics.py @@ -335,4 +335,8 @@ async def async_get_config_entry_diagnostics( }, "schema_discovery": _discovery(coordinator.schema_findings), "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 + # as the adoption block above. + "adopted_curation": entry.runtime_data.curation.as_dicts(), } diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 34af0a2e..d7c0dded 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -6,6 +6,7 @@ from unittest.mock import MagicMock from homeassistant.components.diagnostics import REDACTED +from homeassistant.components.sensor import SensorStateClass from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -18,7 +19,12 @@ CONF_HOP_PASSPHRASE, DOMAIN, ) -from custom_components.span_panel.curation import CurationOverlay +from custom_components.span_panel.curation import ( + CurationOverlay, + CurationRecord, + async_load_curation, + async_save_record, +) from custom_components.span_panel.diagnostics import ( async_get_config_entry_diagnostics, ) @@ -250,7 +256,15 @@ async def test_diagnostics_reports_the_entity_registry(hass: HomeAssistant) -> N coordinator.transport_dead = False coordinator.last_update_success = True coordinator.schema_findings = None - entry.runtime_data = SimpleNamespace(coordinator=coordinator) + # 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 + # mypy cannot see the drift on a `SimpleNamespace`. + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + ) result = await async_get_config_entry_diagnostics(hass, entry) @@ -259,3 +273,52 @@ async def test_diagnostics_reports_the_entity_registry(hass: HomeAssistant) -> N row = rows["sensor.span_panel_l1_voltage"] assert row["disabled_by"] == "integration" assert row["unique_id"] == "span_sp3_diag_003_l1_voltage" + + +async def test_diagnostics_reports_the_stored_curation(hass: HomeAssistant) -> None: + """What the user asserted about adopted rows is the other half of the adoption block. + + The adoption block says what the panel published. Without this one, a + maintainer reading an attachment cannot tell a state class the integration + derived from one the household declared, and those two fail differently. + + Seeded through the store rather than by handing an overlay in, because what + the payload has to carry is what a *loaded* entry holds: the overlay + diagnostics reads is the one setup read off disk. + """ + entry = MockConfigEntry(domain=DOMAIN, data={}, entry_id="curated-entry", title="SPAN Panel") + entry.add_to_hass(hass) + await async_save_record( + hass, + entry, + "bess/battery-2/cell-voltage", + CurationRecord(state_class=SensorStateClass.MEASUREMENT, device_class="voltage"), + ) + await async_save_record(hass, entry, "bess/battery-2/enabled", CurationRecord(promote=True)) + + coordinator = MagicMock() + coordinator.data = SpanPanelSnapshotFactory.create(serial_number="sp3-diag-004") + coordinator.panel_offline = False + coordinator.transport_dead = False + coordinator.last_update_success = True + coordinator.schema_findings = None + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=await async_load_curation(hass, entry), + ) + + result = await async_get_config_entry_diagnostics(hass, entry) + + assert result["adopted_curation"] == { + "bess/battery-2/cell-voltage": {"state_class": "measurement", "device_class": "voltage"}, + "bess/battery-2/enabled": {"entity_category": "none"}, + } + # Keys and enum values, and the assertion is on the shape rather than on a + # filter this test applies: a record that grew a name, an icon or a wire + # value would be a leak the payload cannot redact, because `TO_REDACT` is + # key-based over the config entry and reaches nothing here. + assert all( + set(record) <= {"state_class", "device_class", "entity_category"} + for record in result["adopted_curation"].values() + ) From 5f01ad02bb028fa8fa79b3db1848e6ffc65892a8 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:37:34 -0700 Subject: [PATCH 242/345] chore(release): 2.1.1b1 on span-panel-api 3.4.0 --- custom_components/span_panel/manifest.json | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 5b834a34..47cb63d3 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -27,7 +27,7 @@ "span-panel-api-schema-0==1.1.2", "span-panel-api-schema-1==1.1.3" ], - "version": "2.1.0", + "version": "2.1.1b1", "zeroconf": [ { "type": "_span._tcp.local." diff --git a/pyproject.toml b/pyproject.toml index 172fc1cd..363c8e6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span" -version = "2.1.0" +version = "2.1.1b1" description = "Span Panel Custom Integration for Home Assistant" authors = [{name = "SpanPanel"}] license = {text = "MIT"} From 4d3fbee2d74bef1b23d9b6f62a529564a1dadb31 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:39:52 -0700 Subject: [PATCH 243/345] docs: curation of adopted entities developer.md gains a Curation section covering the .storage overlay and its scope-prefixed keys, validation refusing at save and dropping at construction, the description helpers that keep both AST guards absolute, the two websocket commands' zero-registry-writes boundary, and why the reload is the mechanism rather than a courtesy. The revisable-vs-not table's state_class row moves from "never set at all" to user-curated only, and the diagnostics section gains the adopted_curation block's withholding rule. README.md documents the Adopted tab: what the integration sets versus what is Home Assistant's own, that saving reloads, the total_increasing and statistics-class-removal consequences, and that identity never changes. --- CHANGELOG.md | 7 +++ README.md | 53 +++++++++++++++++--- developer.md | 138 ++++++++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 178 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6a8951c..3057aaa0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to this project will be documented in this file. +## [Unreleased] + +### Added + +- **Adopted entities can now be curated from the dashboard's new Adopted tab** — set a device class, a statistics class and whether the entity stays under + Diagnostics, with the change applied on an automatic reload. + ## [2.1.0] - 8/2026 ### In short diff --git a/README.md b/README.md index 99a45103..b65d2c7b 100644 --- a/README.md +++ b/README.md @@ -465,9 +465,10 @@ number becomes a number entity — and those arrive switched off too. Two things worth knowing before you build on one: -- **Nothing adopted enters long-term statistics.** No adopted entity carries a `state_class`, because the correct one is not published on the wire and guessing - wrong writes corrupt statistics that fixing the panel afterwards does not repair. If you want statistics from an adopted reading, wrap it in a template - sensor, a Riemann-sum integration or a utility meter — a deliberate choice on an entity you enabled. +- **Nothing adopted enters long-term statistics until you say it should.** Adopted entities arrive with no statistics class, because the correct one is not + published on the wire and guessing wrong writes corrupt statistics that fixing the panel afterwards does not repair. You are welcome to supply it — the + [Adopted tab](#the-adopted-tab) is where — or to leave it alone and wrap the reading in a template sensor, a Riemann-sum integration or a utility meter. + Either way it is a deliberate choice on an entity you enabled. - **A new property on a device this integration already models is adopted too**, but as a reading on that device's existing card rather than as a device of its own. See [Adopted Vendor Readings](#adopted-vendor-readings) below. @@ -497,10 +498,48 @@ So deletion means "hide it until next time" for a live reading and "clear it out publishing is left in place reading unknown rather than removed: silence on the wire does not distinguish a property that is gone from one that has not arrived yet, and deleting your entity on a guess is not something an upgrade should do. -**These entities are permanent in id, not in identity.** If one of these readings is later curated properly (delivered as an official part of the integration), -the curated entity is a new entity with its own id and its own history — the adopted one is not renamed into it. That is the trade for surfacing a reading the -moment it appears rather than waiting for a release to model it, and it is why a vendor reading you have come to depend on is worth raising in an issue: -curation is what turns it into something with a real name, a proper category and statistics. +**These entities are permanent in id, not in identity.** If one of these readings is later modelled properly (delivered as an official part of the integration), +that entity is a new entity with its own id and its own history — the adopted one is not renamed into it. That is the trade for surfacing a reading the moment +it appears rather than waiting for a release to model it, and it is why a vendor reading you have come to depend on is worth raising in an issue: being modelled +is what turns it into something with a real name, a proper category and statistics out of the box. + +### The Adopted Tab + +The integration will not guess what an adopted reading means. You are not guessing — it is your device — so the built-in dashboard has an **Adopted** tab where +you can tell the integration what it refuses to infer. It lists every adopted entity on your panel, grouped by the device it belongs to, and expands each one +into a small form. The tab appears only for administrator accounts. + +Three of those fields are the integration's own, and they are the ones Home Assistant has nowhere to put for an entity built from a vendor declaration: + +- **Device class** — what kind of quantity this is (power, temperature, energy), which gives the entity a sensible icon and, for the classes Home Assistant + knows how to convert, lets you pick a display unit. +- **Statistics class** — Home Assistant's `state_class`, which is what enrolls the reading in long-term statistics and makes it usable on an Energy dashboard or + in a long-range history graph. This is the piece nothing else in Home Assistant can set for you. +- **Prominence** — whether the entity stays filed under Diagnostics or is promoted out of it. + +The rest of the form — **name, icon, area, and whether the entity is enabled** — is Home Assistant's own entity settings, shown here so you do not have to go +somewhere else, and saved into Home Assistant's registry exactly as if you had edited the entity directly. The integration never changes any of it on your +behalf; enabling an adopted entity is always something you do. + +You are only offered choices your panel's own declaration allows. A statistics class is offered only on numeric readings; the device classes listed are the ones +compatible with the unit your panel publishes, and the unit itself stays whatever the publisher sends. + +**Saving reloads the integration.** That is not a formality — it is how the setting takes effect. An entity's type information is fixed at the moment the entity +is built, so the reload is what rebuilds it already carrying what you asserted, rather than attaching a statistics class to an entity that has been recording +without one — which Home Assistant reads as its statistics starting over rather than as a change of metadata. + +Two consequences are worth knowing before you use the statistics class: + +- **`total_increasing` tells Home Assistant the reading is a meter that only counts up.** The recorder treats a drop of more than a tenth as the meter being + reset and starts a new cycle, so choosing it for a reading that legitimately falls will manufacture consumption that never happened. Choose it only for a + genuine lifetime total. +- **Removing a statistics class stops statistics, and Home Assistant will say so.** If an entity already has statistics and then loses its statistics class, + Home Assistant raises a repair notice against it — a warning rather than something with a fix button — and stops compiling new statistics for it. The + statistics already collected are not deleted, and the notice clears by itself if you put a statistics class back. + +**Nothing about the entity's identity changes.** It keeps the same entity id, the same unique id and the same history, so dashboards and automations pointing at +it keep working. Statistics simply begin from the point the entity is enabled and writing states with a statistics class; the history it recorded before that +stays as ordinary state history. ### BESS & Grid Management diff --git a/developer.md b/developer.md index 34229cdb..1ddfbfb3 100644 --- a/developer.md +++ b/developer.md @@ -480,22 +480,27 @@ Why by node: the capability catalogs carry **no marker** for "this value is a de and such a list goes stale silently. `ebus-sdk`'s own `topology.py` covers `feeds-device-id` and `fed-by-device-id` and omits `grid-forming-entity`, which lives on the `grid` capability. A node cannot go stale that way. -### Nothing adopted enters long-term statistics +### Nothing adopted enters long-term statistics unless its owner asserts one -No adopted entity carries a `state_class`. `test_no_state_class_is_set_anywhere_in_the_module` reads `adoption.py` as syntax and fails if one ever appears. +No adopted entity carries a `state_class` this integration chose. `test_no_state_class_is_set_anywhere_in_the_module` reads `adoption.py` as syntax and fails if +one ever appears there; a user-asserted one reaches the entity through the description helpers in +[`curation.py`](#the-description-helpers-are-the-only-place-state_class-is-spelled), which is the only module in the integration that spells the word. -Three reasons, and they are independent: +Three reasons the integration will not pick one itself, and they are independent: 1. It is not declared on the wire and is not derivable from one. This integration ships `feedthroughEnergyProducedWh` as `TOTAL` beside `mainMeterEnergyProducedWh` as `TOTAL_INCREASING` — same unit, same device class, opposite classification. 2. A wrong one writes corrupt long-term statistics, and fixing the producer afterwards does not repair them. 3. Enrolling a property nobody asked for into long-term statistics is a permanent write to every install's recorder database. -A user who wants statistics from an adopted reading wraps it in a template sensor, a Riemann-sum integration or a utility meter. That is their call, made on an +None of the three is an argument against the _user_ choosing one, and all three are arguments for it being their choice rather than a default: they own the +vendor device, so they are not guessing, and the assertion is stored where it can be seen and undone. A user who would rather not assert one, or who wants a +different derivation, still wraps the reading in a template sensor, a Riemann-sum integration or a utility meter — either way it is their call, made on an entity they chose to enable. -`device_class` is enumerated in `DEVICE_CLASS_BY_UNIT` rather than inferred. A unit outside the map gets **no** device class — `%` is deliberately absent, -because its uses here are a state of charge, a confidence and a duty cycle, and no single class is right for all of them. +`device_class` is enumerated in `DEVICE_CLASS_BY_UNIT` rather than inferred, and a curated record overrides whatever that map answers. A unit outside the map +gets **no** device class — `%` is deliberately absent, because its uses here are a state of charge, a confidence and a duty cycle, and no single class is right +for all of them. ### The device exists even with no entities @@ -656,6 +661,12 @@ file costs the translation and not the notification. take. **No values, no device name, no serial.** Same rule as `schema_discovery` and for the same reason: `TO_REDACT` is key-based over the config entry and cannot protect a wire value put there. +`adopted_curation` is the companion block, and it is `CurationOverlay.as_dicts()` verbatim: every stored record, keyed by its curation key, carrying its enum +values and nothing else. It withholds under the same rule for a narrower reason — the keys are wire addresses and the values are Core enum members, so there is +no wire value and no user free text in the block by construction. The free text a curated row does have (its name and its icon) lives in Core's registry rather +than in this store, so a diagnostics download cannot leak it from here at all. What the block answers is the question worth asking of a support attachment: +whether a surprising entity is surprising because a user asserted something, and which field it was. + ### Adopted entities declare no field paths `snapshot.adopted_devices` is outside the curated field-path vocabulary by construction — it carries no metadata row, so the producible gate has nothing to @@ -719,14 +730,16 @@ better metadata arrives. Three consequences worth knowing before changing any of ### What metadata may reshape, and what it may not -| Attribute | Revisable later? | -| ------------------------------------------- | --------------------------------------------- | -| `entity_category`, device class, unit, name | **Yes**, freely — no id change, no statistics | -| Platform (`sensor` vs `binary_sensor`) | **No.** The domain is baked into `entity_id` | -| `state_class` | Never set at all | +| Attribute | Revisable later? | +| ------------------------------------------- | ---------------------------------------------------------------------------------------- | +| `entity_category`, device class, unit, name | **Yes**, freely — no id change, no statistics | +| Platform (`sensor` vs `binary_sensor`) | **No.** The domain is baked into `entity_id` | +| `state_class` | **User-curated only**, through the [curation store](#curation-metadata-the-user-asserts) | -The free half is free _because_ of the never half: these entities carry no `state_class`, so they write no long-term statistics, so a later unit or device-class -change has nothing to reinterpret. Contrast a curated entity, where changing a unit under a `state_class` is the unrepairable case. +The free half is free _because_ of the never half, and curation does not spend it: an uncurated row still carries no `state_class`, still writes no long-term +statistics, and still has nothing for a later unit or device-class change to reinterpret. What changes is who may end that: the owner of the device, explicitly, +on one row at a time — and from that point the row is in the same position as a curated entity, where changing a unit under a `state_class` is the unrepairable +case. That is why the curate command answers `incompatible_device_class` rather than storing a device class the declared unit does not admit. The platform is enforced in `resolve_platform`, not remembered: whatever domain the id is already registered under wins, however the declaration later changes. `async_update_entity` raises `ValueError("New entity ID should be same domain")`, so re-deriving the platform from better metadata would not move a row — it @@ -778,6 +791,105 @@ confidence, and the ranking is the argument: The real fix is upstream: a declared `role` on the property, proposed in `SpanPanel_Docs/span/docs/dev/ebus-property-role-proposal.md`. Until then the ranking is the shipping plan, and `entity_category` being free to revise is what makes a conservative default cheap. +## Curation: metadata the user asserts + +Adoption's refusal was never "adopted entities may not have statistics" — it was "the integration will not guess", and those are the same rule only while nobody +who does know the answer has anywhere to say it. `curation.py` is that place. It owns three fields and no others: `state_class`, `device_class`, and promotion +out of `EntityCategory.DIAGNOSTIC`. Everything else a user might want to change about an adopted entity — its name, icon, area, display unit, precision, and +whether it is enabled at all — is registry state Core already owns, and this integration writes none of it. + +Identity is untouched in every case. A curated row keeps its `unique_id`, its `entity_id` and its platform: the overlay changes what an entity declares, never +what it is. That is what makes this safe to apply to entities whose ids are [permanent by design](#terminal-identity) — it is metadata handed to an existing +identity, not a second identity namespace. + +### The store is an overlay keyed by wire address + +`helpers.storage.Store` at `span_panel.curation.{entry_id}`, one per config entry for the same reason `additions.py` has one: two panels in one house curate +independently. The stored shape is `{"records": {key: {field: value}}}`, and a record holds only what the user asserted — a missing field means the adopted +default, and a missing key means the row was never curated at all. + +The keys are scope-prefixed wire addresses rather than `unique_id`s: + +| Half | Curation key | Built by | +| ----------------------------------- | -------------------------------- | ------------------------ | +| Vendor reading on a modelled device | `{scope}/{node}/{property}` | `extension_curation_key` | +| Entity on an adopted device | `{identifier}/{node}/{property}` | `adopted_curation_key` | + +The prefix is what makes a key injective: `path` is `{node}/{property}` on both models and is unique only within one device. The two namespaces cannot collide, +because only an adopted identifier carries the `_adopted_` token. Neither key goes through `get_user_friendly_suffix`, which is what makes `adopted_unique_id` +[deliberately non-injective](#the-device-level-grammar-is-not-injective-and-the-collision-is-caught) — keying the store on a `unique_id` would have inherited +that collision and let one record reach two wire addresses. + +**A record asserting nothing clears its key rather than being stored.** Its stored form is `{}`, which `parse_record` refuses, so writing it would leave a +record on disk that the next load reports as unreadable — the warning meant for a damaged or hand-edited store — and the save after that would delete, over a +value the signature accepts. Save may not write what load rejects. + +Records are never pruned. One whose wire path stops being published goes inert rather than being deleted, which is the same "the integration never decides a +row's fate" stance the rest of adoption takes. The whole store does go when the config entry is removed (`async_forget_curation`), and that one is deliberate: +the keys are wire addresses rather than registry ids, so a store left behind is one the next entry for the same panel would load and apply, re-asserting +metadata the user removed the panel to be rid of. + +### Validation refuses at save, and runs again at construction + +`validate_record` refuses rather than warns, because a stored record is applied unattended at every future setup — a warning would be read once, by nobody. +Everything decidable without the wire is decided in the websocket schema instead (enum membership, the one storable `entity_category`, the key's charset), so +only cross-field questions reach the validator: a `state_class` needs a sensor row with a numeric datatype, a `device_class` must belong to its platform's enum +and must admit the unit the row declares (through Core's own `DEVICE_CLASS_UNITS`), and a control row accepts prominence and nothing else. + +The same validator runs again at construction, where it drops rather than refuses. A record can go stale between the save and a later setup — the vendor may +change a row's unit or datatype — so `sanitise` re-measures each field independently, keeps the ones that still validate, and `CurationOverlay.for_row` emits +one warning naming what it dropped. It never raises: curation must not be able to block setup. It never deletes either, because the wire may revert and the +user's other assertions are still good. + +That is also why the list command reports a record **as stored** rather than as it would be applied, beside a `stale_fields` list naming the difference. +Reporting the sanitised form would show the user an assertion they never made and hide that theirs was dropped. + +### The description helpers are the only place `state_class` is spelled + +`adoption.py` and `extension.py` each carry an AST guard asserting the token appears nowhere in them — not as a keyword, not as an `_attr_state_class` target, +not as a `SensorStateClass` name. Both stay true while their entities carry curated state classes, because neither module builds its own description: both call +`curation.sensor_description`, which takes the wire path, the declared unit, the `DEVICE_CLASS_BY_UNIT` default and the record, and returns the +`SensorEntityDescription` the entity is constructed from. `binary_sensor_device_class` and `entity_category_for` do the same job for the other two fields. + +This ends up stricter than the design asked for. The plan was to relax the adoption guard into "the keyword is permitted when its value comes from the curation +interface"; routing through a helper meant it did not have to relax at all, and the guard newly added for `extension.py` could be the same absolute form rather +than a weaker one. A guard that admits one shape of exception is a guard somebody has to re-read before trusting. + +### The two commands write no registry state + +`websocket_adopted.py` defines `span_panel/adopted/list` and `span_panel/adopted/curate`, and `websocket.py`'s `async_register_commands` registers them beside +`panel_topology` — the import runs that way and only that way, so no cycle can appear as further commands join. Both are `@require_admin`, both take the main +panel's device registry id, and both answer `panel_topology`'s error codes from the same resolution — a consumer that learned one set does not meet a second. +[websocket-api.md](websocket-api.md) is the wire contract; what matters here is the boundary. + +**Enabling is Core's act, and so are naming, icons, areas, display units and precision.** `config/entity_registry/update` already exposes all of them, already +requires admin and already carries the undo, so duplicating any of it here would mean two writers for one field and no rule about which wins. What is left over +is exactly what Core has nowhere to put — a state class, a device class and prominence for an entity built from a vendor declaration — and that is the whole of +what `curate` stores. `entity_category` is the interesting one: it _is_ a registry column, but it is absent from that websocket's schema, which is why promotion +has to come from us. + +Both commands derive their rows through one function, `_rows`, using the same helpers the entity builders use — `resolve_identifier` and `classify` for an +adopted device, `adoptable` and `resolve_platform` for a vendor reading. A second derivation would let the editor disagree with the entities it edits: offering +a state class for a row that is really a control, or a key `curate` cannot resolve. `curate` re-derives rather than trusting the key it was handed, because the +store is keyed on wire addresses and a key nothing publishes would be held forever — read by no entity and shown on no list. + +`_rows` inherits the adopted-device collision rule as a **skip** rather than a listing. A row whose `unique_id` was claimed by a lexically earlier wire path is +left out entirely, because `entity_id` resolves by (platform, `unique_id`): listing it would report the _winner's_ entity beside the loser's curation key, +inviting a record saved against an entity that will never read it under a live `entity_id` saying it will. `_create` already warns and names both addresses, so +the skip is silent. + +### The reload is the mechanism, not a courtesy + +A save has exactly three effects: the record is written, the entry is scheduled for reload, and the result is returned. The reload is the half that reads as +politeness and is not. An entity description is fixed when the entity is constructed, so a record reaches its entity only by that entity being built again — and +being built _with_ it, because a `state_class` that first appears after states have been recorded is a statistics reset rather than a metadata change. + +The response also carries advisory `warnings`, which are consequences of a save rather than objections to it: the write has already happened, and the user asked +for it. `statistics_removed` fires on what a save _leaves_ rather than on how it was spelled — a record narrowed to its other fields drops a state class exactly +as clearing the whole record does — and names Core's answer to that, which is to raise its `state_class_removed` repair and stop compiling statistics for the +entity. Statistics already collected are not deleted. `total_increasing` is the other warning, and it reinterprets a reading rather than describing it: +`sensor/recorder.py`'s `reset_detected` reads a drop of more than a tenth as a meter reset, so a reading that legitimately falls manufactures consumption. + ## Runtime data lives in `runtime.py`, and one helper answers for it `SpanPanelRuntimeData` and `SpanPanelConfigEntry` used to live in `__init__.py`, which imports every platform — so naming the entry type meant importing the From 444e3accf0ba60cf8d726028d0bad6f140172190 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:26:28 -0700 Subject: [PATCH 244/345] security(discovery): probe a pinned entry's configured host over its pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Supervisor-discovery host check reads the serial that decides whether the entry moves, and it read it over plaintext with the entry's anchor sitting unused — the one probe of a configured host that did not ride the pin. Since span-panel-api 3.4.1 exempts the status endpoint from the plaintext warning, nothing in the log would say so either; the probe now takes the entry's own transport, on the add-on's newly published TLS port when it published one, because a reallocated port is the very case this probe exists for. Unpinned entries keep the plaintext probe they always had. (Direct checks run in lieu of the worktree-incompatible uv hooks: pytest 1676 passed, ruff, mypy all clean.) --- custom_components/span_panel/config_flow.py | 24 +++++++-- tests/test_v2_config_flow.py | 58 +++++++++++++++++++++ 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/custom_components/span_panel/config_flow.py b/custom_components/span_panel/config_flow.py index 75c76ec0..e5edbe22 100644 --- a/custom_components/span_panel/config_flow.py +++ b/custom_components/span_panel/config_flow.py @@ -408,9 +408,27 @@ async def _async_hassio_host_update(self, host: str, port: int, serial: str) -> if not configured or configured == host: return {CONF_HOST: host} - detection = await detect_api_version( - configured, port=port, httpx_client=get_async_client(self.hass, verify_ssl=False) - ) + # The identity read below decides whether the entry moves, and a pinned + # entry owes that read its own transport — since 3.4.1 the library's + # plaintext warning is silent for this endpoint, so nothing else would + # say the serial arrived unverified. The TLS port follows the same rule + # as the plaintext one: the add-on's newly published port when it + # published one, because a reallocated port is the very case this probe + # exists for, else the transport's own. 80 is the one value the library + # refuses under a context, so it falls back to the TLS default rather + # than raising out of a background discovery. + transport = panel_rest_transport(self.hass, entry.data) + if transport.ssl_context is not None: + probe_port = self._https_port if self._https_port_known else transport.port + if probe_port == 80: + probe_port = DEFAULT_HTTPS_PORT + detection = await detect_api_version( + configured, port=probe_port, ssl_context=transport.ssl_context + ) + else: + detection = await detect_api_version( + configured, port=port, httpx_client=get_async_client(self.hass, verify_ssl=False) + ) reached = ( detection.api_version == "v2" and detection.status_info is not None diff --git a/tests/test_v2_config_flow.py b/tests/test_v2_config_flow.py index 570ab7ab..659843a3 100644 --- a/tests/test_v2_config_flow.py +++ b/tests/test_v2_config_flow.py @@ -3350,3 +3350,61 @@ async def test_the_https_port_step_refuses_the_plaintext_port( assert refused["type"] == FlowResultType.FORM assert refused["step_id"] == "panel_https_port" assert refused["errors"] == {"base": "https_port_is_plaintext"} + + +@pytest.mark.asyncio +async def test_hassio_probes_a_pinned_entry_over_its_pin(hass: HomeAssistant) -> None: + """The identity read that decides a host move must not travel plaintext under a pin. + + The probe reads the serial out of the status answer, and 3.4.1's warning + exemption means nothing in the log says it went unverified — so the probe + owes a pinned entry its own transport. On the add-on's newly published TLS + port, because a reallocated port is the very case this probe exists for. + """ + _hassio_configured_entry(hass, **{CONF_PANEL_CA_PEM: FAKE_CA_PEM}) + context = MagicMock(name="pinned-context") + probe = AsyncMock(side_effect=_detection_by_host("192.168.1.50", "192.168.1.40")) + + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=context, + ), + patch("custom_components.span_panel.config_flow.detect_api_version", probe), + ): + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=_hassio_service_info({**MOCK_HASSIO_CONFIG, "https_port": 10090}), + ) + + call = next(c for c in probe.call_args_list if c.args[0] == "192.168.1.40") + assert call.kwargs["ssl_context"] is context + assert call.kwargs["port"] == 10090 + + +@pytest.mark.asyncio +async def test_hassio_pinned_probe_takes_the_stored_tls_port_when_none_is_published( + hass: HomeAssistant, +) -> None: + """No published TLS port means the entry's own is the only address for the pin.""" + _hassio_configured_entry(hass, **{CONF_PANEL_CA_PEM: FAKE_CA_PEM, CONF_HTTPS_PORT: 8443}) + context = MagicMock(name="pinned-context") + probe = AsyncMock(side_effect=_detection_by_host("192.168.1.50", "192.168.1.40")) + + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=context, + ), + patch("custom_components.span_panel.config_flow.detect_api_version", probe), + ): + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=_hassio_service_info(MOCK_HASSIO_CONFIG), + ) + + call = next(c for c in probe.call_args_list if c.args[0] == "192.168.1.40") + assert call.kwargs["ssl_context"] is context + assert call.kwargs["port"] == 8443 From 725f117ba9e50fe6f148d1266295936b9980e9ca Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:26:50 -0700 Subject: [PATCH 245/345] feat(curation): ship the Adopted tab frontend --- .../frontend/dist/span-panel-card.js | 36 ++++++++-------- .../span_panel/frontend/dist/span-panel.js | 42 +++++++++---------- frontend.md | 27 ++++++++++++ 3 files changed, 66 insertions(+), 39 deletions(-) diff --git a/custom_components/span_panel/frontend/dist/span-panel-card.js b/custom_components/span_panel/frontend/dist/span-panel-card.js index 1e875aac..702096de 100644 --- a/custom_components/span_panel/frontend/dist/span-panel-card.js +++ b/custom_components/span_panel/frontend/dist/span-panel-card.js @@ -1,26 +1,26 @@ -let t="en";const e={en:{"tab.panel":"Panel","tab.by_panel":"By Panel","tab.by_activity":"By Activity","tab.by_area":"By Area","tab.monitoring":"Monitoring","tab.settings":"Settings","list.search_placeholder":"Search circuits...","list.unassigned_area":"Unassigned","list.no_results":"No circuits found","monitoring.heading":"Monitoring","monitoring.global_settings":"Global Settings","monitoring.enabled":"Enabled","monitoring.continuous":"Continuous (%)","monitoring.spike":"Spike (%)","monitoring.window":"Window (min)","monitoring.cooldown":"Cooldown (min)","monitoring.monitored_points":"Monitored Points","monitoring.col.name":"Name","monitoring.col.continuous":"Continuous","monitoring.col.spike":"Spike","monitoring.col.window":"Window","monitoring.col.cooldown":"Cooldown","monitoring.all_none":"All / None","monitoring.reset":"Reset","notification.heading":"Notification Settings","notification.targets":"Notify Targets","notification.none_selected":"None selected","notification.no_targets":"No notify targets found","notification.all_targets":"All","notification.event_bus_target":"Event Bus (HA event bus)","notification.priority":"Priority","notification.priority.default":"Default","notification.priority.passive":"Passive","notification.priority.active":"Active","notification.priority.time_sensitive":"Time-sensitive","notification.priority.critical":"Critical","notification.hint.critical":"Overrides silent/DND","notification.hint.time_sensitive":"Breaks through Focus","notification.hint.passive":"Delivers silently","notification.hint.active":"Standard delivery","notification.title_template":"Title Template","notification.message_template":"Message Template","notification.placeholders":"Placeholders:","notification.event_bus_help":"Event Bus fires event type","notification.event_bus_payload":"with payload:","notification.test_label":"Test Notification","notification.test_button":"Send Test","notification.test_sending":"Sending...","notification.test_sent":"Test notification sent","error.prefix":"Error:","error.failed_save":"Failed to save","error.failed":"Failed","error.panel_offline":"SPAN Panel unreachable","error.panel_reconnected":"SPAN Panel reconnected","error.panel_offline_named":"{name} unreachable","error.panel_reconnected_named":"{name} reconnected","error.discovery_failed":"Unable to connect to SPAN Panel","error.relay_failed":"Unable to toggle relay","error.shedding_failed":"Unable to update shedding priority","error.threshold_failed":"Unable to save threshold","error.graph_horizon_failed":"Unable to update graph time horizon","error.favorites_fetch_failed":"Unable to load favorites","error.favorites_toggle_failed":"Unable to update favorite","error.history_failed":"Unable to load historical data","error.monitoring_failed":"Unable to load monitoring status","error.graph_settings_failed":"Unable to load graph settings","error.areas_failed":"Area assignments may be out of sync","error.retry":"Retry","card.connecting":"Connecting to SPAN Panel...","settings.heading":"Settings","settings.description":"General integration settings (entity naming, device prefix, circuit numbers) are managed through the integration's options flow.","settings.open_link":"Open SPAN Panel Integration Settings","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Panel monitoring settings","header.graph_settings":"Graph time horizon settings","header.site":"Site","header.grid":"Grid","header.upstream":"Upstream","header.downstream":"Downstream","header.solar":"Solar","header.battery":"Battery","header.toggle_units":"Toggle Watts / Amps","header.enable_switches":"Enable Switches","header.switches_enabled":"Switches Enabled","grid.unknown":"Unknown","grid.configure":"Configure circuit","grid.configure_subdevice":"Configure device","grid.on":"On","grid.off":"Off","subdevice.ev_charger":"EV Charger","subdevice.battery":"Battery","subdevice.fallback":"Sub-device","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Power","sidepanel.graph_settings":"Graph Settings","sidepanel.global_defaults":"Global defaults for all circuits","sidepanel.favorites_subtitle":"Favorites","sidepanel.global_default":"Global Default","sidepanel.list_view_columns":"List View Columns","sidepanel.columns":"Columns","sidepanel.circuit_scales":"Circuit Graph Scales","sidepanel.subdevice_scales":"Sub-Device Graph Scales","sidepanel.reset_to_global":"Reset to global default","sidepanel.relay":"Relay","sidepanel.breaker":"Breaker","sidepanel.shedding_priority":"Shedding Priority","sidepanel.priority_label":"Priority","sidepanel.monitoring":"Monitoring","sidepanel.global":"Global","sidepanel.custom":"Custom","sidepanel.continuous_pct":"Continuous %","sidepanel.spike_pct":"Spike %","sidepanel.window_duration":"Window duration","sidepanel.cooldown":"Cooldown","sidepanel.favorite":"Favorite","sidepanel.save_to_favorites":"Save to favorites","panel.favorites":"Favorites","status.monitoring":"Monitoring","status.circuits":"circuits","status.mains":"mains","status.warning":"warning","status.warnings":"warnings","status.alert":"alert","status.alerts":"alerts","status.override":"override","status.overrides":"overrides","card.no_device":"Open the card editor and select your SPAN Panel device.","card.device_not_found":"Panel device not found. Check device_id in card config.","card.topology_error":"Topology response missing panel_size and no circuits found. Update the SPAN Panel integration.","card.panel_size_error":"Could not determine panel_size. No circuits found and no panel_size attribute. Update the SPAN Panel integration.","editor.panel_label":"SPAN Panel","editor.select_panel":"Select a panel...","editor.chart_window":"Chart time window","editor.days":"days","editor.hours":"hours","editor.minutes":"minutes","editor.chart_metric":"Chart metric","editor.visible_sections":"Visible sections","editor.panel_circuits":"Panel circuits","editor.battery_bess":"Battery (BESS)","editor.ev_charger_evse":"EV Charger (EVSE)","editor.tab_style":"Tab Style","editor.tab_style_text":"Text","editor.tab_style_icon":"Icon","metric.power":"Power","metric.current":"Current","metric.soc":"State of Charge","metric.soe":"State of Energy","shedding.always_on":"Critical","shedding.never":"Non-sheddable","shedding.soc_threshold":"SoC Threshold","shedding.off_grid":"Sheddable","shedding.unknown":"Unknown","shedding.select.never":"Stays on in an outage","shedding.select.soc_threshold":"Stays on until battery threshold","shedding.select.off_grid":"Turns off in an outage"},es:{"tab.panel":"Panel","tab.by_panel":"Por Panel","tab.by_activity":"Por Actividad","tab.by_area":"Por Área","tab.monitoring":"Monitoreo","tab.settings":"Configuración","list.search_placeholder":"Buscar circuitos...","list.unassigned_area":"Sin asignar","list.no_results":"No se encontraron circuitos","monitoring.heading":"Monitoreo","monitoring.global_settings":"Configuración Global","monitoring.enabled":"Activado","monitoring.continuous":"Continuo (%)","monitoring.spike":"Pico (%)","monitoring.window":"Ventana (min)","monitoring.cooldown":"Enfriamiento (min)","monitoring.monitored_points":"Puntos Monitoreados","monitoring.col.name":"Nombre","monitoring.col.continuous":"Continuo","monitoring.col.spike":"Pico","monitoring.col.window":"Ventana","monitoring.col.cooldown":"Enfriamiento","monitoring.all_none":"Todos / Ninguno","monitoring.reset":"Restablecer","notification.heading":"Configuración de Notificaciones","notification.targets":"Destinos de Notificación","notification.none_selected":"Ninguno seleccionado","notification.no_targets":"No se encontraron destinos de notificación","notification.all_targets":"Todos","notification.event_bus_target":"Bus de Eventos (bus de eventos de HA)","notification.priority":"Prioridad","notification.priority.default":"Predeterminado","notification.priority.passive":"Pasivo","notification.priority.active":"Activo","notification.priority.time_sensitive":"Urgente","notification.priority.critical":"Crítico","notification.hint.critical":"Anula silencio/No molestar","notification.hint.time_sensitive":"Atraviesa el modo Concentración","notification.hint.passive":"Entrega silenciosa","notification.hint.active":"Entrega estándar","notification.title_template":"Plantilla de Título","notification.message_template":"Plantilla de Mensaje","notification.placeholders":"Variables:","notification.event_bus_help":"El Bus de Eventos dispara el tipo de evento","notification.event_bus_payload":"con datos:","notification.test_label":"Notificación de prueba","notification.test_button":"Enviar prueba","notification.test_sending":"Enviando...","notification.test_sent":"Notificación de prueba enviada","error.prefix":"Error:","error.failed_save":"Error al guardar","error.failed":"Falló","error.panel_offline":"SPAN Panel inaccesible","error.panel_reconnected":"SPAN Panel reconectado","error.panel_offline_named":"{name} inaccesible","error.panel_reconnected_named":"{name} reconectado","error.discovery_failed":"No se puede conectar al SPAN Panel","error.relay_failed":"No se pudo cambiar el relé","error.shedding_failed":"No se pudo actualizar la prioridad de desconexión","error.threshold_failed":"No se pudo guardar el umbral","error.graph_horizon_failed":"No se pudo actualizar el horizonte temporal del gráfico","error.favorites_fetch_failed":"No se pudieron cargar los favoritos","error.favorites_toggle_failed":"No se pudo actualizar el favorito","error.history_failed":"No se pudieron cargar los datos históricos","error.monitoring_failed":"No se pudo cargar el estado de monitoreo","error.graph_settings_failed":"No se pudo cargar la configuración del gráfico","error.areas_failed":"Las asignaciones de áreas pueden estar desincronizadas","error.retry":"Reintentar","card.connecting":"Conectando al SPAN Panel...","settings.heading":"Configuración","settings.description":"La configuración general de la integración (nombres de entidades, prefijo de dispositivo, números de circuito) se administra a través del flujo de opciones de la integración.","settings.open_link":"Abrir Configuración de Integración SPAN Panel","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Configuración de monitoreo del panel","header.graph_settings":"Configuración del horizonte temporal del gráfico","header.site":"Sitio","header.grid":"Red","header.upstream":"Aguas arriba","header.downstream":"Aguas abajo","header.solar":"Solar","header.battery":"Batería","header.toggle_units":"Alternar Watts / Amperios","header.enable_switches":"Habilitar Interruptores","header.switches_enabled":"Interruptores Habilitados","grid.unknown":"Desconocido","grid.configure":"Configurar circuito","grid.configure_subdevice":"Configurar dispositivo","grid.on":"Enc","grid.off":"Apag","subdevice.ev_charger":"Cargador EV","subdevice.battery":"Batería","subdevice.fallback":"Sub-dispositivo","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Potencia","sidepanel.graph_settings":"Configuración de Gráficos","sidepanel.global_defaults":"Valores predeterminados globales para todos los circuitos","sidepanel.favorites_subtitle":"Favoritos","sidepanel.global_default":"Predeterminado Global","sidepanel.list_view_columns":"Columnas de la lista","sidepanel.columns":"Columnas","sidepanel.circuit_scales":"Escalas de Gráficos de Circuitos","sidepanel.subdevice_scales":"Escalas de Gráficos de Sub-Dispositivos","sidepanel.reset_to_global":"Restablecer al valor global","sidepanel.relay":"Relé","sidepanel.breaker":"Interruptor","sidepanel.shedding_priority":"Prioridad de Desconexción","sidepanel.priority_label":"Prioridad","sidepanel.monitoring":"Monitoreo","sidepanel.global":"Global","sidepanel.custom":"Personalizado","sidepanel.continuous_pct":"Continuo %","sidepanel.spike_pct":"Pico %","sidepanel.window_duration":"Duración de ventana","sidepanel.cooldown":"Enfriamiento","sidepanel.favorite":"Favorito","sidepanel.save_to_favorites":"Guardar en favoritos","panel.favorites":"Favoritos","status.monitoring":"Monitoreo","status.circuits":"circuitos","status.mains":"alimentación","status.warning":"advertencia","status.warnings":"advertencias","status.alert":"alerta","status.alerts":"alertas","status.override":"anulación","status.overrides":"anulaciones","card.no_device":"Abra el editor de tarjeta y seleccione su dispositivo SPAN Panel.","card.device_not_found":"Dispositivo de panel no encontrado. Verifique device_id en la configuración de la tarjeta.","card.topology_error":"La respuesta de topología no contiene panel_size y no se encontraron circuitos. Actualice la integración SPAN Panel.","card.panel_size_error":"No se pudo determinar panel_size. No se encontraron circuitos ni atributo panel_size. Actualice la integración SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Seleccione un panel...","editor.chart_window":"Ventana de tiempo del gráfico","editor.days":"días","editor.hours":"horas","editor.minutes":"minutos","editor.chart_metric":"Métrica del gráfico","editor.visible_sections":"Secciones visibles","editor.panel_circuits":"Circuitos del panel","editor.battery_bess":"Batería (BESS)","editor.ev_charger_evse":"Cargador EV (EVSE)","editor.tab_style":"Estilo de pestañas","editor.tab_style_text":"Texto","editor.tab_style_icon":"Ícono","metric.power":"Potencia","metric.current":"Corriente","metric.soc":"Estado de Carga","metric.soe":"Estado de Energía","shedding.always_on":"Crítico","shedding.never":"No desconectable","shedding.soc_threshold":"Umbral SoC","shedding.off_grid":"Desconectable","shedding.unknown":"Desconocido","shedding.select.never":"Permanece encendido en un corte","shedding.select.soc_threshold":"Encendido hasta umbral de batería","shedding.select.off_grid":"Se apaga en un corte"},fr:{"tab.panel":"Panneau","tab.by_panel":"Par Panneau","tab.by_activity":"Par Activité","tab.by_area":"Par Zone","tab.monitoring":"Surveillance","tab.settings":"Paramètres","list.search_placeholder":"Rechercher des circuits...","list.unassigned_area":"Non attribué","list.no_results":"Aucun circuit trouvé","monitoring.heading":"Surveillance","monitoring.global_settings":"Paramètres Globaux","monitoring.enabled":"Activé","monitoring.continuous":"Continu (%)","monitoring.spike":"Pic (%)","monitoring.window":"Fenêtre (min)","monitoring.cooldown":"Refroidissement (min)","monitoring.monitored_points":"Points Surveillés","monitoring.col.name":"Nom","monitoring.col.continuous":"Continu","monitoring.col.spike":"Pic","monitoring.col.window":"Fenêtre","monitoring.col.cooldown":"Refroidissement","monitoring.all_none":"Tous / Aucun","monitoring.reset":"Réinitialiser","notification.heading":"Paramètres de Notification","notification.targets":"Cibles de Notification","notification.none_selected":"Aucune sélection","notification.no_targets":"Aucune cible de notification trouvée","notification.all_targets":"Tous","notification.event_bus_target":"Bus d'événements (bus d'événements HA)","notification.priority":"Priorité","notification.priority.default":"Par défaut","notification.priority.passive":"Passif","notification.priority.active":"Actif","notification.priority.time_sensitive":"Urgent","notification.priority.critical":"Critique","notification.hint.critical":"Outrepasse silencieux/NPD","notification.hint.time_sensitive":"Traverse le mode Concentration","notification.hint.passive":"Livraison silencieuse","notification.hint.active":"Livraison standard","notification.title_template":"Modèle de Titre","notification.message_template":"Modèle de Message","notification.placeholders":"Variables :","notification.event_bus_help":"Le Bus d'événements déclenche le type d'événement","notification.event_bus_payload":"avec les données :","notification.test_label":"Notification de test","notification.test_button":"Envoyer un test","notification.test_sending":"Envoi...","notification.test_sent":"Notification de test envoyée","error.prefix":"Erreur :","error.failed_save":"Échec de la sauvegarde","error.failed":"Échoué","error.panel_offline":"SPAN Panel inaccessible","error.panel_reconnected":"SPAN Panel reconnecté","error.panel_offline_named":"{name} inaccessible","error.panel_reconnected_named":"{name} reconnecté","error.discovery_failed":"Impossible de se connecter au SPAN Panel","error.relay_failed":"Impossible de basculer le relais","error.shedding_failed":"Impossible de mettre à jour la priorité de délestage","error.threshold_failed":"Impossible d'enregistrer le seuil","error.graph_horizon_failed":"Impossible de mettre à jour l'horizon temporel du graphique","error.favorites_fetch_failed":"Impossible de charger les favoris","error.favorites_toggle_failed":"Impossible de mettre à jour le favori","error.history_failed":"Impossible de charger les données historiques","error.monitoring_failed":"Impossible de charger l'état de surveillance","error.graph_settings_failed":"Impossible de charger les paramètres du graphique","error.areas_failed":"Les affectations de zones peuvent être désynchronisées","error.retry":"Réessayer","card.connecting":"Connexion au SPAN Panel...","settings.heading":"Paramètres","settings.description":"Les paramètres généraux de l'intégration (noms d'entités, préfixe de l'appareil, numéros de circuit) sont gérés via le flux d'options de l'intégration.","settings.open_link":"Ouvrir les Paramètres d'Intégration SPAN Panel","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Paramètres de surveillance du panneau","header.graph_settings":"Paramètres d'horizon temporel du graphique","header.site":"Site","header.grid":"Réseau","header.upstream":"Amont","header.downstream":"Aval","header.solar":"Solaire","header.battery":"Batterie","header.toggle_units":"Basculer Watts / Ampères","header.enable_switches":"Activer les interrupteurs","header.switches_enabled":"Interrupteurs activés","grid.unknown":"Inconnu","grid.configure":"Configurer le circuit","grid.configure_subdevice":"Configurer l'appareil","grid.on":"On","grid.off":"Off","subdevice.ev_charger":"Chargeur VE","subdevice.battery":"Batterie","subdevice.fallback":"Sous-appareil","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Puissance","sidepanel.graph_settings":"Paramètres des Graphiques","sidepanel.global_defaults":"Valeurs par défaut globales pour tous les circuits","sidepanel.favorites_subtitle":"Favoris","sidepanel.global_default":"Défaut Global","sidepanel.list_view_columns":"Colonnes de la liste","sidepanel.columns":"Colonnes","sidepanel.circuit_scales":"Échelles des Graphiques de Circuits","sidepanel.subdevice_scales":"Échelles des Graphiques de Sous-Appareils","sidepanel.reset_to_global":"Réinitialiser à la valeur globale","sidepanel.relay":"Relais","sidepanel.breaker":"Disjoncteur","sidepanel.shedding_priority":"Priorité de Délestage","sidepanel.priority_label":"Priorité","sidepanel.monitoring":"Surveillance","sidepanel.global":"Global","sidepanel.custom":"Personnalisé","sidepanel.continuous_pct":"Continu %","sidepanel.spike_pct":"Pic %","sidepanel.window_duration":"Durée de fenêtre","sidepanel.cooldown":"Refroidissement","sidepanel.favorite":"Favori","sidepanel.save_to_favorites":"Enregistrer dans les favoris","panel.favorites":"Favoris","status.monitoring":"Surveillance","status.circuits":"circuits","status.mains":"alimentation","status.warning":"avertissement","status.warnings":"avertissements","status.alert":"alerte","status.alerts":"alertes","status.override":"remplacement","status.overrides":"remplacements","card.no_device":"Ouvrez l'éditeur de carte et sélectionnez votre appareil SPAN Panel.","card.device_not_found":"Appareil de panneau introuvable. Vérifiez device_id dans la configuration de la carte.","card.topology_error":"La réponse de topologie ne contient pas panel_size et aucun circuit trouvé. Mettez à jour l'intégration SPAN Panel.","card.panel_size_error":"Impossible de déterminer panel_size. Aucun circuit trouvé et aucun attribut panel_size. Mettez à jour l'intégration SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Sélectionnez un panneau...","editor.chart_window":"Fenêtre de temps du graphique","editor.days":"jours","editor.hours":"heures","editor.minutes":"minutes","editor.chart_metric":"Métrique du graphique","editor.visible_sections":"Sections visibles","editor.panel_circuits":"Circuits du panneau","editor.battery_bess":"Batterie (BESS)","editor.ev_charger_evse":"Chargeur VE (EVSE)","editor.tab_style":"Style des onglets","editor.tab_style_text":"Texte","editor.tab_style_icon":"Icône","metric.power":"Puissance","metric.current":"Courant","metric.soc":"État de Charge","metric.soe":"État d'Énergie","shedding.always_on":"Critique","shedding.never":"Non délestable","shedding.soc_threshold":"Seuil SoC","shedding.off_grid":"Délestable","shedding.unknown":"Inconnu","shedding.select.never":"Reste allumé en cas de coupure","shedding.select.soc_threshold":"Allumé jusqu'au seuil batterie","shedding.select.off_grid":"S'éteint en cas de coupure"},ja:{"tab.panel":"パネル","tab.by_panel":"パネル別","tab.by_activity":"活動別","tab.by_area":"エリア別","tab.monitoring":"モニタリング","tab.settings":"設定","list.search_placeholder":"回路を検索...","list.unassigned_area":"未割り当て","list.no_results":"回路が見つかりません","monitoring.heading":"モニタリング","monitoring.global_settings":"グローバル設定","monitoring.enabled":"有効","monitoring.continuous":"継続 (%)","monitoring.spike":"スパイク (%)","monitoring.window":"ウィンドウ (分)","monitoring.cooldown":"クールダウン (分)","monitoring.monitored_points":"監視ポイント","monitoring.col.name":"名前","monitoring.col.continuous":"継続","monitoring.col.spike":"スパイク","monitoring.col.window":"ウィンドウ","monitoring.col.cooldown":"クールダウン","monitoring.all_none":"全選択 / 全解除","monitoring.reset":"リセット","notification.heading":"通知設定","notification.targets":"通知先","notification.none_selected":"未選択","notification.no_targets":"通知先が見つかりません","notification.all_targets":"すべて","notification.event_bus_target":"イベントバス (HAイベントバス)","notification.priority":"優先度","notification.priority.default":"デフォルト","notification.priority.passive":"パッシブ","notification.priority.active":"アクティブ","notification.priority.time_sensitive":"緊急","notification.priority.critical":"重大","notification.hint.critical":"サイレント/おやすみモードを無視","notification.hint.time_sensitive":"集中モードを突破","notification.hint.passive":"サイレント配信","notification.hint.active":"標準配信","notification.title_template":"タイトルテンプレート","notification.message_template":"メッセージテンプレート","notification.placeholders":"プレースホルダー:","notification.event_bus_help":"イベントバスが発行するイベントタイプ","notification.event_bus_payload":"ペイロード:","notification.test_label":"テスト通知","notification.test_button":"テスト送信","notification.test_sending":"送信中...","notification.test_sent":"テスト通知を送信しました","error.prefix":"エラー:","error.failed_save":"保存に失敗","error.failed":"失敗","error.panel_offline":"SPANパネルに接続できません","error.panel_reconnected":"SPANパネルが再接続されました","error.panel_offline_named":"{name}に接続できません","error.panel_reconnected_named":"{name}が再接続されました","error.discovery_failed":"SPANパネルへの接続に失敗しました","error.relay_failed":"リレーの切り替えに失敗しました","error.shedding_failed":"シェディング優先度の更新に失敗しました","error.threshold_failed":"しきい値の保存に失敗しました","error.graph_horizon_failed":"グラフの時間範囲の更新に失敗しました","error.favorites_fetch_failed":"お気に入りの読み込みに失敗しました","error.favorites_toggle_failed":"お気に入りの更新に失敗しました","error.history_failed":"履歴データの読み込みに失敗しました","error.monitoring_failed":"監視ステータスの読み込みに失敗しました","error.graph_settings_failed":"グラフ設定の読み込みに失敗しました","error.areas_failed":"エリア割り当てが同期されていない可能性があります","error.retry":"再試行","card.connecting":"SPANパネルに接続中...","settings.heading":"設定","settings.description":"統合の一般設定(エンティティ名、デバイスプレフィックス、回路番号)は統合のオプションフローで管理されます。","settings.open_link":"SPAN Panel統合設定を開く","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"パネルモニタリング設定","header.graph_settings":"グラフ時間範囲設定","header.site":"サイト","header.grid":"グリッド","header.upstream":"上流","header.downstream":"下流","header.solar":"ソーラー","header.battery":"バッテリー","header.toggle_units":"ワット/アンペア切り替え","header.enable_switches":"スイッチを有効化","header.switches_enabled":"スイッチ有効","grid.unknown":"不明","grid.configure":"回路を設定","grid.configure_subdevice":"デバイスを設定","grid.on":"オン","grid.off":"オフ","subdevice.ev_charger":"EV充電器","subdevice.battery":"バッテリー","subdevice.fallback":"サブデバイス","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"電力","sidepanel.graph_settings":"グラフ設定","sidepanel.global_defaults":"全回路のグローバルデフォルト","sidepanel.favorites_subtitle":"お気に入り","sidepanel.global_default":"グローバルデフォルト","sidepanel.list_view_columns":"リスト表示の列数","sidepanel.columns":"列","sidepanel.circuit_scales":"回路グラフスケール","sidepanel.subdevice_scales":"サブデバイスグラフスケール","sidepanel.reset_to_global":"グローバルにリセット","sidepanel.relay":"リレー","sidepanel.breaker":"ブレーカー","sidepanel.shedding_priority":"シェディング優先度","sidepanel.priority_label":"優先度","sidepanel.monitoring":"モニタリング","sidepanel.global":"グローバル","sidepanel.custom":"カスタム","sidepanel.continuous_pct":"継続 %","sidepanel.spike_pct":"スパイク %","sidepanel.window_duration":"ウィンドウ時間","sidepanel.cooldown":"クールダウン","sidepanel.favorite":"お気に入り","sidepanel.save_to_favorites":"お気に入りに保存","panel.favorites":"お気に入り","status.monitoring":"モニタリング","status.circuits":"回路","status.mains":"主電源","status.warning":"警告","status.warnings":"警告","status.alert":"アラート","status.alerts":"アラート","status.override":"上書き","status.overrides":"上書き","card.no_device":"カードエディタを開いてSPAN Panelデバイスを選択してください。","card.device_not_found":"パネルデバイスが見つかりません。カード設定のdevice_idを確認してください。","card.topology_error":"トポロジー応答にpanel_sizeがなく、回路が見つかりません。SPAN Panel統合を更新してください。","card.panel_size_error":"panel_sizeを判定できません。回路がpanel_size属性が見つかりません。SPAN Panel統合を更新してください。","editor.panel_label":"SPAN Panel","editor.select_panel":"パネルを選択...","editor.chart_window":"グラフ時間ウィンドウ","editor.days":"日","editor.hours":"時間","editor.minutes":"分","editor.chart_metric":"グラフ指標","editor.visible_sections":"表示セクション","editor.panel_circuits":"パネル回路","editor.battery_bess":"バッテリー (BESS)","editor.ev_charger_evse":"EV充電器 (EVSE)","editor.tab_style":"タブスタイル","editor.tab_style_text":"テキスト","editor.tab_style_icon":"アイコン","metric.power":"電力","metric.current":"電流","metric.soc":"充電状態","metric.soe":"エネルギー状態","shedding.always_on":"重要","shedding.never":"切断不可","shedding.soc_threshold":"SoCしきい値","shedding.off_grid":"切断可能","shedding.unknown":"不明","shedding.select.never":"停電時もオンを維持","shedding.select.soc_threshold":"バッテリーしきい値までオン","shedding.select.off_grid":"停電時にオフ"},pt:{"tab.panel":"Painel","tab.by_panel":"Por Painel","tab.by_activity":"Por Atividade","tab.by_area":"Por Área","tab.monitoring":"Monitoramento","tab.settings":"Configurações","list.search_placeholder":"Pesquisar circuitos...","list.unassigned_area":"Não atribuído","list.no_results":"Nenhum circuito encontrado","monitoring.heading":"Monitoramento","monitoring.global_settings":"Configurações Globais","monitoring.enabled":"Ativado","monitoring.continuous":"Contínuo (%)","monitoring.spike":"Pico (%)","monitoring.window":"Janela (min)","monitoring.cooldown":"Resfriamento (min)","monitoring.monitored_points":"Pontos Monitorados","monitoring.col.name":"Nome","monitoring.col.continuous":"Contínuo","monitoring.col.spike":"Pico","monitoring.col.window":"Janela","monitoring.col.cooldown":"Resfriamento","monitoring.all_none":"Todos / Nenhum","monitoring.reset":"Redefinir","notification.heading":"Configurações de Notificação","notification.targets":"Destinos de Notificação","notification.none_selected":"Nenhum selecionado","notification.no_targets":"Nenhum destino de notificação encontrado","notification.all_targets":"Todos","notification.event_bus_target":"Barramento de Eventos (barramento de eventos do HA)","notification.priority":"Prioridade","notification.priority.default":"Padrão","notification.priority.passive":"Passivo","notification.priority.active":"Ativo","notification.priority.time_sensitive":"Urgente","notification.priority.critical":"Crítico","notification.hint.critical":"Substitui silencioso/Não perturbar","notification.hint.time_sensitive":"Atravessa o modo Foco","notification.hint.passive":"Entrega silenciosa","notification.hint.active":"Entrega padrão","notification.title_template":"Modelo de Título","notification.message_template":"Modelo de Mensagem","notification.placeholders":"Variáveis:","notification.event_bus_help":"O Barramento de Eventos dispara o tipo de evento","notification.event_bus_payload":"com dados:","notification.test_label":"Notificação de teste","notification.test_button":"Enviar teste","notification.test_sending":"Enviando...","notification.test_sent":"Notificação de teste enviada","error.prefix":"Erro:","error.failed_save":"Falha ao salvar","error.failed":"Falhou","error.panel_offline":"SPAN Panel inacessível","error.panel_reconnected":"SPAN Panel reconectado","error.panel_offline_named":"{name} inacessível","error.panel_reconnected_named":"{name} reconectado","error.discovery_failed":"Não foi possível conectar ao SPAN Panel","error.relay_failed":"Não foi possível alternar o relé","error.shedding_failed":"Não foi possível atualizar a prioridade de desligamento","error.threshold_failed":"Não foi possível salvar o limite","error.graph_horizon_failed":"Não foi possível atualizar o horizonte temporal do gráfico","error.favorites_fetch_failed":"Não foi possível carregar os favoritos","error.favorites_toggle_failed":"Não foi possível atualizar o favorito","error.history_failed":"Não foi possível carregar os dados históricos","error.monitoring_failed":"Não foi possível carregar o status de monitoramento","error.graph_settings_failed":"Não foi possível carregar as configurações do gráfico","error.areas_failed":"As atribuições de áreas podem estar fora de sincronização","error.retry":"Tentar novamente","card.connecting":"Conectando ao SPAN Panel...","settings.heading":"Configurações","settings.description":"As configurações gerais da integração (nomes de entidades, prefixo do dispositivo, números de circuito) são gerenciadas através do fluxo de opções da integração.","settings.open_link":"Abrir Configurações de Integração SPAN Panel","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Configurações de monitoramento do painel","header.graph_settings":"Configurações do horizonte temporal do gráfico","header.site":"Local","header.grid":"Rede","header.upstream":"Montante","header.downstream":"Jusante","header.solar":"Solar","header.battery":"Bateria","header.toggle_units":"Alternar Watts / Amperes","header.enable_switches":"Ativar Interruptores","header.switches_enabled":"Interruptores Ativados","grid.unknown":"Desconhecido","grid.configure":"Configurar circuito","grid.configure_subdevice":"Configurar dispositivo","grid.on":"Lig","grid.off":"Des","subdevice.ev_charger":"Carregador VE","subdevice.battery":"Bateria","subdevice.fallback":"Sub-dispositivo","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Potência","sidepanel.graph_settings":"Configurações de Gráficos","sidepanel.global_defaults":"Padrões globais para todos os circuitos","sidepanel.favorites_subtitle":"Favoritos","sidepanel.global_default":"Padrão Global","sidepanel.list_view_columns":"Colunas da Lista","sidepanel.columns":"Colunas","sidepanel.circuit_scales":"Escalas de Gráficos de Circuitos","sidepanel.subdevice_scales":"Escalas de Gráficos de Sub-Dispositivos","sidepanel.reset_to_global":"Redefinir para o padrão global","sidepanel.relay":"Relé","sidepanel.breaker":"Disjuntor","sidepanel.shedding_priority":"Prioridade de Desligamento","sidepanel.priority_label":"Prioridade","sidepanel.monitoring":"Monitoramento","sidepanel.global":"Global","sidepanel.custom":"Personalizado","sidepanel.continuous_pct":"Contínuo %","sidepanel.spike_pct":"Pico %","sidepanel.window_duration":"Duração da janela","sidepanel.cooldown":"Resfriamento","sidepanel.favorite":"Favorito","sidepanel.save_to_favorites":"Salvar nos favoritos","panel.favorites":"Favoritos","status.monitoring":"Monitoramento","status.circuits":"circuitos","status.mains":"alimentação","status.warning":"aviso","status.warnings":"avisos","status.alert":"alerta","status.alerts":"alertas","status.override":"substituição","status.overrides":"substituições","card.no_device":"Abra o editor do cartão e selecione seu dispositivo SPAN Panel.","card.device_not_found":"Dispositivo do painel não encontrado. Verifique device_id na configuração do cartão.","card.topology_error":"A resposta de topologia não contém panel_size e nenhum circuito encontrado. Atualize a integração SPAN Panel.","card.panel_size_error":"Não foi possível determinar panel_size. Nenhum circuito encontrado e nenhum atributo panel_size. Atualize a integração SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Selecione um painel...","editor.chart_window":"Janela de tempo do gráfico","editor.days":"dias","editor.hours":"horas","editor.minutes":"minutos","editor.chart_metric":"Métrica do gráfico","editor.visible_sections":"Seções visíveis","editor.panel_circuits":"Circuitos do painel","editor.battery_bess":"Bateria (BESS)","editor.ev_charger_evse":"Carregador VE (EVSE)","editor.tab_style":"Estilo das abas","editor.tab_style_text":"Texto","editor.tab_style_icon":"Ícone","metric.power":"Potência","metric.current":"Corrente","metric.soc":"Estado de Carga","metric.soe":"Estado de Energia","shedding.always_on":"Crítico","shedding.never":"Não desligável","shedding.soc_threshold":"Limite SoC","shedding.off_grid":"Desligável","shedding.unknown":"Desconhecido","shedding.select.never":"Permanece ligado em uma queda","shedding.select.soc_threshold":"Ligado até limite da bateria","shedding.select.off_grid":"Desliga em uma queda"}};function n(n){t=n&&e[n]?n:"en"}function i(n){return e[t]?.[n]??e.en?.[n]??n}function r(n,i){return(e[t]?.[n]??e.en?.[n]??n).replace(/\{(\w+)\}/g,(t,e)=>Object.prototype.hasOwnProperty.call(i,e)?i[e]:`{${e}}`)}const o="power",a="5m",s={"5m":{ms:3e5,refreshMs:1e3,useRealtime:!0},"1h":{ms:36e5,refreshMs:3e4,useRealtime:!1},"1d":{ms:864e5,refreshMs:6e4,useRealtime:!1},"1w":{ms:6048e5,refreshMs:6e4,useRealtime:!1},"1M":{ms:2592e6,refreshMs:6e4,useRealtime:!1}},l="span_panel",c="CLOSED",u="pv",h="bess",d="evse",p="sub_",f=500,g={power:{entityRole:"power",label:()=>i("metric.power"),unit:t=>Math.abs(t)>=1e3?"kW":"W",format:t=>{const e=Math.abs(t);return e>=1e3?(e/1e3).toFixed(1):e<10&&e>0?e.toFixed(1):String(Math.round(e))}},current:{entityRole:"current",label:()=>i("metric.current"),unit:()=>"A",format:t=>Math.abs(t).toFixed(1)}},v={soc:{entityRole:"soc",label:()=>i("metric.soc"),unit:()=>"%",format:t=>String(Math.round(t)),fixedMin:0,fixedMax:100},soe:{entityRole:"soe",label:()=>i("metric.soe"),unit:()=>"kWh",format:t=>t.toFixed(1)},power:g.power},m={always_on:{icon:"mdi:battery",icon2:"mdi:router-wireless",color:"#4caf50",label:()=>i("shedding.always_on")},never:{icon:"mdi:battery",color:"#4caf50",label:()=>i("shedding.never")},soc_threshold:{icon:"mdi:battery-alert-variant-outline",color:"#9c27b0",label:()=>i("shedding.soc_threshold"),textLabel:"SoC"},off_grid:{icon:"mdi:transmission-tower",color:"#ff9800",label:()=>i("shedding.off_grid")},unknown:{icon:"mdi:help-circle-outline",color:"#888",label:()=>i("shedding.unknown")}};var y=function(t,e){return y=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&(t[n]=e[n])},y(t,e)};function _(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function n(){this.constructor=t}y(t,e),t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)}function x(t,e,n,i){var r,o=arguments.length,a=o<3?e:null===i?i=Object.getOwnPropertyDescriptor(e,n):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)a=Reflect.decorate(t,e,n,i);else for(var s=t.length-1;s>=0;s--)(r=t[s])&&(a=(o<3?r(a):o>3?r(e,n,a):r(e,n))||a);return o>3&&a&&Object.defineProperty(e,n,a),a}"function"==typeof SuppressedError&&SuppressedError; +let t="en";const e={en:{"tab.panel":"Panel","tab.by_panel":"By Panel","tab.by_activity":"By Activity","tab.by_area":"By Area","tab.monitoring":"Monitoring","tab.settings":"Settings","tab.adopted":"Adopted","list.search_placeholder":"Search circuits...","list.unassigned_area":"Unassigned","list.no_results":"No circuits found","monitoring.heading":"Monitoring","monitoring.global_settings":"Global Settings","monitoring.enabled":"Enabled","monitoring.continuous":"Continuous (%)","monitoring.spike":"Spike (%)","monitoring.window":"Window (min)","monitoring.cooldown":"Cooldown (min)","monitoring.monitored_points":"Monitored Points","monitoring.col.name":"Name","monitoring.col.continuous":"Continuous","monitoring.col.spike":"Spike","monitoring.col.window":"Window","monitoring.col.cooldown":"Cooldown","monitoring.all_none":"All / None","monitoring.reset":"Reset","notification.heading":"Notification Settings","notification.targets":"Notify Targets","notification.none_selected":"None selected","notification.no_targets":"No notify targets found","notification.all_targets":"All","notification.event_bus_target":"Event Bus (HA event bus)","notification.priority":"Priority","notification.priority.default":"Default","notification.priority.passive":"Passive","notification.priority.active":"Active","notification.priority.time_sensitive":"Time-sensitive","notification.priority.critical":"Critical","notification.hint.critical":"Overrides silent/DND","notification.hint.time_sensitive":"Breaks through Focus","notification.hint.passive":"Delivers silently","notification.hint.active":"Standard delivery","notification.title_template":"Title Template","notification.message_template":"Message Template","notification.placeholders":"Placeholders:","notification.event_bus_help":"Event Bus fires event type","notification.event_bus_payload":"with payload:","notification.test_label":"Test Notification","notification.test_button":"Send Test","notification.test_sending":"Sending...","notification.test_sent":"Test notification sent","error.prefix":"Error:","error.failed_save":"Failed to save","error.failed":"Failed","error.panel_offline":"SPAN Panel unreachable","error.panel_reconnected":"SPAN Panel reconnected","error.panel_offline_named":"{name} unreachable","error.panel_reconnected_named":"{name} reconnected","error.discovery_failed":"Unable to connect to SPAN Panel","error.relay_failed":"Unable to toggle relay","error.shedding_failed":"Unable to update shedding priority","error.threshold_failed":"Unable to save threshold","error.graph_horizon_failed":"Unable to update graph time horizon","error.favorites_fetch_failed":"Unable to load favorites","error.favorites_toggle_failed":"Unable to update favorite","error.history_failed":"Unable to load historical data","error.monitoring_failed":"Unable to load monitoring status","error.graph_settings_failed":"Unable to load graph settings","error.areas_failed":"Area assignments may be out of sync","error.retry":"Retry","card.connecting":"Connecting to SPAN Panel...","settings.heading":"Settings","settings.description":"General integration settings (entity naming, device prefix, circuit numbers) are managed through the integration's options flow.","settings.open_link":"Open SPAN Panel Integration Settings","adopted.heading":"Adopted entities","adopted.description":"Vendor readings and adopted devices arrive with minimal metadata. Curate one to set its device class, statistics class, and prominence — saved changes reload the integration and apply from the next startup on.","adopted.filter_placeholder":"Filter by entity or device name","adopted.no_results":"No adopted entities match this filter","adopted.none":"This panel publishes nothing to curate.","adopted.load_failed":"Unable to load adopted entities","adopted.count":"{count} adopted","adopted.vendor_readings":"VENDOR READINGS","adopted.adopted_device":"ADOPTED DEVICE","adopted.via_panel":"via SPAN Panel","adopted.curated":"CURATED","adopted.stale":"STALE","adopted.stale_note":"The panel no longer supports what was saved for: {fields}","adopted.enable_entity":"Enable entity","adopted.enabled":"ENABLED","adopted.disabled":"DISABLED","adopted.enable_note":"Enabled when saved — curating never enables on its own","adopted.registry_unavailable":"Enable, name, and icon become available once this entity exists in the registry.","adopted.name":"Name","adopted.icon":"Icon","adopted.device_class":"Device class","adopted.device_class_note":"choices limited by the panel's unit ({unit})","adopted.device_class_note_unitless":"choices limited by what the panel publishes","adopted.no_device_class":"No device class","adopted.statistics_class":"Statistics class","adopted.statistics_note":"long-term statistics begin after the next reload","adopted.no_statistics":"No statistics","adopted.prominence":"Prominence","adopted.diagnostic":"Diagnostic","adopted.standard":"Standard","adopted.display_unit":"Display unit","adopted.unit_as_published":"As published","adopted.precision":"Precision","adopted.precision_default":"Default","adopted.read_only":"read-only","adopted.settable":"settable","adopted.save":"Save","adopted.clear":"Clear curation","adopted.reload_note":"Saving reloads the SPAN Panel integration","adopted.saved":"Saved — the integration is reloading","adopted.confirm_heading":"Confirm statistics class","adopted.confirm_setting":"You are setting {name} to {value}.","adopted.confirm_clearing_subject":"You are clearing the statistics class on {name}.","adopted.confirm_total_increasing":"Total increasing treats every drop in the value as a meter reset. If this reading can decrease for any other reason, long-term statistics will be permanently corrupted — fixing the class later does not repair history already written.","adopted.confirm_clearing":"Long-term statistics stop being compiled for this entity, and Home Assistant raises a repair against the statistics already collected under the class you are removing.","adopted.save_anyway":"Save anyway","adopted.cancel":"Cancel","adopted.warn_total_increasing":"Saved as total increasing — every drop in the reading now counts as a meter reset.","adopted.warn_statistics_removed":"This entity has no statistics class any more; Home Assistant will raise a repair against the statistics already collected.","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Panel monitoring settings","header.graph_settings":"Graph time horizon settings","header.site":"Site","header.grid":"Grid","header.upstream":"Upstream","header.downstream":"Downstream","header.solar":"Solar","header.battery":"Battery","header.toggle_units":"Toggle Watts / Amps","header.enable_switches":"Enable Switches","header.switches_enabled":"Switches Enabled","grid.unknown":"Unknown","grid.configure":"Configure circuit","grid.configure_subdevice":"Configure device","grid.on":"On","grid.off":"Off","subdevice.ev_charger":"EV Charger","subdevice.battery":"Battery","subdevice.fallback":"Sub-device","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Power","sidepanel.graph_settings":"Graph Settings","sidepanel.global_defaults":"Global defaults for all circuits","sidepanel.favorites_subtitle":"Favorites","sidepanel.global_default":"Global Default","sidepanel.list_view_columns":"List View Columns","sidepanel.columns":"Columns","sidepanel.circuit_scales":"Circuit Graph Scales","sidepanel.subdevice_scales":"Sub-Device Graph Scales","sidepanel.reset_to_global":"Reset to global default","sidepanel.relay":"Relay","sidepanel.breaker":"Breaker","sidepanel.shedding_priority":"Shedding Priority","sidepanel.priority_label":"Priority","sidepanel.monitoring":"Monitoring","sidepanel.global":"Global","sidepanel.custom":"Custom","sidepanel.continuous_pct":"Continuous %","sidepanel.spike_pct":"Spike %","sidepanel.window_duration":"Window duration","sidepanel.cooldown":"Cooldown","sidepanel.favorite":"Favorite","sidepanel.save_to_favorites":"Save to favorites","panel.favorites":"Favorites","status.monitoring":"Monitoring","status.circuits":"circuits","status.mains":"mains","status.warning":"warning","status.warnings":"warnings","status.alert":"alert","status.alerts":"alerts","status.override":"override","status.overrides":"overrides","card.no_device":"Open the card editor and select your SPAN Panel device.","card.device_not_found":"Panel device not found. Check device_id in card config.","card.topology_error":"Topology response missing panel_size and no circuits found. Update the SPAN Panel integration.","card.panel_size_error":"Could not determine panel_size. No circuits found and no panel_size attribute. Update the SPAN Panel integration.","editor.panel_label":"SPAN Panel","editor.select_panel":"Select a panel...","editor.chart_window":"Chart time window","editor.days":"days","editor.hours":"hours","editor.minutes":"minutes","editor.chart_metric":"Chart metric","editor.visible_sections":"Visible sections","editor.panel_circuits":"Panel circuits","editor.battery_bess":"Battery (BESS)","editor.ev_charger_evse":"EV Charger (EVSE)","editor.tab_style":"Tab Style","editor.tab_style_text":"Text","editor.tab_style_icon":"Icon","metric.power":"Power","metric.current":"Current","metric.soc":"State of Charge","metric.soe":"State of Energy","shedding.always_on":"Critical","shedding.never":"Non-sheddable","shedding.soc_threshold":"SoC Threshold","shedding.off_grid":"Sheddable","shedding.unknown":"Unknown","shedding.select.never":"Stays on in an outage","shedding.select.soc_threshold":"Stays on until battery threshold","shedding.select.off_grid":"Turns off in an outage"},es:{"tab.panel":"Panel","tab.by_panel":"Por Panel","tab.by_activity":"Por Actividad","tab.by_area":"Por Área","tab.monitoring":"Monitoreo","tab.settings":"Configuración","tab.adopted":"Adoptadas","list.search_placeholder":"Buscar circuitos...","list.unassigned_area":"Sin asignar","list.no_results":"No se encontraron circuitos","monitoring.heading":"Monitoreo","monitoring.global_settings":"Configuración Global","monitoring.enabled":"Activado","monitoring.continuous":"Continuo (%)","monitoring.spike":"Pico (%)","monitoring.window":"Ventana (min)","monitoring.cooldown":"Enfriamiento (min)","monitoring.monitored_points":"Puntos Monitoreados","monitoring.col.name":"Nombre","monitoring.col.continuous":"Continuo","monitoring.col.spike":"Pico","monitoring.col.window":"Ventana","monitoring.col.cooldown":"Enfriamiento","monitoring.all_none":"Todos / Ninguno","monitoring.reset":"Restablecer","notification.heading":"Configuración de Notificaciones","notification.targets":"Destinos de Notificación","notification.none_selected":"Ninguno seleccionado","notification.no_targets":"No se encontraron destinos de notificación","notification.all_targets":"Todos","notification.event_bus_target":"Bus de Eventos (bus de eventos de HA)","notification.priority":"Prioridad","notification.priority.default":"Predeterminado","notification.priority.passive":"Pasivo","notification.priority.active":"Activo","notification.priority.time_sensitive":"Urgente","notification.priority.critical":"Crítico","notification.hint.critical":"Anula silencio/No molestar","notification.hint.time_sensitive":"Atraviesa el modo Concentración","notification.hint.passive":"Entrega silenciosa","notification.hint.active":"Entrega estándar","notification.title_template":"Plantilla de Título","notification.message_template":"Plantilla de Mensaje","notification.placeholders":"Variables:","notification.event_bus_help":"El Bus de Eventos dispara el tipo de evento","notification.event_bus_payload":"con datos:","notification.test_label":"Notificación de prueba","notification.test_button":"Enviar prueba","notification.test_sending":"Enviando...","notification.test_sent":"Notificación de prueba enviada","error.prefix":"Error:","error.failed_save":"Error al guardar","error.failed":"Falló","error.panel_offline":"SPAN Panel inaccesible","error.panel_reconnected":"SPAN Panel reconectado","error.panel_offline_named":"{name} inaccesible","error.panel_reconnected_named":"{name} reconectado","error.discovery_failed":"No se puede conectar al SPAN Panel","error.relay_failed":"No se pudo cambiar el relé","error.shedding_failed":"No se pudo actualizar la prioridad de desconexión","error.threshold_failed":"No se pudo guardar el umbral","error.graph_horizon_failed":"No se pudo actualizar el horizonte temporal del gráfico","error.favorites_fetch_failed":"No se pudieron cargar los favoritos","error.favorites_toggle_failed":"No se pudo actualizar el favorito","error.history_failed":"No se pudieron cargar los datos históricos","error.monitoring_failed":"No se pudo cargar el estado de monitoreo","error.graph_settings_failed":"No se pudo cargar la configuración del gráfico","error.areas_failed":"Las asignaciones de áreas pueden estar desincronizadas","error.retry":"Reintentar","card.connecting":"Conectando al SPAN Panel...","settings.heading":"Configuración","settings.description":"La configuración general de la integración (nombres de entidades, prefijo de dispositivo, números de circuito) se administra a través del flujo de opciones de la integración.","settings.open_link":"Abrir Configuración de Integración SPAN Panel","adopted.heading":"Entidades adoptadas","adopted.description":"Las lecturas del proveedor y los dispositivos adoptados llegan con metadatos mínimos. Cura una para definir su clase de dispositivo, clase de estadísticas y prominencia — los cambios guardados recargan la integración y se aplican desde el siguiente arranque.","adopted.filter_placeholder":"Filtrar por nombre de entidad o dispositivo","adopted.no_results":"Ninguna entidad adoptada coincide con este filtro","adopted.none":"Este panel no publica nada que curar.","adopted.load_failed":"No se pudieron cargar las entidades adoptadas","adopted.count":"{count} adoptadas","adopted.vendor_readings":"LECTURAS DEL PROVEEDOR","adopted.adopted_device":"DISPOSITIVO ADOPTADO","adopted.via_panel":"vía SPAN Panel","adopted.curated":"CURADA","adopted.stale":"OBSOLETA","adopted.stale_note":"El panel ya no admite lo guardado para: {fields}","adopted.enable_entity":"Activar entidad","adopted.enabled":"ACTIVADA","adopted.disabled":"DESACTIVADA","adopted.enable_note":"Se activa al guardar — curar nunca activa por sí solo","adopted.registry_unavailable":"Activación, nombre e icono estarán disponibles cuando esta entidad exista en el registro.","adopted.name":"Nombre","adopted.icon":"Icono","adopted.device_class":"Clase de dispositivo","adopted.device_class_note":"opciones limitadas por la unidad del panel ({unit})","adopted.device_class_note_unitless":"opciones limitadas por lo que publica el panel","adopted.no_device_class":"Sin clase de dispositivo","adopted.statistics_class":"Clase de estadísticas","adopted.statistics_note":"las estadísticas a largo plazo comienzan tras la próxima recarga","adopted.no_statistics":"Sin estadísticas","adopted.prominence":"Prominencia","adopted.diagnostic":"Diagnóstico","adopted.standard":"Estándar","adopted.display_unit":"Unidad mostrada","adopted.unit_as_published":"Tal como se publica","adopted.precision":"Precisión","adopted.precision_default":"Predeterminada","adopted.read_only":"solo lectura","adopted.settable":"editable","adopted.save":"Guardar","adopted.clear":"Borrar curación","adopted.reload_note":"Guardar recarga la integración SPAN Panel","adopted.saved":"Guardado — la integración se está recargando","adopted.confirm_heading":"Confirmar clase de estadísticas","adopted.confirm_setting":"Vas a definir {name} como {value}.","adopted.confirm_clearing_subject":"Vas a borrar la clase de estadísticas de {name}.","adopted.confirm_total_increasing":"Total creciente interpreta cada caída del valor como un reinicio del contador. Si esta lectura puede disminuir por cualquier otro motivo, las estadísticas a largo plazo quedarán corrompidas de forma permanente — corregir la clase más tarde no repara el historial ya escrito.","adopted.confirm_clearing":"Dejarán de compilarse estadísticas a largo plazo para esta entidad, y Home Assistant abrirá una reparación sobre las estadísticas ya recogidas bajo la clase que estás quitando.","adopted.save_anyway":"Guardar de todos modos","adopted.cancel":"Cancelar","adopted.warn_total_increasing":"Guardado como total creciente — cada caída de la lectura cuenta ahora como un reinicio del contador.","adopted.warn_statistics_removed":"Esta entidad ya no tiene clase de estadísticas; Home Assistant abrirá una reparación sobre las estadísticas ya recogidas.","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Configuración de monitoreo del panel","header.graph_settings":"Configuración del horizonte temporal del gráfico","header.site":"Sitio","header.grid":"Red","header.upstream":"Aguas arriba","header.downstream":"Aguas abajo","header.solar":"Solar","header.battery":"Batería","header.toggle_units":"Alternar Watts / Amperios","header.enable_switches":"Habilitar Interruptores","header.switches_enabled":"Interruptores Habilitados","grid.unknown":"Desconocido","grid.configure":"Configurar circuito","grid.configure_subdevice":"Configurar dispositivo","grid.on":"Enc","grid.off":"Apag","subdevice.ev_charger":"Cargador EV","subdevice.battery":"Batería","subdevice.fallback":"Sub-dispositivo","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Potencia","sidepanel.graph_settings":"Configuración de Gráficos","sidepanel.global_defaults":"Valores predeterminados globales para todos los circuitos","sidepanel.favorites_subtitle":"Favoritos","sidepanel.global_default":"Predeterminado Global","sidepanel.list_view_columns":"Columnas de la lista","sidepanel.columns":"Columnas","sidepanel.circuit_scales":"Escalas de Gráficos de Circuitos","sidepanel.subdevice_scales":"Escalas de Gráficos de Sub-Dispositivos","sidepanel.reset_to_global":"Restablecer al valor global","sidepanel.relay":"Relé","sidepanel.breaker":"Interruptor","sidepanel.shedding_priority":"Prioridad de Desconexción","sidepanel.priority_label":"Prioridad","sidepanel.monitoring":"Monitoreo","sidepanel.global":"Global","sidepanel.custom":"Personalizado","sidepanel.continuous_pct":"Continuo %","sidepanel.spike_pct":"Pico %","sidepanel.window_duration":"Duración de ventana","sidepanel.cooldown":"Enfriamiento","sidepanel.favorite":"Favorito","sidepanel.save_to_favorites":"Guardar en favoritos","panel.favorites":"Favoritos","status.monitoring":"Monitoreo","status.circuits":"circuitos","status.mains":"alimentación","status.warning":"advertencia","status.warnings":"advertencias","status.alert":"alerta","status.alerts":"alertas","status.override":"anulación","status.overrides":"anulaciones","card.no_device":"Abra el editor de tarjeta y seleccione su dispositivo SPAN Panel.","card.device_not_found":"Dispositivo de panel no encontrado. Verifique device_id en la configuración de la tarjeta.","card.topology_error":"La respuesta de topología no contiene panel_size y no se encontraron circuitos. Actualice la integración SPAN Panel.","card.panel_size_error":"No se pudo determinar panel_size. No se encontraron circuitos ni atributo panel_size. Actualice la integración SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Seleccione un panel...","editor.chart_window":"Ventana de tiempo del gráfico","editor.days":"días","editor.hours":"horas","editor.minutes":"minutos","editor.chart_metric":"Métrica del gráfico","editor.visible_sections":"Secciones visibles","editor.panel_circuits":"Circuitos del panel","editor.battery_bess":"Batería (BESS)","editor.ev_charger_evse":"Cargador EV (EVSE)","editor.tab_style":"Estilo de pestañas","editor.tab_style_text":"Texto","editor.tab_style_icon":"Ícono","metric.power":"Potencia","metric.current":"Corriente","metric.soc":"Estado de Carga","metric.soe":"Estado de Energía","shedding.always_on":"Crítico","shedding.never":"No desconectable","shedding.soc_threshold":"Umbral SoC","shedding.off_grid":"Desconectable","shedding.unknown":"Desconocido","shedding.select.never":"Permanece encendido en un corte","shedding.select.soc_threshold":"Encendido hasta umbral de batería","shedding.select.off_grid":"Se apaga en un corte"},fr:{"tab.panel":"Panneau","tab.by_panel":"Par Panneau","tab.by_activity":"Par Activité","tab.by_area":"Par Zone","tab.monitoring":"Surveillance","tab.settings":"Paramètres","tab.adopted":"Adoptées","list.search_placeholder":"Rechercher des circuits...","list.unassigned_area":"Non attribué","list.no_results":"Aucun circuit trouvé","monitoring.heading":"Surveillance","monitoring.global_settings":"Paramètres Globaux","monitoring.enabled":"Activé","monitoring.continuous":"Continu (%)","monitoring.spike":"Pic (%)","monitoring.window":"Fenêtre (min)","monitoring.cooldown":"Refroidissement (min)","monitoring.monitored_points":"Points Surveillés","monitoring.col.name":"Nom","monitoring.col.continuous":"Continu","monitoring.col.spike":"Pic","monitoring.col.window":"Fenêtre","monitoring.col.cooldown":"Refroidissement","monitoring.all_none":"Tous / Aucun","monitoring.reset":"Réinitialiser","notification.heading":"Paramètres de Notification","notification.targets":"Cibles de Notification","notification.none_selected":"Aucune sélection","notification.no_targets":"Aucune cible de notification trouvée","notification.all_targets":"Tous","notification.event_bus_target":"Bus d'événements (bus d'événements HA)","notification.priority":"Priorité","notification.priority.default":"Par défaut","notification.priority.passive":"Passif","notification.priority.active":"Actif","notification.priority.time_sensitive":"Urgent","notification.priority.critical":"Critique","notification.hint.critical":"Outrepasse silencieux/NPD","notification.hint.time_sensitive":"Traverse le mode Concentration","notification.hint.passive":"Livraison silencieuse","notification.hint.active":"Livraison standard","notification.title_template":"Modèle de Titre","notification.message_template":"Modèle de Message","notification.placeholders":"Variables :","notification.event_bus_help":"Le Bus d'événements déclenche le type d'événement","notification.event_bus_payload":"avec les données :","notification.test_label":"Notification de test","notification.test_button":"Envoyer un test","notification.test_sending":"Envoi...","notification.test_sent":"Notification de test envoyée","error.prefix":"Erreur :","error.failed_save":"Échec de la sauvegarde","error.failed":"Échoué","error.panel_offline":"SPAN Panel inaccessible","error.panel_reconnected":"SPAN Panel reconnecté","error.panel_offline_named":"{name} inaccessible","error.panel_reconnected_named":"{name} reconnecté","error.discovery_failed":"Impossible de se connecter au SPAN Panel","error.relay_failed":"Impossible de basculer le relais","error.shedding_failed":"Impossible de mettre à jour la priorité de délestage","error.threshold_failed":"Impossible d'enregistrer le seuil","error.graph_horizon_failed":"Impossible de mettre à jour l'horizon temporel du graphique","error.favorites_fetch_failed":"Impossible de charger les favoris","error.favorites_toggle_failed":"Impossible de mettre à jour le favori","error.history_failed":"Impossible de charger les données historiques","error.monitoring_failed":"Impossible de charger l'état de surveillance","error.graph_settings_failed":"Impossible de charger les paramètres du graphique","error.areas_failed":"Les affectations de zones peuvent être désynchronisées","error.retry":"Réessayer","card.connecting":"Connexion au SPAN Panel...","settings.heading":"Paramètres","settings.description":"Les paramètres généraux de l'intégration (noms d'entités, préfixe de l'appareil, numéros de circuit) sont gérés via le flux d'options de l'intégration.","settings.open_link":"Ouvrir les Paramètres d'Intégration SPAN Panel","adopted.heading":"Entités adoptées","adopted.description":"Les relevés du fournisseur et les appareils adoptés arrivent avec des métadonnées minimales. Curez-en un pour définir sa classe d'appareil, sa classe de statistiques et sa proéminence — les modifications enregistrées rechargent l'intégration et s'appliquent dès le prochain démarrage.","adopted.filter_placeholder":"Filtrer par nom d'entité ou d'appareil","adopted.no_results":"Aucune entité adoptée ne correspond à ce filtre","adopted.none":"Ce panneau ne publie rien à curer.","adopted.load_failed":"Impossible de charger les entités adoptées","adopted.count":"{count} adoptées","adopted.vendor_readings":"RELEVÉS DU FOURNISSEUR","adopted.adopted_device":"APPAREIL ADOPTÉ","adopted.via_panel":"via SPAN Panel","adopted.curated":"CURÉE","adopted.stale":"OBSOLÈTE","adopted.stale_note":"Le panneau ne prend plus en charge ce qui a été enregistré pour : {fields}","adopted.enable_entity":"Activer l'entité","adopted.enabled":"ACTIVÉE","adopted.disabled":"DÉSACTIVÉE","adopted.enable_note":"Activée à l'enregistrement — la curation n'active jamais d'elle-même","adopted.registry_unavailable":"L'activation, le nom et l'icône seront disponibles dès que cette entité existera dans le registre.","adopted.name":"Nom","adopted.icon":"Icône","adopted.device_class":"Classe d'appareil","adopted.device_class_note":"choix limités par l'unité du panneau ({unit})","adopted.device_class_note_unitless":"choix limités par ce que le panneau publie","adopted.no_device_class":"Aucune classe d'appareil","adopted.statistics_class":"Classe de statistiques","adopted.statistics_note":"les statistiques à long terme commencent après le prochain rechargement","adopted.no_statistics":"Aucune statistique","adopted.prominence":"Proéminence","adopted.diagnostic":"Diagnostic","adopted.standard":"Standard","adopted.display_unit":"Unité affichée","adopted.unit_as_published":"Telle que publiée","adopted.precision":"Précision","adopted.precision_default":"Par défaut","adopted.read_only":"lecture seule","adopted.settable":"modifiable","adopted.save":"Enregistrer","adopted.clear":"Effacer la curation","adopted.reload_note":"L'enregistrement recharge l'intégration SPAN Panel","adopted.saved":"Enregistré — l'intégration se recharge","adopted.confirm_heading":"Confirmer la classe de statistiques","adopted.confirm_setting":"Vous définissez {name} sur {value}.","adopted.confirm_clearing_subject":"Vous effacez la classe de statistiques de {name}.","adopted.confirm_total_increasing":"Total croissant interprète chaque baisse de la valeur comme une remise à zéro du compteur. Si ce relevé peut diminuer pour une autre raison, les statistiques à long terme seront corrompues de façon permanente — corriger la classe plus tard ne répare pas l'historique déjà écrit.","adopted.confirm_clearing":"Les statistiques à long terme cesseront d'être compilées pour cette entité, et Home Assistant ouvrira une réparation sur les statistiques déjà collectées sous la classe que vous retirez.","adopted.save_anyway":"Enregistrer quand même","adopted.cancel":"Annuler","adopted.warn_total_increasing":"Enregistré en total croissant — chaque baisse du relevé compte désormais comme une remise à zéro du compteur.","adopted.warn_statistics_removed":"Cette entité n'a plus de classe de statistiques ; Home Assistant ouvrira une réparation sur les statistiques déjà collectées.","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Paramètres de surveillance du panneau","header.graph_settings":"Paramètres d'horizon temporel du graphique","header.site":"Site","header.grid":"Réseau","header.upstream":"Amont","header.downstream":"Aval","header.solar":"Solaire","header.battery":"Batterie","header.toggle_units":"Basculer Watts / Ampères","header.enable_switches":"Activer les interrupteurs","header.switches_enabled":"Interrupteurs activés","grid.unknown":"Inconnu","grid.configure":"Configurer le circuit","grid.configure_subdevice":"Configurer l'appareil","grid.on":"On","grid.off":"Off","subdevice.ev_charger":"Chargeur VE","subdevice.battery":"Batterie","subdevice.fallback":"Sous-appareil","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Puissance","sidepanel.graph_settings":"Paramètres des Graphiques","sidepanel.global_defaults":"Valeurs par défaut globales pour tous les circuits","sidepanel.favorites_subtitle":"Favoris","sidepanel.global_default":"Défaut Global","sidepanel.list_view_columns":"Colonnes de la liste","sidepanel.columns":"Colonnes","sidepanel.circuit_scales":"Échelles des Graphiques de Circuits","sidepanel.subdevice_scales":"Échelles des Graphiques de Sous-Appareils","sidepanel.reset_to_global":"Réinitialiser à la valeur globale","sidepanel.relay":"Relais","sidepanel.breaker":"Disjoncteur","sidepanel.shedding_priority":"Priorité de Délestage","sidepanel.priority_label":"Priorité","sidepanel.monitoring":"Surveillance","sidepanel.global":"Global","sidepanel.custom":"Personnalisé","sidepanel.continuous_pct":"Continu %","sidepanel.spike_pct":"Pic %","sidepanel.window_duration":"Durée de fenêtre","sidepanel.cooldown":"Refroidissement","sidepanel.favorite":"Favori","sidepanel.save_to_favorites":"Enregistrer dans les favoris","panel.favorites":"Favoris","status.monitoring":"Surveillance","status.circuits":"circuits","status.mains":"alimentation","status.warning":"avertissement","status.warnings":"avertissements","status.alert":"alerte","status.alerts":"alertes","status.override":"remplacement","status.overrides":"remplacements","card.no_device":"Ouvrez l'éditeur de carte et sélectionnez votre appareil SPAN Panel.","card.device_not_found":"Appareil de panneau introuvable. Vérifiez device_id dans la configuration de la carte.","card.topology_error":"La réponse de topologie ne contient pas panel_size et aucun circuit trouvé. Mettez à jour l'intégration SPAN Panel.","card.panel_size_error":"Impossible de déterminer panel_size. Aucun circuit trouvé et aucun attribut panel_size. Mettez à jour l'intégration SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Sélectionnez un panneau...","editor.chart_window":"Fenêtre de temps du graphique","editor.days":"jours","editor.hours":"heures","editor.minutes":"minutes","editor.chart_metric":"Métrique du graphique","editor.visible_sections":"Sections visibles","editor.panel_circuits":"Circuits du panneau","editor.battery_bess":"Batterie (BESS)","editor.ev_charger_evse":"Chargeur VE (EVSE)","editor.tab_style":"Style des onglets","editor.tab_style_text":"Texte","editor.tab_style_icon":"Icône","metric.power":"Puissance","metric.current":"Courant","metric.soc":"État de Charge","metric.soe":"État d'Énergie","shedding.always_on":"Critique","shedding.never":"Non délestable","shedding.soc_threshold":"Seuil SoC","shedding.off_grid":"Délestable","shedding.unknown":"Inconnu","shedding.select.never":"Reste allumé en cas de coupure","shedding.select.soc_threshold":"Allumé jusqu'au seuil batterie","shedding.select.off_grid":"S'éteint en cas de coupure"},ja:{"tab.panel":"パネル","tab.by_panel":"パネル別","tab.by_activity":"活動別","tab.by_area":"エリア別","tab.monitoring":"モニタリング","tab.settings":"設定","tab.adopted":"採用済み","list.search_placeholder":"回路を検索...","list.unassigned_area":"未割り当て","list.no_results":"回路が見つかりません","monitoring.heading":"モニタリング","monitoring.global_settings":"グローバル設定","monitoring.enabled":"有効","monitoring.continuous":"継続 (%)","monitoring.spike":"スパイク (%)","monitoring.window":"ウィンドウ (分)","monitoring.cooldown":"クールダウン (分)","monitoring.monitored_points":"監視ポイント","monitoring.col.name":"名前","monitoring.col.continuous":"継続","monitoring.col.spike":"スパイク","monitoring.col.window":"ウィンドウ","monitoring.col.cooldown":"クールダウン","monitoring.all_none":"全選択 / 全解除","monitoring.reset":"リセット","notification.heading":"通知設定","notification.targets":"通知先","notification.none_selected":"未選択","notification.no_targets":"通知先が見つかりません","notification.all_targets":"すべて","notification.event_bus_target":"イベントバス (HAイベントバス)","notification.priority":"優先度","notification.priority.default":"デフォルト","notification.priority.passive":"パッシブ","notification.priority.active":"アクティブ","notification.priority.time_sensitive":"緊急","notification.priority.critical":"重大","notification.hint.critical":"サイレント/おやすみモードを無視","notification.hint.time_sensitive":"集中モードを突破","notification.hint.passive":"サイレント配信","notification.hint.active":"標準配信","notification.title_template":"タイトルテンプレート","notification.message_template":"メッセージテンプレート","notification.placeholders":"プレースホルダー:","notification.event_bus_help":"イベントバスが発行するイベントタイプ","notification.event_bus_payload":"ペイロード:","notification.test_label":"テスト通知","notification.test_button":"テスト送信","notification.test_sending":"送信中...","notification.test_sent":"テスト通知を送信しました","error.prefix":"エラー:","error.failed_save":"保存に失敗","error.failed":"失敗","error.panel_offline":"SPANパネルに接続できません","error.panel_reconnected":"SPANパネルが再接続されました","error.panel_offline_named":"{name}に接続できません","error.panel_reconnected_named":"{name}が再接続されました","error.discovery_failed":"SPANパネルへの接続に失敗しました","error.relay_failed":"リレーの切り替えに失敗しました","error.shedding_failed":"シェディング優先度の更新に失敗しました","error.threshold_failed":"しきい値の保存に失敗しました","error.graph_horizon_failed":"グラフの時間範囲の更新に失敗しました","error.favorites_fetch_failed":"お気に入りの読み込みに失敗しました","error.favorites_toggle_failed":"お気に入りの更新に失敗しました","error.history_failed":"履歴データの読み込みに失敗しました","error.monitoring_failed":"監視ステータスの読み込みに失敗しました","error.graph_settings_failed":"グラフ設定の読み込みに失敗しました","error.areas_failed":"エリア割り当てが同期されていない可能性があります","error.retry":"再試行","card.connecting":"SPANパネルに接続中...","settings.heading":"設定","settings.description":"統合の一般設定(エンティティ名、デバイスプレフィックス、回路番号)は統合のオプションフローで管理されます。","settings.open_link":"SPAN Panel統合設定を開く","adopted.heading":"採用済みエンティティ","adopted.description":"ベンダーの測定値と採用済みデバイスは最小限のメタデータで登録されます。キュレーションでデバイスクラス、統計クラス、表示区分を設定できます。保存すると統合が再読み込みされ、次回の起動から適用されます。","adopted.filter_placeholder":"エンティティ名またはデバイス名で絞り込み","adopted.no_results":"この条件に一致する採用済みエンティティはありません","adopted.none":"このパネルにキュレーション対象はありません。","adopted.load_failed":"採用済みエンティティを読み込めません","adopted.count":"{count} 件","adopted.vendor_readings":"ベンダー測定値","adopted.adopted_device":"採用済みデバイス","adopted.via_panel":"SPAN Panel 経由","adopted.curated":"キュレーション済み","adopted.stale":"無効","adopted.stale_note":"パネルは保存された次の項目をサポートしなくなりました: {fields}","adopted.enable_entity":"エンティティを有効化","adopted.enabled":"有効","adopted.disabled":"無効","adopted.enable_note":"保存時に有効化されます — キュレーション自体が有効化することはありません","adopted.registry_unavailable":"有効化・名前・アイコンは、このエンティティがレジストリに登録された後に利用できます。","adopted.name":"名前","adopted.icon":"アイコン","adopted.device_class":"デバイスクラス","adopted.device_class_note":"パネルの単位({unit})により選択肢が制限されます","adopted.device_class_note_unitless":"パネルが公開する内容により選択肢が制限されます","adopted.no_device_class":"デバイスクラスなし","adopted.statistics_class":"統計クラス","adopted.statistics_note":"長期統計は次回の再読み込み後に開始されます","adopted.no_statistics":"統計なし","adopted.prominence":"表示区分","adopted.diagnostic":"診断","adopted.standard":"標準","adopted.display_unit":"表示単位","adopted.unit_as_published":"公開されたまま","adopted.precision":"小数点以下桁数","adopted.precision_default":"既定","adopted.read_only":"読み取り専用","adopted.settable":"書き込み可","adopted.save":"保存","adopted.clear":"キュレーションを消去","adopted.reload_note":"保存すると SPAN Panel 統合が再読み込みされます","adopted.saved":"保存しました — 統合を再読み込みしています","adopted.confirm_heading":"統計クラスの確認","adopted.confirm_setting":"{name} を {value} に設定しようとしています。","adopted.confirm_clearing_subject":"{name} の統計クラスを消去しようとしています。","adopted.confirm_total_increasing":"積算増加は値の低下をすべてメーターのリセットとして扱います。この測定値が他の理由でも下がる場合、長期統計は恒久的に破損します。後からクラスを直しても、既に書き込まれた履歴は修復されません。","adopted.confirm_clearing":"このエンティティの長期統計は収集されなくなり、削除するクラスの下で既に収集された統計について Home Assistant が修復項目を作成します。","adopted.save_anyway":"それでも保存","adopted.cancel":"キャンセル","adopted.warn_total_increasing":"積算増加として保存しました — 測定値の低下はすべてメーターのリセットとして数えられます。","adopted.warn_statistics_removed":"このエンティティに統計クラスはなくなりました。既に収集された統計について Home Assistant が修復項目を作成します。","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"パネルモニタリング設定","header.graph_settings":"グラフ時間範囲設定","header.site":"サイト","header.grid":"グリッド","header.upstream":"上流","header.downstream":"下流","header.solar":"ソーラー","header.battery":"バッテリー","header.toggle_units":"ワット/アンペア切り替え","header.enable_switches":"スイッチを有効化","header.switches_enabled":"スイッチ有効","grid.unknown":"不明","grid.configure":"回路を設定","grid.configure_subdevice":"デバイスを設定","grid.on":"オン","grid.off":"オフ","subdevice.ev_charger":"EV充電器","subdevice.battery":"バッテリー","subdevice.fallback":"サブデバイス","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"電力","sidepanel.graph_settings":"グラフ設定","sidepanel.global_defaults":"全回路のグローバルデフォルト","sidepanel.favorites_subtitle":"お気に入り","sidepanel.global_default":"グローバルデフォルト","sidepanel.list_view_columns":"リスト表示の列数","sidepanel.columns":"列","sidepanel.circuit_scales":"回路グラフスケール","sidepanel.subdevice_scales":"サブデバイスグラフスケール","sidepanel.reset_to_global":"グローバルにリセット","sidepanel.relay":"リレー","sidepanel.breaker":"ブレーカー","sidepanel.shedding_priority":"シェディング優先度","sidepanel.priority_label":"優先度","sidepanel.monitoring":"モニタリング","sidepanel.global":"グローバル","sidepanel.custom":"カスタム","sidepanel.continuous_pct":"継続 %","sidepanel.spike_pct":"スパイク %","sidepanel.window_duration":"ウィンドウ時間","sidepanel.cooldown":"クールダウン","sidepanel.favorite":"お気に入り","sidepanel.save_to_favorites":"お気に入りに保存","panel.favorites":"お気に入り","status.monitoring":"モニタリング","status.circuits":"回路","status.mains":"主電源","status.warning":"警告","status.warnings":"警告","status.alert":"アラート","status.alerts":"アラート","status.override":"上書き","status.overrides":"上書き","card.no_device":"カードエディタを開いてSPAN Panelデバイスを選択してください。","card.device_not_found":"パネルデバイスが見つかりません。カード設定のdevice_idを確認してください。","card.topology_error":"トポロジー応答にpanel_sizeがなく、回路が見つかりません。SPAN Panel統合を更新してください。","card.panel_size_error":"panel_sizeを判定できません。回路がpanel_size属性が見つかりません。SPAN Panel統合を更新してください。","editor.panel_label":"SPAN Panel","editor.select_panel":"パネルを選択...","editor.chart_window":"グラフ時間ウィンドウ","editor.days":"日","editor.hours":"時間","editor.minutes":"分","editor.chart_metric":"グラフ指標","editor.visible_sections":"表示セクション","editor.panel_circuits":"パネル回路","editor.battery_bess":"バッテリー (BESS)","editor.ev_charger_evse":"EV充電器 (EVSE)","editor.tab_style":"タブスタイル","editor.tab_style_text":"テキスト","editor.tab_style_icon":"アイコン","metric.power":"電力","metric.current":"電流","metric.soc":"充電状態","metric.soe":"エネルギー状態","shedding.always_on":"重要","shedding.never":"切断不可","shedding.soc_threshold":"SoCしきい値","shedding.off_grid":"切断可能","shedding.unknown":"不明","shedding.select.never":"停電時もオンを維持","shedding.select.soc_threshold":"バッテリーしきい値までオン","shedding.select.off_grid":"停電時にオフ"},pt:{"tab.panel":"Painel","tab.by_panel":"Por Painel","tab.by_activity":"Por Atividade","tab.by_area":"Por Área","tab.monitoring":"Monitoramento","tab.settings":"Configurações","tab.adopted":"Adotadas","list.search_placeholder":"Pesquisar circuitos...","list.unassigned_area":"Não atribuído","list.no_results":"Nenhum circuito encontrado","monitoring.heading":"Monitoramento","monitoring.global_settings":"Configurações Globais","monitoring.enabled":"Ativado","monitoring.continuous":"Contínuo (%)","monitoring.spike":"Pico (%)","monitoring.window":"Janela (min)","monitoring.cooldown":"Resfriamento (min)","monitoring.monitored_points":"Pontos Monitorados","monitoring.col.name":"Nome","monitoring.col.continuous":"Contínuo","monitoring.col.spike":"Pico","monitoring.col.window":"Janela","monitoring.col.cooldown":"Resfriamento","monitoring.all_none":"Todos / Nenhum","monitoring.reset":"Redefinir","notification.heading":"Configurações de Notificação","notification.targets":"Destinos de Notificação","notification.none_selected":"Nenhum selecionado","notification.no_targets":"Nenhum destino de notificação encontrado","notification.all_targets":"Todos","notification.event_bus_target":"Barramento de Eventos (barramento de eventos do HA)","notification.priority":"Prioridade","notification.priority.default":"Padrão","notification.priority.passive":"Passivo","notification.priority.active":"Ativo","notification.priority.time_sensitive":"Urgente","notification.priority.critical":"Crítico","notification.hint.critical":"Substitui silencioso/Não perturbar","notification.hint.time_sensitive":"Atravessa o modo Foco","notification.hint.passive":"Entrega silenciosa","notification.hint.active":"Entrega padrão","notification.title_template":"Modelo de Título","notification.message_template":"Modelo de Mensagem","notification.placeholders":"Variáveis:","notification.event_bus_help":"O Barramento de Eventos dispara o tipo de evento","notification.event_bus_payload":"com dados:","notification.test_label":"Notificação de teste","notification.test_button":"Enviar teste","notification.test_sending":"Enviando...","notification.test_sent":"Notificação de teste enviada","error.prefix":"Erro:","error.failed_save":"Falha ao salvar","error.failed":"Falhou","error.panel_offline":"SPAN Panel inacessível","error.panel_reconnected":"SPAN Panel reconectado","error.panel_offline_named":"{name} inacessível","error.panel_reconnected_named":"{name} reconectado","error.discovery_failed":"Não foi possível conectar ao SPAN Panel","error.relay_failed":"Não foi possível alternar o relé","error.shedding_failed":"Não foi possível atualizar a prioridade de desligamento","error.threshold_failed":"Não foi possível salvar o limite","error.graph_horizon_failed":"Não foi possível atualizar o horizonte temporal do gráfico","error.favorites_fetch_failed":"Não foi possível carregar os favoritos","error.favorites_toggle_failed":"Não foi possível atualizar o favorito","error.history_failed":"Não foi possível carregar os dados históricos","error.monitoring_failed":"Não foi possível carregar o status de monitoramento","error.graph_settings_failed":"Não foi possível carregar as configurações do gráfico","error.areas_failed":"As atribuições de áreas podem estar fora de sincronização","error.retry":"Tentar novamente","card.connecting":"Conectando ao SPAN Panel...","settings.heading":"Configurações","settings.description":"As configurações gerais da integração (nomes de entidades, prefixo do dispositivo, números de circuito) são gerenciadas através do fluxo de opções da integração.","settings.open_link":"Abrir Configurações de Integração SPAN Panel","adopted.heading":"Entidades adotadas","adopted.description":"As leituras do fornecedor e os dispositivos adotados chegam com metadados mínimos. Faça a curadoria de uma para definir sua classe de dispositivo, classe de estatísticas e proeminência — alterações salvas recarregam a integração e valem a partir da próxima inicialização.","adopted.filter_placeholder":"Filtrar por nome de entidade ou dispositivo","adopted.no_results":"Nenhuma entidade adotada corresponde a este filtro","adopted.none":"Este painel não publica nada para curadoria.","adopted.load_failed":"Não foi possível carregar as entidades adotadas","adopted.count":"{count} adotadas","adopted.vendor_readings":"LEITURAS DO FORNECEDOR","adopted.adopted_device":"DISPOSITIVO ADOTADO","adopted.via_panel":"via SPAN Panel","adopted.curated":"CURADA","adopted.stale":"OBSOLETA","adopted.stale_note":"O painel não suporta mais o que foi salvo para: {fields}","adopted.enable_entity":"Ativar entidade","adopted.enabled":"ATIVADA","adopted.disabled":"DESATIVADA","adopted.enable_note":"Ativada ao salvar — a curadoria nunca ativa por conta própria","adopted.registry_unavailable":"Ativação, nome e ícone ficam disponíveis assim que esta entidade existir no registro.","adopted.name":"Nome","adopted.icon":"Ícone","adopted.device_class":"Classe de dispositivo","adopted.device_class_note":"opções limitadas pela unidade do painel ({unit})","adopted.device_class_note_unitless":"opções limitadas pelo que o painel publica","adopted.no_device_class":"Sem classe de dispositivo","adopted.statistics_class":"Classe de estatísticas","adopted.statistics_note":"as estatísticas de longo prazo começam após a próxima recarga","adopted.no_statistics":"Sem estatísticas","adopted.prominence":"Proeminência","adopted.diagnostic":"Diagnóstico","adopted.standard":"Padrão","adopted.display_unit":"Unidade exibida","adopted.unit_as_published":"Como publicada","adopted.precision":"Precisão","adopted.precision_default":"Padrão","adopted.read_only":"somente leitura","adopted.settable":"editável","adopted.save":"Salvar","adopted.clear":"Limpar curadoria","adopted.reload_note":"Salvar recarrega a integração SPAN Panel","adopted.saved":"Salvo — a integração está recarregando","adopted.confirm_heading":"Confirmar classe de estatísticas","adopted.confirm_setting":"Você está definindo {name} como {value}.","adopted.confirm_clearing_subject":"Você está limpando a classe de estatísticas de {name}.","adopted.confirm_total_increasing":"Total crescente trata toda queda do valor como uma reinicialização do medidor. Se esta leitura puder diminuir por qualquer outro motivo, as estatísticas de longo prazo ficarão permanentemente corrompidas — corrigir a classe depois não repara o histórico já gravado.","adopted.confirm_clearing":"As estatísticas de longo prazo deixarão de ser compiladas para esta entidade, e o Home Assistant abrirá um reparo sobre as estatísticas já coletadas na classe que você está removendo.","adopted.save_anyway":"Salvar mesmo assim","adopted.cancel":"Cancelar","adopted.warn_total_increasing":"Salvo como total crescente — cada queda da leitura agora conta como uma reinicialização do medidor.","adopted.warn_statistics_removed":"Esta entidade não tem mais classe de estatísticas; o Home Assistant abrirá um reparo sobre as estatísticas já coletadas.","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Configurações de monitoramento do painel","header.graph_settings":"Configurações do horizonte temporal do gráfico","header.site":"Local","header.grid":"Rede","header.upstream":"Montante","header.downstream":"Jusante","header.solar":"Solar","header.battery":"Bateria","header.toggle_units":"Alternar Watts / Amperes","header.enable_switches":"Ativar Interruptores","header.switches_enabled":"Interruptores Ativados","grid.unknown":"Desconhecido","grid.configure":"Configurar circuito","grid.configure_subdevice":"Configurar dispositivo","grid.on":"Lig","grid.off":"Des","subdevice.ev_charger":"Carregador VE","subdevice.battery":"Bateria","subdevice.fallback":"Sub-dispositivo","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Potência","sidepanel.graph_settings":"Configurações de Gráficos","sidepanel.global_defaults":"Padrões globais para todos os circuitos","sidepanel.favorites_subtitle":"Favoritos","sidepanel.global_default":"Padrão Global","sidepanel.list_view_columns":"Colunas da Lista","sidepanel.columns":"Colunas","sidepanel.circuit_scales":"Escalas de Gráficos de Circuitos","sidepanel.subdevice_scales":"Escalas de Gráficos de Sub-Dispositivos","sidepanel.reset_to_global":"Redefinir para o padrão global","sidepanel.relay":"Relé","sidepanel.breaker":"Disjuntor","sidepanel.shedding_priority":"Prioridade de Desligamento","sidepanel.priority_label":"Prioridade","sidepanel.monitoring":"Monitoramento","sidepanel.global":"Global","sidepanel.custom":"Personalizado","sidepanel.continuous_pct":"Contínuo %","sidepanel.spike_pct":"Pico %","sidepanel.window_duration":"Duração da janela","sidepanel.cooldown":"Resfriamento","sidepanel.favorite":"Favorito","sidepanel.save_to_favorites":"Salvar nos favoritos","panel.favorites":"Favoritos","status.monitoring":"Monitoramento","status.circuits":"circuitos","status.mains":"alimentação","status.warning":"aviso","status.warnings":"avisos","status.alert":"alerta","status.alerts":"alertas","status.override":"substituição","status.overrides":"substituições","card.no_device":"Abra o editor do cartão e selecione seu dispositivo SPAN Panel.","card.device_not_found":"Dispositivo do painel não encontrado. Verifique device_id na configuração do cartão.","card.topology_error":"A resposta de topologia não contém panel_size e nenhum circuito encontrado. Atualize a integração SPAN Panel.","card.panel_size_error":"Não foi possível determinar panel_size. Nenhum circuito encontrado e nenhum atributo panel_size. Atualize a integração SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Selecione um painel...","editor.chart_window":"Janela de tempo do gráfico","editor.days":"dias","editor.hours":"horas","editor.minutes":"minutos","editor.chart_metric":"Métrica do gráfico","editor.visible_sections":"Seções visíveis","editor.panel_circuits":"Circuitos do painel","editor.battery_bess":"Bateria (BESS)","editor.ev_charger_evse":"Carregador VE (EVSE)","editor.tab_style":"Estilo das abas","editor.tab_style_text":"Texto","editor.tab_style_icon":"Ícone","metric.power":"Potência","metric.current":"Corrente","metric.soc":"Estado de Carga","metric.soe":"Estado de Energia","shedding.always_on":"Crítico","shedding.never":"Não desligável","shedding.soc_threshold":"Limite SoC","shedding.off_grid":"Desligável","shedding.unknown":"Desconhecido","shedding.select.never":"Permanece ligado em uma queda","shedding.select.soc_threshold":"Ligado até limite da bateria","shedding.select.off_grid":"Desliga em uma queda"}};function n(n){t=n&&e[n]?n:"en"}function i(n){return e[t]?.[n]??e.en?.[n]??n}function r(n,i){return(e[t]?.[n]??e.en?.[n]??n).replace(/\{(\w+)\}/g,(t,e)=>Object.prototype.hasOwnProperty.call(i,e)?i[e]:`{${e}}`)}const o="power",a="5m",s={"5m":{ms:3e5,refreshMs:1e3,useRealtime:!0},"1h":{ms:36e5,refreshMs:3e4,useRealtime:!1},"1d":{ms:864e5,refreshMs:6e4,useRealtime:!1},"1w":{ms:6048e5,refreshMs:6e4,useRealtime:!1},"1M":{ms:2592e6,refreshMs:6e4,useRealtime:!1}},l="span_panel",c="CLOSED",u="pv",d="bess",h="evse",p="sub_",f=500,g={power:{entityRole:"power",label:()=>i("metric.power"),unit:t=>Math.abs(t)>=1e3?"kW":"W",format:t=>{const e=Math.abs(t);return e>=1e3?(e/1e3).toFixed(1):e<10&&e>0?e.toFixed(1):String(Math.round(e))}},current:{entityRole:"current",label:()=>i("metric.current"),unit:()=>"A",format:t=>Math.abs(t).toFixed(1)}},v={soc:{entityRole:"soc",label:()=>i("metric.soc"),unit:()=>"%",format:t=>String(Math.round(t)),fixedMin:0,fixedMax:100},soe:{entityRole:"soe",label:()=>i("metric.soe"),unit:()=>"kWh",format:t=>t.toFixed(1)},power:g.power},m={always_on:{icon:"mdi:battery",icon2:"mdi:router-wireless",color:"#4caf50",label:()=>i("shedding.always_on")},never:{icon:"mdi:battery",color:"#4caf50",label:()=>i("shedding.never")},soc_threshold:{icon:"mdi:battery-alert-variant-outline",color:"#9c27b0",label:()=>i("shedding.soc_threshold"),textLabel:"SoC"},off_grid:{icon:"mdi:transmission-tower",color:"#ff9800",label:()=>i("shedding.off_grid")},unknown:{icon:"mdi:help-circle-outline",color:"#888",label:()=>i("shedding.unknown")}};var y=function(t,e){return y=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&(t[n]=e[n])},y(t,e)};function _(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function n(){this.constructor=t}y(t,e),t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)}function b(t,e,n,i){var r,o=arguments.length,a=o<3?e:null===i?i=Object.getOwnPropertyDescriptor(e,n):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)a=Reflect.decorate(t,e,n,i);else for(var s=t.length-1;s>=0;s--)(r=t[s])&&(a=(o<3?r(a):o>3?r(e,n,a):r(e,n))||a);return o>3&&a&&Object.defineProperty(e,n,a),a}"function"==typeof SuppressedError&&SuppressedError; /** * @license * Copyright 2019 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const b=globalThis,w=b.ShadowRoot&&(void 0===b.ShadyCSS||b.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,S=Symbol(),C=new WeakMap;let M=class{constructor(t,e,n){if(this._$cssResult$=!0,n!==S)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e}get styleSheet(){let t=this.o;const e=this.t;if(w&&void 0===t){const n=void 0!==e&&1===e.length;n&&(t=C.get(e)),void 0===t&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),n&&C.set(e,t))}return t}toString(){return this.cssText}};const k=t=>new M("string"==typeof t?t:t+"",void 0,S),T=(t,...e)=>{const n=1===t.length?t[0]:e.reduce((e,n,i)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(n)+t[i+1],t[0]);return new M(n,t,S)},D=w?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const n of t.cssRules)e+=n.cssText;return k(e)})(t):t,{is:I,defineProperty:A,getOwnPropertyDescriptor:P,getOwnPropertyNames:L,getOwnPropertySymbols:E,getPrototypeOf:z}=Object,O=globalThis,N=O.trustedTypes,R=N?N.emptyScript:"",H=O.reactiveElementPolyfillSupport,B=(t,e)=>t,F={toAttribute(t,e){switch(e){case Boolean:t=t?R:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,e){let n=t;switch(e){case Boolean:n=null!==t;break;case Number:n=null===t?null:Number(t);break;case Object:case Array:try{n=JSON.parse(t)}catch(t){n=null}}return n}},$=(t,e)=>!I(t,e),V={attribute:!0,type:String,converter:F,reflect:!1,useDefault:!1,hasChanged:$}; +const x=globalThis,w=x.ShadowRoot&&(void 0===x.ShadyCSS||x.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,S=Symbol(),C=new WeakMap;let M=class{constructor(t,e,n){if(this._$cssResult$=!0,n!==S)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e}get styleSheet(){let t=this.o;const e=this.t;if(w&&void 0===t){const n=void 0!==e&&1===e.length;n&&(t=C.get(e)),void 0===t&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),n&&C.set(e,t))}return t}toString(){return this.cssText}};const k=t=>new M("string"==typeof t?t:t+"",void 0,S),T=(t,...e)=>{const n=1===t.length?t[0]:e.reduce((e,n,i)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(n)+t[i+1],t[0]);return new M(n,t,S)},A=w?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const n of t.cssRules)e+=n.cssText;return k(e)})(t):t,{is:D,defineProperty:I,getOwnPropertyDescriptor:P,getOwnPropertyNames:L,getOwnPropertySymbols:E,getPrototypeOf:O}=Object,z=globalThis,N=z.trustedTypes,R=N?N.emptyScript:"",H=z.reactiveElementPolyfillSupport,B=(t,e)=>t,F={toAttribute(t,e){switch(e){case Boolean:t=t?R:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,e){let n=t;switch(e){case Boolean:n=null!==t;break;case Number:n=null===t?null:Number(t);break;case Object:case Array:try{n=JSON.parse(t)}catch(t){n=null}}return n}},$=(t,e)=>!D(t,e),V={attribute:!0,type:String,converter:F,reflect:!1,useDefault:!1,hasChanged:$}; /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */Symbol.metadata??=Symbol("metadata"),O.litPropertyMetadata??=new WeakMap;let W=class extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,e=V){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(t,e),!e.noAccessor){const n=Symbol(),i=this.getPropertyDescriptor(t,n,e);void 0!==i&&A(this.prototype,t,i)}}static getPropertyDescriptor(t,e,n){const{get:i,set:r}=P(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:i,set(e){const o=i?.call(this);r?.call(this,e),this.requestUpdate(t,o,n)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??V}static _$Ei(){if(this.hasOwnProperty(B("elementProperties")))return;const t=z(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty(B("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(B("properties"))){const t=this.properties,e=[...L(t),...E(t)];for(const n of e)this.createProperty(n,t[n])}const t=this[Symbol.metadata];if(null!==t){const e=litPropertyMetadata.get(t);if(void 0!==e)for(const[t,n]of e)this.elementProperties.set(t,n)}this._$Eh=new Map;for(const[t,e]of this.elementProperties){const n=this._$Eu(t,e);void 0!==n&&this._$Eh.set(n,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(t){const e=[];if(Array.isArray(t)){const n=new Set(t.flat(1/0).reverse());for(const t of n)e.unshift(D(t))}else void 0!==t&&e.push(D(t));return e}static _$Eu(t,e){const n=e.attribute;return!1===n?void 0:"string"==typeof n?n:"string"==typeof t?t.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this))}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){const t=new Map,e=this.constructor.elementProperties;for(const n of e.keys())this.hasOwnProperty(n)&&(t.set(n,this[n]),delete this[n]);t.size>0&&(this._$Ep=t)}createRenderRoot(){const t=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((t,e)=>{if(w)t.adoptedStyleSheets=e.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(const n of e){const e=document.createElement("style"),i=b.litNonce;void 0!==i&&e.setAttribute("nonce",i),e.textContent=n.cssText,t.appendChild(e)}})(t,this.constructor.elementStyles),t}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(t=>t.hostConnected?.())}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.())}attributeChangedCallback(t,e,n){this._$AK(t,n)}_$ET(t,e){const n=this.constructor.elementProperties.get(t),i=this.constructor._$Eu(t,n);if(void 0!==i&&!0===n.reflect){const r=(void 0!==n.converter?.toAttribute?n.converter:F).toAttribute(e,n.type);this._$Em=t,null==r?this.removeAttribute(i):this.setAttribute(i,r),this._$Em=null}}_$AK(t,e){const n=this.constructor,i=n._$Eh.get(t);if(void 0!==i&&this._$Em!==i){const t=n.getPropertyOptions(i),r="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:F;this._$Em=i;const o=r.fromAttribute(e,t.type);this[i]=o??this._$Ej?.get(i)??o,this._$Em=null}}requestUpdate(t,e,n,i=!1,r){if(void 0!==t){const o=this.constructor;if(!1===i&&(r=this[t]),n??=o.getPropertyOptions(t),!((n.hasChanged??$)(r,e)||n.useDefault&&n.reflect&&r===this._$Ej?.get(t)&&!this.hasAttribute(o._$Eu(t,n))))return;this.C(t,e,n)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:n,reflect:i,wrapped:r},o){n&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,o??e??this[t]),!0!==r||void 0!==o)||(this._$AL.has(t)||(this.hasUpdated||n||(e=void 0),this._$AL.set(t,e)),!0===i&&this._$Em!==t&&(this._$Eq??=new Set).add(t))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,e]of this._$Ep)this[t]=e;this._$Ep=void 0}const t=this.constructor.elementProperties;if(t.size>0)for(const[e,n]of t){const{wrapped:t}=n,i=this[e];!0!==t||this._$AL.has(e)||void 0===i||this.C(e,void 0,n,i)}}let t=!1;const e=this._$AL;try{t=this.shouldUpdate(e),t?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(e){throw t=!1,this._$EM(),e}t&&this._$AE(e)}willUpdate(t){}_$AE(t){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return!0}update(t){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM()}updated(t){}firstUpdated(t){}};W.elementStyles=[],W.shadowRootOptions={mode:"open"},W[B("elementProperties")]=new Map,W[B("finalized")]=new Map,H?.({ReactiveElement:W}),(O.reactiveElementVersions??=[]).push("2.1.2"); + */Symbol.metadata??=Symbol("metadata"),z.litPropertyMetadata??=new WeakMap;let W=class extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,e=V){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(t,e),!e.noAccessor){const n=Symbol(),i=this.getPropertyDescriptor(t,n,e);void 0!==i&&I(this.prototype,t,i)}}static getPropertyDescriptor(t,e,n){const{get:i,set:r}=P(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:i,set(e){const o=i?.call(this);r?.call(this,e),this.requestUpdate(t,o,n)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??V}static _$Ei(){if(this.hasOwnProperty(B("elementProperties")))return;const t=O(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty(B("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(B("properties"))){const t=this.properties,e=[...L(t),...E(t)];for(const n of e)this.createProperty(n,t[n])}const t=this[Symbol.metadata];if(null!==t){const e=litPropertyMetadata.get(t);if(void 0!==e)for(const[t,n]of e)this.elementProperties.set(t,n)}this._$Eh=new Map;for(const[t,e]of this.elementProperties){const n=this._$Eu(t,e);void 0!==n&&this._$Eh.set(n,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(t){const e=[];if(Array.isArray(t)){const n=new Set(t.flat(1/0).reverse());for(const t of n)e.unshift(A(t))}else void 0!==t&&e.push(A(t));return e}static _$Eu(t,e){const n=e.attribute;return!1===n?void 0:"string"==typeof n?n:"string"==typeof t?t.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this))}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){const t=new Map,e=this.constructor.elementProperties;for(const n of e.keys())this.hasOwnProperty(n)&&(t.set(n,this[n]),delete this[n]);t.size>0&&(this._$Ep=t)}createRenderRoot(){const t=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((t,e)=>{if(w)t.adoptedStyleSheets=e.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(const n of e){const e=document.createElement("style"),i=x.litNonce;void 0!==i&&e.setAttribute("nonce",i),e.textContent=n.cssText,t.appendChild(e)}})(t,this.constructor.elementStyles),t}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(t=>t.hostConnected?.())}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.())}attributeChangedCallback(t,e,n){this._$AK(t,n)}_$ET(t,e){const n=this.constructor.elementProperties.get(t),i=this.constructor._$Eu(t,n);if(void 0!==i&&!0===n.reflect){const r=(void 0!==n.converter?.toAttribute?n.converter:F).toAttribute(e,n.type);this._$Em=t,null==r?this.removeAttribute(i):this.setAttribute(i,r),this._$Em=null}}_$AK(t,e){const n=this.constructor,i=n._$Eh.get(t);if(void 0!==i&&this._$Em!==i){const t=n.getPropertyOptions(i),r="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:F;this._$Em=i;const o=r.fromAttribute(e,t.type);this[i]=o??this._$Ej?.get(i)??o,this._$Em=null}}requestUpdate(t,e,n,i=!1,r){if(void 0!==t){const o=this.constructor;if(!1===i&&(r=this[t]),n??=o.getPropertyOptions(t),!((n.hasChanged??$)(r,e)||n.useDefault&&n.reflect&&r===this._$Ej?.get(t)&&!this.hasAttribute(o._$Eu(t,n))))return;this.C(t,e,n)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:n,reflect:i,wrapped:r},o){n&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,o??e??this[t]),!0!==r||void 0!==o)||(this._$AL.has(t)||(this.hasUpdated||n||(e=void 0),this._$AL.set(t,e)),!0===i&&this._$Em!==t&&(this._$Eq??=new Set).add(t))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,e]of this._$Ep)this[t]=e;this._$Ep=void 0}const t=this.constructor.elementProperties;if(t.size>0)for(const[e,n]of t){const{wrapped:t}=n,i=this[e];!0!==t||this._$AL.has(e)||void 0===i||this.C(e,void 0,n,i)}}let t=!1;const e=this._$AL;try{t=this.shouldUpdate(e),t?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(e){throw t=!1,this._$EM(),e}t&&this._$AE(e)}willUpdate(t){}_$AE(t){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return!0}update(t){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM()}updated(t){}firstUpdated(t){}};W.elementStyles=[],W.shadowRootOptions={mode:"open"},W[B("elementProperties")]=new Map,W[B("finalized")]=new Map,H?.({ReactiveElement:W}),(z.reactiveElementVersions??=[]).push("2.1.2"); /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const U=globalThis,G=t=>t,q=U.trustedTypes,j=q?q.createPolicy("lit-html",{createHTML:t=>t}):void 0,X="$lit$",Y=`lit$${Math.random().toFixed(9).slice(2)}$`,Z="?"+Y,K=`<${Z}>`,Q=document,J=()=>Q.createComment(""),tt=t=>null===t||"object"!=typeof t&&"function"!=typeof t,et=Array.isArray,nt="[ \t\n\f\r]",it=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,rt=/-->/g,ot=/>/g,at=RegExp(`>|${nt}(?:([^\\s"'>=/]+)(${nt}*=${nt}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),st=/'/g,lt=/"/g,ct=/^(?:script|style|textarea|title)$/i,ut=t=>(e,...n)=>({_$litType$:t,strings:e,values:n}),ht=ut(1),dt=ut(2),pt=Symbol.for("lit-noChange"),ft=Symbol.for("lit-nothing"),gt=new WeakMap,vt=Q.createTreeWalker(Q,129);function mt(t,e){if(!et(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==j?j.createHTML(e):e}const yt=(t,e)=>{const n=t.length-1,i=[];let r,o=2===e?"":3===e?"":"",a=it;for(let e=0;e"===l[0]?(a=r??it,c=-1):void 0===l[1]?c=-2:(c=a.lastIndex-l[2].length,s=l[1],a=void 0===l[3]?at:'"'===l[3]?lt:st):a===lt||a===st?a=at:a===rt||a===ot?a=it:(a=at,r=void 0);const h=a===at&&t[e+1].startsWith("/>")?" ":"";o+=a===it?n+K:c>=0?(i.push(s),n.slice(0,c)+X+n.slice(c)+Y+h):n+Y+(-2===c?e:h)}return[mt(t,o+(t[n]||"")+(2===e?"":3===e?"":"")),i]};class _t{constructor({strings:t,_$litType$:e},n){let i;this.parts=[];let r=0,o=0;const a=t.length-1,s=this.parts,[l,c]=yt(t,e);if(this.el=_t.createElement(l,n),vt.currentNode=this.el.content,2===e||3===e){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(i=vt.nextNode())&&s.length0){i.textContent=q?q.emptyScript:"";for(let n=0;net(t)||"function"==typeof t?.[Symbol.iterator])(t)?this.k(t):this._(t)}O(t){return this._$AA.parentNode.insertBefore(t,this._$AB)}T(t){this._$AH!==t&&(this._$AR(),this._$AH=this.O(t))}_(t){this._$AH!==ft&&tt(this._$AH)?this._$AA.nextSibling.data=t:this.T(Q.createTextNode(t)),this._$AH=t}$(t){const{values:e,_$litType$:n}=t,i="number"==typeof n?this._$AC(t):(void 0===n.el&&(n.el=_t.createElement(mt(n.h,n.h[0]),this.options)),n);if(this._$AH?._$AD===i)this._$AH.p(e);else{const t=new bt(i,this),n=t.u(this.options);t.p(e),this.T(n),this._$AH=t}}_$AC(t){let e=gt.get(t.strings);return void 0===e&>.set(t.strings,e=new _t(t)),e}k(t){et(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let n,i=0;for(const r of t)i===e.length?e.push(n=new wt(this.O(J()),this.O(J()),this,this.options)):n=e[i],n._$AI(r),i++;i2||""!==n[0]||""!==n[1]?(this._$AH=Array(n.length-1).fill(new String),this.strings=n):this._$AH=ft}_$AI(t,e=this,n,i){const r=this.strings;let o=!1;if(void 0===r)t=xt(this,t,e,0),o=!tt(t)||t!==this._$AH&&t!==pt,o&&(this._$AH=t);else{const i=t;let a,s;for(t=r[0],a=0;at,q=U.trustedTypes,j=q?q.createPolicy("lit-html",{createHTML:t=>t}):void 0,X="$lit$",Y=`lit$${Math.random().toFixed(9).slice(2)}$`,Z="?"+Y,K=`<${Z}>`,Q=document,J=()=>Q.createComment(""),tt=t=>null===t||"object"!=typeof t&&"function"!=typeof t,et=Array.isArray,nt="[ \t\n\f\r]",it=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,rt=/-->/g,ot=/>/g,at=RegExp(`>|${nt}(?:([^\\s"'>=/]+)(${nt}*=${nt}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),st=/'/g,lt=/"/g,ct=/^(?:script|style|textarea|title)$/i,ut=t=>(e,...n)=>({_$litType$:t,strings:e,values:n}),dt=ut(1),ht=ut(2),pt=Symbol.for("lit-noChange"),ft=Symbol.for("lit-nothing"),gt=new WeakMap,vt=Q.createTreeWalker(Q,129);function mt(t,e){if(!et(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==j?j.createHTML(e):e}const yt=(t,e)=>{const n=t.length-1,i=[];let r,o=2===e?"":3===e?"":"",a=it;for(let e=0;e"===l[0]?(a=r??it,c=-1):void 0===l[1]?c=-2:(c=a.lastIndex-l[2].length,s=l[1],a=void 0===l[3]?at:'"'===l[3]?lt:st):a===lt||a===st?a=at:a===rt||a===ot?a=it:(a=at,r=void 0);const d=a===at&&t[e+1].startsWith("/>")?" ":"";o+=a===it?n+K:c>=0?(i.push(s),n.slice(0,c)+X+n.slice(c)+Y+d):n+Y+(-2===c?e:d)}return[mt(t,o+(t[n]||"")+(2===e?"":3===e?"":"")),i]};class _t{constructor({strings:t,_$litType$:e},n){let i;this.parts=[];let r=0,o=0;const a=t.length-1,s=this.parts,[l,c]=yt(t,e);if(this.el=_t.createElement(l,n),vt.currentNode=this.el.content,2===e||3===e){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(i=vt.nextNode())&&s.length0){i.textContent=q?q.emptyScript:"";for(let n=0;net(t)||"function"==typeof t?.[Symbol.iterator])(t)?this.k(t):this._(t)}O(t){return this._$AA.parentNode.insertBefore(t,this._$AB)}T(t){this._$AH!==t&&(this._$AR(),this._$AH=this.O(t))}_(t){this._$AH!==ft&&tt(this._$AH)?this._$AA.nextSibling.data=t:this.T(Q.createTextNode(t)),this._$AH=t}$(t){const{values:e,_$litType$:n}=t,i="number"==typeof n?this._$AC(t):(void 0===n.el&&(n.el=_t.createElement(mt(n.h,n.h[0]),this.options)),n);if(this._$AH?._$AD===i)this._$AH.p(e);else{const t=new xt(i,this),n=t.u(this.options);t.p(e),this.T(n),this._$AH=t}}_$AC(t){let e=gt.get(t.strings);return void 0===e&>.set(t.strings,e=new _t(t)),e}k(t){et(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let n,i=0;for(const r of t)i===e.length?e.push(n=new wt(this.O(J()),this.O(J()),this,this.options)):n=e[i],n._$AI(r),i++;i2||""!==n[0]||""!==n[1]?(this._$AH=Array(n.length-1).fill(new String),this.strings=n):this._$AH=ft}_$AI(t,e=this,n,i){const r=this.strings;let o=!1;if(void 0===r)t=bt(this,t,e,0),o=!tt(t)||t!==this._$AH&&t!==pt,o&&(this._$AH=t);else{const i=t;let a,s;for(t=r[0],a=0;a{const i=n?.renderBefore??e;let r=i._$litPart$;if(void 0===r){const t=n?.renderBefore??null;i._$litPart$=r=new wt(e.insertBefore(J(),t),t,void 0,n??{})}return r._$AI(t),r})(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return pt}}At._$litElement$=!0,At.finalized=!0,It.litElementHydrateSupport?.({LitElement:At});const Pt=It.litElementPolyfillSupport;Pt?.({LitElement:At}),(It.litElementVersions??=[]).push("4.2.2"); + */class It extends W{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){const t=super.createRenderRoot();return this.renderOptions.renderBefore??=t.firstChild,t}update(t){const e=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(t),this._$Do=((t,e,n)=>{const i=n?.renderBefore??e;let r=i._$litPart$;if(void 0===r){const t=n?.renderBefore??null;i._$litPart$=r=new wt(e.insertBefore(J(),t),t,void 0,n??{})}return r._$AI(t),r})(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return pt}}It._$litElement$=!0,It.finalized=!0,Dt.litElementHydrateSupport?.({LitElement:It});const Pt=Dt.litElementPolyfillSupport;Pt?.({LitElement:It}),(Dt.litElementVersions??=[]).push("4.2.2"); /** * @license * Copyright 2017 Google LLC @@ -31,12 +31,12 @@ const Lt={attribute:!0,type:String,converter:F,reflect:!1,hasChanged:$},Et=(t=Lt * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */function zt(t){return(e,n)=>"object"==typeof n?Et(t,e,n):((t,e,n)=>{const i=e.hasOwnProperty(n);return e.constructor.createProperty(n,t),i?Object.getOwnPropertyDescriptor(e,n):void 0})(t,e,n)} + */function Ot(t){return(e,n)=>"object"==typeof n?Et(t,e,n):((t,e,n)=>{const i=e.hasOwnProperty(n);return e.constructor.createProperty(n,t),i?Object.getOwnPropertyDescriptor(e,n):void 0})(t,e,n)} /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */function Ot(t){return zt({...t,state:!0,attribute:!1})}const Nt={"&":"&","<":"<",">":">",'"':""","'":"'"};function Rt(t){return String(t).replace(/[&<>"']/g,t=>Nt[t]??t)}const Ht="span_panel_list_columns";function Bt(){try{const t=localStorage.getItem(Ht);if(!t)return 1;const e=parseInt(t,10);return 1===e||2===e||3===e?e:1}catch{return 1}}function Ft(t){try{localStorage.setItem(Ht,String(t))}catch{}}function $t(t,e,n={}){const r=Rt(t.device_name||i("header.default_name")),o=Rt(t.serial||""),a=Rt(t.firmware||""),s="current"===(e.chart_metric||"power"),l=!1!==n.showSwitches;return`\n
\n
\n
\n

${r}

\n ${o}\n \n ${l?`
\n ${Rt(i("header.enable_switches"))}\n
\n \n
\n
`:""}\n
\n ${function(t,e){const n="current"===(e.chart_metric||"power"),r=!!t.panel_entities?.site_power,o=!!t.panel_entities?.dsm_state,a=!!t.panel_entities?.current_power,s=!!t.panel_entities?.feedthrough_power,l=!!t.panel_entities?.pv_power,c=!!t.panel_entities?.battery_level;return`\n
\n ${r?`\n
\n ${i("header.site")}\n
\n 0\n ${n?"A":"kW"}\n
\n
`:""}\n ${o?`\n
\n ${i("header.grid")}\n
\n --\n
\n
`:""}\n ${a?`\n
\n ${i("header.upstream")}\n
\n --\n ${n?"A":"kW"}\n
\n
`:""}\n ${s?`\n
\n ${i("header.downstream")}\n
\n --\n ${n?"A":"kW"}\n
\n
`:""}\n ${l?`\n
\n ${i("header.solar")}\n
\n --\n ${n?"A":"kW"}\n
\n
`:""}\n ${c?`\n
\n ${i("header.battery")}\n
\n —\n %\n
\n
`:""}\n
\n `}(t,e)}\n
\n
\n
\n ${a}\n
\n \n \n
\n
\n
\n ${Object.entries(m).filter(([t])=>"unknown"!==t).map(([,t])=>{const e=Rt(t.icon),n=Rt(t.color),i=Rt(t.label());let r;return r=t.icon2?``:t.textLabel?`${Rt(t.textLabel)}`:``,`
${r}${i}
`}).join("")}\n
\n
\n
\n `}const Vt=g.power;function Wt(t){return Vt.unit(t)}function Ut(t){return(t<0?"-":"")+Vt.format(t)}function Gt(t){return(Math.abs(t)/1e3).toFixed(1)}function qt(t){return Math.ceil(t/2)}function jt(t){return t%2==0?1:0}function Xt(t){if(2!==t.length)return null;const[e,n]=[Math.min(...t),Math.max(...t)];return qt(e)===qt(n)?"row-span":jt(e)===jt(n)?"col-span":"row-span"}function Yt(t){const e=t.chart_metric??o;return g[e]??g[o]}function Zt(t,e){const n=function(t){return Yt(t).entityRole}(e);return t.entities?.[n]??t.entities?.power??null}function Kt(t){return new Promise(e=>setTimeout(e,t))}class Qt{constructor(t){this._store=t}async callWS(t,e,n){const i=n?.retries??3,r=n?.errorId??`ws:${String(e.type??"unknown")}`;return this._withRetry(()=>t.callWS(e),i,r,n?.errorMessage)}async callService(t,e,n,i,r,o){const a=o?.retries??3,s=o?.errorId??`svc:${e}.${n}`;return this._withRetry(()=>t.callService(e,n,i,r),a,s,o?.errorMessage)}async _withRetry(t,e,n,r){if(this._store.hasAnyPanelOffline())try{const e=await t();return this._store.remove(n),e}catch(t){const e=t instanceof Error?t:new Error(String(t));throw this._store.add({key:n,level:"error",message:r??i("error.panel_offline"),persistent:!1}),e}let o;for(let i=0;i<=e;i++)try{const e=await t();return this._store.remove(n),e}catch(t){if(o=t instanceof Error?t:new Error(String(t)),i{try{const n={};e&&(n.config_entry_id=e);const o={type:"call_service",domain:l,service:"get_monitoring_status",service_data:n,return_response:!0},a=this._retry?await this._retry.callWS(t,o,{errorId:"fetch:monitoring",errorMessage:i("error.monitoring_failed")}):await t.callWS(o),s=a?.response??null;return r===this._generation&&(this._status=s,this._lastFetch=Date.now()),s}catch(t){return console.warn("SPAN Panel: monitoring status fetch failed",t),r===this._generation&&(this._status=null),this._retry||this._errorStore?.add({key:"fetch:monitoring",level:"warning",message:i("error.monitoring_failed"),persistent:!1}),null}finally{this._inflight?.gen===r&&(this._inflight=null)}})();return this._inflight={gen:r,promise:o},o}invalidate(){this._lastFetch=0,this._generation++}get status(){return this._status}clear(){this._status=null,this._lastFetch=0,this._generation++}}class te{constructor(){this._caches=new Map,this._errorStore=null}get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t;for(const e of this._caches.values())e.errorStore=t}async fetchOne(t,e){let n=this._caches.get(e);return n||(n=new Jt,n.errorStore=this._errorStore,this._caches.set(e,n)),n.fetch(t,e)}invalidate(){for(const t of this._caches.values())t.invalidate()}clear(){this._caches.clear()}}function ee(t,e){return t?.circuits?t.circuits[e]??null:null}function ne(t,e,n,i){const r=[];return n||r.push("circuit-off"),i&&r.push("circuit-producer"),function(t){return!!t&&null!=t.over_threshold_since}(e)&&r.push("circuit-alert"),r.join(" ")}function ie(t,e,n,r,o,a,s,l,h,d=!1){const p=e.entities?.power,f=p?a.states[p]:null,g=f&&parseFloat(f.state)||0,v=e.device_type===u||g<0,y=e.entities?.switch,_=y?a.states[y]:null,x=_?"on"===_.state:(f?.attributes?.relay_state||e.relay_state)===c,b=e.breaker_rating_a,w=b?`${Math.round(b)}A`:"",S=Rt(e.name||i("grid.unknown")),C=Yt(s);let M;if("current"===C.entityRole){const t=e.entities?.current,n=t?a.states[t]:null,i=n&&parseFloat(n.state)||0;M=`${C.format(i)}A`}else M=`${Ut(g)}${Wt(g)}`;const k=h||"unknown";let T="";if("unknown"!==k){const t=m[k]??m.unknown??{icon:"mdi:help",color:"#999",label:()=>"Unknown"},e=Rt(t.label()),n=Rt(t.icon),i=Rt(t.color);if(t.icon2){T=`\n \n \n `}else if(t.textLabel){T=`\n \n ${Rt(t.textLabel)}\n `}else T=``}const D=``;let I="",A=l?.utilization_pct??null;if(null==A&&e.breaker_rating_a){const t=e.entities?.current,n=t?a.states[t]:null,i=n?Math.abs(parseFloat(n.state)||0):0;A=Math.round(i/e.breaker_rating_a*1e3)/10}if(null!=A){I=`=80?"utilization-warning":"utilization-normal"}">${Math.round(A)}%`}return`\n
\n
\n
\n ${w?`${w}`:""}\n ${I}\n ${S}\n
\n
\n \n ${M}\n \n ${!1!==e.is_user_controllable&&e.entities?.switch?`\n
\n ${i(x?"grid.on":"grid.off")}\n \n
\n `:""}\n
\n
\n
\n ${T}\n ${D}\n
\n
\n
\n `}function re(t,e){return`\n
\n —\n
\n `}const oe={names:["power","battery power"],suffixes:["_power"]},ae={names:["battery level","battery percentage"],suffixes:["_battery_level","_battery_percentage"]},se={names:["state of energy"],suffixes:["_soe_kwh"]},le={names:["nameplate capacity"],suffixes:["_nameplate_capacity"]};function ce(t,e){if(!t.entities)return null;for(const[n,i]of Object.entries(t.entities)){if("sensor"!==i.domain)continue;const t=(i.original_name??"").toLowerCase();if(e.names.some(e=>t===e))return n;if(i.unique_id&&e.suffixes.some(t=>i.unique_id.endsWith(t)))return n}return null}function ue(t){return ce(t,oe)}function he(t){return ce(t,ae)}function de(t){return ce(t,se)}function pe(t){return ce(t,le)}function fe(t,e,n,i){const r=n.visible_sub_entities||{};let o="";if(!t.entities)return o;for(const[n,a]of Object.entries(t.entities)){if(i.has(n))continue;if(!0!==r[n])continue;const s=e.states[n];if(!s)continue;let l=a.original_name||s.attributes.friendly_name||n;const c=t.name||"";let u;if(l.startsWith(c+" ")&&(l=l.slice(c.length+1)),e.formatEntityState)u=e.formatEntityState(s);else{u=s.state;const t=s.attributes.unit_of_measurement||"";t&&(u+=" "+t)}if("Wh"===(s.attributes.unit_of_measurement||"")){const t=parseFloat(s.state);isNaN(t)||(u=(t/1e3).toFixed(1)+" kWh")}o+=`\n
\n ${Rt(l)}:\n ${Rt(u)}\n
\n `}return o}function ge(t,e,n,r,o,a){if(n){const e=[{key:`${p}${t}_soc`,title:i("subdevice.soc"),available:!!o},{key:`${p}${t}_soe`,title:i("subdevice.soe"),available:!!a},{key:`${p}${t}_power`,title:i("subdevice.power"),available:!!r}].filter(t=>t.available);return`\n
\n ${e.map(t=>`\n
\n
${Rt(t.title)}
\n
\n
\n `).join("")}\n
\n `}return r?`
`:""}function ve(t){const e=void 0!==t.history_days||void 0!==t.history_hours||void 0!==t.history_minutes,n=60*(60*(24*(e&&parseInt(String(t.history_days))||0)+(e&&parseInt(String(t.history_hours))||0))+(e?parseInt(String(t.history_minutes))||0:5))*1e3;return Math.max(n,6e4)}function me(t){const e=s[t];return e?e.ms:s[a].ms}function ye(t){const e=t/1e3;return e<=600?Math.ceil(e):Math.min(5e3,Math.ceil(e/5))}function _e(t){return Math.max(500,Math.floor(t/5e3))}function xe(t,e,n,i,r,o){t.has(e)||t.set(e,[]);const a=t.get(e);a.push({time:i,value:n});const s=a.findIndex(t=>t.time>=r);s>0?a.splice(0,s):-1===s&&(a.length=0),a.length>o&&a.splice(0,a.length-o)}function be(t,e,n=500){if(0===t.length)return t;t.sort((t,e)=>t.time-e.time);const i=[t[0]];for(let e=1;e=n&&i.push(t[e]);return i.length>e&&i.splice(0,i.length-e),i}async function we(t,e,n,i,r){const o=new Date(Date.now()-i).toISOString(),a=i/36e5>72?"hour":"5minute",s=await t.callWS({type:"recorder/statistics_during_period",start_time:o,statistic_ids:e,period:a,types:["mean"]});for(const[t,e]of Object.entries(s)){const i=n.get(t);if(!i||!e)continue;const o=[];for(const t of e){const e=t.mean;if(null==e||!Number.isFinite(e))continue;const n=t.start;n>0&&o.push({time:n,value:e})}if(o.length>0){const t=r.get(i)||[],e=[...o,...t];e.sort((t,e)=>t.time-e.time),r.set(i,e)}}}async function Se(t,e,n,i,r){const o=new Date(Date.now()-i).toISOString(),a=await t.callWS({type:"history/history_during_period",start_time:o,entity_ids:e,minimal_response:!0,significant_changes_only:!0,no_attributes:!0}),s=ye(i),l=_e(i);for(const[t,e]of Object.entries(a)){const i=n.get(t);if(!i||!e)continue;const o=[];for(const t of e){const e=parseFloat(t.s);if(!Number.isFinite(e))continue;const n=1e3*(t.lu||t.lc||0);n>0&&o.push({time:n,value:e})}if(o.length>0){const t=r.get(i)||[],e=[...o,...t];r.set(i,be(e,s,l))}}}function Ce(t){if(!t.sub_devices)return[];const e=[];for(const[n,i]of Object.entries(t.sub_devices)){const t={power:ue(i)};i.type===h&&(t.soc=he(i),t.soe=de(i));for(const[i,r]of Object.entries(t))r&&e.push({entityId:r,key:`${p}${n}_${i}`,devId:n})}return e}async function Me(t,e,n,i,r,o){if(!e||!t)return;const a=new Map;for(const[t,i]of Object.entries(e.circuits)){const e=Zt(i,n);if(!e)continue;let o;o=r&&r.has(t)?me(r.get(t)):ve(n),a.has(o)||a.set(o,{entityIds:[],uuidByEntity:new Map});const s=a.get(o);s.entityIds.push(e),s.uuidByEntity.set(e,t)}for(const{entityId:t,key:i,devId:r}of Ce(e)){let e;e=o&&o.has(r)?me(o.get(r)):ve(n),a.has(e)||a.set(e,{entityIds:[],uuidByEntity:new Map});const s=a.get(e);s.entityIds.push(t),s.uuidByEntity.set(t,i)}const s=[];for(const[e,n]of a){if(0===n.entityIds.length)continue;e>2592e5?s.push(we(t,n.entityIds,n.uuidByEntity,e,i)):s.push(Se(t,n.entityIds,n.uuidByEntity,e,i))}await Promise.all(s)}var ke=function(){this.firefox=!1,this.ie=!1,this.edge=!1,this.newEdge=!1,this.weChat=!1},Te=new function(){this.browser=new ke,this.node=!1,this.wxa=!1,this.worker=!1,this.svgSupported=!1,this.touchEventsSupported=!1,this.pointerEventsSupported=!1,this.domSupported=!1,this.transformSupported=!1,this.transform3dSupported=!1,this.hasGlobalWindow="undefined"!=typeof window};"object"==typeof wx&&"function"==typeof wx.getSystemInfoSync?(Te.wxa=!0,Te.touchEventsSupported=!0):"undefined"==typeof document&&"undefined"!=typeof self?Te.worker=!0:!Te.hasGlobalWindow||"Deno"in window||"undefined"!=typeof navigator&&"string"==typeof navigator.userAgent&&navigator.userAgent.indexOf("Node.js")>-1?(Te.node=!0,Te.svgSupported=!0):function(t,e){var n=e.browser,i=t.match(/Firefox\/([\d.]+)/),r=t.match(/MSIE\s([\d.]+)/)||t.match(/Trident\/.+?rv:(([\d.]+))/),o=t.match(/Edge?\/([\d.]+)/),a=/micromessenger/i.test(t);i&&(n.firefox=!0,n.version=i[1]);r&&(n.ie=!0,n.version=r[1]);o&&(n.edge=!0,n.version=o[1],n.newEdge=+o[1].split(".")[0]>18);a&&(n.weChat=!0);e.svgSupported="undefined"!=typeof SVGRect,e.touchEventsSupported="ontouchstart"in window&&!n.ie&&!n.edge,e.pointerEventsSupported="onpointerdown"in window&&(n.edge||n.ie&&+n.version>=11);var s=e.domSupported="undefined"!=typeof document;if(s){var l=document.documentElement.style;e.transform3dSupported=(n.ie&&"transition"in l||n.edge||"WebKitCSSMatrix"in window&&"m11"in new WebKitCSSMatrix||"MozPerspective"in l)&&!("OTransition"in l),e.transformSupported=e.transform3dSupported||n.ie&&+n.version>=9}}(navigator.userAgent,Te);var De="sans-serif",Ie="12px "+De;var Ae,Pe,Le=function(t){var e={};if("undefined"==typeof JSON)return e;for(var n=0;n=0)o=r*t.length;else for(var a=0;a>1)%2;a.style.cssText=["position: absolute","visibility: hidden","padding: 0","margin: 0","border-width: 0","user-select: none","width:0","height:0",i[s]+":0",r[l]+":0",i[1-s]+":auto",r[1-l]+":auto",""].join("!important;"),t.appendChild(a),n.push(a)}return e.clearMarkers=function(){en(n,function(t){t.parentNode&&t.parentNode.removeChild(t)})},n}(e,o),s=function(t,e,n){for(var i=n?"invTrans":"trans",r=e[i],o=e.srcCoords,a=[],s=[],l=!0,c=0;c<4;c++){var u=t[c].getBoundingClientRect(),h=2*c,d=u.left,p=u.top;a.push(d,p),l=l&&o&&d===o[h]&&p===o[h+1],s.push(t[c].offsetLeft,t[c].offsetTop)}return l&&r?r:(e.srcCoords=a,e[i]=n?ei(s,a):ei(a,s))}(a,o,r);if(s)return s(t,n,i),!0}return!1}function oi(t){return"CANVAS"===t.nodeName.toUpperCase()}var ai=/([&<>"'])/g,si={"&":"&","<":"<",">":">",'"':""","'":"'"};function li(t){return null==t?"":(t+"").replace(ai,function(t,e){return si[e]})}var ci=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,ui=[],hi=Te.browser.firefox&&+Te.browser.version.split(".")[0]<39;function di(t,e,n,i){return n=n||{},i?pi(t,e,n):hi&&null!=e.layerX&&e.layerX!==e.offsetX?(n.zrX=e.layerX,n.zrY=e.layerY):null!=e.offsetX?(n.zrX=e.offsetX,n.zrY=e.offsetY):pi(t,e,n),n}function pi(t,e,n){if(Te.domSupported&&t.getBoundingClientRect){var i=e.clientX,r=e.clientY;if(oi(t)){var o=t.getBoundingClientRect();return n.zrX=i-o.left,void(n.zrY=r-o.top)}if(ri(ui,t,i,r))return n.zrX=ui[0],void(n.zrY=ui[1])}n.zrX=n.zrY=0}function fi(t){return t||window.event}function gi(t,e,n){if(null!=(e=fi(e)).zrX)return e;var i=e.type;if(i&&i.indexOf("touch")>=0){var r="touchend"!==i?e.targetTouches[0]:e.changedTouches[0];r&&di(t,r,e,n)}else{di(t,e,e,n);var o=function(t){var e=t.wheelDelta;if(e)return e;var n=t.deltaX,i=t.deltaY;if(null==n||null==i)return e;return 3*(0!==i?Math.abs(i):Math.abs(n))*(i>0?-1:i<0?1:n>0?-1:1)}(e);e.zrDelta=o?o/120:-(e.detail||0)/3}var a=e.button;return null==e.which&&void 0!==a&&ci.test(e.type)&&(e.which=1&a?1:2&a?3:4&a?2:0),e}function vi(t,e,n,i){t.removeEventListener(e,n,i)}var mi=function(t){t.preventDefault(),t.stopPropagation(),t.cancelBubble=!0},yi=function(){function t(){this._track=[]}return t.prototype.recognize=function(t,e,n){return this._doTrack(t,e,n),this._recognize(t)},t.prototype.clear=function(){return this._track.length=0,this},t.prototype._doTrack=function(t,e,n){var i=t.touches;if(i){for(var r={points:[],touches:[],target:e,event:t},o=0,a=i.length;o1&&r&&r.length>1){var a=_i(r)/_i(o);!isFinite(a)&&(a=1),e.pinchScale=a;var s=[((i=r)[0][0]+i[1][0])/2,(i[0][1]+i[1][1])/2];return e.pinchX=s[0],e.pinchY=s[1],{type:"pinch",target:t[0].target,event:e}}}}};function bi(t){return t[0]=1,t[1]=0,t[2]=0,t[3]=1,t[4]=0,t[5]=0,t}function wi(t,e){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t[4]=e[4],t[5]=e[5],t}function Si(t,e,n){var i=e[0]*n[0]+e[2]*n[1],r=e[1]*n[0]+e[3]*n[1],o=e[0]*n[2]+e[2]*n[3],a=e[1]*n[2]+e[3]*n[3],s=e[0]*n[4]+e[2]*n[5]+e[4],l=e[1]*n[4]+e[3]*n[5]+e[5];return t[0]=i,t[1]=r,t[2]=o,t[3]=a,t[4]=s,t[5]=l,t}function Ci(t,e,n){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t[4]=e[4]+n[0],t[5]=e[5]+n[1],t}function Mi(t,e,n,i){void 0===i&&(i=[0,0]);var r=e[0],o=e[2],a=e[4],s=e[1],l=e[3],c=e[5],u=Math.sin(n),h=Math.cos(n);return t[0]=r*h+s*u,t[1]=-r*u+s*h,t[2]=o*h+l*u,t[3]=-o*u+h*l,t[4]=h*(a-i[0])+u*(c-i[1])+i[0],t[5]=h*(c-i[1])-u*(a-i[0])+i[1],t}function ki(t,e){var n=e[0],i=e[2],r=e[4],o=e[1],a=e[3],s=e[5],l=n*a-o*i;return l?(l=1/l,t[0]=a*l,t[1]=-o*l,t[2]=-i*l,t[3]=n*l,t[4]=(i*s-a*r)*l,t[5]=(o*r-n*s)*l,t):null}var Ti=function(){function t(t,e){this.x=t||0,this.y=e||0}return t.prototype.copy=function(t){return this.x=t.x,this.y=t.y,this},t.prototype.clone=function(){return new t(this.x,this.y)},t.prototype.set=function(t,e){return this.x=t,this.y=e,this},t.prototype.equal=function(t){return t.x===this.x&&t.y===this.y},t.prototype.add=function(t){return this.x+=t.x,this.y+=t.y,this},t.prototype.scale=function(t){this.x*=t,this.y*=t},t.prototype.scaleAndAdd=function(t,e){this.x+=t.x*e,this.y+=t.y*e},t.prototype.sub=function(t){return this.x-=t.x,this.y-=t.y,this},t.prototype.dot=function(t){return this.x*t.x+this.y*t.y},t.prototype.len=function(){return Math.sqrt(this.x*this.x+this.y*this.y)},t.prototype.lenSquare=function(){return this.x*this.x+this.y*this.y},t.prototype.normalize=function(){var t=this.len();return this.x/=t,this.y/=t,this},t.prototype.distance=function(t){var e=this.x-t.x,n=this.y-t.y;return Math.sqrt(e*e+n*n)},t.prototype.distanceSquare=function(t){var e=this.x-t.x,n=this.y-t.y;return e*e+n*n},t.prototype.negate=function(){return this.x=-this.x,this.y=-this.y,this},t.prototype.transform=function(t){if(t){var e=this.x,n=this.y;return this.x=t[0]*e+t[2]*n+t[4],this.y=t[1]*e+t[3]*n+t[5],this}},t.prototype.toArray=function(t){return t[0]=this.x,t[1]=this.y,t},t.prototype.fromArray=function(t){this.x=t[0],this.y=t[1]},t.set=function(t,e,n){t.x=e,t.y=n},t.copy=function(t,e){t.x=e.x,t.y=e.y},t.len=function(t){return Math.sqrt(t.x*t.x+t.y*t.y)},t.lenSquare=function(t){return t.x*t.x+t.y*t.y},t.dot=function(t,e){return t.x*e.x+t.y*e.y},t.add=function(t,e,n){t.x=e.x+n.x,t.y=e.y+n.y},t.sub=function(t,e,n){t.x=e.x-n.x,t.y=e.y-n.y},t.scale=function(t,e,n){t.x=e.x*n,t.y=e.y*n},t.scaleAndAdd=function(t,e,n,i){t.x=e.x+n.x*i,t.y=e.y+n.y*i},t.lerp=function(t,e,n,i){var r=1-i;t.x=r*e.x+i*n.x,t.y=r*e.y+i*n.y},t}(),Di=Math.min,Ii=Math.max,Ai=Math.abs,Pi=["x","y"],Li=["width","height"],Ei=new Ti,zi=new Ti,Oi=new Ti,Ni=new Ti,Ri=Gi(),Hi=Ri.minTv,Bi=Ri.maxTv,Fi=[0,0],$i=function(){function t(e,n,i,r){t.set(this,e,n,i,r)}return t.set=function(t,e,n,i,r){return i<0&&(e+=i,i=-i),r<0&&(n+=r,r=-r),t.x=e,t.y=n,t.width=i,t.height=r,t},t.prototype.union=function(t){var e=Di(t.x,this.x),n=Di(t.y,this.y);isFinite(this.x)&&isFinite(this.width)?this.width=Ii(t.x+t.width,this.x+this.width)-e:this.width=t.width,isFinite(this.y)&&isFinite(this.height)?this.height=Ii(t.y+t.height,this.y+this.height)-n:this.height=t.height,this.x=e,this.y=n},t.prototype.applyTransform=function(e){t.applyTransform(this,this,e)},t.prototype.calculateTransform=function(t){var e=this,n=t.width/e.width,i=t.height/e.height,r=[1,0,0,1,0,0];return Ci(r,r,[-e.x,-e.y]),function(t,e,n){var i=n[0],r=n[1];t[0]=e[0]*i,t[1]=e[1]*r,t[2]=e[2]*i,t[3]=e[3]*r,t[4]=e[4]*i,t[5]=e[5]*r}(r,r,[n,i]),Ci(r,r,[t.x,t.y]),r},t.prototype.intersect=function(e,n,i){return t.intersect(this,e,n,i)},t.intersect=function(e,n,i,r){i&&Ti.set(i,0,0);var o=r&&r.outIntersectRect||null,a=r&&r.clamp;if(o&&(o.x=o.y=o.width=o.height=NaN),!e||!n)return!1;e instanceof t||(e=t.set(Vi,e.x,e.y,e.width,e.height)),n instanceof t||(n=t.set(Wi,n.x,n.y,n.width,n.height));var s=!!i;Ri.reset(r,s);var l=Ri.touchThreshold,c=e.x+l,u=e.x+e.width-l,h=e.y+l,d=e.y+e.height-l,p=n.x+l,f=n.x+n.width-l,g=n.y+l,v=n.y+n.height-l;if(c>u||h>d||p>f||g>v)return!1;var m=!(u=t.x&&e<=t.x+t.width&&n>=t.y&&n<=t.y+t.height},t.prototype.contain=function(e,n){return t.contain(this,e,n)},t.prototype.clone=function(){return new t(this.x,this.y,this.width,this.height)},t.prototype.copy=function(e){t.copy(this,e)},t.prototype.plain=function(){return{x:this.x,y:this.y,width:this.width,height:this.height}},t.prototype.isFinite=function(){return isFinite(this.x)&&isFinite(this.y)&&isFinite(this.width)&&isFinite(this.height)},t.prototype.isZero=function(){return 0===this.width||0===this.height},t.create=function(e){return new t(e.x,e.y,e.width,e.height)},t.copy=function(t,e){return t.x=e.x,t.y=e.y,t.width=e.width,t.height=e.height,t},t.applyTransform=function(e,n,i){if(i){if(i[1]<1e-5&&i[1]>-1e-5&&i[2]<1e-5&&i[2]>-1e-5){var r=i[0],o=i[3],a=i[4],s=i[5];return e.x=n.x*r+a,e.y=n.y*o+s,e.width=n.width*r,e.height=n.height*o,e.width<0&&(e.x+=e.width,e.width=-e.width),void(e.height<0&&(e.y+=e.height,e.height=-e.height))}Ei.x=Oi.x=n.x,Ei.y=Ni.y=n.y,zi.x=Ni.x=n.x+n.width,zi.y=Oi.y=n.y+n.height,Ei.transform(i),Ni.transform(i),zi.transform(i),Oi.transform(i),e.x=Di(Ei.x,zi.x,Oi.x,Ni.x),e.y=Di(Ei.y,zi.y,Oi.y,Ni.y);var l=Ii(Ei.x,zi.x,Oi.x,Ni.x),c=Ii(Ei.y,zi.y,Oi.y,Ni.y);e.width=l-e.x,e.height=c-e.y}else e!==n&&t.copy(e,n)},t}(),Vi=new $i(0,0,0,0),Wi=new $i(0,0,0,0);function Ui(t,e,n,i,r,o,a,s){var l=Ai(e-n),c=Ai(i-t),u=Di(l,c),h=Pi[r],d=Pi[1-r],p=Li[r];e=c||!Ri.bidirectional)&&(Hi[h]=-c,Hi[d]=0,Ri.useDir&&Ri.calcDirMTV())))}function Gi(){var t=0,e=new Ti,n=new Ti,i={minTv:new Ti,maxTv:new Ti,useDir:!1,dirMinTv:new Ti,touchThreshold:0,bidirectional:!0,negativeSize:!1,reset:function(r,o){i.touchThreshold=0,r&&null!=r.touchThreshold&&(i.touchThreshold=Ii(0,r.touchThreshold)),i.negativeSize=!1,o&&(i.minTv.set(1/0,1/0),i.maxTv.set(0,0),i.useDir=!1,r&&null!=r.direction&&(i.useDir=!0,i.dirMinTv.copy(i.minTv),n.copy(i.minTv),t=r.direction,i.bidirectional=null==r.bidirectional||!!r.bidirectional,i.bidirectional||e.set(Math.cos(t),Math.sin(t))))},calcDirMTV:function(){var o=i.minTv,a=i.dirMinTv,s=o.y*o.y+o.x*o.x,l=Math.sin(t),c=Math.cos(t),u=l*o.y+c*o.x;r(u)?r(o.x)&&r(o.y)&&a.set(0,0):(n.x=s*c/u,n.y=s*l/u,r(n.x)&&r(n.y)?a.set(0,0):(i.bidirectional||e.dot(n)>0)&&n.len()=0;c--){var u=i[c];u===n||u.ignore||u.ignoreCoarsePointer||u.parent&&u.parent.ignoreCoarsePointer||(Ki.copy(u.getBoundingRect()),u.transform&&Ki.applyTransform(u.transform),Ki.intersect(l)&&o.push(u))}if(o.length)for(var h=Math.PI/12,d=2*Math.PI,p=0;p=0;o--){var a=t[o],s=void 0;if(a!==r&&!a.ignore&&(s=Ji(a,n,i))&&(!e.topTarget&&(e.topTarget=a),s!==qi)){e.target=a;break}}}function er(t,e,n){var i=t.painter;return e<0||e>i.getWidth()||n<0||n>i.getHeight()}en(["click","mousedown","mouseup","mousewheel","dblclick","contextmenu"],function(t){Qi.prototype[t]=function(e){var n,i,r=e.zrX,o=e.zrY,a=er(this,r,o);if("mouseup"===t&&a||(i=(n=this.findHover(r,o)).target),"mousedown"===t)this._downEl=i,this._downPoint=[e.zrX,e.zrY],this._upEl=i;else if("mouseup"===t)this._upEl=i;else if("click"===t){if(this._downEl!==this._upEl||!this._downPoint||Gn(this._downPoint,[e.zrX,e.zrY])>4)return;this._downPoint=null}this.dispatchToElement(n,t,e)}});function nr(t,e,n,i){var r=e+1;if(r===n)return 1;if(i(t[r++],t[e])<0){for(;r=0;)r++;return r-e}function ir(t,e,n,i,r){for(i===e&&i++;i>>1])<0?l=o:s=o+1;var c=i-s;switch(c){case 3:t[s+3]=t[s+2];case 2:t[s+2]=t[s+1];case 1:t[s+1]=t[s];break;default:for(;c>0;)t[s+c]=t[s+c-1],c--}t[s]=a}}function rr(t,e,n,i,r,o){var a=0,s=0,l=1;if(o(t,e[n+r])>0){for(s=i-r;l0;)a=l,(l=1+(l<<1))<=0&&(l=s);l>s&&(l=s),a+=r,l+=r}else{for(s=r+1;ls&&(l=s);var c=a;a=r-l,l=r-c}for(a++;a>>1);o(t,e[n+u])>0?a=u+1:l=u}return l}function or(t,e,n,i,r,o){var a=0,s=0,l=1;if(o(t,e[n+r])<0){for(s=r+1;ls&&(l=s);var c=a;a=r-l,l=r-c}else{for(s=i-r;l=0;)a=l,(l=1+(l<<1))<=0&&(l=s);l>s&&(l=s),a+=r,l+=r}for(a++;a>>1);o(t,e[n+u])<0?l=u:a=u+1}return l}function ar(t,e){var n,i,r=7,o=0,a=[];function s(s){var l=n[s],c=i[s],u=n[s+1],h=i[s+1];i[s]=c+h,s===o-3&&(n[s+1]=n[s+2],i[s+1]=i[s+2]),o--;var d=or(t[u],t,l,c,0,e);l+=d,0!==(c-=d)&&0!==(h=rr(t[l+c-1],t,u,h,h-1,e))&&(c<=h?function(n,i,o,s){var l=0;for(l=0;l=7||p>=7);if(f)break;g<0&&(g=0),g+=2}if((r=g)<1&&(r=1),1===i){for(l=0;l=0;l--)t[p+l]=t[d+l];return void(t[h]=a[u])}var f=r;for(;;){var g=0,v=0,m=!1;do{if(e(a[u],t[c])<0){if(t[h--]=t[c--],g++,v=0,0===--i){m=!0;break}}else if(t[h--]=a[u--],v++,g=0,1===--s){m=!0;break}}while((g|v)=0;l--)t[p+l]=t[d+l];if(0===i){m=!0;break}}if(t[h--]=a[u--],1===--s){m=!0;break}if(0!==(v=s-rr(t[c],a,0,s,s-1,e))){for(s-=v,p=(h-=v)+1,d=(u-=v)+1,l=0;l=7||v>=7);if(m)break;f<0&&(f=0),f+=2}(r=f)<1&&(r=1);if(1===s){for(p=(h-=i)+1,d=(c-=i)+1,l=i-1;l>=0;l--)t[p+l]=t[d+l];t[h]=a[u]}else{if(0===s)throw new Error;for(d=h-(s-1),l=0;l1;){var t=o-2;if(t>=1&&i[t-1]<=i[t]+i[t+1]||t>=2&&i[t-2]<=i[t]+i[t-1])i[t-1]i[t+1])break;s(t)}},forceMergeRuns:function(){for(;o>1;){var t=o-2;t>0&&i[t-1]=32;)e|=1&t,t>>=1;return t+e}(r);do{if((o=nr(t,n,i,e))s&&(l=s),ir(t,n,n+l,n+o,e),o=l}a.pushRun(n,o),a.mergeRuns(),r-=o,n+=o}while(0!==r);a.forceMergeRuns()}}}var lr=!1;function cr(){lr||(lr=!0,console.warn("z / z2 / zlevel of displayable is invalid, which may cause unexpected errors"))}function ur(t,e){return t.zlevel===e.zlevel?t.z===e.z?t.z2-e.z2:t.z-e.z:t.zlevel-e.zlevel}var hr,dr=function(){function t(){this._roots=[],this._displayList=[],this._displayListLen=0,this.displayableSortFunc=ur}return t.prototype.traverse=function(t,e){for(var n=0;n=0&&this._roots.splice(i,1)}},t.prototype.delAllRoots=function(){this._roots=[],this._displayList=[],this._displayListLen=0},t.prototype.getRoots=function(){return this._roots},t.prototype.dispose=function(){this._displayList=null,this._roots=null},t}();hr=Te.hasGlobalWindow&&(window.requestAnimationFrame&&window.requestAnimationFrame.bind(window)||window.msRequestAnimationFrame&&window.msRequestAnimationFrame.bind(window)||window.mozRequestAnimationFrame||window.webkitRequestAnimationFrame)||function(t){return setTimeout(t,16)};var pr={linear:function(t){return t},quadraticIn:function(t){return t*t},quadraticOut:function(t){return t*(2-t)},quadraticInOut:function(t){return(t*=2)<1?.5*t*t:-.5*(--t*(t-2)-1)},cubicIn:function(t){return t*t*t},cubicOut:function(t){return--t*t*t+1},cubicInOut:function(t){return(t*=2)<1?.5*t*t*t:.5*((t-=2)*t*t+2)},quarticIn:function(t){return t*t*t*t},quarticOut:function(t){return 1- --t*t*t*t},quarticInOut:function(t){return(t*=2)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2)},quinticIn:function(t){return t*t*t*t*t},quinticOut:function(t){return--t*t*t*t*t+1},quinticInOut:function(t){return(t*=2)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2)},sinusoidalIn:function(t){return 1-Math.cos(t*Math.PI/2)},sinusoidalOut:function(t){return Math.sin(t*Math.PI/2)},sinusoidalInOut:function(t){return.5*(1-Math.cos(Math.PI*t))},exponentialIn:function(t){return 0===t?0:Math.pow(1024,t-1)},exponentialOut:function(t){return 1===t?1:1-Math.pow(2,-10*t)},exponentialInOut:function(t){return 0===t?0:1===t?1:(t*=2)<1?.5*Math.pow(1024,t-1):.5*(2-Math.pow(2,-10*(t-1)))},circularIn:function(t){return 1-Math.sqrt(1-t*t)},circularOut:function(t){return Math.sqrt(1- --t*t)},circularInOut:function(t){return(t*=2)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1)},elasticIn:function(t){var e,n=.1;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=.4*Math.asin(1/n)/(2*Math.PI),-n*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/.4))},elasticOut:function(t){var e,n=.1;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=.4*Math.asin(1/n)/(2*Math.PI),n*Math.pow(2,-10*t)*Math.sin((t-e)*(2*Math.PI)/.4)+1)},elasticInOut:function(t){var e,n=.1,i=.4;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=i*Math.asin(1/n)/(2*Math.PI),(t*=2)<1?n*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/i)*-.5:n*Math.pow(2,-10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/i)*.5+1)},backIn:function(t){var e=1.70158;return t*t*((e+1)*t-e)},backOut:function(t){var e=1.70158;return--t*t*((e+1)*t+e)+1},backInOut:function(t){var e=2.5949095;return(t*=2)<1?t*t*((e+1)*t-e)*.5:.5*((t-=2)*t*((e+1)*t+e)+2)},bounceIn:function(t){return 1-pr.bounceOut(1-t)},bounceOut:function(t){return t<1/2.75?7.5625*t*t:t<2/2.75?7.5625*(t-=1.5/2.75)*t+.75:t<2.5/2.75?7.5625*(t-=2.25/2.75)*t+.9375:7.5625*(t-=2.625/2.75)*t+.984375},bounceInOut:function(t){return t<.5?.5*pr.bounceIn(2*t):.5*pr.bounceOut(2*t-1)+.5}},fr=Math.pow,gr=Math.sqrt,vr=1e-8,mr=1e-4,yr=gr(3),_r=1/3,xr=Hn(),br=Hn(),wr=Hn();function Sr(t){return t>-1e-8&&tvr||t<-1e-8}function Mr(t,e,n,i,r){var o=1-r;return o*o*(o*t+3*r*e)+r*r*(r*i+3*o*n)}function kr(t,e,n,i,r){var o=1-r;return 3*(((e-t)*o+2*(n-e)*r)*o+(i-n)*r*r)}function Tr(t,e,n,i,r,o){var a=i+3*(e-n)-t,s=3*(n-2*e+t),l=3*(e-t),c=t-r,u=s*s-3*a*l,h=s*l-9*a*c,d=l*l-3*s*c,p=0;if(Sr(u)&&Sr(h)){if(Sr(s))o[0]=0;else(C=-l/s)>=0&&C<=1&&(o[p++]=C)}else{var f=h*h-4*u*d;if(Sr(f)){var g=h/u,v=-g/2;(C=-s/a+g)>=0&&C<=1&&(o[p++]=C),v>=0&&v<=1&&(o[p++]=v)}else if(f>0){var m=gr(f),y=u*s+1.5*a*(-h+m),_=u*s+1.5*a*(-h-m);(C=(-s-((y=y<0?-fr(-y,_r):fr(y,_r))+(_=_<0?-fr(-_,_r):fr(_,_r))))/(3*a))>=0&&C<=1&&(o[p++]=C)}else{var x=(2*u*s-3*a*h)/(2*gr(u*u*u)),b=Math.acos(x)/3,w=gr(u),S=Math.cos(b),C=(-s-2*w*S)/(3*a),M=(v=(-s+w*(S+yr*Math.sin(b)))/(3*a),(-s+w*(S-yr*Math.sin(b)))/(3*a));C>=0&&C<=1&&(o[p++]=C),v>=0&&v<=1&&(o[p++]=v),M>=0&&M<=1&&(o[p++]=M)}}return p}function Dr(t,e,n,i,r){var o=6*n-12*e+6*t,a=9*e+3*i-3*t-9*n,s=3*e-3*t,l=0;if(Sr(a)){if(Cr(o))(u=-s/o)>=0&&u<=1&&(r[l++]=u)}else{var c=o*o-4*a*s;if(Sr(c))r[0]=-o/(2*a);else if(c>0){var u,h=gr(c),d=(-o-h)/(2*a);(u=(-o+h)/(2*a))>=0&&u<=1&&(r[l++]=u),d>=0&&d<=1&&(r[l++]=d)}}return l}function Ir(t,e,n,i,r,o){var a=(e-t)*r+t,s=(n-e)*r+e,l=(i-n)*r+n,c=(s-a)*r+a,u=(l-s)*r+s,h=(u-c)*r+c;o[0]=t,o[1]=a,o[2]=c,o[3]=h,o[4]=h,o[5]=u,o[6]=l,o[7]=i}function Ar(t,e,n,i,r,o,a,s,l){for(var c=t,u=e,h=0,d=1/l,p=1;p<=l;p++){var f=p*d,g=Mr(t,n,r,a,f),v=Mr(e,i,o,s,f),m=g-c,y=v-u;h+=Math.sqrt(m*m+y*y),c=g,u=v}return h}function Pr(t,e,n,i){var r=1-i;return r*(r*t+2*i*e)+i*i*n}function Lr(t,e,n,i){return 2*((1-i)*(e-t)+i*(n-e))}function Er(t,e,n){var i=t+n-2*e;return 0===i?.5:(t-e)/i}function zr(t,e,n,i,r){var o=(e-t)*i+t,a=(n-e)*i+e,s=(a-o)*i+o;r[0]=t,r[1]=o,r[2]=s,r[3]=s,r[4]=a,r[5]=n}function Or(t,e,n,i,r,o,a){for(var s=t,l=e,c=0,u=1/a,h=1;h<=a;h++){var d=h*u,p=Pr(t,n,r,d),f=Pr(e,i,o,d),g=p-s,v=f-l;c+=Math.sqrt(g*g+v*v),s=p,l=f}return c}var Nr=/cubic-bezier\(([0-9,\.e ]+)\)/;function Rr(t){var e=t&&Nr.exec(t);if(e){var n=e[1].split(","),i=+kn(n[0]),r=+kn(n[1]),o=+kn(n[2]),a=+kn(n[3]);if(isNaN(i+r+o+a))return;var s=[];return function(t){return t<=0?0:t>=1?1:Tr(0,i,o,1,t,s)&&Mr(0,r,a,1,s[0])}}}var Hr=function(){function t(t){this._inited=!1,this._startTime=0,this._pausedTime=0,this._paused=!1,this._life=t.life||1e3,this._delay=t.delay||0,this.loop=t.loop||!1,this.onframe=t.onframe||Nn,this.ondestroy=t.ondestroy||Nn,this.onrestart=t.onrestart||Nn,t.easing&&this.setEasing(t.easing)}return t.prototype.step=function(t,e){if(this._inited||(this._startTime=t+this._delay,this._inited=!0),!this._paused){var n=this._life,i=t-this._startTime-this._pausedTime,r=i/n;r<0&&(r=0),r=Math.min(r,1);var o=this.easingFunc,a=o?o(r):r;if(this.onframe(a),1===r){if(!this.loop)return!0;var s=i%n;this._startTime=t-s,this._pausedTime=0,this.onrestart()}return!1}this._pausedTime+=e},t.prototype.pause=function(){this._paused=!0},t.prototype.resume=function(){this._paused=!1},t.prototype.setEasing=function(t){this.easing=t,this.easingFunc=un(t)?t:pr[t]||Rr(t)},t}(),Br=function(t){this.value=t},Fr=function(){function t(){this._len=0}return t.prototype.insert=function(t){var e=new Br(t);return this.insertEntry(e),e},t.prototype.insertEntry=function(t){this.head?(this.tail.next=t,t.prev=this.tail,t.next=null,this.tail=t):this.head=this.tail=t,this._len++},t.prototype.remove=function(t){var e=t.prev,n=t.next;e?e.next=n:this.head=n,n?n.prev=e:this.tail=e,t.next=t.prev=null,this._len--},t.prototype.len=function(){return this._len},t.prototype.clear=function(){this.head=this.tail=null,this._len=0},t}(),$r=function(){function t(t){this._list=new Fr,this._maxSize=10,this._map={},this._maxSize=t}return t.prototype.put=function(t,e){var n=this._list,i=this._map,r=null;if(null==i[t]){var o=n.len(),a=this._lastRemovedEntry;if(o>=this._maxSize&&o>0){var s=n.head;n.remove(s),delete i[s.key],r=s.value,this._lastRemovedEntry=s}a?a.value=e:a=new Br(e),a.key=t,n.insertEntry(a),i[t]=a}return r},t.prototype.get=function(t){var e=this._map[t],n=this._list;if(null!=e)return e!==n.tail&&(n.remove(e),n.insertEntry(e)),e.value},t.prototype.clear=function(){this._list.clear(),this._map={}},t.prototype.len=function(){return this._list.len()},t}(),Vr={transparent:[0,0,0,0],aliceblue:[240,248,255,1],antiquewhite:[250,235,215,1],aqua:[0,255,255,1],aquamarine:[127,255,212,1],azure:[240,255,255,1],beige:[245,245,220,1],bisque:[255,228,196,1],black:[0,0,0,1],blanchedalmond:[255,235,205,1],blue:[0,0,255,1],blueviolet:[138,43,226,1],brown:[165,42,42,1],burlywood:[222,184,135,1],cadetblue:[95,158,160,1],chartreuse:[127,255,0,1],chocolate:[210,105,30,1],coral:[255,127,80,1],cornflowerblue:[100,149,237,1],cornsilk:[255,248,220,1],crimson:[220,20,60,1],cyan:[0,255,255,1],darkblue:[0,0,139,1],darkcyan:[0,139,139,1],darkgoldenrod:[184,134,11,1],darkgray:[169,169,169,1],darkgreen:[0,100,0,1],darkgrey:[169,169,169,1],darkkhaki:[189,183,107,1],darkmagenta:[139,0,139,1],darkolivegreen:[85,107,47,1],darkorange:[255,140,0,1],darkorchid:[153,50,204,1],darkred:[139,0,0,1],darksalmon:[233,150,122,1],darkseagreen:[143,188,143,1],darkslateblue:[72,61,139,1],darkslategray:[47,79,79,1],darkslategrey:[47,79,79,1],darkturquoise:[0,206,209,1],darkviolet:[148,0,211,1],deeppink:[255,20,147,1],deepskyblue:[0,191,255,1],dimgray:[105,105,105,1],dimgrey:[105,105,105,1],dodgerblue:[30,144,255,1],firebrick:[178,34,34,1],floralwhite:[255,250,240,1],forestgreen:[34,139,34,1],fuchsia:[255,0,255,1],gainsboro:[220,220,220,1],ghostwhite:[248,248,255,1],gold:[255,215,0,1],goldenrod:[218,165,32,1],gray:[128,128,128,1],green:[0,128,0,1],greenyellow:[173,255,47,1],grey:[128,128,128,1],honeydew:[240,255,240,1],hotpink:[255,105,180,1],indianred:[205,92,92,1],indigo:[75,0,130,1],ivory:[255,255,240,1],khaki:[240,230,140,1],lavender:[230,230,250,1],lavenderblush:[255,240,245,1],lawngreen:[124,252,0,1],lemonchiffon:[255,250,205,1],lightblue:[173,216,230,1],lightcoral:[240,128,128,1],lightcyan:[224,255,255,1],lightgoldenrodyellow:[250,250,210,1],lightgray:[211,211,211,1],lightgreen:[144,238,144,1],lightgrey:[211,211,211,1],lightpink:[255,182,193,1],lightsalmon:[255,160,122,1],lightseagreen:[32,178,170,1],lightskyblue:[135,206,250,1],lightslategray:[119,136,153,1],lightslategrey:[119,136,153,1],lightsteelblue:[176,196,222,1],lightyellow:[255,255,224,1],lime:[0,255,0,1],limegreen:[50,205,50,1],linen:[250,240,230,1],magenta:[255,0,255,1],maroon:[128,0,0,1],mediumaquamarine:[102,205,170,1],mediumblue:[0,0,205,1],mediumorchid:[186,85,211,1],mediumpurple:[147,112,219,1],mediumseagreen:[60,179,113,1],mediumslateblue:[123,104,238,1],mediumspringgreen:[0,250,154,1],mediumturquoise:[72,209,204,1],mediumvioletred:[199,21,133,1],midnightblue:[25,25,112,1],mintcream:[245,255,250,1],mistyrose:[255,228,225,1],moccasin:[255,228,181,1],navajowhite:[255,222,173,1],navy:[0,0,128,1],oldlace:[253,245,230,1],olive:[128,128,0,1],olivedrab:[107,142,35,1],orange:[255,165,0,1],orangered:[255,69,0,1],orchid:[218,112,214,1],palegoldenrod:[238,232,170,1],palegreen:[152,251,152,1],paleturquoise:[175,238,238,1],palevioletred:[219,112,147,1],papayawhip:[255,239,213,1],peachpuff:[255,218,185,1],peru:[205,133,63,1],pink:[255,192,203,1],plum:[221,160,221,1],powderblue:[176,224,230,1],purple:[128,0,128,1],red:[255,0,0,1],rosybrown:[188,143,143,1],royalblue:[65,105,225,1],saddlebrown:[139,69,19,1],salmon:[250,128,114,1],sandybrown:[244,164,96,1],seagreen:[46,139,87,1],seashell:[255,245,238,1],sienna:[160,82,45,1],silver:[192,192,192,1],skyblue:[135,206,235,1],slateblue:[106,90,205,1],slategray:[112,128,144,1],slategrey:[112,128,144,1],snow:[255,250,250,1],springgreen:[0,255,127,1],steelblue:[70,130,180,1],tan:[210,180,140,1],teal:[0,128,128,1],thistle:[216,191,216,1],tomato:[255,99,71,1],turquoise:[64,224,208,1],violet:[238,130,238,1],wheat:[245,222,179,1],white:[255,255,255,1],whitesmoke:[245,245,245,1],yellow:[255,255,0,1],yellowgreen:[154,205,50,1]};function Wr(t){return(t=Math.round(t))<0?0:t>255?255:t}function Ur(t){return t<0?0:t>1?1:t}function Gr(t){var e=t;return e.length&&"%"===e.charAt(e.length-1)?Wr(parseFloat(e)/100*255):Wr(parseInt(e,10))}function qr(t){var e=t;return e.length&&"%"===e.charAt(e.length-1)?Ur(parseFloat(e)/100):Ur(parseFloat(e))}function jr(t,e,n){return n<0?n+=1:n>1&&(n-=1),6*n<1?t+(e-t)*n*6:2*n<1?e:3*n<2?t+(e-t)*(2/3-n)*6:t}function Xr(t,e,n){return t+(e-t)*n}function Yr(t,e,n,i,r){return t[0]=e,t[1]=n,t[2]=i,t[3]=r,t}function Zr(t,e){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t}var Kr=new $r(20),Qr=null;function Jr(t,e){Qr&&Zr(Qr,e),Qr=Kr.put(t,Qr||e.slice())}function to(t,e){if(t){e=e||[];var n=Kr.get(t);if(n)return Zr(e,n);var i=(t+="").replace(/ /g,"").toLowerCase();if(i in Vr)return Zr(e,Vr[i]),Jr(t,e),e;var r,o=i.length;if("#"===i.charAt(0))return 4===o||5===o?(r=parseInt(i.slice(1,4),16))>=0&&r<=4095?(Yr(e,(3840&r)>>4|(3840&r)>>8,240&r|(240&r)>>4,15&r|(15&r)<<4,5===o?parseInt(i.slice(4),16)/15:1),Jr(t,e),e):void Yr(e,0,0,0,1):7===o||9===o?(r=parseInt(i.slice(1,7),16))>=0&&r<=16777215?(Yr(e,(16711680&r)>>16,(65280&r)>>8,255&r,9===o?parseInt(i.slice(7),16)/255:1),Jr(t,e),e):void Yr(e,0,0,0,1):void 0;var a=i.indexOf("("),s=i.indexOf(")");if(-1!==a&&s+1===o){var l=i.substr(0,a),c=i.substr(a+1,s-(a+1)).split(","),u=1;switch(l){case"rgba":if(4!==c.length)return 3===c.length?Yr(e,+c[0],+c[1],+c[2],1):Yr(e,0,0,0,1);u=qr(c.pop());case"rgb":return c.length>=3?(Yr(e,Gr(c[0]),Gr(c[1]),Gr(c[2]),3===c.length?u:qr(c[3])),Jr(t,e),e):void Yr(e,0,0,0,1);case"hsla":return 4!==c.length?void Yr(e,0,0,0,1):(c[3]=qr(c[3]),eo(c,e),Jr(t,e),e);case"hsl":return 3!==c.length?void Yr(e,0,0,0,1):(eo(c,e),Jr(t,e),e);default:return}}Yr(e,0,0,0,1)}}function eo(t,e){var n=(parseFloat(t[0])%360+360)%360/360,i=qr(t[1]),r=qr(t[2]),o=r<=.5?r*(i+1):r+i-r*i,a=2*r-o;return Yr(e=e||[],Wr(255*jr(a,o,n+1/3)),Wr(255*jr(a,o,n)),Wr(255*jr(a,o,n-1/3)),1),4===t.length&&(e[3]=t[3]),e}function no(t,e){var n=to(t);if(n){for(var i=0;i<3;i++)n[i]=n[i]*(1-e)|0,n[i]>255?n[i]=255:n[i]<0&&(n[i]=0);return ro(n,4===n.length?"rgba":"rgb")}}function io(t,e,n,i){var r=to(t);if(t)return r=function(t){if(t){var e,n,i=t[0]/255,r=t[1]/255,o=t[2]/255,a=Math.min(i,r,o),s=Math.max(i,r,o),l=s-a,c=(s+a)/2;if(0===l)e=0,n=0;else{n=c<.5?l/(s+a):l/(2-s-a);var u=((s-i)/6+l/2)/l,h=((s-r)/6+l/2)/l,d=((s-o)/6+l/2)/l;i===s?e=d-h:r===s?e=1/3+u-d:o===s&&(e=2/3+h-u),e<0&&(e+=1),e>1&&(e-=1)}var p=[360*e,n,c];return null!=t[3]&&p.push(t[3]),p}}(r),null!=n&&(r[1]=qr(un(n)?n(r[1]):n)),null!=i&&(r[2]=qr(un(i)?i(r[2]):i)),ro(eo(r),"rgba")}function ro(t,e){if(t&&t.length){var n=t[0]+","+t[1]+","+t[2];return"rgba"!==e&&"hsva"!==e&&"hsla"!==e||(n+=","+t[3]),e+"("+n+")"}}function oo(t,e){var n=to(t);return n?(.299*n[0]+.587*n[1]+.114*n[2])*n[3]/255+(1-n[3])*e:0}var ao=new $r(100);function so(t){if(hn(t)){var e=ao.get(t);return e||(e=no(t,-.1),ao.put(t,e)),e}if(yn(t)){var n=Ze({},t);return n.colorStops=nn(t.colorStops,function(t){return{offset:t.offset,color:no(t.color,-.1)}}),n}return t}var lo=Math.round;function co(t){var e;if(t&&"transparent"!==t){if("string"==typeof t&&t.indexOf("rgba")>-1){var n=to(t);n&&(t="rgb("+n[0]+","+n[1]+","+n[2]+")",e=n[3])}}else t="none";return{color:t,opacity:null==e?1:e}}var uo=1e-4;function ho(t){return t-1e-4}function po(t){return lo(1e3*t)/1e3}function fo(t){return lo(1e4*t)/1e4}var go={left:"start",right:"end",center:"middle",middle:"middle"};function vo(t){return t&&!!t.image}function mo(t){return vo(t)||function(t){return t&&!!t.svgElement}(t)}function yo(t){return"linear"===t.type}function _o(t){return"radial"===t.type}function xo(t){return t&&("linear"===t.type||"radial"===t.type)}function bo(t){return"url(#"+t+")"}function wo(t){var e=t.getGlobalScale(),n=Math.max(e[0],e[1]);return Math.max(Math.ceil(Math.log(n)/Math.log(10)),1)}function So(t){var e=t.x||0,n=t.y||0,i=(t.rotation||0)*Rn,r=bn(t.scaleX,1),o=bn(t.scaleY,1),a=t.skewX||0,s=t.skewY||0,l=[];return(e||n)&&l.push("translate("+e+"px,"+n+"px)"),i&&l.push("rotate("+i+")"),1===r&&1===o||l.push("scale("+r+","+o+")"),(a||s)&&l.push("skew("+lo(a*Rn)+"deg, "+lo(s*Rn)+"deg)"),l.join(" ")}var Co=Te.hasGlobalWindow&&un(window.btoa)?function(t){return window.btoa(unescape(encodeURIComponent(t)))}:"undefined"!=typeof Buffer?function(t){return Buffer.from(t).toString("base64")}:function(t){return null},Mo=Array.prototype.slice;function ko(t,e,n){return(e-t)*n+t}function To(t,e,n,i){for(var r=e.length,o=0;oi?e:t,o=Math.min(n,i),a=r[o-1]||{color:[0,0,0,0],offset:0},s=o;sa)i.length=a;else for(var s=o;s=1},t.prototype.getAdditiveTrack=function(){return this._additiveTrack},t.prototype.addKeyframe=function(t,e,n){this._needsSort=!0;var i=this.keyframes,r=i.length,o=!1,a=6,s=e;if(tn(e)){var l=function(t){return tn(t&&t[0])?2:1}(e);a=l,(1===l&&!pn(e[0])||2===l&&!pn(e[0][0]))&&(o=!0)}else if(pn(e)&&!_n(e))a=0;else if(hn(e))if(isNaN(+e)){var c=to(e);c&&(s=c,a=3)}else a=0;else if(yn(e)){var u=Ze({},s);u.colorStops=nn(e.colorStops,function(t){return{offset:t.offset,color:to(t.color)}}),yo(e)?a=4:_o(e)&&(a=5),s=u}0===r?this.valType=a:a===this.valType&&6!==a||(o=!0),this.discrete=this.discrete||o;var h={time:t,value:s,rawValue:e,percent:0};return n&&(h.easing=n,h.easingFunc=un(n)?n:pr[n]||Rr(n)),i.push(h),h},t.prototype.prepare=function(t,e){var n=this.keyframes;this._needsSort&&n.sort(function(t,e){return t.time-e.time});for(var i=this.valType,r=n.length,o=n[r-1],a=this.discrete,s=Oo(i),l=zo(i),c=0;c=0&&!(l[n].percent<=e);n--);n=p(n,c-2)}else{for(n=d;ne);n++);n=p(n-1,c-2)}r=l[n+1],i=l[n]}if(i&&r){this._lastFr=n,this._lastFrP=e;var f=r.percent-i.percent,g=0===f?1:p((e-i.percent)/f,1);r.easingFunc&&(g=r.easingFunc(g));var v=o?this._additiveValue:h?No:t[u];if(!Oo(s)&&!h||v||(v=this._additiveValue=[]),this.discrete)t[u]=g<1?i.rawValue:r.rawValue;else if(Oo(s))1===s?To(v,i[a],r[a],g):function(t,e,n,i){for(var r=e.length,o=r&&e[0].length,a=0;a0&&s.addKeyframe(0,Lo(l),i),this._trackKeys.push(a)}s.addKeyframe(t,Lo(e[a]),i)}return this._maxTime=Math.max(this._maxTime,t),this},t.prototype.pause=function(){this._clip.pause(),this._paused=!0},t.prototype.resume=function(){this._clip.resume(),this._paused=!1},t.prototype.isPaused=function(){return!!this._paused},t.prototype.duration=function(t){return this._maxTime=t,this._force=!0,this},t.prototype._doneCallback=function(){this._setTracksFinished(),this._clip=null;var t=this._doneCbs;if(t)for(var e=t.length,n=0;n0)){this._started=1;for(var e=this,n=[],i=this._maxTime||0,r=0;r1){var a=o.pop();r.addKeyframe(a.time,t[i]),r.prepare(this._maxTime,r.getAdditiveTrack())}}}},t}();function Bo(){return(new Date).getTime()}var Fo,$o,Vo=function(t){function e(e){var n=t.call(this)||this;return n._running=!1,n._time=0,n._pausedTime=0,n._pauseStart=0,n._paused=!1,e=e||{},n.stage=e.stage||{},n}return _(e,t),e.prototype.addClip=function(t){t.animation&&this.removeClip(t),this._head?(this._tail.next=t,t.prev=this._tail,t.next=null,this._tail=t):this._head=this._tail=t,t.animation=this},e.prototype.addAnimator=function(t){t.animation=this;var e=t.getClip();e&&this.addClip(e)},e.prototype.removeClip=function(t){if(t.animation){var e=t.prev,n=t.next;e?e.next=n:this._head=n,n?n.prev=e:this._tail=e,t.next=t.prev=t.animation=null}},e.prototype.removeAnimator=function(t){var e=t.getClip();e&&this.removeClip(e),t.animation=null},e.prototype.update=function(t){for(var e=Bo()-this._pausedTime,n=e-this._time,i=this._head;i;){var r=i.next;i.step(e,n)?(i.ondestroy(),this.removeClip(i),i=r):i=r}this._time=e,t||(this.trigger("frame",n),this.stage.update&&this.stage.update())},e.prototype._startLoop=function(){var t=this;this._running=!0,hr(function e(){t._running&&(hr(e),!t._paused&&t.update())})},e.prototype.start=function(){this._running||(this._time=Bo(),this._pausedTime=0,this._startLoop())},e.prototype.stop=function(){this._running=!1},e.prototype.pause=function(){this._paused||(this._pauseStart=Bo(),this._paused=!0)},e.prototype.resume=function(){this._paused&&(this._pausedTime+=Bo()-this._pauseStart,this._paused=!1)},e.prototype.clear=function(){for(var t=this._head;t;){var e=t.next;t.prev=t.next=t.animation=null,t=e}this._head=this._tail=null},e.prototype.isFinished=function(){return null==this._head},e.prototype.animate=function(t,e){e=e||{},this.start();var n=new Ho(t,e.loop);return this.addAnimator(n),n},e}(Qn),Wo=Te.domSupported,Uo=($o={pointerdown:1,pointerup:1,pointermove:1,pointerout:1},{mouse:Fo=["click","dblclick","mousewheel","wheel","mouseout","mouseup","mousedown","mousemove","contextmenu"],touch:["touchstart","touchend","touchmove"],pointer:nn(Fo,function(t){var e=t.replace("mouse","pointer");return $o.hasOwnProperty(e)?e:t})}),Go=["mousemove","mouseup"],qo=["pointermove","pointerup"],jo=!1;function Xo(t){var e=t.pointerType;return"pen"===e||"touch"===e}function Yo(t){t&&(t.zrByTouch=!0)}function Zo(t,e){for(var n=e,i=!1;n&&9!==n.nodeType&&!(i=n.domBelongToZr||n!==e&&n===t.painterRoot);)n=n.parentNode;return i}var Ko=function(t,e){this.stopPropagation=Nn,this.stopImmediatePropagation=Nn,this.preventDefault=Nn,this.type=e.type,this.target=this.currentTarget=t.dom,this.pointerType=e.pointerType,this.clientX=e.clientX,this.clientY=e.clientY},Qo={mousedown:function(t){t=gi(this.dom,t),this.__mayPointerCapture=[t.zrX,t.zrY],this.trigger("mousedown",t)},mousemove:function(t){t=gi(this.dom,t);var e=this.__mayPointerCapture;!e||t.zrX===e[0]&&t.zrY===e[1]||this.__togglePointerCapture(!0),this.trigger("mousemove",t)},mouseup:function(t){t=gi(this.dom,t),this.__togglePointerCapture(!1),this.trigger("mouseup",t)},mouseout:function(t){Zo(this,(t=gi(this.dom,t)).toElement||t.relatedTarget)||(this.__pointerCapturing&&(t.zrEventControl="no_globalout"),this.trigger("mouseout",t))},wheel:function(t){jo=!0,t=gi(this.dom,t),this.trigger("mousewheel",t)},mousewheel:function(t){jo||(t=gi(this.dom,t),this.trigger("mousewheel",t))},touchstart:function(t){Yo(t=gi(this.dom,t)),this.__lastTouchMoment=new Date,this.handler.processGesture(t,"start"),Qo.mousemove.call(this,t),Qo.mousedown.call(this,t)},touchmove:function(t){Yo(t=gi(this.dom,t)),this.handler.processGesture(t,"change"),Qo.mousemove.call(this,t)},touchend:function(t){Yo(t=gi(this.dom,t)),this.handler.processGesture(t,"end"),Qo.mouseup.call(this,t),+new Date-+this.__lastTouchMoment<300&&Qo.click.call(this,t)},pointerdown:function(t){Qo.mousedown.call(this,t)},pointermove:function(t){Xo(t)||Qo.mousemove.call(this,t)},pointerup:function(t){Qo.mouseup.call(this,t)},pointerout:function(t){Xo(t)||Qo.mouseout.call(this,t)}};en(["click","dblclick","contextmenu"],function(t){Qo[t]=function(e){e=gi(this.dom,e),this.trigger(t,e)}});var Jo={pointermove:function(t){Xo(t)||Jo.mousemove.call(this,t)},pointerup:function(t){Jo.mouseup.call(this,t)},mousemove:function(t){this.trigger("mousemove",t)},mouseup:function(t){var e=this.__pointerCapturing;this.__togglePointerCapture(!1),this.trigger("mouseup",t),e&&(t.zrEventControl="only_globalout",this.trigger("mouseout",t))}};function ta(t,e){var n=e.domHandlers;Te.pointerEventsSupported?en(Uo.pointer,function(i){na(e,i,function(e){n[i].call(t,e)})}):(Te.touchEventsSupported&&en(Uo.touch,function(i){na(e,i,function(r){n[i].call(t,r),function(t){t.touching=!0,null!=t.touchTimer&&(clearTimeout(t.touchTimer),t.touchTimer=null),t.touchTimer=setTimeout(function(){t.touching=!1,t.touchTimer=null},700)}(e)})}),en(Uo.mouse,function(i){na(e,i,function(r){r=fi(r),e.touching||n[i].call(t,r)})}))}function ea(t,e){function n(n){na(e,n,function(i){i=fi(i),Zo(t,i.target)||(i=function(t,e){return gi(t.dom,new Ko(t,e),!0)}(t,i),e.domHandlers[n].call(t,i))},{capture:!0})}Te.pointerEventsSupported?en(qo,n):Te.touchEventsSupported||en(Go,n)}function na(t,e,n,i){t.mounted[e]=n,t.listenerOpts[e]=i,function(t,e,n,i){t.addEventListener(e,n,i)}(t.domTarget,e,n,i)}function ia(t){var e=t.mounted;for(var n in e)e.hasOwnProperty(n)&&vi(t.domTarget,n,e[n],t.listenerOpts[n]);t.mounted={}}var ra=function(t,e){this.mounted={},this.listenerOpts={},this.touching=!1,this.domTarget=t,this.domHandlers=e},oa=function(t){function e(e,n){var i=t.call(this)||this;return i.__pointerCapturing=!1,i.dom=e,i.painterRoot=n,i._localHandlerScope=new ra(e,Qo),Wo&&(i._globalHandlerScope=new ra(document,Jo)),ta(i,i._localHandlerScope),i}return _(e,t),e.prototype.dispose=function(){ia(this._localHandlerScope),Wo&&ia(this._globalHandlerScope)},e.prototype.setCursor=function(t){this.dom.style&&(this.dom.style.cursor=t||"default")},e.prototype.__togglePointerCapture=function(t){if(this.__mayPointerCapture=null,Wo&&+this.__pointerCapturing^+t){this.__pointerCapturing=t;var e=this._globalHandlerScope;t?ea(this,e):ia(e)}},e}(Qn),aa=1;Te.hasGlobalWindow&&(aa=Math.max(window.devicePixelRatio||window.screen&&window.screen.deviceXDPI/window.screen.logicalXDPI||1,1));var sa=aa,la="#333",ca="#ccc",ua=bi,ha=5e-5;function da(t){return t>ha||t<-5e-5}var pa,fa=[],ga=[],va=[1,0,0,1,0,0],ma=Math.abs,ya=function(){function t(){}var e;return t.prototype.getLocalTransform=function(e){return t.getLocalTransform(this,e)},t.prototype.setPosition=function(t){this.x=t[0],this.y=t[1]},t.prototype.setScale=function(t){this.scaleX=t[0],this.scaleY=t[1]},t.prototype.setSkew=function(t){this.skewX=t[0],this.skewY=t[1]},t.prototype.setOrigin=function(t){this.originX=t[0],this.originY=t[1]},t.prototype.needLocalTransform=function(){return da(this.rotation)||da(this.x)||da(this.y)||da(this.scaleX-1)||da(this.scaleY-1)||da(this.skewX)||da(this.skewY)},t.prototype.updateTransform=function(){var t=this.parent&&this.parent.transform,e=this.needLocalTransform(),n=this.transform;e||t?(n=n||[1,0,0,1,0,0],e?this.getLocalTransform(n):ua(n),t&&(e?Si(n,t,n):wi(n,t)),this.transform=n,this._resolveGlobalScaleRatio(n)):n&&(ua(n),this.invTransform=null)},t.prototype._resolveGlobalScaleRatio=function(t){var e=this.globalScaleRatio;if(null!=e&&1!==e){this.getGlobalScale(fa);var n=fa[0]<0?-1:1,i=fa[1]<0?-1:1,r=((fa[0]-n)*e+n)/fa[0]||0,o=((fa[1]-i)*e+i)/fa[1]||0;t[0]*=r,t[1]*=r,t[2]*=o,t[3]*=o}this.invTransform=this.invTransform||[1,0,0,1,0,0],ki(this.invTransform,t)},t.prototype.getComputedTransform=function(){for(var t=this,e=[];t;)e.push(t),t=t.parent;for(;t=e.pop();)t.updateTransform();return this.transform},t.prototype.setLocalTransform=function(t){if(t){var e=t[0]*t[0]+t[1]*t[1],n=t[2]*t[2]+t[3]*t[3],i=Math.atan2(t[1],t[0]),r=Math.PI/2+i-Math.atan2(t[3],t[2]);n=Math.sqrt(n)*Math.cos(r),e=Math.sqrt(e),this.skewX=r,this.skewY=0,this.rotation=-i,this.x=+t[4],this.y=+t[5],this.scaleX=e,this.scaleY=n,this.originX=0,this.originY=0}},t.prototype.decomposeTransform=function(){if(this.transform){var t=this.parent,e=this.transform;t&&t.transform&&(t.invTransform=t.invTransform||[1,0,0,1,0,0],Si(ga,t.invTransform,e),e=ga);var n=this.originX,i=this.originY;(n||i)&&(va[4]=n,va[5]=i,Si(ga,e,va),ga[4]-=n,ga[5]-=i,e=ga),this.setLocalTransform(e)}},t.prototype.getGlobalScale=function(t){var e=this.transform;return t=t||[],e?(t[0]=Math.sqrt(e[0]*e[0]+e[1]*e[1]),t[1]=Math.sqrt(e[2]*e[2]+e[3]*e[3]),e[0]<0&&(t[0]=-t[0]),e[3]<0&&(t[1]=-t[1]),t):(t[0]=1,t[1]=1,t)},t.prototype.transformCoordToLocal=function(t,e){var n=[t,e],i=this.invTransform;return i&&jn(n,n,i),n},t.prototype.transformCoordToGlobal=function(t,e){var n=[t,e],i=this.transform;return i&&jn(n,n,i),n},t.prototype.getLineScale=function(){var t=this.transform;return t&&ma(t[0]-1)>1e-10&&ma(t[3]-1)>1e-10?Math.sqrt(ma(t[0]*t[3]-t[2]*t[1])):1},t.prototype.copyTransform=function(t){xa(this,t)},t.getLocalTransform=function(t,e){e=e||[];var n=t.originX||0,i=t.originY||0,r=t.scaleX,o=t.scaleY,a=t.anchorX,s=t.anchorY,l=t.rotation||0,c=t.x,u=t.y,h=t.skewX?Math.tan(t.skewX):0,d=t.skewY?Math.tan(-t.skewY):0;if(n||i||a||s){var p=n+a,f=i+s;e[4]=-p*r-h*f*o,e[5]=-f*o-d*p*r}else e[4]=e[5]=0;return e[0]=r,e[3]=o,e[1]=d*r,e[2]=h*o,l&&Mi(e,e,l),e[4]+=n+c,e[5]+=i+u,e},t.initDefaultProps=((e=t.prototype).scaleX=e.scaleY=e.globalScaleRatio=1,void(e.x=e.y=e.originX=e.originY=e.skewX=e.skewY=e.rotation=e.anchorX=e.anchorY=0)),t}(),_a=["x","y","originX","originY","anchorX","anchorY","rotation","scaleX","scaleY","skewX","skewY"];function xa(t,e){for(var n=0;n<_a.length;n++){var i=_a[n];t[i]=e[i]}}function ba(t){pa||(pa=new $r(100)),t=t||Ie;var e=pa.get(t);return e||(e={font:t,strWidthCache:new $r(500),asciiWidthMap:null,asciiWidthMapTried:!1,stWideCharWidth:Ee.measureText("国",t).width,asciiCharWidth:Ee.measureText("a",t).width},pa.put(t,e)),e}var wa=0,Sa=5;function Ca(t,e){return t.asciiWidthMapTried||(t.asciiWidthMap=function(t){if(!(wa>=Sa)){t=t||Ie;for(var e=[],n=+new Date,i=0;i<=127;i++)e[i]=Ee.measureText(String.fromCharCode(i),t).width;var r=+new Date-n;return r>16?wa=Sa:r>2&&wa++,e}}(t.font),t.asciiWidthMapTried=!0),0<=e&&e<=127?null!=t.asciiWidthMap?t.asciiWidthMap[e]:t.asciiCharWidth:t.stWideCharWidth}function Ma(t,e){var n=t.strWidthCache,i=n.get(e);return null==i&&(i=Ee.measureText(e,t.font).width,n.put(e,i)),i}function ka(t,e,n,i){var r=Ma(ba(e),t),o=Aa(e),a=Da(0,r,n),s=Ia(0,o,i);return new $i(a,s,r,o)}function Ta(t,e,n,i){var r=((t||"")+"").split("\n");if(1===r.length)return ka(r[0],e,n,i);for(var o=new $i(0,0,0,0),a=0;a=0?parseFloat(t)/100*e:parseFloat(t):t}function La(t,e,n){var i=e.position||"inside",r=null!=e.distance?e.distance:5,o=n.height,a=n.width,s=o/2,l=n.x,c=n.y,u="left",h="top";if(i instanceof Array)l+=Pa(i[0],n.width),c+=Pa(i[1],n.height),u=null,h=null;else switch(i){case"left":l-=r,c+=s,u="right",h="middle";break;case"right":l+=r+a,c+=s,h="middle";break;case"top":l+=a/2,c-=r,u="center",h="bottom";break;case"bottom":l+=a/2,c+=o+r,u="center";break;case"inside":l+=a/2,c+=s,u="center",h="middle";break;case"insideLeft":l+=r,c+=s,h="middle";break;case"insideRight":l+=a-r,c+=s,u="right",h="middle";break;case"insideTop":l+=a/2,c+=r,u="center";break;case"insideBottom":l+=a/2,c+=o-r,u="center",h="bottom";break;case"insideTopLeft":l+=r,c+=r;break;case"insideTopRight":l+=a-r,c+=r,u="right";break;case"insideBottomLeft":l+=r,c+=o-r,h="bottom";break;case"insideBottomRight":l+=a-r,c+=o-r,u="right",h="bottom"}return(t=t||{}).x=l,t.y=c,t.align=u,t.verticalAlign=h,t}var Ea="__zr_normal__",za=_a.concat(["ignore"]),Oa=rn(_a,function(t,e){return t[e]=!0,t},{ignore:!1}),Na={},Ra=new $i(0,0,0,0),Ha=[],Ba=function(){function t(t){this.id=qe(),this.animators=[],this.currentStates=[],this.states={},this._init(t)}return t.prototype._init=function(t){this.attr(t)},t.prototype.drift=function(t,e,n){switch(this.draggable){case"horizontal":e=0;break;case"vertical":t=0}var i=this.transform;i||(i=this.transform=[1,0,0,1,0,0]),i[4]+=t,i[5]+=e,this.decomposeTransform(),this.markRedraw()},t.prototype.beforeUpdate=function(){},t.prototype.afterUpdate=function(){},t.prototype.update=function(){this.updateTransform(),this.__dirty&&this.updateInnerText()},t.prototype.updateInnerText=function(t){var e=this._textContent;if(e&&(!e.ignore||t)){this.textConfig||(this.textConfig={});var n=this.textConfig,i=n.local,r=e.innerTransformable,o=void 0,a=void 0,s=!1;r.parent=i?this:null;var l=!1;r.copyTransform(e);var c=null!=n.position,u=n.autoOverflowArea,h=void 0;if((u||c)&&(h=Ra,n.layoutRect?h.copy(n.layoutRect):h.copy(this.getBoundingRect()),i||h.applyTransform(this.transform)),c){this.calculateTextPosition?this.calculateTextPosition(Na,n,h):La(Na,n,h),r.x=Na.x,r.y=Na.y,o=Na.align,a=Na.verticalAlign;var d=n.origin;if(d&&null!=n.rotation){var p=void 0,f=void 0;"center"===d?(p=.5*h.width,f=.5*h.height):(p=Pa(d[0],h.width),f=Pa(d[1],h.height)),l=!0,r.originX=-r.x+p+(i?0:h.x),r.originY=-r.y+f+(i?0:h.y)}}null!=n.rotation&&(r.rotation=n.rotation);var g=n.offset;g&&(r.x+=g[0],r.y+=g[1],l||(r.originX=-g[0],r.originY=-g[1]));var v=this._innerTextDefaultStyle||(this._innerTextDefaultStyle={});if(u){var m=v.overflowRect=v.overflowRect||new $i(0,0,0,0);r.getLocalTransform(Ha),ki(Ha,Ha),$i.copy(m,h),m.applyTransform(Ha)}else v.overflowRect=null;var y=void 0,_=void 0,x=void 0;(null==n.inside?"string"==typeof n.position&&n.position.indexOf("inside")>=0:n.inside)&&this.canBeInsideText()?(y=n.insideFill,_=n.insideStroke,null!=y&&"auto"!==y||(y=this.getInsideTextFill()),null!=_&&"auto"!==_||(_=this.getInsideTextStroke(y),x=!0)):(y=n.outsideFill,_=n.outsideStroke,null!=y&&"auto"!==y||(y=this.getOutsideFill()),null!=_&&"auto"!==_||(_=this.getOutsideStroke(y),x=!0)),(y=y||"#000")===v.fill&&_===v.stroke&&x===v.autoStroke&&o===v.align&&a===v.verticalAlign||(s=!0,v.fill=y,v.stroke=_,v.autoStroke=x,v.align=o,v.verticalAlign=a,e.setDefaultTextStyle(v)),e.__dirty|=1,s&&e.dirtyStyle(!0)}},t.prototype.canBeInsideText=function(){return!0},t.prototype.getInsideTextFill=function(){return"#fff"},t.prototype.getInsideTextStroke=function(t){return"#000"},t.prototype.getOutsideFill=function(){return this.__zr&&this.__zr.isDarkMode()?ca:la},t.prototype.getOutsideStroke=function(t){var e=this.__zr&&this.__zr.getBackgroundColor(),n="string"==typeof e&&to(e);n||(n=[255,255,255,1]);for(var i=n[3],r=this.__zr.isDarkMode(),o=0;o<3;o++)n[o]=n[o]*i+(r?0:255)*(1-i);return n[3]=1,ro(n,"rgba")},t.prototype.traverse=function(t,e){},t.prototype.attrKV=function(t,e){"textConfig"===t?this.setTextConfig(e):"textContent"===t?this.setTextContent(e):"clipPath"===t?this.setClipPath(e):"extra"===t?(this.extra=this.extra||{},Ze(this.extra,e)):this[t]=e},t.prototype.hide=function(){this.ignore=!0,this.markRedraw()},t.prototype.show=function(){this.ignore=!1,this.markRedraw()},t.prototype.attr=function(t,e){if("string"==typeof t)this.attrKV(t,e);else if(fn(t))for(var n=an(t),i=0;i0},t.prototype.getState=function(t){return this.states[t]},t.prototype.ensureState=function(t){var e=this.states;return e[t]||(e[t]={}),e[t]},t.prototype.clearStates=function(t){this.useState(Ea,!1,t)},t.prototype.useState=function(t,e,n,i){var r=t===Ea;if(this.hasState()||!r){var o=this.currentStates,a=this.stateTransition;if(!(Qe(o,t)>=0)||!e&&1!==o.length){var s;if(this.stateProxy&&!r&&(s=this.stateProxy(t)),s||(s=this.states&&this.states[t]),s||r){r||this.saveCurrentToNormalState(s);var l=!!(s&&s.hoverLayer||i);l&&this._toggleHoverLayerFlag(!0),this._applyStateObj(t,s,this._normalState,e,!n&&!this.__inHover&&a&&a.duration>0,a);var c=this._textContent,u=this._textGuide;return c&&c.useState(t,e,n,l),u&&u.useState(t,e,n,l),r?(this.currentStates=[],this._normalState={}):e?this.currentStates.push(t):this.currentStates=[t],this._updateAnimationTargets(),this.markRedraw(),!l&&this.__inHover&&(this._toggleHoverLayerFlag(!1),this.__dirty&=-2),s}je("State "+t+" not exists.")}}},t.prototype.useStates=function(t,e,n){if(t.length){var i=[],r=this.currentStates,o=t.length,a=o===r.length;if(a)for(var s=0;s0,p);var f=this._textContent,g=this._textGuide;f&&f.useStates(t,e,h),g&&g.useStates(t,e,h),this._updateAnimationTargets(),this.currentStates=t.slice(),this.markRedraw(),!h&&this.__inHover&&(this._toggleHoverLayerFlag(!1),this.__dirty&=-2)}else this.clearStates()},t.prototype.isSilent=function(){for(var t=this;t;){if(t.silent)return!0;var e=t.__hostTarget;t=e?t.ignoreHostSilent?null:e:t.parent}return!1},t.prototype._updateAnimationTargets=function(){for(var t=0;t=0){var n=this.currentStates.slice();n.splice(e,1),this.useStates(n)}},t.prototype.replaceState=function(t,e,n){var i=this.currentStates.slice(),r=Qe(i,t),o=Qe(i,e)>=0;r>=0?o?i.splice(r,1):i[r]=e:n&&!o&&i.push(e),this.useStates(i)},t.prototype.toggleState=function(t,e){e?this.useState(t,!0):this.removeState(t)},t.prototype._mergeStates=function(t){for(var e,n={},i=0;i=0&&e.splice(n,1)}),this.animators.push(t),n&&n.animation.addAnimator(t),n&&n.wakeUp()},t.prototype.updateDuringAnimation=function(t){this.markRedraw()},t.prototype.stopAnimation=function(t,e){for(var n=this.animators,i=n.length,r=[],o=0;o0&&n.during&&o[0].during(function(t,e){n.during(e)});for(var d=0;d0||r.force&&!a.length){var w,S=void 0,C=void 0,M=void 0;if(s){C={},d&&(S={});for(x=0;x<_;x++){C[m=g[x]]=n[m],d?S[m]=i[m]:n[m]=i[m]}}else if(d){M={};for(x=0;x<_;x++){M[m=g[x]]=Lo(n[m]),Va(n,i,m)}}(w=new Ho(n,!1,!1,h?on(f,function(t){return t.targetName===e}):null)).targetName=e,r.scope&&(w.scope=r.scope),d&&S&&w.whenWithKeys(0,S,g),M&&w.whenWithKeys(0,M,g),w.whenWithKeys(null==c?500:c,s?C:i,g).delay(u||0),t.addAnimator(w,e),a.push(w)}}Je(Ba,Qn),Je(Ba,ya);var Ua=function(t){function e(e){var n=t.call(this)||this;return n.isGroup=!0,n._children=[],n.attr(e),n}return _(e,t),e.prototype.childrenRef=function(){return this._children},e.prototype.children=function(){return this._children.slice()},e.prototype.childAt=function(t){return this._children[t]},e.prototype.childOfName=function(t){for(var e=this._children,n=0;n=0&&(n.splice(i,0,t),this._doAdd(t))}return this},e.prototype.replace=function(t,e){var n=Qe(this._children,t);return n>=0&&this.replaceAt(e,n),this},e.prototype.replaceAt=function(t,e){var n=this._children,i=n[e];if(t&&t!==this&&t.parent!==this&&t!==i){n[e]=t,i.parent=null;var r=this.__zr;r&&i.removeSelfFromZr(r),this._doAdd(t)}return this},e.prototype._doAdd=function(t){t.parent&&t.parent.remove(t),t.parent=this;var e=this.__zr;e&&e!==t.__zr&&t.addSelfToZr(e),e&&e.refresh()},e.prototype.remove=function(t){var e=this.__zr,n=this._children,i=Qe(n,t);return i<0||(n.splice(i,1),t.parent=null,e&&t.removeSelfFromZr(e),e&&e.refresh()),this},e.prototype.removeAll=function(){for(var t=this._children,e=this.__zr,n=0;n":">",'"':""","'":"'"};function Rt(t){return String(t).replace(/[&<>"']/g,t=>Nt[t]??t)}const Ht="span_panel_list_columns";function Bt(){try{const t=localStorage.getItem(Ht);if(!t)return 1;const e=parseInt(t,10);return 1===e||2===e||3===e?e:1}catch{return 1}}function Ft(t){try{localStorage.setItem(Ht,String(t))}catch{}}function $t(t,e,n={}){const r=Rt(t.device_name||i("header.default_name")),o=Rt(t.serial||""),a=Rt(t.firmware||""),s="current"===(e.chart_metric||"power"),l=!1!==n.showSwitches;return`\n
\n
\n
\n

${r}

\n ${o}\n \n ${l?`
\n ${Rt(i("header.enable_switches"))}\n
\n \n
\n
`:""}\n
\n ${function(t,e){const n="current"===(e.chart_metric||"power"),r=!!t.panel_entities?.site_power,o=!!t.panel_entities?.dsm_state,a=!!t.panel_entities?.current_power,s=!!t.panel_entities?.feedthrough_power,l=!!t.panel_entities?.pv_power,c=!!t.panel_entities?.battery_level;return`\n
\n ${r?`\n
\n ${i("header.site")}\n
\n 0\n ${n?"A":"kW"}\n
\n
`:""}\n ${o?`\n
\n ${i("header.grid")}\n
\n --\n
\n
`:""}\n ${a?`\n
\n ${i("header.upstream")}\n
\n --\n ${n?"A":"kW"}\n
\n
`:""}\n ${s?`\n
\n ${i("header.downstream")}\n
\n --\n ${n?"A":"kW"}\n
\n
`:""}\n ${l?`\n
\n ${i("header.solar")}\n
\n --\n ${n?"A":"kW"}\n
\n
`:""}\n ${c?`\n
\n ${i("header.battery")}\n
\n —\n %\n
\n
`:""}\n
\n `}(t,e)}\n
\n
\n
\n ${a}\n
\n \n \n
\n
\n
\n ${Object.entries(m).filter(([t])=>"unknown"!==t).map(([,t])=>{const e=Rt(t.icon),n=Rt(t.color),i=Rt(t.label());let r;return r=t.icon2?``:t.textLabel?`${Rt(t.textLabel)}`:``,`
${r}${i}
`}).join("")}\n
\n
\n
\n `}const Vt=g.power;function Wt(t){return Vt.unit(t)}function Ut(t){return(t<0?"-":"")+Vt.format(t)}function Gt(t){return(Math.abs(t)/1e3).toFixed(1)}function qt(t){return Math.ceil(t/2)}function jt(t){return t%2==0?1:0}function Xt(t){if(2!==t.length)return null;const[e,n]=[Math.min(...t),Math.max(...t)];return qt(e)===qt(n)?"row-span":jt(e)===jt(n)?"col-span":"row-span"}function Yt(t){const e=t.chart_metric??o;return g[e]??g[o]}function Zt(t,e){const n=function(t){return Yt(t).entityRole}(e);return t.entities?.[n]??t.entities?.power??null}function Kt(t){return new Promise(e=>setTimeout(e,t))}class Qt{constructor(t){this._store=t}async callWS(t,e,n){const i=n?.retries??3,r=n?.errorId??`ws:${String(e.type??"unknown")}`;return this._withRetry(()=>t.callWS(e),i,r,n?.errorMessage)}async callService(t,e,n,i,r,o){const a=o?.retries??3,s=o?.errorId??`svc:${e}.${n}`;return this._withRetry(()=>t.callService(e,n,i,r),a,s,o?.errorMessage)}async _withRetry(t,e,n,r){if(this._store.hasAnyPanelOffline())try{const e=await t();return this._store.remove(n),e}catch(t){const e=t instanceof Error?t:new Error(String(t));throw this._store.add({key:n,level:"error",message:r??i("error.panel_offline"),persistent:!1}),e}let o;for(let i=0;i<=e;i++)try{const e=await t();return this._store.remove(n),e}catch(t){if(o=t instanceof Error?t:new Error(String(t)),i{try{const n={};e&&(n.config_entry_id=e);const o={type:"call_service",domain:l,service:"get_monitoring_status",service_data:n,return_response:!0},a=this._retry?await this._retry.callWS(t,o,{errorId:"fetch:monitoring",errorMessage:i("error.monitoring_failed")}):await t.callWS(o),s=a?.response??null;return r===this._generation&&(this._status=s,this._lastFetch=Date.now()),s}catch(t){return console.warn("SPAN Panel: monitoring status fetch failed",t),r===this._generation&&(this._status=null),this._retry||this._errorStore?.add({key:"fetch:monitoring",level:"warning",message:i("error.monitoring_failed"),persistent:!1}),null}finally{this._inflight?.gen===r&&(this._inflight=null)}})();return this._inflight={gen:r,promise:o},o}invalidate(){this._lastFetch=0,this._generation++}get status(){return this._status}clear(){this._status=null,this._lastFetch=0,this._generation++}}class te{constructor(){this._caches=new Map,this._errorStore=null}get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t;for(const e of this._caches.values())e.errorStore=t}async fetchOne(t,e){let n=this._caches.get(e);return n||(n=new Jt,n.errorStore=this._errorStore,this._caches.set(e,n)),n.fetch(t,e)}invalidate(){for(const t of this._caches.values())t.invalidate()}clear(){this._caches.clear()}}function ee(t,e){return t?.circuits?t.circuits[e]??null:null}function ne(t,e,n,i){const r=[];return n||r.push("circuit-off"),i&&r.push("circuit-producer"),function(t){return!!t&&null!=t.over_threshold_since}(e)&&r.push("circuit-alert"),r.join(" ")}function ie(t,e,n,r,o,a,s,l,d,h=!1){const p=e.entities?.power,f=p?a.states[p]:null,g=f&&parseFloat(f.state)||0,v=e.device_type===u||g<0,y=e.entities?.switch,_=y?a.states[y]:null,b=_?"on"===_.state:(f?.attributes?.relay_state||e.relay_state)===c,x=e.breaker_rating_a,w=x?`${Math.round(x)}A`:"",S=Rt(e.name||i("grid.unknown")),C=Yt(s);let M;if("current"===C.entityRole){const t=e.entities?.current,n=t?a.states[t]:null,i=n&&parseFloat(n.state)||0;M=`${C.format(i)}A`}else M=`${Ut(g)}${Wt(g)}`;const k=d||"unknown";let T="";if("unknown"!==k){const t=m[k]??m.unknown??{icon:"mdi:help",color:"#999",label:()=>"Unknown"},e=Rt(t.label()),n=Rt(t.icon),i=Rt(t.color);if(t.icon2){T=`\n \n \n `}else if(t.textLabel){T=`\n \n ${Rt(t.textLabel)}\n `}else T=``}const A=``;let D="",I=l?.utilization_pct??null;if(null==I&&e.breaker_rating_a){const t=e.entities?.current,n=t?a.states[t]:null,i=n?Math.abs(parseFloat(n.state)||0):0;I=Math.round(i/e.breaker_rating_a*1e3)/10}if(null!=I){D=`=80?"utilization-warning":"utilization-normal"}">${Math.round(I)}%`}return`\n
\n
\n
\n ${w?`${w}`:""}\n ${D}\n ${S}\n
\n
\n \n ${M}\n \n ${!1!==e.is_user_controllable&&e.entities?.switch?`\n
\n ${i(b?"grid.on":"grid.off")}\n \n
\n `:""}\n
\n
\n
\n ${T}\n ${A}\n
\n
\n
\n `}function re(t,e){return`\n
\n —\n
\n `}const oe={names:["power","battery power"],suffixes:["_power"]},ae={names:["battery level","battery percentage"],suffixes:["_battery_level","_battery_percentage"]},se={names:["state of energy"],suffixes:["_soe_kwh"]},le={names:["nameplate capacity"],suffixes:["_nameplate_capacity"]};function ce(t,e){if(!t.entities)return null;for(const[n,i]of Object.entries(t.entities)){if("sensor"!==i.domain)continue;const t=(i.original_name??"").toLowerCase();if(e.names.some(e=>t===e))return n;if(i.unique_id&&e.suffixes.some(t=>i.unique_id.endsWith(t)))return n}return null}function ue(t){return ce(t,oe)}function de(t){return ce(t,ae)}function he(t){return ce(t,se)}function pe(t){return ce(t,le)}function fe(t,e,n,i){const r=n.visible_sub_entities||{};let o="";if(!t.entities)return o;for(const[n,a]of Object.entries(t.entities)){if(i.has(n))continue;if(!0!==r[n])continue;const s=e.states[n];if(!s)continue;let l=a.original_name||s.attributes.friendly_name||n;const c=t.name||"";let u;if(l.startsWith(c+" ")&&(l=l.slice(c.length+1)),e.formatEntityState)u=e.formatEntityState(s);else{u=s.state;const t=s.attributes.unit_of_measurement||"";t&&(u+=" "+t)}if("Wh"===(s.attributes.unit_of_measurement||"")){const t=parseFloat(s.state);isNaN(t)||(u=(t/1e3).toFixed(1)+" kWh")}o+=`\n
\n ${Rt(l)}:\n ${Rt(u)}\n
\n `}return o}function ge(t,e,n,r,o,a){if(n){const e=[{key:`${p}${t}_soc`,title:i("subdevice.soc"),available:!!o},{key:`${p}${t}_soe`,title:i("subdevice.soe"),available:!!a},{key:`${p}${t}_power`,title:i("subdevice.power"),available:!!r}].filter(t=>t.available);return`\n
\n ${e.map(t=>`\n
\n
${Rt(t.title)}
\n
\n
\n `).join("")}\n
\n `}return r?`
`:""}function ve(t){const e=void 0!==t.history_days||void 0!==t.history_hours||void 0!==t.history_minutes,n=60*(60*(24*(e&&parseInt(String(t.history_days))||0)+(e&&parseInt(String(t.history_hours))||0))+(e?parseInt(String(t.history_minutes))||0:5))*1e3;return Math.max(n,6e4)}function me(t){const e=s[t];return e?e.ms:s[a].ms}function ye(t){const e=t/1e3;return e<=600?Math.ceil(e):Math.min(5e3,Math.ceil(e/5))}function _e(t){return Math.max(500,Math.floor(t/5e3))}function be(t,e,n,i,r,o){t.has(e)||t.set(e,[]);const a=t.get(e);a.push({time:i,value:n});const s=a.findIndex(t=>t.time>=r);s>0?a.splice(0,s):-1===s&&(a.length=0),a.length>o&&a.splice(0,a.length-o)}function xe(t,e,n=500){if(0===t.length)return t;t.sort((t,e)=>t.time-e.time);const i=[t[0]];for(let e=1;e=n&&i.push(t[e]);return i.length>e&&i.splice(0,i.length-e),i}async function we(t,e,n,i,r){const o=new Date(Date.now()-i).toISOString(),a=i/36e5>72?"hour":"5minute",s=await t.callWS({type:"recorder/statistics_during_period",start_time:o,statistic_ids:e,period:a,types:["mean"]});for(const[t,e]of Object.entries(s)){const i=n.get(t);if(!i||!e)continue;const o=[];for(const t of e){const e=t.mean;if(null==e||!Number.isFinite(e))continue;const n=t.start;n>0&&o.push({time:n,value:e})}if(o.length>0){const t=r.get(i)||[],e=[...o,...t];e.sort((t,e)=>t.time-e.time),r.set(i,e)}}}async function Se(t,e,n,i,r){const o=new Date(Date.now()-i).toISOString(),a=await t.callWS({type:"history/history_during_period",start_time:o,entity_ids:e,minimal_response:!0,significant_changes_only:!0,no_attributes:!0}),s=ye(i),l=_e(i);for(const[t,e]of Object.entries(a)){const i=n.get(t);if(!i||!e)continue;const o=[];for(const t of e){const e=parseFloat(t.s);if(!Number.isFinite(e))continue;const n=1e3*(t.lu||t.lc||0);n>0&&o.push({time:n,value:e})}if(o.length>0){const t=r.get(i)||[],e=[...o,...t];r.set(i,xe(e,s,l))}}}function Ce(t){if(!t.sub_devices)return[];const e=[];for(const[n,i]of Object.entries(t.sub_devices)){const t={power:ue(i)};i.type===d&&(t.soc=de(i),t.soe=he(i));for(const[i,r]of Object.entries(t))r&&e.push({entityId:r,key:`${p}${n}_${i}`,devId:n})}return e}async function Me(t,e,n,i,r,o){if(!e||!t)return;const a=new Map;for(const[t,i]of Object.entries(e.circuits)){const e=Zt(i,n);if(!e)continue;let o;o=r&&r.has(t)?me(r.get(t)):ve(n),a.has(o)||a.set(o,{entityIds:[],uuidByEntity:new Map});const s=a.get(o);s.entityIds.push(e),s.uuidByEntity.set(e,t)}for(const{entityId:t,key:i,devId:r}of Ce(e)){let e;e=o&&o.has(r)?me(o.get(r)):ve(n),a.has(e)||a.set(e,{entityIds:[],uuidByEntity:new Map});const s=a.get(e);s.entityIds.push(t),s.uuidByEntity.set(t,i)}const s=[];for(const[e,n]of a){if(0===n.entityIds.length)continue;e>2592e5?s.push(we(t,n.entityIds,n.uuidByEntity,e,i)):s.push(Se(t,n.entityIds,n.uuidByEntity,e,i))}await Promise.all(s)}var ke=function(){this.firefox=!1,this.ie=!1,this.edge=!1,this.newEdge=!1,this.weChat=!1},Te=new function(){this.browser=new ke,this.node=!1,this.wxa=!1,this.worker=!1,this.svgSupported=!1,this.touchEventsSupported=!1,this.pointerEventsSupported=!1,this.domSupported=!1,this.transformSupported=!1,this.transform3dSupported=!1,this.hasGlobalWindow="undefined"!=typeof window};"object"==typeof wx&&"function"==typeof wx.getSystemInfoSync?(Te.wxa=!0,Te.touchEventsSupported=!0):"undefined"==typeof document&&"undefined"!=typeof self?Te.worker=!0:!Te.hasGlobalWindow||"Deno"in window||"undefined"!=typeof navigator&&"string"==typeof navigator.userAgent&&navigator.userAgent.indexOf("Node.js")>-1?(Te.node=!0,Te.svgSupported=!0):function(t,e){var n=e.browser,i=t.match(/Firefox\/([\d.]+)/),r=t.match(/MSIE\s([\d.]+)/)||t.match(/Trident\/.+?rv:(([\d.]+))/),o=t.match(/Edge?\/([\d.]+)/),a=/micromessenger/i.test(t);i&&(n.firefox=!0,n.version=i[1]);r&&(n.ie=!0,n.version=r[1]);o&&(n.edge=!0,n.version=o[1],n.newEdge=+o[1].split(".")[0]>18);a&&(n.weChat=!0);e.svgSupported="undefined"!=typeof SVGRect,e.touchEventsSupported="ontouchstart"in window&&!n.ie&&!n.edge,e.pointerEventsSupported="onpointerdown"in window&&(n.edge||n.ie&&+n.version>=11);var s=e.domSupported="undefined"!=typeof document;if(s){var l=document.documentElement.style;e.transform3dSupported=(n.ie&&"transition"in l||n.edge||"WebKitCSSMatrix"in window&&"m11"in new WebKitCSSMatrix||"MozPerspective"in l)&&!("OTransition"in l),e.transformSupported=e.transform3dSupported||n.ie&&+n.version>=9}}(navigator.userAgent,Te);var Ae="sans-serif",De="12px "+Ae;var Ie,Pe,Le=function(t){var e={};if("undefined"==typeof JSON)return e;for(var n=0;n=0)o=r*t.length;else for(var a=0;a>1)%2;a.style.cssText=["position: absolute","visibility: hidden","padding: 0","margin: 0","border-width: 0","user-select: none","width:0","height:0",i[s]+":0",r[l]+":0",i[1-s]+":auto",r[1-l]+":auto",""].join("!important;"),t.appendChild(a),n.push(a)}return e.clearMarkers=function(){en(n,function(t){t.parentNode&&t.parentNode.removeChild(t)})},n}(e,o),s=function(t,e,n){for(var i=n?"invTrans":"trans",r=e[i],o=e.srcCoords,a=[],s=[],l=!0,c=0;c<4;c++){var u=t[c].getBoundingClientRect(),d=2*c,h=u.left,p=u.top;a.push(h,p),l=l&&o&&h===o[d]&&p===o[d+1],s.push(t[c].offsetLeft,t[c].offsetTop)}return l&&r?r:(e.srcCoords=a,e[i]=n?ei(s,a):ei(a,s))}(a,o,r);if(s)return s(t,n,i),!0}return!1}function oi(t){return"CANVAS"===t.nodeName.toUpperCase()}var ai=/([&<>"'])/g,si={"&":"&","<":"<",">":">",'"':""","'":"'"};function li(t){return null==t?"":(t+"").replace(ai,function(t,e){return si[e]})}var ci=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,ui=[],di=Te.browser.firefox&&+Te.browser.version.split(".")[0]<39;function hi(t,e,n,i){return n=n||{},i?pi(t,e,n):di&&null!=e.layerX&&e.layerX!==e.offsetX?(n.zrX=e.layerX,n.zrY=e.layerY):null!=e.offsetX?(n.zrX=e.offsetX,n.zrY=e.offsetY):pi(t,e,n),n}function pi(t,e,n){if(Te.domSupported&&t.getBoundingClientRect){var i=e.clientX,r=e.clientY;if(oi(t)){var o=t.getBoundingClientRect();return n.zrX=i-o.left,void(n.zrY=r-o.top)}if(ri(ui,t,i,r))return n.zrX=ui[0],void(n.zrY=ui[1])}n.zrX=n.zrY=0}function fi(t){return t||window.event}function gi(t,e,n){if(null!=(e=fi(e)).zrX)return e;var i=e.type;if(i&&i.indexOf("touch")>=0){var r="touchend"!==i?e.targetTouches[0]:e.changedTouches[0];r&&hi(t,r,e,n)}else{hi(t,e,e,n);var o=function(t){var e=t.wheelDelta;if(e)return e;var n=t.deltaX,i=t.deltaY;if(null==n||null==i)return e;return 3*(0!==i?Math.abs(i):Math.abs(n))*(i>0?-1:i<0?1:n>0?-1:1)}(e);e.zrDelta=o?o/120:-(e.detail||0)/3}var a=e.button;return null==e.which&&void 0!==a&&ci.test(e.type)&&(e.which=1&a?1:2&a?3:4&a?2:0),e}function vi(t,e,n,i){t.removeEventListener(e,n,i)}var mi=function(t){t.preventDefault(),t.stopPropagation(),t.cancelBubble=!0},yi=function(){function t(){this._track=[]}return t.prototype.recognize=function(t,e,n){return this._doTrack(t,e,n),this._recognize(t)},t.prototype.clear=function(){return this._track.length=0,this},t.prototype._doTrack=function(t,e,n){var i=t.touches;if(i){for(var r={points:[],touches:[],target:e,event:t},o=0,a=i.length;o1&&r&&r.length>1){var a=_i(r)/_i(o);!isFinite(a)&&(a=1),e.pinchScale=a;var s=[((i=r)[0][0]+i[1][0])/2,(i[0][1]+i[1][1])/2];return e.pinchX=s[0],e.pinchY=s[1],{type:"pinch",target:t[0].target,event:e}}}}};function xi(t){return t[0]=1,t[1]=0,t[2]=0,t[3]=1,t[4]=0,t[5]=0,t}function wi(t,e){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t[4]=e[4],t[5]=e[5],t}function Si(t,e,n){var i=e[0]*n[0]+e[2]*n[1],r=e[1]*n[0]+e[3]*n[1],o=e[0]*n[2]+e[2]*n[3],a=e[1]*n[2]+e[3]*n[3],s=e[0]*n[4]+e[2]*n[5]+e[4],l=e[1]*n[4]+e[3]*n[5]+e[5];return t[0]=i,t[1]=r,t[2]=o,t[3]=a,t[4]=s,t[5]=l,t}function Ci(t,e,n){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t[4]=e[4]+n[0],t[5]=e[5]+n[1],t}function Mi(t,e,n,i){void 0===i&&(i=[0,0]);var r=e[0],o=e[2],a=e[4],s=e[1],l=e[3],c=e[5],u=Math.sin(n),d=Math.cos(n);return t[0]=r*d+s*u,t[1]=-r*u+s*d,t[2]=o*d+l*u,t[3]=-o*u+d*l,t[4]=d*(a-i[0])+u*(c-i[1])+i[0],t[5]=d*(c-i[1])-u*(a-i[0])+i[1],t}function ki(t,e){var n=e[0],i=e[2],r=e[4],o=e[1],a=e[3],s=e[5],l=n*a-o*i;return l?(l=1/l,t[0]=a*l,t[1]=-o*l,t[2]=-i*l,t[3]=n*l,t[4]=(i*s-a*r)*l,t[5]=(o*r-n*s)*l,t):null}var Ti=function(){function t(t,e){this.x=t||0,this.y=e||0}return t.prototype.copy=function(t){return this.x=t.x,this.y=t.y,this},t.prototype.clone=function(){return new t(this.x,this.y)},t.prototype.set=function(t,e){return this.x=t,this.y=e,this},t.prototype.equal=function(t){return t.x===this.x&&t.y===this.y},t.prototype.add=function(t){return this.x+=t.x,this.y+=t.y,this},t.prototype.scale=function(t){this.x*=t,this.y*=t},t.prototype.scaleAndAdd=function(t,e){this.x+=t.x*e,this.y+=t.y*e},t.prototype.sub=function(t){return this.x-=t.x,this.y-=t.y,this},t.prototype.dot=function(t){return this.x*t.x+this.y*t.y},t.prototype.len=function(){return Math.sqrt(this.x*this.x+this.y*this.y)},t.prototype.lenSquare=function(){return this.x*this.x+this.y*this.y},t.prototype.normalize=function(){var t=this.len();return this.x/=t,this.y/=t,this},t.prototype.distance=function(t){var e=this.x-t.x,n=this.y-t.y;return Math.sqrt(e*e+n*n)},t.prototype.distanceSquare=function(t){var e=this.x-t.x,n=this.y-t.y;return e*e+n*n},t.prototype.negate=function(){return this.x=-this.x,this.y=-this.y,this},t.prototype.transform=function(t){if(t){var e=this.x,n=this.y;return this.x=t[0]*e+t[2]*n+t[4],this.y=t[1]*e+t[3]*n+t[5],this}},t.prototype.toArray=function(t){return t[0]=this.x,t[1]=this.y,t},t.prototype.fromArray=function(t){this.x=t[0],this.y=t[1]},t.set=function(t,e,n){t.x=e,t.y=n},t.copy=function(t,e){t.x=e.x,t.y=e.y},t.len=function(t){return Math.sqrt(t.x*t.x+t.y*t.y)},t.lenSquare=function(t){return t.x*t.x+t.y*t.y},t.dot=function(t,e){return t.x*e.x+t.y*e.y},t.add=function(t,e,n){t.x=e.x+n.x,t.y=e.y+n.y},t.sub=function(t,e,n){t.x=e.x-n.x,t.y=e.y-n.y},t.scale=function(t,e,n){t.x=e.x*n,t.y=e.y*n},t.scaleAndAdd=function(t,e,n,i){t.x=e.x+n.x*i,t.y=e.y+n.y*i},t.lerp=function(t,e,n,i){var r=1-i;t.x=r*e.x+i*n.x,t.y=r*e.y+i*n.y},t}(),Ai=Math.min,Di=Math.max,Ii=Math.abs,Pi=["x","y"],Li=["width","height"],Ei=new Ti,Oi=new Ti,zi=new Ti,Ni=new Ti,Ri=Gi(),Hi=Ri.minTv,Bi=Ri.maxTv,Fi=[0,0],$i=function(){function t(e,n,i,r){t.set(this,e,n,i,r)}return t.set=function(t,e,n,i,r){return i<0&&(e+=i,i=-i),r<0&&(n+=r,r=-r),t.x=e,t.y=n,t.width=i,t.height=r,t},t.prototype.union=function(t){var e=Ai(t.x,this.x),n=Ai(t.y,this.y);isFinite(this.x)&&isFinite(this.width)?this.width=Di(t.x+t.width,this.x+this.width)-e:this.width=t.width,isFinite(this.y)&&isFinite(this.height)?this.height=Di(t.y+t.height,this.y+this.height)-n:this.height=t.height,this.x=e,this.y=n},t.prototype.applyTransform=function(e){t.applyTransform(this,this,e)},t.prototype.calculateTransform=function(t){var e=this,n=t.width/e.width,i=t.height/e.height,r=[1,0,0,1,0,0];return Ci(r,r,[-e.x,-e.y]),function(t,e,n){var i=n[0],r=n[1];t[0]=e[0]*i,t[1]=e[1]*r,t[2]=e[2]*i,t[3]=e[3]*r,t[4]=e[4]*i,t[5]=e[5]*r}(r,r,[n,i]),Ci(r,r,[t.x,t.y]),r},t.prototype.intersect=function(e,n,i){return t.intersect(this,e,n,i)},t.intersect=function(e,n,i,r){i&&Ti.set(i,0,0);var o=r&&r.outIntersectRect||null,a=r&&r.clamp;if(o&&(o.x=o.y=o.width=o.height=NaN),!e||!n)return!1;e instanceof t||(e=t.set(Vi,e.x,e.y,e.width,e.height)),n instanceof t||(n=t.set(Wi,n.x,n.y,n.width,n.height));var s=!!i;Ri.reset(r,s);var l=Ri.touchThreshold,c=e.x+l,u=e.x+e.width-l,d=e.y+l,h=e.y+e.height-l,p=n.x+l,f=n.x+n.width-l,g=n.y+l,v=n.y+n.height-l;if(c>u||d>h||p>f||g>v)return!1;var m=!(u=t.x&&e<=t.x+t.width&&n>=t.y&&n<=t.y+t.height},t.prototype.contain=function(e,n){return t.contain(this,e,n)},t.prototype.clone=function(){return new t(this.x,this.y,this.width,this.height)},t.prototype.copy=function(e){t.copy(this,e)},t.prototype.plain=function(){return{x:this.x,y:this.y,width:this.width,height:this.height}},t.prototype.isFinite=function(){return isFinite(this.x)&&isFinite(this.y)&&isFinite(this.width)&&isFinite(this.height)},t.prototype.isZero=function(){return 0===this.width||0===this.height},t.create=function(e){return new t(e.x,e.y,e.width,e.height)},t.copy=function(t,e){return t.x=e.x,t.y=e.y,t.width=e.width,t.height=e.height,t},t.applyTransform=function(e,n,i){if(i){if(i[1]<1e-5&&i[1]>-1e-5&&i[2]<1e-5&&i[2]>-1e-5){var r=i[0],o=i[3],a=i[4],s=i[5];return e.x=n.x*r+a,e.y=n.y*o+s,e.width=n.width*r,e.height=n.height*o,e.width<0&&(e.x+=e.width,e.width=-e.width),void(e.height<0&&(e.y+=e.height,e.height=-e.height))}Ei.x=zi.x=n.x,Ei.y=Ni.y=n.y,Oi.x=Ni.x=n.x+n.width,Oi.y=zi.y=n.y+n.height,Ei.transform(i),Ni.transform(i),Oi.transform(i),zi.transform(i),e.x=Ai(Ei.x,Oi.x,zi.x,Ni.x),e.y=Ai(Ei.y,Oi.y,zi.y,Ni.y);var l=Di(Ei.x,Oi.x,zi.x,Ni.x),c=Di(Ei.y,Oi.y,zi.y,Ni.y);e.width=l-e.x,e.height=c-e.y}else e!==n&&t.copy(e,n)},t}(),Vi=new $i(0,0,0,0),Wi=new $i(0,0,0,0);function Ui(t,e,n,i,r,o,a,s){var l=Ii(e-n),c=Ii(i-t),u=Ai(l,c),d=Pi[r],h=Pi[1-r],p=Li[r];e=c||!Ri.bidirectional)&&(Hi[d]=-c,Hi[h]=0,Ri.useDir&&Ri.calcDirMTV())))}function Gi(){var t=0,e=new Ti,n=new Ti,i={minTv:new Ti,maxTv:new Ti,useDir:!1,dirMinTv:new Ti,touchThreshold:0,bidirectional:!0,negativeSize:!1,reset:function(r,o){i.touchThreshold=0,r&&null!=r.touchThreshold&&(i.touchThreshold=Di(0,r.touchThreshold)),i.negativeSize=!1,o&&(i.minTv.set(1/0,1/0),i.maxTv.set(0,0),i.useDir=!1,r&&null!=r.direction&&(i.useDir=!0,i.dirMinTv.copy(i.minTv),n.copy(i.minTv),t=r.direction,i.bidirectional=null==r.bidirectional||!!r.bidirectional,i.bidirectional||e.set(Math.cos(t),Math.sin(t))))},calcDirMTV:function(){var o=i.minTv,a=i.dirMinTv,s=o.y*o.y+o.x*o.x,l=Math.sin(t),c=Math.cos(t),u=l*o.y+c*o.x;r(u)?r(o.x)&&r(o.y)&&a.set(0,0):(n.x=s*c/u,n.y=s*l/u,r(n.x)&&r(n.y)?a.set(0,0):(i.bidirectional||e.dot(n)>0)&&n.len()=0;c--){var u=i[c];u===n||u.ignore||u.ignoreCoarsePointer||u.parent&&u.parent.ignoreCoarsePointer||(Ki.copy(u.getBoundingRect()),u.transform&&Ki.applyTransform(u.transform),Ki.intersect(l)&&o.push(u))}if(o.length)for(var d=Math.PI/12,h=2*Math.PI,p=0;p=0;o--){var a=t[o],s=void 0;if(a!==r&&!a.ignore&&(s=Ji(a,n,i))&&(!e.topTarget&&(e.topTarget=a),s!==qi)){e.target=a;break}}}function er(t,e,n){var i=t.painter;return e<0||e>i.getWidth()||n<0||n>i.getHeight()}en(["click","mousedown","mouseup","mousewheel","dblclick","contextmenu"],function(t){Qi.prototype[t]=function(e){var n,i,r=e.zrX,o=e.zrY,a=er(this,r,o);if("mouseup"===t&&a||(i=(n=this.findHover(r,o)).target),"mousedown"===t)this._downEl=i,this._downPoint=[e.zrX,e.zrY],this._upEl=i;else if("mouseup"===t)this._upEl=i;else if("click"===t){if(this._downEl!==this._upEl||!this._downPoint||Gn(this._downPoint,[e.zrX,e.zrY])>4)return;this._downPoint=null}this.dispatchToElement(n,t,e)}});function nr(t,e,n,i){var r=e+1;if(r===n)return 1;if(i(t[r++],t[e])<0){for(;r=0;)r++;return r-e}function ir(t,e,n,i,r){for(i===e&&i++;i>>1])<0?l=o:s=o+1;var c=i-s;switch(c){case 3:t[s+3]=t[s+2];case 2:t[s+2]=t[s+1];case 1:t[s+1]=t[s];break;default:for(;c>0;)t[s+c]=t[s+c-1],c--}t[s]=a}}function rr(t,e,n,i,r,o){var a=0,s=0,l=1;if(o(t,e[n+r])>0){for(s=i-r;l0;)a=l,(l=1+(l<<1))<=0&&(l=s);l>s&&(l=s),a+=r,l+=r}else{for(s=r+1;ls&&(l=s);var c=a;a=r-l,l=r-c}for(a++;a>>1);o(t,e[n+u])>0?a=u+1:l=u}return l}function or(t,e,n,i,r,o){var a=0,s=0,l=1;if(o(t,e[n+r])<0){for(s=r+1;ls&&(l=s);var c=a;a=r-l,l=r-c}else{for(s=i-r;l=0;)a=l,(l=1+(l<<1))<=0&&(l=s);l>s&&(l=s),a+=r,l+=r}for(a++;a>>1);o(t,e[n+u])<0?l=u:a=u+1}return l}function ar(t,e){var n,i,r=7,o=0,a=[];function s(s){var l=n[s],c=i[s],u=n[s+1],d=i[s+1];i[s]=c+d,s===o-3&&(n[s+1]=n[s+2],i[s+1]=i[s+2]),o--;var h=or(t[u],t,l,c,0,e);l+=h,0!==(c-=h)&&0!==(d=rr(t[l+c-1],t,u,d,d-1,e))&&(c<=d?function(n,i,o,s){var l=0;for(l=0;l=7||p>=7);if(f)break;g<0&&(g=0),g+=2}if((r=g)<1&&(r=1),1===i){for(l=0;l=0;l--)t[p+l]=t[h+l];return void(t[d]=a[u])}var f=r;for(;;){var g=0,v=0,m=!1;do{if(e(a[u],t[c])<0){if(t[d--]=t[c--],g++,v=0,0===--i){m=!0;break}}else if(t[d--]=a[u--],v++,g=0,1===--s){m=!0;break}}while((g|v)=0;l--)t[p+l]=t[h+l];if(0===i){m=!0;break}}if(t[d--]=a[u--],1===--s){m=!0;break}if(0!==(v=s-rr(t[c],a,0,s,s-1,e))){for(s-=v,p=(d-=v)+1,h=(u-=v)+1,l=0;l=7||v>=7);if(m)break;f<0&&(f=0),f+=2}(r=f)<1&&(r=1);if(1===s){for(p=(d-=i)+1,h=(c-=i)+1,l=i-1;l>=0;l--)t[p+l]=t[h+l];t[d]=a[u]}else{if(0===s)throw new Error;for(h=d-(s-1),l=0;l1;){var t=o-2;if(t>=1&&i[t-1]<=i[t]+i[t+1]||t>=2&&i[t-2]<=i[t]+i[t-1])i[t-1]i[t+1])break;s(t)}},forceMergeRuns:function(){for(;o>1;){var t=o-2;t>0&&i[t-1]=32;)e|=1&t,t>>=1;return t+e}(r);do{if((o=nr(t,n,i,e))s&&(l=s),ir(t,n,n+l,n+o,e),o=l}a.pushRun(n,o),a.mergeRuns(),r-=o,n+=o}while(0!==r);a.forceMergeRuns()}}}var lr=!1;function cr(){lr||(lr=!0,console.warn("z / z2 / zlevel of displayable is invalid, which may cause unexpected errors"))}function ur(t,e){return t.zlevel===e.zlevel?t.z===e.z?t.z2-e.z2:t.z-e.z:t.zlevel-e.zlevel}var dr,hr=function(){function t(){this._roots=[],this._displayList=[],this._displayListLen=0,this.displayableSortFunc=ur}return t.prototype.traverse=function(t,e){for(var n=0;n=0&&this._roots.splice(i,1)}},t.prototype.delAllRoots=function(){this._roots=[],this._displayList=[],this._displayListLen=0},t.prototype.getRoots=function(){return this._roots},t.prototype.dispose=function(){this._displayList=null,this._roots=null},t}();dr=Te.hasGlobalWindow&&(window.requestAnimationFrame&&window.requestAnimationFrame.bind(window)||window.msRequestAnimationFrame&&window.msRequestAnimationFrame.bind(window)||window.mozRequestAnimationFrame||window.webkitRequestAnimationFrame)||function(t){return setTimeout(t,16)};var pr={linear:function(t){return t},quadraticIn:function(t){return t*t},quadraticOut:function(t){return t*(2-t)},quadraticInOut:function(t){return(t*=2)<1?.5*t*t:-.5*(--t*(t-2)-1)},cubicIn:function(t){return t*t*t},cubicOut:function(t){return--t*t*t+1},cubicInOut:function(t){return(t*=2)<1?.5*t*t*t:.5*((t-=2)*t*t+2)},quarticIn:function(t){return t*t*t*t},quarticOut:function(t){return 1- --t*t*t*t},quarticInOut:function(t){return(t*=2)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2)},quinticIn:function(t){return t*t*t*t*t},quinticOut:function(t){return--t*t*t*t*t+1},quinticInOut:function(t){return(t*=2)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2)},sinusoidalIn:function(t){return 1-Math.cos(t*Math.PI/2)},sinusoidalOut:function(t){return Math.sin(t*Math.PI/2)},sinusoidalInOut:function(t){return.5*(1-Math.cos(Math.PI*t))},exponentialIn:function(t){return 0===t?0:Math.pow(1024,t-1)},exponentialOut:function(t){return 1===t?1:1-Math.pow(2,-10*t)},exponentialInOut:function(t){return 0===t?0:1===t?1:(t*=2)<1?.5*Math.pow(1024,t-1):.5*(2-Math.pow(2,-10*(t-1)))},circularIn:function(t){return 1-Math.sqrt(1-t*t)},circularOut:function(t){return Math.sqrt(1- --t*t)},circularInOut:function(t){return(t*=2)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1)},elasticIn:function(t){var e,n=.1;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=.4*Math.asin(1/n)/(2*Math.PI),-n*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/.4))},elasticOut:function(t){var e,n=.1;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=.4*Math.asin(1/n)/(2*Math.PI),n*Math.pow(2,-10*t)*Math.sin((t-e)*(2*Math.PI)/.4)+1)},elasticInOut:function(t){var e,n=.1,i=.4;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=i*Math.asin(1/n)/(2*Math.PI),(t*=2)<1?n*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/i)*-.5:n*Math.pow(2,-10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/i)*.5+1)},backIn:function(t){var e=1.70158;return t*t*((e+1)*t-e)},backOut:function(t){var e=1.70158;return--t*t*((e+1)*t+e)+1},backInOut:function(t){var e=2.5949095;return(t*=2)<1?t*t*((e+1)*t-e)*.5:.5*((t-=2)*t*((e+1)*t+e)+2)},bounceIn:function(t){return 1-pr.bounceOut(1-t)},bounceOut:function(t){return t<1/2.75?7.5625*t*t:t<2/2.75?7.5625*(t-=1.5/2.75)*t+.75:t<2.5/2.75?7.5625*(t-=2.25/2.75)*t+.9375:7.5625*(t-=2.625/2.75)*t+.984375},bounceInOut:function(t){return t<.5?.5*pr.bounceIn(2*t):.5*pr.bounceOut(2*t-1)+.5}},fr=Math.pow,gr=Math.sqrt,vr=1e-8,mr=1e-4,yr=gr(3),_r=1/3,br=Hn(),xr=Hn(),wr=Hn();function Sr(t){return t>-1e-8&&tvr||t<-1e-8}function Mr(t,e,n,i,r){var o=1-r;return o*o*(o*t+3*r*e)+r*r*(r*i+3*o*n)}function kr(t,e,n,i,r){var o=1-r;return 3*(((e-t)*o+2*(n-e)*r)*o+(i-n)*r*r)}function Tr(t,e,n,i,r,o){var a=i+3*(e-n)-t,s=3*(n-2*e+t),l=3*(e-t),c=t-r,u=s*s-3*a*l,d=s*l-9*a*c,h=l*l-3*s*c,p=0;if(Sr(u)&&Sr(d)){if(Sr(s))o[0]=0;else(C=-l/s)>=0&&C<=1&&(o[p++]=C)}else{var f=d*d-4*u*h;if(Sr(f)){var g=d/u,v=-g/2;(C=-s/a+g)>=0&&C<=1&&(o[p++]=C),v>=0&&v<=1&&(o[p++]=v)}else if(f>0){var m=gr(f),y=u*s+1.5*a*(-d+m),_=u*s+1.5*a*(-d-m);(C=(-s-((y=y<0?-fr(-y,_r):fr(y,_r))+(_=_<0?-fr(-_,_r):fr(_,_r))))/(3*a))>=0&&C<=1&&(o[p++]=C)}else{var b=(2*u*s-3*a*d)/(2*gr(u*u*u)),x=Math.acos(b)/3,w=gr(u),S=Math.cos(x),C=(-s-2*w*S)/(3*a),M=(v=(-s+w*(S+yr*Math.sin(x)))/(3*a),(-s+w*(S-yr*Math.sin(x)))/(3*a));C>=0&&C<=1&&(o[p++]=C),v>=0&&v<=1&&(o[p++]=v),M>=0&&M<=1&&(o[p++]=M)}}return p}function Ar(t,e,n,i,r){var o=6*n-12*e+6*t,a=9*e+3*i-3*t-9*n,s=3*e-3*t,l=0;if(Sr(a)){if(Cr(o))(u=-s/o)>=0&&u<=1&&(r[l++]=u)}else{var c=o*o-4*a*s;if(Sr(c))r[0]=-o/(2*a);else if(c>0){var u,d=gr(c),h=(-o-d)/(2*a);(u=(-o+d)/(2*a))>=0&&u<=1&&(r[l++]=u),h>=0&&h<=1&&(r[l++]=h)}}return l}function Dr(t,e,n,i,r,o){var a=(e-t)*r+t,s=(n-e)*r+e,l=(i-n)*r+n,c=(s-a)*r+a,u=(l-s)*r+s,d=(u-c)*r+c;o[0]=t,o[1]=a,o[2]=c,o[3]=d,o[4]=d,o[5]=u,o[6]=l,o[7]=i}function Ir(t,e,n,i,r,o,a,s,l){for(var c=t,u=e,d=0,h=1/l,p=1;p<=l;p++){var f=p*h,g=Mr(t,n,r,a,f),v=Mr(e,i,o,s,f),m=g-c,y=v-u;d+=Math.sqrt(m*m+y*y),c=g,u=v}return d}function Pr(t,e,n,i){var r=1-i;return r*(r*t+2*i*e)+i*i*n}function Lr(t,e,n,i){return 2*((1-i)*(e-t)+i*(n-e))}function Er(t,e,n){var i=t+n-2*e;return 0===i?.5:(t-e)/i}function Or(t,e,n,i,r){var o=(e-t)*i+t,a=(n-e)*i+e,s=(a-o)*i+o;r[0]=t,r[1]=o,r[2]=s,r[3]=s,r[4]=a,r[5]=n}function zr(t,e,n,i,r,o,a){for(var s=t,l=e,c=0,u=1/a,d=1;d<=a;d++){var h=d*u,p=Pr(t,n,r,h),f=Pr(e,i,o,h),g=p-s,v=f-l;c+=Math.sqrt(g*g+v*v),s=p,l=f}return c}var Nr=/cubic-bezier\(([0-9,\.e ]+)\)/;function Rr(t){var e=t&&Nr.exec(t);if(e){var n=e[1].split(","),i=+kn(n[0]),r=+kn(n[1]),o=+kn(n[2]),a=+kn(n[3]);if(isNaN(i+r+o+a))return;var s=[];return function(t){return t<=0?0:t>=1?1:Tr(0,i,o,1,t,s)&&Mr(0,r,a,1,s[0])}}}var Hr=function(){function t(t){this._inited=!1,this._startTime=0,this._pausedTime=0,this._paused=!1,this._life=t.life||1e3,this._delay=t.delay||0,this.loop=t.loop||!1,this.onframe=t.onframe||Nn,this.ondestroy=t.ondestroy||Nn,this.onrestart=t.onrestart||Nn,t.easing&&this.setEasing(t.easing)}return t.prototype.step=function(t,e){if(this._inited||(this._startTime=t+this._delay,this._inited=!0),!this._paused){var n=this._life,i=t-this._startTime-this._pausedTime,r=i/n;r<0&&(r=0),r=Math.min(r,1);var o=this.easingFunc,a=o?o(r):r;if(this.onframe(a),1===r){if(!this.loop)return!0;var s=i%n;this._startTime=t-s,this._pausedTime=0,this.onrestart()}return!1}this._pausedTime+=e},t.prototype.pause=function(){this._paused=!0},t.prototype.resume=function(){this._paused=!1},t.prototype.setEasing=function(t){this.easing=t,this.easingFunc=un(t)?t:pr[t]||Rr(t)},t}(),Br=function(t){this.value=t},Fr=function(){function t(){this._len=0}return t.prototype.insert=function(t){var e=new Br(t);return this.insertEntry(e),e},t.prototype.insertEntry=function(t){this.head?(this.tail.next=t,t.prev=this.tail,t.next=null,this.tail=t):this.head=this.tail=t,this._len++},t.prototype.remove=function(t){var e=t.prev,n=t.next;e?e.next=n:this.head=n,n?n.prev=e:this.tail=e,t.next=t.prev=null,this._len--},t.prototype.len=function(){return this._len},t.prototype.clear=function(){this.head=this.tail=null,this._len=0},t}(),$r=function(){function t(t){this._list=new Fr,this._maxSize=10,this._map={},this._maxSize=t}return t.prototype.put=function(t,e){var n=this._list,i=this._map,r=null;if(null==i[t]){var o=n.len(),a=this._lastRemovedEntry;if(o>=this._maxSize&&o>0){var s=n.head;n.remove(s),delete i[s.key],r=s.value,this._lastRemovedEntry=s}a?a.value=e:a=new Br(e),a.key=t,n.insertEntry(a),i[t]=a}return r},t.prototype.get=function(t){var e=this._map[t],n=this._list;if(null!=e)return e!==n.tail&&(n.remove(e),n.insertEntry(e)),e.value},t.prototype.clear=function(){this._list.clear(),this._map={}},t.prototype.len=function(){return this._list.len()},t}(),Vr={transparent:[0,0,0,0],aliceblue:[240,248,255,1],antiquewhite:[250,235,215,1],aqua:[0,255,255,1],aquamarine:[127,255,212,1],azure:[240,255,255,1],beige:[245,245,220,1],bisque:[255,228,196,1],black:[0,0,0,1],blanchedalmond:[255,235,205,1],blue:[0,0,255,1],blueviolet:[138,43,226,1],brown:[165,42,42,1],burlywood:[222,184,135,1],cadetblue:[95,158,160,1],chartreuse:[127,255,0,1],chocolate:[210,105,30,1],coral:[255,127,80,1],cornflowerblue:[100,149,237,1],cornsilk:[255,248,220,1],crimson:[220,20,60,1],cyan:[0,255,255,1],darkblue:[0,0,139,1],darkcyan:[0,139,139,1],darkgoldenrod:[184,134,11,1],darkgray:[169,169,169,1],darkgreen:[0,100,0,1],darkgrey:[169,169,169,1],darkkhaki:[189,183,107,1],darkmagenta:[139,0,139,1],darkolivegreen:[85,107,47,1],darkorange:[255,140,0,1],darkorchid:[153,50,204,1],darkred:[139,0,0,1],darksalmon:[233,150,122,1],darkseagreen:[143,188,143,1],darkslateblue:[72,61,139,1],darkslategray:[47,79,79,1],darkslategrey:[47,79,79,1],darkturquoise:[0,206,209,1],darkviolet:[148,0,211,1],deeppink:[255,20,147,1],deepskyblue:[0,191,255,1],dimgray:[105,105,105,1],dimgrey:[105,105,105,1],dodgerblue:[30,144,255,1],firebrick:[178,34,34,1],floralwhite:[255,250,240,1],forestgreen:[34,139,34,1],fuchsia:[255,0,255,1],gainsboro:[220,220,220,1],ghostwhite:[248,248,255,1],gold:[255,215,0,1],goldenrod:[218,165,32,1],gray:[128,128,128,1],green:[0,128,0,1],greenyellow:[173,255,47,1],grey:[128,128,128,1],honeydew:[240,255,240,1],hotpink:[255,105,180,1],indianred:[205,92,92,1],indigo:[75,0,130,1],ivory:[255,255,240,1],khaki:[240,230,140,1],lavender:[230,230,250,1],lavenderblush:[255,240,245,1],lawngreen:[124,252,0,1],lemonchiffon:[255,250,205,1],lightblue:[173,216,230,1],lightcoral:[240,128,128,1],lightcyan:[224,255,255,1],lightgoldenrodyellow:[250,250,210,1],lightgray:[211,211,211,1],lightgreen:[144,238,144,1],lightgrey:[211,211,211,1],lightpink:[255,182,193,1],lightsalmon:[255,160,122,1],lightseagreen:[32,178,170,1],lightskyblue:[135,206,250,1],lightslategray:[119,136,153,1],lightslategrey:[119,136,153,1],lightsteelblue:[176,196,222,1],lightyellow:[255,255,224,1],lime:[0,255,0,1],limegreen:[50,205,50,1],linen:[250,240,230,1],magenta:[255,0,255,1],maroon:[128,0,0,1],mediumaquamarine:[102,205,170,1],mediumblue:[0,0,205,1],mediumorchid:[186,85,211,1],mediumpurple:[147,112,219,1],mediumseagreen:[60,179,113,1],mediumslateblue:[123,104,238,1],mediumspringgreen:[0,250,154,1],mediumturquoise:[72,209,204,1],mediumvioletred:[199,21,133,1],midnightblue:[25,25,112,1],mintcream:[245,255,250,1],mistyrose:[255,228,225,1],moccasin:[255,228,181,1],navajowhite:[255,222,173,1],navy:[0,0,128,1],oldlace:[253,245,230,1],olive:[128,128,0,1],olivedrab:[107,142,35,1],orange:[255,165,0,1],orangered:[255,69,0,1],orchid:[218,112,214,1],palegoldenrod:[238,232,170,1],palegreen:[152,251,152,1],paleturquoise:[175,238,238,1],palevioletred:[219,112,147,1],papayawhip:[255,239,213,1],peachpuff:[255,218,185,1],peru:[205,133,63,1],pink:[255,192,203,1],plum:[221,160,221,1],powderblue:[176,224,230,1],purple:[128,0,128,1],red:[255,0,0,1],rosybrown:[188,143,143,1],royalblue:[65,105,225,1],saddlebrown:[139,69,19,1],salmon:[250,128,114,1],sandybrown:[244,164,96,1],seagreen:[46,139,87,1],seashell:[255,245,238,1],sienna:[160,82,45,1],silver:[192,192,192,1],skyblue:[135,206,235,1],slateblue:[106,90,205,1],slategray:[112,128,144,1],slategrey:[112,128,144,1],snow:[255,250,250,1],springgreen:[0,255,127,1],steelblue:[70,130,180,1],tan:[210,180,140,1],teal:[0,128,128,1],thistle:[216,191,216,1],tomato:[255,99,71,1],turquoise:[64,224,208,1],violet:[238,130,238,1],wheat:[245,222,179,1],white:[255,255,255,1],whitesmoke:[245,245,245,1],yellow:[255,255,0,1],yellowgreen:[154,205,50,1]};function Wr(t){return(t=Math.round(t))<0?0:t>255?255:t}function Ur(t){return t<0?0:t>1?1:t}function Gr(t){var e=t;return e.length&&"%"===e.charAt(e.length-1)?Wr(parseFloat(e)/100*255):Wr(parseInt(e,10))}function qr(t){var e=t;return e.length&&"%"===e.charAt(e.length-1)?Ur(parseFloat(e)/100):Ur(parseFloat(e))}function jr(t,e,n){return n<0?n+=1:n>1&&(n-=1),6*n<1?t+(e-t)*n*6:2*n<1?e:3*n<2?t+(e-t)*(2/3-n)*6:t}function Xr(t,e,n){return t+(e-t)*n}function Yr(t,e,n,i,r){return t[0]=e,t[1]=n,t[2]=i,t[3]=r,t}function Zr(t,e){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t}var Kr=new $r(20),Qr=null;function Jr(t,e){Qr&&Zr(Qr,e),Qr=Kr.put(t,Qr||e.slice())}function to(t,e){if(t){e=e||[];var n=Kr.get(t);if(n)return Zr(e,n);var i=(t+="").replace(/ /g,"").toLowerCase();if(i in Vr)return Zr(e,Vr[i]),Jr(t,e),e;var r,o=i.length;if("#"===i.charAt(0))return 4===o||5===o?(r=parseInt(i.slice(1,4),16))>=0&&r<=4095?(Yr(e,(3840&r)>>4|(3840&r)>>8,240&r|(240&r)>>4,15&r|(15&r)<<4,5===o?parseInt(i.slice(4),16)/15:1),Jr(t,e),e):void Yr(e,0,0,0,1):7===o||9===o?(r=parseInt(i.slice(1,7),16))>=0&&r<=16777215?(Yr(e,(16711680&r)>>16,(65280&r)>>8,255&r,9===o?parseInt(i.slice(7),16)/255:1),Jr(t,e),e):void Yr(e,0,0,0,1):void 0;var a=i.indexOf("("),s=i.indexOf(")");if(-1!==a&&s+1===o){var l=i.substr(0,a),c=i.substr(a+1,s-(a+1)).split(","),u=1;switch(l){case"rgba":if(4!==c.length)return 3===c.length?Yr(e,+c[0],+c[1],+c[2],1):Yr(e,0,0,0,1);u=qr(c.pop());case"rgb":return c.length>=3?(Yr(e,Gr(c[0]),Gr(c[1]),Gr(c[2]),3===c.length?u:qr(c[3])),Jr(t,e),e):void Yr(e,0,0,0,1);case"hsla":return 4!==c.length?void Yr(e,0,0,0,1):(c[3]=qr(c[3]),eo(c,e),Jr(t,e),e);case"hsl":return 3!==c.length?void Yr(e,0,0,0,1):(eo(c,e),Jr(t,e),e);default:return}}Yr(e,0,0,0,1)}}function eo(t,e){var n=(parseFloat(t[0])%360+360)%360/360,i=qr(t[1]),r=qr(t[2]),o=r<=.5?r*(i+1):r+i-r*i,a=2*r-o;return Yr(e=e||[],Wr(255*jr(a,o,n+1/3)),Wr(255*jr(a,o,n)),Wr(255*jr(a,o,n-1/3)),1),4===t.length&&(e[3]=t[3]),e}function no(t,e){var n=to(t);if(n){for(var i=0;i<3;i++)n[i]=n[i]*(1-e)|0,n[i]>255?n[i]=255:n[i]<0&&(n[i]=0);return ro(n,4===n.length?"rgba":"rgb")}}function io(t,e,n,i){var r=to(t);if(t)return r=function(t){if(t){var e,n,i=t[0]/255,r=t[1]/255,o=t[2]/255,a=Math.min(i,r,o),s=Math.max(i,r,o),l=s-a,c=(s+a)/2;if(0===l)e=0,n=0;else{n=c<.5?l/(s+a):l/(2-s-a);var u=((s-i)/6+l/2)/l,d=((s-r)/6+l/2)/l,h=((s-o)/6+l/2)/l;i===s?e=h-d:r===s?e=1/3+u-h:o===s&&(e=2/3+d-u),e<0&&(e+=1),e>1&&(e-=1)}var p=[360*e,n,c];return null!=t[3]&&p.push(t[3]),p}}(r),null!=n&&(r[1]=qr(un(n)?n(r[1]):n)),null!=i&&(r[2]=qr(un(i)?i(r[2]):i)),ro(eo(r),"rgba")}function ro(t,e){if(t&&t.length){var n=t[0]+","+t[1]+","+t[2];return"rgba"!==e&&"hsva"!==e&&"hsla"!==e||(n+=","+t[3]),e+"("+n+")"}}function oo(t,e){var n=to(t);return n?(.299*n[0]+.587*n[1]+.114*n[2])*n[3]/255+(1-n[3])*e:0}var ao=new $r(100);function so(t){if(dn(t)){var e=ao.get(t);return e||(e=no(t,-.1),ao.put(t,e)),e}if(yn(t)){var n=Ze({},t);return n.colorStops=nn(t.colorStops,function(t){return{offset:t.offset,color:no(t.color,-.1)}}),n}return t}var lo=Math.round;function co(t){var e;if(t&&"transparent"!==t){if("string"==typeof t&&t.indexOf("rgba")>-1){var n=to(t);n&&(t="rgb("+n[0]+","+n[1]+","+n[2]+")",e=n[3])}}else t="none";return{color:t,opacity:null==e?1:e}}var uo=1e-4;function ho(t){return t-1e-4}function po(t){return lo(1e3*t)/1e3}function fo(t){return lo(1e4*t)/1e4}var go={left:"start",right:"end",center:"middle",middle:"middle"};function vo(t){return t&&!!t.image}function mo(t){return vo(t)||function(t){return t&&!!t.svgElement}(t)}function yo(t){return"linear"===t.type}function _o(t){return"radial"===t.type}function bo(t){return t&&("linear"===t.type||"radial"===t.type)}function xo(t){return"url(#"+t+")"}function wo(t){var e=t.getGlobalScale(),n=Math.max(e[0],e[1]);return Math.max(Math.ceil(Math.log(n)/Math.log(10)),1)}function So(t){var e=t.x||0,n=t.y||0,i=(t.rotation||0)*Rn,r=xn(t.scaleX,1),o=xn(t.scaleY,1),a=t.skewX||0,s=t.skewY||0,l=[];return(e||n)&&l.push("translate("+e+"px,"+n+"px)"),i&&l.push("rotate("+i+")"),1===r&&1===o||l.push("scale("+r+","+o+")"),(a||s)&&l.push("skew("+lo(a*Rn)+"deg, "+lo(s*Rn)+"deg)"),l.join(" ")}var Co=Te.hasGlobalWindow&&un(window.btoa)?function(t){return window.btoa(unescape(encodeURIComponent(t)))}:"undefined"!=typeof Buffer?function(t){return Buffer.from(t).toString("base64")}:function(t){return null},Mo=Array.prototype.slice;function ko(t,e,n){return(e-t)*n+t}function To(t,e,n,i){for(var r=e.length,o=0;oi?e:t,o=Math.min(n,i),a=r[o-1]||{color:[0,0,0,0],offset:0},s=o;sa)i.length=a;else for(var s=o;s=1},t.prototype.getAdditiveTrack=function(){return this._additiveTrack},t.prototype.addKeyframe=function(t,e,n){this._needsSort=!0;var i=this.keyframes,r=i.length,o=!1,a=6,s=e;if(tn(e)){var l=function(t){return tn(t&&t[0])?2:1}(e);a=l,(1===l&&!pn(e[0])||2===l&&!pn(e[0][0]))&&(o=!0)}else if(pn(e)&&!_n(e))a=0;else if(dn(e))if(isNaN(+e)){var c=to(e);c&&(s=c,a=3)}else a=0;else if(yn(e)){var u=Ze({},s);u.colorStops=nn(e.colorStops,function(t){return{offset:t.offset,color:to(t.color)}}),yo(e)?a=4:_o(e)&&(a=5),s=u}0===r?this.valType=a:a===this.valType&&6!==a||(o=!0),this.discrete=this.discrete||o;var d={time:t,value:s,rawValue:e,percent:0};return n&&(d.easing=n,d.easingFunc=un(n)?n:pr[n]||Rr(n)),i.push(d),d},t.prototype.prepare=function(t,e){var n=this.keyframes;this._needsSort&&n.sort(function(t,e){return t.time-e.time});for(var i=this.valType,r=n.length,o=n[r-1],a=this.discrete,s=zo(i),l=Oo(i),c=0;c=0&&!(l[n].percent<=e);n--);n=p(n,c-2)}else{for(n=h;ne);n++);n=p(n-1,c-2)}r=l[n+1],i=l[n]}if(i&&r){this._lastFr=n,this._lastFrP=e;var f=r.percent-i.percent,g=0===f?1:p((e-i.percent)/f,1);r.easingFunc&&(g=r.easingFunc(g));var v=o?this._additiveValue:d?No:t[u];if(!zo(s)&&!d||v||(v=this._additiveValue=[]),this.discrete)t[u]=g<1?i.rawValue:r.rawValue;else if(zo(s))1===s?To(v,i[a],r[a],g):function(t,e,n,i){for(var r=e.length,o=r&&e[0].length,a=0;a0&&s.addKeyframe(0,Lo(l),i),this._trackKeys.push(a)}s.addKeyframe(t,Lo(e[a]),i)}return this._maxTime=Math.max(this._maxTime,t),this},t.prototype.pause=function(){this._clip.pause(),this._paused=!0},t.prototype.resume=function(){this._clip.resume(),this._paused=!1},t.prototype.isPaused=function(){return!!this._paused},t.prototype.duration=function(t){return this._maxTime=t,this._force=!0,this},t.prototype._doneCallback=function(){this._setTracksFinished(),this._clip=null;var t=this._doneCbs;if(t)for(var e=t.length,n=0;n0)){this._started=1;for(var e=this,n=[],i=this._maxTime||0,r=0;r1){var a=o.pop();r.addKeyframe(a.time,t[i]),r.prepare(this._maxTime,r.getAdditiveTrack())}}}},t}();function Bo(){return(new Date).getTime()}var Fo,$o,Vo=function(t){function e(e){var n=t.call(this)||this;return n._running=!1,n._time=0,n._pausedTime=0,n._pauseStart=0,n._paused=!1,e=e||{},n.stage=e.stage||{},n}return _(e,t),e.prototype.addClip=function(t){t.animation&&this.removeClip(t),this._head?(this._tail.next=t,t.prev=this._tail,t.next=null,this._tail=t):this._head=this._tail=t,t.animation=this},e.prototype.addAnimator=function(t){t.animation=this;var e=t.getClip();e&&this.addClip(e)},e.prototype.removeClip=function(t){if(t.animation){var e=t.prev,n=t.next;e?e.next=n:this._head=n,n?n.prev=e:this._tail=e,t.next=t.prev=t.animation=null}},e.prototype.removeAnimator=function(t){var e=t.getClip();e&&this.removeClip(e),t.animation=null},e.prototype.update=function(t){for(var e=Bo()-this._pausedTime,n=e-this._time,i=this._head;i;){var r=i.next;i.step(e,n)?(i.ondestroy(),this.removeClip(i),i=r):i=r}this._time=e,t||(this.trigger("frame",n),this.stage.update&&this.stage.update())},e.prototype._startLoop=function(){var t=this;this._running=!0,dr(function e(){t._running&&(dr(e),!t._paused&&t.update())})},e.prototype.start=function(){this._running||(this._time=Bo(),this._pausedTime=0,this._startLoop())},e.prototype.stop=function(){this._running=!1},e.prototype.pause=function(){this._paused||(this._pauseStart=Bo(),this._paused=!0)},e.prototype.resume=function(){this._paused&&(this._pausedTime+=Bo()-this._pauseStart,this._paused=!1)},e.prototype.clear=function(){for(var t=this._head;t;){var e=t.next;t.prev=t.next=t.animation=null,t=e}this._head=this._tail=null},e.prototype.isFinished=function(){return null==this._head},e.prototype.animate=function(t,e){e=e||{},this.start();var n=new Ho(t,e.loop);return this.addAnimator(n),n},e}(Qn),Wo=Te.domSupported,Uo=($o={pointerdown:1,pointerup:1,pointermove:1,pointerout:1},{mouse:Fo=["click","dblclick","mousewheel","wheel","mouseout","mouseup","mousedown","mousemove","contextmenu"],touch:["touchstart","touchend","touchmove"],pointer:nn(Fo,function(t){var e=t.replace("mouse","pointer");return $o.hasOwnProperty(e)?e:t})}),Go=["mousemove","mouseup"],qo=["pointermove","pointerup"],jo=!1;function Xo(t){var e=t.pointerType;return"pen"===e||"touch"===e}function Yo(t){t&&(t.zrByTouch=!0)}function Zo(t,e){for(var n=e,i=!1;n&&9!==n.nodeType&&!(i=n.domBelongToZr||n!==e&&n===t.painterRoot);)n=n.parentNode;return i}var Ko=function(t,e){this.stopPropagation=Nn,this.stopImmediatePropagation=Nn,this.preventDefault=Nn,this.type=e.type,this.target=this.currentTarget=t.dom,this.pointerType=e.pointerType,this.clientX=e.clientX,this.clientY=e.clientY},Qo={mousedown:function(t){t=gi(this.dom,t),this.__mayPointerCapture=[t.zrX,t.zrY],this.trigger("mousedown",t)},mousemove:function(t){t=gi(this.dom,t);var e=this.__mayPointerCapture;!e||t.zrX===e[0]&&t.zrY===e[1]||this.__togglePointerCapture(!0),this.trigger("mousemove",t)},mouseup:function(t){t=gi(this.dom,t),this.__togglePointerCapture(!1),this.trigger("mouseup",t)},mouseout:function(t){Zo(this,(t=gi(this.dom,t)).toElement||t.relatedTarget)||(this.__pointerCapturing&&(t.zrEventControl="no_globalout"),this.trigger("mouseout",t))},wheel:function(t){jo=!0,t=gi(this.dom,t),this.trigger("mousewheel",t)},mousewheel:function(t){jo||(t=gi(this.dom,t),this.trigger("mousewheel",t))},touchstart:function(t){Yo(t=gi(this.dom,t)),this.__lastTouchMoment=new Date,this.handler.processGesture(t,"start"),Qo.mousemove.call(this,t),Qo.mousedown.call(this,t)},touchmove:function(t){Yo(t=gi(this.dom,t)),this.handler.processGesture(t,"change"),Qo.mousemove.call(this,t)},touchend:function(t){Yo(t=gi(this.dom,t)),this.handler.processGesture(t,"end"),Qo.mouseup.call(this,t),+new Date-+this.__lastTouchMoment<300&&Qo.click.call(this,t)},pointerdown:function(t){Qo.mousedown.call(this,t)},pointermove:function(t){Xo(t)||Qo.mousemove.call(this,t)},pointerup:function(t){Qo.mouseup.call(this,t)},pointerout:function(t){Xo(t)||Qo.mouseout.call(this,t)}};en(["click","dblclick","contextmenu"],function(t){Qo[t]=function(e){e=gi(this.dom,e),this.trigger(t,e)}});var Jo={pointermove:function(t){Xo(t)||Jo.mousemove.call(this,t)},pointerup:function(t){Jo.mouseup.call(this,t)},mousemove:function(t){this.trigger("mousemove",t)},mouseup:function(t){var e=this.__pointerCapturing;this.__togglePointerCapture(!1),this.trigger("mouseup",t),e&&(t.zrEventControl="only_globalout",this.trigger("mouseout",t))}};function ta(t,e){var n=e.domHandlers;Te.pointerEventsSupported?en(Uo.pointer,function(i){na(e,i,function(e){n[i].call(t,e)})}):(Te.touchEventsSupported&&en(Uo.touch,function(i){na(e,i,function(r){n[i].call(t,r),function(t){t.touching=!0,null!=t.touchTimer&&(clearTimeout(t.touchTimer),t.touchTimer=null),t.touchTimer=setTimeout(function(){t.touching=!1,t.touchTimer=null},700)}(e)})}),en(Uo.mouse,function(i){na(e,i,function(r){r=fi(r),e.touching||n[i].call(t,r)})}))}function ea(t,e){function n(n){na(e,n,function(i){i=fi(i),Zo(t,i.target)||(i=function(t,e){return gi(t.dom,new Ko(t,e),!0)}(t,i),e.domHandlers[n].call(t,i))},{capture:!0})}Te.pointerEventsSupported?en(qo,n):Te.touchEventsSupported||en(Go,n)}function na(t,e,n,i){t.mounted[e]=n,t.listenerOpts[e]=i,function(t,e,n,i){t.addEventListener(e,n,i)}(t.domTarget,e,n,i)}function ia(t){var e=t.mounted;for(var n in e)e.hasOwnProperty(n)&&vi(t.domTarget,n,e[n],t.listenerOpts[n]);t.mounted={}}var ra=function(t,e){this.mounted={},this.listenerOpts={},this.touching=!1,this.domTarget=t,this.domHandlers=e},oa=function(t){function e(e,n){var i=t.call(this)||this;return i.__pointerCapturing=!1,i.dom=e,i.painterRoot=n,i._localHandlerScope=new ra(e,Qo),Wo&&(i._globalHandlerScope=new ra(document,Jo)),ta(i,i._localHandlerScope),i}return _(e,t),e.prototype.dispose=function(){ia(this._localHandlerScope),Wo&&ia(this._globalHandlerScope)},e.prototype.setCursor=function(t){this.dom.style&&(this.dom.style.cursor=t||"default")},e.prototype.__togglePointerCapture=function(t){if(this.__mayPointerCapture=null,Wo&&+this.__pointerCapturing^+t){this.__pointerCapturing=t;var e=this._globalHandlerScope;t?ea(this,e):ia(e)}},e}(Qn),aa=1;Te.hasGlobalWindow&&(aa=Math.max(window.devicePixelRatio||window.screen&&window.screen.deviceXDPI/window.screen.logicalXDPI||1,1));var sa=aa,la="#333",ca="#ccc",ua=xi,da=5e-5;function ha(t){return t>da||t<-5e-5}var pa,fa=[],ga=[],va=[1,0,0,1,0,0],ma=Math.abs,ya=function(){function t(){}var e;return t.prototype.getLocalTransform=function(e){return t.getLocalTransform(this,e)},t.prototype.setPosition=function(t){this.x=t[0],this.y=t[1]},t.prototype.setScale=function(t){this.scaleX=t[0],this.scaleY=t[1]},t.prototype.setSkew=function(t){this.skewX=t[0],this.skewY=t[1]},t.prototype.setOrigin=function(t){this.originX=t[0],this.originY=t[1]},t.prototype.needLocalTransform=function(){return ha(this.rotation)||ha(this.x)||ha(this.y)||ha(this.scaleX-1)||ha(this.scaleY-1)||ha(this.skewX)||ha(this.skewY)},t.prototype.updateTransform=function(){var t=this.parent&&this.parent.transform,e=this.needLocalTransform(),n=this.transform;e||t?(n=n||[1,0,0,1,0,0],e?this.getLocalTransform(n):ua(n),t&&(e?Si(n,t,n):wi(n,t)),this.transform=n,this._resolveGlobalScaleRatio(n)):n&&(ua(n),this.invTransform=null)},t.prototype._resolveGlobalScaleRatio=function(t){var e=this.globalScaleRatio;if(null!=e&&1!==e){this.getGlobalScale(fa);var n=fa[0]<0?-1:1,i=fa[1]<0?-1:1,r=((fa[0]-n)*e+n)/fa[0]||0,o=((fa[1]-i)*e+i)/fa[1]||0;t[0]*=r,t[1]*=r,t[2]*=o,t[3]*=o}this.invTransform=this.invTransform||[1,0,0,1,0,0],ki(this.invTransform,t)},t.prototype.getComputedTransform=function(){for(var t=this,e=[];t;)e.push(t),t=t.parent;for(;t=e.pop();)t.updateTransform();return this.transform},t.prototype.setLocalTransform=function(t){if(t){var e=t[0]*t[0]+t[1]*t[1],n=t[2]*t[2]+t[3]*t[3],i=Math.atan2(t[1],t[0]),r=Math.PI/2+i-Math.atan2(t[3],t[2]);n=Math.sqrt(n)*Math.cos(r),e=Math.sqrt(e),this.skewX=r,this.skewY=0,this.rotation=-i,this.x=+t[4],this.y=+t[5],this.scaleX=e,this.scaleY=n,this.originX=0,this.originY=0}},t.prototype.decomposeTransform=function(){if(this.transform){var t=this.parent,e=this.transform;t&&t.transform&&(t.invTransform=t.invTransform||[1,0,0,1,0,0],Si(ga,t.invTransform,e),e=ga);var n=this.originX,i=this.originY;(n||i)&&(va[4]=n,va[5]=i,Si(ga,e,va),ga[4]-=n,ga[5]-=i,e=ga),this.setLocalTransform(e)}},t.prototype.getGlobalScale=function(t){var e=this.transform;return t=t||[],e?(t[0]=Math.sqrt(e[0]*e[0]+e[1]*e[1]),t[1]=Math.sqrt(e[2]*e[2]+e[3]*e[3]),e[0]<0&&(t[0]=-t[0]),e[3]<0&&(t[1]=-t[1]),t):(t[0]=1,t[1]=1,t)},t.prototype.transformCoordToLocal=function(t,e){var n=[t,e],i=this.invTransform;return i&&jn(n,n,i),n},t.prototype.transformCoordToGlobal=function(t,e){var n=[t,e],i=this.transform;return i&&jn(n,n,i),n},t.prototype.getLineScale=function(){var t=this.transform;return t&&ma(t[0]-1)>1e-10&&ma(t[3]-1)>1e-10?Math.sqrt(ma(t[0]*t[3]-t[2]*t[1])):1},t.prototype.copyTransform=function(t){ba(this,t)},t.getLocalTransform=function(t,e){e=e||[];var n=t.originX||0,i=t.originY||0,r=t.scaleX,o=t.scaleY,a=t.anchorX,s=t.anchorY,l=t.rotation||0,c=t.x,u=t.y,d=t.skewX?Math.tan(t.skewX):0,h=t.skewY?Math.tan(-t.skewY):0;if(n||i||a||s){var p=n+a,f=i+s;e[4]=-p*r-d*f*o,e[5]=-f*o-h*p*r}else e[4]=e[5]=0;return e[0]=r,e[3]=o,e[1]=h*r,e[2]=d*o,l&&Mi(e,e,l),e[4]+=n+c,e[5]+=i+u,e},t.initDefaultProps=((e=t.prototype).scaleX=e.scaleY=e.globalScaleRatio=1,void(e.x=e.y=e.originX=e.originY=e.skewX=e.skewY=e.rotation=e.anchorX=e.anchorY=0)),t}(),_a=["x","y","originX","originY","anchorX","anchorY","rotation","scaleX","scaleY","skewX","skewY"];function ba(t,e){for(var n=0;n<_a.length;n++){var i=_a[n];t[i]=e[i]}}function xa(t){pa||(pa=new $r(100)),t=t||De;var e=pa.get(t);return e||(e={font:t,strWidthCache:new $r(500),asciiWidthMap:null,asciiWidthMapTried:!1,stWideCharWidth:Ee.measureText("国",t).width,asciiCharWidth:Ee.measureText("a",t).width},pa.put(t,e)),e}var wa=0,Sa=5;function Ca(t,e){return t.asciiWidthMapTried||(t.asciiWidthMap=function(t){if(!(wa>=Sa)){t=t||De;for(var e=[],n=+new Date,i=0;i<=127;i++)e[i]=Ee.measureText(String.fromCharCode(i),t).width;var r=+new Date-n;return r>16?wa=Sa:r>2&&wa++,e}}(t.font),t.asciiWidthMapTried=!0),0<=e&&e<=127?null!=t.asciiWidthMap?t.asciiWidthMap[e]:t.asciiCharWidth:t.stWideCharWidth}function Ma(t,e){var n=t.strWidthCache,i=n.get(e);return null==i&&(i=Ee.measureText(e,t.font).width,n.put(e,i)),i}function ka(t,e,n,i){var r=Ma(xa(e),t),o=Ia(e),a=Aa(0,r,n),s=Da(0,o,i);return new $i(a,s,r,o)}function Ta(t,e,n,i){var r=((t||"")+"").split("\n");if(1===r.length)return ka(r[0],e,n,i);for(var o=new $i(0,0,0,0),a=0;a=0?parseFloat(t)/100*e:parseFloat(t):t}function La(t,e,n){var i=e.position||"inside",r=null!=e.distance?e.distance:5,o=n.height,a=n.width,s=o/2,l=n.x,c=n.y,u="left",d="top";if(i instanceof Array)l+=Pa(i[0],n.width),c+=Pa(i[1],n.height),u=null,d=null;else switch(i){case"left":l-=r,c+=s,u="right",d="middle";break;case"right":l+=r+a,c+=s,d="middle";break;case"top":l+=a/2,c-=r,u="center",d="bottom";break;case"bottom":l+=a/2,c+=o+r,u="center";break;case"inside":l+=a/2,c+=s,u="center",d="middle";break;case"insideLeft":l+=r,c+=s,d="middle";break;case"insideRight":l+=a-r,c+=s,u="right",d="middle";break;case"insideTop":l+=a/2,c+=r,u="center";break;case"insideBottom":l+=a/2,c+=o-r,u="center",d="bottom";break;case"insideTopLeft":l+=r,c+=r;break;case"insideTopRight":l+=a-r,c+=r,u="right";break;case"insideBottomLeft":l+=r,c+=o-r,d="bottom";break;case"insideBottomRight":l+=a-r,c+=o-r,u="right",d="bottom"}return(t=t||{}).x=l,t.y=c,t.align=u,t.verticalAlign=d,t}var Ea="__zr_normal__",Oa=_a.concat(["ignore"]),za=rn(_a,function(t,e){return t[e]=!0,t},{ignore:!1}),Na={},Ra=new $i(0,0,0,0),Ha=[],Ba=function(){function t(t){this.id=qe(),this.animators=[],this.currentStates=[],this.states={},this._init(t)}return t.prototype._init=function(t){this.attr(t)},t.prototype.drift=function(t,e,n){switch(this.draggable){case"horizontal":e=0;break;case"vertical":t=0}var i=this.transform;i||(i=this.transform=[1,0,0,1,0,0]),i[4]+=t,i[5]+=e,this.decomposeTransform(),this.markRedraw()},t.prototype.beforeUpdate=function(){},t.prototype.afterUpdate=function(){},t.prototype.update=function(){this.updateTransform(),this.__dirty&&this.updateInnerText()},t.prototype.updateInnerText=function(t){var e=this._textContent;if(e&&(!e.ignore||t)){this.textConfig||(this.textConfig={});var n=this.textConfig,i=n.local,r=e.innerTransformable,o=void 0,a=void 0,s=!1;r.parent=i?this:null;var l=!1;r.copyTransform(e);var c=null!=n.position,u=n.autoOverflowArea,d=void 0;if((u||c)&&(d=Ra,n.layoutRect?d.copy(n.layoutRect):d.copy(this.getBoundingRect()),i||d.applyTransform(this.transform)),c){this.calculateTextPosition?this.calculateTextPosition(Na,n,d):La(Na,n,d),r.x=Na.x,r.y=Na.y,o=Na.align,a=Na.verticalAlign;var h=n.origin;if(h&&null!=n.rotation){var p=void 0,f=void 0;"center"===h?(p=.5*d.width,f=.5*d.height):(p=Pa(h[0],d.width),f=Pa(h[1],d.height)),l=!0,r.originX=-r.x+p+(i?0:d.x),r.originY=-r.y+f+(i?0:d.y)}}null!=n.rotation&&(r.rotation=n.rotation);var g=n.offset;g&&(r.x+=g[0],r.y+=g[1],l||(r.originX=-g[0],r.originY=-g[1]));var v=this._innerTextDefaultStyle||(this._innerTextDefaultStyle={});if(u){var m=v.overflowRect=v.overflowRect||new $i(0,0,0,0);r.getLocalTransform(Ha),ki(Ha,Ha),$i.copy(m,d),m.applyTransform(Ha)}else v.overflowRect=null;var y=void 0,_=void 0,b=void 0;(null==n.inside?"string"==typeof n.position&&n.position.indexOf("inside")>=0:n.inside)&&this.canBeInsideText()?(y=n.insideFill,_=n.insideStroke,null!=y&&"auto"!==y||(y=this.getInsideTextFill()),null!=_&&"auto"!==_||(_=this.getInsideTextStroke(y),b=!0)):(y=n.outsideFill,_=n.outsideStroke,null!=y&&"auto"!==y||(y=this.getOutsideFill()),null!=_&&"auto"!==_||(_=this.getOutsideStroke(y),b=!0)),(y=y||"#000")===v.fill&&_===v.stroke&&b===v.autoStroke&&o===v.align&&a===v.verticalAlign||(s=!0,v.fill=y,v.stroke=_,v.autoStroke=b,v.align=o,v.verticalAlign=a,e.setDefaultTextStyle(v)),e.__dirty|=1,s&&e.dirtyStyle(!0)}},t.prototype.canBeInsideText=function(){return!0},t.prototype.getInsideTextFill=function(){return"#fff"},t.prototype.getInsideTextStroke=function(t){return"#000"},t.prototype.getOutsideFill=function(){return this.__zr&&this.__zr.isDarkMode()?ca:la},t.prototype.getOutsideStroke=function(t){var e=this.__zr&&this.__zr.getBackgroundColor(),n="string"==typeof e&&to(e);n||(n=[255,255,255,1]);for(var i=n[3],r=this.__zr.isDarkMode(),o=0;o<3;o++)n[o]=n[o]*i+(r?0:255)*(1-i);return n[3]=1,ro(n,"rgba")},t.prototype.traverse=function(t,e){},t.prototype.attrKV=function(t,e){"textConfig"===t?this.setTextConfig(e):"textContent"===t?this.setTextContent(e):"clipPath"===t?this.setClipPath(e):"extra"===t?(this.extra=this.extra||{},Ze(this.extra,e)):this[t]=e},t.prototype.hide=function(){this.ignore=!0,this.markRedraw()},t.prototype.show=function(){this.ignore=!1,this.markRedraw()},t.prototype.attr=function(t,e){if("string"==typeof t)this.attrKV(t,e);else if(fn(t))for(var n=an(t),i=0;i0},t.prototype.getState=function(t){return this.states[t]},t.prototype.ensureState=function(t){var e=this.states;return e[t]||(e[t]={}),e[t]},t.prototype.clearStates=function(t){this.useState(Ea,!1,t)},t.prototype.useState=function(t,e,n,i){var r=t===Ea;if(this.hasState()||!r){var o=this.currentStates,a=this.stateTransition;if(!(Qe(o,t)>=0)||!e&&1!==o.length){var s;if(this.stateProxy&&!r&&(s=this.stateProxy(t)),s||(s=this.states&&this.states[t]),s||r){r||this.saveCurrentToNormalState(s);var l=!!(s&&s.hoverLayer||i);l&&this._toggleHoverLayerFlag(!0),this._applyStateObj(t,s,this._normalState,e,!n&&!this.__inHover&&a&&a.duration>0,a);var c=this._textContent,u=this._textGuide;return c&&c.useState(t,e,n,l),u&&u.useState(t,e,n,l),r?(this.currentStates=[],this._normalState={}):e?this.currentStates.push(t):this.currentStates=[t],this._updateAnimationTargets(),this.markRedraw(),!l&&this.__inHover&&(this._toggleHoverLayerFlag(!1),this.__dirty&=-2),s}je("State "+t+" not exists.")}}},t.prototype.useStates=function(t,e,n){if(t.length){var i=[],r=this.currentStates,o=t.length,a=o===r.length;if(a)for(var s=0;s0,p);var f=this._textContent,g=this._textGuide;f&&f.useStates(t,e,d),g&&g.useStates(t,e,d),this._updateAnimationTargets(),this.currentStates=t.slice(),this.markRedraw(),!d&&this.__inHover&&(this._toggleHoverLayerFlag(!1),this.__dirty&=-2)}else this.clearStates()},t.prototype.isSilent=function(){for(var t=this;t;){if(t.silent)return!0;var e=t.__hostTarget;t=e?t.ignoreHostSilent?null:e:t.parent}return!1},t.prototype._updateAnimationTargets=function(){for(var t=0;t=0){var n=this.currentStates.slice();n.splice(e,1),this.useStates(n)}},t.prototype.replaceState=function(t,e,n){var i=this.currentStates.slice(),r=Qe(i,t),o=Qe(i,e)>=0;r>=0?o?i.splice(r,1):i[r]=e:n&&!o&&i.push(e),this.useStates(i)},t.prototype.toggleState=function(t,e){e?this.useState(t,!0):this.removeState(t)},t.prototype._mergeStates=function(t){for(var e,n={},i=0;i=0&&e.splice(n,1)}),this.animators.push(t),n&&n.animation.addAnimator(t),n&&n.wakeUp()},t.prototype.updateDuringAnimation=function(t){this.markRedraw()},t.prototype.stopAnimation=function(t,e){for(var n=this.animators,i=n.length,r=[],o=0;o0&&n.during&&o[0].during(function(t,e){n.during(e)});for(var h=0;h0||r.force&&!a.length){var w,S=void 0,C=void 0,M=void 0;if(s){C={},h&&(S={});for(b=0;b<_;b++){C[m=g[b]]=n[m],h?S[m]=i[m]:n[m]=i[m]}}else if(h){M={};for(b=0;b<_;b++){M[m=g[b]]=Lo(n[m]),Va(n,i,m)}}(w=new Ho(n,!1,!1,d?on(f,function(t){return t.targetName===e}):null)).targetName=e,r.scope&&(w.scope=r.scope),h&&S&&w.whenWithKeys(0,S,g),M&&w.whenWithKeys(0,M,g),w.whenWithKeys(null==c?500:c,s?C:i,g).delay(u||0),t.addAnimator(w,e),a.push(w)}}Je(Ba,Qn),Je(Ba,ya);var Ua=function(t){function e(e){var n=t.call(this)||this;return n.isGroup=!0,n._children=[],n.attr(e),n}return _(e,t),e.prototype.childrenRef=function(){return this._children},e.prototype.children=function(){return this._children.slice()},e.prototype.childAt=function(t){return this._children[t]},e.prototype.childOfName=function(t){for(var e=this._children,n=0;n=0&&(n.splice(i,0,t),this._doAdd(t))}return this},e.prototype.replace=function(t,e){var n=Qe(this._children,t);return n>=0&&this.replaceAt(e,n),this},e.prototype.replaceAt=function(t,e){var n=this._children,i=n[e];if(t&&t!==this&&t.parent!==this&&t!==i){n[e]=t,i.parent=null;var r=this.__zr;r&&i.removeSelfFromZr(r),this._doAdd(t)}return this},e.prototype._doAdd=function(t){t.parent&&t.parent.remove(t),t.parent=this;var e=this.__zr;e&&e!==t.__zr&&t.addSelfToZr(e),e&&e.refresh()},e.prototype.remove=function(t){var e=this.__zr,n=this._children,i=Qe(n,t);return i<0||(n.splice(i,1),t.parent=null,e&&t.removeSelfFromZr(e),e&&e.refresh()),this},e.prototype.removeAll=function(){for(var t=this._children,e=this.__zr,n=0;n0&&(this._stillFrameAccum++,this._stillFrameAccum>this._sleepAfterStill&&this.animation.stop())},t.prototype.setSleepAfterStill=function(t){this._sleepAfterStill=t},t.prototype.wakeUp=function(){this._disposed||(this.animation.start(),this._stillFrameAccum=0)},t.prototype.refreshHover=function(){this._needsRefreshHover=!0},t.prototype.refreshHoverImmediately=function(){this._disposed||(this._needsRefreshHover=!1,this.painter.refreshHover&&"canvas"===this.painter.getType()&&this.painter.refreshHover())},t.prototype.resize=function(t){this._disposed||(t=t||{},this.painter.resize(t.width,t.height),this.handler.resize())},t.prototype.clearAnimation=function(){this._disposed||this.animation.clear()},t.prototype.getWidth=function(){if(!this._disposed)return this.painter.getWidth()},t.prototype.getHeight=function(){if(!this._disposed)return this.painter.getHeight()},t.prototype.setCursorStyle=function(t){this._disposed||this.handler.setCursorStyle(t)},t.prototype.findHover=function(t,e){if(!this._disposed)return this.handler.findHover(t,e)},t.prototype.on=function(t,e,n){return this._disposed||this.handler.on(t,e,n),this},t.prototype.off=function(t,e){this._disposed||this.handler.off(t,e)},t.prototype.trigger=function(t,e){this._disposed||this.handler.trigger(t,e)},t.prototype.clear=function(){if(!this._disposed){for(var t=this.storage.getRoots(),e=0;e0){if(t<=r)return a;if(t>=o)return s}else{if(t>=r)return a;if(t<=o)return s}else{if(t===r)return a;if(t===o)return s}return(t-r)/l*c+a}var es=function(t,e,n){switch(t){case"center":case"middle":t="50%";break;case"left":case"top":t="0%";break;case"right":case"bottom":t="100%"}return ns(t,e,n)};function ns(t,e,n){return hn(t)?(i=t,i.replace(/^\s+|\s+$/g,"")).match(/%$/)?parseFloat(t)/100*e+(n||0):parseFloat(t):null==t?NaN:+t;var i}function is(t,e,n){return null==e&&(e=10),e=Math.min(Math.max(0,e),20),t=(+t).toFixed(e),n?t:+t}function rs(t){if(t=+t,isNaN(t))return 0;if(t>1e-14)for(var e=1,n=0;n<15;n++,e*=10)if(Math.round(t*e)/e===t)return n;return function(t){var e=t.toString().toLowerCase(),n=e.indexOf("e"),i=n>0?+e.slice(n+1):0,r=n>0?n:e.length,o=e.indexOf("."),a=o<0?0:r-1-o;return Math.max(0,a-i)}(t)}function os(t,e){var n=Math.max(rs(t),rs(e)),i=t+e;return n>20?i:is(i,n)}function as(t){var e=2*Math.PI;return(t%e+e)%e}function ss(t){return t>-1e-4&&t=10&&e++,e}function hs(t,e){var n=us(t),i=Math.pow(10,n),r=t/i;return t=(r<1.5?1:r<2.5?2:r<4?3:r<7?5:10)*i,n>=-20?+t.toFixed(n<0?-n:0):t}function ds(t){var e=parseFloat(t);return e==t&&(0!==e||!hn(t)||t.indexOf("x")<=0)?e:NaN}function ps(){return Math.round(9*Math.random())}function fs(t,e){return 0===e?t:fs(e,t%e)}function gs(t,e){return null==t?e:null==e?t:t*e/fs(t,e)}var vs="undefined"!=typeof console&&console.warn&&console.log;function ms(t,e){!function(t,e){vs&&console[t]("[ECharts] "+e)}("error",t)}function ys(t){throw new Error(t)}function _s(t,e,n){return(e-t)*n+t}var xs="series\0";function bs(t){return t instanceof Array?t:null==t?[]:[t]}function ws(t,e,n){if(t){t[e]=t[e]||{},t.emphasis=t.emphasis||{},t.emphasis[e]=t.emphasis[e]||{};for(var i=0,r=n.length;i=0||r&&Qe(r,s)<0)){var l=n.getShallow(s,e);null!=l&&(o[t[a][0]]=l)}}return o}}var Zs=Ys([["fill","color"],["shadowBlur"],["shadowOffsetX"],["shadowOffsetY"],["opacity"],["shadowColor"]]),Ks=function(){function t(){}return t.prototype.getAreaStyle=function(t,e){return Zs(this,t,e)},t}(),Qs=new $r(50);function Js(t){if("string"==typeof t){var e=Qs.get(t);return e&&e.image}return t}function tl(t,e,n,i,r){if(t){if("string"==typeof t){if(e&&e.__zrImageSrc===t||!n)return e;var o=Qs.get(t),a={hostEl:n,cb:i,cbPayload:r};return o?!nl(e=o.image)&&o.pending.push(a):((e=Ee.loadImage(t,el,el)).__zrImageSrc=t,Qs.put(t,e.__cachedImgObj={image:e,pending:[a]})),e}return t}return e}function el(){var t=this.__cachedImgObj;this.onload=this.onerror=this.__cachedImgObj=null;for(var e=0;e=s;c++)l-=s;var u=Ma(a,n);return u>l&&(n="",u=0),l=t-u,r.ellipsis=n,r.ellipsisWidth=u,r.contentWidth=l,r.containerWidth=t,r}function al(t,e,n){var i=n.containerWidth,r=n.contentWidth,o=n.fontMeasureInfo;if(!i)return t.textLine="",void(t.isTruncated=!1);var a=Ma(o,e);if(a<=i)return t.textLine=e,void(t.isTruncated=!1);for(var s=0;;s++){if(a<=r||s>=n.maxIterations){e+=n.ellipsis;break}var l=0===s?sl(e,r,o):a>0?Math.floor(e.length*r/a):0;a=Ma(o,e=e.substr(0,l))}""===e&&(e=n.placeholder),t.textLine=e,t.isTruncated=!0}function sl(t,e,n){for(var i=0,r=0,o=t.length;r0&&f+i.accumWidth>i.width&&(o=e.split("\n"),h=!0),i.accumWidth=f}else{var g=fl(e,u,i.width,i.breakAll,i.accumWidth);i.accumWidth=g.accumWidth+p,a=g.linesWidths,o=g.lines}}o||(o=e.split("\n"));for(var v=ba(u),m=0;m=32&&e<=591||e>=880&&e<=4351||e>=4608&&e<=5119||e>=7680&&e<=8303}(t)||!!dl[t]}function fl(t,e,n,i,r){for(var o=[],a=[],s="",l="",c=0,u=0,h=ba(e),d=0;dn:r+u+f>n)?u?(s||l)&&(g?(s||(s=l,l="",u=c=0),o.push(s),a.push(u-c),l+=p,s="",u=c+=f):(l&&(s+=l,l="",c=0),o.push(s),a.push(u),s=p,u=f)):g?(o.push(l),a.push(c),l=p,c=f):(o.push(p),a.push(f)):(u+=f,g?(l+=p,c+=f):(l&&(s+=l,l="",c=0),s+=p))}else l&&(s+=l,u+=c),o.push(s),a.push(u),s="",l="",c=0,u=0}return l&&(s+=l),s&&(o.push(s),a.push(u)),1===o.length&&(u+=r),{accumWidth:u,lines:o,linesWidths:a}}function gl(t,e,n,i,r,o){if(t.baseX=n,t.baseY=i,t.outerWidth=t.outerHeight=null,e){var a=2*e.width,s=2*e.height;$i.set(vl,Da(n,a,r),Ia(i,s,o),a,s),$i.intersect(e,vl,null,ml);var l=ml.outIntersectRect;t.outerWidth=l.width,t.outerHeight=l.height,t.baseX=Da(l.x,l.width,r,!0),t.baseY=Ia(l.y,l.height,o,!0)}}var vl=new $i(0,0,0,0),ml={outIntersectRect:{},clamp:!0};function yl(t){return null!=t?t+="":t=""}function _l(t,e,n,i){var r=new $i(Da(t.x||0,e,t.textAlign),Ia(t.y||0,n,t.textBaseline),e,n),o=null!=i?i:xl(t)?t.lineWidth:0;return o>0&&(r.x-=o/2,r.y-=o/2,r.width+=o,r.height+=o),r}function xl(t){var e=t.stroke;return null!=e&&"none"!==e&&t.lineWidth>0}var bl="__zr_style_"+Math.round(10*Math.random()),wl={shadowBlur:0,shadowOffsetX:0,shadowOffsetY:0,shadowColor:"#000",opacity:1,blend:"source-over"},Sl={style:{shadowBlur:!0,shadowOffsetX:!0,shadowOffsetY:!0,shadowColor:!0,opacity:!0}};wl[bl]=!0;var Cl=["z","z2","invisible"],Ml=["invisible"],kl=function(t){function e(e){return t.call(this,e)||this}var n;return _(e,t),e.prototype._init=function(e){for(var n=an(e),i=0;i1e-4)return s[0]=t-n,s[1]=e-i,l[0]=t+n,void(l[1]=e+i);if(zl[0]=Ll(r)*n+t,zl[1]=Pl(r)*i+e,Ol[0]=Ll(o)*n+t,Ol[1]=Pl(o)*i+e,c(s,zl,Ol),u(l,zl,Ol),(r%=El)<0&&(r+=El),(o%=El)<0&&(o+=El),r>o&&!a?o+=El:rr&&(Nl[0]=Ll(p)*n+t,Nl[1]=Pl(p)*i+e,c(s,Nl,s),u(l,Nl,l))}var Wl={M:1,L:2,C:3,Q:4,A:5,Z:6,R:7},Ul=[],Gl=[],ql=[],jl=[],Xl=[],Yl=[],Zl=Math.min,Kl=Math.max,Ql=Math.cos,Jl=Math.sin,tc=Math.abs,ec=Math.PI,nc=2*ec,ic="undefined"!=typeof Float32Array,rc=[];function oc(t){return Math.round(t/ec*1e8)/1e8%2*ec}var ac=function(){function t(t){this.dpr=1,this._xi=0,this._yi=0,this._x0=0,this._y0=0,this._len=0,t&&(this._saveData=!1),this._saveData&&(this.data=[])}var e;return t.prototype.increaseVersion=function(){this._version++},t.prototype.getVersion=function(){return this._version},t.prototype.setScale=function(t,e,n){(n=n||0)>0&&(this._ux=tc(n/sa/t)||0,this._uy=tc(n/sa/e)||0)},t.prototype.setDPR=function(t){this.dpr=t},t.prototype.setContext=function(t){this._ctx=t},t.prototype.getContext=function(){return this._ctx},t.prototype.beginPath=function(){return this._ctx&&this._ctx.beginPath(),this.reset(),this},t.prototype.reset=function(){this._saveData&&(this._len=0),this._pathSegLen&&(this._pathSegLen=null,this._pathLen=0),this._version++},t.prototype.moveTo=function(t,e){return this._drawPendingPt(),this.addData(Wl.M,t,e),this._ctx&&this._ctx.moveTo(t,e),this._x0=t,this._y0=e,this._xi=t,this._yi=e,this},t.prototype.lineTo=function(t,e){var n=tc(t-this._xi),i=tc(e-this._yi),r=n>this._ux||i>this._uy;if(this.addData(Wl.L,t,e),this._ctx&&r&&this._ctx.lineTo(t,e),r)this._xi=t,this._yi=e,this._pendingPtDist=0;else{var o=n*n+i*i;o>this._pendingPtDist&&(this._pendingPtX=t,this._pendingPtY=e,this._pendingPtDist=o)}return this},t.prototype.bezierCurveTo=function(t,e,n,i,r,o){return this._drawPendingPt(),this.addData(Wl.C,t,e,n,i,r,o),this._ctx&&this._ctx.bezierCurveTo(t,e,n,i,r,o),this._xi=r,this._yi=o,this},t.prototype.quadraticCurveTo=function(t,e,n,i){return this._drawPendingPt(),this.addData(Wl.Q,t,e,n,i),this._ctx&&this._ctx.quadraticCurveTo(t,e,n,i),this._xi=n,this._yi=i,this},t.prototype.arc=function(t,e,n,i,r,o){this._drawPendingPt(),rc[0]=i,rc[1]=r,function(t,e){var n=oc(t[0]);n<0&&(n+=nc);var i=n-t[0],r=t[1];r+=i,!e&&r-n>=nc?r=n+nc:e&&n-r>=nc?r=n-nc:!e&&n>r?r=n+(nc-oc(n-r)):e&&n0&&o))for(var a=0;ac.length&&(this._expandData(),c=this.data);for(var u=0;u0&&(this._ctx&&this._ctx.lineTo(this._pendingPtX,this._pendingPtY),this._pendingPtDist=0)},t.prototype._expandData=function(){if(!(this.data instanceof Array)){for(var t=[],e=0;e11&&(this.data=new Float32Array(t)))}},t.prototype.getBoundingRect=function(){ql[0]=ql[1]=Xl[0]=Xl[1]=Number.MAX_VALUE,jl[0]=jl[1]=Yl[0]=Yl[1]=-Number.MAX_VALUE;var t,e=this.data,n=0,i=0,r=0,o=0;for(t=0;tn||tc(v)>i||h===e-1)&&(f=Math.sqrt(I*I+v*v),r=g,o=_);break;case Wl.C:var m=t[h++],y=t[h++],_=(g=t[h++],t[h++]),x=t[h++],b=t[h++];f=Ar(r,o,m,y,g,_,x,b,10),r=x,o=b;break;case Wl.Q:f=Or(r,o,m=t[h++],y=t[h++],g=t[h++],_=t[h++],10),r=g,o=_;break;case Wl.A:var w=t[h++],S=t[h++],C=t[h++],M=t[h++],k=t[h++],T=t[h++],D=T+k;h+=1,p&&(a=Ql(k)*C+w,s=Jl(k)*M+S),f=Kl(C,M)*Zl(nc,Math.abs(T)),r=Ql(D)*C+w,o=Jl(D)*M+S;break;case Wl.R:a=r=t[h++],s=o=t[h++],f=2*t[h++]+2*t[h++];break;case Wl.Z:var I=a-r;v=s-o;f=Math.sqrt(I*I+v*v),r=a,o=s}f>=0&&(l[u++]=f,c+=f)}return this._pathLen=c,c},t.prototype.rebuildPath=function(t,e){var n,i,r,o,a,s,l,c,u,h,d=this.data,p=this._ux,f=this._uy,g=this._len,v=e<1,m=0,y=0,_=0;if(!v||(this._pathSegLen||this._calculateLength(),l=this._pathSegLen,c=e*this._pathLen))t:for(var x=0;x0&&(t.lineTo(u,h),_=0),b){case Wl.M:n=r=d[x++],i=o=d[x++],t.moveTo(r,o);break;case Wl.L:a=d[x++],s=d[x++];var S=tc(a-r),C=tc(s-o);if(S>p||C>f){if(v){if(m+(X=l[y++])>c){var M=(c-m)/X;t.lineTo(r*(1-M)+a*M,o*(1-M)+s*M);break t}m+=X}t.lineTo(a,s),r=a,o=s,_=0}else{var k=S*S+C*C;k>_&&(u=a,h=s,_=k)}break;case Wl.C:var T=d[x++],D=d[x++],I=d[x++],A=d[x++],P=d[x++],L=d[x++];if(v){if(m+(X=l[y++])>c){Ir(r,T,I,P,M=(c-m)/X,Ul),Ir(o,D,A,L,M,Gl),t.bezierCurveTo(Ul[1],Gl[1],Ul[2],Gl[2],Ul[3],Gl[3]);break t}m+=X}t.bezierCurveTo(T,D,I,A,P,L),r=P,o=L;break;case Wl.Q:T=d[x++],D=d[x++],I=d[x++],A=d[x++];if(v){if(m+(X=l[y++])>c){zr(r,T,I,M=(c-m)/X,Ul),zr(o,D,A,M,Gl),t.quadraticCurveTo(Ul[1],Gl[1],Ul[2],Gl[2]);break t}m+=X}t.quadraticCurveTo(T,D,I,A),r=I,o=A;break;case Wl.A:var E=d[x++],z=d[x++],O=d[x++],N=d[x++],R=d[x++],H=d[x++],B=d[x++],F=!d[x++],$=O>N?O:N,V=tc(O-N)>.001,W=R+H,U=!1;if(v)m+(X=l[y++])>c&&(W=R+H*(c-m)/X,U=!0),m+=X;if(V&&t.ellipse?t.ellipse(E,z,O,N,B,R,W,F):t.arc(E,z,$,R,W,F),U)break t;w&&(n=Ql(R)*O+E,i=Jl(R)*N+z),r=Ql(W)*O+E,o=Jl(W)*N+z;break;case Wl.R:n=r=d[x],i=o=d[x+1],a=d[x++],s=d[x++];var G=d[x++],q=d[x++];if(v){if(m+(X=l[y++])>c){var j=c-m;t.moveTo(a,s),t.lineTo(a+Zl(j,G),s),(j-=G)>0&&t.lineTo(a+G,s+Zl(j,q)),(j-=q)>0&&t.lineTo(a+Kl(G-j,0),s+q),(j-=G)>0&&t.lineTo(a,s+Kl(q-j,0));break t}m+=X}t.rect(a,s,G,q);break;case Wl.Z:if(v){var X;if(m+(X=l[y++])>c){M=(c-m)/X;t.lineTo(r*(1-M)+n*M,o*(1-M)+i*M);break t}m+=X}t.closePath(),r=n,o=i}}},t.prototype.clone=function(){var e=new t,n=this.data;return e.data=n.slice?n.slice():Array.prototype.slice.call(n),e._len=this._len,e},t.prototype.canSave=function(){return!!this._saveData},t.CMD=Wl,t.initDefaultProps=((e=t.prototype)._saveData=!0,e._ux=0,e._uy=0,e._pendingPtDist=0,void(e._version=0)),t}();function sc(t,e,n,i,r,o,a){if(0===r)return!1;var s=r,l=0;if(a>e+s&&a>i+s||at+s&&o>n+s||oe+h&&u>i+h&&u>o+h&&u>s+h||ut+h&&c>n+h&&c>r+h&&c>a+h||c=0&&pe+c&&l>i+c&&l>o+c||lt+c&&s>n+c&&s>r+c||s=0&&gn||u+cr&&(r+=dc);var d=Math.atan2(l,s);return d<0&&(d+=dc),d>=i&&d<=r||d+dc>=i&&d+dc<=r}function fc(t,e,n,i,r,o){if(o>e&&o>i||or?s:0}var gc=ac.CMD,vc=2*Math.PI;var mc=[-1,-1,-1],yc=[-1,-1];function _c(){var t=yc[0];yc[0]=yc[1],yc[1]=t}function xc(t,e,n,i,r,o,a,s,l,c){if(c>e&&c>i&&c>o&&c>s||c1&&_c(),p=Mr(e,i,o,s,yc[0]),d>1&&(f=Mr(e,i,o,s,yc[1]))),2===d?ve&&s>i&&s>o||s=0&&u<=1&&(r[l++]=u);else{var c=a*a-4*o*s;if(Sr(c))(u=-a/(2*o))>=0&&u<=1&&(r[l++]=u);else if(c>0){var u,h=gr(c),d=(-a-h)/(2*o);(u=(-a+h)/(2*o))>=0&&u<=1&&(r[l++]=u),d>=0&&d<=1&&(r[l++]=d)}}return l}(e,i,o,s,mc);if(0===l)return 0;var c=Er(e,i,o);if(c>=0&&c<=1){for(var u=0,h=Pr(e,i,o,c),d=0;dn||s<-n)return 0;var l=Math.sqrt(n*n-s*s);mc[0]=-l,mc[1]=l;var c=Math.abs(i-r);if(c<1e-4)return 0;if(c>=vc-1e-4){i=0,r=vc;var u=o?1:-1;return a>=mc[0]+t&&a<=mc[1]+t?u:0}if(i>r){var h=i;i=r,r=h}i<0&&(i+=vc,r+=vc);for(var d=0,p=0;p<2;p++){var f=mc[p];if(f+t>a){var g=Math.atan2(s,f);u=o?1:-1;g<0&&(g=vc+g),(g>=i&&g<=r||g+vc>=i&&g+vc<=r)&&(g>Math.PI/2&&g<1.5*Math.PI&&(u=-u),d+=u)}}return d}function Sc(t,e,n,i,r){for(var o,a,s=t.data,l=t.len(),c=0,u=0,h=0,d=0,p=0,f=0;f1&&(n||(c+=fc(u,h,d,p,i,r))),v&&(d=u=s[f],p=h=s[f+1]),g){case gc.M:u=d=s[f++],h=p=s[f++];break;case gc.L:if(n){if(sc(u,h,s[f],s[f+1],e,i,r))return!0}else c+=fc(u,h,s[f],s[f+1],i,r)||0;u=s[f++],h=s[f++];break;case gc.C:if(n){if(lc(u,h,s[f++],s[f++],s[f++],s[f++],s[f],s[f+1],e,i,r))return!0}else c+=xc(u,h,s[f++],s[f++],s[f++],s[f++],s[f],s[f+1],i,r)||0;u=s[f++],h=s[f++];break;case gc.Q:if(n){if(cc(u,h,s[f++],s[f++],s[f],s[f+1],e,i,r))return!0}else c+=bc(u,h,s[f++],s[f++],s[f],s[f+1],i,r)||0;u=s[f++],h=s[f++];break;case gc.A:var m=s[f++],y=s[f++],_=s[f++],x=s[f++],b=s[f++],w=s[f++];f+=1;var S=!!(1-s[f++]);o=Math.cos(b)*_+m,a=Math.sin(b)*x+y,v?(d=o,p=a):c+=fc(u,h,o,a,i,r);var C=(i-m)*x/_+m;if(n){if(pc(m,y,x,b,b+w,S,e,C,r))return!0}else c+=wc(m,y,x,b,b+w,S,C,r);u=Math.cos(b+w)*_+m,h=Math.sin(b+w)*x+y;break;case gc.R:if(d=u=s[f++],p=h=s[f++],o=d+s[f++],a=p+s[f++],n){if(sc(d,p,o,p,e,i,r)||sc(o,p,o,a,e,i,r)||sc(o,a,d,a,e,i,r)||sc(d,a,d,p,e,i,r))return!0}else c+=fc(o,p,o,a,i,r),c+=fc(d,a,d,p,i,r);break;case gc.Z:if(n){if(sc(u,h,d,p,e,i,r))return!0}else c+=fc(u,h,d,p,i,r);u=d,h=p}}return n||function(t,e){return Math.abs(t-e)<1e-4}(h,p)||(c+=fc(u,h,d,p,i,r)||0),0!==c}var Cc=Ke({fill:"#000",stroke:null,strokePercent:1,fillOpacity:1,strokeOpacity:1,lineDashOffset:0,lineWidth:1,lineCap:"butt",miterLimit:10,strokeNoScale:!1,strokeFirst:!1},wl),Mc={style:Ke({fill:!0,stroke:!0,strokePercent:!0,fillOpacity:!0,strokeOpacity:!0,lineDashOffset:!0,lineWidth:!0,miterLimit:!0},Sl.style)},kc=_a.concat(["invisible","culling","z","z2","zlevel","parent"]),Tc=function(t){function e(e){return t.call(this,e)||this}var n;return _(e,t),e.prototype.update=function(){var n=this;t.prototype.update.call(this);var i=this.style;if(i.decal){var r=this._decalEl=this._decalEl||new e;r.buildPath===e.prototype.buildPath&&(r.buildPath=function(t){n.buildPath(t,n.shape)}),r.silent=!0;var o=r.style;for(var a in i)o[a]!==i[a]&&(o[a]=i[a]);o.fill=i.fill?i.decal:null,o.decal=null,o.shadowColor=null,i.strokeFirst&&(o.stroke=null);for(var s=0;s.5?la:e>.2?"#eee":ca}if(t)return ca}return la},e.prototype.getInsideTextStroke=function(t){var e=this.style.fill;if(hn(e)){var n=this.__zr;if(!(!n||!n.isDarkMode())===oo(t,0)<.4)return e}},e.prototype.buildPath=function(t,e,n){},e.prototype.pathUpdated=function(){this.__dirty&=-5},e.prototype.getUpdatedPathProxy=function(t){return!this.path&&this.createPathProxy(),this.path.beginPath(),this.buildPath(this.path,this.shape,t),this.path},e.prototype.createPathProxy=function(){this.path=new ac(!1)},e.prototype.hasStroke=function(){var t=this.style,e=t.stroke;return!(null==e||"none"===e||!(t.lineWidth>0))},e.prototype.hasFill=function(){var t=this.style.fill;return null!=t&&"none"!==t},e.prototype.getBoundingRect=function(){var t=this._rect,e=this.style,n=!t;if(n){var i=!1;this.path||(i=!0,this.createPathProxy());var r=this.path;(i||4&this.__dirty)&&(r.beginPath(),this.buildPath(r,this.shape,!1),this.pathUpdated()),t=r.getBoundingRect()}if(this._rect=t,this.hasStroke()&&this.path&&this.path.len()>0){var o=this._rectStroke||(this._rectStroke=t.clone());if(this.__dirty||n){o.copy(t);var a=e.strokeNoScale?this.getLineScale():1,s=e.lineWidth;if(!this.hasFill()){var l=this.strokeContainThreshold;s=Math.max(s,null==l?4:l)}a>1e-10&&(o.width+=s/a,o.height+=s/a,o.x-=s/a/2,o.y-=s/a/2)}return o}return t},e.prototype.contain=function(t,e){var n=this.transformCoordToLocal(t,e),i=this.getBoundingRect(),r=this.style;if(t=n[0],e=n[1],i.contain(t,e)){var o=this.path;if(this.hasStroke()){var a=r.lineWidth,s=r.strokeNoScale?this.getLineScale():1;if(s>1e-10&&(this.hasFill()||(a=Math.max(a,this.strokeContainThreshold)),function(t,e,n,i){return Sc(t,e,!0,n,i)}(o,a/s,t,e)))return!0}if(this.hasFill())return function(t,e,n){return Sc(t,0,!1,e,n)}(o,t,e)}return!1},e.prototype.dirtyShape=function(){this.__dirty|=4,this._rect&&(this._rect=null),this._decalEl&&this._decalEl.dirtyShape(),this.markRedraw()},e.prototype.dirty=function(){this.dirtyStyle(),this.dirtyShape()},e.prototype.animateShape=function(t){return this.animate("shape",t)},e.prototype.updateDuringAnimation=function(t){"style"===t?this.dirtyStyle():"shape"===t?this.dirtyShape():this.markRedraw()},e.prototype.attrKV=function(e,n){"shape"===e?this.setShape(n):t.prototype.attrKV.call(this,e,n)},e.prototype.setShape=function(t,e){var n=this.shape;return n||(n=this.shape={}),"string"==typeof t?n[t]=e:Ze(n,t),this.dirtyShape(),this},e.prototype.shapeChanged=function(){return!!(4&this.__dirty)},e.prototype.createStyle=function(t){return zn(Cc,t)},e.prototype._innerSaveToNormal=function(e){t.prototype._innerSaveToNormal.call(this,e);var n=this._normalState;e.shape&&!n.shape&&(n.shape=Ze({},this.shape))},e.prototype._applyStateObj=function(e,n,i,r,o,a){t.prototype._applyStateObj.call(this,e,n,i,r,o,a);var s,l=!(n&&r);if(n&&n.shape?o?r?s=n.shape:(s=Ze({},i.shape),Ze(s,n.shape)):(s=Ze({},r?this.shape:i.shape),Ze(s,n.shape)):l&&(s=i.shape),s)if(o){this.shape=Ze({},this.shape);for(var c={},u=an(s),h=0;hc&&(n*=c/(a=n+i),i*=c/a),r+o>c&&(r*=c/(a=r+o),o*=c/a),i+r>u&&(i*=u/(a=i+r),r*=u/a),n+o>u&&(n*=u/(a=n+o),o*=u/a),t.moveTo(s+n,l),t.lineTo(s+c-i,l),0!==i&&t.arc(s+c-i,l+i,i,-Math.PI/2,0),t.lineTo(s+c,l+u-r),0!==r&&t.arc(s+c-r,l+u-r,r,0,Math.PI/2),t.lineTo(s+o,l+u),0!==o&&t.arc(s+o,l+u-o,o,Math.PI/2,Math.PI),t.lineTo(s,l+n),0!==n&&t.arc(s+n,l+n,n,Math.PI,1.5*Math.PI)}(t,e):t.rect(n,i,r,o)},e.prototype.isZeroArea=function(){return!this.shape.width||!this.shape.height},e}(Tc);Bc.prototype.type="rect";var Fc={fill:"#000"},$c={},Vc={style:Ke({fill:!0,stroke:!0,fillOpacity:!0,strokeOpacity:!0,lineWidth:!0,fontSize:!0,lineHeight:!0,width:!0,height:!0,textShadowColor:!0,textShadowBlur:!0,textShadowOffsetX:!0,textShadowOffsetY:!0,backgroundColor:!0,padding:!0,borderColor:!0,borderWidth:!0,borderRadius:!0},Sl.style)},Wc=function(t){function e(e){var n=t.call(this)||this;return n.type="text",n._children=[],n._defaultStyle=Fc,n.attr(e),n}return _(e,t),e.prototype.childrenRef=function(){return this._children},e.prototype.update=function(){t.prototype.update.call(this),this.styleChanged()&&this._updateSubTexts();for(var e=0;em&&p){var _=Math.floor(m/d);f=f||v.length>_,y=(v=v.slice(0,_)).length*d}if(r&&u&&null!=g)for(var x=ol(g,c,e.ellipsis,{minChar:e.truncateMinChar,placeholder:e.placeholder}),b={},w=0;w0,M=0;Mg&&hl(o,a.substring(g,v),e,f),hl(o,d[2],e,f,d[1]),g=il.lastIndex}gh){var z=o.lines.length;D>0?(M.tokens=M.tokens.slice(0,D),S(M,T,k),o.lines=o.lines.slice(0,C+1)):o.lines=o.lines.slice(0,C),o.isTruncated=o.isTruncated||o.lines.length=0&&"right"===(T=_[k]).align;)this._placeToken(T,t,b,f,M,"right",v),w-=T.width,M-=T.width,k--;for(C+=(s-(C-p)-(g-M)-w)/2;S<=k;)T=_[S],this._placeToken(T,t,b,f,C+T.width/2,"center",v),C+=T.width,S++;f+=b}},e.prototype._placeToken=function(t,e,n,i,r,o,a){var s=e.rich[t.styleName]||{};s.text=t.text;var l=t.verticalAlign,c=i+n/2;"top"===l?c=i+t.height/2:"bottom"===l&&(c=i+n-t.height/2),!t.isLineHolder&&eu(s)&&this._renderBackground(s,e,"right"===o?r-t.width:"center"===o?r-t.width/2:r,c-t.height/2,t.width,t.height);var u=!!s.backgroundColor,h=t.textPadding;h&&(r=Jc(r,o,h),c-=t.height/2-h[0]-t.innerHeight/2);var d=this._getOrCreateChild(Ic),p=d.createStyle();d.useStyle(p);var f=this._defaultStyle,g=!1,v=0,m=!1,y=Qc("fill"in s?s.fill:"fill"in e?e.fill:(g=!0,f.fill)),_=Kc("stroke"in s?s.stroke:"stroke"in e?e.stroke:u||a||f.autoStroke&&!g?null:(v=2,m=!0,f.stroke)),x=s.textShadowBlur>0||e.textShadowBlur>0;p.text=t.text,p.x=r,p.y=c,x&&(p.shadowBlur=s.textShadowBlur||e.textShadowBlur||0,p.shadowColor=s.textShadowColor||e.textShadowColor||"transparent",p.shadowOffsetX=s.textShadowOffsetX||e.textShadowOffsetX||0,p.shadowOffsetY=s.textShadowOffsetY||e.textShadowOffsetY||0),p.textAlign=o,p.textBaseline="middle",p.font=t.font||Ie,p.opacity=wn(s.opacity,e.opacity,1),Xc(p,s),_&&(p.lineWidth=wn(s.lineWidth,e.lineWidth,v),p.lineDash=bn(s.lineDash,e.lineDash),p.lineDashOffset=e.lineDashOffset||0,p.stroke=_),y&&(p.fill=y),d.setBoundingRect(_l(p,t.contentWidth,t.contentHeight,m?0:null))},e.prototype._renderBackground=function(t,e,n,i,r,o){var a,s,l,c=t.backgroundColor,u=t.borderWidth,h=t.borderColor,d=c&&c.image,p=c&&!d,f=t.borderRadius,g=this;if(p||t.lineHeight||u&&h){(a=this._getOrCreateChild(Bc)).useStyle(a.createStyle()),a.style.fill=null;var v=a.shape;v.x=n,v.y=i,v.width=r,v.height=o,v.r=f,a.dirtyShape()}if(p)(l=a.style).fill=c||null,l.fillOpacity=bn(t.fillOpacity,1);else if(d){(s=this._getOrCreateChild(Lc)).onload=function(){g.dirtyStyle()};var m=s.style;m.image=c.image,m.x=n,m.y=i,m.width=r,m.height=o}u&&h&&((l=a.style).lineWidth=u,l.stroke=h,l.strokeOpacity=bn(t.strokeOpacity,1),l.lineDash=t.borderDash,l.lineDashOffset=t.borderDashOffset||0,a.strokeContainThreshold=0,a.hasFill()&&a.hasStroke()&&(l.strokeFirst=!0,l.lineWidth*=2));var y=(a||s).style;y.shadowBlur=t.shadowBlur||0,y.shadowColor=t.shadowColor||"transparent",y.shadowOffsetX=t.shadowOffsetX||0,y.shadowOffsetY=t.shadowOffsetY||0,y.opacity=wn(t.opacity,e.opacity,1)},e.makeFont=function(t){var e="";return Yc(t)&&(e=[t.fontStyle,t.fontWeight,jc(t.fontSize),t.fontFamily||"sans-serif"].join(" ")),e&&kn(e)||t.textFont||t.font},e}(kl),Uc={left:!0,right:1,center:1},Gc={top:1,bottom:1,middle:1},qc=["fontStyle","fontWeight","fontSize","fontFamily"];function jc(t){return"string"!=typeof t||-1===t.indexOf("px")&&-1===t.indexOf("rem")&&-1===t.indexOf("em")?isNaN(+t)?"12px":t+"px":t}function Xc(t,e){for(var n=0;n=0,o=!1;if(t instanceof Tc){var a=ou(t),s=r&&a.selectFill||a.normalFill,l=r&&a.selectStroke||a.normalStroke;if(gu(s)||gu(l)){var c=(i=i||{}).style||{};"inherit"===c.fill?(o=!0,i=Ze({},i),(c=Ze({},c)).fill=s):!gu(c.fill)&&gu(s)?(o=!0,i=Ze({},i),(c=Ze({},c)).fill=so(s)):!gu(c.stroke)&&gu(l)&&(o||(i=Ze({},i),c=Ze({},c)),c.stroke=so(l)),i.style=c}}if(i&&null==i.z2){o||(i=Ze({},i));var u=t.z2EmphasisLift;i.z2=t.z2+(null!=u?u:10)}return i}(this,0,e,n);if("blur"===t)return function(t,e,n){var i=Qe(t.currentStates,e)>=0,r=t.style.opacity,o=i?null:function(t,e,n,i){for(var r=t.style,o={},a=0;a0){var o={dataIndex:r,seriesIndex:t.seriesIndex};null!=i&&(o.dataType=i),e.push(o)}})}),e}function Vu(t,e,n){ju(t,!0),Cu(t,Tu),function(t,e,n){var i=nu(t);null!=e?(i.focus=e,i.blurScope=n):i.focus&&(i.focus=null)}(t,e,n)}function Wu(t,e,n,i){i?function(t){ju(t,!1)}(t):Vu(t,e,n)}var Uu=["emphasis","blur","select"],Gu={itemStyle:"getItemStyle",lineStyle:"getLineStyle",areaStyle:"getAreaStyle"};function qu(t,e,n,i){n=n||"itemStyle";for(var r=0;r1&&(a*=eh(f),s*=eh(f));var g=(r===o?-1:1)*eh((a*a*(s*s)-a*a*(p*p)-s*s*(d*d))/(a*a*(p*p)+s*s*(d*d)))||0,v=g*a*p/s,m=g*-s*d/a,y=(t+n)/2+ih(h)*v-nh(h)*m,_=(e+i)/2+nh(h)*v+ih(h)*m,x=sh([1,0],[(d-v)/a,(p-m)/s]),b=[(d-v)/a,(p-m)/s],w=[(-1*d-v)/a,(-1*p-m)/s],S=sh(b,w);if(ah(b,w)<=-1&&(S=rh),ah(b,w)>=1&&(S=0),S<0){var C=Math.round(S/rh*1e6)/1e6;S=2*rh+C%2*rh}u.addData(c,y,_,a,s,x,S,h,o)}var ch=/([mlvhzcqtsa])([^mlvhzcqtsa]*)/gi,uh=/-?([0-9]*\.)?[0-9]+([eE]-?[0-9]+)?/g;var hh=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return _(e,t),e.prototype.applyTransform=function(t){},e}(Tc);function dh(t){return null!=t.setData}function ph(t,e){var n=function(t){var e=new ac;if(!t)return e;var n,i=0,r=0,o=i,a=r,s=ac.CMD,l=t.match(ch);if(!l)return e;for(var c=0;cA*A+P*P&&(C=k,M=T),{cx:C,cy:M,x0:-u,y0:-h,x1:C*(r/b-1),y1:M*(r/b-1)}}function Ah(t,e){var n,i=kh(e.r,0),r=kh(e.r0||0,0),o=i>0;if(o||r>0){if(o||(i=r,r=0),r>i){var a=i;i=r,r=a}var s=e.startAngle,l=e.endAngle;if(!isNaN(s)&&!isNaN(l)){var c=e.cx,u=e.cy,h=!!e.clockwise,d=Ch(l-s),p=d>_h&&d%_h;if(p>Dh&&(d=p),i>Dh)if(d>_h-Dh)t.moveTo(c+i*bh(s),u+i*xh(s)),t.arc(c,u,i,s,l,!h),r>Dh&&(t.moveTo(c+r*bh(l),u+r*xh(l)),t.arc(c,u,r,l,s,h));else{var f=void 0,g=void 0,v=void 0,m=void 0,y=void 0,_=void 0,x=void 0,b=void 0,w=void 0,S=void 0,C=void 0,M=void 0,k=void 0,T=void 0,D=void 0,I=void 0,A=i*bh(s),P=i*xh(s),L=r*bh(l),E=r*xh(l),z=d>Dh;if(z){var O=e.cornerRadius;O&&(n=function(t){var e;if(cn(t)){var n=t.length;if(!n)return t;e=1===n?[t[0],t[0],0,0]:2===n?[t[0],t[0],t[1],t[1]]:3===n?t.concat(t[2]):t}else e=[t,t,t,t];return e}(O),f=n[0],g=n[1],v=n[2],m=n[3]);var N=Ch(i-r)/2;if(y=Th(N,v),_=Th(N,m),x=Th(N,f),b=Th(N,g),C=w=kh(y,_),M=S=kh(x,b),(w>Dh||S>Dh)&&(k=i*bh(l),T=i*xh(l),D=r*bh(s),I=r*xh(s),dDh){var U=Th(v,C),G=Th(m,C),q=Ih(D,I,A,P,i,U,h),j=Ih(k,T,L,E,i,G,h);t.moveTo(c+q.cx+q.x0,u+q.cy+q.y0),C0&&t.arc(c+q.cx,u+q.cy,U,Sh(q.y0,q.x0),Sh(q.y1,q.x1),!h),t.arc(c,u,i,Sh(q.cy+q.y1,q.cx+q.x1),Sh(j.cy+j.y1,j.cx+j.x1),!h),G>0&&t.arc(c+j.cx,u+j.cy,G,Sh(j.y1,j.x1),Sh(j.y0,j.x0),!h))}else t.moveTo(c+A,u+P),t.arc(c,u,i,s,l,!h);else t.moveTo(c+A,u+P);if(r>Dh&&z)if(M>Dh){U=Th(f,M),q=Ih(L,E,k,T,r,-(G=Th(g,M)),h),j=Ih(A,P,D,I,r,-U,h);t.lineTo(c+q.cx+q.x0,u+q.cy+q.y0),M0&&t.arc(c+q.cx,u+q.cy,G,Sh(q.y0,q.x0),Sh(q.y1,q.x1),!h),t.arc(c,u,r,Sh(q.cy+q.y1,q.cx+q.x1),Sh(j.cy+j.y1,j.cx+j.x1),h),U>0&&t.arc(c+j.cx,u+j.cy,U,Sh(j.y1,j.x1),Sh(j.y0,j.x0),!h))}else t.lineTo(c+L,u+E),t.arc(c,u,r,l,s,h);else t.lineTo(c+L,u+E)}else t.moveTo(c,u);t.closePath()}}}var Ph=function(){this.cx=0,this.cy=0,this.r0=0,this.r=0,this.startAngle=0,this.endAngle=2*Math.PI,this.clockwise=!0,this.cornerRadius=0},Lh=function(t){function e(e){return t.call(this,e)||this}return _(e,t),e.prototype.getDefaultShape=function(){return new Ph},e.prototype.buildPath=function(t,e){Ah(t,e)},e.prototype.isZeroArea=function(){return this.shape.startAngle===this.shape.endAngle||this.shape.r===this.shape.r0},e}(Tc);Lh.prototype.type="sector";var Eh=function(){this.cx=0,this.cy=0,this.r=0,this.r0=0},zh=function(t){function e(e){return t.call(this,e)||this}return _(e,t),e.prototype.getDefaultShape=function(){return new Eh},e.prototype.buildPath=function(t,e){var n=e.cx,i=e.cy,r=2*Math.PI;t.moveTo(n+e.r,i),t.arc(n,i,e.r,0,r,!1),t.moveTo(n+e.r0,i),t.arc(n,i,e.r0,0,r,!0)},e}(Tc);function Oh(t,e,n){var i=e.smooth,r=e.points;if(r&&r.length>=2){if(i){var o=function(t,e,n,i){var r,o,a,s,l=[],c=[],u=[],h=[];if(i){a=[1/0,1/0],s=[-1/0,-1/0];for(var d=0,p=t.length;did[1]){if(r=!1,rd.negativeSize||n)return r;var s=ed(id[0]-nd[1]),l=ed(nd[0]-id[1]);Jh(s,l)>ad.len()&&(s=l||!rd.bidirectional)&&(Ti.scale(od,a,-l*i),rd.useDir&&rd.calcDirMTV()))}}return r},t.prototype._getProjMinMaxOnAxis=function(t,e,n){for(var i=this._axes[t],r=this._origin,o=e[0].dot(i)+r[t],a=o,s=o,l=1;l0){var h={duration:u.duration,delay:u.delay||0,easing:u.easing,done:o,force:!!o||!!a,setToFinal:!c,scope:t,during:a};l?e.animateFrom(n,h):e.animateTo(n,h)}else e.stopAnimation(),!l&&e.attr(n),a&&a(1),o&&o()}function dd(t,e,n,i,r,o){hd("update",t,e,n,i,r,o)}function pd(t,e,n,i,r,o){hd("enter",t,e,n,i,r,o)}function fd(t){if(!t.__zr)return!0;for(var e=0;e=-1e-6)return!1;var f=t-r,g=e-o,v=zd(f,g,c,u)/p;if(v<0||v>1)return!1;var m=zd(f,g,h,d)/p;return!(m<0||m>1)}function zd(t,e,n,i){return t*i-n*e}function Od(t,e,n,i,r){return null==e||(pn(e)?Nd[0]=Nd[1]=Nd[2]=Nd[3]=e:(Nd[0]=e[0],Nd[1]=e[1],Nd[2]=e[2],Nd[3]=e[3]),i&&(Nd[0]=Qa(0,Nd[0]),Nd[1]=Qa(0,Nd[1]),Nd[2]=Qa(0,Nd[2]),Nd[3]=Qa(0,Nd[3])),n&&(Nd[0]=-Nd[0],Nd[1]=-Nd[1],Nd[2]=-Nd[2],Nd[3]=-Nd[3]),Rd(t,Nd,"x","width",3,1,r&&r[0]||0),Rd(t,Nd,"y","height",0,2,r&&r[1]||0)),t}var Nd=[0,0,0,0];function Rd(t,e,n,i,r,o,a){var s=e[o]+e[r],l=t[i];t[i]+=s,a=Qa(0,Ka(a,l)),t[i]=0?-e[r]:e[o]>=0?l+e[o]:Ja(s)>1e-8?(l-a)*e[r]/s:0):t[n]-=e[r]}function Hd(t){var e=t.itemTooltipOption,n=t.componentModel,i=t.itemName,r=hn(e)?{formatter:e}:e,o=n.mainType,a=n.componentIndex,s={componentType:o,name:i,$vars:["name"]};s[o+"Index"]=a;var l=t.formatterParamsExtra;l&&en(an(l),function(t){On(s,t)||(s[t]=l[t],s.$vars.push(t))});var c=nu(t.el);c.componentMainType=o,c.componentIndex=a,c.tooltipConfig={name:i,option:Ke({content:i,encodeHTMLContent:!0,formatterParams:s},r)}}function Bd(t,e){var n;t.isGroup&&(n=e(t)),n||t.traverse(e)}function Fd(t,e){if(t)if(cn(t))for(var n=0;ne&&(e=i),ie&&(n=e=0),{min:n,max:e}},clipPointsByRect:function(t,e){return nn(t,function(t){var n=t[0];n=Qa(n,e.x),n=Ka(n,e.x+e.width);var i=t[1];return i=Qa(i,e.y),[n,i=Ka(i,e.y+e.height)]})},clipRectByRect:function(t,e){var n=Qa(t.x,e.x),i=Ka(t.x+t.width,e.x+e.width),r=Qa(t.y,e.y),o=Ka(t.y+t.height,e.y+e.height);if(i>=n&&o>=r)return{x:n,y:r,width:i-n,height:o-r}},createIcon:Ld,ensureCopyRect:Wd,ensureCopyTransform:Ud,expandOrShrinkRect:Od,extendPath:function(t,e){return xd(t,e)},extendShape:function(t){return Tc.extend(t)},getShapeClass:function(t){if(md.hasOwnProperty(t))return md[t]},getTransform:function(t,e){for(var n=bi([]);t&&t!==e;)Si(n,t.getLocalTransform(),n),t=t.parent;return n},groupTransition:Pd,initProps:pd,isBoundingRectAxisAligned:$d,isElementRemoved:fd,lineLineIntersect:Ed,linePolygonIntersect:function(t,e,n,i,r){for(var o=0,a=r[r.length-1];oJa(o[1])?o[0]>0?"right":"left":o[1]>0?"bottom":"top"},traverseElements:Fd,traverseUpdateZ:qd,updateProps:dd}),Yd={};function Zd(t,e,n){var i,r=t.labelFetcher,o=t.labelDataIndex,a=t.labelDimIndex,s=e.normal;r&&(i=r.getFormattedLabel(o,"normal",null,a,s&&s.get("formatter"),null!=n?{interpolatedValue:n}:null)),null==i&&(i=un(t.defaultText)?t.defaultText(o,t,n):t.defaultText);for(var l={normal:i},c=0;c-1?wp:Cp;function Dp(t,e){t=t.toUpperCase(),kp[t]=new _p(e),Mp[t]=e}Dp(Sp,{time:{month:["January","February","March","April","May","June","July","August","September","October","November","December"],monthAbbr:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],dayOfWeek:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],dayOfWeekAbbr:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"]},legend:{selector:{all:"All",inverse:"Inv"}},toolbox:{brush:{title:{rect:"Box Select",polygon:"Lasso Select",lineX:"Horizontally Select",lineY:"Vertically Select",keep:"Keep Selections",clear:"Clear Selections"}},dataView:{title:"Data View",lang:["Data View","Close","Refresh"]},dataZoom:{title:{zoom:"Zoom",back:"Zoom Reset"}},magicType:{title:{line:"Switch to Line Chart",bar:"Switch to Bar Chart",stack:"Stack",tiled:"Tile"}},restore:{title:"Restore"},saveAsImage:{title:"Save as Image",lang:["Right Click to Save Image"]}},series:{typeNames:{pie:"Pie chart",bar:"Bar chart",line:"Line chart",scatter:"Scatter plot",effectScatter:"Ripple scatter plot",radar:"Radar chart",tree:"Tree",treemap:"Treemap",boxplot:"Boxplot",candlestick:"Candlestick",k:"K line chart",heatmap:"Heat map",map:"Map",parallel:"Parallel coordinate map",lines:"Line graph",graph:"Relationship graph",sankey:"Sankey diagram",funnel:"Funnel chart",gauge:"Gauge",pictorialBar:"Pictorial bar",themeRiver:"Theme River Map",sunburst:"Sunburst",custom:"Custom chart",chart:"Chart"}},aria:{general:{withTitle:'This is a chart about "{title}"',withoutTitle:"This is a chart"},series:{single:{prefix:"",withName:" with type {seriesType} named {seriesName}.",withoutName:" with type {seriesType}."},multiple:{prefix:". It consists of {seriesCount} series count.",withName:" The {seriesId} series is a {seriesType} representing {seriesName}.",withoutName:" The {seriesId} series is a {seriesType}.",separator:{middle:"",end:""}}},data:{allData:"The data is as follows: ",partialData:"The first {displayCnt} items are: ",withName:"the data for {name} is {value}",withoutName:"{value}",separator:{middle:", ",end:". "}}}}),Dp(wp,{time:{month:["一月","二月","三月","四月","五月","六月","七月","八月","九月","十月","十一月","十二月"],monthAbbr:["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"],dayOfWeek:["星期日","星期一","星期二","星期三","星期四","星期五","星期六"],dayOfWeekAbbr:["日","一","二","三","四","五","六"]},legend:{selector:{all:"全选",inverse:"反选"}},toolbox:{brush:{title:{rect:"矩形选择",polygon:"圈选",lineX:"横向选择",lineY:"纵向选择",keep:"保持选择",clear:"清除选择"}},dataView:{title:"数据视图",lang:["数据视图","关闭","刷新"]},dataZoom:{title:{zoom:"区域缩放",back:"区域缩放还原"}},magicType:{title:{line:"切换为折线图",bar:"切换为柱状图",stack:"切换为堆叠",tiled:"切换为平铺"}},restore:{title:"还原"},saveAsImage:{title:"保存为图片",lang:["右键另存为图片"]}},series:{typeNames:{pie:"饼图",bar:"柱状图",line:"折线图",scatter:"散点图",effectScatter:"涟漪散点图",radar:"雷达图",tree:"树图",treemap:"矩形树图",boxplot:"箱型图",candlestick:"K线图",k:"K线图",heatmap:"热力图",map:"地图",parallel:"平行坐标图",lines:"线图",graph:"关系图",sankey:"桑基图",funnel:"漏斗图",gauge:"仪表盘图",pictorialBar:"象形柱图",themeRiver:"主题河流图",sunburst:"旭日图",custom:"自定义图表",chart:"图表"}},aria:{general:{withTitle:"这是一个关于“{title}”的图表。",withoutTitle:"这是一个图表,"},series:{single:{prefix:"",withName:"图表类型是{seriesType},表示{seriesName}。",withoutName:"图表类型是{seriesType}。"},multiple:{prefix:"它由{seriesCount}个图表系列组成。",withName:"第{seriesId}个系列是一个表示{seriesName}的{seriesType},",withoutName:"第{seriesId}个系列是一个{seriesType},",separator:{middle:";",end:"。"}}},data:{allData:"其数据是——",partialData:"其中,前{displayCnt}项是——",withName:"{name}的数据是{value}",withoutName:"{value}",separator:{middle:",",end:""}}}});function Ip(){return null}var Ap=1e3,Pp=6e4,Lp=36e5,Ep=864e5,zp=31536e6,Op={year:/({yyyy}|{yy})/,month:/({MMMM}|{MMM}|{MM}|{M})/,day:/({dd}|{d})/,hour:/({HH}|{H}|{hh}|{h})/,minute:/({mm}|{m})/,second:/({ss}|{s})/,millisecond:/({SSS}|{S})/},Np={year:"{yyyy}",month:"{MMM}",day:"{d}",hour:"{HH}:{mm}",minute:"{HH}:{mm}",second:"{HH}:{mm}:{ss}",millisecond:"{HH}:{mm}:{ss} {SSS}"},Rp="{yyyy}-{MM}-{dd}",Hp={year:"{yyyy}",month:"{yyyy}-{MM}",day:Rp,hour:Rp+" "+Np.hour,minute:Rp+" "+Np.minute,second:Rp+" "+Np.second,millisecond:"{yyyy}-{MM}-{dd} {HH}:{mm}:{ss} {SSS}"},Bp=["year","month","day","hour","minute","second","millisecond"],Fp=["year","half-year","quarter","month","week","half-week","day","half-day","quarter-day","hour","minute","second","millisecond"];function $p(t){return hn(t)||un(t)?t:function(t){t=t||{};var e={},n=!0;return en(Bp,function(e){n&&(n=null==t[e])}),en(Bp,function(i,r){var o=t[i];e[i]={};for(var a=null,s=r;s>=0;s--){var l=Bp[s],c=fn(o)&&!cn(o)?o[l]:o,u=void 0;cn(c)?a=(u=c.slice())[0]||"":hn(c)?u=[a=c]:(null==a?a=Np[i]:Op[l].test(a)||(a=e[l][l][0]+" "+a),u=[a],n&&(u[1]="{primary|"+a+"}")),e[i][l]=u}}),e}(t)}function Vp(t,e){return"0000".substr(0,e-(t+="").length)+t}function Wp(t){switch(t){case"half-year":case"quarter":return"month";case"week":case"half-week":return"day";case"half-day":case"quarter-day":return"hour";default:return t}}function Up(t){return t===Wp(t)}function Gp(t,e,n,i){var r=cs(t),o=r[Xp(n)](),a=r[Yp(n)]()+1,s=Math.floor((a-1)/3)+1,l=r[Zp(n)](),c=r["get"+(n?"UTC":"")+"Day"](),u=r[Kp(n)](),h=(u-1)%12+1,d=r[Qp(n)](),p=r[Jp(n)](),f=r[tf(n)](),g=u>=12?"pm":"am",v=g.toUpperCase(),m=i instanceof _p?i:function(t){return kp[t]}(i||Tp)||kp[Cp],y=m.getModel("time"),_=y.get("month"),x=y.get("monthAbbr"),b=y.get("dayOfWeek"),w=y.get("dayOfWeekAbbr");return(e||"").replace(/{a}/g,g+"").replace(/{A}/g,v+"").replace(/{yyyy}/g,o+"").replace(/{yy}/g,Vp(o%100+"",2)).replace(/{Q}/g,s+"").replace(/{MMMM}/g,_[a-1]).replace(/{MMM}/g,x[a-1]).replace(/{MM}/g,Vp(a,2)).replace(/{M}/g,a+"").replace(/{dd}/g,Vp(l,2)).replace(/{d}/g,l+"").replace(/{eeee}/g,b[c]).replace(/{ee}/g,w[c]).replace(/{e}/g,c+"").replace(/{HH}/g,Vp(u,2)).replace(/{H}/g,u+"").replace(/{hh}/g,Vp(h+"",2)).replace(/{h}/g,h+"").replace(/{mm}/g,Vp(d,2)).replace(/{m}/g,d+"").replace(/{ss}/g,Vp(p,2)).replace(/{s}/g,p+"").replace(/{SSS}/g,Vp(f,3)).replace(/{S}/g,f+"")}function qp(t,e){var n=cs(t),i=n[Yp(e)]()+1,r=n[Zp(e)](),o=n[Kp(e)](),a=n[Qp(e)](),s=n[Jp(e)](),l=0===n[tf(e)](),c=l&&0===s,u=c&&0===a,h=u&&0===o,d=h&&1===r;return d&&1===i?"year":d?"month":h?"day":u?"hour":c?"minute":l?"second":"millisecond"}function jp(t,e,n){switch(e){case"year":t[nf(n)](0);case"month":t[rf(n)](1);case"day":t[of(n)](0);case"hour":t[af(n)](0);case"minute":t[sf(n)](0);case"second":t[lf(n)](0)}return t}function Xp(t){return t?"getUTCFullYear":"getFullYear"}function Yp(t){return t?"getUTCMonth":"getMonth"}function Zp(t){return t?"getUTCDate":"getDate"}function Kp(t){return t?"getUTCHours":"getHours"}function Qp(t){return t?"getUTCMinutes":"getMinutes"}function Jp(t){return t?"getUTCSeconds":"getSeconds"}function tf(t){return t?"getUTCMilliseconds":"getMilliseconds"}function ef(t){return t?"setUTCFullYear":"setFullYear"}function nf(t){return t?"setUTCMonth":"setMonth"}function rf(t){return t?"setUTCDate":"setDate"}function of(t){return t?"setUTCHours":"setHours"}function af(t){return t?"setUTCMinutes":"setMinutes"}function sf(t){return t?"setUTCSeconds":"setSeconds"}function lf(t){return t?"setUTCMilliseconds":"setMilliseconds"}function cf(t){if(isNaN(ds(t)))return hn(t)?t:"-";var e=(t+"").split(".");return e[0].replace(/(\d{1,3})(?=(?:\d{3})+(?!\d))/g,"$1,")+(e.length>1?"."+e[1]:"")}function uf(t,e){return t=(t||"").toLowerCase().replace(/-(.)/g,function(t,e){return e.toUpperCase()}),e&&t&&(t=t.charAt(0).toUpperCase()+t.slice(1)),t}var hf=Cn;function df(t,e,n){function i(t){return t&&kn(t)?t:"-"}function r(t){return!(null==t||isNaN(t)||!isFinite(t))}var o="time"===e,a=t instanceof Date;if(o||a){var s=o?cs(t):t;if(!isNaN(+s))return Gp(s,"{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}",n);if(a)return"-"}if("ordinal"===e)return dn(t)?i(t):pn(t)&&r(t)?t+"":"-";var l=ds(t);return r(l)?cf(l):dn(t)?i(t):"boolean"==typeof t?t+"":"-"}var pf=["a","b","c","d","e","f","g"],ff=function(t,e){return"{"+t+(null==e?"":e)+"}"};function gf(t,e,n){cn(e)||(e=[e]);var i=e.length;if(!i)return"";for(var r=e[0].$vars||[],o=0;oi||l.newline?(o=0,u=g,a+=s+n,s=d.height):s=Math.max(s,d.height)}else{var v=d.height+(f?-f.y+d.y:0);(h=a+v)>r||l.newline?(o+=s+n,a=0,h=v,s=d.width):s=Math.max(s,d.width)}l.newline||(l.x=o,l.y=a,l.markRedraw(),"horizontal"===t?o=u+n:a=h+n)})}function Pf(t,e,n){n=hf(n||0);var i=e.width,r=e.height,o=es(t.left,i),a=es(t.top,r),s=es(t.right,i),l=es(t.bottom,r),c=es(t.width,i),u=es(t.height,r),h=n[2]+n[0],d=n[1]+n[3],p=t.aspect;switch(isNaN(c)&&(c=i-s-d-o),isNaN(u)&&(u=r-l-h-a),null!=p&&(isNaN(c)&&isNaN(u)&&(p>i/r?c=.8*i:u=.8*r),isNaN(c)&&(c=p*u),isNaN(u)&&(u=c/p)),isNaN(o)&&(o=i-s-c-d),isNaN(a)&&(a=r-l-u-h),t.left||t.right){case"center":o=i/2-c/2-n[3];break;case"right":o=i-c-d}switch(t.top||t.bottom){case"middle":case"center":a=r/2-u/2-n[0];break;case"bottom":a=r-u-h}o=o||0,a=a||0,isNaN(c)&&(c=i-d-o-(s||0)),isNaN(u)&&(u=r-h-a-(l||0));var f=new $i((e.x||0)+o+n[3],(e.y||0)+a+n[0],c,u);return f.margin=n,f}ln(Af,"vertical"),ln(Af,"horizontal");var Lf=1;function Ef(t,e,n){var i,r,o,a,s=t.boxCoordinateSystem;if(s){var l=function(t){var e=t.getShallow("coord",!0),n=xf;if(null==e){var i=wf.get(t.type);i&&i.getCoord2&&(n=bf,e=i.getCoord2(t))}return{coord:e,from:n}}(t),c=l.coord,u=l.from;if(s.dataToLayout){o=Lf,a=u;var h=s.dataToLayout(c);i=h.contentRect||h.rect}}return null==o&&(o=Lf),o===Lf&&(i||(i={x:0,y:0,width:e.getWidth(),height:e.getHeight()}),r=[i.x+i.width/2,i.y+i.height/2]),{type:o,refContainer:i,refPoint:r,boxCoordFrom:a}}function zf(t){var e=t.layoutMode||t.constructor.layoutMode;return fn(e)?e:e?{type:e}:null}function Of(t,e,n){var i=n&&n.ignoreSize;!cn(i)&&(i=[i,i]);var r=a(If[0],0),o=a(If[1],1);function a(n,r){var o={},a=0,l={},c=0;if(Tf(n,function(e){l[e]=t[e]}),Tf(n,function(t){On(e,t)&&(o[t]=l[t]=e[t]),s(o,t)&&a++,s(l,t)&&c++}),i[r])return s(e,n[1])?l[n[2]]=null:s(e,n[2])&&(l[n[1]]=null),l;if(2!==c&&a){if(a>=2)return o;for(var u=0;u=0;a--)o=Ye(o,n[a],!0);e.defaultOption=o}return e.defaultOption},e.prototype.getReferringComponents=function(t,e){var n=t+"Index",i=t+"Id";return Hs(this.ecModel,t,{index:this.get(n,!0),id:this.get(i,!0)},e)},e.prototype.getBoxLayoutParams=function(){return e=!1,{left:(t=this).getShallow("left",e),top:t.getShallow("top",e),right:t.getShallow("right",e),bottom:t.getShallow("bottom",e),width:t.getShallow("width",e),height:t.getShallow("height",e)};var t,e},e.prototype.getZLevelKey=function(){return""},e.prototype.setZLevel=function(t){this.option.zlevel=t},e.protoInitialize=((n=e.prototype).type="component",n.id="",n.name="",n.mainType="",n.subType="",void(n.componentIndex=0)),e}(_p);Us(Hf,_p),Xs(Hf),function(t){var e={};t.registerSubTypeDefaulter=function(t,n){var i=Vs(t);e[i.main]=n},t.determineSubType=function(n,i){var r=i.type;if(!r){var o=Vs(n).main;t.hasSubTypes(n)&&e[o]&&(r=e[o](i))}return r}}(Hf),function(t,e){function n(t,e){return t[e]||(t[e]={predecessor:[],successor:[]}),t[e]}t.topologicalTravel=function(t,i,r,o){if(t.length){var a=function(t){var i={},r=[];return en(t,function(o){var a=n(i,o),s=function(t,e){var n=[];return en(t,function(t){Qe(e,t)>=0&&n.push(t)}),n}(a.originalDeps=e(o),t);a.entryCount=s.length,0===a.entryCount&&r.push(o),en(s,function(t){Qe(a.predecessor,t)<0&&a.predecessor.push(t);var e=n(i,t);Qe(e.successor,t)<0&&e.successor.push(o)})}),{graph:i,noEntryList:r}}(i),s=a.graph,l=a.noEntryList,c={};for(en(t,function(t){c[t]=!0});l.length;){var u=l.pop(),h=s[u],d=!!c[u];d&&(r.call(o,u,h.originalDeps.slice()),delete c[u]),en(h.successor,d?f:p)}en(c,function(){throw new Error("")})}function p(t){s[t].entryCount--,0===s[t].entryCount&&l.push(t)}function f(t){c[t]=!0,p(t)}}}(Hf,function(t){var e=[];en(Hf.getClassesByMainType(t),function(t){e=e.concat(t.dependencies||t.prototype.dependencies||[])}),e=nn(e,function(t){return Vs(t).main}),"dataset"!==t&&Qe(e,"dataset")<=0&&e.unshift("dataset");return e});var Bf={color:{},darkColor:{},size:{}},Ff=Bf.color={theme:["#5070dd","#b6d634","#505372","#ff994d","#0ca8df","#ffd10a","#fb628b","#785db0","#3fbe95"],neutral00:"#fff",neutral05:"#f4f7fd",neutral10:"#e8ebf0",neutral15:"#dbdee4",neutral20:"#cfd2d7",neutral25:"#c3c5cb",neutral30:"#b7b9be",neutral35:"#aaacb2",neutral40:"#9ea0a5",neutral45:"#929399",neutral50:"#86878c",neutral55:"#797b7f",neutral60:"#6d6e73",neutral65:"#616266",neutral70:"#54555a",neutral75:"#48494d",neutral80:"#3c3c41",neutral85:"#303034",neutral90:"#232328",neutral95:"#17171b",neutral99:"#000",accent05:"#eff1f9",accent10:"#e0e4f2",accent15:"#d0d6ec",accent20:"#c0c9e6",accent25:"#b1bbdf",accent30:"#a1aed9",accent35:"#91a0d3",accent40:"#8292cc",accent45:"#7285c6",accent50:"#6578ba",accent55:"#5c6da9",accent60:"#536298",accent65:"#4a5787",accent70:"#404c76",accent75:"#374165",accent80:"#2e3654",accent85:"#252b43",accent90:"#1b2032",accent95:"#121521",transparent:"rgba(0,0,0,0)",highlight:"rgba(255,231,130,0.8)"};for(var $f in Ze(Ff,{primary:Ff.neutral80,secondary:Ff.neutral70,tertiary:Ff.neutral60,quaternary:Ff.neutral50,disabled:Ff.neutral20,border:Ff.neutral30,borderTint:Ff.neutral20,borderShade:Ff.neutral40,background:Ff.neutral05,backgroundTint:"rgba(234,237,245,0.5)",backgroundTransparent:"rgba(255,255,255,0)",backgroundShade:Ff.neutral10,shadow:"rgba(0,0,0,0.2)",shadowTint:"rgba(129,130,136,0.2)",axisLine:Ff.neutral70,axisLineTint:Ff.neutral40,axisTick:Ff.neutral70,axisTickMinor:Ff.neutral60,axisLabel:Ff.neutral70,axisSplitLine:Ff.neutral15,axisMinorSplitLine:Ff.neutral05}),Ff)if(Ff.hasOwnProperty($f)){var Vf=Ff[$f];"theme"===$f?Bf.darkColor.theme=Ff.theme.slice():"highlight"===$f?Bf.darkColor.highlight="rgba(255,231,130,0.4)":0===$f.indexOf("accent")?Bf.darkColor[$f]=io(Vf,0,function(t){return.5*t},function(t){return Math.min(1,1.3-t)}):Bf.darkColor[$f]=io(Vf,0,function(t){return.9*t},function(t){return 1-Math.pow(t,1.5)})}Bf.size={xxs:2,xs:5,s:10,m:15,l:20,xl:30,xxl:40,xxxl:50};var Wf="";"undefined"!=typeof navigator&&(Wf=navigator.platform||"");var Uf="rgba(0, 0, 0, 0.2)",Gf=Bf.color.theme[0],qf=io(Gf,0,null,.9),jf={darkMode:"auto",colorBy:"series",color:Bf.color.theme,gradientColor:[qf,Gf],aria:{decal:{decals:[{color:Uf,dashArrayX:[1,0],dashArrayY:[2,5],symbolSize:1,rotation:Math.PI/6},{color:Uf,symbol:"circle",dashArrayX:[[8,8],[0,8,8,0]],dashArrayY:[6,0],symbolSize:.8},{color:Uf,dashArrayX:[1,0],dashArrayY:[4,3],rotation:-Math.PI/4},{color:Uf,dashArrayX:[[6,6],[0,6,6,0]],dashArrayY:[6,0]},{color:Uf,dashArrayX:[[1,0],[1,6]],dashArrayY:[1,0,6,0],rotation:Math.PI/4},{color:Uf,symbol:"triangle",dashArrayX:[[9,9],[0,9,9,0]],dashArrayY:[7,2],symbolSize:.75}]}},textStyle:{fontFamily:Wf.match(/^Win/)?"Microsoft YaHei":"sans-serif",fontSize:12,fontStyle:"normal",fontWeight:"normal"},blendMode:null,stateAnimation:{duration:300,easing:"cubicOut"},animation:"auto",animationDuration:1e3,animationDurationUpdate:500,animationEasing:"cubicInOut",animationEasingUpdate:"cubicInOut",animationThreshold:2e3,progressiveThreshold:3e3,progressive:400,hoverLayerThreshold:3e3,useUTC:!1},Xf=En(["tooltip","label","itemName","itemId","itemGroupId","itemChildGroupId","seriesName"]),Yf="original",Zf="arrayRows",Kf="objectRows",Qf="keyedColumns",Jf="typedArray",tg="unknown",eg="column",ng="row",ig=1,rg=2,og=3,ag=Es();function sg(t,e,n){var i={},r=lg(e);if(!r||!t)return i;var o,a,s=[],l=[],c=e.ecModel,u=ag(c).datasetMap,h=r.uid+"_"+n.seriesLayoutBy;en(t=t.slice(),function(e,n){var r=fn(e)?e:t[n]={name:e};"ordinal"===r.type&&null==o&&(o=n,a=f(r)),i[r.name]=[]});var d=u.get(h)||u.set(h,{categoryWayDim:a,valueWayDim:0});function p(t,e,n){for(var i=0;ie)return t[i];return t[n-1]}(i,a):n;if(u=u||n,!u||!u.length)return;var h=u[l];r&&(c[r]=h);return s.paletteIdx=(l+1)%u.length,h}(this,hg,i,r,t,e,n)},t.prototype.clearColorPalette=function(){!function(t,e){e(t).paletteIdx=0,e(t).paletteNameMap={}}(this,hg)},t}();var vg="\0_ec_inner",mg=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return _(e,t),e.prototype.init=function(t,e,n,i,r,o){i=i||{},this.option=null,this._theme=new _p(i),this._locale=new _p(r),this._optionManager=o},e.prototype.setOption=function(t,e,n){var i=xg(e);this._optionManager.setOption(t,n,i),this._resetOption(null,i)},e.prototype.resetOption=function(t,e){return this._resetOption(t,xg(e))},e.prototype._resetOption=function(t,e){var n=!1,i=this._optionManager;if(!t||"recreate"===t){var r=i.mountOption("recreate"===t);this.option&&"recreate"!==t?(this.restoreData(),this._mergeOption(r,e)):fg(this,r),n=!0}if("timeline"!==t&&"media"!==t||this.restoreData(),!t||"recreate"===t||"timeline"===t){var o=i.getTimelineOption(this);o&&(n=!0,this._mergeOption(o,e))}if(!t||"recreate"===t||"media"===t){var a=i.getMediaOption(this);a.length&&en(a,function(t){n=!0,this._mergeOption(t,e)},this)}return n},e.prototype.mergeOption=function(t){this._mergeOption(t,null)},e.prototype._mergeOption=function(t,e){var n=this.option,i=this._componentsMap,r=this._componentsCount,o=[],a=En(),s=e&&e.replaceMergeMainTypeMap;ag(this).datasetMap=En(),en(t,function(t,e){null!=t&&(Hf.hasClass(e)?e&&(o.push(e),a.set(e,!0)):n[e]=null==n[e]?Xe(t):Ye(n[e],t,!0))}),s&&s.each(function(t,e){Hf.hasClass(e)&&!a.get(e)&&(o.push(e),a.set(e,!0))}),Hf.topologicalTravel(o,Hf.getAllClassMainTypes(),function(e){var o=function(t,e,n){var i=ug.get(e);if(!i)return n;var r=i(t);return r?n.concat(r):n}(this,e,bs(t[e])),a=i.get(e),l=a?s&&s.get(e)?"replaceMerge":"normalMerge":"replaceAll",c=ks(a,o,l);(function(t,e,n){en(t,function(t){var i=t.newOption;fn(i)&&(t.keyInfo.mainType=e,t.keyInfo.subType=function(t,e,n,i){return e.type?e.type:n?n.subType:i.determineSubType(t,e)}(e,i,t.existing,n))})})(c,e,Hf),n[e]=null,i.set(e,null),r.set(e,0);var u,h=[],d=[],p=0;en(c,function(t,n){var i=t.existing,r=t.newOption;if(r){var o="series"===e,a=Hf.getClass(e,t.keyInfo.subType,!o);if(!a)return;if("tooltip"===e){if(u)return;u=!0}if(i&&i.constructor===a)i.name=t.keyInfo.name,i.mergeOption(r,this),i.optionUpdated(r,!1);else{var s=Ze({componentIndex:n},t.keyInfo);Ze(i=new a(r,this,this,s),s),t.brandNew&&(i.__requireNewView=!0),i.init(r,this,this),i.optionUpdated(null,!0)}}else i&&(i.mergeOption({},this),i.optionUpdated({},!1));i?(h.push(i.option),d.push(i),p++):(h.push(void 0),d.push(void 0))},this),n[e]=h,i.set(e,d),r.set(e,p),"series"===e&&dg(this)},this),this._seriesIndices||dg(this)},e.prototype.getOption=function(){var t=Xe(this.option);return en(t,function(e,n){if(Hf.hasClass(n)){for(var i=bs(e),r=i.length,o=!1,a=r-1;a>=0;a--)i[a]&&!Ps(i[a])?o=!0:(i[a]=null,!o&&r--);i.length=r,t[n]=i}}),delete t[vg],t},e.prototype.setTheme=function(t){this._theme=new _p(t),this._resetOption("recreate",null)},e.prototype.getTheme=function(){return this._theme},e.prototype.getLocaleModel=function(){return this._locale},e.prototype.setUpdatePayload=function(t){this._payload=t},e.prototype.getUpdatePayload=function(){return this._payload},e.prototype.getComponent=function(t,e){var n=this._componentsMap.get(t);if(n){var i=n[e||0];if(i)return i;if(null==e)for(var r=0;r=e:"max"===n?t<=e:t===e})(i[a],t,o)||(r=!1)}}),r}var kg=en,Tg=fn,Dg=["areaStyle","lineStyle","nodeStyle","linkStyle","chordStyle","label","labelLine"];function Ig(t){var e=t&&t.itemStyle;if(e)for(var n=0,i=Dg.length;nc&&(c=p)}s[0]=l,s[1]=c}},i=function(){return this._data?this._data.length/this._dimSize:0};function r(t){for(var e=0;e=0&&(s=o.interpolatedValue[l])}return null!=s?s+"":""}):void 0},t.prototype.getRawValue=function(t,e){return yv(this.getData(e),t)},t.prototype.formatTooltip=function(t,e,n){},t}();function bv(t){var e,n;return fn(t)?t.type&&(n=t):e=t,{text:e,frag:n}}function wv(t){return new Sv(t)}var Sv=function(){function t(t){t=t||{},this._reset=t.reset,this._plan=t.plan,this._count=t.count,this._onDirty=t.onDirty,this._dirty=!0}return t.prototype.perform=function(t){var e,n=this._upstream,i=t&&t.skip;if(this._dirty&&n){var r=this.context;r.data=r.outputData=n.context.outputData}this.__pipeline&&(this.__pipeline.currentTask=this),this._plan&&!i&&(e=this._plan(this.context));var o,a=u(this._modBy),s=this._modDataCount||0,l=u(t&&t.modBy),c=t&&t.modDataCount||0;function u(t){return!(t>=1)&&(t=1),t}a===l&&s===c||(e="reset"),(this._dirty||"reset"===e)&&(this._dirty=!1,o=this._doReset(i)),this._modBy=l,this._modDataCount=c;var h=t&&t.step;if(this._dueEnd=n?n._outputDueEnd:this._count?this._count(this.context):1/0,this._progress){var d=this._dueIndex,p=Math.min(null!=h?this._dueIndex+h:1/0,this._dueEnd);if(!i&&(o||d1&&i>0?s:a}};return o;function a(){return e=t?null:oi?-this._resultLT:0},t}(),Tv=function(){function t(){}return t.prototype.getRawData=function(){throw new Error("not supported")},t.prototype.getRawDataItem=function(t){throw new Error("not supported")},t.prototype.cloneRawData=function(){},t.prototype.getDimensionInfo=function(t){},t.prototype.cloneAllDimensionInfo=function(){},t.prototype.count=function(){},t.prototype.retrieveValue=function(t,e){},t.prototype.retrieveValueFromItem=function(t,e){},t.prototype.convertValue=function(t,e){return Mv(t,e)},t}();function Dv(t){if(!zv(t.sourceFormat)){ys("")}return t.data}function Iv(t){var e=t.sourceFormat,n=t.data;if(!zv(e)){ys("")}if(e===Zf){for(var i=[],r=0,o=n.length;r65535?Rv:Hv}function Wv(){return[1/0,-1/0]}function Uv(t){var e=t.constructor;return e===Array?t.slice():new e(t)}function Gv(t,e,n,i,r){var o=$v[n||"float"];if(r){var a=t[e],s=a&&a.length;if(s!==i){for(var l=new o(i),c=0;cg[1]&&(g[1]=f)}return this._rawCount=this._count=s,{start:a,end:s}},t.prototype._initDataFromProvider=function(t,e,n){for(var i=this._provider,r=this._chunks,o=this._dimensions,a=o.length,s=this._rawExtent,l=nn(o,function(t){return t.property}),c=0;cv[1]&&(v[1]=g)}}!i.persistent&&i.clean&&i.clean(),this._rawCount=this._count=e,this._extent=[]},t.prototype.count=function(){return this._count},t.prototype.get=function(t,e){if(!(e>=0&&e=0&&e=this._rawCount||t<0)return-1;if(!this._indices)return t;var e=this._indices,n=e[t];if(null!=n&&nt))return o;r=o-1}}return-1},t.prototype.getIndices=function(){var t,e=this._indices;if(e){var n=e.constructor,i=this._count;if(n===Array){t=new n(i);for(var r=0;r=c&&_<=u||isNaN(_))&&(a[s++]=p),p++}d=!0}else if(2===r){f=h[i[0]];var v=h[i[1]],m=t[i[1]][0],y=t[i[1]][1];for(g=0;g=c&&_<=u||isNaN(_))&&(x>=m&&x<=y||isNaN(x))&&(a[s++]=p),p++}d=!0}}if(!d)if(1===r)for(g=0;g=c&&_<=u||isNaN(_))&&(a[s++]=b)}else for(g=0;gt[C][1])&&(w=!1)}w&&(a[s++]=e.getRawIndex(g))}return sv[1]&&(v[1]=g)}}}},t.prototype.lttbDownSample=function(t,e){var n,i,r,o=this.clone([t],!0),a=o._chunks[t],s=this.count(),l=0,c=Math.floor(1/e),u=this.getRawIndex(0),h=new(Vv(this._rawCount))(Math.min(2*(Math.ceil(s/c)+2),s));h[l++]=u;for(var d=1;dn&&(n=i,r=M)}C>0&&Ca&&(f=a-c);for(var g=0;gp&&(p=v,d=c+g)}var m=this.getRawIndex(u),y=this.getRawIndex(d);uc-p&&(s=c-p,a.length=s);for(var f=0;fu[1]&&(u[1]=v),h[d++]=m}return r._count=d,r._indices=h,r._updateGetRawIdx(),r},t.prototype.each=function(t,e){if(this._count)for(var n=t.length,i=this._chunks,r=0,o=this.count();ra&&(a=l)}return i=[o,a],this._extent[t]=i,i},t.prototype.getRawDataItem=function(t){var e=this.getRawIndex(t);if(this._provider.persistent)return this._provider.getItem(e);for(var n=[],i=this._chunks,r=0;r=0?this._indices[t]:-1},t.prototype._updateGetRawIdx=function(){this.getRawIndex=this._indices?this._getRawIdx:this._getRawIdxIdentity},t.internalField=function(){function t(t,e,n,i){return Mv(t[i],this._dimensions[i])}Ov={arrayRows:t,objectRows:function(t,e,n,i){return Mv(t[e],this._dimensions[i])},keyedColumns:t,original:function(t,e,n,i){var r=t&&(null==t.value?t:t.value);return Mv(r instanceof Array?r[i]:r,this._dimensions[i])},typedArray:function(t,e,n,i){return t[i]}}}(),t}(),jv=function(){function t(t){this._sourceList=[],this._storeList=[],this._upstreamSignList=[],this._versionSignBase=0,this._dirty=!0,this._sourceHost=t}return t.prototype.dirty=function(){this._setLocalSource([],[]),this._storeList=[],this._dirty=!0},t.prototype._setLocalSource=function(t,e){this._sourceList=t,this._upstreamSignList=e,this._versionSignBase++,this._versionSignBase>9e10&&(this._versionSignBase=0)},t.prototype._getVersionSign=function(){return this._sourceHost.uid+"_"+this._versionSignBase},t.prototype.prepareSource=function(){this._isDirty()&&(this._createSource(),this._dirty=!1)},t.prototype._createSource=function(){this._setLocalSource([],[]);var t,e,n=this._sourceHost,i=this._getUpstreamSourceManagers(),r=!!i.length;if(Xv(n)){var o=n,a=void 0,s=void 0,l=void 0;if(r){var c=i[0];c.prepareSource(),a=(l=c.getSource()).data,s=l.sourceFormat,e=[c._getVersionSign()]}else s=vn(a=o.get("data",!0))?Jf:Yf,e=[];var u=this._getSourceMetaRawOption()||{},h=l&&l.metaRawOption||{},d=bn(u.seriesLayoutBy,h.seriesLayoutBy)||null,p=bn(u.sourceHeader,h.sourceHeader),f=bn(u.dimensions,h.dimensions);t=d!==h.seriesLayoutBy||!!p!=!!h.sourceHeader||f?[tv(a,{seriesLayoutBy:d,sourceHeader:p,dimensions:f},s)]:[]}else{var g=n;if(r){var v=this._applyTransform(i);t=v.sourceList,e=v.upstreamSignList}else{t=[tv(g.get("source",!0),this._getSourceMetaRawOption(),null)],e=[]}}this._setLocalSource(t,e)},t.prototype._applyTransform=function(t){var e,n=this._sourceHost,i=n.get("transform",!0),r=n.get("fromTransformResult",!0);if(null!=r){1!==t.length&&Yv("")}var o,a=[],s=[];return en(t,function(t){t.prepareSource();var e=t.getSource(r||0);null==r||e||Yv(""),a.push(e),s.push(t._getVersionSign())}),i?e=function(t,e){var n=bs(t),i=n.length;i||ys("");for(var r=0,o=i;r1||n>0&&!t.noHeader;return en(t.blocks,function(t){var n=im(t);n>=e&&(e=n+ +(i&&(!n||em(t)&&!t.noHeader)))}),e}return 0}function rm(t,e,n,i){var r,o=e.noHeader,a=(r=im(e),{html:Qv[r],richText:Jv[r]}),s=[],l=e.blocks||[];Mn(!l||cn(l)),l=l||[];var c=t.orderMode;if(e.sortBlocks&&c){l=l.slice();var u={valueAsc:"asc",valueDesc:"desc"};if(On(u,c)){var h=new kv(u[c],null);l.sort(function(t,e){return h.evaluate(t.sortParam,e.sortParam)})}else"seriesDesc"===c&&l.reverse()}en(l,function(n,r){var o=e.valueFormatter,l=nm(n)(o?Ze(Ze({},t),{valueFormatter:o}):t,n,r>0?a.html:0,i);null!=l&&s.push(l)});var d="richText"===t.renderMode?s.join(a.richText):sm(i,s.join(""),o?n:a.html);if(o)return d;var p=df(e.header,"ordinal",t.useUTC),f=Kv(i,t.renderMode).nameStyle,g=Zv(i);return"richText"===t.renderMode?lm(t,p,f)+a.richText+d:sm(i,'
'+li(p)+"
"+d,n)}function om(t,e,n,i){var r=t.renderMode,o=e.noName,a=e.noValue,s=!e.markerType,l=e.name,c=t.useUTC,u=e.valueFormatter||t.valueFormatter||function(t){return nn(t=cn(t)?t:[t],function(t,e){return df(t,cn(p)?p[e]:p,c)})};if(!o||!a){var h=s?"":t.markupStyleCreator.makeTooltipMarker(e.markerType,e.markerColor||Bf.color.secondary,r),d=o?"":df(l,"ordinal",c),p=e.valueType,f=a?[]:u(e.value,e.dataIndex),g=!s||!o,v=!s&&o,m=Kv(i,r),y=m.nameStyle,_=m.valueStyle;return"richText"===r?(s?"":h)+(o?"":lm(t,d,y))+(a?"":function(t,e,n,i,r){var o=[r],a=i?10:20;return n&&o.push({padding:[0,0,0,a],align:"right"}),t.markupStyleCreator.wrapRichTextStyle(cn(e)?e.join(" "):e,o)}(t,f,g,v,_)):sm(i,(s?"":h)+(o?"":function(t,e,n){return''+li(t)+""}(d,!s,y))+(a?"":function(t,e,n,i){var r=n?"10px":"20px",o=e?"float:right;margin-left:"+r:"";return t=cn(t)?t:[t],''+nn(t,function(t){return li(t)}).join("  ")+""}(f,g,v,_)),n)}}function am(t,e,n,i,r,o){if(t)return nm(t)({useUTC:r,renderMode:n,orderMode:i,markupStyleCreator:e,valueFormatter:t.valueFormatter},t,0,o)}function sm(t,e,n){return'
'+e+'
'}function lm(t,e,n){return t.markupStyleCreator.wrapRichTextStyle(e,n)}function cm(t,e){var n=t.get("padding");return null!=n?n:"richText"===e?[8,10]:10}var um=function(){function t(){this.richTextStyles={},this._nextStyleNameId=ps()}return t.prototype._generateStyleName=function(){return"__EC_aUTo_"+this._nextStyleNameId++},t.prototype.makeTooltipMarker=function(t,e,n){var i="richText"===n?this._generateStyleName():null,r=function(t,e){var n=hn(t)?{color:t,extraCssText:e}:t||{},i=n.color,r=n.type;e=n.extraCssText;var o=n.renderMode||"html";return i?"html"===o?"subItem"===r?'':'':{renderMode:o,content:"{"+(n.markerId||"markerX")+"|} ",style:"subItem"===r?{width:4,height:4,borderRadius:2,backgroundColor:i}:{width:10,height:10,borderRadius:5,backgroundColor:i}}:""}({color:e,type:t,renderMode:n,markerId:i});return hn(r)?r:(this.richTextStyles[i]=r.style,r.content)},t.prototype.wrapRichTextStyle=function(t,e){var n={};cn(e)?en(e,function(t){return Ze(n,t)}):Ze(n,e);var i=this._generateStyleName();return this.richTextStyles[i]=n,"{"+i+"|"+t+"}"},t}();function hm(t){var e,n,i,r,o=t.series,a=t.dataIndex,s=t.multipleSeries,l=o.getData(),c=l.mapDimensionsAll("defaultedTooltip"),u=c.length,h=o.getRawValue(a),d=cn(h),p=function(t,e){return vf(t.getData().getItemVisual(e,"style")[t.visualDrawType])}(o,a);if(u>1||d&&!u){var f=function(t,e,n,i,r){var o=e.getData(),a=rn(t,function(t,e,n){var i=o.getDimensionInfo(n);return t||i&&!1!==i.tooltip&&null!=i.displayName},!1),s=[],l=[],c=[];function u(t,e){var n=o.getDimensionInfo(e);n&&!1!==n.otherDims.tooltip&&(a?c.push(tm("nameValue",{markerType:"subItem",markerColor:r,name:n.displayName,value:t,valueType:n.type})):(s.push(t),l.push(n.type)))}return i.length?en(i,function(t){u(yv(o,n,t),t)}):en(t,u),{inlineValues:s,inlineValueTypes:l,blocks:c}}(h,o,a,c,p);e=f.inlineValues,n=f.inlineValueTypes,i=f.blocks,r=f.inlineValues[0]}else if(u){var g=l.getDimensionInfo(c[0]);r=e=yv(l,a,c[0]),n=g.type}else r=e=d?h[0]:h;var v=As(o),m=v&&o.name||"",y=l.getName(a),_=s?m:y;return tm("section",{header:m,noHeader:s||!v,sortParam:r,blocks:[tm("nameValue",{markerType:"item",markerColor:p,name:_,noName:!kn(_),value:e,valueType:n,dataIndex:a})].concat(i||[])})}var dm=Es();function pm(t,e){return t.getName(e)||t.getId(e)}var fm=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e._selectedDataIndicesMap={},e}var n;return _(e,t),e.prototype.init=function(t,e,n){this.seriesIndex=this.componentIndex,this.dataTask=wv({count:vm,reset:mm}),this.dataTask.context={model:this},this.mergeDefaultAndTheme(t,n),(dm(this).sourceManager=new jv(this)).prepareSource();var i=this.getInitialData(t,n);_m(i,this),this.dataTask.context.data=i,dm(this).dataBeforeProcessed=i,gm(this),this._initSelectedMapFromData(i)},e.prototype.mergeDefaultAndTheme=function(t,e){var n=zf(this),i=n?Nf(t):{},r=this.subType;Hf.hasClass(r)&&(r+="Series"),Ye(t,e.getTheme().get(this.subType)),Ye(t,this.getDefaultOption()),ws(t,"label",["show"]),this.fillDataTextStyle(t.data),n&&Of(t,i,n)},e.prototype.mergeOption=function(t,e){t=Ye(this.option,t,!0),this.fillDataTextStyle(t.data);var n=zf(this);n&&Of(this.option,t,n);var i=dm(this).sourceManager;i.dirty(),i.prepareSource();var r=this.getInitialData(t,e);_m(r,this),this.dataTask.dirty(),this.dataTask.context.data=r,dm(this).dataBeforeProcessed=r,gm(this),this._initSelectedMapFromData(r)},e.prototype.fillDataTextStyle=function(t){if(t&&!vn(t))for(var e=["show"],n=0;n=0&&u<0)&&(c=o,u=r,h=0),r===u&&(l[h++]=e))}),l.length=h,l},e.prototype.formatTooltip=function(t,e,n){return hm({series:this,dataIndex:t,multipleSeries:e})},e.prototype.isAnimationEnabled=function(){var t=this.ecModel;if(Te.node&&(!t||!t.ssr))return!1;var e=this.getShallow("animation");return e&&this.getData().count()>this.getShallow("animationThreshold")&&(e=!1),!!e},e.prototype.restoreData=function(){this.dataTask.dirty()},e.prototype.getColorFromPalette=function(t,e,n){var i=this.ecModel,r=gg.prototype.getColorFromPalette.call(this,t,e,n);return r||(r=i.getColorFromPalette(t,e,n)),r},e.prototype.coordDimToDataDim=function(t){return this.getRawData().mapDimensionsAll(t)},e.prototype.getProgressive=function(){return this.get("progressive")},e.prototype.getProgressiveThreshold=function(){return this.get("progressiveThreshold")},e.prototype.select=function(t,e){this._innerSelect(this.getData(e),t)},e.prototype.unselect=function(t,e){var n=this.option.selectedMap;if(n){var i=this.option.selectedMode,r=this.getData(e);if("series"===i||"all"===n)return this.option.selectedMap={},void(this._selectedDataIndicesMap={});for(var o=0;o=0&&n.push(r)}return n},e.prototype.isSelected=function(t,e){var n=this.option.selectedMap;if(!n)return!1;var i=this.getData(e);return("all"===n||n[pm(i,t)])&&!i.getItemModel(t).get(["select","disabled"])},e.prototype.isUniversalTransitionEnabled=function(){if(this.__universalTransitionEnabled)return!0;var t=this.option.universalTransition;return!!t&&(!0===t||t&&t.enabled)},e.prototype._innerSelect=function(t,e){var n,i,r=this.option,o=r.selectedMode,a=e.length;if(o&&a)if("series"===o)r.selectedMap="all";else if("multiple"===o){fn(r.selectedMap)||(r.selectedMap={});for(var s=r.selectedMap,l=0;l0&&this._innerSelect(t,e)}},e.registerClass=function(t){return Hf.registerClass(t)},e.protoInitialize=((n=e.prototype).type="series.__base__",n.seriesIndex=0,n.ignoreStyleOnData=!1,n.hasSymbolVisual=!1,n.defaultSymbol="circle",n.visualStyleAccessPath="itemStyle",void(n.visualDrawType="fill")),e}(Hf);function gm(t){var e=t.name;As(t)||(t.name=function(t){var e=t.getRawData(),n=e.mapDimensionsAll("seriesName"),i=[];return en(n,function(t){var n=e.getDimensionInfo(t);n.displayName&&i.push(n.displayName)}),i.join(" ")}(t)||e)}function vm(t){return t.model.getRawData().count()}function mm(t){var e=t.model;return e.setData(e.getRawData().cloneShallow()),ym}function ym(t,e){e.outputData&&t.end>e.outputData.count()&&e.model.getRawData().cloneShallow(e.outputData)}function _m(t,e){en(function(t,e){for(var n=new t.constructor(t.length+e.length),i=0;i=0?h():u=setTimeout(h,-r),l=i};return d.clear=function(){u&&(clearTimeout(u),u=null)},d.debounceNextCall=function(t){s=t},d}function Nm(t,e,n,i){var r=t[e];if(r){var o=r[Lm]||r,a=r[zm];if(r[Em]!==n||a!==i){if(null==n||!i)return t[e]=o;(r=t[e]=Om(o,n,"debounce"===i))[Lm]=o,r[zm]=i,r[Em]=n}return r}}function Rm(t,e){var n=t[e];n&&n[Lm]&&(n.clear&&n.clear(),t[e]=n[Lm])}var Hm=Es(),Bm={itemStyle:Ys(vp,!0),lineStyle:Ys(pp,!0)},Fm={lineStyle:"stroke",itemStyle:"fill"};function $m(t,e){var n=t.visualStyleMapper||Bm[e];return n||(console.warn("Unknown style type '"+e+"'."),Bm.itemStyle)}function Vm(t,e){var n=t.visualDrawType||Fm[e];return n||(console.warn("Unknown style type '"+e+"'."),"fill")}var Wm={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){var n=t.getData(),i=t.visualStyleAccessPath||"itemStyle",r=t.getModel(i),o=$m(t,i)(r),a=r.getShallow("decal");a&&(n.setVisual("decal",a),a.dirty=!0);var s=Vm(t,i),l=o[s],c=un(l)?l:null,u="auto"===o.fill||"auto"===o.stroke;if(!o[s]||c||u){var h=t.getColorFromPalette(t.name,null,e.getSeriesCount());o[s]||(o[s]=h,n.setVisual("colorFromPalette",!0)),o.fill="auto"===o.fill||un(o.fill)?h:o.fill,o.stroke="auto"===o.stroke||un(o.stroke)?h:o.stroke}if(n.setVisual("style",o),n.setVisual("drawType",s),!e.isSeriesFiltered(t)&&c)return n.setVisual("colorFromPalette",!1),{dataEach:function(e,n){var i=t.getDataParams(n),r=Ze({},o);r[s]=c(i),e.setItemVisual(n,"style",r)}}}},Um=new _p,Gm={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){if(!t.ignoreStyleOnData&&!e.isSeriesFiltered(t)){var n=t.getData(),i=t.visualStyleAccessPath||"itemStyle",r=$m(t,i),o=n.getVisual("drawType");return{dataEach:n.hasItemOption?function(t,e){var n=t.getRawDataItem(e);if(n&&n[i]){Um.option=n[i];var a=r(Um);Ze(t.ensureUniqueItemVisual(e,"style"),a),Um.option.decal&&(t.setItemVisual(e,"decal",Um.option.decal),Um.option.decal.dirty=!0),o in a&&t.setItemVisual(e,"colorFromPalette",!1)}}:null}}}},qm={performRawSeries:!0,overallReset:function(t){var e=En();t.eachSeries(function(t){var n=t.getColorBy();if(!t.isColorBySeries()){var i=t.type+"-"+n,r=e.get(i);r||(r={},e.set(i,r)),Hm(t).scope=r}}),t.eachSeries(function(e){if(!e.isColorBySeries()&&!t.isSeriesFiltered(e)){var n=e.getRawData(),i={},r=e.getData(),o=Hm(e).scope,a=e.visualStyleAccessPath||"itemStyle",s=Vm(e,a);r.each(function(t){var e=r.getRawIndex(t);i[e]=t}),n.each(function(t){var a=i[t];if(r.getItemVisual(a,"colorFromPalette")){var l=r.ensureUniqueItemVisual(a,"style"),c=n.getName(t)||t+"",u=n.count();l[s]=e.getColorFromPalette(c,o,u)}})}})}},jm=Math.PI;var Xm=function(){function t(t,e,n,i){this._stageTaskMap=En(),this.ecInstance=t,this.api=e,n=this._dataProcessorHandlers=n.slice(),i=this._visualHandlers=i.slice(),this._allHandlers=n.concat(i)}return t.prototype.restoreData=function(t,e){t.restoreData(e),this._stageTaskMap.each(function(t){var e=t.overallTask;e&&e.dirty()})},t.prototype.getPerformArgs=function(t,e){if(t.__pipeline){var n=this._pipelineMap.get(t.__pipeline.id),i=n.context,r=!e&&n.progressiveEnabled&&(!i||i.progressiveRender)&&t.__idxInPipeline>n.blockIndex?n.step:null,o=i&&i.modDataCount;return{step:r,modBy:null!=o?Math.ceil(o/r):null,modDataCount:o}}},t.prototype.getPipeline=function(t){return this._pipelineMap.get(t)},t.prototype.updateStreamModes=function(t,e){var n=this._pipelineMap.get(t.uid),i=t.getData().count(),r=n.progressiveEnabled&&e.incrementalPrepareRender&&i>=n.threshold,o=t.get("large")&&i>=t.get("largeThreshold"),a="mod"===t.get("progressiveChunkMode")?i:null;t.pipelineContext=n.context={progressiveRender:r,modDataCount:a,large:o}},t.prototype.restorePipelines=function(t){var e=this,n=e._pipelineMap=En();t.eachSeries(function(t){var i=t.getProgressive(),r=t.uid;n.set(r,{id:r,head:null,tail:null,threshold:t.getProgressiveThreshold(),progressiveEnabled:i&&!(t.preventIncremental&&t.preventIncremental()),blockIndex:-1,step:Math.round(i||700),count:0}),e._pipe(t,t.dataTask)})},t.prototype.prepareStageTasks=function(){var t=this._stageTaskMap,e=this.api.getModel(),n=this.api;en(this._allHandlers,function(i){var r=t.get(i.uid)||t.set(i.uid,{});Mn(!(i.reset&&i.overallReset),""),i.reset&&this._createSeriesStageTask(i,r,e,n),i.overallReset&&this._createOverallStageTask(i,r,e,n)},this)},t.prototype.prepareView=function(t,e,n,i){var r=t.renderTask,o=r.context;o.model=e,o.ecModel=n,o.api=i,r.__block=!t.incrementalPrepareRender,this._pipe(e,r)},t.prototype.performDataProcessorTasks=function(t,e){this._performStageTasks(this._dataProcessorHandlers,t,e,{block:!0})},t.prototype.performVisualTasks=function(t,e,n){this._performStageTasks(this._visualHandlers,t,e,n)},t.prototype._performStageTasks=function(t,e,n,i){i=i||{};var r=!1,o=this;function a(t,e){return t.setDirty&&(!t.dirtyMap||t.dirtyMap.get(e.__pipeline.id))}en(t,function(t,s){if(!i.visualType||i.visualType===t.visualType){var l=o._stageTaskMap.get(t.uid),c=l.seriesTaskMap,u=l.overallTask;if(u){var h,d=u.agentStubMap;d.each(function(t){a(i,t)&&(t.dirty(),h=!0)}),h&&u.dirty(),o.updatePayload(u,n);var p=o.getPerformArgs(u,i.block);d.each(function(t){t.perform(p)}),u.perform(p)&&(r=!0)}else c&&c.each(function(s,l){a(i,s)&&s.dirty();var c=o.getPerformArgs(s,i.block);c.skip=!t.performRawSeries&&e.isSeriesFiltered(s.context.model),o.updatePayload(s,n),s.perform(c)&&(r=!0)})}}),this.unfinished=r||this.unfinished},t.prototype.performSeriesTasks=function(t){var e;t.eachSeries(function(t){e=t.dataTask.perform()||e}),this.unfinished=e||this.unfinished},t.prototype.plan=function(){this._pipelineMap.each(function(t){var e=t.tail;do{if(e.__block){t.blockIndex=e.__idxInPipeline;break}e=e.getUpstream()}while(e)})},t.prototype.updatePayload=function(t,e){"remain"!==e&&(t.context.payload=e)},t.prototype._createSeriesStageTask=function(t,e,n,i){var r=this,o=e.seriesTaskMap,a=e.seriesTaskMap=En(),s=t.seriesType,l=t.getTargetSeries;function c(e){var s=e.uid,l=a.set(s,o&&o.get(s)||wv({plan:Jm,reset:ty,count:iy}));l.context={model:e,ecModel:n,api:i,useClearVisual:t.isVisual&&!t.isLayout,plan:t.plan,reset:t.reset,scheduler:r},r._pipe(e,l)}t.createOnAllSeries?n.eachRawSeries(c):s?n.eachRawSeriesByType(s,c):l&&l(n,i).each(c)},t.prototype._createOverallStageTask=function(t,e,n,i){var r=this,o=e.overallTask=e.overallTask||wv({reset:Ym});o.context={ecModel:n,api:i,overallReset:t.overallReset,scheduler:r};var a=o.agentStubMap,s=o.agentStubMap=En(),l=t.seriesType,c=t.getTargetSeries,u=!0,h=!1;function d(t){var e=t.uid,n=s.set(e,a&&a.get(e)||(h=!0,wv({reset:Zm,onDirty:Qm})));n.context={model:t,overallProgress:u},n.agent=o,n.__block=u,r._pipe(t,n)}Mn(!t.createOnAllSeries,""),l?n.eachRawSeriesByType(l,d):c?c(n,i).each(d):(u=!1,en(n.getSeries(),d)),h&&o.dirty()},t.prototype._pipe=function(t,e){var n=t.uid,i=this._pipelineMap.get(n);!i.head&&(i.head=e),i.tail&&i.tail.pipe(e),i.tail=e,e.__idxInPipeline=i.count++,e.__pipeline=i},t.wrapStageHandler=function(t,e){return un(t)&&(t={overallReset:t,seriesType:ry(t)}),t.uid=bp("stageHandler"),e&&(t.visualType=e),t},t}();function Ym(t){t.overallReset(t.ecModel,t.api,t.payload)}function Zm(t){return t.overallProgress&&Km}function Km(){this.agent.dirty(),this.getDownstream().dirty()}function Qm(){this.agent&&this.agent.dirty()}function Jm(t){return t.plan?t.plan(t.model,t.ecModel,t.api,t.payload):null}function ty(t){t.useClearVisual&&t.data.clearAllVisual();var e=t.resetDefines=bs(t.reset(t.model,t.ecModel,t.api,t.payload));return e.length>1?nn(e,function(t,e){return ny(e)}):ey}var ey=ny(0);function ny(t){return function(e,n){var i=n.data,r=n.resetDefines[t];if(r&&r.dataEach)for(var o=e.start;o0&&u===r.length-c.length){var h=r.slice(0,u);"data"!==h&&(e.mainType=h,e[c.toLowerCase()]=t,s=!0)}}a.hasOwnProperty(r)&&(n[r]=t,s=!0),s||(i[r]=t)})}return{cptQuery:e,dataQuery:n,otherQuery:i}},t.prototype.filter=function(t,e){var n=this.eventInfo;if(!n)return!0;var i=n.targetEl,r=n.packedEvent,o=n.model,a=n.view;if(!o||!a)return!0;var s=e.cptQuery,l=e.dataQuery;return c(s,o,"mainType")&&c(s,o,"subType")&&c(s,o,"index","componentIndex")&&c(s,o,"name")&&c(s,o,"id")&&c(l,r,"name")&&c(l,r,"dataIndex")&&c(l,r,"dataType")&&(!a.filterForExposedEvent||a.filterForExposedEvent(t,e.otherQuery,i,r));function c(t,e,n,i){return null==t[n]||e[i||n]===t[n]}},t.prototype.afterTrigger=function(){this.eventInfo=null},t}(),vy=["symbol","symbolSize","symbolRotate","symbolOffset"],my=vy.concat(["symbolKeepAspect"]),yy={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){var n=t.getData();if(t.legendIcon&&n.setVisual("legendIcon",t.legendIcon),t.hasSymbolVisual){for(var i={},r={},o=!1,a=0;a=0&&Oy(l)?l:.5,t.createRadialGradient(a,s,0,a,s,l)}(t,e,n):function(t,e,n){var i=null==e.x?0:e.x,r=null==e.x2?1:e.x2,o=null==e.y?0:e.y,a=null==e.y2?0:e.y2;return e.global||(i=i*n.width+n.x,r=r*n.width+n.x,o=o*n.height+n.y,a=a*n.height+n.y),i=Oy(i)?i:0,r=Oy(r)?r:1,o=Oy(o)?o:0,a=Oy(a)?a:0,t.createLinearGradient(i,o,r,a)}(t,e,n),r=e.colorStops,o=0;o0&&(e=i.lineDash,n=i.lineWidth,e&&"solid"!==e&&n>0?"dashed"===e?[4*n,2*n]:"dotted"===e?[n]:pn(e)?[e]:cn(e)?e:null:null),o=i.lineDashOffset;if(r){var a=i.strokeNoScale&&t.getLineScale?t.getLineScale():1;a&&1!==a&&(r=nn(r,function(t){return t/a}),o/=a)}return[r,o]}var Fy=new ac(!0);function $y(t){var e=t.stroke;return!(null==e||"none"===e||!(t.lineWidth>0))}function Vy(t){return"string"==typeof t&&"none"!==t}function Wy(t){var e=t.fill;return null!=e&&"none"!==e}function Uy(t,e){if(null!=e.fillOpacity&&1!==e.fillOpacity){var n=t.globalAlpha;t.globalAlpha=e.fillOpacity*e.opacity,t.fill(),t.globalAlpha=n}else t.fill()}function Gy(t,e){if(null!=e.strokeOpacity&&1!==e.strokeOpacity){var n=t.globalAlpha;t.globalAlpha=e.strokeOpacity*e.opacity,t.stroke(),t.globalAlpha=n}else t.stroke()}function qy(t,e,n){var i=tl(e.image,e.__image,n);if(nl(i)){var r=t.createPattern(i,e.repeat||"repeat");if("function"==typeof DOMMatrix&&r&&r.setTransform){var o=new DOMMatrix;o.translateSelf(e.x||0,e.y||0),o.rotateSelf(0,0,(e.rotation||0)*Rn),o.scaleSelf(e.scaleX||1,e.scaleY||1),r.setTransform(o)}return r}}var jy=["shadowBlur","shadowOffsetX","shadowOffsetY"],Xy=[["lineCap","butt"],["lineJoin","miter"],["miterLimit",10]];function Yy(t,e,n,i,r){var o=!1;if(!i&&e===(n=n||{}))return!1;if(i||e.opacity!==n.opacity){Qy(t,r),o=!0;var a=Math.max(Math.min(e.opacity,1),0);t.globalAlpha=isNaN(a)?wl.opacity:a}(i||e.blend!==n.blend)&&(o||(Qy(t,r),o=!0),t.globalCompositeOperation=e.blend||wl.blend);for(var s=0;s0&&t.unfinished);t.unfinished||this._zr.flush()}}},e.prototype.getDom=function(){return this._dom},e.prototype.getId=function(){return this.id},e.prototype.getZr=function(){return this._zr},e.prototype.isSSR=function(){return this._ssr},e.prototype.setOption=function(t,e,n){if(!this[p_])if(this._disposed)this.id;else{var i,r,o;if(fn(e)&&(n=e.lazyUpdate,i=e.silent,r=e.replaceMerge,o=e.transition,e=e.notMerge),this[p_]=!0,F_(this),!this._model||e){var a=new Cg(this._api),s=this._theme,l=this._model=new mg;l.scheduler=this._scheduler,l.ssr=this._ssr,l.init(null,null,null,s,this._locale,a)}this._model.setOption(t,{replaceMerge:r},Z_);var c={seriesTransition:o,optionChanged:!0};if(n)this[g_]={silent:i,updateParams:c},this[p_]=!1,this.getZr().wakeUp();else{try{w_(this),M_.update.call(this,null,c)}catch(t){throw this[g_]=null,this[p_]=!1,t}this._ssr||this._zr.flush(),this[g_]=null,this[p_]=!1,I_.call(this,i),A_.call(this,i)}}},e.prototype.setTheme=function(t,e){if(!this[p_])if(this._disposed)this.id;else{var n=this._model;if(n){var i=e&&e.silent,r=null;this[g_]&&(null==i&&(i=this[g_].silent),r=this[g_].updateParams,this[g_]=null),this[p_]=!0,F_(this);try{this._updateTheme(t),n.setTheme(this._theme),w_(this),M_.update.call(this,{type:"setTheme"},r)}catch(t){throw this[p_]=!1,t}this[p_]=!1,I_.call(this,i),A_.call(this,i)}}},e.prototype._updateTheme=function(t){hn(t)&&(t=Q_[t]),t&&((t=Xe(t))&&Gg(t,!0),this._theme=t)},e.prototype.getModel=function(){return this._model},e.prototype.getOption=function(){return this._model&&this._model.getOption()},e.prototype.getWidth=function(){return this._zr.getWidth()},e.prototype.getHeight=function(){return this._zr.getHeight()},e.prototype.getDevicePixelRatio=function(){return this._zr.painter.dpr||Te.hasGlobalWindow&&window.devicePixelRatio||1},e.prototype.getRenderedCanvas=function(t){return this.renderToCanvas(t)},e.prototype.renderToCanvas=function(t){return t=t||{},this._zr.painter.getRenderedCanvas({backgroundColor:t.backgroundColor||this._model.get("backgroundColor"),pixelRatio:t.pixelRatio||this.getDevicePixelRatio()})},e.prototype.renderToSVGString=function(t){return t=t||{},this._zr.painter.renderToString({useViewBox:t.useViewBox})},e.prototype.getSvgDataURL=function(){var t=this._zr;return en(t.storage.getDisplayList(),function(t){t.stopAnimation(null,!0)}),t.painter.toDataURL()},e.prototype.getDataURL=function(t){if(!this._disposed){var e=(t=t||{}).excludeComponents,n=this._model,i=[],r=this;en(e,function(t){n.eachComponent({mainType:t},function(t){var e=r._componentsMap[t.__viewId];e.group.ignore||(i.push(e),e.group.ignore=!0)})});var o="svg"===this._zr.painter.getType()?this.getSvgDataURL():this.renderToCanvas(t).toDataURL("image/"+(t&&t.type||"png"));return en(i,function(t){t.group.ignore=!1}),o}this.id},e.prototype.getConnectedDataURL=function(t){if(!this._disposed){var e="svg"===t.type,n=this.group,i=Math.min,r=Math.max,o=1/0;if(ex[n]){var a=o,s=o,l=-1/0,c=-1/0,u=[],h=t&&t.pixelRatio||this.getDevicePixelRatio();en(tx,function(o,h){if(o.group===n){var d=e?o.getZr().painter.getSvgDom().innerHTML:o.renderToCanvas(Xe(t)),p=o.getDom().getBoundingClientRect();a=i(p.left,a),s=i(p.top,s),l=r(p.right,l),c=r(p.bottom,c),u.push({dom:d,left:p.left,top:p.top})}});var d=(l*=h)-(a*=h),p=(c*=h)-(s*=h),f=Ee.createCanvas(),g=Ya(f,{renderer:e?"svg":"canvas"});if(g.resize({width:d,height:p}),e){var v="";return en(u,function(t){var e=t.left-a,n=t.top-s;v+=''+t.dom+""}),g.painter.getSvgRoot().innerHTML=v,t.connectedBackgroundColor&&g.painter.setBackgroundColor(t.connectedBackgroundColor),g.refreshImmediately(),g.painter.toDataURL()}return t.connectedBackgroundColor&&g.add(new Bc({shape:{x:0,y:0,width:d,height:p},style:{fill:t.connectedBackgroundColor}})),en(u,function(t){var e=new Lc({style:{x:t.left*h-a,y:t.top*h-s,image:t.dom}});g.add(e)}),g.refreshImmediately(),f.toDataURL("image/"+(t&&t.type||"png"))}return this.getDataURL(t)}this.id},e.prototype.convertToPixel=function(t,e,n){return k_(this,"convertToPixel",t,e,n)},e.prototype.convertToLayout=function(t,e,n){return k_(this,"convertToLayout",t,e,n)},e.prototype.convertFromPixel=function(t,e,n){return k_(this,"convertFromPixel",t,e,n)},e.prototype.containPixel=function(t,e){var n;if(!this._disposed)return en(Os(this._model,t),function(t,i){i.indexOf("Models")>=0&&en(t,function(t){var r=t.coordinateSystem;if(r&&r.containPoint)n=n||!!r.containPoint(e);else if("seriesModels"===i){var o=this._chartsMap[t.__viewId];o&&o.containPoint&&(n=n||o.containPoint(e,t))}},this)},this),!!n;this.id},e.prototype.getVisual=function(t,e){var n=Os(this._model,t,{defaultMainType:"series"}),i=n.seriesModel.getData(),r=n.hasOwnProperty("dataIndexInside")?n.dataIndexInside:n.hasOwnProperty("dataIndex")?i.indexOfRawIndex(n.dataIndex):null;return null!=r?function(t,e,n){switch(n){case"color":return t.getItemVisual(e,"style")[t.getVisual("drawType")];case"opacity":return t.getItemVisual(e,"style").opacity;case"symbol":case"symbolSize":case"liftZ":return t.getItemVisual(e,n)}}(i,r,e):function(t,e){switch(e){case"color":return t.getVisual("style")[t.getVisual("drawType")];case"opacity":return t.getVisual("style").opacity;case"symbol":case"symbolSize":case"liftZ":return t.getVisual(e)}}(i,e)},e.prototype.getViewOfComponentModel=function(t){return this._componentsMap[t.__viewId]},e.prototype.getViewOfSeriesModel=function(t){return this._chartsMap[t.__viewId]},e.prototype._initEvents=function(){var t=this;en(G_,function(e){var n=function(n){var i,r=t.getModel(),o=n.target;if("globalout"===e?i={}:o&&by(o,function(t){var e=nu(t);if(e&&null!=e.dataIndex){var n=e.dataModel||r.getSeriesByIndex(e.seriesIndex);return i=n&&n.getDataParams(e.dataIndex,e.dataType,o)||{},!0}if(e.eventData)return i=Ze({},e.eventData),!0},!0),i){var a=i.componentType,s=i.componentIndex;"markLine"!==a&&"markPoint"!==a&&"markArea"!==a||(a="series",s=i.seriesIndex);var l=a&&null!=s&&r.getComponent(a,s),c=l&&t["series"===l.mainType?"_chartsMap":"_componentsMap"][l.__viewId];i.event=n,i.type=e,t._$eventProcessor.eventInfo={targetEl:o,packedEvent:i,model:l,view:c},t.trigger(e,i)}};n.zrEventfulCallAtLast=!0,t._zr.on(e,n,t)});var e=this._messageCenter;en(X_,function(n,i){e.on(i,function(e){t.trigger(i,e)})}),function(t,e,n){t.on("selectchanged",function(t){var i=n.getModel();t.isFromClick?(xy("map","selectchanged",e,i,t),xy("pie","selectchanged",e,i,t)):"select"===t.fromAction?(xy("map","selected",e,i,t),xy("pie","selected",e,i,t)):"unselect"===t.fromAction&&(xy("map","unselected",e,i,t),xy("pie","unselected",e,i,t))})}(e,this,this._api)},e.prototype.isDisposed=function(){return this._disposed},e.prototype.clear=function(){this._disposed?this.id:this.setOption({series:[]},!0)},e.prototype.dispose=function(){if(this._disposed)this.id;else{this._disposed=!0,this.getDom()&&Bs(this.getDom(),ix,"");var t=this,e=t._api,n=t._model;en(t._componentsViews,function(t){t.dispose(n,e)}),en(t._chartsViews,function(t){t.dispose(n,e)}),t._zr.dispose(),t._dom=t._model=t._chartsMap=t._componentsMap=t._chartsViews=t._componentsViews=t._scheduler=t._api=t._zr=t._throttledZrFlush=t._theme=t._coordSysMgr=t._messageCenter=null,delete tx[t.id]}},e.prototype.resize=function(t){if(!this[p_])if(this._disposed)this.id;else{this._zr.resize(t);var e=this._model;if(this._loadingFX&&this._loadingFX.resize(),e){var n=e.resetOption("media"),i=t&&t.silent;this[g_]&&(null==i&&(i=this[g_].silent),n=!0,this[g_]=null),this[p_]=!0,F_(this);try{n&&w_(this),M_.update.call(this,{type:"resize",animation:Ze({duration:0},t&&t.animation)})}catch(t){throw this[p_]=!1,t}this[p_]=!1,I_.call(this,i),A_.call(this,i)}}},e.prototype.showLoading=function(t,e){if(this._disposed)this.id;else if(fn(t)&&(e=t,t=""),t=t||"default",this.hideLoading(),J_[t]){var n=J_[t](this._api,e),i=this._zr;this._loadingFX=n,i.add(n)}},e.prototype.hideLoading=function(){this._disposed?this.id:(this._loadingFX&&this._zr.remove(this._loadingFX),this._loadingFX=null)},e.prototype.makeActionFromEvent=function(t){var e=Ze({},t);return e.type=j_[t.type],e},e.prototype.dispatchAction=function(t,e){if(this._disposed)this.id;else if(fn(e)||(e={silent:!!e}),q_[t.type]&&this._model)if(this[p_])this._pendingActions.push(t);else{var n=e.silent;D_.call(this,t,n);var i=e.flush;i?this._zr.flush():!1!==i&&Te.browser.weChat&&this._throttledZrFlush(),I_.call(this,n),A_.call(this,n)}},e.prototype.updateLabelLayout=function(){l_.trigger("series:layoutlabels",this._model,this._api,{updatedSeries:[]})},e.prototype.appendData=function(t){if(this._disposed)this.id;else{var e=t.seriesIndex;this.getModel().getSeriesByIndex(e).appendData(t),this._scheduler.unfinished=!0,this.getZr().wakeUp()}},e.internalField=function(){function t(t){t.clearColorPalette(),t.eachSeries(function(t){t.clearColorPalette()})}function e(t){for(var e=[],n=t.currentStates,i=0;i0?{duration:o,delay:i.get("delay"),easing:i.get("easing")}:null;n.eachRendered(function(t){if(t.states&&t.states.emphasis){if(fd(t))return;if(t instanceof Tc&&function(t){var e=ou(t);e.normalFill=t.style.fill,e.normalStroke=t.style.stroke;var n=t.states.select||{};e.selectFill=n.style&&n.style.fill||null,e.selectStroke=n.style&&n.style.stroke||null}(t),t.__dirty){var n=t.prevStates;n&&t.useStates(n)}if(r){t.stateTransition=a;var i=t.getTextContent(),o=t.getTextGuideLine();i&&(i.stateTransition=a),o&&(o.stateTransition=a)}t.__dirty&&e(t)}})}w_=function(t){var e=t._scheduler;e.restorePipelines(t._model),e.prepareStageTasks(),S_(t,!0),S_(t,!1),e.plan()},S_=function(t,e){for(var n=t._model,i=t._scheduler,r=e?t._componentsViews:t._chartsViews,o=e?t._componentsMap:t._chartsMap,a=t._zr,s=t._api,l=0;le.get("hoverLayerThreshold")&&!Te.node&&!Te.worker&&e.eachSeries(function(e){if(!e.preventUsingHoverLayer){var n=t._chartsMap[e.__viewId];n.__alive&&n.eachRendered(function(t){t.states.emphasis&&(t.states.emphasis.hoverLayer=!0)})}})}(t,e),l_.trigger("series:afterupdate",e,o,s)},H_=function(t){t[v_]=!0,t.getZr().wakeUp()},F_=function(t){t[f_]=(t[f_]+1)%1e3},B_=function(t){t[v_]&&(t.getZr().storage.traverse(function(t){fd(t)||e(t)}),t[v_]=!1)},N_=function(t){return new(function(e){function n(){return null!==e&&e.apply(this,arguments)||this}return _(n,e),n.prototype.getCoordinateSystems=function(){return t._coordSysMgr.getCoordinateSystems()},n.prototype.getComponentByElement=function(e){for(;e;){var n=e.__ecComponentInfo;if(null!=n)return t._model.getComponent(n.mainType,n.index);e=e.parent}},n.prototype.enterEmphasis=function(e,n){Au(e,n),H_(t)},n.prototype.leaveEmphasis=function(e,n){Pu(e,n),H_(t)},n.prototype.enterBlur=function(e){!function(t){Cu(t,_u)}(e),H_(t)},n.prototype.leaveBlur=function(e){Lu(e),H_(t)},n.prototype.enterSelect=function(e){Eu(e),H_(t)},n.prototype.leaveSelect=function(e){zu(e),H_(t)},n.prototype.getModel=function(){return t.getModel()},n.prototype.getViewOfComponentModel=function(e){return t.getViewOfComponentModel(e)},n.prototype.getViewOfSeriesModel=function(e){return t.getViewOfSeriesModel(e)},n.prototype.getMainProcessVersion=function(){return t[f_]},n}(wg))(t)},R_=function(t){function e(t,e){for(var n=0;n=0)){hx.push(n);var o=Xm.wrapStageHandler(n,r);o.__prio=e,o.__raw=n,t.push(o)}}function px(t,e){J_[t]=e}var fx=function(t){var e=(t=Xe(t)).type;e||ys("");var n=e.split(":");2!==n.length&&ys("");var i=!1;"echarts"===n[0]&&(e=n[1],i=!0),t.__isBuiltIn=i,Lv.set(e,t)};function gx(t,e,n,i){return{eventContent:{selected:$u(n),isFromClick:e.isFromClick||!1}}}function vx(t){return null==t?0:t.length||1}function mx(t){return t}ux(u_,Wm),ux(h_,Gm),ux(h_,qm),ux(u_,yy),ux(h_,_y),ux(7e3,function(t,e){t.eachRawSeries(function(n){if(!t.isSeriesFiltered(n)){var i=n.getData();i.hasItemVisual()&&i.each(function(t){var n=i.getItemVisual(t,"decal");n&&(i.ensureUniqueItemVisual(t,"style").decal=r_(n,e))});var r=i.getVisual("decal");if(r)i.getVisual("style").decal=r_(r,e)}})}),ax(Gg),sx(900,function(t){var e=En();t.eachSeries(function(t){var n=t.get("stack");if(n){var i=e.get(n)||e.set(n,[]),r=t.getData(),o={stackResultDimension:r.getCalculationInfo("stackResultDimension"),stackedOverDimension:r.getCalculationInfo("stackedOverDimension"),stackedDimension:r.getCalculationInfo("stackedDimension"),stackedByDimension:r.getCalculationInfo("stackedByDimension"),isStackedByIndex:r.getCalculationInfo("isStackedByIndex"),data:r,seriesModel:t};if(!o.stackedDimension||!o.isStackedByIndex&&!o.stackedByDimension)return;i.push(o)}}),e.each(function(t){0!==t.length&&("seriesDesc"===(t[0].seriesModel.get("stackOrder")||"seriesAsc")&&t.reverse(),en(t,function(e,n){e.data.setCalculationInfo("stackedOnSeries",n>0?t[n-1].seriesModel:null)}),function(t){en(t,function(e,n){var i=[],r=[NaN,NaN],o=[e.stackResultDimension,e.stackedOverDimension],a=e.data,s=e.isStackedByIndex,l=e.seriesModel.get("stackStrategy")||"samesign";a.modify(o,function(o,c,u){var h,d,p=a.get(e.stackedDimension,u);if(isNaN(p))return r;s?d=a.getRawIndex(u):h=a.get(e.stackedByDimension,u);for(var f=NaN,g=n-1;g>=0;g--){var v=t[g];if(s||(d=v.data.rawIndexOf(v.stackedByDimension,h)),d>=0){var m=v.data.getByRawIndex(v.stackResultDimension,d);if("all"===l||"positive"===l&&m>0||"negative"===l&&m<0||"samesign"===l&&p>=0&&m>0||"samesign"===l&&p<=0&&m<0){p=os(p,m),f=m;break}}}return i[0]=p,i[1]=f,i})})}(t))})}),px("default",function(t,e){Ke(e=e||{},{text:"loading",textColor:Bf.color.primary,fontSize:12,fontWeight:"normal",fontStyle:"normal",fontFamily:"sans-serif",maskColor:"rgba(255,255,255,0.8)",showSpinner:!0,color:Bf.color.theme[0],spinnerRadius:10,lineWidth:5,zlevel:0});var n=new Ua,i=new Bc({style:{fill:e.maskColor},zlevel:e.zlevel,z:1e4});n.add(i);var r,o=new Wc({style:{text:e.text,fill:e.textColor,fontSize:e.fontSize,fontWeight:e.fontWeight,fontStyle:e.fontStyle,fontFamily:e.fontFamily},zlevel:e.zlevel,z:10001}),a=new Bc({style:{fill:"none"},textContent:o,textConfig:{position:"right",distance:10},zlevel:e.zlevel,z:10001});return n.add(a),e.showSpinner&&((r=new Xh({shape:{startAngle:-jm/2,endAngle:-jm/2+.1,r:e.spinnerRadius},style:{stroke:e.color,lineCap:"round",lineWidth:e.lineWidth},zlevel:e.zlevel,z:10001})).animateShape(!0).when(1e3,{endAngle:3*jm/2}).start("circularInOut"),r.animateShape(!0).when(1e3,{startAngle:3*jm/2}).delay(300).start("circularInOut"),n.add(r)),n.resize=function(){var n=o.getBoundingRect().width,s=e.showSpinner?e.spinnerRadius:0,l=(t.getWidth()-2*s-(e.showSpinner&&n?10:0)-n)/2-(e.showSpinner&&n?0:5+n/2)+(e.showSpinner?0:n/2)+(n?0:s),c=t.getHeight()/2;e.showSpinner&&r.setShape({cx:l,cy:c}),a.setShape({x:l-s,y:c-s,width:2*s,height:2*s}),i.setShape({x:0,y:0,width:t.getWidth(),height:t.getHeight()})},n.resize(),n}),cx({type:cu,event:cu,update:cu},Nn),cx({type:uu,event:uu,update:uu},Nn),cx({type:hu,event:fu,update:hu,action:Nn,refineEvent:gx,publishNonRefinedEvent:!0}),cx({type:du,event:fu,update:du,action:Nn,refineEvent:gx,publishNonRefinedEvent:!0}),cx({type:pu,event:fu,update:pu,action:Nn,refineEvent:gx,publishNonRefinedEvent:!0}),ox("default",{}),ox("dark",fy);var yx=function(){function t(t,e,n,i,r,o){this._old=t,this._new=e,this._oldKeyGetter=n||mx,this._newKeyGetter=i||mx,this.context=r,this._diffModeMultiple="multiple"===o}return t.prototype.add=function(t){return this._add=t,this},t.prototype.update=function(t){return this._update=t,this},t.prototype.updateManyToOne=function(t){return this._updateManyToOne=t,this},t.prototype.updateOneToMany=function(t){return this._updateOneToMany=t,this},t.prototype.updateManyToMany=function(t){return this._updateManyToMany=t,this},t.prototype.remove=function(t){return this._remove=t,this},t.prototype.execute=function(){this[this._diffModeMultiple?"_executeMultiple":"_executeOneToOne"]()},t.prototype._executeOneToOne=function(){var t=this._old,e=this._new,n={},i=new Array(t.length),r=new Array(e.length);this._initIndexMap(t,null,i,"_oldKeyGetter"),this._initIndexMap(e,n,r,"_newKeyGetter");for(var o=0;o1){var c=s.shift();1===s.length&&(n[a]=s[0]),this._update&&this._update(c,o)}else 1===l?(n[a]=null,this._update&&this._update(s,o)):this._remove&&this._remove(o)}this._performRestAdd(r,n)},t.prototype._executeMultiple=function(){var t=this._old,e=this._new,n={},i={},r=[],o=[];this._initIndexMap(t,n,r,"_oldKeyGetter"),this._initIndexMap(e,i,o,"_newKeyGetter");for(var a=0;a1&&1===h)this._updateManyToOne&&this._updateManyToOne(c,l),i[s]=null;else if(1===u&&h>1)this._updateOneToMany&&this._updateOneToMany(c,l),i[s]=null;else if(1===u&&1===h)this._update&&this._update(c,l),i[s]=null;else if(u>1&&h>1)this._updateManyToMany&&this._updateManyToMany(c,l),i[s]=null;else if(u>1)for(var d=0;d1)for(var a=0;a30}var Ax,Px,Lx,Ex,zx,Ox,Nx,Rx=fn,Hx=nn,Bx="undefined"==typeof Int32Array?Array:Int32Array,Fx=["hasItemOption","_nameList","_idList","_invertedIndicesMap","_dimSummary","userOutput","_rawData","_dimValueGetter","_nameDimIdx","_idDimIdx","_nameRepeatCount"],$x=["_approximateExtent"],Vx=function(){function t(t,e){var n;this.type="list",this._dimOmitted=!1,this._nameList=[],this._idList=[],this._visual={},this._layout={},this._itemVisuals=[],this._itemLayouts=[],this._graphicEls=[],this._approximateExtent={},this._calculationInfo={},this.hasItemOption=!1,this.TRANSFERABLE_METHODS=["cloneShallow","downSample","minmaxDownSample","lttbDownSample","map"],this.CHANGABLE_METHODS=["filterSelf","selectRange"],this.DOWNSAMPLE_METHODS=["downSample","minmaxDownSample","lttbDownSample"];var i=!1;kx(t)?(n=t.dimensions,this._dimOmitted=t.isDimensionOmitted(),this._schema=t):(i=!0,n=t),n=n||["x","y"];for(var r={},o=[],a={},s=!1,l={},c=0;c=e)){var n=this._store.getProvider();this._updateOrdinalMeta();var i=this._nameList,r=this._idList;if(n.getSource().sourceFormat===Yf&&!n.pure)for(var o=[],a=t;a0},t.prototype.ensureUniqueItemVisual=function(t,e){var n=this._itemVisuals,i=n[t];i||(i=n[t]={});var r=i[e];return null==r&&(cn(r=this.getVisual(e))?r=r.slice():Rx(r)&&(r=Ze({},r)),i[e]=r),r},t.prototype.setItemVisual=function(t,e,n){var i=this._itemVisuals[t]||{};this._itemVisuals[t]=i,Rx(e)?Ze(i,e):i[e]=n},t.prototype.clearAllVisual=function(){this._visual={},this._itemVisuals=[]},t.prototype.setLayout=function(t,e){Rx(t)?Ze(this._layout,t):this._layout[t]=e},t.prototype.getLayout=function(t){return this._layout[t]},t.prototype.getItemLayout=function(t){return this._itemLayouts[t]},t.prototype.setItemLayout=function(t,e,n){this._itemLayouts[t]=n?Ze(this._itemLayouts[t]||{},e):e},t.prototype.clearItemLayouts=function(){this._itemLayouts.length=0},t.prototype.setItemGraphicEl=function(t,e){!function(t,e,n,i){if(i){var r=nu(i);r.dataIndex=n,r.dataType=e,r.seriesIndex=t,r.ssrType="chart","group"===i.type&&i.traverse(function(i){var r=nu(i);r.seriesIndex=t,r.dataIndex=n,r.dataType=e,r.ssrType="chart"})}}(this.hostModel&&this.hostModel.seriesIndex,this.dataType,t,e),this._graphicEls[t]=e},t.prototype.getItemGraphicEl=function(t){return this._graphicEls[t]},t.prototype.eachItemGraphicEl=function(t,e){en(this._graphicEls,function(n,i){n&&t&&t.call(e,n,i)})},t.prototype.cloneShallow=function(e){return e||(e=new t(this._schema?this._schema:Hx(this.dimensions,this._getDimInfo,this),this.hostModel)),zx(e,this),e._store=this._store,e},t.prototype.wrapMethod=function(t,e){var n=this[t];un(n)&&(this.__wrappedMethods=this.__wrappedMethods||[],this.__wrappedMethods.push(t),this[t]=function(){var t=n.apply(this,arguments);return e.apply(this,[t].concat(Sn(arguments)))})},t.internalField=(Ax=function(t){var e=t._invertedIndicesMap;en(e,function(n,i){var r=t._dimInfos[i],o=r.ordinalMeta,a=t._store;if(o){n=e[i]=new Bx(o.categories.length);for(var s=0;s1&&(s+="__ec__"+c),i[e]=s}})),t}();function Wx(t,e){Jg(t)||(t=ev(t));var n=(e=e||{}).coordDimensions||[],i=e.dimensionsDefine||t.dimensionsDefine||[],r=En(),o=[],a=function(t,e,n,i){var r=Math.max(t.dimensionsDetectedCount||1,e.length,n.length,i||0);return en(e,function(t){var e;fn(t)&&(e=t.dimsDef)&&(r=Math.max(r,e.length))}),r}(t,n,i,e.dimensionsCount),s=e.canOmitUnusedDimensions&&Ix(a),l=i===t.dimensionsDefine,c=l?Dx(t):Tx(i),u=e.encodeDefine;!u&&e.encodeDefaulter&&(u=e.encodeDefaulter(t,a));for(var h=En(u),d=new Bv(a),p=0;p0&&(i.name=r+(o-1)),o++,e.set(r,o)}}(o),new Mx({source:t,dimensions:o,fullDimensionCount:a,dimensionOmitted:s})}function Ux(t,e,n){if(n||e.hasKey(t)){for(var i=0;e.hasKey(t+i);)i++;t+=i}return e.set(t,!0),t}var Gx=function(t){this.coordSysDims=[],this.axisMap=En(),this.categoryAxisMap=En(),this.coordSysName=t};var qx={cartesian2d:function(t,e,n,i){var r=t.getReferringComponents("xAxis",Rs).models[0],o=t.getReferringComponents("yAxis",Rs).models[0];e.coordSysDims=["x","y"],n.set("x",r),n.set("y",o),jx(r)&&(i.set("x",r),e.firstCategoryDimIndex=0),jx(o)&&(i.set("y",o),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=1))},singleAxis:function(t,e,n,i){var r=t.getReferringComponents("singleAxis",Rs).models[0];e.coordSysDims=["single"],n.set("single",r),jx(r)&&(i.set("single",r),e.firstCategoryDimIndex=0)},polar:function(t,e,n,i){var r=t.getReferringComponents("polar",Rs).models[0],o=r.findAxisModel("radiusAxis"),a=r.findAxisModel("angleAxis");e.coordSysDims=["radius","angle"],n.set("radius",o),n.set("angle",a),jx(o)&&(i.set("radius",o),e.firstCategoryDimIndex=0),jx(a)&&(i.set("angle",a),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=1))},geo:function(t,e,n,i){e.coordSysDims=["lng","lat"]},parallel:function(t,e,n,i){var r=t.ecModel,o=r.getComponent("parallel",t.get("parallelIndex")),a=e.coordSysDims=o.dimensions.slice();en(o.parallelAxisIndex,function(t,o){var s=r.getComponent("parallelAxis",t),l=a[o];n.set(l,s),jx(s)&&(i.set(l,s),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=o))})},matrix:function(t,e,n,i){var r=t.getReferringComponents("matrix",Rs).models[0];e.coordSysDims=["x","y"];var o=r.getDimensionModel("x"),a=r.getDimensionModel("y");n.set("x",o),n.set("y",a),i.set("x",o),i.set("y",a)}};function jx(t){return"category"===t.get("type")}function Xx(t,e,n){var i,r,o,a=(n=n||{}).byIndex,s=n.stackedCoordDimension;!function(t){return!kx(t.schema)}(e)?(r=e.schema,i=r.dimensions,o=e.store):i=e;var l,c,u,h,d=!(!t||!t.get("stack"));if(en(i,function(t,e){hn(t)&&(i[e]=t={name:t}),d&&!t.isExtraCoord&&(a||l||!t.ordinalMeta||(l=t),c||"ordinal"===t.type||"time"===t.type||s&&s!==t.coordDim||(c=t))}),!c||a||l||(a=!0),c){u="__\0ecstackresult_"+t.id,h="__\0ecstackedover_"+t.id,l&&(l.createInvertedIndices=!0);var p=c.coordDim,f=c.type,g=0;en(i,function(t){t.coordDim===p&&g++});var v={name:u,coordDim:p,coordDimIndex:g,type:f,isExtraCoord:!0,isCalculationCoord:!0,storeDimIndex:i.length},m={name:h,coordDim:h,coordDimIndex:g+1,type:f,isExtraCoord:!0,isCalculationCoord:!0,storeDimIndex:i.length+1};r?(o&&(v.storeDimIndex=o.ensureCalculationDimension(h,f),m.storeDimIndex=o.ensureCalculationDimension(u,f)),r.appendCalculationDimension(v),r.appendCalculationDimension(m)):(i.push(v),i.push(m))}return{stackedDimension:c&&c.name,stackedByDimension:l&&l.name,isStackedByIndex:a,stackedOverDimension:h,stackResultDimension:u}}function Yx(t,e){return!!e&&e===t.getCalculationInfo("stackedDimension")}function Zx(t,e,n){n=n||{};var i,r,o=e.getSourceManager();r=(i=o.getSource()).sourceFormat===Yf;var a=function(t){var e=t.get("coordinateSystem"),n=new Gx(e),i=qx[e];if(i)return i(t,n,n.axisMap,n.categoryAxisMap),n}(e),s=function(t,e){var n,i=t.get("coordinateSystem"),r=_f.get(i);return e&&e.coordSysDims&&(n=nn(e.coordSysDims,function(t){var n={name:t},i=e.axisMap.get(t);if(i){var r=i.get("type");n.type=function(t){return"category"===t?"ordinal":"time"===t?"time":"float"}(r)}return n})),n||(n=r&&(r.getDimensionsInfo?r.getDimensionsInfo():r.dimensions.slice())||["x","y"]),n}(e,a),l=n.useEncodeDefaulter,c=un(l)?l:l?ln(sg,s,e):null,u=Wx(i,{coordDimensions:s,generateCoord:n.generateCoord,encodeDefine:e.getEncode(),encodeDefaulter:c,canOmitUnusedDimensions:!r}),h=function(t,e,n){var i,r;return n&&en(t,function(t,o){var a=t.coordDim,s=n.categoryAxisMap.get(a);s&&(null==i&&(i=o),t.ordinalMeta=s.getOrdinalMeta(),e&&(t.createInvertedIndices=!0)),null!=t.otherDims.itemName&&(r=!0)}),r||null==i||(t[i].otherDims.itemName=0),i}(u.dimensions,n.createInvertedIndices,a),d=r?null:o.getSharedDataStore(u),p=Xx(e,{schema:u,store:d}),f=new Vx(u,e);f.setCalculationInfo(p);var g=null!=h&&function(t){if(t.sourceFormat===Yf){var e=function(t){var e=0;for(;er&&(a=o.interval=r);var s=o.intervalPrecision=tb(a);return function(t,e){!isFinite(t[0])&&(t[0]=e[0]),!isFinite(t[1])&&(t[1]=e[1]),eb(t,0,e),eb(t,1,e),t[0]>t[1]&&(t[0]=t[1])}(o.niceTickExtent=[is(Math.ceil(t[0]/a)*a,s),is(Math.floor(t[1]/a)*a,s)],t),o}function Jx(t){var e=Math.pow(10,us(t)),n=t/e;return n?2===n?n=3:3===n?n=5:n*=2:n=1,is(n*e)}function tb(t){return rs(t)+2}function eb(t,e,n){t[e]=Math.max(Math.min(t[e],n[1]),n[0])}function nb(t,e){return t>=e[0]&&t<=e[1]}var ib=function(){function t(){this.normalize=rb,this.scale=ob}return t.prototype.updateMethods=function(t){t.hasBreaks()?(this.normalize=sn(t.normalize,t),this.scale=sn(t.scale,t)):(this.normalize=rb,this.scale=ob)},t}();function rb(t,e){return e[1]===e[0]?.5:(t-e[0])/(e[1]-e[0])}function ob(t,e){return t*(e[1]-e[0])+e[0]}function ab(t,e,n){var i=Math.log(t);return[Math.log(n?e[0]:Math.max(0,e[0]))/i,Math.log(n?e[1]:Math.max(0,e[1]))/i]}var sb=function(){function t(t){this._calculator=new ib,this._setting=t||{},this._extent=[1/0,-1/0]}return t.prototype.getSetting=function(t){return this._setting[t]},t.prototype._innerUnionExtent=function(t){var e=this._extent;this._innerSetExtent(t[0]e[1]?t[1]:e[1])},t.prototype.unionExtentFromData=function(t,e){this._innerUnionExtent(t.getApproximateExtent(e))},t.prototype.getExtent=function(){return this._extent.slice()},t.prototype.setExtent=function(t,e){this._innerSetExtent(t,e)},t.prototype._innerSetExtent=function(t,e){var n=this._extent;isNaN(t)||(n[0]=t),isNaN(e)||(n[1]=e),this._brkCtx&&this._brkCtx.update(n)},t.prototype.setBreaksFromOption=function(t){},t.prototype._innerSetBreak=function(t){this._brkCtx&&(this._brkCtx.setBreaks(t),this._calculator.updateMethods(this._brkCtx),this._brkCtx.update(this._extent))},t.prototype._innerGetBreaks=function(){return this._brkCtx?this._brkCtx.breaks:[]},t.prototype.hasBreaks=function(){return!!this._brkCtx&&this._brkCtx.hasBreaks()},t.prototype._getExtentSpanWithBreaks=function(){return this._brkCtx&&this._brkCtx.hasBreaks()?this._brkCtx.getExtentSpan():this._extent[1]-this._extent[0]},t.prototype.isInExtentRange=function(t){return this._extent[0]<=t&&this._extent[1]>=t},t.prototype.isBlank=function(){return this._isBlank},t.prototype.setBlank=function(t){this._isBlank=t},t}();Xs(sb);var lb=0,cb=function(){function t(t){this.categories=t.categories||[],this._needCollect=t.needCollect,this._deduplication=t.deduplication,this.uid=++lb,this._onCollect=t.onCollect}return t.createByAxisModel=function(e){var n=e.option,i=n.data,r=i&&nn(i,ub);return new t({categories:r,needCollect:!r,deduplication:!1!==n.dedplication})},t.prototype.getOrdinal=function(t){return this._getOrCreateMap().get(t)},t.prototype.parseAndCollect=function(t){var e,n=this._needCollect;if(!hn(t)&&!n)return t;if(n&&!this._deduplication)return e=this.categories.length,this.categories[e]=t,this._onCollect&&this._onCollect(t,e),e;var i=this._getOrCreateMap();return null==(e=i.get(t))&&(n?(e=this.categories.length,this.categories[e]=t,i.set(t,e),this._onCollect&&this._onCollect(t,e)):e=NaN),e},t.prototype._getOrCreateMap=function(){return this._map||(this._map=En(this.categories))},t}();function ub(t){return fn(t)&&null!=t.value?t.value:t+""}var hb=function(t){function e(e){var n=t.call(this,e)||this;n.type="ordinal";var i=n.getSetting("ordinalMeta");return i||(i=new cb({})),cn(i)&&(i=new cb({categories:nn(i,function(t){return fn(t)?t.value:t})})),n._ordinalMeta=i,n._extent=n.getSetting("extent")||[0,i.categories.length-1],n}return _(e,t),e.prototype.parse=function(t){return null==t?NaN:hn(t)?this._ordinalMeta.getOrdinal(t):Math.round(t)},e.prototype.contain=function(t){return nb(t,this._extent)&&t>=0&&t=0&&t=0&&t=t},e.prototype.getOrdinalMeta=function(){return this._ordinalMeta},e.prototype.calcNiceTicks=function(){},e.prototype.calcNiceExtent=function(){},e.type="ordinal",e}(sb);sb.registerClass(hb);var db=is,pb=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.type="interval",e._interval=0,e._intervalPrecision=2,e}return _(e,t),e.prototype.parse=function(t){return null==t||""===t?NaN:Number(t)},e.prototype.contain=function(t){return nb(t,this._extent)},e.prototype.normalize=function(t){return this._calculator.normalize(t,this._extent)},e.prototype.scale=function(t){return this._calculator.scale(t,this._extent)},e.prototype.getInterval=function(){return this._interval},e.prototype.setInterval=function(t){this._interval=t,this._niceExtent=this._extent.slice(),this._intervalPrecision=tb(t)},e.prototype.getTicks=function(t){t=t||{};var e=this._interval,n=this._extent,i=this._niceExtent,r=this._intervalPrecision,o=[];if(!e)return o;t.breakTicks;n[0]=0&&(s=db(s+l*e,r))}if(o.length>0&&s===o[o.length-1].value)break;if(o.length>1e4)return[]}var c=o.length?o[o.length-1].value:i[1];return n[1]>c&&(t.expandToNicedExtent?o.push({value:db(c+e,r)}):o.push({value:n[1]})),t.breakTicks,o},e.prototype.getMinorTicks=function(t){for(var e=this.getTicks({expandToNicedExtent:!0}),n=[],i=this.getExtent(),r=1;ri[0]&&h0&&(o=null===o?s:Math.min(o,s))}n[i]=o}}return n}(t),n=[];return en(t,function(t){var i,r=t.coordinateSystem.getBaseAxis(),o=r.getExtent();if("category"===r.type)i=r.getBandWidth();else if("value"===r.type||"time"===r.type){var a=r.dim+"_"+r.index,s=e[a],l=Math.abs(o[1]-o[0]),c=r.scale.getExtent(),u=Math.abs(c[1]-c[0]);i=s?l/u*s:l}else{var h=t.getData();i=Math.abs(o[1]-o[0])/h.count()}var d=es(t.get("barWidth"),i),p=es(t.get("barMaxWidth"),i),f=es(t.get("barMinWidth")||(function(t){return t.pipelineContext&&t.pipelineContext.large}(t)?.5:1),i),g=t.get("barGap"),v=t.get("barCategoryGap"),m=t.get("defaultBarGap");n.push({bandWidth:i,barWidth:d,barMaxWidth:p,barMinWidth:f,barGap:g,barCategoryGap:v,defaultBarGap:m,axisKey:yb(r),stackId:mb(t)})}),function(t){var e={};en(t,function(t,n){var i=t.axisKey,r=t.bandWidth,o=e[i]||{bandWidth:r,remainedWidth:r,autoWidthCount:0,categoryGap:null,gap:t.defaultBarGap||0,stacks:{}},a=o.stacks;e[i]=o;var s=t.stackId;a[s]||o.autoWidthCount++,a[s]=a[s]||{width:0,maxWidth:0};var l=t.barWidth;l&&!a[s].width&&(a[s].width=l,l=Math.min(o.remainedWidth,l),o.remainedWidth-=l);var c=t.barMaxWidth;c&&(a[s].maxWidth=c);var u=t.barMinWidth;u&&(a[s].minWidth=u);var h=t.barGap;null!=h&&(o.gap=h);var d=t.barCategoryGap;null!=d&&(o.categoryGap=d)});var n={};return en(e,function(t,e){n[e]={};var i=t.stacks,r=t.bandWidth,o=t.categoryGap;if(null==o){var a=an(i).length;o=Math.max(35-4*a,15)+"%"}var s=es(o,r),l=es(t.gap,1),c=t.remainedWidth,u=t.autoWidthCount,h=(c-s)/(u+(u-1)*l);h=Math.max(h,0),en(i,function(t){var e=t.maxWidth,n=t.minWidth;if(t.width){i=t.width;e&&(i=Math.min(i,e)),n&&(i=Math.max(i,n)),t.width=i,c-=i+l*i,u--}else{var i=h;e&&ei&&(i=n),i!==h&&(t.width=i,c-=i+l*i,u--)}}),h=(c-s)/(u+(u-1)*l),h=Math.max(h,0);var d,p=0;en(i,function(t,e){t.width||(t.width=h),d=t,p+=t.width*(1+l)}),d&&(p-=d.width*l);var f=-p/2;en(i,function(t,i){n[e][i]=n[e][i]||{bandWidth:r,offset:f,width:t.width},f+=t.width*(1+l)})}),n}(n)}var xb=function(t){function e(e){var n=t.call(this,e)||this;return n.type="time",n}return _(e,t),e.prototype.getLabel=function(t){var e=this.getSetting("useUTC");return Gp(t.value,Hp[function(t){switch(t){case"year":case"month":return"day";case"millisecond":return"millisecond";default:return"second"}}(Wp(this._minLevelUnit))]||Hp.second,e,this.getSetting("locale"))},e.prototype.getFormattedLabel=function(t,e,n){var i=this.getSetting("useUTC");return function(t,e,n,i,r){var o=null;if(hn(n))o=n;else if(un(n)){var a={time:t.time,level:t.time.level},s=null;s&&s.makeAxisLabelFormatterParamBreak(a,t.break),o=n(t.value,e,a)}else{var l=t.time;if(l){var c=n[l.lowerTimeUnit][l.upperTimeUnit];o=c[Math.min(l.level,c.length-1)]||""}else{var u=qp(t.value,r);o=n[u][u][0]}}return Gp(new Date(t.value),o,r,i)}(t,e,n,this.getSetting("locale"),i)},e.prototype.getTicks=function(t){var e=this._interval,n=this._extent,i=[];if(!e)return i;var r=this.getSetting("useUTC"),o=qp(n[1],r);i.push({value:n[0],time:{level:0,upperTimeUnit:o,lowerTimeUnit:o}});var a=function(t,e,n,i,r,o){var a=1e4,s=Fp,l=0;function c(t,e,n,r,s,c,u){for(var h=function(t,e){var n=new Date(0);n[t](1);var i=n.getTime();n[t](1+e);var r=n.getTime()-i;return function(t,e){return Math.max(0,Math.round((e-t)/r))}}(s,t),d=e,p=new Date(d);da));)if(p[s](p[r]()+t),d=p.getTime(),o){var f=o.calcNiceTickMultiple(d,h);f>0&&(p[s](p[r]()+f*t),d=p.getTime())}u.push({value:d,notAdd:!0})}function u(t,r,o){var a=[],s=!r.length;if(!function(t,e,n,i){return jp(new Date(e),t,i).getTime()===jp(new Date(n),t,i).getTime()}(Wp(t),i[0],i[1],n)){s&&(r=[{value:Tb(i[0],t,n)},{value:i[1]}]);for(var l=0;l=i[0]&&u<=i[1]&&c(d,u,h,p,f,g,a),"year"===t&&o.length>1&&0===l&&o.unshift({value:o[0].value-d})}}for(l=0;l=i[0]&&_<=i[1]&&p++)}var x=r/e;if(p>1.5*x&&f>x/1.5)break;if(h.push(m),p>x||t===s[g])break}d=[]}}var b=on(nn(h,function(t){return on(t,function(t){return t.value>=i[0]&&t.value<=i[1]&&!t.notAdd})}),function(t){return t.length>0}),w=[],S=b.length-1;for(g=0;gn&&(this._approxInterval=n);var r=bb.length,o=Math.min(function(t,e,n,i){for(;n>>1;t[r][1]16?16:t>7.5?7:t>3.5?4:t>1.5?2:1}function Sb(t){return(t/=2592e6)>6?6:t>3?3:t>2?2:1}function Cb(t){return(t/=Lp)>12?12:t>6?6:t>3.5?4:t>2?2:1}function Mb(t,e){return(t/=e?Pp:Ap)>30?30:t>20?20:t>15?15:t>10?10:t>5?5:t>2?2:1}function kb(t){return hs(t)}function Tb(t,e,n){var i=Math.max(0,Qe(Bp,e)-1);return jp(new Date(t),Bp[i],n).getTime()}sb.registerClass(xb);var Db=is,Ib=Math.floor,Ab=Math.ceil,Pb=Math.pow,Lb=Math.log,Eb=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.type="log",e.base=10,e._originalScale=new pb,e}return _(e,t),e.prototype.getTicks=function(e){e=e||{};var n=this._extent.slice(),i=this._originalScale.getExtent(),r=t.prototype.getTicks.call(this,e),o=this.base;return this._originalScale._innerGetBreaks(),nn(r,function(t){var e=t.value,r=null,a=Pb(o,e);return e===n[0]&&this._fixMin?r=i[0]:e===n[1]&&this._fixMax&&(r=i[1]),null!=r&&(a=zb(a,r)),{value:a,break:void 0}},this)},e.prototype._getNonTransBreaks=function(){return this._originalScale._innerGetBreaks()},e.prototype.setExtent=function(e,n){this._originalScale.setExtent(e,n);var i=ab(this.base,[e,n]);t.prototype.setExtent.call(this,i[0],i[1])},e.prototype.getExtent=function(){var e=this.base,n=t.prototype.getExtent.call(this);n[0]=Pb(e,n[0]),n[1]=Pb(e,n[1]);var i=this._originalScale.getExtent();return this._fixMin&&(n[0]=zb(n[0],i[0])),this._fixMax&&(n[1]=zb(n[1],i[1])),n},e.prototype.unionExtentFromData=function(t,e){this._originalScale.unionExtentFromData(t,e);var n=ab(this.base,t.getApproximateExtent(e),!0);this._innerUnionExtent(n)},e.prototype.calcNiceTicks=function(t){t=t||10;var e=this._extent.slice(),n=this._getExtentSpanWithBreaks();if(isFinite(n)&&!(n<=0)){var i,r=(i=n,Math.pow(10,us(i)));for(t/n*r<=.5&&(r*=10);!isNaN(r)&&Math.abs(r)<1&&Math.abs(r)>0;)r*=10;var o=[Db(Ab(e[0]/r)*r),Db(Ib(e[1]/r)*r)];this._interval=r,this._intervalPrecision=tb(r),this._niceExtent=o}},e.prototype.calcNiceExtent=function(e){t.prototype.calcNiceExtent.call(this,e),this._fixMin=e.fixMin,this._fixMax=e.fixMax},e.prototype.contain=function(e){return e=Lb(e)/Lb(this.base),t.prototype.contain.call(this,e)},e.prototype.normalize=function(e){return e=Lb(e)/Lb(this.base),t.prototype.normalize.call(this,e)},e.prototype.scale=function(e){return e=t.prototype.scale.call(this,e),Pb(this.base,e)},e.prototype.setBreaksFromOption=function(t){},e.type="log",e}(pb);function zb(t,e){return Db(t,rs(e))}sb.registerClass(Eb);var Ob=function(){function t(t,e,n){this._prepareParams(t,e,n)}return t.prototype._prepareParams=function(t,e,n){n[1]0&&s>0&&!l&&(a=0),a<0&&s<0&&!c&&(s=0));var h=this._determinedMin,d=this._determinedMax;return null!=h&&(a=h,l=!0),null!=d&&(s=d,c=!0),{min:a,max:s,minFixed:l,maxFixed:c,isBlank:u}},t.prototype.modifyDataMinMax=function(t,e){this[Rb[t]]=e},t.prototype.setDeterminedMinMax=function(t,e){this[Nb[t]]=e},t.prototype.freeze=function(){this.frozen=!0},t}(),Nb={min:"_determinedMin",max:"_determinedMax"},Rb={min:"_dataMin",max:"_dataMax"};function Hb(t,e){return null==e?null:_n(e)?NaN:t.parse(e)}function Bb(t,e){var n=t.type,i=function(t,e,n){var i=t.rawExtentInfo;return i||(i=new Ob(t,e,n),t.rawExtentInfo=i,i)}(t,e,t.getExtent()).calculate();t.setBlank(i.isBlank);var r=i.min,o=i.max,a=e.ecModel;if(a&&"time"===n){var s=function(t,e){var n=[];return e.eachSeriesByType(t,function(t){(function(t){return t.coordinateSystem&&"cartesian2d"===t.coordinateSystem.type})(t)&&n.push(t)}),n}("bar",a),l=!1;if(en(s,function(t){l=l||t.getBaseAxis()===e.axis}),l){var c=_b(s),u=function(t,e,n,i){var r=n.axis.getExtent(),o=Math.abs(r[1]-r[0]),a=function(t,e){if(t&&e)return t[yb(e)]}(i,n.axis);if(void 0===a)return{min:t,max:e};var s=1/0;en(a,function(t){s=Math.min(t.offset,s)});var l=-1/0;en(a,function(t){l=Math.max(t.offset+t.width,l)}),s=Math.abs(s),l=Math.abs(l);var c=s+l,u=e-t,h=u/(1-(s+l)/o)-u;return e+=h*(l/c),t-=h*(s/c),{min:t,max:e}}(r,o,e,c);r=u.min,o=u.max}}return{extent:[r,o],fixMin:i.minFixed,fixMax:i.maxFixed}}function Fb(t,e){var n=e,i=Bb(t,n),r=i.extent,o=n.get("splitNumber");t instanceof Eb&&(t.base=n.get("logBase"));var a=t.type,s=n.get("interval"),l="interval"===a||"time"===a;t.setBreaksFromOption(Xb(n)),t.setExtent(r[0],r[1]),t.calcNiceExtent({splitNumber:o,fixMin:i.fixMin,fixMax:i.fixMax,minInterval:l?n.get("minInterval"):null,maxInterval:l?n.get("maxInterval"):null}),null!=s&&t.setInterval&&t.setInterval(s)}function $b(t){var e=t.getLabelModel().get("formatter");if("time"===t.type){var n=$p(e);return function(e,i){return t.scale.getFormattedLabel(e,i,n)}}if(hn(e))return function(n){var i=t.scale.getLabel(n);return e.replace("{value}",null!=i?i:"")};if(un(e)){if("category"===t.type)return function(n,i){return e(Vb(t,n),n.value-t.scale.getExtent()[0],null)};var i=null;return function(n,r){var o=null;return i&&(o=i.makeAxisLabelFormatterParamBreak(o,n.break)),e(Vb(t,n),r,o)}}return function(e){return t.scale.getLabel(e)}}function Vb(t,e){return"category"===t.type?t.scale.getLabel(e):e.value}function Wb(t){var e=t.get("interval");return null==e?"auto":e}function Ub(t){return"category"===t.type&&0===Wb(t.getLabelModel())}function Gb(t,e){var n={};return en(t.mapDimensionsAll(e),function(e){n[function(t,e){return Yx(t,e)?t.getCalculationInfo("stackResultDimension"):e}(t,e)]=!0}),an(n)}function qb(t){return"middle"===t||"center"===t}function jb(t){return t.getShallow("show")}function Xb(t){t.get("breaks",!0)}var Yb=function(){function t(){}return t.prototype.getNeedCrossZero=function(){return!this.option.scale},t.prototype.getCoordSysModel=function(){},t}(),Zb=[],Kb={registerPreprocessor:ax,registerProcessor:sx,registerPostInit:function(t){lx("afterinit",t)},registerPostUpdate:function(t){lx("afterupdate",t)},registerUpdateLifecycle:lx,registerAction:cx,registerCoordinateSystem:function(t,e){_f.register(t,e)},registerLayout:function(t,e){dx(K_,t,e,1e3,"layout")},registerVisual:ux,registerTransform:fx,registerLoading:px,registerMap:function(t,e,n){var i=c_["registerMap"];i&&i(t,e,n)},registerImpl:function(t,e){c_[t]=e},PRIORITY:d_,ComponentModel:Hf,ComponentView:wm,SeriesModel:fm,ChartView:km,registerComponentModel:function(t){Hf.registerClass(t)},registerComponentView:function(t){wm.registerClass(t)},registerSeriesModel:function(t){fm.registerClass(t)},registerChartView:function(t){km.registerClass(t)},registerCustomSeries:function(t,e){},registerSubTypeDefaulter:function(t,e){Hf.registerSubTypeDefaulter(t,e)},registerPainter:function(t,e){var n;n=e,Ga[t]=n}};function Qb(t){cn(t)?en(t,function(t){Qb(t)}):Qe(Zb,t)>=0||(Zb.push(t),un(t)&&(t={install:t}),t.install(Kb))}var Jb=Es(),tw=Es(),ew=1,nw=2;function iw(t){return{out:{noPxChangeTryDetermine:[]},kind:t}}function rw(t,e){var n=nn(e,function(e){return t.scale.parse(e)});return"time"===t.type&&n.length>0&&(n.sort(),n.unshift(n[0]),n.push(n[n.length-1])),n}function ow(t,e){var n=t.getLabelModel().get("customValues");if(n){var i=$b(t),r=t.scale.getExtent();return{labels:nn(on(rw(t,n),function(t){return t>=r[0]&&t<=r[1]}),function(e){var n={value:e};return{formattedLabel:i(n),rawLabel:t.scale.getLabel(n),tickValue:e,time:void 0,break:void 0}})}}return"category"===t.type?function(t,e){var n=t.getLabelModel(),i=sw(t,n,e);return!n.get("show")||t.scale.isBlank()?{labels:[]}:i}(t,e):function(t){var e=t.scale.getTicks(),n=$b(t);return{labels:nn(e,function(e,i){return{formattedLabel:n(e,i),rawLabel:t.scale.getLabel(e),tickValue:e.value,time:e.time,break:e.break}})}}(t)}function aw(t,e,n){var i=t.getTickModel().get("customValues");if(i){var r=t.scale.getExtent();return{ticks:on(rw(t,i),function(t){return t>=r[0]&&t<=r[1]})}}return"category"===t.type?function(t,e){var n,i,r=lw(t),o=Wb(e),a=hw(r,o);if(a)return a;e.get("show")&&!t.scale.isBlank()||(n=[]);if(un(o))n=vw(t,o,!0);else if("auto"===o){var s=sw(t,t.getLabelModel(),iw(nw));i=s.labelCategoryInterval,n=nn(s.labels,function(t){return t.tickValue})}else n=gw(t,i=o,!0);return dw(r,o,{ticks:n,tickCategoryInterval:i})}(t,e):{ticks:nn(t.scale.getTicks(n),function(t){return t.value})}}function sw(t,e,n){var i,r,o=cw(t),a=Wb(e),s=n.kind===ew;if(!s){var l=hw(o,a);if(l)return l}un(a)?i=vw(t,a):(r="auto"===a?function(t,e){if(e.kind===ew){var n=t.calculateCategoryInterval(e);return e.out.noPxChangeTryDetermine.push(function(){return tw(t).autoInterval=n,!0}),n}var i=tw(t).autoInterval;return null!=i?i:tw(t).autoInterval=t.calculateCategoryInterval(e)}(t,n):a,i=gw(t,r));var c={labels:i,labelCategoryInterval:r};return s?n.out.noPxChangeTryDetermine.push(function(){return dw(o,a,c),!0}):dw(o,a,c),c}var lw=uw("axisTick"),cw=uw("axisLabel");function uw(t){return function(e){return tw(e)[t]||(tw(e)[t]={list:[]})}}function hw(t,e){for(var n=0;ne&&i.axisExtent0===r[0]&&i.axisExtent1===r[1])return o;i.lastTickCount=n,i.lastAutoInterval=e,i.axisExtent0=r[0],i.axisExtent1=r[1]}function gw(t,e,n){var i=$b(t),r=t.scale,o=r.getExtent(),a=t.getLabelModel(),s=[],l=Math.max((e||0)+1,1),c=o[0],u=r.count();0!==c&&l>1&&u/l>2&&(c=Math.round(Math.ceil(c/l)*l));var h=Ub(t),d=a.get("showMinLabel")||h,p=a.get("showMaxLabel")||h;d&&c!==o[0]&&g(o[0]);for(var f=c;f<=o[1];f+=l)g(f);function g(t){var e={value:t};s.push(n?t:{formattedLabel:i(e),rawLabel:r.getLabel(e),tickValue:t,time:void 0,break:void 0})}return p&&f-l!==o[1]&&g(o[1]),s}function vw(t,e,n){var i=t.scale,r=$b(t),o=[];return en(i.getTicks(),function(t){var a=i.getLabel(t),s=t.value;e(t.value,a)&&o.push(n?s:{formattedLabel:r(t),rawLabel:a,tickValue:s,time:void 0,break:void 0})}),o}var mw=[0,1],yw=function(){function t(t,e,n){this.onBand=!1,this.inverse=!1,this.dim=t,this.scale=e,this._extent=n||[0,0]}return t.prototype.contain=function(t){var e=this._extent,n=Math.min(e[0],e[1]),i=Math.max(e[0],e[1]);return t>=n&&t<=i},t.prototype.containData=function(t){return this.scale.contain(this.scale.parse(t))},t.prototype.getExtent=function(){return this._extent.slice()},t.prototype.getPixelPrecision=function(t){return function(t,e){var n=Math.log,i=Math.LN10,r=Math.floor(n(t[1]-t[0])/i),o=Math.round(n(Ja(e[1]-e[0]))/i),a=Math.min(Math.max(-r+o,0),20);return isFinite(a)?a:20}(t||this.scale.getExtent(),this._extent)},t.prototype.setExtent=function(t,e){var n=this._extent;n[0]=t,n[1]=e},t.prototype.dataToCoord=function(t,e){var n=this._extent,i=this.scale;return t=i.normalize(i.parse(t)),this.onBand&&"ordinal"===i.type&&_w(n=n.slice(),i.count()),ts(t,mw,n,e)},t.prototype.coordToData=function(t,e){var n=this._extent,i=this.scale;this.onBand&&"ordinal"===i.type&&_w(n=n.slice(),i.count());var r=ts(t,n,mw,e);return this.scale.scale(r)},t.prototype.pointToData=function(t,e){},t.prototype.getTicksCoords=function(t){var e=(t=t||{}).tickModel||this.getTickModel(),n=nn(aw(this,e,{breakTicks:t.breakTicks,pruneByBreak:t.pruneByBreak}).ticks,function(t){return{coord:this.dataToCoord("ordinal"===this.scale.type?this.scale.getRawOrdinalNumber(t):t),tickValue:t}},this);return function(t,e,n,i){var r=e.length;if(!t.onBand||n||!r)return;var o,a,s=t.getExtent();if(1===r)e[0].coord=s[0],e[0].onBand=!0,o=e[1]={coord:s[1],tickValue:e[0].tickValue,onBand:!0};else{var l=e[r-1].tickValue-e[0].tickValue,c=(e[r-1].coord-e[0].coord)/l;en(e,function(t){t.coord-=c/2,t.onBand=!0});var u=t.scale.getExtent();a=1+u[1]-e[r-1].tickValue,o={coord:e[r-1].coord+c*a,tickValue:u[1]+1,onBand:!0},e.push(o)}var h=s[0]>s[1];d(e[0].coord,s[0])&&(i?e[0].coord=s[0]:e.shift());i&&d(s[0],e[0].coord)&&e.unshift({coord:s[0],onBand:!0});d(s[1],o.coord)&&(i?o.coord=s[1]:e.pop());i&&d(o.coord,s[1])&&e.push({coord:s[1],onBand:!0});function d(t,e){return t=is(t),e=is(e),h?t>e:t0&&t<100||(t=5),nn(this.scale.getMinorTicks(t),function(t){return nn(t,function(t){return{coord:this.dataToCoord(t),tickValue:t}},this)},this)},t.prototype.getViewLabels=function(t){return ow(this,t=t||iw(nw)).labels},t.prototype.getLabelModel=function(){return this.model.getModel("axisLabel")},t.prototype.getTickModel=function(){return this.model.getModel("axisTick")},t.prototype.getBandWidth=function(){var t=this._extent,e=this.scale.getExtent(),n=e[1]-e[0]+(this.onBand?1:0);0===n&&(n=1);var i=Math.abs(t[1]-t[0]);return Math.abs(i)/n},t.prototype.calculateCategoryInterval=function(t){return function(t,e){var n=e.kind,i=function(t){var e=t.getLabelModel();return{axisRotate:t.getRotate?t.getRotate():t.isHorizontal&&!t.isHorizontal()?90:0,labelRotate:e.get("rotate")||0,font:e.getFont()}}(t),r=$b(t),o=(i.axisRotate-i.labelRotate)/180*Math.PI,a=t.scale,s=a.getExtent(),l=a.count();if(s[1]-s[0]<1)return 0;var c=1;l>40&&(c=Math.max(1,Math.floor(l/40)));for(var u=s[0],h=t.dataToCoord(u+1)-t.dataToCoord(u),d=Math.abs(h*Math.cos(o)),p=Math.abs(h*Math.sin(o)),f=0,g=0;u<=s[1];u+=c){var v,m,y=Ta(r({value:u}),i.font,"center","top");v=1.3*y.width,m=1.3*y.height,f=Math.max(f,v,7),g=Math.max(g,m,7)}var _=f/d,x=g/p;isNaN(_)&&(_=1/0),isNaN(x)&&(x=1/0);var b=Math.max(0,Math.floor(Math.min(_,x)));if(n===ew)return e.out.noPxChangeTryDetermine.push(sn(pw,null,t,b,l)),b;var w=fw(t,b,l);return null!=w?w:b}(this,t=t||iw(nw))},t}();function _w(t,e){var n=(t[1]-t[0])/e/2;t[0]+=n,t[1]-=n}var xw=["label","labelLine","layoutOption","priority","defaultAttr","marginForce","minMarginForce","marginDefault","suggestIgnore"];function bw(t,e,n){n=n||3,e?t.dirty|=n:t.dirty&=~n}function ww(t,e){return e=e||3,null==t.dirty||!!(t.dirty&e)}function Sw(t){if(t)return ww(t)&&function(t,e,n){var i=e.getComputedTransform();t.transform=Ud(t.transform,i);var r=t.localRect=Wd(t.localRect,e.getBoundingRect()),o=e.style,a=o.margin,s=n&&n.marginForce,l=n&&n.minMarginForce,c=n&&n.marginDefault,u=o.__marginType;null==u&&c&&(a=c,u=lp.textMargin);for(var h=0;h<4;h++)Cw[h]=u===lp.minMargin&&l&&null!=l[h]?l[h]:s&&null!=s[h]?s[h]:a?a[h]:0;u===lp.textMargin&&Od(r,Cw,!1,!1);var d=t.rect=Wd(t.rect,r);i&&d.applyTransform(i);u===lp.minMargin&&Od(d,Cw,!1,!1);t.axisAligned=$d(i),(t.label=t.label||{}).ignore=e.ignore,bw(t,!1),bw(t,!0,2)}(t,t.label,t),t}var Cw=[0,0,0,0];function Mw(t,e){for(var n=0;n-1&&(s.style.stroke=s.style.fill,s.style.fill=Bf.color.neutral00,s.style.lineWidth=2),e},e.type="series.line",e.dependencies=["grid","polar"],e.defaultOption={z:3,coordinateSystem:"cartesian2d",legendHoverLink:!0,clip:!0,label:{position:"top"},endLabel:{show:!1,valueAnimation:!0,distance:8},lineStyle:{width:2,type:"solid"},emphasis:{scale:!0},step:!1,smooth:!1,smoothMonotone:null,symbol:"emptyCircle",symbolSize:6,symbolRotate:null,showSymbol:!0,showAllSymbol:"auto",connectNulls:!1,sampling:"none",animationEasing:"linear",progressive:0,hoverLayerThreshold:1/0,universalTransition:{divideShape:"clone"},triggerLineEvent:!1},e}(fm);function Iw(t,e){var n=t.mapDimensionsAll("defaultedLabel"),i=n.length;if(1===i){var r=yv(t,e,n[0]);return null!=r?r+"":null}if(i){for(var o=[],a=0;a0?+d:1;k.scaleX=this._sizeX*T,k.scaleY=this._sizeY*T,this.setSymbolScale(1),Wu(this,l,c,u)},e.prototype.setSymbolScale=function(t){this.scaleX=this.scaleY=t},e.prototype.fadeOut=function(t,e,n){var i=this.childAt(0),r=nu(this).dataIndex,o=n&&n.animation;if(this.silent=i.silent=!0,n&&n.fadeLabel){var a=i.getTextContent();a&&gd(a,{style:{opacity:0}},e,{dataIndex:r,removeOpt:o,cb:function(){i.removeTextContent()}})}else i.removeTextContent();gd(i,{style:{opacity:0},scaleX:0,scaleY:0},e,{dataIndex:r,cb:t,removeOpt:o})},e.getSymbolSize=function(t,e){return cn(n=t.getItemVisual(e,"symbolSize"))||(n=[+n,+n]),[n[0]||0,n[1]||0];var n},e.getSymbolZ2=function(t,e){return t.getItemVisual(e,"z2")},e}(Ua);function Pw(t,e){this.parent.drift(t,e)}function Lw(t,e,n,i){return e&&!isNaN(e[0])&&!isNaN(e[1])&&!(i.isIgnore&&i.isIgnore(n))&&!(i.clipShape&&!i.clipShape.contain(e[0],e[1]))&&"none"!==t.getItemVisual(n,"symbol")}function Ew(t){return null==t||fn(t)||(t={isIgnore:t}),t||{}}function zw(t){var e=t.hostModel,n=e.getModel("emphasis");return{emphasisItemStyle:n.getModel("itemStyle").getItemStyle(),blurItemStyle:e.getModel(["blur","itemStyle"]).getItemStyle(),selectItemStyle:e.getModel(["select","itemStyle"]).getItemStyle(),focus:n.get("focus"),blurScope:n.get("blurScope"),emphasisDisabled:n.get("disabled"),hoverScale:n.get("scale"),labelStatesModels:Qd(e),cursorStyle:e.get("cursor")}}var Ow=function(){function t(t){this.group=new Ua,this._SymbolCtor=t||Aw}return t.prototype.updateData=function(t,e){this._progressiveEls=null,e=Ew(e);var n=this.group,i=t.hostModel,r=this._data,o=this._SymbolCtor,a=e.disableAnimation,s=zw(t),l={disableAnimation:a},c=e.getSymbolPoint||function(e){return t.getItemLayout(e)};r||n.removeAll(),t.diff(r).add(function(i){var r=c(i);if(Lw(t,r,i,e)){var a=new o(t,i,s,l);a.setPosition(r),t.setItemGraphicEl(i,a),n.add(a)}}).update(function(u,h){var d=r.getItemGraphicEl(h),p=c(u);if(Lw(t,p,u,e)){var f=t.getItemVisual(u,"symbol")||"circle",g=d&&d.getSymbolType&&d.getSymbolType();if(!d||g&&g!==f)n.remove(d),(d=new o(t,u,s,l)).setPosition(p);else{d.updateData(t,u,s,l);var v={x:p[0],y:p[1]};a?d.attr(v):dd(d,v,i)}n.add(d),t.setItemGraphicEl(u,d)}else n.remove(d)}).remove(function(t){var e=r.getItemGraphicEl(t);e&&e.fadeOut(function(){n.remove(e)},i)}).execute(),this._getSymbolPoint=c,this._data=t},t.prototype.updateLayout=function(){var t=this,e=this._data;e&&e.eachItemGraphicEl(function(e,n){var i=t._getSymbolPoint(n);e.setPosition(i),e.markRedraw()})},t.prototype.incrementalPrepareUpdate=function(t){this._seriesScope=zw(t),this._data=null,this.group.removeAll()},t.prototype.incrementalUpdate=function(t,e,n){function i(t){t.isGroup||(t.incremental=!0,t.ensureState("emphasis").hoverLayer=!0)}this._progressiveEls=[],n=Ew(n);for(var r=t.start;r0?n=i[0]:i[1]<0&&(n=i[1]);return n}(r,n),a=i.dim,s=r.dim,l=e.mapDimension(s),c=e.mapDimension(a),u="x"===s||"radius"===s?1:0,h=nn(t.dimensions,function(t){return e.mapDimension(t)}),d=!1,p=e.getCalculationInfo("stackResultDimension");return Yx(e,h[0])&&(d=!0,h[0]=p),Yx(e,h[1])&&(d=!0,h[1]=p),{dataDimsForPoint:h,valueStart:o,valueAxisDim:s,baseAxisDim:a,stacked:!!d,valueDim:l,baseDim:c,baseDataOffset:u,stackedOverDimension:e.getCalculationInfo("stackedOverDimension")}}function Rw(t,e,n,i){var r=NaN;t.stacked&&(r=n.get(n.getCalculationInfo("stackedOverDimension"),i)),isNaN(r)&&(r=t.valueStart);var o=t.baseDataOffset,a=[];return a[o]=n.get(t.baseDim,i),a[1-o]=r,e.dataToPoint(a)}var Hw=Math.min,Bw=Math.max;function Fw(t,e){return isNaN(t)||isNaN(e)}function $w(t,e,n,i,r,o,a,s,l){for(var c,u,h,d,p,f,g=n,v=0;v=r||g<0)break;if(Fw(m,y)){if(l){g+=o;continue}break}if(g===n)t[o>0?"moveTo":"lineTo"](m,y),h=m,d=y;else{var _=m-c,x=y-u;if(_*_+x*x<.5){g+=o;continue}if(a>0){for(var b=g+o,w=e[2*b],S=e[2*b+1];w===m&&S===y&&v=i||Fw(w,S))p=m,f=y;else{k=w-c,T=S-u;var A=m-c,P=w-m,L=y-u,E=S-y,z=void 0,O=void 0;if("x"===s){var N=k>0?1:-1;p=m-N*(z=Math.abs(A))*a,f=y,D=m+N*(O=Math.abs(P))*a,I=y}else if("y"===s){var R=T>0?1:-1;p=m,f=y-R*(z=Math.abs(L))*a,D=m,I=y+R*(O=Math.abs(E))*a}else z=Math.sqrt(A*A+L*L),p=m-k*a*(1-(M=(O=Math.sqrt(P*P+E*E))/(O+z))),f=y-T*a*(1-M),I=y+T*a*M,D=Hw(D=m+k*a*M,Bw(w,m)),I=Hw(I,Bw(S,y)),D=Bw(D,Hw(w,m)),f=y-(T=(I=Bw(I,Hw(S,y)))-y)*z/O,p=Hw(p=m-(k=D-m)*z/O,Bw(c,m)),f=Hw(f,Bw(u,y)),D=m+(k=m-(p=Bw(p,Hw(c,m))))*O/z,I=y+(T=y-(f=Bw(f,Hw(u,y))))*O/z}t.bezierCurveTo(h,d,p,f,m,y),h=D,d=I}else t.lineTo(m,y)}c=m,u=y,g+=o}return v}var Vw=function(){this.smooth=0,this.smoothConstraint=!0},Ww=function(t){function e(e){var n=t.call(this,e)||this;return n.type="ec-polyline",n}return _(e,t),e.prototype.getDefaultStyle=function(){return{stroke:Bf.color.neutral99,fill:null}},e.prototype.getDefaultShape=function(){return new Vw},e.prototype.buildPath=function(t,e){var n=e.points,i=0,r=n.length/2;if(e.connectNulls){for(;r>0&&Fw(n[2*r-2],n[2*r-1]);r--);for(;i=0){var v=a?(u-i)*g+i:(c-n)*g+n;return a?[t,v]:[v,t]}n=c,i=u;break;case o.C:c=r[l++],u=r[l++],h=r[l++],d=r[l++],p=r[l++],f=r[l++];var m=a?Tr(n,c,h,p,t,s):Tr(i,u,d,f,t,s);if(m>0)for(var y=0;y=0){v=a?Mr(i,u,d,f,_):Mr(n,c,h,p,_);return a?[t,v]:[v,t]}}n=p,i=f}}},e}(Tc),Uw=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return _(e,t),e}(Vw),Gw=function(t){function e(e){var n=t.call(this,e)||this;return n.type="ec-polygon",n}return _(e,t),e.prototype.getDefaultShape=function(){return new Uw},e.prototype.buildPath=function(t,e){var n=e.points,i=e.stackedOnPoints,r=0,o=n.length/2,a=e.smoothMonotone;if(e.connectNulls){for(;o>0&&Fw(n[2*o-2],n[2*o-1]);o--);for(;r=0&&t<=1){var i=t*(e.length-1),r=Math.floor(i),o=Math.ceil(i),a=to(e[r]),s=to(e[o]),l=i-r,c=ro([Wr(Xr(a[0],s[0],l)),Wr(Xr(a[1],s[1],l)),Wr(Xr(a[2],s[2],l)),Ur(Xr(a[3],s[3],l))],"rgba");return n?{color:c,leftIndex:r,rightIndex:o,value:i}:c}}((n-i)/(e.coord-i),[t.color,e.color])}}for(var s=0;se){i?r.push(a(i,l,e)):n&&r.push(a(n,l,0),a(n,l,e));break}n&&(r.push(a(n,l,0)),n=null),r.push(l),i=l}}return r}function Qw(t,e,n){var i=t.get("showAllSymbol"),r="auto"===i;if(!i||r){var o=n.getAxesByScale("ordinal")[0];if(o&&(!r||!function(t,e){var n=t.getExtent(),i=Math.abs(n[1]-n[0])/t.scale.count();isNaN(i)&&(i=0);for(var r=e.count(),o=Math.max(1,Math.round(r/5)),a=0;ai)return!1;return!0}(o,e))){var a=e.mapDimension(o.dim),s={};return en(o.getViewLabels(),function(t){var e=o.scale.getRawOrdinalNumber(t.tickValue);s[e]=1}),function(t){return!s.hasOwnProperty(e.get(a,t))}}}}function Jw(t,e){return isNaN(t)||isNaN(e)}function tS(t,e){return[t[2*e],t[2*e+1]]}function eS(t){if(t.get(["endLabel","show"]))return!0;for(var e=0;e=0;a--){var s=t.getDimensionInfo(i[a].dimension);if("x"===(r=s&&s.coordDim)||"y"===r){o=i[a];break}}if(o){var l=e.getAxis(r),c=nn(o.stops,function(t){return{coord:l.toGlobalCoord(l.dataToCoord(t.value)),color:t.color}}),u=c.length,h=o.outerColors.slice();u&&c[0].coord>c[u-1].coord&&(c.reverse(),h.reverse());var d=Kw(c,"x"===r?n.getWidth():n.getHeight()),p=d.length;if(!p&&u)return c[0].coord<0?h[1]?h[1]:c[u-1].color:h[0]?h[0]:c[0].color;var f=d[0].coord-10,g=d[p-1].coord+10,v=g-f;if(v<.001)return"transparent";en(d,function(t){t.offset=(t.coord-f)/v}),d.push({offset:p?d[p-1].offset:.5,color:h[1]||"transparent"}),d.unshift({offset:p?d[0].offset:.5,color:h[0]||"transparent"});var m=new Kh(0,0,0,0,d,!0);return m[r]=f,m[r+"2"]=g,m}}}(o,i,n)||o.getVisual("style")[o.getVisual("drawType")];if(d&&u.type===i.type&&M===this._step){v&&!p?p=this._newPolygon(l,_):p&&!v&&(f.remove(p),p=this._polygon=null),c||this._initOrUpdateEndLabel(t,i,vf(k));var T=f.getClipPath();if(T)pd(T,{shape:nS(this,i,!1,t).shape},t);else f.setClipPath(nS(this,i,!0,t));x&&h.updateData(o,{isIgnore:w,clipShape:C,disableAnimation:!0,getSymbolPoint:function(t){return[l[2*t],l[2*t+1]]}}),qw(this._stackedOnPoints,_)&&qw(this._points,l)||(g?this._doUpdateAnimation(o,_,i,n,M,m,b):(M&&(_&&(_=Zw(_,l,i,M,b)),l=Zw(l,null,i,M,b)),d.setShape({points:l}),p&&p.setShape({points:l,stackedOnPoints:_})))}else x&&h.updateData(o,{isIgnore:w,clipShape:C,disableAnimation:!0,getSymbolPoint:function(t){return[l[2*t],l[2*t+1]]}}),g&&this._initSymbolLabelAnimation(o,i,C),M&&(_&&(_=Zw(_,l,i,M,b)),l=Zw(l,null,i,M,b)),d=this._newPolyline(l),v?p=this._newPolygon(l,_):p&&(f.remove(p),p=this._polygon=null),c||this._initOrUpdateEndLabel(t,i,vf(k)),f.setClipPath(nS(this,i,!0,t));var D=t.getModel("emphasis"),I=D.get("focus"),A=D.get("blurScope"),P=D.get("disabled");(d.useStyle(Ke(a.getLineStyle(),{fill:"none",stroke:k,lineJoin:"bevel"})),qu(d,t,"lineStyle"),d.style.lineWidth>0&&"bolder"===t.get(["emphasis","lineStyle","width"]))&&(d.getState("emphasis").style.lineWidth=+d.style.lineWidth+1);nu(d).seriesIndex=t.seriesIndex,Wu(d,I,A,P);var L=Yw(t.get("smooth")),E=t.get("smoothMonotone");if(d.setShape({smooth:L,smoothMonotone:E,connectNulls:b}),p){var z=o.getCalculationInfo("stackedOnSeries"),O=0;p.useStyle(Ke(s.getAreaStyle(),{fill:k,opacity:.7,lineJoin:"bevel",decal:o.getVisual("style").decal})),z&&(O=Yw(z.get("smooth"))),p.setShape({smooth:L,stackedOnSmooth:O,smoothMonotone:E,connectNulls:b}),qu(p,t,"areaStyle"),nu(p).seriesIndex=t.seriesIndex,Wu(p,I,A,P)}var N=this._changePolyState;o.eachItemGraphicEl(function(t){t&&(t.onHoverStateChange=N)}),this._polyline.onHoverStateChange=N,this._data=o,this._coordSys=i,this._stackedOnPoints=_,this._points=l,this._step=M,this._valueOrigin=m,t.get("triggerLineEvent")&&(this.packEventData(t,d),p&&this.packEventData(t,p))},e.prototype.packEventData=function(t,e){nu(e).eventData={componentType:"series",componentSubType:"line",componentIndex:t.componentIndex,seriesIndex:t.seriesIndex,seriesName:t.name,seriesType:"line"}},e.prototype.highlight=function(t,e,n,i){var r=t.getData(),o=Ls(r,i);if(this._changePolyState("emphasis"),!(o instanceof Array)&&null!=o&&o>=0){var a=r.getLayout("points"),s=r.getItemGraphicEl(o);if(!s){var l=a[2*o],c=a[2*o+1];if(isNaN(l)||isNaN(c))return;if(this._clipShapeForSymbol&&!this._clipShapeForSymbol.contain(l,c))return;var u=t.get("zlevel")||0,h=t.get("z")||0;(s=new Aw(r,o)).x=l,s.y=c,s.setZ(u,h);var d=s.getSymbolPath().getTextContent();d&&(d.zlevel=u,d.z=h,d.z2=this._polyline.z2+1),s.__temp=!0,r.setItemGraphicEl(o,s),s.stopSymbolAnimation(!0),this.group.add(s)}s.highlight()}else km.prototype.highlight.call(this,t,e,n,i)},e.prototype.downplay=function(t,e,n,i){var r=t.getData(),o=Ls(r,i);if(this._changePolyState("normal"),null!=o&&o>=0){var a=r.getItemGraphicEl(o);a&&(a.__temp?(r.setItemGraphicEl(o,null),this.group.remove(a)):a.downplay())}else km.prototype.downplay.call(this,t,e,n,i)},e.prototype._changePolyState=function(t){var e=this._polygon;Mu(this._polyline,t),e&&Mu(e,t)},e.prototype._newPolyline=function(t){var e=this._polyline;return e&&this._lineGroup.remove(e),e=new Ww({shape:{points:t},segmentIgnoreThreshold:2,z2:10}),this._lineGroup.add(e),this._polyline=e,e},e.prototype._newPolygon=function(t,e){var n=this._polygon;return n&&this._lineGroup.remove(n),n=new Gw({shape:{points:t,stackedOnPoints:e},segmentIgnoreThreshold:2}),this._lineGroup.add(n),this._polygon=n,n},e.prototype._initSymbolLabelAnimation=function(t,e,n){var i,r,o=e.getBaseAxis(),a=o.inverse;"cartesian2d"===e.type?(i=o.isHorizontal(),r=!1):"polar"===e.type&&(i="angle"===o.dim,r=!0);var s=t.hostModel,l=s.get("animationDuration");un(l)&&(l=l(null));var c=s.get("animationDelay")||0,u=un(c)?c(null):c;t.eachItemGraphicEl(function(t,o){var s=t;if(s){var h=[t.x,t.y],d=void 0,p=void 0,f=void 0;if(n)if(r){var g=n,v=e.pointToCoord(h);i?(d=g.startAngle,p=g.endAngle,f=-v[1]/180*Math.PI):(d=g.r0,p=g.r,f=v[0])}else{var m=n;i?(d=m.x,p=m.x+m.width,f=t.x):(d=m.y+m.height,p=m.y,f=t.y)}var y=p===d?0:(f-d)/(p-d);a&&(y=1-y);var _=un(c)?c(o):l*y+u,x=s.getSymbolPath(),b=x.getTextContent();s.attr({scaleX:0,scaleY:0}),s.animateTo({scaleX:1,scaleY:1},{duration:200,setToFinal:!0,delay:_}),b&&b.animateFrom({style:{opacity:0}},{duration:300,delay:_}),x.disableLabelAnimation=!0}})},e.prototype._initOrUpdateEndLabel=function(t,e,n){var i=t.getModel("endLabel");if(eS(t)){var r=t.getData(),o=this._polyline,a=r.getLayout("points");if(!a)return o.removeTextContent(),void(this._endLabel=null);var s=this._endLabel;s||((s=this._endLabel=new Wc({z2:200})).ignoreClip=!0,o.setTextContent(this._endLabel),o.disableLabelAnimation=!0);var l=function(t){for(var e=t.length/2;e>0&&Jw(t[2*e-2],t[2*e-1]);e--);return e-1}(a);l>=0&&(Kd(o,Qd(t,"endLabel"),{inheritColor:n,labelFetcher:t,labelDataIndex:l,defaultText:function(t,e,n){return null!=n?function(t,e){var n=t.mapDimensionsAll("defaultedLabel");if(!cn(e))return e+"";for(var i=[],r=0;r=0&&i.push(e[o])}return i.join(" ")}(r,n):Iw(r,t)},enableTextSetter:!0},function(t,e){var n=e.getBaseAxis(),i=n.isHorizontal(),r=n.inverse,o=i?r?"right":"left":"center",a=i?"middle":r?"top":"bottom";return{normal:{align:t.get("align")||o,verticalAlign:t.get("verticalAlign")||a}}}(i,e)),o.textConfig.position=null)}else this._endLabel&&(this._polyline.removeTextContent(),this._endLabel=null)},e.prototype._endLabelOnDuring=function(t,e,n,i,r,o,a){var s=this._endLabel,l=this._polyline;if(s){t<1&&null==i.originalX&&(i.originalX=s.x,i.originalY=s.y);var c=n.getLayout("points"),u=n.hostModel,h=u.get("connectNulls"),d=o.get("precision"),p=o.get("distance")||0,f=a.getBaseAxis(),g=f.isHorizontal(),v=f.inverse,m=e.shape,y=v?g?m.x:m.y+m.height:g?m.x+m.width:m.y,_=(g?p:0)*(v?-1:1),x=(g?0:-p)*(v?-1:1),b=g?"x":"y",w=function(t,e,n){for(var i,r,o=t.length/2,a="x"===n?0:1,s=0,l=-1,c=0;c=e||i>=e&&r<=e){l=c;break}s=c,i=r}else i=r;return{range:[s,l],t:(e-i)/(r-i)}}(c,y,b),S=w.range,C=S[1]-S[0],M=void 0;if(C>=1){if(C>1&&!h){var k=tS(c,S[0]);s.attr({x:k[0]+_,y:k[1]+x}),r&&(M=u.getRawValue(S[0]))}else{(k=l.getPointOn(y,b))&&s.attr({x:k[0]+_,y:k[1]+x});var T=u.getRawValue(S[0]),D=u.getRawValue(S[1]);r&&(M=function(t,e,n,i,r){var o=null==e||"auto"===e;if(null==i)return i;if(pn(i))return is(f=_s(n||0,i,r),o?Math.max(rs(n||0),rs(i)):e);if(hn(i))return r<1?n:i;for(var a=[],s=n,l=i,c=Math.max(s?s.length:0,l.length),u=0;u0?S[0]:0;k=tS(c,I);r&&(M=u.getRawValue(I)),s.attr({x:k[0]+_,y:k[1]+x})}if(r){var A=sp(s);"function"==typeof A.setLabelText&&A.setLabelText(M)}}},e.prototype._doUpdateAnimation=function(t,e,n,i,r,o,a){var s=this._polyline,l=this._polygon,c=t.hostModel,u=function(t,e,n,i,r,o,a){for(var s=function(t,e){var n=[];return e.diff(t).add(function(t){n.push({cmd:"+",idx:t})}).update(function(t,e){n.push({cmd:"=",idx:e,idx1:t})}).remove(function(t){n.push({cmd:"-",idx:t})}).execute(),n}(t,e),l=[],c=[],u=[],h=[],d=[],p=[],f=[],g=Nw(r,e,a),v=t.getLayout("points")||[],m=e.getLayout("points")||[],y=0;y3e3||l&&Xw(d,f)>3e3)return s.stopAnimation(),s.setShape({points:p}),void(l&&(l.stopAnimation(),l.setShape({points:p,stackedOnPoints:f})));s.shape.__points=u.current,s.shape.points=h;var g={shape:{points:p}};u.current!==h&&(g.shape.__points=u.next),s.stopAnimation(),dd(s,g,c),l&&(l.setShape({points:h,stackedOnPoints:d}),l.stopAnimation(),dd(l,{shape:{stackedOnPoints:f}},c),s.shape.points!==l.shape.points&&(l.shape.points=s.shape.points));for(var v=[],m=u.status,y=0;ye&&(e=t[n]);return isFinite(e)?e:NaN},min:function(t){for(var e=1/0,n=0;ne[1]&&e.reverse(),e},e.prototype.pointToData=function(t,e){return this.coordToData(this.toLocalCoord(t["x"===this.dim?0:1]),e)},e.prototype.setCategorySortInfo=function(t){if("category"!==this.type)return!1;this.model.option.categorySortInfo=t,this.scale.setSortInfo(t)},e}(yw),wS="expandAxisBreak",SS=Math.PI,CS=[[1,2,1,2],[5,3,5,3],[8,3,8,3]],MS=[[0,1,0,1],[0,3,0,3],[0,3,0,3]],kS=Es(),TS=Es(),DS=function(){function t(t){this.recordMap={},this.resolveAxisNameOverlap=t}return t.prototype.ensureRecord=function(t){var e=t.axis.dim,n=t.componentIndex,i=this.recordMap,r=i[e]||(i[e]=[]);return r[n]||(r[n]={ready:{}})},t}();var IS=[1,0,0,1,0,0],AS=new $i(0,0,0,0),PS=function(t,e,n,i,r,o){if(qb(t.nameLocation)){var a=o.stOccupiedRect;a&&LS(function(t,e,n){return t.transform=Ud(t.transform,n),t.localRect=Wd(t.localRect,e),t.rect=Wd(t.rect,e),n&&t.rect.applyTransform(n),t.axisAligned=$d(n),t.obb=void 0,(t.label=t.label||{}).ignore=!1,t}({},a,o.transGroup.transform),i,r)}else ES(o.labelInfoList,o.dirVec,i,r)};function LS(t,e,n){var i=new Ti;Tw(t,e,i,{direction:Math.atan2(n.y,n.x),bidirectional:!1,touchThreshold:.05})&&function(t,e){if(t){t.label.x+=e.x,t.label.y+=e.y,t.label.markRedraw();var n=t.transform;n&&(n[4]+=e.x,n[5]+=e.y);var i=t.rect;i&&(i.x+=e.x,i.y+=e.y);var r=t.obb;r&&r.fromBoundingRect(t.localRect,n)}}(e,i)}function ES(t,e,n,i){for(var r=Ti.dot(i,e)>=0,o=0,a=t.length;o0?"top":"bottom",i="center"):ss(o-SS)?(r=n>0?"bottom":"top",i="center"):(r="middle",i=o>0&&o0?"right":"left":n>0?"left":"right"),{rotation:o,textAlign:i,textVerticalAlign:r}},t.makeAxisEventDataBase=function(t){var e={componentType:t.mainType,componentIndex:t.componentIndex};return e[t.mainType+"Index"]=t.componentIndex,e},t.isLabelSilent=function(t){var e=t.get("tooltip");return t.get("silent")||!(t.get("triggerEvent")||e&&e.show)},t}(),OS=["axisLine","axisTickLabelEstimate","axisTickLabelDetermine","axisName"],NS={axisLine:function(t,e,n,i,r,o,a){var s=i.get(["axisLine","show"]);if("auto"===s&&(s=!0,null!=t.raw.axisLineAutoShow&&(s=!!t.raw.axisLineAutoShow)),s){var l=i.axis.getExtent(),c=o.transform,u=[l[0],0],h=[l[1],0],d=u[0]>h[0];c&&(jn(u,u,c),jn(h,h,c));var p=Ze({lineCap:"round"},i.getModel(["axisLine","lineStyle"]).getLineStyle()),f={strokeContainThreshold:t.raw.strokeContainThreshold||5,silent:!0,z2:1,style:p};if(i.get(["axisLine","breakLine"])&&i.axis.scale.hasBreaks())null.buildAxisBreakLine(i,r,o,f);else{var g=new Vh(Ze({shape:{x1:u[0],y1:u[1],x2:h[0],y2:h[1]}},f));Td(g.shape,g.style.lineWidth),g.anid="line",r.add(g)}var v=i.get(["axisLine","symbol"]);if(null!=v){var m=i.get(["axisLine","symbolSize"]);hn(v)&&(v=[v,v]),(hn(m)||pn(m))&&(m=[m,m]);var y=zy(i.get(["axisLine","symbolOffset"])||0,m),_=m[0],x=m[1];en([{rotate:t.rotation+Math.PI/2,offset:y[0],r:0},{rotate:t.rotation-Math.PI/2,offset:y[1],r:Math.sqrt((u[0]-h[0])*(u[0]-h[0])+(u[1]-h[1])*(u[1]-h[1]))}],function(e,n){if("none"!==v[n]&&null!=v[n]){var i=Ey(v[n],-_/2,-x/2,_,x,p.stroke,!0),o=e.r+e.offset,a=d?h:u;i.attr({rotation:e.rotate,x:a[0]+o*Math.cos(t.rotation),y:a[1]-o*Math.sin(t.rotation),silent:!0,z2:11}),r.add(i)}})}}},axisTickLabelEstimate:function(t,e,n,i,r,o,a,s){FS(e,r,s)&&RS(t,e,n,i,r,o,a,ew)},axisTickLabelDetermine:function(t,e,n,i,r,o,a,s){FS(e,r,s)&&RS(t,e,n,i,r,o,a,nw);var l=function(t,e,n,i){var r=i.axis,o=i.getModel("axisTick"),a=o.get("show");"auto"===a&&(a=!0,null!=t.raw.axisTickAutoShow&&(a=!!t.raw.axisTickAutoShow));if(!a||r.scale.isBlank())return[];for(var s=o.getModel("lineStyle"),l=t.tickDirection*o.get("length"),c=BS(r.getTicksCoords(),n.transform,l,Ke(s.getLineStyle(),{stroke:i.get(["axisLine","lineStyle","color"])}),"ticks"),u=0;ui[1],l="start"===e&&!s||"start"!==e&&s;ss(a-SS/2)?(o=l?"bottom":"top",r="center"):ss(a-1.5*SS)?(o=l?"top":"bottom",r="center"):(o="middle",r=a<1.5*SS&&a>SS/2?l?"left":"right":l?"right":"left");return{rotation:a,textAlign:r,textVerticalAlign:o}}(t.rotation,u,b||0,f),null!=(x=t.raw.axisNameAvailableWidth)&&(x=Math.abs(x/Math.sin(_.rotation)),!isFinite(x)&&(x=null)));var w=d.getFont(),S=i.get("nameTruncate",!0)||{},C=S.ellipsis,M=xn(t.raw.nameTruncateMaxWidth,S.maxWidth,x),k=s.nameMarginLevel||0,T=new Wc({x:v.x,y:v.y,rotation:_.rotation,silent:zS.isLabelSilent(i),style:Jd(d,{text:c,font:w,overflow:"truncate",width:M,ellipsis:C,fill:d.getTextColor()||i.get(["axisLine","lineStyle","color"]),align:d.get("align")||_.textAlign,verticalAlign:d.get("verticalAlign")||_.textVerticalAlign}),z2:1});if(Hd({el:T,componentModel:i,itemName:c}),T.__fullText=c,T.anid="name",i.get("triggerEvent")){var D=zS.makeAxisEventDataBase(i);D.targetType="axisName",D.name=c,nu(T).eventData=D}o.add(T),T.updateTransform(),e.nameEl=T;var I=l.nameLayout=Sw({label:T,priority:T.z2,defaultAttr:{ignore:T.ignore},marginDefault:qb(u)?CS[k]:MS[k]});if(l.nameLocation=u,r.add(T),T.decomposeTransform(),t.shouldNameMoveOverlap&&I){var A=n.ensureRecord(i);n.resolveAxisNameOverlap(t,n,i,I,m,A)}}}};function RS(t,e,n,i,r,o,a,s){$S(e)||function(t,e,n,i,r,o){var a=r.axis,s=xn(t.raw.axisLabelShow,r.get(["axisLabel","show"])),l=new Ua;n.add(l);var c=iw(i);if(!s||a.scale.isBlank())return void VS(e,[],l,c);var u=r.getModel("axisLabel"),h=a.getViewLabels(c),d=(xn(t.raw.labelRotate,u.get("rotate"))||0)*SS/180,p=zS.innerTextLayout(t.rotation,d,t.labelDirection),f=r.getCategories&&r.getCategories(!0),g=[],v=r.get("triggerEvent"),m=1/0,y=-1/0;en(h,function(t,e){var n,i="ordinal"===a.scale.type?a.scale.getRawOrdinalNumber(t.tickValue):t.tickValue,s=t.formattedLabel,c=t.rawLabel,d=u;if(f&&f[i]){var _=f[i];fn(_)&&_.textStyle&&(d=new _p(_.textStyle,u,r.ecModel))}var x=d.getTextColor()||r.get(["axisLine","lineStyle","color"]),b=d.getShallow("align",!0)||p.textAlign,w=bn(d.getShallow("alignMinLabel",!0),b),S=bn(d.getShallow("alignMaxLabel",!0),b),C=d.getShallow("verticalAlign",!0)||d.getShallow("baseline",!0)||p.textVerticalAlign,M=bn(d.getShallow("verticalAlignMinLabel",!0),C),k=bn(d.getShallow("verticalAlignMaxLabel",!0),C),T=10+((null===(n=t.time)||void 0===n?void 0:n.level)||0);m=Math.min(m,T),y=Math.max(y,T);var D=new Wc({x:0,y:0,rotation:0,silent:zS.isLabelSilent(r),z2:T,style:Jd(d,{text:s,align:0===e?w:e===h.length-1?S:b,verticalAlign:0===e?M:e===h.length-1?k:C,fill:un(x)?x("category"===a.type?c:"value"===a.type?i+"":i,e):x})});D.anid="label_"+i;var I=kS(D);if(I.break=t.break,I.tickValue=i,I.layoutRotation=p.rotation,Hd({el:D,componentModel:r,itemName:s,formatterParamsExtra:{isTruncated:function(){return D.isTruncated},value:c,tickIndex:e}}),v){var A=zS.makeAxisEventDataBase(r);A.targetType="axisLabel",A.value=c,A.tickIndex=e,t.break&&(A.break={start:t.break.parsedBreak.vmin,end:t.break.parsedBreak.vmax}),"category"===a.type&&(A.dataIndex=i),nu(D).eventData=A,t.break&&function(t,e,n,i){n.on("click",function(n){var r={type:wS,breaks:[{start:i.parsedBreak.breakOption.start,end:i.parsedBreak.breakOption.end}]};r[t.axis.dim+"AxisIndex"]=t.componentIndex,e.dispatchAction(r)})}(r,o,D,t.break)}g.push(D),l.add(D)});var _=nn(g,function(t){return{label:t,priority:kS(t).break?t.z2+(y-m+1):t.z2,defaultAttr:{ignore:t.ignore}}});VS(e,_,l,c)}(t,e,r,s,i,a);var l=e.labelLayoutList;!function(t,e,n,i){var r=e.get(["axisLabel","margin"]);en(n,function(n,o){var a=Sw(n);if(a){var s=a.label,l=kS(s);a.suggestIgnore=s.ignore,s.ignore=!1,xa(WS,US),WS.x=e.axis.dataToCoord(l.tickValue),WS.y=t.labelOffset+t.labelDirection*r,WS.rotation=l.layoutRotation,i.add(WS),WS.updateTransform(),i.remove(WS),WS.decomposeTransform(),xa(s,WS),s.markRedraw(),bw(a,!0),Sw(a)}})}(t,i,l,o),t.rotation;var c=t.optionHideOverlap;!function(t,e,n){if(Ub(t.axis))return;function i(t,i,r){var o=Sw(e[i]),a=Sw(e[r]);if(o&&a)if(!1===t||o.suggestIgnore)HS(o.label);else if(a.suggestIgnore)HS(a.label);else{var s=.1;if(!n){var l=[0,0,0,0];o=Mw({marginForce:l},o),a=Mw({marginForce:l},a)}Tw(o,a,null,{touchThreshold:s})&&HS(t?a.label:o.label)}}var r=t.get(["axisLabel","showMinLabel"]),o=t.get(["axisLabel","showMaxLabel"]),a=e.length;i(r,0,1),i(o,a-1,a-2)}(i,l,c),c&&function(t){var e=[];function n(t){if(!t.ignore){var e=t.ensureState("emphasis");null==e.ignore&&(e.ignore=!1)}t.ignore=!0}t.sort(function(t,e){return(e.suggestIgnore?1:0)-(t.suggestIgnore?1:0)||e.priority-t.priority});for(var i=0;i.1?"x":"y",u=a.transGroup[c];if(s.sort(function(t,e){return Math.abs(t.label[c]-u)-Math.abs(e.label[c]-u)}),l&&r){var h=o.getExtent(),d=Math.min(h[0],h[1]),p=Math.max(h[0],h[1])-d;r.union(new $i(d,0,p,1))}a.stOccupiedRect=r,a.labelInfoList=s}(t,n,i,l)}function HS(t){t&&(t.ignore=!0)}function BS(t,e,n,i,r){for(var o=[],a=[],s=[],l=0;l=0;o--){var a=t[+n[o]],s=a.model,l=a.scale;Kx(l)&&s.get("alignTicks")&&null==s.get("interval")?r.push(a):(Fb(l,s),Kx(l)&&(e=a))}r.length&&(e||Fb((e=r.pop()).scale,e.model),en(r,function(t){!function(t,e,n){var i=pb.prototype,r=i.getTicks.call(n),o=i.getTicks.call(n,{expandToNicedExtent:!0}),a=r.length-1,s=i.getInterval.call(n),l=Bb(t,e),c=l.extent,u=l.fixMin,h=l.fixMax;"log"===t.type&&(c=ab(t.base,c,!0)),t.setBreaksFromOption(Xb(e)),t.setExtent(c[0],c[1]),t.calcNiceExtent({splitNumber:a,fixMin:u,fixMax:h});var d=i.getExtent.call(t);u&&(c[0]=d[0]),h&&(c[1]=d[1]);var p=i.getInterval.call(t),f=c[0],g=c[1];if(u&&h)p=(g-f)/a;else if(u)for(g=c[0]+p*a;gc[0]&&isFinite(f)&&isFinite(c[0]);)p=Jx(p),f=c[1]-p*a;else{t.getTicks().length-1>a&&(p=Jx(p));var v=p*a;(f=is((g=Math.ceil(c[1]/p)*p)-v))<0&&c[0]>=0?(f=0,g=is(v)):g>0&&c[1]<=0&&(g=0,f=-is(v))}var m=(r[0].value-o[0].value)/s,y=(r[a].value-o[a].value)/s;i.setExtent.call(t,f+p*m,g+p*y),i.setInterval.call(t,p),(m||y)&&i.setNiceExtent.call(t,f+p,g-p)}(t.scale,t.model,e.scale)}))}}this._updateScale(t,this.model),i(n.x),i(n.y);var r={};en(n.x,function(t){KS(n,"y",t,r)}),en(n.y,function(t){KS(n,"x",t,r)}),this.resize(this.model,e)},t.prototype.resize=function(t,e,n){var i=Ef(t,e),r=this._rect=Pf(t.getBoxLayoutParams(),i.refContainer),o=this._axesMap,a=this._coordsList,s=t.get("containLabel");if(JS(o,r),!n){var l=function(t,e,n,i,r){var o=new DS(iC);return en(n,function(n){return en(n,function(n){if(jb(n.model)){var a=!i;n.axisBuilder=function(t,e,n,i,r,o){for(var a=qS(t,n),s=!1,l=!1,c=0;c0&&i>0||n<0&&i<0)}(t)}function JS(t,e){en(t.x,function(t){return tC(t,e.x,e.width)}),en(t.y,function(t){return tC(t,e.y,e.height)})}function tC(t,e,n){var i=[0,n],r=t.inverse?1:0;t.setExtent(i[r],i[1-r]),function(t,e){var n=t.getExtent(),i=n[0]+n[1];t.toGlobalCoord="x"===t.dim?function(t){return t+e}:function(t){return i-t+e},t.toLocalCoord="x"===t.dim?function(t){return t-e}:function(t){return i-t+e}}(t,e)}function eC(t,e,n,i,r,o,a){nC(i,r,ew,e,!1,a);var s=[0,0,0,0];c(0),c(1),u(i,0,NaN),u(i,1,NaN);var l=null==function(t,e,n){if(t&&e)for(var i=0,r=t.length;i0});return Od(i,s,!0,!0,n),JS(r,i),l;function c(t){en(r[yd[t]],function(e){if(jb(e.model)){var n=o.ensureRecord(e.model),i=n.labelInfoList;if(i)for(var r=0;r0&&!_n(e)&&e>1e-4&&(t/=e),t}}function nC(t,e,n,i,r,o){var a=n===nw;en(e,function(e){return en(e,function(e){jb(e.model)&&(!function(t,e,n){var i=qS(e,n);t.updateCfg(i)}(e.axisBuilder,t,e.model),e.axisBuilder.build(a?{axisTickLabelDetermine:!0}:{axisTickLabelEstimate:!0},{noPxChange:r}))})});var s={x:0,y:0};function l(e){s[yd[1-e]]=t[_d[e]]<=.5*o.refContainer[_d[e]]?0:1-e==1?2:1}l(0),l(1),en(e,function(t,e){return en(t,function(t){jb(t.model)&&(("all"===i||a)&&t.axisBuilder.build({axisName:!0},{nameMarginLevel:s[e]}),a&&t.axisBuilder.build({axisLine:!0}))})})}var iC=function(t,e,n,i,r,o){var a="x"===n.axis.dim?"y":"x";PS(t,0,0,i,r,o),qb(t.nameLocation)||en(e.recordMap[a],function(t){t&&t.labelInfoList&&t.dirVec&&ES(t.labelInfoList,t.dirVec,i,r)})};function rC(t,e){var n={axesInfo:{},seriesInvolved:!1,coordSysAxesInfo:{},coordSysMap:{}};return function(t,e,n){var i=e.getComponent("tooltip"),r=e.getComponent("axisPointer"),o=r.get("link",!0)||[],a=[];en(n.getCoordinateSystems(),function(n){if(n.axisPointerEnabled){var s=lC(n.model),l=t.coordSysAxesInfo[s]={};t.coordSysMap[s]=n;var c=n.model.getModel("tooltip",i);if(en(n.getAxes(),ln(p,!1,null)),n.getTooltipAxes&&i&&c.get("show")){var u="axis"===c.get("trigger"),h="cross"===c.get(["axisPointer","type"]),d=n.getTooltipAxes(c.get(["axisPointer","axis"]));(u||h)&&en(d.baseAxes,ln(p,!h||"cross",u)),h&&en(d.otherAxes,ln(p,"cross",!1))}}function p(i,s,u){var h=u.model.getModel("axisPointer",r),d=h.get("show");if(d&&("auto"!==d||i||sC(h))){null==s&&(s=h.get("triggerTooltip")),h=i?function(t,e,n,i,r,o){var a=e.getModel("axisPointer"),s={};en(["type","snap","lineStyle","shadowStyle","label","animation","animationDurationUpdate","animationEasingUpdate","z"],function(t){s[t]=Xe(a.get(t))}),s.snap="category"!==t.type&&!!o,"cross"===a.get("type")&&(s.type="line");var l=s.label||(s.label={});if(null==l.show&&(l.show=!1),"cross"===r){var c=a.get(["label","show"]);if(l.show=null==c||c,!o){var u=s.lineStyle=a.get("crossStyle");u&&Ke(l,u.textStyle)}}return t.model.getModel("axisPointer",new _p(s,n,i))}(u,c,r,e,i,s):h;var p=h.get("snap"),f=h.get("triggerEmphasis"),g=lC(u.model),v=s||p||"category"===u.type,m=t.axesInfo[g]={key:g,axis:u,coordSys:n,axisPointerModel:h,triggerTooltip:s,triggerEmphasis:f,involveSeries:v,snap:p,useHandle:sC(h),seriesModels:[],linkGroup:null};l[g]=m,t.seriesInvolved=t.seriesInvolved||v;var y=function(t,e){for(var n=e.model,i=e.dim,r=0;r=0||t===e}function aC(t){var e=(t.ecModel.getComponent("axisPointer")||{}).coordSysAxesInfo;return e&&e.axesInfo[lC(t)]}function sC(t){return!!t.get(["handle","show"])}function lC(t){return t.type+"||"+t.id}var cC={},uC=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return _(e,t),e.prototype.render=function(e,n,i,r){this.axisPointerClass&&function(t){var e=aC(t);if(e){var n=e.axisPointerModel,i=e.axis.scale,r=n.option,o=n.get("status"),a=n.get("value");null!=a&&(a=i.parse(a));var s=sC(n);null==o&&(r.status=s?"show":"hide");var l=i.getExtent().slice();l[0]>l[1]&&l.reverse(),(null==a||a>l[1])&&(a=l[1]),aa)return!0;if(o){var s=aC(t).seriesDataCount,l=i.getExtent();return Math.abs(l[0]-l[1])/s>a}return!1}return!0===n},t.prototype.makeElOption=function(t,e,n,i,r){},t.prototype.createPointerEl=function(t,e,n,i){var r=e.pointer;if(r){var o=xC(t).pointerEl=new Xd[r.type](bC(e.pointer));t.add(o)}},t.prototype.createLabelEl=function(t,e,n,i){if(e.label){var r=xC(t).labelEl=new Wc(bC(e.label));t.add(r),kC(r,i)}},t.prototype.updatePointerEl=function(t,e,n){var i=xC(t).pointerEl;i&&e.pointer&&(i.setStyle(e.pointer.style),n(i,{shape:e.pointer.shape}))},t.prototype.updateLabelEl=function(t,e,n,i){var r=xC(t).labelEl;r&&(r.setStyle(e.label.style),n(r,{x:e.label.x,y:e.label.y}),kC(r,i))},t.prototype._renderHandle=function(t){if(!this._dragging&&this.updateHandleTransform){var e,n=this._axisPointerModel,i=this._api.getZr(),r=this._handle,o=n.getModel("handle"),a=n.get("status");if(!o.get("show")||!a||"hide"===a)return r&&i.remove(r),void(this._handle=null);this._handle||(e=!0,r=this._handle=Ld(o.get("icon"),{cursor:"move",draggable:!0,onmousemove:function(t){mi(t.event)},onmousedown:wC(this._onHandleDragMove,this,0,0),drift:wC(this._onHandleDragMove,this),ondragend:wC(this._onHandleDragEnd,this)}),i.add(r)),DC(r,n,!1),r.setStyle(o.getItemStyle(null,["color","borderColor","borderWidth","opacity","shadowColor","shadowBlur","shadowOffsetX","shadowOffsetY"]));var s=o.get("size");cn(s)||(s=[s,s]),r.scaleX=s[0]/2,r.scaleY=s[1]/2,Nm(this,"_doDispatchAxisPointer",o.get("throttle")||0,"fixRate"),this._moveHandleToValue(t,e)}},t.prototype._moveHandleToValue=function(t,e){CC(this._axisPointerModel,!e&&this._moveAnimation,this._handle,TC(this.getHandleTransform(t,this._axisModel,this._axisPointerModel)))},t.prototype._onHandleDragMove=function(t,e){var n=this._handle;if(n){this._dragging=!0;var i=this.updateHandleTransform(TC(n),[t,e],this._axisModel,this._axisPointerModel);this._payloadInfo=i,n.stopAnimation(),n.attr(TC(i)),xC(n).lastProp=null,this._doDispatchAxisPointer()}},t.prototype._doDispatchAxisPointer=function(){if(this._handle){var t=this._payloadInfo,e=this._axisModel;this._api.dispatchAction({type:"updateAxisPointer",x:t.cursorPoint[0],y:t.cursorPoint[1],tooltipOption:t.tooltipOption,axesInfo:[{axisDim:e.axis.dim,axisIndex:e.componentIndex}]})}},t.prototype._onHandleDragEnd=function(){if(this._dragging=!1,this._handle){var t=this._axisPointerModel.get("value");this._moveHandleToValue(t),this._api.dispatchAction({type:"hideTip"})}},t.prototype.clear=function(t){this._lastValue=null,this._lastStatus=null;var e=t.getZr(),n=this._group,i=this._handle;e&&n&&(this._lastGraphicKey=null,n&&e.remove(n),i&&e.remove(i),this._group=null,this._handle=null,this._payloadInfo=null),Rm(this,"_doDispatchAxisPointer")},t.prototype.doClear=function(){},t.prototype.buildLabel=function(t,e,n){return{x:t[n=n||0],y:t[1-n],width:e[n],height:e[1-n]}},t}();function CC(t,e,n,i){MC(xC(n).lastProp,i)||(xC(n).lastProp=i,e?dd(n,i,t):(n.stopAnimation(),n.attr(i)))}function MC(t,e){if(fn(t)&&fn(e)){var n=!0;return en(e,function(e,i){n=n&&MC(t[i],e)}),!!n}return t===e}function kC(t,e){t[e.get(["label","show"])?"show":"hide"]()}function TC(t){return{x:t.x||0,y:t.y||0,rotation:t.rotation||0}}function DC(t,e,n){var i=e.get("z"),r=e.get("zlevel");t&&t.traverse(function(t){"group"!==t.type&&(null!=i&&(t.z=i),null!=r&&(t.zlevel=r),t.silent=n)})}function IC(t,e,n,i,r){var o=AC(n.get("value"),e.axis,e.ecModel,n.get("seriesDataIndices"),{precision:n.get(["label","precision"]),formatter:n.get(["label","formatter"])}),a=n.getModel("label"),s=hf(a.get("padding")||0),l=a.getFont(),c=Ta(o,l),u=r.position,h=c.width+s[1]+s[3],d=c.height+s[0]+s[2],p=r.align;"right"===p&&(u[0]-=h),"center"===p&&(u[0]-=h/2);var f=r.verticalAlign;"bottom"===f&&(u[1]-=d),"middle"===f&&(u[1]-=d/2),function(t,e,n,i){var r=i.getWidth(),o=i.getHeight();t[0]=Math.min(t[0]+e,r)-e,t[1]=Math.min(t[1]+n,o)-n,t[0]=Math.max(t[0],0),t[1]=Math.max(t[1],0)}(u,h,d,i);var g=a.get("backgroundColor");g&&"auto"!==g||(g=e.get(["axisLine","lineStyle","color"])),t.label={x:u[0],y:u[1],style:Jd(a,{text:o,font:l,fill:a.getTextColor(),padding:s,backgroundColor:g}),z2:10}}function AC(t,e,n,i,r){t=e.scale.parse(t);var o=e.scale.getLabel({value:t},{precision:r.precision}),a=r.formatter;if(a){var s={value:Vb(e,{value:t}),axisDimension:e.dim,axisIndex:e.index,seriesData:[]};en(i,function(t){var e=n.getSeriesByIndex(t.seriesIndex),i=t.dataIndexInside,r=e&&e.getDataParams(i);r&&s.seriesData.push(r)}),hn(a)?o=a.replace("{value}",o):un(a)&&(o=a(s))}return o}function PC(t,e,n){var i=[1,0,0,1,0,0];return Mi(i,i,n.rotation),Ci(i,i,n.position),Id([t.dataToCoord(e),(n.labelOffset||0)+(n.labelDirection||1)*(n.labelMargin||0)],i)}var LC=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return _(e,t),e.prototype.makeElOption=function(t,e,n,i,r){var o=n.axis,a=o.grid,s=i.get("type"),l=EC(a,o).getOtherAxis(o).getGlobalExtent(),c=o.toGlobalCoord(o.dataToCoord(e,!0));if(s&&"none"!==s){var u=function(t){var e,n=t.get("type"),i=t.getModel(n+"Style");return"line"===n?(e=i.getLineStyle()).fill=null:"shadow"===n&&((e=i.getAreaStyle()).stroke=null),e}(i),h=zC[s](o,c,l);h.style=u,t.graphicKey=h.type,t.pointer=h}!function(t,e,n,i,r,o){var a=zS.innerTextLayout(n.rotation,0,n.labelDirection);n.labelMargin=r.get(["label","margin"]),IC(e,i,r,o,{position:PC(i.axis,t,n),align:a.textAlign,verticalAlign:a.textVerticalAlign})}(e,t,qS(a.getRect(),n),n,i,r)},e.prototype.getHandleTransform=function(t,e,n){var i=qS(e.axis.grid.getRect(),e,{labelInside:!1});i.labelMargin=n.get(["handle","margin"]);var r=PC(e.axis,t,i);return{x:r[0],y:r[1],rotation:i.rotation+(i.labelDirection<0?Math.PI:0)}},e.prototype.updateHandleTransform=function(t,e,n,i){var r=n.axis,o=r.grid,a=r.getGlobalExtent(!0),s=EC(o,r).getOtherAxis(r).getGlobalExtent(),l="x"===r.dim?0:1,c=[t.x,t.y];c[l]+=e[l],c[l]=Math.min(a[1],c[l]),c[l]=Math.max(a[0],c[l]);var u=(s[1]+s[0])/2,h=[u,u];h[l]=c[l];return{x:c[0],y:c[1],rotation:t.rotation,cursorPoint:h,tooltipOption:[{verticalAlign:"middle"},{align:"center"}][l]}},e}(SC);function EC(t,e){var n={};return n[e.dim+"AxisIndex"]=e.index,t.getCartesian(n)}var zC={line:function(t,e,n){var i,r,o;return{type:"Line",subPixelOptimize:!0,shape:(i=[e,n[0]],r=[e,n[1]],o=OC(t),{x1:i[o=o||0],y1:i[1-o],x2:r[o],y2:r[1-o]})}},shadow:function(t,e,n){var i,r,o,a=Math.max(1,t.getBandWidth()),s=n[1]-n[0];return{type:"Rect",shape:(i=[e-a/2,n[0]],r=[a,s],o=OC(t),{x:i[o=o||0],y:i[1-o],width:r[o],height:r[1-o]})}}};function OC(t){return"x"===t.dim?0:1}var NC=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return _(e,t),e.type="axisPointer",e.defaultOption={show:"auto",z:50,type:"line",snap:!1,triggerTooltip:!0,triggerEmphasis:!0,value:null,status:null,link:[],animation:null,animationDurationUpdate:200,lineStyle:{color:Bf.color.border,width:1,type:"dashed"},shadowStyle:{color:Bf.color.shadowTint},label:{show:!0,formatter:null,precision:"auto",margin:3,color:Bf.color.neutral00,padding:[5,7,5,7],backgroundColor:Bf.color.accent60,borderColor:null,borderWidth:0,borderRadius:3},handle:{show:!1,icon:"M10.7,11.9v-1.3H9.3v1.3c-4.9,0.3-8.8,4.4-8.8,9.4c0,5,3.9,9.1,8.8,9.4h1.3c4.9-0.3,8.8-4.4,8.8-9.4C19.5,16.3,15.6,12.2,10.7,11.9z M13.3,24.4H6.7v-1.2h6.6z M13.3,22H6.7v-1.2h6.6z M13.3,19.6H6.7v-1.2h6.6z",size:45,margin:50,color:Bf.color.accent40,throttle:40}},e}(Hf),RC=Es(),HC=en;function BC(t,e,n){if(!Te.node){var i=e.getZr();RC(i).records||(RC(i).records={}),function(t,e){if(RC(t).initialized)return;function n(n,i){t.on(n,function(n){var r=function(t){var e={showTip:[],hideTip:[]},n=function(i){var r=e[i.type];r?r.push(i):(i.dispatchAction=n,t.dispatchAction(i))};return{dispatchAction:n,pendings:e}}(e);HC(RC(t).records,function(t){t&&i(t,n,r.dispatchAction)}),function(t,e){var n,i=t.showTip.length,r=t.hideTip.length;i?n=t.showTip[i-1]:r&&(n=t.hideTip[r-1]);n&&(n.dispatchAction=null,e.dispatchAction(n))}(r.pendings,e)})}RC(t).initialized=!0,n("click",ln($C,"click")),n("mousemove",ln($C,"mousemove")),n("globalout",FC)}(i,e),(RC(i).records[t]||(RC(i).records[t]={})).handler=n}}function FC(t,e,n){t.handler("leave",null,n)}function $C(t,e,n,i){e.handler(t,n,i)}function VC(t,e){if(!Te.node){var n=e.getZr();(RC(n).records||{})[t]&&(RC(n).records[t]=null)}}var WC=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return _(e,t),e.prototype.render=function(t,e,n){var i=e.getComponent("tooltip"),r=t.get("triggerOn")||i&&i.get("triggerOn")||"mousemove|click";BC("axisPointer",n,function(t,e,n){"none"!==r&&("leave"===t||r.indexOf(t)>=0)&&n({type:"updateAxisPointer",currTrigger:t,x:e&&e.offsetX,y:e&&e.offsetY})})},e.prototype.remove=function(t,e){VC("axisPointer",e)},e.prototype.dispose=function(t,e){VC("axisPointer",e)},e.type="axisPointer",e}(wm);function UC(t,e){var n,i=[],r=t.seriesIndex;if(null==r||!(n=e.getSeriesByIndex(r)))return{point:[]};var o=n.getData(),a=Ls(o,t);if(null==a||a<0||cn(a))return{point:[]};var s=o.getItemGraphicEl(a),l=n.coordinateSystem;if(n.getTooltipPosition)i=n.getTooltipPosition(a)||[];else if(l&&l.dataToPoint)if(t.isStacked){var c=l.getBaseAxis(),u=l.getOtherAxis(c).dim,h=c.dim,d="x"===u||"radius"===u?1:0,p=o.mapDimension(h),f=[];f[d]=o.get(p,a),f[1-d]=o.get(o.getCalculationInfo("stackResultDimension"),a),i=l.dataToPoint(f)||[]}else i=l.dataToPoint(o.getValues(nn(l.dimensions,function(t){return o.mapDimension(t)}),a))||[];else if(s){var g=s.getBoundingRect().clone();g.applyTransform(s.transform),i=[g.x+g.width/2,g.y+g.height/2]}return{point:i,el:s}}var GC=Es();function qC(t,e,n){var i=t.currTrigger,r=[t.x,t.y],o=t,a=t.dispatchAction||sn(n.dispatchAction,n),s=e.getComponent("axisPointer").coordSysAxesInfo;if(s){KC(r)&&(r=UC({seriesIndex:o.seriesIndex,dataIndex:o.dataIndex},e).point);var l=KC(r),c=o.axesInfo,u=s.axesInfo,h="leave"===i||KC(r),d={},p={},f={list:[],map:{}},g={showPointer:ln(XC,p),showTooltip:ln(YC,f)};en(s.coordSysMap,function(t,e){var n=l||t.containPoint(r);en(s.coordSysAxesInfo[e],function(t,e){var i=t.axis,o=function(t,e){for(var n=0;n<(t||[]).length;n++){var i=t[n];if(e.axis.dim===i.axisDim&&e.axis.model.componentIndex===i.axisIndex)return i}}(c,t);if(!h&&n&&(!c||o)){var a=o&&o.value;null!=a||l||(a=i.pointToData(r)),null!=a&&jC(t,a,g,!1,d)}})});var v={};return en(u,function(t,e){var n=t.linkGroup;n&&!p[e]&&en(n.axesInfo,function(e,i){var r=p[i];if(e!==t&&r){var o=r.value;n.mapper&&(o=t.axis.scale.parse(n.mapper(o,ZC(e),ZC(t)))),v[t.key]=o}})}),en(v,function(t,e){jC(u[e],t,g,!0,d)}),function(t,e,n){var i=n.axesInfo=[];en(e,function(e,n){var r=e.axisPointerModel.option,o=t[n];o?(!e.useHandle&&(r.status="show"),r.value=o.value,r.seriesDataIndices=(o.payloadBatch||[]).slice()):!e.useHandle&&(r.status="hide"),"show"===r.status&&i.push({axisDim:e.axis.dim,axisIndex:e.axis.model.componentIndex,value:r.value})})}(p,u,d),function(t,e,n,i){if(KC(e)||!t.list.length)return void i({type:"hideTip"});var r=((t.list[0].dataByAxis[0]||{}).seriesDataIndices||[])[0]||{};i({type:"showTip",escapeConnect:!0,x:e[0],y:e[1],tooltipOption:n.tooltipOption,position:n.position,dataIndexInside:r.dataIndexInside,dataIndex:r.dataIndex,seriesIndex:r.seriesIndex,dataByCoordSys:t.list})}(f,r,t,a),function(t,e,n){var i=n.getZr(),r="axisPointerLastHighlights",o=GC(i)[r]||{},a=GC(i)[r]={};en(t,function(t,e){var n=t.axisPointerModel.option;"show"===n.status&&t.triggerEmphasis&&en(n.seriesDataIndices,function(t){var e=t.seriesIndex+" | "+t.dataIndex;a[e]=t})});var s=[],l=[];en(o,function(t,e){!a[e]&&l.push(t)}),en(a,function(t,e){!o[e]&&s.push(t)}),l.length&&n.dispatchAction({type:"downplay",escapeConnect:!0,notBlur:!0,batch:l}),s.length&&n.dispatchAction({type:"highlight",escapeConnect:!0,notBlur:!0,batch:s})}(u,0,n),d}}function jC(t,e,n,i,r){var o=t.axis;if(!o.scale.isBlank()&&o.containData(e))if(t.involveSeries){var a=function(t,e){var n=e.axis,i=n.dim,r=t,o=[],a=Number.MAX_VALUE,s=-1;return en(e.seriesModels,function(e,l){var c,u,h=e.getData().mapDimensionsAll(i);if(e.getAxisTooltipData){var d=e.getAxisTooltipData(h,t,n);u=d.dataIndices,c=d.nestestValue}else{if(!(u=e.indicesOfNearest(i,h[0],t,"category"===n.type?.5:null)).length)return;c=e.getData().get(h[0],u[0])}if(null!=c&&isFinite(c)){var p=t-c,f=Math.abs(p);f<=a&&((f=0&&s<0)&&(a=f,s=p,r=c,o.length=0),en(u,function(t){o.push({seriesIndex:e.seriesIndex,dataIndexInside:t,dataIndex:e.getData().getRawIndex(t)})}))}}),{payloadBatch:o,snapToValue:r}}(e,t),s=a.payloadBatch,l=a.snapToValue;s[0]&&null==r.seriesIndex&&Ze(r,s[0]),!i&&t.snap&&o.containData(l)&&null!=l&&(e=l),n.showPointer(t,e,s),n.showTooltip(t,a,l)}else n.showPointer(t,e)}function XC(t,e,n,i){t[e.key]={value:n,payloadBatch:i}}function YC(t,e,n,i){var r=n.payloadBatch,o=e.axis,a=o.model,s=e.axisPointerModel;if(e.triggerTooltip&&r.length){var l=e.coordSys.model,c=lC(l),u=t.map[c];u||(u=t.map[c]={coordSysId:l.id,coordSysIndex:l.componentIndex,coordSysType:l.type,coordSysMainType:l.mainType,dataByAxis:[]},t.list.push(u)),u.dataByAxis.push({axisDim:o.dim,axisIndex:a.componentIndex,axisType:a.type,axisId:a.id,value:i,valueLabelOpt:{precision:s.get(["label","precision"]),formatter:s.get(["label","formatter"])},seriesDataIndices:r.slice()})}}function ZC(t){var e=t.axis.model,n={},i=n.axisDim=t.axis.dim;return n.axisIndex=n[i+"AxisIndex"]=e.componentIndex,n.axisName=n[i+"AxisName"]=e.name,n.axisId=n[i+"AxisId"]=e.id,n}function KC(t){return!t||null==t[0]||isNaN(t[0])||null==t[1]||isNaN(t[1])}function QC(t){uC.registerAxisPointerClass("CartesianAxisPointer",LC),t.registerComponentModel(NC),t.registerComponentView(WC),t.registerPreprocessor(function(t){if(t){(!t.axisPointer||0===t.axisPointer.length)&&(t.axisPointer={});var e=t.axisPointer.link;e&&!cn(e)&&(t.axisPointer.link=[e])}}),t.registerProcessor(t.PRIORITY.PROCESSOR.STATISTIC,function(t,e){t.getComponent("axisPointer").coordSysAxesInfo=rC(t,e)}),t.registerAction({type:"updateAxisPointer",event:"updateAxisPointer",update:":updateAxisPointer"},qC)}var JC=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return _(e,t),e.type="tooltip",e.dependencies=["axisPointer"],e.defaultOption={z:60,show:!0,showContent:!0,trigger:"item",triggerOn:"mousemove|click",alwaysShowContent:!1,renderMode:"auto",confine:null,showDelay:0,hideDelay:100,transitionDuration:.4,displayTransition:!0,enterable:!1,backgroundColor:Bf.color.neutral00,shadowBlur:10,shadowColor:"rgba(0, 0, 0, .2)",shadowOffsetX:1,shadowOffsetY:2,borderRadius:4,borderWidth:1,defaultBorderColor:Bf.color.border,padding:null,extraCssText:"",axisPointer:{type:"line",axis:"auto",animation:"auto",animationDurationUpdate:200,animationEasingUpdate:"exponentialOut",crossStyle:{color:Bf.color.borderShade,width:1,type:"dashed",textStyle:{}}},textStyle:{color:Bf.color.tertiary,fontSize:14}},e}(Hf);function tM(t){var e=t.get("confine");return null!=e?!!e:"richText"===t.get("renderMode")}function eM(t){if(Te.domSupported)for(var e=document.documentElement.style,n=0,i=t.length;n0&&r.push(function(t,e,n){var i="cubic-bezier(0.23,1,0.32,1)",r="",o="";return n&&(o="opacity"+(r=" "+t/2+"s "+i)+",visibility"+r),e||(r=" "+t+"s "+i,o+=(o.length?",":"")+(Te.transformSupported?""+oM+r:",left"+r+",top"+r)),rM+":"+o}(o,n,i)),a&&r.push("background-color:"+a),en(["width","color","radius"],function(e){var n="border-"+e,i=uf(n),o=t.get(i);null!=o&&r.push(n+":"+o+("color"===e?"":"px"))}),r.push(function(t){var e=[],n=t.get("fontSize"),i=t.getTextColor();i&&e.push("color:"+i),e.push("font:"+t.getFont());var r=bn(t.get("lineHeight"),Math.round(3*n/2));n&&e.push("line-height:"+r+"px");var o=t.get("textShadowColor"),a=t.get("textShadowBlur")||0,s=t.get("textShadowOffsetX")||0,l=t.get("textShadowOffsetY")||0;return o&&a&&e.push("text-shadow:"+s+"px "+l+"px "+a+"px "+o),en(["decoration","align"],function(n){var i=t.get(n);i&&e.push("text-"+n+":"+i)}),e.join(";")}(h)),null!=d&&r.push("padding:"+hf(d).join("px ")+"px"),r.join(";")+";"}function cM(t,e,n,i,r){var o=e&&e.painter;if(n){var a=o&&o.getViewportRoot();a&&function(t,e,n,i,r){ri(ii,e,i,r,!0)&&ri(t,n,ii[0],ii[1])}(t,a,n,i,r)}else{t[0]=i,t[1]=r;var s=o&&o.getViewportRootOffset();s&&(t[0]+=s.offsetLeft,t[1]+=s.offsetTop)}t[2]=t[0]/e.getWidth(),t[3]=t[1]/e.getHeight()}var uM=function(){function t(t,e){if(this._show=!1,this._styleCoord=[0,0,0,0],this._enterable=!0,this._alwaysShowContent=!1,this._firstShow=!0,this._longHide=!0,Te.wxa)return null;var n=document.createElement("div");n.domBelongToZr=!0,this.el=n;var i=this._zr=t.getZr(),r=e.appendTo,o=r&&(hn(r)?document.querySelector(r):mn(r)?r:un(r)&&r(t.getDom()));cM(this._styleCoord,i,o,t.getWidth()/2,t.getHeight()/2),(o||t.getDom()).appendChild(n),this._api=t,this._container=o;var a=this;n.onmouseenter=function(){a._enterable&&(clearTimeout(a._hideTimeout),a._show=!0),a._inContent=!0},n.onmousemove=function(t){if(t=t||window.event,!a._enterable){var e=i.handler;gi(i.painter.getViewportRoot(),t,!0),e.dispatch("mousemove",t)}},n.onmouseleave=function(){a._inContent=!1,a._enterable&&a._show&&a.hideLater(a._hideDelay)}}return t.prototype.update=function(t){if(!this._container){var e=this._api.getDom(),n=(o="position",(a=(r=e).currentStyle||document.defaultView&&document.defaultView.getComputedStyle(r))?a[o]:null),i=e.style;"absolute"!==i.position&&"absolute"!==n&&(i.position="relative")}var r,o,a,s=t.get("alwaysShowContent");s&&this._moveIfResized(),this._alwaysShowContent=s,this._enableDisplayTransition=t.get("displayTransition")&&t.get("transitionDuration")>0,this.el.className=t.get("className")||""},t.prototype.show=function(t,e){clearTimeout(this._hideTimeout),clearTimeout(this._longHideTimeout);var n=this.el,i=n.style,r=this._styleCoord;n.innerHTML?i.cssText=aM+lM(t,!this._firstShow,this._longHide,this._enableDisplayTransition)+sM(r[0],r[1],!0)+"border-color:"+vf(e)+";"+(t.get("extraCssText")||"")+";pointer-events:"+(this._enterable?"auto":"none"):i.display="none",this._show=!0,this._firstShow=!1,this._longHide=!1},t.prototype.setContent=function(t,e,n,i,r){var o=this.el;if(null!=t){var a="";if(hn(r)&&"item"===n.get("trigger")&&!tM(n)&&(a=function(t,e,n){if(!hn(n)||"inside"===n)return"";var i=t.get("backgroundColor"),r=t.get("borderWidth");e=vf(e);var o,a,s="left"===(o=n)?"right":"right"===o?"left":"top"===o?"bottom":"top",l=Math.max(1.5*Math.round(r),6),c="",u=oM+":";Qe(["left","right"],s)>-1?(c+="top:50%",u+="translateY(-50%) rotate("+(a="left"===s?-225:-45)+"deg)"):(c+="left:50%",u+="translateX(-50%) rotate("+(a="top"===s?225:45)+"deg)");var h=a*Math.PI/180,d=l+r,p=d*Math.abs(Math.cos(h))+d*Math.abs(Math.sin(h)),f=e+" solid "+r+"px;";return'
'}(n,i,r)),hn(t))o.innerHTML=t+a;else if(t){o.innerHTML="",cn(t)||(t=[t]);for(var s=0;s=0?this._tryShow(n,i):"leave"===e&&this._hide(i))},this))},e.prototype._keepShow=function(){var t=this._tooltipModel,e=this._ecModel,n=this._api,i=t.get("triggerOn");if(null!=this._lastX&&null!=this._lastY&&"none"!==i&&"click"!==i){var r=this;clearTimeout(this._refreshUpdateTimeout),this._refreshUpdateTimeout=setTimeout(function(){!n.isDisposed()&&r.manuallyShowTip(t,e,n,{x:r._lastX,y:r._lastY,dataByCoordSys:r._lastDataByCoordSys})})}},e.prototype.manuallyShowTip=function(t,e,n,i){if(i.from!==this.uid&&!Te.node&&n.getDom()){var r=yM(i,n);this._ticket="";var o=i.dataByCoordSys,a=function(t,e,n){var i=Ns(t).queryOptionMap,r=i.keys()[0];if(!r||"series"===r)return;var o=Hs(e,r,i.get(r),{useDefault:!1,enableAll:!1,enableNone:!1}),a=o.models[0];if(!a)return;var s,l=n.getViewOfComponentModel(a);if(l.group.traverse(function(e){var n=nu(e).tooltipConfig;if(n&&n.name===t.name)return s=e,!0}),s)return{componentMainType:r,componentIndex:a.componentIndex,el:s}}(i,e,n);if(a){var s=a.el.getBoundingRect().clone();s.applyTransform(a.el.transform),this._tryShow({offsetX:s.x+s.width/2,offsetY:s.y+s.height/2,target:a.el,position:i.position,positionDefault:"bottom"},r)}else if(i.tooltip&&null!=i.x&&null!=i.y){var l=gM;l.x=i.x,l.y=i.y,l.update(),nu(l).tooltipConfig={name:null,option:i.tooltip},this._tryShow({offsetX:i.x,offsetY:i.y,target:l},r)}else if(o)this._tryShow({offsetX:i.x,offsetY:i.y,position:i.position,dataByCoordSys:o,tooltipOption:i.tooltipOption},r);else if(null!=i.seriesIndex){if(this._manuallyAxisShowTip(t,e,n,i))return;var c=UC(i,e),u=c.point[0],h=c.point[1];null!=u&&null!=h&&this._tryShow({offsetX:u,offsetY:h,target:c.el,position:i.position,positionDefault:"bottom"},r)}else null!=i.x&&null!=i.y&&(n.dispatchAction({type:"updateAxisPointer",x:i.x,y:i.y}),this._tryShow({offsetX:i.x,offsetY:i.y,position:i.position,target:n.getZr().findHover(i.x,i.y).target},r))}},e.prototype.manuallyHideTip=function(t,e,n,i){var r=this._tooltipContent;this._tooltipModel&&r.hideLater(this._tooltipModel.get("hideDelay")),this._lastX=this._lastY=this._lastDataByCoordSys=null,i.from!==this.uid&&this._hide(yM(i,n))},e.prototype._manuallyAxisShowTip=function(t,e,n,i){var r=i.seriesIndex,o=i.dataIndex,a=e.getComponent("axisPointer").coordSysAxesInfo;if(null!=r&&null!=o&&null!=a){var s=e.getSeriesByIndex(r);if(s)if("axis"===mM([s.getData().getItemModel(o),s,(s.coordinateSystem||{}).model],this._tooltipModel).get("trigger"))return n.dispatchAction({type:"updateAxisPointer",seriesIndex:r,dataIndex:o,position:i.position}),!0}},e.prototype._tryShow=function(t,e){var n=t.target;if(this._tooltipModel){this._lastX=t.offsetX,this._lastY=t.offsetY;var i=t.dataByCoordSys;if(i&&i.length)this._showAxisTooltip(i,t);else if(n){var r,o;if("legend"===nu(n).ssrType)return;this._lastDataByCoordSys=null,by(n,function(t){if(t.tooltipDisabled)return r=o=null,!0;r||o||(null!=nu(t).dataIndex?r=t:null!=nu(t).tooltipConfig&&(o=t))},!0),r?this._showSeriesItemTooltip(t,r,e):o?this._showComponentItemTooltip(t,o,e):this._hide(e)}else this._lastDataByCoordSys=null,this._hide(e)}},e.prototype._showOrMove=function(t,e){var n=t.get("showDelay");e=sn(e,this),clearTimeout(this._showTimout),n>0?this._showTimout=setTimeout(e,n):e()},e.prototype._showAxisTooltip=function(t,e){var n=this._ecModel,i=this._tooltipModel,r=[e.offsetX,e.offsetY],o=mM([e.tooltipOption],i),a=this._renderMode,s=[],l=tm("section",{blocks:[],noHeader:!0}),c=[],u=new um;en(t,function(t){en(t.dataByAxis,function(t){var e=n.getComponent(t.axisDim+"Axis",t.axisIndex),r=t.value;if(e&&null!=r){var o=AC(r,e.axis,n,t.seriesDataIndices,t.valueLabelOpt),h=tm("section",{header:o,noHeader:!kn(o),sortBlocks:!0,blocks:[]});l.blocks.push(h),en(t.seriesDataIndices,function(l){var d=n.getSeriesByIndex(l.seriesIndex),p=l.dataIndexInside,f=d.getDataParams(p);if(!(f.dataIndex<0)){f.axisDim=t.axisDim,f.axisIndex=t.axisIndex,f.axisType=t.axisType,f.axisId=t.axisId,f.axisValue=Vb(e.axis,{value:r}),f.axisValueLabel=o,f.marker=u.makeTooltipMarker("item",vf(f.color),a);var g=bv(d.formatTooltip(p,!0,null)),v=g.frag;if(v){var m=mM([d],i).get("valueFormatter");h.blocks.push(m?Ze({valueFormatter:m},v):v)}g.text&&c.push(g.text),s.push(f)}})}})}),l.blocks.reverse(),c.reverse();var h=e.position,d=o.get("order"),p=am(l,u,a,d,n.get("useUTC"),o.get("textStyle"));p&&c.unshift(p);var f="richText"===a?"\n\n":"
",g=c.join(f);this._showOrMove(o,function(){this._updateContentNotChangedOnAxis(t,s)?this._updatePosition(o,h,r[0],r[1],this._tooltipContent,s):this._showTooltipContent(o,g,s,Math.random()+"",r[0],r[1],h,null,u)})},e.prototype._showSeriesItemTooltip=function(t,e,n){var i=this._ecModel,r=nu(e),o=r.seriesIndex,a=i.getSeriesByIndex(o),s=r.dataModel||a,l=r.dataIndex,c=r.dataType,u=s.getData(c),h=this._renderMode,d=t.positionDefault,p=mM([u.getItemModel(l),s,a&&(a.coordinateSystem||{}).model],this._tooltipModel,d?{position:d}:null),f=p.get("trigger");if(null==f||"item"===f){var g=s.getDataParams(l,c),v=new um;g.marker=v.makeTooltipMarker("item",vf(g.color),h);var m=bv(s.formatTooltip(l,!1,c)),y=p.get("order"),_=p.get("valueFormatter"),x=m.frag,b=x?am(_?Ze({valueFormatter:_},x):x,v,h,y,i.get("useUTC"),p.get("textStyle")):m.text,w="item_"+s.name+"_"+l;this._showOrMove(p,function(){this._showTooltipContent(p,b,g,w,t.offsetX,t.offsetY,t.position,t.target,v)}),n({type:"showTip",dataIndexInside:l,dataIndex:u.getRawIndex(l),seriesIndex:o,from:this.uid})}},e.prototype._showComponentItemTooltip=function(t,e,n){var i="html"===this._renderMode,r=nu(e),o=r.tooltipConfig.option||{},a=o.encodeHTMLContent;if(hn(o)){o={content:o,formatter:o},a=!0}a&&i&&o.content&&((o=Xe(o)).content=li(o.content));var s=[o],l=this._ecModel.getComponent(r.componentMainType,r.componentIndex);l&&s.push(l),s.push({formatter:o.content});var c=t.positionDefault,u=mM(s,this._tooltipModel,c?{position:c}:null),h=u.get("content"),d=Math.random()+"",p=new um;this._showOrMove(u,function(){var n=Xe(u.get("formatterParams")||{});this._showTooltipContent(u,h,n,d,t.offsetX,t.offsetY,t.position,e,p)}),n({type:"showTip",from:this.uid})},e.prototype._showTooltipContent=function(t,e,n,i,r,o,a,s,l){if(this._ticket="",t.get("showContent")&&t.get("show")){var c=this._tooltipContent;c.setEnterable(t.get("enterable"));var u=t.get("formatter");a=a||t.get("position");var h=e,d=this._getNearestPoint([r,o],n,t.get("trigger"),t.get("borderColor"),t.get("defaultBorderColor",!0)).color;if(u)if(hn(u)){var p=t.ecModel.get("useUTC"),f=cn(n)?n[0]:n;h=u,f&&f.axisType&&f.axisType.indexOf("time")>=0&&(h=Gp(f.axisValue,h,p)),h=gf(h,n,!0)}else if(un(u)){var g=sn(function(e,i){e===this._ticket&&(c.setContent(i,l,t,d,a),this._updatePosition(t,a,r,o,c,n,s))},this);this._ticket=i,h=u(n,i,g)}else h=u;c.setContent(h,l,t,d,a),c.show(t,d),this._updatePosition(t,a,r,o,c,n,s)}},e.prototype._getNearestPoint=function(t,e,n,i,r){return"axis"===n||cn(e)?{color:i||r}:cn(e)?void 0:{color:i||e.color||e.borderColor}},e.prototype._updatePosition=function(t,e,n,i,r,o,a){var s=this._api.getWidth(),l=this._api.getHeight();e=e||t.get("position");var c=r.getSize(),u=t.get("align"),h=t.get("verticalAlign"),d=a&&a.getBoundingRect().clone();if(a&&d.applyTransform(a.transform),un(e)&&(e=e([n,i],o,r.el,d,{viewSize:[s,l],contentSize:c.slice()})),cn(e))n=es(e[0],s),i=es(e[1],l);else if(fn(e)){var p=e;p.width=c[0],p.height=c[1];var f=Pf(p,{width:s,height:l});n=f.x,i=f.y,u=null,h=null}else if(hn(e)&&a){var g=function(t,e,n,i){var r=n[0],o=n[1],a=Math.ceil(Math.SQRT2*i)+8,s=0,l=0,c=e.width,u=e.height;switch(t){case"inside":s=e.x+c/2-r/2,l=e.y+u/2-o/2;break;case"top":s=e.x+c/2-r/2,l=e.y-o-a;break;case"bottom":s=e.x+c/2-r/2,l=e.y+u+a;break;case"left":s=e.x-r-a,l=e.y+u/2-o/2;break;case"right":s=e.x+c+a,l=e.y+u/2-o/2}return[s,l]}(e,d,c,t.get("borderWidth"));n=g[0],i=g[1]}else{g=function(t,e,n,i,r,o,a){var s=n.getSize(),l=s[0],c=s[1];null!=o&&(t+l+o+2>i?t-=l+o:t+=o);null!=a&&(e+c+a>r?e-=c+a:e+=a);return[t,e]}(n,i,r,s,l,u?null:20,h?null:20);n=g[0],i=g[1]}if(u&&(n-=_M(u)?c[0]/2:"right"===u?c[0]:0),h&&(i-=_M(h)?c[1]/2:"bottom"===h?c[1]:0),tM(t)){g=function(t,e,n,i,r){var o=n.getSize(),a=o[0],s=o[1];return t=Math.min(t+a,i)-a,e=Math.min(e+s,r)-s,t=Math.max(t,0),e=Math.max(e,0),[t,e]}(n,i,r,s,l);n=g[0],i=g[1]}r.moveTo(n,i)},e.prototype._updateContentNotChangedOnAxis=function(t,e){var n=this._lastDataByCoordSys,i=this._cbParamsList,r=!!n&&n.length===t.length;return r&&en(n,function(n,o){var a=n.dataByAxis||[],s=(t[o]||{}).dataByAxis||[];(r=r&&a.length===s.length)&&en(a,function(t,n){var o=s[n]||{},a=t.seriesDataIndices||[],l=o.seriesDataIndices||[];(r=r&&t.value===o.value&&t.axisType===o.axisType&&t.axisId===o.axisId&&a.length===l.length)&&en(a,function(t,e){var n=l[e];r=r&&t.seriesIndex===n.seriesIndex&&t.dataIndex===n.dataIndex}),i&&en(t.seriesDataIndices,function(t){var n=t.seriesIndex,o=e[n],a=i[n];o&&a&&a.data!==o.data&&(r=!1)})})}),this._lastDataByCoordSys=t,this._cbParamsList=e,!!r},e.prototype._hide=function(t){this._lastDataByCoordSys=null,t({type:"hideTip",from:this.uid})},e.prototype.dispose=function(t,e){!Te.node&&e.getDom()&&(Rm(this,"_updatePosition"),this._tooltipContent.dispose(),VC("itemTooltip",e))},e.type="tooltip",e}(wm);function mM(t,e,n){var i,r=e.ecModel;n?(i=new _p(n,r,r),i=new _p(e.option,i,r)):i=e;for(var o=t.length-1;o>=0;o--){var a=t[o];a&&(a instanceof _p&&(a=a.get("tooltip",!0)),hn(a)&&(a={formatter:a}),a&&(i=new _p(a,i,r)))}return i}function yM(t,e){return t.dispatchAction||sn(e.dispatchAction,e)}function _M(t){return"center"===t||"middle"===t}var xM=Math.sin,bM=Math.cos,wM=Math.PI,SM=2*Math.PI,CM=180/wM,MM=function(){function t(){}return t.prototype.reset=function(t){this._start=!0,this._d=[],this._str="",this._p=Math.pow(10,t||4)},t.prototype.moveTo=function(t,e){this._add("M",t,e)},t.prototype.lineTo=function(t,e){this._add("L",t,e)},t.prototype.bezierCurveTo=function(t,e,n,i,r,o){this._add("C",t,e,n,i,r,o)},t.prototype.quadraticCurveTo=function(t,e,n,i){this._add("Q",t,e,n,i)},t.prototype.arc=function(t,e,n,i,r,o){this.ellipse(t,e,n,n,0,i,r,o)},t.prototype.ellipse=function(t,e,n,i,r,o,a,s){var l=a-o,c=!s,u=Math.abs(l),h=ho(u-SM)||(c?l>=SM:-l>=SM),d=l>0?l%SM:l%SM+SM,p=!1;p=!!h||!ho(u)&&d>=wM==!!c;var f=t+n*bM(o),g=e+i*xM(o);this._start&&this._add("M",f,g);var v=Math.round(r*CM);if(h){var m=1/this._p,y=(c?1:-1)*(SM-m);this._add("A",n,i,v,1,+c,t+n*bM(o+y),e+i*xM(o+y)),m>.01&&this._add("A",n,i,v,0,+c,f,g)}else{var _=t+n*bM(a),x=e+i*xM(a);this._add("A",n,i,v,+p,+c,_,x)}},t.prototype.rect=function(t,e,n,i){this._add("M",t,e),this._add("l",n,0),this._add("l",0,i),this._add("l",-n,0),this._add("Z")},t.prototype.closePath=function(){this._d.length>0&&this._add("Z")},t.prototype._add=function(t,e,n,i,r,o,a,s,l){for(var c=[],u=this._p,h=1;h"}(r,o)+("style"!==r?li(a):a||"")+(i?""+n+nn(i,function(e){return t(e)}).join(n)+n:"")+("")}(t)}function RM(t){return{zrId:t,shadowCache:{},patternCache:{},gradientCache:{},clipPathCache:{},defs:{},cssNodes:{},cssAnims:{},cssStyleCache:{},cssAnimIdx:0,shadowIdx:0,gradientIdx:0,patternIdx:0,clipPathIdx:0}}function HM(t,e,n,i){return OM("svg","root",{width:t,height:e,xmlns:PM,"xmlns:xlink":LM,version:"1.1",baseProfile:"full",viewBox:!!i&&"0 0 "+t+" "+e},n)}var BM=0;function FM(){return BM++}var $M={cubicIn:"0.32,0,0.67,0",cubicOut:"0.33,1,0.68,1",cubicInOut:"0.65,0,0.35,1",quadraticIn:"0.11,0,0.5,0",quadraticOut:"0.5,1,0.89,1",quadraticInOut:"0.45,0,0.55,1",quarticIn:"0.5,0,0.75,0",quarticOut:"0.25,1,0.5,1",quarticInOut:"0.76,0,0.24,1",quinticIn:"0.64,0,0.78,0",quinticOut:"0.22,1,0.36,1",quinticInOut:"0.83,0,0.17,1",sinusoidalIn:"0.12,0,0.39,0",sinusoidalOut:"0.61,1,0.88,1",sinusoidalInOut:"0.37,0,0.63,1",exponentialIn:"0.7,0,0.84,0",exponentialOut:"0.16,1,0.3,1",exponentialInOut:"0.87,0,0.13,1",circularIn:"0.55,0,1,0.45",circularOut:"0,0.55,0.45,1",circularInOut:"0.85,0,0.15,1"},VM="transform-origin";function WM(t,e,n){var i=Ze({},t.shape);Ze(i,e),t.buildPath(n,i);var r=new MM;return r.reset(wo(t)),n.rebuildPath(r,1),r.generateStr(),r.getStr()}function UM(t,e){var n=e.originX,i=e.originY;(n||i)&&(t[VM]=n+"px "+i+"px")}var GM={fill:"fill",opacity:"opacity",lineWidth:"stroke-width",lineDashOffset:"stroke-dashoffset"};function qM(t,e){var n=e.zrId+"-ani-"+e.cssAnimIdx++;return e.cssAnims[n]=t,n}function jM(t){return hn(t)?$M[t]?"cubic-bezier("+$M[t]+")":Rr(t)?t:"":""}function XM(t,e,n,i){var r=t.animators,o=r.length,a=[];if(t instanceof Yh){var s=function(t,e,n){var i,r,o=t.shape.paths,a={};if(en(o,function(t){var e=RM(n.zrId);e.animation=!0,XM(t,{},e,!0);var o=e.cssAnims,s=e.cssNodes,l=an(o),c=l.length;if(c){var u=o[r=l[c-1]];for(var h in u){var d=u[h];a[h]=a[h]||{d:""},a[h].d+=d.d||""}for(var p in s){var f=s[p].animation;f.indexOf(r)>=0&&(i=f)}}}),i){e.d=!1;var s=qM(a,n);return i.replace(r,s)}}(t,e,n);if(s)a.push(s);else if(!o)return}else if(!o)return;for(var l={},c=0;c0}).length)return qM(u,n)+" "+r[0]+" both"}for(var v in l){(s=g(l[v]))&&a.push(s)}if(a.length){var m=n.zrId+"-cls-"+FM();n.cssNodes["."+m]={animation:a.join(",")},e.class=m}}function YM(t,e,n,i){var r=JSON.stringify(t),o=n.cssStyleCache[r];o||(o=n.zrId+"-cls-"+FM(),n.cssStyleCache[r]=o,n.cssNodes["."+o+":hover"]=t),e.class=e.class?e.class+" "+o:o}var ZM=Math.round;function KM(t){return t&&hn(t.src)}function QM(t){return t&&un(t.toDataURL)}function JM(t,e,n,i){AM(function(r,o){var a="fill"===r||"stroke"===r;a&&xo(o)?uk(e,t,r,i):a&&mo(o)?hk(n,t,r,i):t[r]=o,a&&i.ssr&&"none"===o&&(t["pointer-events"]="visible")},e,n,!1),function(t,e,n){var i=t.style;if(function(t){return t&&(t.shadowBlur||t.shadowOffsetX||t.shadowOffsetY)}(i)){var r=function(t){var e=t.style,n=t.getGlobalScale();return[e.shadowColor,(e.shadowBlur||0).toFixed(2),(e.shadowOffsetX||0).toFixed(2),(e.shadowOffsetY||0).toFixed(2),n[0],n[1]].join(",")}(t),o=n.shadowCache,a=o[r];if(!a){var s=t.getGlobalScale(),l=s[0],c=s[1];if(!l||!c)return;var u=i.shadowOffsetX||0,h=i.shadowOffsetY||0,d=i.shadowBlur,p=co(i.shadowColor),f=p.opacity,g=p.color,v=d/2/l+" "+d/2/c;a=n.zrId+"-s"+n.shadowIdx++,n.defs[a]=OM("filter",a,{id:a,x:"-100%",y:"-100%",width:"300%",height:"300%"},[OM("feDropShadow","",{dx:u/l,dy:h/c,stdDeviation:v,"flood-color":g,"flood-opacity":f})]),o[r]=a}e.filter=bo(a)}}(n,t,i)}function tk(t,e){var n=function(t){if("function"==typeof ja)return ja(t)}(e);n&&(n.each(function(e,n){null!=e&&(t[(EM+n).toLowerCase()]=e+"")}),e.isSilent()&&(t[EM+"silent"]="true"))}function ek(t){return ho(t[0]-1)&&ho(t[1])&&ho(t[2])&&ho(t[3]-1)}function nk(t,e,n){if(e&&(!function(t){return ho(t[4])&&ho(t[5])}(e)||!ek(e))){var i=1e4;t.transform=ek(e)?"translate("+ZM(e[4]*i)/i+" "+ZM(e[5]*i)/i+")":function(t){return"matrix("+po(t[0])+","+po(t[1])+","+po(t[2])+","+po(t[3])+","+fo(t[4])+","+fo(t[5])+")"}(e)}}function ik(t,e,n){for(var i=t.points,r=[],o=0;o=0&&a||o;s&&(r=so(s))}var l=i.lineWidth;l&&(l/=!i.strokeNoScale&&t.transform?t.transform[0]:1);var c={cursor:"pointer"};r&&(c.fill=r),i.stroke&&(c.stroke=i.stroke),l&&(c["stroke-width"]=l),YM(c,e,n)}}(t,o,e),OM(s,t.id+"",o)}function ck(t,e){return t instanceof Tc?lk(t,e):t instanceof Lc?function(t,e){var n=t.style,i=n.image;if(i&&!hn(i)&&(KM(i)?i=i.src:QM(i)&&(i=i.toDataURL())),i){var r=n.x||0,o=n.y||0,a={href:i,width:n.width,height:n.height};return r&&(a.x=r),o&&(a.y=o),nk(a,t.transform),JM(a,n,t,e),tk(a,t),e.animation&&XM(t,a,e),OM("image",t.id+"",a)}}(t,e):t instanceof Ic?function(t,e){var n=t.style,i=n.text;if(null!=i&&(i+=""),i&&!isNaN(n.x)&&!isNaN(n.y)){var r=n.font||Ie,o=n.x||0,a=function(t,e,n){return"top"===n?t+=e/2:"bottom"===n&&(t-=e/2),t}(n.y||0,Aa(r),n.textBaseline),s={"dominant-baseline":"central","text-anchor":go[n.textAlign]||n.textAlign};if(Yc(n)){var l="",c=n.fontStyle,u=jc(n.fontSize);if(!parseFloat(u))return;var h=n.fontFamily||De,d=n.fontWeight;l+="font-size:"+u+";font-family:"+h+";",c&&"normal"!==c&&(l+="font-style:"+c+";"),d&&"normal"!==d&&(l+="font-weight:"+d+";"),s.style=l}else s.style="font: "+r;return i.match(/\s/)&&(s["xml:space"]="preserve"),o&&(s.x=o),a&&(s.y=a),nk(s,t.transform),JM(s,n,t,e),tk(s,t),e.animation&&XM(t,s,e),OM("text",t.id+"",s,void 0,i)}}(t,e):void 0}function uk(t,e,n,i){var r,o=t[n],a={gradientUnits:o.global?"userSpaceOnUse":"objectBoundingBox"};if(yo(o))r="linearGradient",a.x1=o.x,a.y1=o.y,a.x2=o.x2,a.y2=o.y2;else{if(!_o(o))return;r="radialGradient",a.cx=bn(o.x,.5),a.cy=bn(o.y,.5),a.r=bn(o.r,.5)}for(var s=o.colorStops,l=[],c=0,u=s.length;cl?kk(t,null==n[h+1]?null:n[h+1].elm,n,s,h):Tk(t,e,a,l))}(n,i,r):wk(r)?(wk(t.text)&&_k(n,""),kk(n,null,r,0,r.length-1)):wk(i)?Tk(n,i,0,i.length-1):wk(t.text)&&_k(n,""):t.text!==e.text&&(wk(i)&&Tk(n,i,0,i.length-1),_k(n,e.text)))}var Ak=0,Pk=function(){function t(t,e,n){if(this.type="svg",this.refreshHover=function(){},this.configLayer=function(){},this.storage=e,this._opts=n=Ze({},n),this.root=t,this._id="zr"+Ak++,this._oldVNode=HM(n.width,n.height),t&&!n.ssr){var i=this._viewport=document.createElement("div");i.style.cssText="position:relative;overflow:hidden";var r=this._svgDom=this._oldVNode.elm=zM("svg");Dk(null,this._oldVNode),i.appendChild(r),t.appendChild(i)}this.resize(n.width,n.height)}return t.prototype.getType=function(){return this.type},t.prototype.getViewportRoot=function(){return this._viewport},t.prototype.getViewportRootOffset=function(){var t=this.getViewportRoot();if(t)return{offsetLeft:t.offsetLeft||0,offsetTop:t.offsetTop||0}},t.prototype.getSvgDom=function(){return this._svgDom},t.prototype.refresh=function(){if(this.root){var t=this.renderToVNode({willUpdate:!0});t.attrs.style="position:absolute;left:0;top:0;user-select:none",function(t,e){if(Ck(t,e))Ik(t,e);else{var n=t.elm,i=mk(n);Mk(e),null!==i&&(fk(i,e.elm,yk(n)),Tk(i,[t],0,0))}}(this._oldVNode,t),this._oldVNode=t}},t.prototype.renderOneToVNode=function(t){return ck(t,RM(this._id))},t.prototype.renderToVNode=function(t){t=t||{};var e=this.storage.getDisplayList(!0),n=this._width,i=this._height,r=RM(this._id);r.animation=t.animation,r.willUpdate=t.willUpdate,r.compress=t.compress,r.emphasis=t.emphasis,r.ssr=this._opts.ssr;var o=[],a=this._bgVNode=function(t,e,n,i){var r;if(n&&"none"!==n)if(r=OM("rect","bg",{width:t,height:e,x:"0",y:"0"}),xo(n))uk({fill:n},r.attrs,"fill",i);else if(mo(n))hk({style:{fill:n},dirty:Nn,getBoundingRect:function(){return{width:t,height:e}}},r.attrs,"fill",i);else{var o=co(n),a=o.color,s=o.opacity;r.attrs.fill=a,s<1&&(r.attrs["fill-opacity"]=s)}return r}(n,i,this._backgroundColor,r);a&&o.push(a);var s=t.compress?null:this._mainVNode=OM("g","main",{},[]);this._paintList(e,r,s?s.children:o),s&&o.push(s);var l=nn(an(r.defs),function(t){return r.defs[t]});if(l.length&&o.push(OM("defs","defs",{},l)),t.animation){var c=function(t,e,n){var i=(n=n||{}).newline?"\n":"",r=" {"+i,o=i+"}",a=nn(an(t),function(e){return e+r+nn(an(t[e]),function(n){return n+":"+t[e][n]+";"}).join(i)+o}).join(i),s=nn(an(e),function(t){return"@keyframes "+t+r+nn(an(e[t]),function(n){return n+r+nn(an(e[t][n]),function(i){var r=e[t][n][i];return"d"===i&&(r='path("'+r+'")'),i+":"+r+";"}).join(i)+o}).join(i)+o}).join(i);return a||s?[""].join(i):""}(r.cssNodes,r.cssAnims,{newline:!0});if(c){var u=OM("style","stl",{},[],c);o.push(u)}}return HM(n,i,o,t.useViewBox)},t.prototype.renderToString=function(t){return t=t||{},NM(this.renderToVNode({animation:bn(t.cssAnimation,!0),emphasis:bn(t.cssEmphasis,!0),willUpdate:!1,compress:!0,useViewBox:bn(t.useViewBox,!0)}),{newline:!0})},t.prototype.setBackgroundColor=function(t){this._backgroundColor=t},t.prototype.getSvgRoot=function(){return this._mainVNode&&this._mainVNode.elm},t.prototype._paintList=function(t,e,n){for(var i,r,o=t.length,a=[],s=0,l=0,c=0;c=0&&(!h||!r||h[f]!==r[f]);f--);for(var g=p-1;g>f;g--)i=a[--s-1];for(var v=f+1;v10&&"cartesian2d"===o.type&&r){var s=o.getBaseAxis(),l=o.getOtherAxis(s),c=s.getExtent(),u=n.getDevicePixelRatio(),h=Math.abs(c[1]-c[0])*(u||1),d=Math.round(a/h);if(isFinite(d)&&d>1){"lttb"===r?t.setData(i.lttbDownSample(i.mapDimension(l.dim),1/d)):"minmax"===r&&t.setData(i.minmaxDownSample(i.mapDimension(l.dim),1/d));var p=void 0;hn(r)?p=rS[r]:un(r)&&(p=r),p&&t.setData(i.downSample(i.mapDimension(l.dim),1/d,p,oS))}}}}}("line"))},function(t){Qb(_C),Qb(QC)},function(t){Qb(QC),t.registerComponentModel(JC),t.registerComponentView(vM),t.registerAction({type:"showTip",event:"showTip",update:"tooltip:manuallyShowTip"},Nn),t.registerAction({type:"hideTip",event:"hideTip",update:"tooltip:manuallyHideTip"},Nn)},function(t){t.registerPainter("svg",Pk)}]);class Lk extends At{constructor(){super(...arguments),this.options=null,this.data=[],this.height="120px",this._chart=null,this._resizeObserver=null}render(){return ht`
`}connectedCallback(){super.connectedCallback(),this.hasUpdated&&!this._chart&&this.options&&(this._mount(),this._observeResize())}firstUpdated(){this._mount(),this._observeResize()}updated(t){(t.has("options")||t.has("data"))&&(this._chart&&this.options?this._chart.setOption(this._mergedOptions(),!0):!this._chart&&this.options&&this._mount()),t.has("height")&&this._chart&&this._chart.resize()}disconnectedCallback(){this._destroy(),super.disconnectedCallback()}_mount(){if(!this.options)return;const t=this.shadowRoot?.querySelector(".chart-host");t&&(this._chart=rx(t,void 0,{renderer:"svg"}),this._chart.setOption(this._mergedOptions(),!0))}_mergedOptions(){if(!this.options)throw new Error("span-chart: options not set");return{...this.options,series:this.data}}_observeResize(){if("undefined"==typeof ResizeObserver)return;this._resizeObserver=new ResizeObserver(()=>{this._chart&&this._chart.resize()});const t=this.shadowRoot?.querySelector(".chart-host");t&&this._resizeObserver.observe(t)}_destroy(){this._resizeObserver&&(this._resizeObserver.disconnect(),this._resizeObserver=null),this._chart&&(this._chart.dispose(),this._chart=null)}}Lk.styles=T` +var Ga={},qa={};var ja,Xa=function(){function t(t,e,n){var i=this;this._sleepAfterStill=10,this._stillFrameAccum=0,this._needsRefresh=!0,this._needsRefreshHover=!0,this._darkMode=!1,n=n||{},this.dom=e,this.id=t;var r=new hr,o=n.renderer||"canvas";Ga[o]||(o=an(Ga)[0]),n.useDirtyRect=null!=n.useDirtyRect&&n.useDirtyRect;var a=new Ga[o](e,r,n,t),s=n.ssr||a.ssrOnly;this.storage=r,this.painter=a;var l,c=Te.node||Te.worker||s?null:new oa(a.getViewportRoot(),a.root),u=n.useCoarsePointer;(null==u||"auto"===u?Te.touchEventsSupported:!!u)&&(l=xn(n.pointerSize,44)),this.handler=new Qi(r,a,c,a.root,l),this.animation=new Vo({stage:{update:s?null:function(){return i._flush(!0)}}}),s||this.animation.start()}return t.prototype.add=function(t){!this._disposed&&t&&(this.storage.addRoot(t),t.addSelfToZr(this),this.refresh())},t.prototype.remove=function(t){!this._disposed&&t&&(this.storage.delRoot(t),t.removeSelfFromZr(this),this.refresh())},t.prototype.configLayer=function(t,e){this._disposed||(this.painter.configLayer&&this.painter.configLayer(t,e),this.refresh())},t.prototype.setBackgroundColor=function(t){this._disposed||(this.painter.setBackgroundColor&&this.painter.setBackgroundColor(t),this.refresh(),this._backgroundColor=t,this._darkMode=function(t){if(!t)return!1;if("string"==typeof t)return oo(t,1)<.4;if(t.colorStops){for(var e=t.colorStops,n=0,i=e.length,r=0;r0&&(this._stillFrameAccum++,this._stillFrameAccum>this._sleepAfterStill&&this.animation.stop())},t.prototype.setSleepAfterStill=function(t){this._sleepAfterStill=t},t.prototype.wakeUp=function(){this._disposed||(this.animation.start(),this._stillFrameAccum=0)},t.prototype.refreshHover=function(){this._needsRefreshHover=!0},t.prototype.refreshHoverImmediately=function(){this._disposed||(this._needsRefreshHover=!1,this.painter.refreshHover&&"canvas"===this.painter.getType()&&this.painter.refreshHover())},t.prototype.resize=function(t){this._disposed||(t=t||{},this.painter.resize(t.width,t.height),this.handler.resize())},t.prototype.clearAnimation=function(){this._disposed||this.animation.clear()},t.prototype.getWidth=function(){if(!this._disposed)return this.painter.getWidth()},t.prototype.getHeight=function(){if(!this._disposed)return this.painter.getHeight()},t.prototype.setCursorStyle=function(t){this._disposed||this.handler.setCursorStyle(t)},t.prototype.findHover=function(t,e){if(!this._disposed)return this.handler.findHover(t,e)},t.prototype.on=function(t,e,n){return this._disposed||this.handler.on(t,e,n),this},t.prototype.off=function(t,e){this._disposed||this.handler.off(t,e)},t.prototype.trigger=function(t,e){this._disposed||this.handler.trigger(t,e)},t.prototype.clear=function(){if(!this._disposed){for(var t=this.storage.getRoots(),e=0;e0){if(t<=r)return a;if(t>=o)return s}else{if(t>=r)return a;if(t<=o)return s}else{if(t===r)return a;if(t===o)return s}return(t-r)/l*c+a}var es=function(t,e,n){switch(t){case"center":case"middle":t="50%";break;case"left":case"top":t="0%";break;case"right":case"bottom":t="100%"}return ns(t,e,n)};function ns(t,e,n){return dn(t)?(i=t,i.replace(/^\s+|\s+$/g,"")).match(/%$/)?parseFloat(t)/100*e+(n||0):parseFloat(t):null==t?NaN:+t;var i}function is(t,e,n){return null==e&&(e=10),e=Math.min(Math.max(0,e),20),t=(+t).toFixed(e),n?t:+t}function rs(t){if(t=+t,isNaN(t))return 0;if(t>1e-14)for(var e=1,n=0;n<15;n++,e*=10)if(Math.round(t*e)/e===t)return n;return function(t){var e=t.toString().toLowerCase(),n=e.indexOf("e"),i=n>0?+e.slice(n+1):0,r=n>0?n:e.length,o=e.indexOf("."),a=o<0?0:r-1-o;return Math.max(0,a-i)}(t)}function os(t,e){var n=Math.max(rs(t),rs(e)),i=t+e;return n>20?i:is(i,n)}function as(t){var e=2*Math.PI;return(t%e+e)%e}function ss(t){return t>-1e-4&&t=10&&e++,e}function ds(t,e){var n=us(t),i=Math.pow(10,n),r=t/i;return t=(r<1.5?1:r<2.5?2:r<4?3:r<7?5:10)*i,n>=-20?+t.toFixed(n<0?-n:0):t}function hs(t){var e=parseFloat(t);return e==t&&(0!==e||!dn(t)||t.indexOf("x")<=0)?e:NaN}function ps(){return Math.round(9*Math.random())}function fs(t,e){return 0===e?t:fs(e,t%e)}function gs(t,e){return null==t?e:null==e?t:t*e/fs(t,e)}var vs="undefined"!=typeof console&&console.warn&&console.log;function ms(t,e){!function(t,e){vs&&console[t]("[ECharts] "+e)}("error",t)}function ys(t){throw new Error(t)}function _s(t,e,n){return(e-t)*n+t}var bs="series\0";function xs(t){return t instanceof Array?t:null==t?[]:[t]}function ws(t,e,n){if(t){t[e]=t[e]||{},t.emphasis=t.emphasis||{},t.emphasis[e]=t.emphasis[e]||{};for(var i=0,r=n.length;i=0||r&&Qe(r,s)<0)){var l=n.getShallow(s,e);null!=l&&(o[t[a][0]]=l)}}return o}}var Zs=Ys([["fill","color"],["shadowBlur"],["shadowOffsetX"],["shadowOffsetY"],["opacity"],["shadowColor"]]),Ks=function(){function t(){}return t.prototype.getAreaStyle=function(t,e){return Zs(this,t,e)},t}(),Qs=new $r(50);function Js(t){if("string"==typeof t){var e=Qs.get(t);return e&&e.image}return t}function tl(t,e,n,i,r){if(t){if("string"==typeof t){if(e&&e.__zrImageSrc===t||!n)return e;var o=Qs.get(t),a={hostEl:n,cb:i,cbPayload:r};return o?!nl(e=o.image)&&o.pending.push(a):((e=Ee.loadImage(t,el,el)).__zrImageSrc=t,Qs.put(t,e.__cachedImgObj={image:e,pending:[a]})),e}return t}return e}function el(){var t=this.__cachedImgObj;this.onload=this.onerror=this.__cachedImgObj=null;for(var e=0;e=s;c++)l-=s;var u=Ma(a,n);return u>l&&(n="",u=0),l=t-u,r.ellipsis=n,r.ellipsisWidth=u,r.contentWidth=l,r.containerWidth=t,r}function al(t,e,n){var i=n.containerWidth,r=n.contentWidth,o=n.fontMeasureInfo;if(!i)return t.textLine="",void(t.isTruncated=!1);var a=Ma(o,e);if(a<=i)return t.textLine=e,void(t.isTruncated=!1);for(var s=0;;s++){if(a<=r||s>=n.maxIterations){e+=n.ellipsis;break}var l=0===s?sl(e,r,o):a>0?Math.floor(e.length*r/a):0;a=Ma(o,e=e.substr(0,l))}""===e&&(e=n.placeholder),t.textLine=e,t.isTruncated=!0}function sl(t,e,n){for(var i=0,r=0,o=t.length;r0&&f+i.accumWidth>i.width&&(o=e.split("\n"),d=!0),i.accumWidth=f}else{var g=fl(e,u,i.width,i.breakAll,i.accumWidth);i.accumWidth=g.accumWidth+p,a=g.linesWidths,o=g.lines}}o||(o=e.split("\n"));for(var v=xa(u),m=0;m=32&&e<=591||e>=880&&e<=4351||e>=4608&&e<=5119||e>=7680&&e<=8303}(t)||!!hl[t]}function fl(t,e,n,i,r){for(var o=[],a=[],s="",l="",c=0,u=0,d=xa(e),h=0;hn:r+u+f>n)?u?(s||l)&&(g?(s||(s=l,l="",u=c=0),o.push(s),a.push(u-c),l+=p,s="",u=c+=f):(l&&(s+=l,l="",c=0),o.push(s),a.push(u),s=p,u=f)):g?(o.push(l),a.push(c),l=p,c=f):(o.push(p),a.push(f)):(u+=f,g?(l+=p,c+=f):(l&&(s+=l,l="",c=0),s+=p))}else l&&(s+=l,u+=c),o.push(s),a.push(u),s="",l="",c=0,u=0}return l&&(s+=l),s&&(o.push(s),a.push(u)),1===o.length&&(u+=r),{accumWidth:u,lines:o,linesWidths:a}}function gl(t,e,n,i,r,o){if(t.baseX=n,t.baseY=i,t.outerWidth=t.outerHeight=null,e){var a=2*e.width,s=2*e.height;$i.set(vl,Aa(n,a,r),Da(i,s,o),a,s),$i.intersect(e,vl,null,ml);var l=ml.outIntersectRect;t.outerWidth=l.width,t.outerHeight=l.height,t.baseX=Aa(l.x,l.width,r,!0),t.baseY=Da(l.y,l.height,o,!0)}}var vl=new $i(0,0,0,0),ml={outIntersectRect:{},clamp:!0};function yl(t){return null!=t?t+="":t=""}function _l(t,e,n,i){var r=new $i(Aa(t.x||0,e,t.textAlign),Da(t.y||0,n,t.textBaseline),e,n),o=null!=i?i:bl(t)?t.lineWidth:0;return o>0&&(r.x-=o/2,r.y-=o/2,r.width+=o,r.height+=o),r}function bl(t){var e=t.stroke;return null!=e&&"none"!==e&&t.lineWidth>0}var xl="__zr_style_"+Math.round(10*Math.random()),wl={shadowBlur:0,shadowOffsetX:0,shadowOffsetY:0,shadowColor:"#000",opacity:1,blend:"source-over"},Sl={style:{shadowBlur:!0,shadowOffsetX:!0,shadowOffsetY:!0,shadowColor:!0,opacity:!0}};wl[xl]=!0;var Cl=["z","z2","invisible"],Ml=["invisible"],kl=function(t){function e(e){return t.call(this,e)||this}var n;return _(e,t),e.prototype._init=function(e){for(var n=an(e),i=0;i1e-4)return s[0]=t-n,s[1]=e-i,l[0]=t+n,void(l[1]=e+i);if(Ol[0]=Ll(r)*n+t,Ol[1]=Pl(r)*i+e,zl[0]=Ll(o)*n+t,zl[1]=Pl(o)*i+e,c(s,Ol,zl),u(l,Ol,zl),(r%=El)<0&&(r+=El),(o%=El)<0&&(o+=El),r>o&&!a?o+=El:rr&&(Nl[0]=Ll(p)*n+t,Nl[1]=Pl(p)*i+e,c(s,Nl,s),u(l,Nl,l))}var Wl={M:1,L:2,C:3,Q:4,A:5,Z:6,R:7},Ul=[],Gl=[],ql=[],jl=[],Xl=[],Yl=[],Zl=Math.min,Kl=Math.max,Ql=Math.cos,Jl=Math.sin,tc=Math.abs,ec=Math.PI,nc=2*ec,ic="undefined"!=typeof Float32Array,rc=[];function oc(t){return Math.round(t/ec*1e8)/1e8%2*ec}var ac=function(){function t(t){this.dpr=1,this._xi=0,this._yi=0,this._x0=0,this._y0=0,this._len=0,t&&(this._saveData=!1),this._saveData&&(this.data=[])}var e;return t.prototype.increaseVersion=function(){this._version++},t.prototype.getVersion=function(){return this._version},t.prototype.setScale=function(t,e,n){(n=n||0)>0&&(this._ux=tc(n/sa/t)||0,this._uy=tc(n/sa/e)||0)},t.prototype.setDPR=function(t){this.dpr=t},t.prototype.setContext=function(t){this._ctx=t},t.prototype.getContext=function(){return this._ctx},t.prototype.beginPath=function(){return this._ctx&&this._ctx.beginPath(),this.reset(),this},t.prototype.reset=function(){this._saveData&&(this._len=0),this._pathSegLen&&(this._pathSegLen=null,this._pathLen=0),this._version++},t.prototype.moveTo=function(t,e){return this._drawPendingPt(),this.addData(Wl.M,t,e),this._ctx&&this._ctx.moveTo(t,e),this._x0=t,this._y0=e,this._xi=t,this._yi=e,this},t.prototype.lineTo=function(t,e){var n=tc(t-this._xi),i=tc(e-this._yi),r=n>this._ux||i>this._uy;if(this.addData(Wl.L,t,e),this._ctx&&r&&this._ctx.lineTo(t,e),r)this._xi=t,this._yi=e,this._pendingPtDist=0;else{var o=n*n+i*i;o>this._pendingPtDist&&(this._pendingPtX=t,this._pendingPtY=e,this._pendingPtDist=o)}return this},t.prototype.bezierCurveTo=function(t,e,n,i,r,o){return this._drawPendingPt(),this.addData(Wl.C,t,e,n,i,r,o),this._ctx&&this._ctx.bezierCurveTo(t,e,n,i,r,o),this._xi=r,this._yi=o,this},t.prototype.quadraticCurveTo=function(t,e,n,i){return this._drawPendingPt(),this.addData(Wl.Q,t,e,n,i),this._ctx&&this._ctx.quadraticCurveTo(t,e,n,i),this._xi=n,this._yi=i,this},t.prototype.arc=function(t,e,n,i,r,o){this._drawPendingPt(),rc[0]=i,rc[1]=r,function(t,e){var n=oc(t[0]);n<0&&(n+=nc);var i=n-t[0],r=t[1];r+=i,!e&&r-n>=nc?r=n+nc:e&&n-r>=nc?r=n-nc:!e&&n>r?r=n+(nc-oc(n-r)):e&&n0&&o))for(var a=0;ac.length&&(this._expandData(),c=this.data);for(var u=0;u0&&(this._ctx&&this._ctx.lineTo(this._pendingPtX,this._pendingPtY),this._pendingPtDist=0)},t.prototype._expandData=function(){if(!(this.data instanceof Array)){for(var t=[],e=0;e11&&(this.data=new Float32Array(t)))}},t.prototype.getBoundingRect=function(){ql[0]=ql[1]=Xl[0]=Xl[1]=Number.MAX_VALUE,jl[0]=jl[1]=Yl[0]=Yl[1]=-Number.MAX_VALUE;var t,e=this.data,n=0,i=0,r=0,o=0;for(t=0;tn||tc(v)>i||d===e-1)&&(f=Math.sqrt(D*D+v*v),r=g,o=_);break;case Wl.C:var m=t[d++],y=t[d++],_=(g=t[d++],t[d++]),b=t[d++],x=t[d++];f=Ir(r,o,m,y,g,_,b,x,10),r=b,o=x;break;case Wl.Q:f=zr(r,o,m=t[d++],y=t[d++],g=t[d++],_=t[d++],10),r=g,o=_;break;case Wl.A:var w=t[d++],S=t[d++],C=t[d++],M=t[d++],k=t[d++],T=t[d++],A=T+k;d+=1,p&&(a=Ql(k)*C+w,s=Jl(k)*M+S),f=Kl(C,M)*Zl(nc,Math.abs(T)),r=Ql(A)*C+w,o=Jl(A)*M+S;break;case Wl.R:a=r=t[d++],s=o=t[d++],f=2*t[d++]+2*t[d++];break;case Wl.Z:var D=a-r;v=s-o;f=Math.sqrt(D*D+v*v),r=a,o=s}f>=0&&(l[u++]=f,c+=f)}return this._pathLen=c,c},t.prototype.rebuildPath=function(t,e){var n,i,r,o,a,s,l,c,u,d,h=this.data,p=this._ux,f=this._uy,g=this._len,v=e<1,m=0,y=0,_=0;if(!v||(this._pathSegLen||this._calculateLength(),l=this._pathSegLen,c=e*this._pathLen))t:for(var b=0;b0&&(t.lineTo(u,d),_=0),x){case Wl.M:n=r=h[b++],i=o=h[b++],t.moveTo(r,o);break;case Wl.L:a=h[b++],s=h[b++];var S=tc(a-r),C=tc(s-o);if(S>p||C>f){if(v){if(m+(X=l[y++])>c){var M=(c-m)/X;t.lineTo(r*(1-M)+a*M,o*(1-M)+s*M);break t}m+=X}t.lineTo(a,s),r=a,o=s,_=0}else{var k=S*S+C*C;k>_&&(u=a,d=s,_=k)}break;case Wl.C:var T=h[b++],A=h[b++],D=h[b++],I=h[b++],P=h[b++],L=h[b++];if(v){if(m+(X=l[y++])>c){Dr(r,T,D,P,M=(c-m)/X,Ul),Dr(o,A,I,L,M,Gl),t.bezierCurveTo(Ul[1],Gl[1],Ul[2],Gl[2],Ul[3],Gl[3]);break t}m+=X}t.bezierCurveTo(T,A,D,I,P,L),r=P,o=L;break;case Wl.Q:T=h[b++],A=h[b++],D=h[b++],I=h[b++];if(v){if(m+(X=l[y++])>c){Or(r,T,D,M=(c-m)/X,Ul),Or(o,A,I,M,Gl),t.quadraticCurveTo(Ul[1],Gl[1],Ul[2],Gl[2]);break t}m+=X}t.quadraticCurveTo(T,A,D,I),r=D,o=I;break;case Wl.A:var E=h[b++],O=h[b++],z=h[b++],N=h[b++],R=h[b++],H=h[b++],B=h[b++],F=!h[b++],$=z>N?z:N,V=tc(z-N)>.001,W=R+H,U=!1;if(v)m+(X=l[y++])>c&&(W=R+H*(c-m)/X,U=!0),m+=X;if(V&&t.ellipse?t.ellipse(E,O,z,N,B,R,W,F):t.arc(E,O,$,R,W,F),U)break t;w&&(n=Ql(R)*z+E,i=Jl(R)*N+O),r=Ql(W)*z+E,o=Jl(W)*N+O;break;case Wl.R:n=r=h[b],i=o=h[b+1],a=h[b++],s=h[b++];var G=h[b++],q=h[b++];if(v){if(m+(X=l[y++])>c){var j=c-m;t.moveTo(a,s),t.lineTo(a+Zl(j,G),s),(j-=G)>0&&t.lineTo(a+G,s+Zl(j,q)),(j-=q)>0&&t.lineTo(a+Kl(G-j,0),s+q),(j-=G)>0&&t.lineTo(a,s+Kl(q-j,0));break t}m+=X}t.rect(a,s,G,q);break;case Wl.Z:if(v){var X;if(m+(X=l[y++])>c){M=(c-m)/X;t.lineTo(r*(1-M)+n*M,o*(1-M)+i*M);break t}m+=X}t.closePath(),r=n,o=i}}},t.prototype.clone=function(){var e=new t,n=this.data;return e.data=n.slice?n.slice():Array.prototype.slice.call(n),e._len=this._len,e},t.prototype.canSave=function(){return!!this._saveData},t.CMD=Wl,t.initDefaultProps=((e=t.prototype)._saveData=!0,e._ux=0,e._uy=0,e._pendingPtDist=0,void(e._version=0)),t}();function sc(t,e,n,i,r,o,a){if(0===r)return!1;var s=r,l=0;if(a>e+s&&a>i+s||at+s&&o>n+s||oe+d&&u>i+d&&u>o+d&&u>s+d||ut+d&&c>n+d&&c>r+d&&c>a+d||c=0&&pe+c&&l>i+c&&l>o+c||lt+c&&s>n+c&&s>r+c||s=0&&gn||u+cr&&(r+=hc);var h=Math.atan2(l,s);return h<0&&(h+=hc),h>=i&&h<=r||h+hc>=i&&h+hc<=r}function fc(t,e,n,i,r,o){if(o>e&&o>i||or?s:0}var gc=ac.CMD,vc=2*Math.PI;var mc=[-1,-1,-1],yc=[-1,-1];function _c(){var t=yc[0];yc[0]=yc[1],yc[1]=t}function bc(t,e,n,i,r,o,a,s,l,c){if(c>e&&c>i&&c>o&&c>s||c1&&_c(),p=Mr(e,i,o,s,yc[0]),h>1&&(f=Mr(e,i,o,s,yc[1]))),2===h?ve&&s>i&&s>o||s=0&&u<=1&&(r[l++]=u);else{var c=a*a-4*o*s;if(Sr(c))(u=-a/(2*o))>=0&&u<=1&&(r[l++]=u);else if(c>0){var u,d=gr(c),h=(-a-d)/(2*o);(u=(-a+d)/(2*o))>=0&&u<=1&&(r[l++]=u),h>=0&&h<=1&&(r[l++]=h)}}return l}(e,i,o,s,mc);if(0===l)return 0;var c=Er(e,i,o);if(c>=0&&c<=1){for(var u=0,d=Pr(e,i,o,c),h=0;hn||s<-n)return 0;var l=Math.sqrt(n*n-s*s);mc[0]=-l,mc[1]=l;var c=Math.abs(i-r);if(c<1e-4)return 0;if(c>=vc-1e-4){i=0,r=vc;var u=o?1:-1;return a>=mc[0]+t&&a<=mc[1]+t?u:0}if(i>r){var d=i;i=r,r=d}i<0&&(i+=vc,r+=vc);for(var h=0,p=0;p<2;p++){var f=mc[p];if(f+t>a){var g=Math.atan2(s,f);u=o?1:-1;g<0&&(g=vc+g),(g>=i&&g<=r||g+vc>=i&&g+vc<=r)&&(g>Math.PI/2&&g<1.5*Math.PI&&(u=-u),h+=u)}}return h}function Sc(t,e,n,i,r){for(var o,a,s=t.data,l=t.len(),c=0,u=0,d=0,h=0,p=0,f=0;f1&&(n||(c+=fc(u,d,h,p,i,r))),v&&(h=u=s[f],p=d=s[f+1]),g){case gc.M:u=h=s[f++],d=p=s[f++];break;case gc.L:if(n){if(sc(u,d,s[f],s[f+1],e,i,r))return!0}else c+=fc(u,d,s[f],s[f+1],i,r)||0;u=s[f++],d=s[f++];break;case gc.C:if(n){if(lc(u,d,s[f++],s[f++],s[f++],s[f++],s[f],s[f+1],e,i,r))return!0}else c+=bc(u,d,s[f++],s[f++],s[f++],s[f++],s[f],s[f+1],i,r)||0;u=s[f++],d=s[f++];break;case gc.Q:if(n){if(cc(u,d,s[f++],s[f++],s[f],s[f+1],e,i,r))return!0}else c+=xc(u,d,s[f++],s[f++],s[f],s[f+1],i,r)||0;u=s[f++],d=s[f++];break;case gc.A:var m=s[f++],y=s[f++],_=s[f++],b=s[f++],x=s[f++],w=s[f++];f+=1;var S=!!(1-s[f++]);o=Math.cos(x)*_+m,a=Math.sin(x)*b+y,v?(h=o,p=a):c+=fc(u,d,o,a,i,r);var C=(i-m)*b/_+m;if(n){if(pc(m,y,b,x,x+w,S,e,C,r))return!0}else c+=wc(m,y,b,x,x+w,S,C,r);u=Math.cos(x+w)*_+m,d=Math.sin(x+w)*b+y;break;case gc.R:if(h=u=s[f++],p=d=s[f++],o=h+s[f++],a=p+s[f++],n){if(sc(h,p,o,p,e,i,r)||sc(o,p,o,a,e,i,r)||sc(o,a,h,a,e,i,r)||sc(h,a,h,p,e,i,r))return!0}else c+=fc(o,p,o,a,i,r),c+=fc(h,a,h,p,i,r);break;case gc.Z:if(n){if(sc(u,d,h,p,e,i,r))return!0}else c+=fc(u,d,h,p,i,r);u=h,d=p}}return n||function(t,e){return Math.abs(t-e)<1e-4}(d,p)||(c+=fc(u,d,h,p,i,r)||0),0!==c}var Cc=Ke({fill:"#000",stroke:null,strokePercent:1,fillOpacity:1,strokeOpacity:1,lineDashOffset:0,lineWidth:1,lineCap:"butt",miterLimit:10,strokeNoScale:!1,strokeFirst:!1},wl),Mc={style:Ke({fill:!0,stroke:!0,strokePercent:!0,fillOpacity:!0,strokeOpacity:!0,lineDashOffset:!0,lineWidth:!0,miterLimit:!0},Sl.style)},kc=_a.concat(["invisible","culling","z","z2","zlevel","parent"]),Tc=function(t){function e(e){return t.call(this,e)||this}var n;return _(e,t),e.prototype.update=function(){var n=this;t.prototype.update.call(this);var i=this.style;if(i.decal){var r=this._decalEl=this._decalEl||new e;r.buildPath===e.prototype.buildPath&&(r.buildPath=function(t){n.buildPath(t,n.shape)}),r.silent=!0;var o=r.style;for(var a in i)o[a]!==i[a]&&(o[a]=i[a]);o.fill=i.fill?i.decal:null,o.decal=null,o.shadowColor=null,i.strokeFirst&&(o.stroke=null);for(var s=0;s.5?la:e>.2?"#eee":ca}if(t)return ca}return la},e.prototype.getInsideTextStroke=function(t){var e=this.style.fill;if(dn(e)){var n=this.__zr;if(!(!n||!n.isDarkMode())===oo(t,0)<.4)return e}},e.prototype.buildPath=function(t,e,n){},e.prototype.pathUpdated=function(){this.__dirty&=-5},e.prototype.getUpdatedPathProxy=function(t){return!this.path&&this.createPathProxy(),this.path.beginPath(),this.buildPath(this.path,this.shape,t),this.path},e.prototype.createPathProxy=function(){this.path=new ac(!1)},e.prototype.hasStroke=function(){var t=this.style,e=t.stroke;return!(null==e||"none"===e||!(t.lineWidth>0))},e.prototype.hasFill=function(){var t=this.style.fill;return null!=t&&"none"!==t},e.prototype.getBoundingRect=function(){var t=this._rect,e=this.style,n=!t;if(n){var i=!1;this.path||(i=!0,this.createPathProxy());var r=this.path;(i||4&this.__dirty)&&(r.beginPath(),this.buildPath(r,this.shape,!1),this.pathUpdated()),t=r.getBoundingRect()}if(this._rect=t,this.hasStroke()&&this.path&&this.path.len()>0){var o=this._rectStroke||(this._rectStroke=t.clone());if(this.__dirty||n){o.copy(t);var a=e.strokeNoScale?this.getLineScale():1,s=e.lineWidth;if(!this.hasFill()){var l=this.strokeContainThreshold;s=Math.max(s,null==l?4:l)}a>1e-10&&(o.width+=s/a,o.height+=s/a,o.x-=s/a/2,o.y-=s/a/2)}return o}return t},e.prototype.contain=function(t,e){var n=this.transformCoordToLocal(t,e),i=this.getBoundingRect(),r=this.style;if(t=n[0],e=n[1],i.contain(t,e)){var o=this.path;if(this.hasStroke()){var a=r.lineWidth,s=r.strokeNoScale?this.getLineScale():1;if(s>1e-10&&(this.hasFill()||(a=Math.max(a,this.strokeContainThreshold)),function(t,e,n,i){return Sc(t,e,!0,n,i)}(o,a/s,t,e)))return!0}if(this.hasFill())return function(t,e,n){return Sc(t,0,!1,e,n)}(o,t,e)}return!1},e.prototype.dirtyShape=function(){this.__dirty|=4,this._rect&&(this._rect=null),this._decalEl&&this._decalEl.dirtyShape(),this.markRedraw()},e.prototype.dirty=function(){this.dirtyStyle(),this.dirtyShape()},e.prototype.animateShape=function(t){return this.animate("shape",t)},e.prototype.updateDuringAnimation=function(t){"style"===t?this.dirtyStyle():"shape"===t?this.dirtyShape():this.markRedraw()},e.prototype.attrKV=function(e,n){"shape"===e?this.setShape(n):t.prototype.attrKV.call(this,e,n)},e.prototype.setShape=function(t,e){var n=this.shape;return n||(n=this.shape={}),"string"==typeof t?n[t]=e:Ze(n,t),this.dirtyShape(),this},e.prototype.shapeChanged=function(){return!!(4&this.__dirty)},e.prototype.createStyle=function(t){return On(Cc,t)},e.prototype._innerSaveToNormal=function(e){t.prototype._innerSaveToNormal.call(this,e);var n=this._normalState;e.shape&&!n.shape&&(n.shape=Ze({},this.shape))},e.prototype._applyStateObj=function(e,n,i,r,o,a){t.prototype._applyStateObj.call(this,e,n,i,r,o,a);var s,l=!(n&&r);if(n&&n.shape?o?r?s=n.shape:(s=Ze({},i.shape),Ze(s,n.shape)):(s=Ze({},r?this.shape:i.shape),Ze(s,n.shape)):l&&(s=i.shape),s)if(o){this.shape=Ze({},this.shape);for(var c={},u=an(s),d=0;dc&&(n*=c/(a=n+i),i*=c/a),r+o>c&&(r*=c/(a=r+o),o*=c/a),i+r>u&&(i*=u/(a=i+r),r*=u/a),n+o>u&&(n*=u/(a=n+o),o*=u/a),t.moveTo(s+n,l),t.lineTo(s+c-i,l),0!==i&&t.arc(s+c-i,l+i,i,-Math.PI/2,0),t.lineTo(s+c,l+u-r),0!==r&&t.arc(s+c-r,l+u-r,r,0,Math.PI/2),t.lineTo(s+o,l+u),0!==o&&t.arc(s+o,l+u-o,o,Math.PI/2,Math.PI),t.lineTo(s,l+n),0!==n&&t.arc(s+n,l+n,n,Math.PI,1.5*Math.PI)}(t,e):t.rect(n,i,r,o)},e.prototype.isZeroArea=function(){return!this.shape.width||!this.shape.height},e}(Tc);Bc.prototype.type="rect";var Fc={fill:"#000"},$c={},Vc={style:Ke({fill:!0,stroke:!0,fillOpacity:!0,strokeOpacity:!0,lineWidth:!0,fontSize:!0,lineHeight:!0,width:!0,height:!0,textShadowColor:!0,textShadowBlur:!0,textShadowOffsetX:!0,textShadowOffsetY:!0,backgroundColor:!0,padding:!0,borderColor:!0,borderWidth:!0,borderRadius:!0},Sl.style)},Wc=function(t){function e(e){var n=t.call(this)||this;return n.type="text",n._children=[],n._defaultStyle=Fc,n.attr(e),n}return _(e,t),e.prototype.childrenRef=function(){return this._children},e.prototype.update=function(){t.prototype.update.call(this),this.styleChanged()&&this._updateSubTexts();for(var e=0;em&&p){var _=Math.floor(m/h);f=f||v.length>_,y=(v=v.slice(0,_)).length*h}if(r&&u&&null!=g)for(var b=ol(g,c,e.ellipsis,{minChar:e.truncateMinChar,placeholder:e.placeholder}),x={},w=0;w0,M=0;Mg&&dl(o,a.substring(g,v),e,f),dl(o,h[2],e,f,h[1]),g=il.lastIndex}gd){var O=o.lines.length;A>0?(M.tokens=M.tokens.slice(0,A),S(M,T,k),o.lines=o.lines.slice(0,C+1)):o.lines=o.lines.slice(0,C),o.isTruncated=o.isTruncated||o.lines.length=0&&"right"===(T=_[k]).align;)this._placeToken(T,t,x,f,M,"right",v),w-=T.width,M-=T.width,k--;for(C+=(s-(C-p)-(g-M)-w)/2;S<=k;)T=_[S],this._placeToken(T,t,x,f,C+T.width/2,"center",v),C+=T.width,S++;f+=x}},e.prototype._placeToken=function(t,e,n,i,r,o,a){var s=e.rich[t.styleName]||{};s.text=t.text;var l=t.verticalAlign,c=i+n/2;"top"===l?c=i+t.height/2:"bottom"===l&&(c=i+n-t.height/2),!t.isLineHolder&&eu(s)&&this._renderBackground(s,e,"right"===o?r-t.width:"center"===o?r-t.width/2:r,c-t.height/2,t.width,t.height);var u=!!s.backgroundColor,d=t.textPadding;d&&(r=Jc(r,o,d),c-=t.height/2-d[0]-t.innerHeight/2);var h=this._getOrCreateChild(Dc),p=h.createStyle();h.useStyle(p);var f=this._defaultStyle,g=!1,v=0,m=!1,y=Qc("fill"in s?s.fill:"fill"in e?e.fill:(g=!0,f.fill)),_=Kc("stroke"in s?s.stroke:"stroke"in e?e.stroke:u||a||f.autoStroke&&!g?null:(v=2,m=!0,f.stroke)),b=s.textShadowBlur>0||e.textShadowBlur>0;p.text=t.text,p.x=r,p.y=c,b&&(p.shadowBlur=s.textShadowBlur||e.textShadowBlur||0,p.shadowColor=s.textShadowColor||e.textShadowColor||"transparent",p.shadowOffsetX=s.textShadowOffsetX||e.textShadowOffsetX||0,p.shadowOffsetY=s.textShadowOffsetY||e.textShadowOffsetY||0),p.textAlign=o,p.textBaseline="middle",p.font=t.font||De,p.opacity=wn(s.opacity,e.opacity,1),Xc(p,s),_&&(p.lineWidth=wn(s.lineWidth,e.lineWidth,v),p.lineDash=xn(s.lineDash,e.lineDash),p.lineDashOffset=e.lineDashOffset||0,p.stroke=_),y&&(p.fill=y),h.setBoundingRect(_l(p,t.contentWidth,t.contentHeight,m?0:null))},e.prototype._renderBackground=function(t,e,n,i,r,o){var a,s,l,c=t.backgroundColor,u=t.borderWidth,d=t.borderColor,h=c&&c.image,p=c&&!h,f=t.borderRadius,g=this;if(p||t.lineHeight||u&&d){(a=this._getOrCreateChild(Bc)).useStyle(a.createStyle()),a.style.fill=null;var v=a.shape;v.x=n,v.y=i,v.width=r,v.height=o,v.r=f,a.dirtyShape()}if(p)(l=a.style).fill=c||null,l.fillOpacity=xn(t.fillOpacity,1);else if(h){(s=this._getOrCreateChild(Lc)).onload=function(){g.dirtyStyle()};var m=s.style;m.image=c.image,m.x=n,m.y=i,m.width=r,m.height=o}u&&d&&((l=a.style).lineWidth=u,l.stroke=d,l.strokeOpacity=xn(t.strokeOpacity,1),l.lineDash=t.borderDash,l.lineDashOffset=t.borderDashOffset||0,a.strokeContainThreshold=0,a.hasFill()&&a.hasStroke()&&(l.strokeFirst=!0,l.lineWidth*=2));var y=(a||s).style;y.shadowBlur=t.shadowBlur||0,y.shadowColor=t.shadowColor||"transparent",y.shadowOffsetX=t.shadowOffsetX||0,y.shadowOffsetY=t.shadowOffsetY||0,y.opacity=wn(t.opacity,e.opacity,1)},e.makeFont=function(t){var e="";return Yc(t)&&(e=[t.fontStyle,t.fontWeight,jc(t.fontSize),t.fontFamily||"sans-serif"].join(" ")),e&&kn(e)||t.textFont||t.font},e}(kl),Uc={left:!0,right:1,center:1},Gc={top:1,bottom:1,middle:1},qc=["fontStyle","fontWeight","fontSize","fontFamily"];function jc(t){return"string"!=typeof t||-1===t.indexOf("px")&&-1===t.indexOf("rem")&&-1===t.indexOf("em")?isNaN(+t)?"12px":t+"px":t}function Xc(t,e){for(var n=0;n=0,o=!1;if(t instanceof Tc){var a=ou(t),s=r&&a.selectFill||a.normalFill,l=r&&a.selectStroke||a.normalStroke;if(gu(s)||gu(l)){var c=(i=i||{}).style||{};"inherit"===c.fill?(o=!0,i=Ze({},i),(c=Ze({},c)).fill=s):!gu(c.fill)&&gu(s)?(o=!0,i=Ze({},i),(c=Ze({},c)).fill=so(s)):!gu(c.stroke)&&gu(l)&&(o||(i=Ze({},i),c=Ze({},c)),c.stroke=so(l)),i.style=c}}if(i&&null==i.z2){o||(i=Ze({},i));var u=t.z2EmphasisLift;i.z2=t.z2+(null!=u?u:10)}return i}(this,0,e,n);if("blur"===t)return function(t,e,n){var i=Qe(t.currentStates,e)>=0,r=t.style.opacity,o=i?null:function(t,e,n,i){for(var r=t.style,o={},a=0;a0){var o={dataIndex:r,seriesIndex:t.seriesIndex};null!=i&&(o.dataType=i),e.push(o)}})}),e}function Vu(t,e,n){ju(t,!0),Cu(t,Tu),function(t,e,n){var i=nu(t);null!=e?(i.focus=e,i.blurScope=n):i.focus&&(i.focus=null)}(t,e,n)}function Wu(t,e,n,i){i?function(t){ju(t,!1)}(t):Vu(t,e,n)}var Uu=["emphasis","blur","select"],Gu={itemStyle:"getItemStyle",lineStyle:"getLineStyle",areaStyle:"getAreaStyle"};function qu(t,e,n,i){n=n||"itemStyle";for(var r=0;r1&&(a*=ed(f),s*=ed(f));var g=(r===o?-1:1)*ed((a*a*(s*s)-a*a*(p*p)-s*s*(h*h))/(a*a*(p*p)+s*s*(h*h)))||0,v=g*a*p/s,m=g*-s*h/a,y=(t+n)/2+id(d)*v-nd(d)*m,_=(e+i)/2+nd(d)*v+id(d)*m,b=sd([1,0],[(h-v)/a,(p-m)/s]),x=[(h-v)/a,(p-m)/s],w=[(-1*h-v)/a,(-1*p-m)/s],S=sd(x,w);if(ad(x,w)<=-1&&(S=rd),ad(x,w)>=1&&(S=0),S<0){var C=Math.round(S/rd*1e6)/1e6;S=2*rd+C%2*rd}u.addData(c,y,_,a,s,b,S,d,o)}var cd=/([mlvhzcqtsa])([^mlvhzcqtsa]*)/gi,ud=/-?([0-9]*\.)?[0-9]+([eE]-?[0-9]+)?/g;var dd=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return _(e,t),e.prototype.applyTransform=function(t){},e}(Tc);function hd(t){return null!=t.setData}function pd(t,e){var n=function(t){var e=new ac;if(!t)return e;var n,i=0,r=0,o=i,a=r,s=ac.CMD,l=t.match(cd);if(!l)return e;for(var c=0;cI*I+P*P&&(C=k,M=T),{cx:C,cy:M,x0:-u,y0:-d,x1:C*(r/x-1),y1:M*(r/x-1)}}function Id(t,e){var n,i=kd(e.r,0),r=kd(e.r0||0,0),o=i>0;if(o||r>0){if(o||(i=r,r=0),r>i){var a=i;i=r,r=a}var s=e.startAngle,l=e.endAngle;if(!isNaN(s)&&!isNaN(l)){var c=e.cx,u=e.cy,d=!!e.clockwise,h=Cd(l-s),p=h>_d&&h%_d;if(p>Ad&&(h=p),i>Ad)if(h>_d-Ad)t.moveTo(c+i*xd(s),u+i*bd(s)),t.arc(c,u,i,s,l,!d),r>Ad&&(t.moveTo(c+r*xd(l),u+r*bd(l)),t.arc(c,u,r,l,s,d));else{var f=void 0,g=void 0,v=void 0,m=void 0,y=void 0,_=void 0,b=void 0,x=void 0,w=void 0,S=void 0,C=void 0,M=void 0,k=void 0,T=void 0,A=void 0,D=void 0,I=i*xd(s),P=i*bd(s),L=r*xd(l),E=r*bd(l),O=h>Ad;if(O){var z=e.cornerRadius;z&&(n=function(t){var e;if(cn(t)){var n=t.length;if(!n)return t;e=1===n?[t[0],t[0],0,0]:2===n?[t[0],t[0],t[1],t[1]]:3===n?t.concat(t[2]):t}else e=[t,t,t,t];return e}(z),f=n[0],g=n[1],v=n[2],m=n[3]);var N=Cd(i-r)/2;if(y=Td(N,v),_=Td(N,m),b=Td(N,f),x=Td(N,g),C=w=kd(y,_),M=S=kd(b,x),(w>Ad||S>Ad)&&(k=i*xd(l),T=i*bd(l),A=r*xd(s),D=r*bd(s),hAd){var U=Td(v,C),G=Td(m,C),q=Dd(A,D,I,P,i,U,d),j=Dd(k,T,L,E,i,G,d);t.moveTo(c+q.cx+q.x0,u+q.cy+q.y0),C0&&t.arc(c+q.cx,u+q.cy,U,Sd(q.y0,q.x0),Sd(q.y1,q.x1),!d),t.arc(c,u,i,Sd(q.cy+q.y1,q.cx+q.x1),Sd(j.cy+j.y1,j.cx+j.x1),!d),G>0&&t.arc(c+j.cx,u+j.cy,G,Sd(j.y1,j.x1),Sd(j.y0,j.x0),!d))}else t.moveTo(c+I,u+P),t.arc(c,u,i,s,l,!d);else t.moveTo(c+I,u+P);if(r>Ad&&O)if(M>Ad){U=Td(f,M),q=Dd(L,E,k,T,r,-(G=Td(g,M)),d),j=Dd(I,P,A,D,r,-U,d);t.lineTo(c+q.cx+q.x0,u+q.cy+q.y0),M0&&t.arc(c+q.cx,u+q.cy,G,Sd(q.y0,q.x0),Sd(q.y1,q.x1),!d),t.arc(c,u,r,Sd(q.cy+q.y1,q.cx+q.x1),Sd(j.cy+j.y1,j.cx+j.x1),d),U>0&&t.arc(c+j.cx,u+j.cy,U,Sd(j.y1,j.x1),Sd(j.y0,j.x0),!d))}else t.lineTo(c+L,u+E),t.arc(c,u,r,l,s,d);else t.lineTo(c+L,u+E)}else t.moveTo(c,u);t.closePath()}}}var Pd=function(){this.cx=0,this.cy=0,this.r0=0,this.r=0,this.startAngle=0,this.endAngle=2*Math.PI,this.clockwise=!0,this.cornerRadius=0},Ld=function(t){function e(e){return t.call(this,e)||this}return _(e,t),e.prototype.getDefaultShape=function(){return new Pd},e.prototype.buildPath=function(t,e){Id(t,e)},e.prototype.isZeroArea=function(){return this.shape.startAngle===this.shape.endAngle||this.shape.r===this.shape.r0},e}(Tc);Ld.prototype.type="sector";var Ed=function(){this.cx=0,this.cy=0,this.r=0,this.r0=0},Od=function(t){function e(e){return t.call(this,e)||this}return _(e,t),e.prototype.getDefaultShape=function(){return new Ed},e.prototype.buildPath=function(t,e){var n=e.cx,i=e.cy,r=2*Math.PI;t.moveTo(n+e.r,i),t.arc(n,i,e.r,0,r,!1),t.moveTo(n+e.r0,i),t.arc(n,i,e.r0,0,r,!0)},e}(Tc);function zd(t,e,n){var i=e.smooth,r=e.points;if(r&&r.length>=2){if(i){var o=function(t,e,n,i){var r,o,a,s,l=[],c=[],u=[],d=[];if(i){a=[1/0,1/0],s=[-1/0,-1/0];for(var h=0,p=t.length;hih[1]){if(r=!1,rh.negativeSize||n)return r;var s=eh(ih[0]-nh[1]),l=eh(nh[0]-ih[1]);Jd(s,l)>ah.len()&&(s=l||!rh.bidirectional)&&(Ti.scale(oh,a,-l*i),rh.useDir&&rh.calcDirMTV()))}}return r},t.prototype._getProjMinMaxOnAxis=function(t,e,n){for(var i=this._axes[t],r=this._origin,o=e[0].dot(i)+r[t],a=o,s=o,l=1;l0){var d={duration:u.duration,delay:u.delay||0,easing:u.easing,done:o,force:!!o||!!a,setToFinal:!c,scope:t,during:a};l?e.animateFrom(n,d):e.animateTo(n,d)}else e.stopAnimation(),!l&&e.attr(n),a&&a(1),o&&o()}function hh(t,e,n,i,r,o){dh("update",t,e,n,i,r,o)}function ph(t,e,n,i,r,o){dh("enter",t,e,n,i,r,o)}function fh(t){if(!t.__zr)return!0;for(var e=0;e=-1e-6)return!1;var f=t-r,g=e-o,v=Oh(f,g,c,u)/p;if(v<0||v>1)return!1;var m=Oh(f,g,d,h)/p;return!(m<0||m>1)}function Oh(t,e,n,i){return t*i-n*e}function zh(t,e,n,i,r){return null==e||(pn(e)?Nh[0]=Nh[1]=Nh[2]=Nh[3]=e:(Nh[0]=e[0],Nh[1]=e[1],Nh[2]=e[2],Nh[3]=e[3]),i&&(Nh[0]=Qa(0,Nh[0]),Nh[1]=Qa(0,Nh[1]),Nh[2]=Qa(0,Nh[2]),Nh[3]=Qa(0,Nh[3])),n&&(Nh[0]=-Nh[0],Nh[1]=-Nh[1],Nh[2]=-Nh[2],Nh[3]=-Nh[3]),Rh(t,Nh,"x","width",3,1,r&&r[0]||0),Rh(t,Nh,"y","height",0,2,r&&r[1]||0)),t}var Nh=[0,0,0,0];function Rh(t,e,n,i,r,o,a){var s=e[o]+e[r],l=t[i];t[i]+=s,a=Qa(0,Ka(a,l)),t[i]=0?-e[r]:e[o]>=0?l+e[o]:Ja(s)>1e-8?(l-a)*e[r]/s:0):t[n]-=e[r]}function Hh(t){var e=t.itemTooltipOption,n=t.componentModel,i=t.itemName,r=dn(e)?{formatter:e}:e,o=n.mainType,a=n.componentIndex,s={componentType:o,name:i,$vars:["name"]};s[o+"Index"]=a;var l=t.formatterParamsExtra;l&&en(an(l),function(t){zn(s,t)||(s[t]=l[t],s.$vars.push(t))});var c=nu(t.el);c.componentMainType=o,c.componentIndex=a,c.tooltipConfig={name:i,option:Ke({content:i,encodeHTMLContent:!0,formatterParams:s},r)}}function Bh(t,e){var n;t.isGroup&&(n=e(t)),n||t.traverse(e)}function Fh(t,e){if(t)if(cn(t))for(var n=0;ne&&(e=i),ie&&(n=e=0),{min:n,max:e}},clipPointsByRect:function(t,e){return nn(t,function(t){var n=t[0];n=Qa(n,e.x),n=Ka(n,e.x+e.width);var i=t[1];return i=Qa(i,e.y),[n,i=Ka(i,e.y+e.height)]})},clipRectByRect:function(t,e){var n=Qa(t.x,e.x),i=Ka(t.x+t.width,e.x+e.width),r=Qa(t.y,e.y),o=Ka(t.y+t.height,e.y+e.height);if(i>=n&&o>=r)return{x:n,y:r,width:i-n,height:o-r}},createIcon:Lh,ensureCopyRect:Wh,ensureCopyTransform:Uh,expandOrShrinkRect:zh,extendPath:function(t,e){return bh(t,e)},extendShape:function(t){return Tc.extend(t)},getShapeClass:function(t){if(mh.hasOwnProperty(t))return mh[t]},getTransform:function(t,e){for(var n=xi([]);t&&t!==e;)Si(n,t.getLocalTransform(),n),t=t.parent;return n},groupTransition:Ph,initProps:ph,isBoundingRectAxisAligned:$h,isElementRemoved:fh,lineLineIntersect:Eh,linePolygonIntersect:function(t,e,n,i,r){for(var o=0,a=r[r.length-1];oJa(o[1])?o[0]>0?"right":"left":o[1]>0?"bottom":"top"},traverseElements:Fh,traverseUpdateZ:qh,updateProps:hh}),Yh={};function Zh(t,e,n){var i,r=t.labelFetcher,o=t.labelDataIndex,a=t.labelDimIndex,s=e.normal;r&&(i=r.getFormattedLabel(o,"normal",null,a,s&&s.get("formatter"),null!=n?{interpolatedValue:n}:null)),null==i&&(i=un(t.defaultText)?t.defaultText(o,t,n):t.defaultText);for(var l={normal:i},c=0;c-1?wp:Cp;function Ap(t,e){t=t.toUpperCase(),kp[t]=new _p(e),Mp[t]=e}Ap(Sp,{time:{month:["January","February","March","April","May","June","July","August","September","October","November","December"],monthAbbr:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],dayOfWeek:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],dayOfWeekAbbr:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"]},legend:{selector:{all:"All",inverse:"Inv"}},toolbox:{brush:{title:{rect:"Box Select",polygon:"Lasso Select",lineX:"Horizontally Select",lineY:"Vertically Select",keep:"Keep Selections",clear:"Clear Selections"}},dataView:{title:"Data View",lang:["Data View","Close","Refresh"]},dataZoom:{title:{zoom:"Zoom",back:"Zoom Reset"}},magicType:{title:{line:"Switch to Line Chart",bar:"Switch to Bar Chart",stack:"Stack",tiled:"Tile"}},restore:{title:"Restore"},saveAsImage:{title:"Save as Image",lang:["Right Click to Save Image"]}},series:{typeNames:{pie:"Pie chart",bar:"Bar chart",line:"Line chart",scatter:"Scatter plot",effectScatter:"Ripple scatter plot",radar:"Radar chart",tree:"Tree",treemap:"Treemap",boxplot:"Boxplot",candlestick:"Candlestick",k:"K line chart",heatmap:"Heat map",map:"Map",parallel:"Parallel coordinate map",lines:"Line graph",graph:"Relationship graph",sankey:"Sankey diagram",funnel:"Funnel chart",gauge:"Gauge",pictorialBar:"Pictorial bar",themeRiver:"Theme River Map",sunburst:"Sunburst",custom:"Custom chart",chart:"Chart"}},aria:{general:{withTitle:'This is a chart about "{title}"',withoutTitle:"This is a chart"},series:{single:{prefix:"",withName:" with type {seriesType} named {seriesName}.",withoutName:" with type {seriesType}."},multiple:{prefix:". It consists of {seriesCount} series count.",withName:" The {seriesId} series is a {seriesType} representing {seriesName}.",withoutName:" The {seriesId} series is a {seriesType}.",separator:{middle:"",end:""}}},data:{allData:"The data is as follows: ",partialData:"The first {displayCnt} items are: ",withName:"the data for {name} is {value}",withoutName:"{value}",separator:{middle:", ",end:". "}}}}),Ap(wp,{time:{month:["一月","二月","三月","四月","五月","六月","七月","八月","九月","十月","十一月","十二月"],monthAbbr:["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"],dayOfWeek:["星期日","星期一","星期二","星期三","星期四","星期五","星期六"],dayOfWeekAbbr:["日","一","二","三","四","五","六"]},legend:{selector:{all:"全选",inverse:"反选"}},toolbox:{brush:{title:{rect:"矩形选择",polygon:"圈选",lineX:"横向选择",lineY:"纵向选择",keep:"保持选择",clear:"清除选择"}},dataView:{title:"数据视图",lang:["数据视图","关闭","刷新"]},dataZoom:{title:{zoom:"区域缩放",back:"区域缩放还原"}},magicType:{title:{line:"切换为折线图",bar:"切换为柱状图",stack:"切换为堆叠",tiled:"切换为平铺"}},restore:{title:"还原"},saveAsImage:{title:"保存为图片",lang:["右键另存为图片"]}},series:{typeNames:{pie:"饼图",bar:"柱状图",line:"折线图",scatter:"散点图",effectScatter:"涟漪散点图",radar:"雷达图",tree:"树图",treemap:"矩形树图",boxplot:"箱型图",candlestick:"K线图",k:"K线图",heatmap:"热力图",map:"地图",parallel:"平行坐标图",lines:"线图",graph:"关系图",sankey:"桑基图",funnel:"漏斗图",gauge:"仪表盘图",pictorialBar:"象形柱图",themeRiver:"主题河流图",sunburst:"旭日图",custom:"自定义图表",chart:"图表"}},aria:{general:{withTitle:"这是一个关于“{title}”的图表。",withoutTitle:"这是一个图表,"},series:{single:{prefix:"",withName:"图表类型是{seriesType},表示{seriesName}。",withoutName:"图表类型是{seriesType}。"},multiple:{prefix:"它由{seriesCount}个图表系列组成。",withName:"第{seriesId}个系列是一个表示{seriesName}的{seriesType},",withoutName:"第{seriesId}个系列是一个{seriesType},",separator:{middle:";",end:"。"}}},data:{allData:"其数据是——",partialData:"其中,前{displayCnt}项是——",withName:"{name}的数据是{value}",withoutName:"{value}",separator:{middle:",",end:""}}}});function Dp(){return null}var Ip=1e3,Pp=6e4,Lp=36e5,Ep=864e5,Op=31536e6,zp={year:/({yyyy}|{yy})/,month:/({MMMM}|{MMM}|{MM}|{M})/,day:/({dd}|{d})/,hour:/({HH}|{H}|{hh}|{h})/,minute:/({mm}|{m})/,second:/({ss}|{s})/,millisecond:/({SSS}|{S})/},Np={year:"{yyyy}",month:"{MMM}",day:"{d}",hour:"{HH}:{mm}",minute:"{HH}:{mm}",second:"{HH}:{mm}:{ss}",millisecond:"{HH}:{mm}:{ss} {SSS}"},Rp="{yyyy}-{MM}-{dd}",Hp={year:"{yyyy}",month:"{yyyy}-{MM}",day:Rp,hour:Rp+" "+Np.hour,minute:Rp+" "+Np.minute,second:Rp+" "+Np.second,millisecond:"{yyyy}-{MM}-{dd} {HH}:{mm}:{ss} {SSS}"},Bp=["year","month","day","hour","minute","second","millisecond"],Fp=["year","half-year","quarter","month","week","half-week","day","half-day","quarter-day","hour","minute","second","millisecond"];function $p(t){return dn(t)||un(t)?t:function(t){t=t||{};var e={},n=!0;return en(Bp,function(e){n&&(n=null==t[e])}),en(Bp,function(i,r){var o=t[i];e[i]={};for(var a=null,s=r;s>=0;s--){var l=Bp[s],c=fn(o)&&!cn(o)?o[l]:o,u=void 0;cn(c)?a=(u=c.slice())[0]||"":dn(c)?u=[a=c]:(null==a?a=Np[i]:zp[l].test(a)||(a=e[l][l][0]+" "+a),u=[a],n&&(u[1]="{primary|"+a+"}")),e[i][l]=u}}),e}(t)}function Vp(t,e){return"0000".substr(0,e-(t+="").length)+t}function Wp(t){switch(t){case"half-year":case"quarter":return"month";case"week":case"half-week":return"day";case"half-day":case"quarter-day":return"hour";default:return t}}function Up(t){return t===Wp(t)}function Gp(t,e,n,i){var r=cs(t),o=r[Xp(n)](),a=r[Yp(n)]()+1,s=Math.floor((a-1)/3)+1,l=r[Zp(n)](),c=r["get"+(n?"UTC":"")+"Day"](),u=r[Kp(n)](),d=(u-1)%12+1,h=r[Qp(n)](),p=r[Jp(n)](),f=r[tf(n)](),g=u>=12?"pm":"am",v=g.toUpperCase(),m=i instanceof _p?i:function(t){return kp[t]}(i||Tp)||kp[Cp],y=m.getModel("time"),_=y.get("month"),b=y.get("monthAbbr"),x=y.get("dayOfWeek"),w=y.get("dayOfWeekAbbr");return(e||"").replace(/{a}/g,g+"").replace(/{A}/g,v+"").replace(/{yyyy}/g,o+"").replace(/{yy}/g,Vp(o%100+"",2)).replace(/{Q}/g,s+"").replace(/{MMMM}/g,_[a-1]).replace(/{MMM}/g,b[a-1]).replace(/{MM}/g,Vp(a,2)).replace(/{M}/g,a+"").replace(/{dd}/g,Vp(l,2)).replace(/{d}/g,l+"").replace(/{eeee}/g,x[c]).replace(/{ee}/g,w[c]).replace(/{e}/g,c+"").replace(/{HH}/g,Vp(u,2)).replace(/{H}/g,u+"").replace(/{hh}/g,Vp(d+"",2)).replace(/{h}/g,d+"").replace(/{mm}/g,Vp(h,2)).replace(/{m}/g,h+"").replace(/{ss}/g,Vp(p,2)).replace(/{s}/g,p+"").replace(/{SSS}/g,Vp(f,3)).replace(/{S}/g,f+"")}function qp(t,e){var n=cs(t),i=n[Yp(e)]()+1,r=n[Zp(e)](),o=n[Kp(e)](),a=n[Qp(e)](),s=n[Jp(e)](),l=0===n[tf(e)](),c=l&&0===s,u=c&&0===a,d=u&&0===o,h=d&&1===r;return h&&1===i?"year":h?"month":d?"day":u?"hour":c?"minute":l?"second":"millisecond"}function jp(t,e,n){switch(e){case"year":t[nf(n)](0);case"month":t[rf(n)](1);case"day":t[of(n)](0);case"hour":t[af(n)](0);case"minute":t[sf(n)](0);case"second":t[lf(n)](0)}return t}function Xp(t){return t?"getUTCFullYear":"getFullYear"}function Yp(t){return t?"getUTCMonth":"getMonth"}function Zp(t){return t?"getUTCDate":"getDate"}function Kp(t){return t?"getUTCHours":"getHours"}function Qp(t){return t?"getUTCMinutes":"getMinutes"}function Jp(t){return t?"getUTCSeconds":"getSeconds"}function tf(t){return t?"getUTCMilliseconds":"getMilliseconds"}function ef(t){return t?"setUTCFullYear":"setFullYear"}function nf(t){return t?"setUTCMonth":"setMonth"}function rf(t){return t?"setUTCDate":"setDate"}function of(t){return t?"setUTCHours":"setHours"}function af(t){return t?"setUTCMinutes":"setMinutes"}function sf(t){return t?"setUTCSeconds":"setSeconds"}function lf(t){return t?"setUTCMilliseconds":"setMilliseconds"}function cf(t){if(isNaN(hs(t)))return dn(t)?t:"-";var e=(t+"").split(".");return e[0].replace(/(\d{1,3})(?=(?:\d{3})+(?!\d))/g,"$1,")+(e.length>1?"."+e[1]:"")}function uf(t,e){return t=(t||"").toLowerCase().replace(/-(.)/g,function(t,e){return e.toUpperCase()}),e&&t&&(t=t.charAt(0).toUpperCase()+t.slice(1)),t}var df=Cn;function hf(t,e,n){function i(t){return t&&kn(t)?t:"-"}function r(t){return!(null==t||isNaN(t)||!isFinite(t))}var o="time"===e,a=t instanceof Date;if(o||a){var s=o?cs(t):t;if(!isNaN(+s))return Gp(s,"{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}",n);if(a)return"-"}if("ordinal"===e)return hn(t)?i(t):pn(t)&&r(t)?t+"":"-";var l=hs(t);return r(l)?cf(l):hn(t)?i(t):"boolean"==typeof t?t+"":"-"}var pf=["a","b","c","d","e","f","g"],ff=function(t,e){return"{"+t+(null==e?"":e)+"}"};function gf(t,e,n){cn(e)||(e=[e]);var i=e.length;if(!i)return"";for(var r=e[0].$vars||[],o=0;oi||l.newline?(o=0,u=g,a+=s+n,s=h.height):s=Math.max(s,h.height)}else{var v=h.height+(f?-f.y+h.y:0);(d=a+v)>r||l.newline?(o+=s+n,a=0,d=v,s=h.width):s=Math.max(s,h.width)}l.newline||(l.x=o,l.y=a,l.markRedraw(),"horizontal"===t?o=u+n:a=d+n)})}function Pf(t,e,n){n=df(n||0);var i=e.width,r=e.height,o=es(t.left,i),a=es(t.top,r),s=es(t.right,i),l=es(t.bottom,r),c=es(t.width,i),u=es(t.height,r),d=n[2]+n[0],h=n[1]+n[3],p=t.aspect;switch(isNaN(c)&&(c=i-s-h-o),isNaN(u)&&(u=r-l-d-a),null!=p&&(isNaN(c)&&isNaN(u)&&(p>i/r?c=.8*i:u=.8*r),isNaN(c)&&(c=p*u),isNaN(u)&&(u=c/p)),isNaN(o)&&(o=i-s-c-h),isNaN(a)&&(a=r-l-u-d),t.left||t.right){case"center":o=i/2-c/2-n[3];break;case"right":o=i-c-h}switch(t.top||t.bottom){case"middle":case"center":a=r/2-u/2-n[0];break;case"bottom":a=r-u-d}o=o||0,a=a||0,isNaN(c)&&(c=i-h-o-(s||0)),isNaN(u)&&(u=r-d-a-(l||0));var f=new $i((e.x||0)+o+n[3],(e.y||0)+a+n[0],c,u);return f.margin=n,f}ln(If,"vertical"),ln(If,"horizontal");var Lf=1;function Ef(t,e,n){var i,r,o,a,s=t.boxCoordinateSystem;if(s){var l=function(t){var e=t.getShallow("coord",!0),n=bf;if(null==e){var i=wf.get(t.type);i&&i.getCoord2&&(n=xf,e=i.getCoord2(t))}return{coord:e,from:n}}(t),c=l.coord,u=l.from;if(s.dataToLayout){o=Lf,a=u;var d=s.dataToLayout(c);i=d.contentRect||d.rect}}return null==o&&(o=Lf),o===Lf&&(i||(i={x:0,y:0,width:e.getWidth(),height:e.getHeight()}),r=[i.x+i.width/2,i.y+i.height/2]),{type:o,refContainer:i,refPoint:r,boxCoordFrom:a}}function Of(t){var e=t.layoutMode||t.constructor.layoutMode;return fn(e)?e:e?{type:e}:null}function zf(t,e,n){var i=n&&n.ignoreSize;!cn(i)&&(i=[i,i]);var r=a(Df[0],0),o=a(Df[1],1);function a(n,r){var o={},a=0,l={},c=0;if(Tf(n,function(e){l[e]=t[e]}),Tf(n,function(t){zn(e,t)&&(o[t]=l[t]=e[t]),s(o,t)&&a++,s(l,t)&&c++}),i[r])return s(e,n[1])?l[n[2]]=null:s(e,n[2])&&(l[n[1]]=null),l;if(2!==c&&a){if(a>=2)return o;for(var u=0;u=0;a--)o=Ye(o,n[a],!0);e.defaultOption=o}return e.defaultOption},e.prototype.getReferringComponents=function(t,e){var n=t+"Index",i=t+"Id";return Hs(this.ecModel,t,{index:this.get(n,!0),id:this.get(i,!0)},e)},e.prototype.getBoxLayoutParams=function(){return e=!1,{left:(t=this).getShallow("left",e),top:t.getShallow("top",e),right:t.getShallow("right",e),bottom:t.getShallow("bottom",e),width:t.getShallow("width",e),height:t.getShallow("height",e)};var t,e},e.prototype.getZLevelKey=function(){return""},e.prototype.setZLevel=function(t){this.option.zlevel=t},e.protoInitialize=((n=e.prototype).type="component",n.id="",n.name="",n.mainType="",n.subType="",void(n.componentIndex=0)),e}(_p);Us(Hf,_p),Xs(Hf),function(t){var e={};t.registerSubTypeDefaulter=function(t,n){var i=Vs(t);e[i.main]=n},t.determineSubType=function(n,i){var r=i.type;if(!r){var o=Vs(n).main;t.hasSubTypes(n)&&e[o]&&(r=e[o](i))}return r}}(Hf),function(t,e){function n(t,e){return t[e]||(t[e]={predecessor:[],successor:[]}),t[e]}t.topologicalTravel=function(t,i,r,o){if(t.length){var a=function(t){var i={},r=[];return en(t,function(o){var a=n(i,o),s=function(t,e){var n=[];return en(t,function(t){Qe(e,t)>=0&&n.push(t)}),n}(a.originalDeps=e(o),t);a.entryCount=s.length,0===a.entryCount&&r.push(o),en(s,function(t){Qe(a.predecessor,t)<0&&a.predecessor.push(t);var e=n(i,t);Qe(e.successor,t)<0&&e.successor.push(o)})}),{graph:i,noEntryList:r}}(i),s=a.graph,l=a.noEntryList,c={};for(en(t,function(t){c[t]=!0});l.length;){var u=l.pop(),d=s[u],h=!!c[u];h&&(r.call(o,u,d.originalDeps.slice()),delete c[u]),en(d.successor,h?f:p)}en(c,function(){throw new Error("")})}function p(t){s[t].entryCount--,0===s[t].entryCount&&l.push(t)}function f(t){c[t]=!0,p(t)}}}(Hf,function(t){var e=[];en(Hf.getClassesByMainType(t),function(t){e=e.concat(t.dependencies||t.prototype.dependencies||[])}),e=nn(e,function(t){return Vs(t).main}),"dataset"!==t&&Qe(e,"dataset")<=0&&e.unshift("dataset");return e});var Bf={color:{},darkColor:{},size:{}},Ff=Bf.color={theme:["#5070dd","#b6d634","#505372","#ff994d","#0ca8df","#ffd10a","#fb628b","#785db0","#3fbe95"],neutral00:"#fff",neutral05:"#f4f7fd",neutral10:"#e8ebf0",neutral15:"#dbdee4",neutral20:"#cfd2d7",neutral25:"#c3c5cb",neutral30:"#b7b9be",neutral35:"#aaacb2",neutral40:"#9ea0a5",neutral45:"#929399",neutral50:"#86878c",neutral55:"#797b7f",neutral60:"#6d6e73",neutral65:"#616266",neutral70:"#54555a",neutral75:"#48494d",neutral80:"#3c3c41",neutral85:"#303034",neutral90:"#232328",neutral95:"#17171b",neutral99:"#000",accent05:"#eff1f9",accent10:"#e0e4f2",accent15:"#d0d6ec",accent20:"#c0c9e6",accent25:"#b1bbdf",accent30:"#a1aed9",accent35:"#91a0d3",accent40:"#8292cc",accent45:"#7285c6",accent50:"#6578ba",accent55:"#5c6da9",accent60:"#536298",accent65:"#4a5787",accent70:"#404c76",accent75:"#374165",accent80:"#2e3654",accent85:"#252b43",accent90:"#1b2032",accent95:"#121521",transparent:"rgba(0,0,0,0)",highlight:"rgba(255,231,130,0.8)"};for(var $f in Ze(Ff,{primary:Ff.neutral80,secondary:Ff.neutral70,tertiary:Ff.neutral60,quaternary:Ff.neutral50,disabled:Ff.neutral20,border:Ff.neutral30,borderTint:Ff.neutral20,borderShade:Ff.neutral40,background:Ff.neutral05,backgroundTint:"rgba(234,237,245,0.5)",backgroundTransparent:"rgba(255,255,255,0)",backgroundShade:Ff.neutral10,shadow:"rgba(0,0,0,0.2)",shadowTint:"rgba(129,130,136,0.2)",axisLine:Ff.neutral70,axisLineTint:Ff.neutral40,axisTick:Ff.neutral70,axisTickMinor:Ff.neutral60,axisLabel:Ff.neutral70,axisSplitLine:Ff.neutral15,axisMinorSplitLine:Ff.neutral05}),Ff)if(Ff.hasOwnProperty($f)){var Vf=Ff[$f];"theme"===$f?Bf.darkColor.theme=Ff.theme.slice():"highlight"===$f?Bf.darkColor.highlight="rgba(255,231,130,0.4)":0===$f.indexOf("accent")?Bf.darkColor[$f]=io(Vf,0,function(t){return.5*t},function(t){return Math.min(1,1.3-t)}):Bf.darkColor[$f]=io(Vf,0,function(t){return.9*t},function(t){return 1-Math.pow(t,1.5)})}Bf.size={xxs:2,xs:5,s:10,m:15,l:20,xl:30,xxl:40,xxxl:50};var Wf="";"undefined"!=typeof navigator&&(Wf=navigator.platform||"");var Uf="rgba(0, 0, 0, 0.2)",Gf=Bf.color.theme[0],qf=io(Gf,0,null,.9),jf={darkMode:"auto",colorBy:"series",color:Bf.color.theme,gradientColor:[qf,Gf],aria:{decal:{decals:[{color:Uf,dashArrayX:[1,0],dashArrayY:[2,5],symbolSize:1,rotation:Math.PI/6},{color:Uf,symbol:"circle",dashArrayX:[[8,8],[0,8,8,0]],dashArrayY:[6,0],symbolSize:.8},{color:Uf,dashArrayX:[1,0],dashArrayY:[4,3],rotation:-Math.PI/4},{color:Uf,dashArrayX:[[6,6],[0,6,6,0]],dashArrayY:[6,0]},{color:Uf,dashArrayX:[[1,0],[1,6]],dashArrayY:[1,0,6,0],rotation:Math.PI/4},{color:Uf,symbol:"triangle",dashArrayX:[[9,9],[0,9,9,0]],dashArrayY:[7,2],symbolSize:.75}]}},textStyle:{fontFamily:Wf.match(/^Win/)?"Microsoft YaHei":"sans-serif",fontSize:12,fontStyle:"normal",fontWeight:"normal"},blendMode:null,stateAnimation:{duration:300,easing:"cubicOut"},animation:"auto",animationDuration:1e3,animationDurationUpdate:500,animationEasing:"cubicInOut",animationEasingUpdate:"cubicInOut",animationThreshold:2e3,progressiveThreshold:3e3,progressive:400,hoverLayerThreshold:3e3,useUTC:!1},Xf=En(["tooltip","label","itemName","itemId","itemGroupId","itemChildGroupId","seriesName"]),Yf="original",Zf="arrayRows",Kf="objectRows",Qf="keyedColumns",Jf="typedArray",tg="unknown",eg="column",ng="row",ig=1,rg=2,og=3,ag=Es();function sg(t,e,n){var i={},r=lg(e);if(!r||!t)return i;var o,a,s=[],l=[],c=e.ecModel,u=ag(c).datasetMap,d=r.uid+"_"+n.seriesLayoutBy;en(t=t.slice(),function(e,n){var r=fn(e)?e:t[n]={name:e};"ordinal"===r.type&&null==o&&(o=n,a=f(r)),i[r.name]=[]});var h=u.get(d)||u.set(d,{categoryWayDim:a,valueWayDim:0});function p(t,e,n){for(var i=0;ie)return t[i];return t[n-1]}(i,a):n;if(u=u||n,!u||!u.length)return;var d=u[l];r&&(c[r]=d);return s.paletteIdx=(l+1)%u.length,d}(this,dg,i,r,t,e,n)},t.prototype.clearColorPalette=function(){!function(t,e){e(t).paletteIdx=0,e(t).paletteNameMap={}}(this,dg)},t}();var vg="\0_ec_inner",mg=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return _(e,t),e.prototype.init=function(t,e,n,i,r,o){i=i||{},this.option=null,this._theme=new _p(i),this._locale=new _p(r),this._optionManager=o},e.prototype.setOption=function(t,e,n){var i=bg(e);this._optionManager.setOption(t,n,i),this._resetOption(null,i)},e.prototype.resetOption=function(t,e){return this._resetOption(t,bg(e))},e.prototype._resetOption=function(t,e){var n=!1,i=this._optionManager;if(!t||"recreate"===t){var r=i.mountOption("recreate"===t);this.option&&"recreate"!==t?(this.restoreData(),this._mergeOption(r,e)):fg(this,r),n=!0}if("timeline"!==t&&"media"!==t||this.restoreData(),!t||"recreate"===t||"timeline"===t){var o=i.getTimelineOption(this);o&&(n=!0,this._mergeOption(o,e))}if(!t||"recreate"===t||"media"===t){var a=i.getMediaOption(this);a.length&&en(a,function(t){n=!0,this._mergeOption(t,e)},this)}return n},e.prototype.mergeOption=function(t){this._mergeOption(t,null)},e.prototype._mergeOption=function(t,e){var n=this.option,i=this._componentsMap,r=this._componentsCount,o=[],a=En(),s=e&&e.replaceMergeMainTypeMap;ag(this).datasetMap=En(),en(t,function(t,e){null!=t&&(Hf.hasClass(e)?e&&(o.push(e),a.set(e,!0)):n[e]=null==n[e]?Xe(t):Ye(n[e],t,!0))}),s&&s.each(function(t,e){Hf.hasClass(e)&&!a.get(e)&&(o.push(e),a.set(e,!0))}),Hf.topologicalTravel(o,Hf.getAllClassMainTypes(),function(e){var o=function(t,e,n){var i=ug.get(e);if(!i)return n;var r=i(t);return r?n.concat(r):n}(this,e,xs(t[e])),a=i.get(e),l=a?s&&s.get(e)?"replaceMerge":"normalMerge":"replaceAll",c=ks(a,o,l);(function(t,e,n){en(t,function(t){var i=t.newOption;fn(i)&&(t.keyInfo.mainType=e,t.keyInfo.subType=function(t,e,n,i){return e.type?e.type:n?n.subType:i.determineSubType(t,e)}(e,i,t.existing,n))})})(c,e,Hf),n[e]=null,i.set(e,null),r.set(e,0);var u,d=[],h=[],p=0;en(c,function(t,n){var i=t.existing,r=t.newOption;if(r){var o="series"===e,a=Hf.getClass(e,t.keyInfo.subType,!o);if(!a)return;if("tooltip"===e){if(u)return;u=!0}if(i&&i.constructor===a)i.name=t.keyInfo.name,i.mergeOption(r,this),i.optionUpdated(r,!1);else{var s=Ze({componentIndex:n},t.keyInfo);Ze(i=new a(r,this,this,s),s),t.brandNew&&(i.__requireNewView=!0),i.init(r,this,this),i.optionUpdated(null,!0)}}else i&&(i.mergeOption({},this),i.optionUpdated({},!1));i?(d.push(i.option),h.push(i),p++):(d.push(void 0),h.push(void 0))},this),n[e]=d,i.set(e,h),r.set(e,p),"series"===e&&hg(this)},this),this._seriesIndices||hg(this)},e.prototype.getOption=function(){var t=Xe(this.option);return en(t,function(e,n){if(Hf.hasClass(n)){for(var i=xs(e),r=i.length,o=!1,a=r-1;a>=0;a--)i[a]&&!Ps(i[a])?o=!0:(i[a]=null,!o&&r--);i.length=r,t[n]=i}}),delete t[vg],t},e.prototype.setTheme=function(t){this._theme=new _p(t),this._resetOption("recreate",null)},e.prototype.getTheme=function(){return this._theme},e.prototype.getLocaleModel=function(){return this._locale},e.prototype.setUpdatePayload=function(t){this._payload=t},e.prototype.getUpdatePayload=function(){return this._payload},e.prototype.getComponent=function(t,e){var n=this._componentsMap.get(t);if(n){var i=n[e||0];if(i)return i;if(null==e)for(var r=0;r=e:"max"===n?t<=e:t===e})(i[a],t,o)||(r=!1)}}),r}var kg=en,Tg=fn,Ag=["areaStyle","lineStyle","nodeStyle","linkStyle","chordStyle","label","labelLine"];function Dg(t){var e=t&&t.itemStyle;if(e)for(var n=0,i=Ag.length;nc&&(c=p)}s[0]=l,s[1]=c}},i=function(){return this._data?this._data.length/this._dimSize:0};function r(t){for(var e=0;e=0&&(s=o.interpolatedValue[l])}return null!=s?s+"":""}):void 0},t.prototype.getRawValue=function(t,e){return yv(this.getData(e),t)},t.prototype.formatTooltip=function(t,e,n){},t}();function xv(t){var e,n;return fn(t)?t.type&&(n=t):e=t,{text:e,frag:n}}function wv(t){return new Sv(t)}var Sv=function(){function t(t){t=t||{},this._reset=t.reset,this._plan=t.plan,this._count=t.count,this._onDirty=t.onDirty,this._dirty=!0}return t.prototype.perform=function(t){var e,n=this._upstream,i=t&&t.skip;if(this._dirty&&n){var r=this.context;r.data=r.outputData=n.context.outputData}this.__pipeline&&(this.__pipeline.currentTask=this),this._plan&&!i&&(e=this._plan(this.context));var o,a=u(this._modBy),s=this._modDataCount||0,l=u(t&&t.modBy),c=t&&t.modDataCount||0;function u(t){return!(t>=1)&&(t=1),t}a===l&&s===c||(e="reset"),(this._dirty||"reset"===e)&&(this._dirty=!1,o=this._doReset(i)),this._modBy=l,this._modDataCount=c;var d=t&&t.step;if(this._dueEnd=n?n._outputDueEnd:this._count?this._count(this.context):1/0,this._progress){var h=this._dueIndex,p=Math.min(null!=d?this._dueIndex+d:1/0,this._dueEnd);if(!i&&(o||h1&&i>0?s:a}};return o;function a(){return e=t?null:oi?-this._resultLT:0},t}(),Tv=function(){function t(){}return t.prototype.getRawData=function(){throw new Error("not supported")},t.prototype.getRawDataItem=function(t){throw new Error("not supported")},t.prototype.cloneRawData=function(){},t.prototype.getDimensionInfo=function(t){},t.prototype.cloneAllDimensionInfo=function(){},t.prototype.count=function(){},t.prototype.retrieveValue=function(t,e){},t.prototype.retrieveValueFromItem=function(t,e){},t.prototype.convertValue=function(t,e){return Mv(t,e)},t}();function Av(t){if(!Ov(t.sourceFormat)){ys("")}return t.data}function Dv(t){var e=t.sourceFormat,n=t.data;if(!Ov(e)){ys("")}if(e===Zf){for(var i=[],r=0,o=n.length;r65535?Rv:Hv}function Wv(){return[1/0,-1/0]}function Uv(t){var e=t.constructor;return e===Array?t.slice():new e(t)}function Gv(t,e,n,i,r){var o=$v[n||"float"];if(r){var a=t[e],s=a&&a.length;if(s!==i){for(var l=new o(i),c=0;cg[1]&&(g[1]=f)}return this._rawCount=this._count=s,{start:a,end:s}},t.prototype._initDataFromProvider=function(t,e,n){for(var i=this._provider,r=this._chunks,o=this._dimensions,a=o.length,s=this._rawExtent,l=nn(o,function(t){return t.property}),c=0;cv[1]&&(v[1]=g)}}!i.persistent&&i.clean&&i.clean(),this._rawCount=this._count=e,this._extent=[]},t.prototype.count=function(){return this._count},t.prototype.get=function(t,e){if(!(e>=0&&e=0&&e=this._rawCount||t<0)return-1;if(!this._indices)return t;var e=this._indices,n=e[t];if(null!=n&&nt))return o;r=o-1}}return-1},t.prototype.getIndices=function(){var t,e=this._indices;if(e){var n=e.constructor,i=this._count;if(n===Array){t=new n(i);for(var r=0;r=c&&_<=u||isNaN(_))&&(a[s++]=p),p++}h=!0}else if(2===r){f=d[i[0]];var v=d[i[1]],m=t[i[1]][0],y=t[i[1]][1];for(g=0;g=c&&_<=u||isNaN(_))&&(b>=m&&b<=y||isNaN(b))&&(a[s++]=p),p++}h=!0}}if(!h)if(1===r)for(g=0;g=c&&_<=u||isNaN(_))&&(a[s++]=x)}else for(g=0;gt[C][1])&&(w=!1)}w&&(a[s++]=e.getRawIndex(g))}return sv[1]&&(v[1]=g)}}}},t.prototype.lttbDownSample=function(t,e){var n,i,r,o=this.clone([t],!0),a=o._chunks[t],s=this.count(),l=0,c=Math.floor(1/e),u=this.getRawIndex(0),d=new(Vv(this._rawCount))(Math.min(2*(Math.ceil(s/c)+2),s));d[l++]=u;for(var h=1;hn&&(n=i,r=M)}C>0&&Ca&&(f=a-c);for(var g=0;gp&&(p=v,h=c+g)}var m=this.getRawIndex(u),y=this.getRawIndex(h);uc-p&&(s=c-p,a.length=s);for(var f=0;fu[1]&&(u[1]=v),d[h++]=m}return r._count=h,r._indices=d,r._updateGetRawIdx(),r},t.prototype.each=function(t,e){if(this._count)for(var n=t.length,i=this._chunks,r=0,o=this.count();ra&&(a=l)}return i=[o,a],this._extent[t]=i,i},t.prototype.getRawDataItem=function(t){var e=this.getRawIndex(t);if(this._provider.persistent)return this._provider.getItem(e);for(var n=[],i=this._chunks,r=0;r=0?this._indices[t]:-1},t.prototype._updateGetRawIdx=function(){this.getRawIndex=this._indices?this._getRawIdx:this._getRawIdxIdentity},t.internalField=function(){function t(t,e,n,i){return Mv(t[i],this._dimensions[i])}zv={arrayRows:t,objectRows:function(t,e,n,i){return Mv(t[e],this._dimensions[i])},keyedColumns:t,original:function(t,e,n,i){var r=t&&(null==t.value?t:t.value);return Mv(r instanceof Array?r[i]:r,this._dimensions[i])},typedArray:function(t,e,n,i){return t[i]}}}(),t}(),jv=function(){function t(t){this._sourceList=[],this._storeList=[],this._upstreamSignList=[],this._versionSignBase=0,this._dirty=!0,this._sourceHost=t}return t.prototype.dirty=function(){this._setLocalSource([],[]),this._storeList=[],this._dirty=!0},t.prototype._setLocalSource=function(t,e){this._sourceList=t,this._upstreamSignList=e,this._versionSignBase++,this._versionSignBase>9e10&&(this._versionSignBase=0)},t.prototype._getVersionSign=function(){return this._sourceHost.uid+"_"+this._versionSignBase},t.prototype.prepareSource=function(){this._isDirty()&&(this._createSource(),this._dirty=!1)},t.prototype._createSource=function(){this._setLocalSource([],[]);var t,e,n=this._sourceHost,i=this._getUpstreamSourceManagers(),r=!!i.length;if(Xv(n)){var o=n,a=void 0,s=void 0,l=void 0;if(r){var c=i[0];c.prepareSource(),a=(l=c.getSource()).data,s=l.sourceFormat,e=[c._getVersionSign()]}else s=vn(a=o.get("data",!0))?Jf:Yf,e=[];var u=this._getSourceMetaRawOption()||{},d=l&&l.metaRawOption||{},h=xn(u.seriesLayoutBy,d.seriesLayoutBy)||null,p=xn(u.sourceHeader,d.sourceHeader),f=xn(u.dimensions,d.dimensions);t=h!==d.seriesLayoutBy||!!p!=!!d.sourceHeader||f?[tv(a,{seriesLayoutBy:h,sourceHeader:p,dimensions:f},s)]:[]}else{var g=n;if(r){var v=this._applyTransform(i);t=v.sourceList,e=v.upstreamSignList}else{t=[tv(g.get("source",!0),this._getSourceMetaRawOption(),null)],e=[]}}this._setLocalSource(t,e)},t.prototype._applyTransform=function(t){var e,n=this._sourceHost,i=n.get("transform",!0),r=n.get("fromTransformResult",!0);if(null!=r){1!==t.length&&Yv("")}var o,a=[],s=[];return en(t,function(t){t.prepareSource();var e=t.getSource(r||0);null==r||e||Yv(""),a.push(e),s.push(t._getVersionSign())}),i?e=function(t,e){var n=xs(t),i=n.length;i||ys("");for(var r=0,o=i;r1||n>0&&!t.noHeader;return en(t.blocks,function(t){var n=im(t);n>=e&&(e=n+ +(i&&(!n||em(t)&&!t.noHeader)))}),e}return 0}function rm(t,e,n,i){var r,o=e.noHeader,a=(r=im(e),{html:Qv[r],richText:Jv[r]}),s=[],l=e.blocks||[];Mn(!l||cn(l)),l=l||[];var c=t.orderMode;if(e.sortBlocks&&c){l=l.slice();var u={valueAsc:"asc",valueDesc:"desc"};if(zn(u,c)){var d=new kv(u[c],null);l.sort(function(t,e){return d.evaluate(t.sortParam,e.sortParam)})}else"seriesDesc"===c&&l.reverse()}en(l,function(n,r){var o=e.valueFormatter,l=nm(n)(o?Ze(Ze({},t),{valueFormatter:o}):t,n,r>0?a.html:0,i);null!=l&&s.push(l)});var h="richText"===t.renderMode?s.join(a.richText):sm(i,s.join(""),o?n:a.html);if(o)return h;var p=hf(e.header,"ordinal",t.useUTC),f=Kv(i,t.renderMode).nameStyle,g=Zv(i);return"richText"===t.renderMode?lm(t,p,f)+a.richText+h:sm(i,'
'+li(p)+"
"+h,n)}function om(t,e,n,i){var r=t.renderMode,o=e.noName,a=e.noValue,s=!e.markerType,l=e.name,c=t.useUTC,u=e.valueFormatter||t.valueFormatter||function(t){return nn(t=cn(t)?t:[t],function(t,e){return hf(t,cn(p)?p[e]:p,c)})};if(!o||!a){var d=s?"":t.markupStyleCreator.makeTooltipMarker(e.markerType,e.markerColor||Bf.color.secondary,r),h=o?"":hf(l,"ordinal",c),p=e.valueType,f=a?[]:u(e.value,e.dataIndex),g=!s||!o,v=!s&&o,m=Kv(i,r),y=m.nameStyle,_=m.valueStyle;return"richText"===r?(s?"":d)+(o?"":lm(t,h,y))+(a?"":function(t,e,n,i,r){var o=[r],a=i?10:20;return n&&o.push({padding:[0,0,0,a],align:"right"}),t.markupStyleCreator.wrapRichTextStyle(cn(e)?e.join(" "):e,o)}(t,f,g,v,_)):sm(i,(s?"":d)+(o?"":function(t,e,n){return''+li(t)+""}(h,!s,y))+(a?"":function(t,e,n,i){var r=n?"10px":"20px",o=e?"float:right;margin-left:"+r:"";return t=cn(t)?t:[t],''+nn(t,function(t){return li(t)}).join("  ")+""}(f,g,v,_)),n)}}function am(t,e,n,i,r,o){if(t)return nm(t)({useUTC:r,renderMode:n,orderMode:i,markupStyleCreator:e,valueFormatter:t.valueFormatter},t,0,o)}function sm(t,e,n){return'
'+e+'
'}function lm(t,e,n){return t.markupStyleCreator.wrapRichTextStyle(e,n)}function cm(t,e){var n=t.get("padding");return null!=n?n:"richText"===e?[8,10]:10}var um=function(){function t(){this.richTextStyles={},this._nextStyleNameId=ps()}return t.prototype._generateStyleName=function(){return"__EC_aUTo_"+this._nextStyleNameId++},t.prototype.makeTooltipMarker=function(t,e,n){var i="richText"===n?this._generateStyleName():null,r=function(t,e){var n=dn(t)?{color:t,extraCssText:e}:t||{},i=n.color,r=n.type;e=n.extraCssText;var o=n.renderMode||"html";return i?"html"===o?"subItem"===r?'':'':{renderMode:o,content:"{"+(n.markerId||"markerX")+"|} ",style:"subItem"===r?{width:4,height:4,borderRadius:2,backgroundColor:i}:{width:10,height:10,borderRadius:5,backgroundColor:i}}:""}({color:e,type:t,renderMode:n,markerId:i});return dn(r)?r:(this.richTextStyles[i]=r.style,r.content)},t.prototype.wrapRichTextStyle=function(t,e){var n={};cn(e)?en(e,function(t){return Ze(n,t)}):Ze(n,e);var i=this._generateStyleName();return this.richTextStyles[i]=n,"{"+i+"|"+t+"}"},t}();function dm(t){var e,n,i,r,o=t.series,a=t.dataIndex,s=t.multipleSeries,l=o.getData(),c=l.mapDimensionsAll("defaultedTooltip"),u=c.length,d=o.getRawValue(a),h=cn(d),p=function(t,e){return vf(t.getData().getItemVisual(e,"style")[t.visualDrawType])}(o,a);if(u>1||h&&!u){var f=function(t,e,n,i,r){var o=e.getData(),a=rn(t,function(t,e,n){var i=o.getDimensionInfo(n);return t||i&&!1!==i.tooltip&&null!=i.displayName},!1),s=[],l=[],c=[];function u(t,e){var n=o.getDimensionInfo(e);n&&!1!==n.otherDims.tooltip&&(a?c.push(tm("nameValue",{markerType:"subItem",markerColor:r,name:n.displayName,value:t,valueType:n.type})):(s.push(t),l.push(n.type)))}return i.length?en(i,function(t){u(yv(o,n,t),t)}):en(t,u),{inlineValues:s,inlineValueTypes:l,blocks:c}}(d,o,a,c,p);e=f.inlineValues,n=f.inlineValueTypes,i=f.blocks,r=f.inlineValues[0]}else if(u){var g=l.getDimensionInfo(c[0]);r=e=yv(l,a,c[0]),n=g.type}else r=e=h?d[0]:d;var v=Is(o),m=v&&o.name||"",y=l.getName(a),_=s?m:y;return tm("section",{header:m,noHeader:s||!v,sortParam:r,blocks:[tm("nameValue",{markerType:"item",markerColor:p,name:_,noName:!kn(_),value:e,valueType:n,dataIndex:a})].concat(i||[])})}var hm=Es();function pm(t,e){return t.getName(e)||t.getId(e)}var fm=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e._selectedDataIndicesMap={},e}var n;return _(e,t),e.prototype.init=function(t,e,n){this.seriesIndex=this.componentIndex,this.dataTask=wv({count:vm,reset:mm}),this.dataTask.context={model:this},this.mergeDefaultAndTheme(t,n),(hm(this).sourceManager=new jv(this)).prepareSource();var i=this.getInitialData(t,n);_m(i,this),this.dataTask.context.data=i,hm(this).dataBeforeProcessed=i,gm(this),this._initSelectedMapFromData(i)},e.prototype.mergeDefaultAndTheme=function(t,e){var n=Of(this),i=n?Nf(t):{},r=this.subType;Hf.hasClass(r)&&(r+="Series"),Ye(t,e.getTheme().get(this.subType)),Ye(t,this.getDefaultOption()),ws(t,"label",["show"]),this.fillDataTextStyle(t.data),n&&zf(t,i,n)},e.prototype.mergeOption=function(t,e){t=Ye(this.option,t,!0),this.fillDataTextStyle(t.data);var n=Of(this);n&&zf(this.option,t,n);var i=hm(this).sourceManager;i.dirty(),i.prepareSource();var r=this.getInitialData(t,e);_m(r,this),this.dataTask.dirty(),this.dataTask.context.data=r,hm(this).dataBeforeProcessed=r,gm(this),this._initSelectedMapFromData(r)},e.prototype.fillDataTextStyle=function(t){if(t&&!vn(t))for(var e=["show"],n=0;n=0&&u<0)&&(c=o,u=r,d=0),r===u&&(l[d++]=e))}),l.length=d,l},e.prototype.formatTooltip=function(t,e,n){return dm({series:this,dataIndex:t,multipleSeries:e})},e.prototype.isAnimationEnabled=function(){var t=this.ecModel;if(Te.node&&(!t||!t.ssr))return!1;var e=this.getShallow("animation");return e&&this.getData().count()>this.getShallow("animationThreshold")&&(e=!1),!!e},e.prototype.restoreData=function(){this.dataTask.dirty()},e.prototype.getColorFromPalette=function(t,e,n){var i=this.ecModel,r=gg.prototype.getColorFromPalette.call(this,t,e,n);return r||(r=i.getColorFromPalette(t,e,n)),r},e.prototype.coordDimToDataDim=function(t){return this.getRawData().mapDimensionsAll(t)},e.prototype.getProgressive=function(){return this.get("progressive")},e.prototype.getProgressiveThreshold=function(){return this.get("progressiveThreshold")},e.prototype.select=function(t,e){this._innerSelect(this.getData(e),t)},e.prototype.unselect=function(t,e){var n=this.option.selectedMap;if(n){var i=this.option.selectedMode,r=this.getData(e);if("series"===i||"all"===n)return this.option.selectedMap={},void(this._selectedDataIndicesMap={});for(var o=0;o=0&&n.push(r)}return n},e.prototype.isSelected=function(t,e){var n=this.option.selectedMap;if(!n)return!1;var i=this.getData(e);return("all"===n||n[pm(i,t)])&&!i.getItemModel(t).get(["select","disabled"])},e.prototype.isUniversalTransitionEnabled=function(){if(this.__universalTransitionEnabled)return!0;var t=this.option.universalTransition;return!!t&&(!0===t||t&&t.enabled)},e.prototype._innerSelect=function(t,e){var n,i,r=this.option,o=r.selectedMode,a=e.length;if(o&&a)if("series"===o)r.selectedMap="all";else if("multiple"===o){fn(r.selectedMap)||(r.selectedMap={});for(var s=r.selectedMap,l=0;l0&&this._innerSelect(t,e)}},e.registerClass=function(t){return Hf.registerClass(t)},e.protoInitialize=((n=e.prototype).type="series.__base__",n.seriesIndex=0,n.ignoreStyleOnData=!1,n.hasSymbolVisual=!1,n.defaultSymbol="circle",n.visualStyleAccessPath="itemStyle",void(n.visualDrawType="fill")),e}(Hf);function gm(t){var e=t.name;Is(t)||(t.name=function(t){var e=t.getRawData(),n=e.mapDimensionsAll("seriesName"),i=[];return en(n,function(t){var n=e.getDimensionInfo(t);n.displayName&&i.push(n.displayName)}),i.join(" ")}(t)||e)}function vm(t){return t.model.getRawData().count()}function mm(t){var e=t.model;return e.setData(e.getRawData().cloneShallow()),ym}function ym(t,e){e.outputData&&t.end>e.outputData.count()&&e.model.getRawData().cloneShallow(e.outputData)}function _m(t,e){en(function(t,e){for(var n=new t.constructor(t.length+e.length),i=0;i=0?d():u=setTimeout(d,-r),l=i};return h.clear=function(){u&&(clearTimeout(u),u=null)},h.debounceNextCall=function(t){s=t},h}function Nm(t,e,n,i){var r=t[e];if(r){var o=r[Lm]||r,a=r[Om];if(r[Em]!==n||a!==i){if(null==n||!i)return t[e]=o;(r=t[e]=zm(o,n,"debounce"===i))[Lm]=o,r[Om]=i,r[Em]=n}return r}}function Rm(t,e){var n=t[e];n&&n[Lm]&&(n.clear&&n.clear(),t[e]=n[Lm])}var Hm=Es(),Bm={itemStyle:Ys(vp,!0),lineStyle:Ys(pp,!0)},Fm={lineStyle:"stroke",itemStyle:"fill"};function $m(t,e){var n=t.visualStyleMapper||Bm[e];return n||(console.warn("Unknown style type '"+e+"'."),Bm.itemStyle)}function Vm(t,e){var n=t.visualDrawType||Fm[e];return n||(console.warn("Unknown style type '"+e+"'."),"fill")}var Wm={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){var n=t.getData(),i=t.visualStyleAccessPath||"itemStyle",r=t.getModel(i),o=$m(t,i)(r),a=r.getShallow("decal");a&&(n.setVisual("decal",a),a.dirty=!0);var s=Vm(t,i),l=o[s],c=un(l)?l:null,u="auto"===o.fill||"auto"===o.stroke;if(!o[s]||c||u){var d=t.getColorFromPalette(t.name,null,e.getSeriesCount());o[s]||(o[s]=d,n.setVisual("colorFromPalette",!0)),o.fill="auto"===o.fill||un(o.fill)?d:o.fill,o.stroke="auto"===o.stroke||un(o.stroke)?d:o.stroke}if(n.setVisual("style",o),n.setVisual("drawType",s),!e.isSeriesFiltered(t)&&c)return n.setVisual("colorFromPalette",!1),{dataEach:function(e,n){var i=t.getDataParams(n),r=Ze({},o);r[s]=c(i),e.setItemVisual(n,"style",r)}}}},Um=new _p,Gm={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){if(!t.ignoreStyleOnData&&!e.isSeriesFiltered(t)){var n=t.getData(),i=t.visualStyleAccessPath||"itemStyle",r=$m(t,i),o=n.getVisual("drawType");return{dataEach:n.hasItemOption?function(t,e){var n=t.getRawDataItem(e);if(n&&n[i]){Um.option=n[i];var a=r(Um);Ze(t.ensureUniqueItemVisual(e,"style"),a),Um.option.decal&&(t.setItemVisual(e,"decal",Um.option.decal),Um.option.decal.dirty=!0),o in a&&t.setItemVisual(e,"colorFromPalette",!1)}}:null}}}},qm={performRawSeries:!0,overallReset:function(t){var e=En();t.eachSeries(function(t){var n=t.getColorBy();if(!t.isColorBySeries()){var i=t.type+"-"+n,r=e.get(i);r||(r={},e.set(i,r)),Hm(t).scope=r}}),t.eachSeries(function(e){if(!e.isColorBySeries()&&!t.isSeriesFiltered(e)){var n=e.getRawData(),i={},r=e.getData(),o=Hm(e).scope,a=e.visualStyleAccessPath||"itemStyle",s=Vm(e,a);r.each(function(t){var e=r.getRawIndex(t);i[e]=t}),n.each(function(t){var a=i[t];if(r.getItemVisual(a,"colorFromPalette")){var l=r.ensureUniqueItemVisual(a,"style"),c=n.getName(t)||t+"",u=n.count();l[s]=e.getColorFromPalette(c,o,u)}})}})}},jm=Math.PI;var Xm=function(){function t(t,e,n,i){this._stageTaskMap=En(),this.ecInstance=t,this.api=e,n=this._dataProcessorHandlers=n.slice(),i=this._visualHandlers=i.slice(),this._allHandlers=n.concat(i)}return t.prototype.restoreData=function(t,e){t.restoreData(e),this._stageTaskMap.each(function(t){var e=t.overallTask;e&&e.dirty()})},t.prototype.getPerformArgs=function(t,e){if(t.__pipeline){var n=this._pipelineMap.get(t.__pipeline.id),i=n.context,r=!e&&n.progressiveEnabled&&(!i||i.progressiveRender)&&t.__idxInPipeline>n.blockIndex?n.step:null,o=i&&i.modDataCount;return{step:r,modBy:null!=o?Math.ceil(o/r):null,modDataCount:o}}},t.prototype.getPipeline=function(t){return this._pipelineMap.get(t)},t.prototype.updateStreamModes=function(t,e){var n=this._pipelineMap.get(t.uid),i=t.getData().count(),r=n.progressiveEnabled&&e.incrementalPrepareRender&&i>=n.threshold,o=t.get("large")&&i>=t.get("largeThreshold"),a="mod"===t.get("progressiveChunkMode")?i:null;t.pipelineContext=n.context={progressiveRender:r,modDataCount:a,large:o}},t.prototype.restorePipelines=function(t){var e=this,n=e._pipelineMap=En();t.eachSeries(function(t){var i=t.getProgressive(),r=t.uid;n.set(r,{id:r,head:null,tail:null,threshold:t.getProgressiveThreshold(),progressiveEnabled:i&&!(t.preventIncremental&&t.preventIncremental()),blockIndex:-1,step:Math.round(i||700),count:0}),e._pipe(t,t.dataTask)})},t.prototype.prepareStageTasks=function(){var t=this._stageTaskMap,e=this.api.getModel(),n=this.api;en(this._allHandlers,function(i){var r=t.get(i.uid)||t.set(i.uid,{});Mn(!(i.reset&&i.overallReset),""),i.reset&&this._createSeriesStageTask(i,r,e,n),i.overallReset&&this._createOverallStageTask(i,r,e,n)},this)},t.prototype.prepareView=function(t,e,n,i){var r=t.renderTask,o=r.context;o.model=e,o.ecModel=n,o.api=i,r.__block=!t.incrementalPrepareRender,this._pipe(e,r)},t.prototype.performDataProcessorTasks=function(t,e){this._performStageTasks(this._dataProcessorHandlers,t,e,{block:!0})},t.prototype.performVisualTasks=function(t,e,n){this._performStageTasks(this._visualHandlers,t,e,n)},t.prototype._performStageTasks=function(t,e,n,i){i=i||{};var r=!1,o=this;function a(t,e){return t.setDirty&&(!t.dirtyMap||t.dirtyMap.get(e.__pipeline.id))}en(t,function(t,s){if(!i.visualType||i.visualType===t.visualType){var l=o._stageTaskMap.get(t.uid),c=l.seriesTaskMap,u=l.overallTask;if(u){var d,h=u.agentStubMap;h.each(function(t){a(i,t)&&(t.dirty(),d=!0)}),d&&u.dirty(),o.updatePayload(u,n);var p=o.getPerformArgs(u,i.block);h.each(function(t){t.perform(p)}),u.perform(p)&&(r=!0)}else c&&c.each(function(s,l){a(i,s)&&s.dirty();var c=o.getPerformArgs(s,i.block);c.skip=!t.performRawSeries&&e.isSeriesFiltered(s.context.model),o.updatePayload(s,n),s.perform(c)&&(r=!0)})}}),this.unfinished=r||this.unfinished},t.prototype.performSeriesTasks=function(t){var e;t.eachSeries(function(t){e=t.dataTask.perform()||e}),this.unfinished=e||this.unfinished},t.prototype.plan=function(){this._pipelineMap.each(function(t){var e=t.tail;do{if(e.__block){t.blockIndex=e.__idxInPipeline;break}e=e.getUpstream()}while(e)})},t.prototype.updatePayload=function(t,e){"remain"!==e&&(t.context.payload=e)},t.prototype._createSeriesStageTask=function(t,e,n,i){var r=this,o=e.seriesTaskMap,a=e.seriesTaskMap=En(),s=t.seriesType,l=t.getTargetSeries;function c(e){var s=e.uid,l=a.set(s,o&&o.get(s)||wv({plan:Jm,reset:ty,count:iy}));l.context={model:e,ecModel:n,api:i,useClearVisual:t.isVisual&&!t.isLayout,plan:t.plan,reset:t.reset,scheduler:r},r._pipe(e,l)}t.createOnAllSeries?n.eachRawSeries(c):s?n.eachRawSeriesByType(s,c):l&&l(n,i).each(c)},t.prototype._createOverallStageTask=function(t,e,n,i){var r=this,o=e.overallTask=e.overallTask||wv({reset:Ym});o.context={ecModel:n,api:i,overallReset:t.overallReset,scheduler:r};var a=o.agentStubMap,s=o.agentStubMap=En(),l=t.seriesType,c=t.getTargetSeries,u=!0,d=!1;function h(t){var e=t.uid,n=s.set(e,a&&a.get(e)||(d=!0,wv({reset:Zm,onDirty:Qm})));n.context={model:t,overallProgress:u},n.agent=o,n.__block=u,r._pipe(t,n)}Mn(!t.createOnAllSeries,""),l?n.eachRawSeriesByType(l,h):c?c(n,i).each(h):(u=!1,en(n.getSeries(),h)),d&&o.dirty()},t.prototype._pipe=function(t,e){var n=t.uid,i=this._pipelineMap.get(n);!i.head&&(i.head=e),i.tail&&i.tail.pipe(e),i.tail=e,e.__idxInPipeline=i.count++,e.__pipeline=i},t.wrapStageHandler=function(t,e){return un(t)&&(t={overallReset:t,seriesType:ry(t)}),t.uid=xp("stageHandler"),e&&(t.visualType=e),t},t}();function Ym(t){t.overallReset(t.ecModel,t.api,t.payload)}function Zm(t){return t.overallProgress&&Km}function Km(){this.agent.dirty(),this.getDownstream().dirty()}function Qm(){this.agent&&this.agent.dirty()}function Jm(t){return t.plan?t.plan(t.model,t.ecModel,t.api,t.payload):null}function ty(t){t.useClearVisual&&t.data.clearAllVisual();var e=t.resetDefines=xs(t.reset(t.model,t.ecModel,t.api,t.payload));return e.length>1?nn(e,function(t,e){return ny(e)}):ey}var ey=ny(0);function ny(t){return function(e,n){var i=n.data,r=n.resetDefines[t];if(r&&r.dataEach)for(var o=e.start;o0&&u===r.length-c.length){var d=r.slice(0,u);"data"!==d&&(e.mainType=d,e[c.toLowerCase()]=t,s=!0)}}a.hasOwnProperty(r)&&(n[r]=t,s=!0),s||(i[r]=t)})}return{cptQuery:e,dataQuery:n,otherQuery:i}},t.prototype.filter=function(t,e){var n=this.eventInfo;if(!n)return!0;var i=n.targetEl,r=n.packedEvent,o=n.model,a=n.view;if(!o||!a)return!0;var s=e.cptQuery,l=e.dataQuery;return c(s,o,"mainType")&&c(s,o,"subType")&&c(s,o,"index","componentIndex")&&c(s,o,"name")&&c(s,o,"id")&&c(l,r,"name")&&c(l,r,"dataIndex")&&c(l,r,"dataType")&&(!a.filterForExposedEvent||a.filterForExposedEvent(t,e.otherQuery,i,r));function c(t,e,n,i){return null==t[n]||e[i||n]===t[n]}},t.prototype.afterTrigger=function(){this.eventInfo=null},t}(),vy=["symbol","symbolSize","symbolRotate","symbolOffset"],my=vy.concat(["symbolKeepAspect"]),yy={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){var n=t.getData();if(t.legendIcon&&n.setVisual("legendIcon",t.legendIcon),t.hasSymbolVisual){for(var i={},r={},o=!1,a=0;a=0&&zy(l)?l:.5,t.createRadialGradient(a,s,0,a,s,l)}(t,e,n):function(t,e,n){var i=null==e.x?0:e.x,r=null==e.x2?1:e.x2,o=null==e.y?0:e.y,a=null==e.y2?0:e.y2;return e.global||(i=i*n.width+n.x,r=r*n.width+n.x,o=o*n.height+n.y,a=a*n.height+n.y),i=zy(i)?i:0,r=zy(r)?r:1,o=zy(o)?o:0,a=zy(a)?a:0,t.createLinearGradient(i,o,r,a)}(t,e,n),r=e.colorStops,o=0;o0&&(e=i.lineDash,n=i.lineWidth,e&&"solid"!==e&&n>0?"dashed"===e?[4*n,2*n]:"dotted"===e?[n]:pn(e)?[e]:cn(e)?e:null:null),o=i.lineDashOffset;if(r){var a=i.strokeNoScale&&t.getLineScale?t.getLineScale():1;a&&1!==a&&(r=nn(r,function(t){return t/a}),o/=a)}return[r,o]}var Fy=new ac(!0);function $y(t){var e=t.stroke;return!(null==e||"none"===e||!(t.lineWidth>0))}function Vy(t){return"string"==typeof t&&"none"!==t}function Wy(t){var e=t.fill;return null!=e&&"none"!==e}function Uy(t,e){if(null!=e.fillOpacity&&1!==e.fillOpacity){var n=t.globalAlpha;t.globalAlpha=e.fillOpacity*e.opacity,t.fill(),t.globalAlpha=n}else t.fill()}function Gy(t,e){if(null!=e.strokeOpacity&&1!==e.strokeOpacity){var n=t.globalAlpha;t.globalAlpha=e.strokeOpacity*e.opacity,t.stroke(),t.globalAlpha=n}else t.stroke()}function qy(t,e,n){var i=tl(e.image,e.__image,n);if(nl(i)){var r=t.createPattern(i,e.repeat||"repeat");if("function"==typeof DOMMatrix&&r&&r.setTransform){var o=new DOMMatrix;o.translateSelf(e.x||0,e.y||0),o.rotateSelf(0,0,(e.rotation||0)*Rn),o.scaleSelf(e.scaleX||1,e.scaleY||1),r.setTransform(o)}return r}}var jy=["shadowBlur","shadowOffsetX","shadowOffsetY"],Xy=[["lineCap","butt"],["lineJoin","miter"],["miterLimit",10]];function Yy(t,e,n,i,r){var o=!1;if(!i&&e===(n=n||{}))return!1;if(i||e.opacity!==n.opacity){Qy(t,r),o=!0;var a=Math.max(Math.min(e.opacity,1),0);t.globalAlpha=isNaN(a)?wl.opacity:a}(i||e.blend!==n.blend)&&(o||(Qy(t,r),o=!0),t.globalCompositeOperation=e.blend||wl.blend);for(var s=0;s0&&t.unfinished);t.unfinished||this._zr.flush()}}},e.prototype.getDom=function(){return this._dom},e.prototype.getId=function(){return this.id},e.prototype.getZr=function(){return this._zr},e.prototype.isSSR=function(){return this._ssr},e.prototype.setOption=function(t,e,n){if(!this[p_])if(this._disposed)this.id;else{var i,r,o;if(fn(e)&&(n=e.lazyUpdate,i=e.silent,r=e.replaceMerge,o=e.transition,e=e.notMerge),this[p_]=!0,F_(this),!this._model||e){var a=new Cg(this._api),s=this._theme,l=this._model=new mg;l.scheduler=this._scheduler,l.ssr=this._ssr,l.init(null,null,null,s,this._locale,a)}this._model.setOption(t,{replaceMerge:r},Z_);var c={seriesTransition:o,optionChanged:!0};if(n)this[g_]={silent:i,updateParams:c},this[p_]=!1,this.getZr().wakeUp();else{try{w_(this),M_.update.call(this,null,c)}catch(t){throw this[g_]=null,this[p_]=!1,t}this._ssr||this._zr.flush(),this[g_]=null,this[p_]=!1,D_.call(this,i),I_.call(this,i)}}},e.prototype.setTheme=function(t,e){if(!this[p_])if(this._disposed)this.id;else{var n=this._model;if(n){var i=e&&e.silent,r=null;this[g_]&&(null==i&&(i=this[g_].silent),r=this[g_].updateParams,this[g_]=null),this[p_]=!0,F_(this);try{this._updateTheme(t),n.setTheme(this._theme),w_(this),M_.update.call(this,{type:"setTheme"},r)}catch(t){throw this[p_]=!1,t}this[p_]=!1,D_.call(this,i),I_.call(this,i)}}},e.prototype._updateTheme=function(t){dn(t)&&(t=Q_[t]),t&&((t=Xe(t))&&Gg(t,!0),this._theme=t)},e.prototype.getModel=function(){return this._model},e.prototype.getOption=function(){return this._model&&this._model.getOption()},e.prototype.getWidth=function(){return this._zr.getWidth()},e.prototype.getHeight=function(){return this._zr.getHeight()},e.prototype.getDevicePixelRatio=function(){return this._zr.painter.dpr||Te.hasGlobalWindow&&window.devicePixelRatio||1},e.prototype.getRenderedCanvas=function(t){return this.renderToCanvas(t)},e.prototype.renderToCanvas=function(t){return t=t||{},this._zr.painter.getRenderedCanvas({backgroundColor:t.backgroundColor||this._model.get("backgroundColor"),pixelRatio:t.pixelRatio||this.getDevicePixelRatio()})},e.prototype.renderToSVGString=function(t){return t=t||{},this._zr.painter.renderToString({useViewBox:t.useViewBox})},e.prototype.getSvgDataURL=function(){var t=this._zr;return en(t.storage.getDisplayList(),function(t){t.stopAnimation(null,!0)}),t.painter.toDataURL()},e.prototype.getDataURL=function(t){if(!this._disposed){var e=(t=t||{}).excludeComponents,n=this._model,i=[],r=this;en(e,function(t){n.eachComponent({mainType:t},function(t){var e=r._componentsMap[t.__viewId];e.group.ignore||(i.push(e),e.group.ignore=!0)})});var o="svg"===this._zr.painter.getType()?this.getSvgDataURL():this.renderToCanvas(t).toDataURL("image/"+(t&&t.type||"png"));return en(i,function(t){t.group.ignore=!1}),o}this.id},e.prototype.getConnectedDataURL=function(t){if(!this._disposed){var e="svg"===t.type,n=this.group,i=Math.min,r=Math.max,o=1/0;if(eb[n]){var a=o,s=o,l=-1/0,c=-1/0,u=[],d=t&&t.pixelRatio||this.getDevicePixelRatio();en(tb,function(o,d){if(o.group===n){var h=e?o.getZr().painter.getSvgDom().innerHTML:o.renderToCanvas(Xe(t)),p=o.getDom().getBoundingClientRect();a=i(p.left,a),s=i(p.top,s),l=r(p.right,l),c=r(p.bottom,c),u.push({dom:h,left:p.left,top:p.top})}});var h=(l*=d)-(a*=d),p=(c*=d)-(s*=d),f=Ee.createCanvas(),g=Ya(f,{renderer:e?"svg":"canvas"});if(g.resize({width:h,height:p}),e){var v="";return en(u,function(t){var e=t.left-a,n=t.top-s;v+=''+t.dom+""}),g.painter.getSvgRoot().innerHTML=v,t.connectedBackgroundColor&&g.painter.setBackgroundColor(t.connectedBackgroundColor),g.refreshImmediately(),g.painter.toDataURL()}return t.connectedBackgroundColor&&g.add(new Bc({shape:{x:0,y:0,width:h,height:p},style:{fill:t.connectedBackgroundColor}})),en(u,function(t){var e=new Lc({style:{x:t.left*d-a,y:t.top*d-s,image:t.dom}});g.add(e)}),g.refreshImmediately(),f.toDataURL("image/"+(t&&t.type||"png"))}return this.getDataURL(t)}this.id},e.prototype.convertToPixel=function(t,e,n){return k_(this,"convertToPixel",t,e,n)},e.prototype.convertToLayout=function(t,e,n){return k_(this,"convertToLayout",t,e,n)},e.prototype.convertFromPixel=function(t,e,n){return k_(this,"convertFromPixel",t,e,n)},e.prototype.containPixel=function(t,e){var n;if(!this._disposed)return en(zs(this._model,t),function(t,i){i.indexOf("Models")>=0&&en(t,function(t){var r=t.coordinateSystem;if(r&&r.containPoint)n=n||!!r.containPoint(e);else if("seriesModels"===i){var o=this._chartsMap[t.__viewId];o&&o.containPoint&&(n=n||o.containPoint(e,t))}},this)},this),!!n;this.id},e.prototype.getVisual=function(t,e){var n=zs(this._model,t,{defaultMainType:"series"}),i=n.seriesModel.getData(),r=n.hasOwnProperty("dataIndexInside")?n.dataIndexInside:n.hasOwnProperty("dataIndex")?i.indexOfRawIndex(n.dataIndex):null;return null!=r?function(t,e,n){switch(n){case"color":return t.getItemVisual(e,"style")[t.getVisual("drawType")];case"opacity":return t.getItemVisual(e,"style").opacity;case"symbol":case"symbolSize":case"liftZ":return t.getItemVisual(e,n)}}(i,r,e):function(t,e){switch(e){case"color":return t.getVisual("style")[t.getVisual("drawType")];case"opacity":return t.getVisual("style").opacity;case"symbol":case"symbolSize":case"liftZ":return t.getVisual(e)}}(i,e)},e.prototype.getViewOfComponentModel=function(t){return this._componentsMap[t.__viewId]},e.prototype.getViewOfSeriesModel=function(t){return this._chartsMap[t.__viewId]},e.prototype._initEvents=function(){var t=this;en(G_,function(e){var n=function(n){var i,r=t.getModel(),o=n.target;if("globalout"===e?i={}:o&&xy(o,function(t){var e=nu(t);if(e&&null!=e.dataIndex){var n=e.dataModel||r.getSeriesByIndex(e.seriesIndex);return i=n&&n.getDataParams(e.dataIndex,e.dataType,o)||{},!0}if(e.eventData)return i=Ze({},e.eventData),!0},!0),i){var a=i.componentType,s=i.componentIndex;"markLine"!==a&&"markPoint"!==a&&"markArea"!==a||(a="series",s=i.seriesIndex);var l=a&&null!=s&&r.getComponent(a,s),c=l&&t["series"===l.mainType?"_chartsMap":"_componentsMap"][l.__viewId];i.event=n,i.type=e,t._$eventProcessor.eventInfo={targetEl:o,packedEvent:i,model:l,view:c},t.trigger(e,i)}};n.zrEventfulCallAtLast=!0,t._zr.on(e,n,t)});var e=this._messageCenter;en(X_,function(n,i){e.on(i,function(e){t.trigger(i,e)})}),function(t,e,n){t.on("selectchanged",function(t){var i=n.getModel();t.isFromClick?(by("map","selectchanged",e,i,t),by("pie","selectchanged",e,i,t)):"select"===t.fromAction?(by("map","selected",e,i,t),by("pie","selected",e,i,t)):"unselect"===t.fromAction&&(by("map","unselected",e,i,t),by("pie","unselected",e,i,t))})}(e,this,this._api)},e.prototype.isDisposed=function(){return this._disposed},e.prototype.clear=function(){this._disposed?this.id:this.setOption({series:[]},!0)},e.prototype.dispose=function(){if(this._disposed)this.id;else{this._disposed=!0,this.getDom()&&Bs(this.getDom(),ib,"");var t=this,e=t._api,n=t._model;en(t._componentsViews,function(t){t.dispose(n,e)}),en(t._chartsViews,function(t){t.dispose(n,e)}),t._zr.dispose(),t._dom=t._model=t._chartsMap=t._componentsMap=t._chartsViews=t._componentsViews=t._scheduler=t._api=t._zr=t._throttledZrFlush=t._theme=t._coordSysMgr=t._messageCenter=null,delete tb[t.id]}},e.prototype.resize=function(t){if(!this[p_])if(this._disposed)this.id;else{this._zr.resize(t);var e=this._model;if(this._loadingFX&&this._loadingFX.resize(),e){var n=e.resetOption("media"),i=t&&t.silent;this[g_]&&(null==i&&(i=this[g_].silent),n=!0,this[g_]=null),this[p_]=!0,F_(this);try{n&&w_(this),M_.update.call(this,{type:"resize",animation:Ze({duration:0},t&&t.animation)})}catch(t){throw this[p_]=!1,t}this[p_]=!1,D_.call(this,i),I_.call(this,i)}}},e.prototype.showLoading=function(t,e){if(this._disposed)this.id;else if(fn(t)&&(e=t,t=""),t=t||"default",this.hideLoading(),J_[t]){var n=J_[t](this._api,e),i=this._zr;this._loadingFX=n,i.add(n)}},e.prototype.hideLoading=function(){this._disposed?this.id:(this._loadingFX&&this._zr.remove(this._loadingFX),this._loadingFX=null)},e.prototype.makeActionFromEvent=function(t){var e=Ze({},t);return e.type=j_[t.type],e},e.prototype.dispatchAction=function(t,e){if(this._disposed)this.id;else if(fn(e)||(e={silent:!!e}),q_[t.type]&&this._model)if(this[p_])this._pendingActions.push(t);else{var n=e.silent;A_.call(this,t,n);var i=e.flush;i?this._zr.flush():!1!==i&&Te.browser.weChat&&this._throttledZrFlush(),D_.call(this,n),I_.call(this,n)}},e.prototype.updateLabelLayout=function(){l_.trigger("series:layoutlabels",this._model,this._api,{updatedSeries:[]})},e.prototype.appendData=function(t){if(this._disposed)this.id;else{var e=t.seriesIndex;this.getModel().getSeriesByIndex(e).appendData(t),this._scheduler.unfinished=!0,this.getZr().wakeUp()}},e.internalField=function(){function t(t){t.clearColorPalette(),t.eachSeries(function(t){t.clearColorPalette()})}function e(t){for(var e=[],n=t.currentStates,i=0;i0?{duration:o,delay:i.get("delay"),easing:i.get("easing")}:null;n.eachRendered(function(t){if(t.states&&t.states.emphasis){if(fh(t))return;if(t instanceof Tc&&function(t){var e=ou(t);e.normalFill=t.style.fill,e.normalStroke=t.style.stroke;var n=t.states.select||{};e.selectFill=n.style&&n.style.fill||null,e.selectStroke=n.style&&n.style.stroke||null}(t),t.__dirty){var n=t.prevStates;n&&t.useStates(n)}if(r){t.stateTransition=a;var i=t.getTextContent(),o=t.getTextGuideLine();i&&(i.stateTransition=a),o&&(o.stateTransition=a)}t.__dirty&&e(t)}})}w_=function(t){var e=t._scheduler;e.restorePipelines(t._model),e.prepareStageTasks(),S_(t,!0),S_(t,!1),e.plan()},S_=function(t,e){for(var n=t._model,i=t._scheduler,r=e?t._componentsViews:t._chartsViews,o=e?t._componentsMap:t._chartsMap,a=t._zr,s=t._api,l=0;le.get("hoverLayerThreshold")&&!Te.node&&!Te.worker&&e.eachSeries(function(e){if(!e.preventUsingHoverLayer){var n=t._chartsMap[e.__viewId];n.__alive&&n.eachRendered(function(t){t.states.emphasis&&(t.states.emphasis.hoverLayer=!0)})}})}(t,e),l_.trigger("series:afterupdate",e,o,s)},H_=function(t){t[v_]=!0,t.getZr().wakeUp()},F_=function(t){t[f_]=(t[f_]+1)%1e3},B_=function(t){t[v_]&&(t.getZr().storage.traverse(function(t){fh(t)||e(t)}),t[v_]=!1)},N_=function(t){return new(function(e){function n(){return null!==e&&e.apply(this,arguments)||this}return _(n,e),n.prototype.getCoordinateSystems=function(){return t._coordSysMgr.getCoordinateSystems()},n.prototype.getComponentByElement=function(e){for(;e;){var n=e.__ecComponentInfo;if(null!=n)return t._model.getComponent(n.mainType,n.index);e=e.parent}},n.prototype.enterEmphasis=function(e,n){Iu(e,n),H_(t)},n.prototype.leaveEmphasis=function(e,n){Pu(e,n),H_(t)},n.prototype.enterBlur=function(e){!function(t){Cu(t,_u)}(e),H_(t)},n.prototype.leaveBlur=function(e){Lu(e),H_(t)},n.prototype.enterSelect=function(e){Eu(e),H_(t)},n.prototype.leaveSelect=function(e){Ou(e),H_(t)},n.prototype.getModel=function(){return t.getModel()},n.prototype.getViewOfComponentModel=function(e){return t.getViewOfComponentModel(e)},n.prototype.getViewOfSeriesModel=function(e){return t.getViewOfSeriesModel(e)},n.prototype.getMainProcessVersion=function(){return t[f_]},n}(wg))(t)},R_=function(t){function e(t,e){for(var n=0;n=0)){db.push(n);var o=Xm.wrapStageHandler(n,r);o.__prio=e,o.__raw=n,t.push(o)}}function pb(t,e){J_[t]=e}var fb=function(t){var e=(t=Xe(t)).type;e||ys("");var n=e.split(":");2!==n.length&&ys("");var i=!1;"echarts"===n[0]&&(e=n[1],i=!0),t.__isBuiltIn=i,Lv.set(e,t)};function gb(t,e,n,i){return{eventContent:{selected:$u(n),isFromClick:e.isFromClick||!1}}}function vb(t){return null==t?0:t.length||1}function mb(t){return t}ub(u_,Wm),ub(d_,Gm),ub(d_,qm),ub(u_,yy),ub(d_,_y),ub(7e3,function(t,e){t.eachRawSeries(function(n){if(!t.isSeriesFiltered(n)){var i=n.getData();i.hasItemVisual()&&i.each(function(t){var n=i.getItemVisual(t,"decal");n&&(i.ensureUniqueItemVisual(t,"style").decal=r_(n,e))});var r=i.getVisual("decal");if(r)i.getVisual("style").decal=r_(r,e)}})}),ab(Gg),sb(900,function(t){var e=En();t.eachSeries(function(t){var n=t.get("stack");if(n){var i=e.get(n)||e.set(n,[]),r=t.getData(),o={stackResultDimension:r.getCalculationInfo("stackResultDimension"),stackedOverDimension:r.getCalculationInfo("stackedOverDimension"),stackedDimension:r.getCalculationInfo("stackedDimension"),stackedByDimension:r.getCalculationInfo("stackedByDimension"),isStackedByIndex:r.getCalculationInfo("isStackedByIndex"),data:r,seriesModel:t};if(!o.stackedDimension||!o.isStackedByIndex&&!o.stackedByDimension)return;i.push(o)}}),e.each(function(t){0!==t.length&&("seriesDesc"===(t[0].seriesModel.get("stackOrder")||"seriesAsc")&&t.reverse(),en(t,function(e,n){e.data.setCalculationInfo("stackedOnSeries",n>0?t[n-1].seriesModel:null)}),function(t){en(t,function(e,n){var i=[],r=[NaN,NaN],o=[e.stackResultDimension,e.stackedOverDimension],a=e.data,s=e.isStackedByIndex,l=e.seriesModel.get("stackStrategy")||"samesign";a.modify(o,function(o,c,u){var d,h,p=a.get(e.stackedDimension,u);if(isNaN(p))return r;s?h=a.getRawIndex(u):d=a.get(e.stackedByDimension,u);for(var f=NaN,g=n-1;g>=0;g--){var v=t[g];if(s||(h=v.data.rawIndexOf(v.stackedByDimension,d)),h>=0){var m=v.data.getByRawIndex(v.stackResultDimension,h);if("all"===l||"positive"===l&&m>0||"negative"===l&&m<0||"samesign"===l&&p>=0&&m>0||"samesign"===l&&p<=0&&m<0){p=os(p,m),f=m;break}}}return i[0]=p,i[1]=f,i})})}(t))})}),pb("default",function(t,e){Ke(e=e||{},{text:"loading",textColor:Bf.color.primary,fontSize:12,fontWeight:"normal",fontStyle:"normal",fontFamily:"sans-serif",maskColor:"rgba(255,255,255,0.8)",showSpinner:!0,color:Bf.color.theme[0],spinnerRadius:10,lineWidth:5,zlevel:0});var n=new Ua,i=new Bc({style:{fill:e.maskColor},zlevel:e.zlevel,z:1e4});n.add(i);var r,o=new Wc({style:{text:e.text,fill:e.textColor,fontSize:e.fontSize,fontWeight:e.fontWeight,fontStyle:e.fontStyle,fontFamily:e.fontFamily},zlevel:e.zlevel,z:10001}),a=new Bc({style:{fill:"none"},textContent:o,textConfig:{position:"right",distance:10},zlevel:e.zlevel,z:10001});return n.add(a),e.showSpinner&&((r=new Xd({shape:{startAngle:-jm/2,endAngle:-jm/2+.1,r:e.spinnerRadius},style:{stroke:e.color,lineCap:"round",lineWidth:e.lineWidth},zlevel:e.zlevel,z:10001})).animateShape(!0).when(1e3,{endAngle:3*jm/2}).start("circularInOut"),r.animateShape(!0).when(1e3,{startAngle:3*jm/2}).delay(300).start("circularInOut"),n.add(r)),n.resize=function(){var n=o.getBoundingRect().width,s=e.showSpinner?e.spinnerRadius:0,l=(t.getWidth()-2*s-(e.showSpinner&&n?10:0)-n)/2-(e.showSpinner&&n?0:5+n/2)+(e.showSpinner?0:n/2)+(n?0:s),c=t.getHeight()/2;e.showSpinner&&r.setShape({cx:l,cy:c}),a.setShape({x:l-s,y:c-s,width:2*s,height:2*s}),i.setShape({x:0,y:0,width:t.getWidth(),height:t.getHeight()})},n.resize(),n}),cb({type:cu,event:cu,update:cu},Nn),cb({type:uu,event:uu,update:uu},Nn),cb({type:du,event:fu,update:du,action:Nn,refineEvent:gb,publishNonRefinedEvent:!0}),cb({type:hu,event:fu,update:hu,action:Nn,refineEvent:gb,publishNonRefinedEvent:!0}),cb({type:pu,event:fu,update:pu,action:Nn,refineEvent:gb,publishNonRefinedEvent:!0}),ob("default",{}),ob("dark",fy);var yb=function(){function t(t,e,n,i,r,o){this._old=t,this._new=e,this._oldKeyGetter=n||mb,this._newKeyGetter=i||mb,this.context=r,this._diffModeMultiple="multiple"===o}return t.prototype.add=function(t){return this._add=t,this},t.prototype.update=function(t){return this._update=t,this},t.prototype.updateManyToOne=function(t){return this._updateManyToOne=t,this},t.prototype.updateOneToMany=function(t){return this._updateOneToMany=t,this},t.prototype.updateManyToMany=function(t){return this._updateManyToMany=t,this},t.prototype.remove=function(t){return this._remove=t,this},t.prototype.execute=function(){this[this._diffModeMultiple?"_executeMultiple":"_executeOneToOne"]()},t.prototype._executeOneToOne=function(){var t=this._old,e=this._new,n={},i=new Array(t.length),r=new Array(e.length);this._initIndexMap(t,null,i,"_oldKeyGetter"),this._initIndexMap(e,n,r,"_newKeyGetter");for(var o=0;o1){var c=s.shift();1===s.length&&(n[a]=s[0]),this._update&&this._update(c,o)}else 1===l?(n[a]=null,this._update&&this._update(s,o)):this._remove&&this._remove(o)}this._performRestAdd(r,n)},t.prototype._executeMultiple=function(){var t=this._old,e=this._new,n={},i={},r=[],o=[];this._initIndexMap(t,n,r,"_oldKeyGetter"),this._initIndexMap(e,i,o,"_newKeyGetter");for(var a=0;a1&&1===d)this._updateManyToOne&&this._updateManyToOne(c,l),i[s]=null;else if(1===u&&d>1)this._updateOneToMany&&this._updateOneToMany(c,l),i[s]=null;else if(1===u&&1===d)this._update&&this._update(c,l),i[s]=null;else if(u>1&&d>1)this._updateManyToMany&&this._updateManyToMany(c,l),i[s]=null;else if(u>1)for(var h=0;h1)for(var a=0;a30}var Db,Ib,Pb,Lb,Eb,Ob,zb,Nb=fn,Rb=nn,Hb="undefined"==typeof Int32Array?Array:Int32Array,Bb=["hasItemOption","_nameList","_idList","_invertedIndicesMap","_dimSummary","userOutput","_rawData","_dimValueGetter","_nameDimIdx","_idDimIdx","_nameRepeatCount"],Fb=["_approximateExtent"],$b=function(){function t(t,e){var n;this.type="list",this._dimOmitted=!1,this._nameList=[],this._idList=[],this._visual={},this._layout={},this._itemVisuals=[],this._itemLayouts=[],this._graphicEls=[],this._approximateExtent={},this._calculationInfo={},this.hasItemOption=!1,this.TRANSFERABLE_METHODS=["cloneShallow","downSample","minmaxDownSample","lttbDownSample","map"],this.CHANGABLE_METHODS=["filterSelf","selectRange"],this.DOWNSAMPLE_METHODS=["downSample","minmaxDownSample","lttbDownSample"];var i=!1;Mb(t)?(n=t.dimensions,this._dimOmitted=t.isDimensionOmitted(),this._schema=t):(i=!0,n=t),n=n||["x","y"];for(var r={},o=[],a={},s=!1,l={},c=0;c=e)){var n=this._store.getProvider();this._updateOrdinalMeta();var i=this._nameList,r=this._idList;if(n.getSource().sourceFormat===Yf&&!n.pure)for(var o=[],a=t;a0},t.prototype.ensureUniqueItemVisual=function(t,e){var n=this._itemVisuals,i=n[t];i||(i=n[t]={});var r=i[e];return null==r&&(cn(r=this.getVisual(e))?r=r.slice():Nb(r)&&(r=Ze({},r)),i[e]=r),r},t.prototype.setItemVisual=function(t,e,n){var i=this._itemVisuals[t]||{};this._itemVisuals[t]=i,Nb(e)?Ze(i,e):i[e]=n},t.prototype.clearAllVisual=function(){this._visual={},this._itemVisuals=[]},t.prototype.setLayout=function(t,e){Nb(t)?Ze(this._layout,t):this._layout[t]=e},t.prototype.getLayout=function(t){return this._layout[t]},t.prototype.getItemLayout=function(t){return this._itemLayouts[t]},t.prototype.setItemLayout=function(t,e,n){this._itemLayouts[t]=n?Ze(this._itemLayouts[t]||{},e):e},t.prototype.clearItemLayouts=function(){this._itemLayouts.length=0},t.prototype.setItemGraphicEl=function(t,e){!function(t,e,n,i){if(i){var r=nu(i);r.dataIndex=n,r.dataType=e,r.seriesIndex=t,r.ssrType="chart","group"===i.type&&i.traverse(function(i){var r=nu(i);r.seriesIndex=t,r.dataIndex=n,r.dataType=e,r.ssrType="chart"})}}(this.hostModel&&this.hostModel.seriesIndex,this.dataType,t,e),this._graphicEls[t]=e},t.prototype.getItemGraphicEl=function(t){return this._graphicEls[t]},t.prototype.eachItemGraphicEl=function(t,e){en(this._graphicEls,function(n,i){n&&t&&t.call(e,n,i)})},t.prototype.cloneShallow=function(e){return e||(e=new t(this._schema?this._schema:Rb(this.dimensions,this._getDimInfo,this),this.hostModel)),Eb(e,this),e._store=this._store,e},t.prototype.wrapMethod=function(t,e){var n=this[t];un(n)&&(this.__wrappedMethods=this.__wrappedMethods||[],this.__wrappedMethods.push(t),this[t]=function(){var t=n.apply(this,arguments);return e.apply(this,[t].concat(Sn(arguments)))})},t.internalField=(Db=function(t){var e=t._invertedIndicesMap;en(e,function(n,i){var r=t._dimInfos[i],o=r.ordinalMeta,a=t._store;if(o){n=e[i]=new Hb(o.categories.length);for(var s=0;s1&&(s+="__ec__"+c),i[e]=s}})),t}();function Vb(t,e){Jg(t)||(t=ev(t));var n=(e=e||{}).coordDimensions||[],i=e.dimensionsDefine||t.dimensionsDefine||[],r=En(),o=[],a=function(t,e,n,i){var r=Math.max(t.dimensionsDetectedCount||1,e.length,n.length,i||0);return en(e,function(t){var e;fn(t)&&(e=t.dimsDef)&&(r=Math.max(r,e.length))}),r}(t,n,i,e.dimensionsCount),s=e.canOmitUnusedDimensions&&Ab(a),l=i===t.dimensionsDefine,c=l?Tb(t):kb(i),u=e.encodeDefine;!u&&e.encodeDefaulter&&(u=e.encodeDefaulter(t,a));for(var d=En(u),h=new Bv(a),p=0;p0&&(i.name=r+(o-1)),o++,e.set(r,o)}}(o),new Cb({source:t,dimensions:o,fullDimensionCount:a,dimensionOmitted:s})}function Wb(t,e,n){if(n||e.hasKey(t)){for(var i=0;e.hasKey(t+i);)i++;t+=i}return e.set(t,!0),t}var Ub=function(t){this.coordSysDims=[],this.axisMap=En(),this.categoryAxisMap=En(),this.coordSysName=t};var Gb={cartesian2d:function(t,e,n,i){var r=t.getReferringComponents("xAxis",Rs).models[0],o=t.getReferringComponents("yAxis",Rs).models[0];e.coordSysDims=["x","y"],n.set("x",r),n.set("y",o),qb(r)&&(i.set("x",r),e.firstCategoryDimIndex=0),qb(o)&&(i.set("y",o),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=1))},singleAxis:function(t,e,n,i){var r=t.getReferringComponents("singleAxis",Rs).models[0];e.coordSysDims=["single"],n.set("single",r),qb(r)&&(i.set("single",r),e.firstCategoryDimIndex=0)},polar:function(t,e,n,i){var r=t.getReferringComponents("polar",Rs).models[0],o=r.findAxisModel("radiusAxis"),a=r.findAxisModel("angleAxis");e.coordSysDims=["radius","angle"],n.set("radius",o),n.set("angle",a),qb(o)&&(i.set("radius",o),e.firstCategoryDimIndex=0),qb(a)&&(i.set("angle",a),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=1))},geo:function(t,e,n,i){e.coordSysDims=["lng","lat"]},parallel:function(t,e,n,i){var r=t.ecModel,o=r.getComponent("parallel",t.get("parallelIndex")),a=e.coordSysDims=o.dimensions.slice();en(o.parallelAxisIndex,function(t,o){var s=r.getComponent("parallelAxis",t),l=a[o];n.set(l,s),qb(s)&&(i.set(l,s),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=o))})},matrix:function(t,e,n,i){var r=t.getReferringComponents("matrix",Rs).models[0];e.coordSysDims=["x","y"];var o=r.getDimensionModel("x"),a=r.getDimensionModel("y");n.set("x",o),n.set("y",a),i.set("x",o),i.set("y",a)}};function qb(t){return"category"===t.get("type")}function jb(t,e,n){var i,r,o,a=(n=n||{}).byIndex,s=n.stackedCoordDimension;!function(t){return!Mb(t.schema)}(e)?(r=e.schema,i=r.dimensions,o=e.store):i=e;var l,c,u,d,h=!(!t||!t.get("stack"));if(en(i,function(t,e){dn(t)&&(i[e]=t={name:t}),h&&!t.isExtraCoord&&(a||l||!t.ordinalMeta||(l=t),c||"ordinal"===t.type||"time"===t.type||s&&s!==t.coordDim||(c=t))}),!c||a||l||(a=!0),c){u="__\0ecstackresult_"+t.id,d="__\0ecstackedover_"+t.id,l&&(l.createInvertedIndices=!0);var p=c.coordDim,f=c.type,g=0;en(i,function(t){t.coordDim===p&&g++});var v={name:u,coordDim:p,coordDimIndex:g,type:f,isExtraCoord:!0,isCalculationCoord:!0,storeDimIndex:i.length},m={name:d,coordDim:d,coordDimIndex:g+1,type:f,isExtraCoord:!0,isCalculationCoord:!0,storeDimIndex:i.length+1};r?(o&&(v.storeDimIndex=o.ensureCalculationDimension(d,f),m.storeDimIndex=o.ensureCalculationDimension(u,f)),r.appendCalculationDimension(v),r.appendCalculationDimension(m)):(i.push(v),i.push(m))}return{stackedDimension:c&&c.name,stackedByDimension:l&&l.name,isStackedByIndex:a,stackedOverDimension:d,stackResultDimension:u}}function Xb(t,e){return!!e&&e===t.getCalculationInfo("stackedDimension")}function Yb(t,e,n){n=n||{};var i,r,o=e.getSourceManager();r=(i=o.getSource()).sourceFormat===Yf;var a=function(t){var e=t.get("coordinateSystem"),n=new Ub(e),i=Gb[e];if(i)return i(t,n,n.axisMap,n.categoryAxisMap),n}(e),s=function(t,e){var n,i=t.get("coordinateSystem"),r=_f.get(i);return e&&e.coordSysDims&&(n=nn(e.coordSysDims,function(t){var n={name:t},i=e.axisMap.get(t);if(i){var r=i.get("type");n.type=function(t){return"category"===t?"ordinal":"time"===t?"time":"float"}(r)}return n})),n||(n=r&&(r.getDimensionsInfo?r.getDimensionsInfo():r.dimensions.slice())||["x","y"]),n}(e,a),l=n.useEncodeDefaulter,c=un(l)?l:l?ln(sg,s,e):null,u=Vb(i,{coordDimensions:s,generateCoord:n.generateCoord,encodeDefine:e.getEncode(),encodeDefaulter:c,canOmitUnusedDimensions:!r}),d=function(t,e,n){var i,r;return n&&en(t,function(t,o){var a=t.coordDim,s=n.categoryAxisMap.get(a);s&&(null==i&&(i=o),t.ordinalMeta=s.getOrdinalMeta(),e&&(t.createInvertedIndices=!0)),null!=t.otherDims.itemName&&(r=!0)}),r||null==i||(t[i].otherDims.itemName=0),i}(u.dimensions,n.createInvertedIndices,a),h=r?null:o.getSharedDataStore(u),p=jb(e,{schema:u,store:h}),f=new $b(u,e);f.setCalculationInfo(p);var g=null!=d&&function(t){if(t.sourceFormat===Yf){var e=function(t){var e=0;for(;er&&(a=o.interval=r);var s=o.intervalPrecision=Jb(a);return function(t,e){!isFinite(t[0])&&(t[0]=e[0]),!isFinite(t[1])&&(t[1]=e[1]),tx(t,0,e),tx(t,1,e),t[0]>t[1]&&(t[0]=t[1])}(o.niceTickExtent=[is(Math.ceil(t[0]/a)*a,s),is(Math.floor(t[1]/a)*a,s)],t),o}function Qb(t){var e=Math.pow(10,us(t)),n=t/e;return n?2===n?n=3:3===n?n=5:n*=2:n=1,is(n*e)}function Jb(t){return rs(t)+2}function tx(t,e,n){t[e]=Math.max(Math.min(t[e],n[1]),n[0])}function ex(t,e){return t>=e[0]&&t<=e[1]}var nx=function(){function t(){this.normalize=ix,this.scale=rx}return t.prototype.updateMethods=function(t){t.hasBreaks()?(this.normalize=sn(t.normalize,t),this.scale=sn(t.scale,t)):(this.normalize=ix,this.scale=rx)},t}();function ix(t,e){return e[1]===e[0]?.5:(t-e[0])/(e[1]-e[0])}function rx(t,e){return t*(e[1]-e[0])+e[0]}function ox(t,e,n){var i=Math.log(t);return[Math.log(n?e[0]:Math.max(0,e[0]))/i,Math.log(n?e[1]:Math.max(0,e[1]))/i]}var ax=function(){function t(t){this._calculator=new nx,this._setting=t||{},this._extent=[1/0,-1/0]}return t.prototype.getSetting=function(t){return this._setting[t]},t.prototype._innerUnionExtent=function(t){var e=this._extent;this._innerSetExtent(t[0]e[1]?t[1]:e[1])},t.prototype.unionExtentFromData=function(t,e){this._innerUnionExtent(t.getApproximateExtent(e))},t.prototype.getExtent=function(){return this._extent.slice()},t.prototype.setExtent=function(t,e){this._innerSetExtent(t,e)},t.prototype._innerSetExtent=function(t,e){var n=this._extent;isNaN(t)||(n[0]=t),isNaN(e)||(n[1]=e),this._brkCtx&&this._brkCtx.update(n)},t.prototype.setBreaksFromOption=function(t){},t.prototype._innerSetBreak=function(t){this._brkCtx&&(this._brkCtx.setBreaks(t),this._calculator.updateMethods(this._brkCtx),this._brkCtx.update(this._extent))},t.prototype._innerGetBreaks=function(){return this._brkCtx?this._brkCtx.breaks:[]},t.prototype.hasBreaks=function(){return!!this._brkCtx&&this._brkCtx.hasBreaks()},t.prototype._getExtentSpanWithBreaks=function(){return this._brkCtx&&this._brkCtx.hasBreaks()?this._brkCtx.getExtentSpan():this._extent[1]-this._extent[0]},t.prototype.isInExtentRange=function(t){return this._extent[0]<=t&&this._extent[1]>=t},t.prototype.isBlank=function(){return this._isBlank},t.prototype.setBlank=function(t){this._isBlank=t},t}();Xs(ax);var sx=0,lx=function(){function t(t){this.categories=t.categories||[],this._needCollect=t.needCollect,this._deduplication=t.deduplication,this.uid=++sx,this._onCollect=t.onCollect}return t.createByAxisModel=function(e){var n=e.option,i=n.data,r=i&&nn(i,cx);return new t({categories:r,needCollect:!r,deduplication:!1!==n.dedplication})},t.prototype.getOrdinal=function(t){return this._getOrCreateMap().get(t)},t.prototype.parseAndCollect=function(t){var e,n=this._needCollect;if(!dn(t)&&!n)return t;if(n&&!this._deduplication)return e=this.categories.length,this.categories[e]=t,this._onCollect&&this._onCollect(t,e),e;var i=this._getOrCreateMap();return null==(e=i.get(t))&&(n?(e=this.categories.length,this.categories[e]=t,i.set(t,e),this._onCollect&&this._onCollect(t,e)):e=NaN),e},t.prototype._getOrCreateMap=function(){return this._map||(this._map=En(this.categories))},t}();function cx(t){return fn(t)&&null!=t.value?t.value:t+""}var ux=function(t){function e(e){var n=t.call(this,e)||this;n.type="ordinal";var i=n.getSetting("ordinalMeta");return i||(i=new lx({})),cn(i)&&(i=new lx({categories:nn(i,function(t){return fn(t)?t.value:t})})),n._ordinalMeta=i,n._extent=n.getSetting("extent")||[0,i.categories.length-1],n}return _(e,t),e.prototype.parse=function(t){return null==t?NaN:dn(t)?this._ordinalMeta.getOrdinal(t):Math.round(t)},e.prototype.contain=function(t){return ex(t,this._extent)&&t>=0&&t=0&&t=0&&t=t},e.prototype.getOrdinalMeta=function(){return this._ordinalMeta},e.prototype.calcNiceTicks=function(){},e.prototype.calcNiceExtent=function(){},e.type="ordinal",e}(ax);ax.registerClass(ux);var dx=is,hx=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.type="interval",e._interval=0,e._intervalPrecision=2,e}return _(e,t),e.prototype.parse=function(t){return null==t||""===t?NaN:Number(t)},e.prototype.contain=function(t){return ex(t,this._extent)},e.prototype.normalize=function(t){return this._calculator.normalize(t,this._extent)},e.prototype.scale=function(t){return this._calculator.scale(t,this._extent)},e.prototype.getInterval=function(){return this._interval},e.prototype.setInterval=function(t){this._interval=t,this._niceExtent=this._extent.slice(),this._intervalPrecision=Jb(t)},e.prototype.getTicks=function(t){t=t||{};var e=this._interval,n=this._extent,i=this._niceExtent,r=this._intervalPrecision,o=[];if(!e)return o;t.breakTicks;n[0]=0&&(s=dx(s+l*e,r))}if(o.length>0&&s===o[o.length-1].value)break;if(o.length>1e4)return[]}var c=o.length?o[o.length-1].value:i[1];return n[1]>c&&(t.expandToNicedExtent?o.push({value:dx(c+e,r)}):o.push({value:n[1]})),t.breakTicks,o},e.prototype.getMinorTicks=function(t){for(var e=this.getTicks({expandToNicedExtent:!0}),n=[],i=this.getExtent(),r=1;ri[0]&&d0&&(o=null===o?s:Math.min(o,s))}n[i]=o}}return n}(t),n=[];return en(t,function(t){var i,r=t.coordinateSystem.getBaseAxis(),o=r.getExtent();if("category"===r.type)i=r.getBandWidth();else if("value"===r.type||"time"===r.type){var a=r.dim+"_"+r.index,s=e[a],l=Math.abs(o[1]-o[0]),c=r.scale.getExtent(),u=Math.abs(c[1]-c[0]);i=s?l/u*s:l}else{var d=t.getData();i=Math.abs(o[1]-o[0])/d.count()}var h=es(t.get("barWidth"),i),p=es(t.get("barMaxWidth"),i),f=es(t.get("barMinWidth")||(function(t){return t.pipelineContext&&t.pipelineContext.large}(t)?.5:1),i),g=t.get("barGap"),v=t.get("barCategoryGap"),m=t.get("defaultBarGap");n.push({bandWidth:i,barWidth:h,barMaxWidth:p,barMinWidth:f,barGap:g,barCategoryGap:v,defaultBarGap:m,axisKey:mx(r),stackId:vx(t)})}),function(t){var e={};en(t,function(t,n){var i=t.axisKey,r=t.bandWidth,o=e[i]||{bandWidth:r,remainedWidth:r,autoWidthCount:0,categoryGap:null,gap:t.defaultBarGap||0,stacks:{}},a=o.stacks;e[i]=o;var s=t.stackId;a[s]||o.autoWidthCount++,a[s]=a[s]||{width:0,maxWidth:0};var l=t.barWidth;l&&!a[s].width&&(a[s].width=l,l=Math.min(o.remainedWidth,l),o.remainedWidth-=l);var c=t.barMaxWidth;c&&(a[s].maxWidth=c);var u=t.barMinWidth;u&&(a[s].minWidth=u);var d=t.barGap;null!=d&&(o.gap=d);var h=t.barCategoryGap;null!=h&&(o.categoryGap=h)});var n={};return en(e,function(t,e){n[e]={};var i=t.stacks,r=t.bandWidth,o=t.categoryGap;if(null==o){var a=an(i).length;o=Math.max(35-4*a,15)+"%"}var s=es(o,r),l=es(t.gap,1),c=t.remainedWidth,u=t.autoWidthCount,d=(c-s)/(u+(u-1)*l);d=Math.max(d,0),en(i,function(t){var e=t.maxWidth,n=t.minWidth;if(t.width){i=t.width;e&&(i=Math.min(i,e)),n&&(i=Math.max(i,n)),t.width=i,c-=i+l*i,u--}else{var i=d;e&&ei&&(i=n),i!==d&&(t.width=i,c-=i+l*i,u--)}}),d=(c-s)/(u+(u-1)*l),d=Math.max(d,0);var h,p=0;en(i,function(t,e){t.width||(t.width=d),h=t,p+=t.width*(1+l)}),h&&(p-=h.width*l);var f=-p/2;en(i,function(t,i){n[e][i]=n[e][i]||{bandWidth:r,offset:f,width:t.width},f+=t.width*(1+l)})}),n}(n)}var _x=function(t){function e(e){var n=t.call(this,e)||this;return n.type="time",n}return _(e,t),e.prototype.getLabel=function(t){var e=this.getSetting("useUTC");return Gp(t.value,Hp[function(t){switch(t){case"year":case"month":return"day";case"millisecond":return"millisecond";default:return"second"}}(Wp(this._minLevelUnit))]||Hp.second,e,this.getSetting("locale"))},e.prototype.getFormattedLabel=function(t,e,n){var i=this.getSetting("useUTC");return function(t,e,n,i,r){var o=null;if(dn(n))o=n;else if(un(n)){var a={time:t.time,level:t.time.level},s=null;s&&s.makeAxisLabelFormatterParamBreak(a,t.break),o=n(t.value,e,a)}else{var l=t.time;if(l){var c=n[l.lowerTimeUnit][l.upperTimeUnit];o=c[Math.min(l.level,c.length-1)]||""}else{var u=qp(t.value,r);o=n[u][u][0]}}return Gp(new Date(t.value),o,r,i)}(t,e,n,this.getSetting("locale"),i)},e.prototype.getTicks=function(t){var e=this._interval,n=this._extent,i=[];if(!e)return i;var r=this.getSetting("useUTC"),o=qp(n[1],r);i.push({value:n[0],time:{level:0,upperTimeUnit:o,lowerTimeUnit:o}});var a=function(t,e,n,i,r,o){var a=1e4,s=Fp,l=0;function c(t,e,n,r,s,c,u){for(var d=function(t,e){var n=new Date(0);n[t](1);var i=n.getTime();n[t](1+e);var r=n.getTime()-i;return function(t,e){return Math.max(0,Math.round((e-t)/r))}}(s,t),h=e,p=new Date(h);ha));)if(p[s](p[r]()+t),h=p.getTime(),o){var f=o.calcNiceTickMultiple(h,d);f>0&&(p[s](p[r]()+f*t),h=p.getTime())}u.push({value:h,notAdd:!0})}function u(t,r,o){var a=[],s=!r.length;if(!function(t,e,n,i){return jp(new Date(e),t,i).getTime()===jp(new Date(n),t,i).getTime()}(Wp(t),i[0],i[1],n)){s&&(r=[{value:Tx(i[0],t,n)},{value:i[1]}]);for(var l=0;l=i[0]&&u<=i[1]&&c(h,u,d,p,f,g,a),"year"===t&&o.length>1&&0===l&&o.unshift({value:o[0].value-h})}}for(l=0;l=i[0]&&_<=i[1]&&p++)}var b=r/e;if(p>1.5*b&&f>b/1.5)break;if(d.push(m),p>b||t===s[g])break}h=[]}}var x=on(nn(d,function(t){return on(t,function(t){return t.value>=i[0]&&t.value<=i[1]&&!t.notAdd})}),function(t){return t.length>0}),w=[],S=x.length-1;for(g=0;gn&&(this._approxInterval=n);var r=bx.length,o=Math.min(function(t,e,n,i){for(;n>>1;t[r][1]16?16:t>7.5?7:t>3.5?4:t>1.5?2:1}function Sx(t){return(t/=2592e6)>6?6:t>3?3:t>2?2:1}function Cx(t){return(t/=Lp)>12?12:t>6?6:t>3.5?4:t>2?2:1}function Mx(t,e){return(t/=e?Pp:Ip)>30?30:t>20?20:t>15?15:t>10?10:t>5?5:t>2?2:1}function kx(t){return ds(t)}function Tx(t,e,n){var i=Math.max(0,Qe(Bp,e)-1);return jp(new Date(t),Bp[i],n).getTime()}ax.registerClass(_x);var Ax=is,Dx=Math.floor,Ix=Math.ceil,Px=Math.pow,Lx=Math.log,Ex=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.type="log",e.base=10,e._originalScale=new hx,e}return _(e,t),e.prototype.getTicks=function(e){e=e||{};var n=this._extent.slice(),i=this._originalScale.getExtent(),r=t.prototype.getTicks.call(this,e),o=this.base;return this._originalScale._innerGetBreaks(),nn(r,function(t){var e=t.value,r=null,a=Px(o,e);return e===n[0]&&this._fixMin?r=i[0]:e===n[1]&&this._fixMax&&(r=i[1]),null!=r&&(a=Ox(a,r)),{value:a,break:void 0}},this)},e.prototype._getNonTransBreaks=function(){return this._originalScale._innerGetBreaks()},e.prototype.setExtent=function(e,n){this._originalScale.setExtent(e,n);var i=ox(this.base,[e,n]);t.prototype.setExtent.call(this,i[0],i[1])},e.prototype.getExtent=function(){var e=this.base,n=t.prototype.getExtent.call(this);n[0]=Px(e,n[0]),n[1]=Px(e,n[1]);var i=this._originalScale.getExtent();return this._fixMin&&(n[0]=Ox(n[0],i[0])),this._fixMax&&(n[1]=Ox(n[1],i[1])),n},e.prototype.unionExtentFromData=function(t,e){this._originalScale.unionExtentFromData(t,e);var n=ox(this.base,t.getApproximateExtent(e),!0);this._innerUnionExtent(n)},e.prototype.calcNiceTicks=function(t){t=t||10;var e=this._extent.slice(),n=this._getExtentSpanWithBreaks();if(isFinite(n)&&!(n<=0)){var i,r=(i=n,Math.pow(10,us(i)));for(t/n*r<=.5&&(r*=10);!isNaN(r)&&Math.abs(r)<1&&Math.abs(r)>0;)r*=10;var o=[Ax(Ix(e[0]/r)*r),Ax(Dx(e[1]/r)*r)];this._interval=r,this._intervalPrecision=Jb(r),this._niceExtent=o}},e.prototype.calcNiceExtent=function(e){t.prototype.calcNiceExtent.call(this,e),this._fixMin=e.fixMin,this._fixMax=e.fixMax},e.prototype.contain=function(e){return e=Lx(e)/Lx(this.base),t.prototype.contain.call(this,e)},e.prototype.normalize=function(e){return e=Lx(e)/Lx(this.base),t.prototype.normalize.call(this,e)},e.prototype.scale=function(e){return e=t.prototype.scale.call(this,e),Px(this.base,e)},e.prototype.setBreaksFromOption=function(t){},e.type="log",e}(hx);function Ox(t,e){return Ax(t,rs(e))}ax.registerClass(Ex);var zx=function(){function t(t,e,n){this._prepareParams(t,e,n)}return t.prototype._prepareParams=function(t,e,n){n[1]0&&s>0&&!l&&(a=0),a<0&&s<0&&!c&&(s=0));var d=this._determinedMin,h=this._determinedMax;return null!=d&&(a=d,l=!0),null!=h&&(s=h,c=!0),{min:a,max:s,minFixed:l,maxFixed:c,isBlank:u}},t.prototype.modifyDataMinMax=function(t,e){this[Rx[t]]=e},t.prototype.setDeterminedMinMax=function(t,e){this[Nx[t]]=e},t.prototype.freeze=function(){this.frozen=!0},t}(),Nx={min:"_determinedMin",max:"_determinedMax"},Rx={min:"_dataMin",max:"_dataMax"};function Hx(t,e){return null==e?null:_n(e)?NaN:t.parse(e)}function Bx(t,e){var n=t.type,i=function(t,e,n){var i=t.rawExtentInfo;return i||(i=new zx(t,e,n),t.rawExtentInfo=i,i)}(t,e,t.getExtent()).calculate();t.setBlank(i.isBlank);var r=i.min,o=i.max,a=e.ecModel;if(a&&"time"===n){var s=function(t,e){var n=[];return e.eachSeriesByType(t,function(t){(function(t){return t.coordinateSystem&&"cartesian2d"===t.coordinateSystem.type})(t)&&n.push(t)}),n}("bar",a),l=!1;if(en(s,function(t){l=l||t.getBaseAxis()===e.axis}),l){var c=yx(s),u=function(t,e,n,i){var r=n.axis.getExtent(),o=Math.abs(r[1]-r[0]),a=function(t,e){if(t&&e)return t[mx(e)]}(i,n.axis);if(void 0===a)return{min:t,max:e};var s=1/0;en(a,function(t){s=Math.min(t.offset,s)});var l=-1/0;en(a,function(t){l=Math.max(t.offset+t.width,l)}),s=Math.abs(s),l=Math.abs(l);var c=s+l,u=e-t,d=u/(1-(s+l)/o)-u;return e+=d*(l/c),t-=d*(s/c),{min:t,max:e}}(r,o,e,c);r=u.min,o=u.max}}return{extent:[r,o],fixMin:i.minFixed,fixMax:i.maxFixed}}function Fx(t,e){var n=e,i=Bx(t,n),r=i.extent,o=n.get("splitNumber");t instanceof Ex&&(t.base=n.get("logBase"));var a=t.type,s=n.get("interval"),l="interval"===a||"time"===a;t.setBreaksFromOption(Xx(n)),t.setExtent(r[0],r[1]),t.calcNiceExtent({splitNumber:o,fixMin:i.fixMin,fixMax:i.fixMax,minInterval:l?n.get("minInterval"):null,maxInterval:l?n.get("maxInterval"):null}),null!=s&&t.setInterval&&t.setInterval(s)}function $x(t){var e=t.getLabelModel().get("formatter");if("time"===t.type){var n=$p(e);return function(e,i){return t.scale.getFormattedLabel(e,i,n)}}if(dn(e))return function(n){var i=t.scale.getLabel(n);return e.replace("{value}",null!=i?i:"")};if(un(e)){if("category"===t.type)return function(n,i){return e(Vx(t,n),n.value-t.scale.getExtent()[0],null)};var i=null;return function(n,r){var o=null;return i&&(o=i.makeAxisLabelFormatterParamBreak(o,n.break)),e(Vx(t,n),r,o)}}return function(e){return t.scale.getLabel(e)}}function Vx(t,e){return"category"===t.type?t.scale.getLabel(e):e.value}function Wx(t){var e=t.get("interval");return null==e?"auto":e}function Ux(t){return"category"===t.type&&0===Wx(t.getLabelModel())}function Gx(t,e){var n={};return en(t.mapDimensionsAll(e),function(e){n[function(t,e){return Xb(t,e)?t.getCalculationInfo("stackResultDimension"):e}(t,e)]=!0}),an(n)}function qx(t){return"middle"===t||"center"===t}function jx(t){return t.getShallow("show")}function Xx(t){t.get("breaks",!0)}var Yx=function(){function t(){}return t.prototype.getNeedCrossZero=function(){return!this.option.scale},t.prototype.getCoordSysModel=function(){},t}(),Zx=[],Kx={registerPreprocessor:ab,registerProcessor:sb,registerPostInit:function(t){lb("afterinit",t)},registerPostUpdate:function(t){lb("afterupdate",t)},registerUpdateLifecycle:lb,registerAction:cb,registerCoordinateSystem:function(t,e){_f.register(t,e)},registerLayout:function(t,e){hb(K_,t,e,1e3,"layout")},registerVisual:ub,registerTransform:fb,registerLoading:pb,registerMap:function(t,e,n){var i=c_["registerMap"];i&&i(t,e,n)},registerImpl:function(t,e){c_[t]=e},PRIORITY:h_,ComponentModel:Hf,ComponentView:wm,SeriesModel:fm,ChartView:km,registerComponentModel:function(t){Hf.registerClass(t)},registerComponentView:function(t){wm.registerClass(t)},registerSeriesModel:function(t){fm.registerClass(t)},registerChartView:function(t){km.registerClass(t)},registerCustomSeries:function(t,e){},registerSubTypeDefaulter:function(t,e){Hf.registerSubTypeDefaulter(t,e)},registerPainter:function(t,e){var n;n=e,Ga[t]=n}};function Qx(t){cn(t)?en(t,function(t){Qx(t)}):Qe(Zx,t)>=0||(Zx.push(t),un(t)&&(t={install:t}),t.install(Kx))}var Jx=Es(),tw=Es(),ew=1,nw=2;function iw(t){return{out:{noPxChangeTryDetermine:[]},kind:t}}function rw(t,e){var n=nn(e,function(e){return t.scale.parse(e)});return"time"===t.type&&n.length>0&&(n.sort(),n.unshift(n[0]),n.push(n[n.length-1])),n}function ow(t,e){var n=t.getLabelModel().get("customValues");if(n){var i=$x(t),r=t.scale.getExtent();return{labels:nn(on(rw(t,n),function(t){return t>=r[0]&&t<=r[1]}),function(e){var n={value:e};return{formattedLabel:i(n),rawLabel:t.scale.getLabel(n),tickValue:e,time:void 0,break:void 0}})}}return"category"===t.type?function(t,e){var n=t.getLabelModel(),i=sw(t,n,e);return!n.get("show")||t.scale.isBlank()?{labels:[]}:i}(t,e):function(t){var e=t.scale.getTicks(),n=$x(t);return{labels:nn(e,function(e,i){return{formattedLabel:n(e,i),rawLabel:t.scale.getLabel(e),tickValue:e.value,time:e.time,break:e.break}})}}(t)}function aw(t,e,n){var i=t.getTickModel().get("customValues");if(i){var r=t.scale.getExtent();return{ticks:on(rw(t,i),function(t){return t>=r[0]&&t<=r[1]})}}return"category"===t.type?function(t,e){var n,i,r=lw(t),o=Wx(e),a=dw(r,o);if(a)return a;e.get("show")&&!t.scale.isBlank()||(n=[]);if(un(o))n=vw(t,o,!0);else if("auto"===o){var s=sw(t,t.getLabelModel(),iw(nw));i=s.labelCategoryInterval,n=nn(s.labels,function(t){return t.tickValue})}else n=gw(t,i=o,!0);return hw(r,o,{ticks:n,tickCategoryInterval:i})}(t,e):{ticks:nn(t.scale.getTicks(n),function(t){return t.value})}}function sw(t,e,n){var i,r,o=cw(t),a=Wx(e),s=n.kind===ew;if(!s){var l=dw(o,a);if(l)return l}un(a)?i=vw(t,a):(r="auto"===a?function(t,e){if(e.kind===ew){var n=t.calculateCategoryInterval(e);return e.out.noPxChangeTryDetermine.push(function(){return tw(t).autoInterval=n,!0}),n}var i=tw(t).autoInterval;return null!=i?i:tw(t).autoInterval=t.calculateCategoryInterval(e)}(t,n):a,i=gw(t,r));var c={labels:i,labelCategoryInterval:r};return s?n.out.noPxChangeTryDetermine.push(function(){return hw(o,a,c),!0}):hw(o,a,c),c}var lw=uw("axisTick"),cw=uw("axisLabel");function uw(t){return function(e){return tw(e)[t]||(tw(e)[t]={list:[]})}}function dw(t,e){for(var n=0;ne&&i.axisExtent0===r[0]&&i.axisExtent1===r[1])return o;i.lastTickCount=n,i.lastAutoInterval=e,i.axisExtent0=r[0],i.axisExtent1=r[1]}function gw(t,e,n){var i=$x(t),r=t.scale,o=r.getExtent(),a=t.getLabelModel(),s=[],l=Math.max((e||0)+1,1),c=o[0],u=r.count();0!==c&&l>1&&u/l>2&&(c=Math.round(Math.ceil(c/l)*l));var d=Ux(t),h=a.get("showMinLabel")||d,p=a.get("showMaxLabel")||d;h&&c!==o[0]&&g(o[0]);for(var f=c;f<=o[1];f+=l)g(f);function g(t){var e={value:t};s.push(n?t:{formattedLabel:i(e),rawLabel:r.getLabel(e),tickValue:t,time:void 0,break:void 0})}return p&&f-l!==o[1]&&g(o[1]),s}function vw(t,e,n){var i=t.scale,r=$x(t),o=[];return en(i.getTicks(),function(t){var a=i.getLabel(t),s=t.value;e(t.value,a)&&o.push(n?s:{formattedLabel:r(t),rawLabel:a,tickValue:s,time:void 0,break:void 0})}),o}var mw=[0,1],yw=function(){function t(t,e,n){this.onBand=!1,this.inverse=!1,this.dim=t,this.scale=e,this._extent=n||[0,0]}return t.prototype.contain=function(t){var e=this._extent,n=Math.min(e[0],e[1]),i=Math.max(e[0],e[1]);return t>=n&&t<=i},t.prototype.containData=function(t){return this.scale.contain(this.scale.parse(t))},t.prototype.getExtent=function(){return this._extent.slice()},t.prototype.getPixelPrecision=function(t){return function(t,e){var n=Math.log,i=Math.LN10,r=Math.floor(n(t[1]-t[0])/i),o=Math.round(n(Ja(e[1]-e[0]))/i),a=Math.min(Math.max(-r+o,0),20);return isFinite(a)?a:20}(t||this.scale.getExtent(),this._extent)},t.prototype.setExtent=function(t,e){var n=this._extent;n[0]=t,n[1]=e},t.prototype.dataToCoord=function(t,e){var n=this._extent,i=this.scale;return t=i.normalize(i.parse(t)),this.onBand&&"ordinal"===i.type&&_w(n=n.slice(),i.count()),ts(t,mw,n,e)},t.prototype.coordToData=function(t,e){var n=this._extent,i=this.scale;this.onBand&&"ordinal"===i.type&&_w(n=n.slice(),i.count());var r=ts(t,n,mw,e);return this.scale.scale(r)},t.prototype.pointToData=function(t,e){},t.prototype.getTicksCoords=function(t){var e=(t=t||{}).tickModel||this.getTickModel(),n=nn(aw(this,e,{breakTicks:t.breakTicks,pruneByBreak:t.pruneByBreak}).ticks,function(t){return{coord:this.dataToCoord("ordinal"===this.scale.type?this.scale.getRawOrdinalNumber(t):t),tickValue:t}},this);return function(t,e,n,i){var r=e.length;if(!t.onBand||n||!r)return;var o,a,s=t.getExtent();if(1===r)e[0].coord=s[0],e[0].onBand=!0,o=e[1]={coord:s[1],tickValue:e[0].tickValue,onBand:!0};else{var l=e[r-1].tickValue-e[0].tickValue,c=(e[r-1].coord-e[0].coord)/l;en(e,function(t){t.coord-=c/2,t.onBand=!0});var u=t.scale.getExtent();a=1+u[1]-e[r-1].tickValue,o={coord:e[r-1].coord+c*a,tickValue:u[1]+1,onBand:!0},e.push(o)}var d=s[0]>s[1];h(e[0].coord,s[0])&&(i?e[0].coord=s[0]:e.shift());i&&h(s[0],e[0].coord)&&e.unshift({coord:s[0],onBand:!0});h(s[1],o.coord)&&(i?o.coord=s[1]:e.pop());i&&h(o.coord,s[1])&&e.push({coord:s[1],onBand:!0});function h(t,e){return t=is(t),e=is(e),d?t>e:t0&&t<100||(t=5),nn(this.scale.getMinorTicks(t),function(t){return nn(t,function(t){return{coord:this.dataToCoord(t),tickValue:t}},this)},this)},t.prototype.getViewLabels=function(t){return ow(this,t=t||iw(nw)).labels},t.prototype.getLabelModel=function(){return this.model.getModel("axisLabel")},t.prototype.getTickModel=function(){return this.model.getModel("axisTick")},t.prototype.getBandWidth=function(){var t=this._extent,e=this.scale.getExtent(),n=e[1]-e[0]+(this.onBand?1:0);0===n&&(n=1);var i=Math.abs(t[1]-t[0]);return Math.abs(i)/n},t.prototype.calculateCategoryInterval=function(t){return function(t,e){var n=e.kind,i=function(t){var e=t.getLabelModel();return{axisRotate:t.getRotate?t.getRotate():t.isHorizontal&&!t.isHorizontal()?90:0,labelRotate:e.get("rotate")||0,font:e.getFont()}}(t),r=$x(t),o=(i.axisRotate-i.labelRotate)/180*Math.PI,a=t.scale,s=a.getExtent(),l=a.count();if(s[1]-s[0]<1)return 0;var c=1;l>40&&(c=Math.max(1,Math.floor(l/40)));for(var u=s[0],d=t.dataToCoord(u+1)-t.dataToCoord(u),h=Math.abs(d*Math.cos(o)),p=Math.abs(d*Math.sin(o)),f=0,g=0;u<=s[1];u+=c){var v,m,y=Ta(r({value:u}),i.font,"center","top");v=1.3*y.width,m=1.3*y.height,f=Math.max(f,v,7),g=Math.max(g,m,7)}var _=f/h,b=g/p;isNaN(_)&&(_=1/0),isNaN(b)&&(b=1/0);var x=Math.max(0,Math.floor(Math.min(_,b)));if(n===ew)return e.out.noPxChangeTryDetermine.push(sn(pw,null,t,x,l)),x;var w=fw(t,x,l);return null!=w?w:x}(this,t=t||iw(nw))},t}();function _w(t,e){var n=(t[1]-t[0])/e/2;t[0]+=n,t[1]-=n}var bw=["label","labelLine","layoutOption","priority","defaultAttr","marginForce","minMarginForce","marginDefault","suggestIgnore"];function xw(t,e,n){n=n||3,e?t.dirty|=n:t.dirty&=~n}function ww(t,e){return e=e||3,null==t.dirty||!!(t.dirty&e)}function Sw(t){if(t)return ww(t)&&function(t,e,n){var i=e.getComputedTransform();t.transform=Uh(t.transform,i);var r=t.localRect=Wh(t.localRect,e.getBoundingRect()),o=e.style,a=o.margin,s=n&&n.marginForce,l=n&&n.minMarginForce,c=n&&n.marginDefault,u=o.__marginType;null==u&&c&&(a=c,u=lp.textMargin);for(var d=0;d<4;d++)Cw[d]=u===lp.minMargin&&l&&null!=l[d]?l[d]:s&&null!=s[d]?s[d]:a?a[d]:0;u===lp.textMargin&&zh(r,Cw,!1,!1);var h=t.rect=Wh(t.rect,r);i&&h.applyTransform(i);u===lp.minMargin&&zh(h,Cw,!1,!1);t.axisAligned=$h(i),(t.label=t.label||{}).ignore=e.ignore,xw(t,!1),xw(t,!0,2)}(t,t.label,t),t}var Cw=[0,0,0,0];function Mw(t,e){for(var n=0;n-1&&(s.style.stroke=s.style.fill,s.style.fill=Bf.color.neutral00,s.style.lineWidth=2),e},e.type="series.line",e.dependencies=["grid","polar"],e.defaultOption={z:3,coordinateSystem:"cartesian2d",legendHoverLink:!0,clip:!0,label:{position:"top"},endLabel:{show:!1,valueAnimation:!0,distance:8},lineStyle:{width:2,type:"solid"},emphasis:{scale:!0},step:!1,smooth:!1,smoothMonotone:null,symbol:"emptyCircle",symbolSize:6,symbolRotate:null,showSymbol:!0,showAllSymbol:"auto",connectNulls:!1,sampling:"none",animationEasing:"linear",progressive:0,hoverLayerThreshold:1/0,universalTransition:{divideShape:"clone"},triggerLineEvent:!1},e}(fm);function Dw(t,e){var n=t.mapDimensionsAll("defaultedLabel"),i=n.length;if(1===i){var r=yv(t,e,n[0]);return null!=r?r+"":null}if(i){for(var o=[],a=0;a0?+h:1;k.scaleX=this._sizeX*T,k.scaleY=this._sizeY*T,this.setSymbolScale(1),Wu(this,l,c,u)},e.prototype.setSymbolScale=function(t){this.scaleX=this.scaleY=t},e.prototype.fadeOut=function(t,e,n){var i=this.childAt(0),r=nu(this).dataIndex,o=n&&n.animation;if(this.silent=i.silent=!0,n&&n.fadeLabel){var a=i.getTextContent();a&&gh(a,{style:{opacity:0}},e,{dataIndex:r,removeOpt:o,cb:function(){i.removeTextContent()}})}else i.removeTextContent();gh(i,{style:{opacity:0},scaleX:0,scaleY:0},e,{dataIndex:r,cb:t,removeOpt:o})},e.getSymbolSize=function(t,e){return cn(n=t.getItemVisual(e,"symbolSize"))||(n=[+n,+n]),[n[0]||0,n[1]||0];var n},e.getSymbolZ2=function(t,e){return t.getItemVisual(e,"z2")},e}(Ua);function Pw(t,e){this.parent.drift(t,e)}function Lw(t,e,n,i){return e&&!isNaN(e[0])&&!isNaN(e[1])&&!(i.isIgnore&&i.isIgnore(n))&&!(i.clipShape&&!i.clipShape.contain(e[0],e[1]))&&"none"!==t.getItemVisual(n,"symbol")}function Ew(t){return null==t||fn(t)||(t={isIgnore:t}),t||{}}function Ow(t){var e=t.hostModel,n=e.getModel("emphasis");return{emphasisItemStyle:n.getModel("itemStyle").getItemStyle(),blurItemStyle:e.getModel(["blur","itemStyle"]).getItemStyle(),selectItemStyle:e.getModel(["select","itemStyle"]).getItemStyle(),focus:n.get("focus"),blurScope:n.get("blurScope"),emphasisDisabled:n.get("disabled"),hoverScale:n.get("scale"),labelStatesModels:Qh(e),cursorStyle:e.get("cursor")}}var zw=function(){function t(t){this.group=new Ua,this._SymbolCtor=t||Iw}return t.prototype.updateData=function(t,e){this._progressiveEls=null,e=Ew(e);var n=this.group,i=t.hostModel,r=this._data,o=this._SymbolCtor,a=e.disableAnimation,s=Ow(t),l={disableAnimation:a},c=e.getSymbolPoint||function(e){return t.getItemLayout(e)};r||n.removeAll(),t.diff(r).add(function(i){var r=c(i);if(Lw(t,r,i,e)){var a=new o(t,i,s,l);a.setPosition(r),t.setItemGraphicEl(i,a),n.add(a)}}).update(function(u,d){var h=r.getItemGraphicEl(d),p=c(u);if(Lw(t,p,u,e)){var f=t.getItemVisual(u,"symbol")||"circle",g=h&&h.getSymbolType&&h.getSymbolType();if(!h||g&&g!==f)n.remove(h),(h=new o(t,u,s,l)).setPosition(p);else{h.updateData(t,u,s,l);var v={x:p[0],y:p[1]};a?h.attr(v):hh(h,v,i)}n.add(h),t.setItemGraphicEl(u,h)}else n.remove(h)}).remove(function(t){var e=r.getItemGraphicEl(t);e&&e.fadeOut(function(){n.remove(e)},i)}).execute(),this._getSymbolPoint=c,this._data=t},t.prototype.updateLayout=function(){var t=this,e=this._data;e&&e.eachItemGraphicEl(function(e,n){var i=t._getSymbolPoint(n);e.setPosition(i),e.markRedraw()})},t.prototype.incrementalPrepareUpdate=function(t){this._seriesScope=Ow(t),this._data=null,this.group.removeAll()},t.prototype.incrementalUpdate=function(t,e,n){function i(t){t.isGroup||(t.incremental=!0,t.ensureState("emphasis").hoverLayer=!0)}this._progressiveEls=[],n=Ew(n);for(var r=t.start;r0?n=i[0]:i[1]<0&&(n=i[1]);return n}(r,n),a=i.dim,s=r.dim,l=e.mapDimension(s),c=e.mapDimension(a),u="x"===s||"radius"===s?1:0,d=nn(t.dimensions,function(t){return e.mapDimension(t)}),h=!1,p=e.getCalculationInfo("stackResultDimension");return Xb(e,d[0])&&(h=!0,d[0]=p),Xb(e,d[1])&&(h=!0,d[1]=p),{dataDimsForPoint:d,valueStart:o,valueAxisDim:s,baseAxisDim:a,stacked:!!h,valueDim:l,baseDim:c,baseDataOffset:u,stackedOverDimension:e.getCalculationInfo("stackedOverDimension")}}function Rw(t,e,n,i){var r=NaN;t.stacked&&(r=n.get(n.getCalculationInfo("stackedOverDimension"),i)),isNaN(r)&&(r=t.valueStart);var o=t.baseDataOffset,a=[];return a[o]=n.get(t.baseDim,i),a[1-o]=r,e.dataToPoint(a)}var Hw=Math.min,Bw=Math.max;function Fw(t,e){return isNaN(t)||isNaN(e)}function $w(t,e,n,i,r,o,a,s,l){for(var c,u,d,h,p,f,g=n,v=0;v=r||g<0)break;if(Fw(m,y)){if(l){g+=o;continue}break}if(g===n)t[o>0?"moveTo":"lineTo"](m,y),d=m,h=y;else{var _=m-c,b=y-u;if(_*_+b*b<.5){g+=o;continue}if(a>0){for(var x=g+o,w=e[2*x],S=e[2*x+1];w===m&&S===y&&v=i||Fw(w,S))p=m,f=y;else{k=w-c,T=S-u;var I=m-c,P=w-m,L=y-u,E=S-y,O=void 0,z=void 0;if("x"===s){var N=k>0?1:-1;p=m-N*(O=Math.abs(I))*a,f=y,A=m+N*(z=Math.abs(P))*a,D=y}else if("y"===s){var R=T>0?1:-1;p=m,f=y-R*(O=Math.abs(L))*a,A=m,D=y+R*(z=Math.abs(E))*a}else O=Math.sqrt(I*I+L*L),p=m-k*a*(1-(M=(z=Math.sqrt(P*P+E*E))/(z+O))),f=y-T*a*(1-M),D=y+T*a*M,A=Hw(A=m+k*a*M,Bw(w,m)),D=Hw(D,Bw(S,y)),A=Bw(A,Hw(w,m)),f=y-(T=(D=Bw(D,Hw(S,y)))-y)*O/z,p=Hw(p=m-(k=A-m)*O/z,Bw(c,m)),f=Hw(f,Bw(u,y)),A=m+(k=m-(p=Bw(p,Hw(c,m))))*z/O,D=y+(T=y-(f=Bw(f,Hw(u,y))))*z/O}t.bezierCurveTo(d,h,p,f,m,y),d=A,h=D}else t.lineTo(m,y)}c=m,u=y,g+=o}return v}var Vw=function(){this.smooth=0,this.smoothConstraint=!0},Ww=function(t){function e(e){var n=t.call(this,e)||this;return n.type="ec-polyline",n}return _(e,t),e.prototype.getDefaultStyle=function(){return{stroke:Bf.color.neutral99,fill:null}},e.prototype.getDefaultShape=function(){return new Vw},e.prototype.buildPath=function(t,e){var n=e.points,i=0,r=n.length/2;if(e.connectNulls){for(;r>0&&Fw(n[2*r-2],n[2*r-1]);r--);for(;i=0){var v=a?(u-i)*g+i:(c-n)*g+n;return a?[t,v]:[v,t]}n=c,i=u;break;case o.C:c=r[l++],u=r[l++],d=r[l++],h=r[l++],p=r[l++],f=r[l++];var m=a?Tr(n,c,d,p,t,s):Tr(i,u,h,f,t,s);if(m>0)for(var y=0;y=0){v=a?Mr(i,u,h,f,_):Mr(n,c,d,p,_);return a?[t,v]:[v,t]}}n=p,i=f}}},e}(Tc),Uw=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return _(e,t),e}(Vw),Gw=function(t){function e(e){var n=t.call(this,e)||this;return n.type="ec-polygon",n}return _(e,t),e.prototype.getDefaultShape=function(){return new Uw},e.prototype.buildPath=function(t,e){var n=e.points,i=e.stackedOnPoints,r=0,o=n.length/2,a=e.smoothMonotone;if(e.connectNulls){for(;o>0&&Fw(n[2*o-2],n[2*o-1]);o--);for(;r=0&&t<=1){var i=t*(e.length-1),r=Math.floor(i),o=Math.ceil(i),a=to(e[r]),s=to(e[o]),l=i-r,c=ro([Wr(Xr(a[0],s[0],l)),Wr(Xr(a[1],s[1],l)),Wr(Xr(a[2],s[2],l)),Ur(Xr(a[3],s[3],l))],"rgba");return n?{color:c,leftIndex:r,rightIndex:o,value:i}:c}}((n-i)/(e.coord-i),[t.color,e.color])}}for(var s=0;se){i?r.push(a(i,l,e)):n&&r.push(a(n,l,0),a(n,l,e));break}n&&(r.push(a(n,l,0)),n=null),r.push(l),i=l}}return r}function Qw(t,e,n){var i=t.get("showAllSymbol"),r="auto"===i;if(!i||r){var o=n.getAxesByScale("ordinal")[0];if(o&&(!r||!function(t,e){var n=t.getExtent(),i=Math.abs(n[1]-n[0])/t.scale.count();isNaN(i)&&(i=0);for(var r=e.count(),o=Math.max(1,Math.round(r/5)),a=0;ai)return!1;return!0}(o,e))){var a=e.mapDimension(o.dim),s={};return en(o.getViewLabels(),function(t){var e=o.scale.getRawOrdinalNumber(t.tickValue);s[e]=1}),function(t){return!s.hasOwnProperty(e.get(a,t))}}}}function Jw(t,e){return isNaN(t)||isNaN(e)}function tS(t,e){return[t[2*e],t[2*e+1]]}function eS(t){if(t.get(["endLabel","show"]))return!0;for(var e=0;e=0;a--){var s=t.getDimensionInfo(i[a].dimension);if("x"===(r=s&&s.coordDim)||"y"===r){o=i[a];break}}if(o){var l=e.getAxis(r),c=nn(o.stops,function(t){return{coord:l.toGlobalCoord(l.dataToCoord(t.value)),color:t.color}}),u=c.length,d=o.outerColors.slice();u&&c[0].coord>c[u-1].coord&&(c.reverse(),d.reverse());var h=Kw(c,"x"===r?n.getWidth():n.getHeight()),p=h.length;if(!p&&u)return c[0].coord<0?d[1]?d[1]:c[u-1].color:d[0]?d[0]:c[0].color;var f=h[0].coord-10,g=h[p-1].coord+10,v=g-f;if(v<.001)return"transparent";en(h,function(t){t.offset=(t.coord-f)/v}),h.push({offset:p?h[p-1].offset:.5,color:d[1]||"transparent"}),h.unshift({offset:p?h[0].offset:.5,color:d[0]||"transparent"});var m=new Kd(0,0,0,0,h,!0);return m[r]=f,m[r+"2"]=g,m}}}(o,i,n)||o.getVisual("style")[o.getVisual("drawType")];if(h&&u.type===i.type&&M===this._step){v&&!p?p=this._newPolygon(l,_):p&&!v&&(f.remove(p),p=this._polygon=null),c||this._initOrUpdateEndLabel(t,i,vf(k));var T=f.getClipPath();if(T)ph(T,{shape:nS(this,i,!1,t).shape},t);else f.setClipPath(nS(this,i,!0,t));b&&d.updateData(o,{isIgnore:w,clipShape:C,disableAnimation:!0,getSymbolPoint:function(t){return[l[2*t],l[2*t+1]]}}),qw(this._stackedOnPoints,_)&&qw(this._points,l)||(g?this._doUpdateAnimation(o,_,i,n,M,m,x):(M&&(_&&(_=Zw(_,l,i,M,x)),l=Zw(l,null,i,M,x)),h.setShape({points:l}),p&&p.setShape({points:l,stackedOnPoints:_})))}else b&&d.updateData(o,{isIgnore:w,clipShape:C,disableAnimation:!0,getSymbolPoint:function(t){return[l[2*t],l[2*t+1]]}}),g&&this._initSymbolLabelAnimation(o,i,C),M&&(_&&(_=Zw(_,l,i,M,x)),l=Zw(l,null,i,M,x)),h=this._newPolyline(l),v?p=this._newPolygon(l,_):p&&(f.remove(p),p=this._polygon=null),c||this._initOrUpdateEndLabel(t,i,vf(k)),f.setClipPath(nS(this,i,!0,t));var A=t.getModel("emphasis"),D=A.get("focus"),I=A.get("blurScope"),P=A.get("disabled");(h.useStyle(Ke(a.getLineStyle(),{fill:"none",stroke:k,lineJoin:"bevel"})),qu(h,t,"lineStyle"),h.style.lineWidth>0&&"bolder"===t.get(["emphasis","lineStyle","width"]))&&(h.getState("emphasis").style.lineWidth=+h.style.lineWidth+1);nu(h).seriesIndex=t.seriesIndex,Wu(h,D,I,P);var L=Yw(t.get("smooth")),E=t.get("smoothMonotone");if(h.setShape({smooth:L,smoothMonotone:E,connectNulls:x}),p){var O=o.getCalculationInfo("stackedOnSeries"),z=0;p.useStyle(Ke(s.getAreaStyle(),{fill:k,opacity:.7,lineJoin:"bevel",decal:o.getVisual("style").decal})),O&&(z=Yw(O.get("smooth"))),p.setShape({smooth:L,stackedOnSmooth:z,smoothMonotone:E,connectNulls:x}),qu(p,t,"areaStyle"),nu(p).seriesIndex=t.seriesIndex,Wu(p,D,I,P)}var N=this._changePolyState;o.eachItemGraphicEl(function(t){t&&(t.onHoverStateChange=N)}),this._polyline.onHoverStateChange=N,this._data=o,this._coordSys=i,this._stackedOnPoints=_,this._points=l,this._step=M,this._valueOrigin=m,t.get("triggerLineEvent")&&(this.packEventData(t,h),p&&this.packEventData(t,p))},e.prototype.packEventData=function(t,e){nu(e).eventData={componentType:"series",componentSubType:"line",componentIndex:t.componentIndex,seriesIndex:t.seriesIndex,seriesName:t.name,seriesType:"line"}},e.prototype.highlight=function(t,e,n,i){var r=t.getData(),o=Ls(r,i);if(this._changePolyState("emphasis"),!(o instanceof Array)&&null!=o&&o>=0){var a=r.getLayout("points"),s=r.getItemGraphicEl(o);if(!s){var l=a[2*o],c=a[2*o+1];if(isNaN(l)||isNaN(c))return;if(this._clipShapeForSymbol&&!this._clipShapeForSymbol.contain(l,c))return;var u=t.get("zlevel")||0,d=t.get("z")||0;(s=new Iw(r,o)).x=l,s.y=c,s.setZ(u,d);var h=s.getSymbolPath().getTextContent();h&&(h.zlevel=u,h.z=d,h.z2=this._polyline.z2+1),s.__temp=!0,r.setItemGraphicEl(o,s),s.stopSymbolAnimation(!0),this.group.add(s)}s.highlight()}else km.prototype.highlight.call(this,t,e,n,i)},e.prototype.downplay=function(t,e,n,i){var r=t.getData(),o=Ls(r,i);if(this._changePolyState("normal"),null!=o&&o>=0){var a=r.getItemGraphicEl(o);a&&(a.__temp?(r.setItemGraphicEl(o,null),this.group.remove(a)):a.downplay())}else km.prototype.downplay.call(this,t,e,n,i)},e.prototype._changePolyState=function(t){var e=this._polygon;Mu(this._polyline,t),e&&Mu(e,t)},e.prototype._newPolyline=function(t){var e=this._polyline;return e&&this._lineGroup.remove(e),e=new Ww({shape:{points:t},segmentIgnoreThreshold:2,z2:10}),this._lineGroup.add(e),this._polyline=e,e},e.prototype._newPolygon=function(t,e){var n=this._polygon;return n&&this._lineGroup.remove(n),n=new Gw({shape:{points:t,stackedOnPoints:e},segmentIgnoreThreshold:2}),this._lineGroup.add(n),this._polygon=n,n},e.prototype._initSymbolLabelAnimation=function(t,e,n){var i,r,o=e.getBaseAxis(),a=o.inverse;"cartesian2d"===e.type?(i=o.isHorizontal(),r=!1):"polar"===e.type&&(i="angle"===o.dim,r=!0);var s=t.hostModel,l=s.get("animationDuration");un(l)&&(l=l(null));var c=s.get("animationDelay")||0,u=un(c)?c(null):c;t.eachItemGraphicEl(function(t,o){var s=t;if(s){var d=[t.x,t.y],h=void 0,p=void 0,f=void 0;if(n)if(r){var g=n,v=e.pointToCoord(d);i?(h=g.startAngle,p=g.endAngle,f=-v[1]/180*Math.PI):(h=g.r0,p=g.r,f=v[0])}else{var m=n;i?(h=m.x,p=m.x+m.width,f=t.x):(h=m.y+m.height,p=m.y,f=t.y)}var y=p===h?0:(f-h)/(p-h);a&&(y=1-y);var _=un(c)?c(o):l*y+u,b=s.getSymbolPath(),x=b.getTextContent();s.attr({scaleX:0,scaleY:0}),s.animateTo({scaleX:1,scaleY:1},{duration:200,setToFinal:!0,delay:_}),x&&x.animateFrom({style:{opacity:0}},{duration:300,delay:_}),b.disableLabelAnimation=!0}})},e.prototype._initOrUpdateEndLabel=function(t,e,n){var i=t.getModel("endLabel");if(eS(t)){var r=t.getData(),o=this._polyline,a=r.getLayout("points");if(!a)return o.removeTextContent(),void(this._endLabel=null);var s=this._endLabel;s||((s=this._endLabel=new Wc({z2:200})).ignoreClip=!0,o.setTextContent(this._endLabel),o.disableLabelAnimation=!0);var l=function(t){for(var e=t.length/2;e>0&&Jw(t[2*e-2],t[2*e-1]);e--);return e-1}(a);l>=0&&(Kh(o,Qh(t,"endLabel"),{inheritColor:n,labelFetcher:t,labelDataIndex:l,defaultText:function(t,e,n){return null!=n?function(t,e){var n=t.mapDimensionsAll("defaultedLabel");if(!cn(e))return e+"";for(var i=[],r=0;r=0&&i.push(e[o])}return i.join(" ")}(r,n):Dw(r,t)},enableTextSetter:!0},function(t,e){var n=e.getBaseAxis(),i=n.isHorizontal(),r=n.inverse,o=i?r?"right":"left":"center",a=i?"middle":r?"top":"bottom";return{normal:{align:t.get("align")||o,verticalAlign:t.get("verticalAlign")||a}}}(i,e)),o.textConfig.position=null)}else this._endLabel&&(this._polyline.removeTextContent(),this._endLabel=null)},e.prototype._endLabelOnDuring=function(t,e,n,i,r,o,a){var s=this._endLabel,l=this._polyline;if(s){t<1&&null==i.originalX&&(i.originalX=s.x,i.originalY=s.y);var c=n.getLayout("points"),u=n.hostModel,d=u.get("connectNulls"),h=o.get("precision"),p=o.get("distance")||0,f=a.getBaseAxis(),g=f.isHorizontal(),v=f.inverse,m=e.shape,y=v?g?m.x:m.y+m.height:g?m.x+m.width:m.y,_=(g?p:0)*(v?-1:1),b=(g?0:-p)*(v?-1:1),x=g?"x":"y",w=function(t,e,n){for(var i,r,o=t.length/2,a="x"===n?0:1,s=0,l=-1,c=0;c=e||i>=e&&r<=e){l=c;break}s=c,i=r}else i=r;return{range:[s,l],t:(e-i)/(r-i)}}(c,y,x),S=w.range,C=S[1]-S[0],M=void 0;if(C>=1){if(C>1&&!d){var k=tS(c,S[0]);s.attr({x:k[0]+_,y:k[1]+b}),r&&(M=u.getRawValue(S[0]))}else{(k=l.getPointOn(y,x))&&s.attr({x:k[0]+_,y:k[1]+b});var T=u.getRawValue(S[0]),A=u.getRawValue(S[1]);r&&(M=function(t,e,n,i,r){var o=null==e||"auto"===e;if(null==i)return i;if(pn(i))return is(f=_s(n||0,i,r),o?Math.max(rs(n||0),rs(i)):e);if(dn(i))return r<1?n:i;for(var a=[],s=n,l=i,c=Math.max(s?s.length:0,l.length),u=0;u0?S[0]:0;k=tS(c,D);r&&(M=u.getRawValue(D)),s.attr({x:k[0]+_,y:k[1]+b})}if(r){var I=sp(s);"function"==typeof I.setLabelText&&I.setLabelText(M)}}},e.prototype._doUpdateAnimation=function(t,e,n,i,r,o,a){var s=this._polyline,l=this._polygon,c=t.hostModel,u=function(t,e,n,i,r,o,a){for(var s=function(t,e){var n=[];return e.diff(t).add(function(t){n.push({cmd:"+",idx:t})}).update(function(t,e){n.push({cmd:"=",idx:e,idx1:t})}).remove(function(t){n.push({cmd:"-",idx:t})}).execute(),n}(t,e),l=[],c=[],u=[],d=[],h=[],p=[],f=[],g=Nw(r,e,a),v=t.getLayout("points")||[],m=e.getLayout("points")||[],y=0;y3e3||l&&Xw(h,f)>3e3)return s.stopAnimation(),s.setShape({points:p}),void(l&&(l.stopAnimation(),l.setShape({points:p,stackedOnPoints:f})));s.shape.__points=u.current,s.shape.points=d;var g={shape:{points:p}};u.current!==d&&(g.shape.__points=u.next),s.stopAnimation(),hh(s,g,c),l&&(l.setShape({points:d,stackedOnPoints:h}),l.stopAnimation(),hh(l,{shape:{stackedOnPoints:f}},c),s.shape.points!==l.shape.points&&(l.shape.points=s.shape.points));for(var v=[],m=u.status,y=0;ye&&(e=t[n]);return isFinite(e)?e:NaN},min:function(t){for(var e=1/0,n=0;ne[1]&&e.reverse(),e},e.prototype.pointToData=function(t,e){return this.coordToData(this.toLocalCoord(t["x"===this.dim?0:1]),e)},e.prototype.setCategorySortInfo=function(t){if("category"!==this.type)return!1;this.model.option.categorySortInfo=t,this.scale.setSortInfo(t)},e}(yw),wS="expandAxisBreak",SS=Math.PI,CS=[[1,2,1,2],[5,3,5,3],[8,3,8,3]],MS=[[0,1,0,1],[0,3,0,3],[0,3,0,3]],kS=Es(),TS=Es(),AS=function(){function t(t){this.recordMap={},this.resolveAxisNameOverlap=t}return t.prototype.ensureRecord=function(t){var e=t.axis.dim,n=t.componentIndex,i=this.recordMap,r=i[e]||(i[e]=[]);return r[n]||(r[n]={ready:{}})},t}();var DS=[1,0,0,1,0,0],IS=new $i(0,0,0,0),PS=function(t,e,n,i,r,o){if(qx(t.nameLocation)){var a=o.stOccupiedRect;a&&LS(function(t,e,n){return t.transform=Uh(t.transform,n),t.localRect=Wh(t.localRect,e),t.rect=Wh(t.rect,e),n&&t.rect.applyTransform(n),t.axisAligned=$h(n),t.obb=void 0,(t.label=t.label||{}).ignore=!1,t}({},a,o.transGroup.transform),i,r)}else ES(o.labelInfoList,o.dirVec,i,r)};function LS(t,e,n){var i=new Ti;Tw(t,e,i,{direction:Math.atan2(n.y,n.x),bidirectional:!1,touchThreshold:.05})&&function(t,e){if(t){t.label.x+=e.x,t.label.y+=e.y,t.label.markRedraw();var n=t.transform;n&&(n[4]+=e.x,n[5]+=e.y);var i=t.rect;i&&(i.x+=e.x,i.y+=e.y);var r=t.obb;r&&r.fromBoundingRect(t.localRect,n)}}(e,i)}function ES(t,e,n,i){for(var r=Ti.dot(i,e)>=0,o=0,a=t.length;o0?"top":"bottom",i="center"):ss(o-SS)?(r=n>0?"bottom":"top",i="center"):(r="middle",i=o>0&&o0?"right":"left":n>0?"left":"right"),{rotation:o,textAlign:i,textVerticalAlign:r}},t.makeAxisEventDataBase=function(t){var e={componentType:t.mainType,componentIndex:t.componentIndex};return e[t.mainType+"Index"]=t.componentIndex,e},t.isLabelSilent=function(t){var e=t.get("tooltip");return t.get("silent")||!(t.get("triggerEvent")||e&&e.show)},t}(),zS=["axisLine","axisTickLabelEstimate","axisTickLabelDetermine","axisName"],NS={axisLine:function(t,e,n,i,r,o,a){var s=i.get(["axisLine","show"]);if("auto"===s&&(s=!0,null!=t.raw.axisLineAutoShow&&(s=!!t.raw.axisLineAutoShow)),s){var l=i.axis.getExtent(),c=o.transform,u=[l[0],0],d=[l[1],0],h=u[0]>d[0];c&&(jn(u,u,c),jn(d,d,c));var p=Ze({lineCap:"round"},i.getModel(["axisLine","lineStyle"]).getLineStyle()),f={strokeContainThreshold:t.raw.strokeContainThreshold||5,silent:!0,z2:1,style:p};if(i.get(["axisLine","breakLine"])&&i.axis.scale.hasBreaks())null.buildAxisBreakLine(i,r,o,f);else{var g=new Vd(Ze({shape:{x1:u[0],y1:u[1],x2:d[0],y2:d[1]}},f));Th(g.shape,g.style.lineWidth),g.anid="line",r.add(g)}var v=i.get(["axisLine","symbol"]);if(null!=v){var m=i.get(["axisLine","symbolSize"]);dn(v)&&(v=[v,v]),(dn(m)||pn(m))&&(m=[m,m]);var y=Oy(i.get(["axisLine","symbolOffset"])||0,m),_=m[0],b=m[1];en([{rotate:t.rotation+Math.PI/2,offset:y[0],r:0},{rotate:t.rotation-Math.PI/2,offset:y[1],r:Math.sqrt((u[0]-d[0])*(u[0]-d[0])+(u[1]-d[1])*(u[1]-d[1]))}],function(e,n){if("none"!==v[n]&&null!=v[n]){var i=Ey(v[n],-_/2,-b/2,_,b,p.stroke,!0),o=e.r+e.offset,a=h?d:u;i.attr({rotation:e.rotate,x:a[0]+o*Math.cos(t.rotation),y:a[1]-o*Math.sin(t.rotation),silent:!0,z2:11}),r.add(i)}})}}},axisTickLabelEstimate:function(t,e,n,i,r,o,a,s){FS(e,r,s)&&RS(t,e,n,i,r,o,a,ew)},axisTickLabelDetermine:function(t,e,n,i,r,o,a,s){FS(e,r,s)&&RS(t,e,n,i,r,o,a,nw);var l=function(t,e,n,i){var r=i.axis,o=i.getModel("axisTick"),a=o.get("show");"auto"===a&&(a=!0,null!=t.raw.axisTickAutoShow&&(a=!!t.raw.axisTickAutoShow));if(!a||r.scale.isBlank())return[];for(var s=o.getModel("lineStyle"),l=t.tickDirection*o.get("length"),c=BS(r.getTicksCoords(),n.transform,l,Ke(s.getLineStyle(),{stroke:i.get(["axisLine","lineStyle","color"])}),"ticks"),u=0;ui[1],l="start"===e&&!s||"start"!==e&&s;ss(a-SS/2)?(o=l?"bottom":"top",r="center"):ss(a-1.5*SS)?(o=l?"top":"bottom",r="center"):(o="middle",r=a<1.5*SS&&a>SS/2?l?"left":"right":l?"right":"left");return{rotation:a,textAlign:r,textVerticalAlign:o}}(t.rotation,u,x||0,f),null!=(b=t.raw.axisNameAvailableWidth)&&(b=Math.abs(b/Math.sin(_.rotation)),!isFinite(b)&&(b=null)));var w=h.getFont(),S=i.get("nameTruncate",!0)||{},C=S.ellipsis,M=bn(t.raw.nameTruncateMaxWidth,S.maxWidth,b),k=s.nameMarginLevel||0,T=new Wc({x:v.x,y:v.y,rotation:_.rotation,silent:OS.isLabelSilent(i),style:Jh(h,{text:c,font:w,overflow:"truncate",width:M,ellipsis:C,fill:h.getTextColor()||i.get(["axisLine","lineStyle","color"]),align:h.get("align")||_.textAlign,verticalAlign:h.get("verticalAlign")||_.textVerticalAlign}),z2:1});if(Hh({el:T,componentModel:i,itemName:c}),T.__fullText=c,T.anid="name",i.get("triggerEvent")){var A=OS.makeAxisEventDataBase(i);A.targetType="axisName",A.name=c,nu(T).eventData=A}o.add(T),T.updateTransform(),e.nameEl=T;var D=l.nameLayout=Sw({label:T,priority:T.z2,defaultAttr:{ignore:T.ignore},marginDefault:qx(u)?CS[k]:MS[k]});if(l.nameLocation=u,r.add(T),T.decomposeTransform(),t.shouldNameMoveOverlap&&D){var I=n.ensureRecord(i);n.resolveAxisNameOverlap(t,n,i,D,m,I)}}}};function RS(t,e,n,i,r,o,a,s){$S(e)||function(t,e,n,i,r,o){var a=r.axis,s=bn(t.raw.axisLabelShow,r.get(["axisLabel","show"])),l=new Ua;n.add(l);var c=iw(i);if(!s||a.scale.isBlank())return void VS(e,[],l,c);var u=r.getModel("axisLabel"),d=a.getViewLabels(c),h=(bn(t.raw.labelRotate,u.get("rotate"))||0)*SS/180,p=OS.innerTextLayout(t.rotation,h,t.labelDirection),f=r.getCategories&&r.getCategories(!0),g=[],v=r.get("triggerEvent"),m=1/0,y=-1/0;en(d,function(t,e){var n,i="ordinal"===a.scale.type?a.scale.getRawOrdinalNumber(t.tickValue):t.tickValue,s=t.formattedLabel,c=t.rawLabel,h=u;if(f&&f[i]){var _=f[i];fn(_)&&_.textStyle&&(h=new _p(_.textStyle,u,r.ecModel))}var b=h.getTextColor()||r.get(["axisLine","lineStyle","color"]),x=h.getShallow("align",!0)||p.textAlign,w=xn(h.getShallow("alignMinLabel",!0),x),S=xn(h.getShallow("alignMaxLabel",!0),x),C=h.getShallow("verticalAlign",!0)||h.getShallow("baseline",!0)||p.textVerticalAlign,M=xn(h.getShallow("verticalAlignMinLabel",!0),C),k=xn(h.getShallow("verticalAlignMaxLabel",!0),C),T=10+((null===(n=t.time)||void 0===n?void 0:n.level)||0);m=Math.min(m,T),y=Math.max(y,T);var A=new Wc({x:0,y:0,rotation:0,silent:OS.isLabelSilent(r),z2:T,style:Jh(h,{text:s,align:0===e?w:e===d.length-1?S:x,verticalAlign:0===e?M:e===d.length-1?k:C,fill:un(b)?b("category"===a.type?c:"value"===a.type?i+"":i,e):b})});A.anid="label_"+i;var D=kS(A);if(D.break=t.break,D.tickValue=i,D.layoutRotation=p.rotation,Hh({el:A,componentModel:r,itemName:s,formatterParamsExtra:{isTruncated:function(){return A.isTruncated},value:c,tickIndex:e}}),v){var I=OS.makeAxisEventDataBase(r);I.targetType="axisLabel",I.value=c,I.tickIndex=e,t.break&&(I.break={start:t.break.parsedBreak.vmin,end:t.break.parsedBreak.vmax}),"category"===a.type&&(I.dataIndex=i),nu(A).eventData=I,t.break&&function(t,e,n,i){n.on("click",function(n){var r={type:wS,breaks:[{start:i.parsedBreak.breakOption.start,end:i.parsedBreak.breakOption.end}]};r[t.axis.dim+"AxisIndex"]=t.componentIndex,e.dispatchAction(r)})}(r,o,A,t.break)}g.push(A),l.add(A)});var _=nn(g,function(t){return{label:t,priority:kS(t).break?t.z2+(y-m+1):t.z2,defaultAttr:{ignore:t.ignore}}});VS(e,_,l,c)}(t,e,r,s,i,a);var l=e.labelLayoutList;!function(t,e,n,i){var r=e.get(["axisLabel","margin"]);en(n,function(n,o){var a=Sw(n);if(a){var s=a.label,l=kS(s);a.suggestIgnore=s.ignore,s.ignore=!1,ba(WS,US),WS.x=e.axis.dataToCoord(l.tickValue),WS.y=t.labelOffset+t.labelDirection*r,WS.rotation=l.layoutRotation,i.add(WS),WS.updateTransform(),i.remove(WS),WS.decomposeTransform(),ba(s,WS),s.markRedraw(),xw(a,!0),Sw(a)}})}(t,i,l,o),t.rotation;var c=t.optionHideOverlap;!function(t,e,n){if(Ux(t.axis))return;function i(t,i,r){var o=Sw(e[i]),a=Sw(e[r]);if(o&&a)if(!1===t||o.suggestIgnore)HS(o.label);else if(a.suggestIgnore)HS(a.label);else{var s=.1;if(!n){var l=[0,0,0,0];o=Mw({marginForce:l},o),a=Mw({marginForce:l},a)}Tw(o,a,null,{touchThreshold:s})&&HS(t?a.label:o.label)}}var r=t.get(["axisLabel","showMinLabel"]),o=t.get(["axisLabel","showMaxLabel"]),a=e.length;i(r,0,1),i(o,a-1,a-2)}(i,l,c),c&&function(t){var e=[];function n(t){if(!t.ignore){var e=t.ensureState("emphasis");null==e.ignore&&(e.ignore=!1)}t.ignore=!0}t.sort(function(t,e){return(e.suggestIgnore?1:0)-(t.suggestIgnore?1:0)||e.priority-t.priority});for(var i=0;i.1?"x":"y",u=a.transGroup[c];if(s.sort(function(t,e){return Math.abs(t.label[c]-u)-Math.abs(e.label[c]-u)}),l&&r){var d=o.getExtent(),h=Math.min(d[0],d[1]),p=Math.max(d[0],d[1])-h;r.union(new $i(h,0,p,1))}a.stOccupiedRect=r,a.labelInfoList=s}(t,n,i,l)}function HS(t){t&&(t.ignore=!0)}function BS(t,e,n,i,r){for(var o=[],a=[],s=[],l=0;l=0;o--){var a=t[+n[o]],s=a.model,l=a.scale;Zb(l)&&s.get("alignTicks")&&null==s.get("interval")?r.push(a):(Fx(l,s),Zb(l)&&(e=a))}r.length&&(e||Fx((e=r.pop()).scale,e.model),en(r,function(t){!function(t,e,n){var i=hx.prototype,r=i.getTicks.call(n),o=i.getTicks.call(n,{expandToNicedExtent:!0}),a=r.length-1,s=i.getInterval.call(n),l=Bx(t,e),c=l.extent,u=l.fixMin,d=l.fixMax;"log"===t.type&&(c=ox(t.base,c,!0)),t.setBreaksFromOption(Xx(e)),t.setExtent(c[0],c[1]),t.calcNiceExtent({splitNumber:a,fixMin:u,fixMax:d});var h=i.getExtent.call(t);u&&(c[0]=h[0]),d&&(c[1]=h[1]);var p=i.getInterval.call(t),f=c[0],g=c[1];if(u&&d)p=(g-f)/a;else if(u)for(g=c[0]+p*a;gc[0]&&isFinite(f)&&isFinite(c[0]);)p=Qb(p),f=c[1]-p*a;else{t.getTicks().length-1>a&&(p=Qb(p));var v=p*a;(f=is((g=Math.ceil(c[1]/p)*p)-v))<0&&c[0]>=0?(f=0,g=is(v)):g>0&&c[1]<=0&&(g=0,f=-is(v))}var m=(r[0].value-o[0].value)/s,y=(r[a].value-o[a].value)/s;i.setExtent.call(t,f+p*m,g+p*y),i.setInterval.call(t,p),(m||y)&&i.setNiceExtent.call(t,f+p,g-p)}(t.scale,t.model,e.scale)}))}}this._updateScale(t,this.model),i(n.x),i(n.y);var r={};en(n.x,function(t){KS(n,"y",t,r)}),en(n.y,function(t){KS(n,"x",t,r)}),this.resize(this.model,e)},t.prototype.resize=function(t,e,n){var i=Ef(t,e),r=this._rect=Pf(t.getBoxLayoutParams(),i.refContainer),o=this._axesMap,a=this._coordsList,s=t.get("containLabel");if(JS(o,r),!n){var l=function(t,e,n,i,r){var o=new AS(iC);return en(n,function(n){return en(n,function(n){if(jx(n.model)){var a=!i;n.axisBuilder=function(t,e,n,i,r,o){for(var a=qS(t,n),s=!1,l=!1,c=0;c0&&i>0||n<0&&i<0)}(t)}function JS(t,e){en(t.x,function(t){return tC(t,e.x,e.width)}),en(t.y,function(t){return tC(t,e.y,e.height)})}function tC(t,e,n){var i=[0,n],r=t.inverse?1:0;t.setExtent(i[r],i[1-r]),function(t,e){var n=t.getExtent(),i=n[0]+n[1];t.toGlobalCoord="x"===t.dim?function(t){return t+e}:function(t){return i-t+e},t.toLocalCoord="x"===t.dim?function(t){return t-e}:function(t){return i-t+e}}(t,e)}function eC(t,e,n,i,r,o,a){nC(i,r,ew,e,!1,a);var s=[0,0,0,0];c(0),c(1),u(i,0,NaN),u(i,1,NaN);var l=null==function(t,e,n){if(t&&e)for(var i=0,r=t.length;i0});return zh(i,s,!0,!0,n),JS(r,i),l;function c(t){en(r[yh[t]],function(e){if(jx(e.model)){var n=o.ensureRecord(e.model),i=n.labelInfoList;if(i)for(var r=0;r0&&!_n(e)&&e>1e-4&&(t/=e),t}}function nC(t,e,n,i,r,o){var a=n===nw;en(e,function(e){return en(e,function(e){jx(e.model)&&(!function(t,e,n){var i=qS(e,n);t.updateCfg(i)}(e.axisBuilder,t,e.model),e.axisBuilder.build(a?{axisTickLabelDetermine:!0}:{axisTickLabelEstimate:!0},{noPxChange:r}))})});var s={x:0,y:0};function l(e){s[yh[1-e]]=t[_h[e]]<=.5*o.refContainer[_h[e]]?0:1-e==1?2:1}l(0),l(1),en(e,function(t,e){return en(t,function(t){jx(t.model)&&(("all"===i||a)&&t.axisBuilder.build({axisName:!0},{nameMarginLevel:s[e]}),a&&t.axisBuilder.build({axisLine:!0}))})})}var iC=function(t,e,n,i,r,o){var a="x"===n.axis.dim?"y":"x";PS(t,0,0,i,r,o),qx(t.nameLocation)||en(e.recordMap[a],function(t){t&&t.labelInfoList&&t.dirVec&&ES(t.labelInfoList,t.dirVec,i,r)})};function rC(t,e){var n={axesInfo:{},seriesInvolved:!1,coordSysAxesInfo:{},coordSysMap:{}};return function(t,e,n){var i=e.getComponent("tooltip"),r=e.getComponent("axisPointer"),o=r.get("link",!0)||[],a=[];en(n.getCoordinateSystems(),function(n){if(n.axisPointerEnabled){var s=lC(n.model),l=t.coordSysAxesInfo[s]={};t.coordSysMap[s]=n;var c=n.model.getModel("tooltip",i);if(en(n.getAxes(),ln(p,!1,null)),n.getTooltipAxes&&i&&c.get("show")){var u="axis"===c.get("trigger"),d="cross"===c.get(["axisPointer","type"]),h=n.getTooltipAxes(c.get(["axisPointer","axis"]));(u||d)&&en(h.baseAxes,ln(p,!d||"cross",u)),d&&en(h.otherAxes,ln(p,"cross",!1))}}function p(i,s,u){var d=u.model.getModel("axisPointer",r),h=d.get("show");if(h&&("auto"!==h||i||sC(d))){null==s&&(s=d.get("triggerTooltip")),d=i?function(t,e,n,i,r,o){var a=e.getModel("axisPointer"),s={};en(["type","snap","lineStyle","shadowStyle","label","animation","animationDurationUpdate","animationEasingUpdate","z"],function(t){s[t]=Xe(a.get(t))}),s.snap="category"!==t.type&&!!o,"cross"===a.get("type")&&(s.type="line");var l=s.label||(s.label={});if(null==l.show&&(l.show=!1),"cross"===r){var c=a.get(["label","show"]);if(l.show=null==c||c,!o){var u=s.lineStyle=a.get("crossStyle");u&&Ke(l,u.textStyle)}}return t.model.getModel("axisPointer",new _p(s,n,i))}(u,c,r,e,i,s):d;var p=d.get("snap"),f=d.get("triggerEmphasis"),g=lC(u.model),v=s||p||"category"===u.type,m=t.axesInfo[g]={key:g,axis:u,coordSys:n,axisPointerModel:d,triggerTooltip:s,triggerEmphasis:f,involveSeries:v,snap:p,useHandle:sC(d),seriesModels:[],linkGroup:null};l[g]=m,t.seriesInvolved=t.seriesInvolved||v;var y=function(t,e){for(var n=e.model,i=e.dim,r=0;r=0||t===e}function aC(t){var e=(t.ecModel.getComponent("axisPointer")||{}).coordSysAxesInfo;return e&&e.axesInfo[lC(t)]}function sC(t){return!!t.get(["handle","show"])}function lC(t){return t.type+"||"+t.id}var cC={},uC=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return _(e,t),e.prototype.render=function(e,n,i,r){this.axisPointerClass&&function(t){var e=aC(t);if(e){var n=e.axisPointerModel,i=e.axis.scale,r=n.option,o=n.get("status"),a=n.get("value");null!=a&&(a=i.parse(a));var s=sC(n);null==o&&(r.status=s?"show":"hide");var l=i.getExtent().slice();l[0]>l[1]&&l.reverse(),(null==a||a>l[1])&&(a=l[1]),aa)return!0;if(o){var s=aC(t).seriesDataCount,l=i.getExtent();return Math.abs(l[0]-l[1])/s>a}return!1}return!0===n},t.prototype.makeElOption=function(t,e,n,i,r){},t.prototype.createPointerEl=function(t,e,n,i){var r=e.pointer;if(r){var o=bC(t).pointerEl=new Xh[r.type](xC(e.pointer));t.add(o)}},t.prototype.createLabelEl=function(t,e,n,i){if(e.label){var r=bC(t).labelEl=new Wc(xC(e.label));t.add(r),kC(r,i)}},t.prototype.updatePointerEl=function(t,e,n){var i=bC(t).pointerEl;i&&e.pointer&&(i.setStyle(e.pointer.style),n(i,{shape:e.pointer.shape}))},t.prototype.updateLabelEl=function(t,e,n,i){var r=bC(t).labelEl;r&&(r.setStyle(e.label.style),n(r,{x:e.label.x,y:e.label.y}),kC(r,i))},t.prototype._renderHandle=function(t){if(!this._dragging&&this.updateHandleTransform){var e,n=this._axisPointerModel,i=this._api.getZr(),r=this._handle,o=n.getModel("handle"),a=n.get("status");if(!o.get("show")||!a||"hide"===a)return r&&i.remove(r),void(this._handle=null);this._handle||(e=!0,r=this._handle=Lh(o.get("icon"),{cursor:"move",draggable:!0,onmousemove:function(t){mi(t.event)},onmousedown:wC(this._onHandleDragMove,this,0,0),drift:wC(this._onHandleDragMove,this),ondragend:wC(this._onHandleDragEnd,this)}),i.add(r)),AC(r,n,!1),r.setStyle(o.getItemStyle(null,["color","borderColor","borderWidth","opacity","shadowColor","shadowBlur","shadowOffsetX","shadowOffsetY"]));var s=o.get("size");cn(s)||(s=[s,s]),r.scaleX=s[0]/2,r.scaleY=s[1]/2,Nm(this,"_doDispatchAxisPointer",o.get("throttle")||0,"fixRate"),this._moveHandleToValue(t,e)}},t.prototype._moveHandleToValue=function(t,e){CC(this._axisPointerModel,!e&&this._moveAnimation,this._handle,TC(this.getHandleTransform(t,this._axisModel,this._axisPointerModel)))},t.prototype._onHandleDragMove=function(t,e){var n=this._handle;if(n){this._dragging=!0;var i=this.updateHandleTransform(TC(n),[t,e],this._axisModel,this._axisPointerModel);this._payloadInfo=i,n.stopAnimation(),n.attr(TC(i)),bC(n).lastProp=null,this._doDispatchAxisPointer()}},t.prototype._doDispatchAxisPointer=function(){if(this._handle){var t=this._payloadInfo,e=this._axisModel;this._api.dispatchAction({type:"updateAxisPointer",x:t.cursorPoint[0],y:t.cursorPoint[1],tooltipOption:t.tooltipOption,axesInfo:[{axisDim:e.axis.dim,axisIndex:e.componentIndex}]})}},t.prototype._onHandleDragEnd=function(){if(this._dragging=!1,this._handle){var t=this._axisPointerModel.get("value");this._moveHandleToValue(t),this._api.dispatchAction({type:"hideTip"})}},t.prototype.clear=function(t){this._lastValue=null,this._lastStatus=null;var e=t.getZr(),n=this._group,i=this._handle;e&&n&&(this._lastGraphicKey=null,n&&e.remove(n),i&&e.remove(i),this._group=null,this._handle=null,this._payloadInfo=null),Rm(this,"_doDispatchAxisPointer")},t.prototype.doClear=function(){},t.prototype.buildLabel=function(t,e,n){return{x:t[n=n||0],y:t[1-n],width:e[n],height:e[1-n]}},t}();function CC(t,e,n,i){MC(bC(n).lastProp,i)||(bC(n).lastProp=i,e?hh(n,i,t):(n.stopAnimation(),n.attr(i)))}function MC(t,e){if(fn(t)&&fn(e)){var n=!0;return en(e,function(e,i){n=n&&MC(t[i],e)}),!!n}return t===e}function kC(t,e){t[e.get(["label","show"])?"show":"hide"]()}function TC(t){return{x:t.x||0,y:t.y||0,rotation:t.rotation||0}}function AC(t,e,n){var i=e.get("z"),r=e.get("zlevel");t&&t.traverse(function(t){"group"!==t.type&&(null!=i&&(t.z=i),null!=r&&(t.zlevel=r),t.silent=n)})}function DC(t,e,n,i,r){var o=IC(n.get("value"),e.axis,e.ecModel,n.get("seriesDataIndices"),{precision:n.get(["label","precision"]),formatter:n.get(["label","formatter"])}),a=n.getModel("label"),s=df(a.get("padding")||0),l=a.getFont(),c=Ta(o,l),u=r.position,d=c.width+s[1]+s[3],h=c.height+s[0]+s[2],p=r.align;"right"===p&&(u[0]-=d),"center"===p&&(u[0]-=d/2);var f=r.verticalAlign;"bottom"===f&&(u[1]-=h),"middle"===f&&(u[1]-=h/2),function(t,e,n,i){var r=i.getWidth(),o=i.getHeight();t[0]=Math.min(t[0]+e,r)-e,t[1]=Math.min(t[1]+n,o)-n,t[0]=Math.max(t[0],0),t[1]=Math.max(t[1],0)}(u,d,h,i);var g=a.get("backgroundColor");g&&"auto"!==g||(g=e.get(["axisLine","lineStyle","color"])),t.label={x:u[0],y:u[1],style:Jh(a,{text:o,font:l,fill:a.getTextColor(),padding:s,backgroundColor:g}),z2:10}}function IC(t,e,n,i,r){t=e.scale.parse(t);var o=e.scale.getLabel({value:t},{precision:r.precision}),a=r.formatter;if(a){var s={value:Vx(e,{value:t}),axisDimension:e.dim,axisIndex:e.index,seriesData:[]};en(i,function(t){var e=n.getSeriesByIndex(t.seriesIndex),i=t.dataIndexInside,r=e&&e.getDataParams(i);r&&s.seriesData.push(r)}),dn(a)?o=a.replace("{value}",o):un(a)&&(o=a(s))}return o}function PC(t,e,n){var i=[1,0,0,1,0,0];return Mi(i,i,n.rotation),Ci(i,i,n.position),Dh([t.dataToCoord(e),(n.labelOffset||0)+(n.labelDirection||1)*(n.labelMargin||0)],i)}var LC=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return _(e,t),e.prototype.makeElOption=function(t,e,n,i,r){var o=n.axis,a=o.grid,s=i.get("type"),l=EC(a,o).getOtherAxis(o).getGlobalExtent(),c=o.toGlobalCoord(o.dataToCoord(e,!0));if(s&&"none"!==s){var u=function(t){var e,n=t.get("type"),i=t.getModel(n+"Style");return"line"===n?(e=i.getLineStyle()).fill=null:"shadow"===n&&((e=i.getAreaStyle()).stroke=null),e}(i),d=OC[s](o,c,l);d.style=u,t.graphicKey=d.type,t.pointer=d}!function(t,e,n,i,r,o){var a=OS.innerTextLayout(n.rotation,0,n.labelDirection);n.labelMargin=r.get(["label","margin"]),DC(e,i,r,o,{position:PC(i.axis,t,n),align:a.textAlign,verticalAlign:a.textVerticalAlign})}(e,t,qS(a.getRect(),n),n,i,r)},e.prototype.getHandleTransform=function(t,e,n){var i=qS(e.axis.grid.getRect(),e,{labelInside:!1});i.labelMargin=n.get(["handle","margin"]);var r=PC(e.axis,t,i);return{x:r[0],y:r[1],rotation:i.rotation+(i.labelDirection<0?Math.PI:0)}},e.prototype.updateHandleTransform=function(t,e,n,i){var r=n.axis,o=r.grid,a=r.getGlobalExtent(!0),s=EC(o,r).getOtherAxis(r).getGlobalExtent(),l="x"===r.dim?0:1,c=[t.x,t.y];c[l]+=e[l],c[l]=Math.min(a[1],c[l]),c[l]=Math.max(a[0],c[l]);var u=(s[1]+s[0])/2,d=[u,u];d[l]=c[l];return{x:c[0],y:c[1],rotation:t.rotation,cursorPoint:d,tooltipOption:[{verticalAlign:"middle"},{align:"center"}][l]}},e}(SC);function EC(t,e){var n={};return n[e.dim+"AxisIndex"]=e.index,t.getCartesian(n)}var OC={line:function(t,e,n){var i,r,o;return{type:"Line",subPixelOptimize:!0,shape:(i=[e,n[0]],r=[e,n[1]],o=zC(t),{x1:i[o=o||0],y1:i[1-o],x2:r[o],y2:r[1-o]})}},shadow:function(t,e,n){var i,r,o,a=Math.max(1,t.getBandWidth()),s=n[1]-n[0];return{type:"Rect",shape:(i=[e-a/2,n[0]],r=[a,s],o=zC(t),{x:i[o=o||0],y:i[1-o],width:r[o],height:r[1-o]})}}};function zC(t){return"x"===t.dim?0:1}var NC=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return _(e,t),e.type="axisPointer",e.defaultOption={show:"auto",z:50,type:"line",snap:!1,triggerTooltip:!0,triggerEmphasis:!0,value:null,status:null,link:[],animation:null,animationDurationUpdate:200,lineStyle:{color:Bf.color.border,width:1,type:"dashed"},shadowStyle:{color:Bf.color.shadowTint},label:{show:!0,formatter:null,precision:"auto",margin:3,color:Bf.color.neutral00,padding:[5,7,5,7],backgroundColor:Bf.color.accent60,borderColor:null,borderWidth:0,borderRadius:3},handle:{show:!1,icon:"M10.7,11.9v-1.3H9.3v1.3c-4.9,0.3-8.8,4.4-8.8,9.4c0,5,3.9,9.1,8.8,9.4h1.3c4.9-0.3,8.8-4.4,8.8-9.4C19.5,16.3,15.6,12.2,10.7,11.9z M13.3,24.4H6.7v-1.2h6.6z M13.3,22H6.7v-1.2h6.6z M13.3,19.6H6.7v-1.2h6.6z",size:45,margin:50,color:Bf.color.accent40,throttle:40}},e}(Hf),RC=Es(),HC=en;function BC(t,e,n){if(!Te.node){var i=e.getZr();RC(i).records||(RC(i).records={}),function(t,e){if(RC(t).initialized)return;function n(n,i){t.on(n,function(n){var r=function(t){var e={showTip:[],hideTip:[]},n=function(i){var r=e[i.type];r?r.push(i):(i.dispatchAction=n,t.dispatchAction(i))};return{dispatchAction:n,pendings:e}}(e);HC(RC(t).records,function(t){t&&i(t,n,r.dispatchAction)}),function(t,e){var n,i=t.showTip.length,r=t.hideTip.length;i?n=t.showTip[i-1]:r&&(n=t.hideTip[r-1]);n&&(n.dispatchAction=null,e.dispatchAction(n))}(r.pendings,e)})}RC(t).initialized=!0,n("click",ln($C,"click")),n("mousemove",ln($C,"mousemove")),n("globalout",FC)}(i,e),(RC(i).records[t]||(RC(i).records[t]={})).handler=n}}function FC(t,e,n){t.handler("leave",null,n)}function $C(t,e,n,i){e.handler(t,n,i)}function VC(t,e){if(!Te.node){var n=e.getZr();(RC(n).records||{})[t]&&(RC(n).records[t]=null)}}var WC=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return _(e,t),e.prototype.render=function(t,e,n){var i=e.getComponent("tooltip"),r=t.get("triggerOn")||i&&i.get("triggerOn")||"mousemove|click";BC("axisPointer",n,function(t,e,n){"none"!==r&&("leave"===t||r.indexOf(t)>=0)&&n({type:"updateAxisPointer",currTrigger:t,x:e&&e.offsetX,y:e&&e.offsetY})})},e.prototype.remove=function(t,e){VC("axisPointer",e)},e.prototype.dispose=function(t,e){VC("axisPointer",e)},e.type="axisPointer",e}(wm);function UC(t,e){var n,i=[],r=t.seriesIndex;if(null==r||!(n=e.getSeriesByIndex(r)))return{point:[]};var o=n.getData(),a=Ls(o,t);if(null==a||a<0||cn(a))return{point:[]};var s=o.getItemGraphicEl(a),l=n.coordinateSystem;if(n.getTooltipPosition)i=n.getTooltipPosition(a)||[];else if(l&&l.dataToPoint)if(t.isStacked){var c=l.getBaseAxis(),u=l.getOtherAxis(c).dim,d=c.dim,h="x"===u||"radius"===u?1:0,p=o.mapDimension(d),f=[];f[h]=o.get(p,a),f[1-h]=o.get(o.getCalculationInfo("stackResultDimension"),a),i=l.dataToPoint(f)||[]}else i=l.dataToPoint(o.getValues(nn(l.dimensions,function(t){return o.mapDimension(t)}),a))||[];else if(s){var g=s.getBoundingRect().clone();g.applyTransform(s.transform),i=[g.x+g.width/2,g.y+g.height/2]}return{point:i,el:s}}var GC=Es();function qC(t,e,n){var i=t.currTrigger,r=[t.x,t.y],o=t,a=t.dispatchAction||sn(n.dispatchAction,n),s=e.getComponent("axisPointer").coordSysAxesInfo;if(s){KC(r)&&(r=UC({seriesIndex:o.seriesIndex,dataIndex:o.dataIndex},e).point);var l=KC(r),c=o.axesInfo,u=s.axesInfo,d="leave"===i||KC(r),h={},p={},f={list:[],map:{}},g={showPointer:ln(XC,p),showTooltip:ln(YC,f)};en(s.coordSysMap,function(t,e){var n=l||t.containPoint(r);en(s.coordSysAxesInfo[e],function(t,e){var i=t.axis,o=function(t,e){for(var n=0;n<(t||[]).length;n++){var i=t[n];if(e.axis.dim===i.axisDim&&e.axis.model.componentIndex===i.axisIndex)return i}}(c,t);if(!d&&n&&(!c||o)){var a=o&&o.value;null!=a||l||(a=i.pointToData(r)),null!=a&&jC(t,a,g,!1,h)}})});var v={};return en(u,function(t,e){var n=t.linkGroup;n&&!p[e]&&en(n.axesInfo,function(e,i){var r=p[i];if(e!==t&&r){var o=r.value;n.mapper&&(o=t.axis.scale.parse(n.mapper(o,ZC(e),ZC(t)))),v[t.key]=o}})}),en(v,function(t,e){jC(u[e],t,g,!0,h)}),function(t,e,n){var i=n.axesInfo=[];en(e,function(e,n){var r=e.axisPointerModel.option,o=t[n];o?(!e.useHandle&&(r.status="show"),r.value=o.value,r.seriesDataIndices=(o.payloadBatch||[]).slice()):!e.useHandle&&(r.status="hide"),"show"===r.status&&i.push({axisDim:e.axis.dim,axisIndex:e.axis.model.componentIndex,value:r.value})})}(p,u,h),function(t,e,n,i){if(KC(e)||!t.list.length)return void i({type:"hideTip"});var r=((t.list[0].dataByAxis[0]||{}).seriesDataIndices||[])[0]||{};i({type:"showTip",escapeConnect:!0,x:e[0],y:e[1],tooltipOption:n.tooltipOption,position:n.position,dataIndexInside:r.dataIndexInside,dataIndex:r.dataIndex,seriesIndex:r.seriesIndex,dataByCoordSys:t.list})}(f,r,t,a),function(t,e,n){var i=n.getZr(),r="axisPointerLastHighlights",o=GC(i)[r]||{},a=GC(i)[r]={};en(t,function(t,e){var n=t.axisPointerModel.option;"show"===n.status&&t.triggerEmphasis&&en(n.seriesDataIndices,function(t){var e=t.seriesIndex+" | "+t.dataIndex;a[e]=t})});var s=[],l=[];en(o,function(t,e){!a[e]&&l.push(t)}),en(a,function(t,e){!o[e]&&s.push(t)}),l.length&&n.dispatchAction({type:"downplay",escapeConnect:!0,notBlur:!0,batch:l}),s.length&&n.dispatchAction({type:"highlight",escapeConnect:!0,notBlur:!0,batch:s})}(u,0,n),h}}function jC(t,e,n,i,r){var o=t.axis;if(!o.scale.isBlank()&&o.containData(e))if(t.involveSeries){var a=function(t,e){var n=e.axis,i=n.dim,r=t,o=[],a=Number.MAX_VALUE,s=-1;return en(e.seriesModels,function(e,l){var c,u,d=e.getData().mapDimensionsAll(i);if(e.getAxisTooltipData){var h=e.getAxisTooltipData(d,t,n);u=h.dataIndices,c=h.nestestValue}else{if(!(u=e.indicesOfNearest(i,d[0],t,"category"===n.type?.5:null)).length)return;c=e.getData().get(d[0],u[0])}if(null!=c&&isFinite(c)){var p=t-c,f=Math.abs(p);f<=a&&((f=0&&s<0)&&(a=f,s=p,r=c,o.length=0),en(u,function(t){o.push({seriesIndex:e.seriesIndex,dataIndexInside:t,dataIndex:e.getData().getRawIndex(t)})}))}}),{payloadBatch:o,snapToValue:r}}(e,t),s=a.payloadBatch,l=a.snapToValue;s[0]&&null==r.seriesIndex&&Ze(r,s[0]),!i&&t.snap&&o.containData(l)&&null!=l&&(e=l),n.showPointer(t,e,s),n.showTooltip(t,a,l)}else n.showPointer(t,e)}function XC(t,e,n,i){t[e.key]={value:n,payloadBatch:i}}function YC(t,e,n,i){var r=n.payloadBatch,o=e.axis,a=o.model,s=e.axisPointerModel;if(e.triggerTooltip&&r.length){var l=e.coordSys.model,c=lC(l),u=t.map[c];u||(u=t.map[c]={coordSysId:l.id,coordSysIndex:l.componentIndex,coordSysType:l.type,coordSysMainType:l.mainType,dataByAxis:[]},t.list.push(u)),u.dataByAxis.push({axisDim:o.dim,axisIndex:a.componentIndex,axisType:a.type,axisId:a.id,value:i,valueLabelOpt:{precision:s.get(["label","precision"]),formatter:s.get(["label","formatter"])},seriesDataIndices:r.slice()})}}function ZC(t){var e=t.axis.model,n={},i=n.axisDim=t.axis.dim;return n.axisIndex=n[i+"AxisIndex"]=e.componentIndex,n.axisName=n[i+"AxisName"]=e.name,n.axisId=n[i+"AxisId"]=e.id,n}function KC(t){return!t||null==t[0]||isNaN(t[0])||null==t[1]||isNaN(t[1])}function QC(t){uC.registerAxisPointerClass("CartesianAxisPointer",LC),t.registerComponentModel(NC),t.registerComponentView(WC),t.registerPreprocessor(function(t){if(t){(!t.axisPointer||0===t.axisPointer.length)&&(t.axisPointer={});var e=t.axisPointer.link;e&&!cn(e)&&(t.axisPointer.link=[e])}}),t.registerProcessor(t.PRIORITY.PROCESSOR.STATISTIC,function(t,e){t.getComponent("axisPointer").coordSysAxesInfo=rC(t,e)}),t.registerAction({type:"updateAxisPointer",event:"updateAxisPointer",update:":updateAxisPointer"},qC)}var JC=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return _(e,t),e.type="tooltip",e.dependencies=["axisPointer"],e.defaultOption={z:60,show:!0,showContent:!0,trigger:"item",triggerOn:"mousemove|click",alwaysShowContent:!1,renderMode:"auto",confine:null,showDelay:0,hideDelay:100,transitionDuration:.4,displayTransition:!0,enterable:!1,backgroundColor:Bf.color.neutral00,shadowBlur:10,shadowColor:"rgba(0, 0, 0, .2)",shadowOffsetX:1,shadowOffsetY:2,borderRadius:4,borderWidth:1,defaultBorderColor:Bf.color.border,padding:null,extraCssText:"",axisPointer:{type:"line",axis:"auto",animation:"auto",animationDurationUpdate:200,animationEasingUpdate:"exponentialOut",crossStyle:{color:Bf.color.borderShade,width:1,type:"dashed",textStyle:{}}},textStyle:{color:Bf.color.tertiary,fontSize:14}},e}(Hf);function tM(t){var e=t.get("confine");return null!=e?!!e:"richText"===t.get("renderMode")}function eM(t){if(Te.domSupported)for(var e=document.documentElement.style,n=0,i=t.length;n0&&r.push(function(t,e,n){var i="cubic-bezier(0.23,1,0.32,1)",r="",o="";return n&&(o="opacity"+(r=" "+t/2+"s "+i)+",visibility"+r),e||(r=" "+t+"s "+i,o+=(o.length?",":"")+(Te.transformSupported?""+oM+r:",left"+r+",top"+r)),rM+":"+o}(o,n,i)),a&&r.push("background-color:"+a),en(["width","color","radius"],function(e){var n="border-"+e,i=uf(n),o=t.get(i);null!=o&&r.push(n+":"+o+("color"===e?"":"px"))}),r.push(function(t){var e=[],n=t.get("fontSize"),i=t.getTextColor();i&&e.push("color:"+i),e.push("font:"+t.getFont());var r=xn(t.get("lineHeight"),Math.round(3*n/2));n&&e.push("line-height:"+r+"px");var o=t.get("textShadowColor"),a=t.get("textShadowBlur")||0,s=t.get("textShadowOffsetX")||0,l=t.get("textShadowOffsetY")||0;return o&&a&&e.push("text-shadow:"+s+"px "+l+"px "+a+"px "+o),en(["decoration","align"],function(n){var i=t.get(n);i&&e.push("text-"+n+":"+i)}),e.join(";")}(d)),null!=h&&r.push("padding:"+df(h).join("px ")+"px"),r.join(";")+";"}function cM(t,e,n,i,r){var o=e&&e.painter;if(n){var a=o&&o.getViewportRoot();a&&function(t,e,n,i,r){ri(ii,e,i,r,!0)&&ri(t,n,ii[0],ii[1])}(t,a,n,i,r)}else{t[0]=i,t[1]=r;var s=o&&o.getViewportRootOffset();s&&(t[0]+=s.offsetLeft,t[1]+=s.offsetTop)}t[2]=t[0]/e.getWidth(),t[3]=t[1]/e.getHeight()}var uM=function(){function t(t,e){if(this._show=!1,this._styleCoord=[0,0,0,0],this._enterable=!0,this._alwaysShowContent=!1,this._firstShow=!0,this._longHide=!0,Te.wxa)return null;var n=document.createElement("div");n.domBelongToZr=!0,this.el=n;var i=this._zr=t.getZr(),r=e.appendTo,o=r&&(dn(r)?document.querySelector(r):mn(r)?r:un(r)&&r(t.getDom()));cM(this._styleCoord,i,o,t.getWidth()/2,t.getHeight()/2),(o||t.getDom()).appendChild(n),this._api=t,this._container=o;var a=this;n.onmouseenter=function(){a._enterable&&(clearTimeout(a._hideTimeout),a._show=!0),a._inContent=!0},n.onmousemove=function(t){if(t=t||window.event,!a._enterable){var e=i.handler;gi(i.painter.getViewportRoot(),t,!0),e.dispatch("mousemove",t)}},n.onmouseleave=function(){a._inContent=!1,a._enterable&&a._show&&a.hideLater(a._hideDelay)}}return t.prototype.update=function(t){if(!this._container){var e=this._api.getDom(),n=(o="position",(a=(r=e).currentStyle||document.defaultView&&document.defaultView.getComputedStyle(r))?a[o]:null),i=e.style;"absolute"!==i.position&&"absolute"!==n&&(i.position="relative")}var r,o,a,s=t.get("alwaysShowContent");s&&this._moveIfResized(),this._alwaysShowContent=s,this._enableDisplayTransition=t.get("displayTransition")&&t.get("transitionDuration")>0,this.el.className=t.get("className")||""},t.prototype.show=function(t,e){clearTimeout(this._hideTimeout),clearTimeout(this._longHideTimeout);var n=this.el,i=n.style,r=this._styleCoord;n.innerHTML?i.cssText=aM+lM(t,!this._firstShow,this._longHide,this._enableDisplayTransition)+sM(r[0],r[1],!0)+"border-color:"+vf(e)+";"+(t.get("extraCssText")||"")+";pointer-events:"+(this._enterable?"auto":"none"):i.display="none",this._show=!0,this._firstShow=!1,this._longHide=!1},t.prototype.setContent=function(t,e,n,i,r){var o=this.el;if(null!=t){var a="";if(dn(r)&&"item"===n.get("trigger")&&!tM(n)&&(a=function(t,e,n){if(!dn(n)||"inside"===n)return"";var i=t.get("backgroundColor"),r=t.get("borderWidth");e=vf(e);var o,a,s="left"===(o=n)?"right":"right"===o?"left":"top"===o?"bottom":"top",l=Math.max(1.5*Math.round(r),6),c="",u=oM+":";Qe(["left","right"],s)>-1?(c+="top:50%",u+="translateY(-50%) rotate("+(a="left"===s?-225:-45)+"deg)"):(c+="left:50%",u+="translateX(-50%) rotate("+(a="top"===s?225:45)+"deg)");var d=a*Math.PI/180,h=l+r,p=h*Math.abs(Math.cos(d))+h*Math.abs(Math.sin(d)),f=e+" solid "+r+"px;";return'
'}(n,i,r)),dn(t))o.innerHTML=t+a;else if(t){o.innerHTML="",cn(t)||(t=[t]);for(var s=0;s=0?this._tryShow(n,i):"leave"===e&&this._hide(i))},this))},e.prototype._keepShow=function(){var t=this._tooltipModel,e=this._ecModel,n=this._api,i=t.get("triggerOn");if(null!=this._lastX&&null!=this._lastY&&"none"!==i&&"click"!==i){var r=this;clearTimeout(this._refreshUpdateTimeout),this._refreshUpdateTimeout=setTimeout(function(){!n.isDisposed()&&r.manuallyShowTip(t,e,n,{x:r._lastX,y:r._lastY,dataByCoordSys:r._lastDataByCoordSys})})}},e.prototype.manuallyShowTip=function(t,e,n,i){if(i.from!==this.uid&&!Te.node&&n.getDom()){var r=yM(i,n);this._ticket="";var o=i.dataByCoordSys,a=function(t,e,n){var i=Ns(t).queryOptionMap,r=i.keys()[0];if(!r||"series"===r)return;var o=Hs(e,r,i.get(r),{useDefault:!1,enableAll:!1,enableNone:!1}),a=o.models[0];if(!a)return;var s,l=n.getViewOfComponentModel(a);if(l.group.traverse(function(e){var n=nu(e).tooltipConfig;if(n&&n.name===t.name)return s=e,!0}),s)return{componentMainType:r,componentIndex:a.componentIndex,el:s}}(i,e,n);if(a){var s=a.el.getBoundingRect().clone();s.applyTransform(a.el.transform),this._tryShow({offsetX:s.x+s.width/2,offsetY:s.y+s.height/2,target:a.el,position:i.position,positionDefault:"bottom"},r)}else if(i.tooltip&&null!=i.x&&null!=i.y){var l=gM;l.x=i.x,l.y=i.y,l.update(),nu(l).tooltipConfig={name:null,option:i.tooltip},this._tryShow({offsetX:i.x,offsetY:i.y,target:l},r)}else if(o)this._tryShow({offsetX:i.x,offsetY:i.y,position:i.position,dataByCoordSys:o,tooltipOption:i.tooltipOption},r);else if(null!=i.seriesIndex){if(this._manuallyAxisShowTip(t,e,n,i))return;var c=UC(i,e),u=c.point[0],d=c.point[1];null!=u&&null!=d&&this._tryShow({offsetX:u,offsetY:d,target:c.el,position:i.position,positionDefault:"bottom"},r)}else null!=i.x&&null!=i.y&&(n.dispatchAction({type:"updateAxisPointer",x:i.x,y:i.y}),this._tryShow({offsetX:i.x,offsetY:i.y,position:i.position,target:n.getZr().findHover(i.x,i.y).target},r))}},e.prototype.manuallyHideTip=function(t,e,n,i){var r=this._tooltipContent;this._tooltipModel&&r.hideLater(this._tooltipModel.get("hideDelay")),this._lastX=this._lastY=this._lastDataByCoordSys=null,i.from!==this.uid&&this._hide(yM(i,n))},e.prototype._manuallyAxisShowTip=function(t,e,n,i){var r=i.seriesIndex,o=i.dataIndex,a=e.getComponent("axisPointer").coordSysAxesInfo;if(null!=r&&null!=o&&null!=a){var s=e.getSeriesByIndex(r);if(s)if("axis"===mM([s.getData().getItemModel(o),s,(s.coordinateSystem||{}).model],this._tooltipModel).get("trigger"))return n.dispatchAction({type:"updateAxisPointer",seriesIndex:r,dataIndex:o,position:i.position}),!0}},e.prototype._tryShow=function(t,e){var n=t.target;if(this._tooltipModel){this._lastX=t.offsetX,this._lastY=t.offsetY;var i=t.dataByCoordSys;if(i&&i.length)this._showAxisTooltip(i,t);else if(n){var r,o;if("legend"===nu(n).ssrType)return;this._lastDataByCoordSys=null,xy(n,function(t){if(t.tooltipDisabled)return r=o=null,!0;r||o||(null!=nu(t).dataIndex?r=t:null!=nu(t).tooltipConfig&&(o=t))},!0),r?this._showSeriesItemTooltip(t,r,e):o?this._showComponentItemTooltip(t,o,e):this._hide(e)}else this._lastDataByCoordSys=null,this._hide(e)}},e.prototype._showOrMove=function(t,e){var n=t.get("showDelay");e=sn(e,this),clearTimeout(this._showTimout),n>0?this._showTimout=setTimeout(e,n):e()},e.prototype._showAxisTooltip=function(t,e){var n=this._ecModel,i=this._tooltipModel,r=[e.offsetX,e.offsetY],o=mM([e.tooltipOption],i),a=this._renderMode,s=[],l=tm("section",{blocks:[],noHeader:!0}),c=[],u=new um;en(t,function(t){en(t.dataByAxis,function(t){var e=n.getComponent(t.axisDim+"Axis",t.axisIndex),r=t.value;if(e&&null!=r){var o=IC(r,e.axis,n,t.seriesDataIndices,t.valueLabelOpt),d=tm("section",{header:o,noHeader:!kn(o),sortBlocks:!0,blocks:[]});l.blocks.push(d),en(t.seriesDataIndices,function(l){var h=n.getSeriesByIndex(l.seriesIndex),p=l.dataIndexInside,f=h.getDataParams(p);if(!(f.dataIndex<0)){f.axisDim=t.axisDim,f.axisIndex=t.axisIndex,f.axisType=t.axisType,f.axisId=t.axisId,f.axisValue=Vx(e.axis,{value:r}),f.axisValueLabel=o,f.marker=u.makeTooltipMarker("item",vf(f.color),a);var g=xv(h.formatTooltip(p,!0,null)),v=g.frag;if(v){var m=mM([h],i).get("valueFormatter");d.blocks.push(m?Ze({valueFormatter:m},v):v)}g.text&&c.push(g.text),s.push(f)}})}})}),l.blocks.reverse(),c.reverse();var d=e.position,h=o.get("order"),p=am(l,u,a,h,n.get("useUTC"),o.get("textStyle"));p&&c.unshift(p);var f="richText"===a?"\n\n":"
",g=c.join(f);this._showOrMove(o,function(){this._updateContentNotChangedOnAxis(t,s)?this._updatePosition(o,d,r[0],r[1],this._tooltipContent,s):this._showTooltipContent(o,g,s,Math.random()+"",r[0],r[1],d,null,u)})},e.prototype._showSeriesItemTooltip=function(t,e,n){var i=this._ecModel,r=nu(e),o=r.seriesIndex,a=i.getSeriesByIndex(o),s=r.dataModel||a,l=r.dataIndex,c=r.dataType,u=s.getData(c),d=this._renderMode,h=t.positionDefault,p=mM([u.getItemModel(l),s,a&&(a.coordinateSystem||{}).model],this._tooltipModel,h?{position:h}:null),f=p.get("trigger");if(null==f||"item"===f){var g=s.getDataParams(l,c),v=new um;g.marker=v.makeTooltipMarker("item",vf(g.color),d);var m=xv(s.formatTooltip(l,!1,c)),y=p.get("order"),_=p.get("valueFormatter"),b=m.frag,x=b?am(_?Ze({valueFormatter:_},b):b,v,d,y,i.get("useUTC"),p.get("textStyle")):m.text,w="item_"+s.name+"_"+l;this._showOrMove(p,function(){this._showTooltipContent(p,x,g,w,t.offsetX,t.offsetY,t.position,t.target,v)}),n({type:"showTip",dataIndexInside:l,dataIndex:u.getRawIndex(l),seriesIndex:o,from:this.uid})}},e.prototype._showComponentItemTooltip=function(t,e,n){var i="html"===this._renderMode,r=nu(e),o=r.tooltipConfig.option||{},a=o.encodeHTMLContent;if(dn(o)){o={content:o,formatter:o},a=!0}a&&i&&o.content&&((o=Xe(o)).content=li(o.content));var s=[o],l=this._ecModel.getComponent(r.componentMainType,r.componentIndex);l&&s.push(l),s.push({formatter:o.content});var c=t.positionDefault,u=mM(s,this._tooltipModel,c?{position:c}:null),d=u.get("content"),h=Math.random()+"",p=new um;this._showOrMove(u,function(){var n=Xe(u.get("formatterParams")||{});this._showTooltipContent(u,d,n,h,t.offsetX,t.offsetY,t.position,e,p)}),n({type:"showTip",from:this.uid})},e.prototype._showTooltipContent=function(t,e,n,i,r,o,a,s,l){if(this._ticket="",t.get("showContent")&&t.get("show")){var c=this._tooltipContent;c.setEnterable(t.get("enterable"));var u=t.get("formatter");a=a||t.get("position");var d=e,h=this._getNearestPoint([r,o],n,t.get("trigger"),t.get("borderColor"),t.get("defaultBorderColor",!0)).color;if(u)if(dn(u)){var p=t.ecModel.get("useUTC"),f=cn(n)?n[0]:n;d=u,f&&f.axisType&&f.axisType.indexOf("time")>=0&&(d=Gp(f.axisValue,d,p)),d=gf(d,n,!0)}else if(un(u)){var g=sn(function(e,i){e===this._ticket&&(c.setContent(i,l,t,h,a),this._updatePosition(t,a,r,o,c,n,s))},this);this._ticket=i,d=u(n,i,g)}else d=u;c.setContent(d,l,t,h,a),c.show(t,h),this._updatePosition(t,a,r,o,c,n,s)}},e.prototype._getNearestPoint=function(t,e,n,i,r){return"axis"===n||cn(e)?{color:i||r}:cn(e)?void 0:{color:i||e.color||e.borderColor}},e.prototype._updatePosition=function(t,e,n,i,r,o,a){var s=this._api.getWidth(),l=this._api.getHeight();e=e||t.get("position");var c=r.getSize(),u=t.get("align"),d=t.get("verticalAlign"),h=a&&a.getBoundingRect().clone();if(a&&h.applyTransform(a.transform),un(e)&&(e=e([n,i],o,r.el,h,{viewSize:[s,l],contentSize:c.slice()})),cn(e))n=es(e[0],s),i=es(e[1],l);else if(fn(e)){var p=e;p.width=c[0],p.height=c[1];var f=Pf(p,{width:s,height:l});n=f.x,i=f.y,u=null,d=null}else if(dn(e)&&a){var g=function(t,e,n,i){var r=n[0],o=n[1],a=Math.ceil(Math.SQRT2*i)+8,s=0,l=0,c=e.width,u=e.height;switch(t){case"inside":s=e.x+c/2-r/2,l=e.y+u/2-o/2;break;case"top":s=e.x+c/2-r/2,l=e.y-o-a;break;case"bottom":s=e.x+c/2-r/2,l=e.y+u+a;break;case"left":s=e.x-r-a,l=e.y+u/2-o/2;break;case"right":s=e.x+c+a,l=e.y+u/2-o/2}return[s,l]}(e,h,c,t.get("borderWidth"));n=g[0],i=g[1]}else{g=function(t,e,n,i,r,o,a){var s=n.getSize(),l=s[0],c=s[1];null!=o&&(t+l+o+2>i?t-=l+o:t+=o);null!=a&&(e+c+a>r?e-=c+a:e+=a);return[t,e]}(n,i,r,s,l,u?null:20,d?null:20);n=g[0],i=g[1]}if(u&&(n-=_M(u)?c[0]/2:"right"===u?c[0]:0),d&&(i-=_M(d)?c[1]/2:"bottom"===d?c[1]:0),tM(t)){g=function(t,e,n,i,r){var o=n.getSize(),a=o[0],s=o[1];return t=Math.min(t+a,i)-a,e=Math.min(e+s,r)-s,t=Math.max(t,0),e=Math.max(e,0),[t,e]}(n,i,r,s,l);n=g[0],i=g[1]}r.moveTo(n,i)},e.prototype._updateContentNotChangedOnAxis=function(t,e){var n=this._lastDataByCoordSys,i=this._cbParamsList,r=!!n&&n.length===t.length;return r&&en(n,function(n,o){var a=n.dataByAxis||[],s=(t[o]||{}).dataByAxis||[];(r=r&&a.length===s.length)&&en(a,function(t,n){var o=s[n]||{},a=t.seriesDataIndices||[],l=o.seriesDataIndices||[];(r=r&&t.value===o.value&&t.axisType===o.axisType&&t.axisId===o.axisId&&a.length===l.length)&&en(a,function(t,e){var n=l[e];r=r&&t.seriesIndex===n.seriesIndex&&t.dataIndex===n.dataIndex}),i&&en(t.seriesDataIndices,function(t){var n=t.seriesIndex,o=e[n],a=i[n];o&&a&&a.data!==o.data&&(r=!1)})})}),this._lastDataByCoordSys=t,this._cbParamsList=e,!!r},e.prototype._hide=function(t){this._lastDataByCoordSys=null,t({type:"hideTip",from:this.uid})},e.prototype.dispose=function(t,e){!Te.node&&e.getDom()&&(Rm(this,"_updatePosition"),this._tooltipContent.dispose(),VC("itemTooltip",e))},e.type="tooltip",e}(wm);function mM(t,e,n){var i,r=e.ecModel;n?(i=new _p(n,r,r),i=new _p(e.option,i,r)):i=e;for(var o=t.length-1;o>=0;o--){var a=t[o];a&&(a instanceof _p&&(a=a.get("tooltip",!0)),dn(a)&&(a={formatter:a}),a&&(i=new _p(a,i,r)))}return i}function yM(t,e){return t.dispatchAction||sn(e.dispatchAction,e)}function _M(t){return"center"===t||"middle"===t}var bM=Math.sin,xM=Math.cos,wM=Math.PI,SM=2*Math.PI,CM=180/wM,MM=function(){function t(){}return t.prototype.reset=function(t){this._start=!0,this._d=[],this._str="",this._p=Math.pow(10,t||4)},t.prototype.moveTo=function(t,e){this._add("M",t,e)},t.prototype.lineTo=function(t,e){this._add("L",t,e)},t.prototype.bezierCurveTo=function(t,e,n,i,r,o){this._add("C",t,e,n,i,r,o)},t.prototype.quadraticCurveTo=function(t,e,n,i){this._add("Q",t,e,n,i)},t.prototype.arc=function(t,e,n,i,r,o){this.ellipse(t,e,n,n,0,i,r,o)},t.prototype.ellipse=function(t,e,n,i,r,o,a,s){var l=a-o,c=!s,u=Math.abs(l),d=ho(u-SM)||(c?l>=SM:-l>=SM),h=l>0?l%SM:l%SM+SM,p=!1;p=!!d||!ho(u)&&h>=wM==!!c;var f=t+n*xM(o),g=e+i*bM(o);this._start&&this._add("M",f,g);var v=Math.round(r*CM);if(d){var m=1/this._p,y=(c?1:-1)*(SM-m);this._add("A",n,i,v,1,+c,t+n*xM(o+y),e+i*bM(o+y)),m>.01&&this._add("A",n,i,v,0,+c,f,g)}else{var _=t+n*xM(a),b=e+i*bM(a);this._add("A",n,i,v,+p,+c,_,b)}},t.prototype.rect=function(t,e,n,i){this._add("M",t,e),this._add("l",n,0),this._add("l",0,i),this._add("l",-n,0),this._add("Z")},t.prototype.closePath=function(){this._d.length>0&&this._add("Z")},t.prototype._add=function(t,e,n,i,r,o,a,s,l){for(var c=[],u=this._p,d=1;d"}(r,o)+("style"!==r?li(a):a||"")+(i?""+n+nn(i,function(e){return t(e)}).join(n)+n:"")+("")}(t)}function RM(t){return{zrId:t,shadowCache:{},patternCache:{},gradientCache:{},clipPathCache:{},defs:{},cssNodes:{},cssAnims:{},cssStyleCache:{},cssAnimIdx:0,shadowIdx:0,gradientIdx:0,patternIdx:0,clipPathIdx:0}}function HM(t,e,n,i){return zM("svg","root",{width:t,height:e,xmlns:PM,"xmlns:xlink":LM,version:"1.1",baseProfile:"full",viewBox:!!i&&"0 0 "+t+" "+e},n)}var BM=0;function FM(){return BM++}var $M={cubicIn:"0.32,0,0.67,0",cubicOut:"0.33,1,0.68,1",cubicInOut:"0.65,0,0.35,1",quadraticIn:"0.11,0,0.5,0",quadraticOut:"0.5,1,0.89,1",quadraticInOut:"0.45,0,0.55,1",quarticIn:"0.5,0,0.75,0",quarticOut:"0.25,1,0.5,1",quarticInOut:"0.76,0,0.24,1",quinticIn:"0.64,0,0.78,0",quinticOut:"0.22,1,0.36,1",quinticInOut:"0.83,0,0.17,1",sinusoidalIn:"0.12,0,0.39,0",sinusoidalOut:"0.61,1,0.88,1",sinusoidalInOut:"0.37,0,0.63,1",exponentialIn:"0.7,0,0.84,0",exponentialOut:"0.16,1,0.3,1",exponentialInOut:"0.87,0,0.13,1",circularIn:"0.55,0,1,0.45",circularOut:"0,0.55,0.45,1",circularInOut:"0.85,0,0.15,1"},VM="transform-origin";function WM(t,e,n){var i=Ze({},t.shape);Ze(i,e),t.buildPath(n,i);var r=new MM;return r.reset(wo(t)),n.rebuildPath(r,1),r.generateStr(),r.getStr()}function UM(t,e){var n=e.originX,i=e.originY;(n||i)&&(t[VM]=n+"px "+i+"px")}var GM={fill:"fill",opacity:"opacity",lineWidth:"stroke-width",lineDashOffset:"stroke-dashoffset"};function qM(t,e){var n=e.zrId+"-ani-"+e.cssAnimIdx++;return e.cssAnims[n]=t,n}function jM(t){return dn(t)?$M[t]?"cubic-bezier("+$M[t]+")":Rr(t)?t:"":""}function XM(t,e,n,i){var r=t.animators,o=r.length,a=[];if(t instanceof Yd){var s=function(t,e,n){var i,r,o=t.shape.paths,a={};if(en(o,function(t){var e=RM(n.zrId);e.animation=!0,XM(t,{},e,!0);var o=e.cssAnims,s=e.cssNodes,l=an(o),c=l.length;if(c){var u=o[r=l[c-1]];for(var d in u){var h=u[d];a[d]=a[d]||{d:""},a[d].d+=h.d||""}for(var p in s){var f=s[p].animation;f.indexOf(r)>=0&&(i=f)}}}),i){e.d=!1;var s=qM(a,n);return i.replace(r,s)}}(t,e,n);if(s)a.push(s);else if(!o)return}else if(!o)return;for(var l={},c=0;c0}).length)return qM(u,n)+" "+r[0]+" both"}for(var v in l){(s=g(l[v]))&&a.push(s)}if(a.length){var m=n.zrId+"-cls-"+FM();n.cssNodes["."+m]={animation:a.join(",")},e.class=m}}function YM(t,e,n,i){var r=JSON.stringify(t),o=n.cssStyleCache[r];o||(o=n.zrId+"-cls-"+FM(),n.cssStyleCache[r]=o,n.cssNodes["."+o+":hover"]=t),e.class=e.class?e.class+" "+o:o}var ZM=Math.round;function KM(t){return t&&dn(t.src)}function QM(t){return t&&un(t.toDataURL)}function JM(t,e,n,i){IM(function(r,o){var a="fill"===r||"stroke"===r;a&&bo(o)?uk(e,t,r,i):a&&mo(o)?dk(n,t,r,i):t[r]=o,a&&i.ssr&&"none"===o&&(t["pointer-events"]="visible")},e,n,!1),function(t,e,n){var i=t.style;if(function(t){return t&&(t.shadowBlur||t.shadowOffsetX||t.shadowOffsetY)}(i)){var r=function(t){var e=t.style,n=t.getGlobalScale();return[e.shadowColor,(e.shadowBlur||0).toFixed(2),(e.shadowOffsetX||0).toFixed(2),(e.shadowOffsetY||0).toFixed(2),n[0],n[1]].join(",")}(t),o=n.shadowCache,a=o[r];if(!a){var s=t.getGlobalScale(),l=s[0],c=s[1];if(!l||!c)return;var u=i.shadowOffsetX||0,d=i.shadowOffsetY||0,h=i.shadowBlur,p=co(i.shadowColor),f=p.opacity,g=p.color,v=h/2/l+" "+h/2/c;a=n.zrId+"-s"+n.shadowIdx++,n.defs[a]=zM("filter",a,{id:a,x:"-100%",y:"-100%",width:"300%",height:"300%"},[zM("feDropShadow","",{dx:u/l,dy:d/c,stdDeviation:v,"flood-color":g,"flood-opacity":f})]),o[r]=a}e.filter=xo(a)}}(n,t,i)}function tk(t,e){var n=function(t){if("function"==typeof ja)return ja(t)}(e);n&&(n.each(function(e,n){null!=e&&(t[(EM+n).toLowerCase()]=e+"")}),e.isSilent()&&(t[EM+"silent"]="true"))}function ek(t){return ho(t[0]-1)&&ho(t[1])&&ho(t[2])&&ho(t[3]-1)}function nk(t,e,n){if(e&&(!function(t){return ho(t[4])&&ho(t[5])}(e)||!ek(e))){var i=1e4;t.transform=ek(e)?"translate("+ZM(e[4]*i)/i+" "+ZM(e[5]*i)/i+")":function(t){return"matrix("+po(t[0])+","+po(t[1])+","+po(t[2])+","+po(t[3])+","+fo(t[4])+","+fo(t[5])+")"}(e)}}function ik(t,e,n){for(var i=t.points,r=[],o=0;o=0&&a||o;s&&(r=so(s))}var l=i.lineWidth;l&&(l/=!i.strokeNoScale&&t.transform?t.transform[0]:1);var c={cursor:"pointer"};r&&(c.fill=r),i.stroke&&(c.stroke=i.stroke),l&&(c["stroke-width"]=l),YM(c,e,n)}}(t,o,e),zM(s,t.id+"",o)}function ck(t,e){return t instanceof Tc?lk(t,e):t instanceof Lc?function(t,e){var n=t.style,i=n.image;if(i&&!dn(i)&&(KM(i)?i=i.src:QM(i)&&(i=i.toDataURL())),i){var r=n.x||0,o=n.y||0,a={href:i,width:n.width,height:n.height};return r&&(a.x=r),o&&(a.y=o),nk(a,t.transform),JM(a,n,t,e),tk(a,t),e.animation&&XM(t,a,e),zM("image",t.id+"",a)}}(t,e):t instanceof Dc?function(t,e){var n=t.style,i=n.text;if(null!=i&&(i+=""),i&&!isNaN(n.x)&&!isNaN(n.y)){var r=n.font||De,o=n.x||0,a=function(t,e,n){return"top"===n?t+=e/2:"bottom"===n&&(t-=e/2),t}(n.y||0,Ia(r),n.textBaseline),s={"dominant-baseline":"central","text-anchor":go[n.textAlign]||n.textAlign};if(Yc(n)){var l="",c=n.fontStyle,u=jc(n.fontSize);if(!parseFloat(u))return;var d=n.fontFamily||Ae,h=n.fontWeight;l+="font-size:"+u+";font-family:"+d+";",c&&"normal"!==c&&(l+="font-style:"+c+";"),h&&"normal"!==h&&(l+="font-weight:"+h+";"),s.style=l}else s.style="font: "+r;return i.match(/\s/)&&(s["xml:space"]="preserve"),o&&(s.x=o),a&&(s.y=a),nk(s,t.transform),JM(s,n,t,e),tk(s,t),e.animation&&XM(t,s,e),zM("text",t.id+"",s,void 0,i)}}(t,e):void 0}function uk(t,e,n,i){var r,o=t[n],a={gradientUnits:o.global?"userSpaceOnUse":"objectBoundingBox"};if(yo(o))r="linearGradient",a.x1=o.x,a.y1=o.y,a.x2=o.x2,a.y2=o.y2;else{if(!_o(o))return;r="radialGradient",a.cx=xn(o.x,.5),a.cy=xn(o.y,.5),a.r=xn(o.r,.5)}for(var s=o.colorStops,l=[],c=0,u=s.length;cl?kk(t,null==n[d+1]?null:n[d+1].elm,n,s,d):Tk(t,e,a,l))}(n,i,r):wk(r)?(wk(t.text)&&_k(n,""),kk(n,null,r,0,r.length-1)):wk(i)?Tk(n,i,0,i.length-1):wk(t.text)&&_k(n,""):t.text!==e.text&&(wk(i)&&Tk(n,i,0,i.length-1),_k(n,e.text)))}var Ik=0,Pk=function(){function t(t,e,n){if(this.type="svg",this.refreshHover=function(){},this.configLayer=function(){},this.storage=e,this._opts=n=Ze({},n),this.root=t,this._id="zr"+Ik++,this._oldVNode=HM(n.width,n.height),t&&!n.ssr){var i=this._viewport=document.createElement("div");i.style.cssText="position:relative;overflow:hidden";var r=this._svgDom=this._oldVNode.elm=OM("svg");Ak(null,this._oldVNode),i.appendChild(r),t.appendChild(i)}this.resize(n.width,n.height)}return t.prototype.getType=function(){return this.type},t.prototype.getViewportRoot=function(){return this._viewport},t.prototype.getViewportRootOffset=function(){var t=this.getViewportRoot();if(t)return{offsetLeft:t.offsetLeft||0,offsetTop:t.offsetTop||0}},t.prototype.getSvgDom=function(){return this._svgDom},t.prototype.refresh=function(){if(this.root){var t=this.renderToVNode({willUpdate:!0});t.attrs.style="position:absolute;left:0;top:0;user-select:none",function(t,e){if(Ck(t,e))Dk(t,e);else{var n=t.elm,i=mk(n);Mk(e),null!==i&&(fk(i,e.elm,yk(n)),Tk(i,[t],0,0))}}(this._oldVNode,t),this._oldVNode=t}},t.prototype.renderOneToVNode=function(t){return ck(t,RM(this._id))},t.prototype.renderToVNode=function(t){t=t||{};var e=this.storage.getDisplayList(!0),n=this._width,i=this._height,r=RM(this._id);r.animation=t.animation,r.willUpdate=t.willUpdate,r.compress=t.compress,r.emphasis=t.emphasis,r.ssr=this._opts.ssr;var o=[],a=this._bgVNode=function(t,e,n,i){var r;if(n&&"none"!==n)if(r=zM("rect","bg",{width:t,height:e,x:"0",y:"0"}),bo(n))uk({fill:n},r.attrs,"fill",i);else if(mo(n))dk({style:{fill:n},dirty:Nn,getBoundingRect:function(){return{width:t,height:e}}},r.attrs,"fill",i);else{var o=co(n),a=o.color,s=o.opacity;r.attrs.fill=a,s<1&&(r.attrs["fill-opacity"]=s)}return r}(n,i,this._backgroundColor,r);a&&o.push(a);var s=t.compress?null:this._mainVNode=zM("g","main",{},[]);this._paintList(e,r,s?s.children:o),s&&o.push(s);var l=nn(an(r.defs),function(t){return r.defs[t]});if(l.length&&o.push(zM("defs","defs",{},l)),t.animation){var c=function(t,e,n){var i=(n=n||{}).newline?"\n":"",r=" {"+i,o=i+"}",a=nn(an(t),function(e){return e+r+nn(an(t[e]),function(n){return n+":"+t[e][n]+";"}).join(i)+o}).join(i),s=nn(an(e),function(t){return"@keyframes "+t+r+nn(an(e[t]),function(n){return n+r+nn(an(e[t][n]),function(i){var r=e[t][n][i];return"d"===i&&(r='path("'+r+'")'),i+":"+r+";"}).join(i)+o}).join(i)+o}).join(i);return a||s?[""].join(i):""}(r.cssNodes,r.cssAnims,{newline:!0});if(c){var u=zM("style","stl",{},[],c);o.push(u)}}return HM(n,i,o,t.useViewBox)},t.prototype.renderToString=function(t){return t=t||{},NM(this.renderToVNode({animation:xn(t.cssAnimation,!0),emphasis:xn(t.cssEmphasis,!0),willUpdate:!1,compress:!0,useViewBox:xn(t.useViewBox,!0)}),{newline:!0})},t.prototype.setBackgroundColor=function(t){this._backgroundColor=t},t.prototype.getSvgRoot=function(){return this._mainVNode&&this._mainVNode.elm},t.prototype._paintList=function(t,e,n){for(var i,r,o=t.length,a=[],s=0,l=0,c=0;c=0&&(!d||!r||d[f]!==r[f]);f--);for(var g=p-1;g>f;g--)i=a[--s-1];for(var v=f+1;v10&&"cartesian2d"===o.type&&r){var s=o.getBaseAxis(),l=o.getOtherAxis(s),c=s.getExtent(),u=n.getDevicePixelRatio(),d=Math.abs(c[1]-c[0])*(u||1),h=Math.round(a/d);if(isFinite(h)&&h>1){"lttb"===r?t.setData(i.lttbDownSample(i.mapDimension(l.dim),1/h)):"minmax"===r&&t.setData(i.minmaxDownSample(i.mapDimension(l.dim),1/h));var p=void 0;dn(r)?p=rS[r]:un(r)&&(p=r),p&&t.setData(i.downSample(i.mapDimension(l.dim),1/h,p,oS))}}}}}("line"))},function(t){Qx(_C),Qx(QC)},function(t){Qx(QC),t.registerComponentModel(JC),t.registerComponentView(vM),t.registerAction({type:"showTip",event:"showTip",update:"tooltip:manuallyShowTip"},Nn),t.registerAction({type:"hideTip",event:"hideTip",update:"tooltip:manuallyHideTip"},Nn)},function(t){t.registerPainter("svg",Pk)}]);class Lk extends It{constructor(){super(...arguments),this.options=null,this.data=[],this.height="120px",this._chart=null,this._resizeObserver=null}render(){return dt`
`}connectedCallback(){super.connectedCallback(),this.hasUpdated&&!this._chart&&this.options&&(this._mount(),this._observeResize())}firstUpdated(){this._mount(),this._observeResize()}updated(t){(t.has("options")||t.has("data"))&&(this._chart&&this.options?this._chart.setOption(this._mergedOptions(),!0):!this._chart&&this.options&&this._mount()),t.has("height")&&this._chart&&this._chart.resize()}disconnectedCallback(){this._destroy(),super.disconnectedCallback()}_mount(){if(!this.options)return;const t=this.shadowRoot?.querySelector(".chart-host");t&&(this._chart=rb(t,void 0,{renderer:"svg"}),this._chart.setOption(this._mergedOptions(),!0))}_mergedOptions(){if(!this.options)throw new Error("span-chart: options not set");return{...this.options,series:this.data}}_observeResize(){if("undefined"==typeof ResizeObserver)return;this._resizeObserver=new ResizeObserver(()=>{this._chart&&this._chart.resize()});const t=this.shadowRoot?.querySelector(".chart-host");t&&this._resizeObserver.observe(t)}_destroy(){this._resizeObserver&&(this._resizeObserver.disconnect(),this._resizeObserver=null),this._chart&&(this._chart.dispose(),this._chart=null)}}Lk.styles=T` :host { display: block; width: 100%; @@ -55,11 +55,11 @@ var Ga={},qa={};var ja,Xa=function(){function t(t,e,n){var i=this;this._sleepAft width: 100%; height: 100%; } - `,x([zt({attribute:!1})],Lk.prototype,"options",void 0),x([zt({attribute:!1})],Lk.prototype,"data",void 0),x([zt({type:String})],Lk.prototype,"height",void 0);try{customElements.get("span-chart")||customElements.define("span-chart",Lk)}catch{}function Ek(t,e,n,i,r,a,s,l,c){const{options:u,series:h}=function(t,e,n,i,r,a=!1){n||(n=g[o]);const s=i?"140, 160, 220":"77, 217, 175",l=`rgb(${s})`,c=Date.now(),u=c-e,h=void 0!==n.fixedMin&&void 0!==n.fixedMax,d=(t??[]).filter(t=>t.time>=u).map(t=>[t.time,Math.abs(t.value)]),p=[{type:"line",data:d,showSymbol:!1,smooth:!1,...a?{}:{step:"end"},lineStyle:{width:1.5,color:l},areaStyle:{color:{type:"linear",x:0,y:0,x2:0,y2:1,colorStops:[{offset:0,color:`rgba(${s}, 0.18)`},{offset:1,color:`rgba(${s}, 0.18)`}]}},itemStyle:{color:l}}],f=d.length>0?function(t){let e=0;for(const n of t)n[1]>e&&(e=n[1]);return e}(d):0,v={type:"value",splitNumber:4,axisLabel:{fontSize:10,formatter:f<10?t=>0===t?"0":t.toFixed(1):t=>n.format(t)},splitLine:{lineStyle:{opacity:.15}}};h?(v.min=n.fixedMin,v.max=n.fixedMax):f<1&&(v.min=0,v.max=1),r&&"current"===n.entityRole&&(v.min=0,v.max=Math.ceil(1.25*r),p.push({type:"line",data:[[u,.8*r],[c,.8*r]],showSymbol:!1,lineStyle:{width:1,color:"rgba(255, 200, 40, 0.6)",type:"dashed"},itemStyle:{color:"transparent"},tooltip:{show:!1}}),p.push({type:"line",data:[[u,r],[c,r]],showSymbol:!1,lineStyle:{width:1.5,color:"rgba(255, 60, 60, 0.7)",type:"solid"},itemStyle:{color:"transparent"},tooltip:{show:!1}}));const m={xAxis:{type:"time",min:u,max:c,axisLabel:{fontSize:10},splitLine:{show:!1}},yAxis:v,grid:{top:8,right:4,bottom:0,left:0,containLabel:!0},tooltip:{trigger:"axis",axisPointer:{type:"line",lineStyle:{type:"dashed"}},formatter:t=>{if(!t||0===t.length)return"";const e=t[0],i=new Date(e.value[0]).toLocaleString(void 0,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit"}),r=parseFloat(e.value[1].toFixed(2));return`
${i}
${n.format(r)} ${n.unit(r)}
`}},animation:!1};return{options:m,series:p}}(n,i,r,a,l,c),d=s??120;t.style.minHeight=d+"px";let p=t.querySelector("span-chart");p||(p=document.createElement("span-chart"),p.style.display="block",p.style.width="100%",t.innerHTML="",t.appendChild(p));const f=t.clientHeight;p.height=(f>0?f:d)+"px",p.options=u,p.data=h}function zk(t){return"function"==typeof globalThis.CSS?.escape?CSS.escape(t):t.replace(/["\\]/g,"\\$&")}function Ok(t,e,n,i,r){const o=t.querySelector(".panel-stats");o&&function(t,e,n,i,r){const o="current"===(i.chart_metric||"power"),a=t.querySelector(".stat-consumption .stat-value"),s=t.querySelector(".stat-consumption .stat-unit");if(o){const t=n.panel_entities?.site_power,i=t?e.states[t]:null,r=i?parseFloat(i.attributes?.amperage):NaN;a&&(a.textContent=Number.isFinite(r)?Math.abs(r).toFixed(1):"--"),s&&(s.textContent="A")}else{let t=r;const i=n.panel_entities?.site_power;if(i){const n=e.states[i];n&&(t=Math.abs(parseFloat(n.state)||0))}a&&(a.textContent=Gt(t)),s&&(s.textContent="kW")}const l=t.querySelector(".stat-upstream .stat-value"),c=t.querySelector(".stat-upstream .stat-unit");if(l){const t=n.panel_entities?.current_power,i=t?e.states[t]:null;if(o){const t=i?parseFloat(i.attributes?.amperage):NaN;l.textContent=Number.isFinite(t)?Math.abs(t).toFixed(1):"--",c&&(c.textContent="A")}else{const t=i?Math.abs(parseFloat(i.state)||0):0;l.textContent=Gt(t),c&&(c.textContent="kW")}}const u=t.querySelector(".stat-downstream .stat-value"),h=t.querySelector(".stat-downstream .stat-unit");if(u){const t=n.panel_entities?.feedthrough_power,i=t?e.states[t]:null;if(o){const t=i?parseFloat(i.attributes?.amperage):NaN;u.textContent=Number.isFinite(t)?Math.abs(t).toFixed(1):"--",h&&(h.textContent="A")}else{const t=i?Math.abs(parseFloat(i.state)||0):0;u.textContent=Gt(t),h&&(h.textContent="kW")}}const d=t.querySelector(".stat-solar .stat-value"),p=t.querySelector(".stat-solar .stat-unit");if(d){const t=n.panel_entities?.pv_power,i=t?e.states[t]:null;if(o){const t=i?parseFloat(i.attributes?.amperage):NaN;d.textContent=Number.isFinite(t)?Math.abs(t).toFixed(1):"--",p&&(p.textContent="A")}else{if(i){const t=Math.abs(parseFloat(i.state)||0);d.textContent=Gt(t)}else d.textContent="--";p&&(p.textContent="kW")}}const f=t.querySelector(".stat-battery .stat-value");if(f){const t=n.panel_entities?.battery_level,i=t?e.states[t]:null;i&&(f.textContent=`${Math.round(parseFloat(i.state)||0)}`)}const g=t.querySelector(".stat-grid-state .stat-value");if(g){const t=n.panel_entities?.dsm_state,i=t?e.states[t]:null;g.textContent=i?e.formatEntityState?.(i)||i.state:"--"}}(o,e,n,i,r)}class Nk{get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t,this._retry=t?new Qt(t):null}constructor(){this._errorStore=null,this._retry=null,this._settings=null,this._lastFetch=0,this._fetching=!1}async fetch(t,e){const n=Date.now();if(this._fetching)return this._settings;if(this._settings&&n-this._lastFetch<3e4)return this._settings;this._fetching=!0;try{const n={};e&&(n.config_entry_id=e);const r={type:"call_service",domain:l,service:"get_graph_settings",service_data:n,return_response:!0},o=this._retry?await this._retry.callWS(t,r,{errorId:"fetch:graph_settings",errorMessage:i("error.graph_settings_failed")}):await t.callWS(r);this._settings=o?.response??null,this._lastFetch=Date.now()}catch(t){console.warn("SPAN Panel: graph settings fetch failed",t),this._settings=null,this._retry||this._errorStore?.add({key:"fetch:graph_settings",level:"warning",message:i("error.graph_settings_failed"),persistent:!1})}finally{this._fetching=!1}return this._settings}invalidate(){this._lastFetch=0}get settings(){return this._settings}clear(){this._settings=null,this._lastFetch=0}}function Rk(t,e){if(!t)return a;const n=t.circuits?.[e];return n?.has_override?n.horizon:t.global_horizon??a}function Hk(t,e){if(!t)return a;const n=t.sub_devices?.[e];return n?.has_override?n.horizon:t.global_horizon??a}class Bk{constructor(){this.powerHistory=new Map,this.horizonMap=new Map,this.subDeviceHorizonMap=new Map,this.monitoringCache=new Jt,this.monitoringMultiCache=new te,this.graphSettingsCache=new Nk,this._errorStore=null,this._hass=null,this._topology=null,this._config=null,this._configEntryId=null,this._favRefs=null,this._perPanelInfo=new Map,this._panelFavorites=null,this._showMonitoring=!1,this._updateInterval=null,this._recorderRefreshInterval=null,this._resizeObserver=null,this._lastWidth=0,this._resizeDebounce=null}get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t,this.monitoringCache.errorStore=t,this.graphSettingsCache.errorStore=t,this.monitoringMultiCache.errorStore=t}get hass(){return this._hass}set hass(t){this._hass=t}get topology(){return this._topology}get config(){return this._config}set showMonitoring(t){this._showMonitoring=t}init(t,e,n,i){this._topology=t,this._config=e,this._hass=n,this._configEntryId=i}setFavoriteRefs(t){this._favRefs=t}clearFavoriteRefs(){this._favRefs=null}setPanelFavorites(t){this._panelFavorites=t}setFavoritesPerPanelInfo(t){this._perPanelInfo=t??new Map}get _inFavoritesView(){return null!==this._favRefs}setConfig(t){this._config=t}buildHorizonMaps(t){if(this.horizonMap.clear(),this.subDeviceHorizonMap.clear(),t&&this._topology?.circuits)for(const e of Object.keys(this._topology.circuits))this.horizonMap.set(e,Rk(t,e));if(t&&this._topology?.sub_devices)for(const e of Object.keys(this._topology.sub_devices))this.subDeviceHorizonMap.set(e,Hk(t,e))}async fetchAndBuildHorizonMaps(){try{this._favRefs?await this._buildFavoritesHorizonMaps():(await this.graphSettingsCache.fetch(this._hass,this._configEntryId),this.buildHorizonMaps(this.graphSettingsCache.settings))}catch(t){console.warn("SPAN Panel: graph settings fetch failed",t),this.graphSettingsCache.errorStore||this._errorStore?.add({key:"fetch:graph_settings",level:"warning",message:i("error.graph_settings_failed"),persistent:!1})}}async fetchMergedMonitoringStatus(t){if(!this._hass||0===t.length)return null;const e=this._hass;return function(t){let e=!1;const n={},i={};for(const r of t)r&&(e=!0,r.circuits&&Object.assign(n,r.circuits),r.mains&&Object.assign(i,r.mains));return e?{circuits:n,mains:i}:null}(await Promise.all(t.map(t=>this.monitoringMultiCache.fetchOne(e,t))))}async _buildFavoritesHorizonMaps(){if(!this._hass||!this._favRefs||!this._topology)return;const t=new Set;for(const e of Object.values(this._favRefs))e.configEntryId&&t.add(e.configEntryId);const e=new Map;await Promise.all(Array.from(t).map(async t=>{e.set(t,await this._fetchGraphSettingsFresh(t))})),this.horizonMap.clear(),this.subDeviceHorizonMap.clear();for(const t of Object.keys(this._topology.circuits)){const n=this._favRefs[t],i=n?.configEntryId?e.get(n.configEntryId)??null:null,r=n?.targetId??t;this.horizonMap.set(t,Rk(i,r))}if(this._topology.sub_devices)for(const t of Object.keys(this._topology.sub_devices)){const n=this._favRefs[t],i=n?.configEntryId?e.get(n.configEntryId)??null:null,r=n?.targetId??t;this.subDeviceHorizonMap.set(t,Hk(i,r))}}async loadHistory(){await Me(this._hass,this._topology,this._config,this.powerHistory,this.horizonMap,this.subDeviceHorizonMap)}recordSamples(){if(!this._topology||!this._hass||!this._config)return;const t=Date.now();for(const[e,n]of Object.entries(this._topology.circuits)){const i=this.horizonMap.get(e)??a;if(!s[i]?.useRealtime)continue;const r=Zt(n,this._config);if(!r)continue;const o=this._hass.states[r];if(!o)continue;const l=parseFloat(o.state);if(isNaN(l))continue;const c=me(i),u=ye(c),h=_e(c),d=t-c,p=this.powerHistory.get(e)??[];p.length>0&&t-p[p.length-1].time0&&t-p[p.length-1].time0&&this._topology)for(const{key:t,devId:e}of Ce(this._topology))n.has(e)&&r.add(t);const o=new Map;try{await Me(this._hass,this._topology,this._config,o,e,n);for(const t of e.keys()){const e=o.get(t);e?this.powerHistory.set(t,e):this.powerHistory.delete(t)}for(const t of r){const e=o.get(t);e?this.powerHistory.set(t,e):this.powerHistory.delete(t)}this.updateDOM(t)}catch(t){console.warn("SPAN Panel: history refresh failed",t),this._errorStore?.add({key:"fetch:history",level:"warning",message:i("error.history_failed"),persistent:!1})}}updateDOM(t){this._hass&&this._topology&&this._config&&(function(t,e,n,r,o,a){if(!t||!n||!e)return;const s=ve(r);let l=0;for(const[,t]of Object.entries(n.circuits)){const n=t.entities?.power;if(!n)continue;const i=e.states[n],r=i&&parseFloat(i.state)||0;t.device_type!==u&&(l+=Math.abs(r))}Ok(t,e,n,r,l);const h=Yt(r),d="current"===h.entityRole;for(const[r,l]of Object.entries(n.circuits)){const n=t.querySelector(`.circuit-slot[data-uuid="${zk(r)}"]`);if(!n)continue;const p=l.entities?.power,f=p?e.states[p]:null,g=f&&parseFloat(f.state)||0,v=l.device_type===u||g<0,y=l.entities?.switch,_=y?e.states[y]:null,x=_?"on"===_.state:(f?.attributes?.relay_state||l.relay_state)===c,b=n.querySelector(".power-value");if(b)if(d){const t=l.entities?.current,n=t?e.states[t]:null,i=n&&parseFloat(n.state)||0;b.innerHTML=`${h.format(i)}A`}else b.innerHTML=`${Ut(g)}${Wt(g)}`;const w=n.querySelector(".toggle-pill");if(w){w.className="toggle-pill "+(x?"toggle-on":"toggle-off");const t=w.querySelector(".toggle-label");t&&(t.textContent=i(x?"grid.on":"grid.off"))}let S;if(n.classList.toggle("circuit-off",!x),n.classList.toggle("circuit-producer",v),l.always_on)S="always_on";else{const t=l.entities?.select,n=t?e.states[t]:null;S=n?n.state:"unknown"}const C=m[S]??m.unknown,M=n.querySelector(".shedding-icon");M&&(M.setAttribute("icon",C.icon),M.style.color=C.color,M.title=C.label());const k=n.querySelector(".shedding-icon-secondary");k&&(C.icon2?(k.setAttribute("icon",C.icon2),k.style.color=C.color,k.style.display=""):k.style.display="none");const T=n.querySelector(".shedding-label");T&&(C.textLabel?(T.textContent=C.textLabel,T.style.color=C.color,T.style.display=""):T.style.display="none");const D=n.querySelector(".chart-container");if(D){const t=o.get(r)||[],e=n.classList.contains("circuit-col-span")?200:100,i=a?.has(r)?me(a.get(r)):s,c=l.device_type===u;Ek(D,0,t,i,h,v,e,l.breaker_rating_a??void 0,c)}}}(t,this._hass,this._topology,this._config,this.powerHistory,this.horizonMap),function(t,e,n,i,r,o){if(!n.sub_devices)return;const a=ve(i);for(const[i,s]of Object.entries(n.sub_devices)){const n=t.querySelector(`[data-subdev="${zk(i)}"]`);if(!n)continue;const l=ue(s);if(l){const t=e.states[l],i=t&&parseFloat(t.state)||0,r=n.querySelector(".sub-power-value");r&&(r.innerHTML=`${Ut(i)} ${Wt(i)}`)}const c=n.querySelectorAll("[data-chart-key]");for(const t of c){const e=t.dataset.chartKey;if(!e)continue;const n=r.get(e)||[];let s=v.power;e.endsWith("_soc")?s=v.soc:e.endsWith("_soe")&&(s=v.soe);const l=!!t.closest(".bess-chart-col");Ek(t,0,n,o?.has(i)?me(o.get(i)):a,s,!1,l?120:150,void 0,e.endsWith("_soc")||e.endsWith("_soe"))}for(const t of Object.keys(s.entities||{})){const i=n.querySelector(`[data-eid="${zk(t)}"]`);if(!i)continue;const r=e.states[t];if(r){let t;if(e.formatEntityState)t=e.formatEntityState(r);else{t=r.state;const e=r.attributes.unit_of_measurement||"";e&&(t+=" "+e)}if("Wh"===(r.attributes.unit_of_measurement||"")){const e=parseFloat(r.state);isNaN(e)||(t=(e/1e3).toFixed(1)+" kWh")}i.textContent=t}}}}(t,this._hass,this._topology,this._config,this.powerHistory,this.subDeviceHorizonMap))}async onGraphSettingsChanged(t){if(this._hass){this._favRefs?await this._buildFavoritesHorizonMaps():(this.graphSettingsCache.invalidate(),await this.graphSettingsCache.fetch(this._hass,this._configEntryId),this.buildHorizonMaps(this.graphSettingsCache.settings)),this.powerHistory.clear();try{await this.loadHistory()}catch{}this.updateDOM(t)}}onToggleClick(t,e){const n=t.target,r=n?.closest(".toggle-pill");if(!r)return;const o=e.querySelector(".slide-confirm");if(!o||!o.classList.contains("confirmed"))return;t.stopPropagation(),t.preventDefault();const a=r.closest("[data-uuid]");if(!a||!this._topology||!this._hass)return;const s=a.dataset.uuid;if(!s)return;const l=this._topology.circuits[s];if(!l)return;const c=l.entities?.switch;if(!c)return;const u=this._hass.states[c];if(!u)return void console.warn("SPAN Panel: switch entity not found:",c);const h="on"===u.state?"turn_off":"turn_on";this._hass.callService("switch",h,{},{entity_id:c}).catch(t=>{console.warn("SPAN Panel: switch service call failed",t),this._errorStore?.add({key:"service:relay",level:"error",message:i("error.relay_failed"),persistent:!1})})}async onGearClick(t,e){const n=t.target,i=n?.closest(".gear-icon");if(!i)return;const r=e.querySelector("span-side-panel");if(!r||!this._hass)return;if(r.hass=this._hass,r.errorStore=this.errorStore,i.classList.contains("panel-gear")){if(this._inFavoritesView){const t=await this._buildFavoritesSections();if(0===t.length)return;return void r.open({favoritesMode:!0,perPanelSections:t})}return await this.graphSettingsCache.fetch(this._hass,this._configEntryId),void r.open({panelMode:!0,topology:this._topology,graphSettings:this.graphSettingsCache.settings,showFavorites:null!==this._panelFavorites,favoritePanelDeviceId:this._panelFavorites?.panelDeviceId,favoriteCircuitUuids:this._panelFavorites?.circuitUuids,favoriteSubDeviceIds:this._panelFavorites?.subDeviceIds,configEntryId:this._configEntryId})}const o=i.dataset.uuid;if(o&&this._topology){const t=this._topology.circuits[o];if(t){const e=this._favRefs?.[o]??null,n=e&&"circuit"===e.kind?e.targetId:o,i=e?.configEntryId??this._configEntryId;let s,l;e?[s,l]=await Promise.all([this._fetchGraphSettingsFresh(i),this._fetchMonitoringStatusFresh(i)]):(await Promise.all([this.graphSettingsCache.fetch(this._hass,i),this.monitoringCache.fetch(this._hass,i)]),s=this.graphSettingsCache.settings,l=this.monitoringCache.status);const c=t.entities?.current??t.entities?.power,u=c?l?.circuits?.[c]??null:null,h=s?.global_horizon??a,d=s?.circuits?.[n],p=d?{...d,globalHorizon:h}:{horizon:h,has_override:!1,globalHorizon:h},f=e?.panelDeviceId??this._panelFavorites?.panelDeviceId,g=null!==e||(this._panelFavorites?.circuitUuids.has(n)??!1),v=this._inFavoritesView||null!==this._panelFavorites;return void r.open({...t,uuid:n,monitoringInfo:u,showMonitoring:this._showMonitoring,graphHorizonInfo:p,showFavorites:v,favoritePanelDeviceId:f,isFavorite:g,configEntryId:i})}}const s=i.dataset.subdevId;if(s&&this._topology?.sub_devices?.[s]){const t=this._topology.sub_devices[s],e=this._favRefs?.[s]??null,n=e&&"sub_device"===e.kind?e.targetId:s,i=e?.configEntryId??this._configEntryId;let o;e?o=await this._fetchGraphSettingsFresh(i):(await this.graphSettingsCache.fetch(this._hass,i),o=this.graphSettingsCache.settings);const l=o?.global_horizon??a,c=o?.sub_devices?.[n],u=c?{...c,globalHorizon:l}:{horizon:l,has_override:!1,globalHorizon:l},h=e?.panelDeviceId??this._panelFavorites?.panelDeviceId,d=null!==e||(this._panelFavorites?.subDeviceIds.has(n)??!1),p=this._inFavoritesView||null!==this._panelFavorites;r.open({subDeviceMode:!0,subDeviceId:n,name:t.name??n,deviceType:t.type??"",entities:t.entities,graphHorizonInfo:u,showFavorites:p,favoritePanelDeviceId:h,isFavorite:d,configEntryId:i})}}async _buildFavoritesSections(){if(!this._hass||!this._favRefs)return[];const t=function(t,e){const n=new Map;for(const i of Object.values(t)){if("circuit"!==i.kind)continue;const t=e.get(i.panelDeviceId);if(void 0===t)continue;let r=n.get(i.panelDeviceId);void 0===r&&(r={panelDeviceId:i.panelDeviceId,panelName:t.panelName,topology:t.topology,configEntryId:t.configEntryId,favoriteCircuitUuids:new Set},n.set(i.panelDeviceId,r)),r.favoriteCircuitUuids.add(i.targetId)}return Array.from(n.values()).sort((t,e)=>t.panelName.localeCompare(e.panelName))}(this._favRefs,this._perPanelInfo);if(0===t.length)return[];return await Promise.all(t.map(async t=>({panelDeviceId:t.panelDeviceId,panelName:t.panelName,topology:t.topology,graphSettings:await this._fetchGraphSettingsFresh(t.configEntryId),favoriteCircuitUuids:t.favoriteCircuitUuids,configEntryId:t.configEntryId})))}async _fetchGraphSettingsFresh(t){if(!this._hass)return null;try{const e={};t&&(e.config_entry_id=t);const n={type:"call_service",domain:l,service:"get_graph_settings",service_data:e,return_response:!0},r=this._errorStore?new Qt(this._errorStore):null,o=r?await r.callWS(this._hass,n,{errorId:"fetch:graph_settings",errorMessage:i("error.graph_settings_failed")}):await this._hass.callWS(n);return o?.response??null}catch(t){return console.warn("SPAN Panel: fresh graph settings fetch failed",t),null}}async _fetchMonitoringStatusFresh(t){if(!this._hass)return null;try{const e={};t&&(e.config_entry_id=t);const n={type:"call_service",domain:l,service:"get_monitoring_status",service_data:e,return_response:!0},r=this._errorStore?new Qt(this._errorStore):null,o=r?await r.callWS(this._hass,n,{errorId:"fetch:monitoring",errorMessage:i("error.monitoring_failed")}):await this._hass.callWS(n),a=o?.response;return a?{circuits:a.circuits,mains:a.mains}:null}catch(t){return console.warn("SPAN Panel: fresh monitoring status fetch failed",t),null}}bindSlideConfirm(t,e){const n=t.querySelector(".slide-confirm-knob"),i=t.querySelector(".slide-confirm-text");if(!n||!i)return;let r=!1,o=0,a=0;const s=e=>{t.classList.contains("confirmed")||(r=!0,o=e-n.offsetLeft,a=t.offsetWidth-n.offsetWidth-4,n.classList.remove("snapping"))},l=t=>{if(!r)return;const e=Math.max(2,Math.min(t-o,a));n.style.left=e+"px"},c=()=>{if(!r)return;r=!1;(n.offsetLeft-2)/a>=.9?(n.style.left=a+"px",t.classList.add("confirmed"),n.querySelector("span-icon")?.setAttribute("icon","mdi:lock-open"),i.textContent=t.dataset.textOn??"",e&&e.classList.remove("switches-disabled")):(n.classList.add("snapping"),n.style.left="2px")};n.addEventListener("mousedown",t=>{t.preventDefault(),s(t.clientX)}),t.addEventListener("mousemove",t=>l(t.clientX)),t.addEventListener("mouseup",c),t.addEventListener("mouseleave",c),n.addEventListener("touchstart",t=>{t.preventDefault(),s(t.touches[0].clientX)},{passive:!1}),t.addEventListener("touchmove",t=>l(t.touches[0].clientX),{passive:!0}),t.addEventListener("touchend",c),t.addEventListener("touchcancel",c),t.addEventListener("click",()=>{t.classList.contains("confirmed")&&(t.classList.remove("confirmed"),n.classList.add("snapping"),n.style.left="2px",n.querySelector("span-icon")?.setAttribute("icon","mdi:lock"),i.textContent=t.dataset.textOff??"",e&&e.classList.add("switches-disabled"))})}startIntervals(t,e){this._updateInterval=setInterval(()=>{this.recordSamples(),this.updateDOM(t),e&&e()},1e3),this._recorderRefreshInterval=setInterval(()=>{this.refreshRecorderData(t)},3e4)}stopIntervals(){this._updateInterval&&(clearInterval(this._updateInterval),this._updateInterval=null),this._recorderRefreshInterval&&(clearInterval(this._recorderRefreshInterval),this._recorderRefreshInterval=null),this.cleanupResizeObserver()}setupResizeObserver(t,e){this.cleanupResizeObserver(),e&&(this._lastWidth=e.clientWidth,this._resizeObserver=new ResizeObserver(e=>{const n=e[0];if(!n)return;const i=n.contentRect.width;Math.abs(i-this._lastWidth)<5||(this._lastWidth=i,this._resizeDebounce&&clearTimeout(this._resizeDebounce),this._resizeDebounce=setTimeout(()=>{for(const e of t.querySelectorAll(".chart-container")){const t=e.querySelector("span-chart");t&&t.remove()}this.updateDOM(t)},150))}),this._resizeObserver.observe(e))}cleanupResizeObserver(){this._resizeObserver&&(this._resizeObserver.disconnect(),this._resizeObserver=null),this._resizeDebounce&&(clearTimeout(this._resizeDebounce),this._resizeDebounce=null)}reset(){this.powerHistory.clear(),this.horizonMap.clear(),this.subDeviceHorizonMap.clear(),this.monitoringCache.clear(),this.monitoringMultiCache.clear(),this.graphSettingsCache.clear()}}function Fk(t=""){const e=t?` value="${Rt(t)}"`:"",n=t?"":"display:none;";return`\n
\n \n \n
\n `}function $k(t,e,n,r,o,a,s){const l=e.entities?.power,u=l?n.states[l]:null,h=u&&parseFloat(u.state)||0,d=e.entities?.switch,p=d?n.states[d]:null,f=p?"on"===p.state:(u?.attributes?.relay_state||e.relay_state)===c,g=e.breaker_rating_a,v=g?`${Math.round(g)}A`:"",y=Rt(e.name||i("grid.unknown")),_=Yt(r),x="current"===_.entityRole;let b;if(f)if(x){const t=e.entities?.current,i=t?n.states[t]:null,r=i&&parseFloat(i.state)||0;b=`${_.format(r)}A`}else b=`${Ut(h)}${Wt(h)}`;else b="";const w=a||"unknown";let S="";if("unknown"!==w){const t=m[w]??m.unknown??{icon:"mdi:help",color:"#999",label:()=>"Unknown"};S=t.icon2?`\n \n \n `:t.textLabel?`\n \n ${t.textLabel}\n `:``}let C="",M=o?.utilization_pct??null;if(null==M&&e.breaker_rating_a){const t=e.entities?.current,i=t?n.states[t]:null,r=i?Math.abs(parseFloat(i.state)||0):0;M=Math.round(r/e.breaker_rating_a*1e3)/10}if(null!=M){C=`=80?"utilization-warning":"utilization-normal"}">${Math.round(M)}%`}const k=``,T=!1!==e.is_user_controllable&&!!e.entities?.switch?`
\n ${i(f?"grid.on":"grid.off")}\n \n
`:`${f?"ON":"OFF"}`;return`\n
\n ${v?`${v}`:""}\n ${C}\n ${y}\n ${S}\n ${T}\n \n ${b}\n \n ${k}\n \n
\n `}function Vk(t,e,n,i,r){const o=e.entities?.power,a=o?n.states[o]:null,s=a&&parseFloat(a.state)||0,l=e.device_type===u||s<0,h=e.entities?.switch,d=h?n.states[h]:null,p=ne(0,r,d?"on"===d.state:(a?.attributes?.relay_state||e.relay_state)===c,l),f=Rt(t);return`\n
\n
\n
\n
\n
\n `}function Wk(t){return`
${Rt(t)}
`}function Uk(t,e){const n=e.hysteresisPx??48,i=new WeakSet;let r=null;const o=t=>{const n=t.querySelector(e.nameSelector);return!!n&&(0!==t.clientWidth&&(n.clientWidth<1||n.scrollWidth>n.clientWidth+1))},a=()=>{const i=t.querySelectorAll(e.rowSelector);if(0===i.length)return;const a=i[0].clientWidth;if(0===a)return;if(null!==r){if(a>r+n){for(const t of i)t.classList.remove(e.foldClass);r=null}else for(const t of i)t.classList.add(e.foldClass);return}let s=!1;for(const t of i)if(o(t)){s=!0;break}if(s){for(const t of i)t.classList.add(e.foldClass);r=a}},s=new ResizeObserver(()=>a()),l=()=>{const n=t.querySelectorAll(e.rowSelector);for(const t of n)i.has(t)||(i.add(t),s.observe(t));requestAnimationFrame(()=>a())};l();const c=new MutationObserver(t=>{(t=>{const n=t=>{for(const n of t)if(n instanceof Element){if(n.matches(e.rowSelector))return!0;if(n.querySelector(e.rowSelector))return!0}return!1};for(const e of t)if(n(e.addedNodes)||n(e.removedNodes))return!0;return!1})(t)&&l()});return c.observe(t,{childList:!0,subtree:!0}),()=>{s.disconnect(),c.disconnect()}}function Gk(t,e,n){const i=t.entities?.switch,r=i?e.states[i]:null,o=t.entities?.power,a=o?e.states[o]:null,s=r?"on"===r.state:(a?.attributes?.relay_state||t.relay_state)===c;let l;if("current"===(n.chart_metric||"power")){const n=t.entities?.current,i=n?e.states[n]:null;l=i?Math.abs(parseFloat(i.state)||0):0}else l=a?Math.abs(parseFloat(a.state)||0):0;return{isOn:s,value:l}}function qk(t,e){if(t.always_on)return"always_on";const n=t.entities?.select,i=n?e.states[n]:null;return i?i.state:"unknown"}function jk(t,e,n,i){const r=Gk(t,n,i),o=Gk(e,n,i);return r.isOn&&!o.isOn?-1:!r.isOn&&o.isOn?1:o.value-r.value}function Xk(t,e,n){return t.sort((t,i)=>jk(t[1],i[1],e,n))}function Yk(t){return t.entities?.current??t.entities?.power??""}class Zk{constructor(t){this._expandedUuids=new Set,this._searchQuery="",this._container=null,this._clickHandler=null,this._inputHandler=null,this._graphSettingsHandler=null,this._hass=null,this._topology=null,this._config=null,this._monitoringStatus=null,this._viewName=null,this._columns=1,this._foldUnobserve=null,this._ctrl=t}setColumns(t){const e=Math.max(1,Math.min(3,Math.floor(t)));this._columns=e}setInitialExpansion(t){this._expandedUuids=new Set(t)}setInitialSearchQuery(t){this._searchQuery=t}setViewName(t){this._viewName=t}renderActivityView(t,e,n,i,r,o){this._unbindEvents(),this._hass=e,this._topology=n,this._config=i,this._monitoringStatus=r;const a=Xk(Object.entries(n.circuits),e,i);let s=o+Fk(this._searchQuery);s+=`
`;for(const[t,n]of a){const o=ee(r,Yk(n)),a=qk(n,e),l=this._expandedUuids.has(t);s+=`
`,s+=$k(t,n,e,i,o,a,l),l&&(s+=Vk(t,n,e,0,o)),s+="
"}s+="
",s+="",t.innerHTML=s;const l=t.querySelector("span-side-panel");l&&(l.hass=e,l.errorStore=this._ctrl.errorStore),this._bindEvents(t),this._searchQuery&&this._applyFilter(t),this._ctrl.updateDOM(t),this._attachFoldObserver(t)}renderAreaView(t,e,n,r,o,a){this._unbindEvents(),this._hass=e,this._topology=n,this._config=r,this._monitoringStatus=o;const s=i("list.unassigned_area"),l=new Map;for(const[t,e]of Object.entries(n.circuits)){const n=e.area??s,i=l.get(n);i?i.push([t,e]):l.set(n,[[t,e]])}const c=[...l.keys()].sort((t,e)=>t===s?1:e===s?-1:t.localeCompare(e));let u=a+Fk(this._searchQuery);u+=`
`;for(const t of c){const n=l.get(t);if(!n)continue;const i=Xk(n,e,r);u+=Wk(t);for(const[t,n]of i){const i=ee(o,Yk(n)),a=qk(n,e),s=this._expandedUuids.has(t);u+=`
`,u+=$k(t,n,e,r,i,a,s),s&&(u+=Vk(t,n,e,0,i)),u+="
"}}u+="
",u+="",t.innerHTML=u;const h=t.querySelector("span-side-panel");h&&(h.hass=e,h.errorStore=this._ctrl.errorStore),this._bindEvents(t),this._searchQuery&&this._applyFilter(t),this._ctrl.updateDOM(t),this._attachFoldObserver(t)}updateCollapsedRows(t,e,n,r){const o=Yt(r),a="current"===o.entityRole,s=t.querySelectorAll(".list-row[data-row-uuid]");for(const t of s){const s=t.dataset.rowUuid;if(!s)continue;const l=n.circuits[s];if(!l)continue;const{isOn:c,value:u}=Gk(l,e,r),h=t.querySelector(".list-power-value");if(h)if(c)if(a)h.innerHTML=`${o.format(u)}A`;else{const t=l.entities?.power,n=t?e.states[t]:null,i=n&&parseFloat(n.state)||0;h.innerHTML=`${Ut(i)}${Wt(i)}`}else h.innerHTML="";const d=t.querySelector(".toggle-pill");if(d){d.classList.toggle("toggle-on",c),d.classList.toggle("toggle-off",!c);const t=d.querySelector(".toggle-label");t&&(t.textContent=i(c?"grid.on":"grid.off"))}const p=t.querySelector(".list-status-badge");p&&(p.textContent=c?"ON":"OFF",p.classList.toggle("list-status-on",c),p.classList.toggle("list-status-off",!c)),t.classList.toggle("circuit-off",!c)}!function(t,e,n,i){const r=t.querySelector(".list-view");if(r)for(const t of function(t,e){let n={anchor:null,units:[]};const i=[n];for(const r of[...t.children])if(r.classList.contains("area-header"))n={anchor:r,units:[]},i.push(n);else if(r.classList.contains("list-cell")){const t=r.dataset.cellUuid,i=t?e.circuits[t]:void 0;t&&i&&n.units.push({cell:r,uuid:t,circuit:i})}return i}(r,n)){if(t.units.length<2)continue;const n=[...t.units].sort((t,n)=>jk(t.circuit,n.circuit,e,i));if(!n.some((e,n)=>e.uuid!==t.units[n].uuid))continue;let o=t.anchor;for(const t of n)o?o.after(t.cell):r.prepend(t.cell),o=t.cell}}(t,e,n,r)}stop(){this._unbindEvents(),null===this._viewName&&(this._expandedUuids.clear(),this._searchQuery=""),this._hass=null,this._topology=null,this._config=null,this._monitoringStatus=null}_dispatchFavoritesViewState(){if(!this._viewName||!this._container)return;const t={view:this._viewName,expanded:[...this._expandedUuids],searchQuery:this._searchQuery};this._container.dispatchEvent(new CustomEvent("favorites-view-state-changed",{detail:t,bubbles:!0,composed:!0}))}_bindEvents(t){this._container=t,this._clickHandler=e=>{const n=e.target;if(!n)return;const i=n.closest(".list-expand-toggle");if(i){const t=i.dataset.expandUuid;return void(t&&this._toggleExpand(t))}if(n.closest(".gear-icon"))return void this._ctrl.onGearClick(e,t);if(n.closest(".toggle-pill"))return void this._ctrl.onToggleClick(e,t);if(n.closest(".list-search-clear")){const e=t.querySelector(".list-search");return void(e&&(e.value="",e.dispatchEvent(new Event("input",{bubbles:!0}))))}const r=n.closest(".unit-btn");if(r){const e=r.dataset.unit;e&&t.dispatchEvent(new CustomEvent("unit-changed",{detail:e,bubbles:!0,composed:!0}))}},this._inputHandler=e=>{const n=e.target;n&&n.classList.contains("list-search")&&(this._searchQuery=n.value.toLowerCase(),this._applyFilter(t),this._dispatchFavoritesViewState())},this._graphSettingsHandler=()=>{this._ctrl.onGraphSettingsChanged(t).then(()=>{this._ctrl.updateDOM(t)}).catch(()=>{})},t.addEventListener("click",this._clickHandler),t.addEventListener("input",this._inputHandler),t.addEventListener("graph-settings-changed",this._graphSettingsHandler);const e=t.querySelector(".slide-confirm");e&&(this._ctrl.bindSlideConfirm(e,t),t.classList.add("switches-disabled"))}_unbindEvents(){this._container&&(this._clickHandler&&this._container.removeEventListener("click",this._clickHandler),this._inputHandler&&this._container.removeEventListener("input",this._inputHandler),this._graphSettingsHandler&&this._container.removeEventListener("graph-settings-changed",this._graphSettingsHandler)),this._foldUnobserve&&(this._foldUnobserve(),this._foldUnobserve=null),this._container=null,this._clickHandler=null,this._inputHandler=null,this._graphSettingsHandler=null}_attachFoldObserver(t){this._foldUnobserve&&(this._foldUnobserve(),this._foldUnobserve=null),this._foldUnobserve=Uk(t,{rowSelector:".list-row",nameSelector:".list-circuit-name",foldClass:"is-folded"})}_applyFilter(t){const e=t.querySelector(".list-search-clear");e&&(e.style.display=this._searchQuery?"":"none");const n=t.querySelectorAll(".list-cell[data-cell-uuid]");for(const t of n){const e=t.querySelector(".list-circuit-name"),n=(e?.textContent?.toLowerCase()??"").includes(this._searchQuery);t.style.display=n?"":"none"}const i=t.querySelectorAll(".area-header");for(const t of i){let e=!1,n=t.nextElementSibling;for(;n&&!n.classList.contains("area-header");){if(n.classList.contains("list-cell")&&"none"!==n.style.display){e=!0;break}n=n.nextElementSibling}t.style.display=e?"":"none"}}_toggleExpand(t){if(!(this._container&&this._hass&&this._topology&&this._config))return;const e=zk(t),n=this._container.querySelector(`.list-cell[data-cell-uuid="${e}"]`);if(!n)return;const i=n.querySelector(`.list-row[data-row-uuid="${e}"]`),r=n.querySelector(`.list-expand-toggle[data-expand-uuid="${e}"]`);if(i){if(this._expandedUuids.has(t)){this._expandedUuids.delete(t);const o=n.querySelector(`.list-expanded-content[data-expanded-uuid="${e}"]`);o&&o.remove(),r&&r.classList.remove("expanded"),i.classList.remove("list-row-expanded")}else{this._expandedUuids.add(t);const e=this._topology.circuits[t];if(!e)return;const n=ee(this._monitoringStatus,Yk(e)),o=Vk(t,e,this._hass,this._config,n);i.insertAdjacentHTML("afterend",o),r&&r.classList.add("expanded"),i.classList.add("list-row-expanded"),this._ctrl.updateDOM(this._container)}this._dispatchFavoritesViewState()}}}async function Kk(t,e){const[n,i,r]=await Promise.all([t.callWS({type:"config/area_registry/list"}),t.callWS({type:"config/entity_registry/list"}),t.callWS({type:"config/device_registry/list"})]),o=new Map;for(const t of n)o.set(t.area_id,t.name);const a=new Map;for(const t of i)t.area_id&&a.set(t.entity_id,t.area_id);const s=new Map;for(const t of r)s.set(t.id,t.area_id);let l;if(e.device_id){const t=s.get(e.device_id);t&&(l=o.get(t))}for(const t of Object.values(e.circuits)){let e;for(const n of Object.values(t.entities)){if(!n)continue;const t=a.get(n);if(t){e=o.get(t);break}}e||(e=l),t.area=e}}class Qk{constructor(){this._persistent=new Map,this._transient=null,this._transientTimer=null,this._subscribers=new Set,this._watchedPanels=new Map}add(t){const e={...t,timestamp:Date.now()};if(e.persistent)this._persistent.set(e.key,e);else{this._clearTransient(),this._transient=e;const t=e.ttl??5e3;this._transientTimer=setTimeout(()=>{this._transient=null,this._transientTimer=null,this._notify()},t)}this._notify()}remove(t){if(this._persistent.has(t))return this._persistent.delete(t),void this._notify();this._transient?.key===t&&(this._clearTransient(),this._notify())}clear(t){void 0===t?(this._persistent.clear(),this._clearTransient(),this._watchedPanels.clear()):!0===t.persistent?this._persistent.clear():!1===t.persistent&&this._clearTransient(),this._notify()}get active(){const t=[...this._persistent.values()];return null!==this._transient&&t.push(this._transient),t}hasPersistent(t){return this._persistent.has(t)}hasAnyPanelOffline(){for(const t of this._persistent.keys())if("panel-offline"===t||t.startsWith("panel-offline:"))return!0;return!1}subscribe(t){return this._subscribers.add(t),()=>{this._subscribers.delete(t)}}watchPanelStatus(t){this.watchPanelStatuses([{entityId:t,panelName:null}])}watchPanelStatuses(t){const e=this._watchedPanels,n=new Map;for(const i of t){const t=e.get(i.entityId);n.set(i.entityId,{panelName:i.panelName??null,wasOffline:t?.wasOffline??!1})}const i=this._isSingleUnnamed(e),r=this._isSingleUnnamed(n);for(const t of e.keys()){n.has(t)&&i===r||this._persistent.delete(this._offlineKey(t,i))}this._watchedPanels=n,this._notify()}clearPanelStatusWatch(){if(0===this._watchedPanels.size)return;const t=this._isSingleUnnamed(this._watchedPanels);for(const e of this._watchedPanels.keys())this._persistent.delete(this._offlineKey(e,t));this._watchedPanels.clear(),this._notify()}updateHass(t){if(0===this._watchedPanels.size)return;const e=this._isSingleUnnamed(this._watchedPanels);for(const[n,o]of this._watchedPanels){const a=t.states[n]?.state,s="on"===a,l=this._offlineKey(n,e),c=this._reconnectKey(n,e);if(s){const t=o.wasOffline;o.wasOffline=!1,this.remove(l),t&&this.add({key:c,level:"info",message:null===o.panelName?i("error.panel_reconnected"):r("error.panel_reconnected_named",{name:o.panelName}),persistent:!1})}else o.wasOffline=!0,this.hasPersistent(l)||this.add({key:l,level:"error",message:null===o.panelName?i("error.panel_offline"):r("error.panel_offline_named",{name:o.panelName}),persistent:!0})}}dispose(){this._clearTransient(),this._persistent.clear(),this._subscribers.clear(),this._watchedPanels.clear()}_isSingleUnnamed(t){if(1!==t.size)return!1;for(const e of t.values())return null===e.panelName;return!1}_offlineKey(t,e){return e?"panel-offline":`panel-offline:${t}`}_reconnectKey(t,e){return e?"panel-reconnected":`panel-reconnected:${t}`}_clearTransient(){null!==this._transientTimer&&(clearTimeout(this._transientTimer),this._transientTimer=null),this._transient=null}_notify(){for(const t of this._subscribers)try{t()}catch(t){console.warn("SPAN Panel: error-store subscriber threw",t)}}}function Jk(t){let e=0;for(const n of Object.values(t))if(n)for(const t of n.tabs)t>e&&(e=t);return e>0?e+e%2:0}function tT(t){return t?{id:t.id,name:t.name,name_by_user:t.name_by_user,config_entries:t.config_entries,identifiers:t.identifiers,via_device_id:t.via_device_id,sw_version:t.sw_version,model:t.model}:null}const eT="favorites-changed";async function nT(t,e,n={}){const i=await t.callWS({type:"call_service",domain:l,service:e,service_data:n,return_response:!0});return i?.response??null}const iT=Object.keys(m).filter(t=>"unknown"!==t&&"always_on"!==t);class rT extends HTMLElement{constructor(){super(),this.errorStore=null,this.attachShadow({mode:"open"}),this._hass=null,this._config=null,this._debounceTimers={}}set hass(t){this._hass=t,this.hasAttribute("open")&&this._config&&this._updateLiveState()}get hass(){return this._hass}disconnectedCallback(){this._clearDebounceTimers(),this._config=null}open(t){this._config=t,this._render(),this.offsetHeight,this.setAttribute("open",""),this.setAttribute("data-mode",this._modeFor(t))}close(){this._clearDebounceTimers(),this.removeAttribute("open"),this.removeAttribute("data-mode"),this._config=null,this.dispatchEvent(new CustomEvent("side-panel-closed",{bubbles:!0,composed:!0}))}_clearDebounceTimers(){for(const t of Object.keys(this._debounceTimers))clearTimeout(this._debounceTimers[t]);this._debounceTimers={}}_modeFor(t){return t.favoritesMode?"favorites":t.panelMode?"panel":t.subDeviceMode?"subDevice":"circuit"}_render(){const t=this._config;if(!t)return;const e=this.shadowRoot;if(!e)return;e.innerHTML="";const n=document.createElement("style");n.textContent='\n :host {\n display: block;\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n width: 360px;\n max-width: 90vw;\n z-index: 1000;\n transform: translateX(100%);\n transition: transform 0.3s ease;\n pointer-events: none;\n }\n :host([open]) {\n transform: translateX(0);\n pointer-events: auto;\n }\n\n .backdrop {\n display: none;\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba(0, 0, 0, 0.3);\n z-index: -1;\n }\n :host([open]) .backdrop {\n display: block;\n }\n\n .panel {\n height: 100%;\n background: var(--card-background-color, #fff);\n border-left: 1px solid var(--divider-color, #e0e0e0);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n .panel-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 16px;\n border-bottom: 1px solid var(--divider-color, #e0e0e0);\n }\n .panel-header .title {\n font-size: 18px;\n font-weight: 500;\n color: var(--primary-text-color, #212121);\n margin: 0;\n }\n .panel-header .subtitle {\n font-size: 13px;\n color: var(--secondary-text-color, #727272);\n margin: 2px 0 0 0;\n }\n .close-btn {\n background: none;\n border: none;\n cursor: pointer;\n color: var(--secondary-text-color, #727272);\n padding: 4px;\n line-height: 1;\n font-size: 20px;\n }\n\n .panel-body {\n flex: 1;\n overflow-y: auto;\n padding: 16px;\n }\n\n .section {\n margin-bottom: 20px;\n }\n .section-label {\n font-size: 12px;\n font-weight: 600;\n text-transform: uppercase;\n color: var(--secondary-text-color, #727272);\n margin: 0 0 8px 0;\n letter-spacing: 0.5px;\n }\n\n .field-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 8px 0;\n }\n .field-label {\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n }\n\n select {\n padding: 6px 8px;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 4px;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 14px;\n }\n\n input[type="number"] {\n width: 72px;\n padding: 6px 8px;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 4px;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 14px;\n text-align: right;\n }\n input[type="number"]:disabled {\n opacity: 0.5;\n }\n\n .radio-group {\n display: flex;\n gap: 16px;\n padding: 8px 0;\n }\n .radio-group label {\n display: flex;\n align-items: center;\n gap: 6px;\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n cursor: pointer;\n }\n\n .horizon-bar {\n display: flex;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 6px;\n overflow: hidden;\n margin-top: 4px;\n }\n .horizon-segment {\n flex: 1;\n padding: 6px 0;\n text-align: center;\n font-size: 13px;\n cursor: pointer;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n border: none;\n border-right: 1px solid var(--divider-color, #e0e0e0);\n transition: background 0.15s ease, color 0.15s ease;\n user-select: none;\n line-height: 1.4;\n }\n .horizon-segment:last-child {\n border-right: none;\n }\n .horizon-segment:hover:not(.active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .horizon-segment.active {\n background: var(--primary-color, #03a9f4);\n color: #fff;\n font-weight: 600;\n }\n .horizon-segment.referenced {\n box-shadow: inset 0 -3px 0 var(--primary-color, #03a9f4);\n }\n\n .unit-toggle {\n display: inline-flex;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 6px;\n overflow: hidden;\n }\n .unit-btn {\n padding: 4px 10px;\n border: none;\n border-right: 1px solid var(--divider-color, #e0e0e0);\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 13px;\n font-weight: 500;\n cursor: pointer;\n transition: background 0.15s ease, color 0.15s ease;\n }\n .unit-btn:last-child {\n border-right: none;\n }\n .unit-btn:hover:not(.unit-active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .unit-btn.unit-active {\n background: var(--primary-color, #03a9f4);\n color: #fff;\n font-weight: 600;\n }\n\n .monitoring-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n }\n\n .fav-heart {\n background: none;\n border: 1px solid var(--divider-color, #e0e0e0);\n color: var(--secondary-text-color, #727272);\n border-radius: 4px;\n padding: 2px 6px;\n cursor: pointer;\n font-size: 0.9em;\n margin-right: 6px;\n line-height: 1;\n display: inline-flex;\n align-items: center;\n }\n .fav-heart.active {\n color: var(--primary-color, #03a9f4);\n border-color: var(--primary-color, #03a9f4);\n }\n .fav-heart:hover:not(.active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .fav-heart span-icon {\n --mdc-icon-size: 16px;\n }\n\n .panel-mode-info {\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n line-height: 1.6;\n }\n .panel-mode-info p {\n margin: 0 0 12px 0;\n }\n\n',e.appendChild(n);const i=document.createElement("div");i.className="backdrop",i.addEventListener("click",()=>this.close()),e.appendChild(i);const r=document.createElement("div");r.className="panel",e.appendChild(r),t.favoritesMode?this._renderFavoritesMode(r):t.panelMode?this._renderPanelMode(r):t.subDeviceMode?this._renderSubDeviceMode(r,t):this._renderCircuitMode(r,t)}_renderPanelMode(t){const e=this._config,n=this._createHeader(i("sidepanel.graph_settings"),i("sidepanel.global_defaults"));t.appendChild(n);const r=document.createElement("div");r.className="panel-body";const o=e.graphSettings,l=e.topology,c=o?.global_horizon??a,u=o?.circuits??{};r.appendChild(this._buildListColumnsSection());const h=document.createElement("div");h.className="section";const d=document.createElement("div");d.className="section-label",d.textContent=i("sidepanel.graph_horizon"),h.appendChild(d);const p=document.createElement("div");p.className="field-row";const g=document.createElement("span");g.className="field-label",g.textContent=i("sidepanel.global_default"),p.appendChild(g);const v=document.createElement("select");for(const t of Object.keys(s)){const e=document.createElement("option");e.value=t;const n=`horizon.${t}`,r=i(n);e.textContent=r!==n?r:t,t===c&&(e.selected=!0),v.appendChild(e)}if(v.addEventListener("change",()=>{const t={horizon:v.value};e.configEntryId&&(t.config_entry_id=e.configEntryId),this._callDomainService("set_graph_time_horizon",t).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})}),p.appendChild(v),h.appendChild(p),r.appendChild(h),l?.circuits){const t=document.createElement("div");t.className="section";const n=document.createElement("div");n.className="section-label",n.textContent=i("sidepanel.circuit_scales"),t.appendChild(n);const o=Object.entries(l.circuits).sort(([,t],[,e])=>(t.name||"").localeCompare(e.name||""));for(const[n,i]of o){const r=this._buildPanelModeCircuitRow(n,i,u[n],c,e.configEntryId??null,e.showFavorites??!1,e.favoritePanelDeviceId,e.favoriteCircuitUuids);t.appendChild(r)}r.appendChild(t)}const m=o?.sub_devices??{};if(l?.sub_devices){const t=document.createElement("div");t.className="section";const n=document.createElement("div");n.className="section-label",n.textContent=i("sidepanel.subdevice_scales"),t.appendChild(n);const o=Object.entries(l.sub_devices).sort(([,t],[,e])=>(t.name||"").localeCompare(e.name||""));for(const[n,r]of o){const o=document.createElement("div");o.className="field-row";const a=document.createElement("span");if(a.className="field-label",a.textContent=r.name||n,a.style.cssText="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;",o.appendChild(a),e.showFavorites&&e.favoritePanelDeviceId){const t=this._buildSubDeviceFavoriteHeart(r.entities,e.favoriteSubDeviceIds?.has(n)??!1);t&&o.appendChild(t)}const l=m[n]||{horizon:c,has_override:!1},u=l.has_override?l.horizon:c,h=document.createElement("select");h.dataset.subdevId=n;for(const t of Object.keys(s)){const e=document.createElement("option");e.value=t;const n=`horizon.${t}`,r=i(n);e.textContent=r!==n?r:t,t===u&&(e.selected=!0),h.appendChild(e)}if(h.addEventListener("change",()=>{this._debounce(`subdev-${n}`,f,()=>{const t={subdevice_id:n,horizon:h.value};e.configEntryId&&(t.config_entry_id=e.configEntryId),this._callDomainService("set_subdevice_graph_horizon",t).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})})}),o.appendChild(h),l.has_override){const t=document.createElement("button");t.textContent="↺",t.title=i("sidepanel.reset_to_global"),Object.assign(t.style,{background:"none",border:"1px solid var(--divider-color, #e0e0e0)",color:"var(--primary-text-color)",borderRadius:"4px",padding:"3px 6px",cursor:"pointer",marginLeft:"4px",fontSize:"0.85em"}),t.addEventListener("click",()=>{const r={subdevice_id:n};e.configEntryId&&(r.config_entry_id=e.configEntryId),this._callDomainService("clear_subdevice_graph_horizon",r).then(()=>{h.value=c,t.remove(),this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})}),o.appendChild(t)}t.appendChild(o)}r.appendChild(t)}t.appendChild(r)}_buildPanelModeCircuitRow(t,e,n,r,o,a,l,c){const u=document.createElement("div");u.className="field-row";const h=document.createElement("span");if(h.className="field-label",h.textContent=e.name||t,h.style.cssText="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;",u.appendChild(h),a&&l){const n=this._buildFavoriteHeart(e.entities,c?.has(t)??!1);n&&u.appendChild(n)}const d=n||{horizon:r,has_override:!1},p=d.has_override?d.horizon:r,g=document.createElement("select");g.dataset.uuid=t;for(const t of Object.keys(s)){const e=document.createElement("option");e.value=t;const n=`horizon.${t}`,r=i(n);e.textContent=r!==n?r:t,t===p&&(e.selected=!0),g.appendChild(e)}if(g.addEventListener("change",()=>{this._debounce(`circuit-${t}`,f,()=>{const e={circuit_id:t,horizon:g.value};o&&(e.config_entry_id=o),this._callDomainService("set_circuit_graph_horizon",e).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})})}),u.appendChild(g),d.has_override){const e=document.createElement("button");e.textContent="↺",e.title=i("sidepanel.reset_to_global"),Object.assign(e.style,{background:"none",border:"1px solid var(--divider-color, #e0e0e0)",color:"var(--primary-text-color)",borderRadius:"4px",padding:"3px 6px",cursor:"pointer",marginLeft:"4px",fontSize:"0.85em"}),e.addEventListener("click",()=>{const n={circuit_id:t};o&&(n.config_entry_id=o),this._callDomainService("clear_circuit_graph_horizon",n).then(()=>{g.value=r,e.remove(),this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})}),u.appendChild(e)}return u}_renderFavoritesMode(t){const e=this._config,n=this._createHeader(i("sidepanel.graph_settings"),i("sidepanel.favorites_subtitle"));t.appendChild(n);const r=document.createElement("div");r.className="panel-body",r.appendChild(this._buildListColumnsSection());for(const t of e.perPanelSections)r.appendChild(this._buildFavoritesPanelSection(t));t.appendChild(r)}_buildFavoritesPanelSection(t){const e=document.createElement("div");e.className="section";const n=document.createElement("div");n.className="section-label",n.textContent=t.panelName,e.appendChild(n);const i=t.graphSettings?.global_horizon??a,r=t.graphSettings?.circuits??{},o=function(t){const e=t.circuits??{};return Object.entries(e).map(([t,e])=>({uuid:t,circuit:e})).sort((t,e)=>(t.circuit.name||"").localeCompare(e.circuit.name||""))}(t.topology);for(const{uuid:n,circuit:a}of o){const o=this._buildPanelModeCircuitRow(n,a,r[n],i,t.configEntryId,!0,t.panelDeviceId,t.favoriteCircuitUuids);e.appendChild(o)}return e}_renderCircuitMode(t,e){const n=`${Rt(String(e.breaker_rating_a))}A · ${Rt(String(e.voltage))}V · Tabs [${Rt(String(e.tabs))}]`,i=this._createHeader(Rt(e.name),n);t.appendChild(i);const r=document.createElement("div");r.className="panel-body",t.appendChild(r),this._renderRelaySection(r,e),e.showFavorites&&this._renderFavoriteSection(r,e),this._renderSheddingSection(r,e),this._renderGraphHorizonSection(r,e),e.showMonitoring&&this._renderMonitoringSection(r,e)}_favoriteEntityId(t){return t?.current??t?.power??null}_subDeviceFavoriteEntityId(t){if(!t)return null;let e=null;for(const[n,i]of Object.entries(t)){if("sensor"===i.domain)return n;e||(e=n)}return e}_buildSubDeviceFavoriteHeart(t,e){const n=this._subDeviceFavoriteEntityId(t);return n?this._buildHeartButton(n,e):null}_buildListColumnsSection(){const t=document.createElement("div");t.className="section";const e=document.createElement("div");e.className="section-label",e.textContent=i("sidepanel.list_view_columns"),t.appendChild(e);const n=document.createElement("div");n.className="field-row";const r=document.createElement("span");r.className="field-label",r.textContent=i("sidepanel.columns"),n.appendChild(r);const o=Bt(),a=document.createElement("div");a.className="unit-toggle";for(const t of[1,2,3]){const e=document.createElement("button");e.type="button",e.className="unit-btn"+(t===o?" unit-active":""),e.dataset.columns=String(t),e.textContent=String(t),e.addEventListener("click",()=>{Ft(t);for(const t of a.querySelectorAll(".unit-btn"))t.classList.toggle("unit-active",t===e);this.dispatchEvent(new CustomEvent("list-columns-changed",{detail:t,bubbles:!0,composed:!0}))}),a.appendChild(e)}return n.appendChild(a),t.appendChild(n),t}_buildFavoriteHeart(t,e){const n=this._favoriteEntityId(t);return n?this._buildHeartButton(n,e):(console.warn("SPAN Panel: circuit has no current/power sensor; favorite heart suppressed"),null)}_buildHeartButton(t,e){const n=document.createElement("button");n.type="button",n.className=e?"fav-heart active":"fav-heart",n.dataset.role="fav-heart",n.title=i("sidepanel.save_to_favorites"),n.setAttribute("role","switch"),n.setAttribute("aria-checked",String(e)),n.setAttribute("aria-label",i("sidepanel.save_to_favorites"));const r=document.createElement("span-icon");return r.setAttribute("icon",e?"mdi:heart":"mdi:heart-outline"),n.appendChild(r),n.addEventListener("click",e=>{e.stopPropagation(),this._toggleFavoriteEntity(n,r,t).catch(()=>{})}),n}async _toggleFavoriteEntity(t,e,n){if(!this._hass)return;const r=t.classList.contains("active"),o=!r;t.classList.toggle("active",o),e.setAttribute("icon",o?"mdi:heart":"mdi:heart-outline"),t.setAttribute("aria-checked",String(o));try{o?await async function(t,e){const n=await nT(t,"add_favorite",{entity_id:e});return document.dispatchEvent(new CustomEvent(eT)),n?.favorites??{}}(this._hass,n):await async function(t,e){const n=await nT(t,"remove_favorite",{entity_id:e});return document.dispatchEvent(new CustomEvent(eT)),n?.favorites??{}}(this._hass,n)}catch(n){throw t.classList.toggle("active",r),e.setAttribute("icon",r?"mdi:heart":"mdi:heart-outline"),t.setAttribute("aria-checked",String(r)),console.warn("SPAN Panel: favorite toggle failed",n),this.errorStore?.add({key:"service:favorites",level:"error",message:i("error.favorites_toggle_failed"),persistent:!1}),n}}_renderFavoriteSection(t,e){const n=this._favoriteEntityId(e.entities);n&&this._appendFavoriteHeartSection(t,n,!0===e.isFavorite)}_appendFavoriteHeartSection(t,e,n){const r=document.createElement("div");r.className="section",r.innerHTML=``;const o=document.createElement("div");o.className="field-row";const a=document.createElement("span");a.className="field-label",a.textContent=i("sidepanel.save_to_favorites"),o.appendChild(a),o.appendChild(this._buildHeartButton(e,n)),r.appendChild(o),t.appendChild(r)}_renderSubDeviceMode(t,e){const n=this._createHeader(Rt(e.name),Rt(e.deviceType));t.appendChild(n);const i=document.createElement("div");i.className="panel-body",t.appendChild(i),e.showFavorites&&this._renderSubDeviceFavoriteSection(i,e),this._renderSubDeviceHorizonSection(i,e)}_renderSubDeviceFavoriteSection(t,e){const n=this._subDeviceFavoriteEntityId(e.entities);n&&this._appendFavoriteHeartSection(t,n,!0===e.isFavorite)}_renderSubDeviceHorizonSection(t,e){const n=document.createElement("div");n.className="section";const r=document.createElement("div");r.className="section-label",r.textContent=i("sidepanel.graph_horizon"),n.appendChild(r);const o=e.graphHorizonInfo,l=!0===o?.has_override,c=o?.horizon||a,u=o?.globalHorizon||a,h=document.createElement("div");h.className="horizon-bar";const d=[{key:"global",label:i("sidepanel.global")}];for(const t of Object.keys(s))d.push({key:t,label:t});const p=l?c:"global",f=t=>{for(const e of h.querySelectorAll(".horizon-segment")){const n=e.dataset.horizon;e.classList.toggle("active",n===t),e.classList.toggle("referenced","global"===t&&n===u)}};for(const{key:t,label:n}of d){const r=document.createElement("button");r.type="button",r.className="horizon-segment",r.dataset.horizon=t,r.textContent=n,r.classList.toggle("active",t===p),r.classList.toggle("referenced","global"===p&&t===u),r.addEventListener("click",()=>{if(r.classList.contains("active"))return;const n={subdevice_id:e.subDeviceId};e.configEntryId&&(n.config_entry_id=e.configEntryId),"global"===t?(f("global"),this._callDomainService("clear_subdevice_graph_horizon",n).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})):(f(t),this._callDomainService("set_subdevice_graph_horizon",{...n,horizon:t}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})}))}),h.appendChild(r)}n.appendChild(h),t.appendChild(n)}_createHeader(t,e){const n=document.createElement("div");n.className="panel-header";const i=document.createElement("div"),r=Rt(t),o=Rt(e);i.innerHTML=`
${r}
`+(o?`
${o}
`:"");const a=document.createElement("button");return a.className="close-btn",a.innerHTML="✕",a.addEventListener("click",()=>this.close()),n.appendChild(i),n.appendChild(a),n}_renderRelaySection(t,e){if(!1===e.is_user_controllable||!e.entities?.switch)return;const n=document.createElement("div");n.className="section",n.innerHTML=``;const r=document.createElement("div");r.className="field-row";const o=document.createElement("span");o.className="field-label",o.textContent=i("sidepanel.breaker");const a=document.createElement("span-switch");a.dataset.role="relay-toggle";const s=e.entities.switch,l=this._hass?.states?.[s]?.state;"on"===l&&a.setAttribute("checked",""),a.addEventListener("change",()=>{const t=a.hasAttribute("checked")||a.checked;this._callService("switch",t?"turn_on":"turn_off",{entity_id:s}).catch(t=>{console.warn("SPAN Panel: relay toggle failed",t),this.errorStore?.add({key:"service:relay",level:"error",message:i("error.relay_failed"),persistent:!1})})}),r.appendChild(o),r.appendChild(a),n.appendChild(r),t.appendChild(n)}_renderSheddingSection(t,e){if(!e.entities?.select)return;const n=document.createElement("div");n.className="section",n.innerHTML=``;const r=document.createElement("div");r.className="field-row";const o=document.createElement("span");o.className="field-label",o.textContent=i("sidepanel.priority_label");const a=document.createElement("select");a.dataset.role="shedding-select";const s=e.entities.select,l=this._hass?.states?.[s]?.state||"";for(const t of iT){const e=m[t];if(!e)continue;const n=document.createElement("option");n.value=t,n.textContent=i(`shedding.select.${t}`)||e.label(),t===l&&(n.selected=!0),a.appendChild(n)}a.addEventListener("change",()=>{this._callService("select","select_option",{entity_id:s,option:a.value}).catch(t=>{console.warn("SPAN Panel: shedding update failed",t),this.errorStore?.add({key:"service:shedding",level:"error",message:i("error.shedding_failed"),persistent:!1})})}),r.appendChild(o),r.appendChild(a),n.appendChild(r),t.appendChild(n)}_renderGraphHorizonSection(t,e){const n=document.createElement("div");n.className="section";const r=document.createElement("div");r.className="section-label",r.textContent=i("sidepanel.graph_horizon"),n.appendChild(r);const o=e.graphHorizonInfo,l=!0===o?.has_override,c=o?.horizon||a,u=o?.globalHorizon||a,h=document.createElement("div");h.className="horizon-bar";const d=[{key:"global",label:i("sidepanel.global")}];for(const t of Object.keys(s))d.push({key:t,label:t});const p=l?c:"global",f=t=>{for(const e of h.querySelectorAll(".horizon-segment")){const n=e.dataset.horizon;e.classList.toggle("active",n===t),e.classList.toggle("referenced","global"===t&&n===u)}};for(const{key:t,label:n}of d){const r=document.createElement("button");r.type="button",r.className="horizon-segment",r.dataset.horizon=t,r.textContent=n,r.classList.toggle("active",t===p),r.classList.toggle("referenced","global"===p&&t===u),r.addEventListener("click",()=>{if(r.classList.contains("active"))return;const n={circuit_id:e.uuid};e.configEntryId&&(n.config_entry_id=e.configEntryId),"global"===t?(f("global"),this._callDomainService("clear_circuit_graph_horizon",n).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})):(f(t),this._callDomainService("set_circuit_graph_horizon",{...n,horizon:t}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})}))}),h.appendChild(r)}n.appendChild(h),t.appendChild(n)}_renderMonitoringSection(t,e){const n=document.createElement("div");n.className="section";const r=document.createElement("div");r.className="monitoring-header";const o=document.createElement("div");o.className="section-label",o.textContent=i("sidepanel.monitoring"),o.style.margin="0";const a=document.createElement("span-switch");a.dataset.role="monitoring-toggle";const s=e.monitoringInfo,l=null!=s&&!1!==s.monitoring_enabled;l&&a.setAttribute("checked",""),r.appendChild(o),r.appendChild(a),n.appendChild(r);const c=document.createElement("div");c.dataset.role="monitoring-details",c.style.display=l?"block":"none",n.appendChild(c);const u=!0===s?.has_override,h=document.createElement("div");h.className="radio-group",h.innerHTML=`\n \n \n `,c.appendChild(h);const d=document.createElement("div");d.dataset.role="threshold-fields",d.style.display=u?"block":"none";const p=s?.continuous_threshold_pct??80,f=s?.spike_threshold_pct??100,g=s?.window_duration_m??15,v=s?.cooldown_duration_m??15;d.appendChild(this._createThresholdRow(i("sidepanel.continuous_pct"),"continuous",p,e)),d.appendChild(this._createThresholdRow(i("sidepanel.spike_pct"),"spike",f,e)),d.appendChild(this._createDurationRow(i("sidepanel.window_duration"),"window-m",g,1,180,"m",e)),d.appendChild(this._createDurationRow(i("sidepanel.cooldown"),"cooldown-m",v,1,180,"m",e)),c.appendChild(d),a.addEventListener("change",()=>{const t=a.checked;c.style.display=t?"block":"none";const n={circuit_id:e.entities?.power||e.uuid,monitoring_enabled:t};e.configEntryId&&(n.config_entry_id=e.configEntryId),this._callDomainService("set_circuit_threshold",n).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:i("error.threshold_failed"),persistent:!1})})});const m=h.querySelectorAll('input[type="radio"]');for(const t of m)t.addEventListener("change",()=>{const n="custom"===t.value&&t.checked;if(d.style.display=n?"block":"none",!n&&t.checked){const t={circuit_id:e.entities?.power||e.uuid};e.configEntryId&&(t.config_entry_id=e.configEntryId),this._callDomainService("clear_circuit_threshold",t).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:i("error.threshold_failed"),persistent:!1})})}});t.appendChild(n)}_createThresholdRow(t,e,n,r){const o=document.createElement("div");o.className="field-row";const a=document.createElement("span");a.className="field-label",a.textContent=t;const s=document.createElement("input");return s.type="number",s.min="0",s.max="200",s.value=String(n),s.dataset.role=`threshold-${e}`,s.addEventListener("input",()=>{this._debounce(`threshold-${e}`,f,()=>{const t=this.shadowRoot;if(!t)return;const e=t.querySelector('[data-role="threshold-continuous"]'),n=t.querySelector('[data-role="threshold-spike"]'),o=t.querySelector('[data-role="threshold-window-m"]'),a=t.querySelector('[data-role="threshold-cooldown-m"]'),s={circuit_id:r.entities?.power||r.uuid,continuous_threshold_pct:e?Number(e.value):void 0,spike_threshold_pct:n?Number(n.value):void 0,window_duration_m:o?Number(o.value):void 0,cooldown_duration_m:a?Number(a.value):void 0};r.configEntryId&&(s.config_entry_id=r.configEntryId),this._callDomainService("set_circuit_threshold",s).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:i("error.threshold_failed"),persistent:!1})})})}),o.appendChild(a),o.appendChild(s),o}_createDurationRow(t,e,n,r,o,a,s,l=!1){const c=document.createElement("div");c.className="field-row";const u=document.createElement("span");u.className="field-label",u.textContent=t;const h=document.createElement("div"),d=document.createElement("input");d.type="number",d.min=String(r),d.max=String(o),d.value=String(n),d.dataset.role=`threshold-${e}`,l&&(d.disabled=!0);const p=document.createElement("span");return p.textContent=a,h.appendChild(d),h.appendChild(p),l||d.addEventListener("input",()=>{this._debounce(`threshold-${e}`,f,()=>{const t=this.shadowRoot;if(!t)return;const e=t.querySelector('[data-role="threshold-continuous"]'),n=t.querySelector('[data-role="threshold-spike"]'),r=t.querySelector('[data-role="threshold-window-m"]'),o={circuit_id:s.uuid,continuous_threshold_pct:e?Number(e.value):void 0,spike_threshold_pct:n?Number(n.value):void 0,window_duration_m:r?Number(r.value):void 0};s.configEntryId&&(o.config_entry_id=s.configEntryId),this._callDomainService("set_circuit_threshold",o).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:i("error.threshold_failed"),persistent:!1})})})}),c.appendChild(u),c.appendChild(h),c}_updateLiveState(){if(!this._config||this._config.panelMode)return;const t=this._config;if(!t.subDeviceMode&&!t.favoritesMode){if(t.entities?.switch){const e=this.shadowRoot?.querySelector('[data-role="relay-toggle"]');if(e){const n=this._hass?.states?.[t.entities.switch]?.state;"on"===n?e.setAttribute("checked",""):e.removeAttribute("checked")}}if(t.entities?.select){const e=this.shadowRoot?.querySelector('[data-role="shedding-select"]');if(e){const n=this._hass?.states?.[t.entities.select]?.state||"";e.value=n}}}}_callService(t,e,n){return this._hass?Promise.resolve(this._hass.callService(t,e,n)):Promise.resolve()}_callDomainService(t,e){return this._hass?this._hass.callWS({type:"call_service",domain:l,service:t,service_data:e}):Promise.resolve()}_debounce(t,e,n){this._debounceTimers[t]&&clearTimeout(this._debounceTimers[t]),this._debounceTimers[t]=setTimeout(()=>{delete this._debounceTimers[t],n()},e)}}try{customElements.get("span-side-panel")||customElements.define("span-side-panel",rT)}catch{}class oT extends At{constructor(){super(...arguments),this._store=null,this._unsub=null,this._errors=[]}set store(t){if(this._store===t)return;this._unsub?.(),this._unsub=null,this._store=t,this._errors=t.active;const e=t;this._unsub=t.subscribe(()=>{this._errors=e.active})}connectedCallback(){if(super.connectedCallback(),this._store&&!this._unsub){const t=this._store;this._errors=t.active,this._unsub=t.subscribe(()=>{this._errors=t.active})}}disconnectedCallback(){super.disconnectedCallback(),this._unsub?.(),this._unsub=null}render(){return 0===this._errors.length?ft:ht`${this._errors.map(t=>ht` + `,b([Ot({attribute:!1})],Lk.prototype,"options",void 0),b([Ot({attribute:!1})],Lk.prototype,"data",void 0),b([Ot({type:String})],Lk.prototype,"height",void 0);try{customElements.get("span-chart")||customElements.define("span-chart",Lk)}catch{}function Ek(t,e,n,i,r,a,s,l,c){const{options:u,series:d}=function(t,e,n,i,r,a=!1){n||(n=g[o]);const s=i?"140, 160, 220":"77, 217, 175",l=`rgb(${s})`,c=Date.now(),u=c-e,d=void 0!==n.fixedMin&&void 0!==n.fixedMax,h=(t??[]).filter(t=>t.time>=u).map(t=>[t.time,Math.abs(t.value)]),p=[{type:"line",data:h,showSymbol:!1,smooth:!1,...a?{}:{step:"end"},lineStyle:{width:1.5,color:l},areaStyle:{color:{type:"linear",x:0,y:0,x2:0,y2:1,colorStops:[{offset:0,color:`rgba(${s}, 0.18)`},{offset:1,color:`rgba(${s}, 0.18)`}]}},itemStyle:{color:l}}],f=h.length>0?function(t){let e=0;for(const n of t)n[1]>e&&(e=n[1]);return e}(h):0,v={type:"value",splitNumber:4,axisLabel:{fontSize:10,formatter:f<10?t=>0===t?"0":t.toFixed(1):t=>n.format(t)},splitLine:{lineStyle:{opacity:.15}}};d?(v.min=n.fixedMin,v.max=n.fixedMax):f<1&&(v.min=0,v.max=1),r&&"current"===n.entityRole&&(v.min=0,v.max=Math.ceil(1.25*r),p.push({type:"line",data:[[u,.8*r],[c,.8*r]],showSymbol:!1,lineStyle:{width:1,color:"rgba(255, 200, 40, 0.6)",type:"dashed"},itemStyle:{color:"transparent"},tooltip:{show:!1}}),p.push({type:"line",data:[[u,r],[c,r]],showSymbol:!1,lineStyle:{width:1.5,color:"rgba(255, 60, 60, 0.7)",type:"solid"},itemStyle:{color:"transparent"},tooltip:{show:!1}}));const m={xAxis:{type:"time",min:u,max:c,axisLabel:{fontSize:10},splitLine:{show:!1}},yAxis:v,grid:{top:8,right:4,bottom:0,left:0,containLabel:!0},tooltip:{trigger:"axis",axisPointer:{type:"line",lineStyle:{type:"dashed"}},formatter:t=>{if(!t||0===t.length)return"";const e=t[0],i=new Date(e.value[0]).toLocaleString(void 0,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit"}),r=parseFloat(e.value[1].toFixed(2));return`
${i}
${n.format(r)} ${n.unit(r)}
`}},animation:!1};return{options:m,series:p}}(n,i,r,a,l,c),h=s??120;t.style.minHeight=h+"px";let p=t.querySelector("span-chart");p||(p=document.createElement("span-chart"),p.style.display="block",p.style.width="100%",t.innerHTML="",t.appendChild(p));const f=t.clientHeight;p.height=(f>0?f:h)+"px",p.options=u,p.data=d}function Ok(t){return"function"==typeof globalThis.CSS?.escape?CSS.escape(t):t.replace(/["\\]/g,"\\$&")}function zk(t,e,n,i,r){const o=t.querySelector(".panel-stats");o&&function(t,e,n,i,r){const o="current"===(i.chart_metric||"power"),a=t.querySelector(".stat-consumption .stat-value"),s=t.querySelector(".stat-consumption .stat-unit");if(o){const t=n.panel_entities?.site_power,i=t?e.states[t]:null,r=i?parseFloat(i.attributes?.amperage):NaN;a&&(a.textContent=Number.isFinite(r)?Math.abs(r).toFixed(1):"--"),s&&(s.textContent="A")}else{let t=r;const i=n.panel_entities?.site_power;if(i){const n=e.states[i];n&&(t=Math.abs(parseFloat(n.state)||0))}a&&(a.textContent=Gt(t)),s&&(s.textContent="kW")}const l=t.querySelector(".stat-upstream .stat-value"),c=t.querySelector(".stat-upstream .stat-unit");if(l){const t=n.panel_entities?.current_power,i=t?e.states[t]:null;if(o){const t=i?parseFloat(i.attributes?.amperage):NaN;l.textContent=Number.isFinite(t)?Math.abs(t).toFixed(1):"--",c&&(c.textContent="A")}else{const t=i?Math.abs(parseFloat(i.state)||0):0;l.textContent=Gt(t),c&&(c.textContent="kW")}}const u=t.querySelector(".stat-downstream .stat-value"),d=t.querySelector(".stat-downstream .stat-unit");if(u){const t=n.panel_entities?.feedthrough_power,i=t?e.states[t]:null;if(o){const t=i?parseFloat(i.attributes?.amperage):NaN;u.textContent=Number.isFinite(t)?Math.abs(t).toFixed(1):"--",d&&(d.textContent="A")}else{const t=i?Math.abs(parseFloat(i.state)||0):0;u.textContent=Gt(t),d&&(d.textContent="kW")}}const h=t.querySelector(".stat-solar .stat-value"),p=t.querySelector(".stat-solar .stat-unit");if(h){const t=n.panel_entities?.pv_power,i=t?e.states[t]:null;if(o){const t=i?parseFloat(i.attributes?.amperage):NaN;h.textContent=Number.isFinite(t)?Math.abs(t).toFixed(1):"--",p&&(p.textContent="A")}else{if(i){const t=Math.abs(parseFloat(i.state)||0);h.textContent=Gt(t)}else h.textContent="--";p&&(p.textContent="kW")}}const f=t.querySelector(".stat-battery .stat-value");if(f){const t=n.panel_entities?.battery_level,i=t?e.states[t]:null;i&&(f.textContent=`${Math.round(parseFloat(i.state)||0)}`)}const g=t.querySelector(".stat-grid-state .stat-value");if(g){const t=n.panel_entities?.dsm_state,i=t?e.states[t]:null;g.textContent=i?e.formatEntityState?.(i)||i.state:"--"}}(o,e,n,i,r)}class Nk{get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t,this._retry=t?new Qt(t):null}constructor(){this._errorStore=null,this._retry=null,this._settings=null,this._lastFetch=0,this._fetching=!1}async fetch(t,e){const n=Date.now();if(this._fetching)return this._settings;if(this._settings&&n-this._lastFetch<3e4)return this._settings;this._fetching=!0;try{const n={};e&&(n.config_entry_id=e);const r={type:"call_service",domain:l,service:"get_graph_settings",service_data:n,return_response:!0},o=this._retry?await this._retry.callWS(t,r,{errorId:"fetch:graph_settings",errorMessage:i("error.graph_settings_failed")}):await t.callWS(r);this._settings=o?.response??null,this._lastFetch=Date.now()}catch(t){console.warn("SPAN Panel: graph settings fetch failed",t),this._settings=null,this._retry||this._errorStore?.add({key:"fetch:graph_settings",level:"warning",message:i("error.graph_settings_failed"),persistent:!1})}finally{this._fetching=!1}return this._settings}invalidate(){this._lastFetch=0}get settings(){return this._settings}clear(){this._settings=null,this._lastFetch=0}}function Rk(t,e){if(!t)return a;const n=t.circuits?.[e];return n?.has_override?n.horizon:t.global_horizon??a}function Hk(t,e){if(!t)return a;const n=t.sub_devices?.[e];return n?.has_override?n.horizon:t.global_horizon??a}class Bk{constructor(){this.powerHistory=new Map,this.horizonMap=new Map,this.subDeviceHorizonMap=new Map,this.monitoringCache=new Jt,this.monitoringMultiCache=new te,this.graphSettingsCache=new Nk,this._errorStore=null,this._hass=null,this._topology=null,this._config=null,this._configEntryId=null,this._favRefs=null,this._perPanelInfo=new Map,this._panelFavorites=null,this._showMonitoring=!1,this._updateInterval=null,this._recorderRefreshInterval=null,this._resizeObserver=null,this._lastWidth=0,this._resizeDebounce=null}get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t,this.monitoringCache.errorStore=t,this.graphSettingsCache.errorStore=t,this.monitoringMultiCache.errorStore=t}get hass(){return this._hass}set hass(t){this._hass=t}get topology(){return this._topology}get config(){return this._config}set showMonitoring(t){this._showMonitoring=t}init(t,e,n,i){this._topology=t,this._config=e,this._hass=n,this._configEntryId=i}setFavoriteRefs(t){this._favRefs=t}clearFavoriteRefs(){this._favRefs=null}setPanelFavorites(t){this._panelFavorites=t}setFavoritesPerPanelInfo(t){this._perPanelInfo=t??new Map}get _inFavoritesView(){return null!==this._favRefs}setConfig(t){this._config=t}buildHorizonMaps(t){if(this.horizonMap.clear(),this.subDeviceHorizonMap.clear(),t&&this._topology?.circuits)for(const e of Object.keys(this._topology.circuits))this.horizonMap.set(e,Rk(t,e));if(t&&this._topology?.sub_devices)for(const e of Object.keys(this._topology.sub_devices))this.subDeviceHorizonMap.set(e,Hk(t,e))}async fetchAndBuildHorizonMaps(){try{this._favRefs?await this._buildFavoritesHorizonMaps():(await this.graphSettingsCache.fetch(this._hass,this._configEntryId),this.buildHorizonMaps(this.graphSettingsCache.settings))}catch(t){console.warn("SPAN Panel: graph settings fetch failed",t),this.graphSettingsCache.errorStore||this._errorStore?.add({key:"fetch:graph_settings",level:"warning",message:i("error.graph_settings_failed"),persistent:!1})}}async fetchMergedMonitoringStatus(t){if(!this._hass||0===t.length)return null;const e=this._hass;return function(t){let e=!1;const n={},i={};for(const r of t)r&&(e=!0,r.circuits&&Object.assign(n,r.circuits),r.mains&&Object.assign(i,r.mains));return e?{circuits:n,mains:i}:null}(await Promise.all(t.map(t=>this.monitoringMultiCache.fetchOne(e,t))))}async _buildFavoritesHorizonMaps(){if(!this._hass||!this._favRefs||!this._topology)return;const t=new Set;for(const e of Object.values(this._favRefs))e.configEntryId&&t.add(e.configEntryId);const e=new Map;await Promise.all(Array.from(t).map(async t=>{e.set(t,await this._fetchGraphSettingsFresh(t))})),this.horizonMap.clear(),this.subDeviceHorizonMap.clear();for(const t of Object.keys(this._topology.circuits)){const n=this._favRefs[t],i=n?.configEntryId?e.get(n.configEntryId)??null:null,r=n?.targetId??t;this.horizonMap.set(t,Rk(i,r))}if(this._topology.sub_devices)for(const t of Object.keys(this._topology.sub_devices)){const n=this._favRefs[t],i=n?.configEntryId?e.get(n.configEntryId)??null:null,r=n?.targetId??t;this.subDeviceHorizonMap.set(t,Hk(i,r))}}async loadHistory(){await Me(this._hass,this._topology,this._config,this.powerHistory,this.horizonMap,this.subDeviceHorizonMap)}recordSamples(){if(!this._topology||!this._hass||!this._config)return;const t=Date.now();for(const[e,n]of Object.entries(this._topology.circuits)){const i=this.horizonMap.get(e)??a;if(!s[i]?.useRealtime)continue;const r=Zt(n,this._config);if(!r)continue;const o=this._hass.states[r];if(!o)continue;const l=parseFloat(o.state);if(isNaN(l))continue;const c=me(i),u=ye(c),d=_e(c),h=t-c,p=this.powerHistory.get(e)??[];p.length>0&&t-p[p.length-1].time0&&t-p[p.length-1].time0&&this._topology)for(const{key:t,devId:e}of Ce(this._topology))n.has(e)&&r.add(t);const o=new Map;try{await Me(this._hass,this._topology,this._config,o,e,n);for(const t of e.keys()){const e=o.get(t);e?this.powerHistory.set(t,e):this.powerHistory.delete(t)}for(const t of r){const e=o.get(t);e?this.powerHistory.set(t,e):this.powerHistory.delete(t)}this.updateDOM(t)}catch(t){console.warn("SPAN Panel: history refresh failed",t),this._errorStore?.add({key:"fetch:history",level:"warning",message:i("error.history_failed"),persistent:!1})}}updateDOM(t){this._hass&&this._topology&&this._config&&(function(t,e,n,r,o,a){if(!t||!n||!e)return;const s=ve(r);let l=0;for(const[,t]of Object.entries(n.circuits)){const n=t.entities?.power;if(!n)continue;const i=e.states[n],r=i&&parseFloat(i.state)||0;t.device_type!==u&&(l+=Math.abs(r))}zk(t,e,n,r,l);const d=Yt(r),h="current"===d.entityRole;for(const[r,l]of Object.entries(n.circuits)){const n=t.querySelector(`.circuit-slot[data-uuid="${Ok(r)}"]`);if(!n)continue;const p=l.entities?.power,f=p?e.states[p]:null,g=f&&parseFloat(f.state)||0,v=l.device_type===u||g<0,y=l.entities?.switch,_=y?e.states[y]:null,b=_?"on"===_.state:(f?.attributes?.relay_state||l.relay_state)===c,x=n.querySelector(".power-value");if(x)if(h){const t=l.entities?.current,n=t?e.states[t]:null,i=n&&parseFloat(n.state)||0;x.innerHTML=`${d.format(i)}A`}else x.innerHTML=`${Ut(g)}${Wt(g)}`;const w=n.querySelector(".toggle-pill");if(w){w.className="toggle-pill "+(b?"toggle-on":"toggle-off");const t=w.querySelector(".toggle-label");t&&(t.textContent=i(b?"grid.on":"grid.off"))}let S;if(n.classList.toggle("circuit-off",!b),n.classList.toggle("circuit-producer",v),l.always_on)S="always_on";else{const t=l.entities?.select,n=t?e.states[t]:null;S=n?n.state:"unknown"}const C=m[S]??m.unknown,M=n.querySelector(".shedding-icon");M&&(M.setAttribute("icon",C.icon),M.style.color=C.color,M.title=C.label());const k=n.querySelector(".shedding-icon-secondary");k&&(C.icon2?(k.setAttribute("icon",C.icon2),k.style.color=C.color,k.style.display=""):k.style.display="none");const T=n.querySelector(".shedding-label");T&&(C.textLabel?(T.textContent=C.textLabel,T.style.color=C.color,T.style.display=""):T.style.display="none");const A=n.querySelector(".chart-container");if(A){const t=o.get(r)||[],e=n.classList.contains("circuit-col-span")?200:100,i=a?.has(r)?me(a.get(r)):s,c=l.device_type===u;Ek(A,0,t,i,d,v,e,l.breaker_rating_a??void 0,c)}}}(t,this._hass,this._topology,this._config,this.powerHistory,this.horizonMap),function(t,e,n,i,r,o){if(!n.sub_devices)return;const a=ve(i);for(const[i,s]of Object.entries(n.sub_devices)){const n=t.querySelector(`[data-subdev="${Ok(i)}"]`);if(!n)continue;const l=ue(s);if(l){const t=e.states[l],i=t&&parseFloat(t.state)||0,r=n.querySelector(".sub-power-value");r&&(r.innerHTML=`${Ut(i)} ${Wt(i)}`)}const c=n.querySelectorAll("[data-chart-key]");for(const t of c){const e=t.dataset.chartKey;if(!e)continue;const n=r.get(e)||[];let s=v.power;e.endsWith("_soc")?s=v.soc:e.endsWith("_soe")&&(s=v.soe);const l=!!t.closest(".bess-chart-col");Ek(t,0,n,o?.has(i)?me(o.get(i)):a,s,!1,l?120:150,void 0,e.endsWith("_soc")||e.endsWith("_soe"))}for(const t of Object.keys(s.entities||{})){const i=n.querySelector(`[data-eid="${Ok(t)}"]`);if(!i)continue;const r=e.states[t];if(r){let t;if(e.formatEntityState)t=e.formatEntityState(r);else{t=r.state;const e=r.attributes.unit_of_measurement||"";e&&(t+=" "+e)}if("Wh"===(r.attributes.unit_of_measurement||"")){const e=parseFloat(r.state);isNaN(e)||(t=(e/1e3).toFixed(1)+" kWh")}i.textContent=t}}}}(t,this._hass,this._topology,this._config,this.powerHistory,this.subDeviceHorizonMap))}async onGraphSettingsChanged(t){if(this._hass){this._favRefs?await this._buildFavoritesHorizonMaps():(this.graphSettingsCache.invalidate(),await this.graphSettingsCache.fetch(this._hass,this._configEntryId),this.buildHorizonMaps(this.graphSettingsCache.settings)),this.powerHistory.clear();try{await this.loadHistory()}catch{}this.updateDOM(t)}}onToggleClick(t,e){const n=t.target,r=n?.closest(".toggle-pill");if(!r)return;const o=e.querySelector(".slide-confirm");if(!o||!o.classList.contains("confirmed"))return;t.stopPropagation(),t.preventDefault();const a=r.closest("[data-uuid]");if(!a||!this._topology||!this._hass)return;const s=a.dataset.uuid;if(!s)return;const l=this._topology.circuits[s];if(!l)return;const c=l.entities?.switch;if(!c)return;const u=this._hass.states[c];if(!u)return void console.warn("SPAN Panel: switch entity not found:",c);const d="on"===u.state?"turn_off":"turn_on";this._hass.callService("switch",d,{},{entity_id:c}).catch(t=>{console.warn("SPAN Panel: switch service call failed",t),this._errorStore?.add({key:"service:relay",level:"error",message:i("error.relay_failed"),persistent:!1})})}async onGearClick(t,e){const n=t.target,i=n?.closest(".gear-icon");if(!i)return;const r=e.querySelector("span-side-panel");if(!r||!this._hass)return;if(r.hass=this._hass,r.errorStore=this.errorStore,i.classList.contains("panel-gear")){if(this._inFavoritesView){const t=await this._buildFavoritesSections();if(0===t.length)return;return void r.open({favoritesMode:!0,perPanelSections:t})}return await this.graphSettingsCache.fetch(this._hass,this._configEntryId),void r.open({panelMode:!0,topology:this._topology,graphSettings:this.graphSettingsCache.settings,showFavorites:null!==this._panelFavorites,favoritePanelDeviceId:this._panelFavorites?.panelDeviceId,favoriteCircuitUuids:this._panelFavorites?.circuitUuids,favoriteSubDeviceIds:this._panelFavorites?.subDeviceIds,configEntryId:this._configEntryId})}const o=i.dataset.uuid;if(o&&this._topology){const t=this._topology.circuits[o];if(t){const e=this._favRefs?.[o]??null,n=e&&"circuit"===e.kind?e.targetId:o,i=e?.configEntryId??this._configEntryId;let s,l;e?[s,l]=await Promise.all([this._fetchGraphSettingsFresh(i),this._fetchMonitoringStatusFresh(i)]):(await Promise.all([this.graphSettingsCache.fetch(this._hass,i),this.monitoringCache.fetch(this._hass,i)]),s=this.graphSettingsCache.settings,l=this.monitoringCache.status);const c=t.entities?.current??t.entities?.power,u=c?l?.circuits?.[c]??null:null,d=s?.global_horizon??a,h=s?.circuits?.[n],p=h?{...h,globalHorizon:d}:{horizon:d,has_override:!1,globalHorizon:d},f=e?.panelDeviceId??this._panelFavorites?.panelDeviceId,g=null!==e||(this._panelFavorites?.circuitUuids.has(n)??!1),v=this._inFavoritesView||null!==this._panelFavorites;return void r.open({...t,uuid:n,monitoringInfo:u,showMonitoring:this._showMonitoring,graphHorizonInfo:p,showFavorites:v,favoritePanelDeviceId:f,isFavorite:g,configEntryId:i})}}const s=i.dataset.subdevId;if(s&&this._topology?.sub_devices?.[s]){const t=this._topology.sub_devices[s],e=this._favRefs?.[s]??null,n=e&&"sub_device"===e.kind?e.targetId:s,i=e?.configEntryId??this._configEntryId;let o;e?o=await this._fetchGraphSettingsFresh(i):(await this.graphSettingsCache.fetch(this._hass,i),o=this.graphSettingsCache.settings);const l=o?.global_horizon??a,c=o?.sub_devices?.[n],u=c?{...c,globalHorizon:l}:{horizon:l,has_override:!1,globalHorizon:l},d=e?.panelDeviceId??this._panelFavorites?.panelDeviceId,h=null!==e||(this._panelFavorites?.subDeviceIds.has(n)??!1),p=this._inFavoritesView||null!==this._panelFavorites;r.open({subDeviceMode:!0,subDeviceId:n,name:t.name??n,deviceType:t.type??"",entities:t.entities,graphHorizonInfo:u,showFavorites:p,favoritePanelDeviceId:d,isFavorite:h,configEntryId:i})}}async _buildFavoritesSections(){if(!this._hass||!this._favRefs)return[];const t=function(t,e){const n=new Map;for(const i of Object.values(t)){if("circuit"!==i.kind)continue;const t=e.get(i.panelDeviceId);if(void 0===t)continue;let r=n.get(i.panelDeviceId);void 0===r&&(r={panelDeviceId:i.panelDeviceId,panelName:t.panelName,topology:t.topology,configEntryId:t.configEntryId,favoriteCircuitUuids:new Set},n.set(i.panelDeviceId,r)),r.favoriteCircuitUuids.add(i.targetId)}return Array.from(n.values()).sort((t,e)=>t.panelName.localeCompare(e.panelName))}(this._favRefs,this._perPanelInfo);if(0===t.length)return[];return await Promise.all(t.map(async t=>({panelDeviceId:t.panelDeviceId,panelName:t.panelName,topology:t.topology,graphSettings:await this._fetchGraphSettingsFresh(t.configEntryId),favoriteCircuitUuids:t.favoriteCircuitUuids,configEntryId:t.configEntryId})))}async _fetchGraphSettingsFresh(t){if(!this._hass)return null;try{const e={};t&&(e.config_entry_id=t);const n={type:"call_service",domain:l,service:"get_graph_settings",service_data:e,return_response:!0},r=this._errorStore?new Qt(this._errorStore):null,o=r?await r.callWS(this._hass,n,{errorId:"fetch:graph_settings",errorMessage:i("error.graph_settings_failed")}):await this._hass.callWS(n);return o?.response??null}catch(t){return console.warn("SPAN Panel: fresh graph settings fetch failed",t),null}}async _fetchMonitoringStatusFresh(t){if(!this._hass)return null;try{const e={};t&&(e.config_entry_id=t);const n={type:"call_service",domain:l,service:"get_monitoring_status",service_data:e,return_response:!0},r=this._errorStore?new Qt(this._errorStore):null,o=r?await r.callWS(this._hass,n,{errorId:"fetch:monitoring",errorMessage:i("error.monitoring_failed")}):await this._hass.callWS(n),a=o?.response;return a?{circuits:a.circuits,mains:a.mains}:null}catch(t){return console.warn("SPAN Panel: fresh monitoring status fetch failed",t),null}}bindSlideConfirm(t,e){const n=t.querySelector(".slide-confirm-knob"),i=t.querySelector(".slide-confirm-text");if(!n||!i)return;let r=!1,o=0,a=0;const s=e=>{t.classList.contains("confirmed")||(r=!0,o=e-n.offsetLeft,a=t.offsetWidth-n.offsetWidth-4,n.classList.remove("snapping"))},l=t=>{if(!r)return;const e=Math.max(2,Math.min(t-o,a));n.style.left=e+"px"},c=()=>{if(!r)return;r=!1;(n.offsetLeft-2)/a>=.9?(n.style.left=a+"px",t.classList.add("confirmed"),n.querySelector("span-icon")?.setAttribute("icon","mdi:lock-open"),i.textContent=t.dataset.textOn??"",e&&e.classList.remove("switches-disabled")):(n.classList.add("snapping"),n.style.left="2px")};n.addEventListener("mousedown",t=>{t.preventDefault(),s(t.clientX)}),t.addEventListener("mousemove",t=>l(t.clientX)),t.addEventListener("mouseup",c),t.addEventListener("mouseleave",c),n.addEventListener("touchstart",t=>{t.preventDefault(),s(t.touches[0].clientX)},{passive:!1}),t.addEventListener("touchmove",t=>l(t.touches[0].clientX),{passive:!0}),t.addEventListener("touchend",c),t.addEventListener("touchcancel",c),t.addEventListener("click",()=>{t.classList.contains("confirmed")&&(t.classList.remove("confirmed"),n.classList.add("snapping"),n.style.left="2px",n.querySelector("span-icon")?.setAttribute("icon","mdi:lock"),i.textContent=t.dataset.textOff??"",e&&e.classList.add("switches-disabled"))})}startIntervals(t,e){this._updateInterval=setInterval(()=>{this.recordSamples(),this.updateDOM(t),e&&e()},1e3),this._recorderRefreshInterval=setInterval(()=>{this.refreshRecorderData(t)},3e4)}stopIntervals(){this._updateInterval&&(clearInterval(this._updateInterval),this._updateInterval=null),this._recorderRefreshInterval&&(clearInterval(this._recorderRefreshInterval),this._recorderRefreshInterval=null),this.cleanupResizeObserver()}setupResizeObserver(t,e){this.cleanupResizeObserver(),e&&(this._lastWidth=e.clientWidth,this._resizeObserver=new ResizeObserver(e=>{const n=e[0];if(!n)return;const i=n.contentRect.width;Math.abs(i-this._lastWidth)<5||(this._lastWidth=i,this._resizeDebounce&&clearTimeout(this._resizeDebounce),this._resizeDebounce=setTimeout(()=>{for(const e of t.querySelectorAll(".chart-container")){const t=e.querySelector("span-chart");t&&t.remove()}this.updateDOM(t)},150))}),this._resizeObserver.observe(e))}cleanupResizeObserver(){this._resizeObserver&&(this._resizeObserver.disconnect(),this._resizeObserver=null),this._resizeDebounce&&(clearTimeout(this._resizeDebounce),this._resizeDebounce=null)}reset(){this.powerHistory.clear(),this.horizonMap.clear(),this.subDeviceHorizonMap.clear(),this.monitoringCache.clear(),this.monitoringMultiCache.clear(),this.graphSettingsCache.clear()}}function Fk(t=""){const e=t?` value="${Rt(t)}"`:"",n=t?"":"display:none;";return`\n
\n \n \n
\n `}function $k(t,e,n,r,o,a,s){const l=e.entities?.power,u=l?n.states[l]:null,d=u&&parseFloat(u.state)||0,h=e.entities?.switch,p=h?n.states[h]:null,f=p?"on"===p.state:(u?.attributes?.relay_state||e.relay_state)===c,g=e.breaker_rating_a,v=g?`${Math.round(g)}A`:"",y=Rt(e.name||i("grid.unknown")),_=Yt(r),b="current"===_.entityRole;let x;if(f)if(b){const t=e.entities?.current,i=t?n.states[t]:null,r=i&&parseFloat(i.state)||0;x=`${_.format(r)}A`}else x=`${Ut(d)}${Wt(d)}`;else x="";const w=a||"unknown";let S="";if("unknown"!==w){const t=m[w]??m.unknown??{icon:"mdi:help",color:"#999",label:()=>"Unknown"};S=t.icon2?`\n \n \n `:t.textLabel?`\n \n ${t.textLabel}\n `:``}let C="",M=o?.utilization_pct??null;if(null==M&&e.breaker_rating_a){const t=e.entities?.current,i=t?n.states[t]:null,r=i?Math.abs(parseFloat(i.state)||0):0;M=Math.round(r/e.breaker_rating_a*1e3)/10}if(null!=M){C=`=80?"utilization-warning":"utilization-normal"}">${Math.round(M)}%`}const k=``,T=!1!==e.is_user_controllable&&!!e.entities?.switch?`
\n ${i(f?"grid.on":"grid.off")}\n \n
`:`${f?"ON":"OFF"}`;return`\n
\n ${v?`${v}`:""}\n ${C}\n ${y}\n ${S}\n ${T}\n \n ${x}\n \n ${k}\n \n
\n `}function Vk(t,e,n,i,r){const o=e.entities?.power,a=o?n.states[o]:null,s=a&&parseFloat(a.state)||0,l=e.device_type===u||s<0,d=e.entities?.switch,h=d?n.states[d]:null,p=ne(0,r,h?"on"===h.state:(a?.attributes?.relay_state||e.relay_state)===c,l),f=Rt(t);return`\n
\n
\n
\n
\n
\n `}function Wk(t){return`
${Rt(t)}
`}function Uk(t,e){const n=e.hysteresisPx??48,i=new WeakSet;let r=null;const o=t=>{const n=t.querySelector(e.nameSelector);return!!n&&(0!==t.clientWidth&&(n.clientWidth<1||n.scrollWidth>n.clientWidth+1))},a=()=>{const i=t.querySelectorAll(e.rowSelector);if(0===i.length)return;const a=i[0].clientWidth;if(0===a)return;if(null!==r){if(a>r+n){for(const t of i)t.classList.remove(e.foldClass);r=null}else for(const t of i)t.classList.add(e.foldClass);return}let s=!1;for(const t of i)if(o(t)){s=!0;break}if(s){for(const t of i)t.classList.add(e.foldClass);r=a}},s=new ResizeObserver(()=>a()),l=()=>{const n=t.querySelectorAll(e.rowSelector);for(const t of n)i.has(t)||(i.add(t),s.observe(t));requestAnimationFrame(()=>a())};l();const c=new MutationObserver(t=>{(t=>{const n=t=>{for(const n of t)if(n instanceof Element){if(n.matches(e.rowSelector))return!0;if(n.querySelector(e.rowSelector))return!0}return!1};for(const e of t)if(n(e.addedNodes)||n(e.removedNodes))return!0;return!1})(t)&&l()});return c.observe(t,{childList:!0,subtree:!0}),()=>{s.disconnect(),c.disconnect()}}function Gk(t,e,n){const i=t.entities?.switch,r=i?e.states[i]:null,o=t.entities?.power,a=o?e.states[o]:null,s=r?"on"===r.state:(a?.attributes?.relay_state||t.relay_state)===c;let l;if("current"===(n.chart_metric||"power")){const n=t.entities?.current,i=n?e.states[n]:null;l=i?Math.abs(parseFloat(i.state)||0):0}else l=a?Math.abs(parseFloat(a.state)||0):0;return{isOn:s,value:l}}function qk(t,e){if(t.always_on)return"always_on";const n=t.entities?.select,i=n?e.states[n]:null;return i?i.state:"unknown"}function jk(t,e,n,i){const r=Gk(t,n,i),o=Gk(e,n,i);return r.isOn&&!o.isOn?-1:!r.isOn&&o.isOn?1:o.value-r.value}function Xk(t,e,n){return t.sort((t,i)=>jk(t[1],i[1],e,n))}function Yk(t){return t.entities?.current??t.entities?.power??""}class Zk{constructor(t){this._expandedUuids=new Set,this._searchQuery="",this._container=null,this._clickHandler=null,this._inputHandler=null,this._graphSettingsHandler=null,this._hass=null,this._topology=null,this._config=null,this._monitoringStatus=null,this._viewName=null,this._columns=1,this._foldUnobserve=null,this._ctrl=t}setColumns(t){const e=Math.max(1,Math.min(3,Math.floor(t)));this._columns=e}setInitialExpansion(t){this._expandedUuids=new Set(t)}setInitialSearchQuery(t){this._searchQuery=t}setViewName(t){this._viewName=t}renderActivityView(t,e,n,i,r,o){this._unbindEvents(),this._hass=e,this._topology=n,this._config=i,this._monitoringStatus=r;const a=Xk(Object.entries(n.circuits),e,i);let s=o+Fk(this._searchQuery);s+=`
`;for(const[t,n]of a){const o=ee(r,Yk(n)),a=qk(n,e),l=this._expandedUuids.has(t);s+=`
`,s+=$k(t,n,e,i,o,a,l),l&&(s+=Vk(t,n,e,0,o)),s+="
"}s+="
",s+="",t.innerHTML=s;const l=t.querySelector("span-side-panel");l&&(l.hass=e,l.errorStore=this._ctrl.errorStore),this._bindEvents(t),this._searchQuery&&this._applyFilter(t),this._ctrl.updateDOM(t),this._attachFoldObserver(t)}renderAreaView(t,e,n,r,o,a){this._unbindEvents(),this._hass=e,this._topology=n,this._config=r,this._monitoringStatus=o;const s=i("list.unassigned_area"),l=new Map;for(const[t,e]of Object.entries(n.circuits)){const n=e.area??s,i=l.get(n);i?i.push([t,e]):l.set(n,[[t,e]])}const c=[...l.keys()].sort((t,e)=>t===s?1:e===s?-1:t.localeCompare(e));let u=a+Fk(this._searchQuery);u+=`
`;for(const t of c){const n=l.get(t);if(!n)continue;const i=Xk(n,e,r);u+=Wk(t);for(const[t,n]of i){const i=ee(o,Yk(n)),a=qk(n,e),s=this._expandedUuids.has(t);u+=`
`,u+=$k(t,n,e,r,i,a,s),s&&(u+=Vk(t,n,e,0,i)),u+="
"}}u+="
",u+="",t.innerHTML=u;const d=t.querySelector("span-side-panel");d&&(d.hass=e,d.errorStore=this._ctrl.errorStore),this._bindEvents(t),this._searchQuery&&this._applyFilter(t),this._ctrl.updateDOM(t),this._attachFoldObserver(t)}updateCollapsedRows(t,e,n,r){const o=Yt(r),a="current"===o.entityRole,s=t.querySelectorAll(".list-row[data-row-uuid]");for(const t of s){const s=t.dataset.rowUuid;if(!s)continue;const l=n.circuits[s];if(!l)continue;const{isOn:c,value:u}=Gk(l,e,r),d=t.querySelector(".list-power-value");if(d)if(c)if(a)d.innerHTML=`${o.format(u)}A`;else{const t=l.entities?.power,n=t?e.states[t]:null,i=n&&parseFloat(n.state)||0;d.innerHTML=`${Ut(i)}${Wt(i)}`}else d.innerHTML="";const h=t.querySelector(".toggle-pill");if(h){h.classList.toggle("toggle-on",c),h.classList.toggle("toggle-off",!c);const t=h.querySelector(".toggle-label");t&&(t.textContent=i(c?"grid.on":"grid.off"))}const p=t.querySelector(".list-status-badge");p&&(p.textContent=c?"ON":"OFF",p.classList.toggle("list-status-on",c),p.classList.toggle("list-status-off",!c)),t.classList.toggle("circuit-off",!c)}!function(t,e,n,i){const r=t.querySelector(".list-view");if(r)for(const t of function(t,e){let n={anchor:null,units:[]};const i=[n];for(const r of[...t.children])if(r.classList.contains("area-header"))n={anchor:r,units:[]},i.push(n);else if(r.classList.contains("list-cell")){const t=r.dataset.cellUuid,i=t?e.circuits[t]:void 0;t&&i&&n.units.push({cell:r,uuid:t,circuit:i})}return i}(r,n)){if(t.units.length<2)continue;const n=[...t.units].sort((t,n)=>jk(t.circuit,n.circuit,e,i));if(!n.some((e,n)=>e.uuid!==t.units[n].uuid))continue;let o=t.anchor;for(const t of n)o?o.after(t.cell):r.prepend(t.cell),o=t.cell}}(t,e,n,r)}stop(){this._unbindEvents(),null===this._viewName&&(this._expandedUuids.clear(),this._searchQuery=""),this._hass=null,this._topology=null,this._config=null,this._monitoringStatus=null}_dispatchFavoritesViewState(){if(!this._viewName||!this._container)return;const t={view:this._viewName,expanded:[...this._expandedUuids],searchQuery:this._searchQuery};this._container.dispatchEvent(new CustomEvent("favorites-view-state-changed",{detail:t,bubbles:!0,composed:!0}))}_bindEvents(t){this._container=t,this._clickHandler=e=>{const n=e.target;if(!n)return;const i=n.closest(".list-expand-toggle");if(i){const t=i.dataset.expandUuid;return void(t&&this._toggleExpand(t))}if(n.closest(".gear-icon"))return void this._ctrl.onGearClick(e,t);if(n.closest(".toggle-pill"))return void this._ctrl.onToggleClick(e,t);if(n.closest(".list-search-clear")){const e=t.querySelector(".list-search");return void(e&&(e.value="",e.dispatchEvent(new Event("input",{bubbles:!0}))))}const r=n.closest(".unit-btn");if(r){const e=r.dataset.unit;e&&t.dispatchEvent(new CustomEvent("unit-changed",{detail:e,bubbles:!0,composed:!0}))}},this._inputHandler=e=>{const n=e.target;n&&n.classList.contains("list-search")&&(this._searchQuery=n.value.toLowerCase(),this._applyFilter(t),this._dispatchFavoritesViewState())},this._graphSettingsHandler=()=>{this._ctrl.onGraphSettingsChanged(t).then(()=>{this._ctrl.updateDOM(t)}).catch(()=>{})},t.addEventListener("click",this._clickHandler),t.addEventListener("input",this._inputHandler),t.addEventListener("graph-settings-changed",this._graphSettingsHandler);const e=t.querySelector(".slide-confirm");e&&(this._ctrl.bindSlideConfirm(e,t),t.classList.add("switches-disabled"))}_unbindEvents(){this._container&&(this._clickHandler&&this._container.removeEventListener("click",this._clickHandler),this._inputHandler&&this._container.removeEventListener("input",this._inputHandler),this._graphSettingsHandler&&this._container.removeEventListener("graph-settings-changed",this._graphSettingsHandler)),this._foldUnobserve&&(this._foldUnobserve(),this._foldUnobserve=null),this._container=null,this._clickHandler=null,this._inputHandler=null,this._graphSettingsHandler=null}_attachFoldObserver(t){this._foldUnobserve&&(this._foldUnobserve(),this._foldUnobserve=null),this._foldUnobserve=Uk(t,{rowSelector:".list-row",nameSelector:".list-circuit-name",foldClass:"is-folded"})}_applyFilter(t){const e=t.querySelector(".list-search-clear");e&&(e.style.display=this._searchQuery?"":"none");const n=t.querySelectorAll(".list-cell[data-cell-uuid]");for(const t of n){const e=t.querySelector(".list-circuit-name"),n=(e?.textContent?.toLowerCase()??"").includes(this._searchQuery);t.style.display=n?"":"none"}const i=t.querySelectorAll(".area-header");for(const t of i){let e=!1,n=t.nextElementSibling;for(;n&&!n.classList.contains("area-header");){if(n.classList.contains("list-cell")&&"none"!==n.style.display){e=!0;break}n=n.nextElementSibling}t.style.display=e?"":"none"}}_toggleExpand(t){if(!(this._container&&this._hass&&this._topology&&this._config))return;const e=Ok(t),n=this._container.querySelector(`.list-cell[data-cell-uuid="${e}"]`);if(!n)return;const i=n.querySelector(`.list-row[data-row-uuid="${e}"]`),r=n.querySelector(`.list-expand-toggle[data-expand-uuid="${e}"]`);if(i){if(this._expandedUuids.has(t)){this._expandedUuids.delete(t);const o=n.querySelector(`.list-expanded-content[data-expanded-uuid="${e}"]`);o&&o.remove(),r&&r.classList.remove("expanded"),i.classList.remove("list-row-expanded")}else{this._expandedUuids.add(t);const e=this._topology.circuits[t];if(!e)return;const n=ee(this._monitoringStatus,Yk(e)),o=Vk(t,e,this._hass,this._config,n);i.insertAdjacentHTML("afterend",o),r&&r.classList.add("expanded"),i.classList.add("list-row-expanded"),this._ctrl.updateDOM(this._container)}this._dispatchFavoritesViewState()}}}async function Kk(t,e){const[n,i,r]=await Promise.all([t.callWS({type:"config/area_registry/list"}),t.callWS({type:"config/entity_registry/list"}),t.callWS({type:"config/device_registry/list"})]),o=new Map;for(const t of n)o.set(t.area_id,t.name);const a=new Map;for(const t of i)t.area_id&&a.set(t.entity_id,t.area_id);const s=new Map;for(const t of r)s.set(t.id,t.area_id);let l;if(e.device_id){const t=s.get(e.device_id);t&&(l=o.get(t))}for(const t of Object.values(e.circuits)){let e;for(const n of Object.values(t.entities)){if(!n)continue;const t=a.get(n);if(t){e=o.get(t);break}}e||(e=l),t.area=e}}class Qk{constructor(){this._persistent=new Map,this._transient=null,this._transientTimer=null,this._subscribers=new Set,this._watchedPanels=new Map}add(t){const e={...t,timestamp:Date.now()};if(e.persistent)this._persistent.set(e.key,e);else{this._clearTransient(),this._transient=e;const t=e.ttl??5e3;this._transientTimer=setTimeout(()=>{this._transient=null,this._transientTimer=null,this._notify()},t)}this._notify()}remove(t){if(this._persistent.has(t))return this._persistent.delete(t),void this._notify();this._transient?.key===t&&(this._clearTransient(),this._notify())}clear(t){void 0===t?(this._persistent.clear(),this._clearTransient(),this._watchedPanels.clear()):!0===t.persistent?this._persistent.clear():!1===t.persistent&&this._clearTransient(),this._notify()}get active(){const t=[...this._persistent.values()];return null!==this._transient&&t.push(this._transient),t}hasPersistent(t){return this._persistent.has(t)}hasAnyPanelOffline(){for(const t of this._persistent.keys())if("panel-offline"===t||t.startsWith("panel-offline:"))return!0;return!1}subscribe(t){return this._subscribers.add(t),()=>{this._subscribers.delete(t)}}watchPanelStatus(t){this.watchPanelStatuses([{entityId:t,panelName:null}])}watchPanelStatuses(t){const e=this._watchedPanels,n=new Map;for(const i of t){const t=e.get(i.entityId);n.set(i.entityId,{panelName:i.panelName??null,wasOffline:t?.wasOffline??!1})}const i=this._isSingleUnnamed(e),r=this._isSingleUnnamed(n);for(const t of e.keys()){n.has(t)&&i===r||this._persistent.delete(this._offlineKey(t,i))}this._watchedPanels=n,this._notify()}clearPanelStatusWatch(){if(0===this._watchedPanels.size)return;const t=this._isSingleUnnamed(this._watchedPanels);for(const e of this._watchedPanels.keys())this._persistent.delete(this._offlineKey(e,t));this._watchedPanels.clear(),this._notify()}updateHass(t){if(0===this._watchedPanels.size)return;const e=this._isSingleUnnamed(this._watchedPanels);for(const[n,o]of this._watchedPanels){const a=t.states[n]?.state,s="on"===a,l=this._offlineKey(n,e),c=this._reconnectKey(n,e);if(s){const t=o.wasOffline;o.wasOffline=!1,this.remove(l),t&&this.add({key:c,level:"info",message:null===o.panelName?i("error.panel_reconnected"):r("error.panel_reconnected_named",{name:o.panelName}),persistent:!1})}else o.wasOffline=!0,this.hasPersistent(l)||this.add({key:l,level:"error",message:null===o.panelName?i("error.panel_offline"):r("error.panel_offline_named",{name:o.panelName}),persistent:!0})}}dispose(){this._clearTransient(),this._persistent.clear(),this._subscribers.clear(),this._watchedPanels.clear()}_isSingleUnnamed(t){if(1!==t.size)return!1;for(const e of t.values())return null===e.panelName;return!1}_offlineKey(t,e){return e?"panel-offline":`panel-offline:${t}`}_reconnectKey(t,e){return e?"panel-reconnected":`panel-reconnected:${t}`}_clearTransient(){null!==this._transientTimer&&(clearTimeout(this._transientTimer),this._transientTimer=null),this._transient=null}_notify(){for(const t of this._subscribers)try{t()}catch(t){console.warn("SPAN Panel: error-store subscriber threw",t)}}}function Jk(t){let e=0;for(const n of Object.values(t))if(n)for(const t of n.tabs)t>e&&(e=t);return e>0?e+e%2:0}function tT(t){return t?{id:t.id,name:t.name,name_by_user:t.name_by_user,config_entries:t.config_entries,identifiers:t.identifiers,via_device_id:t.via_device_id,sw_version:t.sw_version,model:t.model}:null}const eT="favorites-changed";async function nT(t,e,n={}){const i=await t.callWS({type:"call_service",domain:l,service:e,service_data:n,return_response:!0});return i?.response??null}const iT=Object.keys(m).filter(t=>"unknown"!==t&&"always_on"!==t);class rT extends HTMLElement{constructor(){super(),this.errorStore=null,this.attachShadow({mode:"open"}),this._hass=null,this._config=null,this._debounceTimers={}}set hass(t){this._hass=t,this.hasAttribute("open")&&this._config&&this._updateLiveState()}get hass(){return this._hass}disconnectedCallback(){this._clearDebounceTimers(),this._config=null}open(t){this._config=t,this._render(),this.offsetHeight,this.setAttribute("open",""),this.setAttribute("data-mode",this._modeFor(t))}close(){this._clearDebounceTimers(),this.removeAttribute("open"),this.removeAttribute("data-mode"),this._config=null,this.dispatchEvent(new CustomEvent("side-panel-closed",{bubbles:!0,composed:!0}))}_clearDebounceTimers(){for(const t of Object.keys(this._debounceTimers))clearTimeout(this._debounceTimers[t]);this._debounceTimers={}}_modeFor(t){return t.favoritesMode?"favorites":t.panelMode?"panel":t.subDeviceMode?"subDevice":"circuit"}_render(){const t=this._config;if(!t)return;const e=this.shadowRoot;if(!e)return;e.innerHTML="";const n=document.createElement("style");n.textContent='\n :host {\n display: block;\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n width: 360px;\n max-width: 90vw;\n z-index: 1000;\n transform: translateX(100%);\n transition: transform 0.3s ease;\n pointer-events: none;\n }\n :host([open]) {\n transform: translateX(0);\n pointer-events: auto;\n }\n\n .backdrop {\n display: none;\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba(0, 0, 0, 0.3);\n z-index: -1;\n }\n :host([open]) .backdrop {\n display: block;\n }\n\n .panel {\n height: 100%;\n background: var(--card-background-color, #fff);\n border-left: 1px solid var(--divider-color, #e0e0e0);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n .panel-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 16px;\n border-bottom: 1px solid var(--divider-color, #e0e0e0);\n }\n .panel-header .title {\n font-size: 18px;\n font-weight: 500;\n color: var(--primary-text-color, #212121);\n margin: 0;\n }\n .panel-header .subtitle {\n font-size: 13px;\n color: var(--secondary-text-color, #727272);\n margin: 2px 0 0 0;\n }\n .close-btn {\n background: none;\n border: none;\n cursor: pointer;\n color: var(--secondary-text-color, #727272);\n padding: 4px;\n line-height: 1;\n font-size: 20px;\n }\n\n .panel-body {\n flex: 1;\n overflow-y: auto;\n padding: 16px;\n }\n\n .section {\n margin-bottom: 20px;\n }\n .section-label {\n font-size: 12px;\n font-weight: 600;\n text-transform: uppercase;\n color: var(--secondary-text-color, #727272);\n margin: 0 0 8px 0;\n letter-spacing: 0.5px;\n }\n\n .field-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 8px 0;\n }\n .field-label {\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n }\n\n select {\n padding: 6px 8px;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 4px;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 14px;\n }\n\n input[type="number"] {\n width: 72px;\n padding: 6px 8px;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 4px;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 14px;\n text-align: right;\n }\n input[type="number"]:disabled {\n opacity: 0.5;\n }\n\n .radio-group {\n display: flex;\n gap: 16px;\n padding: 8px 0;\n }\n .radio-group label {\n display: flex;\n align-items: center;\n gap: 6px;\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n cursor: pointer;\n }\n\n .horizon-bar {\n display: flex;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 6px;\n overflow: hidden;\n margin-top: 4px;\n }\n .horizon-segment {\n flex: 1;\n padding: 6px 0;\n text-align: center;\n font-size: 13px;\n cursor: pointer;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n border: none;\n border-right: 1px solid var(--divider-color, #e0e0e0);\n transition: background 0.15s ease, color 0.15s ease;\n user-select: none;\n line-height: 1.4;\n }\n .horizon-segment:last-child {\n border-right: none;\n }\n .horizon-segment:hover:not(.active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .horizon-segment.active {\n background: var(--primary-color, #03a9f4);\n color: #fff;\n font-weight: 600;\n }\n .horizon-segment.referenced {\n box-shadow: inset 0 -3px 0 var(--primary-color, #03a9f4);\n }\n\n .unit-toggle {\n display: inline-flex;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 6px;\n overflow: hidden;\n }\n .unit-btn {\n padding: 4px 10px;\n border: none;\n border-right: 1px solid var(--divider-color, #e0e0e0);\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 13px;\n font-weight: 500;\n cursor: pointer;\n transition: background 0.15s ease, color 0.15s ease;\n }\n .unit-btn:last-child {\n border-right: none;\n }\n .unit-btn:hover:not(.unit-active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .unit-btn.unit-active {\n background: var(--primary-color, #03a9f4);\n color: #fff;\n font-weight: 600;\n }\n\n .monitoring-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n }\n\n .fav-heart {\n background: none;\n border: 1px solid var(--divider-color, #e0e0e0);\n color: var(--secondary-text-color, #727272);\n border-radius: 4px;\n padding: 2px 6px;\n cursor: pointer;\n font-size: 0.9em;\n margin-right: 6px;\n line-height: 1;\n display: inline-flex;\n align-items: center;\n }\n .fav-heart.active {\n color: var(--primary-color, #03a9f4);\n border-color: var(--primary-color, #03a9f4);\n }\n .fav-heart:hover:not(.active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .fav-heart span-icon {\n --mdc-icon-size: 16px;\n }\n\n .panel-mode-info {\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n line-height: 1.6;\n }\n .panel-mode-info p {\n margin: 0 0 12px 0;\n }\n\n',e.appendChild(n);const i=document.createElement("div");i.className="backdrop",i.addEventListener("click",()=>this.close()),e.appendChild(i);const r=document.createElement("div");r.className="panel",e.appendChild(r),t.favoritesMode?this._renderFavoritesMode(r):t.panelMode?this._renderPanelMode(r):t.subDeviceMode?this._renderSubDeviceMode(r,t):this._renderCircuitMode(r,t)}_renderPanelMode(t){const e=this._config,n=this._createHeader(i("sidepanel.graph_settings"),i("sidepanel.global_defaults"));t.appendChild(n);const r=document.createElement("div");r.className="panel-body";const o=e.graphSettings,l=e.topology,c=o?.global_horizon??a,u=o?.circuits??{};r.appendChild(this._buildListColumnsSection());const d=document.createElement("div");d.className="section";const h=document.createElement("div");h.className="section-label",h.textContent=i("sidepanel.graph_horizon"),d.appendChild(h);const p=document.createElement("div");p.className="field-row";const g=document.createElement("span");g.className="field-label",g.textContent=i("sidepanel.global_default"),p.appendChild(g);const v=document.createElement("select");for(const t of Object.keys(s)){const e=document.createElement("option");e.value=t;const n=`horizon.${t}`,r=i(n);e.textContent=r!==n?r:t,t===c&&(e.selected=!0),v.appendChild(e)}if(v.addEventListener("change",()=>{const t={horizon:v.value};e.configEntryId&&(t.config_entry_id=e.configEntryId),this._callDomainService("set_graph_time_horizon",t).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})}),p.appendChild(v),d.appendChild(p),r.appendChild(d),l?.circuits){const t=document.createElement("div");t.className="section";const n=document.createElement("div");n.className="section-label",n.textContent=i("sidepanel.circuit_scales"),t.appendChild(n);const o=Object.entries(l.circuits).sort(([,t],[,e])=>(t.name||"").localeCompare(e.name||""));for(const[n,i]of o){const r=this._buildPanelModeCircuitRow(n,i,u[n],c,e.configEntryId??null,e.showFavorites??!1,e.favoritePanelDeviceId,e.favoriteCircuitUuids);t.appendChild(r)}r.appendChild(t)}const m=o?.sub_devices??{};if(l?.sub_devices){const t=document.createElement("div");t.className="section";const n=document.createElement("div");n.className="section-label",n.textContent=i("sidepanel.subdevice_scales"),t.appendChild(n);const o=Object.entries(l.sub_devices).sort(([,t],[,e])=>(t.name||"").localeCompare(e.name||""));for(const[n,r]of o){const o=document.createElement("div");o.className="field-row";const a=document.createElement("span");if(a.className="field-label",a.textContent=r.name||n,a.style.cssText="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;",o.appendChild(a),e.showFavorites&&e.favoritePanelDeviceId){const t=this._buildSubDeviceFavoriteHeart(r.entities,e.favoriteSubDeviceIds?.has(n)??!1);t&&o.appendChild(t)}const l=m[n]||{horizon:c,has_override:!1},u=l.has_override?l.horizon:c,d=document.createElement("select");d.dataset.subdevId=n;for(const t of Object.keys(s)){const e=document.createElement("option");e.value=t;const n=`horizon.${t}`,r=i(n);e.textContent=r!==n?r:t,t===u&&(e.selected=!0),d.appendChild(e)}if(d.addEventListener("change",()=>{this._debounce(`subdev-${n}`,f,()=>{const t={subdevice_id:n,horizon:d.value};e.configEntryId&&(t.config_entry_id=e.configEntryId),this._callDomainService("set_subdevice_graph_horizon",t).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})})}),o.appendChild(d),l.has_override){const t=document.createElement("button");t.textContent="↺",t.title=i("sidepanel.reset_to_global"),Object.assign(t.style,{background:"none",border:"1px solid var(--divider-color, #e0e0e0)",color:"var(--primary-text-color)",borderRadius:"4px",padding:"3px 6px",cursor:"pointer",marginLeft:"4px",fontSize:"0.85em"}),t.addEventListener("click",()=>{const r={subdevice_id:n};e.configEntryId&&(r.config_entry_id=e.configEntryId),this._callDomainService("clear_subdevice_graph_horizon",r).then(()=>{d.value=c,t.remove(),this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})}),o.appendChild(t)}t.appendChild(o)}r.appendChild(t)}t.appendChild(r)}_buildPanelModeCircuitRow(t,e,n,r,o,a,l,c){const u=document.createElement("div");u.className="field-row";const d=document.createElement("span");if(d.className="field-label",d.textContent=e.name||t,d.style.cssText="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;",u.appendChild(d),a&&l){const n=this._buildFavoriteHeart(e.entities,c?.has(t)??!1);n&&u.appendChild(n)}const h=n||{horizon:r,has_override:!1},p=h.has_override?h.horizon:r,g=document.createElement("select");g.dataset.uuid=t;for(const t of Object.keys(s)){const e=document.createElement("option");e.value=t;const n=`horizon.${t}`,r=i(n);e.textContent=r!==n?r:t,t===p&&(e.selected=!0),g.appendChild(e)}if(g.addEventListener("change",()=>{this._debounce(`circuit-${t}`,f,()=>{const e={circuit_id:t,horizon:g.value};o&&(e.config_entry_id=o),this._callDomainService("set_circuit_graph_horizon",e).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})})}),u.appendChild(g),h.has_override){const e=document.createElement("button");e.textContent="↺",e.title=i("sidepanel.reset_to_global"),Object.assign(e.style,{background:"none",border:"1px solid var(--divider-color, #e0e0e0)",color:"var(--primary-text-color)",borderRadius:"4px",padding:"3px 6px",cursor:"pointer",marginLeft:"4px",fontSize:"0.85em"}),e.addEventListener("click",()=>{const n={circuit_id:t};o&&(n.config_entry_id=o),this._callDomainService("clear_circuit_graph_horizon",n).then(()=>{g.value=r,e.remove(),this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})}),u.appendChild(e)}return u}_renderFavoritesMode(t){const e=this._config,n=this._createHeader(i("sidepanel.graph_settings"),i("sidepanel.favorites_subtitle"));t.appendChild(n);const r=document.createElement("div");r.className="panel-body",r.appendChild(this._buildListColumnsSection());for(const t of e.perPanelSections)r.appendChild(this._buildFavoritesPanelSection(t));t.appendChild(r)}_buildFavoritesPanelSection(t){const e=document.createElement("div");e.className="section";const n=document.createElement("div");n.className="section-label",n.textContent=t.panelName,e.appendChild(n);const i=t.graphSettings?.global_horizon??a,r=t.graphSettings?.circuits??{},o=function(t){const e=t.circuits??{};return Object.entries(e).map(([t,e])=>({uuid:t,circuit:e})).sort((t,e)=>(t.circuit.name||"").localeCompare(e.circuit.name||""))}(t.topology);for(const{uuid:n,circuit:a}of o){const o=this._buildPanelModeCircuitRow(n,a,r[n],i,t.configEntryId,!0,t.panelDeviceId,t.favoriteCircuitUuids);e.appendChild(o)}return e}_renderCircuitMode(t,e){const n=`${Rt(String(e.breaker_rating_a))}A · ${Rt(String(e.voltage))}V · Tabs [${Rt(String(e.tabs))}]`,i=this._createHeader(Rt(e.name),n);t.appendChild(i);const r=document.createElement("div");r.className="panel-body",t.appendChild(r),this._renderRelaySection(r,e),e.showFavorites&&this._renderFavoriteSection(r,e),this._renderSheddingSection(r,e),this._renderGraphHorizonSection(r,e),e.showMonitoring&&this._renderMonitoringSection(r,e)}_favoriteEntityId(t){return t?.current??t?.power??null}_subDeviceFavoriteEntityId(t){if(!t)return null;let e=null;for(const[n,i]of Object.entries(t)){if("sensor"===i.domain)return n;e||(e=n)}return e}_buildSubDeviceFavoriteHeart(t,e){const n=this._subDeviceFavoriteEntityId(t);return n?this._buildHeartButton(n,e):null}_buildListColumnsSection(){const t=document.createElement("div");t.className="section";const e=document.createElement("div");e.className="section-label",e.textContent=i("sidepanel.list_view_columns"),t.appendChild(e);const n=document.createElement("div");n.className="field-row";const r=document.createElement("span");r.className="field-label",r.textContent=i("sidepanel.columns"),n.appendChild(r);const o=Bt(),a=document.createElement("div");a.className="unit-toggle";for(const t of[1,2,3]){const e=document.createElement("button");e.type="button",e.className="unit-btn"+(t===o?" unit-active":""),e.dataset.columns=String(t),e.textContent=String(t),e.addEventListener("click",()=>{Ft(t);for(const t of a.querySelectorAll(".unit-btn"))t.classList.toggle("unit-active",t===e);this.dispatchEvent(new CustomEvent("list-columns-changed",{detail:t,bubbles:!0,composed:!0}))}),a.appendChild(e)}return n.appendChild(a),t.appendChild(n),t}_buildFavoriteHeart(t,e){const n=this._favoriteEntityId(t);return n?this._buildHeartButton(n,e):(console.warn("SPAN Panel: circuit has no current/power sensor; favorite heart suppressed"),null)}_buildHeartButton(t,e){const n=document.createElement("button");n.type="button",n.className=e?"fav-heart active":"fav-heart",n.dataset.role="fav-heart",n.title=i("sidepanel.save_to_favorites"),n.setAttribute("role","switch"),n.setAttribute("aria-checked",String(e)),n.setAttribute("aria-label",i("sidepanel.save_to_favorites"));const r=document.createElement("span-icon");return r.setAttribute("icon",e?"mdi:heart":"mdi:heart-outline"),n.appendChild(r),n.addEventListener("click",e=>{e.stopPropagation(),this._toggleFavoriteEntity(n,r,t).catch(()=>{})}),n}async _toggleFavoriteEntity(t,e,n){if(!this._hass)return;const r=t.classList.contains("active"),o=!r;t.classList.toggle("active",o),e.setAttribute("icon",o?"mdi:heart":"mdi:heart-outline"),t.setAttribute("aria-checked",String(o));try{o?await async function(t,e){const n=await nT(t,"add_favorite",{entity_id:e});return document.dispatchEvent(new CustomEvent(eT)),n?.favorites??{}}(this._hass,n):await async function(t,e){const n=await nT(t,"remove_favorite",{entity_id:e});return document.dispatchEvent(new CustomEvent(eT)),n?.favorites??{}}(this._hass,n)}catch(n){throw t.classList.toggle("active",r),e.setAttribute("icon",r?"mdi:heart":"mdi:heart-outline"),t.setAttribute("aria-checked",String(r)),console.warn("SPAN Panel: favorite toggle failed",n),this.errorStore?.add({key:"service:favorites",level:"error",message:i("error.favorites_toggle_failed"),persistent:!1}),n}}_renderFavoriteSection(t,e){const n=this._favoriteEntityId(e.entities);n&&this._appendFavoriteHeartSection(t,n,!0===e.isFavorite)}_appendFavoriteHeartSection(t,e,n){const r=document.createElement("div");r.className="section",r.innerHTML=``;const o=document.createElement("div");o.className="field-row";const a=document.createElement("span");a.className="field-label",a.textContent=i("sidepanel.save_to_favorites"),o.appendChild(a),o.appendChild(this._buildHeartButton(e,n)),r.appendChild(o),t.appendChild(r)}_renderSubDeviceMode(t,e){const n=this._createHeader(Rt(e.name),Rt(e.deviceType));t.appendChild(n);const i=document.createElement("div");i.className="panel-body",t.appendChild(i),e.showFavorites&&this._renderSubDeviceFavoriteSection(i,e),this._renderSubDeviceHorizonSection(i,e)}_renderSubDeviceFavoriteSection(t,e){const n=this._subDeviceFavoriteEntityId(e.entities);n&&this._appendFavoriteHeartSection(t,n,!0===e.isFavorite)}_renderSubDeviceHorizonSection(t,e){const n=document.createElement("div");n.className="section";const r=document.createElement("div");r.className="section-label",r.textContent=i("sidepanel.graph_horizon"),n.appendChild(r);const o=e.graphHorizonInfo,l=!0===o?.has_override,c=o?.horizon||a,u=o?.globalHorizon||a,d=document.createElement("div");d.className="horizon-bar";const h=[{key:"global",label:i("sidepanel.global")}];for(const t of Object.keys(s))h.push({key:t,label:t});const p=l?c:"global",f=t=>{for(const e of d.querySelectorAll(".horizon-segment")){const n=e.dataset.horizon;e.classList.toggle("active",n===t),e.classList.toggle("referenced","global"===t&&n===u)}};for(const{key:t,label:n}of h){const r=document.createElement("button");r.type="button",r.className="horizon-segment",r.dataset.horizon=t,r.textContent=n,r.classList.toggle("active",t===p),r.classList.toggle("referenced","global"===p&&t===u),r.addEventListener("click",()=>{if(r.classList.contains("active"))return;const n={subdevice_id:e.subDeviceId};e.configEntryId&&(n.config_entry_id=e.configEntryId),"global"===t?(f("global"),this._callDomainService("clear_subdevice_graph_horizon",n).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})):(f(t),this._callDomainService("set_subdevice_graph_horizon",{...n,horizon:t}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})}))}),d.appendChild(r)}n.appendChild(d),t.appendChild(n)}_createHeader(t,e){const n=document.createElement("div");n.className="panel-header";const i=document.createElement("div"),r=Rt(t),o=Rt(e);i.innerHTML=`
${r}
`+(o?`
${o}
`:"");const a=document.createElement("button");return a.className="close-btn",a.innerHTML="✕",a.addEventListener("click",()=>this.close()),n.appendChild(i),n.appendChild(a),n}_renderRelaySection(t,e){if(!1===e.is_user_controllable||!e.entities?.switch)return;const n=document.createElement("div");n.className="section",n.innerHTML=``;const r=document.createElement("div");r.className="field-row";const o=document.createElement("span");o.className="field-label",o.textContent=i("sidepanel.breaker");const a=document.createElement("span-switch");a.dataset.role="relay-toggle";const s=e.entities.switch,l=this._hass?.states?.[s]?.state;"on"===l&&a.setAttribute("checked",""),a.addEventListener("change",()=>{const t=a.hasAttribute("checked")||a.checked;this._callService("switch",t?"turn_on":"turn_off",{entity_id:s}).catch(t=>{console.warn("SPAN Panel: relay toggle failed",t),this.errorStore?.add({key:"service:relay",level:"error",message:i("error.relay_failed"),persistent:!1})})}),r.appendChild(o),r.appendChild(a),n.appendChild(r),t.appendChild(n)}_renderSheddingSection(t,e){if(!e.entities?.select)return;const n=document.createElement("div");n.className="section",n.innerHTML=``;const r=document.createElement("div");r.className="field-row";const o=document.createElement("span");o.className="field-label",o.textContent=i("sidepanel.priority_label");const a=document.createElement("select");a.dataset.role="shedding-select";const s=e.entities.select,l=this._hass?.states?.[s]?.state||"";for(const t of iT){const e=m[t];if(!e)continue;const n=document.createElement("option");n.value=t,n.textContent=i(`shedding.select.${t}`)||e.label(),t===l&&(n.selected=!0),a.appendChild(n)}a.addEventListener("change",()=>{this._callService("select","select_option",{entity_id:s,option:a.value}).catch(t=>{console.warn("SPAN Panel: shedding update failed",t),this.errorStore?.add({key:"service:shedding",level:"error",message:i("error.shedding_failed"),persistent:!1})})}),r.appendChild(o),r.appendChild(a),n.appendChild(r),t.appendChild(n)}_renderGraphHorizonSection(t,e){const n=document.createElement("div");n.className="section";const r=document.createElement("div");r.className="section-label",r.textContent=i("sidepanel.graph_horizon"),n.appendChild(r);const o=e.graphHorizonInfo,l=!0===o?.has_override,c=o?.horizon||a,u=o?.globalHorizon||a,d=document.createElement("div");d.className="horizon-bar";const h=[{key:"global",label:i("sidepanel.global")}];for(const t of Object.keys(s))h.push({key:t,label:t});const p=l?c:"global",f=t=>{for(const e of d.querySelectorAll(".horizon-segment")){const n=e.dataset.horizon;e.classList.toggle("active",n===t),e.classList.toggle("referenced","global"===t&&n===u)}};for(const{key:t,label:n}of h){const r=document.createElement("button");r.type="button",r.className="horizon-segment",r.dataset.horizon=t,r.textContent=n,r.classList.toggle("active",t===p),r.classList.toggle("referenced","global"===p&&t===u),r.addEventListener("click",()=>{if(r.classList.contains("active"))return;const n={circuit_id:e.uuid};e.configEntryId&&(n.config_entry_id=e.configEntryId),"global"===t?(f("global"),this._callDomainService("clear_circuit_graph_horizon",n).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})):(f(t),this._callDomainService("set_circuit_graph_horizon",{...n,horizon:t}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})}))}),d.appendChild(r)}n.appendChild(d),t.appendChild(n)}_renderMonitoringSection(t,e){const n=document.createElement("div");n.className="section";const r=document.createElement("div");r.className="monitoring-header";const o=document.createElement("div");o.className="section-label",o.textContent=i("sidepanel.monitoring"),o.style.margin="0";const a=document.createElement("span-switch");a.dataset.role="monitoring-toggle";const s=e.monitoringInfo,l=null!=s&&!1!==s.monitoring_enabled;l&&a.setAttribute("checked",""),r.appendChild(o),r.appendChild(a),n.appendChild(r);const c=document.createElement("div");c.dataset.role="monitoring-details",c.style.display=l?"block":"none",n.appendChild(c);const u=!0===s?.has_override,d=document.createElement("div");d.className="radio-group",d.innerHTML=`\n \n \n `,c.appendChild(d);const h=document.createElement("div");h.dataset.role="threshold-fields",h.style.display=u?"block":"none";const p=s?.continuous_threshold_pct??80,f=s?.spike_threshold_pct??100,g=s?.window_duration_m??15,v=s?.cooldown_duration_m??15;h.appendChild(this._createThresholdRow(i("sidepanel.continuous_pct"),"continuous",p,e)),h.appendChild(this._createThresholdRow(i("sidepanel.spike_pct"),"spike",f,e)),h.appendChild(this._createDurationRow(i("sidepanel.window_duration"),"window-m",g,1,180,"m",e)),h.appendChild(this._createDurationRow(i("sidepanel.cooldown"),"cooldown-m",v,1,180,"m",e)),c.appendChild(h),a.addEventListener("change",()=>{const t=a.checked;c.style.display=t?"block":"none";const n={circuit_id:e.entities?.power||e.uuid,monitoring_enabled:t};e.configEntryId&&(n.config_entry_id=e.configEntryId),this._callDomainService("set_circuit_threshold",n).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:i("error.threshold_failed"),persistent:!1})})});const m=d.querySelectorAll('input[type="radio"]');for(const t of m)t.addEventListener("change",()=>{const n="custom"===t.value&&t.checked;if(h.style.display=n?"block":"none",!n&&t.checked){const t={circuit_id:e.entities?.power||e.uuid};e.configEntryId&&(t.config_entry_id=e.configEntryId),this._callDomainService("clear_circuit_threshold",t).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:i("error.threshold_failed"),persistent:!1})})}});t.appendChild(n)}_createThresholdRow(t,e,n,r){const o=document.createElement("div");o.className="field-row";const a=document.createElement("span");a.className="field-label",a.textContent=t;const s=document.createElement("input");return s.type="number",s.min="0",s.max="200",s.value=String(n),s.dataset.role=`threshold-${e}`,s.addEventListener("input",()=>{this._debounce(`threshold-${e}`,f,()=>{const t=this.shadowRoot;if(!t)return;const e=t.querySelector('[data-role="threshold-continuous"]'),n=t.querySelector('[data-role="threshold-spike"]'),o=t.querySelector('[data-role="threshold-window-m"]'),a=t.querySelector('[data-role="threshold-cooldown-m"]'),s={circuit_id:r.entities?.power||r.uuid,continuous_threshold_pct:e?Number(e.value):void 0,spike_threshold_pct:n?Number(n.value):void 0,window_duration_m:o?Number(o.value):void 0,cooldown_duration_m:a?Number(a.value):void 0};r.configEntryId&&(s.config_entry_id=r.configEntryId),this._callDomainService("set_circuit_threshold",s).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:i("error.threshold_failed"),persistent:!1})})})}),o.appendChild(a),o.appendChild(s),o}_createDurationRow(t,e,n,r,o,a,s,l=!1){const c=document.createElement("div");c.className="field-row";const u=document.createElement("span");u.className="field-label",u.textContent=t;const d=document.createElement("div"),h=document.createElement("input");h.type="number",h.min=String(r),h.max=String(o),h.value=String(n),h.dataset.role=`threshold-${e}`,l&&(h.disabled=!0);const p=document.createElement("span");return p.textContent=a,d.appendChild(h),d.appendChild(p),l||h.addEventListener("input",()=>{this._debounce(`threshold-${e}`,f,()=>{const t=this.shadowRoot;if(!t)return;const e=t.querySelector('[data-role="threshold-continuous"]'),n=t.querySelector('[data-role="threshold-spike"]'),r=t.querySelector('[data-role="threshold-window-m"]'),o={circuit_id:s.uuid,continuous_threshold_pct:e?Number(e.value):void 0,spike_threshold_pct:n?Number(n.value):void 0,window_duration_m:r?Number(r.value):void 0};s.configEntryId&&(o.config_entry_id=s.configEntryId),this._callDomainService("set_circuit_threshold",o).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:i("error.threshold_failed"),persistent:!1})})})}),c.appendChild(u),c.appendChild(d),c}_updateLiveState(){if(!this._config||this._config.panelMode)return;const t=this._config;if(!t.subDeviceMode&&!t.favoritesMode){if(t.entities?.switch){const e=this.shadowRoot?.querySelector('[data-role="relay-toggle"]');if(e){const n=this._hass?.states?.[t.entities.switch]?.state;"on"===n?e.setAttribute("checked",""):e.removeAttribute("checked")}}if(t.entities?.select){const e=this.shadowRoot?.querySelector('[data-role="shedding-select"]');if(e){const n=this._hass?.states?.[t.entities.select]?.state||"";e.value=n}}}}_callService(t,e,n){return this._hass?Promise.resolve(this._hass.callService(t,e,n)):Promise.resolve()}_callDomainService(t,e){return this._hass?this._hass.callWS({type:"call_service",domain:l,service:t,service_data:e}):Promise.resolve()}_debounce(t,e,n){this._debounceTimers[t]&&clearTimeout(this._debounceTimers[t]),this._debounceTimers[t]=setTimeout(()=>{delete this._debounceTimers[t],n()},e)}}try{customElements.get("span-side-panel")||customElements.define("span-side-panel",rT)}catch{}class oT extends It{constructor(){super(...arguments),this._store=null,this._unsub=null,this._errors=[]}set store(t){if(this._store===t)return;this._unsub?.(),this._unsub=null,this._store=t,this._errors=t.active;const e=t;this._unsub=t.subscribe(()=>{this._errors=e.active})}connectedCallback(){if(super.connectedCallback(),this._store&&!this._unsub){const t=this._store;this._errors=t.active,this._unsub=t.subscribe(()=>{this._errors=t.active})}}disconnectedCallback(){super.disconnectedCallback(),this._unsub?.(),this._unsub=null}render(){return 0===this._errors.length?ft:dt`${this._errors.map(t=>dt` `)}`}_iconForLevel(t){switch(t){case"error":return"mdi:alert-circle";case"warning":return"mdi:alert";default:return"mdi:information"}}}oT.styles=T` :host { @@ -111,7 +111,7 @@ var Ga={},qa={};var ja,Xa=function(){function t(t,e,n){var i=this;this._sleepAft .retry-btn:hover { opacity: 0.8; } - `,x([Ot()],oT.prototype,"_errors",void 0);try{customElements.get("span-error-banner")||customElements.define("span-error-banner",oT)}catch{}const aT=Object.freeze({"mdi:alert":"M13 14H11V9H13M13 18H11V16H13M1 21H23L12 2L1 21Z","mdi:alert-circle":"M13,13H11V7H13M13,17H11V15H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z","mdi:battery":"M16.67,4H15V2H9V4H7.33A1.33,1.33 0 0,0 6,5.33V20.67C6,21.4 6.6,22 7.33,22H16.67A1.33,1.33 0 0,0 18,20.67V5.33C18,4.6 17.4,4 16.67,4Z","mdi:battery-alert-variant-outline":"M14 20H6V6H14M14.67 4H13V2H7V4H5.33C4.6 4 4 4.6 4 5.33V20.67C4 21.4 4.6 22 5.33 22H14.67C15.4 22 16 21.4 16 20.67V5.33C16 4.6 15.4 4 14.67 4M21 7H19V13H21V8M21 15H19V17H21V15Z","mdi:chevron-down":"M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z","mdi:close":"M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z","mdi:cog":"M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.21,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.21,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.67 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z","mdi:heart":"M12,21.35L10.55,20.03C5.4,15.36 2,12.27 2,8.5C2,5.41 4.42,3 7.5,3C9.24,3 10.91,3.81 12,5.08C13.09,3.81 14.76,3 16.5,3C19.58,3 22,5.41 22,8.5C22,12.27 18.6,15.36 13.45,20.03L12,21.35Z","mdi:heart-outline":"M12.1,18.55L12,18.65L11.89,18.55C7.14,14.24 4,11.39 4,8.5C4,6.5 5.5,5 7.5,5C9.04,5 10.54,6 11.07,7.36H12.93C13.46,6 14.96,5 16.5,5C18.5,5 20,6.5 20,8.5C20,11.39 16.86,14.24 12.1,18.55M16.5,3C14.76,3 13.09,3.81 12,5.08C10.91,3.81 9.24,3 7.5,3C4.42,3 2,5.41 2,8.5C2,12.27 5.4,15.36 10.55,20.03L12,21.35L13.45,20.03C18.6,15.36 22,12.27 22,8.5C22,5.41 19.58,3 16.5,3Z","mdi:help":"M10,19H13V22H10V19M12,2C17.35,2.22 19.68,7.62 16.5,11.67C15.67,12.67 14.33,13.33 13.67,14.17C13,15 13,16 13,17H10C10,15.33 10,13.92 10.67,12.92C11.33,11.92 12.67,11.33 13.5,10.67C15.92,8.43 15.32,5.26 12,5A3,3 0 0,0 9,8H6A6,6 0 0,1 12,2Z","mdi:help-circle-outline":"M11,18H13V16H11V18M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,6A4,4 0 0,0 8,10H10A2,2 0 0,1 12,8A2,2 0 0,1 14,10C14,12 11,11.75 11,15H13C13,12.75 16,12.5 16,10A4,4 0 0,0 12,6Z","mdi:home-group":"M17,16H15V22H12V17H8V22H5V16H3L10,10L17,16M6,2L10,6H9V9H7V6H5V9H3V6H2L6,2M18,3L23,8H22V12H19V9H17V12H15.34L14,10.87V8H13L18,3Z","mdi:information":"M13,9H11V7H13M13,17H11V11H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z","mdi:lock":"M12,17A2,2 0 0,0 14,15C14,13.89 13.1,13 12,13A2,2 0 0,0 10,15A2,2 0 0,0 12,17M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6A2,2 0 0,1 4,20V10C4,8.89 4.9,8 6,8H7V6A5,5 0 0,1 12,1A5,5 0 0,1 17,6V8H18M12,3A3,3 0 0,0 9,6V8H15V6A3,3 0 0,0 12,3Z","mdi:lock-open":"M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6C4.89,22 4,21.1 4,20V10A2,2 0 0,1 6,8H15V6A3,3 0 0,0 12,3A3,3 0 0,0 9,6H7A5,5 0 0,1 12,1A5,5 0 0,1 17,6V8H18M12,17A2,2 0 0,0 14,15A2,2 0 0,0 12,13A2,2 0 0,0 10,15A2,2 0 0,0 12,17Z","mdi:menu":"M3,6H21V8H3V6M3,11H21V13H3V11M3,16H21V18H3V16Z","mdi:monitor-eye":"M3 4V16H21V4H3M3 2H21C22.1 2 23 2.89 23 4V16C23 16.53 22.79 17.04 22.41 17.41C22.04 17.79 21.53 18 21 18H14V20H16V22H8V20H10V18H3C2.47 18 1.96 17.79 1.59 17.41C1.21 17.04 1 16.53 1 16V4C1 2.89 1.89 2 3 2M10.84 8.93C11.15 8.63 11.57 8.45 12 8.45C12.43 8.46 12.85 8.63 13.16 8.94C13.46 9.24 13.64 9.66 13.64 10.09C13.64 10.53 13.46 10.94 13.16 11.25C12.85 11.56 12.43 11.73 12 11.73C11.57 11.73 11.15 11.55 10.84 11.25C10.54 10.94 10.36 10.53 10.36 10.09C10.36 9.66 10.54 9.24 10.84 8.93M10.07 12C10.58 12.53 11.28 12.82 12 12.82C12.72 12.82 13.42 12.53 13.93 12C14.44 11.5 14.73 10.81 14.73 10.09C14.73 9.37 14.44 8.67 13.93 8.16C13.42 7.65 12.72 7.36 12 7.36C11.28 7.36 10.58 7.65 10.07 8.16C9.56 8.67 9.27 9.37 9.27 10.09C9.27 10.81 9.56 11.5 10.07 12M6 10.09C6.94 7.7 9.27 6 12 6C14.73 6 17.06 7.7 18 10.09C17.06 12.5 14.73 14.18 12 14.18C9.27 14.18 6.94 12.5 6 10.09Z","mdi:router-wireless":"M20.2,5.9L21,5.1C19.6,3.7 17.8,3 16,3C14.2,3 12.4,3.7 11,5.1L11.8,5.9C13,4.8 14.5,4.2 16,4.2C17.5,4.2 19,4.8 20.2,5.9M19.3,6.7C18.4,5.8 17.2,5.3 16,5.3C14.8,5.3 13.6,5.8 12.7,6.7L13.5,7.5C14.2,6.8 15.1,6.5 16,6.5C16.9,6.5 17.8,6.8 18.5,7.5L19.3,6.7M19,13H17V9H15V13H5A2,2 0 0,0 3,15V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V15A2,2 0 0,0 19,13M8,18H6V16H8V18M11.5,18H9.5V16H11.5V18M15,18H13V16H15V18Z","mdi:sort-descending":"M19 7H22L18 3L14 7H17V21H19M2 17H12V19H2M6 5V7H2V5M2 11H9V13H2V11Z","mdi:transmission-tower":"M8.28,5.45L6.5,4.55L7.76,2H16.23L17.5,4.55L15.72,5.44L15,4H9L8.28,5.45M18.62,8H14.09L13.3,5H10.7L9.91,8H5.38L4.1,10.55L5.89,11.44L6.62,10H17.38L18.1,11.45L19.89,10.56L18.62,8M17.77,22H15.7L15.46,21.1L12,15.9L8.53,21.1L8.3,22H6.23L9.12,11H11.19L10.83,12.35L12,14.1L13.16,12.35L12.81,11H14.88L17.77,22M11.4,15L10.5,13.65L9.32,18.13L11.4,15M14.68,18.12L13.5,13.64L12.6,15L14.68,18.12Z","mdi:view-dashboard":"M13,3V9H21V3M13,21H21V11H13M3,21H11V15H3M3,13H11V3H3V13Z"}),sT=new Set;class lT extends At{constructor(){super(...arguments),this.icon=""}render(){if(!this.icon)return ft;const t=aT[this.icon];return t?ht``:(e=this.icon,sT.has(e)||(sT.add(e),console.warn(`SPAN: unknown icon "${e}". Add it to MDI_PATHS in span-icon.ts.`)),ft);var e}}lT.styles=T` + `,b([zt()],oT.prototype,"_errors",void 0);try{customElements.get("span-error-banner")||customElements.define("span-error-banner",oT)}catch{}const aT=Object.freeze({"mdi:alert":"M13 14H11V9H13M13 18H11V16H13M1 21H23L12 2L1 21Z","mdi:alert-circle":"M13,13H11V7H13M13,17H11V15H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z","mdi:battery":"M16.67,4H15V2H9V4H7.33A1.33,1.33 0 0,0 6,5.33V20.67C6,21.4 6.6,22 7.33,22H16.67A1.33,1.33 0 0,0 18,20.67V5.33C18,4.6 17.4,4 16.67,4Z","mdi:battery-alert-variant-outline":"M14 20H6V6H14M14.67 4H13V2H7V4H5.33C4.6 4 4 4.6 4 5.33V20.67C4 21.4 4.6 22 5.33 22H14.67C15.4 22 16 21.4 16 20.67V5.33C16 4.6 15.4 4 14.67 4M21 7H19V13H21V8M21 15H19V17H21V15Z","mdi:chevron-down":"M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z","mdi:close":"M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z","mdi:cog":"M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.21,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.21,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.67 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z","mdi:heart":"M12,21.35L10.55,20.03C5.4,15.36 2,12.27 2,8.5C2,5.41 4.42,3 7.5,3C9.24,3 10.91,3.81 12,5.08C13.09,3.81 14.76,3 16.5,3C19.58,3 22,5.41 22,8.5C22,12.27 18.6,15.36 13.45,20.03L12,21.35Z","mdi:heart-outline":"M12.1,18.55L12,18.65L11.89,18.55C7.14,14.24 4,11.39 4,8.5C4,6.5 5.5,5 7.5,5C9.04,5 10.54,6 11.07,7.36H12.93C13.46,6 14.96,5 16.5,5C18.5,5 20,6.5 20,8.5C20,11.39 16.86,14.24 12.1,18.55M16.5,3C14.76,3 13.09,3.81 12,5.08C10.91,3.81 9.24,3 7.5,3C4.42,3 2,5.41 2,8.5C2,12.27 5.4,15.36 10.55,20.03L12,21.35L13.45,20.03C18.6,15.36 22,12.27 22,8.5C22,5.41 19.58,3 16.5,3Z","mdi:help":"M10,19H13V22H10V19M12,2C17.35,2.22 19.68,7.62 16.5,11.67C15.67,12.67 14.33,13.33 13.67,14.17C13,15 13,16 13,17H10C10,15.33 10,13.92 10.67,12.92C11.33,11.92 12.67,11.33 13.5,10.67C15.92,8.43 15.32,5.26 12,5A3,3 0 0,0 9,8H6A6,6 0 0,1 12,2Z","mdi:help-circle-outline":"M11,18H13V16H11V18M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,6A4,4 0 0,0 8,10H10A2,2 0 0,1 12,8A2,2 0 0,1 14,10C14,12 11,11.75 11,15H13C13,12.75 16,12.5 16,10A4,4 0 0,0 12,6Z","mdi:home-group":"M17,16H15V22H12V17H8V22H5V16H3L10,10L17,16M6,2L10,6H9V9H7V6H5V9H3V6H2L6,2M18,3L23,8H22V12H19V9H17V12H15.34L14,10.87V8H13L18,3Z","mdi:information":"M13,9H11V7H13M13,17H11V11H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z","mdi:lock":"M12,17A2,2 0 0,0 14,15C14,13.89 13.1,13 12,13A2,2 0 0,0 10,15A2,2 0 0,0 12,17M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6A2,2 0 0,1 4,20V10C4,8.89 4.9,8 6,8H7V6A5,5 0 0,1 12,1A5,5 0 0,1 17,6V8H18M12,3A3,3 0 0,0 9,6V8H15V6A3,3 0 0,0 12,3Z","mdi:lock-open":"M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6C4.89,22 4,21.1 4,20V10A2,2 0 0,1 6,8H15V6A3,3 0 0,0 12,3A3,3 0 0,0 9,6H7A5,5 0 0,1 12,1A5,5 0 0,1 17,6V8H18M12,17A2,2 0 0,0 14,15A2,2 0 0,0 12,13A2,2 0 0,0 10,15A2,2 0 0,0 12,17Z","mdi:menu":"M3,6H21V8H3V6M3,11H21V13H3V11M3,16H21V18H3V16Z","mdi:monitor-eye":"M3 4V16H21V4H3M3 2H21C22.1 2 23 2.89 23 4V16C23 16.53 22.79 17.04 22.41 17.41C22.04 17.79 21.53 18 21 18H14V20H16V22H8V20H10V18H3C2.47 18 1.96 17.79 1.59 17.41C1.21 17.04 1 16.53 1 16V4C1 2.89 1.89 2 3 2M10.84 8.93C11.15 8.63 11.57 8.45 12 8.45C12.43 8.46 12.85 8.63 13.16 8.94C13.46 9.24 13.64 9.66 13.64 10.09C13.64 10.53 13.46 10.94 13.16 11.25C12.85 11.56 12.43 11.73 12 11.73C11.57 11.73 11.15 11.55 10.84 11.25C10.54 10.94 10.36 10.53 10.36 10.09C10.36 9.66 10.54 9.24 10.84 8.93M10.07 12C10.58 12.53 11.28 12.82 12 12.82C12.72 12.82 13.42 12.53 13.93 12C14.44 11.5 14.73 10.81 14.73 10.09C14.73 9.37 14.44 8.67 13.93 8.16C13.42 7.65 12.72 7.36 12 7.36C11.28 7.36 10.58 7.65 10.07 8.16C9.56 8.67 9.27 9.37 9.27 10.09C9.27 10.81 9.56 11.5 10.07 12M6 10.09C6.94 7.7 9.27 6 12 6C14.73 6 17.06 7.7 18 10.09C17.06 12.5 14.73 14.18 12 14.18C9.27 14.18 6.94 12.5 6 10.09Z","mdi:router-wireless":"M20.2,5.9L21,5.1C19.6,3.7 17.8,3 16,3C14.2,3 12.4,3.7 11,5.1L11.8,5.9C13,4.8 14.5,4.2 16,4.2C17.5,4.2 19,4.8 20.2,5.9M19.3,6.7C18.4,5.8 17.2,5.3 16,5.3C14.8,5.3 13.6,5.8 12.7,6.7L13.5,7.5C14.2,6.8 15.1,6.5 16,6.5C16.9,6.5 17.8,6.8 18.5,7.5L19.3,6.7M19,13H17V9H15V13H5A2,2 0 0,0 3,15V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V15A2,2 0 0,0 19,13M8,18H6V16H8V18M11.5,18H9.5V16H11.5V18M15,18H13V16H15V18Z","mdi:sort-descending":"M19 7H22L18 3L14 7H17V21H19M2 17H12V19H2M6 5V7H2V5M2 11H9V13H2V11Z","mdi:transmission-tower":"M8.28,5.45L6.5,4.55L7.76,2H16.23L17.5,4.55L15.72,5.44L15,4H9L8.28,5.45M18.62,8H14.09L13.3,5H10.7L9.91,8H5.38L4.1,10.55L5.89,11.44L6.62,10H17.38L18.1,11.45L19.89,10.56L18.62,8M17.77,22H15.7L15.46,21.1L12,15.9L8.53,21.1L8.3,22H6.23L9.12,11H11.19L10.83,12.35L12,14.1L13.16,12.35L12.81,11H14.88L17.77,22M11.4,15L10.5,13.65L9.32,18.13L11.4,15M14.68,18.12L13.5,13.64L12.6,15L14.68,18.12Z","mdi:view-dashboard":"M13,3V9H21V3M13,21H21V11H13M3,21H11V15H3M3,13H11V3H3V13Z"}),sT=new Set;class lT extends It{constructor(){super(...arguments),this.icon=""}render(){if(!this.icon)return ft;const t=aT[this.icon];return t?dt``:(e=this.icon,sT.has(e)||(sT.add(e),console.warn(`SPAN: unknown icon "${e}". Add it to MDI_PATHS in span-icon.ts.`)),ft);var e}}lT.styles=T` :host { display: inline-flex; align-items: center; @@ -128,7 +128,7 @@ var Ga={},qa={};var ja,Xa=function(){function t(t,e,n){var i=this;this._sleepAft display: block; fill: currentColor; } - `,x([zt({type:String})],lT.prototype,"icon",void 0);try{customElements.get("span-icon")||customElements.define("span-icon",lT)}catch{}class cT extends At{constructor(){super(...arguments),this.checked=!1,this.disabled=!1,this._onActivate=t=>{if(this.disabled)return t.preventDefault(),void t.stopPropagation();this.checked=!this.checked,this.dispatchEvent(new Event("change",{bubbles:!0,composed:!0}))},this._onKeydown=t=>{" "!==t.key&&"Enter"!==t.key||(t.preventDefault(),this._onActivate(t))}}connectedCallback(){super.connectedCallback(),this.hasAttribute("tabindex")||this.setAttribute("tabindex","0"),this.hasAttribute("role")||this.setAttribute("role","switch"),this.setAttribute("aria-checked",String(this.checked)),this.addEventListener("click",this._onActivate),this.addEventListener("keydown",this._onKeydown)}disconnectedCallback(){this.removeEventListener("click",this._onActivate),this.removeEventListener("keydown",this._onKeydown),super.disconnectedCallback()}updated(t){t.has("checked")&&this.setAttribute("aria-checked",String(this.checked)),t.has("disabled")&&this.setAttribute("aria-disabled",String(this.disabled))}render(){return ht` + `,b([Ot({type:String})],lT.prototype,"icon",void 0);try{customElements.get("span-icon")||customElements.define("span-icon",lT)}catch{}class cT extends It{constructor(){super(...arguments),this.checked=!1,this.disabled=!1,this._onActivate=t=>{if(this.disabled)return t.preventDefault(),void t.stopPropagation();this.checked=!this.checked,this.dispatchEvent(new Event("change",{bubbles:!0,composed:!0}))},this._onKeydown=t=>{" "!==t.key&&"Enter"!==t.key||(t.preventDefault(),this._onActivate(t))}}connectedCallback(){super.connectedCallback(),this.hasAttribute("tabindex")||this.setAttribute("tabindex","0"),this.hasAttribute("role")||this.setAttribute("role","switch"),this.setAttribute("aria-checked",String(this.checked)),this.addEventListener("click",this._onActivate),this.addEventListener("keydown",this._onKeydown)}disconnectedCallback(){this.removeEventListener("click",this._onActivate),this.removeEventListener("keydown",this._onKeydown),super.disconnectedCallback()}updated(t){t.has("checked")&&this.setAttribute("aria-checked",String(this.checked)),t.has("disabled")&&this.setAttribute("aria-disabled",String(this.disabled))}render(){return dt`
@@ -185,12 +185,12 @@ var Ga={},qa={};var ja,Xa=function(){function t(t,e,n){var i=this;this._sleepAft outline: 2px solid var(--span-switch-on); outline-offset: 2px; } - `,x([zt({type:Boolean,reflect:!0})],cT.prototype,"checked",void 0),x([zt({type:Boolean,reflect:!0})],cT.prototype,"disabled",void 0);try{customElements.get("span-switch")||customElements.define("span-switch",cT)}catch{}const uT=[{name:"Kitchen",watts:"120",path:"M0,28 L8,26 L16,24 L24,22 L32,25 L40,20 L48,18 L56,22 L64,19 L72,16 L80,18 L88,15 L96,17 L104,14 L112,16 L120,13"},{name:"Living Room",watts:"85",path:"M0,22 L8,24 L16,20 L24,26 L32,18 L40,22 L48,16 L56,20 L64,24 L72,18 L80,22 L88,20 L96,16 L104,22 L112,18 L120,20"},{name:"Master Bed",watts:"193",path:"M0,8 L8,10 L16,8 L24,12 L32,10 L40,8 L48,10 L56,8 L64,10 L72,8 L80,12 L88,10 L96,8 L104,10 L112,8 L120,10"},{name:"HVAC",watts:"64",path:"M0,30 L8,28 L16,26 L24,22 L32,18 L40,14 L48,18 L56,22 L64,26 L72,22 L80,18 L88,22 L96,26 L104,22 L112,18 L120,22"}];let hT=class extends At{constructor(){super(...arguments),this._config={},this._discovered=!1,this._discovering=!1,this._topology=null,this._activeTab="panel",this._panelDevice=null,this._panelSize=0,this._historyLoaded=!1,this._ctrl=new Bk,this._listCtrl=new Zk(this._ctrl),this._errorStore=new Qk,this._areaUnsub=null,this._areaSubscribing=!1,this._tabBarCleanup=null}get _configEntryId(){return this._panelDevice?.config_entries?.[0]??null}get _root(){const t=this.shadowRoot;if(!t)throw new Error("span-panel-card: shadow root is not available");return t}connectedCallback(){super.connectedCallback(),this._ctrl.startIntervals(this._root)}disconnectedCallback(){this._ctrl.stopIntervals(),this._listCtrl.stop(),this._areaSubscribing=!1,this._areaUnsub&&(this._areaUnsub(),this._areaUnsub=null),this._tabBarCleanup&&(this._tabBarCleanup(),this._tabBarCleanup=null),this._errorStore.dispose(),super.disconnectedCallback()}setConfig(t){this._errorStore.clear(),this._config=t,this._discovered=!1,this._discovering=!1,this._historyLoaded=!1,this._topology=null,this._panelDevice=null,this._panelSize=0,this._activeTab="panel",this._ctrl.reset(),this._ctrl.setConfig(t),this._ctrl.errorStore=this._errorStore}getCardSize(){return Math.ceil(this._panelSize/2)+3}static getConfigElement(){return document.createElement("span-panel-card-editor")}static getStubConfig(){return{device_id:"",history_days:0,history_hours:0,history_minutes:5,chart_metric:o,show_panel:!0,show_battery:!0,show_evse:!0}}render(){if(n(this.hass?.language),!this._config.device_id)return this._renderPreview();if(!this._discovered){const t=this._errorStore.hasPersistent("discovery-failed");return ht` + `,b([Ot({type:Boolean,reflect:!0})],cT.prototype,"checked",void 0),b([Ot({type:Boolean,reflect:!0})],cT.prototype,"disabled",void 0);try{customElements.get("span-switch")||customElements.define("span-switch",cT)}catch{}const uT=[{name:"Kitchen",watts:"120",path:"M0,28 L8,26 L16,24 L24,22 L32,25 L40,20 L48,18 L56,22 L64,19 L72,16 L80,18 L88,15 L96,17 L104,14 L112,16 L120,13"},{name:"Living Room",watts:"85",path:"M0,22 L8,24 L16,20 L24,26 L32,18 L40,22 L48,16 L56,20 L64,24 L72,18 L80,22 L88,20 L96,16 L104,22 L112,18 L120,20"},{name:"Master Bed",watts:"193",path:"M0,8 L8,10 L16,8 L24,12 L32,10 L40,8 L48,10 L56,8 L64,10 L72,8 L80,12 L88,10 L96,8 L104,10 L112,8 L120,10"},{name:"HVAC",watts:"64",path:"M0,30 L8,28 L16,26 L24,22 L32,18 L40,14 L48,18 L56,22 L64,26 L72,22 L80,18 L88,22 L96,26 L104,22 L112,18 L120,22"}];let dT=class extends It{constructor(){super(...arguments),this._config={},this._discovered=!1,this._discovering=!1,this._topology=null,this._activeTab="panel",this._panelDevice=null,this._panelSize=0,this._historyLoaded=!1,this._ctrl=new Bk,this._listCtrl=new Zk(this._ctrl),this._errorStore=new Qk,this._areaUnsub=null,this._areaSubscribing=!1,this._tabBarCleanup=null}get _configEntryId(){return this._panelDevice?.config_entries?.[0]??null}get _root(){const t=this.shadowRoot;if(!t)throw new Error("span-panel-card: shadow root is not available");return t}connectedCallback(){super.connectedCallback(),this._ctrl.startIntervals(this._root)}disconnectedCallback(){this._ctrl.stopIntervals(),this._listCtrl.stop(),this._areaSubscribing=!1,this._areaUnsub&&(this._areaUnsub(),this._areaUnsub=null),this._tabBarCleanup&&(this._tabBarCleanup(),this._tabBarCleanup=null),this._errorStore.dispose(),super.disconnectedCallback()}setConfig(t){this._errorStore.clear(),this._config=t,this._discovered=!1,this._discovering=!1,this._historyLoaded=!1,this._topology=null,this._panelDevice=null,this._panelSize=0,this._activeTab="panel",this._ctrl.reset(),this._ctrl.setConfig(t),this._ctrl.errorStore=this._errorStore}getCardSize(){return Math.ceil(this._panelSize/2)+3}static getConfigElement(){return document.createElement("span-panel-card-editor")}static getStubConfig(){return{device_id:"",history_days:0,history_hours:0,history_minutes:5,chart_metric:o,show_panel:!0,show_battery:!0,show_evse:!0}}render(){if(n(this.hass?.language),!this._config.device_id)return this._renderPreview();if(!this._discovered){const t=this._errorStore.hasPersistent("discovery-failed");return dt`
- ${t?ft:ht`
${Rt(i("card.connecting"))}
`} + ${t?ft:dt`
${Rt(i("card.connecting"))}
`}
- `}return ht` + `}return dt`
- `}updated(t){if(t.has("hass")&&this.hass&&(n(this.hass.language),this._ctrl.hass=this.hass,this._errorStore.updateHass(this.hass),this._config.device_id))if(this._discovered||this._discovering){if(this._discovered){this._ctrl.recordSamples(),this._ctrl.updateDOM(this._root);const t=this._root.querySelector("span-side-panel");t&&(t.hass=this.hass,t.errorStore=this._errorStore)}this._discovered&&"panel"!==this._activeTab&&this._topology&&this._listCtrl.updateCollapsedRows(this._root,this.hass,this._topology,this._config)}else this._startDiscovery()}async _startDiscovery(){this._discovering||(this._discovering=!0,await this._discoverTopology(),this._errorStore.hasPersistent("discovery-failed")?this._discovering=!1:(this._discovered=!0,this._discovering=!1,this._ctrl.init(this._topology,this._config,this.hass,this._configEntryId),this._topology?.panel_entities?.panel_status&&(this._errorStore.watchPanelStatus(this._topology.panel_entities.panel_status),this._errorStore.updateHass(this.hass)),this._topology&&(this._areaSubscribing=!0,async function(t,e,n,r){if(!t.connection)return()=>{};const o=async()=>{try{const i=new Map;for(const[t,n]of Object.entries(e.circuits))i.set(t,n.area);await Kk(t,e);for(const[t,r]of Object.entries(e.circuits))if(r.area!==i.get(t))return void n()}catch(t){console.warn("[span-panel] area registry update failed:",t),r?.add({key:"fetch:areas",level:"warning",message:i("error.areas_failed"),persistent:!1})}},[a,s]=await Promise.all([t.connection.subscribeEvents(o,"entity_registry_updated"),t.connection.subscribeEvents(o,"area_registry_updated")]);return()=>{a(),s()}}(this.hass,this._topology,()=>{"area"===this._activeTab&&this._discovered&&this._populateCardContent()},this._errorStore).then(t=>{this._areaSubscribing?this._areaUnsub=t:t()}).catch(t=>{this._areaSubscribing=!1,console.warn("SPAN Panel: area subscription failed",t),this._errorStore.add({key:"subscribe:area",level:"warning",message:i("error.areas_failed"),persistent:!1})})),await this.updateComplete,this._populateCardContent(),this._loadHistory(),this._ctrl.monitoringCache.fetch(this.hass,this._configEntryId).then(()=>{this._discovered&&this._ctrl.updateDOM(this._root)})))}async _discoverTopology(){if(!this.hass)return;const t=new Qt(this._errorStore);try{const e=await async function(t,e,n){if(!e)throw new Error(i("card.device_not_found"));const r={type:`${l}/panel_topology`,device_id:e},o=n?await n.callWS(t,r,{errorId:"fetch:topology"}):await t.callWS(r),a=o.panel_size??Jk(o.circuits);if(!a)throw new Error(i("card.topology_error"));const s={type:"config/device_registry/list"},c=tT((n?await n.callWS(t,s,{errorId:"fetch:topology"}):await t.callWS(s)).find(t=>t.id===e));return await Kk(t,o),{topology:o,panelDevice:c,panelSize:a}}(this.hass,this._config.device_id,t);this._topology=e.topology,this._panelDevice=e.panelDevice,this._panelSize=e.panelSize}catch(e){console.error("SPAN Panel: topology fetch failed, falling back to entity discovery",e);try{const e=await async function(t,e,n){const r={type:"config/device_registry/list"},o={type:"config/entity_registry/list"},[a,s]=await Promise.all([n?n.callWS(t,r,{errorId:"fetch:topology"}):t.callWS(r),n?n.callWS(t,o,{errorId:"fetch:topology"}):t.callWS(o)]),c=tT(a.find(t=>t.id===e));if(!c)return{topology:null,panelDevice:null,panelSize:0};const u=s.filter(t=>t.device_id===e),h=a.filter(t=>t.via_device_id===e),d=new Set(h.map(t=>t.id)),p=s.filter(t=>void 0!==t.device_id&&d.has(t.device_id)),f={},g=c.name_by_user??c.name??"";for(const e of[...u,...p]){const n=t.states[e.entity_id];if(!n)continue;const i=n.attributes,r=i.tabs;if("string"!=typeof r||!r.startsWith("tabs ["))continue;const o=r.slice(6,-1);let a;if(a=o.includes(":")?o.split(":").map(Number):[Number(o)],!a.every(Number.isFinite))continue;const s=e.unique_id.split("_");let l=null;for(let t=2;t=16&&/^[a-f0-9]+$/i.test(e)){l=e;break}}if(!l)continue;let c=("string"==typeof i.friendly_name?i.friendly_name:void 0)??e.entity_id;for(const t of[" Power"," Consumed Energy"," Produced Energy"])if(c.endsWith(t)){c=c.slice(0,-t.length);break}g&&c.startsWith(g+" ")&&(c=c.slice(g.length+1));const u=e.entity_id.replace(/^sensor\./,"").replace(/_power$/,""),h="number"==typeof i.voltage?i.voltage:2===a.length?240:120,d={power:e.entity_id,switch:`switch.${u}_breaker`,breaker_rating:`sensor.${u}_breaker_rating`};f[l]={tabs:a,name:c,voltage:h,device_type:"string"==typeof i.device_type?i.device_type:"circuit",relay_state:"string"==typeof i.relay_state?i.relay_state:"UNKNOWN",is_user_controllable:!0,breaker_rating_a:null,entities:d}}let v="";if(c.identifiers)for(const t of c.identifiers){if(!Array.isArray(t)||t.length<2)continue;const[e,n]=t;e===l&&"string"==typeof n&&(v=n)}let m=0;for(const e of u){const n=t.states[e.entity_id];if(n&&"number"==typeof n.attributes.panel_size){m=n.attributes.panel_size;break}}if(m||(m=Jk(f)),!m)throw new Error(i("card.panel_size_error"));const y={};for(const e of h){const n=s.filter(t=>t.device_id===e.id),i=(e.model??"").toLowerCase(),r=i.includes("battery")||(e.identifiers??[]).some(t=>t[1].toLowerCase().includes("bess")),o=i.includes("drive")||(e.identifiers??[]).some(t=>t[1].toLowerCase().includes("evse")),a={};for(const e of n){const n=e.entity_id.split(".")[0],i=t.states[e.entity_id],r=i?.attributes?.friendly_name;a[e.entity_id]={domain:n??"",original_name:"string"==typeof r?r:e.entity_id}}y[e.id]={name:e.name_by_user??e.name??"",type:r?"bess":o?"evse":"unknown",entities:a}}const _={serial:v,firmware:c.sw_version??"",panel_size:m,device_id:e,device_name:c.name_by_user??c.name??i("header.default_name"),circuits:f,sub_devices:y};return await Kk(t,_),{topology:_,panelDevice:c,panelSize:m}}(this.hass,this._config.device_id,t);this._topology=e.topology,this._panelDevice=e.panelDevice,this._panelSize=e.panelSize}catch(t){console.error("SPAN Panel: fallback discovery also failed",t),this._errorStore.add({key:"discovery-failed",level:"error",message:i("error.discovery_failed"),persistent:!0,retryFn:()=>{this._errorStore.remove("discovery-failed"),this._startDiscovery()}})}}}async _loadHistory(){if(!this._historyLoaded&&this._topology&&this.hass){this._historyLoaded=!0,await this._ctrl.fetchAndBuildHorizonMaps();try{await this._ctrl.loadHistory(),this._ctrl.updateDOM(this._root)}catch(t){console.warn("SPAN Panel: history fetch failed, charts will populate live",t)}}}_populateCardContent(){const t=this._root.querySelector("#card-content");if(!(t&&this.hass&&this._topology&&this._panelSize))return;const e=this._root.querySelector("#card-tabs");if(e){const t=[{id:"panel",label:i("tab.by_panel"),icon:"mdi:view-dashboard"},{id:"activity",label:i("tab.by_activity"),icon:"mdi:sort-descending"},{id:"area",label:i("tab.by_area"),icon:"mdi:home-group"}];e.innerHTML=(n=t,r=this._activeTab,o=this._config.tab_style??"text",`
${n.map(t=>{const e=t.id===r?" active":"",n=Rt(t.id);return"icon"===o?``:``}).join("")}
`),this._tabBarCleanup&&(this._tabBarCleanup(),this._tabBarCleanup=null),this._tabBarCleanup=function(t,e){const n=t=>{const n=t.target.closest(".shared-tab");if(n){const t=n.dataset.tab;t&&e(t)}};return t.addEventListener("click",n),()=>{t.removeEventListener("click",n)}}(e,t=>{["panel","activity","area"].includes(t)&&(this._activeTab=t,this._listCtrl.stop(),this._populateCardContent())})}var n,r,o;if("panel"===this._activeTab){const e=Math.ceil(this._panelSize/2),n=$t(this._topology,this._config),r=this._ctrl.monitoringCache.status,o=function(t){if(!t)return"";const e=Object.values(t.circuits??{}),n=Object.values(t.mains??{}),r=[...e,...n],o=r.filter(t=>void 0!==t.utilization_pct&&t.utilization_pct>=80&&t.utilization_pct<100).length,a=r.filter(t=>void 0!==t.utilization_pct&&t.utilization_pct>=100).length,s=r.filter(t=>t.has_override).length;return`\n
\n ✓ ${i("status.monitoring")} · ${e.length} ${i("status.circuits")} · ${n.length} ${i("status.mains")}\n \n ${o>0?`${o} ${i(o>1?"status.warnings":"status.warning")}`:""}\n ${a>0?`${a} ${i(a>1?"status.alerts":"status.alert")}`:""}\n ${s>0?`${s} ${i(s>1?"status.overrides":"status.override")}`:""}\n \n
\n `}(r),a=function(t,e,n,i,r){const o=new Map,a=new Set;for(const[e,n]of Object.entries(t.circuits)){const t=n.tabs;if(!t||0===t.length)continue;const i=Math.min(...t),r=1===t.length?"single":Xt(t)??"single";o.set(i,{uuid:e,circuit:n,layout:r});for(const e of t)a.add(e)}const s=new Set,l=new Set;for(const[t,e]of o)if("col-span"===e.layout){const n=e.circuit.tabs,i=qt(Math.max(...n));0===jt(t)?s.add(i):l.add(i)}function c(t){const e=t.circuit.entities?.current??t.circuit.entities?.power,i=r?ee(r,e??""):null;let o;if(t.circuit.always_on)o="always_on";else{const e=t.circuit.entities?.select;o=e&&n.states[e]?n.states[e].state:"unknown"}return{monInfo:i,sheddingPriority:o}}let u="";for(let t=1;t<=e;t++){const e=2*t-1,r=2*t,h=o.get(e),d=o.get(r);if(u+=`
${e}
`,h&&"row-span"===h.layout){const{monInfo:e,sheddingPriority:o}=c(h);u+=ie(h.uuid,h.circuit,t,"2 / 5","row-span",n,i,e,o),u+=`
${r}
`;continue}if(!s.has(t))if(!h||"col-span"!==h.layout&&"single"!==h.layout)a.has(e)||(u+=re(t,"2"));else{const{monInfo:e,sheddingPriority:r}=c(h);u+=ie(h.uuid,h.circuit,t,"2",h.layout,n,i,e,r)}if(!l.has(t))if(!d||"col-span"!==d.layout&&"single"!==d.layout)a.has(r)||(u+=re(t,"4"));else{const{monInfo:e,sheddingPriority:r}=c(d);u+=ie(d.uuid,d.circuit,t,"4",d.layout,n,i,e,r)}u+=`
${r}
`}return u}(this._topology,e,this.hass,this._config,r),s=function(t,e,n){const r=!1!==n.show_battery,o=!1!==n.show_evse;if(!t.sub_devices)return"";const a=Object.entries(t.sub_devices).filter(([,t])=>!(t.type===h&&!r||t.type===d&&!o));if(0===a.length)return"";const s=[];for(const[t,i]of a){const r=ue(i),o=i.type===h,a=o?he(i):null,l=o?de(i):null,c=o?pe(i):null,u=fe(i,e,n,new Set([r,a,l,c].filter(t=>null!==t))),d=ge(t,0,o,r,a,l);(r||d||u)&&s.push({devId:t,sub:i,powerEid:r,chartsHTML:d,entHTML:u})}if(0===s.length)return"";const l=s.filter(t=>t.sub.type===d).length;let c=0,u="";for(const{devId:t,sub:n,powerEid:r,chartsHTML:o,entHTML:a}of s){const s=n.type===d?i("subdevice.ev_charger"):n.type===h?i("subdevice.battery"):i("subdevice.fallback"),p=r?e.states[r]:void 0,f=p&&parseFloat(p.state)||0,g=n.type===h,v=n.type===d;let m="";g?m="sub-device-bess":v&&(c++,c===l&&l%2==1&&(m="sub-device-full")),u+=`\n
\n
\n ${Rt(s)}\n ${Rt(n.name||"")}\n ${r?`${Ut(f)} ${Wt(f)}`:""}\n \n
\n ${o}\n ${a}\n
\n `}return u}(this._topology,this.hass,this._config);t.innerHTML=`\n ${n}\n ${o}\n ${s?`
${s}
`:""}\n ${!1!==this._config.show_panel?`
${a}
`:""}\n `;const l=t.querySelector(".slide-confirm");if(l){const t=this._root.querySelector(".span-card");this._ctrl.bindSlideConfirm(l,t),t&&t.classList.add("switches-disabled")}const c=this._root.querySelector("span-side-panel");c&&(c.hass=this.hass,c.errorStore=this._errorStore),this._ctrl.recordSamples(),this._ctrl.updateDOM(this._root),this._ctrl.setupResizeObserver(this._root,this._root.querySelector(".span-card"))}else if("activity"===this._activeTab){t.innerHTML="";const e=$t(this._topology,this._config);this._listCtrl.setColumns(Bt()),this._listCtrl.renderActivityView(t,this.hass,this._topology,this._config,this._ctrl.monitoringCache.status,e),this._ctrl.updateDOM(this._root)}else if("area"===this._activeTab){t.innerHTML="";const e=$t(this._topology,this._config);this._listCtrl.setColumns(Bt()),this._listCtrl.renderAreaView(t,this.hass,this._topology,this._config,this._ctrl.monitoringCache.status,e),this._ctrl.updateDOM(this._root)}}_onCardClick(t){if("panel"!==this._activeTab)return;const e=t.target;if(!e)return;const n=e.closest(".unit-btn");if(n)return void this._onUnitToggle(n);if(e.closest(".toggle-pill"))return void this._ctrl.onToggleClick(t,this._root);e.closest(".gear-icon")&&this._ctrl.onGearClick(t,this._root)}async _onUnitToggle(t){const e=t.dataset.unit;e&&e!==(this._config.chart_metric??"power")&&(this._config={...this._config,chart_metric:e},this._ctrl.setConfig(this._config),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config},bubbles:!0,composed:!0})),this._ctrl.powerHistory.clear(),this._historyLoaded=!1,this._populateCardContent(),await this._loadHistory(),this._ctrl.updateDOM(this._root))}async _onListUnitChanged(t){const e=t.detail;e&&e!==(this._config.chart_metric??"power")&&(this._config={...this._config,chart_metric:e},this._ctrl.setConfig(this._config),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config},bubbles:!0,composed:!0})),this._ctrl.powerHistory.clear(),this._historyLoaded=!1,this._populateCardContent(),await this._loadHistory(),this._ctrl.updateDOM(this._root))}_onGraphSettingsChanged(){this._ctrl.onGraphSettingsChanged(this._root)}_onListColumnsChanged(t){const e=t.detail;"number"!=typeof e||1!==e&&2!==e&&3!==e||"activity"!==this._activeTab&&"area"!==this._activeTab||this._populateCardContent()}_onSidePanelClosed(){this._ctrl.monitoringCache.invalidate(),this._ctrl.graphSettingsCache.invalidate()}_renderPreview(){const t=uT.map(t=>ht` + `}updated(t){if(t.has("hass")&&this.hass&&(n(this.hass.language),this._ctrl.hass=this.hass,this._errorStore.updateHass(this.hass),this._config.device_id))if(this._discovered||this._discovering){if(this._discovered){this._ctrl.recordSamples(),this._ctrl.updateDOM(this._root);const t=this._root.querySelector("span-side-panel");t&&(t.hass=this.hass,t.errorStore=this._errorStore)}this._discovered&&"panel"!==this._activeTab&&this._topology&&this._listCtrl.updateCollapsedRows(this._root,this.hass,this._topology,this._config)}else this._startDiscovery()}async _startDiscovery(){this._discovering||(this._discovering=!0,await this._discoverTopology(),this._errorStore.hasPersistent("discovery-failed")?this._discovering=!1:(this._discovered=!0,this._discovering=!1,this._ctrl.init(this._topology,this._config,this.hass,this._configEntryId),this._topology?.panel_entities?.panel_status&&(this._errorStore.watchPanelStatus(this._topology.panel_entities.panel_status),this._errorStore.updateHass(this.hass)),this._topology&&(this._areaSubscribing=!0,async function(t,e,n,r){if(!t.connection)return()=>{};const o=async()=>{try{const i=new Map;for(const[t,n]of Object.entries(e.circuits))i.set(t,n.area);await Kk(t,e);for(const[t,r]of Object.entries(e.circuits))if(r.area!==i.get(t))return void n()}catch(t){console.warn("[span-panel] area registry update failed:",t),r?.add({key:"fetch:areas",level:"warning",message:i("error.areas_failed"),persistent:!1})}},[a,s]=await Promise.all([t.connection.subscribeEvents(o,"entity_registry_updated"),t.connection.subscribeEvents(o,"area_registry_updated")]);return()=>{a(),s()}}(this.hass,this._topology,()=>{"area"===this._activeTab&&this._discovered&&this._populateCardContent()},this._errorStore).then(t=>{this._areaSubscribing?this._areaUnsub=t:t()}).catch(t=>{this._areaSubscribing=!1,console.warn("SPAN Panel: area subscription failed",t),this._errorStore.add({key:"subscribe:area",level:"warning",message:i("error.areas_failed"),persistent:!1})})),await this.updateComplete,this._populateCardContent(),this._loadHistory(),this._ctrl.monitoringCache.fetch(this.hass,this._configEntryId).then(()=>{this._discovered&&this._ctrl.updateDOM(this._root)})))}async _discoverTopology(){if(!this.hass)return;const t=new Qt(this._errorStore);try{const e=await async function(t,e,n){if(!e)throw new Error(i("card.device_not_found"));const r={type:`${l}/panel_topology`,device_id:e},o=n?await n.callWS(t,r,{errorId:"fetch:topology"}):await t.callWS(r),a=o.panel_size??Jk(o.circuits);if(!a)throw new Error(i("card.topology_error"));const s={type:"config/device_registry/list"},c=tT((n?await n.callWS(t,s,{errorId:"fetch:topology"}):await t.callWS(s)).find(t=>t.id===e));return await Kk(t,o),{topology:o,panelDevice:c,panelSize:a}}(this.hass,this._config.device_id,t);this._topology=e.topology,this._panelDevice=e.panelDevice,this._panelSize=e.panelSize}catch(e){console.error("SPAN Panel: topology fetch failed, falling back to entity discovery",e);try{const e=await async function(t,e,n){const r={type:"config/device_registry/list"},o={type:"config/entity_registry/list"},[a,s]=await Promise.all([n?n.callWS(t,r,{errorId:"fetch:topology"}):t.callWS(r),n?n.callWS(t,o,{errorId:"fetch:topology"}):t.callWS(o)]),c=tT(a.find(t=>t.id===e));if(!c)return{topology:null,panelDevice:null,panelSize:0};const u=s.filter(t=>t.device_id===e),d=a.filter(t=>t.via_device_id===e),h=new Set(d.map(t=>t.id)),p=s.filter(t=>void 0!==t.device_id&&h.has(t.device_id)),f={},g=c.name_by_user??c.name??"";for(const e of[...u,...p]){const n=t.states[e.entity_id];if(!n)continue;const i=n.attributes,r=i.tabs;if("string"!=typeof r||!r.startsWith("tabs ["))continue;const o=r.slice(6,-1);let a;if(a=o.includes(":")?o.split(":").map(Number):[Number(o)],!a.every(Number.isFinite))continue;const s=e.unique_id.split("_");let l=null;for(let t=2;t=16&&/^[a-f0-9]+$/i.test(e)){l=e;break}}if(!l)continue;let c=("string"==typeof i.friendly_name?i.friendly_name:void 0)??e.entity_id;for(const t of[" Power"," Consumed Energy"," Produced Energy"])if(c.endsWith(t)){c=c.slice(0,-t.length);break}g&&c.startsWith(g+" ")&&(c=c.slice(g.length+1));const u=e.entity_id.replace(/^sensor\./,"").replace(/_power$/,""),d="number"==typeof i.voltage?i.voltage:2===a.length?240:120,h={power:e.entity_id,switch:`switch.${u}_breaker`,breaker_rating:`sensor.${u}_breaker_rating`};f[l]={tabs:a,name:c,voltage:d,device_type:"string"==typeof i.device_type?i.device_type:"circuit",relay_state:"string"==typeof i.relay_state?i.relay_state:"UNKNOWN",is_user_controllable:!0,breaker_rating_a:null,entities:h}}let v="";if(c.identifiers)for(const t of c.identifiers){if(!Array.isArray(t)||t.length<2)continue;const[e,n]=t;e===l&&"string"==typeof n&&(v=n)}let m=0;for(const e of u){const n=t.states[e.entity_id];if(n&&"number"==typeof n.attributes.panel_size){m=n.attributes.panel_size;break}}if(m||(m=Jk(f)),!m)throw new Error(i("card.panel_size_error"));const y={};for(const e of d){const n=s.filter(t=>t.device_id===e.id),i=(e.model??"").toLowerCase(),r=i.includes("battery")||(e.identifiers??[]).some(t=>t[1].toLowerCase().includes("bess")),o=i.includes("drive")||(e.identifiers??[]).some(t=>t[1].toLowerCase().includes("evse")),a={};for(const e of n){const n=e.entity_id.split(".")[0],i=t.states[e.entity_id],r=i?.attributes?.friendly_name;a[e.entity_id]={domain:n??"",original_name:"string"==typeof r?r:e.entity_id}}y[e.id]={name:e.name_by_user??e.name??"",type:r?"bess":o?"evse":"unknown",entities:a}}const _={serial:v,firmware:c.sw_version??"",panel_size:m,device_id:e,device_name:c.name_by_user??c.name??i("header.default_name"),circuits:f,sub_devices:y};return await Kk(t,_),{topology:_,panelDevice:c,panelSize:m}}(this.hass,this._config.device_id,t);this._topology=e.topology,this._panelDevice=e.panelDevice,this._panelSize=e.panelSize}catch(t){console.error("SPAN Panel: fallback discovery also failed",t),this._errorStore.add({key:"discovery-failed",level:"error",message:i("error.discovery_failed"),persistent:!0,retryFn:()=>{this._errorStore.remove("discovery-failed"),this._startDiscovery()}})}}}async _loadHistory(){if(!this._historyLoaded&&this._topology&&this.hass){this._historyLoaded=!0,await this._ctrl.fetchAndBuildHorizonMaps();try{await this._ctrl.loadHistory(),this._ctrl.updateDOM(this._root)}catch(t){console.warn("SPAN Panel: history fetch failed, charts will populate live",t)}}}_populateCardContent(){const t=this._root.querySelector("#card-content");if(!(t&&this.hass&&this._topology&&this._panelSize))return;const e=this._root.querySelector("#card-tabs");if(e){const t=[{id:"panel",label:i("tab.by_panel"),icon:"mdi:view-dashboard"},{id:"activity",label:i("tab.by_activity"),icon:"mdi:sort-descending"},{id:"area",label:i("tab.by_area"),icon:"mdi:home-group"}];e.innerHTML=(n=t,r=this._activeTab,o=this._config.tab_style??"text",`
${n.map(t=>{const e=t.id===r?" active":"",n=Rt(t.id);return"icon"===o?``:``}).join("")}
`),this._tabBarCleanup&&(this._tabBarCleanup(),this._tabBarCleanup=null),this._tabBarCleanup=function(t,e){const n=t=>{const n=t.target.closest(".shared-tab");if(n){const t=n.dataset.tab;t&&e(t)}};return t.addEventListener("click",n),()=>{t.removeEventListener("click",n)}}(e,t=>{["panel","activity","area"].includes(t)&&(this._activeTab=t,this._listCtrl.stop(),this._populateCardContent())})}var n,r,o;if("panel"===this._activeTab){const e=Math.ceil(this._panelSize/2),n=$t(this._topology,this._config),r=this._ctrl.monitoringCache.status,o=function(t){if(!t)return"";const e=Object.values(t.circuits??{}),n=Object.values(t.mains??{}),r=[...e,...n],o=r.filter(t=>void 0!==t.utilization_pct&&t.utilization_pct>=80&&t.utilization_pct<100).length,a=r.filter(t=>void 0!==t.utilization_pct&&t.utilization_pct>=100).length,s=r.filter(t=>t.has_override).length;return`\n
\n ✓ ${i("status.monitoring")} · ${e.length} ${i("status.circuits")} · ${n.length} ${i("status.mains")}\n \n ${o>0?`${o} ${i(o>1?"status.warnings":"status.warning")}`:""}\n ${a>0?`${a} ${i(a>1?"status.alerts":"status.alert")}`:""}\n ${s>0?`${s} ${i(s>1?"status.overrides":"status.override")}`:""}\n \n
\n `}(r),a=function(t,e,n,i,r){const o=new Map,a=new Set;for(const[e,n]of Object.entries(t.circuits)){const t=n.tabs;if(!t||0===t.length)continue;const i=Math.min(...t),r=1===t.length?"single":Xt(t)??"single";o.set(i,{uuid:e,circuit:n,layout:r});for(const e of t)a.add(e)}const s=new Set,l=new Set;for(const[t,e]of o)if("col-span"===e.layout){const n=e.circuit.tabs,i=qt(Math.max(...n));0===jt(t)?s.add(i):l.add(i)}function c(t){const e=t.circuit.entities?.current??t.circuit.entities?.power,i=r?ee(r,e??""):null;let o;if(t.circuit.always_on)o="always_on";else{const e=t.circuit.entities?.select;o=e&&n.states[e]?n.states[e].state:"unknown"}return{monInfo:i,sheddingPriority:o}}let u="";for(let t=1;t<=e;t++){const e=2*t-1,r=2*t,d=o.get(e),h=o.get(r);if(u+=`
${e}
`,d&&"row-span"===d.layout){const{monInfo:e,sheddingPriority:o}=c(d);u+=ie(d.uuid,d.circuit,t,"2 / 5","row-span",n,i,e,o),u+=`
${r}
`;continue}if(!s.has(t))if(!d||"col-span"!==d.layout&&"single"!==d.layout)a.has(e)||(u+=re(t,"2"));else{const{monInfo:e,sheddingPriority:r}=c(d);u+=ie(d.uuid,d.circuit,t,"2",d.layout,n,i,e,r)}if(!l.has(t))if(!h||"col-span"!==h.layout&&"single"!==h.layout)a.has(r)||(u+=re(t,"4"));else{const{monInfo:e,sheddingPriority:r}=c(h);u+=ie(h.uuid,h.circuit,t,"4",h.layout,n,i,e,r)}u+=`
${r}
`}return u}(this._topology,e,this.hass,this._config,r),s=function(t,e,n){const r=!1!==n.show_battery,o=!1!==n.show_evse;if(!t.sub_devices)return"";const a=Object.entries(t.sub_devices).filter(([,t])=>!(t.type===d&&!r||t.type===h&&!o));if(0===a.length)return"";const s=a.filter(([,t])=>t.type===h).length;let l=0,c="";for(const[t,r]of a){const o=r.type===h?i("subdevice.ev_charger"):r.type===d?i("subdevice.battery"):i("subdevice.fallback"),a=ue(r),u=a?e.states[a]:void 0,p=u&&parseFloat(u.state)||0,f=r.type===d,g=r.type===h,v=f?de(r):null,m=f?he(r):null,y=f?pe(r):null,_=fe(r,e,n,new Set([a,v,m,y].filter(t=>null!==t))),b=ge(t,0,f,a,v,m);let x="";f?x="sub-device-bess":g&&(l++,l===s&&s%2==1&&(x="sub-device-full")),c+=`\n
\n
\n ${Rt(o)}\n ${Rt(r.name||"")}\n ${a?`${Ut(p)} ${Wt(p)}`:""}\n \n
\n ${b}\n ${_}\n
\n `}return c}(this._topology,this.hass,this._config);t.innerHTML=`\n ${n}\n ${o}\n ${s?`
${s}
`:""}\n ${!1!==this._config.show_panel?`
${a}
`:""}\n `;const l=t.querySelector(".slide-confirm");if(l){const t=this._root.querySelector(".span-card");this._ctrl.bindSlideConfirm(l,t),t&&t.classList.add("switches-disabled")}const c=this._root.querySelector("span-side-panel");c&&(c.hass=this.hass,c.errorStore=this._errorStore),this._ctrl.recordSamples(),this._ctrl.updateDOM(this._root),this._ctrl.setupResizeObserver(this._root,this._root.querySelector(".span-card"))}else if("activity"===this._activeTab){t.innerHTML="";const e=$t(this._topology,this._config);this._listCtrl.setColumns(Bt()),this._listCtrl.renderActivityView(t,this.hass,this._topology,this._config,this._ctrl.monitoringCache.status,e),this._ctrl.updateDOM(this._root)}else if("area"===this._activeTab){t.innerHTML="";const e=$t(this._topology,this._config);this._listCtrl.setColumns(Bt()),this._listCtrl.renderAreaView(t,this.hass,this._topology,this._config,this._ctrl.monitoringCache.status,e),this._ctrl.updateDOM(this._root)}}_onCardClick(t){if("panel"!==this._activeTab)return;const e=t.target;if(!e)return;const n=e.closest(".unit-btn");if(n)return void this._onUnitToggle(n);if(e.closest(".toggle-pill"))return void this._ctrl.onToggleClick(t,this._root);e.closest(".gear-icon")&&this._ctrl.onGearClick(t,this._root)}async _onUnitToggle(t){const e=t.dataset.unit;e&&e!==(this._config.chart_metric??"power")&&(this._config={...this._config,chart_metric:e},this._ctrl.setConfig(this._config),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config},bubbles:!0,composed:!0})),this._ctrl.powerHistory.clear(),this._historyLoaded=!1,this._populateCardContent(),await this._loadHistory(),this._ctrl.updateDOM(this._root))}async _onListUnitChanged(t){const e=t.detail;e&&e!==(this._config.chart_metric??"power")&&(this._config={...this._config,chart_metric:e},this._ctrl.setConfig(this._config),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config},bubbles:!0,composed:!0})),this._ctrl.powerHistory.clear(),this._historyLoaded=!1,this._populateCardContent(),await this._loadHistory(),this._ctrl.updateDOM(this._root))}_onGraphSettingsChanged(){this._ctrl.onGraphSettingsChanged(this._root)}_onListColumnsChanged(t){const e=t.detail;"number"!=typeof e||1!==e&&2!==e&&3!==e||"activity"!==this._activeTab&&"area"!==this._activeTab||this._populateCardContent()}_onSidePanelClosed(){this._ctrl.monitoringCache.invalidate(),this._ctrl.graphSettingsCache.invalidate()}_renderPreview(){const t=uT.map(t=>dt`
${t.name} @@ -216,7 +216,7 @@ var Ga={},qa={};var ja,Xa=function(){function t(t,e,n){var i=this;this._sleepAft
- `);return ht` + `);return dt`
@@ -227,4 +227,4 @@ var Ga={},qa={};var ja,Xa=function(){function t(t,e,n){var i=this;this._sleepAft
${i("card.no_device")}
- `}};hT.styles=k('\n :host {\n --span-accent: var(--primary-color, #4dd9af);\n }\n\n /* Card shell — replaces . Theme variables (--ha-card-*) are\n stable HA contracts (not the deprecated component APIs flagged by the\n 2026.4 frontend blog), so they stay in place to keep visual parity\n with the rest of HA\'s dashboards. */\n .span-card {\n display: block;\n padding: 24px;\n background: var(--card-background-color, #1c1c1c);\n color: var(--primary-text-color, #e0e0e0);\n border-radius: var(--ha-card-border-radius, 12px);\n border: var(--ha-card-border-width, 1px) solid var(--ha-card-border-color, var(--divider-color, #333));\n box-shadow: var(--ha-card-box-shadow, none);\n }\n\n .panel-header {\n display: flex;\n flex-wrap: wrap;\n justify-content: space-between;\n align-items: flex-start;\n gap: 8px 16px;\n margin-bottom: 20px;\n padding-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n .header-left { flex: 1 1 300px; min-width: 0; }\n .header-center { flex: 0 0 auto; }\n .header-right { flex: 0 1 auto; min-width: 0; }\n\n .panel-identity {\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 8px 12px;\n margin-bottom: 12px;\n }\n\n .panel-title {\n font-size: 1.8em;\n font-weight: 700;\n margin: 0;\n color: var(--primary-text-color, #fff);\n }\n\n .panel-serial {\n font-size: 0.85em;\n color: var(--secondary-text-color, #999);\n font-family: monospace;\n }\n\n .panel-stats {\n display: flex;\n flex-wrap: wrap;\n gap: 16px 32px;\n }\n\n /* Favorites view header: gear + slide-to-arm + right-anchored legend/W-A cluster. */\n .favorites-summary {\n padding: 8px 24px;\n border-bottom: 1px solid var(--divider-color, #e0e0e0);\n display: flex;\n align-items: center;\n gap: 12px;\n }\n /* Override the generic .gear-icon { margin-left: auto } rule so the\n favorites gear stays flush-left instead of floating to the right of\n the flex row (same idea as .panel-identity .panel-gear does for\n real-panel headers). */\n .favorites-summary .favorites-gear {\n margin-left: 0;\n }\n /* Right-anchored cluster wrapping the shedding legend + W/A unit toggle.\n margin-left:auto moved here from .favorites-summary-unit-toggle so the\n legend and toggle cluster together, matching the real-panel header\n layout. */\n .favorites-summary-right {\n margin-left: auto;\n display: flex;\n align-items: center;\n gap: 16px;\n }\n .favorites-subdevices-section {\n padding: 8px 16px 0;\n }\n\n /* Favorites view: responsive grid of per-contributing-panel status cards. */\n .favorites-panel-stats-grid {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));\n gap: 12px;\n padding: 12px 24px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n .favorites-panel-card {\n background: var(--secondary-background-color, rgba(255, 255, 255, 0.04));\n border: 1px solid var(--divider-color, #333);\n border-radius: 8px;\n padding: 10px 14px;\n display: flex;\n flex-direction: column;\n gap: 6px;\n }\n .favorites-panel-card-title {\n font-size: 0.85em;\n font-weight: 600;\n color: var(--primary-text-color);\n opacity: 0.85;\n }\n .favorites-panel-card .panel-stats {\n gap: 10px 20px;\n }\n .favorites-panel-card .stat-value {\n font-size: 1.15em;\n }\n\n .stat { display: flex; flex-direction: column; }\n .stat-label { font-size: 0.8em; color: var(--secondary-text-color, #999); margin-bottom: 2px; }\n .stat-row { display: flex; align-items: baseline; gap: 2px; }\n .stat-value { font-size: 1.5em; font-weight: 700; color: var(--primary-text-color, #fff); }\n .stat-unit { font-size: 0.7em; font-weight: 400; color: var(--secondary-text-color, #999); }\n\n .header-right { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; padding-top: 8px; }\n .header-right-top { display: flex; gap: 20px; align-items: center; }\n .meta-item { font-size: 0.8em; color: var(--secondary-text-color, #999); }\n\n .shedding-legend { display: flex; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }\n .shedding-legend-item { display: inline-flex; align-items: center; gap: 3px; }\n .shedding-legend-item span-icon { --mdc-icon-size: 16px; }\n .shedding-legend-secondary { --mdc-icon-size: 12px; opacity: 0.8; }\n .shedding-legend-text { font-size: 9px; font-weight: 600; }\n .shedding-legend-label { font-size: 0.7em; color: var(--secondary-text-color, #999); }\n\n .panel-gear {\n background: none;\n border: none;\n cursor: pointer;\n color: var(--secondary-text-color);\n opacity: 0.6;\n padding: 4px;\n margin-left: 8px;\n vertical-align: middle;\n }\n .panel-gear:hover { opacity: 1; }\n .header-center {\n display: flex;\n align-items: flex-start;\n justify-content: center;\n padding-top: 8px;\n }\n .panel-identity .panel-gear {\n margin-left: 0;\n }\n .slide-confirm {\n position: relative;\n display: inline-flex;\n align-items: center;\n width: 160px;\n height: 28px;\n border-radius: 14px;\n background: color-mix(in srgb, var(--primary-color, #4dd9af) 20%, var(--secondary-background-color, #333));\n vertical-align: middle;\n overflow: hidden;\n user-select: none;\n touch-action: none;\n }\n .slide-confirm-text {\n position: absolute;\n width: 100%;\n text-align: center;\n font-size: 0.65em;\n font-weight: 600;\n color: var(--secondary-text-color, #999);\n pointer-events: none;\n z-index: 0;\n }\n .slide-confirm-knob {\n position: absolute;\n left: 2px;\n top: 2px;\n width: 24px;\n height: 24px;\n border-radius: 50%;\n background: var(--secondary-text-color, #666);\n display: flex;\n align-items: center;\n justify-content: center;\n cursor: grab;\n z-index: 1;\n transition: none;\n }\n .slide-confirm-knob span-icon {\n --mdc-icon-size: 14px;\n color: var(--card-background-color, #1c1c1c);\n }\n .slide-confirm-knob.snapping {\n transition: left 0.25s ease;\n }\n .slide-confirm.confirmed {\n background: color-mix(in srgb, var(--state-active-color, var(--span-accent)) 25%, transparent);\n }\n .slide-confirm.confirmed .slide-confirm-text {\n color: var(--state-active-color, var(--span-accent));\n }\n .slide-confirm.confirmed .slide-confirm-knob {\n background: var(--state-active-color, var(--span-accent));\n }\n .switches-disabled .toggle-pill {\n opacity: 0.3;\n pointer-events: none;\n }\n .unit-toggle {\n display: inline-flex;\n background: var(--secondary-background-color, #333);\n border-radius: 6px;\n overflow: hidden;\n margin-left: 8px;\n }\n .unit-btn {\n padding: 4px 10px;\n border: none;\n background: none;\n color: var(--secondary-text-color);\n font-size: 0.75em;\n font-weight: 600;\n cursor: pointer;\n }\n .unit-btn.unit-active {\n background: var(--primary-color, #4dd9af);\n color: var(--text-primary-color, #000);\n }\n\n .monitoring-summary {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 6px 16px;\n font-size: 0.8em;\n background: rgba(76, 175, 80, 0.1);\n border: 1px solid var(--divider-color, #333);\n border-top: none;\n }\n .monitoring-active { color: #4caf50; }\n .monitoring-counts { display: flex; gap: 12px; }\n .count-warning { color: #ff9800; }\n .count-alert { color: #f44336; }\n .count-overrides { color: var(--secondary-text-color); }\n\n .panel-grid {\n display: grid;\n /* Five columns: left tab label, left cell, explicit 8px spacer,\n right cell, right tab label. Spacer is in-band rather than a\n column-gap so we can keep inter-cell space without paying an\n equal gap between each cell and its tab label. The tab columns\n are sized to fit a 2-digit breaker number (the font is 0.85em\n of the panel body ≈ 14px glyph width). */\n grid-template-columns: 14px 1fr 8px 1fr 14px;\n column-gap: 0;\n row-gap: 8px;\n align-items: stretch;\n }\n\n .tab-label {\n display: flex;\n align-items: center;\n font-size: 0.85em;\n font-weight: 600;\n color: var(--secondary-text-color, #999);\n user-select: none;\n }\n .tab-left { justify-content: flex-start; }\n .tab-right { justify-content: flex-end; }\n\n .circuit-slot {\n background: var(--secondary-background-color, var(--card-background-color, #2a2a2a));\n border: 1px solid var(--divider-color, #333);\n border-radius: 12px;\n padding: 14px 16px 20px;\n min-height: 140px;\n transition: opacity 0.3s;\n position: relative;\n overflow: hidden;\n }\n\n .circuit-col-span { min-height: 280px; }\n .circuit-row-span { border-left: 3px solid var(--span-accent); }\n .circuit-off .circuit-name,\n .circuit-off .breaker-badge,\n .circuit-off .power-value,\n .circuit-off .chart-container { opacity: 0.35; }\n .circuit-off .toggle-pill,\n .circuit-off .gear-icon { opacity: 1; }\n\n .circuit-empty {\n opacity: 0.2;\n min-height: 60px;\n display: flex;\n align-items: center;\n justify-content: center;\n border-style: dashed;\n }\n .empty-label { color: var(--secondary-text-color, #999); font-size: 0.85em; }\n\n .circuit-header {\n display: flex;\n justify-content: space-between;\n align-items: flex-start;\n margin-bottom: 6px;\n gap: 8px;\n }\n\n .circuit-info { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }\n\n .breaker-badge {\n background: color-mix(in srgb, var(--span-accent) 15%, transparent);\n color: var(--span-accent);\n font-size: 0.7em;\n font-weight: 700;\n padding: 2px 3px;\n border-radius: 4px;\n white-space: nowrap;\n border: 1px solid color-mix(in srgb, var(--span-accent) 25%, transparent);\n flex-shrink: 0;\n }\n\n .circuit-name {\n font-size: 0.9em;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n color: var(--primary-text-color, #e0e0e0);\n }\n\n .circuit-controls { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }\n\n /* Truncation-driven fold for By Panel breaker cells. The .is-folded\n class is added/removed by the JS observer in\n src/core/truncation-fold.ts when the .circuit-name actually\n ellipsizes. Pixel thresholds can\'t get this right because name\n length varies wildly per circuit (e.g. "Spa" vs\n "Commissioned PV System") — only measuring the live name vs its\n container catches the exact moment of truncation.\n\n When folded the nested flex wrappers (.circuit-header,\n .circuit-info, .circuit-controls, .circuit-status) collapse via\n \'display: contents\' so the leaf elements participate directly in\n the outer grid: name gets the whole first row, readings/controls/\n gear drop to a second row, chart stays as the full-width third. */\n .circuit-slot.is-folded {\n display: grid;\n /* Columns: badges + relay-toggle pack tight on the left, slack\n absorbed by the 1fr column between the relay and the power\n reading, keeping power + gear pinned to the right edge. The\n previous layout placed the slack between the shedding icon and\n the relay, which read as wasted padding the user pointed out. */\n grid-template-columns: auto auto auto auto 1fr auto auto;\n /* Rows: name and controls sized to content; chart absorbs any\n extra cell height. Without the explicit 1fr on row 3, a tall\n cell (e.g. .circuit-col-span\'s 280px min-height for 240V\n double-pole breakers) distributes excess space equally across\n all three rows via the default align-content:stretch, which\n pushes the chart down and vertically inflates the badge and\n relay toggle to fill the controls row. */\n grid-template-rows: auto auto 1fr;\n grid-template-areas:\n "name name name name name name name"\n "badge util shed status . power gear"\n "chart chart chart chart chart chart chart";\n row-gap: 6px;\n column-gap: 8px;\n }\n .circuit-slot.is-folded > .circuit-header,\n .circuit-slot.is-folded > .circuit-status,\n .circuit-slot.is-folded > .circuit-header > .circuit-info,\n .circuit-slot.is-folded > .circuit-header > .circuit-controls {\n display: contents;\n }\n .circuit-slot.is-folded .circuit-name {\n grid-area: name;\n justify-self: start;\n }\n .circuit-slot.is-folded .breaker-badge {\n grid-area: badge;\n }\n .circuit-slot.is-folded .utilization {\n grid-area: util;\n }\n .circuit-slot.is-folded .shedding-icon,\n .circuit-slot.is-folded .shedding-composite {\n grid-area: shed;\n }\n .circuit-slot.is-folded .toggle-pill {\n grid-area: status;\n justify-self: end;\n }\n .circuit-slot.is-folded .power-value {\n grid-area: power;\n justify-self: end;\n }\n .circuit-slot.is-folded .gear-icon.circuit-gear {\n grid-area: gear;\n justify-self: end;\n }\n .circuit-slot.is-folded > .chart-container {\n grid-area: chart;\n }\n\n .power-value { font-size: 0.9em; color: var(--primary-text-color, #fff); white-space: nowrap; }\n .power-value strong { font-weight: 700; font-size: 1.1em; }\n .power-unit { font-size: 0.8em; font-weight: 400; color: var(--secondary-text-color, #999); margin-left: 1px; }\n .circuit-producer .power-value strong { color: var(--info-color, #4fc3f7); }\n\n .toggle-pill {\n display: flex;\n align-items: center;\n gap: 3px;\n padding: 2px 4px;\n border-radius: 10px;\n cursor: pointer;\n font-size: 0.65em;\n font-weight: 600;\n transition: background 0.2s;\n user-select: none;\n min-width: 40px;\n }\n .toggle-on {\n padding-left: 6px;\n background: color-mix(in srgb, var(--state-active-color, var(--span-accent)) 25%, transparent);\n color: var(--state-active-color, var(--span-accent));\n }\n .toggle-off {\n padding-right: 6px;\n background: color-mix(in srgb, var(--secondary-text-color) 15%, transparent);\n color: var(--secondary-text-color, #999);\n }\n .toggle-knob {\n width: 14px;\n height: 14px;\n border-radius: 50%;\n transition: background 0.2s, margin 0.2s;\n }\n .toggle-on .toggle-knob {\n background: var(--state-active-color, var(--span-accent));\n margin-left: auto;\n }\n .toggle-off .toggle-knob {\n background: var(--secondary-text-color, #999);\n margin-right: auto;\n order: -1;\n }\n\n .circuit-status {\n display: flex;\n align-items: center;\n gap: 4px;\n margin-top: 4px;\n padding: 0 4px;\n }\n .shedding-icon { opacity: 0.8; cursor: default; }\n .shedding-composite {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n }\n .shedding-icon-secondary { opacity: 0.8; }\n .shedding-label {\n font-size: 10px;\n font-weight: 600;\n opacity: 0.8;\n }\n .gear-icon {\n background: none;\n border: none;\n cursor: pointer;\n padding: 2px;\n opacity: 0.6;\n transition: opacity 0.2s;\n margin-left: auto;\n }\n .gear-icon:hover { opacity: 1; }\n .utilization {\n font-size: 0.75em;\n font-weight: 600;\n }\n .utilization-normal { color: #4caf50; }\n .utilization-warning { color: #ff9800; }\n .utilization-alert { color: #f44336; }\n .circuit-alert {\n border-color: #f44336 !important;\n box-shadow: 0 0 8px rgba(244, 67, 54, 0.3);\n }\n .chart-container {\n width: 100%;\n aspect-ratio: 4 / 1;\n margin-top: 4px;\n overflow: hidden;\n min-width: 0;\n }\n\n .sub-devices {\n display: grid;\n grid-template-columns: repeat(2, 1fr);\n gap: 12px;\n margin-bottom: 20px;\n padding-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n\n .sub-device {\n background: var(--secondary-background-color, var(--card-background-color, #2a2a2a));\n border: 1px solid var(--divider-color, #333);\n border-radius: 12px;\n padding: 14px 16px;\n }\n .sub-device-bess,\n .sub-device-full {\n grid-column: 1 / -1;\n }\n\n .sub-device-header { display: flex; gap: 10px; align-items: baseline; margin-bottom: 8px; }\n .sub-device-type { font-size: 0.7em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--span-accent); }\n .sub-device-name { font-size: 0.85em; color: var(--secondary-text-color, #999); flex: 1; }\n .sub-power-value { font-size: 0.9em; color: var(--primary-text-color, #fff); white-space: nowrap; }\n .sub-power-value strong { font-weight: 700; font-size: 1.1em; }\n .sub-device .chart-container { margin-bottom: 8px; aspect-ratio: auto; }\n\n .bess-charts {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(0, 1fr));\n gap: 12px;\n margin-bottom: 10px;\n }\n .bess-chart-col { min-width: 0; }\n .bess-chart-title {\n font-size: 0.75em;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n color: var(--secondary-text-color, #999);\n margin-bottom: 4px;\n }\n .bess-chart-col .chart-container { aspect-ratio: auto; }\n .sub-entity { display: flex; gap: 6px; padding: 3px 0; font-size: 0.85em; }\n .sub-entity-name { color: var(--secondary-text-color, #999); }\n .sub-entity-value { font-weight: 500; color: var(--primary-text-color, #e0e0e0); }\n\n /* ── Shared tab bar ────────────────────────────────────── */\n\n .shared-tab-bar {\n display: flex;\n gap: 0;\n margin-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n\n .shared-tab {\n padding: 8px 16px;\n cursor: pointer;\n font-size: 0.9em;\n font-weight: 500;\n color: var(--primary-text-color);\n opacity: 0.6;\n border: none;\n border-bottom: 2px solid transparent;\n background: none;\n transition: opacity 0.15s;\n }\n\n .shared-tab:hover {\n opacity: 0.85;\n }\n\n .shared-tab.active {\n opacity: 1;\n border-bottom-color: var(--span-accent);\n }\n\n /* ── List view search ──────────────────────────────────── */\n\n .list-search-container {\n margin-bottom: 12px;\n position: relative;\n }\n\n .list-search {\n width: 100%;\n padding: 8px 36px 8px 12px;\n border-radius: 8px;\n border: 1px solid var(--divider-color, #333);\n background: var(--secondary-background-color, #2a2a2a);\n color: var(--primary-text-color);\n font-size: 0.9em;\n box-sizing: border-box;\n outline: none;\n }\n\n .list-search:focus {\n border-color: var(--span-accent);\n }\n\n .list-search-clear {\n position: absolute;\n right: 8px;\n top: 50%;\n transform: translateY(-50%);\n background: none;\n border: none;\n color: var(--secondary-text-color);\n cursor: pointer;\n padding: 2px;\n display: flex;\n align-items: center;\n opacity: 0.7;\n }\n\n .list-search-clear:hover {\n opacity: 1;\n }\n\n .list-unit-toggle {\n display: inline-flex;\n margin-bottom: 12px;\n }\n\n /* ── List rows ─────────────────────────────────────────── */\n\n .list-view {\n display: flex;\n flex-direction: column;\n gap: 6px;\n }\n /* Each circuit is wrapped in a .list-cell so the row + its optional\n expanded chart stay together. In single-column flex mode the cell\n just stacks naturally. In multi-column grid mode the cell becomes\n one grid item, so the chart is always in the same column as its\n row. Area headers (rendered as siblings, not inside a cell) span\n all columns via their inline "grid-column: 1 / -1". */\n .list-cell {\n display: flex;\n flex-direction: column;\n min-width: 0;\n }\n .list-view[data-columns="2"],\n .list-view[data-columns="3"] {\n display: grid;\n grid-template-columns: repeat(var(--list-cols), minmax(0, 1fr));\n gap: 6px 8px;\n flex-direction: initial;\n }\n /* On narrow viewports a 2/3-column list would squeeze rows into an\n unreadable shape, so force stacking regardless of user preference. */\n @media (max-width: 599px) {\n .list-view[data-columns="2"],\n .list-view[data-columns="3"] {\n display: flex;\n flex-direction: column;\n }\n }\n\n .list-row {\n display: flex;\n align-items: center;\n padding: 12px 16px;\n gap: 10px;\n /* min-width: 0 lets the row shrink below the sum of its\n non-shrinking children when its parent .list-cell is in a\n narrow CSS-grid track (multi-column list mode). Without this\n the row would maintain its intrinsic min-content width and\n overflow the cell, leaving the name unshrunk and the\n truncation-fold observer with no signal to react to. */\n min-width: 0;\n background: var(--card-background-color, #1c1c1c);\n border: 1px solid var(--divider-color, #333);\n border-radius: 8px;\n cursor: pointer;\n transition: background 0.15s;\n }\n\n .list-row:hover {\n background: var(--secondary-background-color, #2a2a2a);\n }\n\n .list-row.circuit-off {\n opacity: 0.5;\n }\n\n .list-row.list-row-expanded {\n border-bottom-left-radius: 0;\n border-bottom-right-radius: 0;\n border-bottom-color: transparent;\n }\n\n .list-circuit-name {\n flex: 1;\n color: var(--primary-text-color);\n font-size: 0.9em;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .list-status-badge {\n font-size: 0.75em;\n font-weight: 600;\n padding: 2px 8px;\n border-radius: 4px;\n flex-shrink: 0;\n }\n\n .list-status-on {\n color: #4dd9af;\n }\n\n .list-status-off {\n color: #f44336;\n }\n\n .list-power-value {\n font-size: 0.9em;\n font-weight: 600;\n flex-shrink: 0;\n /* No min-width / text-align:right: the old 70px right-aligned\n cell left a visible blank column for short readings (e.g.\n "1.3A" in a 70px slot), which robbed horizontal space from\n .list-circuit-name on narrow rows. Let the value hug the\n preceding relay control and size to its content so the freed\n width flows back into the flex:1 name column. */\n }\n\n .list-expand-toggle {\n background: none;\n border: none;\n color: var(--secondary-text-color);\n cursor: pointer;\n padding: 4px;\n transition: transform 0.2s;\n display: flex;\n align-items: center;\n flex-shrink: 0;\n }\n\n .list-expand-toggle.expanded {\n transform: rotate(180deg);\n }\n\n .list-row .gear-icon {\n background: transparent;\n border: none;\n padding: 2px;\n cursor: pointer;\n color: #555;\n display: inline-flex;\n align-items: center;\n }\n .list-row .gear-icon:hover {\n color: var(--primary-text-color);\n }\n\n /* Truncation-driven fold for list rows. The .is-folded class is\n added/removed by the JS observer in src/core/truncation-fold.ts\n when the .list-circuit-name actually ellipsizes — pixel breakpoints\n can\'t track this because name length varies wildly per circuit\n ("Spa" vs "Commissioned PV System") and any single threshold\n misfires for the other end of the range. Switch to a two-row grid\n so the name gets the full width (paired only with the expand\n chevron) and the badges/controls/reading/gear drop to a secondary\n row underneath. Named areas keep the CSS readable despite the flat\n HTML child order. */\n .list-row.is-folded {\n display: grid;\n /* Row 1: name spans the row up to the chevron at the trailing\n column. Row 2: badge + util + shed + relay-toggle pack left,\n the 1fr column absorbs slack between the relay and the power\n reading, power + gear stay pinned to the right edge. The\n earlier layout placed the slack between the shedding icon and\n the relay, which the user flagged as wasted padding. */\n grid-template-columns: auto auto auto auto 1fr auto auto;\n grid-template-areas:\n "name name name name name name chevron"\n "badge util shed status . power gear";\n row-gap: 6px;\n column-gap: 8px;\n }\n .list-row.is-folded > .list-circuit-name {\n grid-area: name;\n justify-self: start;\n }\n .list-row.is-folded > .list-expand-toggle {\n grid-area: chevron;\n }\n .list-row.is-folded > .breaker-badge {\n grid-area: badge;\n }\n .list-row.is-folded > .utilization {\n grid-area: util;\n }\n .list-row.is-folded > .shedding-icon,\n .list-row.is-folded > .shedding-composite {\n grid-area: shed;\n }\n .list-row.is-folded > .toggle-pill,\n .list-row.is-folded > .list-status-badge {\n grid-area: status;\n }\n .list-row.is-folded > .list-power-value {\n grid-area: power;\n justify-self: end;\n }\n .list-row.is-folded > .gear-icon.circuit-gear {\n grid-area: gear;\n justify-self: end;\n }\n\n /* ── Expanded circuit content ──────────────────────────── */\n\n .list-expanded-content {\n padding: 0;\n background: var(--card-background-color, #1c1c1c);\n border: 1px solid var(--divider-color, #333);\n border-top: none;\n border-radius: 0 0 8px 8px;\n margin-top: -6px;\n margin-bottom: 2px;\n }\n\n .circuit-slot.circuit-chart-only {\n border: none;\n margin: 0;\n background: none;\n padding: 8px 12px;\n min-height: 0;\n }\n\n /* ── Area headers ──────────────────────────────────────── */\n\n .area-header {\n padding: 16px 12px 6px;\n font-weight: 600;\n font-size: 0.85em;\n color: var(--secondary-text-color);\n text-transform: uppercase;\n letter-spacing: 0.05em;\n }\n\n /* ── No results ────────────────────────────────────────── */\n\n .list-no-results {\n padding: 24px;\n text-align: center;\n color: var(--secondary-text-color);\n }\n\n'),x([zt({attribute:!1})],hT.prototype,"hass",void 0),x([Ot()],hT.prototype,"_config",void 0),x([Ot()],hT.prototype,"_discovered",void 0),x([Ot()],hT.prototype,"_discovering",void 0),x([Ot()],hT.prototype,"_topology",void 0),x([Ot()],hT.prototype,"_activeTab",void 0),hT=x([(t=>(e,n)=>{void 0!==n?n.addInitializer(()=>{customElements.define(t,e)}):customElements.define(t,e)})("span-panel-card")],hT);class dT extends HTMLElement{constructor(){super(...arguments),this._config={},this._hass=null,this._panels=null,this._availableRoles=null,this._built=!1,this._panelSelect=null,this._daysInput=null,this._hoursInput=null,this._minsInput=null,this._metricSelect=null,this._checkboxes={},this._entityContainers={},this._tabStyleSelect=null}setConfig(t){this._config={...t},this._updateControls()}set hass(t){this._hass=t,this._panels?this._built||this._buildEditor():this._discoverPanels()}async _discoverPanels(){if(!this._hass)return;const t=await this._hass.callWS({type:"config/device_registry/list"});this._panels=t.filter(t=>(t.identifiers??[]).some(t=>t[0]===l)&&!t.via_device_id).map(t=>{const e=(t.identifiers??[]).find(t=>t[0]===l)?.[1]??"",n=t.name_by_user??t.name??i("editor.panel_label");return{device_id:t.id,label:`${n} (${e})`}}),this._buildEditor()}_buildEditor(){this.innerHTML="",this._built=!0;const t=document.createElement("div");t.style.padding="16px";const e="\n width: 100%;\n padding: 10px 12px;\n border-radius: 8px;\n border: 1px solid var(--divider-color, #333);\n background: var(--card-background-color, var(--secondary-background-color, #1c1c1c));\n color: var(--primary-text-color, #e0e0e0);\n font-size: 1em;\n cursor: pointer;\n appearance: auto;\n box-sizing: border-box;\n ",n="display: block; font-weight: 500; margin-bottom: 8px; color: var(--primary-text-color);",i="margin-bottom: 16px;";this._buildPanelSelector(t,e,n,i),this._buildTimeWindow(t,e,n,i),this._buildMetricSelector(t,e,n,i),this._buildTabStyleSelector(t,e,n,i),this._buildSectionCheckboxes(t,n,i),this.appendChild(t),this._populateMetricSelect(),this._config.device_id&&this._discoverAvailableRoles(this._config.device_id)}_buildPanelSelector(t,e,n,r){const o=document.createElement("div");o.style.cssText=r;const a=document.createElement("label");a.textContent=i("editor.panel_label"),a.style.cssText=n;const s=document.createElement("select");s.style.cssText=e;const l=document.createElement("option");if(l.value="",l.textContent=i("editor.select_panel"),s.appendChild(l),this._panels)for(const t of this._panels){const e=document.createElement("option");e.value=t.device_id,e.textContent=t.label,t.device_id===this._config.device_id&&(e.selected=!0),s.appendChild(e)}s.addEventListener("change",()=>{this._config={...this._config,device_id:s.value},this._fireConfigChanged(),this._discoverAvailableRoles(s.value)}),o.appendChild(a),o.appendChild(s),t.appendChild(o),this._panelSelect=s}_buildTimeWindow(t,e,n,r){const o=document.createElement("div");o.style.cssText=r;const a=document.createElement("label");a.textContent=i("editor.chart_window"),a.style.cssText=n;const s=document.createElement("div");s.style.cssText="display: flex; gap: 12px; align-items: center; flex-wrap: wrap;";const l=e+"width: 70px; cursor: text;",c=(t,e,n,i)=>{const r=document.createElement("div");r.style.cssText="display: flex; align-items: center; gap: 6px;";const o=document.createElement("input");o.type="number",o.min=e,o.max=n,o.value=String(t),o.style.cssText=l;const a=document.createElement("span");return a.textContent=i,a.style.cssText="font-size: 0.9em; color: var(--secondary-text-color);",r.appendChild(o),r.appendChild(a),{wrap:r,input:o}},u=parseInt(String(this._config.history_days))||0,h=parseInt(String(this._config.history_hours))||0,d=parseInt(String(this._config.history_minutes))||0,p=c(u,"0","30",i("editor.days")),f=c(h,"0","23",i("editor.hours")),g=c(d,"0","59",i("editor.minutes")),v=()=>{this._config={...this._config,history_days:parseInt(p.input.value)||0,history_hours:parseInt(f.input.value)||0,history_minutes:parseInt(g.input.value)||0},this._fireConfigChanged()};p.input.addEventListener("change",v),f.input.addEventListener("change",v),g.input.addEventListener("change",v),s.appendChild(p.wrap),s.appendChild(f.wrap),s.appendChild(g.wrap),o.appendChild(a),o.appendChild(s),t.appendChild(o),this._daysInput=p.input,this._hoursInput=f.input,this._minsInput=g.input}_buildMetricSelector(t,e,n,r){const o=document.createElement("div");o.style.cssText=r;const a=document.createElement("label");a.textContent=i("editor.chart_metric"),a.style.cssText=n;const s=document.createElement("select");s.style.cssText=e,s.addEventListener("change",()=>{this._config={...this._config,chart_metric:s.value},this._fireConfigChanged()}),o.appendChild(a),o.appendChild(s),t.appendChild(o),this._metricSelect=s}_buildTabStyleSelector(t,e,n,r){const o=document.createElement("div");o.style.cssText=r;const a=document.createElement("label");a.textContent=i("editor.tab_style"),a.style.cssText=n;const s=document.createElement("select");s.style.cssText=e;const l=[{value:"text",text:i("editor.tab_style_text")},{value:"icon",text:i("editor.tab_style_icon")}];for(const t of l){const e=document.createElement("option");e.value=t.value,e.textContent=t.text,t.value===(this._config.tab_style??"text")&&(e.selected=!0),s.appendChild(e)}s.addEventListener("change",()=>{this._config={...this._config,tab_style:s.value},this._fireConfigChanged()}),o.appendChild(a),o.appendChild(s),t.appendChild(o),this._tabStyleSelect=s}_buildSectionCheckboxes(t,e,n){const r=document.createElement("div");r.style.cssText=n;const o=document.createElement("label");o.textContent=i("editor.visible_sections"),o.style.cssText=e,r.appendChild(o);const a=[{key:"show_panel",label:i("editor.panel_circuits"),subDeviceType:null},{key:"show_battery",label:i("editor.battery_bess"),subDeviceType:"bess"},{key:"show_evse",label:i("editor.ev_charger_evse"),subDeviceType:"evse"}];this._checkboxes={},this._entityContainers={};for(const t of a){const e=document.createElement("div");e.style.cssText="display: flex; align-items: center; gap: 8px; margin-bottom: 6px; cursor: pointer;";const n=document.createElement("input");n.type="checkbox",n.checked=!1!==this._config[t.key],n.style.cssText="width: 18px; height: 18px; cursor: pointer; accent-color: var(--primary-color);";const i=document.createElement("span");i.textContent=t.label,i.style.cssText="font-size: 0.9em; color: var(--primary-text-color); cursor: pointer;",e.appendChild(n),e.appendChild(i),r.appendChild(e),this._checkboxes[t.key]=n;let o=null;t.subDeviceType&&(o=document.createElement("div"),o.style.cssText="padding-left: 26px;",o.style.display=n.checked?"block":"none",r.appendChild(o),this._entityContainers[t.subDeviceType]=o),n.addEventListener("change",()=>{this._config={...this._config,[t.key]:n.checked},o&&(o.style.display=n.checked?"block":"none"),this._fireConfigChanged()})}t.appendChild(r)}_isChartEntity(t,e,n){const i=(e.original_name??"").toLowerCase(),r=e.unique_id??"";if("power"===i||"battery power"===i||r.endsWith("_power"))return!0;if("bess"===n){if("battery level"===i||"battery percentage"===i||r.endsWith("_battery_level")||r.endsWith("_battery_percentage"))return!0;if("state of energy"===i||r.endsWith("_soe_kwh"))return!0;if("nameplate capacity"===i||r.endsWith("_nameplate_capacity"))return!0}return!1}_populateEntityCheckboxes(t){const e=this._config.visible_sub_entities??{};for(const[,n]of Object.entries(t)){const t=n.type?this._entityContainers[n.type]:void 0;if(t&&(t.innerHTML="",n.entities))for(const[i,r]of Object.entries(n.entities)){if("sensor"===r.domain&&this._isChartEntity(i,r,n.type??""))continue;const o=document.createElement("div");o.style.cssText="display: flex; align-items: center; gap: 8px; margin-bottom: 5px; cursor: pointer;";const a=document.createElement("input");a.type="checkbox",a.checked=!0===e[i],a.style.cssText="width: 16px; height: 16px; cursor: pointer; accent-color: var(--primary-color);";const s=document.createElement("span");let l=r.original_name??i;const c=n.name??"";l.startsWith(c+" ")&&(l=l.slice(c.length+1)),s.textContent=l,s.style.cssText="font-size: 0.85em; color: var(--primary-text-color); cursor: pointer;",o.appendChild(a),o.appendChild(s),t.appendChild(o),a.addEventListener("change",()=>{const t={...this._config.visible_sub_entities??{}};a.checked?t[i]=!0:delete t[i],this._config={...this._config,visible_sub_entities:t},this._fireConfigChanged()})}}}async _discoverAvailableRoles(t){if(this._hass&&t)try{const e=await this._hass.callWS({type:`${l}/panel_topology`,device_id:t}),n=new Set;for(const t of Object.values(e.circuits??{}))for(const e of Object.keys(t.entities??{}))n.add(e);this._availableRoles=n,this._populateMetricSelect(),e.sub_devices&&this._populateEntityCheckboxes(e.sub_devices)}catch{this._availableRoles=null,this._populateMetricSelect()}}_populateMetricSelect(){const t=this._metricSelect;if(!t)return;const e=this._config.chart_metric??o;t.innerHTML="";for(const[n,i]of Object.entries(g)){if(this._availableRoles&&!this._availableRoles.has(i.entityRole))continue;const r=document.createElement("option");r.value=n,r.textContent=i.label(),n===e&&(r.selected=!0),t.appendChild(r)}}_updateControls(){if(this._panelSelect&&(this._panelSelect.value=this._config.device_id??""),this._daysInput&&(this._daysInput.value=String(parseInt(String(this._config.history_days))||0)),this._hoursInput&&(this._hoursInput.value=String(parseInt(String(this._config.history_hours))||0)),this._minsInput&&(this._minsInput.value=String(parseInt(String(this._config.history_minutes))||0)),this._metricSelect&&(this._metricSelect.value=this._config.chart_metric??o),this._checkboxes)for(const[t,e]of Object.entries(this._checkboxes))e.checked=!1!==this._config[t];this._tabStyleSelect&&(this._tabStyleSelect.value=this._config.tab_style??"text")}_fireConfigChanged(){this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}}try{customElements.get("span-panel-card-editor")||customElements.define("span-panel-card-editor",dT)}catch{}window.customCards=window.customCards??[],window.customCards.push({type:"span-panel-card",name:"SPAN Panel",description:"Physical panel layout with live power charts matching the SPAN frontend",preview:!0}),console.warn("%c SPAN-PANEL-CARD %c v0.9.4 ","background: var(--primary-color, #4dd9af); color: var(--text-primary-color, #000); font-weight: 700; padding: 2px 6px; border-radius: 4px 0 0 4px;","background: var(--secondary-background-color, #333); color: var(--primary-text-color, #fff); padding: 2px 6px; border-radius: 0 4px 4px 0;"); + `}};dT.styles=k('\n :host {\n --span-accent: var(--primary-color, #4dd9af);\n }\n\n /* Card shell — replaces . Theme variables (--ha-card-*) are\n stable HA contracts (not the deprecated component APIs flagged by the\n 2026.4 frontend blog), so they stay in place to keep visual parity\n with the rest of HA\'s dashboards. */\n .span-card {\n display: block;\n padding: 24px;\n background: var(--card-background-color, #1c1c1c);\n color: var(--primary-text-color, #e0e0e0);\n border-radius: var(--ha-card-border-radius, 12px);\n border: var(--ha-card-border-width, 1px) solid var(--ha-card-border-color, var(--divider-color, #333));\n box-shadow: var(--ha-card-box-shadow, none);\n }\n\n .panel-header {\n display: flex;\n flex-wrap: wrap;\n justify-content: space-between;\n align-items: flex-start;\n gap: 8px 16px;\n margin-bottom: 20px;\n padding-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n .header-left { flex: 1 1 300px; min-width: 0; }\n .header-center { flex: 0 0 auto; }\n .header-right { flex: 0 1 auto; min-width: 0; }\n\n .panel-identity {\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 8px 12px;\n margin-bottom: 12px;\n }\n\n .panel-title {\n font-size: 1.8em;\n font-weight: 700;\n margin: 0;\n color: var(--primary-text-color, #fff);\n }\n\n .panel-serial {\n font-size: 0.85em;\n color: var(--secondary-text-color, #999);\n font-family: monospace;\n }\n\n .panel-stats {\n display: flex;\n flex-wrap: wrap;\n gap: 16px 32px;\n }\n\n /* Favorites view header: gear + slide-to-arm + right-anchored legend/W-A cluster. */\n .favorites-summary {\n padding: 8px 24px;\n border-bottom: 1px solid var(--divider-color, #e0e0e0);\n display: flex;\n align-items: center;\n gap: 12px;\n }\n /* Override the generic .gear-icon { margin-left: auto } rule so the\n favorites gear stays flush-left instead of floating to the right of\n the flex row (same idea as .panel-identity .panel-gear does for\n real-panel headers). */\n .favorites-summary .favorites-gear {\n margin-left: 0;\n }\n /* Right-anchored cluster wrapping the shedding legend + W/A unit toggle.\n margin-left:auto moved here from .favorites-summary-unit-toggle so the\n legend and toggle cluster together, matching the real-panel header\n layout. */\n .favorites-summary-right {\n margin-left: auto;\n display: flex;\n align-items: center;\n gap: 16px;\n }\n .favorites-subdevices-section {\n padding: 8px 16px 0;\n }\n\n /* Favorites view: responsive grid of per-contributing-panel status cards. */\n .favorites-panel-stats-grid {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));\n gap: 12px;\n padding: 12px 24px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n .favorites-panel-card {\n background: var(--secondary-background-color, rgba(255, 255, 255, 0.04));\n border: 1px solid var(--divider-color, #333);\n border-radius: 8px;\n padding: 10px 14px;\n display: flex;\n flex-direction: column;\n gap: 6px;\n }\n .favorites-panel-card-title {\n font-size: 0.85em;\n font-weight: 600;\n color: var(--primary-text-color);\n opacity: 0.85;\n }\n .favorites-panel-card .panel-stats {\n gap: 10px 20px;\n }\n .favorites-panel-card .stat-value {\n font-size: 1.15em;\n }\n\n .stat { display: flex; flex-direction: column; }\n .stat-label { font-size: 0.8em; color: var(--secondary-text-color, #999); margin-bottom: 2px; }\n .stat-row { display: flex; align-items: baseline; gap: 2px; }\n .stat-value { font-size: 1.5em; font-weight: 700; color: var(--primary-text-color, #fff); }\n .stat-unit { font-size: 0.7em; font-weight: 400; color: var(--secondary-text-color, #999); }\n\n .header-right { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; padding-top: 8px; }\n .header-right-top { display: flex; gap: 20px; align-items: center; }\n .meta-item { font-size: 0.8em; color: var(--secondary-text-color, #999); }\n\n .shedding-legend { display: flex; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }\n .shedding-legend-item { display: inline-flex; align-items: center; gap: 3px; }\n .shedding-legend-item span-icon { --mdc-icon-size: 16px; }\n .shedding-legend-secondary { --mdc-icon-size: 12px; opacity: 0.8; }\n .shedding-legend-text { font-size: 9px; font-weight: 600; }\n .shedding-legend-label { font-size: 0.7em; color: var(--secondary-text-color, #999); }\n\n .panel-gear {\n background: none;\n border: none;\n cursor: pointer;\n color: var(--secondary-text-color);\n opacity: 0.6;\n padding: 4px;\n margin-left: 8px;\n vertical-align: middle;\n }\n .panel-gear:hover { opacity: 1; }\n .header-center {\n display: flex;\n align-items: flex-start;\n justify-content: center;\n padding-top: 8px;\n }\n .panel-identity .panel-gear {\n margin-left: 0;\n }\n .slide-confirm {\n position: relative;\n display: inline-flex;\n align-items: center;\n width: 160px;\n height: 28px;\n border-radius: 14px;\n background: color-mix(in srgb, var(--primary-color, #4dd9af) 20%, var(--secondary-background-color, #333));\n vertical-align: middle;\n overflow: hidden;\n user-select: none;\n touch-action: none;\n }\n .slide-confirm-text {\n position: absolute;\n width: 100%;\n text-align: center;\n font-size: 0.65em;\n font-weight: 600;\n color: var(--secondary-text-color, #999);\n pointer-events: none;\n z-index: 0;\n }\n .slide-confirm-knob {\n position: absolute;\n left: 2px;\n top: 2px;\n width: 24px;\n height: 24px;\n border-radius: 50%;\n background: var(--secondary-text-color, #666);\n display: flex;\n align-items: center;\n justify-content: center;\n cursor: grab;\n z-index: 1;\n transition: none;\n }\n .slide-confirm-knob span-icon {\n --mdc-icon-size: 14px;\n color: var(--card-background-color, #1c1c1c);\n }\n .slide-confirm-knob.snapping {\n transition: left 0.25s ease;\n }\n .slide-confirm.confirmed {\n background: color-mix(in srgb, var(--state-active-color, var(--span-accent)) 25%, transparent);\n }\n .slide-confirm.confirmed .slide-confirm-text {\n color: var(--state-active-color, var(--span-accent));\n }\n .slide-confirm.confirmed .slide-confirm-knob {\n background: var(--state-active-color, var(--span-accent));\n }\n .switches-disabled .toggle-pill {\n opacity: 0.3;\n pointer-events: none;\n }\n .unit-toggle {\n display: inline-flex;\n background: var(--secondary-background-color, #333);\n border-radius: 6px;\n overflow: hidden;\n margin-left: 8px;\n }\n .unit-btn {\n padding: 4px 10px;\n border: none;\n background: none;\n color: var(--secondary-text-color);\n font-size: 0.75em;\n font-weight: 600;\n cursor: pointer;\n }\n .unit-btn.unit-active {\n background: var(--primary-color, #4dd9af);\n color: var(--text-primary-color, #000);\n }\n\n .monitoring-summary {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 6px 16px;\n font-size: 0.8em;\n background: rgba(76, 175, 80, 0.1);\n border: 1px solid var(--divider-color, #333);\n border-top: none;\n }\n .monitoring-active { color: #4caf50; }\n .monitoring-counts { display: flex; gap: 12px; }\n .count-warning { color: #ff9800; }\n .count-alert { color: #f44336; }\n .count-overrides { color: var(--secondary-text-color); }\n\n .panel-grid {\n display: grid;\n /* Five columns: left tab label, left cell, explicit 8px spacer,\n right cell, right tab label. Spacer is in-band rather than a\n column-gap so we can keep inter-cell space without paying an\n equal gap between each cell and its tab label. The tab columns\n are sized to fit a 2-digit breaker number (the font is 0.85em\n of the panel body ≈ 14px glyph width). */\n grid-template-columns: 14px 1fr 8px 1fr 14px;\n column-gap: 0;\n row-gap: 8px;\n align-items: stretch;\n }\n\n .tab-label {\n display: flex;\n align-items: center;\n font-size: 0.85em;\n font-weight: 600;\n color: var(--secondary-text-color, #999);\n user-select: none;\n }\n .tab-left { justify-content: flex-start; }\n .tab-right { justify-content: flex-end; }\n\n .circuit-slot {\n background: var(--secondary-background-color, var(--card-background-color, #2a2a2a));\n border: 1px solid var(--divider-color, #333);\n border-radius: 12px;\n padding: 14px 16px 20px;\n min-height: 140px;\n transition: opacity 0.3s;\n position: relative;\n overflow: hidden;\n }\n\n .circuit-col-span { min-height: 280px; }\n .circuit-row-span { border-left: 3px solid var(--span-accent); }\n .circuit-off .circuit-name,\n .circuit-off .breaker-badge,\n .circuit-off .power-value,\n .circuit-off .chart-container { opacity: 0.35; }\n .circuit-off .toggle-pill,\n .circuit-off .gear-icon { opacity: 1; }\n\n .circuit-empty {\n opacity: 0.2;\n min-height: 60px;\n display: flex;\n align-items: center;\n justify-content: center;\n border-style: dashed;\n }\n .empty-label { color: var(--secondary-text-color, #999); font-size: 0.85em; }\n\n .circuit-header {\n display: flex;\n justify-content: space-between;\n align-items: flex-start;\n margin-bottom: 6px;\n gap: 8px;\n }\n\n .circuit-info { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }\n\n .breaker-badge {\n background: color-mix(in srgb, var(--span-accent) 15%, transparent);\n color: var(--span-accent);\n font-size: 0.7em;\n font-weight: 700;\n padding: 2px 3px;\n border-radius: 4px;\n white-space: nowrap;\n border: 1px solid color-mix(in srgb, var(--span-accent) 25%, transparent);\n flex-shrink: 0;\n }\n\n .circuit-name {\n font-size: 0.9em;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n color: var(--primary-text-color, #e0e0e0);\n }\n\n .circuit-controls { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }\n\n /* Truncation-driven fold for By Panel breaker cells. The .is-folded\n class is added/removed by the JS observer in\n src/core/truncation-fold.ts when the .circuit-name actually\n ellipsizes. Pixel thresholds can\'t get this right because name\n length varies wildly per circuit (e.g. "Spa" vs\n "Commissioned PV System") — only measuring the live name vs its\n container catches the exact moment of truncation.\n\n When folded the nested flex wrappers (.circuit-header,\n .circuit-info, .circuit-controls, .circuit-status) collapse via\n \'display: contents\' so the leaf elements participate directly in\n the outer grid: name gets the whole first row, readings/controls/\n gear drop to a second row, chart stays as the full-width third. */\n .circuit-slot.is-folded {\n display: grid;\n /* Columns: badges + relay-toggle pack tight on the left, slack\n absorbed by the 1fr column between the relay and the power\n reading, keeping power + gear pinned to the right edge. The\n previous layout placed the slack between the shedding icon and\n the relay, which read as wasted padding the user pointed out. */\n grid-template-columns: auto auto auto auto 1fr auto auto;\n /* Rows: name and controls sized to content; chart absorbs any\n extra cell height. Without the explicit 1fr on row 3, a tall\n cell (e.g. .circuit-col-span\'s 280px min-height for 240V\n double-pole breakers) distributes excess space equally across\n all three rows via the default align-content:stretch, which\n pushes the chart down and vertically inflates the badge and\n relay toggle to fill the controls row. */\n grid-template-rows: auto auto 1fr;\n grid-template-areas:\n "name name name name name name name"\n "badge util shed status . power gear"\n "chart chart chart chart chart chart chart";\n row-gap: 6px;\n column-gap: 8px;\n }\n .circuit-slot.is-folded > .circuit-header,\n .circuit-slot.is-folded > .circuit-status,\n .circuit-slot.is-folded > .circuit-header > .circuit-info,\n .circuit-slot.is-folded > .circuit-header > .circuit-controls {\n display: contents;\n }\n .circuit-slot.is-folded .circuit-name {\n grid-area: name;\n justify-self: start;\n }\n .circuit-slot.is-folded .breaker-badge {\n grid-area: badge;\n }\n .circuit-slot.is-folded .utilization {\n grid-area: util;\n }\n .circuit-slot.is-folded .shedding-icon,\n .circuit-slot.is-folded .shedding-composite {\n grid-area: shed;\n }\n .circuit-slot.is-folded .toggle-pill {\n grid-area: status;\n justify-self: end;\n }\n .circuit-slot.is-folded .power-value {\n grid-area: power;\n justify-self: end;\n }\n .circuit-slot.is-folded .gear-icon.circuit-gear {\n grid-area: gear;\n justify-self: end;\n }\n .circuit-slot.is-folded > .chart-container {\n grid-area: chart;\n }\n\n .power-value { font-size: 0.9em; color: var(--primary-text-color, #fff); white-space: nowrap; }\n .power-value strong { font-weight: 700; font-size: 1.1em; }\n .power-unit { font-size: 0.8em; font-weight: 400; color: var(--secondary-text-color, #999); margin-left: 1px; }\n .circuit-producer .power-value strong { color: var(--info-color, #4fc3f7); }\n\n .toggle-pill {\n display: flex;\n align-items: center;\n gap: 3px;\n padding: 2px 4px;\n border-radius: 10px;\n cursor: pointer;\n font-size: 0.65em;\n font-weight: 600;\n transition: background 0.2s;\n user-select: none;\n min-width: 40px;\n }\n .toggle-on {\n padding-left: 6px;\n background: color-mix(in srgb, var(--state-active-color, var(--span-accent)) 25%, transparent);\n color: var(--state-active-color, var(--span-accent));\n }\n .toggle-off {\n padding-right: 6px;\n background: color-mix(in srgb, var(--secondary-text-color) 15%, transparent);\n color: var(--secondary-text-color, #999);\n }\n .toggle-knob {\n width: 14px;\n height: 14px;\n border-radius: 50%;\n transition: background 0.2s, margin 0.2s;\n }\n .toggle-on .toggle-knob {\n background: var(--state-active-color, var(--span-accent));\n margin-left: auto;\n }\n .toggle-off .toggle-knob {\n background: var(--secondary-text-color, #999);\n margin-right: auto;\n order: -1;\n }\n\n .circuit-status {\n display: flex;\n align-items: center;\n gap: 4px;\n margin-top: 4px;\n padding: 0 4px;\n }\n .shedding-icon { opacity: 0.8; cursor: default; }\n .shedding-composite {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n }\n .shedding-icon-secondary { opacity: 0.8; }\n .shedding-label {\n font-size: 10px;\n font-weight: 600;\n opacity: 0.8;\n }\n .gear-icon {\n background: none;\n border: none;\n cursor: pointer;\n padding: 2px;\n opacity: 0.6;\n transition: opacity 0.2s;\n margin-left: auto;\n }\n .gear-icon:hover { opacity: 1; }\n .utilization {\n font-size: 0.75em;\n font-weight: 600;\n }\n .utilization-normal { color: #4caf50; }\n .utilization-warning { color: #ff9800; }\n .utilization-alert { color: #f44336; }\n .circuit-alert {\n border-color: #f44336 !important;\n box-shadow: 0 0 8px rgba(244, 67, 54, 0.3);\n }\n .chart-container {\n width: 100%;\n aspect-ratio: 4 / 1;\n margin-top: 4px;\n overflow: hidden;\n min-width: 0;\n }\n\n .sub-devices {\n display: grid;\n grid-template-columns: repeat(2, 1fr);\n gap: 12px;\n margin-bottom: 20px;\n padding-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n\n .sub-device {\n background: var(--secondary-background-color, var(--card-background-color, #2a2a2a));\n border: 1px solid var(--divider-color, #333);\n border-radius: 12px;\n padding: 14px 16px;\n }\n .sub-device-bess,\n .sub-device-full {\n grid-column: 1 / -1;\n }\n\n .sub-device-header { display: flex; gap: 10px; align-items: baseline; margin-bottom: 8px; }\n .sub-device-type { font-size: 0.7em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--span-accent); }\n .sub-device-name { font-size: 0.85em; color: var(--secondary-text-color, #999); flex: 1; }\n .sub-power-value { font-size: 0.9em; color: var(--primary-text-color, #fff); white-space: nowrap; }\n .sub-power-value strong { font-weight: 700; font-size: 1.1em; }\n .sub-device .chart-container { margin-bottom: 8px; aspect-ratio: auto; }\n\n .bess-charts {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(0, 1fr));\n gap: 12px;\n margin-bottom: 10px;\n }\n .bess-chart-col { min-width: 0; }\n .bess-chart-title {\n font-size: 0.75em;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n color: var(--secondary-text-color, #999);\n margin-bottom: 4px;\n }\n .bess-chart-col .chart-container { aspect-ratio: auto; }\n .sub-entity { display: flex; gap: 6px; padding: 3px 0; font-size: 0.85em; }\n .sub-entity-name { color: var(--secondary-text-color, #999); }\n .sub-entity-value { font-weight: 500; color: var(--primary-text-color, #e0e0e0); }\n\n /* ── Shared tab bar ────────────────────────────────────── */\n\n .shared-tab-bar {\n display: flex;\n gap: 0;\n margin-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n\n .shared-tab {\n padding: 8px 16px;\n cursor: pointer;\n font-size: 0.9em;\n font-weight: 500;\n color: var(--primary-text-color);\n opacity: 0.6;\n border: none;\n border-bottom: 2px solid transparent;\n background: none;\n transition: opacity 0.15s;\n }\n\n .shared-tab:hover {\n opacity: 0.85;\n }\n\n .shared-tab.active {\n opacity: 1;\n border-bottom-color: var(--span-accent);\n }\n\n /* ── List view search ──────────────────────────────────── */\n\n .list-search-container {\n margin-bottom: 12px;\n position: relative;\n }\n\n .list-search {\n width: 100%;\n padding: 8px 36px 8px 12px;\n border-radius: 8px;\n border: 1px solid var(--divider-color, #333);\n background: var(--secondary-background-color, #2a2a2a);\n color: var(--primary-text-color);\n font-size: 0.9em;\n box-sizing: border-box;\n outline: none;\n }\n\n .list-search:focus {\n border-color: var(--span-accent);\n }\n\n .list-search-clear {\n position: absolute;\n right: 8px;\n top: 50%;\n transform: translateY(-50%);\n background: none;\n border: none;\n color: var(--secondary-text-color);\n cursor: pointer;\n padding: 2px;\n display: flex;\n align-items: center;\n opacity: 0.7;\n }\n\n .list-search-clear:hover {\n opacity: 1;\n }\n\n .list-unit-toggle {\n display: inline-flex;\n margin-bottom: 12px;\n }\n\n /* ── List rows ─────────────────────────────────────────── */\n\n .list-view {\n display: flex;\n flex-direction: column;\n gap: 6px;\n }\n /* Each circuit is wrapped in a .list-cell so the row + its optional\n expanded chart stay together. In single-column flex mode the cell\n just stacks naturally. In multi-column grid mode the cell becomes\n one grid item, so the chart is always in the same column as its\n row. Area headers (rendered as siblings, not inside a cell) span\n all columns via their inline "grid-column: 1 / -1". */\n .list-cell {\n display: flex;\n flex-direction: column;\n min-width: 0;\n }\n .list-view[data-columns="2"],\n .list-view[data-columns="3"] {\n display: grid;\n grid-template-columns: repeat(var(--list-cols), minmax(0, 1fr));\n gap: 6px 8px;\n flex-direction: initial;\n }\n /* On narrow viewports a 2/3-column list would squeeze rows into an\n unreadable shape, so force stacking regardless of user preference. */\n @media (max-width: 599px) {\n .list-view[data-columns="2"],\n .list-view[data-columns="3"] {\n display: flex;\n flex-direction: column;\n }\n }\n\n .list-row {\n display: flex;\n align-items: center;\n padding: 12px 16px;\n gap: 10px;\n /* min-width: 0 lets the row shrink below the sum of its\n non-shrinking children when its parent .list-cell is in a\n narrow CSS-grid track (multi-column list mode). Without this\n the row would maintain its intrinsic min-content width and\n overflow the cell, leaving the name unshrunk and the\n truncation-fold observer with no signal to react to. */\n min-width: 0;\n background: var(--card-background-color, #1c1c1c);\n border: 1px solid var(--divider-color, #333);\n border-radius: 8px;\n cursor: pointer;\n transition: background 0.15s;\n }\n\n .list-row:hover {\n background: var(--secondary-background-color, #2a2a2a);\n }\n\n .list-row.circuit-off {\n opacity: 0.5;\n }\n\n .list-row.list-row-expanded {\n border-bottom-left-radius: 0;\n border-bottom-right-radius: 0;\n border-bottom-color: transparent;\n }\n\n .list-circuit-name {\n flex: 1;\n color: var(--primary-text-color);\n font-size: 0.9em;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .list-status-badge {\n font-size: 0.75em;\n font-weight: 600;\n padding: 2px 8px;\n border-radius: 4px;\n flex-shrink: 0;\n }\n\n .list-status-on {\n color: #4dd9af;\n }\n\n .list-status-off {\n color: #f44336;\n }\n\n .list-power-value {\n font-size: 0.9em;\n font-weight: 600;\n flex-shrink: 0;\n /* No min-width / text-align:right: the old 70px right-aligned\n cell left a visible blank column for short readings (e.g.\n "1.3A" in a 70px slot), which robbed horizontal space from\n .list-circuit-name on narrow rows. Let the value hug the\n preceding relay control and size to its content so the freed\n width flows back into the flex:1 name column. */\n }\n\n .list-expand-toggle {\n background: none;\n border: none;\n color: var(--secondary-text-color);\n cursor: pointer;\n padding: 4px;\n transition: transform 0.2s;\n display: flex;\n align-items: center;\n flex-shrink: 0;\n }\n\n .list-expand-toggle.expanded {\n transform: rotate(180deg);\n }\n\n .list-row .gear-icon {\n background: transparent;\n border: none;\n padding: 2px;\n cursor: pointer;\n color: #555;\n display: inline-flex;\n align-items: center;\n }\n .list-row .gear-icon:hover {\n color: var(--primary-text-color);\n }\n\n /* Truncation-driven fold for list rows. The .is-folded class is\n added/removed by the JS observer in src/core/truncation-fold.ts\n when the .list-circuit-name actually ellipsizes — pixel breakpoints\n can\'t track this because name length varies wildly per circuit\n ("Spa" vs "Commissioned PV System") and any single threshold\n misfires for the other end of the range. Switch to a two-row grid\n so the name gets the full width (paired only with the expand\n chevron) and the badges/controls/reading/gear drop to a secondary\n row underneath. Named areas keep the CSS readable despite the flat\n HTML child order. */\n .list-row.is-folded {\n display: grid;\n /* Row 1: name spans the row up to the chevron at the trailing\n column. Row 2: badge + util + shed + relay-toggle pack left,\n the 1fr column absorbs slack between the relay and the power\n reading, power + gear stay pinned to the right edge. The\n earlier layout placed the slack between the shedding icon and\n the relay, which the user flagged as wasted padding. */\n grid-template-columns: auto auto auto auto 1fr auto auto;\n grid-template-areas:\n "name name name name name name chevron"\n "badge util shed status . power gear";\n row-gap: 6px;\n column-gap: 8px;\n }\n .list-row.is-folded > .list-circuit-name {\n grid-area: name;\n justify-self: start;\n }\n .list-row.is-folded > .list-expand-toggle {\n grid-area: chevron;\n }\n .list-row.is-folded > .breaker-badge {\n grid-area: badge;\n }\n .list-row.is-folded > .utilization {\n grid-area: util;\n }\n .list-row.is-folded > .shedding-icon,\n .list-row.is-folded > .shedding-composite {\n grid-area: shed;\n }\n .list-row.is-folded > .toggle-pill,\n .list-row.is-folded > .list-status-badge {\n grid-area: status;\n }\n .list-row.is-folded > .list-power-value {\n grid-area: power;\n justify-self: end;\n }\n .list-row.is-folded > .gear-icon.circuit-gear {\n grid-area: gear;\n justify-self: end;\n }\n\n /* ── Expanded circuit content ──────────────────────────── */\n\n .list-expanded-content {\n padding: 0;\n background: var(--card-background-color, #1c1c1c);\n border: 1px solid var(--divider-color, #333);\n border-top: none;\n border-radius: 0 0 8px 8px;\n margin-top: -6px;\n margin-bottom: 2px;\n }\n\n .circuit-slot.circuit-chart-only {\n border: none;\n margin: 0;\n background: none;\n padding: 8px 12px;\n min-height: 0;\n }\n\n /* ── Area headers ──────────────────────────────────────── */\n\n .area-header {\n padding: 16px 12px 6px;\n font-weight: 600;\n font-size: 0.85em;\n color: var(--secondary-text-color);\n text-transform: uppercase;\n letter-spacing: 0.05em;\n }\n\n /* ── No results ────────────────────────────────────────── */\n\n .list-no-results {\n padding: 24px;\n text-align: center;\n color: var(--secondary-text-color);\n }\n\n'),b([Ot({attribute:!1})],dT.prototype,"hass",void 0),b([zt()],dT.prototype,"_config",void 0),b([zt()],dT.prototype,"_discovered",void 0),b([zt()],dT.prototype,"_discovering",void 0),b([zt()],dT.prototype,"_topology",void 0),b([zt()],dT.prototype,"_activeTab",void 0),dT=b([(t=>(e,n)=>{void 0!==n?n.addInitializer(()=>{customElements.define(t,e)}):customElements.define(t,e)})("span-panel-card")],dT);class hT extends HTMLElement{constructor(){super(...arguments),this._config={},this._hass=null,this._panels=null,this._availableRoles=null,this._built=!1,this._panelSelect=null,this._daysInput=null,this._hoursInput=null,this._minsInput=null,this._metricSelect=null,this._checkboxes={},this._entityContainers={},this._tabStyleSelect=null}setConfig(t){this._config={...t},this._updateControls()}set hass(t){this._hass=t,this._panels?this._built||this._buildEditor():this._discoverPanels()}async _discoverPanels(){if(!this._hass)return;const t=await this._hass.callWS({type:"config/device_registry/list"});this._panels=t.filter(t=>(t.identifiers??[]).some(t=>t[0]===l)&&!t.via_device_id).map(t=>{const e=(t.identifiers??[]).find(t=>t[0]===l)?.[1]??"",n=t.name_by_user??t.name??i("editor.panel_label");return{device_id:t.id,label:`${n} (${e})`}}),this._buildEditor()}_buildEditor(){this.innerHTML="",this._built=!0;const t=document.createElement("div");t.style.padding="16px";const e="\n width: 100%;\n padding: 10px 12px;\n border-radius: 8px;\n border: 1px solid var(--divider-color, #333);\n background: var(--card-background-color, var(--secondary-background-color, #1c1c1c));\n color: var(--primary-text-color, #e0e0e0);\n font-size: 1em;\n cursor: pointer;\n appearance: auto;\n box-sizing: border-box;\n ",n="display: block; font-weight: 500; margin-bottom: 8px; color: var(--primary-text-color);",i="margin-bottom: 16px;";this._buildPanelSelector(t,e,n,i),this._buildTimeWindow(t,e,n,i),this._buildMetricSelector(t,e,n,i),this._buildTabStyleSelector(t,e,n,i),this._buildSectionCheckboxes(t,n,i),this.appendChild(t),this._populateMetricSelect(),this._config.device_id&&this._discoverAvailableRoles(this._config.device_id)}_buildPanelSelector(t,e,n,r){const o=document.createElement("div");o.style.cssText=r;const a=document.createElement("label");a.textContent=i("editor.panel_label"),a.style.cssText=n;const s=document.createElement("select");s.style.cssText=e;const l=document.createElement("option");if(l.value="",l.textContent=i("editor.select_panel"),s.appendChild(l),this._panels)for(const t of this._panels){const e=document.createElement("option");e.value=t.device_id,e.textContent=t.label,t.device_id===this._config.device_id&&(e.selected=!0),s.appendChild(e)}s.addEventListener("change",()=>{this._config={...this._config,device_id:s.value},this._fireConfigChanged(),this._discoverAvailableRoles(s.value)}),o.appendChild(a),o.appendChild(s),t.appendChild(o),this._panelSelect=s}_buildTimeWindow(t,e,n,r){const o=document.createElement("div");o.style.cssText=r;const a=document.createElement("label");a.textContent=i("editor.chart_window"),a.style.cssText=n;const s=document.createElement("div");s.style.cssText="display: flex; gap: 12px; align-items: center; flex-wrap: wrap;";const l=e+"width: 70px; cursor: text;",c=(t,e,n,i)=>{const r=document.createElement("div");r.style.cssText="display: flex; align-items: center; gap: 6px;";const o=document.createElement("input");o.type="number",o.min=e,o.max=n,o.value=String(t),o.style.cssText=l;const a=document.createElement("span");return a.textContent=i,a.style.cssText="font-size: 0.9em; color: var(--secondary-text-color);",r.appendChild(o),r.appendChild(a),{wrap:r,input:o}},u=parseInt(String(this._config.history_days))||0,d=parseInt(String(this._config.history_hours))||0,h=parseInt(String(this._config.history_minutes))||0,p=c(u,"0","30",i("editor.days")),f=c(d,"0","23",i("editor.hours")),g=c(h,"0","59",i("editor.minutes")),v=()=>{this._config={...this._config,history_days:parseInt(p.input.value)||0,history_hours:parseInt(f.input.value)||0,history_minutes:parseInt(g.input.value)||0},this._fireConfigChanged()};p.input.addEventListener("change",v),f.input.addEventListener("change",v),g.input.addEventListener("change",v),s.appendChild(p.wrap),s.appendChild(f.wrap),s.appendChild(g.wrap),o.appendChild(a),o.appendChild(s),t.appendChild(o),this._daysInput=p.input,this._hoursInput=f.input,this._minsInput=g.input}_buildMetricSelector(t,e,n,r){const o=document.createElement("div");o.style.cssText=r;const a=document.createElement("label");a.textContent=i("editor.chart_metric"),a.style.cssText=n;const s=document.createElement("select");s.style.cssText=e,s.addEventListener("change",()=>{this._config={...this._config,chart_metric:s.value},this._fireConfigChanged()}),o.appendChild(a),o.appendChild(s),t.appendChild(o),this._metricSelect=s}_buildTabStyleSelector(t,e,n,r){const o=document.createElement("div");o.style.cssText=r;const a=document.createElement("label");a.textContent=i("editor.tab_style"),a.style.cssText=n;const s=document.createElement("select");s.style.cssText=e;const l=[{value:"text",text:i("editor.tab_style_text")},{value:"icon",text:i("editor.tab_style_icon")}];for(const t of l){const e=document.createElement("option");e.value=t.value,e.textContent=t.text,t.value===(this._config.tab_style??"text")&&(e.selected=!0),s.appendChild(e)}s.addEventListener("change",()=>{this._config={...this._config,tab_style:s.value},this._fireConfigChanged()}),o.appendChild(a),o.appendChild(s),t.appendChild(o),this._tabStyleSelect=s}_buildSectionCheckboxes(t,e,n){const r=document.createElement("div");r.style.cssText=n;const o=document.createElement("label");o.textContent=i("editor.visible_sections"),o.style.cssText=e,r.appendChild(o);const a=[{key:"show_panel",label:i("editor.panel_circuits"),subDeviceType:null},{key:"show_battery",label:i("editor.battery_bess"),subDeviceType:"bess"},{key:"show_evse",label:i("editor.ev_charger_evse"),subDeviceType:"evse"}];this._checkboxes={},this._entityContainers={};for(const t of a){const e=document.createElement("div");e.style.cssText="display: flex; align-items: center; gap: 8px; margin-bottom: 6px; cursor: pointer;";const n=document.createElement("input");n.type="checkbox",n.checked=!1!==this._config[t.key],n.style.cssText="width: 18px; height: 18px; cursor: pointer; accent-color: var(--primary-color);";const i=document.createElement("span");i.textContent=t.label,i.style.cssText="font-size: 0.9em; color: var(--primary-text-color); cursor: pointer;",e.appendChild(n),e.appendChild(i),r.appendChild(e),this._checkboxes[t.key]=n;let o=null;t.subDeviceType&&(o=document.createElement("div"),o.style.cssText="padding-left: 26px;",o.style.display=n.checked?"block":"none",r.appendChild(o),this._entityContainers[t.subDeviceType]=o),n.addEventListener("change",()=>{this._config={...this._config,[t.key]:n.checked},o&&(o.style.display=n.checked?"block":"none"),this._fireConfigChanged()})}t.appendChild(r)}_isChartEntity(t,e,n){const i=(e.original_name??"").toLowerCase(),r=e.unique_id??"";if("power"===i||"battery power"===i||r.endsWith("_power"))return!0;if("bess"===n){if("battery level"===i||"battery percentage"===i||r.endsWith("_battery_level")||r.endsWith("_battery_percentage"))return!0;if("state of energy"===i||r.endsWith("_soe_kwh"))return!0;if("nameplate capacity"===i||r.endsWith("_nameplate_capacity"))return!0}return!1}_populateEntityCheckboxes(t){const e=this._config.visible_sub_entities??{};for(const[,n]of Object.entries(t)){const t=n.type?this._entityContainers[n.type]:void 0;if(t&&(t.innerHTML="",n.entities))for(const[i,r]of Object.entries(n.entities)){if("sensor"===r.domain&&this._isChartEntity(i,r,n.type??""))continue;const o=document.createElement("div");o.style.cssText="display: flex; align-items: center; gap: 8px; margin-bottom: 5px; cursor: pointer;";const a=document.createElement("input");a.type="checkbox",a.checked=!0===e[i],a.style.cssText="width: 16px; height: 16px; cursor: pointer; accent-color: var(--primary-color);";const s=document.createElement("span");let l=r.original_name??i;const c=n.name??"";l.startsWith(c+" ")&&(l=l.slice(c.length+1)),s.textContent=l,s.style.cssText="font-size: 0.85em; color: var(--primary-text-color); cursor: pointer;",o.appendChild(a),o.appendChild(s),t.appendChild(o),a.addEventListener("change",()=>{const t={...this._config.visible_sub_entities??{}};a.checked?t[i]=!0:delete t[i],this._config={...this._config,visible_sub_entities:t},this._fireConfigChanged()})}}}async _discoverAvailableRoles(t){if(this._hass&&t)try{const e=await this._hass.callWS({type:`${l}/panel_topology`,device_id:t}),n=new Set;for(const t of Object.values(e.circuits??{}))for(const e of Object.keys(t.entities??{}))n.add(e);this._availableRoles=n,this._populateMetricSelect(),e.sub_devices&&this._populateEntityCheckboxes(e.sub_devices)}catch{this._availableRoles=null,this._populateMetricSelect()}}_populateMetricSelect(){const t=this._metricSelect;if(!t)return;const e=this._config.chart_metric??o;t.innerHTML="";for(const[n,i]of Object.entries(g)){if(this._availableRoles&&!this._availableRoles.has(i.entityRole))continue;const r=document.createElement("option");r.value=n,r.textContent=i.label(),n===e&&(r.selected=!0),t.appendChild(r)}}_updateControls(){if(this._panelSelect&&(this._panelSelect.value=this._config.device_id??""),this._daysInput&&(this._daysInput.value=String(parseInt(String(this._config.history_days))||0)),this._hoursInput&&(this._hoursInput.value=String(parseInt(String(this._config.history_hours))||0)),this._minsInput&&(this._minsInput.value=String(parseInt(String(this._config.history_minutes))||0)),this._metricSelect&&(this._metricSelect.value=this._config.chart_metric??o),this._checkboxes)for(const[t,e]of Object.entries(this._checkboxes))e.checked=!1!==this._config[t];this._tabStyleSelect&&(this._tabStyleSelect.value=this._config.tab_style??"text")}_fireConfigChanged(){this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}}try{customElements.get("span-panel-card-editor")||customElements.define("span-panel-card-editor",hT)}catch{}window.customCards=window.customCards??[],window.customCards.push({type:"span-panel-card",name:"SPAN Panel",description:"Physical panel layout with live power charts matching the SPAN frontend",preview:!0}),console.warn("%c SPAN-PANEL-CARD %c v0.9.4 ","background: var(--primary-color, #4dd9af); color: var(--text-primary-color, #000); font-weight: 700; padding: 2px 6px; border-radius: 4px 0 0 4px;","background: var(--secondary-background-color, #333); color: var(--primary-text-color, #fff); padding: 2px 6px; border-radius: 0 4px 4px 0;"); diff --git a/custom_components/span_panel/frontend/dist/span-panel.js b/custom_components/span_panel/frontend/dist/span-panel.js index ff64f65c..9949e0e7 100644 --- a/custom_components/span_panel/frontend/dist/span-panel.js +++ b/custom_components/span_panel/frontend/dist/span-panel.js @@ -1,26 +1,26 @@ -let t="en";const e={en:{"tab.panel":"Panel","tab.by_panel":"By Panel","tab.by_activity":"By Activity","tab.by_area":"By Area","tab.monitoring":"Monitoring","tab.settings":"Settings","list.search_placeholder":"Search circuits...","list.unassigned_area":"Unassigned","list.no_results":"No circuits found","monitoring.heading":"Monitoring","monitoring.global_settings":"Global Settings","monitoring.enabled":"Enabled","monitoring.continuous":"Continuous (%)","monitoring.spike":"Spike (%)","monitoring.window":"Window (min)","monitoring.cooldown":"Cooldown (min)","monitoring.monitored_points":"Monitored Points","monitoring.col.name":"Name","monitoring.col.continuous":"Continuous","monitoring.col.spike":"Spike","monitoring.col.window":"Window","monitoring.col.cooldown":"Cooldown","monitoring.all_none":"All / None","monitoring.reset":"Reset","notification.heading":"Notification Settings","notification.targets":"Notify Targets","notification.none_selected":"None selected","notification.no_targets":"No notify targets found","notification.all_targets":"All","notification.event_bus_target":"Event Bus (HA event bus)","notification.priority":"Priority","notification.priority.default":"Default","notification.priority.passive":"Passive","notification.priority.active":"Active","notification.priority.time_sensitive":"Time-sensitive","notification.priority.critical":"Critical","notification.hint.critical":"Overrides silent/DND","notification.hint.time_sensitive":"Breaks through Focus","notification.hint.passive":"Delivers silently","notification.hint.active":"Standard delivery","notification.title_template":"Title Template","notification.message_template":"Message Template","notification.placeholders":"Placeholders:","notification.event_bus_help":"Event Bus fires event type","notification.event_bus_payload":"with payload:","notification.test_label":"Test Notification","notification.test_button":"Send Test","notification.test_sending":"Sending...","notification.test_sent":"Test notification sent","error.prefix":"Error:","error.failed_save":"Failed to save","error.failed":"Failed","error.panel_offline":"SPAN Panel unreachable","error.panel_reconnected":"SPAN Panel reconnected","error.panel_offline_named":"{name} unreachable","error.panel_reconnected_named":"{name} reconnected","error.discovery_failed":"Unable to connect to SPAN Panel","error.relay_failed":"Unable to toggle relay","error.shedding_failed":"Unable to update shedding priority","error.threshold_failed":"Unable to save threshold","error.graph_horizon_failed":"Unable to update graph time horizon","error.favorites_fetch_failed":"Unable to load favorites","error.favorites_toggle_failed":"Unable to update favorite","error.history_failed":"Unable to load historical data","error.monitoring_failed":"Unable to load monitoring status","error.graph_settings_failed":"Unable to load graph settings","error.areas_failed":"Area assignments may be out of sync","error.retry":"Retry","card.connecting":"Connecting to SPAN Panel...","settings.heading":"Settings","settings.description":"General integration settings (entity naming, device prefix, circuit numbers) are managed through the integration's options flow.","settings.open_link":"Open SPAN Panel Integration Settings","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Panel monitoring settings","header.graph_settings":"Graph time horizon settings","header.site":"Site","header.grid":"Grid","header.upstream":"Upstream","header.downstream":"Downstream","header.solar":"Solar","header.battery":"Battery","header.toggle_units":"Toggle Watts / Amps","header.enable_switches":"Enable Switches","header.switches_enabled":"Switches Enabled","grid.unknown":"Unknown","grid.configure":"Configure circuit","grid.configure_subdevice":"Configure device","grid.on":"On","grid.off":"Off","subdevice.ev_charger":"EV Charger","subdevice.battery":"Battery","subdevice.fallback":"Sub-device","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Power","sidepanel.graph_settings":"Graph Settings","sidepanel.global_defaults":"Global defaults for all circuits","sidepanel.favorites_subtitle":"Favorites","sidepanel.global_default":"Global Default","sidepanel.list_view_columns":"List View Columns","sidepanel.columns":"Columns","sidepanel.circuit_scales":"Circuit Graph Scales","sidepanel.subdevice_scales":"Sub-Device Graph Scales","sidepanel.reset_to_global":"Reset to global default","sidepanel.relay":"Relay","sidepanel.breaker":"Breaker","sidepanel.shedding_priority":"Shedding Priority","sidepanel.priority_label":"Priority","sidepanel.monitoring":"Monitoring","sidepanel.global":"Global","sidepanel.custom":"Custom","sidepanel.continuous_pct":"Continuous %","sidepanel.spike_pct":"Spike %","sidepanel.window_duration":"Window duration","sidepanel.cooldown":"Cooldown","sidepanel.favorite":"Favorite","sidepanel.save_to_favorites":"Save to favorites","panel.favorites":"Favorites","status.monitoring":"Monitoring","status.circuits":"circuits","status.mains":"mains","status.warning":"warning","status.warnings":"warnings","status.alert":"alert","status.alerts":"alerts","status.override":"override","status.overrides":"overrides","card.no_device":"Open the card editor and select your SPAN Panel device.","card.device_not_found":"Panel device not found. Check device_id in card config.","card.topology_error":"Topology response missing panel_size and no circuits found. Update the SPAN Panel integration.","card.panel_size_error":"Could not determine panel_size. No circuits found and no panel_size attribute. Update the SPAN Panel integration.","editor.panel_label":"SPAN Panel","editor.select_panel":"Select a panel...","editor.chart_window":"Chart time window","editor.days":"days","editor.hours":"hours","editor.minutes":"minutes","editor.chart_metric":"Chart metric","editor.visible_sections":"Visible sections","editor.panel_circuits":"Panel circuits","editor.battery_bess":"Battery (BESS)","editor.ev_charger_evse":"EV Charger (EVSE)","editor.tab_style":"Tab Style","editor.tab_style_text":"Text","editor.tab_style_icon":"Icon","metric.power":"Power","metric.current":"Current","metric.soc":"State of Charge","metric.soe":"State of Energy","shedding.always_on":"Critical","shedding.never":"Non-sheddable","shedding.soc_threshold":"SoC Threshold","shedding.off_grid":"Sheddable","shedding.unknown":"Unknown","shedding.select.never":"Stays on in an outage","shedding.select.soc_threshold":"Stays on until battery threshold","shedding.select.off_grid":"Turns off in an outage"},es:{"tab.panel":"Panel","tab.by_panel":"Por Panel","tab.by_activity":"Por Actividad","tab.by_area":"Por Área","tab.monitoring":"Monitoreo","tab.settings":"Configuración","list.search_placeholder":"Buscar circuitos...","list.unassigned_area":"Sin asignar","list.no_results":"No se encontraron circuitos","monitoring.heading":"Monitoreo","monitoring.global_settings":"Configuración Global","monitoring.enabled":"Activado","monitoring.continuous":"Continuo (%)","monitoring.spike":"Pico (%)","monitoring.window":"Ventana (min)","monitoring.cooldown":"Enfriamiento (min)","monitoring.monitored_points":"Puntos Monitoreados","monitoring.col.name":"Nombre","monitoring.col.continuous":"Continuo","monitoring.col.spike":"Pico","monitoring.col.window":"Ventana","monitoring.col.cooldown":"Enfriamiento","monitoring.all_none":"Todos / Ninguno","monitoring.reset":"Restablecer","notification.heading":"Configuración de Notificaciones","notification.targets":"Destinos de Notificación","notification.none_selected":"Ninguno seleccionado","notification.no_targets":"No se encontraron destinos de notificación","notification.all_targets":"Todos","notification.event_bus_target":"Bus de Eventos (bus de eventos de HA)","notification.priority":"Prioridad","notification.priority.default":"Predeterminado","notification.priority.passive":"Pasivo","notification.priority.active":"Activo","notification.priority.time_sensitive":"Urgente","notification.priority.critical":"Crítico","notification.hint.critical":"Anula silencio/No molestar","notification.hint.time_sensitive":"Atraviesa el modo Concentración","notification.hint.passive":"Entrega silenciosa","notification.hint.active":"Entrega estándar","notification.title_template":"Plantilla de Título","notification.message_template":"Plantilla de Mensaje","notification.placeholders":"Variables:","notification.event_bus_help":"El Bus de Eventos dispara el tipo de evento","notification.event_bus_payload":"con datos:","notification.test_label":"Notificación de prueba","notification.test_button":"Enviar prueba","notification.test_sending":"Enviando...","notification.test_sent":"Notificación de prueba enviada","error.prefix":"Error:","error.failed_save":"Error al guardar","error.failed":"Falló","error.panel_offline":"SPAN Panel inaccesible","error.panel_reconnected":"SPAN Panel reconectado","error.panel_offline_named":"{name} inaccesible","error.panel_reconnected_named":"{name} reconectado","error.discovery_failed":"No se puede conectar al SPAN Panel","error.relay_failed":"No se pudo cambiar el relé","error.shedding_failed":"No se pudo actualizar la prioridad de desconexión","error.threshold_failed":"No se pudo guardar el umbral","error.graph_horizon_failed":"No se pudo actualizar el horizonte temporal del gráfico","error.favorites_fetch_failed":"No se pudieron cargar los favoritos","error.favorites_toggle_failed":"No se pudo actualizar el favorito","error.history_failed":"No se pudieron cargar los datos históricos","error.monitoring_failed":"No se pudo cargar el estado de monitoreo","error.graph_settings_failed":"No se pudo cargar la configuración del gráfico","error.areas_failed":"Las asignaciones de áreas pueden estar desincronizadas","error.retry":"Reintentar","card.connecting":"Conectando al SPAN Panel...","settings.heading":"Configuración","settings.description":"La configuración general de la integración (nombres de entidades, prefijo de dispositivo, números de circuito) se administra a través del flujo de opciones de la integración.","settings.open_link":"Abrir Configuración de Integración SPAN Panel","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Configuración de monitoreo del panel","header.graph_settings":"Configuración del horizonte temporal del gráfico","header.site":"Sitio","header.grid":"Red","header.upstream":"Aguas arriba","header.downstream":"Aguas abajo","header.solar":"Solar","header.battery":"Batería","header.toggle_units":"Alternar Watts / Amperios","header.enable_switches":"Habilitar Interruptores","header.switches_enabled":"Interruptores Habilitados","grid.unknown":"Desconocido","grid.configure":"Configurar circuito","grid.configure_subdevice":"Configurar dispositivo","grid.on":"Enc","grid.off":"Apag","subdevice.ev_charger":"Cargador EV","subdevice.battery":"Batería","subdevice.fallback":"Sub-dispositivo","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Potencia","sidepanel.graph_settings":"Configuración de Gráficos","sidepanel.global_defaults":"Valores predeterminados globales para todos los circuitos","sidepanel.favorites_subtitle":"Favoritos","sidepanel.global_default":"Predeterminado Global","sidepanel.list_view_columns":"Columnas de la lista","sidepanel.columns":"Columnas","sidepanel.circuit_scales":"Escalas de Gráficos de Circuitos","sidepanel.subdevice_scales":"Escalas de Gráficos de Sub-Dispositivos","sidepanel.reset_to_global":"Restablecer al valor global","sidepanel.relay":"Relé","sidepanel.breaker":"Interruptor","sidepanel.shedding_priority":"Prioridad de Desconexción","sidepanel.priority_label":"Prioridad","sidepanel.monitoring":"Monitoreo","sidepanel.global":"Global","sidepanel.custom":"Personalizado","sidepanel.continuous_pct":"Continuo %","sidepanel.spike_pct":"Pico %","sidepanel.window_duration":"Duración de ventana","sidepanel.cooldown":"Enfriamiento","sidepanel.favorite":"Favorito","sidepanel.save_to_favorites":"Guardar en favoritos","panel.favorites":"Favoritos","status.monitoring":"Monitoreo","status.circuits":"circuitos","status.mains":"alimentación","status.warning":"advertencia","status.warnings":"advertencias","status.alert":"alerta","status.alerts":"alertas","status.override":"anulación","status.overrides":"anulaciones","card.no_device":"Abra el editor de tarjeta y seleccione su dispositivo SPAN Panel.","card.device_not_found":"Dispositivo de panel no encontrado. Verifique device_id en la configuración de la tarjeta.","card.topology_error":"La respuesta de topología no contiene panel_size y no se encontraron circuitos. Actualice la integración SPAN Panel.","card.panel_size_error":"No se pudo determinar panel_size. No se encontraron circuitos ni atributo panel_size. Actualice la integración SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Seleccione un panel...","editor.chart_window":"Ventana de tiempo del gráfico","editor.days":"días","editor.hours":"horas","editor.minutes":"minutos","editor.chart_metric":"Métrica del gráfico","editor.visible_sections":"Secciones visibles","editor.panel_circuits":"Circuitos del panel","editor.battery_bess":"Batería (BESS)","editor.ev_charger_evse":"Cargador EV (EVSE)","editor.tab_style":"Estilo de pestañas","editor.tab_style_text":"Texto","editor.tab_style_icon":"Ícono","metric.power":"Potencia","metric.current":"Corriente","metric.soc":"Estado de Carga","metric.soe":"Estado de Energía","shedding.always_on":"Crítico","shedding.never":"No desconectable","shedding.soc_threshold":"Umbral SoC","shedding.off_grid":"Desconectable","shedding.unknown":"Desconocido","shedding.select.never":"Permanece encendido en un corte","shedding.select.soc_threshold":"Encendido hasta umbral de batería","shedding.select.off_grid":"Se apaga en un corte"},fr:{"tab.panel":"Panneau","tab.by_panel":"Par Panneau","tab.by_activity":"Par Activité","tab.by_area":"Par Zone","tab.monitoring":"Surveillance","tab.settings":"Paramètres","list.search_placeholder":"Rechercher des circuits...","list.unassigned_area":"Non attribué","list.no_results":"Aucun circuit trouvé","monitoring.heading":"Surveillance","monitoring.global_settings":"Paramètres Globaux","monitoring.enabled":"Activé","monitoring.continuous":"Continu (%)","monitoring.spike":"Pic (%)","monitoring.window":"Fenêtre (min)","monitoring.cooldown":"Refroidissement (min)","monitoring.monitored_points":"Points Surveillés","monitoring.col.name":"Nom","monitoring.col.continuous":"Continu","monitoring.col.spike":"Pic","monitoring.col.window":"Fenêtre","monitoring.col.cooldown":"Refroidissement","monitoring.all_none":"Tous / Aucun","monitoring.reset":"Réinitialiser","notification.heading":"Paramètres de Notification","notification.targets":"Cibles de Notification","notification.none_selected":"Aucune sélection","notification.no_targets":"Aucune cible de notification trouvée","notification.all_targets":"Tous","notification.event_bus_target":"Bus d'événements (bus d'événements HA)","notification.priority":"Priorité","notification.priority.default":"Par défaut","notification.priority.passive":"Passif","notification.priority.active":"Actif","notification.priority.time_sensitive":"Urgent","notification.priority.critical":"Critique","notification.hint.critical":"Outrepasse silencieux/NPD","notification.hint.time_sensitive":"Traverse le mode Concentration","notification.hint.passive":"Livraison silencieuse","notification.hint.active":"Livraison standard","notification.title_template":"Modèle de Titre","notification.message_template":"Modèle de Message","notification.placeholders":"Variables :","notification.event_bus_help":"Le Bus d'événements déclenche le type d'événement","notification.event_bus_payload":"avec les données :","notification.test_label":"Notification de test","notification.test_button":"Envoyer un test","notification.test_sending":"Envoi...","notification.test_sent":"Notification de test envoyée","error.prefix":"Erreur :","error.failed_save":"Échec de la sauvegarde","error.failed":"Échoué","error.panel_offline":"SPAN Panel inaccessible","error.panel_reconnected":"SPAN Panel reconnecté","error.panel_offline_named":"{name} inaccessible","error.panel_reconnected_named":"{name} reconnecté","error.discovery_failed":"Impossible de se connecter au SPAN Panel","error.relay_failed":"Impossible de basculer le relais","error.shedding_failed":"Impossible de mettre à jour la priorité de délestage","error.threshold_failed":"Impossible d'enregistrer le seuil","error.graph_horizon_failed":"Impossible de mettre à jour l'horizon temporel du graphique","error.favorites_fetch_failed":"Impossible de charger les favoris","error.favorites_toggle_failed":"Impossible de mettre à jour le favori","error.history_failed":"Impossible de charger les données historiques","error.monitoring_failed":"Impossible de charger l'état de surveillance","error.graph_settings_failed":"Impossible de charger les paramètres du graphique","error.areas_failed":"Les affectations de zones peuvent être désynchronisées","error.retry":"Réessayer","card.connecting":"Connexion au SPAN Panel...","settings.heading":"Paramètres","settings.description":"Les paramètres généraux de l'intégration (noms d'entités, préfixe de l'appareil, numéros de circuit) sont gérés via le flux d'options de l'intégration.","settings.open_link":"Ouvrir les Paramètres d'Intégration SPAN Panel","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Paramètres de surveillance du panneau","header.graph_settings":"Paramètres d'horizon temporel du graphique","header.site":"Site","header.grid":"Réseau","header.upstream":"Amont","header.downstream":"Aval","header.solar":"Solaire","header.battery":"Batterie","header.toggle_units":"Basculer Watts / Ampères","header.enable_switches":"Activer les interrupteurs","header.switches_enabled":"Interrupteurs activés","grid.unknown":"Inconnu","grid.configure":"Configurer le circuit","grid.configure_subdevice":"Configurer l'appareil","grid.on":"On","grid.off":"Off","subdevice.ev_charger":"Chargeur VE","subdevice.battery":"Batterie","subdevice.fallback":"Sous-appareil","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Puissance","sidepanel.graph_settings":"Paramètres des Graphiques","sidepanel.global_defaults":"Valeurs par défaut globales pour tous les circuits","sidepanel.favorites_subtitle":"Favoris","sidepanel.global_default":"Défaut Global","sidepanel.list_view_columns":"Colonnes de la liste","sidepanel.columns":"Colonnes","sidepanel.circuit_scales":"Échelles des Graphiques de Circuits","sidepanel.subdevice_scales":"Échelles des Graphiques de Sous-Appareils","sidepanel.reset_to_global":"Réinitialiser à la valeur globale","sidepanel.relay":"Relais","sidepanel.breaker":"Disjoncteur","sidepanel.shedding_priority":"Priorité de Délestage","sidepanel.priority_label":"Priorité","sidepanel.monitoring":"Surveillance","sidepanel.global":"Global","sidepanel.custom":"Personnalisé","sidepanel.continuous_pct":"Continu %","sidepanel.spike_pct":"Pic %","sidepanel.window_duration":"Durée de fenêtre","sidepanel.cooldown":"Refroidissement","sidepanel.favorite":"Favori","sidepanel.save_to_favorites":"Enregistrer dans les favoris","panel.favorites":"Favoris","status.monitoring":"Surveillance","status.circuits":"circuits","status.mains":"alimentation","status.warning":"avertissement","status.warnings":"avertissements","status.alert":"alerte","status.alerts":"alertes","status.override":"remplacement","status.overrides":"remplacements","card.no_device":"Ouvrez l'éditeur de carte et sélectionnez votre appareil SPAN Panel.","card.device_not_found":"Appareil de panneau introuvable. Vérifiez device_id dans la configuration de la carte.","card.topology_error":"La réponse de topologie ne contient pas panel_size et aucun circuit trouvé. Mettez à jour l'intégration SPAN Panel.","card.panel_size_error":"Impossible de déterminer panel_size. Aucun circuit trouvé et aucun attribut panel_size. Mettez à jour l'intégration SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Sélectionnez un panneau...","editor.chart_window":"Fenêtre de temps du graphique","editor.days":"jours","editor.hours":"heures","editor.minutes":"minutes","editor.chart_metric":"Métrique du graphique","editor.visible_sections":"Sections visibles","editor.panel_circuits":"Circuits du panneau","editor.battery_bess":"Batterie (BESS)","editor.ev_charger_evse":"Chargeur VE (EVSE)","editor.tab_style":"Style des onglets","editor.tab_style_text":"Texte","editor.tab_style_icon":"Icône","metric.power":"Puissance","metric.current":"Courant","metric.soc":"État de Charge","metric.soe":"État d'Énergie","shedding.always_on":"Critique","shedding.never":"Non délestable","shedding.soc_threshold":"Seuil SoC","shedding.off_grid":"Délestable","shedding.unknown":"Inconnu","shedding.select.never":"Reste allumé en cas de coupure","shedding.select.soc_threshold":"Allumé jusqu'au seuil batterie","shedding.select.off_grid":"S'éteint en cas de coupure"},ja:{"tab.panel":"パネル","tab.by_panel":"パネル別","tab.by_activity":"活動別","tab.by_area":"エリア別","tab.monitoring":"モニタリング","tab.settings":"設定","list.search_placeholder":"回路を検索...","list.unassigned_area":"未割り当て","list.no_results":"回路が見つかりません","monitoring.heading":"モニタリング","monitoring.global_settings":"グローバル設定","monitoring.enabled":"有効","monitoring.continuous":"継続 (%)","monitoring.spike":"スパイク (%)","monitoring.window":"ウィンドウ (分)","monitoring.cooldown":"クールダウン (分)","monitoring.monitored_points":"監視ポイント","monitoring.col.name":"名前","monitoring.col.continuous":"継続","monitoring.col.spike":"スパイク","monitoring.col.window":"ウィンドウ","monitoring.col.cooldown":"クールダウン","monitoring.all_none":"全選択 / 全解除","monitoring.reset":"リセット","notification.heading":"通知設定","notification.targets":"通知先","notification.none_selected":"未選択","notification.no_targets":"通知先が見つかりません","notification.all_targets":"すべて","notification.event_bus_target":"イベントバス (HAイベントバス)","notification.priority":"優先度","notification.priority.default":"デフォルト","notification.priority.passive":"パッシブ","notification.priority.active":"アクティブ","notification.priority.time_sensitive":"緊急","notification.priority.critical":"重大","notification.hint.critical":"サイレント/おやすみモードを無視","notification.hint.time_sensitive":"集中モードを突破","notification.hint.passive":"サイレント配信","notification.hint.active":"標準配信","notification.title_template":"タイトルテンプレート","notification.message_template":"メッセージテンプレート","notification.placeholders":"プレースホルダー:","notification.event_bus_help":"イベントバスが発行するイベントタイプ","notification.event_bus_payload":"ペイロード:","notification.test_label":"テスト通知","notification.test_button":"テスト送信","notification.test_sending":"送信中...","notification.test_sent":"テスト通知を送信しました","error.prefix":"エラー:","error.failed_save":"保存に失敗","error.failed":"失敗","error.panel_offline":"SPANパネルに接続できません","error.panel_reconnected":"SPANパネルが再接続されました","error.panel_offline_named":"{name}に接続できません","error.panel_reconnected_named":"{name}が再接続されました","error.discovery_failed":"SPANパネルへの接続に失敗しました","error.relay_failed":"リレーの切り替えに失敗しました","error.shedding_failed":"シェディング優先度の更新に失敗しました","error.threshold_failed":"しきい値の保存に失敗しました","error.graph_horizon_failed":"グラフの時間範囲の更新に失敗しました","error.favorites_fetch_failed":"お気に入りの読み込みに失敗しました","error.favorites_toggle_failed":"お気に入りの更新に失敗しました","error.history_failed":"履歴データの読み込みに失敗しました","error.monitoring_failed":"監視ステータスの読み込みに失敗しました","error.graph_settings_failed":"グラフ設定の読み込みに失敗しました","error.areas_failed":"エリア割り当てが同期されていない可能性があります","error.retry":"再試行","card.connecting":"SPANパネルに接続中...","settings.heading":"設定","settings.description":"統合の一般設定(エンティティ名、デバイスプレフィックス、回路番号)は統合のオプションフローで管理されます。","settings.open_link":"SPAN Panel統合設定を開く","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"パネルモニタリング設定","header.graph_settings":"グラフ時間範囲設定","header.site":"サイト","header.grid":"グリッド","header.upstream":"上流","header.downstream":"下流","header.solar":"ソーラー","header.battery":"バッテリー","header.toggle_units":"ワット/アンペア切り替え","header.enable_switches":"スイッチを有効化","header.switches_enabled":"スイッチ有効","grid.unknown":"不明","grid.configure":"回路を設定","grid.configure_subdevice":"デバイスを設定","grid.on":"オン","grid.off":"オフ","subdevice.ev_charger":"EV充電器","subdevice.battery":"バッテリー","subdevice.fallback":"サブデバイス","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"電力","sidepanel.graph_settings":"グラフ設定","sidepanel.global_defaults":"全回路のグローバルデフォルト","sidepanel.favorites_subtitle":"お気に入り","sidepanel.global_default":"グローバルデフォルト","sidepanel.list_view_columns":"リスト表示の列数","sidepanel.columns":"列","sidepanel.circuit_scales":"回路グラフスケール","sidepanel.subdevice_scales":"サブデバイスグラフスケール","sidepanel.reset_to_global":"グローバルにリセット","sidepanel.relay":"リレー","sidepanel.breaker":"ブレーカー","sidepanel.shedding_priority":"シェディング優先度","sidepanel.priority_label":"優先度","sidepanel.monitoring":"モニタリング","sidepanel.global":"グローバル","sidepanel.custom":"カスタム","sidepanel.continuous_pct":"継続 %","sidepanel.spike_pct":"スパイク %","sidepanel.window_duration":"ウィンドウ時間","sidepanel.cooldown":"クールダウン","sidepanel.favorite":"お気に入り","sidepanel.save_to_favorites":"お気に入りに保存","panel.favorites":"お気に入り","status.monitoring":"モニタリング","status.circuits":"回路","status.mains":"主電源","status.warning":"警告","status.warnings":"警告","status.alert":"アラート","status.alerts":"アラート","status.override":"上書き","status.overrides":"上書き","card.no_device":"カードエディタを開いてSPAN Panelデバイスを選択してください。","card.device_not_found":"パネルデバイスが見つかりません。カード設定のdevice_idを確認してください。","card.topology_error":"トポロジー応答にpanel_sizeがなく、回路が見つかりません。SPAN Panel統合を更新してください。","card.panel_size_error":"panel_sizeを判定できません。回路がpanel_size属性が見つかりません。SPAN Panel統合を更新してください。","editor.panel_label":"SPAN Panel","editor.select_panel":"パネルを選択...","editor.chart_window":"グラフ時間ウィンドウ","editor.days":"日","editor.hours":"時間","editor.minutes":"分","editor.chart_metric":"グラフ指標","editor.visible_sections":"表示セクション","editor.panel_circuits":"パネル回路","editor.battery_bess":"バッテリー (BESS)","editor.ev_charger_evse":"EV充電器 (EVSE)","editor.tab_style":"タブスタイル","editor.tab_style_text":"テキスト","editor.tab_style_icon":"アイコン","metric.power":"電力","metric.current":"電流","metric.soc":"充電状態","metric.soe":"エネルギー状態","shedding.always_on":"重要","shedding.never":"切断不可","shedding.soc_threshold":"SoCしきい値","shedding.off_grid":"切断可能","shedding.unknown":"不明","shedding.select.never":"停電時もオンを維持","shedding.select.soc_threshold":"バッテリーしきい値までオン","shedding.select.off_grid":"停電時にオフ"},pt:{"tab.panel":"Painel","tab.by_panel":"Por Painel","tab.by_activity":"Por Atividade","tab.by_area":"Por Área","tab.monitoring":"Monitoramento","tab.settings":"Configurações","list.search_placeholder":"Pesquisar circuitos...","list.unassigned_area":"Não atribuído","list.no_results":"Nenhum circuito encontrado","monitoring.heading":"Monitoramento","monitoring.global_settings":"Configurações Globais","monitoring.enabled":"Ativado","monitoring.continuous":"Contínuo (%)","monitoring.spike":"Pico (%)","monitoring.window":"Janela (min)","monitoring.cooldown":"Resfriamento (min)","monitoring.monitored_points":"Pontos Monitorados","monitoring.col.name":"Nome","monitoring.col.continuous":"Contínuo","monitoring.col.spike":"Pico","monitoring.col.window":"Janela","monitoring.col.cooldown":"Resfriamento","monitoring.all_none":"Todos / Nenhum","monitoring.reset":"Redefinir","notification.heading":"Configurações de Notificação","notification.targets":"Destinos de Notificação","notification.none_selected":"Nenhum selecionado","notification.no_targets":"Nenhum destino de notificação encontrado","notification.all_targets":"Todos","notification.event_bus_target":"Barramento de Eventos (barramento de eventos do HA)","notification.priority":"Prioridade","notification.priority.default":"Padrão","notification.priority.passive":"Passivo","notification.priority.active":"Ativo","notification.priority.time_sensitive":"Urgente","notification.priority.critical":"Crítico","notification.hint.critical":"Substitui silencioso/Não perturbar","notification.hint.time_sensitive":"Atravessa o modo Foco","notification.hint.passive":"Entrega silenciosa","notification.hint.active":"Entrega padrão","notification.title_template":"Modelo de Título","notification.message_template":"Modelo de Mensagem","notification.placeholders":"Variáveis:","notification.event_bus_help":"O Barramento de Eventos dispara o tipo de evento","notification.event_bus_payload":"com dados:","notification.test_label":"Notificação de teste","notification.test_button":"Enviar teste","notification.test_sending":"Enviando...","notification.test_sent":"Notificação de teste enviada","error.prefix":"Erro:","error.failed_save":"Falha ao salvar","error.failed":"Falhou","error.panel_offline":"SPAN Panel inacessível","error.panel_reconnected":"SPAN Panel reconectado","error.panel_offline_named":"{name} inacessível","error.panel_reconnected_named":"{name} reconectado","error.discovery_failed":"Não foi possível conectar ao SPAN Panel","error.relay_failed":"Não foi possível alternar o relé","error.shedding_failed":"Não foi possível atualizar a prioridade de desligamento","error.threshold_failed":"Não foi possível salvar o limite","error.graph_horizon_failed":"Não foi possível atualizar o horizonte temporal do gráfico","error.favorites_fetch_failed":"Não foi possível carregar os favoritos","error.favorites_toggle_failed":"Não foi possível atualizar o favorito","error.history_failed":"Não foi possível carregar os dados históricos","error.monitoring_failed":"Não foi possível carregar o status de monitoramento","error.graph_settings_failed":"Não foi possível carregar as configurações do gráfico","error.areas_failed":"As atribuições de áreas podem estar fora de sincronização","error.retry":"Tentar novamente","card.connecting":"Conectando ao SPAN Panel...","settings.heading":"Configurações","settings.description":"As configurações gerais da integração (nomes de entidades, prefixo do dispositivo, números de circuito) são gerenciadas através do fluxo de opções da integração.","settings.open_link":"Abrir Configurações de Integração SPAN Panel","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Configurações de monitoramento do painel","header.graph_settings":"Configurações do horizonte temporal do gráfico","header.site":"Local","header.grid":"Rede","header.upstream":"Montante","header.downstream":"Jusante","header.solar":"Solar","header.battery":"Bateria","header.toggle_units":"Alternar Watts / Amperes","header.enable_switches":"Ativar Interruptores","header.switches_enabled":"Interruptores Ativados","grid.unknown":"Desconhecido","grid.configure":"Configurar circuito","grid.configure_subdevice":"Configurar dispositivo","grid.on":"Lig","grid.off":"Des","subdevice.ev_charger":"Carregador VE","subdevice.battery":"Bateria","subdevice.fallback":"Sub-dispositivo","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Potência","sidepanel.graph_settings":"Configurações de Gráficos","sidepanel.global_defaults":"Padrões globais para todos os circuitos","sidepanel.favorites_subtitle":"Favoritos","sidepanel.global_default":"Padrão Global","sidepanel.list_view_columns":"Colunas da Lista","sidepanel.columns":"Colunas","sidepanel.circuit_scales":"Escalas de Gráficos de Circuitos","sidepanel.subdevice_scales":"Escalas de Gráficos de Sub-Dispositivos","sidepanel.reset_to_global":"Redefinir para o padrão global","sidepanel.relay":"Relé","sidepanel.breaker":"Disjuntor","sidepanel.shedding_priority":"Prioridade de Desligamento","sidepanel.priority_label":"Prioridade","sidepanel.monitoring":"Monitoramento","sidepanel.global":"Global","sidepanel.custom":"Personalizado","sidepanel.continuous_pct":"Contínuo %","sidepanel.spike_pct":"Pico %","sidepanel.window_duration":"Duração da janela","sidepanel.cooldown":"Resfriamento","sidepanel.favorite":"Favorito","sidepanel.save_to_favorites":"Salvar nos favoritos","panel.favorites":"Favoritos","status.monitoring":"Monitoramento","status.circuits":"circuitos","status.mains":"alimentação","status.warning":"aviso","status.warnings":"avisos","status.alert":"alerta","status.alerts":"alertas","status.override":"substituição","status.overrides":"substituições","card.no_device":"Abra o editor do cartão e selecione seu dispositivo SPAN Panel.","card.device_not_found":"Dispositivo do painel não encontrado. Verifique device_id na configuração do cartão.","card.topology_error":"A resposta de topologia não contém panel_size e nenhum circuito encontrado. Atualize a integração SPAN Panel.","card.panel_size_error":"Não foi possível determinar panel_size. Nenhum circuito encontrado e nenhum atributo panel_size. Atualize a integração SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Selecione um painel...","editor.chart_window":"Janela de tempo do gráfico","editor.days":"dias","editor.hours":"horas","editor.minutes":"minutos","editor.chart_metric":"Métrica do gráfico","editor.visible_sections":"Seções visíveis","editor.panel_circuits":"Circuitos do painel","editor.battery_bess":"Bateria (BESS)","editor.ev_charger_evse":"Carregador VE (EVSE)","editor.tab_style":"Estilo das abas","editor.tab_style_text":"Texto","editor.tab_style_icon":"Ícone","metric.power":"Potência","metric.current":"Corrente","metric.soc":"Estado de Carga","metric.soe":"Estado de Energia","shedding.always_on":"Crítico","shedding.never":"Não desligável","shedding.soc_threshold":"Limite SoC","shedding.off_grid":"Desligável","shedding.unknown":"Desconhecido","shedding.select.never":"Permanece ligado em uma queda","shedding.select.soc_threshold":"Ligado até limite da bateria","shedding.select.off_grid":"Desliga em uma queda"}};function n(n){return e[t]?.[n]??e.en?.[n]??n}function i(n,i){return(e[t]?.[n]??e.en?.[n]??n).replace(/\{(\w+)\}/g,(t,e)=>Object.prototype.hasOwnProperty.call(i,e)?i[e]:`{${e}}`)}const r="power",o="5m",a={"5m":{ms:3e5,refreshMs:1e3,useRealtime:!0},"1h":{ms:36e5,refreshMs:3e4,useRealtime:!1},"1d":{ms:864e5,refreshMs:6e4,useRealtime:!1},"1w":{ms:6048e5,refreshMs:6e4,useRealtime:!1},"1M":{ms:2592e6,refreshMs:6e4,useRealtime:!1}},s="span_panel",l="CLOSED",c="pv",u="bess",h="evse",d="sub_",p=500,f={power:{entityRole:"power",label:()=>n("metric.power"),unit:t=>Math.abs(t)>=1e3?"kW":"W",format:t=>{const e=Math.abs(t);return e>=1e3?(e/1e3).toFixed(1):e<10&&e>0?e.toFixed(1):String(Math.round(e))}},current:{entityRole:"current",label:()=>n("metric.current"),unit:()=>"A",format:t=>Math.abs(t).toFixed(1)}},g={soc:{entityRole:"soc",label:()=>n("metric.soc"),unit:()=>"%",format:t=>String(Math.round(t)),fixedMin:0,fixedMax:100},soe:{entityRole:"soe",label:()=>n("metric.soe"),unit:()=>"kWh",format:t=>t.toFixed(1)},power:f.power},v={always_on:{icon:"mdi:battery",icon2:"mdi:router-wireless",color:"#4caf50",label:()=>n("shedding.always_on")},never:{icon:"mdi:battery",color:"#4caf50",label:()=>n("shedding.never")},soc_threshold:{icon:"mdi:battery-alert-variant-outline",color:"#9c27b0",label:()=>n("shedding.soc_threshold"),textLabel:"SoC"},off_grid:{icon:"mdi:transmission-tower",color:"#ff9800",label:()=>n("shedding.off_grid")},unknown:{icon:"mdi:help-circle-outline",color:"#888",label:()=>n("shedding.unknown")}};var m=function(t,e){return m=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&(t[n]=e[n])},m(t,e)};function y(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function n(){this.constructor=t}m(t,e),t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)}function _(t,e,n,i){var r,o=arguments.length,a=o<3?e:null===i?i=Object.getOwnPropertyDescriptor(e,n):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)a=Reflect.decorate(t,e,n,i);else for(var s=t.length-1;s>=0;s--)(r=t[s])&&(a=(o<3?r(a):o>3?r(e,n,a):r(e,n))||a);return o>3&&a&&Object.defineProperty(e,n,a),a}"function"==typeof SuppressedError&&SuppressedError; +let t="en";const e={en:{"tab.panel":"Panel","tab.by_panel":"By Panel","tab.by_activity":"By Activity","tab.by_area":"By Area","tab.monitoring":"Monitoring","tab.settings":"Settings","tab.adopted":"Adopted","list.search_placeholder":"Search circuits...","list.unassigned_area":"Unassigned","list.no_results":"No circuits found","monitoring.heading":"Monitoring","monitoring.global_settings":"Global Settings","monitoring.enabled":"Enabled","monitoring.continuous":"Continuous (%)","monitoring.spike":"Spike (%)","monitoring.window":"Window (min)","monitoring.cooldown":"Cooldown (min)","monitoring.monitored_points":"Monitored Points","monitoring.col.name":"Name","monitoring.col.continuous":"Continuous","monitoring.col.spike":"Spike","monitoring.col.window":"Window","monitoring.col.cooldown":"Cooldown","monitoring.all_none":"All / None","monitoring.reset":"Reset","notification.heading":"Notification Settings","notification.targets":"Notify Targets","notification.none_selected":"None selected","notification.no_targets":"No notify targets found","notification.all_targets":"All","notification.event_bus_target":"Event Bus (HA event bus)","notification.priority":"Priority","notification.priority.default":"Default","notification.priority.passive":"Passive","notification.priority.active":"Active","notification.priority.time_sensitive":"Time-sensitive","notification.priority.critical":"Critical","notification.hint.critical":"Overrides silent/DND","notification.hint.time_sensitive":"Breaks through Focus","notification.hint.passive":"Delivers silently","notification.hint.active":"Standard delivery","notification.title_template":"Title Template","notification.message_template":"Message Template","notification.placeholders":"Placeholders:","notification.event_bus_help":"Event Bus fires event type","notification.event_bus_payload":"with payload:","notification.test_label":"Test Notification","notification.test_button":"Send Test","notification.test_sending":"Sending...","notification.test_sent":"Test notification sent","error.prefix":"Error:","error.failed_save":"Failed to save","error.failed":"Failed","error.panel_offline":"SPAN Panel unreachable","error.panel_reconnected":"SPAN Panel reconnected","error.panel_offline_named":"{name} unreachable","error.panel_reconnected_named":"{name} reconnected","error.discovery_failed":"Unable to connect to SPAN Panel","error.relay_failed":"Unable to toggle relay","error.shedding_failed":"Unable to update shedding priority","error.threshold_failed":"Unable to save threshold","error.graph_horizon_failed":"Unable to update graph time horizon","error.favorites_fetch_failed":"Unable to load favorites","error.favorites_toggle_failed":"Unable to update favorite","error.history_failed":"Unable to load historical data","error.monitoring_failed":"Unable to load monitoring status","error.graph_settings_failed":"Unable to load graph settings","error.areas_failed":"Area assignments may be out of sync","error.retry":"Retry","card.connecting":"Connecting to SPAN Panel...","settings.heading":"Settings","settings.description":"General integration settings (entity naming, device prefix, circuit numbers) are managed through the integration's options flow.","settings.open_link":"Open SPAN Panel Integration Settings","adopted.heading":"Adopted entities","adopted.description":"Vendor readings and adopted devices arrive with minimal metadata. Curate one to set its device class, statistics class, and prominence — saved changes reload the integration and apply from the next startup on.","adopted.filter_placeholder":"Filter by entity or device name","adopted.no_results":"No adopted entities match this filter","adopted.none":"This panel publishes nothing to curate.","adopted.load_failed":"Unable to load adopted entities","adopted.count":"{count} adopted","adopted.vendor_readings":"VENDOR READINGS","adopted.adopted_device":"ADOPTED DEVICE","adopted.via_panel":"via SPAN Panel","adopted.curated":"CURATED","adopted.stale":"STALE","adopted.stale_note":"The panel no longer supports what was saved for: {fields}","adopted.enable_entity":"Enable entity","adopted.enabled":"ENABLED","adopted.disabled":"DISABLED","adopted.enable_note":"Enabled when saved — curating never enables on its own","adopted.registry_unavailable":"Enable, name, and icon become available once this entity exists in the registry.","adopted.name":"Name","adopted.icon":"Icon","adopted.device_class":"Device class","adopted.device_class_note":"choices limited by the panel's unit ({unit})","adopted.device_class_note_unitless":"choices limited by what the panel publishes","adopted.no_device_class":"No device class","adopted.statistics_class":"Statistics class","adopted.statistics_note":"long-term statistics begin after the next reload","adopted.no_statistics":"No statistics","adopted.prominence":"Prominence","adopted.diagnostic":"Diagnostic","adopted.standard":"Standard","adopted.display_unit":"Display unit","adopted.unit_as_published":"As published","adopted.precision":"Precision","adopted.precision_default":"Default","adopted.read_only":"read-only","adopted.settable":"settable","adopted.save":"Save","adopted.clear":"Clear curation","adopted.reload_note":"Saving reloads the SPAN Panel integration","adopted.saved":"Saved — the integration is reloading","adopted.confirm_heading":"Confirm statistics class","adopted.confirm_setting":"You are setting {name} to {value}.","adopted.confirm_clearing_subject":"You are clearing the statistics class on {name}.","adopted.confirm_total_increasing":"Total increasing treats every drop in the value as a meter reset. If this reading can decrease for any other reason, long-term statistics will be permanently corrupted — fixing the class later does not repair history already written.","adopted.confirm_clearing":"Long-term statistics stop being compiled for this entity, and Home Assistant raises a repair against the statistics already collected under the class you are removing.","adopted.save_anyway":"Save anyway","adopted.cancel":"Cancel","adopted.warn_total_increasing":"Saved as total increasing — every drop in the reading now counts as a meter reset.","adopted.warn_statistics_removed":"This entity has no statistics class any more; Home Assistant will raise a repair against the statistics already collected.","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Panel monitoring settings","header.graph_settings":"Graph time horizon settings","header.site":"Site","header.grid":"Grid","header.upstream":"Upstream","header.downstream":"Downstream","header.solar":"Solar","header.battery":"Battery","header.toggle_units":"Toggle Watts / Amps","header.enable_switches":"Enable Switches","header.switches_enabled":"Switches Enabled","grid.unknown":"Unknown","grid.configure":"Configure circuit","grid.configure_subdevice":"Configure device","grid.on":"On","grid.off":"Off","subdevice.ev_charger":"EV Charger","subdevice.battery":"Battery","subdevice.fallback":"Sub-device","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Power","sidepanel.graph_settings":"Graph Settings","sidepanel.global_defaults":"Global defaults for all circuits","sidepanel.favorites_subtitle":"Favorites","sidepanel.global_default":"Global Default","sidepanel.list_view_columns":"List View Columns","sidepanel.columns":"Columns","sidepanel.circuit_scales":"Circuit Graph Scales","sidepanel.subdevice_scales":"Sub-Device Graph Scales","sidepanel.reset_to_global":"Reset to global default","sidepanel.relay":"Relay","sidepanel.breaker":"Breaker","sidepanel.shedding_priority":"Shedding Priority","sidepanel.priority_label":"Priority","sidepanel.monitoring":"Monitoring","sidepanel.global":"Global","sidepanel.custom":"Custom","sidepanel.continuous_pct":"Continuous %","sidepanel.spike_pct":"Spike %","sidepanel.window_duration":"Window duration","sidepanel.cooldown":"Cooldown","sidepanel.favorite":"Favorite","sidepanel.save_to_favorites":"Save to favorites","panel.favorites":"Favorites","status.monitoring":"Monitoring","status.circuits":"circuits","status.mains":"mains","status.warning":"warning","status.warnings":"warnings","status.alert":"alert","status.alerts":"alerts","status.override":"override","status.overrides":"overrides","card.no_device":"Open the card editor and select your SPAN Panel device.","card.device_not_found":"Panel device not found. Check device_id in card config.","card.topology_error":"Topology response missing panel_size and no circuits found. Update the SPAN Panel integration.","card.panel_size_error":"Could not determine panel_size. No circuits found and no panel_size attribute. Update the SPAN Panel integration.","editor.panel_label":"SPAN Panel","editor.select_panel":"Select a panel...","editor.chart_window":"Chart time window","editor.days":"days","editor.hours":"hours","editor.minutes":"minutes","editor.chart_metric":"Chart metric","editor.visible_sections":"Visible sections","editor.panel_circuits":"Panel circuits","editor.battery_bess":"Battery (BESS)","editor.ev_charger_evse":"EV Charger (EVSE)","editor.tab_style":"Tab Style","editor.tab_style_text":"Text","editor.tab_style_icon":"Icon","metric.power":"Power","metric.current":"Current","metric.soc":"State of Charge","metric.soe":"State of Energy","shedding.always_on":"Critical","shedding.never":"Non-sheddable","shedding.soc_threshold":"SoC Threshold","shedding.off_grid":"Sheddable","shedding.unknown":"Unknown","shedding.select.never":"Stays on in an outage","shedding.select.soc_threshold":"Stays on until battery threshold","shedding.select.off_grid":"Turns off in an outage"},es:{"tab.panel":"Panel","tab.by_panel":"Por Panel","tab.by_activity":"Por Actividad","tab.by_area":"Por Área","tab.monitoring":"Monitoreo","tab.settings":"Configuración","tab.adopted":"Adoptadas","list.search_placeholder":"Buscar circuitos...","list.unassigned_area":"Sin asignar","list.no_results":"No se encontraron circuitos","monitoring.heading":"Monitoreo","monitoring.global_settings":"Configuración Global","monitoring.enabled":"Activado","monitoring.continuous":"Continuo (%)","monitoring.spike":"Pico (%)","monitoring.window":"Ventana (min)","monitoring.cooldown":"Enfriamiento (min)","monitoring.monitored_points":"Puntos Monitoreados","monitoring.col.name":"Nombre","monitoring.col.continuous":"Continuo","monitoring.col.spike":"Pico","monitoring.col.window":"Ventana","monitoring.col.cooldown":"Enfriamiento","monitoring.all_none":"Todos / Ninguno","monitoring.reset":"Restablecer","notification.heading":"Configuración de Notificaciones","notification.targets":"Destinos de Notificación","notification.none_selected":"Ninguno seleccionado","notification.no_targets":"No se encontraron destinos de notificación","notification.all_targets":"Todos","notification.event_bus_target":"Bus de Eventos (bus de eventos de HA)","notification.priority":"Prioridad","notification.priority.default":"Predeterminado","notification.priority.passive":"Pasivo","notification.priority.active":"Activo","notification.priority.time_sensitive":"Urgente","notification.priority.critical":"Crítico","notification.hint.critical":"Anula silencio/No molestar","notification.hint.time_sensitive":"Atraviesa el modo Concentración","notification.hint.passive":"Entrega silenciosa","notification.hint.active":"Entrega estándar","notification.title_template":"Plantilla de Título","notification.message_template":"Plantilla de Mensaje","notification.placeholders":"Variables:","notification.event_bus_help":"El Bus de Eventos dispara el tipo de evento","notification.event_bus_payload":"con datos:","notification.test_label":"Notificación de prueba","notification.test_button":"Enviar prueba","notification.test_sending":"Enviando...","notification.test_sent":"Notificación de prueba enviada","error.prefix":"Error:","error.failed_save":"Error al guardar","error.failed":"Falló","error.panel_offline":"SPAN Panel inaccesible","error.panel_reconnected":"SPAN Panel reconectado","error.panel_offline_named":"{name} inaccesible","error.panel_reconnected_named":"{name} reconectado","error.discovery_failed":"No se puede conectar al SPAN Panel","error.relay_failed":"No se pudo cambiar el relé","error.shedding_failed":"No se pudo actualizar la prioridad de desconexión","error.threshold_failed":"No se pudo guardar el umbral","error.graph_horizon_failed":"No se pudo actualizar el horizonte temporal del gráfico","error.favorites_fetch_failed":"No se pudieron cargar los favoritos","error.favorites_toggle_failed":"No se pudo actualizar el favorito","error.history_failed":"No se pudieron cargar los datos históricos","error.monitoring_failed":"No se pudo cargar el estado de monitoreo","error.graph_settings_failed":"No se pudo cargar la configuración del gráfico","error.areas_failed":"Las asignaciones de áreas pueden estar desincronizadas","error.retry":"Reintentar","card.connecting":"Conectando al SPAN Panel...","settings.heading":"Configuración","settings.description":"La configuración general de la integración (nombres de entidades, prefijo de dispositivo, números de circuito) se administra a través del flujo de opciones de la integración.","settings.open_link":"Abrir Configuración de Integración SPAN Panel","adopted.heading":"Entidades adoptadas","adopted.description":"Las lecturas del proveedor y los dispositivos adoptados llegan con metadatos mínimos. Cura una para definir su clase de dispositivo, clase de estadísticas y prominencia — los cambios guardados recargan la integración y se aplican desde el siguiente arranque.","adopted.filter_placeholder":"Filtrar por nombre de entidad o dispositivo","adopted.no_results":"Ninguna entidad adoptada coincide con este filtro","adopted.none":"Este panel no publica nada que curar.","adopted.load_failed":"No se pudieron cargar las entidades adoptadas","adopted.count":"{count} adoptadas","adopted.vendor_readings":"LECTURAS DEL PROVEEDOR","adopted.adopted_device":"DISPOSITIVO ADOPTADO","adopted.via_panel":"vía SPAN Panel","adopted.curated":"CURADA","adopted.stale":"OBSOLETA","adopted.stale_note":"El panel ya no admite lo guardado para: {fields}","adopted.enable_entity":"Activar entidad","adopted.enabled":"ACTIVADA","adopted.disabled":"DESACTIVADA","adopted.enable_note":"Se activa al guardar — curar nunca activa por sí solo","adopted.registry_unavailable":"Activación, nombre e icono estarán disponibles cuando esta entidad exista en el registro.","adopted.name":"Nombre","adopted.icon":"Icono","adopted.device_class":"Clase de dispositivo","adopted.device_class_note":"opciones limitadas por la unidad del panel ({unit})","adopted.device_class_note_unitless":"opciones limitadas por lo que publica el panel","adopted.no_device_class":"Sin clase de dispositivo","adopted.statistics_class":"Clase de estadísticas","adopted.statistics_note":"las estadísticas a largo plazo comienzan tras la próxima recarga","adopted.no_statistics":"Sin estadísticas","adopted.prominence":"Prominencia","adopted.diagnostic":"Diagnóstico","adopted.standard":"Estándar","adopted.display_unit":"Unidad mostrada","adopted.unit_as_published":"Tal como se publica","adopted.precision":"Precisión","adopted.precision_default":"Predeterminada","adopted.read_only":"solo lectura","adopted.settable":"editable","adopted.save":"Guardar","adopted.clear":"Borrar curación","adopted.reload_note":"Guardar recarga la integración SPAN Panel","adopted.saved":"Guardado — la integración se está recargando","adopted.confirm_heading":"Confirmar clase de estadísticas","adopted.confirm_setting":"Vas a definir {name} como {value}.","adopted.confirm_clearing_subject":"Vas a borrar la clase de estadísticas de {name}.","adopted.confirm_total_increasing":"Total creciente interpreta cada caída del valor como un reinicio del contador. Si esta lectura puede disminuir por cualquier otro motivo, las estadísticas a largo plazo quedarán corrompidas de forma permanente — corregir la clase más tarde no repara el historial ya escrito.","adopted.confirm_clearing":"Dejarán de compilarse estadísticas a largo plazo para esta entidad, y Home Assistant abrirá una reparación sobre las estadísticas ya recogidas bajo la clase que estás quitando.","adopted.save_anyway":"Guardar de todos modos","adopted.cancel":"Cancelar","adopted.warn_total_increasing":"Guardado como total creciente — cada caída de la lectura cuenta ahora como un reinicio del contador.","adopted.warn_statistics_removed":"Esta entidad ya no tiene clase de estadísticas; Home Assistant abrirá una reparación sobre las estadísticas ya recogidas.","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Configuración de monitoreo del panel","header.graph_settings":"Configuración del horizonte temporal del gráfico","header.site":"Sitio","header.grid":"Red","header.upstream":"Aguas arriba","header.downstream":"Aguas abajo","header.solar":"Solar","header.battery":"Batería","header.toggle_units":"Alternar Watts / Amperios","header.enable_switches":"Habilitar Interruptores","header.switches_enabled":"Interruptores Habilitados","grid.unknown":"Desconocido","grid.configure":"Configurar circuito","grid.configure_subdevice":"Configurar dispositivo","grid.on":"Enc","grid.off":"Apag","subdevice.ev_charger":"Cargador EV","subdevice.battery":"Batería","subdevice.fallback":"Sub-dispositivo","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Potencia","sidepanel.graph_settings":"Configuración de Gráficos","sidepanel.global_defaults":"Valores predeterminados globales para todos los circuitos","sidepanel.favorites_subtitle":"Favoritos","sidepanel.global_default":"Predeterminado Global","sidepanel.list_view_columns":"Columnas de la lista","sidepanel.columns":"Columnas","sidepanel.circuit_scales":"Escalas de Gráficos de Circuitos","sidepanel.subdevice_scales":"Escalas de Gráficos de Sub-Dispositivos","sidepanel.reset_to_global":"Restablecer al valor global","sidepanel.relay":"Relé","sidepanel.breaker":"Interruptor","sidepanel.shedding_priority":"Prioridad de Desconexción","sidepanel.priority_label":"Prioridad","sidepanel.monitoring":"Monitoreo","sidepanel.global":"Global","sidepanel.custom":"Personalizado","sidepanel.continuous_pct":"Continuo %","sidepanel.spike_pct":"Pico %","sidepanel.window_duration":"Duración de ventana","sidepanel.cooldown":"Enfriamiento","sidepanel.favorite":"Favorito","sidepanel.save_to_favorites":"Guardar en favoritos","panel.favorites":"Favoritos","status.monitoring":"Monitoreo","status.circuits":"circuitos","status.mains":"alimentación","status.warning":"advertencia","status.warnings":"advertencias","status.alert":"alerta","status.alerts":"alertas","status.override":"anulación","status.overrides":"anulaciones","card.no_device":"Abra el editor de tarjeta y seleccione su dispositivo SPAN Panel.","card.device_not_found":"Dispositivo de panel no encontrado. Verifique device_id en la configuración de la tarjeta.","card.topology_error":"La respuesta de topología no contiene panel_size y no se encontraron circuitos. Actualice la integración SPAN Panel.","card.panel_size_error":"No se pudo determinar panel_size. No se encontraron circuitos ni atributo panel_size. Actualice la integración SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Seleccione un panel...","editor.chart_window":"Ventana de tiempo del gráfico","editor.days":"días","editor.hours":"horas","editor.minutes":"minutos","editor.chart_metric":"Métrica del gráfico","editor.visible_sections":"Secciones visibles","editor.panel_circuits":"Circuitos del panel","editor.battery_bess":"Batería (BESS)","editor.ev_charger_evse":"Cargador EV (EVSE)","editor.tab_style":"Estilo de pestañas","editor.tab_style_text":"Texto","editor.tab_style_icon":"Ícono","metric.power":"Potencia","metric.current":"Corriente","metric.soc":"Estado de Carga","metric.soe":"Estado de Energía","shedding.always_on":"Crítico","shedding.never":"No desconectable","shedding.soc_threshold":"Umbral SoC","shedding.off_grid":"Desconectable","shedding.unknown":"Desconocido","shedding.select.never":"Permanece encendido en un corte","shedding.select.soc_threshold":"Encendido hasta umbral de batería","shedding.select.off_grid":"Se apaga en un corte"},fr:{"tab.panel":"Panneau","tab.by_panel":"Par Panneau","tab.by_activity":"Par Activité","tab.by_area":"Par Zone","tab.monitoring":"Surveillance","tab.settings":"Paramètres","tab.adopted":"Adoptées","list.search_placeholder":"Rechercher des circuits...","list.unassigned_area":"Non attribué","list.no_results":"Aucun circuit trouvé","monitoring.heading":"Surveillance","monitoring.global_settings":"Paramètres Globaux","monitoring.enabled":"Activé","monitoring.continuous":"Continu (%)","monitoring.spike":"Pic (%)","monitoring.window":"Fenêtre (min)","monitoring.cooldown":"Refroidissement (min)","monitoring.monitored_points":"Points Surveillés","monitoring.col.name":"Nom","monitoring.col.continuous":"Continu","monitoring.col.spike":"Pic","monitoring.col.window":"Fenêtre","monitoring.col.cooldown":"Refroidissement","monitoring.all_none":"Tous / Aucun","monitoring.reset":"Réinitialiser","notification.heading":"Paramètres de Notification","notification.targets":"Cibles de Notification","notification.none_selected":"Aucune sélection","notification.no_targets":"Aucune cible de notification trouvée","notification.all_targets":"Tous","notification.event_bus_target":"Bus d'événements (bus d'événements HA)","notification.priority":"Priorité","notification.priority.default":"Par défaut","notification.priority.passive":"Passif","notification.priority.active":"Actif","notification.priority.time_sensitive":"Urgent","notification.priority.critical":"Critique","notification.hint.critical":"Outrepasse silencieux/NPD","notification.hint.time_sensitive":"Traverse le mode Concentration","notification.hint.passive":"Livraison silencieuse","notification.hint.active":"Livraison standard","notification.title_template":"Modèle de Titre","notification.message_template":"Modèle de Message","notification.placeholders":"Variables :","notification.event_bus_help":"Le Bus d'événements déclenche le type d'événement","notification.event_bus_payload":"avec les données :","notification.test_label":"Notification de test","notification.test_button":"Envoyer un test","notification.test_sending":"Envoi...","notification.test_sent":"Notification de test envoyée","error.prefix":"Erreur :","error.failed_save":"Échec de la sauvegarde","error.failed":"Échoué","error.panel_offline":"SPAN Panel inaccessible","error.panel_reconnected":"SPAN Panel reconnecté","error.panel_offline_named":"{name} inaccessible","error.panel_reconnected_named":"{name} reconnecté","error.discovery_failed":"Impossible de se connecter au SPAN Panel","error.relay_failed":"Impossible de basculer le relais","error.shedding_failed":"Impossible de mettre à jour la priorité de délestage","error.threshold_failed":"Impossible d'enregistrer le seuil","error.graph_horizon_failed":"Impossible de mettre à jour l'horizon temporel du graphique","error.favorites_fetch_failed":"Impossible de charger les favoris","error.favorites_toggle_failed":"Impossible de mettre à jour le favori","error.history_failed":"Impossible de charger les données historiques","error.monitoring_failed":"Impossible de charger l'état de surveillance","error.graph_settings_failed":"Impossible de charger les paramètres du graphique","error.areas_failed":"Les affectations de zones peuvent être désynchronisées","error.retry":"Réessayer","card.connecting":"Connexion au SPAN Panel...","settings.heading":"Paramètres","settings.description":"Les paramètres généraux de l'intégration (noms d'entités, préfixe de l'appareil, numéros de circuit) sont gérés via le flux d'options de l'intégration.","settings.open_link":"Ouvrir les Paramètres d'Intégration SPAN Panel","adopted.heading":"Entités adoptées","adopted.description":"Les relevés du fournisseur et les appareils adoptés arrivent avec des métadonnées minimales. Curez-en un pour définir sa classe d'appareil, sa classe de statistiques et sa proéminence — les modifications enregistrées rechargent l'intégration et s'appliquent dès le prochain démarrage.","adopted.filter_placeholder":"Filtrer par nom d'entité ou d'appareil","adopted.no_results":"Aucune entité adoptée ne correspond à ce filtre","adopted.none":"Ce panneau ne publie rien à curer.","adopted.load_failed":"Impossible de charger les entités adoptées","adopted.count":"{count} adoptées","adopted.vendor_readings":"RELEVÉS DU FOURNISSEUR","adopted.adopted_device":"APPAREIL ADOPTÉ","adopted.via_panel":"via SPAN Panel","adopted.curated":"CURÉE","adopted.stale":"OBSOLÈTE","adopted.stale_note":"Le panneau ne prend plus en charge ce qui a été enregistré pour : {fields}","adopted.enable_entity":"Activer l'entité","adopted.enabled":"ACTIVÉE","adopted.disabled":"DÉSACTIVÉE","adopted.enable_note":"Activée à l'enregistrement — la curation n'active jamais d'elle-même","adopted.registry_unavailable":"L'activation, le nom et l'icône seront disponibles dès que cette entité existera dans le registre.","adopted.name":"Nom","adopted.icon":"Icône","adopted.device_class":"Classe d'appareil","adopted.device_class_note":"choix limités par l'unité du panneau ({unit})","adopted.device_class_note_unitless":"choix limités par ce que le panneau publie","adopted.no_device_class":"Aucune classe d'appareil","adopted.statistics_class":"Classe de statistiques","adopted.statistics_note":"les statistiques à long terme commencent après le prochain rechargement","adopted.no_statistics":"Aucune statistique","adopted.prominence":"Proéminence","adopted.diagnostic":"Diagnostic","adopted.standard":"Standard","adopted.display_unit":"Unité affichée","adopted.unit_as_published":"Telle que publiée","adopted.precision":"Précision","adopted.precision_default":"Par défaut","adopted.read_only":"lecture seule","adopted.settable":"modifiable","adopted.save":"Enregistrer","adopted.clear":"Effacer la curation","adopted.reload_note":"L'enregistrement recharge l'intégration SPAN Panel","adopted.saved":"Enregistré — l'intégration se recharge","adopted.confirm_heading":"Confirmer la classe de statistiques","adopted.confirm_setting":"Vous définissez {name} sur {value}.","adopted.confirm_clearing_subject":"Vous effacez la classe de statistiques de {name}.","adopted.confirm_total_increasing":"Total croissant interprète chaque baisse de la valeur comme une remise à zéro du compteur. Si ce relevé peut diminuer pour une autre raison, les statistiques à long terme seront corrompues de façon permanente — corriger la classe plus tard ne répare pas l'historique déjà écrit.","adopted.confirm_clearing":"Les statistiques à long terme cesseront d'être compilées pour cette entité, et Home Assistant ouvrira une réparation sur les statistiques déjà collectées sous la classe que vous retirez.","adopted.save_anyway":"Enregistrer quand même","adopted.cancel":"Annuler","adopted.warn_total_increasing":"Enregistré en total croissant — chaque baisse du relevé compte désormais comme une remise à zéro du compteur.","adopted.warn_statistics_removed":"Cette entité n'a plus de classe de statistiques ; Home Assistant ouvrira une réparation sur les statistiques déjà collectées.","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Paramètres de surveillance du panneau","header.graph_settings":"Paramètres d'horizon temporel du graphique","header.site":"Site","header.grid":"Réseau","header.upstream":"Amont","header.downstream":"Aval","header.solar":"Solaire","header.battery":"Batterie","header.toggle_units":"Basculer Watts / Ampères","header.enable_switches":"Activer les interrupteurs","header.switches_enabled":"Interrupteurs activés","grid.unknown":"Inconnu","grid.configure":"Configurer le circuit","grid.configure_subdevice":"Configurer l'appareil","grid.on":"On","grid.off":"Off","subdevice.ev_charger":"Chargeur VE","subdevice.battery":"Batterie","subdevice.fallback":"Sous-appareil","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Puissance","sidepanel.graph_settings":"Paramètres des Graphiques","sidepanel.global_defaults":"Valeurs par défaut globales pour tous les circuits","sidepanel.favorites_subtitle":"Favoris","sidepanel.global_default":"Défaut Global","sidepanel.list_view_columns":"Colonnes de la liste","sidepanel.columns":"Colonnes","sidepanel.circuit_scales":"Échelles des Graphiques de Circuits","sidepanel.subdevice_scales":"Échelles des Graphiques de Sous-Appareils","sidepanel.reset_to_global":"Réinitialiser à la valeur globale","sidepanel.relay":"Relais","sidepanel.breaker":"Disjoncteur","sidepanel.shedding_priority":"Priorité de Délestage","sidepanel.priority_label":"Priorité","sidepanel.monitoring":"Surveillance","sidepanel.global":"Global","sidepanel.custom":"Personnalisé","sidepanel.continuous_pct":"Continu %","sidepanel.spike_pct":"Pic %","sidepanel.window_duration":"Durée de fenêtre","sidepanel.cooldown":"Refroidissement","sidepanel.favorite":"Favori","sidepanel.save_to_favorites":"Enregistrer dans les favoris","panel.favorites":"Favoris","status.monitoring":"Surveillance","status.circuits":"circuits","status.mains":"alimentation","status.warning":"avertissement","status.warnings":"avertissements","status.alert":"alerte","status.alerts":"alertes","status.override":"remplacement","status.overrides":"remplacements","card.no_device":"Ouvrez l'éditeur de carte et sélectionnez votre appareil SPAN Panel.","card.device_not_found":"Appareil de panneau introuvable. Vérifiez device_id dans la configuration de la carte.","card.topology_error":"La réponse de topologie ne contient pas panel_size et aucun circuit trouvé. Mettez à jour l'intégration SPAN Panel.","card.panel_size_error":"Impossible de déterminer panel_size. Aucun circuit trouvé et aucun attribut panel_size. Mettez à jour l'intégration SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Sélectionnez un panneau...","editor.chart_window":"Fenêtre de temps du graphique","editor.days":"jours","editor.hours":"heures","editor.minutes":"minutes","editor.chart_metric":"Métrique du graphique","editor.visible_sections":"Sections visibles","editor.panel_circuits":"Circuits du panneau","editor.battery_bess":"Batterie (BESS)","editor.ev_charger_evse":"Chargeur VE (EVSE)","editor.tab_style":"Style des onglets","editor.tab_style_text":"Texte","editor.tab_style_icon":"Icône","metric.power":"Puissance","metric.current":"Courant","metric.soc":"État de Charge","metric.soe":"État d'Énergie","shedding.always_on":"Critique","shedding.never":"Non délestable","shedding.soc_threshold":"Seuil SoC","shedding.off_grid":"Délestable","shedding.unknown":"Inconnu","shedding.select.never":"Reste allumé en cas de coupure","shedding.select.soc_threshold":"Allumé jusqu'au seuil batterie","shedding.select.off_grid":"S'éteint en cas de coupure"},ja:{"tab.panel":"パネル","tab.by_panel":"パネル別","tab.by_activity":"活動別","tab.by_area":"エリア別","tab.monitoring":"モニタリング","tab.settings":"設定","tab.adopted":"採用済み","list.search_placeholder":"回路を検索...","list.unassigned_area":"未割り当て","list.no_results":"回路が見つかりません","monitoring.heading":"モニタリング","monitoring.global_settings":"グローバル設定","monitoring.enabled":"有効","monitoring.continuous":"継続 (%)","monitoring.spike":"スパイク (%)","monitoring.window":"ウィンドウ (分)","monitoring.cooldown":"クールダウン (分)","monitoring.monitored_points":"監視ポイント","monitoring.col.name":"名前","monitoring.col.continuous":"継続","monitoring.col.spike":"スパイク","monitoring.col.window":"ウィンドウ","monitoring.col.cooldown":"クールダウン","monitoring.all_none":"全選択 / 全解除","monitoring.reset":"リセット","notification.heading":"通知設定","notification.targets":"通知先","notification.none_selected":"未選択","notification.no_targets":"通知先が見つかりません","notification.all_targets":"すべて","notification.event_bus_target":"イベントバス (HAイベントバス)","notification.priority":"優先度","notification.priority.default":"デフォルト","notification.priority.passive":"パッシブ","notification.priority.active":"アクティブ","notification.priority.time_sensitive":"緊急","notification.priority.critical":"重大","notification.hint.critical":"サイレント/おやすみモードを無視","notification.hint.time_sensitive":"集中モードを突破","notification.hint.passive":"サイレント配信","notification.hint.active":"標準配信","notification.title_template":"タイトルテンプレート","notification.message_template":"メッセージテンプレート","notification.placeholders":"プレースホルダー:","notification.event_bus_help":"イベントバスが発行するイベントタイプ","notification.event_bus_payload":"ペイロード:","notification.test_label":"テスト通知","notification.test_button":"テスト送信","notification.test_sending":"送信中...","notification.test_sent":"テスト通知を送信しました","error.prefix":"エラー:","error.failed_save":"保存に失敗","error.failed":"失敗","error.panel_offline":"SPANパネルに接続できません","error.panel_reconnected":"SPANパネルが再接続されました","error.panel_offline_named":"{name}に接続できません","error.panel_reconnected_named":"{name}が再接続されました","error.discovery_failed":"SPANパネルへの接続に失敗しました","error.relay_failed":"リレーの切り替えに失敗しました","error.shedding_failed":"シェディング優先度の更新に失敗しました","error.threshold_failed":"しきい値の保存に失敗しました","error.graph_horizon_failed":"グラフの時間範囲の更新に失敗しました","error.favorites_fetch_failed":"お気に入りの読み込みに失敗しました","error.favorites_toggle_failed":"お気に入りの更新に失敗しました","error.history_failed":"履歴データの読み込みに失敗しました","error.monitoring_failed":"監視ステータスの読み込みに失敗しました","error.graph_settings_failed":"グラフ設定の読み込みに失敗しました","error.areas_failed":"エリア割り当てが同期されていない可能性があります","error.retry":"再試行","card.connecting":"SPANパネルに接続中...","settings.heading":"設定","settings.description":"統合の一般設定(エンティティ名、デバイスプレフィックス、回路番号)は統合のオプションフローで管理されます。","settings.open_link":"SPAN Panel統合設定を開く","adopted.heading":"採用済みエンティティ","adopted.description":"ベンダーの測定値と採用済みデバイスは最小限のメタデータで登録されます。キュレーションでデバイスクラス、統計クラス、表示区分を設定できます。保存すると統合が再読み込みされ、次回の起動から適用されます。","adopted.filter_placeholder":"エンティティ名またはデバイス名で絞り込み","adopted.no_results":"この条件に一致する採用済みエンティティはありません","adopted.none":"このパネルにキュレーション対象はありません。","adopted.load_failed":"採用済みエンティティを読み込めません","adopted.count":"{count} 件","adopted.vendor_readings":"ベンダー測定値","adopted.adopted_device":"採用済みデバイス","adopted.via_panel":"SPAN Panel 経由","adopted.curated":"キュレーション済み","adopted.stale":"無効","adopted.stale_note":"パネルは保存された次の項目をサポートしなくなりました: {fields}","adopted.enable_entity":"エンティティを有効化","adopted.enabled":"有効","adopted.disabled":"無効","adopted.enable_note":"保存時に有効化されます — キュレーション自体が有効化することはありません","adopted.registry_unavailable":"有効化・名前・アイコンは、このエンティティがレジストリに登録された後に利用できます。","adopted.name":"名前","adopted.icon":"アイコン","adopted.device_class":"デバイスクラス","adopted.device_class_note":"パネルの単位({unit})により選択肢が制限されます","adopted.device_class_note_unitless":"パネルが公開する内容により選択肢が制限されます","adopted.no_device_class":"デバイスクラスなし","adopted.statistics_class":"統計クラス","adopted.statistics_note":"長期統計は次回の再読み込み後に開始されます","adopted.no_statistics":"統計なし","adopted.prominence":"表示区分","adopted.diagnostic":"診断","adopted.standard":"標準","adopted.display_unit":"表示単位","adopted.unit_as_published":"公開されたまま","adopted.precision":"小数点以下桁数","adopted.precision_default":"既定","adopted.read_only":"読み取り専用","adopted.settable":"書き込み可","adopted.save":"保存","adopted.clear":"キュレーションを消去","adopted.reload_note":"保存すると SPAN Panel 統合が再読み込みされます","adopted.saved":"保存しました — 統合を再読み込みしています","adopted.confirm_heading":"統計クラスの確認","adopted.confirm_setting":"{name} を {value} に設定しようとしています。","adopted.confirm_clearing_subject":"{name} の統計クラスを消去しようとしています。","adopted.confirm_total_increasing":"積算増加は値の低下をすべてメーターのリセットとして扱います。この測定値が他の理由でも下がる場合、長期統計は恒久的に破損します。後からクラスを直しても、既に書き込まれた履歴は修復されません。","adopted.confirm_clearing":"このエンティティの長期統計は収集されなくなり、削除するクラスの下で既に収集された統計について Home Assistant が修復項目を作成します。","adopted.save_anyway":"それでも保存","adopted.cancel":"キャンセル","adopted.warn_total_increasing":"積算増加として保存しました — 測定値の低下はすべてメーターのリセットとして数えられます。","adopted.warn_statistics_removed":"このエンティティに統計クラスはなくなりました。既に収集された統計について Home Assistant が修復項目を作成します。","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"パネルモニタリング設定","header.graph_settings":"グラフ時間範囲設定","header.site":"サイト","header.grid":"グリッド","header.upstream":"上流","header.downstream":"下流","header.solar":"ソーラー","header.battery":"バッテリー","header.toggle_units":"ワット/アンペア切り替え","header.enable_switches":"スイッチを有効化","header.switches_enabled":"スイッチ有効","grid.unknown":"不明","grid.configure":"回路を設定","grid.configure_subdevice":"デバイスを設定","grid.on":"オン","grid.off":"オフ","subdevice.ev_charger":"EV充電器","subdevice.battery":"バッテリー","subdevice.fallback":"サブデバイス","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"電力","sidepanel.graph_settings":"グラフ設定","sidepanel.global_defaults":"全回路のグローバルデフォルト","sidepanel.favorites_subtitle":"お気に入り","sidepanel.global_default":"グローバルデフォルト","sidepanel.list_view_columns":"リスト表示の列数","sidepanel.columns":"列","sidepanel.circuit_scales":"回路グラフスケール","sidepanel.subdevice_scales":"サブデバイスグラフスケール","sidepanel.reset_to_global":"グローバルにリセット","sidepanel.relay":"リレー","sidepanel.breaker":"ブレーカー","sidepanel.shedding_priority":"シェディング優先度","sidepanel.priority_label":"優先度","sidepanel.monitoring":"モニタリング","sidepanel.global":"グローバル","sidepanel.custom":"カスタム","sidepanel.continuous_pct":"継続 %","sidepanel.spike_pct":"スパイク %","sidepanel.window_duration":"ウィンドウ時間","sidepanel.cooldown":"クールダウン","sidepanel.favorite":"お気に入り","sidepanel.save_to_favorites":"お気に入りに保存","panel.favorites":"お気に入り","status.monitoring":"モニタリング","status.circuits":"回路","status.mains":"主電源","status.warning":"警告","status.warnings":"警告","status.alert":"アラート","status.alerts":"アラート","status.override":"上書き","status.overrides":"上書き","card.no_device":"カードエディタを開いてSPAN Panelデバイスを選択してください。","card.device_not_found":"パネルデバイスが見つかりません。カード設定のdevice_idを確認してください。","card.topology_error":"トポロジー応答にpanel_sizeがなく、回路が見つかりません。SPAN Panel統合を更新してください。","card.panel_size_error":"panel_sizeを判定できません。回路がpanel_size属性が見つかりません。SPAN Panel統合を更新してください。","editor.panel_label":"SPAN Panel","editor.select_panel":"パネルを選択...","editor.chart_window":"グラフ時間ウィンドウ","editor.days":"日","editor.hours":"時間","editor.minutes":"分","editor.chart_metric":"グラフ指標","editor.visible_sections":"表示セクション","editor.panel_circuits":"パネル回路","editor.battery_bess":"バッテリー (BESS)","editor.ev_charger_evse":"EV充電器 (EVSE)","editor.tab_style":"タブスタイル","editor.tab_style_text":"テキスト","editor.tab_style_icon":"アイコン","metric.power":"電力","metric.current":"電流","metric.soc":"充電状態","metric.soe":"エネルギー状態","shedding.always_on":"重要","shedding.never":"切断不可","shedding.soc_threshold":"SoCしきい値","shedding.off_grid":"切断可能","shedding.unknown":"不明","shedding.select.never":"停電時もオンを維持","shedding.select.soc_threshold":"バッテリーしきい値までオン","shedding.select.off_grid":"停電時にオフ"},pt:{"tab.panel":"Painel","tab.by_panel":"Por Painel","tab.by_activity":"Por Atividade","tab.by_area":"Por Área","tab.monitoring":"Monitoramento","tab.settings":"Configurações","tab.adopted":"Adotadas","list.search_placeholder":"Pesquisar circuitos...","list.unassigned_area":"Não atribuído","list.no_results":"Nenhum circuito encontrado","monitoring.heading":"Monitoramento","monitoring.global_settings":"Configurações Globais","monitoring.enabled":"Ativado","monitoring.continuous":"Contínuo (%)","monitoring.spike":"Pico (%)","monitoring.window":"Janela (min)","monitoring.cooldown":"Resfriamento (min)","monitoring.monitored_points":"Pontos Monitorados","monitoring.col.name":"Nome","monitoring.col.continuous":"Contínuo","monitoring.col.spike":"Pico","monitoring.col.window":"Janela","monitoring.col.cooldown":"Resfriamento","monitoring.all_none":"Todos / Nenhum","monitoring.reset":"Redefinir","notification.heading":"Configurações de Notificação","notification.targets":"Destinos de Notificação","notification.none_selected":"Nenhum selecionado","notification.no_targets":"Nenhum destino de notificação encontrado","notification.all_targets":"Todos","notification.event_bus_target":"Barramento de Eventos (barramento de eventos do HA)","notification.priority":"Prioridade","notification.priority.default":"Padrão","notification.priority.passive":"Passivo","notification.priority.active":"Ativo","notification.priority.time_sensitive":"Urgente","notification.priority.critical":"Crítico","notification.hint.critical":"Substitui silencioso/Não perturbar","notification.hint.time_sensitive":"Atravessa o modo Foco","notification.hint.passive":"Entrega silenciosa","notification.hint.active":"Entrega padrão","notification.title_template":"Modelo de Título","notification.message_template":"Modelo de Mensagem","notification.placeholders":"Variáveis:","notification.event_bus_help":"O Barramento de Eventos dispara o tipo de evento","notification.event_bus_payload":"com dados:","notification.test_label":"Notificação de teste","notification.test_button":"Enviar teste","notification.test_sending":"Enviando...","notification.test_sent":"Notificação de teste enviada","error.prefix":"Erro:","error.failed_save":"Falha ao salvar","error.failed":"Falhou","error.panel_offline":"SPAN Panel inacessível","error.panel_reconnected":"SPAN Panel reconectado","error.panel_offline_named":"{name} inacessível","error.panel_reconnected_named":"{name} reconectado","error.discovery_failed":"Não foi possível conectar ao SPAN Panel","error.relay_failed":"Não foi possível alternar o relé","error.shedding_failed":"Não foi possível atualizar a prioridade de desligamento","error.threshold_failed":"Não foi possível salvar o limite","error.graph_horizon_failed":"Não foi possível atualizar o horizonte temporal do gráfico","error.favorites_fetch_failed":"Não foi possível carregar os favoritos","error.favorites_toggle_failed":"Não foi possível atualizar o favorito","error.history_failed":"Não foi possível carregar os dados históricos","error.monitoring_failed":"Não foi possível carregar o status de monitoramento","error.graph_settings_failed":"Não foi possível carregar as configurações do gráfico","error.areas_failed":"As atribuições de áreas podem estar fora de sincronização","error.retry":"Tentar novamente","card.connecting":"Conectando ao SPAN Panel...","settings.heading":"Configurações","settings.description":"As configurações gerais da integração (nomes de entidades, prefixo do dispositivo, números de circuito) são gerenciadas através do fluxo de opções da integração.","settings.open_link":"Abrir Configurações de Integração SPAN Panel","adopted.heading":"Entidades adotadas","adopted.description":"As leituras do fornecedor e os dispositivos adotados chegam com metadados mínimos. Faça a curadoria de uma para definir sua classe de dispositivo, classe de estatísticas e proeminência — alterações salvas recarregam a integração e valem a partir da próxima inicialização.","adopted.filter_placeholder":"Filtrar por nome de entidade ou dispositivo","adopted.no_results":"Nenhuma entidade adotada corresponde a este filtro","adopted.none":"Este painel não publica nada para curadoria.","adopted.load_failed":"Não foi possível carregar as entidades adotadas","adopted.count":"{count} adotadas","adopted.vendor_readings":"LEITURAS DO FORNECEDOR","adopted.adopted_device":"DISPOSITIVO ADOTADO","adopted.via_panel":"via SPAN Panel","adopted.curated":"CURADA","adopted.stale":"OBSOLETA","adopted.stale_note":"O painel não suporta mais o que foi salvo para: {fields}","adopted.enable_entity":"Ativar entidade","adopted.enabled":"ATIVADA","adopted.disabled":"DESATIVADA","adopted.enable_note":"Ativada ao salvar — a curadoria nunca ativa por conta própria","adopted.registry_unavailable":"Ativação, nome e ícone ficam disponíveis assim que esta entidade existir no registro.","adopted.name":"Nome","adopted.icon":"Ícone","adopted.device_class":"Classe de dispositivo","adopted.device_class_note":"opções limitadas pela unidade do painel ({unit})","adopted.device_class_note_unitless":"opções limitadas pelo que o painel publica","adopted.no_device_class":"Sem classe de dispositivo","adopted.statistics_class":"Classe de estatísticas","adopted.statistics_note":"as estatísticas de longo prazo começam após a próxima recarga","adopted.no_statistics":"Sem estatísticas","adopted.prominence":"Proeminência","adopted.diagnostic":"Diagnóstico","adopted.standard":"Padrão","adopted.display_unit":"Unidade exibida","adopted.unit_as_published":"Como publicada","adopted.precision":"Precisão","adopted.precision_default":"Padrão","adopted.read_only":"somente leitura","adopted.settable":"editável","adopted.save":"Salvar","adopted.clear":"Limpar curadoria","adopted.reload_note":"Salvar recarrega a integração SPAN Panel","adopted.saved":"Salvo — a integração está recarregando","adopted.confirm_heading":"Confirmar classe de estatísticas","adopted.confirm_setting":"Você está definindo {name} como {value}.","adopted.confirm_clearing_subject":"Você está limpando a classe de estatísticas de {name}.","adopted.confirm_total_increasing":"Total crescente trata toda queda do valor como uma reinicialização do medidor. Se esta leitura puder diminuir por qualquer outro motivo, as estatísticas de longo prazo ficarão permanentemente corrompidas — corrigir a classe depois não repara o histórico já gravado.","adopted.confirm_clearing":"As estatísticas de longo prazo deixarão de ser compiladas para esta entidade, e o Home Assistant abrirá um reparo sobre as estatísticas já coletadas na classe que você está removendo.","adopted.save_anyway":"Salvar mesmo assim","adopted.cancel":"Cancelar","adopted.warn_total_increasing":"Salvo como total crescente — cada queda da leitura agora conta como uma reinicialização do medidor.","adopted.warn_statistics_removed":"Esta entidade não tem mais classe de estatísticas; o Home Assistant abrirá um reparo sobre as estatísticas já coletadas.","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Configurações de monitoramento do painel","header.graph_settings":"Configurações do horizonte temporal do gráfico","header.site":"Local","header.grid":"Rede","header.upstream":"Montante","header.downstream":"Jusante","header.solar":"Solar","header.battery":"Bateria","header.toggle_units":"Alternar Watts / Amperes","header.enable_switches":"Ativar Interruptores","header.switches_enabled":"Interruptores Ativados","grid.unknown":"Desconhecido","grid.configure":"Configurar circuito","grid.configure_subdevice":"Configurar dispositivo","grid.on":"Lig","grid.off":"Des","subdevice.ev_charger":"Carregador VE","subdevice.battery":"Bateria","subdevice.fallback":"Sub-dispositivo","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Potência","sidepanel.graph_settings":"Configurações de Gráficos","sidepanel.global_defaults":"Padrões globais para todos os circuitos","sidepanel.favorites_subtitle":"Favoritos","sidepanel.global_default":"Padrão Global","sidepanel.list_view_columns":"Colunas da Lista","sidepanel.columns":"Colunas","sidepanel.circuit_scales":"Escalas de Gráficos de Circuitos","sidepanel.subdevice_scales":"Escalas de Gráficos de Sub-Dispositivos","sidepanel.reset_to_global":"Redefinir para o padrão global","sidepanel.relay":"Relé","sidepanel.breaker":"Disjuntor","sidepanel.shedding_priority":"Prioridade de Desligamento","sidepanel.priority_label":"Prioridade","sidepanel.monitoring":"Monitoramento","sidepanel.global":"Global","sidepanel.custom":"Personalizado","sidepanel.continuous_pct":"Contínuo %","sidepanel.spike_pct":"Pico %","sidepanel.window_duration":"Duração da janela","sidepanel.cooldown":"Resfriamento","sidepanel.favorite":"Favorito","sidepanel.save_to_favorites":"Salvar nos favoritos","panel.favorites":"Favoritos","status.monitoring":"Monitoramento","status.circuits":"circuitos","status.mains":"alimentação","status.warning":"aviso","status.warnings":"avisos","status.alert":"alerta","status.alerts":"alertas","status.override":"substituição","status.overrides":"substituições","card.no_device":"Abra o editor do cartão e selecione seu dispositivo SPAN Panel.","card.device_not_found":"Dispositivo do painel não encontrado. Verifique device_id na configuração do cartão.","card.topology_error":"A resposta de topologia não contém panel_size e nenhum circuito encontrado. Atualize a integração SPAN Panel.","card.panel_size_error":"Não foi possível determinar panel_size. Nenhum circuito encontrado e nenhum atributo panel_size. Atualize a integração SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Selecione um painel...","editor.chart_window":"Janela de tempo do gráfico","editor.days":"dias","editor.hours":"horas","editor.minutes":"minutos","editor.chart_metric":"Métrica do gráfico","editor.visible_sections":"Seções visíveis","editor.panel_circuits":"Circuitos do painel","editor.battery_bess":"Bateria (BESS)","editor.ev_charger_evse":"Carregador VE (EVSE)","editor.tab_style":"Estilo das abas","editor.tab_style_text":"Texto","editor.tab_style_icon":"Ícone","metric.power":"Potência","metric.current":"Corrente","metric.soc":"Estado de Carga","metric.soe":"Estado de Energia","shedding.always_on":"Crítico","shedding.never":"Não desligável","shedding.soc_threshold":"Limite SoC","shedding.off_grid":"Desligável","shedding.unknown":"Desconhecido","shedding.select.never":"Permanece ligado em uma queda","shedding.select.soc_threshold":"Ligado até limite da bateria","shedding.select.off_grid":"Desliga em uma queda"}};function n(n){return e[t]?.[n]??e.en?.[n]??n}function i(n,i){return(e[t]?.[n]??e.en?.[n]??n).replace(/\{(\w+)\}/g,(t,e)=>Object.prototype.hasOwnProperty.call(i,e)?i[e]:`{${e}}`)}const r="power",o="5m",a={"5m":{ms:3e5,refreshMs:1e3,useRealtime:!0},"1h":{ms:36e5,refreshMs:3e4,useRealtime:!1},"1d":{ms:864e5,refreshMs:6e4,useRealtime:!1},"1w":{ms:6048e5,refreshMs:6e4,useRealtime:!1},"1M":{ms:2592e6,refreshMs:6e4,useRealtime:!1}},s="span_panel",l="CLOSED",c="pv",d="bess",u="evse",h="sub_",p=500,f={power:{entityRole:"power",label:()=>n("metric.power"),unit:t=>Math.abs(t)>=1e3?"kW":"W",format:t=>{const e=Math.abs(t);return e>=1e3?(e/1e3).toFixed(1):e<10&&e>0?e.toFixed(1):String(Math.round(e))}},current:{entityRole:"current",label:()=>n("metric.current"),unit:()=>"A",format:t=>Math.abs(t).toFixed(1)}},g={soc:{entityRole:"soc",label:()=>n("metric.soc"),unit:()=>"%",format:t=>String(Math.round(t)),fixedMin:0,fixedMax:100},soe:{entityRole:"soe",label:()=>n("metric.soe"),unit:()=>"kWh",format:t=>t.toFixed(1)},power:f.power},v={always_on:{icon:"mdi:battery",icon2:"mdi:router-wireless",color:"#4caf50",label:()=>n("shedding.always_on")},never:{icon:"mdi:battery",color:"#4caf50",label:()=>n("shedding.never")},soc_threshold:{icon:"mdi:battery-alert-variant-outline",color:"#9c27b0",label:()=>n("shedding.soc_threshold"),textLabel:"SoC"},off_grid:{icon:"mdi:transmission-tower",color:"#ff9800",label:()=>n("shedding.off_grid")},unknown:{icon:"mdi:help-circle-outline",color:"#888",label:()=>n("shedding.unknown")}};var m=function(t,e){return m=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&(t[n]=e[n])},m(t,e)};function y(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function n(){this.constructor=t}m(t,e),t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)}function _(t,e,n,i){var r,o=arguments.length,a=o<3?e:null===i?i=Object.getOwnPropertyDescriptor(e,n):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)a=Reflect.decorate(t,e,n,i);else for(var s=t.length-1;s>=0;s--)(r=t[s])&&(a=(o<3?r(a):o>3?r(e,n,a):r(e,n))||a);return o>3&&a&&Object.defineProperty(e,n,a),a}"function"==typeof SuppressedError&&SuppressedError; /** * @license * Copyright 2019 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const b=globalThis,x=b.ShadowRoot&&(void 0===b.ShadyCSS||b.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,w=Symbol(),S=new WeakMap;let C=class{constructor(t,e,n){if(this._$cssResult$=!0,n!==w)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e}get styleSheet(){let t=this.o;const e=this.t;if(x&&void 0===t){const n=void 0!==e&&1===e.length;n&&(t=S.get(e)),void 0===t&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),n&&S.set(e,t))}return t}toString(){return this.cssText}};const k=t=>new C("string"==typeof t?t:t+"",void 0,w),M=(t,...e)=>{const n=1===t.length?t[0]:e.reduce((e,n,i)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(n)+t[i+1],t[0]);return new C(n,t,w)},T=x?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const n of t.cssRules)e+=n.cssText;return k(e)})(t):t,{is:I,defineProperty:D,getOwnPropertyDescriptor:A,getOwnPropertyNames:P,getOwnPropertySymbols:L,getPrototypeOf:E}=Object,z=globalThis,N=z.trustedTypes,O=N?N.emptyScript:"",R=z.reactiveElementPolyfillSupport,$=(t,e)=>t,H={toAttribute(t,e){switch(e){case Boolean:t=t?O:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,e){let n=t;switch(e){case Boolean:n=null!==t;break;case Number:n=null===t?null:Number(t);break;case Object:case Array:try{n=JSON.parse(t)}catch(t){n=null}}return n}},F=(t,e)=>!I(t,e),B={attribute:!0,type:String,converter:H,reflect:!1,useDefault:!1,hasChanged:F}; +const b=globalThis,x=b.ShadowRoot&&(void 0===b.ShadyCSS||b.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,w=Symbol(),S=new WeakMap;let C=class{constructor(t,e,n){if(this._$cssResult$=!0,n!==w)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e}get styleSheet(){let t=this.o;const e=this.t;if(x&&void 0===t){const n=void 0!==e&&1===e.length;n&&(t=S.get(e)),void 0===t&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),n&&S.set(e,t))}return t}toString(){return this.cssText}};const k=t=>new C("string"==typeof t?t:t+"",void 0,w),M=(t,...e)=>{const n=1===t.length?t[0]:e.reduce((e,n,i)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(n)+t[i+1],t[0]);return new C(n,t,w)},T=x?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const n of t.cssRules)e+=n.cssText;return k(e)})(t):t,{is:I,defineProperty:D,getOwnPropertyDescriptor:A,getOwnPropertyNames:P,getOwnPropertySymbols:L,getPrototypeOf:E}=Object,z=globalThis,N=z.trustedTypes,O=N?N.emptyScript:"",$=z.reactiveElementPolyfillSupport,R=(t,e)=>t,H={toAttribute(t,e){switch(e){case Boolean:t=t?O:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,e){let n=t;switch(e){case Boolean:n=null!==t;break;case Number:n=null===t?null:Number(t);break;case Object:case Array:try{n=JSON.parse(t)}catch(t){n=null}}return n}},F=(t,e)=>!I(t,e),B={attribute:!0,type:String,converter:H,reflect:!1,useDefault:!1,hasChanged:F}; /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */Symbol.metadata??=Symbol("metadata"),z.litPropertyMetadata??=new WeakMap;let V=class extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,e=B){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(t,e),!e.noAccessor){const n=Symbol(),i=this.getPropertyDescriptor(t,n,e);void 0!==i&&D(this.prototype,t,i)}}static getPropertyDescriptor(t,e,n){const{get:i,set:r}=A(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:i,set(e){const o=i?.call(this);r?.call(this,e),this.requestUpdate(t,o,n)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??B}static _$Ei(){if(this.hasOwnProperty($("elementProperties")))return;const t=E(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty($("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty($("properties"))){const t=this.properties,e=[...P(t),...L(t)];for(const n of e)this.createProperty(n,t[n])}const t=this[Symbol.metadata];if(null!==t){const e=litPropertyMetadata.get(t);if(void 0!==e)for(const[t,n]of e)this.elementProperties.set(t,n)}this._$Eh=new Map;for(const[t,e]of this.elementProperties){const n=this._$Eu(t,e);void 0!==n&&this._$Eh.set(n,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(t){const e=[];if(Array.isArray(t)){const n=new Set(t.flat(1/0).reverse());for(const t of n)e.unshift(T(t))}else void 0!==t&&e.push(T(t));return e}static _$Eu(t,e){const n=e.attribute;return!1===n?void 0:"string"==typeof n?n:"string"==typeof t?t.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this))}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){const t=new Map,e=this.constructor.elementProperties;for(const n of e.keys())this.hasOwnProperty(n)&&(t.set(n,this[n]),delete this[n]);t.size>0&&(this._$Ep=t)}createRenderRoot(){const t=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((t,e)=>{if(x)t.adoptedStyleSheets=e.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(const n of e){const e=document.createElement("style"),i=b.litNonce;void 0!==i&&e.setAttribute("nonce",i),e.textContent=n.cssText,t.appendChild(e)}})(t,this.constructor.elementStyles),t}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(t=>t.hostConnected?.())}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.())}attributeChangedCallback(t,e,n){this._$AK(t,n)}_$ET(t,e){const n=this.constructor.elementProperties.get(t),i=this.constructor._$Eu(t,n);if(void 0!==i&&!0===n.reflect){const r=(void 0!==n.converter?.toAttribute?n.converter:H).toAttribute(e,n.type);this._$Em=t,null==r?this.removeAttribute(i):this.setAttribute(i,r),this._$Em=null}}_$AK(t,e){const n=this.constructor,i=n._$Eh.get(t);if(void 0!==i&&this._$Em!==i){const t=n.getPropertyOptions(i),r="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:H;this._$Em=i;const o=r.fromAttribute(e,t.type);this[i]=o??this._$Ej?.get(i)??o,this._$Em=null}}requestUpdate(t,e,n,i=!1,r){if(void 0!==t){const o=this.constructor;if(!1===i&&(r=this[t]),n??=o.getPropertyOptions(t),!((n.hasChanged??F)(r,e)||n.useDefault&&n.reflect&&r===this._$Ej?.get(t)&&!this.hasAttribute(o._$Eu(t,n))))return;this.C(t,e,n)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:n,reflect:i,wrapped:r},o){n&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,o??e??this[t]),!0!==r||void 0!==o)||(this._$AL.has(t)||(this.hasUpdated||n||(e=void 0),this._$AL.set(t,e)),!0===i&&this._$Em!==t&&(this._$Eq??=new Set).add(t))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,e]of this._$Ep)this[t]=e;this._$Ep=void 0}const t=this.constructor.elementProperties;if(t.size>0)for(const[e,n]of t){const{wrapped:t}=n,i=this[e];!0!==t||this._$AL.has(e)||void 0===i||this.C(e,void 0,n,i)}}let t=!1;const e=this._$AL;try{t=this.shouldUpdate(e),t?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(e){throw t=!1,this._$EM(),e}t&&this._$AE(e)}willUpdate(t){}_$AE(t){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return!0}update(t){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM()}updated(t){}firstUpdated(t){}};V.elementStyles=[],V.shadowRootOptions={mode:"open"},V[$("elementProperties")]=new Map,V[$("finalized")]=new Map,R?.({ReactiveElement:V}),(z.reactiveElementVersions??=[]).push("2.1.2"); + */Symbol.metadata??=Symbol("metadata"),z.litPropertyMetadata??=new WeakMap;let V=class extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,e=B){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(t,e),!e.noAccessor){const n=Symbol(),i=this.getPropertyDescriptor(t,n,e);void 0!==i&&D(this.prototype,t,i)}}static getPropertyDescriptor(t,e,n){const{get:i,set:r}=A(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:i,set(e){const o=i?.call(this);r?.call(this,e),this.requestUpdate(t,o,n)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??B}static _$Ei(){if(this.hasOwnProperty(R("elementProperties")))return;const t=E(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty(R("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(R("properties"))){const t=this.properties,e=[...P(t),...L(t)];for(const n of e)this.createProperty(n,t[n])}const t=this[Symbol.metadata];if(null!==t){const e=litPropertyMetadata.get(t);if(void 0!==e)for(const[t,n]of e)this.elementProperties.set(t,n)}this._$Eh=new Map;for(const[t,e]of this.elementProperties){const n=this._$Eu(t,e);void 0!==n&&this._$Eh.set(n,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(t){const e=[];if(Array.isArray(t)){const n=new Set(t.flat(1/0).reverse());for(const t of n)e.unshift(T(t))}else void 0!==t&&e.push(T(t));return e}static _$Eu(t,e){const n=e.attribute;return!1===n?void 0:"string"==typeof n?n:"string"==typeof t?t.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this))}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){const t=new Map,e=this.constructor.elementProperties;for(const n of e.keys())this.hasOwnProperty(n)&&(t.set(n,this[n]),delete this[n]);t.size>0&&(this._$Ep=t)}createRenderRoot(){const t=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((t,e)=>{if(x)t.adoptedStyleSheets=e.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(const n of e){const e=document.createElement("style"),i=b.litNonce;void 0!==i&&e.setAttribute("nonce",i),e.textContent=n.cssText,t.appendChild(e)}})(t,this.constructor.elementStyles),t}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(t=>t.hostConnected?.())}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.())}attributeChangedCallback(t,e,n){this._$AK(t,n)}_$ET(t,e){const n=this.constructor.elementProperties.get(t),i=this.constructor._$Eu(t,n);if(void 0!==i&&!0===n.reflect){const r=(void 0!==n.converter?.toAttribute?n.converter:H).toAttribute(e,n.type);this._$Em=t,null==r?this.removeAttribute(i):this.setAttribute(i,r),this._$Em=null}}_$AK(t,e){const n=this.constructor,i=n._$Eh.get(t);if(void 0!==i&&this._$Em!==i){const t=n.getPropertyOptions(i),r="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:H;this._$Em=i;const o=r.fromAttribute(e,t.type);this[i]=o??this._$Ej?.get(i)??o,this._$Em=null}}requestUpdate(t,e,n,i=!1,r){if(void 0!==t){const o=this.constructor;if(!1===i&&(r=this[t]),n??=o.getPropertyOptions(t),!((n.hasChanged??F)(r,e)||n.useDefault&&n.reflect&&r===this._$Ej?.get(t)&&!this.hasAttribute(o._$Eu(t,n))))return;this.C(t,e,n)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:n,reflect:i,wrapped:r},o){n&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,o??e??this[t]),!0!==r||void 0!==o)||(this._$AL.has(t)||(this.hasUpdated||n||(e=void 0),this._$AL.set(t,e)),!0===i&&this._$Em!==t&&(this._$Eq??=new Set).add(t))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,e]of this._$Ep)this[t]=e;this._$Ep=void 0}const t=this.constructor.elementProperties;if(t.size>0)for(const[e,n]of t){const{wrapped:t}=n,i=this[e];!0!==t||this._$AL.has(e)||void 0===i||this.C(e,void 0,n,i)}}let t=!1;const e=this._$AL;try{t=this.shouldUpdate(e),t?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(e){throw t=!1,this._$EM(),e}t&&this._$AE(e)}willUpdate(t){}_$AE(t){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return!0}update(t){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM()}updated(t){}firstUpdated(t){}};V.elementStyles=[],V.shadowRootOptions={mode:"open"},V[R("elementProperties")]=new Map,V[R("finalized")]=new Map,$?.({ReactiveElement:V}),(z.reactiveElementVersions??=[]).push("2.1.2"); /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const W=globalThis,U=t=>t,G=W.trustedTypes,q=G?G.createPolicy("lit-html",{createHTML:t=>t}):void 0,j="$lit$",X=`lit$${Math.random().toFixed(9).slice(2)}$`,Y="?"+X,Z=`<${Y}>`,K=document,Q=()=>K.createComment(""),J=t=>null===t||"object"!=typeof t&&"function"!=typeof t,tt=Array.isArray,et="[ \t\n\f\r]",nt=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,it=/-->/g,rt=/>/g,ot=RegExp(`>|${et}(?:([^\\s"'>=/]+)(${et}*=${et}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),at=/'/g,st=/"/g,lt=/^(?:script|style|textarea|title)$/i,ct=t=>(e,...n)=>({_$litType$:t,strings:e,values:n}),ut=ct(1),ht=ct(2),dt=Symbol.for("lit-noChange"),pt=Symbol.for("lit-nothing"),ft=new WeakMap,gt=K.createTreeWalker(K,129);function vt(t,e){if(!tt(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==q?q.createHTML(e):e}const mt=(t,e)=>{const n=t.length-1,i=[];let r,o=2===e?"":3===e?"":"",a=nt;for(let e=0;e"===l[0]?(a=r??nt,c=-1):void 0===l[1]?c=-2:(c=a.lastIndex-l[2].length,s=l[1],a=void 0===l[3]?ot:'"'===l[3]?st:at):a===st||a===at?a=ot:a===it||a===rt?a=nt:(a=ot,r=void 0);const h=a===ot&&t[e+1].startsWith("/>")?" ":"";o+=a===nt?n+Z:c>=0?(i.push(s),n.slice(0,c)+j+n.slice(c)+X+h):n+X+(-2===c?e:h)}return[vt(t,o+(t[n]||"")+(2===e?"":3===e?"":"")),i]};class yt{constructor({strings:t,_$litType$:e},n){let i;this.parts=[];let r=0,o=0;const a=t.length-1,s=this.parts,[l,c]=mt(t,e);if(this.el=yt.createElement(l,n),gt.currentNode=this.el.content,2===e||3===e){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(i=gt.nextNode())&&s.length0){i.textContent=G?G.emptyScript:"";for(let n=0;ntt(t)||"function"==typeof t?.[Symbol.iterator])(t)?this.k(t):this._(t)}O(t){return this._$AA.parentNode.insertBefore(t,this._$AB)}T(t){this._$AH!==t&&(this._$AR(),this._$AH=this.O(t))}_(t){this._$AH!==pt&&J(this._$AH)?this._$AA.nextSibling.data=t:this.T(K.createTextNode(t)),this._$AH=t}$(t){const{values:e,_$litType$:n}=t,i="number"==typeof n?this._$AC(t):(void 0===n.el&&(n.el=yt.createElement(vt(n.h,n.h[0]),this.options)),n);if(this._$AH?._$AD===i)this._$AH.p(e);else{const t=new bt(i,this),n=t.u(this.options);t.p(e),this.T(n),this._$AH=t}}_$AC(t){let e=ft.get(t.strings);return void 0===e&&ft.set(t.strings,e=new yt(t)),e}k(t){tt(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let n,i=0;for(const r of t)i===e.length?e.push(n=new xt(this.O(Q()),this.O(Q()),this,this.options)):n=e[i],n._$AI(r),i++;i2||""!==n[0]||""!==n[1]?(this._$AH=Array(n.length-1).fill(new String),this.strings=n):this._$AH=pt}_$AI(t,e=this,n,i){const r=this.strings;let o=!1;if(void 0===r)t=_t(this,t,e,0),o=!J(t)||t!==this._$AH&&t!==dt,o&&(this._$AH=t);else{const i=t;let a,s;for(t=r[0],a=0;at,G=W.trustedTypes,q=G?G.createPolicy("lit-html",{createHTML:t=>t}):void 0,j="$lit$",X=`lit$${Math.random().toFixed(9).slice(2)}$`,Y="?"+X,Z=`<${Y}>`,K=document,Q=()=>K.createComment(""),J=t=>null===t||"object"!=typeof t&&"function"!=typeof t,tt=Array.isArray,et="[ \t\n\f\r]",nt=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,it=/-->/g,rt=/>/g,ot=RegExp(`>|${et}(?:([^\\s"'>=/]+)(${et}*=${et}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),at=/'/g,st=/"/g,lt=/^(?:script|style|textarea|title)$/i,ct=t=>(e,...n)=>({_$litType$:t,strings:e,values:n}),dt=ct(1),ut=ct(2),ht=Symbol.for("lit-noChange"),pt=Symbol.for("lit-nothing"),ft=new WeakMap,gt=K.createTreeWalker(K,129);function vt(t,e){if(!tt(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==q?q.createHTML(e):e}const mt=(t,e)=>{const n=t.length-1,i=[];let r,o=2===e?"":3===e?"":"",a=nt;for(let e=0;e"===l[0]?(a=r??nt,c=-1):void 0===l[1]?c=-2:(c=a.lastIndex-l[2].length,s=l[1],a=void 0===l[3]?ot:'"'===l[3]?st:at):a===st||a===at?a=ot:a===it||a===rt?a=nt:(a=ot,r=void 0);const u=a===ot&&t[e+1].startsWith("/>")?" ":"";o+=a===nt?n+Z:c>=0?(i.push(s),n.slice(0,c)+j+n.slice(c)+X+u):n+X+(-2===c?e:u)}return[vt(t,o+(t[n]||"")+(2===e?"":3===e?"":"")),i]};class yt{constructor({strings:t,_$litType$:e},n){let i;this.parts=[];let r=0,o=0;const a=t.length-1,s=this.parts,[l,c]=mt(t,e);if(this.el=yt.createElement(l,n),gt.currentNode=this.el.content,2===e||3===e){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(i=gt.nextNode())&&s.length0){i.textContent=G?G.emptyScript:"";for(let n=0;ntt(t)||"function"==typeof t?.[Symbol.iterator])(t)?this.k(t):this._(t)}O(t){return this._$AA.parentNode.insertBefore(t,this._$AB)}T(t){this._$AH!==t&&(this._$AR(),this._$AH=this.O(t))}_(t){this._$AH!==pt&&J(this._$AH)?this._$AA.nextSibling.data=t:this.T(K.createTextNode(t)),this._$AH=t}$(t){const{values:e,_$litType$:n}=t,i="number"==typeof n?this._$AC(t):(void 0===n.el&&(n.el=yt.createElement(vt(n.h,n.h[0]),this.options)),n);if(this._$AH?._$AD===i)this._$AH.p(e);else{const t=new bt(i,this),n=t.u(this.options);t.p(e),this.T(n),this._$AH=t}}_$AC(t){let e=ft.get(t.strings);return void 0===e&&ft.set(t.strings,e=new yt(t)),e}k(t){tt(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let n,i=0;for(const r of t)i===e.length?e.push(n=new xt(this.O(Q()),this.O(Q()),this,this.options)):n=e[i],n._$AI(r),i++;i2||""!==n[0]||""!==n[1]?(this._$AH=Array(n.length-1).fill(new String),this.strings=n):this._$AH=pt}_$AI(t,e=this,n,i){const r=this.strings;let o=!1;if(void 0===r)t=_t(this,t,e,0),o=!J(t)||t!==this._$AH&&t!==ht,o&&(this._$AH=t);else{const i=t;let a,s;for(t=r[0],a=0;a{const i=n?.renderBefore??e;let r=i._$litPart$;if(void 0===r){const t=n?.renderBefore??null;i._$litPart$=r=new xt(e.insertBefore(Q(),t),t,void 0,n??{})}return r._$AI(t),r})(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return dt}};Dt._$litElement$=!0,Dt.finalized=!0,It.litElementHydrateSupport?.({LitElement:Dt});const At=It.litElementPolyfillSupport;At?.({LitElement:Dt}),(It.litElementVersions??=[]).push("4.2.2"); + */let Dt=class extends V{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){const t=super.createRenderRoot();return this.renderOptions.renderBefore??=t.firstChild,t}update(t){const e=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(t),this._$Do=((t,e,n)=>{const i=n?.renderBefore??e;let r=i._$litPart$;if(void 0===r){const t=n?.renderBefore??null;i._$litPart$=r=new xt(e.insertBefore(Q(),t),t,void 0,n??{})}return r._$AI(t),r})(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return ht}};Dt._$litElement$=!0,Dt.finalized=!0,It.litElementHydrateSupport?.({LitElement:Dt});const At=It.litElementPolyfillSupport;At?.({LitElement:Dt}),(It.litElementVersions??=[]).push("4.2.2"); /** * @license * Copyright 2017 Google LLC @@ -46,11 +46,11 @@ const Pt={attribute:!0,type:String,converter:H,reflect:!1,hasChanged:F},Lt=(t=Pt * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */let Rt=class extends Ot{constructor(t){if(super(t),this.it=pt,t.type!==Nt)throw Error(this.constructor.directiveName+"() can only be used in child bindings")}render(t){if(t===pt||null==t)return this._t=void 0,this.it=t;if(t===dt)return t;if("string"!=typeof t)throw Error(this.constructor.directiveName+"() called with a non-string value");if(t===this.it)return this._t;this.it=t;const e=[t];return e.raw=e,this._t={_$litType$:this.constructor.resultType,strings:e,values:[]}}};Rt.directiveName="unsafeHTML",Rt.resultType=1;const $t=(t=>(...e)=>({_$litDirective$:t,values:e}))(Rt);class Ht{constructor(){this._persistent=new Map,this._transient=null,this._transientTimer=null,this._subscribers=new Set,this._watchedPanels=new Map}add(t){const e={...t,timestamp:Date.now()};if(e.persistent)this._persistent.set(e.key,e);else{this._clearTransient(),this._transient=e;const t=e.ttl??5e3;this._transientTimer=setTimeout(()=>{this._transient=null,this._transientTimer=null,this._notify()},t)}this._notify()}remove(t){if(this._persistent.has(t))return this._persistent.delete(t),void this._notify();this._transient?.key===t&&(this._clearTransient(),this._notify())}clear(t){void 0===t?(this._persistent.clear(),this._clearTransient(),this._watchedPanels.clear()):!0===t.persistent?this._persistent.clear():!1===t.persistent&&this._clearTransient(),this._notify()}get active(){const t=[...this._persistent.values()];return null!==this._transient&&t.push(this._transient),t}hasPersistent(t){return this._persistent.has(t)}hasAnyPanelOffline(){for(const t of this._persistent.keys())if("panel-offline"===t||t.startsWith("panel-offline:"))return!0;return!1}subscribe(t){return this._subscribers.add(t),()=>{this._subscribers.delete(t)}}watchPanelStatus(t){this.watchPanelStatuses([{entityId:t,panelName:null}])}watchPanelStatuses(t){const e=this._watchedPanels,n=new Map;for(const i of t){const t=e.get(i.entityId);n.set(i.entityId,{panelName:i.panelName??null,wasOffline:t?.wasOffline??!1})}const i=this._isSingleUnnamed(e),r=this._isSingleUnnamed(n);for(const t of e.keys()){n.has(t)&&i===r||this._persistent.delete(this._offlineKey(t,i))}this._watchedPanels=n,this._notify()}clearPanelStatusWatch(){if(0===this._watchedPanels.size)return;const t=this._isSingleUnnamed(this._watchedPanels);for(const e of this._watchedPanels.keys())this._persistent.delete(this._offlineKey(e,t));this._watchedPanels.clear(),this._notify()}updateHass(t){if(0===this._watchedPanels.size)return;const e=this._isSingleUnnamed(this._watchedPanels);for(const[r,o]of this._watchedPanels){const a=t.states[r]?.state,s="on"===a,l=this._offlineKey(r,e),c=this._reconnectKey(r,e);if(s){const t=o.wasOffline;o.wasOffline=!1,this.remove(l),t&&this.add({key:c,level:"info",message:null===o.panelName?n("error.panel_reconnected"):i("error.panel_reconnected_named",{name:o.panelName}),persistent:!1})}else o.wasOffline=!0,this.hasPersistent(l)||this.add({key:l,level:"error",message:null===o.panelName?n("error.panel_offline"):i("error.panel_offline_named",{name:o.panelName}),persistent:!0})}}dispose(){this._clearTransient(),this._persistent.clear(),this._subscribers.clear(),this._watchedPanels.clear()}_isSingleUnnamed(t){if(1!==t.size)return!1;for(const e of t.values())return null===e.panelName;return!1}_offlineKey(t,e){return e?"panel-offline":`panel-offline:${t}`}_reconnectKey(t,e){return e?"panel-reconnected":`panel-reconnected:${t}`}_clearTransient(){null!==this._transientTimer&&(clearTimeout(this._transientTimer),this._transientTimer=null),this._transient=null}_notify(){for(const t of this._subscribers)try{t()}catch(t){console.warn("SPAN Panel: error-store subscriber threw",t)}}}const Ft={"&":"&","<":"<",">":">",'"':""","'":"'"};function Bt(t){return String(t).replace(/[&<>"']/g,t=>Ft[t]??t)}const Vt="span_panel_list_columns";function Wt(){try{const t=localStorage.getItem(Vt);if(!t)return 1;const e=parseInt(t,10);return 1===e||2===e||3===e?e:1}catch{return 1}}function Ut(t){try{localStorage.setItem(Vt,String(t))}catch{}}function Gt(t){return new Promise(e=>setTimeout(e,t))}class qt{constructor(t){this._store=t}async callWS(t,e,n){const i=n?.retries??3,r=n?.errorId??`ws:${String(e.type??"unknown")}`;return this._withRetry(()=>t.callWS(e),i,r,n?.errorMessage)}async callService(t,e,n,i,r,o){const a=o?.retries??3,s=o?.errorId??`svc:${e}.${n}`;return this._withRetry(()=>t.callService(e,n,i,r),a,s,o?.errorMessage)}async _withRetry(t,e,i,r){if(this._store.hasAnyPanelOffline())try{const e=await t();return this._store.remove(i),e}catch(t){const e=t instanceof Error?t:new Error(String(t));throw this._store.add({key:i,level:"error",message:r??n("error.panel_offline"),persistent:!1}),e}let o;for(let n=0;n<=e;n++)try{const e=await t();return this._store.remove(i),e}catch(t){if(o=t instanceof Error?t:new Error(String(t)),n{try{const e={type:"call_service",domain:s,service:"get_favorites",service_data:{},return_response:!0},r=this._retry?await this._retry.callWS(t,e,{errorId:"fetch:favorites",errorMessage:n("error.favorites_fetch_failed")}):await t.callWS(e),o=r?.response?.favorites??{};return i===this._generation&&(this._map=o,this._lastFetch=Date.now()),o}catch(t){return console.warn("SPAN Panel: favorites fetch failed",t),this._retry||this._errorStore?.add({key:"fetch:favorites",level:"warning",message:n("error.favorites_fetch_failed"),persistent:!1}),this._map??{}}finally{this._inflight?.gen===i&&(this._inflight=null)}})();return this._inflight={gen:i,promise:r},r}invalidate(){this._lastFetch=0,this._generation++}clear(){this._map=null,this._lastFetch=0,this._generation++}get map(){return this._map??{}}}function Zt(t){for(const e of Object.values(t)){if((e.circuits?.length??0)>0)return!0;if((e.sub_devices?.length??0)>0)return!0}return!1}const Kt=Object.keys(v).filter(t=>"unknown"!==t&&"always_on"!==t);class Qt extends HTMLElement{constructor(){super(),this.errorStore=null,this.attachShadow({mode:"open"}),this._hass=null,this._config=null,this._debounceTimers={}}set hass(t){this._hass=t,this.hasAttribute("open")&&this._config&&this._updateLiveState()}get hass(){return this._hass}disconnectedCallback(){this._clearDebounceTimers(),this._config=null}open(t){this._config=t,this._render(),this.offsetHeight,this.setAttribute("open",""),this.setAttribute("data-mode",this._modeFor(t))}close(){this._clearDebounceTimers(),this.removeAttribute("open"),this.removeAttribute("data-mode"),this._config=null,this.dispatchEvent(new CustomEvent("side-panel-closed",{bubbles:!0,composed:!0}))}_clearDebounceTimers(){for(const t of Object.keys(this._debounceTimers))clearTimeout(this._debounceTimers[t]);this._debounceTimers={}}_modeFor(t){return t.favoritesMode?"favorites":t.panelMode?"panel":t.subDeviceMode?"subDevice":"circuit"}_render(){const t=this._config;if(!t)return;const e=this.shadowRoot;if(!e)return;e.innerHTML="";const n=document.createElement("style");n.textContent='\n :host {\n display: block;\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n width: 360px;\n max-width: 90vw;\n z-index: 1000;\n transform: translateX(100%);\n transition: transform 0.3s ease;\n pointer-events: none;\n }\n :host([open]) {\n transform: translateX(0);\n pointer-events: auto;\n }\n\n .backdrop {\n display: none;\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba(0, 0, 0, 0.3);\n z-index: -1;\n }\n :host([open]) .backdrop {\n display: block;\n }\n\n .panel {\n height: 100%;\n background: var(--card-background-color, #fff);\n border-left: 1px solid var(--divider-color, #e0e0e0);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n .panel-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 16px;\n border-bottom: 1px solid var(--divider-color, #e0e0e0);\n }\n .panel-header .title {\n font-size: 18px;\n font-weight: 500;\n color: var(--primary-text-color, #212121);\n margin: 0;\n }\n .panel-header .subtitle {\n font-size: 13px;\n color: var(--secondary-text-color, #727272);\n margin: 2px 0 0 0;\n }\n .close-btn {\n background: none;\n border: none;\n cursor: pointer;\n color: var(--secondary-text-color, #727272);\n padding: 4px;\n line-height: 1;\n font-size: 20px;\n }\n\n .panel-body {\n flex: 1;\n overflow-y: auto;\n padding: 16px;\n }\n\n .section {\n margin-bottom: 20px;\n }\n .section-label {\n font-size: 12px;\n font-weight: 600;\n text-transform: uppercase;\n color: var(--secondary-text-color, #727272);\n margin: 0 0 8px 0;\n letter-spacing: 0.5px;\n }\n\n .field-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 8px 0;\n }\n .field-label {\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n }\n\n select {\n padding: 6px 8px;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 4px;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 14px;\n }\n\n input[type="number"] {\n width: 72px;\n padding: 6px 8px;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 4px;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 14px;\n text-align: right;\n }\n input[type="number"]:disabled {\n opacity: 0.5;\n }\n\n .radio-group {\n display: flex;\n gap: 16px;\n padding: 8px 0;\n }\n .radio-group label {\n display: flex;\n align-items: center;\n gap: 6px;\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n cursor: pointer;\n }\n\n .horizon-bar {\n display: flex;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 6px;\n overflow: hidden;\n margin-top: 4px;\n }\n .horizon-segment {\n flex: 1;\n padding: 6px 0;\n text-align: center;\n font-size: 13px;\n cursor: pointer;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n border: none;\n border-right: 1px solid var(--divider-color, #e0e0e0);\n transition: background 0.15s ease, color 0.15s ease;\n user-select: none;\n line-height: 1.4;\n }\n .horizon-segment:last-child {\n border-right: none;\n }\n .horizon-segment:hover:not(.active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .horizon-segment.active {\n background: var(--primary-color, #03a9f4);\n color: #fff;\n font-weight: 600;\n }\n .horizon-segment.referenced {\n box-shadow: inset 0 -3px 0 var(--primary-color, #03a9f4);\n }\n\n .unit-toggle {\n display: inline-flex;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 6px;\n overflow: hidden;\n }\n .unit-btn {\n padding: 4px 10px;\n border: none;\n border-right: 1px solid var(--divider-color, #e0e0e0);\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 13px;\n font-weight: 500;\n cursor: pointer;\n transition: background 0.15s ease, color 0.15s ease;\n }\n .unit-btn:last-child {\n border-right: none;\n }\n .unit-btn:hover:not(.unit-active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .unit-btn.unit-active {\n background: var(--primary-color, #03a9f4);\n color: #fff;\n font-weight: 600;\n }\n\n .monitoring-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n }\n\n .fav-heart {\n background: none;\n border: 1px solid var(--divider-color, #e0e0e0);\n color: var(--secondary-text-color, #727272);\n border-radius: 4px;\n padding: 2px 6px;\n cursor: pointer;\n font-size: 0.9em;\n margin-right: 6px;\n line-height: 1;\n display: inline-flex;\n align-items: center;\n }\n .fav-heart.active {\n color: var(--primary-color, #03a9f4);\n border-color: var(--primary-color, #03a9f4);\n }\n .fav-heart:hover:not(.active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .fav-heart span-icon {\n --mdc-icon-size: 16px;\n }\n\n .panel-mode-info {\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n line-height: 1.6;\n }\n .panel-mode-info p {\n margin: 0 0 12px 0;\n }\n\n',e.appendChild(n);const i=document.createElement("div");i.className="backdrop",i.addEventListener("click",()=>this.close()),e.appendChild(i);const r=document.createElement("div");r.className="panel",e.appendChild(r),t.favoritesMode?this._renderFavoritesMode(r):t.panelMode?this._renderPanelMode(r):t.subDeviceMode?this._renderSubDeviceMode(r,t):this._renderCircuitMode(r,t)}_renderPanelMode(t){const e=this._config,i=this._createHeader(n("sidepanel.graph_settings"),n("sidepanel.global_defaults"));t.appendChild(i);const r=document.createElement("div");r.className="panel-body";const s=e.graphSettings,l=e.topology,c=s?.global_horizon??o,u=s?.circuits??{};r.appendChild(this._buildListColumnsSection());const h=document.createElement("div");h.className="section";const d=document.createElement("div");d.className="section-label",d.textContent=n("sidepanel.graph_horizon"),h.appendChild(d);const f=document.createElement("div");f.className="field-row";const g=document.createElement("span");g.className="field-label",g.textContent=n("sidepanel.global_default"),f.appendChild(g);const v=document.createElement("select");for(const t of Object.keys(a)){const e=document.createElement("option");e.value=t;const i=`horizon.${t}`,r=n(i);e.textContent=r!==i?r:t,t===c&&(e.selected=!0),v.appendChild(e)}if(v.addEventListener("change",()=>{const t={horizon:v.value};e.configEntryId&&(t.config_entry_id=e.configEntryId),this._callDomainService("set_graph_time_horizon",t).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})}),f.appendChild(v),h.appendChild(f),r.appendChild(h),l?.circuits){const t=document.createElement("div");t.className="section";const i=document.createElement("div");i.className="section-label",i.textContent=n("sidepanel.circuit_scales"),t.appendChild(i);const o=Object.entries(l.circuits).sort(([,t],[,e])=>(t.name||"").localeCompare(e.name||""));for(const[n,i]of o){const r=this._buildPanelModeCircuitRow(n,i,u[n],c,e.configEntryId??null,e.showFavorites??!1,e.favoritePanelDeviceId,e.favoriteCircuitUuids);t.appendChild(r)}r.appendChild(t)}const m=s?.sub_devices??{};if(l?.sub_devices){const t=document.createElement("div");t.className="section";const i=document.createElement("div");i.className="section-label",i.textContent=n("sidepanel.subdevice_scales"),t.appendChild(i);const o=Object.entries(l.sub_devices).sort(([,t],[,e])=>(t.name||"").localeCompare(e.name||""));for(const[i,r]of o){const o=document.createElement("div");o.className="field-row";const s=document.createElement("span");if(s.className="field-label",s.textContent=r.name||i,s.style.cssText="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;",o.appendChild(s),e.showFavorites&&e.favoritePanelDeviceId){const t=this._buildSubDeviceFavoriteHeart(r.entities,e.favoriteSubDeviceIds?.has(i)??!1);t&&o.appendChild(t)}const l=m[i]||{horizon:c,has_override:!1},u=l.has_override?l.horizon:c,h=document.createElement("select");h.dataset.subdevId=i;for(const t of Object.keys(a)){const e=document.createElement("option");e.value=t;const i=`horizon.${t}`,r=n(i);e.textContent=r!==i?r:t,t===u&&(e.selected=!0),h.appendChild(e)}if(h.addEventListener("change",()=>{this._debounce(`subdev-${i}`,p,()=>{const t={subdevice_id:i,horizon:h.value};e.configEntryId&&(t.config_entry_id=e.configEntryId),this._callDomainService("set_subdevice_graph_horizon",t).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})})}),o.appendChild(h),l.has_override){const t=document.createElement("button");t.textContent="↺",t.title=n("sidepanel.reset_to_global"),Object.assign(t.style,{background:"none",border:"1px solid var(--divider-color, #e0e0e0)",color:"var(--primary-text-color)",borderRadius:"4px",padding:"3px 6px",cursor:"pointer",marginLeft:"4px",fontSize:"0.85em"}),t.addEventListener("click",()=>{const r={subdevice_id:i};e.configEntryId&&(r.config_entry_id=e.configEntryId),this._callDomainService("clear_subdevice_graph_horizon",r).then(()=>{h.value=c,t.remove(),this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})}),o.appendChild(t)}t.appendChild(o)}r.appendChild(t)}t.appendChild(r)}_buildPanelModeCircuitRow(t,e,i,r,o,s,l,c){const u=document.createElement("div");u.className="field-row";const h=document.createElement("span");if(h.className="field-label",h.textContent=e.name||t,h.style.cssText="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;",u.appendChild(h),s&&l){const n=this._buildFavoriteHeart(e.entities,c?.has(t)??!1);n&&u.appendChild(n)}const d=i||{horizon:r,has_override:!1},f=d.has_override?d.horizon:r,g=document.createElement("select");g.dataset.uuid=t;for(const t of Object.keys(a)){const e=document.createElement("option");e.value=t;const i=`horizon.${t}`,r=n(i);e.textContent=r!==i?r:t,t===f&&(e.selected=!0),g.appendChild(e)}if(g.addEventListener("change",()=>{this._debounce(`circuit-${t}`,p,()=>{const e={circuit_id:t,horizon:g.value};o&&(e.config_entry_id=o),this._callDomainService("set_circuit_graph_horizon",e).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})})}),u.appendChild(g),d.has_override){const e=document.createElement("button");e.textContent="↺",e.title=n("sidepanel.reset_to_global"),Object.assign(e.style,{background:"none",border:"1px solid var(--divider-color, #e0e0e0)",color:"var(--primary-text-color)",borderRadius:"4px",padding:"3px 6px",cursor:"pointer",marginLeft:"4px",fontSize:"0.85em"}),e.addEventListener("click",()=>{const i={circuit_id:t};o&&(i.config_entry_id=o),this._callDomainService("clear_circuit_graph_horizon",i).then(()=>{g.value=r,e.remove(),this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})}),u.appendChild(e)}return u}_renderFavoritesMode(t){const e=this._config,i=this._createHeader(n("sidepanel.graph_settings"),n("sidepanel.favorites_subtitle"));t.appendChild(i);const r=document.createElement("div");r.className="panel-body",r.appendChild(this._buildListColumnsSection());for(const t of e.perPanelSections)r.appendChild(this._buildFavoritesPanelSection(t));t.appendChild(r)}_buildFavoritesPanelSection(t){const e=document.createElement("div");e.className="section";const n=document.createElement("div");n.className="section-label",n.textContent=t.panelName,e.appendChild(n);const i=t.graphSettings?.global_horizon??o,r=t.graphSettings?.circuits??{},a=function(t){const e=t.circuits??{};return Object.entries(e).map(([t,e])=>({uuid:t,circuit:e})).sort((t,e)=>(t.circuit.name||"").localeCompare(e.circuit.name||""))}(t.topology);for(const{uuid:n,circuit:o}of a){const a=this._buildPanelModeCircuitRow(n,o,r[n],i,t.configEntryId,!0,t.panelDeviceId,t.favoriteCircuitUuids);e.appendChild(a)}return e}_renderCircuitMode(t,e){const n=`${Bt(String(e.breaker_rating_a))}A · ${Bt(String(e.voltage))}V · Tabs [${Bt(String(e.tabs))}]`,i=this._createHeader(Bt(e.name),n);t.appendChild(i);const r=document.createElement("div");r.className="panel-body",t.appendChild(r),this._renderRelaySection(r,e),e.showFavorites&&this._renderFavoriteSection(r,e),this._renderSheddingSection(r,e),this._renderGraphHorizonSection(r,e),e.showMonitoring&&this._renderMonitoringSection(r,e)}_favoriteEntityId(t){return t?.current??t?.power??null}_subDeviceFavoriteEntityId(t){if(!t)return null;let e=null;for(const[n,i]of Object.entries(t)){if("sensor"===i.domain)return n;e||(e=n)}return e}_buildSubDeviceFavoriteHeart(t,e){const n=this._subDeviceFavoriteEntityId(t);return n?this._buildHeartButton(n,e):null}_buildListColumnsSection(){const t=document.createElement("div");t.className="section";const e=document.createElement("div");e.className="section-label",e.textContent=n("sidepanel.list_view_columns"),t.appendChild(e);const i=document.createElement("div");i.className="field-row";const r=document.createElement("span");r.className="field-label",r.textContent=n("sidepanel.columns"),i.appendChild(r);const o=Wt(),a=document.createElement("div");a.className="unit-toggle";for(const t of[1,2,3]){const e=document.createElement("button");e.type="button",e.className="unit-btn"+(t===o?" unit-active":""),e.dataset.columns=String(t),e.textContent=String(t),e.addEventListener("click",()=>{Ut(t);for(const t of a.querySelectorAll(".unit-btn"))t.classList.toggle("unit-active",t===e);this.dispatchEvent(new CustomEvent("list-columns-changed",{detail:t,bubbles:!0,composed:!0}))}),a.appendChild(e)}return i.appendChild(a),t.appendChild(i),t}_buildFavoriteHeart(t,e){const n=this._favoriteEntityId(t);return n?this._buildHeartButton(n,e):(console.warn("SPAN Panel: circuit has no current/power sensor; favorite heart suppressed"),null)}_buildHeartButton(t,e){const i=document.createElement("button");i.type="button",i.className=e?"fav-heart active":"fav-heart",i.dataset.role="fav-heart",i.title=n("sidepanel.save_to_favorites"),i.setAttribute("role","switch"),i.setAttribute("aria-checked",String(e)),i.setAttribute("aria-label",n("sidepanel.save_to_favorites"));const r=document.createElement("span-icon");return r.setAttribute("icon",e?"mdi:heart":"mdi:heart-outline"),i.appendChild(r),i.addEventListener("click",e=>{e.stopPropagation(),this._toggleFavoriteEntity(i,r,t).catch(()=>{})}),i}async _toggleFavoriteEntity(t,e,i){if(!this._hass)return;const r=t.classList.contains("active"),o=!r;t.classList.toggle("active",o),e.setAttribute("icon",o?"mdi:heart":"mdi:heart-outline"),t.setAttribute("aria-checked",String(o));try{o?await async function(t,e){const n=await Xt(t,"add_favorite",{entity_id:e});return document.dispatchEvent(new CustomEvent(jt)),n?.favorites??{}}(this._hass,i):await async function(t,e){const n=await Xt(t,"remove_favorite",{entity_id:e});return document.dispatchEvent(new CustomEvent(jt)),n?.favorites??{}}(this._hass,i)}catch(i){throw t.classList.toggle("active",r),e.setAttribute("icon",r?"mdi:heart":"mdi:heart-outline"),t.setAttribute("aria-checked",String(r)),console.warn("SPAN Panel: favorite toggle failed",i),this.errorStore?.add({key:"service:favorites",level:"error",message:n("error.favorites_toggle_failed"),persistent:!1}),i}}_renderFavoriteSection(t,e){const n=this._favoriteEntityId(e.entities);n&&this._appendFavoriteHeartSection(t,n,!0===e.isFavorite)}_appendFavoriteHeartSection(t,e,i){const r=document.createElement("div");r.className="section",r.innerHTML=``;const o=document.createElement("div");o.className="field-row";const a=document.createElement("span");a.className="field-label",a.textContent=n("sidepanel.save_to_favorites"),o.appendChild(a),o.appendChild(this._buildHeartButton(e,i)),r.appendChild(o),t.appendChild(r)}_renderSubDeviceMode(t,e){const n=this._createHeader(Bt(e.name),Bt(e.deviceType));t.appendChild(n);const i=document.createElement("div");i.className="panel-body",t.appendChild(i),e.showFavorites&&this._renderSubDeviceFavoriteSection(i,e),this._renderSubDeviceHorizonSection(i,e)}_renderSubDeviceFavoriteSection(t,e){const n=this._subDeviceFavoriteEntityId(e.entities);n&&this._appendFavoriteHeartSection(t,n,!0===e.isFavorite)}_renderSubDeviceHorizonSection(t,e){const i=document.createElement("div");i.className="section";const r=document.createElement("div");r.className="section-label",r.textContent=n("sidepanel.graph_horizon"),i.appendChild(r);const s=e.graphHorizonInfo,l=!0===s?.has_override,c=s?.horizon||o,u=s?.globalHorizon||o,h=document.createElement("div");h.className="horizon-bar";const d=[{key:"global",label:n("sidepanel.global")}];for(const t of Object.keys(a))d.push({key:t,label:t});const p=l?c:"global",f=t=>{for(const e of h.querySelectorAll(".horizon-segment")){const n=e.dataset.horizon;e.classList.toggle("active",n===t),e.classList.toggle("referenced","global"===t&&n===u)}};for(const{key:t,label:i}of d){const r=document.createElement("button");r.type="button",r.className="horizon-segment",r.dataset.horizon=t,r.textContent=i,r.classList.toggle("active",t===p),r.classList.toggle("referenced","global"===p&&t===u),r.addEventListener("click",()=>{if(r.classList.contains("active"))return;const i={subdevice_id:e.subDeviceId};e.configEntryId&&(i.config_entry_id=e.configEntryId),"global"===t?(f("global"),this._callDomainService("clear_subdevice_graph_horizon",i).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})):(f(t),this._callDomainService("set_subdevice_graph_horizon",{...i,horizon:t}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})}))}),h.appendChild(r)}i.appendChild(h),t.appendChild(i)}_createHeader(t,e){const n=document.createElement("div");n.className="panel-header";const i=document.createElement("div"),r=Bt(t),o=Bt(e);i.innerHTML=`
${r}
`+(o?`
${o}
`:"");const a=document.createElement("button");return a.className="close-btn",a.innerHTML="✕",a.addEventListener("click",()=>this.close()),n.appendChild(i),n.appendChild(a),n}_renderRelaySection(t,e){if(!1===e.is_user_controllable||!e.entities?.switch)return;const i=document.createElement("div");i.className="section",i.innerHTML=``;const r=document.createElement("div");r.className="field-row";const o=document.createElement("span");o.className="field-label",o.textContent=n("sidepanel.breaker");const a=document.createElement("span-switch");a.dataset.role="relay-toggle";const s=e.entities.switch,l=this._hass?.states?.[s]?.state;"on"===l&&a.setAttribute("checked",""),a.addEventListener("change",()=>{const t=a.hasAttribute("checked")||a.checked;this._callService("switch",t?"turn_on":"turn_off",{entity_id:s}).catch(t=>{console.warn("SPAN Panel: relay toggle failed",t),this.errorStore?.add({key:"service:relay",level:"error",message:n("error.relay_failed"),persistent:!1})})}),r.appendChild(o),r.appendChild(a),i.appendChild(r),t.appendChild(i)}_renderSheddingSection(t,e){if(!e.entities?.select)return;const i=document.createElement("div");i.className="section",i.innerHTML=``;const r=document.createElement("div");r.className="field-row";const o=document.createElement("span");o.className="field-label",o.textContent=n("sidepanel.priority_label");const a=document.createElement("select");a.dataset.role="shedding-select";const s=e.entities.select,l=this._hass?.states?.[s]?.state||"";for(const t of Kt){const e=v[t];if(!e)continue;const i=document.createElement("option");i.value=t,i.textContent=n(`shedding.select.${t}`)||e.label(),t===l&&(i.selected=!0),a.appendChild(i)}a.addEventListener("change",()=>{this._callService("select","select_option",{entity_id:s,option:a.value}).catch(t=>{console.warn("SPAN Panel: shedding update failed",t),this.errorStore?.add({key:"service:shedding",level:"error",message:n("error.shedding_failed"),persistent:!1})})}),r.appendChild(o),r.appendChild(a),i.appendChild(r),t.appendChild(i)}_renderGraphHorizonSection(t,e){const i=document.createElement("div");i.className="section";const r=document.createElement("div");r.className="section-label",r.textContent=n("sidepanel.graph_horizon"),i.appendChild(r);const s=e.graphHorizonInfo,l=!0===s?.has_override,c=s?.horizon||o,u=s?.globalHorizon||o,h=document.createElement("div");h.className="horizon-bar";const d=[{key:"global",label:n("sidepanel.global")}];for(const t of Object.keys(a))d.push({key:t,label:t});const p=l?c:"global",f=t=>{for(const e of h.querySelectorAll(".horizon-segment")){const n=e.dataset.horizon;e.classList.toggle("active",n===t),e.classList.toggle("referenced","global"===t&&n===u)}};for(const{key:t,label:i}of d){const r=document.createElement("button");r.type="button",r.className="horizon-segment",r.dataset.horizon=t,r.textContent=i,r.classList.toggle("active",t===p),r.classList.toggle("referenced","global"===p&&t===u),r.addEventListener("click",()=>{if(r.classList.contains("active"))return;const i={circuit_id:e.uuid};e.configEntryId&&(i.config_entry_id=e.configEntryId),"global"===t?(f("global"),this._callDomainService("clear_circuit_graph_horizon",i).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})):(f(t),this._callDomainService("set_circuit_graph_horizon",{...i,horizon:t}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})}))}),h.appendChild(r)}i.appendChild(h),t.appendChild(i)}_renderMonitoringSection(t,e){const i=document.createElement("div");i.className="section";const r=document.createElement("div");r.className="monitoring-header";const o=document.createElement("div");o.className="section-label",o.textContent=n("sidepanel.monitoring"),o.style.margin="0";const a=document.createElement("span-switch");a.dataset.role="monitoring-toggle";const s=e.monitoringInfo,l=null!=s&&!1!==s.monitoring_enabled;l&&a.setAttribute("checked",""),r.appendChild(o),r.appendChild(a),i.appendChild(r);const c=document.createElement("div");c.dataset.role="monitoring-details",c.style.display=l?"block":"none",i.appendChild(c);const u=!0===s?.has_override,h=document.createElement("div");h.className="radio-group",h.innerHTML=`\n \n \n `,c.appendChild(h);const d=document.createElement("div");d.dataset.role="threshold-fields",d.style.display=u?"block":"none";const p=s?.continuous_threshold_pct??80,f=s?.spike_threshold_pct??100,g=s?.window_duration_m??15,v=s?.cooldown_duration_m??15;d.appendChild(this._createThresholdRow(n("sidepanel.continuous_pct"),"continuous",p,e)),d.appendChild(this._createThresholdRow(n("sidepanel.spike_pct"),"spike",f,e)),d.appendChild(this._createDurationRow(n("sidepanel.window_duration"),"window-m",g,1,180,"m",e)),d.appendChild(this._createDurationRow(n("sidepanel.cooldown"),"cooldown-m",v,1,180,"m",e)),c.appendChild(d),a.addEventListener("change",()=>{const t=a.checked;c.style.display=t?"block":"none";const i={circuit_id:e.entities?.power||e.uuid,monitoring_enabled:t};e.configEntryId&&(i.config_entry_id=e.configEntryId),this._callDomainService("set_circuit_threshold",i).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})})});const m=h.querySelectorAll('input[type="radio"]');for(const t of m)t.addEventListener("change",()=>{const i="custom"===t.value&&t.checked;if(d.style.display=i?"block":"none",!i&&t.checked){const t={circuit_id:e.entities?.power||e.uuid};e.configEntryId&&(t.config_entry_id=e.configEntryId),this._callDomainService("clear_circuit_threshold",t).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})})}});t.appendChild(i)}_createThresholdRow(t,e,i,r){const o=document.createElement("div");o.className="field-row";const a=document.createElement("span");a.className="field-label",a.textContent=t;const s=document.createElement("input");return s.type="number",s.min="0",s.max="200",s.value=String(i),s.dataset.role=`threshold-${e}`,s.addEventListener("input",()=>{this._debounce(`threshold-${e}`,p,()=>{const t=this.shadowRoot;if(!t)return;const e=t.querySelector('[data-role="threshold-continuous"]'),i=t.querySelector('[data-role="threshold-spike"]'),o=t.querySelector('[data-role="threshold-window-m"]'),a=t.querySelector('[data-role="threshold-cooldown-m"]'),s={circuit_id:r.entities?.power||r.uuid,continuous_threshold_pct:e?Number(e.value):void 0,spike_threshold_pct:i?Number(i.value):void 0,window_duration_m:o?Number(o.value):void 0,cooldown_duration_m:a?Number(a.value):void 0};r.configEntryId&&(s.config_entry_id=r.configEntryId),this._callDomainService("set_circuit_threshold",s).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})})})}),o.appendChild(a),o.appendChild(s),o}_createDurationRow(t,e,i,r,o,a,s,l=!1){const c=document.createElement("div");c.className="field-row";const u=document.createElement("span");u.className="field-label",u.textContent=t;const h=document.createElement("div"),d=document.createElement("input");d.type="number",d.min=String(r),d.max=String(o),d.value=String(i),d.dataset.role=`threshold-${e}`,l&&(d.disabled=!0);const f=document.createElement("span");return f.textContent=a,h.appendChild(d),h.appendChild(f),l||d.addEventListener("input",()=>{this._debounce(`threshold-${e}`,p,()=>{const t=this.shadowRoot;if(!t)return;const e=t.querySelector('[data-role="threshold-continuous"]'),i=t.querySelector('[data-role="threshold-spike"]'),r=t.querySelector('[data-role="threshold-window-m"]'),o={circuit_id:s.uuid,continuous_threshold_pct:e?Number(e.value):void 0,spike_threshold_pct:i?Number(i.value):void 0,window_duration_m:r?Number(r.value):void 0};s.configEntryId&&(o.config_entry_id=s.configEntryId),this._callDomainService("set_circuit_threshold",o).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})})})}),c.appendChild(u),c.appendChild(h),c}_updateLiveState(){if(!this._config||this._config.panelMode)return;const t=this._config;if(!t.subDeviceMode&&!t.favoritesMode){if(t.entities?.switch){const e=this.shadowRoot?.querySelector('[data-role="relay-toggle"]');if(e){const n=this._hass?.states?.[t.entities.switch]?.state;"on"===n?e.setAttribute("checked",""):e.removeAttribute("checked")}}if(t.entities?.select){const e=this.shadowRoot?.querySelector('[data-role="shedding-select"]');if(e){const n=this._hass?.states?.[t.entities.select]?.state||"";e.value=n}}}}_callService(t,e,n){return this._hass?Promise.resolve(this._hass.callService(t,e,n)):Promise.resolve()}_callDomainService(t,e){return this._hass?this._hass.callWS({type:"call_service",domain:s,service:t,service_data:e}):Promise.resolve()}_debounce(t,e,n){this._debounceTimers[t]&&clearTimeout(this._debounceTimers[t]),this._debounceTimers[t]=setTimeout(()=>{delete this._debounceTimers[t],n()},e)}}try{customElements.get("span-side-panel")||customElements.define("span-side-panel",Qt)}catch{}class Jt extends Dt{constructor(){super(...arguments),this._store=null,this._unsub=null,this._errors=[]}set store(t){if(this._store===t)return;this._unsub?.(),this._unsub=null,this._store=t,this._errors=t.active;const e=t;this._unsub=t.subscribe(()=>{this._errors=e.active})}connectedCallback(){if(super.connectedCallback(),this._store&&!this._unsub){const t=this._store;this._errors=t.active,this._unsub=t.subscribe(()=>{this._errors=t.active})}}disconnectedCallback(){super.disconnectedCallback(),this._unsub?.(),this._unsub=null}render(){return 0===this._errors.length?pt:ut`${this._errors.map(t=>ut` + */let $t=class extends Ot{constructor(t){if(super(t),this.it=pt,t.type!==Nt)throw Error(this.constructor.directiveName+"() can only be used in child bindings")}render(t){if(t===pt||null==t)return this._t=void 0,this.it=t;if(t===ht)return t;if("string"!=typeof t)throw Error(this.constructor.directiveName+"() called with a non-string value");if(t===this.it)return this._t;this.it=t;const e=[t];return e.raw=e,this._t={_$litType$:this.constructor.resultType,strings:e,values:[]}}};$t.directiveName="unsafeHTML",$t.resultType=1;const Rt=(t=>(...e)=>({_$litDirective$:t,values:e}))($t);class Ht{constructor(){this._persistent=new Map,this._transient=null,this._transientTimer=null,this._subscribers=new Set,this._watchedPanels=new Map}add(t){const e={...t,timestamp:Date.now()};if(e.persistent)this._persistent.set(e.key,e);else{this._clearTransient(),this._transient=e;const t=e.ttl??5e3;this._transientTimer=setTimeout(()=>{this._transient=null,this._transientTimer=null,this._notify()},t)}this._notify()}remove(t){if(this._persistent.has(t))return this._persistent.delete(t),void this._notify();this._transient?.key===t&&(this._clearTransient(),this._notify())}clear(t){void 0===t?(this._persistent.clear(),this._clearTransient(),this._watchedPanels.clear()):!0===t.persistent?this._persistent.clear():!1===t.persistent&&this._clearTransient(),this._notify()}get active(){const t=[...this._persistent.values()];return null!==this._transient&&t.push(this._transient),t}hasPersistent(t){return this._persistent.has(t)}hasAnyPanelOffline(){for(const t of this._persistent.keys())if("panel-offline"===t||t.startsWith("panel-offline:"))return!0;return!1}subscribe(t){return this._subscribers.add(t),()=>{this._subscribers.delete(t)}}watchPanelStatus(t){this.watchPanelStatuses([{entityId:t,panelName:null}])}watchPanelStatuses(t){const e=this._watchedPanels,n=new Map;for(const i of t){const t=e.get(i.entityId);n.set(i.entityId,{panelName:i.panelName??null,wasOffline:t?.wasOffline??!1})}const i=this._isSingleUnnamed(e),r=this._isSingleUnnamed(n);for(const t of e.keys()){n.has(t)&&i===r||this._persistent.delete(this._offlineKey(t,i))}this._watchedPanels=n,this._notify()}clearPanelStatusWatch(){if(0===this._watchedPanels.size)return;const t=this._isSingleUnnamed(this._watchedPanels);for(const e of this._watchedPanels.keys())this._persistent.delete(this._offlineKey(e,t));this._watchedPanels.clear(),this._notify()}updateHass(t){if(0===this._watchedPanels.size)return;const e=this._isSingleUnnamed(this._watchedPanels);for(const[r,o]of this._watchedPanels){const a=t.states[r]?.state,s="on"===a,l=this._offlineKey(r,e),c=this._reconnectKey(r,e);if(s){const t=o.wasOffline;o.wasOffline=!1,this.remove(l),t&&this.add({key:c,level:"info",message:null===o.panelName?n("error.panel_reconnected"):i("error.panel_reconnected_named",{name:o.panelName}),persistent:!1})}else o.wasOffline=!0,this.hasPersistent(l)||this.add({key:l,level:"error",message:null===o.panelName?n("error.panel_offline"):i("error.panel_offline_named",{name:o.panelName}),persistent:!0})}}dispose(){this._clearTransient(),this._persistent.clear(),this._subscribers.clear(),this._watchedPanels.clear()}_isSingleUnnamed(t){if(1!==t.size)return!1;for(const e of t.values())return null===e.panelName;return!1}_offlineKey(t,e){return e?"panel-offline":`panel-offline:${t}`}_reconnectKey(t,e){return e?"panel-reconnected":`panel-reconnected:${t}`}_clearTransient(){null!==this._transientTimer&&(clearTimeout(this._transientTimer),this._transientTimer=null),this._transient=null}_notify(){for(const t of this._subscribers)try{t()}catch(t){console.warn("SPAN Panel: error-store subscriber threw",t)}}}const Ft={"&":"&","<":"<",">":">",'"':""","'":"'"};function Bt(t){return String(t).replace(/[&<>"']/g,t=>Ft[t]??t)}const Vt="span_panel_list_columns";function Wt(){try{const t=localStorage.getItem(Vt);if(!t)return 1;const e=parseInt(t,10);return 1===e||2===e||3===e?e:1}catch{return 1}}function Ut(t){try{localStorage.setItem(Vt,String(t))}catch{}}function Gt(t){return new Promise(e=>setTimeout(e,t))}class qt{constructor(t){this._store=t}async callWS(t,e,n){const i=n?.retries??3,r=n?.errorId??`ws:${String(e.type??"unknown")}`;return this._withRetry(()=>t.callWS(e),i,r,n?.errorMessage)}async callService(t,e,n,i,r,o){const a=o?.retries??3,s=o?.errorId??`svc:${e}.${n}`;return this._withRetry(()=>t.callService(e,n,i,r),a,s,o?.errorMessage)}async _withRetry(t,e,i,r){if(this._store.hasAnyPanelOffline())try{const e=await t();return this._store.remove(i),e}catch(t){const e=t instanceof Error?t:new Error(String(t));throw this._store.add({key:i,level:"error",message:r??n("error.panel_offline"),persistent:!1}),e}let o;for(let n=0;n<=e;n++)try{const e=await t();return this._store.remove(i),e}catch(t){if(o=t instanceof Error?t:new Error(String(t)),n{try{const e={type:"call_service",domain:s,service:"get_favorites",service_data:{},return_response:!0},r=this._retry?await this._retry.callWS(t,e,{errorId:"fetch:favorites",errorMessage:n("error.favorites_fetch_failed")}):await t.callWS(e),o=r?.response?.favorites??{};return i===this._generation&&(this._map=o,this._lastFetch=Date.now()),o}catch(t){return console.warn("SPAN Panel: favorites fetch failed",t),this._retry||this._errorStore?.add({key:"fetch:favorites",level:"warning",message:n("error.favorites_fetch_failed"),persistent:!1}),this._map??{}}finally{this._inflight?.gen===i&&(this._inflight=null)}})();return this._inflight={gen:i,promise:r},r}invalidate(){this._lastFetch=0,this._generation++}clear(){this._map=null,this._lastFetch=0,this._generation++}get map(){return this._map??{}}}function Zt(t){for(const e of Object.values(t)){if((e.circuits?.length??0)>0)return!0;if((e.sub_devices?.length??0)>0)return!0}return!1}const Kt=Object.keys(v).filter(t=>"unknown"!==t&&"always_on"!==t);class Qt extends HTMLElement{constructor(){super(),this.errorStore=null,this.attachShadow({mode:"open"}),this._hass=null,this._config=null,this._debounceTimers={}}set hass(t){this._hass=t,this.hasAttribute("open")&&this._config&&this._updateLiveState()}get hass(){return this._hass}disconnectedCallback(){this._clearDebounceTimers(),this._config=null}open(t){this._config=t,this._render(),this.offsetHeight,this.setAttribute("open",""),this.setAttribute("data-mode",this._modeFor(t))}close(){this._clearDebounceTimers(),this.removeAttribute("open"),this.removeAttribute("data-mode"),this._config=null,this.dispatchEvent(new CustomEvent("side-panel-closed",{bubbles:!0,composed:!0}))}_clearDebounceTimers(){for(const t of Object.keys(this._debounceTimers))clearTimeout(this._debounceTimers[t]);this._debounceTimers={}}_modeFor(t){return t.favoritesMode?"favorites":t.panelMode?"panel":t.subDeviceMode?"subDevice":"circuit"}_render(){const t=this._config;if(!t)return;const e=this.shadowRoot;if(!e)return;e.innerHTML="";const n=document.createElement("style");n.textContent='\n :host {\n display: block;\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n width: 360px;\n max-width: 90vw;\n z-index: 1000;\n transform: translateX(100%);\n transition: transform 0.3s ease;\n pointer-events: none;\n }\n :host([open]) {\n transform: translateX(0);\n pointer-events: auto;\n }\n\n .backdrop {\n display: none;\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba(0, 0, 0, 0.3);\n z-index: -1;\n }\n :host([open]) .backdrop {\n display: block;\n }\n\n .panel {\n height: 100%;\n background: var(--card-background-color, #fff);\n border-left: 1px solid var(--divider-color, #e0e0e0);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n .panel-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 16px;\n border-bottom: 1px solid var(--divider-color, #e0e0e0);\n }\n .panel-header .title {\n font-size: 18px;\n font-weight: 500;\n color: var(--primary-text-color, #212121);\n margin: 0;\n }\n .panel-header .subtitle {\n font-size: 13px;\n color: var(--secondary-text-color, #727272);\n margin: 2px 0 0 0;\n }\n .close-btn {\n background: none;\n border: none;\n cursor: pointer;\n color: var(--secondary-text-color, #727272);\n padding: 4px;\n line-height: 1;\n font-size: 20px;\n }\n\n .panel-body {\n flex: 1;\n overflow-y: auto;\n padding: 16px;\n }\n\n .section {\n margin-bottom: 20px;\n }\n .section-label {\n font-size: 12px;\n font-weight: 600;\n text-transform: uppercase;\n color: var(--secondary-text-color, #727272);\n margin: 0 0 8px 0;\n letter-spacing: 0.5px;\n }\n\n .field-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 8px 0;\n }\n .field-label {\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n }\n\n select {\n padding: 6px 8px;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 4px;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 14px;\n }\n\n input[type="number"] {\n width: 72px;\n padding: 6px 8px;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 4px;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 14px;\n text-align: right;\n }\n input[type="number"]:disabled {\n opacity: 0.5;\n }\n\n .radio-group {\n display: flex;\n gap: 16px;\n padding: 8px 0;\n }\n .radio-group label {\n display: flex;\n align-items: center;\n gap: 6px;\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n cursor: pointer;\n }\n\n .horizon-bar {\n display: flex;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 6px;\n overflow: hidden;\n margin-top: 4px;\n }\n .horizon-segment {\n flex: 1;\n padding: 6px 0;\n text-align: center;\n font-size: 13px;\n cursor: pointer;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n border: none;\n border-right: 1px solid var(--divider-color, #e0e0e0);\n transition: background 0.15s ease, color 0.15s ease;\n user-select: none;\n line-height: 1.4;\n }\n .horizon-segment:last-child {\n border-right: none;\n }\n .horizon-segment:hover:not(.active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .horizon-segment.active {\n background: var(--primary-color, #03a9f4);\n color: #fff;\n font-weight: 600;\n }\n .horizon-segment.referenced {\n box-shadow: inset 0 -3px 0 var(--primary-color, #03a9f4);\n }\n\n .unit-toggle {\n display: inline-flex;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 6px;\n overflow: hidden;\n }\n .unit-btn {\n padding: 4px 10px;\n border: none;\n border-right: 1px solid var(--divider-color, #e0e0e0);\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 13px;\n font-weight: 500;\n cursor: pointer;\n transition: background 0.15s ease, color 0.15s ease;\n }\n .unit-btn:last-child {\n border-right: none;\n }\n .unit-btn:hover:not(.unit-active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .unit-btn.unit-active {\n background: var(--primary-color, #03a9f4);\n color: #fff;\n font-weight: 600;\n }\n\n .monitoring-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n }\n\n .fav-heart {\n background: none;\n border: 1px solid var(--divider-color, #e0e0e0);\n color: var(--secondary-text-color, #727272);\n border-radius: 4px;\n padding: 2px 6px;\n cursor: pointer;\n font-size: 0.9em;\n margin-right: 6px;\n line-height: 1;\n display: inline-flex;\n align-items: center;\n }\n .fav-heart.active {\n color: var(--primary-color, #03a9f4);\n border-color: var(--primary-color, #03a9f4);\n }\n .fav-heart:hover:not(.active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .fav-heart span-icon {\n --mdc-icon-size: 16px;\n }\n\n .panel-mode-info {\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n line-height: 1.6;\n }\n .panel-mode-info p {\n margin: 0 0 12px 0;\n }\n\n',e.appendChild(n);const i=document.createElement("div");i.className="backdrop",i.addEventListener("click",()=>this.close()),e.appendChild(i);const r=document.createElement("div");r.className="panel",e.appendChild(r),t.favoritesMode?this._renderFavoritesMode(r):t.panelMode?this._renderPanelMode(r):t.subDeviceMode?this._renderSubDeviceMode(r,t):this._renderCircuitMode(r,t)}_renderPanelMode(t){const e=this._config,i=this._createHeader(n("sidepanel.graph_settings"),n("sidepanel.global_defaults"));t.appendChild(i);const r=document.createElement("div");r.className="panel-body";const s=e.graphSettings,l=e.topology,c=s?.global_horizon??o,d=s?.circuits??{};r.appendChild(this._buildListColumnsSection());const u=document.createElement("div");u.className="section";const h=document.createElement("div");h.className="section-label",h.textContent=n("sidepanel.graph_horizon"),u.appendChild(h);const f=document.createElement("div");f.className="field-row";const g=document.createElement("span");g.className="field-label",g.textContent=n("sidepanel.global_default"),f.appendChild(g);const v=document.createElement("select");for(const t of Object.keys(a)){const e=document.createElement("option");e.value=t;const i=`horizon.${t}`,r=n(i);e.textContent=r!==i?r:t,t===c&&(e.selected=!0),v.appendChild(e)}if(v.addEventListener("change",()=>{const t={horizon:v.value};e.configEntryId&&(t.config_entry_id=e.configEntryId),this._callDomainService("set_graph_time_horizon",t).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})}),f.appendChild(v),u.appendChild(f),r.appendChild(u),l?.circuits){const t=document.createElement("div");t.className="section";const i=document.createElement("div");i.className="section-label",i.textContent=n("sidepanel.circuit_scales"),t.appendChild(i);const o=Object.entries(l.circuits).sort(([,t],[,e])=>(t.name||"").localeCompare(e.name||""));for(const[n,i]of o){const r=this._buildPanelModeCircuitRow(n,i,d[n],c,e.configEntryId??null,e.showFavorites??!1,e.favoritePanelDeviceId,e.favoriteCircuitUuids);t.appendChild(r)}r.appendChild(t)}const m=s?.sub_devices??{};if(l?.sub_devices){const t=document.createElement("div");t.className="section";const i=document.createElement("div");i.className="section-label",i.textContent=n("sidepanel.subdevice_scales"),t.appendChild(i);const o=Object.entries(l.sub_devices).sort(([,t],[,e])=>(t.name||"").localeCompare(e.name||""));for(const[i,r]of o){const o=document.createElement("div");o.className="field-row";const s=document.createElement("span");if(s.className="field-label",s.textContent=r.name||i,s.style.cssText="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;",o.appendChild(s),e.showFavorites&&e.favoritePanelDeviceId){const t=this._buildSubDeviceFavoriteHeart(r.entities,e.favoriteSubDeviceIds?.has(i)??!1);t&&o.appendChild(t)}const l=m[i]||{horizon:c,has_override:!1},d=l.has_override?l.horizon:c,u=document.createElement("select");u.dataset.subdevId=i;for(const t of Object.keys(a)){const e=document.createElement("option");e.value=t;const i=`horizon.${t}`,r=n(i);e.textContent=r!==i?r:t,t===d&&(e.selected=!0),u.appendChild(e)}if(u.addEventListener("change",()=>{this._debounce(`subdev-${i}`,p,()=>{const t={subdevice_id:i,horizon:u.value};e.configEntryId&&(t.config_entry_id=e.configEntryId),this._callDomainService("set_subdevice_graph_horizon",t).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})})}),o.appendChild(u),l.has_override){const t=document.createElement("button");t.textContent="↺",t.title=n("sidepanel.reset_to_global"),Object.assign(t.style,{background:"none",border:"1px solid var(--divider-color, #e0e0e0)",color:"var(--primary-text-color)",borderRadius:"4px",padding:"3px 6px",cursor:"pointer",marginLeft:"4px",fontSize:"0.85em"}),t.addEventListener("click",()=>{const r={subdevice_id:i};e.configEntryId&&(r.config_entry_id=e.configEntryId),this._callDomainService("clear_subdevice_graph_horizon",r).then(()=>{u.value=c,t.remove(),this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})}),o.appendChild(t)}t.appendChild(o)}r.appendChild(t)}t.appendChild(r)}_buildPanelModeCircuitRow(t,e,i,r,o,s,l,c){const d=document.createElement("div");d.className="field-row";const u=document.createElement("span");if(u.className="field-label",u.textContent=e.name||t,u.style.cssText="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;",d.appendChild(u),s&&l){const n=this._buildFavoriteHeart(e.entities,c?.has(t)??!1);n&&d.appendChild(n)}const h=i||{horizon:r,has_override:!1},f=h.has_override?h.horizon:r,g=document.createElement("select");g.dataset.uuid=t;for(const t of Object.keys(a)){const e=document.createElement("option");e.value=t;const i=`horizon.${t}`,r=n(i);e.textContent=r!==i?r:t,t===f&&(e.selected=!0),g.appendChild(e)}if(g.addEventListener("change",()=>{this._debounce(`circuit-${t}`,p,()=>{const e={circuit_id:t,horizon:g.value};o&&(e.config_entry_id=o),this._callDomainService("set_circuit_graph_horizon",e).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})})}),d.appendChild(g),h.has_override){const e=document.createElement("button");e.textContent="↺",e.title=n("sidepanel.reset_to_global"),Object.assign(e.style,{background:"none",border:"1px solid var(--divider-color, #e0e0e0)",color:"var(--primary-text-color)",borderRadius:"4px",padding:"3px 6px",cursor:"pointer",marginLeft:"4px",fontSize:"0.85em"}),e.addEventListener("click",()=>{const i={circuit_id:t};o&&(i.config_entry_id=o),this._callDomainService("clear_circuit_graph_horizon",i).then(()=>{g.value=r,e.remove(),this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})}),d.appendChild(e)}return d}_renderFavoritesMode(t){const e=this._config,i=this._createHeader(n("sidepanel.graph_settings"),n("sidepanel.favorites_subtitle"));t.appendChild(i);const r=document.createElement("div");r.className="panel-body",r.appendChild(this._buildListColumnsSection());for(const t of e.perPanelSections)r.appendChild(this._buildFavoritesPanelSection(t));t.appendChild(r)}_buildFavoritesPanelSection(t){const e=document.createElement("div");e.className="section";const n=document.createElement("div");n.className="section-label",n.textContent=t.panelName,e.appendChild(n);const i=t.graphSettings?.global_horizon??o,r=t.graphSettings?.circuits??{},a=function(t){const e=t.circuits??{};return Object.entries(e).map(([t,e])=>({uuid:t,circuit:e})).sort((t,e)=>(t.circuit.name||"").localeCompare(e.circuit.name||""))}(t.topology);for(const{uuid:n,circuit:o}of a){const a=this._buildPanelModeCircuitRow(n,o,r[n],i,t.configEntryId,!0,t.panelDeviceId,t.favoriteCircuitUuids);e.appendChild(a)}return e}_renderCircuitMode(t,e){const n=`${Bt(String(e.breaker_rating_a))}A · ${Bt(String(e.voltage))}V · Tabs [${Bt(String(e.tabs))}]`,i=this._createHeader(Bt(e.name),n);t.appendChild(i);const r=document.createElement("div");r.className="panel-body",t.appendChild(r),this._renderRelaySection(r,e),e.showFavorites&&this._renderFavoriteSection(r,e),this._renderSheddingSection(r,e),this._renderGraphHorizonSection(r,e),e.showMonitoring&&this._renderMonitoringSection(r,e)}_favoriteEntityId(t){return t?.current??t?.power??null}_subDeviceFavoriteEntityId(t){if(!t)return null;let e=null;for(const[n,i]of Object.entries(t)){if("sensor"===i.domain)return n;e||(e=n)}return e}_buildSubDeviceFavoriteHeart(t,e){const n=this._subDeviceFavoriteEntityId(t);return n?this._buildHeartButton(n,e):null}_buildListColumnsSection(){const t=document.createElement("div");t.className="section";const e=document.createElement("div");e.className="section-label",e.textContent=n("sidepanel.list_view_columns"),t.appendChild(e);const i=document.createElement("div");i.className="field-row";const r=document.createElement("span");r.className="field-label",r.textContent=n("sidepanel.columns"),i.appendChild(r);const o=Wt(),a=document.createElement("div");a.className="unit-toggle";for(const t of[1,2,3]){const e=document.createElement("button");e.type="button",e.className="unit-btn"+(t===o?" unit-active":""),e.dataset.columns=String(t),e.textContent=String(t),e.addEventListener("click",()=>{Ut(t);for(const t of a.querySelectorAll(".unit-btn"))t.classList.toggle("unit-active",t===e);this.dispatchEvent(new CustomEvent("list-columns-changed",{detail:t,bubbles:!0,composed:!0}))}),a.appendChild(e)}return i.appendChild(a),t.appendChild(i),t}_buildFavoriteHeart(t,e){const n=this._favoriteEntityId(t);return n?this._buildHeartButton(n,e):(console.warn("SPAN Panel: circuit has no current/power sensor; favorite heart suppressed"),null)}_buildHeartButton(t,e){const i=document.createElement("button");i.type="button",i.className=e?"fav-heart active":"fav-heart",i.dataset.role="fav-heart",i.title=n("sidepanel.save_to_favorites"),i.setAttribute("role","switch"),i.setAttribute("aria-checked",String(e)),i.setAttribute("aria-label",n("sidepanel.save_to_favorites"));const r=document.createElement("span-icon");return r.setAttribute("icon",e?"mdi:heart":"mdi:heart-outline"),i.appendChild(r),i.addEventListener("click",e=>{e.stopPropagation(),this._toggleFavoriteEntity(i,r,t).catch(()=>{})}),i}async _toggleFavoriteEntity(t,e,i){if(!this._hass)return;const r=t.classList.contains("active"),o=!r;t.classList.toggle("active",o),e.setAttribute("icon",o?"mdi:heart":"mdi:heart-outline"),t.setAttribute("aria-checked",String(o));try{o?await async function(t,e){const n=await Xt(t,"add_favorite",{entity_id:e});return document.dispatchEvent(new CustomEvent(jt)),n?.favorites??{}}(this._hass,i):await async function(t,e){const n=await Xt(t,"remove_favorite",{entity_id:e});return document.dispatchEvent(new CustomEvent(jt)),n?.favorites??{}}(this._hass,i)}catch(i){throw t.classList.toggle("active",r),e.setAttribute("icon",r?"mdi:heart":"mdi:heart-outline"),t.setAttribute("aria-checked",String(r)),console.warn("SPAN Panel: favorite toggle failed",i),this.errorStore?.add({key:"service:favorites",level:"error",message:n("error.favorites_toggle_failed"),persistent:!1}),i}}_renderFavoriteSection(t,e){const n=this._favoriteEntityId(e.entities);n&&this._appendFavoriteHeartSection(t,n,!0===e.isFavorite)}_appendFavoriteHeartSection(t,e,i){const r=document.createElement("div");r.className="section",r.innerHTML=``;const o=document.createElement("div");o.className="field-row";const a=document.createElement("span");a.className="field-label",a.textContent=n("sidepanel.save_to_favorites"),o.appendChild(a),o.appendChild(this._buildHeartButton(e,i)),r.appendChild(o),t.appendChild(r)}_renderSubDeviceMode(t,e){const n=this._createHeader(Bt(e.name),Bt(e.deviceType));t.appendChild(n);const i=document.createElement("div");i.className="panel-body",t.appendChild(i),e.showFavorites&&this._renderSubDeviceFavoriteSection(i,e),this._renderSubDeviceHorizonSection(i,e)}_renderSubDeviceFavoriteSection(t,e){const n=this._subDeviceFavoriteEntityId(e.entities);n&&this._appendFavoriteHeartSection(t,n,!0===e.isFavorite)}_renderSubDeviceHorizonSection(t,e){const i=document.createElement("div");i.className="section";const r=document.createElement("div");r.className="section-label",r.textContent=n("sidepanel.graph_horizon"),i.appendChild(r);const s=e.graphHorizonInfo,l=!0===s?.has_override,c=s?.horizon||o,d=s?.globalHorizon||o,u=document.createElement("div");u.className="horizon-bar";const h=[{key:"global",label:n("sidepanel.global")}];for(const t of Object.keys(a))h.push({key:t,label:t});const p=l?c:"global",f=t=>{for(const e of u.querySelectorAll(".horizon-segment")){const n=e.dataset.horizon;e.classList.toggle("active",n===t),e.classList.toggle("referenced","global"===t&&n===d)}};for(const{key:t,label:i}of h){const r=document.createElement("button");r.type="button",r.className="horizon-segment",r.dataset.horizon=t,r.textContent=i,r.classList.toggle("active",t===p),r.classList.toggle("referenced","global"===p&&t===d),r.addEventListener("click",()=>{if(r.classList.contains("active"))return;const i={subdevice_id:e.subDeviceId};e.configEntryId&&(i.config_entry_id=e.configEntryId),"global"===t?(f("global"),this._callDomainService("clear_subdevice_graph_horizon",i).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})):(f(t),this._callDomainService("set_subdevice_graph_horizon",{...i,horizon:t}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})}))}),u.appendChild(r)}i.appendChild(u),t.appendChild(i)}_createHeader(t,e){const n=document.createElement("div");n.className="panel-header";const i=document.createElement("div"),r=Bt(t),o=Bt(e);i.innerHTML=`
${r}
`+(o?`
${o}
`:"");const a=document.createElement("button");return a.className="close-btn",a.innerHTML="✕",a.addEventListener("click",()=>this.close()),n.appendChild(i),n.appendChild(a),n}_renderRelaySection(t,e){if(!1===e.is_user_controllable||!e.entities?.switch)return;const i=document.createElement("div");i.className="section",i.innerHTML=``;const r=document.createElement("div");r.className="field-row";const o=document.createElement("span");o.className="field-label",o.textContent=n("sidepanel.breaker");const a=document.createElement("span-switch");a.dataset.role="relay-toggle";const s=e.entities.switch,l=this._hass?.states?.[s]?.state;"on"===l&&a.setAttribute("checked",""),a.addEventListener("change",()=>{const t=a.hasAttribute("checked")||a.checked;this._callService("switch",t?"turn_on":"turn_off",{entity_id:s}).catch(t=>{console.warn("SPAN Panel: relay toggle failed",t),this.errorStore?.add({key:"service:relay",level:"error",message:n("error.relay_failed"),persistent:!1})})}),r.appendChild(o),r.appendChild(a),i.appendChild(r),t.appendChild(i)}_renderSheddingSection(t,e){if(!e.entities?.select)return;const i=document.createElement("div");i.className="section",i.innerHTML=``;const r=document.createElement("div");r.className="field-row";const o=document.createElement("span");o.className="field-label",o.textContent=n("sidepanel.priority_label");const a=document.createElement("select");a.dataset.role="shedding-select";const s=e.entities.select,l=this._hass?.states?.[s]?.state||"";for(const t of Kt){const e=v[t];if(!e)continue;const i=document.createElement("option");i.value=t,i.textContent=n(`shedding.select.${t}`)||e.label(),t===l&&(i.selected=!0),a.appendChild(i)}a.addEventListener("change",()=>{this._callService("select","select_option",{entity_id:s,option:a.value}).catch(t=>{console.warn("SPAN Panel: shedding update failed",t),this.errorStore?.add({key:"service:shedding",level:"error",message:n("error.shedding_failed"),persistent:!1})})}),r.appendChild(o),r.appendChild(a),i.appendChild(r),t.appendChild(i)}_renderGraphHorizonSection(t,e){const i=document.createElement("div");i.className="section";const r=document.createElement("div");r.className="section-label",r.textContent=n("sidepanel.graph_horizon"),i.appendChild(r);const s=e.graphHorizonInfo,l=!0===s?.has_override,c=s?.horizon||o,d=s?.globalHorizon||o,u=document.createElement("div");u.className="horizon-bar";const h=[{key:"global",label:n("sidepanel.global")}];for(const t of Object.keys(a))h.push({key:t,label:t});const p=l?c:"global",f=t=>{for(const e of u.querySelectorAll(".horizon-segment")){const n=e.dataset.horizon;e.classList.toggle("active",n===t),e.classList.toggle("referenced","global"===t&&n===d)}};for(const{key:t,label:i}of h){const r=document.createElement("button");r.type="button",r.className="horizon-segment",r.dataset.horizon=t,r.textContent=i,r.classList.toggle("active",t===p),r.classList.toggle("referenced","global"===p&&t===d),r.addEventListener("click",()=>{if(r.classList.contains("active"))return;const i={circuit_id:e.uuid};e.configEntryId&&(i.config_entry_id=e.configEntryId),"global"===t?(f("global"),this._callDomainService("clear_circuit_graph_horizon",i).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})):(f(t),this._callDomainService("set_circuit_graph_horizon",{...i,horizon:t}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})}))}),u.appendChild(r)}i.appendChild(u),t.appendChild(i)}_renderMonitoringSection(t,e){const i=document.createElement("div");i.className="section";const r=document.createElement("div");r.className="monitoring-header";const o=document.createElement("div");o.className="section-label",o.textContent=n("sidepanel.monitoring"),o.style.margin="0";const a=document.createElement("span-switch");a.dataset.role="monitoring-toggle";const s=e.monitoringInfo,l=null!=s&&!1!==s.monitoring_enabled;l&&a.setAttribute("checked",""),r.appendChild(o),r.appendChild(a),i.appendChild(r);const c=document.createElement("div");c.dataset.role="monitoring-details",c.style.display=l?"block":"none",i.appendChild(c);const d=!0===s?.has_override,u=document.createElement("div");u.className="radio-group",u.innerHTML=`\n \n \n `,c.appendChild(u);const h=document.createElement("div");h.dataset.role="threshold-fields",h.style.display=d?"block":"none";const p=s?.continuous_threshold_pct??80,f=s?.spike_threshold_pct??100,g=s?.window_duration_m??15,v=s?.cooldown_duration_m??15;h.appendChild(this._createThresholdRow(n("sidepanel.continuous_pct"),"continuous",p,e)),h.appendChild(this._createThresholdRow(n("sidepanel.spike_pct"),"spike",f,e)),h.appendChild(this._createDurationRow(n("sidepanel.window_duration"),"window-m",g,1,180,"m",e)),h.appendChild(this._createDurationRow(n("sidepanel.cooldown"),"cooldown-m",v,1,180,"m",e)),c.appendChild(h),a.addEventListener("change",()=>{const t=a.checked;c.style.display=t?"block":"none";const i={circuit_id:e.entities?.power||e.uuid,monitoring_enabled:t};e.configEntryId&&(i.config_entry_id=e.configEntryId),this._callDomainService("set_circuit_threshold",i).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})})});const m=u.querySelectorAll('input[type="radio"]');for(const t of m)t.addEventListener("change",()=>{const i="custom"===t.value&&t.checked;if(h.style.display=i?"block":"none",!i&&t.checked){const t={circuit_id:e.entities?.power||e.uuid};e.configEntryId&&(t.config_entry_id=e.configEntryId),this._callDomainService("clear_circuit_threshold",t).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})})}});t.appendChild(i)}_createThresholdRow(t,e,i,r){const o=document.createElement("div");o.className="field-row";const a=document.createElement("span");a.className="field-label",a.textContent=t;const s=document.createElement("input");return s.type="number",s.min="0",s.max="200",s.value=String(i),s.dataset.role=`threshold-${e}`,s.addEventListener("input",()=>{this._debounce(`threshold-${e}`,p,()=>{const t=this.shadowRoot;if(!t)return;const e=t.querySelector('[data-role="threshold-continuous"]'),i=t.querySelector('[data-role="threshold-spike"]'),o=t.querySelector('[data-role="threshold-window-m"]'),a=t.querySelector('[data-role="threshold-cooldown-m"]'),s={circuit_id:r.entities?.power||r.uuid,continuous_threshold_pct:e?Number(e.value):void 0,spike_threshold_pct:i?Number(i.value):void 0,window_duration_m:o?Number(o.value):void 0,cooldown_duration_m:a?Number(a.value):void 0};r.configEntryId&&(s.config_entry_id=r.configEntryId),this._callDomainService("set_circuit_threshold",s).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})})})}),o.appendChild(a),o.appendChild(s),o}_createDurationRow(t,e,i,r,o,a,s,l=!1){const c=document.createElement("div");c.className="field-row";const d=document.createElement("span");d.className="field-label",d.textContent=t;const u=document.createElement("div"),h=document.createElement("input");h.type="number",h.min=String(r),h.max=String(o),h.value=String(i),h.dataset.role=`threshold-${e}`,l&&(h.disabled=!0);const f=document.createElement("span");return f.textContent=a,u.appendChild(h),u.appendChild(f),l||h.addEventListener("input",()=>{this._debounce(`threshold-${e}`,p,()=>{const t=this.shadowRoot;if(!t)return;const e=t.querySelector('[data-role="threshold-continuous"]'),i=t.querySelector('[data-role="threshold-spike"]'),r=t.querySelector('[data-role="threshold-window-m"]'),o={circuit_id:s.uuid,continuous_threshold_pct:e?Number(e.value):void 0,spike_threshold_pct:i?Number(i.value):void 0,window_duration_m:r?Number(r.value):void 0};s.configEntryId&&(o.config_entry_id=s.configEntryId),this._callDomainService("set_circuit_threshold",o).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})})})}),c.appendChild(d),c.appendChild(u),c}_updateLiveState(){if(!this._config||this._config.panelMode)return;const t=this._config;if(!t.subDeviceMode&&!t.favoritesMode){if(t.entities?.switch){const e=this.shadowRoot?.querySelector('[data-role="relay-toggle"]');if(e){const n=this._hass?.states?.[t.entities.switch]?.state;"on"===n?e.setAttribute("checked",""):e.removeAttribute("checked")}}if(t.entities?.select){const e=this.shadowRoot?.querySelector('[data-role="shedding-select"]');if(e){const n=this._hass?.states?.[t.entities.select]?.state||"";e.value=n}}}}_callService(t,e,n){return this._hass?Promise.resolve(this._hass.callService(t,e,n)):Promise.resolve()}_callDomainService(t,e){return this._hass?this._hass.callWS({type:"call_service",domain:s,service:t,service_data:e}):Promise.resolve()}_debounce(t,e,n){this._debounceTimers[t]&&clearTimeout(this._debounceTimers[t]),this._debounceTimers[t]=setTimeout(()=>{delete this._debounceTimers[t],n()},e)}}try{customElements.get("span-side-panel")||customElements.define("span-side-panel",Qt)}catch{}class Jt extends Dt{constructor(){super(...arguments),this._store=null,this._unsub=null,this._errors=[]}set store(t){if(this._store===t)return;this._unsub?.(),this._unsub=null,this._store=t,this._errors=t.active;const e=t;this._unsub=t.subscribe(()=>{this._errors=e.active})}connectedCallback(){if(super.connectedCallback(),this._store&&!this._unsub){const t=this._store;this._errors=t.active,this._unsub=t.subscribe(()=>{this._errors=t.active})}}disconnectedCallback(){super.disconnectedCallback(),this._unsub?.(),this._unsub=null}render(){return 0===this._errors.length?pt:dt`${this._errors.map(t=>dt` `)}`}_iconForLevel(t){switch(t){case"error":return"mdi:alert-circle";case"warning":return"mdi:alert";default:return"mdi:information"}}}Jt.styles=M` :host { @@ -102,7 +102,7 @@ const Pt={attribute:!0,type:String,converter:H,reflect:!1,hasChanged:F},Lt=(t=Pt .retry-btn:hover { opacity: 0.8; } - `,_([zt()],Jt.prototype,"_errors",void 0);try{customElements.get("span-error-banner")||customElements.define("span-error-banner",Jt)}catch{}var te="M3,6H21V8H3V6M3,11H21V13H3V11M3,16H21V18H3V16Z";const ee=Object.freeze({"mdi:alert":"M13 14H11V9H13M13 18H11V16H13M1 21H23L12 2L1 21Z","mdi:alert-circle":"M13,13H11V7H13M13,17H11V15H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z","mdi:battery":"M16.67,4H15V2H9V4H7.33A1.33,1.33 0 0,0 6,5.33V20.67C6,21.4 6.6,22 7.33,22H16.67A1.33,1.33 0 0,0 18,20.67V5.33C18,4.6 17.4,4 16.67,4Z","mdi:battery-alert-variant-outline":"M14 20H6V6H14M14.67 4H13V2H7V4H5.33C4.6 4 4 4.6 4 5.33V20.67C4 21.4 4.6 22 5.33 22H14.67C15.4 22 16 21.4 16 20.67V5.33C16 4.6 15.4 4 14.67 4M21 7H19V13H21V8M21 15H19V17H21V15Z","mdi:chevron-down":"M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z","mdi:close":"M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z","mdi:cog":"M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.21,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.21,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.67 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z","mdi:heart":"M12,21.35L10.55,20.03C5.4,15.36 2,12.27 2,8.5C2,5.41 4.42,3 7.5,3C9.24,3 10.91,3.81 12,5.08C13.09,3.81 14.76,3 16.5,3C19.58,3 22,5.41 22,8.5C22,12.27 18.6,15.36 13.45,20.03L12,21.35Z","mdi:heart-outline":"M12.1,18.55L12,18.65L11.89,18.55C7.14,14.24 4,11.39 4,8.5C4,6.5 5.5,5 7.5,5C9.04,5 10.54,6 11.07,7.36H12.93C13.46,6 14.96,5 16.5,5C18.5,5 20,6.5 20,8.5C20,11.39 16.86,14.24 12.1,18.55M16.5,3C14.76,3 13.09,3.81 12,5.08C10.91,3.81 9.24,3 7.5,3C4.42,3 2,5.41 2,8.5C2,12.27 5.4,15.36 10.55,20.03L12,21.35L13.45,20.03C18.6,15.36 22,12.27 22,8.5C22,5.41 19.58,3 16.5,3Z","mdi:help":"M10,19H13V22H10V19M12,2C17.35,2.22 19.68,7.62 16.5,11.67C15.67,12.67 14.33,13.33 13.67,14.17C13,15 13,16 13,17H10C10,15.33 10,13.92 10.67,12.92C11.33,11.92 12.67,11.33 13.5,10.67C15.92,8.43 15.32,5.26 12,5A3,3 0 0,0 9,8H6A6,6 0 0,1 12,2Z","mdi:help-circle-outline":"M11,18H13V16H11V18M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,6A4,4 0 0,0 8,10H10A2,2 0 0,1 12,8A2,2 0 0,1 14,10C14,12 11,11.75 11,15H13C13,12.75 16,12.5 16,10A4,4 0 0,0 12,6Z","mdi:home-group":"M17,16H15V22H12V17H8V22H5V16H3L10,10L17,16M6,2L10,6H9V9H7V6H5V9H3V6H2L6,2M18,3L23,8H22V12H19V9H17V12H15.34L14,10.87V8H13L18,3Z","mdi:information":"M13,9H11V7H13M13,17H11V11H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z","mdi:lock":"M12,17A2,2 0 0,0 14,15C14,13.89 13.1,13 12,13A2,2 0 0,0 10,15A2,2 0 0,0 12,17M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6A2,2 0 0,1 4,20V10C4,8.89 4.9,8 6,8H7V6A5,5 0 0,1 12,1A5,5 0 0,1 17,6V8H18M12,3A3,3 0 0,0 9,6V8H15V6A3,3 0 0,0 12,3Z","mdi:lock-open":"M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6C4.89,22 4,21.1 4,20V10A2,2 0 0,1 6,8H15V6A3,3 0 0,0 12,3A3,3 0 0,0 9,6H7A5,5 0 0,1 12,1A5,5 0 0,1 17,6V8H18M12,17A2,2 0 0,0 14,15A2,2 0 0,0 12,13A2,2 0 0,0 10,15A2,2 0 0,0 12,17Z","mdi:menu":te,"mdi:monitor-eye":"M3 4V16H21V4H3M3 2H21C22.1 2 23 2.89 23 4V16C23 16.53 22.79 17.04 22.41 17.41C22.04 17.79 21.53 18 21 18H14V20H16V22H8V20H10V18H3C2.47 18 1.96 17.79 1.59 17.41C1.21 17.04 1 16.53 1 16V4C1 2.89 1.89 2 3 2M10.84 8.93C11.15 8.63 11.57 8.45 12 8.45C12.43 8.46 12.85 8.63 13.16 8.94C13.46 9.24 13.64 9.66 13.64 10.09C13.64 10.53 13.46 10.94 13.16 11.25C12.85 11.56 12.43 11.73 12 11.73C11.57 11.73 11.15 11.55 10.84 11.25C10.54 10.94 10.36 10.53 10.36 10.09C10.36 9.66 10.54 9.24 10.84 8.93M10.07 12C10.58 12.53 11.28 12.82 12 12.82C12.72 12.82 13.42 12.53 13.93 12C14.44 11.5 14.73 10.81 14.73 10.09C14.73 9.37 14.44 8.67 13.93 8.16C13.42 7.65 12.72 7.36 12 7.36C11.28 7.36 10.58 7.65 10.07 8.16C9.56 8.67 9.27 9.37 9.27 10.09C9.27 10.81 9.56 11.5 10.07 12M6 10.09C6.94 7.7 9.27 6 12 6C14.73 6 17.06 7.7 18 10.09C17.06 12.5 14.73 14.18 12 14.18C9.27 14.18 6.94 12.5 6 10.09Z","mdi:router-wireless":"M20.2,5.9L21,5.1C19.6,3.7 17.8,3 16,3C14.2,3 12.4,3.7 11,5.1L11.8,5.9C13,4.8 14.5,4.2 16,4.2C17.5,4.2 19,4.8 20.2,5.9M19.3,6.7C18.4,5.8 17.2,5.3 16,5.3C14.8,5.3 13.6,5.8 12.7,6.7L13.5,7.5C14.2,6.8 15.1,6.5 16,6.5C16.9,6.5 17.8,6.8 18.5,7.5L19.3,6.7M19,13H17V9H15V13H5A2,2 0 0,0 3,15V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V15A2,2 0 0,0 19,13M8,18H6V16H8V18M11.5,18H9.5V16H11.5V18M15,18H13V16H15V18Z","mdi:sort-descending":"M19 7H22L18 3L14 7H17V21H19M2 17H12V19H2M6 5V7H2V5M2 11H9V13H2V11Z","mdi:transmission-tower":"M8.28,5.45L6.5,4.55L7.76,2H16.23L17.5,4.55L15.72,5.44L15,4H9L8.28,5.45M18.62,8H14.09L13.3,5H10.7L9.91,8H5.38L4.1,10.55L5.89,11.44L6.62,10H17.38L18.1,11.45L19.89,10.56L18.62,8M17.77,22H15.7L15.46,21.1L12,15.9L8.53,21.1L8.3,22H6.23L9.12,11H11.19L10.83,12.35L12,14.1L13.16,12.35L12.81,11H14.88L17.77,22M11.4,15L10.5,13.65L9.32,18.13L11.4,15M14.68,18.12L13.5,13.64L12.6,15L14.68,18.12Z","mdi:view-dashboard":"M13,3V9H21V3M13,21H21V11H13M3,21H11V15H3M3,13H11V3H3V13Z"}),ne=new Set;class ie extends Dt{constructor(){super(...arguments),this.icon=""}render(){if(!this.icon)return pt;const t=ee[this.icon];return t?ut``:(e=this.icon,ne.has(e)||(ne.add(e),console.warn(`SPAN: unknown icon "${e}". Add it to MDI_PATHS in span-icon.ts.`)),pt);var e}}ie.styles=M` + `,_([zt()],Jt.prototype,"_errors",void 0);try{customElements.get("span-error-banner")||customElements.define("span-error-banner",Jt)}catch{}var te="M3,6H21V8H3V6M3,11H21V13H3V11M3,16H21V18H3V16Z";const ee=Object.freeze({"mdi:alert":"M13 14H11V9H13M13 18H11V16H13M1 21H23L12 2L1 21Z","mdi:alert-circle":"M13,13H11V7H13M13,17H11V15H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z","mdi:battery":"M16.67,4H15V2H9V4H7.33A1.33,1.33 0 0,0 6,5.33V20.67C6,21.4 6.6,22 7.33,22H16.67A1.33,1.33 0 0,0 18,20.67V5.33C18,4.6 17.4,4 16.67,4Z","mdi:battery-alert-variant-outline":"M14 20H6V6H14M14.67 4H13V2H7V4H5.33C4.6 4 4 4.6 4 5.33V20.67C4 21.4 4.6 22 5.33 22H14.67C15.4 22 16 21.4 16 20.67V5.33C16 4.6 15.4 4 14.67 4M21 7H19V13H21V8M21 15H19V17H21V15Z","mdi:chevron-down":"M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z","mdi:close":"M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z","mdi:cog":"M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.21,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.21,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.67 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z","mdi:heart":"M12,21.35L10.55,20.03C5.4,15.36 2,12.27 2,8.5C2,5.41 4.42,3 7.5,3C9.24,3 10.91,3.81 12,5.08C13.09,3.81 14.76,3 16.5,3C19.58,3 22,5.41 22,8.5C22,12.27 18.6,15.36 13.45,20.03L12,21.35Z","mdi:heart-outline":"M12.1,18.55L12,18.65L11.89,18.55C7.14,14.24 4,11.39 4,8.5C4,6.5 5.5,5 7.5,5C9.04,5 10.54,6 11.07,7.36H12.93C13.46,6 14.96,5 16.5,5C18.5,5 20,6.5 20,8.5C20,11.39 16.86,14.24 12.1,18.55M16.5,3C14.76,3 13.09,3.81 12,5.08C10.91,3.81 9.24,3 7.5,3C4.42,3 2,5.41 2,8.5C2,12.27 5.4,15.36 10.55,20.03L12,21.35L13.45,20.03C18.6,15.36 22,12.27 22,8.5C22,5.41 19.58,3 16.5,3Z","mdi:help":"M10,19H13V22H10V19M12,2C17.35,2.22 19.68,7.62 16.5,11.67C15.67,12.67 14.33,13.33 13.67,14.17C13,15 13,16 13,17H10C10,15.33 10,13.92 10.67,12.92C11.33,11.92 12.67,11.33 13.5,10.67C15.92,8.43 15.32,5.26 12,5A3,3 0 0,0 9,8H6A6,6 0 0,1 12,2Z","mdi:help-circle-outline":"M11,18H13V16H11V18M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,6A4,4 0 0,0 8,10H10A2,2 0 0,1 12,8A2,2 0 0,1 14,10C14,12 11,11.75 11,15H13C13,12.75 16,12.5 16,10A4,4 0 0,0 12,6Z","mdi:home-group":"M17,16H15V22H12V17H8V22H5V16H3L10,10L17,16M6,2L10,6H9V9H7V6H5V9H3V6H2L6,2M18,3L23,8H22V12H19V9H17V12H15.34L14,10.87V8H13L18,3Z","mdi:information":"M13,9H11V7H13M13,17H11V11H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z","mdi:lock":"M12,17A2,2 0 0,0 14,15C14,13.89 13.1,13 12,13A2,2 0 0,0 10,15A2,2 0 0,0 12,17M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6A2,2 0 0,1 4,20V10C4,8.89 4.9,8 6,8H7V6A5,5 0 0,1 12,1A5,5 0 0,1 17,6V8H18M12,3A3,3 0 0,0 9,6V8H15V6A3,3 0 0,0 12,3Z","mdi:lock-open":"M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6C4.89,22 4,21.1 4,20V10A2,2 0 0,1 6,8H15V6A3,3 0 0,0 12,3A3,3 0 0,0 9,6H7A5,5 0 0,1 12,1A5,5 0 0,1 17,6V8H18M12,17A2,2 0 0,0 14,15A2,2 0 0,0 12,13A2,2 0 0,0 10,15A2,2 0 0,0 12,17Z","mdi:menu":te,"mdi:monitor-eye":"M3 4V16H21V4H3M3 2H21C22.1 2 23 2.89 23 4V16C23 16.53 22.79 17.04 22.41 17.41C22.04 17.79 21.53 18 21 18H14V20H16V22H8V20H10V18H3C2.47 18 1.96 17.79 1.59 17.41C1.21 17.04 1 16.53 1 16V4C1 2.89 1.89 2 3 2M10.84 8.93C11.15 8.63 11.57 8.45 12 8.45C12.43 8.46 12.85 8.63 13.16 8.94C13.46 9.24 13.64 9.66 13.64 10.09C13.64 10.53 13.46 10.94 13.16 11.25C12.85 11.56 12.43 11.73 12 11.73C11.57 11.73 11.15 11.55 10.84 11.25C10.54 10.94 10.36 10.53 10.36 10.09C10.36 9.66 10.54 9.24 10.84 8.93M10.07 12C10.58 12.53 11.28 12.82 12 12.82C12.72 12.82 13.42 12.53 13.93 12C14.44 11.5 14.73 10.81 14.73 10.09C14.73 9.37 14.44 8.67 13.93 8.16C13.42 7.65 12.72 7.36 12 7.36C11.28 7.36 10.58 7.65 10.07 8.16C9.56 8.67 9.27 9.37 9.27 10.09C9.27 10.81 9.56 11.5 10.07 12M6 10.09C6.94 7.7 9.27 6 12 6C14.73 6 17.06 7.7 18 10.09C17.06 12.5 14.73 14.18 12 14.18C9.27 14.18 6.94 12.5 6 10.09Z","mdi:router-wireless":"M20.2,5.9L21,5.1C19.6,3.7 17.8,3 16,3C14.2,3 12.4,3.7 11,5.1L11.8,5.9C13,4.8 14.5,4.2 16,4.2C17.5,4.2 19,4.8 20.2,5.9M19.3,6.7C18.4,5.8 17.2,5.3 16,5.3C14.8,5.3 13.6,5.8 12.7,6.7L13.5,7.5C14.2,6.8 15.1,6.5 16,6.5C16.9,6.5 17.8,6.8 18.5,7.5L19.3,6.7M19,13H17V9H15V13H5A2,2 0 0,0 3,15V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V15A2,2 0 0,0 19,13M8,18H6V16H8V18M11.5,18H9.5V16H11.5V18M15,18H13V16H15V18Z","mdi:sort-descending":"M19 7H22L18 3L14 7H17V21H19M2 17H12V19H2M6 5V7H2V5M2 11H9V13H2V11Z","mdi:transmission-tower":"M8.28,5.45L6.5,4.55L7.76,2H16.23L17.5,4.55L15.72,5.44L15,4H9L8.28,5.45M18.62,8H14.09L13.3,5H10.7L9.91,8H5.38L4.1,10.55L5.89,11.44L6.62,10H17.38L18.1,11.45L19.89,10.56L18.62,8M17.77,22H15.7L15.46,21.1L12,15.9L8.53,21.1L8.3,22H6.23L9.12,11H11.19L10.83,12.35L12,14.1L13.16,12.35L12.81,11H14.88L17.77,22M11.4,15L10.5,13.65L9.32,18.13L11.4,15M14.68,18.12L13.5,13.64L12.6,15L14.68,18.12Z","mdi:view-dashboard":"M13,3V9H21V3M13,21H21V11H13M3,21H11V15H3M3,13H11V3H3V13Z"}),ne=new Set;class ie extends Dt{constructor(){super(...arguments),this.icon=""}render(){if(!this.icon)return pt;const t=ee[this.icon];return t?dt``:(e=this.icon,ne.has(e)||(ne.add(e),console.warn(`SPAN: unknown icon "${e}". Add it to MDI_PATHS in span-icon.ts.`)),pt);var e}}ie.styles=M` :host { display: inline-flex; align-items: center; @@ -119,7 +119,7 @@ const Pt={attribute:!0,type:String,converter:H,reflect:!1,hasChanged:F},Lt=(t=Pt display: block; fill: currentColor; } - `,_([Et({type:String})],ie.prototype,"icon",void 0);try{customElements.get("span-icon")||customElements.define("span-icon",ie)}catch{}class re extends Dt{constructor(){super(...arguments),this.checked=!1,this.disabled=!1,this._onActivate=t=>{if(this.disabled)return t.preventDefault(),void t.stopPropagation();this.checked=!this.checked,this.dispatchEvent(new Event("change",{bubbles:!0,composed:!0}))},this._onKeydown=t=>{" "!==t.key&&"Enter"!==t.key||(t.preventDefault(),this._onActivate(t))}}connectedCallback(){super.connectedCallback(),this.hasAttribute("tabindex")||this.setAttribute("tabindex","0"),this.hasAttribute("role")||this.setAttribute("role","switch"),this.setAttribute("aria-checked",String(this.checked)),this.addEventListener("click",this._onActivate),this.addEventListener("keydown",this._onKeydown)}disconnectedCallback(){this.removeEventListener("click",this._onActivate),this.removeEventListener("keydown",this._onKeydown),super.disconnectedCallback()}updated(t){t.has("checked")&&this.setAttribute("aria-checked",String(this.checked)),t.has("disabled")&&this.setAttribute("aria-disabled",String(this.disabled))}render(){return ut` + `,_([Et({type:String})],ie.prototype,"icon",void 0);try{customElements.get("span-icon")||customElements.define("span-icon",ie)}catch{}class re extends Dt{constructor(){super(...arguments),this.checked=!1,this.disabled=!1,this._onActivate=t=>{if(this.disabled)return t.preventDefault(),void t.stopPropagation();this.checked=!this.checked,this.dispatchEvent(new Event("change",{bubbles:!0,composed:!0}))},this._onKeydown=t=>{" "!==t.key&&"Enter"!==t.key||(t.preventDefault(),this._onActivate(t))}}connectedCallback(){super.connectedCallback(),this.hasAttribute("tabindex")||this.setAttribute("tabindex","0"),this.hasAttribute("role")||this.setAttribute("role","switch"),this.setAttribute("aria-checked",String(this.checked)),this.addEventListener("click",this._onActivate),this.addEventListener("keydown",this._onKeydown)}disconnectedCallback(){this.removeEventListener("click",this._onActivate),this.removeEventListener("keydown",this._onKeydown),super.disconnectedCallback()}updated(t){t.has("checked")&&this.setAttribute("aria-checked",String(this.checked)),t.has("disabled")&&this.setAttribute("aria-disabled",String(this.disabled))}render(){return dt`
@@ -176,9 +176,9 @@ const Pt={attribute:!0,type:String,converter:H,reflect:!1,hasChanged:F},Lt=(t=Pt outline: 2px solid var(--span-switch-on); outline-offset: 2px; } - `,_([Et({type:Boolean,reflect:!0})],re.prototype,"checked",void 0),_([Et({type:Boolean,reflect:!0})],re.prototype,"disabled",void 0);try{customElements.get("span-switch")||customElements.define("span-switch",re)}catch{}class oe extends Dt{constructor(){super(...arguments),this.narrow=!1,this._toggle=()=>{this.dispatchEvent(new CustomEvent("hass-toggle-menu",{bubbles:!0,composed:!0}))}}render(){return ut` + `,_([Et({type:Boolean,reflect:!0})],re.prototype,"checked",void 0),_([Et({type:Boolean,reflect:!0})],re.prototype,"disabled",void 0);try{customElements.get("span-switch")||customElements.define("span-switch",re)}catch{}class oe extends Dt{constructor(){super(...arguments),this.narrow=!1,this._toggle=()=>{this.dispatchEvent(new CustomEvent("hass-toggle-menu",{bubbles:!0,composed:!0}))}}render(){return dt` `}}oe.styles=M` :host { @@ -214,7 +214,7 @@ const Pt={attribute:!0,type:String,converter:H,reflect:!1,hasChanged:F},Lt=(t=Pt height: 24px; fill: currentColor; } - `,_([Et({type:Boolean,reflect:!0})],oe.prototype,"narrow",void 0);try{customElements.get("span-menu-button")||customElements.define("span-menu-button",oe)}catch{}async function ae(t,e){const[n,i,r]=await Promise.all([t.callWS({type:"config/area_registry/list"}),t.callWS({type:"config/entity_registry/list"}),t.callWS({type:"config/device_registry/list"})]),o=new Map;for(const t of n)o.set(t.area_id,t.name);const a=new Map;for(const t of i)t.area_id&&a.set(t.entity_id,t.area_id);const s=new Map;for(const t of r)s.set(t.id,t.area_id);let l;if(e.device_id){const t=s.get(e.device_id);t&&(l=o.get(t))}for(const t of Object.values(e.circuits)){let e;for(const n of Object.values(t.entities)){if(!n)continue;const t=a.get(n);if(t){e=o.get(t);break}}e||(e=l),t.area=e}}async function se(t,e,i){if(!e)throw new Error(n("card.device_not_found"));const r={type:`${s}/panel_topology`,device_id:e},o=i?await i.callWS(t,r,{errorId:"fetch:topology"}):await t.callWS(r),a=o.panel_size??function(t){let e=0;for(const n of Object.values(t))if(n)for(const t of n.tabs)t>e&&(e=t);return e>0?e+e%2:0}(o.circuits);if(!a)throw new Error(n("card.topology_error"));const l={type:"config/device_registry/list"},c=i?await i.callWS(t,l,{errorId:"fetch:topology"}):await t.callWS(l),u=(h=c.find(t=>t.id===e),h?{id:h.id,name:h.name,name_by_user:h.name_by_user,config_entries:h.config_entries,identifiers:h.identifiers,via_device_id:h.via_device_id,sw_version:h.sw_version,model:h.model}:null);var h;return await ae(t,o),{topology:o,panelDevice:u,panelSize:a}}function le(){return`
\n ${Object.entries(v).filter(([t])=>"unknown"!==t).map(([,t])=>{const e=Bt(t.icon),n=Bt(t.color),i=Bt(t.label());let r;if(t.icon2){r=``}else if(t.textLabel){r=`${Bt(t.textLabel)}`}else r=``;return`
${r}${i}
`}).join("")}\n
`}function ce(t,e,i){const r="current"===(e.chart_metric||"power"),o=!!t.panel_entities?.site_power,a=!!t.panel_entities?.dsm_state,s=!!t.panel_entities?.current_power,l=!!t.panel_entities?.feedthrough_power,c=!!t.panel_entities?.pv_power,u=!!t.panel_entities?.battery_level;return`\n
\n ${o?`\n
\n ${n("header.site")}\n
\n 0\n ${r?"A":"kW"}\n
\n
`:""}\n ${a?`\n
\n ${n("header.grid")}\n
\n --\n
\n
`:""}\n ${s?`\n
\n ${n("header.upstream")}\n
\n --\n ${r?"A":"kW"}\n
\n
`:""}\n ${l?`\n
\n ${n("header.downstream")}\n
\n --\n ${r?"A":"kW"}\n
\n
`:""}\n ${c?`\n
\n ${n("header.solar")}\n
\n --\n ${r?"A":"kW"}\n
\n
`:""}\n ${u?`\n
\n ${n("header.battery")}\n
\n —\n %\n
\n
`:""}\n
\n `}function ue(t,e,i={}){const r=Bt(t.device_name||n("header.default_name")),o=Bt(t.serial||""),a=Bt(t.firmware||""),s="current"===(e.chart_metric||"power"),l=!1!==i.showSwitches;return`\n
\n
\n
\n

${r}

\n ${o}\n \n ${l?`
\n ${Bt(n("header.enable_switches"))}\n
\n \n
\n
`:""}\n
\n ${ce(t,e)}\n
\n
\n
\n ${a}\n
\n \n \n
\n
\n ${le()}\n
\n
\n `}const he=f.power;function de(t){return he.unit(t)}function pe(t){return(t<0?"-":"")+he.format(t)}function fe(t){return(Math.abs(t)/1e3).toFixed(1)}function ge(t){return Math.ceil(t/2)}function ve(t){return t%2==0?1:0}function me(t){if(2!==t.length)return null;const[e,n]=[Math.min(...t),Math.max(...t)];return ge(e)===ge(n)?"row-span":ve(e)===ve(n)?"col-span":"row-span"}function ye(t){const e=t.chart_metric??r;return f[e]??f[r]}function _e(t,e){const n=function(t){return ye(t).entityRole}(e);return t.entities?.[n]??t.entities?.power??null}class be{constructor(){this._status=null,this._lastFetch=0,this._inflight=null,this._generation=0,this._errorStore=null,this._retry=null}get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t,this._retry=t?new qt(t):null}async fetch(t,e){const i=Date.now();if(this._inflight&&this._inflight.gen===this._generation)return this._inflight.promise;if(this._status&&i-this._lastFetch<3e4)return this._status;const r=this._generation,o=(async()=>{try{const i={};e&&(i.config_entry_id=e);const o={type:"call_service",domain:s,service:"get_monitoring_status",service_data:i,return_response:!0},a=this._retry?await this._retry.callWS(t,o,{errorId:"fetch:monitoring",errorMessage:n("error.monitoring_failed")}):await t.callWS(o),l=a?.response??null;return r===this._generation&&(this._status=l,this._lastFetch=Date.now()),l}catch(t){return console.warn("SPAN Panel: monitoring status fetch failed",t),r===this._generation&&(this._status=null),this._retry||this._errorStore?.add({key:"fetch:monitoring",level:"warning",message:n("error.monitoring_failed"),persistent:!1}),null}finally{this._inflight?.gen===r&&(this._inflight=null)}})();return this._inflight={gen:r,promise:o},o}invalidate(){this._lastFetch=0,this._generation++}get status(){return this._status}clear(){this._status=null,this._lastFetch=0,this._generation++}}class xe{constructor(){this._caches=new Map,this._errorStore=null}get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t;for(const e of this._caches.values())e.errorStore=t}async fetchOne(t,e){let n=this._caches.get(e);return n||(n=new be,n.errorStore=this._errorStore,this._caches.set(e,n)),n.fetch(t,e)}invalidate(){for(const t of this._caches.values())t.invalidate()}clear(){this._caches.clear()}}function we(t,e){return t?.circuits?t.circuits[e]??null:null}function Se(t,e,n,i){const r=[];return n||r.push("circuit-off"),i&&r.push("circuit-producer"),function(t){return!!t&&null!=t.over_threshold_since}(e)&&r.push("circuit-alert"),r.join(" ")}function Ce(t,e,i,r,o,a,s,u,h,d=!1){const p=e.entities?.power,f=p?a.states[p]:null,g=f&&parseFloat(f.state)||0,m=e.device_type===c||g<0,y=e.entities?.switch,_=y?a.states[y]:null,b=_?"on"===_.state:(f?.attributes?.relay_state||e.relay_state)===l,x=e.breaker_rating_a,w=x?`${Math.round(x)}A`:"",S=Bt(e.name||n("grid.unknown")),C=ye(s);let k;if("current"===C.entityRole){const t=e.entities?.current,n=t?a.states[t]:null,i=n&&parseFloat(n.state)||0;k=`${C.format(i)}A`}else k=`${pe(g)}${de(g)}`;const M=h||"unknown";let T="";if("unknown"!==M){const t=v[M]??v.unknown??{icon:"mdi:help",color:"#999",label:()=>"Unknown"},e=Bt(t.label()),n=Bt(t.icon),i=Bt(t.color);if(t.icon2){T=`\n \n \n `}else if(t.textLabel){T=`\n \n ${Bt(t.textLabel)}\n `}else T=``}const I=``;let D="",A=u?.utilization_pct??null;if(null==A&&e.breaker_rating_a){const t=e.entities?.current,n=t?a.states[t]:null,i=n?Math.abs(parseFloat(n.state)||0):0;A=Math.round(i/e.breaker_rating_a*1e3)/10}if(null!=A){D=`=80?"utilization-warning":"utilization-normal"}">${Math.round(A)}%`}return`\n
\n
\n
\n ${w?`${w}`:""}\n ${D}\n ${S}\n
\n
\n \n ${k}\n \n ${!1!==e.is_user_controllable&&e.entities?.switch?`\n
\n ${n(b?"grid.on":"grid.off")}\n \n
\n `:""}\n
\n
\n
\n ${T}\n ${I}\n
\n
\n
\n `}function ke(t,e){return`\n
\n —\n
\n `}const Me={names:["power","battery power"],suffixes:["_power"]},Te={names:["battery level","battery percentage"],suffixes:["_battery_level","_battery_percentage"]},Ie={names:["state of energy"],suffixes:["_soe_kwh"]},De={names:["nameplate capacity"],suffixes:["_nameplate_capacity"]};function Ae(t,e){if(!t.entities)return null;for(const[n,i]of Object.entries(t.entities)){if("sensor"!==i.domain)continue;const t=(i.original_name??"").toLowerCase();if(e.names.some(e=>t===e))return n;if(i.unique_id&&e.suffixes.some(t=>i.unique_id.endsWith(t)))return n}return null}function Pe(t){return Ae(t,Me)}function Le(t){return Ae(t,Te)}function Ee(t){return Ae(t,Ie)}function ze(t){return Ae(t,De)}function Ne(t,e,i){const r=!1!==i.show_battery,o=!1!==i.show_evse;if(!t.sub_devices)return"";const a=Object.entries(t.sub_devices).filter(([,t])=>!(t.type===u&&!r)&&!(t.type===h&&!o));if(0===a.length)return"";const s=[];for(const[t,n]of a){const r=Pe(n),o=n.type===u,a=o?Le(n):null,l=o?Ee(n):null,c=o?ze(n):null,h=Oe(n,e,i,new Set([r,a,l,c].filter(t=>null!==t))),d=Re(t,n,o,r,a,l);(r||d||h)&&s.push({devId:t,sub:n,powerEid:r,chartsHTML:d,entHTML:h})}if(0===s.length)return"";const l=s.filter(t=>t.sub.type===h).length;let c=0,d="";for(const{devId:t,sub:i,powerEid:r,chartsHTML:o,entHTML:a}of s){const s=i.type===h?n("subdevice.ev_charger"):i.type===u?n("subdevice.battery"):n("subdevice.fallback"),p=r?e.states[r]:void 0,f=p&&parseFloat(p.state)||0,g=i.type===u,v=i.type===h;let m="";g?m="sub-device-bess":v&&(c++,c===l&&l%2==1&&(m="sub-device-full")),d+=`\n
\n
\n ${Bt(s)}\n ${Bt(i.name||"")}\n ${r?`${pe(f)} ${de(f)}`:""}\n \n
\n ${o}\n ${a}\n
\n `}return d}function Oe(t,e,n,i){const r=n.visible_sub_entities||{};let o="";if(!t.entities)return o;for(const[n,a]of Object.entries(t.entities)){if(i.has(n))continue;if(!0!==r[n])continue;const s=e.states[n];if(!s)continue;let l=a.original_name||s.attributes.friendly_name||n;const c=t.name||"";let u;if(l.startsWith(c+" ")&&(l=l.slice(c.length+1)),e.formatEntityState)u=e.formatEntityState(s);else{u=s.state;const t=s.attributes.unit_of_measurement||"";t&&(u+=" "+t)}if("Wh"===(s.attributes.unit_of_measurement||"")){const t=parseFloat(s.state);isNaN(t)||(u=(t/1e3).toFixed(1)+" kWh")}o+=`\n
\n ${Bt(l)}:\n ${Bt(u)}\n
\n `}return o}function Re(t,e,i,r,o,a){if(i){const e=[{key:`${d}${t}_soc`,title:n("subdevice.soc"),available:!!o},{key:`${d}${t}_soe`,title:n("subdevice.soe"),available:!!a},{key:`${d}${t}_power`,title:n("subdevice.power"),available:!!r}].filter(t=>t.available);return`\n
\n ${e.map(t=>`\n
\n
${Bt(t.title)}
\n
\n
\n `).join("")}\n
\n `}return r?`
`:""}function $e(t){const e=void 0!==t.history_days||void 0!==t.history_hours||void 0!==t.history_minutes,n=60*(60*(24*(e&&parseInt(String(t.history_days))||0)+(e&&parseInt(String(t.history_hours))||0))+(e?parseInt(String(t.history_minutes))||0:5))*1e3;return Math.max(n,6e4)}function He(t){const e=a[t];return e?e.ms:a[o].ms}function Fe(t){const e=t/1e3;return e<=600?Math.ceil(e):Math.min(5e3,Math.ceil(e/5))}function Be(t){return Math.max(500,Math.floor(t/5e3))}function Ve(t,e,n,i,r,o){t.has(e)||t.set(e,[]);const a=t.get(e);a.push({time:i,value:n});const s=a.findIndex(t=>t.time>=r);s>0?a.splice(0,s):-1===s&&(a.length=0),a.length>o&&a.splice(0,a.length-o)}function We(t,e,n=500){if(0===t.length)return t;t.sort((t,e)=>t.time-e.time);const i=[t[0]];for(let e=1;e=n&&i.push(t[e]);return i.length>e&&i.splice(0,i.length-e),i}async function Ue(t,e,n,i,r){const o=new Date(Date.now()-i).toISOString(),a=i/36e5>72?"hour":"5minute",s=await t.callWS({type:"recorder/statistics_during_period",start_time:o,statistic_ids:e,period:a,types:["mean"]});for(const[t,e]of Object.entries(s)){const i=n.get(t);if(!i||!e)continue;const o=[];for(const t of e){const e=t.mean;if(null==e||!Number.isFinite(e))continue;const n=t.start;n>0&&o.push({time:n,value:e})}if(o.length>0){const t=r.get(i)||[],e=[...o,...t];e.sort((t,e)=>t.time-e.time),r.set(i,e)}}}async function Ge(t,e,n,i,r){const o=new Date(Date.now()-i).toISOString(),a=await t.callWS({type:"history/history_during_period",start_time:o,entity_ids:e,minimal_response:!0,significant_changes_only:!0,no_attributes:!0}),s=Fe(i),l=Be(i);for(const[t,e]of Object.entries(a)){const i=n.get(t);if(!i||!e)continue;const o=[];for(const t of e){const e=parseFloat(t.s);if(!Number.isFinite(e))continue;const n=1e3*(t.lu||t.lc||0);n>0&&o.push({time:n,value:e})}if(o.length>0){const t=r.get(i)||[],e=[...o,...t];r.set(i,We(e,s,l))}}}function qe(t){if(!t.sub_devices)return[];const e=[];for(const[n,i]of Object.entries(t.sub_devices)){const t={power:Pe(i)};i.type===u&&(t.soc=Le(i),t.soe=Ee(i));for(const[i,r]of Object.entries(t))r&&e.push({entityId:r,key:`${d}${n}_${i}`,devId:n})}return e}async function je(t,e,n,i,r,o){if(!e||!t)return;const a=new Map;for(const[t,i]of Object.entries(e.circuits)){const e=_e(i,n);if(!e)continue;let o;o=r&&r.has(t)?He(r.get(t)):$e(n),a.has(o)||a.set(o,{entityIds:[],uuidByEntity:new Map});const s=a.get(o);s.entityIds.push(e),s.uuidByEntity.set(e,t)}for(const{entityId:t,key:i,devId:r}of qe(e)){let e;e=o&&o.has(r)?He(o.get(r)):$e(n),a.has(e)||a.set(e,{entityIds:[],uuidByEntity:new Map});const s=a.get(e);s.entityIds.push(t),s.uuidByEntity.set(t,i)}const s=[];for(const[e,n]of a){if(0===n.entityIds.length)continue;e>2592e5?s.push(Ue(t,n.entityIds,n.uuidByEntity,e,i)):s.push(Ge(t,n.entityIds,n.uuidByEntity,e,i))}await Promise.all(s)}var Xe=function(){this.firefox=!1,this.ie=!1,this.edge=!1,this.newEdge=!1,this.weChat=!1},Ye=new function(){this.browser=new Xe,this.node=!1,this.wxa=!1,this.worker=!1,this.svgSupported=!1,this.touchEventsSupported=!1,this.pointerEventsSupported=!1,this.domSupported=!1,this.transformSupported=!1,this.transform3dSupported=!1,this.hasGlobalWindow="undefined"!=typeof window};"object"==typeof wx&&"function"==typeof wx.getSystemInfoSync?(Ye.wxa=!0,Ye.touchEventsSupported=!0):"undefined"==typeof document&&"undefined"!=typeof self?Ye.worker=!0:!Ye.hasGlobalWindow||"Deno"in window||"undefined"!=typeof navigator&&"string"==typeof navigator.userAgent&&navigator.userAgent.indexOf("Node.js")>-1?(Ye.node=!0,Ye.svgSupported=!0):function(t,e){var n=e.browser,i=t.match(/Firefox\/([\d.]+)/),r=t.match(/MSIE\s([\d.]+)/)||t.match(/Trident\/.+?rv:(([\d.]+))/),o=t.match(/Edge?\/([\d.]+)/),a=/micromessenger/i.test(t);i&&(n.firefox=!0,n.version=i[1]);r&&(n.ie=!0,n.version=r[1]);o&&(n.edge=!0,n.version=o[1],n.newEdge=+o[1].split(".")[0]>18);a&&(n.weChat=!0);e.svgSupported="undefined"!=typeof SVGRect,e.touchEventsSupported="ontouchstart"in window&&!n.ie&&!n.edge,e.pointerEventsSupported="onpointerdown"in window&&(n.edge||n.ie&&+n.version>=11);var s=e.domSupported="undefined"!=typeof document;if(s){var l=document.documentElement.style;e.transform3dSupported=(n.ie&&"transition"in l||n.edge||"WebKitCSSMatrix"in window&&"m11"in new WebKitCSSMatrix||"MozPerspective"in l)&&!("OTransition"in l),e.transformSupported=e.transform3dSupported||n.ie&&+n.version>=9}}(navigator.userAgent,Ye);var Ze="sans-serif",Ke="12px "+Ze;var Qe,Je,tn=function(t){var e={};if("undefined"==typeof JSON)return e;for(var n=0;n=0)o=r*t.length;else for(var a=0;a>1)%2;a.style.cssText=["position: absolute","visibility: hidden","padding: 0","margin: 0","border-width: 0","user-select: none","width:0","height:0",i[s]+":0",r[l]+":0",i[1-s]+":auto",r[1-l]+":auto",""].join("!important;"),t.appendChild(a),n.push(a)}return e.clearMarkers=function(){Cn(n,function(t){t.parentNode&&t.parentNode.removeChild(t)})},n}(e,o),s=function(t,e,n){for(var i=n?"invTrans":"trans",r=e[i],o=e.srcCoords,a=[],s=[],l=!0,c=0;c<4;c++){var u=t[c].getBoundingClientRect(),h=2*c,d=u.left,p=u.top;a.push(d,p),l=l&&o&&d===o[h]&&p===o[h+1],s.push(t[c].offsetLeft,t[c].offsetTop)}return l&&r?r:(e.srcCoords=a,e[i]=n?Si(s,a):Si(a,s))}(a,o,r);if(s)return s(t,n,i),!0}return!1}function Ti(t){return"CANVAS"===t.nodeName.toUpperCase()}var Ii=/([&<>"'])/g,Di={"&":"&","<":"<",">":">",'"':""","'":"'"};function Ai(t){return null==t?"":(t+"").replace(Ii,function(t,e){return Di[e]})}var Pi=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,Li=[],Ei=Ye.browser.firefox&&+Ye.browser.version.split(".")[0]<39;function zi(t,e,n,i){return n=n||{},i?Ni(t,e,n):Ei&&null!=e.layerX&&e.layerX!==e.offsetX?(n.zrX=e.layerX,n.zrY=e.layerY):null!=e.offsetX?(n.zrX=e.offsetX,n.zrY=e.offsetY):Ni(t,e,n),n}function Ni(t,e,n){if(Ye.domSupported&&t.getBoundingClientRect){var i=e.clientX,r=e.clientY;if(Ti(t)){var o=t.getBoundingClientRect();return n.zrX=i-o.left,void(n.zrY=r-o.top)}if(Mi(Li,t,i,r))return n.zrX=Li[0],void(n.zrY=Li[1])}n.zrX=n.zrY=0}function Oi(t){return t||window.event}function Ri(t,e,n){if(null!=(e=Oi(e)).zrX)return e;var i=e.type;if(i&&i.indexOf("touch")>=0){var r="touchend"!==i?e.targetTouches[0]:e.changedTouches[0];r&&zi(t,r,e,n)}else{zi(t,e,e,n);var o=function(t){var e=t.wheelDelta;if(e)return e;var n=t.deltaX,i=t.deltaY;if(null==n||null==i)return e;return 3*(0!==i?Math.abs(i):Math.abs(n))*(i>0?-1:i<0?1:n>0?-1:1)}(e);e.zrDelta=o?o/120:-(e.detail||0)/3}var a=e.button;return null==e.which&&void 0!==a&&Pi.test(e.type)&&(e.which=1&a?1:2&a?3:4&a?2:0),e}function $i(t,e,n,i){t.removeEventListener(e,n,i)}var Hi=function(t){t.preventDefault(),t.stopPropagation(),t.cancelBubble=!0},Fi=function(){function t(){this._track=[]}return t.prototype.recognize=function(t,e,n){return this._doTrack(t,e,n),this._recognize(t)},t.prototype.clear=function(){return this._track.length=0,this},t.prototype._doTrack=function(t,e,n){var i=t.touches;if(i){for(var r={points:[],touches:[],target:e,event:t},o=0,a=i.length;o1&&r&&r.length>1){var a=Bi(r)/Bi(o);!isFinite(a)&&(a=1),e.pinchScale=a;var s=[((i=r)[0][0]+i[1][0])/2,(i[0][1]+i[1][1])/2];return e.pinchX=s[0],e.pinchY=s[1],{type:"pinch",target:t[0].target,event:e}}}}};function Wi(t){return t[0]=1,t[1]=0,t[2]=0,t[3]=1,t[4]=0,t[5]=0,t}function Ui(t,e){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t[4]=e[4],t[5]=e[5],t}function Gi(t,e,n){var i=e[0]*n[0]+e[2]*n[1],r=e[1]*n[0]+e[3]*n[1],o=e[0]*n[2]+e[2]*n[3],a=e[1]*n[2]+e[3]*n[3],s=e[0]*n[4]+e[2]*n[5]+e[4],l=e[1]*n[4]+e[3]*n[5]+e[5];return t[0]=i,t[1]=r,t[2]=o,t[3]=a,t[4]=s,t[5]=l,t}function qi(t,e,n){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t[4]=e[4]+n[0],t[5]=e[5]+n[1],t}function ji(t,e,n,i){void 0===i&&(i=[0,0]);var r=e[0],o=e[2],a=e[4],s=e[1],l=e[3],c=e[5],u=Math.sin(n),h=Math.cos(n);return t[0]=r*h+s*u,t[1]=-r*u+s*h,t[2]=o*h+l*u,t[3]=-o*u+h*l,t[4]=h*(a-i[0])+u*(c-i[1])+i[0],t[5]=h*(c-i[1])-u*(a-i[0])+i[1],t}function Xi(t,e){var n=e[0],i=e[2],r=e[4],o=e[1],a=e[3],s=e[5],l=n*a-o*i;return l?(l=1/l,t[0]=a*l,t[1]=-o*l,t[2]=-i*l,t[3]=n*l,t[4]=(i*s-a*r)*l,t[5]=(o*r-n*s)*l,t):null}var Yi=function(){function t(t,e){this.x=t||0,this.y=e||0}return t.prototype.copy=function(t){return this.x=t.x,this.y=t.y,this},t.prototype.clone=function(){return new t(this.x,this.y)},t.prototype.set=function(t,e){return this.x=t,this.y=e,this},t.prototype.equal=function(t){return t.x===this.x&&t.y===this.y},t.prototype.add=function(t){return this.x+=t.x,this.y+=t.y,this},t.prototype.scale=function(t){this.x*=t,this.y*=t},t.prototype.scaleAndAdd=function(t,e){this.x+=t.x*e,this.y+=t.y*e},t.prototype.sub=function(t){return this.x-=t.x,this.y-=t.y,this},t.prototype.dot=function(t){return this.x*t.x+this.y*t.y},t.prototype.len=function(){return Math.sqrt(this.x*this.x+this.y*this.y)},t.prototype.lenSquare=function(){return this.x*this.x+this.y*this.y},t.prototype.normalize=function(){var t=this.len();return this.x/=t,this.y/=t,this},t.prototype.distance=function(t){var e=this.x-t.x,n=this.y-t.y;return Math.sqrt(e*e+n*n)},t.prototype.distanceSquare=function(t){var e=this.x-t.x,n=this.y-t.y;return e*e+n*n},t.prototype.negate=function(){return this.x=-this.x,this.y=-this.y,this},t.prototype.transform=function(t){if(t){var e=this.x,n=this.y;return this.x=t[0]*e+t[2]*n+t[4],this.y=t[1]*e+t[3]*n+t[5],this}},t.prototype.toArray=function(t){return t[0]=this.x,t[1]=this.y,t},t.prototype.fromArray=function(t){this.x=t[0],this.y=t[1]},t.set=function(t,e,n){t.x=e,t.y=n},t.copy=function(t,e){t.x=e.x,t.y=e.y},t.len=function(t){return Math.sqrt(t.x*t.x+t.y*t.y)},t.lenSquare=function(t){return t.x*t.x+t.y*t.y},t.dot=function(t,e){return t.x*e.x+t.y*e.y},t.add=function(t,e,n){t.x=e.x+n.x,t.y=e.y+n.y},t.sub=function(t,e,n){t.x=e.x-n.x,t.y=e.y-n.y},t.scale=function(t,e,n){t.x=e.x*n,t.y=e.y*n},t.scaleAndAdd=function(t,e,n,i){t.x=e.x+n.x*i,t.y=e.y+n.y*i},t.lerp=function(t,e,n,i){var r=1-i;t.x=r*e.x+i*n.x,t.y=r*e.y+i*n.y},t}(),Zi=Math.min,Ki=Math.max,Qi=Math.abs,Ji=["x","y"],tr=["width","height"],er=new Yi,nr=new Yi,ir=new Yi,rr=new Yi,or=pr(),ar=or.minTv,sr=or.maxTv,lr=[0,0],cr=function(){function t(e,n,i,r){t.set(this,e,n,i,r)}return t.set=function(t,e,n,i,r){return i<0&&(e+=i,i=-i),r<0&&(n+=r,r=-r),t.x=e,t.y=n,t.width=i,t.height=r,t},t.prototype.union=function(t){var e=Zi(t.x,this.x),n=Zi(t.y,this.y);isFinite(this.x)&&isFinite(this.width)?this.width=Ki(t.x+t.width,this.x+this.width)-e:this.width=t.width,isFinite(this.y)&&isFinite(this.height)?this.height=Ki(t.y+t.height,this.y+this.height)-n:this.height=t.height,this.x=e,this.y=n},t.prototype.applyTransform=function(e){t.applyTransform(this,this,e)},t.prototype.calculateTransform=function(t){var e=this,n=t.width/e.width,i=t.height/e.height,r=[1,0,0,1,0,0];return qi(r,r,[-e.x,-e.y]),function(t,e,n){var i=n[0],r=n[1];t[0]=e[0]*i,t[1]=e[1]*r,t[2]=e[2]*i,t[3]=e[3]*r,t[4]=e[4]*i,t[5]=e[5]*r}(r,r,[n,i]),qi(r,r,[t.x,t.y]),r},t.prototype.intersect=function(e,n,i){return t.intersect(this,e,n,i)},t.intersect=function(e,n,i,r){i&&Yi.set(i,0,0);var o=r&&r.outIntersectRect||null,a=r&&r.clamp;if(o&&(o.x=o.y=o.width=o.height=NaN),!e||!n)return!1;e instanceof t||(e=t.set(ur,e.x,e.y,e.width,e.height)),n instanceof t||(n=t.set(hr,n.x,n.y,n.width,n.height));var s=!!i;or.reset(r,s);var l=or.touchThreshold,c=e.x+l,u=e.x+e.width-l,h=e.y+l,d=e.y+e.height-l,p=n.x+l,f=n.x+n.width-l,g=n.y+l,v=n.y+n.height-l;if(c>u||h>d||p>f||g>v)return!1;var m=!(u=t.x&&e<=t.x+t.width&&n>=t.y&&n<=t.y+t.height},t.prototype.contain=function(e,n){return t.contain(this,e,n)},t.prototype.clone=function(){return new t(this.x,this.y,this.width,this.height)},t.prototype.copy=function(e){t.copy(this,e)},t.prototype.plain=function(){return{x:this.x,y:this.y,width:this.width,height:this.height}},t.prototype.isFinite=function(){return isFinite(this.x)&&isFinite(this.y)&&isFinite(this.width)&&isFinite(this.height)},t.prototype.isZero=function(){return 0===this.width||0===this.height},t.create=function(e){return new t(e.x,e.y,e.width,e.height)},t.copy=function(t,e){return t.x=e.x,t.y=e.y,t.width=e.width,t.height=e.height,t},t.applyTransform=function(e,n,i){if(i){if(i[1]<1e-5&&i[1]>-1e-5&&i[2]<1e-5&&i[2]>-1e-5){var r=i[0],o=i[3],a=i[4],s=i[5];return e.x=n.x*r+a,e.y=n.y*o+s,e.width=n.width*r,e.height=n.height*o,e.width<0&&(e.x+=e.width,e.width=-e.width),void(e.height<0&&(e.y+=e.height,e.height=-e.height))}er.x=ir.x=n.x,er.y=rr.y=n.y,nr.x=rr.x=n.x+n.width,nr.y=ir.y=n.y+n.height,er.transform(i),rr.transform(i),nr.transform(i),ir.transform(i),e.x=Zi(er.x,nr.x,ir.x,rr.x),e.y=Zi(er.y,nr.y,ir.y,rr.y);var l=Ki(er.x,nr.x,ir.x,rr.x),c=Ki(er.y,nr.y,ir.y,rr.y);e.width=l-e.x,e.height=c-e.y}else e!==n&&t.copy(e,n)},t}(),ur=new cr(0,0,0,0),hr=new cr(0,0,0,0);function dr(t,e,n,i,r,o,a,s){var l=Qi(e-n),c=Qi(i-t),u=Zi(l,c),h=Ji[r],d=Ji[1-r],p=tr[r];e=c||!or.bidirectional)&&(ar[h]=-c,ar[d]=0,or.useDir&&or.calcDirMTV())))}function pr(){var t=0,e=new Yi,n=new Yi,i={minTv:new Yi,maxTv:new Yi,useDir:!1,dirMinTv:new Yi,touchThreshold:0,bidirectional:!0,negativeSize:!1,reset:function(r,o){i.touchThreshold=0,r&&null!=r.touchThreshold&&(i.touchThreshold=Ki(0,r.touchThreshold)),i.negativeSize=!1,o&&(i.minTv.set(1/0,1/0),i.maxTv.set(0,0),i.useDir=!1,r&&null!=r.direction&&(i.useDir=!0,i.dirMinTv.copy(i.minTv),n.copy(i.minTv),t=r.direction,i.bidirectional=null==r.bidirectional||!!r.bidirectional,i.bidirectional||e.set(Math.cos(t),Math.sin(t))))},calcDirMTV:function(){var o=i.minTv,a=i.dirMinTv,s=o.y*o.y+o.x*o.x,l=Math.sin(t),c=Math.cos(t),u=l*o.y+c*o.x;r(u)?r(o.x)&&r(o.y)&&a.set(0,0):(n.x=s*c/u,n.y=s*l/u,r(n.x)&&r(n.y)?a.set(0,0):(i.bidirectional||e.dot(n)>0)&&n.len()=0;c--){var u=i[c];u===n||u.ignore||u.ignoreCoarsePointer||u.parent&&u.parent.ignoreCoarsePointer||(_r.copy(u.getBoundingRect()),u.transform&&_r.applyTransform(u.transform),_r.intersect(l)&&o.push(u))}if(o.length)for(var h=Math.PI/12,d=2*Math.PI,p=0;p=0;o--){var a=t[o],s=void 0;if(a!==r&&!a.ignore&&(s=xr(a,n,i))&&(!e.topTarget&&(e.topTarget=a),s!==fr)){e.target=a;break}}}function Sr(t,e,n){var i=t.painter;return e<0||e>i.getWidth()||n<0||n>i.getHeight()}Cn(["click","mousedown","mouseup","mousewheel","dblclick","contextmenu"],function(t){br.prototype[t]=function(e){var n,i,r=e.zrX,o=e.zrY,a=Sr(this,r,o);if("mouseup"===t&&a||(i=(n=this.findHover(r,o)).target),"mousedown"===t)this._downEl=i,this._downPoint=[e.zrX,e.zrY],this._upEl=i;else if("mouseup"===t)this._upEl=i;else if("click"===t){if(this._downEl!==this._upEl||!this._downPoint||pi(this._downPoint,[e.zrX,e.zrY])>4)return;this._downPoint=null}this.dispatchToElement(n,t,e)}});function Cr(t,e,n,i){var r=e+1;if(r===n)return 1;if(i(t[r++],t[e])<0){for(;r=0;)r++;return r-e}function kr(t,e,n,i,r){for(i===e&&i++;i>>1])<0?l=o:s=o+1;var c=i-s;switch(c){case 3:t[s+3]=t[s+2];case 2:t[s+2]=t[s+1];case 1:t[s+1]=t[s];break;default:for(;c>0;)t[s+c]=t[s+c-1],c--}t[s]=a}}function Mr(t,e,n,i,r,o){var a=0,s=0,l=1;if(o(t,e[n+r])>0){for(s=i-r;l0;)a=l,(l=1+(l<<1))<=0&&(l=s);l>s&&(l=s),a+=r,l+=r}else{for(s=r+1;ls&&(l=s);var c=a;a=r-l,l=r-c}for(a++;a>>1);o(t,e[n+u])>0?a=u+1:l=u}return l}function Tr(t,e,n,i,r,o){var a=0,s=0,l=1;if(o(t,e[n+r])<0){for(s=r+1;ls&&(l=s);var c=a;a=r-l,l=r-c}else{for(s=i-r;l=0;)a=l,(l=1+(l<<1))<=0&&(l=s);l>s&&(l=s),a+=r,l+=r}for(a++;a>>1);o(t,e[n+u])<0?l=u:a=u+1}return l}function Ir(t,e){var n,i,r=7,o=0,a=[];function s(s){var l=n[s],c=i[s],u=n[s+1],h=i[s+1];i[s]=c+h,s===o-3&&(n[s+1]=n[s+2],i[s+1]=i[s+2]),o--;var d=Tr(t[u],t,l,c,0,e);l+=d,0!==(c-=d)&&0!==(h=Mr(t[l+c-1],t,u,h,h-1,e))&&(c<=h?function(n,i,o,s){var l=0;for(l=0;l=7||p>=7);if(f)break;g<0&&(g=0),g+=2}if((r=g)<1&&(r=1),1===i){for(l=0;l=0;l--)t[p+l]=t[d+l];return void(t[h]=a[u])}var f=r;for(;;){var g=0,v=0,m=!1;do{if(e(a[u],t[c])<0){if(t[h--]=t[c--],g++,v=0,0===--i){m=!0;break}}else if(t[h--]=a[u--],v++,g=0,1===--s){m=!0;break}}while((g|v)=0;l--)t[p+l]=t[d+l];if(0===i){m=!0;break}}if(t[h--]=a[u--],1===--s){m=!0;break}if(0!==(v=s-Mr(t[c],a,0,s,s-1,e))){for(s-=v,p=(h-=v)+1,d=(u-=v)+1,l=0;l=7||v>=7);if(m)break;f<0&&(f=0),f+=2}(r=f)<1&&(r=1);if(1===s){for(p=(h-=i)+1,d=(c-=i)+1,l=i-1;l>=0;l--)t[p+l]=t[d+l];t[h]=a[u]}else{if(0===s)throw new Error;for(d=h-(s-1),l=0;l1;){var t=o-2;if(t>=1&&i[t-1]<=i[t]+i[t+1]||t>=2&&i[t-2]<=i[t]+i[t-1])i[t-1]i[t+1])break;s(t)}},forceMergeRuns:function(){for(;o>1;){var t=o-2;t>0&&i[t-1]=32;)e|=1&t,t>>=1;return t+e}(r);do{if((o=Cr(t,n,i,e))s&&(l=s),kr(t,n,n+l,n+o,e),o=l}a.pushRun(n,o),a.mergeRuns(),r-=o,n+=o}while(0!==r);a.forceMergeRuns()}}}var Ar=!1;function Pr(){Ar||(Ar=!0,console.warn("z / z2 / zlevel of displayable is invalid, which may cause unexpected errors"))}function Lr(t,e){return t.zlevel===e.zlevel?t.z===e.z?t.z2-e.z2:t.z-e.z:t.zlevel-e.zlevel}var Er,zr=function(){function t(){this._roots=[],this._displayList=[],this._displayListLen=0,this.displayableSortFunc=Lr}return t.prototype.traverse=function(t,e){for(var n=0;n=0&&this._roots.splice(i,1)}},t.prototype.delAllRoots=function(){this._roots=[],this._displayList=[],this._displayListLen=0},t.prototype.getRoots=function(){return this._roots},t.prototype.dispose=function(){this._displayList=null,this._roots=null},t}();Er=Ye.hasGlobalWindow&&(window.requestAnimationFrame&&window.requestAnimationFrame.bind(window)||window.msRequestAnimationFrame&&window.msRequestAnimationFrame.bind(window)||window.mozRequestAnimationFrame||window.webkitRequestAnimationFrame)||function(t){return setTimeout(t,16)};var Nr={linear:function(t){return t},quadraticIn:function(t){return t*t},quadraticOut:function(t){return t*(2-t)},quadraticInOut:function(t){return(t*=2)<1?.5*t*t:-.5*(--t*(t-2)-1)},cubicIn:function(t){return t*t*t},cubicOut:function(t){return--t*t*t+1},cubicInOut:function(t){return(t*=2)<1?.5*t*t*t:.5*((t-=2)*t*t+2)},quarticIn:function(t){return t*t*t*t},quarticOut:function(t){return 1- --t*t*t*t},quarticInOut:function(t){return(t*=2)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2)},quinticIn:function(t){return t*t*t*t*t},quinticOut:function(t){return--t*t*t*t*t+1},quinticInOut:function(t){return(t*=2)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2)},sinusoidalIn:function(t){return 1-Math.cos(t*Math.PI/2)},sinusoidalOut:function(t){return Math.sin(t*Math.PI/2)},sinusoidalInOut:function(t){return.5*(1-Math.cos(Math.PI*t))},exponentialIn:function(t){return 0===t?0:Math.pow(1024,t-1)},exponentialOut:function(t){return 1===t?1:1-Math.pow(2,-10*t)},exponentialInOut:function(t){return 0===t?0:1===t?1:(t*=2)<1?.5*Math.pow(1024,t-1):.5*(2-Math.pow(2,-10*(t-1)))},circularIn:function(t){return 1-Math.sqrt(1-t*t)},circularOut:function(t){return Math.sqrt(1- --t*t)},circularInOut:function(t){return(t*=2)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1)},elasticIn:function(t){var e,n=.1;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=.4*Math.asin(1/n)/(2*Math.PI),-n*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/.4))},elasticOut:function(t){var e,n=.1;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=.4*Math.asin(1/n)/(2*Math.PI),n*Math.pow(2,-10*t)*Math.sin((t-e)*(2*Math.PI)/.4)+1)},elasticInOut:function(t){var e,n=.1,i=.4;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=i*Math.asin(1/n)/(2*Math.PI),(t*=2)<1?n*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/i)*-.5:n*Math.pow(2,-10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/i)*.5+1)},backIn:function(t){var e=1.70158;return t*t*((e+1)*t-e)},backOut:function(t){var e=1.70158;return--t*t*((e+1)*t+e)+1},backInOut:function(t){var e=2.5949095;return(t*=2)<1?t*t*((e+1)*t-e)*.5:.5*((t-=2)*t*((e+1)*t+e)+2)},bounceIn:function(t){return 1-Nr.bounceOut(1-t)},bounceOut:function(t){return t<1/2.75?7.5625*t*t:t<2/2.75?7.5625*(t-=1.5/2.75)*t+.75:t<2.5/2.75?7.5625*(t-=2.25/2.75)*t+.9375:7.5625*(t-=2.625/2.75)*t+.984375},bounceInOut:function(t){return t<.5?.5*Nr.bounceIn(2*t):.5*Nr.bounceOut(2*t-1)+.5}},Or=Math.pow,Rr=Math.sqrt,$r=1e-8,Hr=1e-4,Fr=Rr(3),Br=1/3,Vr=ai(),Wr=ai(),Ur=ai();function Gr(t){return t>-1e-8&&t<$r}function qr(t){return t>$r||t<-1e-8}function jr(t,e,n,i,r){var o=1-r;return o*o*(o*t+3*r*e)+r*r*(r*i+3*o*n)}function Xr(t,e,n,i,r){var o=1-r;return 3*(((e-t)*o+2*(n-e)*r)*o+(i-n)*r*r)}function Yr(t,e,n,i,r,o){var a=i+3*(e-n)-t,s=3*(n-2*e+t),l=3*(e-t),c=t-r,u=s*s-3*a*l,h=s*l-9*a*c,d=l*l-3*s*c,p=0;if(Gr(u)&&Gr(h)){if(Gr(s))o[0]=0;else(C=-l/s)>=0&&C<=1&&(o[p++]=C)}else{var f=h*h-4*u*d;if(Gr(f)){var g=h/u,v=-g/2;(C=-s/a+g)>=0&&C<=1&&(o[p++]=C),v>=0&&v<=1&&(o[p++]=v)}else if(f>0){var m=Rr(f),y=u*s+1.5*a*(-h+m),_=u*s+1.5*a*(-h-m);(C=(-s-((y=y<0?-Or(-y,Br):Or(y,Br))+(_=_<0?-Or(-_,Br):Or(_,Br))))/(3*a))>=0&&C<=1&&(o[p++]=C)}else{var b=(2*u*s-3*a*h)/(2*Rr(u*u*u)),x=Math.acos(b)/3,w=Rr(u),S=Math.cos(x),C=(-s-2*w*S)/(3*a),k=(v=(-s+w*(S+Fr*Math.sin(x)))/(3*a),(-s+w*(S-Fr*Math.sin(x)))/(3*a));C>=0&&C<=1&&(o[p++]=C),v>=0&&v<=1&&(o[p++]=v),k>=0&&k<=1&&(o[p++]=k)}}return p}function Zr(t,e,n,i,r){var o=6*n-12*e+6*t,a=9*e+3*i-3*t-9*n,s=3*e-3*t,l=0;if(Gr(a)){if(qr(o))(u=-s/o)>=0&&u<=1&&(r[l++]=u)}else{var c=o*o-4*a*s;if(Gr(c))r[0]=-o/(2*a);else if(c>0){var u,h=Rr(c),d=(-o-h)/(2*a);(u=(-o+h)/(2*a))>=0&&u<=1&&(r[l++]=u),d>=0&&d<=1&&(r[l++]=d)}}return l}function Kr(t,e,n,i,r,o){var a=(e-t)*r+t,s=(n-e)*r+e,l=(i-n)*r+n,c=(s-a)*r+a,u=(l-s)*r+s,h=(u-c)*r+c;o[0]=t,o[1]=a,o[2]=c,o[3]=h,o[4]=h,o[5]=u,o[6]=l,o[7]=i}function Qr(t,e,n,i,r,o,a,s,l){for(var c=t,u=e,h=0,d=1/l,p=1;p<=l;p++){var f=p*d,g=jr(t,n,r,a,f),v=jr(e,i,o,s,f),m=g-c,y=v-u;h+=Math.sqrt(m*m+y*y),c=g,u=v}return h}function Jr(t,e,n,i){var r=1-i;return r*(r*t+2*i*e)+i*i*n}function to(t,e,n,i){return 2*((1-i)*(e-t)+i*(n-e))}function eo(t,e,n){var i=t+n-2*e;return 0===i?.5:(t-e)/i}function no(t,e,n,i,r){var o=(e-t)*i+t,a=(n-e)*i+e,s=(a-o)*i+o;r[0]=t,r[1]=o,r[2]=s,r[3]=s,r[4]=a,r[5]=n}function io(t,e,n,i,r,o,a){for(var s=t,l=e,c=0,u=1/a,h=1;h<=a;h++){var d=h*u,p=Jr(t,n,r,d),f=Jr(e,i,o,d),g=p-s,v=f-l;c+=Math.sqrt(g*g+v*v),s=p,l=f}return c}var ro=/cubic-bezier\(([0-9,\.e ]+)\)/;function oo(t){var e=t&&ro.exec(t);if(e){var n=e[1].split(","),i=+Xn(n[0]),r=+Xn(n[1]),o=+Xn(n[2]),a=+Xn(n[3]);if(isNaN(i+r+o+a))return;var s=[];return function(t){return t<=0?0:t>=1?1:Yr(0,i,o,1,t,s)&&jr(0,r,a,1,s[0])}}}var ao=function(){function t(t){this._inited=!1,this._startTime=0,this._pausedTime=0,this._paused=!1,this._life=t.life||1e3,this._delay=t.delay||0,this.loop=t.loop||!1,this.onframe=t.onframe||ri,this.ondestroy=t.ondestroy||ri,this.onrestart=t.onrestart||ri,t.easing&&this.setEasing(t.easing)}return t.prototype.step=function(t,e){if(this._inited||(this._startTime=t+this._delay,this._inited=!0),!this._paused){var n=this._life,i=t-this._startTime-this._pausedTime,r=i/n;r<0&&(r=0),r=Math.min(r,1);var o=this.easingFunc,a=o?o(r):r;if(this.onframe(a),1===r){if(!this.loop)return!0;var s=i%n;this._startTime=t-s,this._pausedTime=0,this.onrestart()}return!1}this._pausedTime+=e},t.prototype.pause=function(){this._paused=!0},t.prototype.resume=function(){this._paused=!1},t.prototype.setEasing=function(t){this.easing=t,this.easingFunc=Ln(t)?t:Nr[t]||oo(t)},t}(),so=function(t){this.value=t},lo=function(){function t(){this._len=0}return t.prototype.insert=function(t){var e=new so(t);return this.insertEntry(e),e},t.prototype.insertEntry=function(t){this.head?(this.tail.next=t,t.prev=this.tail,t.next=null,this.tail=t):this.head=this.tail=t,this._len++},t.prototype.remove=function(t){var e=t.prev,n=t.next;e?e.next=n:this.head=n,n?n.prev=e:this.tail=e,t.next=t.prev=null,this._len--},t.prototype.len=function(){return this._len},t.prototype.clear=function(){this.head=this.tail=null,this._len=0},t}(),co=function(){function t(t){this._list=new lo,this._maxSize=10,this._map={},this._maxSize=t}return t.prototype.put=function(t,e){var n=this._list,i=this._map,r=null;if(null==i[t]){var o=n.len(),a=this._lastRemovedEntry;if(o>=this._maxSize&&o>0){var s=n.head;n.remove(s),delete i[s.key],r=s.value,this._lastRemovedEntry=s}a?a.value=e:a=new so(e),a.key=t,n.insertEntry(a),i[t]=a}return r},t.prototype.get=function(t){var e=this._map[t],n=this._list;if(null!=e)return e!==n.tail&&(n.remove(e),n.insertEntry(e)),e.value},t.prototype.clear=function(){this._list.clear(),this._map={}},t.prototype.len=function(){return this._list.len()},t}(),uo={transparent:[0,0,0,0],aliceblue:[240,248,255,1],antiquewhite:[250,235,215,1],aqua:[0,255,255,1],aquamarine:[127,255,212,1],azure:[240,255,255,1],beige:[245,245,220,1],bisque:[255,228,196,1],black:[0,0,0,1],blanchedalmond:[255,235,205,1],blue:[0,0,255,1],blueviolet:[138,43,226,1],brown:[165,42,42,1],burlywood:[222,184,135,1],cadetblue:[95,158,160,1],chartreuse:[127,255,0,1],chocolate:[210,105,30,1],coral:[255,127,80,1],cornflowerblue:[100,149,237,1],cornsilk:[255,248,220,1],crimson:[220,20,60,1],cyan:[0,255,255,1],darkblue:[0,0,139,1],darkcyan:[0,139,139,1],darkgoldenrod:[184,134,11,1],darkgray:[169,169,169,1],darkgreen:[0,100,0,1],darkgrey:[169,169,169,1],darkkhaki:[189,183,107,1],darkmagenta:[139,0,139,1],darkolivegreen:[85,107,47,1],darkorange:[255,140,0,1],darkorchid:[153,50,204,1],darkred:[139,0,0,1],darksalmon:[233,150,122,1],darkseagreen:[143,188,143,1],darkslateblue:[72,61,139,1],darkslategray:[47,79,79,1],darkslategrey:[47,79,79,1],darkturquoise:[0,206,209,1],darkviolet:[148,0,211,1],deeppink:[255,20,147,1],deepskyblue:[0,191,255,1],dimgray:[105,105,105,1],dimgrey:[105,105,105,1],dodgerblue:[30,144,255,1],firebrick:[178,34,34,1],floralwhite:[255,250,240,1],forestgreen:[34,139,34,1],fuchsia:[255,0,255,1],gainsboro:[220,220,220,1],ghostwhite:[248,248,255,1],gold:[255,215,0,1],goldenrod:[218,165,32,1],gray:[128,128,128,1],green:[0,128,0,1],greenyellow:[173,255,47,1],grey:[128,128,128,1],honeydew:[240,255,240,1],hotpink:[255,105,180,1],indianred:[205,92,92,1],indigo:[75,0,130,1],ivory:[255,255,240,1],khaki:[240,230,140,1],lavender:[230,230,250,1],lavenderblush:[255,240,245,1],lawngreen:[124,252,0,1],lemonchiffon:[255,250,205,1],lightblue:[173,216,230,1],lightcoral:[240,128,128,1],lightcyan:[224,255,255,1],lightgoldenrodyellow:[250,250,210,1],lightgray:[211,211,211,1],lightgreen:[144,238,144,1],lightgrey:[211,211,211,1],lightpink:[255,182,193,1],lightsalmon:[255,160,122,1],lightseagreen:[32,178,170,1],lightskyblue:[135,206,250,1],lightslategray:[119,136,153,1],lightslategrey:[119,136,153,1],lightsteelblue:[176,196,222,1],lightyellow:[255,255,224,1],lime:[0,255,0,1],limegreen:[50,205,50,1],linen:[250,240,230,1],magenta:[255,0,255,1],maroon:[128,0,0,1],mediumaquamarine:[102,205,170,1],mediumblue:[0,0,205,1],mediumorchid:[186,85,211,1],mediumpurple:[147,112,219,1],mediumseagreen:[60,179,113,1],mediumslateblue:[123,104,238,1],mediumspringgreen:[0,250,154,1],mediumturquoise:[72,209,204,1],mediumvioletred:[199,21,133,1],midnightblue:[25,25,112,1],mintcream:[245,255,250,1],mistyrose:[255,228,225,1],moccasin:[255,228,181,1],navajowhite:[255,222,173,1],navy:[0,0,128,1],oldlace:[253,245,230,1],olive:[128,128,0,1],olivedrab:[107,142,35,1],orange:[255,165,0,1],orangered:[255,69,0,1],orchid:[218,112,214,1],palegoldenrod:[238,232,170,1],palegreen:[152,251,152,1],paleturquoise:[175,238,238,1],palevioletred:[219,112,147,1],papayawhip:[255,239,213,1],peachpuff:[255,218,185,1],peru:[205,133,63,1],pink:[255,192,203,1],plum:[221,160,221,1],powderblue:[176,224,230,1],purple:[128,0,128,1],red:[255,0,0,1],rosybrown:[188,143,143,1],royalblue:[65,105,225,1],saddlebrown:[139,69,19,1],salmon:[250,128,114,1],sandybrown:[244,164,96,1],seagreen:[46,139,87,1],seashell:[255,245,238,1],sienna:[160,82,45,1],silver:[192,192,192,1],skyblue:[135,206,235,1],slateblue:[106,90,205,1],slategray:[112,128,144,1],slategrey:[112,128,144,1],snow:[255,250,250,1],springgreen:[0,255,127,1],steelblue:[70,130,180,1],tan:[210,180,140,1],teal:[0,128,128,1],thistle:[216,191,216,1],tomato:[255,99,71,1],turquoise:[64,224,208,1],violet:[238,130,238,1],wheat:[245,222,179,1],white:[255,255,255,1],whitesmoke:[245,245,245,1],yellow:[255,255,0,1],yellowgreen:[154,205,50,1]};function ho(t){return(t=Math.round(t))<0?0:t>255?255:t}function po(t){return t<0?0:t>1?1:t}function fo(t){var e=t;return e.length&&"%"===e.charAt(e.length-1)?ho(parseFloat(e)/100*255):ho(parseInt(e,10))}function go(t){var e=t;return e.length&&"%"===e.charAt(e.length-1)?po(parseFloat(e)/100):po(parseFloat(e))}function vo(t,e,n){return n<0?n+=1:n>1&&(n-=1),6*n<1?t+(e-t)*n*6:2*n<1?e:3*n<2?t+(e-t)*(2/3-n)*6:t}function mo(t,e,n){return t+(e-t)*n}function yo(t,e,n,i,r){return t[0]=e,t[1]=n,t[2]=i,t[3]=r,t}function _o(t,e){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t}var bo=new co(20),xo=null;function wo(t,e){xo&&_o(xo,e),xo=bo.put(t,xo||e.slice())}function So(t,e){if(t){e=e||[];var n=bo.get(t);if(n)return _o(e,n);var i=(t+="").replace(/ /g,"").toLowerCase();if(i in uo)return _o(e,uo[i]),wo(t,e),e;var r,o=i.length;if("#"===i.charAt(0))return 4===o||5===o?(r=parseInt(i.slice(1,4),16))>=0&&r<=4095?(yo(e,(3840&r)>>4|(3840&r)>>8,240&r|(240&r)>>4,15&r|(15&r)<<4,5===o?parseInt(i.slice(4),16)/15:1),wo(t,e),e):void yo(e,0,0,0,1):7===o||9===o?(r=parseInt(i.slice(1,7),16))>=0&&r<=16777215?(yo(e,(16711680&r)>>16,(65280&r)>>8,255&r,9===o?parseInt(i.slice(7),16)/255:1),wo(t,e),e):void yo(e,0,0,0,1):void 0;var a=i.indexOf("("),s=i.indexOf(")");if(-1!==a&&s+1===o){var l=i.substr(0,a),c=i.substr(a+1,s-(a+1)).split(","),u=1;switch(l){case"rgba":if(4!==c.length)return 3===c.length?yo(e,+c[0],+c[1],+c[2],1):yo(e,0,0,0,1);u=go(c.pop());case"rgb":return c.length>=3?(yo(e,fo(c[0]),fo(c[1]),fo(c[2]),3===c.length?u:go(c[3])),wo(t,e),e):void yo(e,0,0,0,1);case"hsla":return 4!==c.length?void yo(e,0,0,0,1):(c[3]=go(c[3]),Co(c,e),wo(t,e),e);case"hsl":return 3!==c.length?void yo(e,0,0,0,1):(Co(c,e),wo(t,e),e);default:return}}yo(e,0,0,0,1)}}function Co(t,e){var n=(parseFloat(t[0])%360+360)%360/360,i=go(t[1]),r=go(t[2]),o=r<=.5?r*(i+1):r+i-r*i,a=2*r-o;return yo(e=e||[],ho(255*vo(a,o,n+1/3)),ho(255*vo(a,o,n)),ho(255*vo(a,o,n-1/3)),1),4===t.length&&(e[3]=t[3]),e}function ko(t,e){var n=So(t);if(n){for(var i=0;i<3;i++)n[i]=n[i]*(1-e)|0,n[i]>255?n[i]=255:n[i]<0&&(n[i]=0);return To(n,4===n.length?"rgba":"rgb")}}function Mo(t,e,n,i){var r=So(t);if(t)return r=function(t){if(t){var e,n,i=t[0]/255,r=t[1]/255,o=t[2]/255,a=Math.min(i,r,o),s=Math.max(i,r,o),l=s-a,c=(s+a)/2;if(0===l)e=0,n=0;else{n=c<.5?l/(s+a):l/(2-s-a);var u=((s-i)/6+l/2)/l,h=((s-r)/6+l/2)/l,d=((s-o)/6+l/2)/l;i===s?e=d-h:r===s?e=1/3+u-d:o===s&&(e=2/3+h-u),e<0&&(e+=1),e>1&&(e-=1)}var p=[360*e,n,c];return null!=t[3]&&p.push(t[3]),p}}(r),null!=n&&(r[1]=go(Ln(n)?n(r[1]):n)),null!=i&&(r[2]=go(Ln(i)?i(r[2]):i)),To(Co(r),"rgba")}function To(t,e){if(t&&t.length){var n=t[0]+","+t[1]+","+t[2];return"rgba"!==e&&"hsva"!==e&&"hsla"!==e||(n+=","+t[3]),e+"("+n+")"}}function Io(t,e){var n=So(t);return n?(.299*n[0]+.587*n[1]+.114*n[2])*n[3]/255+(1-n[3])*e:0}var Do=new co(100);function Ao(t){if(En(t)){var e=Do.get(t);return e||(e=ko(t,-.1),Do.put(t,e)),e}if(Fn(t)){var n=_n({},t);return n.colorStops=kn(t.colorStops,function(t){return{offset:t.offset,color:ko(t.color,-.1)}}),n}return t}var Po=Math.round;function Lo(t){var e;if(t&&"transparent"!==t){if("string"==typeof t&&t.indexOf("rgba")>-1){var n=So(t);n&&(t="rgb("+n[0]+","+n[1]+","+n[2]+")",e=n[3])}}else t="none";return{color:t,opacity:null==e?1:e}}var Eo=1e-4;function zo(t){return t-1e-4}function No(t){return Po(1e3*t)/1e3}function Oo(t){return Po(1e4*t)/1e4}var Ro={left:"start",right:"end",center:"middle",middle:"middle"};function $o(t){return t&&!!t.image}function Ho(t){return $o(t)||function(t){return t&&!!t.svgElement}(t)}function Fo(t){return"linear"===t.type}function Bo(t){return"radial"===t.type}function Vo(t){return t&&("linear"===t.type||"radial"===t.type)}function Wo(t){return"url(#"+t+")"}function Uo(t){var e=t.getGlobalScale(),n=Math.max(e[0],e[1]);return Math.max(Math.ceil(Math.log(n)/Math.log(10)),1)}function Go(t){var e=t.x||0,n=t.y||0,i=(t.rotation||0)*oi,r=Wn(t.scaleX,1),o=Wn(t.scaleY,1),a=t.skewX||0,s=t.skewY||0,l=[];return(e||n)&&l.push("translate("+e+"px,"+n+"px)"),i&&l.push("rotate("+i+")"),1===r&&1===o||l.push("scale("+r+","+o+")"),(a||s)&&l.push("skew("+Po(a*oi)+"deg, "+Po(s*oi)+"deg)"),l.join(" ")}var qo=Ye.hasGlobalWindow&&Ln(window.btoa)?function(t){return window.btoa(unescape(encodeURIComponent(t)))}:"undefined"!=typeof Buffer?function(t){return Buffer.from(t).toString("base64")}:function(t){return null},jo=Array.prototype.slice;function Xo(t,e,n){return(e-t)*n+t}function Yo(t,e,n,i){for(var r=e.length,o=0;oi?e:t,o=Math.min(n,i),a=r[o-1]||{color:[0,0,0,0],offset:0},s=o;sa)i.length=a;else for(var s=o;s=1},t.prototype.getAdditiveTrack=function(){return this._additiveTrack},t.prototype.addKeyframe=function(t,e,n){this._needsSort=!0;var i=this.keyframes,r=i.length,o=!1,a=6,s=e;if(Sn(e)){var l=function(t){return Sn(t&&t[0])?2:1}(e);a=l,(1===l&&!Nn(e[0])||2===l&&!Nn(e[0][0]))&&(o=!0)}else if(Nn(e)&&!Bn(e))a=0;else if(En(e))if(isNaN(+e)){var c=So(e);c&&(s=c,a=3)}else a=0;else if(Fn(e)){var u=_n({},s);u.colorStops=kn(e.colorStops,function(t){return{offset:t.offset,color:So(t.color)}}),Fo(e)?a=4:Bo(e)&&(a=5),s=u}0===r?this.valType=a:a===this.valType&&6!==a||(o=!0),this.discrete=this.discrete||o;var h={time:t,value:s,rawValue:e,percent:0};return n&&(h.easing=n,h.easingFunc=Ln(n)?n:Nr[n]||oo(n)),i.push(h),h},t.prototype.prepare=function(t,e){var n=this.keyframes;this._needsSort&&n.sort(function(t,e){return t.time-e.time});for(var i=this.valType,r=n.length,o=n[r-1],a=this.discrete,s=ia(i),l=na(i),c=0;c=0&&!(l[n].percent<=e);n--);n=p(n,c-2)}else{for(n=d;ne);n++);n=p(n-1,c-2)}r=l[n+1],i=l[n]}if(i&&r){this._lastFr=n,this._lastFrP=e;var f=r.percent-i.percent,g=0===f?1:p((e-i.percent)/f,1);r.easingFunc&&(g=r.easingFunc(g));var v=o?this._additiveValue:h?ra:t[u];if(!ia(s)&&!h||v||(v=this._additiveValue=[]),this.discrete)t[u]=g<1?i.rawValue:r.rawValue;else if(ia(s))1===s?Yo(v,i[a],r[a],g):function(t,e,n,i){for(var r=e.length,o=r&&e[0].length,a=0;a0&&s.addKeyframe(0,ta(l),i),this._trackKeys.push(a)}s.addKeyframe(t,ta(e[a]),i)}return this._maxTime=Math.max(this._maxTime,t),this},t.prototype.pause=function(){this._clip.pause(),this._paused=!0},t.prototype.resume=function(){this._clip.resume(),this._paused=!1},t.prototype.isPaused=function(){return!!this._paused},t.prototype.duration=function(t){return this._maxTime=t,this._force=!0,this},t.prototype._doneCallback=function(){this._setTracksFinished(),this._clip=null;var t=this._doneCbs;if(t)for(var e=t.length,n=0;n0)){this._started=1;for(var e=this,n=[],i=this._maxTime||0,r=0;r1){var a=o.pop();r.addKeyframe(a.time,t[i]),r.prepare(this._maxTime,r.getAdditiveTrack())}}}},t}();function sa(){return(new Date).getTime()}var la,ca,ua=function(t){function e(e){var n=t.call(this)||this;return n._running=!1,n._time=0,n._pausedTime=0,n._pauseStart=0,n._paused=!1,e=e||{},n.stage=e.stage||{},n}return y(e,t),e.prototype.addClip=function(t){t.animation&&this.removeClip(t),this._head?(this._tail.next=t,t.prev=this._tail,t.next=null,this._tail=t):this._head=this._tail=t,t.animation=this},e.prototype.addAnimator=function(t){t.animation=this;var e=t.getClip();e&&this.addClip(e)},e.prototype.removeClip=function(t){if(t.animation){var e=t.prev,n=t.next;e?e.next=n:this._head=n,n?n.prev=e:this._tail=e,t.next=t.prev=t.animation=null}},e.prototype.removeAnimator=function(t){var e=t.getClip();e&&this.removeClip(e),t.animation=null},e.prototype.update=function(t){for(var e=sa()-this._pausedTime,n=e-this._time,i=this._head;i;){var r=i.next;i.step(e,n)?(i.ondestroy(),this.removeClip(i),i=r):i=r}this._time=e,t||(this.trigger("frame",n),this.stage.update&&this.stage.update())},e.prototype._startLoop=function(){var t=this;this._running=!0,Er(function e(){t._running&&(Er(e),!t._paused&&t.update())})},e.prototype.start=function(){this._running||(this._time=sa(),this._pausedTime=0,this._startLoop())},e.prototype.stop=function(){this._running=!1},e.prototype.pause=function(){this._paused||(this._pauseStart=sa(),this._paused=!0)},e.prototype.resume=function(){this._paused&&(this._pausedTime+=sa()-this._pauseStart,this._paused=!1)},e.prototype.clear=function(){for(var t=this._head;t;){var e=t.next;t.prev=t.next=t.animation=null,t=e}this._head=this._tail=null},e.prototype.isFinished=function(){return null==this._head},e.prototype.animate=function(t,e){e=e||{},this.start();var n=new aa(t,e.loop);return this.addAnimator(n),n},e}(bi),ha=Ye.domSupported,da=(ca={pointerdown:1,pointerup:1,pointermove:1,pointerout:1},{mouse:la=["click","dblclick","mousewheel","wheel","mouseout","mouseup","mousedown","mousemove","contextmenu"],touch:["touchstart","touchend","touchmove"],pointer:kn(la,function(t){var e=t.replace("mouse","pointer");return ca.hasOwnProperty(e)?e:t})}),pa=["mousemove","mouseup"],fa=["pointermove","pointerup"],ga=!1;function va(t){var e=t.pointerType;return"pen"===e||"touch"===e}function ma(t){t&&(t.zrByTouch=!0)}function ya(t,e){for(var n=e,i=!1;n&&9!==n.nodeType&&!(i=n.domBelongToZr||n!==e&&n===t.painterRoot);)n=n.parentNode;return i}var _a=function(t,e){this.stopPropagation=ri,this.stopImmediatePropagation=ri,this.preventDefault=ri,this.type=e.type,this.target=this.currentTarget=t.dom,this.pointerType=e.pointerType,this.clientX=e.clientX,this.clientY=e.clientY},ba={mousedown:function(t){t=Ri(this.dom,t),this.__mayPointerCapture=[t.zrX,t.zrY],this.trigger("mousedown",t)},mousemove:function(t){t=Ri(this.dom,t);var e=this.__mayPointerCapture;!e||t.zrX===e[0]&&t.zrY===e[1]||this.__togglePointerCapture(!0),this.trigger("mousemove",t)},mouseup:function(t){t=Ri(this.dom,t),this.__togglePointerCapture(!1),this.trigger("mouseup",t)},mouseout:function(t){ya(this,(t=Ri(this.dom,t)).toElement||t.relatedTarget)||(this.__pointerCapturing&&(t.zrEventControl="no_globalout"),this.trigger("mouseout",t))},wheel:function(t){ga=!0,t=Ri(this.dom,t),this.trigger("mousewheel",t)},mousewheel:function(t){ga||(t=Ri(this.dom,t),this.trigger("mousewheel",t))},touchstart:function(t){ma(t=Ri(this.dom,t)),this.__lastTouchMoment=new Date,this.handler.processGesture(t,"start"),ba.mousemove.call(this,t),ba.mousedown.call(this,t)},touchmove:function(t){ma(t=Ri(this.dom,t)),this.handler.processGesture(t,"change"),ba.mousemove.call(this,t)},touchend:function(t){ma(t=Ri(this.dom,t)),this.handler.processGesture(t,"end"),ba.mouseup.call(this,t),+new Date-+this.__lastTouchMoment<300&&ba.click.call(this,t)},pointerdown:function(t){ba.mousedown.call(this,t)},pointermove:function(t){va(t)||ba.mousemove.call(this,t)},pointerup:function(t){ba.mouseup.call(this,t)},pointerout:function(t){va(t)||ba.mouseout.call(this,t)}};Cn(["click","dblclick","contextmenu"],function(t){ba[t]=function(e){e=Ri(this.dom,e),this.trigger(t,e)}});var xa={pointermove:function(t){va(t)||xa.mousemove.call(this,t)},pointerup:function(t){xa.mouseup.call(this,t)},mousemove:function(t){this.trigger("mousemove",t)},mouseup:function(t){var e=this.__pointerCapturing;this.__togglePointerCapture(!1),this.trigger("mouseup",t),e&&(t.zrEventControl="only_globalout",this.trigger("mouseout",t))}};function wa(t,e){var n=e.domHandlers;Ye.pointerEventsSupported?Cn(da.pointer,function(i){Ca(e,i,function(e){n[i].call(t,e)})}):(Ye.touchEventsSupported&&Cn(da.touch,function(i){Ca(e,i,function(r){n[i].call(t,r),function(t){t.touching=!0,null!=t.touchTimer&&(clearTimeout(t.touchTimer),t.touchTimer=null),t.touchTimer=setTimeout(function(){t.touching=!1,t.touchTimer=null},700)}(e)})}),Cn(da.mouse,function(i){Ca(e,i,function(r){r=Oi(r),e.touching||n[i].call(t,r)})}))}function Sa(t,e){function n(n){Ca(e,n,function(i){i=Oi(i),ya(t,i.target)||(i=function(t,e){return Ri(t.dom,new _a(t,e),!0)}(t,i),e.domHandlers[n].call(t,i))},{capture:!0})}Ye.pointerEventsSupported?Cn(fa,n):Ye.touchEventsSupported||Cn(pa,n)}function Ca(t,e,n,i){t.mounted[e]=n,t.listenerOpts[e]=i,function(t,e,n,i){t.addEventListener(e,n,i)}(t.domTarget,e,n,i)}function ka(t){var e=t.mounted;for(var n in e)e.hasOwnProperty(n)&&$i(t.domTarget,n,e[n],t.listenerOpts[n]);t.mounted={}}var Ma=function(t,e){this.mounted={},this.listenerOpts={},this.touching=!1,this.domTarget=t,this.domHandlers=e},Ta=function(t){function e(e,n){var i=t.call(this)||this;return i.__pointerCapturing=!1,i.dom=e,i.painterRoot=n,i._localHandlerScope=new Ma(e,ba),ha&&(i._globalHandlerScope=new Ma(document,xa)),wa(i,i._localHandlerScope),i}return y(e,t),e.prototype.dispose=function(){ka(this._localHandlerScope),ha&&ka(this._globalHandlerScope)},e.prototype.setCursor=function(t){this.dom.style&&(this.dom.style.cursor=t||"default")},e.prototype.__togglePointerCapture=function(t){if(this.__mayPointerCapture=null,ha&&+this.__pointerCapturing^+t){this.__pointerCapturing=t;var e=this._globalHandlerScope;t?Sa(this,e):ka(e)}},e}(bi),Ia=1;Ye.hasGlobalWindow&&(Ia=Math.max(window.devicePixelRatio||window.screen&&window.screen.deviceXDPI/window.screen.logicalXDPI||1,1));var Da=Ia,Aa="#333",Pa="#ccc",La=Wi,Ea=5e-5;function za(t){return t>Ea||t<-5e-5}var Na,Oa=[],Ra=[],$a=[1,0,0,1,0,0],Ha=Math.abs,Fa=function(){function t(){}var e;return t.prototype.getLocalTransform=function(e){return t.getLocalTransform(this,e)},t.prototype.setPosition=function(t){this.x=t[0],this.y=t[1]},t.prototype.setScale=function(t){this.scaleX=t[0],this.scaleY=t[1]},t.prototype.setSkew=function(t){this.skewX=t[0],this.skewY=t[1]},t.prototype.setOrigin=function(t){this.originX=t[0],this.originY=t[1]},t.prototype.needLocalTransform=function(){return za(this.rotation)||za(this.x)||za(this.y)||za(this.scaleX-1)||za(this.scaleY-1)||za(this.skewX)||za(this.skewY)},t.prototype.updateTransform=function(){var t=this.parent&&this.parent.transform,e=this.needLocalTransform(),n=this.transform;e||t?(n=n||[1,0,0,1,0,0],e?this.getLocalTransform(n):La(n),t&&(e?Gi(n,t,n):Ui(n,t)),this.transform=n,this._resolveGlobalScaleRatio(n)):n&&(La(n),this.invTransform=null)},t.prototype._resolveGlobalScaleRatio=function(t){var e=this.globalScaleRatio;if(null!=e&&1!==e){this.getGlobalScale(Oa);var n=Oa[0]<0?-1:1,i=Oa[1]<0?-1:1,r=((Oa[0]-n)*e+n)/Oa[0]||0,o=((Oa[1]-i)*e+i)/Oa[1]||0;t[0]*=r,t[1]*=r,t[2]*=o,t[3]*=o}this.invTransform=this.invTransform||[1,0,0,1,0,0],Xi(this.invTransform,t)},t.prototype.getComputedTransform=function(){for(var t=this,e=[];t;)e.push(t),t=t.parent;for(;t=e.pop();)t.updateTransform();return this.transform},t.prototype.setLocalTransform=function(t){if(t){var e=t[0]*t[0]+t[1]*t[1],n=t[2]*t[2]+t[3]*t[3],i=Math.atan2(t[1],t[0]),r=Math.PI/2+i-Math.atan2(t[3],t[2]);n=Math.sqrt(n)*Math.cos(r),e=Math.sqrt(e),this.skewX=r,this.skewY=0,this.rotation=-i,this.x=+t[4],this.y=+t[5],this.scaleX=e,this.scaleY=n,this.originX=0,this.originY=0}},t.prototype.decomposeTransform=function(){if(this.transform){var t=this.parent,e=this.transform;t&&t.transform&&(t.invTransform=t.invTransform||[1,0,0,1,0,0],Gi(Ra,t.invTransform,e),e=Ra);var n=this.originX,i=this.originY;(n||i)&&($a[4]=n,$a[5]=i,Gi(Ra,e,$a),Ra[4]-=n,Ra[5]-=i,e=Ra),this.setLocalTransform(e)}},t.prototype.getGlobalScale=function(t){var e=this.transform;return t=t||[],e?(t[0]=Math.sqrt(e[0]*e[0]+e[1]*e[1]),t[1]=Math.sqrt(e[2]*e[2]+e[3]*e[3]),e[0]<0&&(t[0]=-t[0]),e[3]<0&&(t[1]=-t[1]),t):(t[0]=1,t[1]=1,t)},t.prototype.transformCoordToLocal=function(t,e){var n=[t,e],i=this.invTransform;return i&&gi(n,n,i),n},t.prototype.transformCoordToGlobal=function(t,e){var n=[t,e],i=this.transform;return i&&gi(n,n,i),n},t.prototype.getLineScale=function(){var t=this.transform;return t&&Ha(t[0]-1)>1e-10&&Ha(t[3]-1)>1e-10?Math.sqrt(Ha(t[0]*t[3]-t[2]*t[1])):1},t.prototype.copyTransform=function(t){Va(this,t)},t.getLocalTransform=function(t,e){e=e||[];var n=t.originX||0,i=t.originY||0,r=t.scaleX,o=t.scaleY,a=t.anchorX,s=t.anchorY,l=t.rotation||0,c=t.x,u=t.y,h=t.skewX?Math.tan(t.skewX):0,d=t.skewY?Math.tan(-t.skewY):0;if(n||i||a||s){var p=n+a,f=i+s;e[4]=-p*r-h*f*o,e[5]=-f*o-d*p*r}else e[4]=e[5]=0;return e[0]=r,e[3]=o,e[1]=d*r,e[2]=h*o,l&&ji(e,e,l),e[4]+=n+c,e[5]+=i+u,e},t.initDefaultProps=((e=t.prototype).scaleX=e.scaleY=e.globalScaleRatio=1,void(e.x=e.y=e.originX=e.originY=e.skewX=e.skewY=e.rotation=e.anchorX=e.anchorY=0)),t}(),Ba=["x","y","originX","originY","anchorX","anchorY","rotation","scaleX","scaleY","skewX","skewY"];function Va(t,e){for(var n=0;n=Ga)){t=t||Ke;for(var e=[],n=+new Date,i=0;i<=127;i++)e[i]=en.measureText(String.fromCharCode(i),t).width;var r=+new Date-n;return r>16?Ua=Ga:r>2&&Ua++,e}}(t.font),t.asciiWidthMapTried=!0),0<=e&&e<=127?null!=t.asciiWidthMap?t.asciiWidthMap[e]:t.asciiCharWidth:t.stWideCharWidth}function ja(t,e){var n=t.strWidthCache,i=n.get(e);return null==i&&(i=en.measureText(e,t.font).width,n.put(e,i)),i}function Xa(t,e,n,i){var r=ja(Wa(e),t),o=Qa(e),a=Za(0,r,n),s=Ka(0,o,i);return new cr(a,s,r,o)}function Ya(t,e,n,i){var r=((t||"")+"").split("\n");if(1===r.length)return Xa(r[0],e,n,i);for(var o=new cr(0,0,0,0),a=0;a=0?parseFloat(t)/100*e:parseFloat(t):t}function ts(t,e,n){var i=e.position||"inside",r=null!=e.distance?e.distance:5,o=n.height,a=n.width,s=o/2,l=n.x,c=n.y,u="left",h="top";if(i instanceof Array)l+=Ja(i[0],n.width),c+=Ja(i[1],n.height),u=null,h=null;else switch(i){case"left":l-=r,c+=s,u="right",h="middle";break;case"right":l+=r+a,c+=s,h="middle";break;case"top":l+=a/2,c-=r,u="center",h="bottom";break;case"bottom":l+=a/2,c+=o+r,u="center";break;case"inside":l+=a/2,c+=s,u="center",h="middle";break;case"insideLeft":l+=r,c+=s,h="middle";break;case"insideRight":l+=a-r,c+=s,u="right",h="middle";break;case"insideTop":l+=a/2,c+=r,u="center";break;case"insideBottom":l+=a/2,c+=o-r,u="center",h="bottom";break;case"insideTopLeft":l+=r,c+=r;break;case"insideTopRight":l+=a-r,c+=r,u="right";break;case"insideBottomLeft":l+=r,c+=o-r,h="bottom";break;case"insideBottomRight":l+=a-r,c+=o-r,u="right",h="bottom"}return(t=t||{}).x=l,t.y=c,t.align=u,t.verticalAlign=h,t}var es="__zr_normal__",ns=Ba.concat(["ignore"]),is=Mn(Ba,function(t,e){return t[e]=!0,t},{ignore:!1}),rs={},os=new cr(0,0,0,0),as=[],ss=function(){function t(t){this.id=gn(),this.animators=[],this.currentStates=[],this.states={},this._init(t)}return t.prototype._init=function(t){this.attr(t)},t.prototype.drift=function(t,e,n){switch(this.draggable){case"horizontal":e=0;break;case"vertical":t=0}var i=this.transform;i||(i=this.transform=[1,0,0,1,0,0]),i[4]+=t,i[5]+=e,this.decomposeTransform(),this.markRedraw()},t.prototype.beforeUpdate=function(){},t.prototype.afterUpdate=function(){},t.prototype.update=function(){this.updateTransform(),this.__dirty&&this.updateInnerText()},t.prototype.updateInnerText=function(t){var e=this._textContent;if(e&&(!e.ignore||t)){this.textConfig||(this.textConfig={});var n=this.textConfig,i=n.local,r=e.innerTransformable,o=void 0,a=void 0,s=!1;r.parent=i?this:null;var l=!1;r.copyTransform(e);var c=null!=n.position,u=n.autoOverflowArea,h=void 0;if((u||c)&&(h=os,n.layoutRect?h.copy(n.layoutRect):h.copy(this.getBoundingRect()),i||h.applyTransform(this.transform)),c){this.calculateTextPosition?this.calculateTextPosition(rs,n,h):ts(rs,n,h),r.x=rs.x,r.y=rs.y,o=rs.align,a=rs.verticalAlign;var d=n.origin;if(d&&null!=n.rotation){var p=void 0,f=void 0;"center"===d?(p=.5*h.width,f=.5*h.height):(p=Ja(d[0],h.width),f=Ja(d[1],h.height)),l=!0,r.originX=-r.x+p+(i?0:h.x),r.originY=-r.y+f+(i?0:h.y)}}null!=n.rotation&&(r.rotation=n.rotation);var g=n.offset;g&&(r.x+=g[0],r.y+=g[1],l||(r.originX=-g[0],r.originY=-g[1]));var v=this._innerTextDefaultStyle||(this._innerTextDefaultStyle={});if(u){var m=v.overflowRect=v.overflowRect||new cr(0,0,0,0);r.getLocalTransform(as),Xi(as,as),cr.copy(m,h),m.applyTransform(as)}else v.overflowRect=null;var y=void 0,_=void 0,b=void 0;(null==n.inside?"string"==typeof n.position&&n.position.indexOf("inside")>=0:n.inside)&&this.canBeInsideText()?(y=n.insideFill,_=n.insideStroke,null!=y&&"auto"!==y||(y=this.getInsideTextFill()),null!=_&&"auto"!==_||(_=this.getInsideTextStroke(y),b=!0)):(y=n.outsideFill,_=n.outsideStroke,null!=y&&"auto"!==y||(y=this.getOutsideFill()),null!=_&&"auto"!==_||(_=this.getOutsideStroke(y),b=!0)),(y=y||"#000")===v.fill&&_===v.stroke&&b===v.autoStroke&&o===v.align&&a===v.verticalAlign||(s=!0,v.fill=y,v.stroke=_,v.autoStroke=b,v.align=o,v.verticalAlign=a,e.setDefaultTextStyle(v)),e.__dirty|=1,s&&e.dirtyStyle(!0)}},t.prototype.canBeInsideText=function(){return!0},t.prototype.getInsideTextFill=function(){return"#fff"},t.prototype.getInsideTextStroke=function(t){return"#000"},t.prototype.getOutsideFill=function(){return this.__zr&&this.__zr.isDarkMode()?Pa:Aa},t.prototype.getOutsideStroke=function(t){var e=this.__zr&&this.__zr.getBackgroundColor(),n="string"==typeof e&&So(e);n||(n=[255,255,255,1]);for(var i=n[3],r=this.__zr.isDarkMode(),o=0;o<3;o++)n[o]=n[o]*i+(r?0:255)*(1-i);return n[3]=1,To(n,"rgba")},t.prototype.traverse=function(t,e){},t.prototype.attrKV=function(t,e){"textConfig"===t?this.setTextConfig(e):"textContent"===t?this.setTextContent(e):"clipPath"===t?this.setClipPath(e):"extra"===t?(this.extra=this.extra||{},_n(this.extra,e)):this[t]=e},t.prototype.hide=function(){this.ignore=!0,this.markRedraw()},t.prototype.show=function(){this.ignore=!1,this.markRedraw()},t.prototype.attr=function(t,e){if("string"==typeof t)this.attrKV(t,e);else if(On(t))for(var n=In(t),i=0;i0},t.prototype.getState=function(t){return this.states[t]},t.prototype.ensureState=function(t){var e=this.states;return e[t]||(e[t]={}),e[t]},t.prototype.clearStates=function(t){this.useState(es,!1,t)},t.prototype.useState=function(t,e,n,i){var r=t===es;if(this.hasState()||!r){var o=this.currentStates,a=this.stateTransition;if(!(xn(o,t)>=0)||!e&&1!==o.length){var s;if(this.stateProxy&&!r&&(s=this.stateProxy(t)),s||(s=this.states&&this.states[t]),s||r){r||this.saveCurrentToNormalState(s);var l=!!(s&&s.hoverLayer||i);l&&this._toggleHoverLayerFlag(!0),this._applyStateObj(t,s,this._normalState,e,!n&&!this.__inHover&&a&&a.duration>0,a);var c=this._textContent,u=this._textGuide;return c&&c.useState(t,e,n,l),u&&u.useState(t,e,n,l),r?(this.currentStates=[],this._normalState={}):e?this.currentStates.push(t):this.currentStates=[t],this._updateAnimationTargets(),this.markRedraw(),!l&&this.__inHover&&(this._toggleHoverLayerFlag(!1),this.__dirty&=-2),s}vn("State "+t+" not exists.")}}},t.prototype.useStates=function(t,e,n){if(t.length){var i=[],r=this.currentStates,o=t.length,a=o===r.length;if(a)for(var s=0;s0,p);var f=this._textContent,g=this._textGuide;f&&f.useStates(t,e,h),g&&g.useStates(t,e,h),this._updateAnimationTargets(),this.currentStates=t.slice(),this.markRedraw(),!h&&this.__inHover&&(this._toggleHoverLayerFlag(!1),this.__dirty&=-2)}else this.clearStates()},t.prototype.isSilent=function(){for(var t=this;t;){if(t.silent)return!0;var e=t.__hostTarget;t=e?t.ignoreHostSilent?null:e:t.parent}return!1},t.prototype._updateAnimationTargets=function(){for(var t=0;t=0){var n=this.currentStates.slice();n.splice(e,1),this.useStates(n)}},t.prototype.replaceState=function(t,e,n){var i=this.currentStates.slice(),r=xn(i,t),o=xn(i,e)>=0;r>=0?o?i.splice(r,1):i[r]=e:n&&!o&&i.push(e),this.useStates(i)},t.prototype.toggleState=function(t,e){e?this.useState(t,!0):this.removeState(t)},t.prototype._mergeStates=function(t){for(var e,n={},i=0;i=0&&e.splice(n,1)}),this.animators.push(t),n&&n.animation.addAnimator(t),n&&n.wakeUp()},t.prototype.updateDuringAnimation=function(t){this.markRedraw()},t.prototype.stopAnimation=function(t,e){for(var n=this.animators,i=n.length,r=[],o=0;o0&&n.during&&o[0].during(function(t,e){n.during(e)});for(var d=0;d0||r.force&&!a.length){var w,S=void 0,C=void 0,k=void 0;if(s){C={},d&&(S={});for(b=0;b<_;b++){C[m=g[b]]=n[m],d?S[m]=i[m]:n[m]=i[m]}}else if(d){k={};for(b=0;b<_;b++){k[m=g[b]]=ta(n[m]),us(n,i,m)}}(w=new aa(n,!1,!1,h?Tn(f,function(t){return t.targetName===e}):null)).targetName=e,r.scope&&(w.scope=r.scope),d&&S&&w.whenWithKeys(0,S,g),k&&w.whenWithKeys(0,k,g),w.whenWithKeys(null==c?500:c,s?C:i,g).delay(u||0),t.addAnimator(w,e),a.push(w)}}wn(ss,bi),wn(ss,Fa);var ds=function(t){function e(e){var n=t.call(this)||this;return n.isGroup=!0,n._children=[],n.attr(e),n}return y(e,t),e.prototype.childrenRef=function(){return this._children},e.prototype.children=function(){return this._children.slice()},e.prototype.childAt=function(t){return this._children[t]},e.prototype.childOfName=function(t){for(var e=this._children,n=0;n=0&&(n.splice(i,0,t),this._doAdd(t))}return this},e.prototype.replace=function(t,e){var n=xn(this._children,t);return n>=0&&this.replaceAt(e,n),this},e.prototype.replaceAt=function(t,e){var n=this._children,i=n[e];if(t&&t!==this&&t.parent!==this&&t!==i){n[e]=t,i.parent=null;var r=this.__zr;r&&i.removeSelfFromZr(r),this._doAdd(t)}return this},e.prototype._doAdd=function(t){t.parent&&t.parent.remove(t),t.parent=this;var e=this.__zr;e&&e!==t.__zr&&t.addSelfToZr(e),e&&e.refresh()},e.prototype.remove=function(t){var e=this.__zr,n=this._children,i=xn(n,t);return i<0||(n.splice(i,1),t.parent=null,e&&t.removeSelfFromZr(e),e&&e.refresh()),this},e.prototype.removeAll=function(){for(var t=this._children,e=this.__zr,n=0;ne&&(e=t);return e>0?e+e%2:0}(o.circuits);if(!a)throw new Error(n("card.topology_error"));const l={type:"config/device_registry/list"},c=i?await i.callWS(t,l,{errorId:"fetch:topology"}):await t.callWS(l),d=(u=c.find(t=>t.id===e),u?{id:u.id,name:u.name,name_by_user:u.name_by_user,config_entries:u.config_entries,identifiers:u.identifiers,via_device_id:u.via_device_id,sw_version:u.sw_version,model:u.model}:null);var u;return await ae(t,o),{topology:o,panelDevice:d,panelSize:a}}function le(){return`
\n ${Object.entries(v).filter(([t])=>"unknown"!==t).map(([,t])=>{const e=Bt(t.icon),n=Bt(t.color),i=Bt(t.label());let r;if(t.icon2){r=``}else if(t.textLabel){r=`${Bt(t.textLabel)}`}else r=``;return`
${r}${i}
`}).join("")}\n
`}function ce(t,e,i){const r="current"===(e.chart_metric||"power"),o=!!t.panel_entities?.site_power,a=!!t.panel_entities?.dsm_state,s=!!t.panel_entities?.current_power,l=!!t.panel_entities?.feedthrough_power,c=!!t.panel_entities?.pv_power,d=!!t.panel_entities?.battery_level;return`\n
\n ${o?`\n
\n ${n("header.site")}\n
\n 0\n ${r?"A":"kW"}\n
\n
`:""}\n ${a?`\n
\n ${n("header.grid")}\n
\n --\n
\n
`:""}\n ${s?`\n
\n ${n("header.upstream")}\n
\n --\n ${r?"A":"kW"}\n
\n
`:""}\n ${l?`\n
\n ${n("header.downstream")}\n
\n --\n ${r?"A":"kW"}\n
\n
`:""}\n ${c?`\n
\n ${n("header.solar")}\n
\n --\n ${r?"A":"kW"}\n
\n
`:""}\n ${d?`\n
\n ${n("header.battery")}\n
\n —\n %\n
\n
`:""}\n
\n `}function de(t,e,i={}){const r=Bt(t.device_name||n("header.default_name")),o=Bt(t.serial||""),a=Bt(t.firmware||""),s="current"===(e.chart_metric||"power"),l=!1!==i.showSwitches;return`\n
\n
\n
\n

${r}

\n ${o}\n \n ${l?`
\n ${Bt(n("header.enable_switches"))}\n
\n \n
\n
`:""}\n
\n ${ce(t,e)}\n
\n
\n
\n ${a}\n
\n \n \n
\n
\n ${le()}\n
\n
\n `}const ue=f.power;function he(t){return ue.unit(t)}function pe(t){return(t<0?"-":"")+ue.format(t)}function fe(t){return(Math.abs(t)/1e3).toFixed(1)}function ge(t){return Math.ceil(t/2)}function ve(t){return t%2==0?1:0}function me(t){if(2!==t.length)return null;const[e,n]=[Math.min(...t),Math.max(...t)];return ge(e)===ge(n)?"row-span":ve(e)===ve(n)?"col-span":"row-span"}function ye(t){const e=t.chart_metric??r;return f[e]??f[r]}function _e(t,e){const n=function(t){return ye(t).entityRole}(e);return t.entities?.[n]??t.entities?.power??null}class be{constructor(){this._status=null,this._lastFetch=0,this._inflight=null,this._generation=0,this._errorStore=null,this._retry=null}get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t,this._retry=t?new qt(t):null}async fetch(t,e){const i=Date.now();if(this._inflight&&this._inflight.gen===this._generation)return this._inflight.promise;if(this._status&&i-this._lastFetch<3e4)return this._status;const r=this._generation,o=(async()=>{try{const i={};e&&(i.config_entry_id=e);const o={type:"call_service",domain:s,service:"get_monitoring_status",service_data:i,return_response:!0},a=this._retry?await this._retry.callWS(t,o,{errorId:"fetch:monitoring",errorMessage:n("error.monitoring_failed")}):await t.callWS(o),l=a?.response??null;return r===this._generation&&(this._status=l,this._lastFetch=Date.now()),l}catch(t){return console.warn("SPAN Panel: monitoring status fetch failed",t),r===this._generation&&(this._status=null),this._retry||this._errorStore?.add({key:"fetch:monitoring",level:"warning",message:n("error.monitoring_failed"),persistent:!1}),null}finally{this._inflight?.gen===r&&(this._inflight=null)}})();return this._inflight={gen:r,promise:o},o}invalidate(){this._lastFetch=0,this._generation++}get status(){return this._status}clear(){this._status=null,this._lastFetch=0,this._generation++}}class xe{constructor(){this._caches=new Map,this._errorStore=null}get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t;for(const e of this._caches.values())e.errorStore=t}async fetchOne(t,e){let n=this._caches.get(e);return n||(n=new be,n.errorStore=this._errorStore,this._caches.set(e,n)),n.fetch(t,e)}invalidate(){for(const t of this._caches.values())t.invalidate()}clear(){this._caches.clear()}}function we(t,e){return t?.circuits?t.circuits[e]??null:null}function Se(t,e,n,i){const r=[];return n||r.push("circuit-off"),i&&r.push("circuit-producer"),function(t){return!!t&&null!=t.over_threshold_since}(e)&&r.push("circuit-alert"),r.join(" ")}function Ce(t,e,i,r,o,a,s,d,u,h=!1){const p=e.entities?.power,f=p?a.states[p]:null,g=f&&parseFloat(f.state)||0,m=e.device_type===c||g<0,y=e.entities?.switch,_=y?a.states[y]:null,b=_?"on"===_.state:(f?.attributes?.relay_state||e.relay_state)===l,x=e.breaker_rating_a,w=x?`${Math.round(x)}A`:"",S=Bt(e.name||n("grid.unknown")),C=ye(s);let k;if("current"===C.entityRole){const t=e.entities?.current,n=t?a.states[t]:null,i=n&&parseFloat(n.state)||0;k=`${C.format(i)}A`}else k=`${pe(g)}${he(g)}`;const M=u||"unknown";let T="";if("unknown"!==M){const t=v[M]??v.unknown??{icon:"mdi:help",color:"#999",label:()=>"Unknown"},e=Bt(t.label()),n=Bt(t.icon),i=Bt(t.color);if(t.icon2){T=`\n \n \n `}else if(t.textLabel){T=`\n \n ${Bt(t.textLabel)}\n `}else T=``}const I=``;let D="",A=d?.utilization_pct??null;if(null==A&&e.breaker_rating_a){const t=e.entities?.current,n=t?a.states[t]:null,i=n?Math.abs(parseFloat(n.state)||0):0;A=Math.round(i/e.breaker_rating_a*1e3)/10}if(null!=A){D=`=80?"utilization-warning":"utilization-normal"}">${Math.round(A)}%`}return`\n
\n
\n
\n ${w?`${w}`:""}\n ${D}\n ${S}\n
\n
\n \n ${k}\n \n ${!1!==e.is_user_controllable&&e.entities?.switch?`\n
\n ${n(b?"grid.on":"grid.off")}\n \n
\n `:""}\n
\n
\n
\n ${T}\n ${I}\n
\n
\n
\n `}function ke(t,e){return`\n
\n —\n
\n `}const Me={names:["power","battery power"],suffixes:["_power"]},Te={names:["battery level","battery percentage"],suffixes:["_battery_level","_battery_percentage"]},Ie={names:["state of energy"],suffixes:["_soe_kwh"]},De={names:["nameplate capacity"],suffixes:["_nameplate_capacity"]};function Ae(t,e){if(!t.entities)return null;for(const[n,i]of Object.entries(t.entities)){if("sensor"!==i.domain)continue;const t=(i.original_name??"").toLowerCase();if(e.names.some(e=>t===e))return n;if(i.unique_id&&e.suffixes.some(t=>i.unique_id.endsWith(t)))return n}return null}function Pe(t){return Ae(t,Me)}function Le(t){return Ae(t,Te)}function Ee(t){return Ae(t,Ie)}function ze(t){return Ae(t,De)}function Ne(t,e,i){const r=!1!==i.show_battery,o=!1!==i.show_evse;if(!t.sub_devices)return"";const a=Object.entries(t.sub_devices).filter(([,t])=>!(t.type===d&&!r)&&!(t.type===u&&!o));if(0===a.length)return"";const s=a.filter(([,t])=>t.type===u).length;let l=0,c="";for(const[t,r]of a){const o=r.type===u?n("subdevice.ev_charger"):r.type===d?n("subdevice.battery"):n("subdevice.fallback"),a=Pe(r),h=a?e.states[a]:void 0,p=h&&parseFloat(h.state)||0,f=r.type===d,g=r.type===u,v=f?Le(r):null,m=f?Ee(r):null,y=f?ze(r):null,_=Oe(r,e,i,new Set([a,v,m,y].filter(t=>null!==t))),b=$e(t,r,f,a,v,m);let x="";f?x="sub-device-bess":g&&(l++,l===s&&s%2==1&&(x="sub-device-full")),c+=`\n
\n
\n ${Bt(o)}\n ${Bt(r.name||"")}\n ${a?`${pe(p)} ${he(p)}`:""}\n \n
\n ${b}\n ${_}\n
\n `}return c}function Oe(t,e,n,i){const r=n.visible_sub_entities||{};let o="";if(!t.entities)return o;for(const[n,a]of Object.entries(t.entities)){if(i.has(n))continue;if(!0!==r[n])continue;const s=e.states[n];if(!s)continue;let l=a.original_name||s.attributes.friendly_name||n;const c=t.name||"";let d;if(l.startsWith(c+" ")&&(l=l.slice(c.length+1)),e.formatEntityState)d=e.formatEntityState(s);else{d=s.state;const t=s.attributes.unit_of_measurement||"";t&&(d+=" "+t)}if("Wh"===(s.attributes.unit_of_measurement||"")){const t=parseFloat(s.state);isNaN(t)||(d=(t/1e3).toFixed(1)+" kWh")}o+=`\n
\n ${Bt(l)}:\n ${Bt(d)}\n
\n `}return o}function $e(t,e,i,r,o,a){if(i){const e=[{key:`${h}${t}_soc`,title:n("subdevice.soc"),available:!!o},{key:`${h}${t}_soe`,title:n("subdevice.soe"),available:!!a},{key:`${h}${t}_power`,title:n("subdevice.power"),available:!!r}].filter(t=>t.available);return`\n
\n ${e.map(t=>`\n
\n
${Bt(t.title)}
\n
\n
\n `).join("")}\n
\n `}return r?`
`:""}function Re(t){const e=void 0!==t.history_days||void 0!==t.history_hours||void 0!==t.history_minutes,n=60*(60*(24*(e&&parseInt(String(t.history_days))||0)+(e&&parseInt(String(t.history_hours))||0))+(e?parseInt(String(t.history_minutes))||0:5))*1e3;return Math.max(n,6e4)}function He(t){const e=a[t];return e?e.ms:a[o].ms}function Fe(t){const e=t/1e3;return e<=600?Math.ceil(e):Math.min(5e3,Math.ceil(e/5))}function Be(t){return Math.max(500,Math.floor(t/5e3))}function Ve(t,e,n,i,r,o){t.has(e)||t.set(e,[]);const a=t.get(e);a.push({time:i,value:n});const s=a.findIndex(t=>t.time>=r);s>0?a.splice(0,s):-1===s&&(a.length=0),a.length>o&&a.splice(0,a.length-o)}function We(t,e,n=500){if(0===t.length)return t;t.sort((t,e)=>t.time-e.time);const i=[t[0]];for(let e=1;e=n&&i.push(t[e]);return i.length>e&&i.splice(0,i.length-e),i}async function Ue(t,e,n,i,r){const o=new Date(Date.now()-i).toISOString(),a=i/36e5>72?"hour":"5minute",s=await t.callWS({type:"recorder/statistics_during_period",start_time:o,statistic_ids:e,period:a,types:["mean"]});for(const[t,e]of Object.entries(s)){const i=n.get(t);if(!i||!e)continue;const o=[];for(const t of e){const e=t.mean;if(null==e||!Number.isFinite(e))continue;const n=t.start;n>0&&o.push({time:n,value:e})}if(o.length>0){const t=r.get(i)||[],e=[...o,...t];e.sort((t,e)=>t.time-e.time),r.set(i,e)}}}async function Ge(t,e,n,i,r){const o=new Date(Date.now()-i).toISOString(),a=await t.callWS({type:"history/history_during_period",start_time:o,entity_ids:e,minimal_response:!0,significant_changes_only:!0,no_attributes:!0}),s=Fe(i),l=Be(i);for(const[t,e]of Object.entries(a)){const i=n.get(t);if(!i||!e)continue;const o=[];for(const t of e){const e=parseFloat(t.s);if(!Number.isFinite(e))continue;const n=1e3*(t.lu||t.lc||0);n>0&&o.push({time:n,value:e})}if(o.length>0){const t=r.get(i)||[],e=[...o,...t];r.set(i,We(e,s,l))}}}function qe(t){if(!t.sub_devices)return[];const e=[];for(const[n,i]of Object.entries(t.sub_devices)){const t={power:Pe(i)};i.type===d&&(t.soc=Le(i),t.soe=Ee(i));for(const[i,r]of Object.entries(t))r&&e.push({entityId:r,key:`${h}${n}_${i}`,devId:n})}return e}async function je(t,e,n,i,r,o){if(!e||!t)return;const a=new Map;for(const[t,i]of Object.entries(e.circuits)){const e=_e(i,n);if(!e)continue;let o;o=r&&r.has(t)?He(r.get(t)):Re(n),a.has(o)||a.set(o,{entityIds:[],uuidByEntity:new Map});const s=a.get(o);s.entityIds.push(e),s.uuidByEntity.set(e,t)}for(const{entityId:t,key:i,devId:r}of qe(e)){let e;e=o&&o.has(r)?He(o.get(r)):Re(n),a.has(e)||a.set(e,{entityIds:[],uuidByEntity:new Map});const s=a.get(e);s.entityIds.push(t),s.uuidByEntity.set(t,i)}const s=[];for(const[e,n]of a){if(0===n.entityIds.length)continue;e>2592e5?s.push(Ue(t,n.entityIds,n.uuidByEntity,e,i)):s.push(Ge(t,n.entityIds,n.uuidByEntity,e,i))}await Promise.all(s)}var Xe=function(){this.firefox=!1,this.ie=!1,this.edge=!1,this.newEdge=!1,this.weChat=!1},Ye=new function(){this.browser=new Xe,this.node=!1,this.wxa=!1,this.worker=!1,this.svgSupported=!1,this.touchEventsSupported=!1,this.pointerEventsSupported=!1,this.domSupported=!1,this.transformSupported=!1,this.transform3dSupported=!1,this.hasGlobalWindow="undefined"!=typeof window};"object"==typeof wx&&"function"==typeof wx.getSystemInfoSync?(Ye.wxa=!0,Ye.touchEventsSupported=!0):"undefined"==typeof document&&"undefined"!=typeof self?Ye.worker=!0:!Ye.hasGlobalWindow||"Deno"in window||"undefined"!=typeof navigator&&"string"==typeof navigator.userAgent&&navigator.userAgent.indexOf("Node.js")>-1?(Ye.node=!0,Ye.svgSupported=!0):function(t,e){var n=e.browser,i=t.match(/Firefox\/([\d.]+)/),r=t.match(/MSIE\s([\d.]+)/)||t.match(/Trident\/.+?rv:(([\d.]+))/),o=t.match(/Edge?\/([\d.]+)/),a=/micromessenger/i.test(t);i&&(n.firefox=!0,n.version=i[1]);r&&(n.ie=!0,n.version=r[1]);o&&(n.edge=!0,n.version=o[1],n.newEdge=+o[1].split(".")[0]>18);a&&(n.weChat=!0);e.svgSupported="undefined"!=typeof SVGRect,e.touchEventsSupported="ontouchstart"in window&&!n.ie&&!n.edge,e.pointerEventsSupported="onpointerdown"in window&&(n.edge||n.ie&&+n.version>=11);var s=e.domSupported="undefined"!=typeof document;if(s){var l=document.documentElement.style;e.transform3dSupported=(n.ie&&"transition"in l||n.edge||"WebKitCSSMatrix"in window&&"m11"in new WebKitCSSMatrix||"MozPerspective"in l)&&!("OTransition"in l),e.transformSupported=e.transform3dSupported||n.ie&&+n.version>=9}}(navigator.userAgent,Ye);var Ze="sans-serif",Ke="12px "+Ze;var Qe,Je,tn=function(t){var e={};if("undefined"==typeof JSON)return e;for(var n=0;n=0)o=r*t.length;else for(var a=0;a>1)%2;a.style.cssText=["position: absolute","visibility: hidden","padding: 0","margin: 0","border-width: 0","user-select: none","width:0","height:0",i[s]+":0",r[l]+":0",i[1-s]+":auto",r[1-l]+":auto",""].join("!important;"),t.appendChild(a),n.push(a)}return e.clearMarkers=function(){Cn(n,function(t){t.parentNode&&t.parentNode.removeChild(t)})},n}(e,o),s=function(t,e,n){for(var i=n?"invTrans":"trans",r=e[i],o=e.srcCoords,a=[],s=[],l=!0,c=0;c<4;c++){var d=t[c].getBoundingClientRect(),u=2*c,h=d.left,p=d.top;a.push(h,p),l=l&&o&&h===o[u]&&p===o[u+1],s.push(t[c].offsetLeft,t[c].offsetTop)}return l&&r?r:(e.srcCoords=a,e[i]=n?Si(s,a):Si(a,s))}(a,o,r);if(s)return s(t,n,i),!0}return!1}function Ti(t){return"CANVAS"===t.nodeName.toUpperCase()}var Ii=/([&<>"'])/g,Di={"&":"&","<":"<",">":">",'"':""","'":"'"};function Ai(t){return null==t?"":(t+"").replace(Ii,function(t,e){return Di[e]})}var Pi=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,Li=[],Ei=Ye.browser.firefox&&+Ye.browser.version.split(".")[0]<39;function zi(t,e,n,i){return n=n||{},i?Ni(t,e,n):Ei&&null!=e.layerX&&e.layerX!==e.offsetX?(n.zrX=e.layerX,n.zrY=e.layerY):null!=e.offsetX?(n.zrX=e.offsetX,n.zrY=e.offsetY):Ni(t,e,n),n}function Ni(t,e,n){if(Ye.domSupported&&t.getBoundingClientRect){var i=e.clientX,r=e.clientY;if(Ti(t)){var o=t.getBoundingClientRect();return n.zrX=i-o.left,void(n.zrY=r-o.top)}if(Mi(Li,t,i,r))return n.zrX=Li[0],void(n.zrY=Li[1])}n.zrX=n.zrY=0}function Oi(t){return t||window.event}function $i(t,e,n){if(null!=(e=Oi(e)).zrX)return e;var i=e.type;if(i&&i.indexOf("touch")>=0){var r="touchend"!==i?e.targetTouches[0]:e.changedTouches[0];r&&zi(t,r,e,n)}else{zi(t,e,e,n);var o=function(t){var e=t.wheelDelta;if(e)return e;var n=t.deltaX,i=t.deltaY;if(null==n||null==i)return e;return 3*(0!==i?Math.abs(i):Math.abs(n))*(i>0?-1:i<0?1:n>0?-1:1)}(e);e.zrDelta=o?o/120:-(e.detail||0)/3}var a=e.button;return null==e.which&&void 0!==a&&Pi.test(e.type)&&(e.which=1&a?1:2&a?3:4&a?2:0),e}function Ri(t,e,n,i){t.removeEventListener(e,n,i)}var Hi=function(t){t.preventDefault(),t.stopPropagation(),t.cancelBubble=!0},Fi=function(){function t(){this._track=[]}return t.prototype.recognize=function(t,e,n){return this._doTrack(t,e,n),this._recognize(t)},t.prototype.clear=function(){return this._track.length=0,this},t.prototype._doTrack=function(t,e,n){var i=t.touches;if(i){for(var r={points:[],touches:[],target:e,event:t},o=0,a=i.length;o1&&r&&r.length>1){var a=Bi(r)/Bi(o);!isFinite(a)&&(a=1),e.pinchScale=a;var s=[((i=r)[0][0]+i[1][0])/2,(i[0][1]+i[1][1])/2];return e.pinchX=s[0],e.pinchY=s[1],{type:"pinch",target:t[0].target,event:e}}}}};function Wi(t){return t[0]=1,t[1]=0,t[2]=0,t[3]=1,t[4]=0,t[5]=0,t}function Ui(t,e){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t[4]=e[4],t[5]=e[5],t}function Gi(t,e,n){var i=e[0]*n[0]+e[2]*n[1],r=e[1]*n[0]+e[3]*n[1],o=e[0]*n[2]+e[2]*n[3],a=e[1]*n[2]+e[3]*n[3],s=e[0]*n[4]+e[2]*n[5]+e[4],l=e[1]*n[4]+e[3]*n[5]+e[5];return t[0]=i,t[1]=r,t[2]=o,t[3]=a,t[4]=s,t[5]=l,t}function qi(t,e,n){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t[4]=e[4]+n[0],t[5]=e[5]+n[1],t}function ji(t,e,n,i){void 0===i&&(i=[0,0]);var r=e[0],o=e[2],a=e[4],s=e[1],l=e[3],c=e[5],d=Math.sin(n),u=Math.cos(n);return t[0]=r*u+s*d,t[1]=-r*d+s*u,t[2]=o*u+l*d,t[3]=-o*d+u*l,t[4]=u*(a-i[0])+d*(c-i[1])+i[0],t[5]=u*(c-i[1])-d*(a-i[0])+i[1],t}function Xi(t,e){var n=e[0],i=e[2],r=e[4],o=e[1],a=e[3],s=e[5],l=n*a-o*i;return l?(l=1/l,t[0]=a*l,t[1]=-o*l,t[2]=-i*l,t[3]=n*l,t[4]=(i*s-a*r)*l,t[5]=(o*r-n*s)*l,t):null}var Yi=function(){function t(t,e){this.x=t||0,this.y=e||0}return t.prototype.copy=function(t){return this.x=t.x,this.y=t.y,this},t.prototype.clone=function(){return new t(this.x,this.y)},t.prototype.set=function(t,e){return this.x=t,this.y=e,this},t.prototype.equal=function(t){return t.x===this.x&&t.y===this.y},t.prototype.add=function(t){return this.x+=t.x,this.y+=t.y,this},t.prototype.scale=function(t){this.x*=t,this.y*=t},t.prototype.scaleAndAdd=function(t,e){this.x+=t.x*e,this.y+=t.y*e},t.prototype.sub=function(t){return this.x-=t.x,this.y-=t.y,this},t.prototype.dot=function(t){return this.x*t.x+this.y*t.y},t.prototype.len=function(){return Math.sqrt(this.x*this.x+this.y*this.y)},t.prototype.lenSquare=function(){return this.x*this.x+this.y*this.y},t.prototype.normalize=function(){var t=this.len();return this.x/=t,this.y/=t,this},t.prototype.distance=function(t){var e=this.x-t.x,n=this.y-t.y;return Math.sqrt(e*e+n*n)},t.prototype.distanceSquare=function(t){var e=this.x-t.x,n=this.y-t.y;return e*e+n*n},t.prototype.negate=function(){return this.x=-this.x,this.y=-this.y,this},t.prototype.transform=function(t){if(t){var e=this.x,n=this.y;return this.x=t[0]*e+t[2]*n+t[4],this.y=t[1]*e+t[3]*n+t[5],this}},t.prototype.toArray=function(t){return t[0]=this.x,t[1]=this.y,t},t.prototype.fromArray=function(t){this.x=t[0],this.y=t[1]},t.set=function(t,e,n){t.x=e,t.y=n},t.copy=function(t,e){t.x=e.x,t.y=e.y},t.len=function(t){return Math.sqrt(t.x*t.x+t.y*t.y)},t.lenSquare=function(t){return t.x*t.x+t.y*t.y},t.dot=function(t,e){return t.x*e.x+t.y*e.y},t.add=function(t,e,n){t.x=e.x+n.x,t.y=e.y+n.y},t.sub=function(t,e,n){t.x=e.x-n.x,t.y=e.y-n.y},t.scale=function(t,e,n){t.x=e.x*n,t.y=e.y*n},t.scaleAndAdd=function(t,e,n,i){t.x=e.x+n.x*i,t.y=e.y+n.y*i},t.lerp=function(t,e,n,i){var r=1-i;t.x=r*e.x+i*n.x,t.y=r*e.y+i*n.y},t}(),Zi=Math.min,Ki=Math.max,Qi=Math.abs,Ji=["x","y"],tr=["width","height"],er=new Yi,nr=new Yi,ir=new Yi,rr=new Yi,or=pr(),ar=or.minTv,sr=or.maxTv,lr=[0,0],cr=function(){function t(e,n,i,r){t.set(this,e,n,i,r)}return t.set=function(t,e,n,i,r){return i<0&&(e+=i,i=-i),r<0&&(n+=r,r=-r),t.x=e,t.y=n,t.width=i,t.height=r,t},t.prototype.union=function(t){var e=Zi(t.x,this.x),n=Zi(t.y,this.y);isFinite(this.x)&&isFinite(this.width)?this.width=Ki(t.x+t.width,this.x+this.width)-e:this.width=t.width,isFinite(this.y)&&isFinite(this.height)?this.height=Ki(t.y+t.height,this.y+this.height)-n:this.height=t.height,this.x=e,this.y=n},t.prototype.applyTransform=function(e){t.applyTransform(this,this,e)},t.prototype.calculateTransform=function(t){var e=this,n=t.width/e.width,i=t.height/e.height,r=[1,0,0,1,0,0];return qi(r,r,[-e.x,-e.y]),function(t,e,n){var i=n[0],r=n[1];t[0]=e[0]*i,t[1]=e[1]*r,t[2]=e[2]*i,t[3]=e[3]*r,t[4]=e[4]*i,t[5]=e[5]*r}(r,r,[n,i]),qi(r,r,[t.x,t.y]),r},t.prototype.intersect=function(e,n,i){return t.intersect(this,e,n,i)},t.intersect=function(e,n,i,r){i&&Yi.set(i,0,0);var o=r&&r.outIntersectRect||null,a=r&&r.clamp;if(o&&(o.x=o.y=o.width=o.height=NaN),!e||!n)return!1;e instanceof t||(e=t.set(dr,e.x,e.y,e.width,e.height)),n instanceof t||(n=t.set(ur,n.x,n.y,n.width,n.height));var s=!!i;or.reset(r,s);var l=or.touchThreshold,c=e.x+l,d=e.x+e.width-l,u=e.y+l,h=e.y+e.height-l,p=n.x+l,f=n.x+n.width-l,g=n.y+l,v=n.y+n.height-l;if(c>d||u>h||p>f||g>v)return!1;var m=!(d=t.x&&e<=t.x+t.width&&n>=t.y&&n<=t.y+t.height},t.prototype.contain=function(e,n){return t.contain(this,e,n)},t.prototype.clone=function(){return new t(this.x,this.y,this.width,this.height)},t.prototype.copy=function(e){t.copy(this,e)},t.prototype.plain=function(){return{x:this.x,y:this.y,width:this.width,height:this.height}},t.prototype.isFinite=function(){return isFinite(this.x)&&isFinite(this.y)&&isFinite(this.width)&&isFinite(this.height)},t.prototype.isZero=function(){return 0===this.width||0===this.height},t.create=function(e){return new t(e.x,e.y,e.width,e.height)},t.copy=function(t,e){return t.x=e.x,t.y=e.y,t.width=e.width,t.height=e.height,t},t.applyTransform=function(e,n,i){if(i){if(i[1]<1e-5&&i[1]>-1e-5&&i[2]<1e-5&&i[2]>-1e-5){var r=i[0],o=i[3],a=i[4],s=i[5];return e.x=n.x*r+a,e.y=n.y*o+s,e.width=n.width*r,e.height=n.height*o,e.width<0&&(e.x+=e.width,e.width=-e.width),void(e.height<0&&(e.y+=e.height,e.height=-e.height))}er.x=ir.x=n.x,er.y=rr.y=n.y,nr.x=rr.x=n.x+n.width,nr.y=ir.y=n.y+n.height,er.transform(i),rr.transform(i),nr.transform(i),ir.transform(i),e.x=Zi(er.x,nr.x,ir.x,rr.x),e.y=Zi(er.y,nr.y,ir.y,rr.y);var l=Ki(er.x,nr.x,ir.x,rr.x),c=Ki(er.y,nr.y,ir.y,rr.y);e.width=l-e.x,e.height=c-e.y}else e!==n&&t.copy(e,n)},t}(),dr=new cr(0,0,0,0),ur=new cr(0,0,0,0);function hr(t,e,n,i,r,o,a,s){var l=Qi(e-n),c=Qi(i-t),d=Zi(l,c),u=Ji[r],h=Ji[1-r],p=tr[r];e=c||!or.bidirectional)&&(ar[u]=-c,ar[h]=0,or.useDir&&or.calcDirMTV())))}function pr(){var t=0,e=new Yi,n=new Yi,i={minTv:new Yi,maxTv:new Yi,useDir:!1,dirMinTv:new Yi,touchThreshold:0,bidirectional:!0,negativeSize:!1,reset:function(r,o){i.touchThreshold=0,r&&null!=r.touchThreshold&&(i.touchThreshold=Ki(0,r.touchThreshold)),i.negativeSize=!1,o&&(i.minTv.set(1/0,1/0),i.maxTv.set(0,0),i.useDir=!1,r&&null!=r.direction&&(i.useDir=!0,i.dirMinTv.copy(i.minTv),n.copy(i.minTv),t=r.direction,i.bidirectional=null==r.bidirectional||!!r.bidirectional,i.bidirectional||e.set(Math.cos(t),Math.sin(t))))},calcDirMTV:function(){var o=i.minTv,a=i.dirMinTv,s=o.y*o.y+o.x*o.x,l=Math.sin(t),c=Math.cos(t),d=l*o.y+c*o.x;r(d)?r(o.x)&&r(o.y)&&a.set(0,0):(n.x=s*c/d,n.y=s*l/d,r(n.x)&&r(n.y)?a.set(0,0):(i.bidirectional||e.dot(n)>0)&&n.len()=0;c--){var d=i[c];d===n||d.ignore||d.ignoreCoarsePointer||d.parent&&d.parent.ignoreCoarsePointer||(_r.copy(d.getBoundingRect()),d.transform&&_r.applyTransform(d.transform),_r.intersect(l)&&o.push(d))}if(o.length)for(var u=Math.PI/12,h=2*Math.PI,p=0;p=0;o--){var a=t[o],s=void 0;if(a!==r&&!a.ignore&&(s=xr(a,n,i))&&(!e.topTarget&&(e.topTarget=a),s!==fr)){e.target=a;break}}}function Sr(t,e,n){var i=t.painter;return e<0||e>i.getWidth()||n<0||n>i.getHeight()}Cn(["click","mousedown","mouseup","mousewheel","dblclick","contextmenu"],function(t){br.prototype[t]=function(e){var n,i,r=e.zrX,o=e.zrY,a=Sr(this,r,o);if("mouseup"===t&&a||(i=(n=this.findHover(r,o)).target),"mousedown"===t)this._downEl=i,this._downPoint=[e.zrX,e.zrY],this._upEl=i;else if("mouseup"===t)this._upEl=i;else if("click"===t){if(this._downEl!==this._upEl||!this._downPoint||pi(this._downPoint,[e.zrX,e.zrY])>4)return;this._downPoint=null}this.dispatchToElement(n,t,e)}});function Cr(t,e,n,i){var r=e+1;if(r===n)return 1;if(i(t[r++],t[e])<0){for(;r=0;)r++;return r-e}function kr(t,e,n,i,r){for(i===e&&i++;i>>1])<0?l=o:s=o+1;var c=i-s;switch(c){case 3:t[s+3]=t[s+2];case 2:t[s+2]=t[s+1];case 1:t[s+1]=t[s];break;default:for(;c>0;)t[s+c]=t[s+c-1],c--}t[s]=a}}function Mr(t,e,n,i,r,o){var a=0,s=0,l=1;if(o(t,e[n+r])>0){for(s=i-r;l0;)a=l,(l=1+(l<<1))<=0&&(l=s);l>s&&(l=s),a+=r,l+=r}else{for(s=r+1;ls&&(l=s);var c=a;a=r-l,l=r-c}for(a++;a>>1);o(t,e[n+d])>0?a=d+1:l=d}return l}function Tr(t,e,n,i,r,o){var a=0,s=0,l=1;if(o(t,e[n+r])<0){for(s=r+1;ls&&(l=s);var c=a;a=r-l,l=r-c}else{for(s=i-r;l=0;)a=l,(l=1+(l<<1))<=0&&(l=s);l>s&&(l=s),a+=r,l+=r}for(a++;a>>1);o(t,e[n+d])<0?l=d:a=d+1}return l}function Ir(t,e){var n,i,r=7,o=0,a=[];function s(s){var l=n[s],c=i[s],d=n[s+1],u=i[s+1];i[s]=c+u,s===o-3&&(n[s+1]=n[s+2],i[s+1]=i[s+2]),o--;var h=Tr(t[d],t,l,c,0,e);l+=h,0!==(c-=h)&&0!==(u=Mr(t[l+c-1],t,d,u,u-1,e))&&(c<=u?function(n,i,o,s){var l=0;for(l=0;l=7||p>=7);if(f)break;g<0&&(g=0),g+=2}if((r=g)<1&&(r=1),1===i){for(l=0;l=0;l--)t[p+l]=t[h+l];return void(t[u]=a[d])}var f=r;for(;;){var g=0,v=0,m=!1;do{if(e(a[d],t[c])<0){if(t[u--]=t[c--],g++,v=0,0===--i){m=!0;break}}else if(t[u--]=a[d--],v++,g=0,1===--s){m=!0;break}}while((g|v)=0;l--)t[p+l]=t[h+l];if(0===i){m=!0;break}}if(t[u--]=a[d--],1===--s){m=!0;break}if(0!==(v=s-Mr(t[c],a,0,s,s-1,e))){for(s-=v,p=(u-=v)+1,h=(d-=v)+1,l=0;l=7||v>=7);if(m)break;f<0&&(f=0),f+=2}(r=f)<1&&(r=1);if(1===s){for(p=(u-=i)+1,h=(c-=i)+1,l=i-1;l>=0;l--)t[p+l]=t[h+l];t[u]=a[d]}else{if(0===s)throw new Error;for(h=u-(s-1),l=0;l1;){var t=o-2;if(t>=1&&i[t-1]<=i[t]+i[t+1]||t>=2&&i[t-2]<=i[t]+i[t-1])i[t-1]i[t+1])break;s(t)}},forceMergeRuns:function(){for(;o>1;){var t=o-2;t>0&&i[t-1]=32;)e|=1&t,t>>=1;return t+e}(r);do{if((o=Cr(t,n,i,e))s&&(l=s),kr(t,n,n+l,n+o,e),o=l}a.pushRun(n,o),a.mergeRuns(),r-=o,n+=o}while(0!==r);a.forceMergeRuns()}}}var Ar=!1;function Pr(){Ar||(Ar=!0,console.warn("z / z2 / zlevel of displayable is invalid, which may cause unexpected errors"))}function Lr(t,e){return t.zlevel===e.zlevel?t.z===e.z?t.z2-e.z2:t.z-e.z:t.zlevel-e.zlevel}var Er,zr=function(){function t(){this._roots=[],this._displayList=[],this._displayListLen=0,this.displayableSortFunc=Lr}return t.prototype.traverse=function(t,e){for(var n=0;n=0&&this._roots.splice(i,1)}},t.prototype.delAllRoots=function(){this._roots=[],this._displayList=[],this._displayListLen=0},t.prototype.getRoots=function(){return this._roots},t.prototype.dispose=function(){this._displayList=null,this._roots=null},t}();Er=Ye.hasGlobalWindow&&(window.requestAnimationFrame&&window.requestAnimationFrame.bind(window)||window.msRequestAnimationFrame&&window.msRequestAnimationFrame.bind(window)||window.mozRequestAnimationFrame||window.webkitRequestAnimationFrame)||function(t){return setTimeout(t,16)};var Nr={linear:function(t){return t},quadraticIn:function(t){return t*t},quadraticOut:function(t){return t*(2-t)},quadraticInOut:function(t){return(t*=2)<1?.5*t*t:-.5*(--t*(t-2)-1)},cubicIn:function(t){return t*t*t},cubicOut:function(t){return--t*t*t+1},cubicInOut:function(t){return(t*=2)<1?.5*t*t*t:.5*((t-=2)*t*t+2)},quarticIn:function(t){return t*t*t*t},quarticOut:function(t){return 1- --t*t*t*t},quarticInOut:function(t){return(t*=2)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2)},quinticIn:function(t){return t*t*t*t*t},quinticOut:function(t){return--t*t*t*t*t+1},quinticInOut:function(t){return(t*=2)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2)},sinusoidalIn:function(t){return 1-Math.cos(t*Math.PI/2)},sinusoidalOut:function(t){return Math.sin(t*Math.PI/2)},sinusoidalInOut:function(t){return.5*(1-Math.cos(Math.PI*t))},exponentialIn:function(t){return 0===t?0:Math.pow(1024,t-1)},exponentialOut:function(t){return 1===t?1:1-Math.pow(2,-10*t)},exponentialInOut:function(t){return 0===t?0:1===t?1:(t*=2)<1?.5*Math.pow(1024,t-1):.5*(2-Math.pow(2,-10*(t-1)))},circularIn:function(t){return 1-Math.sqrt(1-t*t)},circularOut:function(t){return Math.sqrt(1- --t*t)},circularInOut:function(t){return(t*=2)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1)},elasticIn:function(t){var e,n=.1;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=.4*Math.asin(1/n)/(2*Math.PI),-n*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/.4))},elasticOut:function(t){var e,n=.1;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=.4*Math.asin(1/n)/(2*Math.PI),n*Math.pow(2,-10*t)*Math.sin((t-e)*(2*Math.PI)/.4)+1)},elasticInOut:function(t){var e,n=.1,i=.4;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=i*Math.asin(1/n)/(2*Math.PI),(t*=2)<1?n*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/i)*-.5:n*Math.pow(2,-10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/i)*.5+1)},backIn:function(t){var e=1.70158;return t*t*((e+1)*t-e)},backOut:function(t){var e=1.70158;return--t*t*((e+1)*t+e)+1},backInOut:function(t){var e=2.5949095;return(t*=2)<1?t*t*((e+1)*t-e)*.5:.5*((t-=2)*t*((e+1)*t+e)+2)},bounceIn:function(t){return 1-Nr.bounceOut(1-t)},bounceOut:function(t){return t<1/2.75?7.5625*t*t:t<2/2.75?7.5625*(t-=1.5/2.75)*t+.75:t<2.5/2.75?7.5625*(t-=2.25/2.75)*t+.9375:7.5625*(t-=2.625/2.75)*t+.984375},bounceInOut:function(t){return t<.5?.5*Nr.bounceIn(2*t):.5*Nr.bounceOut(2*t-1)+.5}},Or=Math.pow,$r=Math.sqrt,Rr=1e-8,Hr=1e-4,Fr=$r(3),Br=1/3,Vr=ai(),Wr=ai(),Ur=ai();function Gr(t){return t>-1e-8&&tRr||t<-1e-8}function jr(t,e,n,i,r){var o=1-r;return o*o*(o*t+3*r*e)+r*r*(r*i+3*o*n)}function Xr(t,e,n,i,r){var o=1-r;return 3*(((e-t)*o+2*(n-e)*r)*o+(i-n)*r*r)}function Yr(t,e,n,i,r,o){var a=i+3*(e-n)-t,s=3*(n-2*e+t),l=3*(e-t),c=t-r,d=s*s-3*a*l,u=s*l-9*a*c,h=l*l-3*s*c,p=0;if(Gr(d)&&Gr(u)){if(Gr(s))o[0]=0;else(C=-l/s)>=0&&C<=1&&(o[p++]=C)}else{var f=u*u-4*d*h;if(Gr(f)){var g=u/d,v=-g/2;(C=-s/a+g)>=0&&C<=1&&(o[p++]=C),v>=0&&v<=1&&(o[p++]=v)}else if(f>0){var m=$r(f),y=d*s+1.5*a*(-u+m),_=d*s+1.5*a*(-u-m);(C=(-s-((y=y<0?-Or(-y,Br):Or(y,Br))+(_=_<0?-Or(-_,Br):Or(_,Br))))/(3*a))>=0&&C<=1&&(o[p++]=C)}else{var b=(2*d*s-3*a*u)/(2*$r(d*d*d)),x=Math.acos(b)/3,w=$r(d),S=Math.cos(x),C=(-s-2*w*S)/(3*a),k=(v=(-s+w*(S+Fr*Math.sin(x)))/(3*a),(-s+w*(S-Fr*Math.sin(x)))/(3*a));C>=0&&C<=1&&(o[p++]=C),v>=0&&v<=1&&(o[p++]=v),k>=0&&k<=1&&(o[p++]=k)}}return p}function Zr(t,e,n,i,r){var o=6*n-12*e+6*t,a=9*e+3*i-3*t-9*n,s=3*e-3*t,l=0;if(Gr(a)){if(qr(o))(d=-s/o)>=0&&d<=1&&(r[l++]=d)}else{var c=o*o-4*a*s;if(Gr(c))r[0]=-o/(2*a);else if(c>0){var d,u=$r(c),h=(-o-u)/(2*a);(d=(-o+u)/(2*a))>=0&&d<=1&&(r[l++]=d),h>=0&&h<=1&&(r[l++]=h)}}return l}function Kr(t,e,n,i,r,o){var a=(e-t)*r+t,s=(n-e)*r+e,l=(i-n)*r+n,c=(s-a)*r+a,d=(l-s)*r+s,u=(d-c)*r+c;o[0]=t,o[1]=a,o[2]=c,o[3]=u,o[4]=u,o[5]=d,o[6]=l,o[7]=i}function Qr(t,e,n,i,r,o,a,s,l){for(var c=t,d=e,u=0,h=1/l,p=1;p<=l;p++){var f=p*h,g=jr(t,n,r,a,f),v=jr(e,i,o,s,f),m=g-c,y=v-d;u+=Math.sqrt(m*m+y*y),c=g,d=v}return u}function Jr(t,e,n,i){var r=1-i;return r*(r*t+2*i*e)+i*i*n}function to(t,e,n,i){return 2*((1-i)*(e-t)+i*(n-e))}function eo(t,e,n){var i=t+n-2*e;return 0===i?.5:(t-e)/i}function no(t,e,n,i,r){var o=(e-t)*i+t,a=(n-e)*i+e,s=(a-o)*i+o;r[0]=t,r[1]=o,r[2]=s,r[3]=s,r[4]=a,r[5]=n}function io(t,e,n,i,r,o,a){for(var s=t,l=e,c=0,d=1/a,u=1;u<=a;u++){var h=u*d,p=Jr(t,n,r,h),f=Jr(e,i,o,h),g=p-s,v=f-l;c+=Math.sqrt(g*g+v*v),s=p,l=f}return c}var ro=/cubic-bezier\(([0-9,\.e ]+)\)/;function oo(t){var e=t&&ro.exec(t);if(e){var n=e[1].split(","),i=+Xn(n[0]),r=+Xn(n[1]),o=+Xn(n[2]),a=+Xn(n[3]);if(isNaN(i+r+o+a))return;var s=[];return function(t){return t<=0?0:t>=1?1:Yr(0,i,o,1,t,s)&&jr(0,r,a,1,s[0])}}}var ao=function(){function t(t){this._inited=!1,this._startTime=0,this._pausedTime=0,this._paused=!1,this._life=t.life||1e3,this._delay=t.delay||0,this.loop=t.loop||!1,this.onframe=t.onframe||ri,this.ondestroy=t.ondestroy||ri,this.onrestart=t.onrestart||ri,t.easing&&this.setEasing(t.easing)}return t.prototype.step=function(t,e){if(this._inited||(this._startTime=t+this._delay,this._inited=!0),!this._paused){var n=this._life,i=t-this._startTime-this._pausedTime,r=i/n;r<0&&(r=0),r=Math.min(r,1);var o=this.easingFunc,a=o?o(r):r;if(this.onframe(a),1===r){if(!this.loop)return!0;var s=i%n;this._startTime=t-s,this._pausedTime=0,this.onrestart()}return!1}this._pausedTime+=e},t.prototype.pause=function(){this._paused=!0},t.prototype.resume=function(){this._paused=!1},t.prototype.setEasing=function(t){this.easing=t,this.easingFunc=Ln(t)?t:Nr[t]||oo(t)},t}(),so=function(t){this.value=t},lo=function(){function t(){this._len=0}return t.prototype.insert=function(t){var e=new so(t);return this.insertEntry(e),e},t.prototype.insertEntry=function(t){this.head?(this.tail.next=t,t.prev=this.tail,t.next=null,this.tail=t):this.head=this.tail=t,this._len++},t.prototype.remove=function(t){var e=t.prev,n=t.next;e?e.next=n:this.head=n,n?n.prev=e:this.tail=e,t.next=t.prev=null,this._len--},t.prototype.len=function(){return this._len},t.prototype.clear=function(){this.head=this.tail=null,this._len=0},t}(),co=function(){function t(t){this._list=new lo,this._maxSize=10,this._map={},this._maxSize=t}return t.prototype.put=function(t,e){var n=this._list,i=this._map,r=null;if(null==i[t]){var o=n.len(),a=this._lastRemovedEntry;if(o>=this._maxSize&&o>0){var s=n.head;n.remove(s),delete i[s.key],r=s.value,this._lastRemovedEntry=s}a?a.value=e:a=new so(e),a.key=t,n.insertEntry(a),i[t]=a}return r},t.prototype.get=function(t){var e=this._map[t],n=this._list;if(null!=e)return e!==n.tail&&(n.remove(e),n.insertEntry(e)),e.value},t.prototype.clear=function(){this._list.clear(),this._map={}},t.prototype.len=function(){return this._list.len()},t}(),uo={transparent:[0,0,0,0],aliceblue:[240,248,255,1],antiquewhite:[250,235,215,1],aqua:[0,255,255,1],aquamarine:[127,255,212,1],azure:[240,255,255,1],beige:[245,245,220,1],bisque:[255,228,196,1],black:[0,0,0,1],blanchedalmond:[255,235,205,1],blue:[0,0,255,1],blueviolet:[138,43,226,1],brown:[165,42,42,1],burlywood:[222,184,135,1],cadetblue:[95,158,160,1],chartreuse:[127,255,0,1],chocolate:[210,105,30,1],coral:[255,127,80,1],cornflowerblue:[100,149,237,1],cornsilk:[255,248,220,1],crimson:[220,20,60,1],cyan:[0,255,255,1],darkblue:[0,0,139,1],darkcyan:[0,139,139,1],darkgoldenrod:[184,134,11,1],darkgray:[169,169,169,1],darkgreen:[0,100,0,1],darkgrey:[169,169,169,1],darkkhaki:[189,183,107,1],darkmagenta:[139,0,139,1],darkolivegreen:[85,107,47,1],darkorange:[255,140,0,1],darkorchid:[153,50,204,1],darkred:[139,0,0,1],darksalmon:[233,150,122,1],darkseagreen:[143,188,143,1],darkslateblue:[72,61,139,1],darkslategray:[47,79,79,1],darkslategrey:[47,79,79,1],darkturquoise:[0,206,209,1],darkviolet:[148,0,211,1],deeppink:[255,20,147,1],deepskyblue:[0,191,255,1],dimgray:[105,105,105,1],dimgrey:[105,105,105,1],dodgerblue:[30,144,255,1],firebrick:[178,34,34,1],floralwhite:[255,250,240,1],forestgreen:[34,139,34,1],fuchsia:[255,0,255,1],gainsboro:[220,220,220,1],ghostwhite:[248,248,255,1],gold:[255,215,0,1],goldenrod:[218,165,32,1],gray:[128,128,128,1],green:[0,128,0,1],greenyellow:[173,255,47,1],grey:[128,128,128,1],honeydew:[240,255,240,1],hotpink:[255,105,180,1],indianred:[205,92,92,1],indigo:[75,0,130,1],ivory:[255,255,240,1],khaki:[240,230,140,1],lavender:[230,230,250,1],lavenderblush:[255,240,245,1],lawngreen:[124,252,0,1],lemonchiffon:[255,250,205,1],lightblue:[173,216,230,1],lightcoral:[240,128,128,1],lightcyan:[224,255,255,1],lightgoldenrodyellow:[250,250,210,1],lightgray:[211,211,211,1],lightgreen:[144,238,144,1],lightgrey:[211,211,211,1],lightpink:[255,182,193,1],lightsalmon:[255,160,122,1],lightseagreen:[32,178,170,1],lightskyblue:[135,206,250,1],lightslategray:[119,136,153,1],lightslategrey:[119,136,153,1],lightsteelblue:[176,196,222,1],lightyellow:[255,255,224,1],lime:[0,255,0,1],limegreen:[50,205,50,1],linen:[250,240,230,1],magenta:[255,0,255,1],maroon:[128,0,0,1],mediumaquamarine:[102,205,170,1],mediumblue:[0,0,205,1],mediumorchid:[186,85,211,1],mediumpurple:[147,112,219,1],mediumseagreen:[60,179,113,1],mediumslateblue:[123,104,238,1],mediumspringgreen:[0,250,154,1],mediumturquoise:[72,209,204,1],mediumvioletred:[199,21,133,1],midnightblue:[25,25,112,1],mintcream:[245,255,250,1],mistyrose:[255,228,225,1],moccasin:[255,228,181,1],navajowhite:[255,222,173,1],navy:[0,0,128,1],oldlace:[253,245,230,1],olive:[128,128,0,1],olivedrab:[107,142,35,1],orange:[255,165,0,1],orangered:[255,69,0,1],orchid:[218,112,214,1],palegoldenrod:[238,232,170,1],palegreen:[152,251,152,1],paleturquoise:[175,238,238,1],palevioletred:[219,112,147,1],papayawhip:[255,239,213,1],peachpuff:[255,218,185,1],peru:[205,133,63,1],pink:[255,192,203,1],plum:[221,160,221,1],powderblue:[176,224,230,1],purple:[128,0,128,1],red:[255,0,0,1],rosybrown:[188,143,143,1],royalblue:[65,105,225,1],saddlebrown:[139,69,19,1],salmon:[250,128,114,1],sandybrown:[244,164,96,1],seagreen:[46,139,87,1],seashell:[255,245,238,1],sienna:[160,82,45,1],silver:[192,192,192,1],skyblue:[135,206,235,1],slateblue:[106,90,205,1],slategray:[112,128,144,1],slategrey:[112,128,144,1],snow:[255,250,250,1],springgreen:[0,255,127,1],steelblue:[70,130,180,1],tan:[210,180,140,1],teal:[0,128,128,1],thistle:[216,191,216,1],tomato:[255,99,71,1],turquoise:[64,224,208,1],violet:[238,130,238,1],wheat:[245,222,179,1],white:[255,255,255,1],whitesmoke:[245,245,245,1],yellow:[255,255,0,1],yellowgreen:[154,205,50,1]};function ho(t){return(t=Math.round(t))<0?0:t>255?255:t}function po(t){return t<0?0:t>1?1:t}function fo(t){var e=t;return e.length&&"%"===e.charAt(e.length-1)?ho(parseFloat(e)/100*255):ho(parseInt(e,10))}function go(t){var e=t;return e.length&&"%"===e.charAt(e.length-1)?po(parseFloat(e)/100):po(parseFloat(e))}function vo(t,e,n){return n<0?n+=1:n>1&&(n-=1),6*n<1?t+(e-t)*n*6:2*n<1?e:3*n<2?t+(e-t)*(2/3-n)*6:t}function mo(t,e,n){return t+(e-t)*n}function yo(t,e,n,i,r){return t[0]=e,t[1]=n,t[2]=i,t[3]=r,t}function _o(t,e){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t}var bo=new co(20),xo=null;function wo(t,e){xo&&_o(xo,e),xo=bo.put(t,xo||e.slice())}function So(t,e){if(t){e=e||[];var n=bo.get(t);if(n)return _o(e,n);var i=(t+="").replace(/ /g,"").toLowerCase();if(i in uo)return _o(e,uo[i]),wo(t,e),e;var r,o=i.length;if("#"===i.charAt(0))return 4===o||5===o?(r=parseInt(i.slice(1,4),16))>=0&&r<=4095?(yo(e,(3840&r)>>4|(3840&r)>>8,240&r|(240&r)>>4,15&r|(15&r)<<4,5===o?parseInt(i.slice(4),16)/15:1),wo(t,e),e):void yo(e,0,0,0,1):7===o||9===o?(r=parseInt(i.slice(1,7),16))>=0&&r<=16777215?(yo(e,(16711680&r)>>16,(65280&r)>>8,255&r,9===o?parseInt(i.slice(7),16)/255:1),wo(t,e),e):void yo(e,0,0,0,1):void 0;var a=i.indexOf("("),s=i.indexOf(")");if(-1!==a&&s+1===o){var l=i.substr(0,a),c=i.substr(a+1,s-(a+1)).split(","),d=1;switch(l){case"rgba":if(4!==c.length)return 3===c.length?yo(e,+c[0],+c[1],+c[2],1):yo(e,0,0,0,1);d=go(c.pop());case"rgb":return c.length>=3?(yo(e,fo(c[0]),fo(c[1]),fo(c[2]),3===c.length?d:go(c[3])),wo(t,e),e):void yo(e,0,0,0,1);case"hsla":return 4!==c.length?void yo(e,0,0,0,1):(c[3]=go(c[3]),Co(c,e),wo(t,e),e);case"hsl":return 3!==c.length?void yo(e,0,0,0,1):(Co(c,e),wo(t,e),e);default:return}}yo(e,0,0,0,1)}}function Co(t,e){var n=(parseFloat(t[0])%360+360)%360/360,i=go(t[1]),r=go(t[2]),o=r<=.5?r*(i+1):r+i-r*i,a=2*r-o;return yo(e=e||[],ho(255*vo(a,o,n+1/3)),ho(255*vo(a,o,n)),ho(255*vo(a,o,n-1/3)),1),4===t.length&&(e[3]=t[3]),e}function ko(t,e){var n=So(t);if(n){for(var i=0;i<3;i++)n[i]=n[i]*(1-e)|0,n[i]>255?n[i]=255:n[i]<0&&(n[i]=0);return To(n,4===n.length?"rgba":"rgb")}}function Mo(t,e,n,i){var r=So(t);if(t)return r=function(t){if(t){var e,n,i=t[0]/255,r=t[1]/255,o=t[2]/255,a=Math.min(i,r,o),s=Math.max(i,r,o),l=s-a,c=(s+a)/2;if(0===l)e=0,n=0;else{n=c<.5?l/(s+a):l/(2-s-a);var d=((s-i)/6+l/2)/l,u=((s-r)/6+l/2)/l,h=((s-o)/6+l/2)/l;i===s?e=h-u:r===s?e=1/3+d-h:o===s&&(e=2/3+u-d),e<0&&(e+=1),e>1&&(e-=1)}var p=[360*e,n,c];return null!=t[3]&&p.push(t[3]),p}}(r),null!=n&&(r[1]=go(Ln(n)?n(r[1]):n)),null!=i&&(r[2]=go(Ln(i)?i(r[2]):i)),To(Co(r),"rgba")}function To(t,e){if(t&&t.length){var n=t[0]+","+t[1]+","+t[2];return"rgba"!==e&&"hsva"!==e&&"hsla"!==e||(n+=","+t[3]),e+"("+n+")"}}function Io(t,e){var n=So(t);return n?(.299*n[0]+.587*n[1]+.114*n[2])*n[3]/255+(1-n[3])*e:0}var Do=new co(100);function Ao(t){if(En(t)){var e=Do.get(t);return e||(e=ko(t,-.1),Do.put(t,e)),e}if(Fn(t)){var n=_n({},t);return n.colorStops=kn(t.colorStops,function(t){return{offset:t.offset,color:ko(t.color,-.1)}}),n}return t}var Po=Math.round;function Lo(t){var e;if(t&&"transparent"!==t){if("string"==typeof t&&t.indexOf("rgba")>-1){var n=So(t);n&&(t="rgb("+n[0]+","+n[1]+","+n[2]+")",e=n[3])}}else t="none";return{color:t,opacity:null==e?1:e}}var Eo=1e-4;function zo(t){return t-1e-4}function No(t){return Po(1e3*t)/1e3}function Oo(t){return Po(1e4*t)/1e4}var $o={left:"start",right:"end",center:"middle",middle:"middle"};function Ro(t){return t&&!!t.image}function Ho(t){return Ro(t)||function(t){return t&&!!t.svgElement}(t)}function Fo(t){return"linear"===t.type}function Bo(t){return"radial"===t.type}function Vo(t){return t&&("linear"===t.type||"radial"===t.type)}function Wo(t){return"url(#"+t+")"}function Uo(t){var e=t.getGlobalScale(),n=Math.max(e[0],e[1]);return Math.max(Math.ceil(Math.log(n)/Math.log(10)),1)}function Go(t){var e=t.x||0,n=t.y||0,i=(t.rotation||0)*oi,r=Wn(t.scaleX,1),o=Wn(t.scaleY,1),a=t.skewX||0,s=t.skewY||0,l=[];return(e||n)&&l.push("translate("+e+"px,"+n+"px)"),i&&l.push("rotate("+i+")"),1===r&&1===o||l.push("scale("+r+","+o+")"),(a||s)&&l.push("skew("+Po(a*oi)+"deg, "+Po(s*oi)+"deg)"),l.join(" ")}var qo=Ye.hasGlobalWindow&&Ln(window.btoa)?function(t){return window.btoa(unescape(encodeURIComponent(t)))}:"undefined"!=typeof Buffer?function(t){return Buffer.from(t).toString("base64")}:function(t){return null},jo=Array.prototype.slice;function Xo(t,e,n){return(e-t)*n+t}function Yo(t,e,n,i){for(var r=e.length,o=0;oi?e:t,o=Math.min(n,i),a=r[o-1]||{color:[0,0,0,0],offset:0},s=o;sa)i.length=a;else for(var s=o;s=1},t.prototype.getAdditiveTrack=function(){return this._additiveTrack},t.prototype.addKeyframe=function(t,e,n){this._needsSort=!0;var i=this.keyframes,r=i.length,o=!1,a=6,s=e;if(Sn(e)){var l=function(t){return Sn(t&&t[0])?2:1}(e);a=l,(1===l&&!Nn(e[0])||2===l&&!Nn(e[0][0]))&&(o=!0)}else if(Nn(e)&&!Bn(e))a=0;else if(En(e))if(isNaN(+e)){var c=So(e);c&&(s=c,a=3)}else a=0;else if(Fn(e)){var d=_n({},s);d.colorStops=kn(e.colorStops,function(t){return{offset:t.offset,color:So(t.color)}}),Fo(e)?a=4:Bo(e)&&(a=5),s=d}0===r?this.valType=a:a===this.valType&&6!==a||(o=!0),this.discrete=this.discrete||o;var u={time:t,value:s,rawValue:e,percent:0};return n&&(u.easing=n,u.easingFunc=Ln(n)?n:Nr[n]||oo(n)),i.push(u),u},t.prototype.prepare=function(t,e){var n=this.keyframes;this._needsSort&&n.sort(function(t,e){return t.time-e.time});for(var i=this.valType,r=n.length,o=n[r-1],a=this.discrete,s=ia(i),l=na(i),c=0;c=0&&!(l[n].percent<=e);n--);n=p(n,c-2)}else{for(n=h;ne);n++);n=p(n-1,c-2)}r=l[n+1],i=l[n]}if(i&&r){this._lastFr=n,this._lastFrP=e;var f=r.percent-i.percent,g=0===f?1:p((e-i.percent)/f,1);r.easingFunc&&(g=r.easingFunc(g));var v=o?this._additiveValue:u?ra:t[d];if(!ia(s)&&!u||v||(v=this._additiveValue=[]),this.discrete)t[d]=g<1?i.rawValue:r.rawValue;else if(ia(s))1===s?Yo(v,i[a],r[a],g):function(t,e,n,i){for(var r=e.length,o=r&&e[0].length,a=0;a0&&s.addKeyframe(0,ta(l),i),this._trackKeys.push(a)}s.addKeyframe(t,ta(e[a]),i)}return this._maxTime=Math.max(this._maxTime,t),this},t.prototype.pause=function(){this._clip.pause(),this._paused=!0},t.prototype.resume=function(){this._clip.resume(),this._paused=!1},t.prototype.isPaused=function(){return!!this._paused},t.prototype.duration=function(t){return this._maxTime=t,this._force=!0,this},t.prototype._doneCallback=function(){this._setTracksFinished(),this._clip=null;var t=this._doneCbs;if(t)for(var e=t.length,n=0;n0)){this._started=1;for(var e=this,n=[],i=this._maxTime||0,r=0;r1){var a=o.pop();r.addKeyframe(a.time,t[i]),r.prepare(this._maxTime,r.getAdditiveTrack())}}}},t}();function sa(){return(new Date).getTime()}var la,ca,da=function(t){function e(e){var n=t.call(this)||this;return n._running=!1,n._time=0,n._pausedTime=0,n._pauseStart=0,n._paused=!1,e=e||{},n.stage=e.stage||{},n}return y(e,t),e.prototype.addClip=function(t){t.animation&&this.removeClip(t),this._head?(this._tail.next=t,t.prev=this._tail,t.next=null,this._tail=t):this._head=this._tail=t,t.animation=this},e.prototype.addAnimator=function(t){t.animation=this;var e=t.getClip();e&&this.addClip(e)},e.prototype.removeClip=function(t){if(t.animation){var e=t.prev,n=t.next;e?e.next=n:this._head=n,n?n.prev=e:this._tail=e,t.next=t.prev=t.animation=null}},e.prototype.removeAnimator=function(t){var e=t.getClip();e&&this.removeClip(e),t.animation=null},e.prototype.update=function(t){for(var e=sa()-this._pausedTime,n=e-this._time,i=this._head;i;){var r=i.next;i.step(e,n)?(i.ondestroy(),this.removeClip(i),i=r):i=r}this._time=e,t||(this.trigger("frame",n),this.stage.update&&this.stage.update())},e.prototype._startLoop=function(){var t=this;this._running=!0,Er(function e(){t._running&&(Er(e),!t._paused&&t.update())})},e.prototype.start=function(){this._running||(this._time=sa(),this._pausedTime=0,this._startLoop())},e.prototype.stop=function(){this._running=!1},e.prototype.pause=function(){this._paused||(this._pauseStart=sa(),this._paused=!0)},e.prototype.resume=function(){this._paused&&(this._pausedTime+=sa()-this._pauseStart,this._paused=!1)},e.prototype.clear=function(){for(var t=this._head;t;){var e=t.next;t.prev=t.next=t.animation=null,t=e}this._head=this._tail=null},e.prototype.isFinished=function(){return null==this._head},e.prototype.animate=function(t,e){e=e||{},this.start();var n=new aa(t,e.loop);return this.addAnimator(n),n},e}(bi),ua=Ye.domSupported,ha=(ca={pointerdown:1,pointerup:1,pointermove:1,pointerout:1},{mouse:la=["click","dblclick","mousewheel","wheel","mouseout","mouseup","mousedown","mousemove","contextmenu"],touch:["touchstart","touchend","touchmove"],pointer:kn(la,function(t){var e=t.replace("mouse","pointer");return ca.hasOwnProperty(e)?e:t})}),pa=["mousemove","mouseup"],fa=["pointermove","pointerup"],ga=!1;function va(t){var e=t.pointerType;return"pen"===e||"touch"===e}function ma(t){t&&(t.zrByTouch=!0)}function ya(t,e){for(var n=e,i=!1;n&&9!==n.nodeType&&!(i=n.domBelongToZr||n!==e&&n===t.painterRoot);)n=n.parentNode;return i}var _a=function(t,e){this.stopPropagation=ri,this.stopImmediatePropagation=ri,this.preventDefault=ri,this.type=e.type,this.target=this.currentTarget=t.dom,this.pointerType=e.pointerType,this.clientX=e.clientX,this.clientY=e.clientY},ba={mousedown:function(t){t=$i(this.dom,t),this.__mayPointerCapture=[t.zrX,t.zrY],this.trigger("mousedown",t)},mousemove:function(t){t=$i(this.dom,t);var e=this.__mayPointerCapture;!e||t.zrX===e[0]&&t.zrY===e[1]||this.__togglePointerCapture(!0),this.trigger("mousemove",t)},mouseup:function(t){t=$i(this.dom,t),this.__togglePointerCapture(!1),this.trigger("mouseup",t)},mouseout:function(t){ya(this,(t=$i(this.dom,t)).toElement||t.relatedTarget)||(this.__pointerCapturing&&(t.zrEventControl="no_globalout"),this.trigger("mouseout",t))},wheel:function(t){ga=!0,t=$i(this.dom,t),this.trigger("mousewheel",t)},mousewheel:function(t){ga||(t=$i(this.dom,t),this.trigger("mousewheel",t))},touchstart:function(t){ma(t=$i(this.dom,t)),this.__lastTouchMoment=new Date,this.handler.processGesture(t,"start"),ba.mousemove.call(this,t),ba.mousedown.call(this,t)},touchmove:function(t){ma(t=$i(this.dom,t)),this.handler.processGesture(t,"change"),ba.mousemove.call(this,t)},touchend:function(t){ma(t=$i(this.dom,t)),this.handler.processGesture(t,"end"),ba.mouseup.call(this,t),+new Date-+this.__lastTouchMoment<300&&ba.click.call(this,t)},pointerdown:function(t){ba.mousedown.call(this,t)},pointermove:function(t){va(t)||ba.mousemove.call(this,t)},pointerup:function(t){ba.mouseup.call(this,t)},pointerout:function(t){va(t)||ba.mouseout.call(this,t)}};Cn(["click","dblclick","contextmenu"],function(t){ba[t]=function(e){e=$i(this.dom,e),this.trigger(t,e)}});var xa={pointermove:function(t){va(t)||xa.mousemove.call(this,t)},pointerup:function(t){xa.mouseup.call(this,t)},mousemove:function(t){this.trigger("mousemove",t)},mouseup:function(t){var e=this.__pointerCapturing;this.__togglePointerCapture(!1),this.trigger("mouseup",t),e&&(t.zrEventControl="only_globalout",this.trigger("mouseout",t))}};function wa(t,e){var n=e.domHandlers;Ye.pointerEventsSupported?Cn(ha.pointer,function(i){Ca(e,i,function(e){n[i].call(t,e)})}):(Ye.touchEventsSupported&&Cn(ha.touch,function(i){Ca(e,i,function(r){n[i].call(t,r),function(t){t.touching=!0,null!=t.touchTimer&&(clearTimeout(t.touchTimer),t.touchTimer=null),t.touchTimer=setTimeout(function(){t.touching=!1,t.touchTimer=null},700)}(e)})}),Cn(ha.mouse,function(i){Ca(e,i,function(r){r=Oi(r),e.touching||n[i].call(t,r)})}))}function Sa(t,e){function n(n){Ca(e,n,function(i){i=Oi(i),ya(t,i.target)||(i=function(t,e){return $i(t.dom,new _a(t,e),!0)}(t,i),e.domHandlers[n].call(t,i))},{capture:!0})}Ye.pointerEventsSupported?Cn(fa,n):Ye.touchEventsSupported||Cn(pa,n)}function Ca(t,e,n,i){t.mounted[e]=n,t.listenerOpts[e]=i,function(t,e,n,i){t.addEventListener(e,n,i)}(t.domTarget,e,n,i)}function ka(t){var e=t.mounted;for(var n in e)e.hasOwnProperty(n)&&Ri(t.domTarget,n,e[n],t.listenerOpts[n]);t.mounted={}}var Ma=function(t,e){this.mounted={},this.listenerOpts={},this.touching=!1,this.domTarget=t,this.domHandlers=e},Ta=function(t){function e(e,n){var i=t.call(this)||this;return i.__pointerCapturing=!1,i.dom=e,i.painterRoot=n,i._localHandlerScope=new Ma(e,ba),ua&&(i._globalHandlerScope=new Ma(document,xa)),wa(i,i._localHandlerScope),i}return y(e,t),e.prototype.dispose=function(){ka(this._localHandlerScope),ua&&ka(this._globalHandlerScope)},e.prototype.setCursor=function(t){this.dom.style&&(this.dom.style.cursor=t||"default")},e.prototype.__togglePointerCapture=function(t){if(this.__mayPointerCapture=null,ua&&+this.__pointerCapturing^+t){this.__pointerCapturing=t;var e=this._globalHandlerScope;t?Sa(this,e):ka(e)}},e}(bi),Ia=1;Ye.hasGlobalWindow&&(Ia=Math.max(window.devicePixelRatio||window.screen&&window.screen.deviceXDPI/window.screen.logicalXDPI||1,1));var Da=Ia,Aa="#333",Pa="#ccc",La=Wi,Ea=5e-5;function za(t){return t>Ea||t<-5e-5}var Na,Oa=[],$a=[],Ra=[1,0,0,1,0,0],Ha=Math.abs,Fa=function(){function t(){}var e;return t.prototype.getLocalTransform=function(e){return t.getLocalTransform(this,e)},t.prototype.setPosition=function(t){this.x=t[0],this.y=t[1]},t.prototype.setScale=function(t){this.scaleX=t[0],this.scaleY=t[1]},t.prototype.setSkew=function(t){this.skewX=t[0],this.skewY=t[1]},t.prototype.setOrigin=function(t){this.originX=t[0],this.originY=t[1]},t.prototype.needLocalTransform=function(){return za(this.rotation)||za(this.x)||za(this.y)||za(this.scaleX-1)||za(this.scaleY-1)||za(this.skewX)||za(this.skewY)},t.prototype.updateTransform=function(){var t=this.parent&&this.parent.transform,e=this.needLocalTransform(),n=this.transform;e||t?(n=n||[1,0,0,1,0,0],e?this.getLocalTransform(n):La(n),t&&(e?Gi(n,t,n):Ui(n,t)),this.transform=n,this._resolveGlobalScaleRatio(n)):n&&(La(n),this.invTransform=null)},t.prototype._resolveGlobalScaleRatio=function(t){var e=this.globalScaleRatio;if(null!=e&&1!==e){this.getGlobalScale(Oa);var n=Oa[0]<0?-1:1,i=Oa[1]<0?-1:1,r=((Oa[0]-n)*e+n)/Oa[0]||0,o=((Oa[1]-i)*e+i)/Oa[1]||0;t[0]*=r,t[1]*=r,t[2]*=o,t[3]*=o}this.invTransform=this.invTransform||[1,0,0,1,0,0],Xi(this.invTransform,t)},t.prototype.getComputedTransform=function(){for(var t=this,e=[];t;)e.push(t),t=t.parent;for(;t=e.pop();)t.updateTransform();return this.transform},t.prototype.setLocalTransform=function(t){if(t){var e=t[0]*t[0]+t[1]*t[1],n=t[2]*t[2]+t[3]*t[3],i=Math.atan2(t[1],t[0]),r=Math.PI/2+i-Math.atan2(t[3],t[2]);n=Math.sqrt(n)*Math.cos(r),e=Math.sqrt(e),this.skewX=r,this.skewY=0,this.rotation=-i,this.x=+t[4],this.y=+t[5],this.scaleX=e,this.scaleY=n,this.originX=0,this.originY=0}},t.prototype.decomposeTransform=function(){if(this.transform){var t=this.parent,e=this.transform;t&&t.transform&&(t.invTransform=t.invTransform||[1,0,0,1,0,0],Gi($a,t.invTransform,e),e=$a);var n=this.originX,i=this.originY;(n||i)&&(Ra[4]=n,Ra[5]=i,Gi($a,e,Ra),$a[4]-=n,$a[5]-=i,e=$a),this.setLocalTransform(e)}},t.prototype.getGlobalScale=function(t){var e=this.transform;return t=t||[],e?(t[0]=Math.sqrt(e[0]*e[0]+e[1]*e[1]),t[1]=Math.sqrt(e[2]*e[2]+e[3]*e[3]),e[0]<0&&(t[0]=-t[0]),e[3]<0&&(t[1]=-t[1]),t):(t[0]=1,t[1]=1,t)},t.prototype.transformCoordToLocal=function(t,e){var n=[t,e],i=this.invTransform;return i&&gi(n,n,i),n},t.prototype.transformCoordToGlobal=function(t,e){var n=[t,e],i=this.transform;return i&&gi(n,n,i),n},t.prototype.getLineScale=function(){var t=this.transform;return t&&Ha(t[0]-1)>1e-10&&Ha(t[3]-1)>1e-10?Math.sqrt(Ha(t[0]*t[3]-t[2]*t[1])):1},t.prototype.copyTransform=function(t){Va(this,t)},t.getLocalTransform=function(t,e){e=e||[];var n=t.originX||0,i=t.originY||0,r=t.scaleX,o=t.scaleY,a=t.anchorX,s=t.anchorY,l=t.rotation||0,c=t.x,d=t.y,u=t.skewX?Math.tan(t.skewX):0,h=t.skewY?Math.tan(-t.skewY):0;if(n||i||a||s){var p=n+a,f=i+s;e[4]=-p*r-u*f*o,e[5]=-f*o-h*p*r}else e[4]=e[5]=0;return e[0]=r,e[3]=o,e[1]=h*r,e[2]=u*o,l&&ji(e,e,l),e[4]+=n+c,e[5]+=i+d,e},t.initDefaultProps=((e=t.prototype).scaleX=e.scaleY=e.globalScaleRatio=1,void(e.x=e.y=e.originX=e.originY=e.skewX=e.skewY=e.rotation=e.anchorX=e.anchorY=0)),t}(),Ba=["x","y","originX","originY","anchorX","anchorY","rotation","scaleX","scaleY","skewX","skewY"];function Va(t,e){for(var n=0;n=Ga)){t=t||Ke;for(var e=[],n=+new Date,i=0;i<=127;i++)e[i]=en.measureText(String.fromCharCode(i),t).width;var r=+new Date-n;return r>16?Ua=Ga:r>2&&Ua++,e}}(t.font),t.asciiWidthMapTried=!0),0<=e&&e<=127?null!=t.asciiWidthMap?t.asciiWidthMap[e]:t.asciiCharWidth:t.stWideCharWidth}function ja(t,e){var n=t.strWidthCache,i=n.get(e);return null==i&&(i=en.measureText(e,t.font).width,n.put(e,i)),i}function Xa(t,e,n,i){var r=ja(Wa(e),t),o=Qa(e),a=Za(0,r,n),s=Ka(0,o,i);return new cr(a,s,r,o)}function Ya(t,e,n,i){var r=((t||"")+"").split("\n");if(1===r.length)return Xa(r[0],e,n,i);for(var o=new cr(0,0,0,0),a=0;a=0?parseFloat(t)/100*e:parseFloat(t):t}function ts(t,e,n){var i=e.position||"inside",r=null!=e.distance?e.distance:5,o=n.height,a=n.width,s=o/2,l=n.x,c=n.y,d="left",u="top";if(i instanceof Array)l+=Ja(i[0],n.width),c+=Ja(i[1],n.height),d=null,u=null;else switch(i){case"left":l-=r,c+=s,d="right",u="middle";break;case"right":l+=r+a,c+=s,u="middle";break;case"top":l+=a/2,c-=r,d="center",u="bottom";break;case"bottom":l+=a/2,c+=o+r,d="center";break;case"inside":l+=a/2,c+=s,d="center",u="middle";break;case"insideLeft":l+=r,c+=s,u="middle";break;case"insideRight":l+=a-r,c+=s,d="right",u="middle";break;case"insideTop":l+=a/2,c+=r,d="center";break;case"insideBottom":l+=a/2,c+=o-r,d="center",u="bottom";break;case"insideTopLeft":l+=r,c+=r;break;case"insideTopRight":l+=a-r,c+=r,d="right";break;case"insideBottomLeft":l+=r,c+=o-r,u="bottom";break;case"insideBottomRight":l+=a-r,c+=o-r,d="right",u="bottom"}return(t=t||{}).x=l,t.y=c,t.align=d,t.verticalAlign=u,t}var es="__zr_normal__",ns=Ba.concat(["ignore"]),is=Mn(Ba,function(t,e){return t[e]=!0,t},{ignore:!1}),rs={},os=new cr(0,0,0,0),as=[],ss=function(){function t(t){this.id=gn(),this.animators=[],this.currentStates=[],this.states={},this._init(t)}return t.prototype._init=function(t){this.attr(t)},t.prototype.drift=function(t,e,n){switch(this.draggable){case"horizontal":e=0;break;case"vertical":t=0}var i=this.transform;i||(i=this.transform=[1,0,0,1,0,0]),i[4]+=t,i[5]+=e,this.decomposeTransform(),this.markRedraw()},t.prototype.beforeUpdate=function(){},t.prototype.afterUpdate=function(){},t.prototype.update=function(){this.updateTransform(),this.__dirty&&this.updateInnerText()},t.prototype.updateInnerText=function(t){var e=this._textContent;if(e&&(!e.ignore||t)){this.textConfig||(this.textConfig={});var n=this.textConfig,i=n.local,r=e.innerTransformable,o=void 0,a=void 0,s=!1;r.parent=i?this:null;var l=!1;r.copyTransform(e);var c=null!=n.position,d=n.autoOverflowArea,u=void 0;if((d||c)&&(u=os,n.layoutRect?u.copy(n.layoutRect):u.copy(this.getBoundingRect()),i||u.applyTransform(this.transform)),c){this.calculateTextPosition?this.calculateTextPosition(rs,n,u):ts(rs,n,u),r.x=rs.x,r.y=rs.y,o=rs.align,a=rs.verticalAlign;var h=n.origin;if(h&&null!=n.rotation){var p=void 0,f=void 0;"center"===h?(p=.5*u.width,f=.5*u.height):(p=Ja(h[0],u.width),f=Ja(h[1],u.height)),l=!0,r.originX=-r.x+p+(i?0:u.x),r.originY=-r.y+f+(i?0:u.y)}}null!=n.rotation&&(r.rotation=n.rotation);var g=n.offset;g&&(r.x+=g[0],r.y+=g[1],l||(r.originX=-g[0],r.originY=-g[1]));var v=this._innerTextDefaultStyle||(this._innerTextDefaultStyle={});if(d){var m=v.overflowRect=v.overflowRect||new cr(0,0,0,0);r.getLocalTransform(as),Xi(as,as),cr.copy(m,u),m.applyTransform(as)}else v.overflowRect=null;var y=void 0,_=void 0,b=void 0;(null==n.inside?"string"==typeof n.position&&n.position.indexOf("inside")>=0:n.inside)&&this.canBeInsideText()?(y=n.insideFill,_=n.insideStroke,null!=y&&"auto"!==y||(y=this.getInsideTextFill()),null!=_&&"auto"!==_||(_=this.getInsideTextStroke(y),b=!0)):(y=n.outsideFill,_=n.outsideStroke,null!=y&&"auto"!==y||(y=this.getOutsideFill()),null!=_&&"auto"!==_||(_=this.getOutsideStroke(y),b=!0)),(y=y||"#000")===v.fill&&_===v.stroke&&b===v.autoStroke&&o===v.align&&a===v.verticalAlign||(s=!0,v.fill=y,v.stroke=_,v.autoStroke=b,v.align=o,v.verticalAlign=a,e.setDefaultTextStyle(v)),e.__dirty|=1,s&&e.dirtyStyle(!0)}},t.prototype.canBeInsideText=function(){return!0},t.prototype.getInsideTextFill=function(){return"#fff"},t.prototype.getInsideTextStroke=function(t){return"#000"},t.prototype.getOutsideFill=function(){return this.__zr&&this.__zr.isDarkMode()?Pa:Aa},t.prototype.getOutsideStroke=function(t){var e=this.__zr&&this.__zr.getBackgroundColor(),n="string"==typeof e&&So(e);n||(n=[255,255,255,1]);for(var i=n[3],r=this.__zr.isDarkMode(),o=0;o<3;o++)n[o]=n[o]*i+(r?0:255)*(1-i);return n[3]=1,To(n,"rgba")},t.prototype.traverse=function(t,e){},t.prototype.attrKV=function(t,e){"textConfig"===t?this.setTextConfig(e):"textContent"===t?this.setTextContent(e):"clipPath"===t?this.setClipPath(e):"extra"===t?(this.extra=this.extra||{},_n(this.extra,e)):this[t]=e},t.prototype.hide=function(){this.ignore=!0,this.markRedraw()},t.prototype.show=function(){this.ignore=!1,this.markRedraw()},t.prototype.attr=function(t,e){if("string"==typeof t)this.attrKV(t,e);else if(On(t))for(var n=In(t),i=0;i0},t.prototype.getState=function(t){return this.states[t]},t.prototype.ensureState=function(t){var e=this.states;return e[t]||(e[t]={}),e[t]},t.prototype.clearStates=function(t){this.useState(es,!1,t)},t.prototype.useState=function(t,e,n,i){var r=t===es;if(this.hasState()||!r){var o=this.currentStates,a=this.stateTransition;if(!(xn(o,t)>=0)||!e&&1!==o.length){var s;if(this.stateProxy&&!r&&(s=this.stateProxy(t)),s||(s=this.states&&this.states[t]),s||r){r||this.saveCurrentToNormalState(s);var l=!!(s&&s.hoverLayer||i);l&&this._toggleHoverLayerFlag(!0),this._applyStateObj(t,s,this._normalState,e,!n&&!this.__inHover&&a&&a.duration>0,a);var c=this._textContent,d=this._textGuide;return c&&c.useState(t,e,n,l),d&&d.useState(t,e,n,l),r?(this.currentStates=[],this._normalState={}):e?this.currentStates.push(t):this.currentStates=[t],this._updateAnimationTargets(),this.markRedraw(),!l&&this.__inHover&&(this._toggleHoverLayerFlag(!1),this.__dirty&=-2),s}vn("State "+t+" not exists.")}}},t.prototype.useStates=function(t,e,n){if(t.length){var i=[],r=this.currentStates,o=t.length,a=o===r.length;if(a)for(var s=0;s0,p);var f=this._textContent,g=this._textGuide;f&&f.useStates(t,e,u),g&&g.useStates(t,e,u),this._updateAnimationTargets(),this.currentStates=t.slice(),this.markRedraw(),!u&&this.__inHover&&(this._toggleHoverLayerFlag(!1),this.__dirty&=-2)}else this.clearStates()},t.prototype.isSilent=function(){for(var t=this;t;){if(t.silent)return!0;var e=t.__hostTarget;t=e?t.ignoreHostSilent?null:e:t.parent}return!1},t.prototype._updateAnimationTargets=function(){for(var t=0;t=0){var n=this.currentStates.slice();n.splice(e,1),this.useStates(n)}},t.prototype.replaceState=function(t,e,n){var i=this.currentStates.slice(),r=xn(i,t),o=xn(i,e)>=0;r>=0?o?i.splice(r,1):i[r]=e:n&&!o&&i.push(e),this.useStates(i)},t.prototype.toggleState=function(t,e){e?this.useState(t,!0):this.removeState(t)},t.prototype._mergeStates=function(t){for(var e,n={},i=0;i=0&&e.splice(n,1)}),this.animators.push(t),n&&n.animation.addAnimator(t),n&&n.wakeUp()},t.prototype.updateDuringAnimation=function(t){this.markRedraw()},t.prototype.stopAnimation=function(t,e){for(var n=this.animators,i=n.length,r=[],o=0;o0&&n.during&&o[0].during(function(t,e){n.during(e)});for(var h=0;h0||r.force&&!a.length){var w,S=void 0,C=void 0,k=void 0;if(s){C={},h&&(S={});for(b=0;b<_;b++){C[m=g[b]]=n[m],h?S[m]=i[m]:n[m]=i[m]}}else if(h){k={};for(b=0;b<_;b++){k[m=g[b]]=ta(n[m]),ds(n,i,m)}}(w=new aa(n,!1,!1,u?Tn(f,function(t){return t.targetName===e}):null)).targetName=e,r.scope&&(w.scope=r.scope),h&&S&&w.whenWithKeys(0,S,g),k&&w.whenWithKeys(0,k,g),w.whenWithKeys(null==c?500:c,s?C:i,g).delay(d||0),t.addAnimator(w,e),a.push(w)}}wn(ss,bi),wn(ss,Fa);var hs=function(t){function e(e){var n=t.call(this)||this;return n.isGroup=!0,n._children=[],n.attr(e),n}return y(e,t),e.prototype.childrenRef=function(){return this._children},e.prototype.children=function(){return this._children.slice()},e.prototype.childAt=function(t){return this._children[t]},e.prototype.childOfName=function(t){for(var e=this._children,n=0;n=0&&(n.splice(i,0,t),this._doAdd(t))}return this},e.prototype.replace=function(t,e){var n=xn(this._children,t);return n>=0&&this.replaceAt(e,n),this},e.prototype.replaceAt=function(t,e){var n=this._children,i=n[e];if(t&&t!==this&&t.parent!==this&&t!==i){n[e]=t,i.parent=null;var r=this.__zr;r&&i.removeSelfFromZr(r),this._doAdd(t)}return this},e.prototype._doAdd=function(t){t.parent&&t.parent.remove(t),t.parent=this;var e=this.__zr;e&&e!==t.__zr&&t.addSelfToZr(e),e&&e.refresh()},e.prototype.remove=function(t){var e=this.__zr,n=this._children,i=xn(n,t);return i<0||(n.splice(i,1),t.parent=null,e&&t.removeSelfFromZr(e),e&&e.refresh()),this},e.prototype.removeAll=function(){for(var t=this._children,e=this.__zr,n=0;n0&&(this._stillFrameAccum++,this._stillFrameAccum>this._sleepAfterStill&&this.animation.stop())},t.prototype.setSleepAfterStill=function(t){this._sleepAfterStill=t},t.prototype.wakeUp=function(){this._disposed||(this.animation.start(),this._stillFrameAccum=0)},t.prototype.refreshHover=function(){this._needsRefreshHover=!0},t.prototype.refreshHoverImmediately=function(){this._disposed||(this._needsRefreshHover=!1,this.painter.refreshHover&&"canvas"===this.painter.getType()&&this.painter.refreshHover())},t.prototype.resize=function(t){this._disposed||(t=t||{},this.painter.resize(t.width,t.height),this.handler.resize())},t.prototype.clearAnimation=function(){this._disposed||this.animation.clear()},t.prototype.getWidth=function(){if(!this._disposed)return this.painter.getWidth()},t.prototype.getHeight=function(){if(!this._disposed)return this.painter.getHeight()},t.prototype.setCursorStyle=function(t){this._disposed||this.handler.setCursorStyle(t)},t.prototype.findHover=function(t,e){if(!this._disposed)return this.handler.findHover(t,e)},t.prototype.on=function(t,e,n){return this._disposed||this.handler.on(t,e,n),this},t.prototype.off=function(t,e){this._disposed||this.handler.off(t,e)},t.prototype.trigger=function(t,e){this._disposed||this.handler.trigger(t,e)},t.prototype.clear=function(){if(!this._disposed){for(var t=this.storage.getRoots(),e=0;e0){if(t<=r)return a;if(t>=o)return s}else{if(t>=r)return a;if(t<=o)return s}else{if(t===r)return a;if(t===o)return s}return(t-r)/l*c+a}var Ss=function(t,e,n){switch(t){case"center":case"middle":t="50%";break;case"left":case"top":t="0%";break;case"right":case"bottom":t="100%"}return Cs(t,e,n)};function Cs(t,e,n){return En(t)?(i=t,i.replace(/^\s+|\s+$/g,"")).match(/%$/)?parseFloat(t)/100*e+(n||0):parseFloat(t):null==t?NaN:+t;var i}function ks(t,e,n){return null==e&&(e=10),e=Math.min(Math.max(0,e),20),t=(+t).toFixed(e),n?t:+t}function Ms(t){if(t=+t,isNaN(t))return 0;if(t>1e-14)for(var e=1,n=0;n<15;n++,e*=10)if(Math.round(t*e)/e===t)return n;return function(t){var e=t.toString().toLowerCase(),n=e.indexOf("e"),i=n>0?+e.slice(n+1):0,r=n>0?n:e.length,o=e.indexOf("."),a=o<0?0:r-1-o;return Math.max(0,a-i)}(t)}function Ts(t,e){var n=Math.max(Ms(t),Ms(e)),i=t+e;return n>20?i:ks(i,n)}function Is(t){var e=2*Math.PI;return(t%e+e)%e}function Ds(t){return t>-1e-4&&t=10&&e++,e}function Es(t,e){var n=Ls(t),i=Math.pow(10,n),r=t/i;return t=(r<1.5?1:r<2.5?2:r<4?3:r<7?5:10)*i,n>=-20?+t.toFixed(n<0?-n:0):t}function zs(t){var e=parseFloat(t);return e==t&&(0!==e||!En(t)||t.indexOf("x")<=0)?e:NaN}function Ns(){return Math.round(9*Math.random())}function Os(t,e){return 0===e?t:Os(e,t%e)}function Rs(t,e){return null==t?e:null==e?t:t*e/Os(t,e)}var $s="undefined"!=typeof console&&console.warn&&console.log;function Hs(t,e){!function(t,e){$s&&console[t]("[ECharts] "+e)}("error",t)}function Fs(t){throw new Error(t)}function Bs(t,e,n){return(e-t)*n+t}var Vs="series\0";function Ws(t){return t instanceof Array?t:null==t?[]:[t]}function Us(t,e,n){if(t){t[e]=t[e]||{},t.emphasis=t.emphasis||{},t.emphasis[e]=t.emphasis[e]||{};for(var i=0,r=n.length;i=0||r&&xn(r,s)<0)){var l=n.getShallow(s,e);null!=l&&(o[t[a][0]]=l)}}return o}}var yl=ml([["fill","color"],["shadowBlur"],["shadowOffsetX"],["shadowOffsetY"],["opacity"],["shadowColor"]]),_l=function(){function t(){}return t.prototype.getAreaStyle=function(t,e){return yl(this,t,e)},t}(),bl=new co(50);function xl(t){if("string"==typeof t){var e=bl.get(t);return e&&e.image}return t}function wl(t,e,n,i,r){if(t){if("string"==typeof t){if(e&&e.__zrImageSrc===t||!n)return e;var o=bl.get(t),a={hostEl:n,cb:i,cbPayload:r};return o?!Cl(e=o.image)&&o.pending.push(a):((e=en.loadImage(t,Sl,Sl)).__zrImageSrc=t,bl.put(t,e.__cachedImgObj={image:e,pending:[a]})),e}return t}return e}function Sl(){var t=this.__cachedImgObj;this.onload=this.onerror=this.__cachedImgObj=null;for(var e=0;e=s;c++)l-=s;var u=ja(a,n);return u>l&&(n="",u=0),l=t-u,r.ellipsis=n,r.ellipsisWidth=u,r.contentWidth=l,r.containerWidth=t,r}function Il(t,e,n){var i=n.containerWidth,r=n.contentWidth,o=n.fontMeasureInfo;if(!i)return t.textLine="",void(t.isTruncated=!1);var a=ja(o,e);if(a<=i)return t.textLine=e,void(t.isTruncated=!1);for(var s=0;;s++){if(a<=r||s>=n.maxIterations){e+=n.ellipsis;break}var l=0===s?Dl(e,r,o):a>0?Math.floor(e.length*r/a):0;a=ja(o,e=e.substr(0,l))}""===e&&(e=n.placeholder),t.textLine=e,t.isTruncated=!0}function Dl(t,e,n){for(var i=0,r=0,o=t.length;r0&&f+i.accumWidth>i.width&&(o=e.split("\n"),h=!0),i.accumWidth=f}else{var g=Ol(e,u,i.width,i.breakAll,i.accumWidth);i.accumWidth=g.accumWidth+p,a=g.linesWidths,o=g.lines}}o||(o=e.split("\n"));for(var v=Wa(u),m=0;m=32&&e<=591||e>=880&&e<=4351||e>=4608&&e<=5119||e>=7680&&e<=8303}(t)||!!zl[t]}function Ol(t,e,n,i,r){for(var o=[],a=[],s="",l="",c=0,u=0,h=Wa(e),d=0;dn:r+u+f>n)?u?(s||l)&&(g?(s||(s=l,l="",u=c=0),o.push(s),a.push(u-c),l+=p,s="",u=c+=f):(l&&(s+=l,l="",c=0),o.push(s),a.push(u),s=p,u=f)):g?(o.push(l),a.push(c),l=p,c=f):(o.push(p),a.push(f)):(u+=f,g?(l+=p,c+=f):(l&&(s+=l,l="",c=0),s+=p))}else l&&(s+=l,u+=c),o.push(s),a.push(u),s="",l="",c=0,u=0}return l&&(s+=l),s&&(o.push(s),a.push(u)),1===o.length&&(u+=r),{accumWidth:u,lines:o,linesWidths:a}}function Rl(t,e,n,i,r,o){if(t.baseX=n,t.baseY=i,t.outerWidth=t.outerHeight=null,e){var a=2*e.width,s=2*e.height;cr.set($l,Za(n,a,r),Ka(i,s,o),a,s),cr.intersect(e,$l,null,Hl);var l=Hl.outIntersectRect;t.outerWidth=l.width,t.outerHeight=l.height,t.baseX=Za(l.x,l.width,r,!0),t.baseY=Ka(l.y,l.height,o,!0)}}var $l=new cr(0,0,0,0),Hl={outIntersectRect:{},clamp:!0};function Fl(t){return null!=t?t+="":t=""}function Bl(t,e,n,i){var r=new cr(Za(t.x||0,e,t.textAlign),Ka(t.y||0,n,t.textBaseline),e,n),o=null!=i?i:Vl(t)?t.lineWidth:0;return o>0&&(r.x-=o/2,r.y-=o/2,r.width+=o,r.height+=o),r}function Vl(t){var e=t.stroke;return null!=e&&"none"!==e&&t.lineWidth>0}var Wl="__zr_style_"+Math.round(10*Math.random()),Ul={shadowBlur:0,shadowOffsetX:0,shadowOffsetY:0,shadowColor:"#000",opacity:1,blend:"source-over"},Gl={style:{shadowBlur:!0,shadowOffsetX:!0,shadowOffsetY:!0,shadowColor:!0,opacity:!0}};Ul[Wl]=!0;var ql=["z","z2","invisible"],jl=["invisible"],Xl=function(t){function e(e){return t.call(this,e)||this}var n;return y(e,t),e.prototype._init=function(e){for(var n=In(e),i=0;i1e-4)return s[0]=t-n,s[1]=e-i,l[0]=t+n,void(l[1]=e+i);if(nc[0]=tc(r)*n+t,nc[1]=Jl(r)*i+e,ic[0]=tc(o)*n+t,ic[1]=Jl(o)*i+e,c(s,nc,ic),u(l,nc,ic),(r%=ec)<0&&(r+=ec),(o%=ec)<0&&(o+=ec),r>o&&!a?o+=ec:rr&&(rc[0]=tc(p)*n+t,rc[1]=Jl(p)*i+e,c(s,rc,s),u(l,rc,l))}var hc={M:1,L:2,C:3,Q:4,A:5,Z:6,R:7},dc=[],pc=[],fc=[],gc=[],vc=[],mc=[],yc=Math.min,_c=Math.max,bc=Math.cos,xc=Math.sin,wc=Math.abs,Sc=Math.PI,Cc=2*Sc,kc="undefined"!=typeof Float32Array,Mc=[];function Tc(t){return Math.round(t/Sc*1e8)/1e8%2*Sc}var Ic=function(){function t(t){this.dpr=1,this._xi=0,this._yi=0,this._x0=0,this._y0=0,this._len=0,t&&(this._saveData=!1),this._saveData&&(this.data=[])}var e;return t.prototype.increaseVersion=function(){this._version++},t.prototype.getVersion=function(){return this._version},t.prototype.setScale=function(t,e,n){(n=n||0)>0&&(this._ux=wc(n/Da/t)||0,this._uy=wc(n/Da/e)||0)},t.prototype.setDPR=function(t){this.dpr=t},t.prototype.setContext=function(t){this._ctx=t},t.prototype.getContext=function(){return this._ctx},t.prototype.beginPath=function(){return this._ctx&&this._ctx.beginPath(),this.reset(),this},t.prototype.reset=function(){this._saveData&&(this._len=0),this._pathSegLen&&(this._pathSegLen=null,this._pathLen=0),this._version++},t.prototype.moveTo=function(t,e){return this._drawPendingPt(),this.addData(hc.M,t,e),this._ctx&&this._ctx.moveTo(t,e),this._x0=t,this._y0=e,this._xi=t,this._yi=e,this},t.prototype.lineTo=function(t,e){var n=wc(t-this._xi),i=wc(e-this._yi),r=n>this._ux||i>this._uy;if(this.addData(hc.L,t,e),this._ctx&&r&&this._ctx.lineTo(t,e),r)this._xi=t,this._yi=e,this._pendingPtDist=0;else{var o=n*n+i*i;o>this._pendingPtDist&&(this._pendingPtX=t,this._pendingPtY=e,this._pendingPtDist=o)}return this},t.prototype.bezierCurveTo=function(t,e,n,i,r,o){return this._drawPendingPt(),this.addData(hc.C,t,e,n,i,r,o),this._ctx&&this._ctx.bezierCurveTo(t,e,n,i,r,o),this._xi=r,this._yi=o,this},t.prototype.quadraticCurveTo=function(t,e,n,i){return this._drawPendingPt(),this.addData(hc.Q,t,e,n,i),this._ctx&&this._ctx.quadraticCurveTo(t,e,n,i),this._xi=n,this._yi=i,this},t.prototype.arc=function(t,e,n,i,r,o){this._drawPendingPt(),Mc[0]=i,Mc[1]=r,function(t,e){var n=Tc(t[0]);n<0&&(n+=Cc);var i=n-t[0],r=t[1];r+=i,!e&&r-n>=Cc?r=n+Cc:e&&n-r>=Cc?r=n-Cc:!e&&n>r?r=n+(Cc-Tc(n-r)):e&&n0&&o))for(var a=0;ac.length&&(this._expandData(),c=this.data);for(var u=0;u0&&(this._ctx&&this._ctx.lineTo(this._pendingPtX,this._pendingPtY),this._pendingPtDist=0)},t.prototype._expandData=function(){if(!(this.data instanceof Array)){for(var t=[],e=0;e11&&(this.data=new Float32Array(t)))}},t.prototype.getBoundingRect=function(){fc[0]=fc[1]=vc[0]=vc[1]=Number.MAX_VALUE,gc[0]=gc[1]=mc[0]=mc[1]=-Number.MAX_VALUE;var t,e=this.data,n=0,i=0,r=0,o=0;for(t=0;tn||wc(v)>i||h===e-1)&&(f=Math.sqrt(D*D+v*v),r=g,o=_);break;case hc.C:var m=t[h++],y=t[h++],_=(g=t[h++],t[h++]),b=t[h++],x=t[h++];f=Qr(r,o,m,y,g,_,b,x,10),r=b,o=x;break;case hc.Q:f=io(r,o,m=t[h++],y=t[h++],g=t[h++],_=t[h++],10),r=g,o=_;break;case hc.A:var w=t[h++],S=t[h++],C=t[h++],k=t[h++],M=t[h++],T=t[h++],I=T+M;h+=1,p&&(a=bc(M)*C+w,s=xc(M)*k+S),f=_c(C,k)*yc(Cc,Math.abs(T)),r=bc(I)*C+w,o=xc(I)*k+S;break;case hc.R:a=r=t[h++],s=o=t[h++],f=2*t[h++]+2*t[h++];break;case hc.Z:var D=a-r;v=s-o;f=Math.sqrt(D*D+v*v),r=a,o=s}f>=0&&(l[u++]=f,c+=f)}return this._pathLen=c,c},t.prototype.rebuildPath=function(t,e){var n,i,r,o,a,s,l,c,u,h,d=this.data,p=this._ux,f=this._uy,g=this._len,v=e<1,m=0,y=0,_=0;if(!v||(this._pathSegLen||this._calculateLength(),l=this._pathSegLen,c=e*this._pathLen))t:for(var b=0;b0&&(t.lineTo(u,h),_=0),x){case hc.M:n=r=d[b++],i=o=d[b++],t.moveTo(r,o);break;case hc.L:a=d[b++],s=d[b++];var S=wc(a-r),C=wc(s-o);if(S>p||C>f){if(v){if(m+(X=l[y++])>c){var k=(c-m)/X;t.lineTo(r*(1-k)+a*k,o*(1-k)+s*k);break t}m+=X}t.lineTo(a,s),r=a,o=s,_=0}else{var M=S*S+C*C;M>_&&(u=a,h=s,_=M)}break;case hc.C:var T=d[b++],I=d[b++],D=d[b++],A=d[b++],P=d[b++],L=d[b++];if(v){if(m+(X=l[y++])>c){Kr(r,T,D,P,k=(c-m)/X,dc),Kr(o,I,A,L,k,pc),t.bezierCurveTo(dc[1],pc[1],dc[2],pc[2],dc[3],pc[3]);break t}m+=X}t.bezierCurveTo(T,I,D,A,P,L),r=P,o=L;break;case hc.Q:T=d[b++],I=d[b++],D=d[b++],A=d[b++];if(v){if(m+(X=l[y++])>c){no(r,T,D,k=(c-m)/X,dc),no(o,I,A,k,pc),t.quadraticCurveTo(dc[1],pc[1],dc[2],pc[2]);break t}m+=X}t.quadraticCurveTo(T,I,D,A),r=D,o=A;break;case hc.A:var E=d[b++],z=d[b++],N=d[b++],O=d[b++],R=d[b++],$=d[b++],H=d[b++],F=!d[b++],B=N>O?N:O,V=wc(N-O)>.001,W=R+$,U=!1;if(v)m+(X=l[y++])>c&&(W=R+$*(c-m)/X,U=!0),m+=X;if(V&&t.ellipse?t.ellipse(E,z,N,O,H,R,W,F):t.arc(E,z,B,R,W,F),U)break t;w&&(n=bc(R)*N+E,i=xc(R)*O+z),r=bc(W)*N+E,o=xc(W)*O+z;break;case hc.R:n=r=d[b],i=o=d[b+1],a=d[b++],s=d[b++];var G=d[b++],q=d[b++];if(v){if(m+(X=l[y++])>c){var j=c-m;t.moveTo(a,s),t.lineTo(a+yc(j,G),s),(j-=G)>0&&t.lineTo(a+G,s+yc(j,q)),(j-=q)>0&&t.lineTo(a+_c(G-j,0),s+q),(j-=G)>0&&t.lineTo(a,s+_c(q-j,0));break t}m+=X}t.rect(a,s,G,q);break;case hc.Z:if(v){var X;if(m+(X=l[y++])>c){k=(c-m)/X;t.lineTo(r*(1-k)+n*k,o*(1-k)+i*k);break t}m+=X}t.closePath(),r=n,o=i}}},t.prototype.clone=function(){var e=new t,n=this.data;return e.data=n.slice?n.slice():Array.prototype.slice.call(n),e._len=this._len,e},t.prototype.canSave=function(){return!!this._saveData},t.CMD=hc,t.initDefaultProps=((e=t.prototype)._saveData=!0,e._ux=0,e._uy=0,e._pendingPtDist=0,void(e._version=0)),t}();function Dc(t,e,n,i,r,o,a){if(0===r)return!1;var s=r,l=0;if(a>e+s&&a>i+s||at+s&&o>n+s||oe+h&&u>i+h&&u>o+h&&u>s+h||ut+h&&c>n+h&&c>r+h&&c>a+h||c=0&&pe+c&&l>i+c&&l>o+c||lt+c&&s>n+c&&s>r+c||s=0&&gn||u+cr&&(r+=zc);var d=Math.atan2(l,s);return d<0&&(d+=zc),d>=i&&d<=r||d+zc>=i&&d+zc<=r}function Oc(t,e,n,i,r,o){if(o>e&&o>i||or?s:0}var Rc=Ic.CMD,$c=2*Math.PI;var Hc=[-1,-1,-1],Fc=[-1,-1];function Bc(){var t=Fc[0];Fc[0]=Fc[1],Fc[1]=t}function Vc(t,e,n,i,r,o,a,s,l,c){if(c>e&&c>i&&c>o&&c>s||c1&&Bc(),p=jr(e,i,o,s,Fc[0]),d>1&&(f=jr(e,i,o,s,Fc[1]))),2===d?ve&&s>i&&s>o||s=0&&u<=1&&(r[l++]=u);else{var c=a*a-4*o*s;if(Gr(c))(u=-a/(2*o))>=0&&u<=1&&(r[l++]=u);else if(c>0){var u,h=Rr(c),d=(-a-h)/(2*o);(u=(-a+h)/(2*o))>=0&&u<=1&&(r[l++]=u),d>=0&&d<=1&&(r[l++]=d)}}return l}(e,i,o,s,Hc);if(0===l)return 0;var c=eo(e,i,o);if(c>=0&&c<=1){for(var u=0,h=Jr(e,i,o,c),d=0;dn||s<-n)return 0;var l=Math.sqrt(n*n-s*s);Hc[0]=-l,Hc[1]=l;var c=Math.abs(i-r);if(c<1e-4)return 0;if(c>=$c-1e-4){i=0,r=$c;var u=o?1:-1;return a>=Hc[0]+t&&a<=Hc[1]+t?u:0}if(i>r){var h=i;i=r,r=h}i<0&&(i+=$c,r+=$c);for(var d=0,p=0;p<2;p++){var f=Hc[p];if(f+t>a){var g=Math.atan2(s,f);u=o?1:-1;g<0&&(g=$c+g),(g>=i&&g<=r||g+$c>=i&&g+$c<=r)&&(g>Math.PI/2&&g<1.5*Math.PI&&(u=-u),d+=u)}}return d}function Gc(t,e,n,i,r){for(var o,a,s=t.data,l=t.len(),c=0,u=0,h=0,d=0,p=0,f=0;f1&&(n||(c+=Oc(u,h,d,p,i,r))),v&&(d=u=s[f],p=h=s[f+1]),g){case Rc.M:u=d=s[f++],h=p=s[f++];break;case Rc.L:if(n){if(Dc(u,h,s[f],s[f+1],e,i,r))return!0}else c+=Oc(u,h,s[f],s[f+1],i,r)||0;u=s[f++],h=s[f++];break;case Rc.C:if(n){if(Ac(u,h,s[f++],s[f++],s[f++],s[f++],s[f],s[f+1],e,i,r))return!0}else c+=Vc(u,h,s[f++],s[f++],s[f++],s[f++],s[f],s[f+1],i,r)||0;u=s[f++],h=s[f++];break;case Rc.Q:if(n){if(Pc(u,h,s[f++],s[f++],s[f],s[f+1],e,i,r))return!0}else c+=Wc(u,h,s[f++],s[f++],s[f],s[f+1],i,r)||0;u=s[f++],h=s[f++];break;case Rc.A:var m=s[f++],y=s[f++],_=s[f++],b=s[f++],x=s[f++],w=s[f++];f+=1;var S=!!(1-s[f++]);o=Math.cos(x)*_+m,a=Math.sin(x)*b+y,v?(d=o,p=a):c+=Oc(u,h,o,a,i,r);var C=(i-m)*b/_+m;if(n){if(Nc(m,y,b,x,x+w,S,e,C,r))return!0}else c+=Uc(m,y,b,x,x+w,S,C,r);u=Math.cos(x+w)*_+m,h=Math.sin(x+w)*b+y;break;case Rc.R:if(d=u=s[f++],p=h=s[f++],o=d+s[f++],a=p+s[f++],n){if(Dc(d,p,o,p,e,i,r)||Dc(o,p,o,a,e,i,r)||Dc(o,a,d,a,e,i,r)||Dc(d,a,d,p,e,i,r))return!0}else c+=Oc(o,p,o,a,i,r),c+=Oc(d,a,d,p,i,r);break;case Rc.Z:if(n){if(Dc(u,h,d,p,e,i,r))return!0}else c+=Oc(u,h,d,p,i,r);u=d,h=p}}return n||function(t,e){return Math.abs(t-e)<1e-4}(h,p)||(c+=Oc(u,h,d,p,i,r)||0),0!==c}var qc=bn({fill:"#000",stroke:null,strokePercent:1,fillOpacity:1,strokeOpacity:1,lineDashOffset:0,lineWidth:1,lineCap:"butt",miterLimit:10,strokeNoScale:!1,strokeFirst:!1},Ul),jc={style:bn({fill:!0,stroke:!0,strokePercent:!0,fillOpacity:!0,strokeOpacity:!0,lineDashOffset:!0,lineWidth:!0,miterLimit:!0},Gl.style)},Xc=Ba.concat(["invisible","culling","z","z2","zlevel","parent"]),Yc=function(t){function e(e){return t.call(this,e)||this}var n;return y(e,t),e.prototype.update=function(){var n=this;t.prototype.update.call(this);var i=this.style;if(i.decal){var r=this._decalEl=this._decalEl||new e;r.buildPath===e.prototype.buildPath&&(r.buildPath=function(t){n.buildPath(t,n.shape)}),r.silent=!0;var o=r.style;for(var a in i)o[a]!==i[a]&&(o[a]=i[a]);o.fill=i.fill?i.decal:null,o.decal=null,o.shadowColor=null,i.strokeFirst&&(o.stroke=null);for(var s=0;s.5?Aa:e>.2?"#eee":Pa}if(t)return Pa}return Aa},e.prototype.getInsideTextStroke=function(t){var e=this.style.fill;if(En(e)){var n=this.__zr;if(!(!n||!n.isDarkMode())===Io(t,0)<.4)return e}},e.prototype.buildPath=function(t,e,n){},e.prototype.pathUpdated=function(){this.__dirty&=-5},e.prototype.getUpdatedPathProxy=function(t){return!this.path&&this.createPathProxy(),this.path.beginPath(),this.buildPath(this.path,this.shape,t),this.path},e.prototype.createPathProxy=function(){this.path=new Ic(!1)},e.prototype.hasStroke=function(){var t=this.style,e=t.stroke;return!(null==e||"none"===e||!(t.lineWidth>0))},e.prototype.hasFill=function(){var t=this.style.fill;return null!=t&&"none"!==t},e.prototype.getBoundingRect=function(){var t=this._rect,e=this.style,n=!t;if(n){var i=!1;this.path||(i=!0,this.createPathProxy());var r=this.path;(i||4&this.__dirty)&&(r.beginPath(),this.buildPath(r,this.shape,!1),this.pathUpdated()),t=r.getBoundingRect()}if(this._rect=t,this.hasStroke()&&this.path&&this.path.len()>0){var o=this._rectStroke||(this._rectStroke=t.clone());if(this.__dirty||n){o.copy(t);var a=e.strokeNoScale?this.getLineScale():1,s=e.lineWidth;if(!this.hasFill()){var l=this.strokeContainThreshold;s=Math.max(s,null==l?4:l)}a>1e-10&&(o.width+=s/a,o.height+=s/a,o.x-=s/a/2,o.y-=s/a/2)}return o}return t},e.prototype.contain=function(t,e){var n=this.transformCoordToLocal(t,e),i=this.getBoundingRect(),r=this.style;if(t=n[0],e=n[1],i.contain(t,e)){var o=this.path;if(this.hasStroke()){var a=r.lineWidth,s=r.strokeNoScale?this.getLineScale():1;if(s>1e-10&&(this.hasFill()||(a=Math.max(a,this.strokeContainThreshold)),function(t,e,n,i){return Gc(t,e,!0,n,i)}(o,a/s,t,e)))return!0}if(this.hasFill())return function(t,e,n){return Gc(t,0,!1,e,n)}(o,t,e)}return!1},e.prototype.dirtyShape=function(){this.__dirty|=4,this._rect&&(this._rect=null),this._decalEl&&this._decalEl.dirtyShape(),this.markRedraw()},e.prototype.dirty=function(){this.dirtyStyle(),this.dirtyShape()},e.prototype.animateShape=function(t){return this.animate("shape",t)},e.prototype.updateDuringAnimation=function(t){"style"===t?this.dirtyStyle():"shape"===t?this.dirtyShape():this.markRedraw()},e.prototype.attrKV=function(e,n){"shape"===e?this.setShape(n):t.prototype.attrKV.call(this,e,n)},e.prototype.setShape=function(t,e){var n=this.shape;return n||(n=this.shape={}),"string"==typeof t?n[t]=e:_n(n,t),this.dirtyShape(),this},e.prototype.shapeChanged=function(){return!!(4&this.__dirty)},e.prototype.createStyle=function(t){return ni(qc,t)},e.prototype._innerSaveToNormal=function(e){t.prototype._innerSaveToNormal.call(this,e);var n=this._normalState;e.shape&&!n.shape&&(n.shape=_n({},this.shape))},e.prototype._applyStateObj=function(e,n,i,r,o,a){t.prototype._applyStateObj.call(this,e,n,i,r,o,a);var s,l=!(n&&r);if(n&&n.shape?o?r?s=n.shape:(s=_n({},i.shape),_n(s,n.shape)):(s=_n({},r?this.shape:i.shape),_n(s,n.shape)):l&&(s=i.shape),s)if(o){this.shape=_n({},this.shape);for(var c={},u=In(s),h=0;hc&&(n*=c/(a=n+i),i*=c/a),r+o>c&&(r*=c/(a=r+o),o*=c/a),i+r>u&&(i*=u/(a=i+r),r*=u/a),n+o>u&&(n*=u/(a=n+o),o*=u/a),t.moveTo(s+n,l),t.lineTo(s+c-i,l),0!==i&&t.arc(s+c-i,l+i,i,-Math.PI/2,0),t.lineTo(s+c,l+u-r),0!==r&&t.arc(s+c-r,l+u-r,r,0,Math.PI/2),t.lineTo(s+o,l+u),0!==o&&t.arc(s+o,l+u-o,o,Math.PI/2,Math.PI),t.lineTo(s,l+n),0!==n&&t.arc(s+n,l+n,n,Math.PI,1.5*Math.PI)}(t,e):t.rect(n,i,r,o)},e.prototype.isZeroArea=function(){return!this.shape.width||!this.shape.height},e}(Yc);su.prototype.type="rect";var lu={fill:"#000"},cu={},uu={style:bn({fill:!0,stroke:!0,fillOpacity:!0,strokeOpacity:!0,lineWidth:!0,fontSize:!0,lineHeight:!0,width:!0,height:!0,textShadowColor:!0,textShadowBlur:!0,textShadowOffsetX:!0,textShadowOffsetY:!0,backgroundColor:!0,padding:!0,borderColor:!0,borderWidth:!0,borderRadius:!0},Gl.style)},hu=function(t){function e(e){var n=t.call(this)||this;return n.type="text",n._children=[],n._defaultStyle=lu,n.attr(e),n}return y(e,t),e.prototype.childrenRef=function(){return this._children},e.prototype.update=function(){t.prototype.update.call(this),this.styleChanged()&&this._updateSubTexts();for(var e=0;em&&p){var _=Math.floor(m/d);f=f||v.length>_,y=(v=v.slice(0,_)).length*d}if(r&&u&&null!=g)for(var b=Tl(g,c,e.ellipsis,{minChar:e.truncateMinChar,placeholder:e.placeholder}),x={},w=0;w0,k=0;kg&&El(o,a.substring(g,v),e,f),El(o,d[2],e,f,d[1]),g=kl.lastIndex}gh){var z=o.lines.length;I>0?(k.tokens=k.tokens.slice(0,I),S(k,T,M),o.lines=o.lines.slice(0,C+1)):o.lines=o.lines.slice(0,C),o.isTruncated=o.isTruncated||o.lines.length=0&&"right"===(T=_[M]).align;)this._placeToken(T,t,x,f,k,"right",v),w-=T.width,k-=T.width,M--;for(C+=(s-(C-p)-(g-k)-w)/2;S<=M;)T=_[S],this._placeToken(T,t,x,f,C+T.width/2,"center",v),C+=T.width,S++;f+=x}},e.prototype._placeToken=function(t,e,n,i,r,o,a){var s=e.rich[t.styleName]||{};s.text=t.text;var l=t.verticalAlign,c=i+n/2;"top"===l?c=i+t.height/2:"bottom"===l&&(c=i+n-t.height/2),!t.isLineHolder&&Su(s)&&this._renderBackground(s,e,"right"===o?r-t.width:"center"===o?r-t.width/2:r,c-t.height/2,t.width,t.height);var u=!!s.backgroundColor,h=t.textPadding;h&&(r=xu(r,o,h),c-=t.height/2-h[0]-t.innerHeight/2);var d=this._getOrCreateChild(Kc),p=d.createStyle();d.useStyle(p);var f=this._defaultStyle,g=!1,v=0,m=!1,y=bu("fill"in s?s.fill:"fill"in e?e.fill:(g=!0,f.fill)),_=_u("stroke"in s?s.stroke:"stroke"in e?e.stroke:u||a||f.autoStroke&&!g?null:(v=2,m=!0,f.stroke)),b=s.textShadowBlur>0||e.textShadowBlur>0;p.text=t.text,p.x=r,p.y=c,b&&(p.shadowBlur=s.textShadowBlur||e.textShadowBlur||0,p.shadowColor=s.textShadowColor||e.textShadowColor||"transparent",p.shadowOffsetX=s.textShadowOffsetX||e.textShadowOffsetX||0,p.shadowOffsetY=s.textShadowOffsetY||e.textShadowOffsetY||0),p.textAlign=o,p.textBaseline="middle",p.font=t.font||Ke,p.opacity=Un(s.opacity,e.opacity,1),vu(p,s),_&&(p.lineWidth=Un(s.lineWidth,e.lineWidth,v),p.lineDash=Wn(s.lineDash,e.lineDash),p.lineDashOffset=e.lineDashOffset||0,p.stroke=_),y&&(p.fill=y),d.setBoundingRect(Bl(p,t.contentWidth,t.contentHeight,m?0:null))},e.prototype._renderBackground=function(t,e,n,i,r,o){var a,s,l,c=t.backgroundColor,u=t.borderWidth,h=t.borderColor,d=c&&c.image,p=c&&!d,f=t.borderRadius,g=this;if(p||t.lineHeight||u&&h){(a=this._getOrCreateChild(su)).useStyle(a.createStyle()),a.style.fill=null;var v=a.shape;v.x=n,v.y=i,v.width=r,v.height=o,v.r=f,a.dirtyShape()}if(p)(l=a.style).fill=c||null,l.fillOpacity=Wn(t.fillOpacity,1);else if(d){(s=this._getOrCreateChild(tu)).onload=function(){g.dirtyStyle()};var m=s.style;m.image=c.image,m.x=n,m.y=i,m.width=r,m.height=o}u&&h&&((l=a.style).lineWidth=u,l.stroke=h,l.strokeOpacity=Wn(t.strokeOpacity,1),l.lineDash=t.borderDash,l.lineDashOffset=t.borderDashOffset||0,a.strokeContainThreshold=0,a.hasFill()&&a.hasStroke()&&(l.strokeFirst=!0,l.lineWidth*=2));var y=(a||s).style;y.shadowBlur=t.shadowBlur||0,y.shadowColor=t.shadowColor||"transparent",y.shadowOffsetX=t.shadowOffsetX||0,y.shadowOffsetY=t.shadowOffsetY||0,y.opacity=Un(t.opacity,e.opacity,1)},e.makeFont=function(t){var e="";return mu(t)&&(e=[t.fontStyle,t.fontWeight,gu(t.fontSize),t.fontFamily||"sans-serif"].join(" ")),e&&Xn(e)||t.textFont||t.font},e}(Xl),du={left:!0,right:1,center:1},pu={top:1,bottom:1,middle:1},fu=["fontStyle","fontWeight","fontSize","fontFamily"];function gu(t){return"string"!=typeof t||-1===t.indexOf("px")&&-1===t.indexOf("rem")&&-1===t.indexOf("em")?isNaN(+t)?"12px":t+"px":t}function vu(t,e){for(var n=0;n=0,o=!1;if(t instanceof Yc){var a=Tu(t),s=r&&a.selectFill||a.normalFill,l=r&&a.selectStroke||a.normalStroke;if(Ru(s)||Ru(l)){var c=(i=i||{}).style||{};"inherit"===c.fill?(o=!0,i=_n({},i),(c=_n({},c)).fill=s):!Ru(c.fill)&&Ru(s)?(o=!0,i=_n({},i),(c=_n({},c)).fill=Ao(s)):!Ru(c.stroke)&&Ru(l)&&(o||(i=_n({},i),c=_n({},c)),c.stroke=Ao(l)),i.style=c}}if(i&&null==i.z2){o||(i=_n({},i));var u=t.z2EmphasisLift;i.z2=t.z2+(null!=u?u:10)}return i}(this,0,e,n);if("blur"===t)return function(t,e,n){var i=xn(t.currentStates,e)>=0,r=t.style.opacity,o=i?null:function(t,e,n,i){for(var r=t.style,o={},a=0;a0){var o={dataIndex:r,seriesIndex:t.seriesIndex};null!=i&&(o.dataType=i),e.push(o)}})}),e}function uh(t,e,n){gh(t,!0),qu(t,Yu),function(t,e,n){var i=Cu(t);null!=e?(i.focus=e,i.blurScope=n):i.focus&&(i.focus=null)}(t,e,n)}function hh(t,e,n,i){i?function(t){gh(t,!1)}(t):uh(t,e,n)}var dh=["emphasis","blur","select"],ph={itemStyle:"getItemStyle",lineStyle:"getLineStyle",areaStyle:"getAreaStyle"};function fh(t,e,n,i){n=n||"itemStyle";for(var r=0;r1&&(a*=Sh(f),s*=Sh(f));var g=(r===o?-1:1)*Sh((a*a*(s*s)-a*a*(p*p)-s*s*(d*d))/(a*a*(p*p)+s*s*(d*d)))||0,v=g*a*p/s,m=g*-s*d/a,y=(t+n)/2+kh(h)*v-Ch(h)*m,_=(e+i)/2+Ch(h)*v+kh(h)*m,b=Dh([1,0],[(d-v)/a,(p-m)/s]),x=[(d-v)/a,(p-m)/s],w=[(-1*d-v)/a,(-1*p-m)/s],S=Dh(x,w);if(Ih(x,w)<=-1&&(S=Mh),Ih(x,w)>=1&&(S=0),S<0){var C=Math.round(S/Mh*1e6)/1e6;S=2*Mh+C%2*Mh}u.addData(c,y,_,a,s,b,S,h,o)}var Ph=/([mlvhzcqtsa])([^mlvhzcqtsa]*)/gi,Lh=/-?([0-9]*\.)?[0-9]+([eE]-?[0-9]+)?/g;var Eh=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return y(e,t),e.prototype.applyTransform=function(t){},e}(Yc);function zh(t){return null!=t.setData}function Nh(t,e){var n=function(t){var e=new Ic;if(!t)return e;var n,i=0,r=0,o=i,a=r,s=Ic.CMD,l=t.match(Ph);if(!l)return e;for(var c=0;cA*A+P*P&&(C=M,k=T),{cx:C,cy:k,x0:-u,y0:-h,x1:C*(r/x-1),y1:k*(r/x-1)}}function Qh(t,e){var n,i=Xh(e.r,0),r=Xh(e.r0||0,0),o=i>0;if(o||r>0){if(o||(i=r,r=0),r>i){var a=i;i=r,r=a}var s=e.startAngle,l=e.endAngle;if(!isNaN(s)&&!isNaN(l)){var c=e.cx,u=e.cy,h=!!e.clockwise,d=qh(l-s),p=d>Bh&&d%Bh;if(p>Zh&&(d=p),i>Zh)if(d>Bh-Zh)t.moveTo(c+i*Wh(s),u+i*Vh(s)),t.arc(c,u,i,s,l,!h),r>Zh&&(t.moveTo(c+r*Wh(l),u+r*Vh(l)),t.arc(c,u,r,l,s,h));else{var f=void 0,g=void 0,v=void 0,m=void 0,y=void 0,_=void 0,b=void 0,x=void 0,w=void 0,S=void 0,C=void 0,k=void 0,M=void 0,T=void 0,I=void 0,D=void 0,A=i*Wh(s),P=i*Vh(s),L=r*Wh(l),E=r*Vh(l),z=d>Zh;if(z){var N=e.cornerRadius;N&&(n=function(t){var e;if(Pn(t)){var n=t.length;if(!n)return t;e=1===n?[t[0],t[0],0,0]:2===n?[t[0],t[0],t[1],t[1]]:3===n?t.concat(t[2]):t}else e=[t,t,t,t];return e}(N),f=n[0],g=n[1],v=n[2],m=n[3]);var O=qh(i-r)/2;if(y=Yh(O,v),_=Yh(O,m),b=Yh(O,f),x=Yh(O,g),C=w=Xh(y,_),k=S=Xh(b,x),(w>Zh||S>Zh)&&(M=i*Wh(l),T=i*Vh(l),I=r*Wh(s),D=r*Vh(s),dZh){var U=Yh(v,C),G=Yh(m,C),q=Kh(I,D,A,P,i,U,h),j=Kh(M,T,L,E,i,G,h);t.moveTo(c+q.cx+q.x0,u+q.cy+q.y0),C0&&t.arc(c+q.cx,u+q.cy,U,Gh(q.y0,q.x0),Gh(q.y1,q.x1),!h),t.arc(c,u,i,Gh(q.cy+q.y1,q.cx+q.x1),Gh(j.cy+j.y1,j.cx+j.x1),!h),G>0&&t.arc(c+j.cx,u+j.cy,G,Gh(j.y1,j.x1),Gh(j.y0,j.x0),!h))}else t.moveTo(c+A,u+P),t.arc(c,u,i,s,l,!h);else t.moveTo(c+A,u+P);if(r>Zh&&z)if(k>Zh){U=Yh(f,k),q=Kh(L,E,M,T,r,-(G=Yh(g,k)),h),j=Kh(A,P,I,D,r,-U,h);t.lineTo(c+q.cx+q.x0,u+q.cy+q.y0),k0&&t.arc(c+q.cx,u+q.cy,G,Gh(q.y0,q.x0),Gh(q.y1,q.x1),!h),t.arc(c,u,r,Gh(q.cy+q.y1,q.cx+q.x1),Gh(j.cy+j.y1,j.cx+j.x1),h),U>0&&t.arc(c+j.cx,u+j.cy,U,Gh(j.y1,j.x1),Gh(j.y0,j.x0),!h))}else t.lineTo(c+L,u+E),t.arc(c,u,r,l,s,h);else t.lineTo(c+L,u+E)}else t.moveTo(c,u);t.closePath()}}}var Jh=function(){this.cx=0,this.cy=0,this.r0=0,this.r=0,this.startAngle=0,this.endAngle=2*Math.PI,this.clockwise=!0,this.cornerRadius=0},td=function(t){function e(e){return t.call(this,e)||this}return y(e,t),e.prototype.getDefaultShape=function(){return new Jh},e.prototype.buildPath=function(t,e){Qh(t,e)},e.prototype.isZeroArea=function(){return this.shape.startAngle===this.shape.endAngle||this.shape.r===this.shape.r0},e}(Yc);td.prototype.type="sector";var ed=function(){this.cx=0,this.cy=0,this.r=0,this.r0=0},nd=function(t){function e(e){return t.call(this,e)||this}return y(e,t),e.prototype.getDefaultShape=function(){return new ed},e.prototype.buildPath=function(t,e){var n=e.cx,i=e.cy,r=2*Math.PI;t.moveTo(n+e.r,i),t.arc(n,i,e.r,0,r,!1),t.moveTo(n+e.r0,i),t.arc(n,i,e.r0,0,r,!0)},e}(Yc);function id(t,e,n){var i=e.smooth,r=e.points;if(r&&r.length>=2){if(i){var o=function(t,e,n,i){var r,o,a,s,l=[],c=[],u=[],h=[];if(i){a=[1/0,1/0],s=[-1/0,-1/0];for(var d=0,p=t.length;dkd[1]){if(r=!1,Md.negativeSize||n)return r;var s=Sd(kd[0]-Cd[1]),l=Sd(Cd[0]-kd[1]);xd(s,l)>Id.len()&&(s=l||!Md.bidirectional)&&(Yi.scale(Td,a,-l*i),Md.useDir&&Md.calcDirMTV()))}}return r},t.prototype._getProjMinMaxOnAxis=function(t,e,n){for(var i=this._axes[t],r=this._origin,o=e[0].dot(i)+r[t],a=o,s=o,l=1;l0){var h={duration:u.duration,delay:u.delay||0,easing:u.easing,done:o,force:!!o||!!a,setToFinal:!c,scope:t,during:a};l?e.animateFrom(n,h):e.animateTo(n,h)}else e.stopAnimation(),!l&&e.attr(n),a&&a(1),o&&o()}function zd(t,e,n,i,r,o){Ed("update",t,e,n,i,r,o)}function Nd(t,e,n,i,r,o){Ed("enter",t,e,n,i,r,o)}function Od(t){if(!t.__zr)return!0;for(var e=0;e=-1e-6)return!1;var f=t-r,g=e-o,v=np(f,g,c,u)/p;if(v<0||v>1)return!1;var m=np(f,g,h,d)/p;return!(m<0||m>1)}function np(t,e,n,i){return t*i-n*e}function ip(t,e,n,i,r){return null==e||(Nn(e)?rp[0]=rp[1]=rp[2]=rp[3]=e:(rp[0]=e[0],rp[1]=e[1],rp[2]=e[2],rp[3]=e[3]),i&&(rp[0]=bs(0,rp[0]),rp[1]=bs(0,rp[1]),rp[2]=bs(0,rp[2]),rp[3]=bs(0,rp[3])),n&&(rp[0]=-rp[0],rp[1]=-rp[1],rp[2]=-rp[2],rp[3]=-rp[3]),op(t,rp,"x","width",3,1,r&&r[0]||0),op(t,rp,"y","height",0,2,r&&r[1]||0)),t}var rp=[0,0,0,0];function op(t,e,n,i,r,o,a){var s=e[o]+e[r],l=t[i];t[i]+=s,a=bs(0,_s(a,l)),t[i]=0?-e[r]:e[o]>=0?l+e[o]:xs(s)>1e-8?(l-a)*e[r]/s:0):t[n]-=e[r]}function ap(t){var e=t.itemTooltipOption,n=t.componentModel,i=t.itemName,r=En(e)?{formatter:e}:e,o=n.mainType,a=n.componentIndex,s={componentType:o,name:i,$vars:["name"]};s[o+"Index"]=a;var l=t.formatterParamsExtra;l&&Cn(In(l),function(t){ii(s,t)||(s[t]=l[t],s.$vars.push(t))});var c=Cu(t.el);c.componentMainType=o,c.componentIndex=a,c.tooltipConfig={name:i,option:bn({content:i,encodeHTMLContent:!0,formatterParams:s},r)}}function sp(t,e){var n;t.isGroup&&(n=e(t)),n||t.traverse(e)}function lp(t,e){if(t)if(Pn(t))for(var n=0;ne&&(e=i),ie&&(n=e=0),{min:n,max:e}},clipPointsByRect:function(t,e){return kn(t,function(t){var n=t[0];n=bs(n,e.x),n=_s(n,e.x+e.width);var i=t[1];return i=bs(i,e.y),[n,i=_s(i,e.y+e.height)]})},clipRectByRect:function(t,e){var n=bs(t.x,e.x),i=_s(t.x+t.width,e.x+e.width),r=bs(t.y,e.y),o=_s(t.y+t.height,e.y+e.height);if(i>=n&&o>=r)return{x:n,y:r,width:i-n,height:o-r}},createIcon:tp,ensureCopyRect:hp,ensureCopyTransform:dp,expandOrShrinkRect:ip,extendPath:function(t,e){return Vd(t,e)},extendShape:function(t){return Yc.extend(t)},getShapeClass:function(t){if(Hd.hasOwnProperty(t))return Hd[t]},getTransform:function(t,e){for(var n=Wi([]);t&&t!==e;)Gi(n,t.getLocalTransform(),n),t=t.parent;return n},groupTransition:Jd,initProps:Nd,isBoundingRectAxisAligned:cp,isElementRemoved:Od,lineLineIntersect:ep,linePolygonIntersect:function(t,e,n,i,r){for(var o=0,a=r[r.length-1];oxs(o[1])?o[0]>0?"right":"left":o[1]>0?"bottom":"top"},traverseElements:lp,traverseUpdateZ:fp,updateProps:zd}),mp={};function yp(t,e,n){var i,r=t.labelFetcher,o=t.labelDataIndex,a=t.labelDimIndex,s=e.normal;r&&(i=r.getFormattedLabel(o,"normal",null,a,s&&s.get("formatter"),null!=n?{interpolatedValue:n}:null)),null==i&&(i=Ln(t.defaultText)?t.defaultText(o,t,n):t.defaultText);for(var l={normal:i},c=0;c-1?Up:qp;function Zp(t,e){t=t.toUpperCase(),Xp[t]=new Bp(e),jp[t]=e}Zp(Gp,{time:{month:["January","February","March","April","May","June","July","August","September","October","November","December"],monthAbbr:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],dayOfWeek:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],dayOfWeekAbbr:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"]},legend:{selector:{all:"All",inverse:"Inv"}},toolbox:{brush:{title:{rect:"Box Select",polygon:"Lasso Select",lineX:"Horizontally Select",lineY:"Vertically Select",keep:"Keep Selections",clear:"Clear Selections"}},dataView:{title:"Data View",lang:["Data View","Close","Refresh"]},dataZoom:{title:{zoom:"Zoom",back:"Zoom Reset"}},magicType:{title:{line:"Switch to Line Chart",bar:"Switch to Bar Chart",stack:"Stack",tiled:"Tile"}},restore:{title:"Restore"},saveAsImage:{title:"Save as Image",lang:["Right Click to Save Image"]}},series:{typeNames:{pie:"Pie chart",bar:"Bar chart",line:"Line chart",scatter:"Scatter plot",effectScatter:"Ripple scatter plot",radar:"Radar chart",tree:"Tree",treemap:"Treemap",boxplot:"Boxplot",candlestick:"Candlestick",k:"K line chart",heatmap:"Heat map",map:"Map",parallel:"Parallel coordinate map",lines:"Line graph",graph:"Relationship graph",sankey:"Sankey diagram",funnel:"Funnel chart",gauge:"Gauge",pictorialBar:"Pictorial bar",themeRiver:"Theme River Map",sunburst:"Sunburst",custom:"Custom chart",chart:"Chart"}},aria:{general:{withTitle:'This is a chart about "{title}"',withoutTitle:"This is a chart"},series:{single:{prefix:"",withName:" with type {seriesType} named {seriesName}.",withoutName:" with type {seriesType}."},multiple:{prefix:". It consists of {seriesCount} series count.",withName:" The {seriesId} series is a {seriesType} representing {seriesName}.",withoutName:" The {seriesId} series is a {seriesType}.",separator:{middle:"",end:""}}},data:{allData:"The data is as follows: ",partialData:"The first {displayCnt} items are: ",withName:"the data for {name} is {value}",withoutName:"{value}",separator:{middle:", ",end:". "}}}}),Zp(Up,{time:{month:["一月","二月","三月","四月","五月","六月","七月","八月","九月","十月","十一月","十二月"],monthAbbr:["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"],dayOfWeek:["星期日","星期一","星期二","星期三","星期四","星期五","星期六"],dayOfWeekAbbr:["日","一","二","三","四","五","六"]},legend:{selector:{all:"全选",inverse:"反选"}},toolbox:{brush:{title:{rect:"矩形选择",polygon:"圈选",lineX:"横向选择",lineY:"纵向选择",keep:"保持选择",clear:"清除选择"}},dataView:{title:"数据视图",lang:["数据视图","关闭","刷新"]},dataZoom:{title:{zoom:"区域缩放",back:"区域缩放还原"}},magicType:{title:{line:"切换为折线图",bar:"切换为柱状图",stack:"切换为堆叠",tiled:"切换为平铺"}},restore:{title:"还原"},saveAsImage:{title:"保存为图片",lang:["右键另存为图片"]}},series:{typeNames:{pie:"饼图",bar:"柱状图",line:"折线图",scatter:"散点图",effectScatter:"涟漪散点图",radar:"雷达图",tree:"树图",treemap:"矩形树图",boxplot:"箱型图",candlestick:"K线图",k:"K线图",heatmap:"热力图",map:"地图",parallel:"平行坐标图",lines:"线图",graph:"关系图",sankey:"桑基图",funnel:"漏斗图",gauge:"仪表盘图",pictorialBar:"象形柱图",themeRiver:"主题河流图",sunburst:"旭日图",custom:"自定义图表",chart:"图表"}},aria:{general:{withTitle:"这是一个关于“{title}”的图表。",withoutTitle:"这是一个图表,"},series:{single:{prefix:"",withName:"图表类型是{seriesType},表示{seriesName}。",withoutName:"图表类型是{seriesType}。"},multiple:{prefix:"它由{seriesCount}个图表系列组成。",withName:"第{seriesId}个系列是一个表示{seriesName}的{seriesType},",withoutName:"第{seriesId}个系列是一个{seriesType},",separator:{middle:";",end:"。"}}},data:{allData:"其数据是——",partialData:"其中,前{displayCnt}项是——",withName:"{name}的数据是{value}",withoutName:"{value}",separator:{middle:",",end:""}}}});function Kp(){return null}var Qp=1e3,Jp=6e4,tf=36e5,ef=864e5,nf=31536e6,rf={year:/({yyyy}|{yy})/,month:/({MMMM}|{MMM}|{MM}|{M})/,day:/({dd}|{d})/,hour:/({HH}|{H}|{hh}|{h})/,minute:/({mm}|{m})/,second:/({ss}|{s})/,millisecond:/({SSS}|{S})/},of={year:"{yyyy}",month:"{MMM}",day:"{d}",hour:"{HH}:{mm}",minute:"{HH}:{mm}",second:"{HH}:{mm}:{ss}",millisecond:"{HH}:{mm}:{ss} {SSS}"},af="{yyyy}-{MM}-{dd}",sf={year:"{yyyy}",month:"{yyyy}-{MM}",day:af,hour:af+" "+of.hour,minute:af+" "+of.minute,second:af+" "+of.second,millisecond:"{yyyy}-{MM}-{dd} {HH}:{mm}:{ss} {SSS}"},lf=["year","month","day","hour","minute","second","millisecond"],cf=["year","half-year","quarter","month","week","half-week","day","half-day","quarter-day","hour","minute","second","millisecond"];function uf(t){return En(t)||Ln(t)?t:function(t){t=t||{};var e={},n=!0;return Cn(lf,function(e){n&&(n=null==t[e])}),Cn(lf,function(i,r){var o=t[i];e[i]={};for(var a=null,s=r;s>=0;s--){var l=lf[s],c=On(o)&&!Pn(o)?o[l]:o,u=void 0;Pn(c)?a=(u=c.slice())[0]||"":En(c)?u=[a=c]:(null==a?a=of[i]:rf[l].test(a)||(a=e[l][l][0]+" "+a),u=[a],n&&(u[1]="{primary|"+a+"}")),e[i][l]=u}}),e}(t)}function hf(t,e){return"0000".substr(0,e-(t+="").length)+t}function df(t){switch(t){case"half-year":case"quarter":return"month";case"week":case"half-week":return"day";case"half-day":case"quarter-day":return"hour";default:return t}}function pf(t){return t===df(t)}function ff(t,e,n,i){var r=Ps(t),o=r[mf(n)](),a=r[yf(n)]()+1,s=Math.floor((a-1)/3)+1,l=r[_f(n)](),c=r["get"+(n?"UTC":"")+"Day"](),u=r[bf(n)](),h=(u-1)%12+1,d=r[xf(n)](),p=r[wf(n)](),f=r[Sf(n)](),g=u>=12?"pm":"am",v=g.toUpperCase(),m=i instanceof Bp?i:function(t){return Xp[t]}(i||Yp)||Xp[qp],y=m.getModel("time"),_=y.get("month"),b=y.get("monthAbbr"),x=y.get("dayOfWeek"),w=y.get("dayOfWeekAbbr");return(e||"").replace(/{a}/g,g+"").replace(/{A}/g,v+"").replace(/{yyyy}/g,o+"").replace(/{yy}/g,hf(o%100+"",2)).replace(/{Q}/g,s+"").replace(/{MMMM}/g,_[a-1]).replace(/{MMM}/g,b[a-1]).replace(/{MM}/g,hf(a,2)).replace(/{M}/g,a+"").replace(/{dd}/g,hf(l,2)).replace(/{d}/g,l+"").replace(/{eeee}/g,x[c]).replace(/{ee}/g,w[c]).replace(/{e}/g,c+"").replace(/{HH}/g,hf(u,2)).replace(/{H}/g,u+"").replace(/{hh}/g,hf(h+"",2)).replace(/{h}/g,h+"").replace(/{mm}/g,hf(d,2)).replace(/{m}/g,d+"").replace(/{ss}/g,hf(p,2)).replace(/{s}/g,p+"").replace(/{SSS}/g,hf(f,3)).replace(/{S}/g,f+"")}function gf(t,e){var n=Ps(t),i=n[yf(e)]()+1,r=n[_f(e)](),o=n[bf(e)](),a=n[xf(e)](),s=n[wf(e)](),l=0===n[Sf(e)](),c=l&&0===s,u=c&&0===a,h=u&&0===o,d=h&&1===r;return d&&1===i?"year":d?"month":h?"day":u?"hour":c?"minute":l?"second":"millisecond"}function vf(t,e,n){switch(e){case"year":t[kf(n)](0);case"month":t[Mf(n)](1);case"day":t[Tf(n)](0);case"hour":t[If(n)](0);case"minute":t[Df(n)](0);case"second":t[Af(n)](0)}return t}function mf(t){return t?"getUTCFullYear":"getFullYear"}function yf(t){return t?"getUTCMonth":"getMonth"}function _f(t){return t?"getUTCDate":"getDate"}function bf(t){return t?"getUTCHours":"getHours"}function xf(t){return t?"getUTCMinutes":"getMinutes"}function wf(t){return t?"getUTCSeconds":"getSeconds"}function Sf(t){return t?"getUTCMilliseconds":"getMilliseconds"}function Cf(t){return t?"setUTCFullYear":"setFullYear"}function kf(t){return t?"setUTCMonth":"setMonth"}function Mf(t){return t?"setUTCDate":"setDate"}function Tf(t){return t?"setUTCHours":"setHours"}function If(t){return t?"setUTCMinutes":"setMinutes"}function Df(t){return t?"setUTCSeconds":"setSeconds"}function Af(t){return t?"setUTCMilliseconds":"setMilliseconds"}function Pf(t){if(isNaN(zs(t)))return En(t)?t:"-";var e=(t+"").split(".");return e[0].replace(/(\d{1,3})(?=(?:\d{3})+(?!\d))/g,"$1,")+(e.length>1?"."+e[1]:"")}function Lf(t,e){return t=(t||"").toLowerCase().replace(/-(.)/g,function(t,e){return e.toUpperCase()}),e&&t&&(t=t.charAt(0).toUpperCase()+t.slice(1)),t}var Ef=qn;function zf(t,e,n){function i(t){return t&&Xn(t)?t:"-"}function r(t){return!(null==t||isNaN(t)||!isFinite(t))}var o="time"===e,a=t instanceof Date;if(o||a){var s=o?Ps(t):t;if(!isNaN(+s))return ff(s,"{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}",n);if(a)return"-"}if("ordinal"===e)return zn(t)?i(t):Nn(t)&&r(t)?t+"":"-";var l=zs(t);return r(l)?Pf(l):zn(t)?i(t):"boolean"==typeof t?t+"":"-"}var Nf=["a","b","c","d","e","f","g"],Of=function(t,e){return"{"+t+(null==e?"":e)+"}"};function Rf(t,e,n){Pn(e)||(e=[e]);var i=e.length;if(!i)return"";for(var r=e[0].$vars||[],o=0;oi||l.newline?(o=0,u=g,a+=s+n,s=d.height):s=Math.max(s,d.height)}else{var v=d.height+(f?-f.y+d.y:0);(h=a+v)>r||l.newline?(o+=s+n,a=0,h=v,s=d.width):s=Math.max(s,d.width)}l.newline||(l.x=o,l.y=a,l.markRedraw(),"horizontal"===t?o=u+n:a=h+n)})}function Jf(t,e,n){n=Ef(n||0);var i=e.width,r=e.height,o=Ss(t.left,i),a=Ss(t.top,r),s=Ss(t.right,i),l=Ss(t.bottom,r),c=Ss(t.width,i),u=Ss(t.height,r),h=n[2]+n[0],d=n[1]+n[3],p=t.aspect;switch(isNaN(c)&&(c=i-s-d-o),isNaN(u)&&(u=r-l-h-a),null!=p&&(isNaN(c)&&isNaN(u)&&(p>i/r?c=.8*i:u=.8*r),isNaN(c)&&(c=p*u),isNaN(u)&&(u=c/p)),isNaN(o)&&(o=i-s-c-d),isNaN(a)&&(a=r-l-u-h),t.left||t.right){case"center":o=i/2-c/2-n[3];break;case"right":o=i-c-d}switch(t.top||t.bottom){case"middle":case"center":a=r/2-u/2-n[0];break;case"bottom":a=r-u-h}o=o||0,a=a||0,isNaN(c)&&(c=i-d-o-(s||0)),isNaN(u)&&(u=r-h-a-(l||0));var f=new cr((e.x||0)+o+n[3],(e.y||0)+a+n[0],c,u);return f.margin=n,f}An(Qf,"vertical"),An(Qf,"horizontal");var tg=1;function eg(t,e,n){var i,r,o,a,s=t.boxCoordinateSystem;if(s){var l=function(t){var e=t.getShallow("coord",!0),n=Vf;if(null==e){var i=Uf.get(t.type);i&&i.getCoord2&&(n=Wf,e=i.getCoord2(t))}return{coord:e,from:n}}(t),c=l.coord,u=l.from;if(s.dataToLayout){o=tg,a=u;var h=s.dataToLayout(c);i=h.contentRect||h.rect}}return null==o&&(o=tg),o===tg&&(i||(i={x:0,y:0,width:e.getWidth(),height:e.getHeight()}),r=[i.x+i.width/2,i.y+i.height/2]),{type:o,refContainer:i,refPoint:r,boxCoordFrom:a}}function ng(t){var e=t.layoutMode||t.constructor.layoutMode;return On(e)?e:e?{type:e}:null}function ig(t,e,n){var i=n&&n.ignoreSize;!Pn(i)&&(i=[i,i]);var r=a(Kf[0],0),o=a(Kf[1],1);function a(n,r){var o={},a=0,l={},c=0;if(Yf(n,function(e){l[e]=t[e]}),Yf(n,function(t){ii(e,t)&&(o[t]=l[t]=e[t]),s(o,t)&&a++,s(l,t)&&c++}),i[r])return s(e,n[1])?l[n[2]]=null:s(e,n[2])&&(l[n[1]]=null),l;if(2!==c&&a){if(a>=2)return o;for(var u=0;u=0;a--)o=yn(o,n[a],!0);e.defaultOption=o}return e.defaultOption},e.prototype.getReferringComponents=function(t,e){var n=t+"Index",i=t+"Id";return al(this.ecModel,t,{index:this.get(n,!0),id:this.get(i,!0)},e)},e.prototype.getBoxLayoutParams=function(){return e=!1,{left:(t=this).getShallow("left",e),top:t.getShallow("top",e),right:t.getShallow("right",e),bottom:t.getShallow("bottom",e),width:t.getShallow("width",e),height:t.getShallow("height",e)};var t,e},e.prototype.getZLevelKey=function(){return""},e.prototype.setZLevel=function(t){this.option.zlevel=t},e.protoInitialize=((n=e.prototype).type="component",n.id="",n.name="",n.mainType="",n.subType="",void(n.componentIndex=0)),e}(Bp);dl(ag,Bp),vl(ag),function(t){var e={};t.registerSubTypeDefaulter=function(t,n){var i=ul(t);e[i.main]=n},t.determineSubType=function(n,i){var r=i.type;if(!r){var o=ul(n).main;t.hasSubTypes(n)&&e[o]&&(r=e[o](i))}return r}}(ag),function(t,e){function n(t,e){return t[e]||(t[e]={predecessor:[],successor:[]}),t[e]}t.topologicalTravel=function(t,i,r,o){if(t.length){var a=function(t){var i={},r=[];return Cn(t,function(o){var a=n(i,o),s=function(t,e){var n=[];return Cn(t,function(t){xn(e,t)>=0&&n.push(t)}),n}(a.originalDeps=e(o),t);a.entryCount=s.length,0===a.entryCount&&r.push(o),Cn(s,function(t){xn(a.predecessor,t)<0&&a.predecessor.push(t);var e=n(i,t);xn(e.successor,t)<0&&e.successor.push(o)})}),{graph:i,noEntryList:r}}(i),s=a.graph,l=a.noEntryList,c={};for(Cn(t,function(t){c[t]=!0});l.length;){var u=l.pop(),h=s[u],d=!!c[u];d&&(r.call(o,u,h.originalDeps.slice()),delete c[u]),Cn(h.successor,d?f:p)}Cn(c,function(){throw new Error("")})}function p(t){s[t].entryCount--,0===s[t].entryCount&&l.push(t)}function f(t){c[t]=!0,p(t)}}}(ag,function(t){var e=[];Cn(ag.getClassesByMainType(t),function(t){e=e.concat(t.dependencies||t.prototype.dependencies||[])}),e=kn(e,function(t){return ul(t).main}),"dataset"!==t&&xn(e,"dataset")<=0&&e.unshift("dataset");return e});var sg={color:{},darkColor:{},size:{}},lg=sg.color={theme:["#5070dd","#b6d634","#505372","#ff994d","#0ca8df","#ffd10a","#fb628b","#785db0","#3fbe95"],neutral00:"#fff",neutral05:"#f4f7fd",neutral10:"#e8ebf0",neutral15:"#dbdee4",neutral20:"#cfd2d7",neutral25:"#c3c5cb",neutral30:"#b7b9be",neutral35:"#aaacb2",neutral40:"#9ea0a5",neutral45:"#929399",neutral50:"#86878c",neutral55:"#797b7f",neutral60:"#6d6e73",neutral65:"#616266",neutral70:"#54555a",neutral75:"#48494d",neutral80:"#3c3c41",neutral85:"#303034",neutral90:"#232328",neutral95:"#17171b",neutral99:"#000",accent05:"#eff1f9",accent10:"#e0e4f2",accent15:"#d0d6ec",accent20:"#c0c9e6",accent25:"#b1bbdf",accent30:"#a1aed9",accent35:"#91a0d3",accent40:"#8292cc",accent45:"#7285c6",accent50:"#6578ba",accent55:"#5c6da9",accent60:"#536298",accent65:"#4a5787",accent70:"#404c76",accent75:"#374165",accent80:"#2e3654",accent85:"#252b43",accent90:"#1b2032",accent95:"#121521",transparent:"rgba(0,0,0,0)",highlight:"rgba(255,231,130,0.8)"};for(var cg in _n(lg,{primary:lg.neutral80,secondary:lg.neutral70,tertiary:lg.neutral60,quaternary:lg.neutral50,disabled:lg.neutral20,border:lg.neutral30,borderTint:lg.neutral20,borderShade:lg.neutral40,background:lg.neutral05,backgroundTint:"rgba(234,237,245,0.5)",backgroundTransparent:"rgba(255,255,255,0)",backgroundShade:lg.neutral10,shadow:"rgba(0,0,0,0.2)",shadowTint:"rgba(129,130,136,0.2)",axisLine:lg.neutral70,axisLineTint:lg.neutral40,axisTick:lg.neutral70,axisTickMinor:lg.neutral60,axisLabel:lg.neutral70,axisSplitLine:lg.neutral15,axisMinorSplitLine:lg.neutral05}),lg)if(lg.hasOwnProperty(cg)){var ug=lg[cg];"theme"===cg?sg.darkColor.theme=lg.theme.slice():"highlight"===cg?sg.darkColor.highlight="rgba(255,231,130,0.4)":0===cg.indexOf("accent")?sg.darkColor[cg]=Mo(ug,0,function(t){return.5*t},function(t){return Math.min(1,1.3-t)}):sg.darkColor[cg]=Mo(ug,0,function(t){return.9*t},function(t){return 1-Math.pow(t,1.5)})}sg.size={xxs:2,xs:5,s:10,m:15,l:20,xl:30,xxl:40,xxxl:50};var hg="";"undefined"!=typeof navigator&&(hg=navigator.platform||"");var dg="rgba(0, 0, 0, 0.2)",pg=sg.color.theme[0],fg=Mo(pg,0,null,.9),gg={darkMode:"auto",colorBy:"series",color:sg.color.theme,gradientColor:[fg,pg],aria:{decal:{decals:[{color:dg,dashArrayX:[1,0],dashArrayY:[2,5],symbolSize:1,rotation:Math.PI/6},{color:dg,symbol:"circle",dashArrayX:[[8,8],[0,8,8,0]],dashArrayY:[6,0],symbolSize:.8},{color:dg,dashArrayX:[1,0],dashArrayY:[4,3],rotation:-Math.PI/4},{color:dg,dashArrayX:[[6,6],[0,6,6,0]],dashArrayY:[6,0]},{color:dg,dashArrayX:[[1,0],[1,6]],dashArrayY:[1,0,6,0],rotation:Math.PI/4},{color:dg,symbol:"triangle",dashArrayX:[[9,9],[0,9,9,0]],dashArrayY:[7,2],symbolSize:.75}]}},textStyle:{fontFamily:hg.match(/^Win/)?"Microsoft YaHei":"sans-serif",fontSize:12,fontStyle:"normal",fontWeight:"normal"},blendMode:null,stateAnimation:{duration:300,easing:"cubicOut"},animation:"auto",animationDuration:1e3,animationDurationUpdate:500,animationEasing:"cubicInOut",animationEasingUpdate:"cubicInOut",animationThreshold:2e3,progressiveThreshold:3e3,progressive:400,hoverLayerThreshold:3e3,useUTC:!1},vg=ei(["tooltip","label","itemName","itemId","itemGroupId","itemChildGroupId","seriesName"]),mg="original",yg="arrayRows",_g="objectRows",bg="keyedColumns",xg="typedArray",wg="unknown",Sg="column",Cg="row",kg=1,Mg=2,Tg=3,Ig=el();function Dg(t,e,n){var i={},r=Ag(e);if(!r||!t)return i;var o,a,s=[],l=[],c=e.ecModel,u=Ig(c).datasetMap,h=r.uid+"_"+n.seriesLayoutBy;Cn(t=t.slice(),function(e,n){var r=On(e)?e:t[n]={name:e};"ordinal"===r.type&&null==o&&(o=n,a=f(r)),i[r.name]=[]});var d=u.get(h)||u.set(h,{categoryWayDim:a,valueWayDim:0});function p(t,e,n){for(var i=0;ie)return t[i];return t[n-1]}(i,a):n;if(u=u||n,!u||!u.length)return;var h=u[l];r&&(c[r]=h);return s.paletteIdx=(l+1)%u.length,h}(this,Eg,i,r,t,e,n)},t.prototype.clearColorPalette=function(){!function(t,e){e(t).paletteIdx=0,e(t).paletteNameMap={}}(this,Eg)},t}();var $g="\0_ec_inner",Hg=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return y(e,t),e.prototype.init=function(t,e,n,i,r,o){i=i||{},this.option=null,this._theme=new Bp(i),this._locale=new Bp(r),this._optionManager=o},e.prototype.setOption=function(t,e,n){var i=Vg(e);this._optionManager.setOption(t,n,i),this._resetOption(null,i)},e.prototype.resetOption=function(t,e){return this._resetOption(t,Vg(e))},e.prototype._resetOption=function(t,e){var n=!1,i=this._optionManager;if(!t||"recreate"===t){var r=i.mountOption("recreate"===t);this.option&&"recreate"!==t?(this.restoreData(),this._mergeOption(r,e)):Og(this,r),n=!0}if("timeline"!==t&&"media"!==t||this.restoreData(),!t||"recreate"===t||"timeline"===t){var o=i.getTimelineOption(this);o&&(n=!0,this._mergeOption(o,e))}if(!t||"recreate"===t||"media"===t){var a=i.getMediaOption(this);a.length&&Cn(a,function(t){n=!0,this._mergeOption(t,e)},this)}return n},e.prototype.mergeOption=function(t){this._mergeOption(t,null)},e.prototype._mergeOption=function(t,e){var n=this.option,i=this._componentsMap,r=this._componentsCount,o=[],a=ei(),s=e&&e.replaceMergeMainTypeMap;Ig(this).datasetMap=ei(),Cn(t,function(t,e){null!=t&&(ag.hasClass(e)?e&&(o.push(e),a.set(e,!0)):n[e]=null==n[e]?mn(t):yn(n[e],t,!0))}),s&&s.each(function(t,e){ag.hasClass(e)&&!a.get(e)&&(o.push(e),a.set(e,!0))}),ag.topologicalTravel(o,ag.getAllClassMainTypes(),function(e){var o=function(t,e,n){var i=Lg.get(e);if(!i)return n;var r=i(t);return r?n.concat(r):n}(this,e,Ws(t[e])),a=i.get(e),l=a?s&&s.get(e)?"replaceMerge":"normalMerge":"replaceAll",c=Xs(a,o,l);(function(t,e,n){Cn(t,function(t){var i=t.newOption;On(i)&&(t.keyInfo.mainType=e,t.keyInfo.subType=function(t,e,n,i){return e.type?e.type:n?n.subType:i.determineSubType(t,e)}(e,i,t.existing,n))})})(c,e,ag),n[e]=null,i.set(e,null),r.set(e,0);var u,h=[],d=[],p=0;Cn(c,function(t,n){var i=t.existing,r=t.newOption;if(r){var o="series"===e,a=ag.getClass(e,t.keyInfo.subType,!o);if(!a)return;if("tooltip"===e){if(u)return;u=!0}if(i&&i.constructor===a)i.name=t.keyInfo.name,i.mergeOption(r,this),i.optionUpdated(r,!1);else{var s=_n({componentIndex:n},t.keyInfo);_n(i=new a(r,this,this,s),s),t.brandNew&&(i.__requireNewView=!0),i.init(r,this,this),i.optionUpdated(null,!0)}}else i&&(i.mergeOption({},this),i.optionUpdated({},!1));i?(h.push(i.option),d.push(i),p++):(h.push(void 0),d.push(void 0))},this),n[e]=h,i.set(e,d),r.set(e,p),"series"===e&&zg(this)},this),this._seriesIndices||zg(this)},e.prototype.getOption=function(){var t=mn(this.option);return Cn(t,function(e,n){if(ag.hasClass(n)){for(var i=Ws(e),r=i.length,o=!1,a=r-1;a>=0;a--)i[a]&&!Js(i[a])?o=!0:(i[a]=null,!o&&r--);i.length=r,t[n]=i}}),delete t[$g],t},e.prototype.setTheme=function(t){this._theme=new Bp(t),this._resetOption("recreate",null)},e.prototype.getTheme=function(){return this._theme},e.prototype.getLocaleModel=function(){return this._locale},e.prototype.setUpdatePayload=function(t){this._payload=t},e.prototype.getUpdatePayload=function(){return this._payload},e.prototype.getComponent=function(t,e){var n=this._componentsMap.get(t);if(n){var i=n[e||0];if(i)return i;if(null==e)for(var r=0;r=e:"max"===n?t<=e:t===e})(i[a],t,o)||(r=!1)}}),r}var Xg=Cn,Yg=On,Zg=["areaStyle","lineStyle","nodeStyle","linkStyle","chordStyle","label","labelLine"];function Kg(t){var e=t&&t.itemStyle;if(e)for(var n=0,i=Zg.length;nc&&(c=p)}s[0]=l,s[1]=c}},i=function(){return this._data?this._data.length/this._dimSize:0};function r(t){for(var e=0;e=0&&(s=o.interpolatedValue[l])}return null!=s?s+"":""}):void 0},t.prototype.getRawValue=function(t,e){return Fv(this.getData(e),t)},t.prototype.formatTooltip=function(t,e,n){},t}();function Wv(t){var e,n;return On(t)?t.type&&(n=t):e=t,{text:e,frag:n}}function Uv(t){return new Gv(t)}var Gv=function(){function t(t){t=t||{},this._reset=t.reset,this._plan=t.plan,this._count=t.count,this._onDirty=t.onDirty,this._dirty=!0}return t.prototype.perform=function(t){var e,n=this._upstream,i=t&&t.skip;if(this._dirty&&n){var r=this.context;r.data=r.outputData=n.context.outputData}this.__pipeline&&(this.__pipeline.currentTask=this),this._plan&&!i&&(e=this._plan(this.context));var o,a=u(this._modBy),s=this._modDataCount||0,l=u(t&&t.modBy),c=t&&t.modDataCount||0;function u(t){return!(t>=1)&&(t=1),t}a===l&&s===c||(e="reset"),(this._dirty||"reset"===e)&&(this._dirty=!1,o=this._doReset(i)),this._modBy=l,this._modDataCount=c;var h=t&&t.step;if(this._dueEnd=n?n._outputDueEnd:this._count?this._count(this.context):1/0,this._progress){var d=this._dueIndex,p=Math.min(null!=h?this._dueIndex+h:1/0,this._dueEnd);if(!i&&(o||d1&&i>0?s:a}};return o;function a(){return e=t?null:oi?-this._resultLT:0},t}(),Yv=function(){function t(){}return t.prototype.getRawData=function(){throw new Error("not supported")},t.prototype.getRawDataItem=function(t){throw new Error("not supported")},t.prototype.cloneRawData=function(){},t.prototype.getDimensionInfo=function(t){},t.prototype.cloneAllDimensionInfo=function(){},t.prototype.count=function(){},t.prototype.retrieveValue=function(t,e){},t.prototype.retrieveValueFromItem=function(t,e){},t.prototype.convertValue=function(t,e){return jv(t,e)},t}();function Zv(t){if(!nm(t.sourceFormat)){Fs("")}return t.data}function Kv(t){var e=t.sourceFormat,n=t.data;if(!nm(e)){Fs("")}if(e===yg){for(var i=[],r=0,o=n.length;r65535?om:am}function hm(){return[1/0,-1/0]}function dm(t){var e=t.constructor;return e===Array?t.slice():new e(t)}function pm(t,e,n,i,r){var o=cm[n||"float"];if(r){var a=t[e],s=a&&a.length;if(s!==i){for(var l=new o(i),c=0;cg[1]&&(g[1]=f)}return this._rawCount=this._count=s,{start:a,end:s}},t.prototype._initDataFromProvider=function(t,e,n){for(var i=this._provider,r=this._chunks,o=this._dimensions,a=o.length,s=this._rawExtent,l=kn(o,function(t){return t.property}),c=0;cv[1]&&(v[1]=g)}}!i.persistent&&i.clean&&i.clean(),this._rawCount=this._count=e,this._extent=[]},t.prototype.count=function(){return this._count},t.prototype.get=function(t,e){if(!(e>=0&&e=0&&e=this._rawCount||t<0)return-1;if(!this._indices)return t;var e=this._indices,n=e[t];if(null!=n&&nt))return o;r=o-1}}return-1},t.prototype.getIndices=function(){var t,e=this._indices;if(e){var n=e.constructor,i=this._count;if(n===Array){t=new n(i);for(var r=0;r=c&&_<=u||isNaN(_))&&(a[s++]=p),p++}d=!0}else if(2===r){f=h[i[0]];var v=h[i[1]],m=t[i[1]][0],y=t[i[1]][1];for(g=0;g=c&&_<=u||isNaN(_))&&(b>=m&&b<=y||isNaN(b))&&(a[s++]=p),p++}d=!0}}if(!d)if(1===r)for(g=0;g=c&&_<=u||isNaN(_))&&(a[s++]=x)}else for(g=0;gt[C][1])&&(w=!1)}w&&(a[s++]=e.getRawIndex(g))}return sv[1]&&(v[1]=g)}}}},t.prototype.lttbDownSample=function(t,e){var n,i,r,o=this.clone([t],!0),a=o._chunks[t],s=this.count(),l=0,c=Math.floor(1/e),u=this.getRawIndex(0),h=new(um(this._rawCount))(Math.min(2*(Math.ceil(s/c)+2),s));h[l++]=u;for(var d=1;dn&&(n=i,r=k)}C>0&&Ca&&(f=a-c);for(var g=0;gp&&(p=v,d=c+g)}var m=this.getRawIndex(u),y=this.getRawIndex(d);uc-p&&(s=c-p,a.length=s);for(var f=0;fu[1]&&(u[1]=v),h[d++]=m}return r._count=d,r._indices=h,r._updateGetRawIdx(),r},t.prototype.each=function(t,e){if(this._count)for(var n=t.length,i=this._chunks,r=0,o=this.count();ra&&(a=l)}return i=[o,a],this._extent[t]=i,i},t.prototype.getRawDataItem=function(t){var e=this.getRawIndex(t);if(this._provider.persistent)return this._provider.getItem(e);for(var n=[],i=this._chunks,r=0;r=0?this._indices[t]:-1},t.prototype._updateGetRawIdx=function(){this.getRawIndex=this._indices?this._getRawIdx:this._getRawIdxIdentity},t.internalField=function(){function t(t,e,n,i){return jv(t[i],this._dimensions[i])}im={arrayRows:t,objectRows:function(t,e,n,i){return jv(t[e],this._dimensions[i])},keyedColumns:t,original:function(t,e,n,i){var r=t&&(null==t.value?t:t.value);return jv(r instanceof Array?r[i]:r,this._dimensions[i])},typedArray:function(t,e,n,i){return t[i]}}}(),t}(),gm=function(){function t(t){this._sourceList=[],this._storeList=[],this._upstreamSignList=[],this._versionSignBase=0,this._dirty=!0,this._sourceHost=t}return t.prototype.dirty=function(){this._setLocalSource([],[]),this._storeList=[],this._dirty=!0},t.prototype._setLocalSource=function(t,e){this._sourceList=t,this._upstreamSignList=e,this._versionSignBase++,this._versionSignBase>9e10&&(this._versionSignBase=0)},t.prototype._getVersionSign=function(){return this._sourceHost.uid+"_"+this._versionSignBase},t.prototype.prepareSource=function(){this._isDirty()&&(this._createSource(),this._dirty=!1)},t.prototype._createSource=function(){this._setLocalSource([],[]);var t,e,n=this._sourceHost,i=this._getUpstreamSourceManagers(),r=!!i.length;if(vm(n)){var o=n,a=void 0,s=void 0,l=void 0;if(r){var c=i[0];c.prepareSource(),a=(l=c.getSource()).data,s=l.sourceFormat,e=[c._getVersionSign()]}else s=$n(a=o.get("data",!0))?xg:mg,e=[];var u=this._getSourceMetaRawOption()||{},h=l&&l.metaRawOption||{},d=Wn(u.seriesLayoutBy,h.seriesLayoutBy)||null,p=Wn(u.sourceHeader,h.sourceHeader),f=Wn(u.dimensions,h.dimensions);t=d!==h.seriesLayoutBy||!!p!=!!h.sourceHeader||f?[wv(a,{seriesLayoutBy:d,sourceHeader:p,dimensions:f},s)]:[]}else{var g=n;if(r){var v=this._applyTransform(i);t=v.sourceList,e=v.upstreamSignList}else{t=[wv(g.get("source",!0),this._getSourceMetaRawOption(),null)],e=[]}}this._setLocalSource(t,e)},t.prototype._applyTransform=function(t){var e,n=this._sourceHost,i=n.get("transform",!0),r=n.get("fromTransformResult",!0);if(null!=r){1!==t.length&&mm("")}var o,a=[],s=[];return Cn(t,function(t){t.prepareSource();var e=t.getSource(r||0);null==r||e||mm(""),a.push(e),s.push(t._getVersionSign())}),i?e=function(t,e){var n=Ws(t),i=n.length;i||Fs("");for(var r=0,o=i;r1||n>0&&!t.noHeader;return Cn(t.blocks,function(t){var n=km(t);n>=e&&(e=n+ +(i&&(!n||Sm(t)&&!t.noHeader)))}),e}return 0}function Mm(t,e,n,i){var r,o=e.noHeader,a=(r=km(e),{html:bm[r],richText:xm[r]}),s=[],l=e.blocks||[];jn(!l||Pn(l)),l=l||[];var c=t.orderMode;if(e.sortBlocks&&c){l=l.slice();var u={valueAsc:"asc",valueDesc:"desc"};if(ii(u,c)){var h=new Xv(u[c],null);l.sort(function(t,e){return h.evaluate(t.sortParam,e.sortParam)})}else"seriesDesc"===c&&l.reverse()}Cn(l,function(n,r){var o=e.valueFormatter,l=Cm(n)(o?_n(_n({},t),{valueFormatter:o}):t,n,r>0?a.html:0,i);null!=l&&s.push(l)});var d="richText"===t.renderMode?s.join(a.richText):Dm(i,s.join(""),o?n:a.html);if(o)return d;var p=zf(e.header,"ordinal",t.useUTC),f=_m(i,t.renderMode).nameStyle,g=ym(i);return"richText"===t.renderMode?Am(t,p,f)+a.richText+d:Dm(i,'
'+Ai(p)+"
"+d,n)}function Tm(t,e,n,i){var r=t.renderMode,o=e.noName,a=e.noValue,s=!e.markerType,l=e.name,c=t.useUTC,u=e.valueFormatter||t.valueFormatter||function(t){return kn(t=Pn(t)?t:[t],function(t,e){return zf(t,Pn(p)?p[e]:p,c)})};if(!o||!a){var h=s?"":t.markupStyleCreator.makeTooltipMarker(e.markerType,e.markerColor||sg.color.secondary,r),d=o?"":zf(l,"ordinal",c),p=e.valueType,f=a?[]:u(e.value,e.dataIndex),g=!s||!o,v=!s&&o,m=_m(i,r),y=m.nameStyle,_=m.valueStyle;return"richText"===r?(s?"":h)+(o?"":Am(t,d,y))+(a?"":function(t,e,n,i,r){var o=[r],a=i?10:20;return n&&o.push({padding:[0,0,0,a],align:"right"}),t.markupStyleCreator.wrapRichTextStyle(Pn(e)?e.join(" "):e,o)}(t,f,g,v,_)):Dm(i,(s?"":h)+(o?"":function(t,e,n){return''+Ai(t)+""}(d,!s,y))+(a?"":function(t,e,n,i){var r=n?"10px":"20px",o=e?"float:right;margin-left:"+r:"";return t=Pn(t)?t:[t],''+kn(t,function(t){return Ai(t)}).join("  ")+""}(f,g,v,_)),n)}}function Im(t,e,n,i,r,o){if(t)return Cm(t)({useUTC:r,renderMode:n,orderMode:i,markupStyleCreator:e,valueFormatter:t.valueFormatter},t,0,o)}function Dm(t,e,n){return'
'+e+'
'}function Am(t,e,n){return t.markupStyleCreator.wrapRichTextStyle(e,n)}function Pm(t,e){var n=t.get("padding");return null!=n?n:"richText"===e?[8,10]:10}var Lm=function(){function t(){this.richTextStyles={},this._nextStyleNameId=Ns()}return t.prototype._generateStyleName=function(){return"__EC_aUTo_"+this._nextStyleNameId++},t.prototype.makeTooltipMarker=function(t,e,n){var i="richText"===n?this._generateStyleName():null,r=function(t,e){var n=En(t)?{color:t,extraCssText:e}:t||{},i=n.color,r=n.type;e=n.extraCssText;var o=n.renderMode||"html";return i?"html"===o?"subItem"===r?'':'':{renderMode:o,content:"{"+(n.markerId||"markerX")+"|} ",style:"subItem"===r?{width:4,height:4,borderRadius:2,backgroundColor:i}:{width:10,height:10,borderRadius:5,backgroundColor:i}}:""}({color:e,type:t,renderMode:n,markerId:i});return En(r)?r:(this.richTextStyles[i]=r.style,r.content)},t.prototype.wrapRichTextStyle=function(t,e){var n={};Pn(e)?Cn(e,function(t){return _n(n,t)}):_n(n,e);var i=this._generateStyleName();return this.richTextStyles[i]=n,"{"+i+"|"+t+"}"},t}();function Em(t){var e,n,i,r,o=t.series,a=t.dataIndex,s=t.multipleSeries,l=o.getData(),c=l.mapDimensionsAll("defaultedTooltip"),u=c.length,h=o.getRawValue(a),d=Pn(h),p=function(t,e){return $f(t.getData().getItemVisual(e,"style")[t.visualDrawType])}(o,a);if(u>1||d&&!u){var f=function(t,e,n,i,r){var o=e.getData(),a=Mn(t,function(t,e,n){var i=o.getDimensionInfo(n);return t||i&&!1!==i.tooltip&&null!=i.displayName},!1),s=[],l=[],c=[];function u(t,e){var n=o.getDimensionInfo(e);n&&!1!==n.otherDims.tooltip&&(a?c.push(wm("nameValue",{markerType:"subItem",markerColor:r,name:n.displayName,value:t,valueType:n.type})):(s.push(t),l.push(n.type)))}return i.length?Cn(i,function(t){u(Fv(o,n,t),t)}):Cn(t,u),{inlineValues:s,inlineValueTypes:l,blocks:c}}(h,o,a,c,p);e=f.inlineValues,n=f.inlineValueTypes,i=f.blocks,r=f.inlineValues[0]}else if(u){var g=l.getDimensionInfo(c[0]);r=e=Fv(l,a,c[0]),n=g.type}else r=e=d?h[0]:h;var v=Qs(o),m=v&&o.name||"",y=l.getName(a),_=s?m:y;return wm("section",{header:m,noHeader:s||!v,sortParam:r,blocks:[wm("nameValue",{markerType:"item",markerColor:p,name:_,noName:!Xn(_),value:e,valueType:n,dataIndex:a})].concat(i||[])})}var zm=el();function Nm(t,e){return t.getName(e)||t.getId(e)}var Om=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e._selectedDataIndicesMap={},e}var n;return y(e,t),e.prototype.init=function(t,e,n){this.seriesIndex=this.componentIndex,this.dataTask=Uv({count:$m,reset:Hm}),this.dataTask.context={model:this},this.mergeDefaultAndTheme(t,n),(zm(this).sourceManager=new gm(this)).prepareSource();var i=this.getInitialData(t,n);Bm(i,this),this.dataTask.context.data=i,zm(this).dataBeforeProcessed=i,Rm(this),this._initSelectedMapFromData(i)},e.prototype.mergeDefaultAndTheme=function(t,e){var n=ng(this),i=n?rg(t):{},r=this.subType;ag.hasClass(r)&&(r+="Series"),yn(t,e.getTheme().get(this.subType)),yn(t,this.getDefaultOption()),Us(t,"label",["show"]),this.fillDataTextStyle(t.data),n&&ig(t,i,n)},e.prototype.mergeOption=function(t,e){t=yn(this.option,t,!0),this.fillDataTextStyle(t.data);var n=ng(this);n&&ig(this.option,t,n);var i=zm(this).sourceManager;i.dirty(),i.prepareSource();var r=this.getInitialData(t,e);Bm(r,this),this.dataTask.dirty(),this.dataTask.context.data=r,zm(this).dataBeforeProcessed=r,Rm(this),this._initSelectedMapFromData(r)},e.prototype.fillDataTextStyle=function(t){if(t&&!$n(t))for(var e=["show"],n=0;n=0&&u<0)&&(c=o,u=r,h=0),r===u&&(l[h++]=e))}),l.length=h,l},e.prototype.formatTooltip=function(t,e,n){return Em({series:this,dataIndex:t,multipleSeries:e})},e.prototype.isAnimationEnabled=function(){var t=this.ecModel;if(Ye.node&&(!t||!t.ssr))return!1;var e=this.getShallow("animation");return e&&this.getData().count()>this.getShallow("animationThreshold")&&(e=!1),!!e},e.prototype.restoreData=function(){this.dataTask.dirty()},e.prototype.getColorFromPalette=function(t,e,n){var i=this.ecModel,r=Rg.prototype.getColorFromPalette.call(this,t,e,n);return r||(r=i.getColorFromPalette(t,e,n)),r},e.prototype.coordDimToDataDim=function(t){return this.getRawData().mapDimensionsAll(t)},e.prototype.getProgressive=function(){return this.get("progressive")},e.prototype.getProgressiveThreshold=function(){return this.get("progressiveThreshold")},e.prototype.select=function(t,e){this._innerSelect(this.getData(e),t)},e.prototype.unselect=function(t,e){var n=this.option.selectedMap;if(n){var i=this.option.selectedMode,r=this.getData(e);if("series"===i||"all"===n)return this.option.selectedMap={},void(this._selectedDataIndicesMap={});for(var o=0;o=0&&n.push(r)}return n},e.prototype.isSelected=function(t,e){var n=this.option.selectedMap;if(!n)return!1;var i=this.getData(e);return("all"===n||n[Nm(i,t)])&&!i.getItemModel(t).get(["select","disabled"])},e.prototype.isUniversalTransitionEnabled=function(){if(this.__universalTransitionEnabled)return!0;var t=this.option.universalTransition;return!!t&&(!0===t||t&&t.enabled)},e.prototype._innerSelect=function(t,e){var n,i,r=this.option,o=r.selectedMode,a=e.length;if(o&&a)if("series"===o)r.selectedMap="all";else if("multiple"===o){On(r.selectedMap)||(r.selectedMap={});for(var s=r.selectedMap,l=0;l0&&this._innerSelect(t,e)}},e.registerClass=function(t){return ag.registerClass(t)},e.protoInitialize=((n=e.prototype).type="series.__base__",n.seriesIndex=0,n.ignoreStyleOnData=!1,n.hasSymbolVisual=!1,n.defaultSymbol="circle",n.visualStyleAccessPath="itemStyle",void(n.visualDrawType="fill")),e}(ag);function Rm(t){var e=t.name;Qs(t)||(t.name=function(t){var e=t.getRawData(),n=e.mapDimensionsAll("seriesName"),i=[];return Cn(n,function(t){var n=e.getDimensionInfo(t);n.displayName&&i.push(n.displayName)}),i.join(" ")}(t)||e)}function $m(t){return t.model.getRawData().count()}function Hm(t){var e=t.model;return e.setData(e.getRawData().cloneShallow()),Fm}function Fm(t,e){e.outputData&&t.end>e.outputData.count()&&e.model.getRawData().cloneShallow(e.outputData)}function Bm(t,e){Cn(function(t,e){for(var n=new t.constructor(t.length+e.length),i=0;i=0?h():u=setTimeout(h,-r),l=i};return d.clear=function(){u&&(clearTimeout(u),u=null)},d.debounceNextCall=function(t){s=t},d}function ry(t,e,n,i){var r=t[e];if(r){var o=r[ty]||r,a=r[ny];if(r[ey]!==n||a!==i){if(null==n||!i)return t[e]=o;(r=t[e]=iy(o,n,"debounce"===i))[ty]=o,r[ny]=i,r[ey]=n}return r}}function oy(t,e){var n=t[e];n&&n[ty]&&(n.clear&&n.clear(),t[e]=n[ty])}var ay=el(),sy={itemStyle:ml($p,!0),lineStyle:ml(Np,!0)},ly={lineStyle:"stroke",itemStyle:"fill"};function cy(t,e){var n=t.visualStyleMapper||sy[e];return n||(console.warn("Unknown style type '"+e+"'."),sy.itemStyle)}function uy(t,e){var n=t.visualDrawType||ly[e];return n||(console.warn("Unknown style type '"+e+"'."),"fill")}var hy={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){var n=t.getData(),i=t.visualStyleAccessPath||"itemStyle",r=t.getModel(i),o=cy(t,i)(r),a=r.getShallow("decal");a&&(n.setVisual("decal",a),a.dirty=!0);var s=uy(t,i),l=o[s],c=Ln(l)?l:null,u="auto"===o.fill||"auto"===o.stroke;if(!o[s]||c||u){var h=t.getColorFromPalette(t.name,null,e.getSeriesCount());o[s]||(o[s]=h,n.setVisual("colorFromPalette",!0)),o.fill="auto"===o.fill||Ln(o.fill)?h:o.fill,o.stroke="auto"===o.stroke||Ln(o.stroke)?h:o.stroke}if(n.setVisual("style",o),n.setVisual("drawType",s),!e.isSeriesFiltered(t)&&c)return n.setVisual("colorFromPalette",!1),{dataEach:function(e,n){var i=t.getDataParams(n),r=_n({},o);r[s]=c(i),e.setItemVisual(n,"style",r)}}}},dy=new Bp,py={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){if(!t.ignoreStyleOnData&&!e.isSeriesFiltered(t)){var n=t.getData(),i=t.visualStyleAccessPath||"itemStyle",r=cy(t,i),o=n.getVisual("drawType");return{dataEach:n.hasItemOption?function(t,e){var n=t.getRawDataItem(e);if(n&&n[i]){dy.option=n[i];var a=r(dy);_n(t.ensureUniqueItemVisual(e,"style"),a),dy.option.decal&&(t.setItemVisual(e,"decal",dy.option.decal),dy.option.decal.dirty=!0),o in a&&t.setItemVisual(e,"colorFromPalette",!1)}}:null}}}},fy={performRawSeries:!0,overallReset:function(t){var e=ei();t.eachSeries(function(t){var n=t.getColorBy();if(!t.isColorBySeries()){var i=t.type+"-"+n,r=e.get(i);r||(r={},e.set(i,r)),ay(t).scope=r}}),t.eachSeries(function(e){if(!e.isColorBySeries()&&!t.isSeriesFiltered(e)){var n=e.getRawData(),i={},r=e.getData(),o=ay(e).scope,a=e.visualStyleAccessPath||"itemStyle",s=uy(e,a);r.each(function(t){var e=r.getRawIndex(t);i[e]=t}),n.each(function(t){var a=i[t];if(r.getItemVisual(a,"colorFromPalette")){var l=r.ensureUniqueItemVisual(a,"style"),c=n.getName(t)||t+"",u=n.count();l[s]=e.getColorFromPalette(c,o,u)}})}})}},gy=Math.PI;var vy=function(){function t(t,e,n,i){this._stageTaskMap=ei(),this.ecInstance=t,this.api=e,n=this._dataProcessorHandlers=n.slice(),i=this._visualHandlers=i.slice(),this._allHandlers=n.concat(i)}return t.prototype.restoreData=function(t,e){t.restoreData(e),this._stageTaskMap.each(function(t){var e=t.overallTask;e&&e.dirty()})},t.prototype.getPerformArgs=function(t,e){if(t.__pipeline){var n=this._pipelineMap.get(t.__pipeline.id),i=n.context,r=!e&&n.progressiveEnabled&&(!i||i.progressiveRender)&&t.__idxInPipeline>n.blockIndex?n.step:null,o=i&&i.modDataCount;return{step:r,modBy:null!=o?Math.ceil(o/r):null,modDataCount:o}}},t.prototype.getPipeline=function(t){return this._pipelineMap.get(t)},t.prototype.updateStreamModes=function(t,e){var n=this._pipelineMap.get(t.uid),i=t.getData().count(),r=n.progressiveEnabled&&e.incrementalPrepareRender&&i>=n.threshold,o=t.get("large")&&i>=t.get("largeThreshold"),a="mod"===t.get("progressiveChunkMode")?i:null;t.pipelineContext=n.context={progressiveRender:r,modDataCount:a,large:o}},t.prototype.restorePipelines=function(t){var e=this,n=e._pipelineMap=ei();t.eachSeries(function(t){var i=t.getProgressive(),r=t.uid;n.set(r,{id:r,head:null,tail:null,threshold:t.getProgressiveThreshold(),progressiveEnabled:i&&!(t.preventIncremental&&t.preventIncremental()),blockIndex:-1,step:Math.round(i||700),count:0}),e._pipe(t,t.dataTask)})},t.prototype.prepareStageTasks=function(){var t=this._stageTaskMap,e=this.api.getModel(),n=this.api;Cn(this._allHandlers,function(i){var r=t.get(i.uid)||t.set(i.uid,{});jn(!(i.reset&&i.overallReset),""),i.reset&&this._createSeriesStageTask(i,r,e,n),i.overallReset&&this._createOverallStageTask(i,r,e,n)},this)},t.prototype.prepareView=function(t,e,n,i){var r=t.renderTask,o=r.context;o.model=e,o.ecModel=n,o.api=i,r.__block=!t.incrementalPrepareRender,this._pipe(e,r)},t.prototype.performDataProcessorTasks=function(t,e){this._performStageTasks(this._dataProcessorHandlers,t,e,{block:!0})},t.prototype.performVisualTasks=function(t,e,n){this._performStageTasks(this._visualHandlers,t,e,n)},t.prototype._performStageTasks=function(t,e,n,i){i=i||{};var r=!1,o=this;function a(t,e){return t.setDirty&&(!t.dirtyMap||t.dirtyMap.get(e.__pipeline.id))}Cn(t,function(t,s){if(!i.visualType||i.visualType===t.visualType){var l=o._stageTaskMap.get(t.uid),c=l.seriesTaskMap,u=l.overallTask;if(u){var h,d=u.agentStubMap;d.each(function(t){a(i,t)&&(t.dirty(),h=!0)}),h&&u.dirty(),o.updatePayload(u,n);var p=o.getPerformArgs(u,i.block);d.each(function(t){t.perform(p)}),u.perform(p)&&(r=!0)}else c&&c.each(function(s,l){a(i,s)&&s.dirty();var c=o.getPerformArgs(s,i.block);c.skip=!t.performRawSeries&&e.isSeriesFiltered(s.context.model),o.updatePayload(s,n),s.perform(c)&&(r=!0)})}}),this.unfinished=r||this.unfinished},t.prototype.performSeriesTasks=function(t){var e;t.eachSeries(function(t){e=t.dataTask.perform()||e}),this.unfinished=e||this.unfinished},t.prototype.plan=function(){this._pipelineMap.each(function(t){var e=t.tail;do{if(e.__block){t.blockIndex=e.__idxInPipeline;break}e=e.getUpstream()}while(e)})},t.prototype.updatePayload=function(t,e){"remain"!==e&&(t.context.payload=e)},t.prototype._createSeriesStageTask=function(t,e,n,i){var r=this,o=e.seriesTaskMap,a=e.seriesTaskMap=ei(),s=t.seriesType,l=t.getTargetSeries;function c(e){var s=e.uid,l=a.set(s,o&&o.get(s)||Uv({plan:xy,reset:wy,count:ky}));l.context={model:e,ecModel:n,api:i,useClearVisual:t.isVisual&&!t.isLayout,plan:t.plan,reset:t.reset,scheduler:r},r._pipe(e,l)}t.createOnAllSeries?n.eachRawSeries(c):s?n.eachRawSeriesByType(s,c):l&&l(n,i).each(c)},t.prototype._createOverallStageTask=function(t,e,n,i){var r=this,o=e.overallTask=e.overallTask||Uv({reset:my});o.context={ecModel:n,api:i,overallReset:t.overallReset,scheduler:r};var a=o.agentStubMap,s=o.agentStubMap=ei(),l=t.seriesType,c=t.getTargetSeries,u=!0,h=!1;function d(t){var e=t.uid,n=s.set(e,a&&a.get(e)||(h=!0,Uv({reset:yy,onDirty:by})));n.context={model:t,overallProgress:u},n.agent=o,n.__block=u,r._pipe(t,n)}jn(!t.createOnAllSeries,""),l?n.eachRawSeriesByType(l,d):c?c(n,i).each(d):(u=!1,Cn(n.getSeries(),d)),h&&o.dirty()},t.prototype._pipe=function(t,e){var n=t.uid,i=this._pipelineMap.get(n);!i.head&&(i.head=e),i.tail&&i.tail.pipe(e),i.tail=e,e.__idxInPipeline=i.count++,e.__pipeline=i},t.wrapStageHandler=function(t,e){return Ln(t)&&(t={overallReset:t,seriesType:My(t)}),t.uid=Wp("stageHandler"),e&&(t.visualType=e),t},t}();function my(t){t.overallReset(t.ecModel,t.api,t.payload)}function yy(t){return t.overallProgress&&_y}function _y(){this.agent.dirty(),this.getDownstream().dirty()}function by(){this.agent&&this.agent.dirty()}function xy(t){return t.plan?t.plan(t.model,t.ecModel,t.api,t.payload):null}function wy(t){t.useClearVisual&&t.data.clearAllVisual();var e=t.resetDefines=Ws(t.reset(t.model,t.ecModel,t.api,t.payload));return e.length>1?kn(e,function(t,e){return Cy(e)}):Sy}var Sy=Cy(0);function Cy(t){return function(e,n){var i=n.data,r=n.resetDefines[t];if(r&&r.dataEach)for(var o=e.start;o0&&u===r.length-c.length){var h=r.slice(0,u);"data"!==h&&(e.mainType=h,e[c.toLowerCase()]=t,s=!0)}}a.hasOwnProperty(r)&&(n[r]=t,s=!0),s||(i[r]=t)})}return{cptQuery:e,dataQuery:n,otherQuery:i}},t.prototype.filter=function(t,e){var n=this.eventInfo;if(!n)return!0;var i=n.targetEl,r=n.packedEvent,o=n.model,a=n.view;if(!o||!a)return!0;var s=e.cptQuery,l=e.dataQuery;return c(s,o,"mainType")&&c(s,o,"subType")&&c(s,o,"index","componentIndex")&&c(s,o,"name")&&c(s,o,"id")&&c(l,r,"name")&&c(l,r,"dataIndex")&&c(l,r,"dataType")&&(!a.filterForExposedEvent||a.filterForExposedEvent(t,e.otherQuery,i,r));function c(t,e,n,i){return null==t[n]||e[i||n]===t[n]}},t.prototype.afterTrigger=function(){this.eventInfo=null},t}(),$y=["symbol","symbolSize","symbolRotate","symbolOffset"],Hy=$y.concat(["symbolKeepAspect"]),Fy={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){var n=t.getData();if(t.legendIcon&&n.setVisual("legendIcon",t.legendIcon),t.hasSymbolVisual){for(var i={},r={},o=!1,a=0;a<$y.length;a++){var s=$y[a],l=t.get(s);Ln(l)?(o=!0,r[s]=l):i[s]=l}if(i.symbol=i.symbol||t.defaultSymbol,n.setVisual(_n({legendIcon:t.legendIcon||i.symbol,symbolKeepAspect:t.get("symbolKeepAspect")},i)),!e.isSeriesFiltered(t)){var c=In(r);return{dataEach:o?function(e,n){for(var i=t.getRawValue(n),o=t.getDataParams(n),a=0;a=0&&i_(l)?l:.5,t.createRadialGradient(a,s,0,a,s,l)}(t,e,n):function(t,e,n){var i=null==e.x?0:e.x,r=null==e.x2?1:e.x2,o=null==e.y?0:e.y,a=null==e.y2?0:e.y2;return e.global||(i=i*n.width+n.x,r=r*n.width+n.x,o=o*n.height+n.y,a=a*n.height+n.y),i=i_(i)?i:0,r=i_(r)?r:1,o=i_(o)?o:0,a=i_(a)?a:0,t.createLinearGradient(i,o,r,a)}(t,e,n),r=e.colorStops,o=0;o0&&(e=i.lineDash,n=i.lineWidth,e&&"solid"!==e&&n>0?"dashed"===e?[4*n,2*n]:"dotted"===e?[n]:Nn(e)?[e]:Pn(e)?e:null:null),o=i.lineDashOffset;if(r){var a=i.strokeNoScale&&t.getLineScale?t.getLineScale():1;a&&1!==a&&(r=kn(r,function(t){return t/a}),o/=a)}return[r,o]}var l_=new Ic(!0);function c_(t){var e=t.stroke;return!(null==e||"none"===e||!(t.lineWidth>0))}function u_(t){return"string"==typeof t&&"none"!==t}function h_(t){var e=t.fill;return null!=e&&"none"!==e}function d_(t,e){if(null!=e.fillOpacity&&1!==e.fillOpacity){var n=t.globalAlpha;t.globalAlpha=e.fillOpacity*e.opacity,t.fill(),t.globalAlpha=n}else t.fill()}function p_(t,e){if(null!=e.strokeOpacity&&1!==e.strokeOpacity){var n=t.globalAlpha;t.globalAlpha=e.strokeOpacity*e.opacity,t.stroke(),t.globalAlpha=n}else t.stroke()}function f_(t,e,n){var i=wl(e.image,e.__image,n);if(Cl(i)){var r=t.createPattern(i,e.repeat||"repeat");if("function"==typeof DOMMatrix&&r&&r.setTransform){var o=new DOMMatrix;o.translateSelf(e.x||0,e.y||0),o.rotateSelf(0,0,(e.rotation||0)*oi),o.scaleSelf(e.scaleX||1,e.scaleY||1),r.setTransform(o)}return r}}var g_=["shadowBlur","shadowOffsetX","shadowOffsetY"],v_=[["lineCap","butt"],["lineJoin","miter"],["miterLimit",10]];function m_(t,e,n,i,r){var o=!1;if(!i&&e===(n=n||{}))return!1;if(i||e.opacity!==n.opacity){b_(t,r),o=!0;var a=Math.max(Math.min(e.opacity,1),0);t.globalAlpha=isNaN(a)?Ul.opacity:a}(i||e.blend!==n.blend)&&(o||(b_(t,r),o=!0),t.globalCompositeOperation=e.blend||Ul.blend);for(var s=0;s0&&t.unfinished);t.unfinished||this._zr.flush()}}},e.prototype.getDom=function(){return this._dom},e.prototype.getId=function(){return this.id},e.prototype.getZr=function(){return this._zr},e.prototype.isSSR=function(){return this._ssr},e.prototype.setOption=function(t,e,n){if(!this[N_])if(this._disposed)this.id;else{var i,r,o;if(On(e)&&(n=e.lazyUpdate,i=e.silent,r=e.replaceMerge,o=e.transition,e=e.notMerge),this[N_]=!0,lb(this),!this._model||e){var a=new qg(this._api),s=this._theme,l=this._model=new Hg;l.scheduler=this._scheduler,l.ssr=this._ssr,l.init(null,null,null,s,this._locale,a)}this._model.setOption(t,{replaceMerge:r},yb);var c={seriesTransition:o,optionChanged:!0};if(n)this[R_]={silent:i,updateParams:c},this[N_]=!1,this.getZr().wakeUp();else{try{U_(this),j_.update.call(this,null,c)}catch(t){throw this[R_]=null,this[N_]=!1,t}this._ssr||this._zr.flush(),this[R_]=null,this[N_]=!1,K_.call(this,i),Q_.call(this,i)}}},e.prototype.setTheme=function(t,e){if(!this[N_])if(this._disposed)this.id;else{var n=this._model;if(n){var i=e&&e.silent,r=null;this[R_]&&(null==i&&(i=this[R_].silent),r=this[R_].updateParams,this[R_]=null),this[N_]=!0,lb(this);try{this._updateTheme(t),n.setTheme(this._theme),U_(this),j_.update.call(this,{type:"setTheme"},r)}catch(t){throw this[N_]=!1,t}this[N_]=!1,K_.call(this,i),Q_.call(this,i)}}},e.prototype._updateTheme=function(t){En(t)&&(t=bb[t]),t&&((t=mn(t))&&pv(t,!0),this._theme=t)},e.prototype.getModel=function(){return this._model},e.prototype.getOption=function(){return this._model&&this._model.getOption()},e.prototype.getWidth=function(){return this._zr.getWidth()},e.prototype.getHeight=function(){return this._zr.getHeight()},e.prototype.getDevicePixelRatio=function(){return this._zr.painter.dpr||Ye.hasGlobalWindow&&window.devicePixelRatio||1},e.prototype.getRenderedCanvas=function(t){return this.renderToCanvas(t)},e.prototype.renderToCanvas=function(t){return t=t||{},this._zr.painter.getRenderedCanvas({backgroundColor:t.backgroundColor||this._model.get("backgroundColor"),pixelRatio:t.pixelRatio||this.getDevicePixelRatio()})},e.prototype.renderToSVGString=function(t){return t=t||{},this._zr.painter.renderToString({useViewBox:t.useViewBox})},e.prototype.getSvgDataURL=function(){var t=this._zr;return Cn(t.storage.getDisplayList(),function(t){t.stopAnimation(null,!0)}),t.painter.toDataURL()},e.prototype.getDataURL=function(t){if(!this._disposed){var e=(t=t||{}).excludeComponents,n=this._model,i=[],r=this;Cn(e,function(t){n.eachComponent({mainType:t},function(t){var e=r._componentsMap[t.__viewId];e.group.ignore||(i.push(e),e.group.ignore=!0)})});var o="svg"===this._zr.painter.getType()?this.getSvgDataURL():this.renderToCanvas(t).toDataURL("image/"+(t&&t.type||"png"));return Cn(i,function(t){t.group.ignore=!1}),o}this.id},e.prototype.getConnectedDataURL=function(t){if(!this._disposed){var e="svg"===t.type,n=this.group,i=Math.min,r=Math.max,o=1/0;if(Sb[n]){var a=o,s=o,l=-1/0,c=-1/0,u=[],h=t&&t.pixelRatio||this.getDevicePixelRatio();Cn(wb,function(o,h){if(o.group===n){var d=e?o.getZr().painter.getSvgDom().innerHTML:o.renderToCanvas(mn(t)),p=o.getDom().getBoundingClientRect();a=i(p.left,a),s=i(p.top,s),l=r(p.right,l),c=r(p.bottom,c),u.push({dom:d,left:p.left,top:p.top})}});var d=(l*=h)-(a*=h),p=(c*=h)-(s*=h),f=en.createCanvas(),g=ms(f,{renderer:e?"svg":"canvas"});if(g.resize({width:d,height:p}),e){var v="";return Cn(u,function(t){var e=t.left-a,n=t.top-s;v+=''+t.dom+""}),g.painter.getSvgRoot().innerHTML=v,t.connectedBackgroundColor&&g.painter.setBackgroundColor(t.connectedBackgroundColor),g.refreshImmediately(),g.painter.toDataURL()}return t.connectedBackgroundColor&&g.add(new su({shape:{x:0,y:0,width:d,height:p},style:{fill:t.connectedBackgroundColor}})),Cn(u,function(t){var e=new tu({style:{x:t.left*h-a,y:t.top*h-s,image:t.dom}});g.add(e)}),g.refreshImmediately(),f.toDataURL("image/"+(t&&t.type||"png"))}return this.getDataURL(t)}this.id},e.prototype.convertToPixel=function(t,e,n){return X_(this,"convertToPixel",t,e,n)},e.prototype.convertToLayout=function(t,e,n){return X_(this,"convertToLayout",t,e,n)},e.prototype.convertFromPixel=function(t,e,n){return X_(this,"convertFromPixel",t,e,n)},e.prototype.containPixel=function(t,e){var n;if(!this._disposed)return Cn(il(this._model,t),function(t,i){i.indexOf("Models")>=0&&Cn(t,function(t){var r=t.coordinateSystem;if(r&&r.containPoint)n=n||!!r.containPoint(e);else if("seriesModels"===i){var o=this._chartsMap[t.__viewId];o&&o.containPoint&&(n=n||o.containPoint(e,t))}},this)},this),!!n;this.id},e.prototype.getVisual=function(t,e){var n=il(this._model,t,{defaultMainType:"series"}),i=n.seriesModel.getData(),r=n.hasOwnProperty("dataIndexInside")?n.dataIndexInside:n.hasOwnProperty("dataIndex")?i.indexOfRawIndex(n.dataIndex):null;return null!=r?function(t,e,n){switch(n){case"color":return t.getItemVisual(e,"style")[t.getVisual("drawType")];case"opacity":return t.getItemVisual(e,"style").opacity;case"symbol":case"symbolSize":case"liftZ":return t.getItemVisual(e,n)}}(i,r,e):function(t,e){switch(e){case"color":return t.getVisual("style")[t.getVisual("drawType")];case"opacity":return t.getVisual("style").opacity;case"symbol":case"symbolSize":case"liftZ":return t.getVisual(e)}}(i,e)},e.prototype.getViewOfComponentModel=function(t){return this._componentsMap[t.__viewId]},e.prototype.getViewOfSeriesModel=function(t){return this._chartsMap[t.__viewId]},e.prototype._initEvents=function(){var t=this;Cn(pb,function(e){var n=function(n){var i,r=t.getModel(),o=n.target;if("globalout"===e?i={}:o&&Wy(o,function(t){var e=Cu(t);if(e&&null!=e.dataIndex){var n=e.dataModel||r.getSeriesByIndex(e.seriesIndex);return i=n&&n.getDataParams(e.dataIndex,e.dataType,o)||{},!0}if(e.eventData)return i=_n({},e.eventData),!0},!0),i){var a=i.componentType,s=i.componentIndex;"markLine"!==a&&"markPoint"!==a&&"markArea"!==a||(a="series",s=i.seriesIndex);var l=a&&null!=s&&r.getComponent(a,s),c=l&&t["series"===l.mainType?"_chartsMap":"_componentsMap"][l.__viewId];i.event=n,i.type=e,t._$eventProcessor.eventInfo={targetEl:o,packedEvent:i,model:l,view:c},t.trigger(e,i)}};n.zrEventfulCallAtLast=!0,t._zr.on(e,n,t)});var e=this._messageCenter;Cn(vb,function(n,i){e.on(i,function(e){t.trigger(i,e)})}),function(t,e,n){t.on("selectchanged",function(t){var i=n.getModel();t.isFromClick?(Vy("map","selectchanged",e,i,t),Vy("pie","selectchanged",e,i,t)):"select"===t.fromAction?(Vy("map","selected",e,i,t),Vy("pie","selected",e,i,t)):"unselect"===t.fromAction&&(Vy("map","unselected",e,i,t),Vy("pie","unselected",e,i,t))})}(e,this,this._api)},e.prototype.isDisposed=function(){return this._disposed},e.prototype.clear=function(){this._disposed?this.id:this.setOption({series:[]},!0)},e.prototype.dispose=function(){if(this._disposed)this.id;else{this._disposed=!0,this.getDom()&&sl(this.getDom(),kb,"");var t=this,e=t._api,n=t._model;Cn(t._componentsViews,function(t){t.dispose(n,e)}),Cn(t._chartsViews,function(t){t.dispose(n,e)}),t._zr.dispose(),t._dom=t._model=t._chartsMap=t._componentsMap=t._chartsViews=t._componentsViews=t._scheduler=t._api=t._zr=t._throttledZrFlush=t._theme=t._coordSysMgr=t._messageCenter=null,delete wb[t.id]}},e.prototype.resize=function(t){if(!this[N_])if(this._disposed)this.id;else{this._zr.resize(t);var e=this._model;if(this._loadingFX&&this._loadingFX.resize(),e){var n=e.resetOption("media"),i=t&&t.silent;this[R_]&&(null==i&&(i=this[R_].silent),n=!0,this[R_]=null),this[N_]=!0,lb(this);try{n&&U_(this),j_.update.call(this,{type:"resize",animation:_n({duration:0},t&&t.animation)})}catch(t){throw this[N_]=!1,t}this[N_]=!1,K_.call(this,i),Q_.call(this,i)}}},e.prototype.showLoading=function(t,e){if(this._disposed)this.id;else if(On(t)&&(e=t,t=""),t=t||"default",this.hideLoading(),xb[t]){var n=xb[t](this._api,e),i=this._zr;this._loadingFX=n,i.add(n)}},e.prototype.hideLoading=function(){this._disposed?this.id:(this._loadingFX&&this._zr.remove(this._loadingFX),this._loadingFX=null)},e.prototype.makeActionFromEvent=function(t){var e=_n({},t);return e.type=gb[t.type],e},e.prototype.dispatchAction=function(t,e){if(this._disposed)this.id;else if(On(e)||(e={silent:!!e}),fb[t.type]&&this._model)if(this[N_])this._pendingActions.push(t);else{var n=e.silent;Z_.call(this,t,n);var i=e.flush;i?this._zr.flush():!1!==i&&Ye.browser.weChat&&this._throttledZrFlush(),K_.call(this,n),Q_.call(this,n)}},e.prototype.updateLabelLayout=function(){A_.trigger("series:layoutlabels",this._model,this._api,{updatedSeries:[]})},e.prototype.appendData=function(t){if(this._disposed)this.id;else{var e=t.seriesIndex;this.getModel().getSeriesByIndex(e).appendData(t),this._scheduler.unfinished=!0,this.getZr().wakeUp()}},e.internalField=function(){function t(t){t.clearColorPalette(),t.eachSeries(function(t){t.clearColorPalette()})}function e(t){for(var e=[],n=t.currentStates,i=0;i0?{duration:o,delay:i.get("delay"),easing:i.get("easing")}:null;n.eachRendered(function(t){if(t.states&&t.states.emphasis){if(Od(t))return;if(t instanceof Yc&&function(t){var e=Tu(t);e.normalFill=t.style.fill,e.normalStroke=t.style.stroke;var n=t.states.select||{};e.selectFill=n.style&&n.style.fill||null,e.selectStroke=n.style&&n.style.stroke||null}(t),t.__dirty){var n=t.prevStates;n&&t.useStates(n)}if(r){t.stateTransition=a;var i=t.getTextContent(),o=t.getTextGuideLine();i&&(i.stateTransition=a),o&&(o.stateTransition=a)}t.__dirty&&e(t)}})}U_=function(t){var e=t._scheduler;e.restorePipelines(t._model),e.prepareStageTasks(),G_(t,!0),G_(t,!1),e.plan()},G_=function(t,e){for(var n=t._model,i=t._scheduler,r=e?t._componentsViews:t._chartsViews,o=e?t._componentsMap:t._chartsMap,a=t._zr,s=t._api,l=0;le.get("hoverLayerThreshold")&&!Ye.node&&!Ye.worker&&e.eachSeries(function(e){if(!e.preventUsingHoverLayer){var n=t._chartsMap[e.__viewId];n.__alive&&n.eachRendered(function(t){t.states.emphasis&&(t.states.emphasis.hoverLayer=!0)})}})}(t,e),A_.trigger("series:afterupdate",e,o,s)},ab=function(t){t[$_]=!0,t.getZr().wakeUp()},lb=function(t){t[O_]=(t[O_]+1)%1e3},sb=function(t){t[$_]&&(t.getZr().storage.traverse(function(t){Od(t)||e(t)}),t[$_]=!1)},rb=function(t){return new(function(e){function n(){return null!==e&&e.apply(this,arguments)||this}return y(n,e),n.prototype.getCoordinateSystems=function(){return t._coordSysMgr.getCoordinateSystems()},n.prototype.getComponentByElement=function(e){for(;e;){var n=e.__ecComponentInfo;if(null!=n)return t._model.getComponent(n.mainType,n.index);e=e.parent}},n.prototype.enterEmphasis=function(e,n){Qu(e,n),ab(t)},n.prototype.leaveEmphasis=function(e,n){Ju(e,n),ab(t)},n.prototype.enterBlur=function(e){!function(t){qu(t,Bu)}(e),ab(t)},n.prototype.leaveBlur=function(e){th(e),ab(t)},n.prototype.enterSelect=function(e){eh(e),ab(t)},n.prototype.leaveSelect=function(e){nh(e),ab(t)},n.prototype.getModel=function(){return t.getModel()},n.prototype.getViewOfComponentModel=function(e){return t.getViewOfComponentModel(e)},n.prototype.getViewOfSeriesModel=function(e){return t.getViewOfSeriesModel(e)},n.prototype.getMainProcessVersion=function(){return t[O_]},n}(Ug))(t)},ob=function(t){function e(t,e){for(var n=0;n=0)){Eb.push(n);var o=vy.wrapStageHandler(n,r);o.__prio=e,o.__raw=n,t.push(o)}}function Nb(t,e){xb[t]=e}var Ob=function(t){var e=(t=mn(t)).type;e||Fs("");var n=e.split(":");2!==n.length&&Fs("");var i=!1;"echarts"===n[0]&&(e=n[1],i=!0),t.__isBuiltIn=i,tm.set(e,t)};function Rb(t,e,n,i){return{eventContent:{selected:ch(n),isFromClick:e.isFromClick||!1}}}function $b(t){return null==t?0:t.length||1}function Hb(t){return t}Lb(L_,hy),Lb(E_,py),Lb(E_,fy),Lb(L_,Fy),Lb(E_,By),Lb(7e3,function(t,e){t.eachRawSeries(function(n){if(!t.isSeriesFiltered(n)){var i=n.getData();i.hasItemVisual()&&i.each(function(t){var n=i.getItemVisual(t,"decal");n&&(i.ensureUniqueItemVisual(t,"style").decal=M_(n,e))});var r=i.getVisual("decal");if(r)i.getVisual("style").decal=M_(r,e)}})}),Ib(pv),Db(900,function(t){var e=ei();t.eachSeries(function(t){var n=t.get("stack");if(n){var i=e.get(n)||e.set(n,[]),r=t.getData(),o={stackResultDimension:r.getCalculationInfo("stackResultDimension"),stackedOverDimension:r.getCalculationInfo("stackedOverDimension"),stackedDimension:r.getCalculationInfo("stackedDimension"),stackedByDimension:r.getCalculationInfo("stackedByDimension"),isStackedByIndex:r.getCalculationInfo("isStackedByIndex"),data:r,seriesModel:t};if(!o.stackedDimension||!o.isStackedByIndex&&!o.stackedByDimension)return;i.push(o)}}),e.each(function(t){0!==t.length&&("seriesDesc"===(t[0].seriesModel.get("stackOrder")||"seriesAsc")&&t.reverse(),Cn(t,function(e,n){e.data.setCalculationInfo("stackedOnSeries",n>0?t[n-1].seriesModel:null)}),function(t){Cn(t,function(e,n){var i=[],r=[NaN,NaN],o=[e.stackResultDimension,e.stackedOverDimension],a=e.data,s=e.isStackedByIndex,l=e.seriesModel.get("stackStrategy")||"samesign";a.modify(o,function(o,c,u){var h,d,p=a.get(e.stackedDimension,u);if(isNaN(p))return r;s?d=a.getRawIndex(u):h=a.get(e.stackedByDimension,u);for(var f=NaN,g=n-1;g>=0;g--){var v=t[g];if(s||(d=v.data.rawIndexOf(v.stackedByDimension,h)),d>=0){var m=v.data.getByRawIndex(v.stackResultDimension,d);if("all"===l||"positive"===l&&m>0||"negative"===l&&m<0||"samesign"===l&&p>=0&&m>0||"samesign"===l&&p<=0&&m<0){p=Ts(p,m),f=m;break}}}return i[0]=p,i[1]=f,i})})}(t))})}),Nb("default",function(t,e){bn(e=e||{},{text:"loading",textColor:sg.color.primary,fontSize:12,fontWeight:"normal",fontStyle:"normal",fontFamily:"sans-serif",maskColor:"rgba(255,255,255,0.8)",showSpinner:!0,color:sg.color.theme[0],spinnerRadius:10,lineWidth:5,zlevel:0});var n=new ds,i=new su({style:{fill:e.maskColor},zlevel:e.zlevel,z:1e4});n.add(i);var r,o=new hu({style:{text:e.text,fill:e.textColor,fontSize:e.fontSize,fontWeight:e.fontWeight,fontStyle:e.fontStyle,fontFamily:e.fontFamily},zlevel:e.zlevel,z:10001}),a=new su({style:{fill:"none"},textContent:o,textConfig:{position:"right",distance:10},zlevel:e.zlevel,z:10001});return n.add(a),e.showSpinner&&((r=new vd({shape:{startAngle:-gy/2,endAngle:-gy/2+.1,r:e.spinnerRadius},style:{stroke:e.color,lineCap:"round",lineWidth:e.lineWidth},zlevel:e.zlevel,z:10001})).animateShape(!0).when(1e3,{endAngle:3*gy/2}).start("circularInOut"),r.animateShape(!0).when(1e3,{startAngle:3*gy/2}).delay(300).start("circularInOut"),n.add(r)),n.resize=function(){var n=o.getBoundingRect().width,s=e.showSpinner?e.spinnerRadius:0,l=(t.getWidth()-2*s-(e.showSpinner&&n?10:0)-n)/2-(e.showSpinner&&n?0:5+n/2)+(e.showSpinner?0:n/2)+(n?0:s),c=t.getHeight()/2;e.showSpinner&&r.setShape({cx:l,cy:c}),a.setShape({x:l-s,y:c-s,width:2*s,height:2*s}),i.setShape({x:0,y:0,width:t.getWidth(),height:t.getHeight()})},n.resize(),n}),Pb({type:Pu,event:Pu,update:Pu},ri),Pb({type:Lu,event:Lu,update:Lu},ri),Pb({type:Eu,event:Ou,update:Eu,action:ri,refineEvent:Rb,publishNonRefinedEvent:!0}),Pb({type:zu,event:Ou,update:zu,action:ri,refineEvent:Rb,publishNonRefinedEvent:!0}),Pb({type:Nu,event:Ou,update:Nu,action:ri,refineEvent:Rb,publishNonRefinedEvent:!0}),Tb("default",{}),Tb("dark",Oy);var Fb=function(){function t(t,e,n,i,r,o){this._old=t,this._new=e,this._oldKeyGetter=n||Hb,this._newKeyGetter=i||Hb,this.context=r,this._diffModeMultiple="multiple"===o}return t.prototype.add=function(t){return this._add=t,this},t.prototype.update=function(t){return this._update=t,this},t.prototype.updateManyToOne=function(t){return this._updateManyToOne=t,this},t.prototype.updateOneToMany=function(t){return this._updateOneToMany=t,this},t.prototype.updateManyToMany=function(t){return this._updateManyToMany=t,this},t.prototype.remove=function(t){return this._remove=t,this},t.prototype.execute=function(){this[this._diffModeMultiple?"_executeMultiple":"_executeOneToOne"]()},t.prototype._executeOneToOne=function(){var t=this._old,e=this._new,n={},i=new Array(t.length),r=new Array(e.length);this._initIndexMap(t,null,i,"_oldKeyGetter"),this._initIndexMap(e,n,r,"_newKeyGetter");for(var o=0;o1){var c=s.shift();1===s.length&&(n[a]=s[0]),this._update&&this._update(c,o)}else 1===l?(n[a]=null,this._update&&this._update(s,o)):this._remove&&this._remove(o)}this._performRestAdd(r,n)},t.prototype._executeMultiple=function(){var t=this._old,e=this._new,n={},i={},r=[],o=[];this._initIndexMap(t,n,r,"_oldKeyGetter"),this._initIndexMap(e,i,o,"_newKeyGetter");for(var a=0;a1&&1===h)this._updateManyToOne&&this._updateManyToOne(c,l),i[s]=null;else if(1===u&&h>1)this._updateOneToMany&&this._updateOneToMany(c,l),i[s]=null;else if(1===u&&1===h)this._update&&this._update(c,l),i[s]=null;else if(u>1&&h>1)this._updateManyToMany&&this._updateManyToMany(c,l),i[s]=null;else if(u>1)for(var d=0;d1)for(var a=0;a30}var Kb,Qb,Jb,tx,ex,nx,ix,rx=On,ox=kn,ax="undefined"==typeof Int32Array?Array:Int32Array,sx=["hasItemOption","_nameList","_idList","_invertedIndicesMap","_dimSummary","userOutput","_rawData","_dimValueGetter","_nameDimIdx","_idDimIdx","_nameRepeatCount"],lx=["_approximateExtent"],cx=function(){function t(t,e){var n;this.type="list",this._dimOmitted=!1,this._nameList=[],this._idList=[],this._visual={},this._layout={},this._itemVisuals=[],this._itemLayouts=[],this._graphicEls=[],this._approximateExtent={},this._calculationInfo={},this.hasItemOption=!1,this.TRANSFERABLE_METHODS=["cloneShallow","downSample","minmaxDownSample","lttbDownSample","map"],this.CHANGABLE_METHODS=["filterSelf","selectRange"],this.DOWNSAMPLE_METHODS=["downSample","minmaxDownSample","lttbDownSample"];var i=!1;jb(t)?(n=t.dimensions,this._dimOmitted=t.isDimensionOmitted(),this._schema=t):(i=!0,n=t),n=n||["x","y"];for(var r={},o=[],a={},s=!1,l={},c=0;c=e)){var n=this._store.getProvider();this._updateOrdinalMeta();var i=this._nameList,r=this._idList;if(n.getSource().sourceFormat===mg&&!n.pure)for(var o=[],a=t;a0},t.prototype.ensureUniqueItemVisual=function(t,e){var n=this._itemVisuals,i=n[t];i||(i=n[t]={});var r=i[e];return null==r&&(Pn(r=this.getVisual(e))?r=r.slice():rx(r)&&(r=_n({},r)),i[e]=r),r},t.prototype.setItemVisual=function(t,e,n){var i=this._itemVisuals[t]||{};this._itemVisuals[t]=i,rx(e)?_n(i,e):i[e]=n},t.prototype.clearAllVisual=function(){this._visual={},this._itemVisuals=[]},t.prototype.setLayout=function(t,e){rx(t)?_n(this._layout,t):this._layout[t]=e},t.prototype.getLayout=function(t){return this._layout[t]},t.prototype.getItemLayout=function(t){return this._itemLayouts[t]},t.prototype.setItemLayout=function(t,e,n){this._itemLayouts[t]=n?_n(this._itemLayouts[t]||{},e):e},t.prototype.clearItemLayouts=function(){this._itemLayouts.length=0},t.prototype.setItemGraphicEl=function(t,e){!function(t,e,n,i){if(i){var r=Cu(i);r.dataIndex=n,r.dataType=e,r.seriesIndex=t,r.ssrType="chart","group"===i.type&&i.traverse(function(i){var r=Cu(i);r.seriesIndex=t,r.dataIndex=n,r.dataType=e,r.ssrType="chart"})}}(this.hostModel&&this.hostModel.seriesIndex,this.dataType,t,e),this._graphicEls[t]=e},t.prototype.getItemGraphicEl=function(t){return this._graphicEls[t]},t.prototype.eachItemGraphicEl=function(t,e){Cn(this._graphicEls,function(n,i){n&&t&&t.call(e,n,i)})},t.prototype.cloneShallow=function(e){return e||(e=new t(this._schema?this._schema:ox(this.dimensions,this._getDimInfo,this),this.hostModel)),ex(e,this),e._store=this._store,e},t.prototype.wrapMethod=function(t,e){var n=this[t];Ln(n)&&(this.__wrappedMethods=this.__wrappedMethods||[],this.__wrappedMethods.push(t),this[t]=function(){var t=n.apply(this,arguments);return e.apply(this,[t].concat(Gn(arguments)))})},t.internalField=(Kb=function(t){var e=t._invertedIndicesMap;Cn(e,function(n,i){var r=t._dimInfos[i],o=r.ordinalMeta,a=t._store;if(o){n=e[i]=new ax(o.categories.length);for(var s=0;s1&&(s+="__ec__"+c),i[e]=s}})),t}();function ux(t,e){xv(t)||(t=Sv(t));var n=(e=e||{}).coordDimensions||[],i=e.dimensionsDefine||t.dimensionsDefine||[],r=ei(),o=[],a=function(t,e,n,i){var r=Math.max(t.dimensionsDetectedCount||1,e.length,n.length,i||0);return Cn(e,function(t){var e;On(t)&&(e=t.dimsDef)&&(r=Math.max(r,e.length))}),r}(t,n,i,e.dimensionsCount),s=e.canOmitUnusedDimensions&&Zb(a),l=i===t.dimensionsDefine,c=l?Yb(t):Xb(i),u=e.encodeDefine;!u&&e.encodeDefaulter&&(u=e.encodeDefaulter(t,a));for(var h=ei(u),d=new sm(a),p=0;p0&&(i.name=r+(o-1)),o++,e.set(r,o)}}(o),new qb({source:t,dimensions:o,fullDimensionCount:a,dimensionOmitted:s})}function hx(t,e,n){if(n||e.hasKey(t)){for(var i=0;e.hasKey(t+i);)i++;t+=i}return e.set(t,!0),t}var dx=function(t){this.coordSysDims=[],this.axisMap=ei(),this.categoryAxisMap=ei(),this.coordSysName=t};var px={cartesian2d:function(t,e,n,i){var r=t.getReferringComponents("xAxis",ol).models[0],o=t.getReferringComponents("yAxis",ol).models[0];e.coordSysDims=["x","y"],n.set("x",r),n.set("y",o),fx(r)&&(i.set("x",r),e.firstCategoryDimIndex=0),fx(o)&&(i.set("y",o),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=1))},singleAxis:function(t,e,n,i){var r=t.getReferringComponents("singleAxis",ol).models[0];e.coordSysDims=["single"],n.set("single",r),fx(r)&&(i.set("single",r),e.firstCategoryDimIndex=0)},polar:function(t,e,n,i){var r=t.getReferringComponents("polar",ol).models[0],o=r.findAxisModel("radiusAxis"),a=r.findAxisModel("angleAxis");e.coordSysDims=["radius","angle"],n.set("radius",o),n.set("angle",a),fx(o)&&(i.set("radius",o),e.firstCategoryDimIndex=0),fx(a)&&(i.set("angle",a),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=1))},geo:function(t,e,n,i){e.coordSysDims=["lng","lat"]},parallel:function(t,e,n,i){var r=t.ecModel,o=r.getComponent("parallel",t.get("parallelIndex")),a=e.coordSysDims=o.dimensions.slice();Cn(o.parallelAxisIndex,function(t,o){var s=r.getComponent("parallelAxis",t),l=a[o];n.set(l,s),fx(s)&&(i.set(l,s),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=o))})},matrix:function(t,e,n,i){var r=t.getReferringComponents("matrix",ol).models[0];e.coordSysDims=["x","y"];var o=r.getDimensionModel("x"),a=r.getDimensionModel("y");n.set("x",o),n.set("y",a),i.set("x",o),i.set("y",a)}};function fx(t){return"category"===t.get("type")}function gx(t,e,n){var i,r,o,a=(n=n||{}).byIndex,s=n.stackedCoordDimension;!function(t){return!jb(t.schema)}(e)?(r=e.schema,i=r.dimensions,o=e.store):i=e;var l,c,u,h,d=!(!t||!t.get("stack"));if(Cn(i,function(t,e){En(t)&&(i[e]=t={name:t}),d&&!t.isExtraCoord&&(a||l||!t.ordinalMeta||(l=t),c||"ordinal"===t.type||"time"===t.type||s&&s!==t.coordDim||(c=t))}),!c||a||l||(a=!0),c){u="__\0ecstackresult_"+t.id,h="__\0ecstackedover_"+t.id,l&&(l.createInvertedIndices=!0);var p=c.coordDim,f=c.type,g=0;Cn(i,function(t){t.coordDim===p&&g++});var v={name:u,coordDim:p,coordDimIndex:g,type:f,isExtraCoord:!0,isCalculationCoord:!0,storeDimIndex:i.length},m={name:h,coordDim:h,coordDimIndex:g+1,type:f,isExtraCoord:!0,isCalculationCoord:!0,storeDimIndex:i.length+1};r?(o&&(v.storeDimIndex=o.ensureCalculationDimension(h,f),m.storeDimIndex=o.ensureCalculationDimension(u,f)),r.appendCalculationDimension(v),r.appendCalculationDimension(m)):(i.push(v),i.push(m))}return{stackedDimension:c&&c.name,stackedByDimension:l&&l.name,isStackedByIndex:a,stackedOverDimension:h,stackResultDimension:u}}function vx(t,e){return!!e&&e===t.getCalculationInfo("stackedDimension")}function mx(t,e,n){n=n||{};var i,r,o=e.getSourceManager();r=(i=o.getSource()).sourceFormat===mg;var a=function(t){var e=t.get("coordinateSystem"),n=new dx(e),i=px[e];if(i)return i(t,n,n.axisMap,n.categoryAxisMap),n}(e),s=function(t,e){var n,i=t.get("coordinateSystem"),r=Bf.get(i);return e&&e.coordSysDims&&(n=kn(e.coordSysDims,function(t){var n={name:t},i=e.axisMap.get(t);if(i){var r=i.get("type");n.type=function(t){return"category"===t?"ordinal":"time"===t?"time":"float"}(r)}return n})),n||(n=r&&(r.getDimensionsInfo?r.getDimensionsInfo():r.dimensions.slice())||["x","y"]),n}(e,a),l=n.useEncodeDefaulter,c=Ln(l)?l:l?An(Dg,s,e):null,u=ux(i,{coordDimensions:s,generateCoord:n.generateCoord,encodeDefine:e.getEncode(),encodeDefaulter:c,canOmitUnusedDimensions:!r}),h=function(t,e,n){var i,r;return n&&Cn(t,function(t,o){var a=t.coordDim,s=n.categoryAxisMap.get(a);s&&(null==i&&(i=o),t.ordinalMeta=s.getOrdinalMeta(),e&&(t.createInvertedIndices=!0)),null!=t.otherDims.itemName&&(r=!0)}),r||null==i||(t[i].otherDims.itemName=0),i}(u.dimensions,n.createInvertedIndices,a),d=r?null:o.getSharedDataStore(u),p=gx(e,{schema:u,store:d}),f=new cx(u,e);f.setCalculationInfo(p);var g=null!=h&&function(t){if(t.sourceFormat===mg){var e=function(t){var e=0;for(;er&&(a=o.interval=r);var s=o.intervalPrecision=xx(a);return function(t,e){!isFinite(t[0])&&(t[0]=e[0]),!isFinite(t[1])&&(t[1]=e[1]),Sx(t,0,e),Sx(t,1,e),t[0]>t[1]&&(t[0]=t[1])}(o.niceTickExtent=[ks(Math.ceil(t[0]/a)*a,s),ks(Math.floor(t[1]/a)*a,s)],t),o}function bx(t){var e=Math.pow(10,Ls(t)),n=t/e;return n?2===n?n=3:3===n?n=5:n*=2:n=1,ks(n*e)}function xx(t){return Ms(t)+2}function Sx(t,e,n){t[e]=Math.max(Math.min(t[e],n[1]),n[0])}function Cx(t,e){return t>=e[0]&&t<=e[1]}var kx=function(){function t(){this.normalize=Mx,this.scale=Tx}return t.prototype.updateMethods=function(t){t.hasBreaks()?(this.normalize=Dn(t.normalize,t),this.scale=Dn(t.scale,t)):(this.normalize=Mx,this.scale=Tx)},t}();function Mx(t,e){return e[1]===e[0]?.5:(t-e[0])/(e[1]-e[0])}function Tx(t,e){return t*(e[1]-e[0])+e[0]}function Ix(t,e,n){var i=Math.log(t);return[Math.log(n?e[0]:Math.max(0,e[0]))/i,Math.log(n?e[1]:Math.max(0,e[1]))/i]}var Dx=function(){function t(t){this._calculator=new kx,this._setting=t||{},this._extent=[1/0,-1/0]}return t.prototype.getSetting=function(t){return this._setting[t]},t.prototype._innerUnionExtent=function(t){var e=this._extent;this._innerSetExtent(t[0]e[1]?t[1]:e[1])},t.prototype.unionExtentFromData=function(t,e){this._innerUnionExtent(t.getApproximateExtent(e))},t.prototype.getExtent=function(){return this._extent.slice()},t.prototype.setExtent=function(t,e){this._innerSetExtent(t,e)},t.prototype._innerSetExtent=function(t,e){var n=this._extent;isNaN(t)||(n[0]=t),isNaN(e)||(n[1]=e),this._brkCtx&&this._brkCtx.update(n)},t.prototype.setBreaksFromOption=function(t){},t.prototype._innerSetBreak=function(t){this._brkCtx&&(this._brkCtx.setBreaks(t),this._calculator.updateMethods(this._brkCtx),this._brkCtx.update(this._extent))},t.prototype._innerGetBreaks=function(){return this._brkCtx?this._brkCtx.breaks:[]},t.prototype.hasBreaks=function(){return!!this._brkCtx&&this._brkCtx.hasBreaks()},t.prototype._getExtentSpanWithBreaks=function(){return this._brkCtx&&this._brkCtx.hasBreaks()?this._brkCtx.getExtentSpan():this._extent[1]-this._extent[0]},t.prototype.isInExtentRange=function(t){return this._extent[0]<=t&&this._extent[1]>=t},t.prototype.isBlank=function(){return this._isBlank},t.prototype.setBlank=function(t){this._isBlank=t},t}();vl(Dx);var Ax=0,Px=function(){function t(t){this.categories=t.categories||[],this._needCollect=t.needCollect,this._deduplication=t.deduplication,this.uid=++Ax,this._onCollect=t.onCollect}return t.createByAxisModel=function(e){var n=e.option,i=n.data,r=i&&kn(i,Lx);return new t({categories:r,needCollect:!r,deduplication:!1!==n.dedplication})},t.prototype.getOrdinal=function(t){return this._getOrCreateMap().get(t)},t.prototype.parseAndCollect=function(t){var e,n=this._needCollect;if(!En(t)&&!n)return t;if(n&&!this._deduplication)return e=this.categories.length,this.categories[e]=t,this._onCollect&&this._onCollect(t,e),e;var i=this._getOrCreateMap();return null==(e=i.get(t))&&(n?(e=this.categories.length,this.categories[e]=t,i.set(t,e),this._onCollect&&this._onCollect(t,e)):e=NaN),e},t.prototype._getOrCreateMap=function(){return this._map||(this._map=ei(this.categories))},t}();function Lx(t){return On(t)&&null!=t.value?t.value:t+""}var Ex=function(t){function e(e){var n=t.call(this,e)||this;n.type="ordinal";var i=n.getSetting("ordinalMeta");return i||(i=new Px({})),Pn(i)&&(i=new Px({categories:kn(i,function(t){return On(t)?t.value:t})})),n._ordinalMeta=i,n._extent=n.getSetting("extent")||[0,i.categories.length-1],n}return y(e,t),e.prototype.parse=function(t){return null==t?NaN:En(t)?this._ordinalMeta.getOrdinal(t):Math.round(t)},e.prototype.contain=function(t){return Cx(t,this._extent)&&t>=0&&t=0&&t=0&&t=t},e.prototype.getOrdinalMeta=function(){return this._ordinalMeta},e.prototype.calcNiceTicks=function(){},e.prototype.calcNiceExtent=function(){},e.type="ordinal",e}(Dx);Dx.registerClass(Ex);var zx=ks,Nx=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.type="interval",e._interval=0,e._intervalPrecision=2,e}return y(e,t),e.prototype.parse=function(t){return null==t||""===t?NaN:Number(t)},e.prototype.contain=function(t){return Cx(t,this._extent)},e.prototype.normalize=function(t){return this._calculator.normalize(t,this._extent)},e.prototype.scale=function(t){return this._calculator.scale(t,this._extent)},e.prototype.getInterval=function(){return this._interval},e.prototype.setInterval=function(t){this._interval=t,this._niceExtent=this._extent.slice(),this._intervalPrecision=xx(t)},e.prototype.getTicks=function(t){t=t||{};var e=this._interval,n=this._extent,i=this._niceExtent,r=this._intervalPrecision,o=[];if(!e)return o;t.breakTicks;n[0]=0&&(s=zx(s+l*e,r))}if(o.length>0&&s===o[o.length-1].value)break;if(o.length>1e4)return[]}var c=o.length?o[o.length-1].value:i[1];return n[1]>c&&(t.expandToNicedExtent?o.push({value:zx(c+e,r)}):o.push({value:n[1]})),t.breakTicks,o},e.prototype.getMinorTicks=function(t){for(var e=this.getTicks({expandToNicedExtent:!0}),n=[],i=this.getExtent(),r=1;ri[0]&&h0&&(o=null===o?s:Math.min(o,s))}n[i]=o}}return n}(t),n=[];return Cn(t,function(t){var i,r=t.coordinateSystem.getBaseAxis(),o=r.getExtent();if("category"===r.type)i=r.getBandWidth();else if("value"===r.type||"time"===r.type){var a=r.dim+"_"+r.index,s=e[a],l=Math.abs(o[1]-o[0]),c=r.scale.getExtent(),u=Math.abs(c[1]-c[0]);i=s?l/u*s:l}else{var h=t.getData();i=Math.abs(o[1]-o[0])/h.count()}var d=Ss(t.get("barWidth"),i),p=Ss(t.get("barMaxWidth"),i),f=Ss(t.get("barMinWidth")||(function(t){return t.pipelineContext&&t.pipelineContext.large}(t)?.5:1),i),g=t.get("barGap"),v=t.get("barCategoryGap"),m=t.get("defaultBarGap");n.push({bandWidth:i,barWidth:d,barMaxWidth:p,barMinWidth:f,barGap:g,barCategoryGap:v,defaultBarGap:m,axisKey:Fx(r),stackId:Hx(t)})}),function(t){var e={};Cn(t,function(t,n){var i=t.axisKey,r=t.bandWidth,o=e[i]||{bandWidth:r,remainedWidth:r,autoWidthCount:0,categoryGap:null,gap:t.defaultBarGap||0,stacks:{}},a=o.stacks;e[i]=o;var s=t.stackId;a[s]||o.autoWidthCount++,a[s]=a[s]||{width:0,maxWidth:0};var l=t.barWidth;l&&!a[s].width&&(a[s].width=l,l=Math.min(o.remainedWidth,l),o.remainedWidth-=l);var c=t.barMaxWidth;c&&(a[s].maxWidth=c);var u=t.barMinWidth;u&&(a[s].minWidth=u);var h=t.barGap;null!=h&&(o.gap=h);var d=t.barCategoryGap;null!=d&&(o.categoryGap=d)});var n={};return Cn(e,function(t,e){n[e]={};var i=t.stacks,r=t.bandWidth,o=t.categoryGap;if(null==o){var a=In(i).length;o=Math.max(35-4*a,15)+"%"}var s=Ss(o,r),l=Ss(t.gap,1),c=t.remainedWidth,u=t.autoWidthCount,h=(c-s)/(u+(u-1)*l);h=Math.max(h,0),Cn(i,function(t){var e=t.maxWidth,n=t.minWidth;if(t.width){i=t.width;e&&(i=Math.min(i,e)),n&&(i=Math.max(i,n)),t.width=i,c-=i+l*i,u--}else{var i=h;e&&ei&&(i=n),i!==h&&(t.width=i,c-=i+l*i,u--)}}),h=(c-s)/(u+(u-1)*l),h=Math.max(h,0);var d,p=0;Cn(i,function(t,e){t.width||(t.width=h),d=t,p+=t.width*(1+l)}),d&&(p-=d.width*l);var f=-p/2;Cn(i,function(t,i){n[e][i]=n[e][i]||{bandWidth:r,offset:f,width:t.width},f+=t.width*(1+l)})}),n}(n)}var Vx=function(t){function e(e){var n=t.call(this,e)||this;return n.type="time",n}return y(e,t),e.prototype.getLabel=function(t){var e=this.getSetting("useUTC");return ff(t.value,sf[function(t){switch(t){case"year":case"month":return"day";case"millisecond":return"millisecond";default:return"second"}}(df(this._minLevelUnit))]||sf.second,e,this.getSetting("locale"))},e.prototype.getFormattedLabel=function(t,e,n){var i=this.getSetting("useUTC");return function(t,e,n,i,r){var o=null;if(En(n))o=n;else if(Ln(n)){var a={time:t.time,level:t.time.level},s=null;s&&s.makeAxisLabelFormatterParamBreak(a,t.break),o=n(t.value,e,a)}else{var l=t.time;if(l){var c=n[l.lowerTimeUnit][l.upperTimeUnit];o=c[Math.min(l.level,c.length-1)]||""}else{var u=gf(t.value,r);o=n[u][u][0]}}return ff(new Date(t.value),o,r,i)}(t,e,n,this.getSetting("locale"),i)},e.prototype.getTicks=function(t){var e=this._interval,n=this._extent,i=[];if(!e)return i;var r=this.getSetting("useUTC"),o=gf(n[1],r);i.push({value:n[0],time:{level:0,upperTimeUnit:o,lowerTimeUnit:o}});var a=function(t,e,n,i,r,o){var a=1e4,s=cf,l=0;function c(t,e,n,r,s,c,u){for(var h=function(t,e){var n=new Date(0);n[t](1);var i=n.getTime();n[t](1+e);var r=n.getTime()-i;return function(t,e){return Math.max(0,Math.round((e-t)/r))}}(s,t),d=e,p=new Date(d);da));)if(p[s](p[r]()+t),d=p.getTime(),o){var f=o.calcNiceTickMultiple(d,h);f>0&&(p[s](p[r]()+f*t),d=p.getTime())}u.push({value:d,notAdd:!0})}function u(t,r,o){var a=[],s=!r.length;if(!function(t,e,n,i){return vf(new Date(e),t,i).getTime()===vf(new Date(n),t,i).getTime()}(df(t),i[0],i[1],n)){s&&(r=[{value:Yx(i[0],t,n)},{value:i[1]}]);for(var l=0;l=i[0]&&u<=i[1]&&c(d,u,h,p,f,g,a),"year"===t&&o.length>1&&0===l&&o.unshift({value:o[0].value-d})}}for(l=0;l=i[0]&&_<=i[1]&&p++)}var b=r/e;if(p>1.5*b&&f>b/1.5)break;if(h.push(m),p>b||t===s[g])break}d=[]}}var x=Tn(kn(h,function(t){return Tn(t,function(t){return t.value>=i[0]&&t.value<=i[1]&&!t.notAdd})}),function(t){return t.length>0}),w=[],S=x.length-1;for(g=0;gn&&(this._approxInterval=n);var r=Wx.length,o=Math.min(function(t,e,n,i){for(;n>>1;t[r][1]16?16:t>7.5?7:t>3.5?4:t>1.5?2:1}function Gx(t){return(t/=2592e6)>6?6:t>3?3:t>2?2:1}function qx(t){return(t/=tf)>12?12:t>6?6:t>3.5?4:t>2?2:1}function jx(t,e){return(t/=e?Jp:Qp)>30?30:t>20?20:t>15?15:t>10?10:t>5?5:t>2?2:1}function Xx(t){return Es(t)}function Yx(t,e,n){var i=Math.max(0,xn(lf,e)-1);return vf(new Date(t),lf[i],n).getTime()}Dx.registerClass(Vx);var Zx=ks,Kx=Math.floor,Qx=Math.ceil,Jx=Math.pow,tw=Math.log,ew=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.type="log",e.base=10,e._originalScale=new Nx,e}return y(e,t),e.prototype.getTicks=function(e){e=e||{};var n=this._extent.slice(),i=this._originalScale.getExtent(),r=t.prototype.getTicks.call(this,e),o=this.base;return this._originalScale._innerGetBreaks(),kn(r,function(t){var e=t.value,r=null,a=Jx(o,e);return e===n[0]&&this._fixMin?r=i[0]:e===n[1]&&this._fixMax&&(r=i[1]),null!=r&&(a=nw(a,r)),{value:a,break:void 0}},this)},e.prototype._getNonTransBreaks=function(){return this._originalScale._innerGetBreaks()},e.prototype.setExtent=function(e,n){this._originalScale.setExtent(e,n);var i=Ix(this.base,[e,n]);t.prototype.setExtent.call(this,i[0],i[1])},e.prototype.getExtent=function(){var e=this.base,n=t.prototype.getExtent.call(this);n[0]=Jx(e,n[0]),n[1]=Jx(e,n[1]);var i=this._originalScale.getExtent();return this._fixMin&&(n[0]=nw(n[0],i[0])),this._fixMax&&(n[1]=nw(n[1],i[1])),n},e.prototype.unionExtentFromData=function(t,e){this._originalScale.unionExtentFromData(t,e);var n=Ix(this.base,t.getApproximateExtent(e),!0);this._innerUnionExtent(n)},e.prototype.calcNiceTicks=function(t){t=t||10;var e=this._extent.slice(),n=this._getExtentSpanWithBreaks();if(isFinite(n)&&!(n<=0)){var i,r=(i=n,Math.pow(10,Ls(i)));for(t/n*r<=.5&&(r*=10);!isNaN(r)&&Math.abs(r)<1&&Math.abs(r)>0;)r*=10;var o=[Zx(Qx(e[0]/r)*r),Zx(Kx(e[1]/r)*r)];this._interval=r,this._intervalPrecision=xx(r),this._niceExtent=o}},e.prototype.calcNiceExtent=function(e){t.prototype.calcNiceExtent.call(this,e),this._fixMin=e.fixMin,this._fixMax=e.fixMax},e.prototype.contain=function(e){return e=tw(e)/tw(this.base),t.prototype.contain.call(this,e)},e.prototype.normalize=function(e){return e=tw(e)/tw(this.base),t.prototype.normalize.call(this,e)},e.prototype.scale=function(e){return e=t.prototype.scale.call(this,e),Jx(this.base,e)},e.prototype.setBreaksFromOption=function(t){},e.type="log",e}(Nx);function nw(t,e){return Zx(t,Ms(e))}Dx.registerClass(ew);var iw=function(){function t(t,e,n){this._prepareParams(t,e,n)}return t.prototype._prepareParams=function(t,e,n){n[1]0&&s>0&&!l&&(a=0),a<0&&s<0&&!c&&(s=0));var h=this._determinedMin,d=this._determinedMax;return null!=h&&(a=h,l=!0),null!=d&&(s=d,c=!0),{min:a,max:s,minFixed:l,maxFixed:c,isBlank:u}},t.prototype.modifyDataMinMax=function(t,e){this[ow[t]]=e},t.prototype.setDeterminedMinMax=function(t,e){this[rw[t]]=e},t.prototype.freeze=function(){this.frozen=!0},t}(),rw={min:"_determinedMin",max:"_determinedMax"},ow={min:"_dataMin",max:"_dataMax"};function aw(t,e){return null==e?null:Bn(e)?NaN:t.parse(e)}function sw(t,e){var n=t.type,i=function(t,e,n){var i=t.rawExtentInfo;return i||(i=new iw(t,e,n),t.rawExtentInfo=i,i)}(t,e,t.getExtent()).calculate();t.setBlank(i.isBlank);var r=i.min,o=i.max,a=e.ecModel;if(a&&"time"===n){var s=function(t,e){var n=[];return e.eachSeriesByType(t,function(t){(function(t){return t.coordinateSystem&&"cartesian2d"===t.coordinateSystem.type})(t)&&n.push(t)}),n}("bar",a),l=!1;if(Cn(s,function(t){l=l||t.getBaseAxis()===e.axis}),l){var c=Bx(s),u=function(t,e,n,i){var r=n.axis.getExtent(),o=Math.abs(r[1]-r[0]),a=function(t,e){if(t&&e)return t[Fx(e)]}(i,n.axis);if(void 0===a)return{min:t,max:e};var s=1/0;Cn(a,function(t){s=Math.min(t.offset,s)});var l=-1/0;Cn(a,function(t){l=Math.max(t.offset+t.width,l)}),s=Math.abs(s),l=Math.abs(l);var c=s+l,u=e-t,h=u/(1-(s+l)/o)-u;return e+=h*(l/c),t-=h*(s/c),{min:t,max:e}}(r,o,e,c);r=u.min,o=u.max}}return{extent:[r,o],fixMin:i.minFixed,fixMax:i.maxFixed}}function lw(t,e){var n=e,i=sw(t,n),r=i.extent,o=n.get("splitNumber");t instanceof ew&&(t.base=n.get("logBase"));var a=t.type,s=n.get("interval"),l="interval"===a||"time"===a;t.setBreaksFromOption(vw(n)),t.setExtent(r[0],r[1]),t.calcNiceExtent({splitNumber:o,fixMin:i.fixMin,fixMax:i.fixMax,minInterval:l?n.get("minInterval"):null,maxInterval:l?n.get("maxInterval"):null}),null!=s&&t.setInterval&&t.setInterval(s)}function cw(t){var e=t.getLabelModel().get("formatter");if("time"===t.type){var n=uf(e);return function(e,i){return t.scale.getFormattedLabel(e,i,n)}}if(En(e))return function(n){var i=t.scale.getLabel(n);return e.replace("{value}",null!=i?i:"")};if(Ln(e)){if("category"===t.type)return function(n,i){return e(uw(t,n),n.value-t.scale.getExtent()[0],null)};var i=null;return function(n,r){var o=null;return i&&(o=i.makeAxisLabelFormatterParamBreak(o,n.break)),e(uw(t,n),r,o)}}return function(e){return t.scale.getLabel(e)}}function uw(t,e){return"category"===t.type?t.scale.getLabel(e):e.value}function hw(t){var e=t.get("interval");return null==e?"auto":e}function dw(t){return"category"===t.type&&0===hw(t.getLabelModel())}function pw(t,e){var n={};return Cn(t.mapDimensionsAll(e),function(e){n[function(t,e){return vx(t,e)?t.getCalculationInfo("stackResultDimension"):e}(t,e)]=!0}),In(n)}function fw(t){return"middle"===t||"center"===t}function gw(t){return t.getShallow("show")}function vw(t){t.get("breaks",!0)}var mw=function(){function t(){}return t.prototype.getNeedCrossZero=function(){return!this.option.scale},t.prototype.getCoordSysModel=function(){},t}(),yw=[],_w={registerPreprocessor:Ib,registerProcessor:Db,registerPostInit:function(t){Ab("afterinit",t)},registerPostUpdate:function(t){Ab("afterupdate",t)},registerUpdateLifecycle:Ab,registerAction:Pb,registerCoordinateSystem:function(t,e){Bf.register(t,e)},registerLayout:function(t,e){zb(_b,t,e,1e3,"layout")},registerVisual:Lb,registerTransform:Ob,registerLoading:Nb,registerMap:function(t,e,n){var i=P_["registerMap"];i&&i(t,e,n)},registerImpl:function(t,e){P_[t]=e},PRIORITY:z_,ComponentModel:ag,ComponentView:Um,SeriesModel:Om,ChartView:Xm,registerComponentModel:function(t){ag.registerClass(t)},registerComponentView:function(t){Um.registerClass(t)},registerSeriesModel:function(t){Om.registerClass(t)},registerChartView:function(t){Xm.registerClass(t)},registerCustomSeries:function(t,e){},registerSubTypeDefaulter:function(t,e){ag.registerSubTypeDefaulter(t,e)},registerPainter:function(t,e){var n;n=e,ps[t]=n}};function bw(t){Pn(t)?Cn(t,function(t){bw(t)}):xn(yw,t)>=0||(yw.push(t),Ln(t)&&(t={install:t}),t.install(_w))}var xw=el(),ww=el(),Sw=1,Cw=2;function kw(t){return{out:{noPxChangeTryDetermine:[]},kind:t}}function Mw(t,e){var n=kn(e,function(e){return t.scale.parse(e)});return"time"===t.type&&n.length>0&&(n.sort(),n.unshift(n[0]),n.push(n[n.length-1])),n}function Tw(t,e){var n=t.getLabelModel().get("customValues");if(n){var i=cw(t),r=t.scale.getExtent();return{labels:kn(Tn(Mw(t,n),function(t){return t>=r[0]&&t<=r[1]}),function(e){var n={value:e};return{formattedLabel:i(n),rawLabel:t.scale.getLabel(n),tickValue:e,time:void 0,break:void 0}})}}return"category"===t.type?function(t,e){var n=t.getLabelModel(),i=Dw(t,n,e);return!n.get("show")||t.scale.isBlank()?{labels:[]}:i}(t,e):function(t){var e=t.scale.getTicks(),n=cw(t);return{labels:kn(e,function(e,i){return{formattedLabel:n(e,i),rawLabel:t.scale.getLabel(e),tickValue:e.value,time:e.time,break:e.break}})}}(t)}function Iw(t,e,n){var i=t.getTickModel().get("customValues");if(i){var r=t.scale.getExtent();return{ticks:Tn(Mw(t,i),function(t){return t>=r[0]&&t<=r[1]})}}return"category"===t.type?function(t,e){var n,i,r=Aw(t),o=hw(e),a=Ew(r,o);if(a)return a;e.get("show")&&!t.scale.isBlank()||(n=[]);if(Ln(o))n=$w(t,o,!0);else if("auto"===o){var s=Dw(t,t.getLabelModel(),kw(Cw));i=s.labelCategoryInterval,n=kn(s.labels,function(t){return t.tickValue})}else n=Rw(t,i=o,!0);return zw(r,o,{ticks:n,tickCategoryInterval:i})}(t,e):{ticks:kn(t.scale.getTicks(n),function(t){return t.value})}}function Dw(t,e,n){var i,r,o=Pw(t),a=hw(e),s=n.kind===Sw;if(!s){var l=Ew(o,a);if(l)return l}Ln(a)?i=$w(t,a):(r="auto"===a?function(t,e){if(e.kind===Sw){var n=t.calculateCategoryInterval(e);return e.out.noPxChangeTryDetermine.push(function(){return ww(t).autoInterval=n,!0}),n}var i=ww(t).autoInterval;return null!=i?i:ww(t).autoInterval=t.calculateCategoryInterval(e)}(t,n):a,i=Rw(t,r));var c={labels:i,labelCategoryInterval:r};return s?n.out.noPxChangeTryDetermine.push(function(){return zw(o,a,c),!0}):zw(o,a,c),c}var Aw=Lw("axisTick"),Pw=Lw("axisLabel");function Lw(t){return function(e){return ww(e)[t]||(ww(e)[t]={list:[]})}}function Ew(t,e){for(var n=0;ne&&i.axisExtent0===r[0]&&i.axisExtent1===r[1])return o;i.lastTickCount=n,i.lastAutoInterval=e,i.axisExtent0=r[0],i.axisExtent1=r[1]}function Rw(t,e,n){var i=cw(t),r=t.scale,o=r.getExtent(),a=t.getLabelModel(),s=[],l=Math.max((e||0)+1,1),c=o[0],u=r.count();0!==c&&l>1&&u/l>2&&(c=Math.round(Math.ceil(c/l)*l));var h=dw(t),d=a.get("showMinLabel")||h,p=a.get("showMaxLabel")||h;d&&c!==o[0]&&g(o[0]);for(var f=c;f<=o[1];f+=l)g(f);function g(t){var e={value:t};s.push(n?t:{formattedLabel:i(e),rawLabel:r.getLabel(e),tickValue:t,time:void 0,break:void 0})}return p&&f-l!==o[1]&&g(o[1]),s}function $w(t,e,n){var i=t.scale,r=cw(t),o=[];return Cn(i.getTicks(),function(t){var a=i.getLabel(t),s=t.value;e(t.value,a)&&o.push(n?s:{formattedLabel:r(t),rawLabel:a,tickValue:s,time:void 0,break:void 0})}),o}var Hw=[0,1],Fw=function(){function t(t,e,n){this.onBand=!1,this.inverse=!1,this.dim=t,this.scale=e,this._extent=n||[0,0]}return t.prototype.contain=function(t){var e=this._extent,n=Math.min(e[0],e[1]),i=Math.max(e[0],e[1]);return t>=n&&t<=i},t.prototype.containData=function(t){return this.scale.contain(this.scale.parse(t))},t.prototype.getExtent=function(){return this._extent.slice()},t.prototype.getPixelPrecision=function(t){return function(t,e){var n=Math.log,i=Math.LN10,r=Math.floor(n(t[1]-t[0])/i),o=Math.round(n(xs(e[1]-e[0]))/i),a=Math.min(Math.max(-r+o,0),20);return isFinite(a)?a:20}(t||this.scale.getExtent(),this._extent)},t.prototype.setExtent=function(t,e){var n=this._extent;n[0]=t,n[1]=e},t.prototype.dataToCoord=function(t,e){var n=this._extent,i=this.scale;return t=i.normalize(i.parse(t)),this.onBand&&"ordinal"===i.type&&Bw(n=n.slice(),i.count()),ws(t,Hw,n,e)},t.prototype.coordToData=function(t,e){var n=this._extent,i=this.scale;this.onBand&&"ordinal"===i.type&&Bw(n=n.slice(),i.count());var r=ws(t,n,Hw,e);return this.scale.scale(r)},t.prototype.pointToData=function(t,e){},t.prototype.getTicksCoords=function(t){var e=(t=t||{}).tickModel||this.getTickModel(),n=kn(Iw(this,e,{breakTicks:t.breakTicks,pruneByBreak:t.pruneByBreak}).ticks,function(t){return{coord:this.dataToCoord("ordinal"===this.scale.type?this.scale.getRawOrdinalNumber(t):t),tickValue:t}},this);return function(t,e,n,i){var r=e.length;if(!t.onBand||n||!r)return;var o,a,s=t.getExtent();if(1===r)e[0].coord=s[0],e[0].onBand=!0,o=e[1]={coord:s[1],tickValue:e[0].tickValue,onBand:!0};else{var l=e[r-1].tickValue-e[0].tickValue,c=(e[r-1].coord-e[0].coord)/l;Cn(e,function(t){t.coord-=c/2,t.onBand=!0});var u=t.scale.getExtent();a=1+u[1]-e[r-1].tickValue,o={coord:e[r-1].coord+c*a,tickValue:u[1]+1,onBand:!0},e.push(o)}var h=s[0]>s[1];d(e[0].coord,s[0])&&(i?e[0].coord=s[0]:e.shift());i&&d(s[0],e[0].coord)&&e.unshift({coord:s[0],onBand:!0});d(s[1],o.coord)&&(i?o.coord=s[1]:e.pop());i&&d(o.coord,s[1])&&e.push({coord:s[1],onBand:!0});function d(t,e){return t=ks(t),e=ks(e),h?t>e:t0&&t<100||(t=5),kn(this.scale.getMinorTicks(t),function(t){return kn(t,function(t){return{coord:this.dataToCoord(t),tickValue:t}},this)},this)},t.prototype.getViewLabels=function(t){return Tw(this,t=t||kw(Cw)).labels},t.prototype.getLabelModel=function(){return this.model.getModel("axisLabel")},t.prototype.getTickModel=function(){return this.model.getModel("axisTick")},t.prototype.getBandWidth=function(){var t=this._extent,e=this.scale.getExtent(),n=e[1]-e[0]+(this.onBand?1:0);0===n&&(n=1);var i=Math.abs(t[1]-t[0]);return Math.abs(i)/n},t.prototype.calculateCategoryInterval=function(t){return function(t,e){var n=e.kind,i=function(t){var e=t.getLabelModel();return{axisRotate:t.getRotate?t.getRotate():t.isHorizontal&&!t.isHorizontal()?90:0,labelRotate:e.get("rotate")||0,font:e.getFont()}}(t),r=cw(t),o=(i.axisRotate-i.labelRotate)/180*Math.PI,a=t.scale,s=a.getExtent(),l=a.count();if(s[1]-s[0]<1)return 0;var c=1;l>40&&(c=Math.max(1,Math.floor(l/40)));for(var u=s[0],h=t.dataToCoord(u+1)-t.dataToCoord(u),d=Math.abs(h*Math.cos(o)),p=Math.abs(h*Math.sin(o)),f=0,g=0;u<=s[1];u+=c){var v,m,y=Ya(r({value:u}),i.font,"center","top");v=1.3*y.width,m=1.3*y.height,f=Math.max(f,v,7),g=Math.max(g,m,7)}var _=f/d,b=g/p;isNaN(_)&&(_=1/0),isNaN(b)&&(b=1/0);var x=Math.max(0,Math.floor(Math.min(_,b)));if(n===Sw)return e.out.noPxChangeTryDetermine.push(Dn(Nw,null,t,x,l)),x;var w=Ow(t,x,l);return null!=w?w:x}(this,t=t||kw(Cw))},t}();function Bw(t,e){var n=(t[1]-t[0])/e/2;t[0]+=n,t[1]-=n}var Vw=["label","labelLine","layoutOption","priority","defaultAttr","marginForce","minMarginForce","marginDefault","suggestIgnore"];function Ww(t,e,n){n=n||3,e?t.dirty|=n:t.dirty&=~n}function Uw(t,e){return e=e||3,null==t.dirty||!!(t.dirty&e)}function Gw(t){if(t)return Uw(t)&&function(t,e,n){var i=e.getComputedTransform();t.transform=dp(t.transform,i);var r=t.localRect=hp(t.localRect,e.getBoundingRect()),o=e.style,a=o.margin,s=n&&n.marginForce,l=n&&n.minMarginForce,c=n&&n.marginDefault,u=o.__marginType;null==u&&c&&(a=c,u=Ap.textMargin);for(var h=0;h<4;h++)qw[h]=u===Ap.minMargin&&l&&null!=l[h]?l[h]:s&&null!=s[h]?s[h]:a?a[h]:0;u===Ap.textMargin&&ip(r,qw,!1,!1);var d=t.rect=hp(t.rect,r);i&&d.applyTransform(i);u===Ap.minMargin&&ip(d,qw,!1,!1);t.axisAligned=cp(i),(t.label=t.label||{}).ignore=e.ignore,Ww(t,!1),Ww(t,!0,2)}(t,t.label,t),t}var qw=[0,0,0,0];function jw(t,e){for(var n=0;n-1&&(s.style.stroke=s.style.fill,s.style.fill=sg.color.neutral00,s.style.lineWidth=2),e},e.type="series.line",e.dependencies=["grid","polar"],e.defaultOption={z:3,coordinateSystem:"cartesian2d",legendHoverLink:!0,clip:!0,label:{position:"top"},endLabel:{show:!1,valueAnimation:!0,distance:8},lineStyle:{width:2,type:"solid"},emphasis:{scale:!0},step:!1,smooth:!1,smoothMonotone:null,symbol:"emptyCircle",symbolSize:6,symbolRotate:null,showSymbol:!0,showAllSymbol:"auto",connectNulls:!1,sampling:"none",animationEasing:"linear",progressive:0,hoverLayerThreshold:1/0,universalTransition:{divideShape:"clone"},triggerLineEvent:!1},e}(Om);function Kw(t,e){var n=t.mapDimensionsAll("defaultedLabel"),i=n.length;if(1===i){var r=Fv(t,e,n[0]);return null!=r?r+"":null}if(i){for(var o=[],a=0;a0?+d:1;M.scaleX=this._sizeX*T,M.scaleY=this._sizeY*T,this.setSymbolScale(1),hh(this,l,c,u)},e.prototype.setSymbolScale=function(t){this.scaleX=this.scaleY=t},e.prototype.fadeOut=function(t,e,n){var i=this.childAt(0),r=Cu(this).dataIndex,o=n&&n.animation;if(this.silent=i.silent=!0,n&&n.fadeLabel){var a=i.getTextContent();a&&Rd(a,{style:{opacity:0}},e,{dataIndex:r,removeOpt:o,cb:function(){i.removeTextContent()}})}else i.removeTextContent();Rd(i,{style:{opacity:0},scaleX:0,scaleY:0},e,{dataIndex:r,cb:t,removeOpt:o})},e.getSymbolSize=function(t,e){return Pn(n=t.getItemVisual(e,"symbolSize"))||(n=[+n,+n]),[n[0]||0,n[1]||0];var n},e.getSymbolZ2=function(t,e){return t.getItemVisual(e,"z2")},e}(ds);function Jw(t,e){this.parent.drift(t,e)}function tS(t,e,n,i){return e&&!isNaN(e[0])&&!isNaN(e[1])&&!(i.isIgnore&&i.isIgnore(n))&&!(i.clipShape&&!i.clipShape.contain(e[0],e[1]))&&"none"!==t.getItemVisual(n,"symbol")}function eS(t){return null==t||On(t)||(t={isIgnore:t}),t||{}}function nS(t){var e=t.hostModel,n=e.getModel("emphasis");return{emphasisItemStyle:n.getModel("itemStyle").getItemStyle(),blurItemStyle:e.getModel(["blur","itemStyle"]).getItemStyle(),selectItemStyle:e.getModel(["select","itemStyle"]).getItemStyle(),focus:n.get("focus"),blurScope:n.get("blurScope"),emphasisDisabled:n.get("disabled"),hoverScale:n.get("scale"),labelStatesModels:bp(e),cursorStyle:e.get("cursor")}}var iS=function(){function t(t){this.group=new ds,this._SymbolCtor=t||Qw}return t.prototype.updateData=function(t,e){this._progressiveEls=null,e=eS(e);var n=this.group,i=t.hostModel,r=this._data,o=this._SymbolCtor,a=e.disableAnimation,s=nS(t),l={disableAnimation:a},c=e.getSymbolPoint||function(e){return t.getItemLayout(e)};r||n.removeAll(),t.diff(r).add(function(i){var r=c(i);if(tS(t,r,i,e)){var a=new o(t,i,s,l);a.setPosition(r),t.setItemGraphicEl(i,a),n.add(a)}}).update(function(u,h){var d=r.getItemGraphicEl(h),p=c(u);if(tS(t,p,u,e)){var f=t.getItemVisual(u,"symbol")||"circle",g=d&&d.getSymbolType&&d.getSymbolType();if(!d||g&&g!==f)n.remove(d),(d=new o(t,u,s,l)).setPosition(p);else{d.updateData(t,u,s,l);var v={x:p[0],y:p[1]};a?d.attr(v):zd(d,v,i)}n.add(d),t.setItemGraphicEl(u,d)}else n.remove(d)}).remove(function(t){var e=r.getItemGraphicEl(t);e&&e.fadeOut(function(){n.remove(e)},i)}).execute(),this._getSymbolPoint=c,this._data=t},t.prototype.updateLayout=function(){var t=this,e=this._data;e&&e.eachItemGraphicEl(function(e,n){var i=t._getSymbolPoint(n);e.setPosition(i),e.markRedraw()})},t.prototype.incrementalPrepareUpdate=function(t){this._seriesScope=nS(t),this._data=null,this.group.removeAll()},t.prototype.incrementalUpdate=function(t,e,n){function i(t){t.isGroup||(t.incremental=!0,t.ensureState("emphasis").hoverLayer=!0)}this._progressiveEls=[],n=eS(n);for(var r=t.start;r0?n=i[0]:i[1]<0&&(n=i[1]);return n}(r,n),a=i.dim,s=r.dim,l=e.mapDimension(s),c=e.mapDimension(a),u="x"===s||"radius"===s?1:0,h=kn(t.dimensions,function(t){return e.mapDimension(t)}),d=!1,p=e.getCalculationInfo("stackResultDimension");return vx(e,h[0])&&(d=!0,h[0]=p),vx(e,h[1])&&(d=!0,h[1]=p),{dataDimsForPoint:h,valueStart:o,valueAxisDim:s,baseAxisDim:a,stacked:!!d,valueDim:l,baseDim:c,baseDataOffset:u,stackedOverDimension:e.getCalculationInfo("stackedOverDimension")}}function oS(t,e,n,i){var r=NaN;t.stacked&&(r=n.get(n.getCalculationInfo("stackedOverDimension"),i)),isNaN(r)&&(r=t.valueStart);var o=t.baseDataOffset,a=[];return a[o]=n.get(t.baseDim,i),a[1-o]=r,e.dataToPoint(a)}var aS=Math.min,sS=Math.max;function lS(t,e){return isNaN(t)||isNaN(e)}function cS(t,e,n,i,r,o,a,s,l){for(var c,u,h,d,p,f,g=n,v=0;v=r||g<0)break;if(lS(m,y)){if(l){g+=o;continue}break}if(g===n)t[o>0?"moveTo":"lineTo"](m,y),h=m,d=y;else{var _=m-c,b=y-u;if(_*_+b*b<.5){g+=o;continue}if(a>0){for(var x=g+o,w=e[2*x],S=e[2*x+1];w===m&&S===y&&v=i||lS(w,S))p=m,f=y;else{M=w-c,T=S-u;var A=m-c,P=w-m,L=y-u,E=S-y,z=void 0,N=void 0;if("x"===s){var O=M>0?1:-1;p=m-O*(z=Math.abs(A))*a,f=y,I=m+O*(N=Math.abs(P))*a,D=y}else if("y"===s){var R=T>0?1:-1;p=m,f=y-R*(z=Math.abs(L))*a,I=m,D=y+R*(N=Math.abs(E))*a}else z=Math.sqrt(A*A+L*L),p=m-M*a*(1-(k=(N=Math.sqrt(P*P+E*E))/(N+z))),f=y-T*a*(1-k),D=y+T*a*k,I=aS(I=m+M*a*k,sS(w,m)),D=aS(D,sS(S,y)),I=sS(I,aS(w,m)),f=y-(T=(D=sS(D,aS(S,y)))-y)*z/N,p=aS(p=m-(M=I-m)*z/N,sS(c,m)),f=aS(f,sS(u,y)),I=m+(M=m-(p=sS(p,aS(c,m))))*N/z,D=y+(T=y-(f=sS(f,aS(u,y))))*N/z}t.bezierCurveTo(h,d,p,f,m,y),h=I,d=D}else t.lineTo(m,y)}c=m,u=y,g+=o}return v}var uS=function(){this.smooth=0,this.smoothConstraint=!0},hS=function(t){function e(e){var n=t.call(this,e)||this;return n.type="ec-polyline",n}return y(e,t),e.prototype.getDefaultStyle=function(){return{stroke:sg.color.neutral99,fill:null}},e.prototype.getDefaultShape=function(){return new uS},e.prototype.buildPath=function(t,e){var n=e.points,i=0,r=n.length/2;if(e.connectNulls){for(;r>0&&lS(n[2*r-2],n[2*r-1]);r--);for(;i=0){var v=a?(u-i)*g+i:(c-n)*g+n;return a?[t,v]:[v,t]}n=c,i=u;break;case o.C:c=r[l++],u=r[l++],h=r[l++],d=r[l++],p=r[l++],f=r[l++];var m=a?Yr(n,c,h,p,t,s):Yr(i,u,d,f,t,s);if(m>0)for(var y=0;y=0){v=a?jr(i,u,d,f,_):jr(n,c,h,p,_);return a?[t,v]:[v,t]}}n=p,i=f}}},e}(Yc),dS=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return y(e,t),e}(uS),pS=function(t){function e(e){var n=t.call(this,e)||this;return n.type="ec-polygon",n}return y(e,t),e.prototype.getDefaultShape=function(){return new dS},e.prototype.buildPath=function(t,e){var n=e.points,i=e.stackedOnPoints,r=0,o=n.length/2,a=e.smoothMonotone;if(e.connectNulls){for(;o>0&&lS(n[2*o-2],n[2*o-1]);o--);for(;r=0&&t<=1){var i=t*(e.length-1),r=Math.floor(i),o=Math.ceil(i),a=So(e[r]),s=So(e[o]),l=i-r,c=To([ho(mo(a[0],s[0],l)),ho(mo(a[1],s[1],l)),ho(mo(a[2],s[2],l)),po(mo(a[3],s[3],l))],"rgba");return n?{color:c,leftIndex:r,rightIndex:o,value:i}:c}}((n-i)/(e.coord-i),[t.color,e.color])}}for(var s=0;se){i?r.push(a(i,l,e)):n&&r.push(a(n,l,0),a(n,l,e));break}n&&(r.push(a(n,l,0)),n=null),r.push(l),i=l}}return r}function bS(t,e,n){var i=t.get("showAllSymbol"),r="auto"===i;if(!i||r){var o=n.getAxesByScale("ordinal")[0];if(o&&(!r||!function(t,e){var n=t.getExtent(),i=Math.abs(n[1]-n[0])/t.scale.count();isNaN(i)&&(i=0);for(var r=e.count(),o=Math.max(1,Math.round(r/5)),a=0;ai)return!1;return!0}(o,e))){var a=e.mapDimension(o.dim),s={};return Cn(o.getViewLabels(),function(t){var e=o.scale.getRawOrdinalNumber(t.tickValue);s[e]=1}),function(t){return!s.hasOwnProperty(e.get(a,t))}}}}function xS(t,e){return isNaN(t)||isNaN(e)}function wS(t,e){return[t[2*e],t[2*e+1]]}function SS(t){if(t.get(["endLabel","show"]))return!0;for(var e=0;e=0;a--){var s=t.getDimensionInfo(i[a].dimension);if("x"===(r=s&&s.coordDim)||"y"===r){o=i[a];break}}if(o){var l=e.getAxis(r),c=kn(o.stops,function(t){return{coord:l.toGlobalCoord(l.dataToCoord(t.value)),color:t.color}}),u=c.length,h=o.outerColors.slice();u&&c[0].coord>c[u-1].coord&&(c.reverse(),h.reverse());var d=_S(c,"x"===r?n.getWidth():n.getHeight()),p=d.length;if(!p&&u)return c[0].coord<0?h[1]?h[1]:c[u-1].color:h[0]?h[0]:c[0].color;var f=d[0].coord-10,g=d[p-1].coord+10,v=g-f;if(v<.001)return"transparent";Cn(d,function(t){t.offset=(t.coord-f)/v}),d.push({offset:p?d[p-1].offset:.5,color:h[1]||"transparent"}),d.unshift({offset:p?d[0].offset:.5,color:h[0]||"transparent"});var m=new _d(0,0,0,0,d,!0);return m[r]=f,m[r+"2"]=g,m}}}(o,i,n)||o.getVisual("style")[o.getVisual("drawType")];if(d&&u.type===i.type&&k===this._step){v&&!p?p=this._newPolygon(l,_):p&&!v&&(f.remove(p),p=this._polygon=null),c||this._initOrUpdateEndLabel(t,i,$f(M));var T=f.getClipPath();if(T)Nd(T,{shape:CS(this,i,!1,t).shape},t);else f.setClipPath(CS(this,i,!0,t));b&&h.updateData(o,{isIgnore:w,clipShape:C,disableAnimation:!0,getSymbolPoint:function(t){return[l[2*t],l[2*t+1]]}}),fS(this._stackedOnPoints,_)&&fS(this._points,l)||(g?this._doUpdateAnimation(o,_,i,n,k,m,x):(k&&(_&&(_=yS(_,l,i,k,x)),l=yS(l,null,i,k,x)),d.setShape({points:l}),p&&p.setShape({points:l,stackedOnPoints:_})))}else b&&h.updateData(o,{isIgnore:w,clipShape:C,disableAnimation:!0,getSymbolPoint:function(t){return[l[2*t],l[2*t+1]]}}),g&&this._initSymbolLabelAnimation(o,i,C),k&&(_&&(_=yS(_,l,i,k,x)),l=yS(l,null,i,k,x)),d=this._newPolyline(l),v?p=this._newPolygon(l,_):p&&(f.remove(p),p=this._polygon=null),c||this._initOrUpdateEndLabel(t,i,$f(M)),f.setClipPath(CS(this,i,!0,t));var I=t.getModel("emphasis"),D=I.get("focus"),A=I.get("blurScope"),P=I.get("disabled");(d.useStyle(bn(a.getLineStyle(),{fill:"none",stroke:M,lineJoin:"bevel"})),fh(d,t,"lineStyle"),d.style.lineWidth>0&&"bolder"===t.get(["emphasis","lineStyle","width"]))&&(d.getState("emphasis").style.lineWidth=+d.style.lineWidth+1);Cu(d).seriesIndex=t.seriesIndex,hh(d,D,A,P);var L=mS(t.get("smooth")),E=t.get("smoothMonotone");if(d.setShape({smooth:L,smoothMonotone:E,connectNulls:x}),p){var z=o.getCalculationInfo("stackedOnSeries"),N=0;p.useStyle(bn(s.getAreaStyle(),{fill:M,opacity:.7,lineJoin:"bevel",decal:o.getVisual("style").decal})),z&&(N=mS(z.get("smooth"))),p.setShape({smooth:L,stackedOnSmooth:N,smoothMonotone:E,connectNulls:x}),fh(p,t,"areaStyle"),Cu(p).seriesIndex=t.seriesIndex,hh(p,D,A,P)}var O=this._changePolyState;o.eachItemGraphicEl(function(t){t&&(t.onHoverStateChange=O)}),this._polyline.onHoverStateChange=O,this._data=o,this._coordSys=i,this._stackedOnPoints=_,this._points=l,this._step=k,this._valueOrigin=m,t.get("triggerLineEvent")&&(this.packEventData(t,d),p&&this.packEventData(t,p))},e.prototype.packEventData=function(t,e){Cu(e).eventData={componentType:"series",componentSubType:"line",componentIndex:t.componentIndex,seriesIndex:t.seriesIndex,seriesName:t.name,seriesType:"line"}},e.prototype.highlight=function(t,e,n,i){var r=t.getData(),o=tl(r,i);if(this._changePolyState("emphasis"),!(o instanceof Array)&&null!=o&&o>=0){var a=r.getLayout("points"),s=r.getItemGraphicEl(o);if(!s){var l=a[2*o],c=a[2*o+1];if(isNaN(l)||isNaN(c))return;if(this._clipShapeForSymbol&&!this._clipShapeForSymbol.contain(l,c))return;var u=t.get("zlevel")||0,h=t.get("z")||0;(s=new Qw(r,o)).x=l,s.y=c,s.setZ(u,h);var d=s.getSymbolPath().getTextContent();d&&(d.zlevel=u,d.z=h,d.z2=this._polyline.z2+1),s.__temp=!0,r.setItemGraphicEl(o,s),s.stopSymbolAnimation(!0),this.group.add(s)}s.highlight()}else Xm.prototype.highlight.call(this,t,e,n,i)},e.prototype.downplay=function(t,e,n,i){var r=t.getData(),o=tl(r,i);if(this._changePolyState("normal"),null!=o&&o>=0){var a=r.getItemGraphicEl(o);a&&(a.__temp?(r.setItemGraphicEl(o,null),this.group.remove(a)):a.downplay())}else Xm.prototype.downplay.call(this,t,e,n,i)},e.prototype._changePolyState=function(t){var e=this._polygon;ju(this._polyline,t),e&&ju(e,t)},e.prototype._newPolyline=function(t){var e=this._polyline;return e&&this._lineGroup.remove(e),e=new hS({shape:{points:t},segmentIgnoreThreshold:2,z2:10}),this._lineGroup.add(e),this._polyline=e,e},e.prototype._newPolygon=function(t,e){var n=this._polygon;return n&&this._lineGroup.remove(n),n=new pS({shape:{points:t,stackedOnPoints:e},segmentIgnoreThreshold:2}),this._lineGroup.add(n),this._polygon=n,n},e.prototype._initSymbolLabelAnimation=function(t,e,n){var i,r,o=e.getBaseAxis(),a=o.inverse;"cartesian2d"===e.type?(i=o.isHorizontal(),r=!1):"polar"===e.type&&(i="angle"===o.dim,r=!0);var s=t.hostModel,l=s.get("animationDuration");Ln(l)&&(l=l(null));var c=s.get("animationDelay")||0,u=Ln(c)?c(null):c;t.eachItemGraphicEl(function(t,o){var s=t;if(s){var h=[t.x,t.y],d=void 0,p=void 0,f=void 0;if(n)if(r){var g=n,v=e.pointToCoord(h);i?(d=g.startAngle,p=g.endAngle,f=-v[1]/180*Math.PI):(d=g.r0,p=g.r,f=v[0])}else{var m=n;i?(d=m.x,p=m.x+m.width,f=t.x):(d=m.y+m.height,p=m.y,f=t.y)}var y=p===d?0:(f-d)/(p-d);a&&(y=1-y);var _=Ln(c)?c(o):l*y+u,b=s.getSymbolPath(),x=b.getTextContent();s.attr({scaleX:0,scaleY:0}),s.animateTo({scaleX:1,scaleY:1},{duration:200,setToFinal:!0,delay:_}),x&&x.animateFrom({style:{opacity:0}},{duration:300,delay:_}),b.disableLabelAnimation=!0}})},e.prototype._initOrUpdateEndLabel=function(t,e,n){var i=t.getModel("endLabel");if(SS(t)){var r=t.getData(),o=this._polyline,a=r.getLayout("points");if(!a)return o.removeTextContent(),void(this._endLabel=null);var s=this._endLabel;s||((s=this._endLabel=new hu({z2:200})).ignoreClip=!0,o.setTextContent(this._endLabel),o.disableLabelAnimation=!0);var l=function(t){for(var e=t.length/2;e>0&&xS(t[2*e-2],t[2*e-1]);e--);return e-1}(a);l>=0&&(_p(o,bp(t,"endLabel"),{inheritColor:n,labelFetcher:t,labelDataIndex:l,defaultText:function(t,e,n){return null!=n?function(t,e){var n=t.mapDimensionsAll("defaultedLabel");if(!Pn(e))return e+"";for(var i=[],r=0;r=0&&i.push(e[o])}return i.join(" ")}(r,n):Kw(r,t)},enableTextSetter:!0},function(t,e){var n=e.getBaseAxis(),i=n.isHorizontal(),r=n.inverse,o=i?r?"right":"left":"center",a=i?"middle":r?"top":"bottom";return{normal:{align:t.get("align")||o,verticalAlign:t.get("verticalAlign")||a}}}(i,e)),o.textConfig.position=null)}else this._endLabel&&(this._polyline.removeTextContent(),this._endLabel=null)},e.prototype._endLabelOnDuring=function(t,e,n,i,r,o,a){var s=this._endLabel,l=this._polyline;if(s){t<1&&null==i.originalX&&(i.originalX=s.x,i.originalY=s.y);var c=n.getLayout("points"),u=n.hostModel,h=u.get("connectNulls"),d=o.get("precision"),p=o.get("distance")||0,f=a.getBaseAxis(),g=f.isHorizontal(),v=f.inverse,m=e.shape,y=v?g?m.x:m.y+m.height:g?m.x+m.width:m.y,_=(g?p:0)*(v?-1:1),b=(g?0:-p)*(v?-1:1),x=g?"x":"y",w=function(t,e,n){for(var i,r,o=t.length/2,a="x"===n?0:1,s=0,l=-1,c=0;c=e||i>=e&&r<=e){l=c;break}s=c,i=r}else i=r;return{range:[s,l],t:(e-i)/(r-i)}}(c,y,x),S=w.range,C=S[1]-S[0],k=void 0;if(C>=1){if(C>1&&!h){var M=wS(c,S[0]);s.attr({x:M[0]+_,y:M[1]+b}),r&&(k=u.getRawValue(S[0]))}else{(M=l.getPointOn(y,x))&&s.attr({x:M[0]+_,y:M[1]+b});var T=u.getRawValue(S[0]),I=u.getRawValue(S[1]);r&&(k=function(t,e,n,i,r){var o=null==e||"auto"===e;if(null==i)return i;if(Nn(i))return ks(f=Bs(n||0,i,r),o?Math.max(Ms(n||0),Ms(i)):e);if(En(i))return r<1?n:i;for(var a=[],s=n,l=i,c=Math.max(s?s.length:0,l.length),u=0;u0?S[0]:0;M=wS(c,D);r&&(k=u.getRawValue(D)),s.attr({x:M[0]+_,y:M[1]+b})}if(r){var A=Dp(s);"function"==typeof A.setLabelText&&A.setLabelText(k)}}},e.prototype._doUpdateAnimation=function(t,e,n,i,r,o,a){var s=this._polyline,l=this._polygon,c=t.hostModel,u=function(t,e,n,i,r,o,a){for(var s=function(t,e){var n=[];return e.diff(t).add(function(t){n.push({cmd:"+",idx:t})}).update(function(t,e){n.push({cmd:"=",idx:e,idx1:t})}).remove(function(t){n.push({cmd:"-",idx:t})}).execute(),n}(t,e),l=[],c=[],u=[],h=[],d=[],p=[],f=[],g=rS(r,e,a),v=t.getLayout("points")||[],m=e.getLayout("points")||[],y=0;y3e3||l&&vS(d,f)>3e3)return s.stopAnimation(),s.setShape({points:p}),void(l&&(l.stopAnimation(),l.setShape({points:p,stackedOnPoints:f})));s.shape.__points=u.current,s.shape.points=h;var g={shape:{points:p}};u.current!==h&&(g.shape.__points=u.next),s.stopAnimation(),zd(s,g,c),l&&(l.setShape({points:h,stackedOnPoints:d}),l.stopAnimation(),zd(l,{shape:{stackedOnPoints:f}},c),s.shape.points!==l.shape.points&&(l.shape.points=s.shape.points));for(var v=[],m=u.status,y=0;ye&&(e=t[n]);return isFinite(e)?e:NaN},min:function(t){for(var e=1/0,n=0;ne[1]&&e.reverse(),e},e.prototype.pointToData=function(t,e){return this.coordToData(this.toLocalCoord(t["x"===this.dim?0:1]),e)},e.prototype.setCategorySortInfo=function(t){if("category"!==this.type)return!1;this.model.option.categorySortInfo=t,this.scale.setSortInfo(t)},e}(Fw),US="expandAxisBreak",GS=Math.PI,qS=[[1,2,1,2],[5,3,5,3],[8,3,8,3]],jS=[[0,1,0,1],[0,3,0,3],[0,3,0,3]],XS=el(),YS=el(),ZS=function(){function t(t){this.recordMap={},this.resolveAxisNameOverlap=t}return t.prototype.ensureRecord=function(t){var e=t.axis.dim,n=t.componentIndex,i=this.recordMap,r=i[e]||(i[e]=[]);return r[n]||(r[n]={ready:{}})},t}();var KS=[1,0,0,1,0,0],QS=new cr(0,0,0,0),JS=function(t,e,n,i,r,o){if(fw(t.nameLocation)){var a=o.stOccupiedRect;a&&tC(function(t,e,n){return t.transform=dp(t.transform,n),t.localRect=hp(t.localRect,e),t.rect=hp(t.rect,e),n&&t.rect.applyTransform(n),t.axisAligned=cp(n),t.obb=void 0,(t.label=t.label||{}).ignore=!1,t}({},a,o.transGroup.transform),i,r)}else eC(o.labelInfoList,o.dirVec,i,r)};function tC(t,e,n){var i=new Yi;Yw(t,e,i,{direction:Math.atan2(n.y,n.x),bidirectional:!1,touchThreshold:.05})&&function(t,e){if(t){t.label.x+=e.x,t.label.y+=e.y,t.label.markRedraw();var n=t.transform;n&&(n[4]+=e.x,n[5]+=e.y);var i=t.rect;i&&(i.x+=e.x,i.y+=e.y);var r=t.obb;r&&r.fromBoundingRect(t.localRect,n)}}(e,i)}function eC(t,e,n,i){for(var r=Yi.dot(i,e)>=0,o=0,a=t.length;o0?"top":"bottom",i="center"):Ds(o-GS)?(r=n>0?"bottom":"top",i="center"):(r="middle",i=o>0&&o0?"right":"left":n>0?"left":"right"),{rotation:o,textAlign:i,textVerticalAlign:r}},t.makeAxisEventDataBase=function(t){var e={componentType:t.mainType,componentIndex:t.componentIndex};return e[t.mainType+"Index"]=t.componentIndex,e},t.isLabelSilent=function(t){var e=t.get("tooltip");return t.get("silent")||!(t.get("triggerEvent")||e&&e.show)},t}(),iC=["axisLine","axisTickLabelEstimate","axisTickLabelDetermine","axisName"],rC={axisLine:function(t,e,n,i,r,o,a){var s=i.get(["axisLine","show"]);if("auto"===s&&(s=!0,null!=t.raw.axisLineAutoShow&&(s=!!t.raw.axisLineAutoShow)),s){var l=i.axis.getExtent(),c=o.transform,u=[l[0],0],h=[l[1],0],d=u[0]>h[0];c&&(gi(u,u,c),gi(h,h,c));var p=_n({lineCap:"round"},i.getModel(["axisLine","lineStyle"]).getLineStyle()),f={strokeContainThreshold:t.raw.strokeContainThreshold||5,silent:!0,z2:1,style:p};if(i.get(["axisLine","breakLine"])&&i.axis.scale.hasBreaks())null.buildAxisBreakLine(i,r,o,f);else{var g=new ud(_n({shape:{x1:u[0],y1:u[1],x2:h[0],y2:h[1]}},f));Yd(g.shape,g.style.lineWidth),g.anid="line",r.add(g)}var v=i.get(["axisLine","symbol"]);if(null!=v){var m=i.get(["axisLine","symbolSize"]);En(v)&&(v=[v,v]),(En(m)||Nn(m))&&(m=[m,m]);var y=n_(i.get(["axisLine","symbolOffset"])||0,m),_=m[0],b=m[1];Cn([{rotate:t.rotation+Math.PI/2,offset:y[0],r:0},{rotate:t.rotation-Math.PI/2,offset:y[1],r:Math.sqrt((u[0]-h[0])*(u[0]-h[0])+(u[1]-h[1])*(u[1]-h[1]))}],function(e,n){if("none"!==v[n]&&null!=v[n]){var i=e_(v[n],-_/2,-b/2,_,b,p.stroke,!0),o=e.r+e.offset,a=d?h:u;i.attr({rotation:e.rotate,x:a[0]+o*Math.cos(t.rotation),y:a[1]-o*Math.sin(t.rotation),silent:!0,z2:11}),r.add(i)}})}}},axisTickLabelEstimate:function(t,e,n,i,r,o,a,s){lC(e,r,s)&&oC(t,e,n,i,r,o,a,Sw)},axisTickLabelDetermine:function(t,e,n,i,r,o,a,s){lC(e,r,s)&&oC(t,e,n,i,r,o,a,Cw);var l=function(t,e,n,i){var r=i.axis,o=i.getModel("axisTick"),a=o.get("show");"auto"===a&&(a=!0,null!=t.raw.axisTickAutoShow&&(a=!!t.raw.axisTickAutoShow));if(!a||r.scale.isBlank())return[];for(var s=o.getModel("lineStyle"),l=t.tickDirection*o.get("length"),c=sC(r.getTicksCoords(),n.transform,l,bn(s.getLineStyle(),{stroke:i.get(["axisLine","lineStyle","color"])}),"ticks"),u=0;ui[1],l="start"===e&&!s||"start"!==e&&s;Ds(a-GS/2)?(o=l?"bottom":"top",r="center"):Ds(a-1.5*GS)?(o=l?"top":"bottom",r="center"):(o="middle",r=a<1.5*GS&&a>GS/2?l?"left":"right":l?"right":"left");return{rotation:a,textAlign:r,textVerticalAlign:o}}(t.rotation,u,x||0,f),null!=(b=t.raw.axisNameAvailableWidth)&&(b=Math.abs(b/Math.sin(_.rotation)),!isFinite(b)&&(b=null)));var w=d.getFont(),S=i.get("nameTruncate",!0)||{},C=S.ellipsis,k=Vn(t.raw.nameTruncateMaxWidth,S.maxWidth,b),M=s.nameMarginLevel||0,T=new hu({x:v.x,y:v.y,rotation:_.rotation,silent:nC.isLabelSilent(i),style:xp(d,{text:c,font:w,overflow:"truncate",width:k,ellipsis:C,fill:d.getTextColor()||i.get(["axisLine","lineStyle","color"]),align:d.get("align")||_.textAlign,verticalAlign:d.get("verticalAlign")||_.textVerticalAlign}),z2:1});if(ap({el:T,componentModel:i,itemName:c}),T.__fullText=c,T.anid="name",i.get("triggerEvent")){var I=nC.makeAxisEventDataBase(i);I.targetType="axisName",I.name=c,Cu(T).eventData=I}o.add(T),T.updateTransform(),e.nameEl=T;var D=l.nameLayout=Gw({label:T,priority:T.z2,defaultAttr:{ignore:T.ignore},marginDefault:fw(u)?qS[M]:jS[M]});if(l.nameLocation=u,r.add(T),T.decomposeTransform(),t.shouldNameMoveOverlap&&D){var A=n.ensureRecord(i);n.resolveAxisNameOverlap(t,n,i,D,m,A)}}}};function oC(t,e,n,i,r,o,a,s){cC(e)||function(t,e,n,i,r,o){var a=r.axis,s=Vn(t.raw.axisLabelShow,r.get(["axisLabel","show"])),l=new ds;n.add(l);var c=kw(i);if(!s||a.scale.isBlank())return void uC(e,[],l,c);var u=r.getModel("axisLabel"),h=a.getViewLabels(c),d=(Vn(t.raw.labelRotate,u.get("rotate"))||0)*GS/180,p=nC.innerTextLayout(t.rotation,d,t.labelDirection),f=r.getCategories&&r.getCategories(!0),g=[],v=r.get("triggerEvent"),m=1/0,y=-1/0;Cn(h,function(t,e){var n,i="ordinal"===a.scale.type?a.scale.getRawOrdinalNumber(t.tickValue):t.tickValue,s=t.formattedLabel,c=t.rawLabel,d=u;if(f&&f[i]){var _=f[i];On(_)&&_.textStyle&&(d=new Bp(_.textStyle,u,r.ecModel))}var b=d.getTextColor()||r.get(["axisLine","lineStyle","color"]),x=d.getShallow("align",!0)||p.textAlign,w=Wn(d.getShallow("alignMinLabel",!0),x),S=Wn(d.getShallow("alignMaxLabel",!0),x),C=d.getShallow("verticalAlign",!0)||d.getShallow("baseline",!0)||p.textVerticalAlign,k=Wn(d.getShallow("verticalAlignMinLabel",!0),C),M=Wn(d.getShallow("verticalAlignMaxLabel",!0),C),T=10+((null===(n=t.time)||void 0===n?void 0:n.level)||0);m=Math.min(m,T),y=Math.max(y,T);var I=new hu({x:0,y:0,rotation:0,silent:nC.isLabelSilent(r),z2:T,style:xp(d,{text:s,align:0===e?w:e===h.length-1?S:x,verticalAlign:0===e?k:e===h.length-1?M:C,fill:Ln(b)?b("category"===a.type?c:"value"===a.type?i+"":i,e):b})});I.anid="label_"+i;var D=XS(I);if(D.break=t.break,D.tickValue=i,D.layoutRotation=p.rotation,ap({el:I,componentModel:r,itemName:s,formatterParamsExtra:{isTruncated:function(){return I.isTruncated},value:c,tickIndex:e}}),v){var A=nC.makeAxisEventDataBase(r);A.targetType="axisLabel",A.value=c,A.tickIndex=e,t.break&&(A.break={start:t.break.parsedBreak.vmin,end:t.break.parsedBreak.vmax}),"category"===a.type&&(A.dataIndex=i),Cu(I).eventData=A,t.break&&function(t,e,n,i){n.on("click",function(n){var r={type:US,breaks:[{start:i.parsedBreak.breakOption.start,end:i.parsedBreak.breakOption.end}]};r[t.axis.dim+"AxisIndex"]=t.componentIndex,e.dispatchAction(r)})}(r,o,I,t.break)}g.push(I),l.add(I)});var _=kn(g,function(t){return{label:t,priority:XS(t).break?t.z2+(y-m+1):t.z2,defaultAttr:{ignore:t.ignore}}});uC(e,_,l,c)}(t,e,r,s,i,a);var l=e.labelLayoutList;!function(t,e,n,i){var r=e.get(["axisLabel","margin"]);Cn(n,function(n,o){var a=Gw(n);if(a){var s=a.label,l=XS(s);a.suggestIgnore=s.ignore,s.ignore=!1,Va(hC,dC),hC.x=e.axis.dataToCoord(l.tickValue),hC.y=t.labelOffset+t.labelDirection*r,hC.rotation=l.layoutRotation,i.add(hC),hC.updateTransform(),i.remove(hC),hC.decomposeTransform(),Va(s,hC),s.markRedraw(),Ww(a,!0),Gw(a)}})}(t,i,l,o),t.rotation;var c=t.optionHideOverlap;!function(t,e,n){if(dw(t.axis))return;function i(t,i,r){var o=Gw(e[i]),a=Gw(e[r]);if(o&&a)if(!1===t||o.suggestIgnore)aC(o.label);else if(a.suggestIgnore)aC(a.label);else{var s=.1;if(!n){var l=[0,0,0,0];o=jw({marginForce:l},o),a=jw({marginForce:l},a)}Yw(o,a,null,{touchThreshold:s})&&aC(t?a.label:o.label)}}var r=t.get(["axisLabel","showMinLabel"]),o=t.get(["axisLabel","showMaxLabel"]),a=e.length;i(r,0,1),i(o,a-1,a-2)}(i,l,c),c&&function(t){var e=[];function n(t){if(!t.ignore){var e=t.ensureState("emphasis");null==e.ignore&&(e.ignore=!1)}t.ignore=!0}t.sort(function(t,e){return(e.suggestIgnore?1:0)-(t.suggestIgnore?1:0)||e.priority-t.priority});for(var i=0;i.1?"x":"y",u=a.transGroup[c];if(s.sort(function(t,e){return Math.abs(t.label[c]-u)-Math.abs(e.label[c]-u)}),l&&r){var h=o.getExtent(),d=Math.min(h[0],h[1]),p=Math.max(h[0],h[1])-d;r.union(new cr(d,0,p,1))}a.stOccupiedRect=r,a.labelInfoList=s}(t,n,i,l)}function aC(t){t&&(t.ignore=!0)}function sC(t,e,n,i,r){for(var o=[],a=[],s=[],l=0;l=0;o--){var a=t[+n[o]],s=a.model,l=a.scale;yx(l)&&s.get("alignTicks")&&null==s.get("interval")?r.push(a):(lw(l,s),yx(l)&&(e=a))}r.length&&(e||lw((e=r.pop()).scale,e.model),Cn(r,function(t){!function(t,e,n){var i=Nx.prototype,r=i.getTicks.call(n),o=i.getTicks.call(n,{expandToNicedExtent:!0}),a=r.length-1,s=i.getInterval.call(n),l=sw(t,e),c=l.extent,u=l.fixMin,h=l.fixMax;"log"===t.type&&(c=Ix(t.base,c,!0)),t.setBreaksFromOption(vw(e)),t.setExtent(c[0],c[1]),t.calcNiceExtent({splitNumber:a,fixMin:u,fixMax:h});var d=i.getExtent.call(t);u&&(c[0]=d[0]),h&&(c[1]=d[1]);var p=i.getInterval.call(t),f=c[0],g=c[1];if(u&&h)p=(g-f)/a;else if(u)for(g=c[0]+p*a;gc[0]&&isFinite(f)&&isFinite(c[0]);)p=bx(p),f=c[1]-p*a;else{t.getTicks().length-1>a&&(p=bx(p));var v=p*a;(f=ks((g=Math.ceil(c[1]/p)*p)-v))<0&&c[0]>=0?(f=0,g=ks(v)):g>0&&c[1]<=0&&(g=0,f=-ks(v))}var m=(r[0].value-o[0].value)/s,y=(r[a].value-o[a].value)/s;i.setExtent.call(t,f+p*m,g+p*y),i.setInterval.call(t,p),(m||y)&&i.setNiceExtent.call(t,f+p,g-p)}(t.scale,t.model,e.scale)}))}}this._updateScale(t,this.model),i(n.x),i(n.y);var r={};Cn(n.x,function(t){_C(n,"y",t,r)}),Cn(n.y,function(t){_C(n,"x",t,r)}),this.resize(this.model,e)},t.prototype.resize=function(t,e,n){var i=eg(t,e),r=this._rect=Jf(t.getBoxLayoutParams(),i.refContainer),o=this._axesMap,a=this._coordsList,s=t.get("containLabel");if(xC(o,r),!n){var l=function(t,e,n,i,r){var o=new ZS(kC);return Cn(n,function(n){return Cn(n,function(n){if(gw(n.model)){var a=!i;n.axisBuilder=function(t,e,n,i,r,o){for(var a=fC(t,n),s=!1,l=!1,c=0;c0&&i>0||n<0&&i<0)}(t)}function xC(t,e){Cn(t.x,function(t){return wC(t,e.x,e.width)}),Cn(t.y,function(t){return wC(t,e.y,e.height)})}function wC(t,e,n){var i=[0,n],r=t.inverse?1:0;t.setExtent(i[r],i[1-r]),function(t,e){var n=t.getExtent(),i=n[0]+n[1];t.toGlobalCoord="x"===t.dim?function(t){return t+e}:function(t){return i-t+e},t.toLocalCoord="x"===t.dim?function(t){return t-e}:function(t){return i-t+e}}(t,e)}function SC(t,e,n,i,r,o,a){CC(i,r,Sw,e,!1,a);var s=[0,0,0,0];c(0),c(1),u(i,0,NaN),u(i,1,NaN);var l=null==function(t,e,n){if(t&&e)for(var i=0,r=t.length;i0});return ip(i,s,!0,!0,n),xC(r,i),l;function c(t){Cn(r[Fd[t]],function(e){if(gw(e.model)){var n=o.ensureRecord(e.model),i=n.labelInfoList;if(i)for(var r=0;r0&&!Bn(e)&&e>1e-4&&(t/=e),t}}function CC(t,e,n,i,r,o){var a=n===Cw;Cn(e,function(e){return Cn(e,function(e){gw(e.model)&&(!function(t,e,n){var i=fC(e,n);t.updateCfg(i)}(e.axisBuilder,t,e.model),e.axisBuilder.build(a?{axisTickLabelDetermine:!0}:{axisTickLabelEstimate:!0},{noPxChange:r}))})});var s={x:0,y:0};function l(e){s[Fd[1-e]]=t[Bd[e]]<=.5*o.refContainer[Bd[e]]?0:1-e==1?2:1}l(0),l(1),Cn(e,function(t,e){return Cn(t,function(t){gw(t.model)&&(("all"===i||a)&&t.axisBuilder.build({axisName:!0},{nameMarginLevel:s[e]}),a&&t.axisBuilder.build({axisLine:!0}))})})}var kC=function(t,e,n,i,r,o){var a="x"===n.axis.dim?"y":"x";JS(t,0,0,i,r,o),fw(t.nameLocation)||Cn(e.recordMap[a],function(t){t&&t.labelInfoList&&t.dirVec&&eC(t.labelInfoList,t.dirVec,i,r)})};function MC(t,e){var n={axesInfo:{},seriesInvolved:!1,coordSysAxesInfo:{},coordSysMap:{}};return function(t,e,n){var i=e.getComponent("tooltip"),r=e.getComponent("axisPointer"),o=r.get("link",!0)||[],a=[];Cn(n.getCoordinateSystems(),function(n){if(n.axisPointerEnabled){var s=AC(n.model),l=t.coordSysAxesInfo[s]={};t.coordSysMap[s]=n;var c=n.model.getModel("tooltip",i);if(Cn(n.getAxes(),An(p,!1,null)),n.getTooltipAxes&&i&&c.get("show")){var u="axis"===c.get("trigger"),h="cross"===c.get(["axisPointer","type"]),d=n.getTooltipAxes(c.get(["axisPointer","axis"]));(u||h)&&Cn(d.baseAxes,An(p,!h||"cross",u)),h&&Cn(d.otherAxes,An(p,"cross",!1))}}function p(i,s,u){var h=u.model.getModel("axisPointer",r),d=h.get("show");if(d&&("auto"!==d||i||DC(h))){null==s&&(s=h.get("triggerTooltip")),h=i?function(t,e,n,i,r,o){var a=e.getModel("axisPointer"),s={};Cn(["type","snap","lineStyle","shadowStyle","label","animation","animationDurationUpdate","animationEasingUpdate","z"],function(t){s[t]=mn(a.get(t))}),s.snap="category"!==t.type&&!!o,"cross"===a.get("type")&&(s.type="line");var l=s.label||(s.label={});if(null==l.show&&(l.show=!1),"cross"===r){var c=a.get(["label","show"]);if(l.show=null==c||c,!o){var u=s.lineStyle=a.get("crossStyle");u&&bn(l,u.textStyle)}}return t.model.getModel("axisPointer",new Bp(s,n,i))}(u,c,r,e,i,s):h;var p=h.get("snap"),f=h.get("triggerEmphasis"),g=AC(u.model),v=s||p||"category"===u.type,m=t.axesInfo[g]={key:g,axis:u,coordSys:n,axisPointerModel:h,triggerTooltip:s,triggerEmphasis:f,involveSeries:v,snap:p,useHandle:DC(h),seriesModels:[],linkGroup:null};l[g]=m,t.seriesInvolved=t.seriesInvolved||v;var y=function(t,e){for(var n=e.model,i=e.dim,r=0;r=0||t===e}function IC(t){var e=(t.ecModel.getComponent("axisPointer")||{}).coordSysAxesInfo;return e&&e.axesInfo[AC(t)]}function DC(t){return!!t.get(["handle","show"])}function AC(t){return t.type+"||"+t.id}var PC={},LC=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return y(e,t),e.prototype.render=function(e,n,i,r){this.axisPointerClass&&function(t){var e=IC(t);if(e){var n=e.axisPointerModel,i=e.axis.scale,r=n.option,o=n.get("status"),a=n.get("value");null!=a&&(a=i.parse(a));var s=DC(n);null==o&&(r.status=s?"show":"hide");var l=i.getExtent().slice();l[0]>l[1]&&l.reverse(),(null==a||a>l[1])&&(a=l[1]),aa)return!0;if(o){var s=IC(t).seriesDataCount,l=i.getExtent();return Math.abs(l[0]-l[1])/s>a}return!1}return!0===n},t.prototype.makeElOption=function(t,e,n,i,r){},t.prototype.createPointerEl=function(t,e,n,i){var r=e.pointer;if(r){var o=VC(t).pointerEl=new vp[r.type](WC(e.pointer));t.add(o)}},t.prototype.createLabelEl=function(t,e,n,i){if(e.label){var r=VC(t).labelEl=new hu(WC(e.label));t.add(r),XC(r,i)}},t.prototype.updatePointerEl=function(t,e,n){var i=VC(t).pointerEl;i&&e.pointer&&(i.setStyle(e.pointer.style),n(i,{shape:e.pointer.shape}))},t.prototype.updateLabelEl=function(t,e,n,i){var r=VC(t).labelEl;r&&(r.setStyle(e.label.style),n(r,{x:e.label.x,y:e.label.y}),XC(r,i))},t.prototype._renderHandle=function(t){if(!this._dragging&&this.updateHandleTransform){var e,n=this._axisPointerModel,i=this._api.getZr(),r=this._handle,o=n.getModel("handle"),a=n.get("status");if(!o.get("show")||!a||"hide"===a)return r&&i.remove(r),void(this._handle=null);this._handle||(e=!0,r=this._handle=tp(o.get("icon"),{cursor:"move",draggable:!0,onmousemove:function(t){Hi(t.event)},onmousedown:UC(this._onHandleDragMove,this,0,0),drift:UC(this._onHandleDragMove,this),ondragend:UC(this._onHandleDragEnd,this)}),i.add(r)),ZC(r,n,!1),r.setStyle(o.getItemStyle(null,["color","borderColor","borderWidth","opacity","shadowColor","shadowBlur","shadowOffsetX","shadowOffsetY"]));var s=o.get("size");Pn(s)||(s=[s,s]),r.scaleX=s[0]/2,r.scaleY=s[1]/2,ry(this,"_doDispatchAxisPointer",o.get("throttle")||0,"fixRate"),this._moveHandleToValue(t,e)}},t.prototype._moveHandleToValue=function(t,e){qC(this._axisPointerModel,!e&&this._moveAnimation,this._handle,YC(this.getHandleTransform(t,this._axisModel,this._axisPointerModel)))},t.prototype._onHandleDragMove=function(t,e){var n=this._handle;if(n){this._dragging=!0;var i=this.updateHandleTransform(YC(n),[t,e],this._axisModel,this._axisPointerModel);this._payloadInfo=i,n.stopAnimation(),n.attr(YC(i)),VC(n).lastProp=null,this._doDispatchAxisPointer()}},t.prototype._doDispatchAxisPointer=function(){if(this._handle){var t=this._payloadInfo,e=this._axisModel;this._api.dispatchAction({type:"updateAxisPointer",x:t.cursorPoint[0],y:t.cursorPoint[1],tooltipOption:t.tooltipOption,axesInfo:[{axisDim:e.axis.dim,axisIndex:e.componentIndex}]})}},t.prototype._onHandleDragEnd=function(){if(this._dragging=!1,this._handle){var t=this._axisPointerModel.get("value");this._moveHandleToValue(t),this._api.dispatchAction({type:"hideTip"})}},t.prototype.clear=function(t){this._lastValue=null,this._lastStatus=null;var e=t.getZr(),n=this._group,i=this._handle;e&&n&&(this._lastGraphicKey=null,n&&e.remove(n),i&&e.remove(i),this._group=null,this._handle=null,this._payloadInfo=null),oy(this,"_doDispatchAxisPointer")},t.prototype.doClear=function(){},t.prototype.buildLabel=function(t,e,n){return{x:t[n=n||0],y:t[1-n],width:e[n],height:e[1-n]}},t}();function qC(t,e,n,i){jC(VC(n).lastProp,i)||(VC(n).lastProp=i,e?zd(n,i,t):(n.stopAnimation(),n.attr(i)))}function jC(t,e){if(On(t)&&On(e)){var n=!0;return Cn(e,function(e,i){n=n&&jC(t[i],e)}),!!n}return t===e}function XC(t,e){t[e.get(["label","show"])?"show":"hide"]()}function YC(t){return{x:t.x||0,y:t.y||0,rotation:t.rotation||0}}function ZC(t,e,n){var i=e.get("z"),r=e.get("zlevel");t&&t.traverse(function(t){"group"!==t.type&&(null!=i&&(t.z=i),null!=r&&(t.zlevel=r),t.silent=n)})}function KC(t,e,n,i,r){var o=QC(n.get("value"),e.axis,e.ecModel,n.get("seriesDataIndices"),{precision:n.get(["label","precision"]),formatter:n.get(["label","formatter"])}),a=n.getModel("label"),s=Ef(a.get("padding")||0),l=a.getFont(),c=Ya(o,l),u=r.position,h=c.width+s[1]+s[3],d=c.height+s[0]+s[2],p=r.align;"right"===p&&(u[0]-=h),"center"===p&&(u[0]-=h/2);var f=r.verticalAlign;"bottom"===f&&(u[1]-=d),"middle"===f&&(u[1]-=d/2),function(t,e,n,i){var r=i.getWidth(),o=i.getHeight();t[0]=Math.min(t[0]+e,r)-e,t[1]=Math.min(t[1]+n,o)-n,t[0]=Math.max(t[0],0),t[1]=Math.max(t[1],0)}(u,h,d,i);var g=a.get("backgroundColor");g&&"auto"!==g||(g=e.get(["axisLine","lineStyle","color"])),t.label={x:u[0],y:u[1],style:xp(a,{text:o,font:l,fill:a.getTextColor(),padding:s,backgroundColor:g}),z2:10}}function QC(t,e,n,i,r){t=e.scale.parse(t);var o=e.scale.getLabel({value:t},{precision:r.precision}),a=r.formatter;if(a){var s={value:uw(e,{value:t}),axisDimension:e.dim,axisIndex:e.index,seriesData:[]};Cn(i,function(t){var e=n.getSeriesByIndex(t.seriesIndex),i=t.dataIndexInside,r=e&&e.getDataParams(i);r&&s.seriesData.push(r)}),En(a)?o=a.replace("{value}",o):Ln(a)&&(o=a(s))}return o}function JC(t,e,n){var i=[1,0,0,1,0,0];return ji(i,i,n.rotation),qi(i,i,n.position),Kd([t.dataToCoord(e),(n.labelOffset||0)+(n.labelDirection||1)*(n.labelMargin||0)],i)}var tk=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return y(e,t),e.prototype.makeElOption=function(t,e,n,i,r){var o=n.axis,a=o.grid,s=i.get("type"),l=ek(a,o).getOtherAxis(o).getGlobalExtent(),c=o.toGlobalCoord(o.dataToCoord(e,!0));if(s&&"none"!==s){var u=function(t){var e,n=t.get("type"),i=t.getModel(n+"Style");return"line"===n?(e=i.getLineStyle()).fill=null:"shadow"===n&&((e=i.getAreaStyle()).stroke=null),e}(i),h=nk[s](o,c,l);h.style=u,t.graphicKey=h.type,t.pointer=h}!function(t,e,n,i,r,o){var a=nC.innerTextLayout(n.rotation,0,n.labelDirection);n.labelMargin=r.get(["label","margin"]),KC(e,i,r,o,{position:JC(i.axis,t,n),align:a.textAlign,verticalAlign:a.textVerticalAlign})}(e,t,fC(a.getRect(),n),n,i,r)},e.prototype.getHandleTransform=function(t,e,n){var i=fC(e.axis.grid.getRect(),e,{labelInside:!1});i.labelMargin=n.get(["handle","margin"]);var r=JC(e.axis,t,i);return{x:r[0],y:r[1],rotation:i.rotation+(i.labelDirection<0?Math.PI:0)}},e.prototype.updateHandleTransform=function(t,e,n,i){var r=n.axis,o=r.grid,a=r.getGlobalExtent(!0),s=ek(o,r).getOtherAxis(r).getGlobalExtent(),l="x"===r.dim?0:1,c=[t.x,t.y];c[l]+=e[l],c[l]=Math.min(a[1],c[l]),c[l]=Math.max(a[0],c[l]);var u=(s[1]+s[0])/2,h=[u,u];h[l]=c[l];return{x:c[0],y:c[1],rotation:t.rotation,cursorPoint:h,tooltipOption:[{verticalAlign:"middle"},{align:"center"}][l]}},e}(GC);function ek(t,e){var n={};return n[e.dim+"AxisIndex"]=e.index,t.getCartesian(n)}var nk={line:function(t,e,n){var i,r,o;return{type:"Line",subPixelOptimize:!0,shape:(i=[e,n[0]],r=[e,n[1]],o=ik(t),{x1:i[o=o||0],y1:i[1-o],x2:r[o],y2:r[1-o]})}},shadow:function(t,e,n){var i,r,o,a=Math.max(1,t.getBandWidth()),s=n[1]-n[0];return{type:"Rect",shape:(i=[e-a/2,n[0]],r=[a,s],o=ik(t),{x:i[o=o||0],y:i[1-o],width:r[o],height:r[1-o]})}}};function ik(t){return"x"===t.dim?0:1}var rk=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return y(e,t),e.type="axisPointer",e.defaultOption={show:"auto",z:50,type:"line",snap:!1,triggerTooltip:!0,triggerEmphasis:!0,value:null,status:null,link:[],animation:null,animationDurationUpdate:200,lineStyle:{color:sg.color.border,width:1,type:"dashed"},shadowStyle:{color:sg.color.shadowTint},label:{show:!0,formatter:null,precision:"auto",margin:3,color:sg.color.neutral00,padding:[5,7,5,7],backgroundColor:sg.color.accent60,borderColor:null,borderWidth:0,borderRadius:3},handle:{show:!1,icon:"M10.7,11.9v-1.3H9.3v1.3c-4.9,0.3-8.8,4.4-8.8,9.4c0,5,3.9,9.1,8.8,9.4h1.3c4.9-0.3,8.8-4.4,8.8-9.4C19.5,16.3,15.6,12.2,10.7,11.9z M13.3,24.4H6.7v-1.2h6.6z M13.3,22H6.7v-1.2h6.6z M13.3,19.6H6.7v-1.2h6.6z",size:45,margin:50,color:sg.color.accent40,throttle:40}},e}(ag),ok=el(),ak=Cn;function sk(t,e,n){if(!Ye.node){var i=e.getZr();ok(i).records||(ok(i).records={}),function(t,e){if(ok(t).initialized)return;function n(n,i){t.on(n,function(n){var r=function(t){var e={showTip:[],hideTip:[]},n=function(i){var r=e[i.type];r?r.push(i):(i.dispatchAction=n,t.dispatchAction(i))};return{dispatchAction:n,pendings:e}}(e);ak(ok(t).records,function(t){t&&i(t,n,r.dispatchAction)}),function(t,e){var n,i=t.showTip.length,r=t.hideTip.length;i?n=t.showTip[i-1]:r&&(n=t.hideTip[r-1]);n&&(n.dispatchAction=null,e.dispatchAction(n))}(r.pendings,e)})}ok(t).initialized=!0,n("click",An(ck,"click")),n("mousemove",An(ck,"mousemove")),n("globalout",lk)}(i,e),(ok(i).records[t]||(ok(i).records[t]={})).handler=n}}function lk(t,e,n){t.handler("leave",null,n)}function ck(t,e,n,i){e.handler(t,n,i)}function uk(t,e){if(!Ye.node){var n=e.getZr();(ok(n).records||{})[t]&&(ok(n).records[t]=null)}}var hk=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return y(e,t),e.prototype.render=function(t,e,n){var i=e.getComponent("tooltip"),r=t.get("triggerOn")||i&&i.get("triggerOn")||"mousemove|click";sk("axisPointer",n,function(t,e,n){"none"!==r&&("leave"===t||r.indexOf(t)>=0)&&n({type:"updateAxisPointer",currTrigger:t,x:e&&e.offsetX,y:e&&e.offsetY})})},e.prototype.remove=function(t,e){uk("axisPointer",e)},e.prototype.dispose=function(t,e){uk("axisPointer",e)},e.type="axisPointer",e}(Um);function dk(t,e){var n,i=[],r=t.seriesIndex;if(null==r||!(n=e.getSeriesByIndex(r)))return{point:[]};var o=n.getData(),a=tl(o,t);if(null==a||a<0||Pn(a))return{point:[]};var s=o.getItemGraphicEl(a),l=n.coordinateSystem;if(n.getTooltipPosition)i=n.getTooltipPosition(a)||[];else if(l&&l.dataToPoint)if(t.isStacked){var c=l.getBaseAxis(),u=l.getOtherAxis(c).dim,h=c.dim,d="x"===u||"radius"===u?1:0,p=o.mapDimension(h),f=[];f[d]=o.get(p,a),f[1-d]=o.get(o.getCalculationInfo("stackResultDimension"),a),i=l.dataToPoint(f)||[]}else i=l.dataToPoint(o.getValues(kn(l.dimensions,function(t){return o.mapDimension(t)}),a))||[];else if(s){var g=s.getBoundingRect().clone();g.applyTransform(s.transform),i=[g.x+g.width/2,g.y+g.height/2]}return{point:i,el:s}}var pk=el();function fk(t,e,n){var i=t.currTrigger,r=[t.x,t.y],o=t,a=t.dispatchAction||Dn(n.dispatchAction,n),s=e.getComponent("axisPointer").coordSysAxesInfo;if(s){_k(r)&&(r=dk({seriesIndex:o.seriesIndex,dataIndex:o.dataIndex},e).point);var l=_k(r),c=o.axesInfo,u=s.axesInfo,h="leave"===i||_k(r),d={},p={},f={list:[],map:{}},g={showPointer:An(vk,p),showTooltip:An(mk,f)};Cn(s.coordSysMap,function(t,e){var n=l||t.containPoint(r);Cn(s.coordSysAxesInfo[e],function(t,e){var i=t.axis,o=function(t,e){for(var n=0;n<(t||[]).length;n++){var i=t[n];if(e.axis.dim===i.axisDim&&e.axis.model.componentIndex===i.axisIndex)return i}}(c,t);if(!h&&n&&(!c||o)){var a=o&&o.value;null!=a||l||(a=i.pointToData(r)),null!=a&&gk(t,a,g,!1,d)}})});var v={};return Cn(u,function(t,e){var n=t.linkGroup;n&&!p[e]&&Cn(n.axesInfo,function(e,i){var r=p[i];if(e!==t&&r){var o=r.value;n.mapper&&(o=t.axis.scale.parse(n.mapper(o,yk(e),yk(t)))),v[t.key]=o}})}),Cn(v,function(t,e){gk(u[e],t,g,!0,d)}),function(t,e,n){var i=n.axesInfo=[];Cn(e,function(e,n){var r=e.axisPointerModel.option,o=t[n];o?(!e.useHandle&&(r.status="show"),r.value=o.value,r.seriesDataIndices=(o.payloadBatch||[]).slice()):!e.useHandle&&(r.status="hide"),"show"===r.status&&i.push({axisDim:e.axis.dim,axisIndex:e.axis.model.componentIndex,value:r.value})})}(p,u,d),function(t,e,n,i){if(_k(e)||!t.list.length)return void i({type:"hideTip"});var r=((t.list[0].dataByAxis[0]||{}).seriesDataIndices||[])[0]||{};i({type:"showTip",escapeConnect:!0,x:e[0],y:e[1],tooltipOption:n.tooltipOption,position:n.position,dataIndexInside:r.dataIndexInside,dataIndex:r.dataIndex,seriesIndex:r.seriesIndex,dataByCoordSys:t.list})}(f,r,t,a),function(t,e,n){var i=n.getZr(),r="axisPointerLastHighlights",o=pk(i)[r]||{},a=pk(i)[r]={};Cn(t,function(t,e){var n=t.axisPointerModel.option;"show"===n.status&&t.triggerEmphasis&&Cn(n.seriesDataIndices,function(t){var e=t.seriesIndex+" | "+t.dataIndex;a[e]=t})});var s=[],l=[];Cn(o,function(t,e){!a[e]&&l.push(t)}),Cn(a,function(t,e){!o[e]&&s.push(t)}),l.length&&n.dispatchAction({type:"downplay",escapeConnect:!0,notBlur:!0,batch:l}),s.length&&n.dispatchAction({type:"highlight",escapeConnect:!0,notBlur:!0,batch:s})}(u,0,n),d}}function gk(t,e,n,i,r){var o=t.axis;if(!o.scale.isBlank()&&o.containData(e))if(t.involveSeries){var a=function(t,e){var n=e.axis,i=n.dim,r=t,o=[],a=Number.MAX_VALUE,s=-1;return Cn(e.seriesModels,function(e,l){var c,u,h=e.getData().mapDimensionsAll(i);if(e.getAxisTooltipData){var d=e.getAxisTooltipData(h,t,n);u=d.dataIndices,c=d.nestestValue}else{if(!(u=e.indicesOfNearest(i,h[0],t,"category"===n.type?.5:null)).length)return;c=e.getData().get(h[0],u[0])}if(null!=c&&isFinite(c)){var p=t-c,f=Math.abs(p);f<=a&&((f=0&&s<0)&&(a=f,s=p,r=c,o.length=0),Cn(u,function(t){o.push({seriesIndex:e.seriesIndex,dataIndexInside:t,dataIndex:e.getData().getRawIndex(t)})}))}}),{payloadBatch:o,snapToValue:r}}(e,t),s=a.payloadBatch,l=a.snapToValue;s[0]&&null==r.seriesIndex&&_n(r,s[0]),!i&&t.snap&&o.containData(l)&&null!=l&&(e=l),n.showPointer(t,e,s),n.showTooltip(t,a,l)}else n.showPointer(t,e)}function vk(t,e,n,i){t[e.key]={value:n,payloadBatch:i}}function mk(t,e,n,i){var r=n.payloadBatch,o=e.axis,a=o.model,s=e.axisPointerModel;if(e.triggerTooltip&&r.length){var l=e.coordSys.model,c=AC(l),u=t.map[c];u||(u=t.map[c]={coordSysId:l.id,coordSysIndex:l.componentIndex,coordSysType:l.type,coordSysMainType:l.mainType,dataByAxis:[]},t.list.push(u)),u.dataByAxis.push({axisDim:o.dim,axisIndex:a.componentIndex,axisType:a.type,axisId:a.id,value:i,valueLabelOpt:{precision:s.get(["label","precision"]),formatter:s.get(["label","formatter"])},seriesDataIndices:r.slice()})}}function yk(t){var e=t.axis.model,n={},i=n.axisDim=t.axis.dim;return n.axisIndex=n[i+"AxisIndex"]=e.componentIndex,n.axisName=n[i+"AxisName"]=e.name,n.axisId=n[i+"AxisId"]=e.id,n}function _k(t){return!t||null==t[0]||isNaN(t[0])||null==t[1]||isNaN(t[1])}function bk(t){LC.registerAxisPointerClass("CartesianAxisPointer",tk),t.registerComponentModel(rk),t.registerComponentView(hk),t.registerPreprocessor(function(t){if(t){(!t.axisPointer||0===t.axisPointer.length)&&(t.axisPointer={});var e=t.axisPointer.link;e&&!Pn(e)&&(t.axisPointer.link=[e])}}),t.registerProcessor(t.PRIORITY.PROCESSOR.STATISTIC,function(t,e){t.getComponent("axisPointer").coordSysAxesInfo=MC(t,e)}),t.registerAction({type:"updateAxisPointer",event:"updateAxisPointer",update:":updateAxisPointer"},fk)}var xk=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return y(e,t),e.type="tooltip",e.dependencies=["axisPointer"],e.defaultOption={z:60,show:!0,showContent:!0,trigger:"item",triggerOn:"mousemove|click",alwaysShowContent:!1,renderMode:"auto",confine:null,showDelay:0,hideDelay:100,transitionDuration:.4,displayTransition:!0,enterable:!1,backgroundColor:sg.color.neutral00,shadowBlur:10,shadowColor:"rgba(0, 0, 0, .2)",shadowOffsetX:1,shadowOffsetY:2,borderRadius:4,borderWidth:1,defaultBorderColor:sg.color.border,padding:null,extraCssText:"",axisPointer:{type:"line",axis:"auto",animation:"auto",animationDurationUpdate:200,animationEasingUpdate:"exponentialOut",crossStyle:{color:sg.color.borderShade,width:1,type:"dashed",textStyle:{}}},textStyle:{color:sg.color.tertiary,fontSize:14}},e}(ag);function wk(t){var e=t.get("confine");return null!=e?!!e:"richText"===t.get("renderMode")}function Sk(t){if(Ye.domSupported)for(var e=document.documentElement.style,n=0,i=t.length;n0&&r.push(function(t,e,n){var i="cubic-bezier(0.23,1,0.32,1)",r="",o="";return n&&(o="opacity"+(r=" "+t/2+"s "+i)+",visibility"+r),e||(r=" "+t+"s "+i,o+=(o.length?",":"")+(Ye.transformSupported?""+Tk+r:",left"+r+",top"+r)),Mk+":"+o}(o,n,i)),a&&r.push("background-color:"+a),Cn(["width","color","radius"],function(e){var n="border-"+e,i=Lf(n),o=t.get(i);null!=o&&r.push(n+":"+o+("color"===e?"":"px"))}),r.push(function(t){var e=[],n=t.get("fontSize"),i=t.getTextColor();i&&e.push("color:"+i),e.push("font:"+t.getFont());var r=Wn(t.get("lineHeight"),Math.round(3*n/2));n&&e.push("line-height:"+r+"px");var o=t.get("textShadowColor"),a=t.get("textShadowBlur")||0,s=t.get("textShadowOffsetX")||0,l=t.get("textShadowOffsetY")||0;return o&&a&&e.push("text-shadow:"+s+"px "+l+"px "+a+"px "+o),Cn(["decoration","align"],function(n){var i=t.get(n);i&&e.push("text-"+n+":"+i)}),e.join(";")}(h)),null!=d&&r.push("padding:"+Ef(d).join("px ")+"px"),r.join(";")+";"}function Pk(t,e,n,i,r){var o=e&&e.painter;if(n){var a=o&&o.getViewportRoot();a&&function(t,e,n,i,r){Mi(ki,e,i,r,!0)&&Mi(t,n,ki[0],ki[1])}(t,a,n,i,r)}else{t[0]=i,t[1]=r;var s=o&&o.getViewportRootOffset();s&&(t[0]+=s.offsetLeft,t[1]+=s.offsetTop)}t[2]=t[0]/e.getWidth(),t[3]=t[1]/e.getHeight()}var Lk=function(){function t(t,e){if(this._show=!1,this._styleCoord=[0,0,0,0],this._enterable=!0,this._alwaysShowContent=!1,this._firstShow=!0,this._longHide=!0,Ye.wxa)return null;var n=document.createElement("div");n.domBelongToZr=!0,this.el=n;var i=this._zr=t.getZr(),r=e.appendTo,o=r&&(En(r)?document.querySelector(r):Hn(r)?r:Ln(r)&&r(t.getDom()));Pk(this._styleCoord,i,o,t.getWidth()/2,t.getHeight()/2),(o||t.getDom()).appendChild(n),this._api=t,this._container=o;var a=this;n.onmouseenter=function(){a._enterable&&(clearTimeout(a._hideTimeout),a._show=!0),a._inContent=!0},n.onmousemove=function(t){if(t=t||window.event,!a._enterable){var e=i.handler;Ri(i.painter.getViewportRoot(),t,!0),e.dispatch("mousemove",t)}},n.onmouseleave=function(){a._inContent=!1,a._enterable&&a._show&&a.hideLater(a._hideDelay)}}return t.prototype.update=function(t){if(!this._container){var e=this._api.getDom(),n=(o="position",(a=(r=e).currentStyle||document.defaultView&&document.defaultView.getComputedStyle(r))?a[o]:null),i=e.style;"absolute"!==i.position&&"absolute"!==n&&(i.position="relative")}var r,o,a,s=t.get("alwaysShowContent");s&&this._moveIfResized(),this._alwaysShowContent=s,this._enableDisplayTransition=t.get("displayTransition")&&t.get("transitionDuration")>0,this.el.className=t.get("className")||""},t.prototype.show=function(t,e){clearTimeout(this._hideTimeout),clearTimeout(this._longHideTimeout);var n=this.el,i=n.style,r=this._styleCoord;n.innerHTML?i.cssText=Ik+Ak(t,!this._firstShow,this._longHide,this._enableDisplayTransition)+Dk(r[0],r[1],!0)+"border-color:"+$f(e)+";"+(t.get("extraCssText")||"")+";pointer-events:"+(this._enterable?"auto":"none"):i.display="none",this._show=!0,this._firstShow=!1,this._longHide=!1},t.prototype.setContent=function(t,e,n,i,r){var o=this.el;if(null!=t){var a="";if(En(r)&&"item"===n.get("trigger")&&!wk(n)&&(a=function(t,e,n){if(!En(n)||"inside"===n)return"";var i=t.get("backgroundColor"),r=t.get("borderWidth");e=$f(e);var o,a,s="left"===(o=n)?"right":"right"===o?"left":"top"===o?"bottom":"top",l=Math.max(1.5*Math.round(r),6),c="",u=Tk+":";xn(["left","right"],s)>-1?(c+="top:50%",u+="translateY(-50%) rotate("+(a="left"===s?-225:-45)+"deg)"):(c+="left:50%",u+="translateX(-50%) rotate("+(a="top"===s?225:45)+"deg)");var h=a*Math.PI/180,d=l+r,p=d*Math.abs(Math.cos(h))+d*Math.abs(Math.sin(h)),f=e+" solid "+r+"px;";return'
'}(n,i,r)),En(t))o.innerHTML=t+a;else if(t){o.innerHTML="",Pn(t)||(t=[t]);for(var s=0;s=0?this._tryShow(n,i):"leave"===e&&this._hide(i))},this))},e.prototype._keepShow=function(){var t=this._tooltipModel,e=this._ecModel,n=this._api,i=t.get("triggerOn");if(null!=this._lastX&&null!=this._lastY&&"none"!==i&&"click"!==i){var r=this;clearTimeout(this._refreshUpdateTimeout),this._refreshUpdateTimeout=setTimeout(function(){!n.isDisposed()&&r.manuallyShowTip(t,e,n,{x:r._lastX,y:r._lastY,dataByCoordSys:r._lastDataByCoordSys})})}},e.prototype.manuallyShowTip=function(t,e,n,i){if(i.from!==this.uid&&!Ye.node&&n.getDom()){var r=Fk(i,n);this._ticket="";var o=i.dataByCoordSys,a=function(t,e,n){var i=rl(t).queryOptionMap,r=i.keys()[0];if(!r||"series"===r)return;var o=al(e,r,i.get(r),{useDefault:!1,enableAll:!1,enableNone:!1}),a=o.models[0];if(!a)return;var s,l=n.getViewOfComponentModel(a);if(l.group.traverse(function(e){var n=Cu(e).tooltipConfig;if(n&&n.name===t.name)return s=e,!0}),s)return{componentMainType:r,componentIndex:a.componentIndex,el:s}}(i,e,n);if(a){var s=a.el.getBoundingRect().clone();s.applyTransform(a.el.transform),this._tryShow({offsetX:s.x+s.width/2,offsetY:s.y+s.height/2,target:a.el,position:i.position,positionDefault:"bottom"},r)}else if(i.tooltip&&null!=i.x&&null!=i.y){var l=Rk;l.x=i.x,l.y=i.y,l.update(),Cu(l).tooltipConfig={name:null,option:i.tooltip},this._tryShow({offsetX:i.x,offsetY:i.y,target:l},r)}else if(o)this._tryShow({offsetX:i.x,offsetY:i.y,position:i.position,dataByCoordSys:o,tooltipOption:i.tooltipOption},r);else if(null!=i.seriesIndex){if(this._manuallyAxisShowTip(t,e,n,i))return;var c=dk(i,e),u=c.point[0],h=c.point[1];null!=u&&null!=h&&this._tryShow({offsetX:u,offsetY:h,target:c.el,position:i.position,positionDefault:"bottom"},r)}else null!=i.x&&null!=i.y&&(n.dispatchAction({type:"updateAxisPointer",x:i.x,y:i.y}),this._tryShow({offsetX:i.x,offsetY:i.y,position:i.position,target:n.getZr().findHover(i.x,i.y).target},r))}},e.prototype.manuallyHideTip=function(t,e,n,i){var r=this._tooltipContent;this._tooltipModel&&r.hideLater(this._tooltipModel.get("hideDelay")),this._lastX=this._lastY=this._lastDataByCoordSys=null,i.from!==this.uid&&this._hide(Fk(i,n))},e.prototype._manuallyAxisShowTip=function(t,e,n,i){var r=i.seriesIndex,o=i.dataIndex,a=e.getComponent("axisPointer").coordSysAxesInfo;if(null!=r&&null!=o&&null!=a){var s=e.getSeriesByIndex(r);if(s)if("axis"===Hk([s.getData().getItemModel(o),s,(s.coordinateSystem||{}).model],this._tooltipModel).get("trigger"))return n.dispatchAction({type:"updateAxisPointer",seriesIndex:r,dataIndex:o,position:i.position}),!0}},e.prototype._tryShow=function(t,e){var n=t.target;if(this._tooltipModel){this._lastX=t.offsetX,this._lastY=t.offsetY;var i=t.dataByCoordSys;if(i&&i.length)this._showAxisTooltip(i,t);else if(n){var r,o;if("legend"===Cu(n).ssrType)return;this._lastDataByCoordSys=null,Wy(n,function(t){if(t.tooltipDisabled)return r=o=null,!0;r||o||(null!=Cu(t).dataIndex?r=t:null!=Cu(t).tooltipConfig&&(o=t))},!0),r?this._showSeriesItemTooltip(t,r,e):o?this._showComponentItemTooltip(t,o,e):this._hide(e)}else this._lastDataByCoordSys=null,this._hide(e)}},e.prototype._showOrMove=function(t,e){var n=t.get("showDelay");e=Dn(e,this),clearTimeout(this._showTimout),n>0?this._showTimout=setTimeout(e,n):e()},e.prototype._showAxisTooltip=function(t,e){var n=this._ecModel,i=this._tooltipModel,r=[e.offsetX,e.offsetY],o=Hk([e.tooltipOption],i),a=this._renderMode,s=[],l=wm("section",{blocks:[],noHeader:!0}),c=[],u=new Lm;Cn(t,function(t){Cn(t.dataByAxis,function(t){var e=n.getComponent(t.axisDim+"Axis",t.axisIndex),r=t.value;if(e&&null!=r){var o=QC(r,e.axis,n,t.seriesDataIndices,t.valueLabelOpt),h=wm("section",{header:o,noHeader:!Xn(o),sortBlocks:!0,blocks:[]});l.blocks.push(h),Cn(t.seriesDataIndices,function(l){var d=n.getSeriesByIndex(l.seriesIndex),p=l.dataIndexInside,f=d.getDataParams(p);if(!(f.dataIndex<0)){f.axisDim=t.axisDim,f.axisIndex=t.axisIndex,f.axisType=t.axisType,f.axisId=t.axisId,f.axisValue=uw(e.axis,{value:r}),f.axisValueLabel=o,f.marker=u.makeTooltipMarker("item",$f(f.color),a);var g=Wv(d.formatTooltip(p,!0,null)),v=g.frag;if(v){var m=Hk([d],i).get("valueFormatter");h.blocks.push(m?_n({valueFormatter:m},v):v)}g.text&&c.push(g.text),s.push(f)}})}})}),l.blocks.reverse(),c.reverse();var h=e.position,d=o.get("order"),p=Im(l,u,a,d,n.get("useUTC"),o.get("textStyle"));p&&c.unshift(p);var f="richText"===a?"\n\n":"
",g=c.join(f);this._showOrMove(o,function(){this._updateContentNotChangedOnAxis(t,s)?this._updatePosition(o,h,r[0],r[1],this._tooltipContent,s):this._showTooltipContent(o,g,s,Math.random()+"",r[0],r[1],h,null,u)})},e.prototype._showSeriesItemTooltip=function(t,e,n){var i=this._ecModel,r=Cu(e),o=r.seriesIndex,a=i.getSeriesByIndex(o),s=r.dataModel||a,l=r.dataIndex,c=r.dataType,u=s.getData(c),h=this._renderMode,d=t.positionDefault,p=Hk([u.getItemModel(l),s,a&&(a.coordinateSystem||{}).model],this._tooltipModel,d?{position:d}:null),f=p.get("trigger");if(null==f||"item"===f){var g=s.getDataParams(l,c),v=new Lm;g.marker=v.makeTooltipMarker("item",$f(g.color),h);var m=Wv(s.formatTooltip(l,!1,c)),y=p.get("order"),_=p.get("valueFormatter"),b=m.frag,x=b?Im(_?_n({valueFormatter:_},b):b,v,h,y,i.get("useUTC"),p.get("textStyle")):m.text,w="item_"+s.name+"_"+l;this._showOrMove(p,function(){this._showTooltipContent(p,x,g,w,t.offsetX,t.offsetY,t.position,t.target,v)}),n({type:"showTip",dataIndexInside:l,dataIndex:u.getRawIndex(l),seriesIndex:o,from:this.uid})}},e.prototype._showComponentItemTooltip=function(t,e,n){var i="html"===this._renderMode,r=Cu(e),o=r.tooltipConfig.option||{},a=o.encodeHTMLContent;if(En(o)){o={content:o,formatter:o},a=!0}a&&i&&o.content&&((o=mn(o)).content=Ai(o.content));var s=[o],l=this._ecModel.getComponent(r.componentMainType,r.componentIndex);l&&s.push(l),s.push({formatter:o.content});var c=t.positionDefault,u=Hk(s,this._tooltipModel,c?{position:c}:null),h=u.get("content"),d=Math.random()+"",p=new Lm;this._showOrMove(u,function(){var n=mn(u.get("formatterParams")||{});this._showTooltipContent(u,h,n,d,t.offsetX,t.offsetY,t.position,e,p)}),n({type:"showTip",from:this.uid})},e.prototype._showTooltipContent=function(t,e,n,i,r,o,a,s,l){if(this._ticket="",t.get("showContent")&&t.get("show")){var c=this._tooltipContent;c.setEnterable(t.get("enterable"));var u=t.get("formatter");a=a||t.get("position");var h=e,d=this._getNearestPoint([r,o],n,t.get("trigger"),t.get("borderColor"),t.get("defaultBorderColor",!0)).color;if(u)if(En(u)){var p=t.ecModel.get("useUTC"),f=Pn(n)?n[0]:n;h=u,f&&f.axisType&&f.axisType.indexOf("time")>=0&&(h=ff(f.axisValue,h,p)),h=Rf(h,n,!0)}else if(Ln(u)){var g=Dn(function(e,i){e===this._ticket&&(c.setContent(i,l,t,d,a),this._updatePosition(t,a,r,o,c,n,s))},this);this._ticket=i,h=u(n,i,g)}else h=u;c.setContent(h,l,t,d,a),c.show(t,d),this._updatePosition(t,a,r,o,c,n,s)}},e.prototype._getNearestPoint=function(t,e,n,i,r){return"axis"===n||Pn(e)?{color:i||r}:Pn(e)?void 0:{color:i||e.color||e.borderColor}},e.prototype._updatePosition=function(t,e,n,i,r,o,a){var s=this._api.getWidth(),l=this._api.getHeight();e=e||t.get("position");var c=r.getSize(),u=t.get("align"),h=t.get("verticalAlign"),d=a&&a.getBoundingRect().clone();if(a&&d.applyTransform(a.transform),Ln(e)&&(e=e([n,i],o,r.el,d,{viewSize:[s,l],contentSize:c.slice()})),Pn(e))n=Ss(e[0],s),i=Ss(e[1],l);else if(On(e)){var p=e;p.width=c[0],p.height=c[1];var f=Jf(p,{width:s,height:l});n=f.x,i=f.y,u=null,h=null}else if(En(e)&&a){var g=function(t,e,n,i){var r=n[0],o=n[1],a=Math.ceil(Math.SQRT2*i)+8,s=0,l=0,c=e.width,u=e.height;switch(t){case"inside":s=e.x+c/2-r/2,l=e.y+u/2-o/2;break;case"top":s=e.x+c/2-r/2,l=e.y-o-a;break;case"bottom":s=e.x+c/2-r/2,l=e.y+u+a;break;case"left":s=e.x-r-a,l=e.y+u/2-o/2;break;case"right":s=e.x+c+a,l=e.y+u/2-o/2}return[s,l]}(e,d,c,t.get("borderWidth"));n=g[0],i=g[1]}else{g=function(t,e,n,i,r,o,a){var s=n.getSize(),l=s[0],c=s[1];null!=o&&(t+l+o+2>i?t-=l+o:t+=o);null!=a&&(e+c+a>r?e-=c+a:e+=a);return[t,e]}(n,i,r,s,l,u?null:20,h?null:20);n=g[0],i=g[1]}if(u&&(n-=Bk(u)?c[0]/2:"right"===u?c[0]:0),h&&(i-=Bk(h)?c[1]/2:"bottom"===h?c[1]:0),wk(t)){g=function(t,e,n,i,r){var o=n.getSize(),a=o[0],s=o[1];return t=Math.min(t+a,i)-a,e=Math.min(e+s,r)-s,t=Math.max(t,0),e=Math.max(e,0),[t,e]}(n,i,r,s,l);n=g[0],i=g[1]}r.moveTo(n,i)},e.prototype._updateContentNotChangedOnAxis=function(t,e){var n=this._lastDataByCoordSys,i=this._cbParamsList,r=!!n&&n.length===t.length;return r&&Cn(n,function(n,o){var a=n.dataByAxis||[],s=(t[o]||{}).dataByAxis||[];(r=r&&a.length===s.length)&&Cn(a,function(t,n){var o=s[n]||{},a=t.seriesDataIndices||[],l=o.seriesDataIndices||[];(r=r&&t.value===o.value&&t.axisType===o.axisType&&t.axisId===o.axisId&&a.length===l.length)&&Cn(a,function(t,e){var n=l[e];r=r&&t.seriesIndex===n.seriesIndex&&t.dataIndex===n.dataIndex}),i&&Cn(t.seriesDataIndices,function(t){var n=t.seriesIndex,o=e[n],a=i[n];o&&a&&a.data!==o.data&&(r=!1)})})}),this._lastDataByCoordSys=t,this._cbParamsList=e,!!r},e.prototype._hide=function(t){this._lastDataByCoordSys=null,t({type:"hideTip",from:this.uid})},e.prototype.dispose=function(t,e){!Ye.node&&e.getDom()&&(oy(this,"_updatePosition"),this._tooltipContent.dispose(),uk("itemTooltip",e))},e.type="tooltip",e}(Um);function Hk(t,e,n){var i,r=e.ecModel;n?(i=new Bp(n,r,r),i=new Bp(e.option,i,r)):i=e;for(var o=t.length-1;o>=0;o--){var a=t[o];a&&(a instanceof Bp&&(a=a.get("tooltip",!0)),En(a)&&(a={formatter:a}),a&&(i=new Bp(a,i,r)))}return i}function Fk(t,e){return t.dispatchAction||Dn(e.dispatchAction,e)}function Bk(t){return"center"===t||"middle"===t}var Vk=Math.sin,Wk=Math.cos,Uk=Math.PI,Gk=2*Math.PI,qk=180/Uk,jk=function(){function t(){}return t.prototype.reset=function(t){this._start=!0,this._d=[],this._str="",this._p=Math.pow(10,t||4)},t.prototype.moveTo=function(t,e){this._add("M",t,e)},t.prototype.lineTo=function(t,e){this._add("L",t,e)},t.prototype.bezierCurveTo=function(t,e,n,i,r,o){this._add("C",t,e,n,i,r,o)},t.prototype.quadraticCurveTo=function(t,e,n,i){this._add("Q",t,e,n,i)},t.prototype.arc=function(t,e,n,i,r,o){this.ellipse(t,e,n,n,0,i,r,o)},t.prototype.ellipse=function(t,e,n,i,r,o,a,s){var l=a-o,c=!s,u=Math.abs(l),h=zo(u-Gk)||(c?l>=Gk:-l>=Gk),d=l>0?l%Gk:l%Gk+Gk,p=!1;p=!!h||!zo(u)&&d>=Uk==!!c;var f=t+n*Wk(o),g=e+i*Vk(o);this._start&&this._add("M",f,g);var v=Math.round(r*qk);if(h){var m=1/this._p,y=(c?1:-1)*(Gk-m);this._add("A",n,i,v,1,+c,t+n*Wk(o+y),e+i*Vk(o+y)),m>.01&&this._add("A",n,i,v,0,+c,f,g)}else{var _=t+n*Wk(a),b=e+i*Vk(a);this._add("A",n,i,v,+p,+c,_,b)}},t.prototype.rect=function(t,e,n,i){this._add("M",t,e),this._add("l",n,0),this._add("l",0,i),this._add("l",-n,0),this._add("Z")},t.prototype.closePath=function(){this._d.length>0&&this._add("Z")},t.prototype._add=function(t,e,n,i,r,o,a,s,l){for(var c=[],u=this._p,h=1;h"}(r,o)+("style"!==r?Ai(a):a||"")+(i?""+n+kn(i,function(e){return t(e)}).join(n)+n:"")+("")}(t)}function oM(t){return{zrId:t,shadowCache:{},patternCache:{},gradientCache:{},clipPathCache:{},defs:{},cssNodes:{},cssAnims:{},cssStyleCache:{},cssAnimIdx:0,shadowIdx:0,gradientIdx:0,patternIdx:0,clipPathIdx:0}}function aM(t,e,n,i){return iM("svg","root",{width:t,height:e,xmlns:Jk,"xmlns:xlink":tM,version:"1.1",baseProfile:"full",viewBox:!!i&&"0 0 "+t+" "+e},n)}var sM=0;function lM(){return sM++}var cM={cubicIn:"0.32,0,0.67,0",cubicOut:"0.33,1,0.68,1",cubicInOut:"0.65,0,0.35,1",quadraticIn:"0.11,0,0.5,0",quadraticOut:"0.5,1,0.89,1",quadraticInOut:"0.45,0,0.55,1",quarticIn:"0.5,0,0.75,0",quarticOut:"0.25,1,0.5,1",quarticInOut:"0.76,0,0.24,1",quinticIn:"0.64,0,0.78,0",quinticOut:"0.22,1,0.36,1",quinticInOut:"0.83,0,0.17,1",sinusoidalIn:"0.12,0,0.39,0",sinusoidalOut:"0.61,1,0.88,1",sinusoidalInOut:"0.37,0,0.63,1",exponentialIn:"0.7,0,0.84,0",exponentialOut:"0.16,1,0.3,1",exponentialInOut:"0.87,0,0.13,1",circularIn:"0.55,0,1,0.45",circularOut:"0,0.55,0.45,1",circularInOut:"0.85,0,0.15,1"},uM="transform-origin";function hM(t,e,n){var i=_n({},t.shape);_n(i,e),t.buildPath(n,i);var r=new jk;return r.reset(Uo(t)),n.rebuildPath(r,1),r.generateStr(),r.getStr()}function dM(t,e){var n=e.originX,i=e.originY;(n||i)&&(t[uM]=n+"px "+i+"px")}var pM={fill:"fill",opacity:"opacity",lineWidth:"stroke-width",lineDashOffset:"stroke-dashoffset"};function fM(t,e){var n=e.zrId+"-ani-"+e.cssAnimIdx++;return e.cssAnims[n]=t,n}function gM(t){return En(t)?cM[t]?"cubic-bezier("+cM[t]+")":oo(t)?t:"":""}function vM(t,e,n,i){var r=t.animators,o=r.length,a=[];if(t instanceof md){var s=function(t,e,n){var i,r,o=t.shape.paths,a={};if(Cn(o,function(t){var e=oM(n.zrId);e.animation=!0,vM(t,{},e,!0);var o=e.cssAnims,s=e.cssNodes,l=In(o),c=l.length;if(c){var u=o[r=l[c-1]];for(var h in u){var d=u[h];a[h]=a[h]||{d:""},a[h].d+=d.d||""}for(var p in s){var f=s[p].animation;f.indexOf(r)>=0&&(i=f)}}}),i){e.d=!1;var s=fM(a,n);return i.replace(r,s)}}(t,e,n);if(s)a.push(s);else if(!o)return}else if(!o)return;for(var l={},c=0;c0}).length)return fM(u,n)+" "+r[0]+" both"}for(var v in l){(s=g(l[v]))&&a.push(s)}if(a.length){var m=n.zrId+"-cls-"+lM();n.cssNodes["."+m]={animation:a.join(",")},e.class=m}}function mM(t,e,n,i){var r=JSON.stringify(t),o=n.cssStyleCache[r];o||(o=n.zrId+"-cls-"+lM(),n.cssStyleCache[r]=o,n.cssNodes["."+o+":hover"]=t),e.class=e.class?e.class+" "+o:o}var yM=Math.round;function _M(t){return t&&En(t.src)}function bM(t){return t&&Ln(t.toDataURL)}function xM(t,e,n,i){Qk(function(r,o){var a="fill"===r||"stroke"===r;a&&Vo(o)?LM(e,t,r,i):a&&Ho(o)?EM(n,t,r,i):t[r]=o,a&&i.ssr&&"none"===o&&(t["pointer-events"]="visible")},e,n,!1),function(t,e,n){var i=t.style;if(function(t){return t&&(t.shadowBlur||t.shadowOffsetX||t.shadowOffsetY)}(i)){var r=function(t){var e=t.style,n=t.getGlobalScale();return[e.shadowColor,(e.shadowBlur||0).toFixed(2),(e.shadowOffsetX||0).toFixed(2),(e.shadowOffsetY||0).toFixed(2),n[0],n[1]].join(",")}(t),o=n.shadowCache,a=o[r];if(!a){var s=t.getGlobalScale(),l=s[0],c=s[1];if(!l||!c)return;var u=i.shadowOffsetX||0,h=i.shadowOffsetY||0,d=i.shadowBlur,p=Lo(i.shadowColor),f=p.opacity,g=p.color,v=d/2/l+" "+d/2/c;a=n.zrId+"-s"+n.shadowIdx++,n.defs[a]=iM("filter",a,{id:a,x:"-100%",y:"-100%",width:"300%",height:"300%"},[iM("feDropShadow","",{dx:u/l,dy:h/c,stdDeviation:v,"flood-color":g,"flood-opacity":f})]),o[r]=a}e.filter=Wo(a)}}(n,t,i)}function wM(t,e){var n=function(t){if("function"==typeof gs)return gs(t)}(e);n&&(n.each(function(e,n){null!=e&&(t[(eM+n).toLowerCase()]=e+"")}),e.isSilent()&&(t[eM+"silent"]="true"))}function SM(t){return zo(t[0]-1)&&zo(t[1])&&zo(t[2])&&zo(t[3]-1)}function CM(t,e,n){if(e&&(!function(t){return zo(t[4])&&zo(t[5])}(e)||!SM(e))){var i=1e4;t.transform=SM(e)?"translate("+yM(e[4]*i)/i+" "+yM(e[5]*i)/i+")":function(t){return"matrix("+No(t[0])+","+No(t[1])+","+No(t[2])+","+No(t[3])+","+Oo(t[4])+","+Oo(t[5])+")"}(e)}}function kM(t,e,n){for(var i=t.points,r=[],o=0;o=0&&a||o;s&&(r=Ao(s))}var l=i.lineWidth;l&&(l/=!i.strokeNoScale&&t.transform?t.transform[0]:1);var c={cursor:"pointer"};r&&(c.fill=r),i.stroke&&(c.stroke=i.stroke),l&&(c["stroke-width"]=l),mM(c,e,n)}}(t,o,e),iM(s,t.id+"",o)}function PM(t,e){return t instanceof Yc?AM(t,e):t instanceof tu?function(t,e){var n=t.style,i=n.image;if(i&&!En(i)&&(_M(i)?i=i.src:bM(i)&&(i=i.toDataURL())),i){var r=n.x||0,o=n.y||0,a={href:i,width:n.width,height:n.height};return r&&(a.x=r),o&&(a.y=o),CM(a,t.transform),xM(a,n,t,e),wM(a,t),e.animation&&vM(t,a,e),iM("image",t.id+"",a)}}(t,e):t instanceof Kc?function(t,e){var n=t.style,i=n.text;if(null!=i&&(i+=""),i&&!isNaN(n.x)&&!isNaN(n.y)){var r=n.font||Ke,o=n.x||0,a=function(t,e,n){return"top"===n?t+=e/2:"bottom"===n&&(t-=e/2),t}(n.y||0,Qa(r),n.textBaseline),s={"dominant-baseline":"central","text-anchor":Ro[n.textAlign]||n.textAlign};if(mu(n)){var l="",c=n.fontStyle,u=gu(n.fontSize);if(!parseFloat(u))return;var h=n.fontFamily||Ze,d=n.fontWeight;l+="font-size:"+u+";font-family:"+h+";",c&&"normal"!==c&&(l+="font-style:"+c+";"),d&&"normal"!==d&&(l+="font-weight:"+d+";"),s.style=l}else s.style="font: "+r;return i.match(/\s/)&&(s["xml:space"]="preserve"),o&&(s.x=o),a&&(s.y=a),CM(s,t.transform),xM(s,n,t,e),wM(s,t),e.animation&&vM(t,s,e),iM("text",t.id+"",s,void 0,i)}}(t,e):void 0}function LM(t,e,n,i){var r,o=t[n],a={gradientUnits:o.global?"userSpaceOnUse":"objectBoundingBox"};if(Fo(o))r="linearGradient",a.x1=o.x,a.y1=o.y,a.x2=o.x2,a.y2=o.y2;else{if(!Bo(o))return;r="radialGradient",a.cx=Wn(o.x,.5),a.cy=Wn(o.y,.5),a.r=Wn(o.r,.5)}for(var s=o.colorStops,l=[],c=0,u=s.length;cl?XM(t,null==n[h+1]?null:n[h+1].elm,n,s,h):YM(t,e,a,l))}(n,i,r):UM(r)?(UM(t.text)&&BM(n,""),XM(n,null,r,0,r.length-1)):UM(i)?YM(n,i,0,i.length-1):UM(t.text)&&BM(n,""):t.text!==e.text&&(UM(i)&&YM(n,i,0,i.length-1),BM(n,e.text)))}var QM=0,JM=function(){function t(t,e,n){if(this.type="svg",this.refreshHover=function(){},this.configLayer=function(){},this.storage=e,this._opts=n=_n({},n),this.root=t,this._id="zr"+QM++,this._oldVNode=aM(n.width,n.height),t&&!n.ssr){var i=this._viewport=document.createElement("div");i.style.cssText="position:relative;overflow:hidden";var r=this._svgDom=this._oldVNode.elm=nM("svg");ZM(null,this._oldVNode),i.appendChild(r),t.appendChild(i)}this.resize(n.width,n.height)}return t.prototype.getType=function(){return this.type},t.prototype.getViewportRoot=function(){return this._viewport},t.prototype.getViewportRootOffset=function(){var t=this.getViewportRoot();if(t)return{offsetLeft:t.offsetLeft||0,offsetTop:t.offsetTop||0}},t.prototype.getSvgDom=function(){return this._svgDom},t.prototype.refresh=function(){if(this.root){var t=this.renderToVNode({willUpdate:!0});t.attrs.style="position:absolute;left:0;top:0;user-select:none",function(t,e){if(qM(t,e))KM(t,e);else{var n=t.elm,i=HM(n);jM(e),null!==i&&(OM(i,e.elm,FM(n)),YM(i,[t],0,0))}}(this._oldVNode,t),this._oldVNode=t}},t.prototype.renderOneToVNode=function(t){return PM(t,oM(this._id))},t.prototype.renderToVNode=function(t){t=t||{};var e=this.storage.getDisplayList(!0),n=this._width,i=this._height,r=oM(this._id);r.animation=t.animation,r.willUpdate=t.willUpdate,r.compress=t.compress,r.emphasis=t.emphasis,r.ssr=this._opts.ssr;var o=[],a=this._bgVNode=function(t,e,n,i){var r;if(n&&"none"!==n)if(r=iM("rect","bg",{width:t,height:e,x:"0",y:"0"}),Vo(n))LM({fill:n},r.attrs,"fill",i);else if(Ho(n))EM({style:{fill:n},dirty:ri,getBoundingRect:function(){return{width:t,height:e}}},r.attrs,"fill",i);else{var o=Lo(n),a=o.color,s=o.opacity;r.attrs.fill=a,s<1&&(r.attrs["fill-opacity"]=s)}return r}(n,i,this._backgroundColor,r);a&&o.push(a);var s=t.compress?null:this._mainVNode=iM("g","main",{},[]);this._paintList(e,r,s?s.children:o),s&&o.push(s);var l=kn(In(r.defs),function(t){return r.defs[t]});if(l.length&&o.push(iM("defs","defs",{},l)),t.animation){var c=function(t,e,n){var i=(n=n||{}).newline?"\n":"",r=" {"+i,o=i+"}",a=kn(In(t),function(e){return e+r+kn(In(t[e]),function(n){return n+":"+t[e][n]+";"}).join(i)+o}).join(i),s=kn(In(e),function(t){return"@keyframes "+t+r+kn(In(e[t]),function(n){return n+r+kn(In(e[t][n]),function(i){var r=e[t][n][i];return"d"===i&&(r='path("'+r+'")'),i+":"+r+";"}).join(i)+o}).join(i)+o}).join(i);return a||s?[""].join(i):""}(r.cssNodes,r.cssAnims,{newline:!0});if(c){var u=iM("style","stl",{},[],c);o.push(u)}}return aM(n,i,o,t.useViewBox)},t.prototype.renderToString=function(t){return t=t||{},rM(this.renderToVNode({animation:Wn(t.cssAnimation,!0),emphasis:Wn(t.cssEmphasis,!0),willUpdate:!1,compress:!0,useViewBox:Wn(t.useViewBox,!0)}),{newline:!0})},t.prototype.setBackgroundColor=function(t){this._backgroundColor=t},t.prototype.getSvgRoot=function(){return this._mainVNode&&this._mainVNode.elm},t.prototype._paintList=function(t,e,n){for(var i,r,o=t.length,a=[],s=0,l=0,c=0;c=0&&(!h||!r||h[f]!==r[f]);f--);for(var g=p-1;g>f;g--)i=a[--s-1];for(var v=f+1;v10&&"cartesian2d"===o.type&&r){var s=o.getBaseAxis(),l=o.getOtherAxis(s),c=s.getExtent(),u=n.getDevicePixelRatio(),h=Math.abs(c[1]-c[0])*(u||1),d=Math.round(a/h);if(isFinite(d)&&d>1){"lttb"===r?t.setData(i.lttbDownSample(i.mapDimension(l.dim),1/d)):"minmax"===r&&t.setData(i.minmaxDownSample(i.mapDimension(l.dim),1/d));var p=void 0;En(r)?p=MS[r]:Ln(r)&&(p=r),p&&t.setData(i.downSample(i.mapDimension(l.dim),1/d,p,TS))}}}}}("line"))},function(t){bw(BC),bw(bk)},function(t){bw(bk),t.registerComponentModel(xk),t.registerComponentView($k),t.registerAction({type:"showTip",event:"showTip",update:"tooltip:manuallyShowTip"},ri),t.registerAction({type:"hideTip",event:"hideTip",update:"tooltip:manuallyHideTip"},ri)},function(t){t.registerPainter("svg",JM)}]);class tT extends Dt{constructor(){super(...arguments),this.options=null,this.data=[],this.height="120px",this._chart=null,this._resizeObserver=null}render(){return ut`
`}connectedCallback(){super.connectedCallback(),this.hasUpdated&&!this._chart&&this.options&&(this._mount(),this._observeResize())}firstUpdated(){this._mount(),this._observeResize()}updated(t){(t.has("options")||t.has("data"))&&(this._chart&&this.options?this._chart.setOption(this._mergedOptions(),!0):!this._chart&&this.options&&this._mount()),t.has("height")&&this._chart&&this._chart.resize()}disconnectedCallback(){this._destroy(),super.disconnectedCallback()}_mount(){if(!this.options)return;const t=this.shadowRoot?.querySelector(".chart-host");t&&(this._chart=Mb(t,void 0,{renderer:"svg"}),this._chart.setOption(this._mergedOptions(),!0))}_mergedOptions(){if(!this.options)throw new Error("span-chart: options not set");return{...this.options,series:this.data}}_observeResize(){if("undefined"==typeof ResizeObserver)return;this._resizeObserver=new ResizeObserver(()=>{this._chart&&this._chart.resize()});const t=this.shadowRoot?.querySelector(".chart-host");t&&this._resizeObserver.observe(t)}_destroy(){this._resizeObserver&&(this._resizeObserver.disconnect(),this._resizeObserver=null),this._chart&&(this._chart.dispose(),this._chart=null)}}tT.styles=M` +var ps={},fs={};var gs,vs=function(){function t(t,e,n){var i=this;this._sleepAfterStill=10,this._stillFrameAccum=0,this._needsRefresh=!0,this._needsRefreshHover=!0,this._darkMode=!1,n=n||{},this.dom=e,this.id=t;var r=new zr,o=n.renderer||"canvas";ps[o]||(o=In(ps)[0]),n.useDirtyRect=null!=n.useDirtyRect&&n.useDirtyRect;var a=new ps[o](e,r,n,t),s=n.ssr||a.ssrOnly;this.storage=r,this.painter=a;var l,c=Ye.node||Ye.worker||s?null:new Ta(a.getViewportRoot(),a.root),d=n.useCoarsePointer;(null==d||"auto"===d?Ye.touchEventsSupported:!!d)&&(l=Wn(n.pointerSize,44)),this.handler=new br(r,a,c,a.root,l),this.animation=new da({stage:{update:s?null:function(){return i._flush(!0)}}}),s||this.animation.start()}return t.prototype.add=function(t){!this._disposed&&t&&(this.storage.addRoot(t),t.addSelfToZr(this),this.refresh())},t.prototype.remove=function(t){!this._disposed&&t&&(this.storage.delRoot(t),t.removeSelfFromZr(this),this.refresh())},t.prototype.configLayer=function(t,e){this._disposed||(this.painter.configLayer&&this.painter.configLayer(t,e),this.refresh())},t.prototype.setBackgroundColor=function(t){this._disposed||(this.painter.setBackgroundColor&&this.painter.setBackgroundColor(t),this.refresh(),this._backgroundColor=t,this._darkMode=function(t){if(!t)return!1;if("string"==typeof t)return Io(t,1)<.4;if(t.colorStops){for(var e=t.colorStops,n=0,i=e.length,r=0;r0&&(this._stillFrameAccum++,this._stillFrameAccum>this._sleepAfterStill&&this.animation.stop())},t.prototype.setSleepAfterStill=function(t){this._sleepAfterStill=t},t.prototype.wakeUp=function(){this._disposed||(this.animation.start(),this._stillFrameAccum=0)},t.prototype.refreshHover=function(){this._needsRefreshHover=!0},t.prototype.refreshHoverImmediately=function(){this._disposed||(this._needsRefreshHover=!1,this.painter.refreshHover&&"canvas"===this.painter.getType()&&this.painter.refreshHover())},t.prototype.resize=function(t){this._disposed||(t=t||{},this.painter.resize(t.width,t.height),this.handler.resize())},t.prototype.clearAnimation=function(){this._disposed||this.animation.clear()},t.prototype.getWidth=function(){if(!this._disposed)return this.painter.getWidth()},t.prototype.getHeight=function(){if(!this._disposed)return this.painter.getHeight()},t.prototype.setCursorStyle=function(t){this._disposed||this.handler.setCursorStyle(t)},t.prototype.findHover=function(t,e){if(!this._disposed)return this.handler.findHover(t,e)},t.prototype.on=function(t,e,n){return this._disposed||this.handler.on(t,e,n),this},t.prototype.off=function(t,e){this._disposed||this.handler.off(t,e)},t.prototype.trigger=function(t,e){this._disposed||this.handler.trigger(t,e)},t.prototype.clear=function(){if(!this._disposed){for(var t=this.storage.getRoots(),e=0;e0){if(t<=r)return a;if(t>=o)return s}else{if(t>=r)return a;if(t<=o)return s}else{if(t===r)return a;if(t===o)return s}return(t-r)/l*c+a}var Ss=function(t,e,n){switch(t){case"center":case"middle":t="50%";break;case"left":case"top":t="0%";break;case"right":case"bottom":t="100%"}return Cs(t,e,n)};function Cs(t,e,n){return En(t)?(i=t,i.replace(/^\s+|\s+$/g,"")).match(/%$/)?parseFloat(t)/100*e+(n||0):parseFloat(t):null==t?NaN:+t;var i}function ks(t,e,n){return null==e&&(e=10),e=Math.min(Math.max(0,e),20),t=(+t).toFixed(e),n?t:+t}function Ms(t){if(t=+t,isNaN(t))return 0;if(t>1e-14)for(var e=1,n=0;n<15;n++,e*=10)if(Math.round(t*e)/e===t)return n;return function(t){var e=t.toString().toLowerCase(),n=e.indexOf("e"),i=n>0?+e.slice(n+1):0,r=n>0?n:e.length,o=e.indexOf("."),a=o<0?0:r-1-o;return Math.max(0,a-i)}(t)}function Ts(t,e){var n=Math.max(Ms(t),Ms(e)),i=t+e;return n>20?i:ks(i,n)}function Is(t){var e=2*Math.PI;return(t%e+e)%e}function Ds(t){return t>-1e-4&&t=10&&e++,e}function Es(t,e){var n=Ls(t),i=Math.pow(10,n),r=t/i;return t=(r<1.5?1:r<2.5?2:r<4?3:r<7?5:10)*i,n>=-20?+t.toFixed(n<0?-n:0):t}function zs(t){var e=parseFloat(t);return e==t&&(0!==e||!En(t)||t.indexOf("x")<=0)?e:NaN}function Ns(){return Math.round(9*Math.random())}function Os(t,e){return 0===e?t:Os(e,t%e)}function $s(t,e){return null==t?e:null==e?t:t*e/Os(t,e)}var Rs="undefined"!=typeof console&&console.warn&&console.log;function Hs(t,e){!function(t,e){Rs&&console[t]("[ECharts] "+e)}("error",t)}function Fs(t){throw new Error(t)}function Bs(t,e,n){return(e-t)*n+t}var Vs="series\0";function Ws(t){return t instanceof Array?t:null==t?[]:[t]}function Us(t,e,n){if(t){t[e]=t[e]||{},t.emphasis=t.emphasis||{},t.emphasis[e]=t.emphasis[e]||{};for(var i=0,r=n.length;i=0||r&&xn(r,s)<0)){var l=n.getShallow(s,e);null!=l&&(o[t[a][0]]=l)}}return o}}var yl=ml([["fill","color"],["shadowBlur"],["shadowOffsetX"],["shadowOffsetY"],["opacity"],["shadowColor"]]),_l=function(){function t(){}return t.prototype.getAreaStyle=function(t,e){return yl(this,t,e)},t}(),bl=new co(50);function xl(t){if("string"==typeof t){var e=bl.get(t);return e&&e.image}return t}function wl(t,e,n,i,r){if(t){if("string"==typeof t){if(e&&e.__zrImageSrc===t||!n)return e;var o=bl.get(t),a={hostEl:n,cb:i,cbPayload:r};return o?!Cl(e=o.image)&&o.pending.push(a):((e=en.loadImage(t,Sl,Sl)).__zrImageSrc=t,bl.put(t,e.__cachedImgObj={image:e,pending:[a]})),e}return t}return e}function Sl(){var t=this.__cachedImgObj;this.onload=this.onerror=this.__cachedImgObj=null;for(var e=0;e=s;c++)l-=s;var d=ja(a,n);return d>l&&(n="",d=0),l=t-d,r.ellipsis=n,r.ellipsisWidth=d,r.contentWidth=l,r.containerWidth=t,r}function Il(t,e,n){var i=n.containerWidth,r=n.contentWidth,o=n.fontMeasureInfo;if(!i)return t.textLine="",void(t.isTruncated=!1);var a=ja(o,e);if(a<=i)return t.textLine=e,void(t.isTruncated=!1);for(var s=0;;s++){if(a<=r||s>=n.maxIterations){e+=n.ellipsis;break}var l=0===s?Dl(e,r,o):a>0?Math.floor(e.length*r/a):0;a=ja(o,e=e.substr(0,l))}""===e&&(e=n.placeholder),t.textLine=e,t.isTruncated=!0}function Dl(t,e,n){for(var i=0,r=0,o=t.length;r0&&f+i.accumWidth>i.width&&(o=e.split("\n"),u=!0),i.accumWidth=f}else{var g=Ol(e,d,i.width,i.breakAll,i.accumWidth);i.accumWidth=g.accumWidth+p,a=g.linesWidths,o=g.lines}}o||(o=e.split("\n"));for(var v=Wa(d),m=0;m=32&&e<=591||e>=880&&e<=4351||e>=4608&&e<=5119||e>=7680&&e<=8303}(t)||!!zl[t]}function Ol(t,e,n,i,r){for(var o=[],a=[],s="",l="",c=0,d=0,u=Wa(e),h=0;hn:r+d+f>n)?d?(s||l)&&(g?(s||(s=l,l="",d=c=0),o.push(s),a.push(d-c),l+=p,s="",d=c+=f):(l&&(s+=l,l="",c=0),o.push(s),a.push(d),s=p,d=f)):g?(o.push(l),a.push(c),l=p,c=f):(o.push(p),a.push(f)):(d+=f,g?(l+=p,c+=f):(l&&(s+=l,l="",c=0),s+=p))}else l&&(s+=l,d+=c),o.push(s),a.push(d),s="",l="",c=0,d=0}return l&&(s+=l),s&&(o.push(s),a.push(d)),1===o.length&&(d+=r),{accumWidth:d,lines:o,linesWidths:a}}function $l(t,e,n,i,r,o){if(t.baseX=n,t.baseY=i,t.outerWidth=t.outerHeight=null,e){var a=2*e.width,s=2*e.height;cr.set(Rl,Za(n,a,r),Ka(i,s,o),a,s),cr.intersect(e,Rl,null,Hl);var l=Hl.outIntersectRect;t.outerWidth=l.width,t.outerHeight=l.height,t.baseX=Za(l.x,l.width,r,!0),t.baseY=Ka(l.y,l.height,o,!0)}}var Rl=new cr(0,0,0,0),Hl={outIntersectRect:{},clamp:!0};function Fl(t){return null!=t?t+="":t=""}function Bl(t,e,n,i){var r=new cr(Za(t.x||0,e,t.textAlign),Ka(t.y||0,n,t.textBaseline),e,n),o=null!=i?i:Vl(t)?t.lineWidth:0;return o>0&&(r.x-=o/2,r.y-=o/2,r.width+=o,r.height+=o),r}function Vl(t){var e=t.stroke;return null!=e&&"none"!==e&&t.lineWidth>0}var Wl="__zr_style_"+Math.round(10*Math.random()),Ul={shadowBlur:0,shadowOffsetX:0,shadowOffsetY:0,shadowColor:"#000",opacity:1,blend:"source-over"},Gl={style:{shadowBlur:!0,shadowOffsetX:!0,shadowOffsetY:!0,shadowColor:!0,opacity:!0}};Ul[Wl]=!0;var ql=["z","z2","invisible"],jl=["invisible"],Xl=function(t){function e(e){return t.call(this,e)||this}var n;return y(e,t),e.prototype._init=function(e){for(var n=In(e),i=0;i1e-4)return s[0]=t-n,s[1]=e-i,l[0]=t+n,void(l[1]=e+i);if(nc[0]=tc(r)*n+t,nc[1]=Jl(r)*i+e,ic[0]=tc(o)*n+t,ic[1]=Jl(o)*i+e,c(s,nc,ic),d(l,nc,ic),(r%=ec)<0&&(r+=ec),(o%=ec)<0&&(o+=ec),r>o&&!a?o+=ec:rr&&(rc[0]=tc(p)*n+t,rc[1]=Jl(p)*i+e,c(s,rc,s),d(l,rc,l))}var uc={M:1,L:2,C:3,Q:4,A:5,Z:6,R:7},hc=[],pc=[],fc=[],gc=[],vc=[],mc=[],yc=Math.min,_c=Math.max,bc=Math.cos,xc=Math.sin,wc=Math.abs,Sc=Math.PI,Cc=2*Sc,kc="undefined"!=typeof Float32Array,Mc=[];function Tc(t){return Math.round(t/Sc*1e8)/1e8%2*Sc}var Ic=function(){function t(t){this.dpr=1,this._xi=0,this._yi=0,this._x0=0,this._y0=0,this._len=0,t&&(this._saveData=!1),this._saveData&&(this.data=[])}var e;return t.prototype.increaseVersion=function(){this._version++},t.prototype.getVersion=function(){return this._version},t.prototype.setScale=function(t,e,n){(n=n||0)>0&&(this._ux=wc(n/Da/t)||0,this._uy=wc(n/Da/e)||0)},t.prototype.setDPR=function(t){this.dpr=t},t.prototype.setContext=function(t){this._ctx=t},t.prototype.getContext=function(){return this._ctx},t.prototype.beginPath=function(){return this._ctx&&this._ctx.beginPath(),this.reset(),this},t.prototype.reset=function(){this._saveData&&(this._len=0),this._pathSegLen&&(this._pathSegLen=null,this._pathLen=0),this._version++},t.prototype.moveTo=function(t,e){return this._drawPendingPt(),this.addData(uc.M,t,e),this._ctx&&this._ctx.moveTo(t,e),this._x0=t,this._y0=e,this._xi=t,this._yi=e,this},t.prototype.lineTo=function(t,e){var n=wc(t-this._xi),i=wc(e-this._yi),r=n>this._ux||i>this._uy;if(this.addData(uc.L,t,e),this._ctx&&r&&this._ctx.lineTo(t,e),r)this._xi=t,this._yi=e,this._pendingPtDist=0;else{var o=n*n+i*i;o>this._pendingPtDist&&(this._pendingPtX=t,this._pendingPtY=e,this._pendingPtDist=o)}return this},t.prototype.bezierCurveTo=function(t,e,n,i,r,o){return this._drawPendingPt(),this.addData(uc.C,t,e,n,i,r,o),this._ctx&&this._ctx.bezierCurveTo(t,e,n,i,r,o),this._xi=r,this._yi=o,this},t.prototype.quadraticCurveTo=function(t,e,n,i){return this._drawPendingPt(),this.addData(uc.Q,t,e,n,i),this._ctx&&this._ctx.quadraticCurveTo(t,e,n,i),this._xi=n,this._yi=i,this},t.prototype.arc=function(t,e,n,i,r,o){this._drawPendingPt(),Mc[0]=i,Mc[1]=r,function(t,e){var n=Tc(t[0]);n<0&&(n+=Cc);var i=n-t[0],r=t[1];r+=i,!e&&r-n>=Cc?r=n+Cc:e&&n-r>=Cc?r=n-Cc:!e&&n>r?r=n+(Cc-Tc(n-r)):e&&n0&&o))for(var a=0;ac.length&&(this._expandData(),c=this.data);for(var d=0;d0&&(this._ctx&&this._ctx.lineTo(this._pendingPtX,this._pendingPtY),this._pendingPtDist=0)},t.prototype._expandData=function(){if(!(this.data instanceof Array)){for(var t=[],e=0;e11&&(this.data=new Float32Array(t)))}},t.prototype.getBoundingRect=function(){fc[0]=fc[1]=vc[0]=vc[1]=Number.MAX_VALUE,gc[0]=gc[1]=mc[0]=mc[1]=-Number.MAX_VALUE;var t,e=this.data,n=0,i=0,r=0,o=0;for(t=0;tn||wc(v)>i||u===e-1)&&(f=Math.sqrt(D*D+v*v),r=g,o=_);break;case uc.C:var m=t[u++],y=t[u++],_=(g=t[u++],t[u++]),b=t[u++],x=t[u++];f=Qr(r,o,m,y,g,_,b,x,10),r=b,o=x;break;case uc.Q:f=io(r,o,m=t[u++],y=t[u++],g=t[u++],_=t[u++],10),r=g,o=_;break;case uc.A:var w=t[u++],S=t[u++],C=t[u++],k=t[u++],M=t[u++],T=t[u++],I=T+M;u+=1,p&&(a=bc(M)*C+w,s=xc(M)*k+S),f=_c(C,k)*yc(Cc,Math.abs(T)),r=bc(I)*C+w,o=xc(I)*k+S;break;case uc.R:a=r=t[u++],s=o=t[u++],f=2*t[u++]+2*t[u++];break;case uc.Z:var D=a-r;v=s-o;f=Math.sqrt(D*D+v*v),r=a,o=s}f>=0&&(l[d++]=f,c+=f)}return this._pathLen=c,c},t.prototype.rebuildPath=function(t,e){var n,i,r,o,a,s,l,c,d,u,h=this.data,p=this._ux,f=this._uy,g=this._len,v=e<1,m=0,y=0,_=0;if(!v||(this._pathSegLen||this._calculateLength(),l=this._pathSegLen,c=e*this._pathLen))t:for(var b=0;b0&&(t.lineTo(d,u),_=0),x){case uc.M:n=r=h[b++],i=o=h[b++],t.moveTo(r,o);break;case uc.L:a=h[b++],s=h[b++];var S=wc(a-r),C=wc(s-o);if(S>p||C>f){if(v){if(m+(X=l[y++])>c){var k=(c-m)/X;t.lineTo(r*(1-k)+a*k,o*(1-k)+s*k);break t}m+=X}t.lineTo(a,s),r=a,o=s,_=0}else{var M=S*S+C*C;M>_&&(d=a,u=s,_=M)}break;case uc.C:var T=h[b++],I=h[b++],D=h[b++],A=h[b++],P=h[b++],L=h[b++];if(v){if(m+(X=l[y++])>c){Kr(r,T,D,P,k=(c-m)/X,hc),Kr(o,I,A,L,k,pc),t.bezierCurveTo(hc[1],pc[1],hc[2],pc[2],hc[3],pc[3]);break t}m+=X}t.bezierCurveTo(T,I,D,A,P,L),r=P,o=L;break;case uc.Q:T=h[b++],I=h[b++],D=h[b++],A=h[b++];if(v){if(m+(X=l[y++])>c){no(r,T,D,k=(c-m)/X,hc),no(o,I,A,k,pc),t.quadraticCurveTo(hc[1],pc[1],hc[2],pc[2]);break t}m+=X}t.quadraticCurveTo(T,I,D,A),r=D,o=A;break;case uc.A:var E=h[b++],z=h[b++],N=h[b++],O=h[b++],$=h[b++],R=h[b++],H=h[b++],F=!h[b++],B=N>O?N:O,V=wc(N-O)>.001,W=$+R,U=!1;if(v)m+(X=l[y++])>c&&(W=$+R*(c-m)/X,U=!0),m+=X;if(V&&t.ellipse?t.ellipse(E,z,N,O,H,$,W,F):t.arc(E,z,B,$,W,F),U)break t;w&&(n=bc($)*N+E,i=xc($)*O+z),r=bc(W)*N+E,o=xc(W)*O+z;break;case uc.R:n=r=h[b],i=o=h[b+1],a=h[b++],s=h[b++];var G=h[b++],q=h[b++];if(v){if(m+(X=l[y++])>c){var j=c-m;t.moveTo(a,s),t.lineTo(a+yc(j,G),s),(j-=G)>0&&t.lineTo(a+G,s+yc(j,q)),(j-=q)>0&&t.lineTo(a+_c(G-j,0),s+q),(j-=G)>0&&t.lineTo(a,s+_c(q-j,0));break t}m+=X}t.rect(a,s,G,q);break;case uc.Z:if(v){var X;if(m+(X=l[y++])>c){k=(c-m)/X;t.lineTo(r*(1-k)+n*k,o*(1-k)+i*k);break t}m+=X}t.closePath(),r=n,o=i}}},t.prototype.clone=function(){var e=new t,n=this.data;return e.data=n.slice?n.slice():Array.prototype.slice.call(n),e._len=this._len,e},t.prototype.canSave=function(){return!!this._saveData},t.CMD=uc,t.initDefaultProps=((e=t.prototype)._saveData=!0,e._ux=0,e._uy=0,e._pendingPtDist=0,void(e._version=0)),t}();function Dc(t,e,n,i,r,o,a){if(0===r)return!1;var s=r,l=0;if(a>e+s&&a>i+s||at+s&&o>n+s||oe+u&&d>i+u&&d>o+u&&d>s+u||dt+u&&c>n+u&&c>r+u&&c>a+u||c=0&&pe+c&&l>i+c&&l>o+c||lt+c&&s>n+c&&s>r+c||s=0&&gn||d+cr&&(r+=zc);var h=Math.atan2(l,s);return h<0&&(h+=zc),h>=i&&h<=r||h+zc>=i&&h+zc<=r}function Oc(t,e,n,i,r,o){if(o>e&&o>i||or?s:0}var $c=Ic.CMD,Rc=2*Math.PI;var Hc=[-1,-1,-1],Fc=[-1,-1];function Bc(){var t=Fc[0];Fc[0]=Fc[1],Fc[1]=t}function Vc(t,e,n,i,r,o,a,s,l,c){if(c>e&&c>i&&c>o&&c>s||c1&&Bc(),p=jr(e,i,o,s,Fc[0]),h>1&&(f=jr(e,i,o,s,Fc[1]))),2===h?ve&&s>i&&s>o||s=0&&d<=1&&(r[l++]=d);else{var c=a*a-4*o*s;if(Gr(c))(d=-a/(2*o))>=0&&d<=1&&(r[l++]=d);else if(c>0){var d,u=$r(c),h=(-a-u)/(2*o);(d=(-a+u)/(2*o))>=0&&d<=1&&(r[l++]=d),h>=0&&h<=1&&(r[l++]=h)}}return l}(e,i,o,s,Hc);if(0===l)return 0;var c=eo(e,i,o);if(c>=0&&c<=1){for(var d=0,u=Jr(e,i,o,c),h=0;hn||s<-n)return 0;var l=Math.sqrt(n*n-s*s);Hc[0]=-l,Hc[1]=l;var c=Math.abs(i-r);if(c<1e-4)return 0;if(c>=Rc-1e-4){i=0,r=Rc;var d=o?1:-1;return a>=Hc[0]+t&&a<=Hc[1]+t?d:0}if(i>r){var u=i;i=r,r=u}i<0&&(i+=Rc,r+=Rc);for(var h=0,p=0;p<2;p++){var f=Hc[p];if(f+t>a){var g=Math.atan2(s,f);d=o?1:-1;g<0&&(g=Rc+g),(g>=i&&g<=r||g+Rc>=i&&g+Rc<=r)&&(g>Math.PI/2&&g<1.5*Math.PI&&(d=-d),h+=d)}}return h}function Gc(t,e,n,i,r){for(var o,a,s=t.data,l=t.len(),c=0,d=0,u=0,h=0,p=0,f=0;f1&&(n||(c+=Oc(d,u,h,p,i,r))),v&&(h=d=s[f],p=u=s[f+1]),g){case $c.M:d=h=s[f++],u=p=s[f++];break;case $c.L:if(n){if(Dc(d,u,s[f],s[f+1],e,i,r))return!0}else c+=Oc(d,u,s[f],s[f+1],i,r)||0;d=s[f++],u=s[f++];break;case $c.C:if(n){if(Ac(d,u,s[f++],s[f++],s[f++],s[f++],s[f],s[f+1],e,i,r))return!0}else c+=Vc(d,u,s[f++],s[f++],s[f++],s[f++],s[f],s[f+1],i,r)||0;d=s[f++],u=s[f++];break;case $c.Q:if(n){if(Pc(d,u,s[f++],s[f++],s[f],s[f+1],e,i,r))return!0}else c+=Wc(d,u,s[f++],s[f++],s[f],s[f+1],i,r)||0;d=s[f++],u=s[f++];break;case $c.A:var m=s[f++],y=s[f++],_=s[f++],b=s[f++],x=s[f++],w=s[f++];f+=1;var S=!!(1-s[f++]);o=Math.cos(x)*_+m,a=Math.sin(x)*b+y,v?(h=o,p=a):c+=Oc(d,u,o,a,i,r);var C=(i-m)*b/_+m;if(n){if(Nc(m,y,b,x,x+w,S,e,C,r))return!0}else c+=Uc(m,y,b,x,x+w,S,C,r);d=Math.cos(x+w)*_+m,u=Math.sin(x+w)*b+y;break;case $c.R:if(h=d=s[f++],p=u=s[f++],o=h+s[f++],a=p+s[f++],n){if(Dc(h,p,o,p,e,i,r)||Dc(o,p,o,a,e,i,r)||Dc(o,a,h,a,e,i,r)||Dc(h,a,h,p,e,i,r))return!0}else c+=Oc(o,p,o,a,i,r),c+=Oc(h,a,h,p,i,r);break;case $c.Z:if(n){if(Dc(d,u,h,p,e,i,r))return!0}else c+=Oc(d,u,h,p,i,r);d=h,u=p}}return n||function(t,e){return Math.abs(t-e)<1e-4}(u,p)||(c+=Oc(d,u,h,p,i,r)||0),0!==c}var qc=bn({fill:"#000",stroke:null,strokePercent:1,fillOpacity:1,strokeOpacity:1,lineDashOffset:0,lineWidth:1,lineCap:"butt",miterLimit:10,strokeNoScale:!1,strokeFirst:!1},Ul),jc={style:bn({fill:!0,stroke:!0,strokePercent:!0,fillOpacity:!0,strokeOpacity:!0,lineDashOffset:!0,lineWidth:!0,miterLimit:!0},Gl.style)},Xc=Ba.concat(["invisible","culling","z","z2","zlevel","parent"]),Yc=function(t){function e(e){return t.call(this,e)||this}var n;return y(e,t),e.prototype.update=function(){var n=this;t.prototype.update.call(this);var i=this.style;if(i.decal){var r=this._decalEl=this._decalEl||new e;r.buildPath===e.prototype.buildPath&&(r.buildPath=function(t){n.buildPath(t,n.shape)}),r.silent=!0;var o=r.style;for(var a in i)o[a]!==i[a]&&(o[a]=i[a]);o.fill=i.fill?i.decal:null,o.decal=null,o.shadowColor=null,i.strokeFirst&&(o.stroke=null);for(var s=0;s.5?Aa:e>.2?"#eee":Pa}if(t)return Pa}return Aa},e.prototype.getInsideTextStroke=function(t){var e=this.style.fill;if(En(e)){var n=this.__zr;if(!(!n||!n.isDarkMode())===Io(t,0)<.4)return e}},e.prototype.buildPath=function(t,e,n){},e.prototype.pathUpdated=function(){this.__dirty&=-5},e.prototype.getUpdatedPathProxy=function(t){return!this.path&&this.createPathProxy(),this.path.beginPath(),this.buildPath(this.path,this.shape,t),this.path},e.prototype.createPathProxy=function(){this.path=new Ic(!1)},e.prototype.hasStroke=function(){var t=this.style,e=t.stroke;return!(null==e||"none"===e||!(t.lineWidth>0))},e.prototype.hasFill=function(){var t=this.style.fill;return null!=t&&"none"!==t},e.prototype.getBoundingRect=function(){var t=this._rect,e=this.style,n=!t;if(n){var i=!1;this.path||(i=!0,this.createPathProxy());var r=this.path;(i||4&this.__dirty)&&(r.beginPath(),this.buildPath(r,this.shape,!1),this.pathUpdated()),t=r.getBoundingRect()}if(this._rect=t,this.hasStroke()&&this.path&&this.path.len()>0){var o=this._rectStroke||(this._rectStroke=t.clone());if(this.__dirty||n){o.copy(t);var a=e.strokeNoScale?this.getLineScale():1,s=e.lineWidth;if(!this.hasFill()){var l=this.strokeContainThreshold;s=Math.max(s,null==l?4:l)}a>1e-10&&(o.width+=s/a,o.height+=s/a,o.x-=s/a/2,o.y-=s/a/2)}return o}return t},e.prototype.contain=function(t,e){var n=this.transformCoordToLocal(t,e),i=this.getBoundingRect(),r=this.style;if(t=n[0],e=n[1],i.contain(t,e)){var o=this.path;if(this.hasStroke()){var a=r.lineWidth,s=r.strokeNoScale?this.getLineScale():1;if(s>1e-10&&(this.hasFill()||(a=Math.max(a,this.strokeContainThreshold)),function(t,e,n,i){return Gc(t,e,!0,n,i)}(o,a/s,t,e)))return!0}if(this.hasFill())return function(t,e,n){return Gc(t,0,!1,e,n)}(o,t,e)}return!1},e.prototype.dirtyShape=function(){this.__dirty|=4,this._rect&&(this._rect=null),this._decalEl&&this._decalEl.dirtyShape(),this.markRedraw()},e.prototype.dirty=function(){this.dirtyStyle(),this.dirtyShape()},e.prototype.animateShape=function(t){return this.animate("shape",t)},e.prototype.updateDuringAnimation=function(t){"style"===t?this.dirtyStyle():"shape"===t?this.dirtyShape():this.markRedraw()},e.prototype.attrKV=function(e,n){"shape"===e?this.setShape(n):t.prototype.attrKV.call(this,e,n)},e.prototype.setShape=function(t,e){var n=this.shape;return n||(n=this.shape={}),"string"==typeof t?n[t]=e:_n(n,t),this.dirtyShape(),this},e.prototype.shapeChanged=function(){return!!(4&this.__dirty)},e.prototype.createStyle=function(t){return ni(qc,t)},e.prototype._innerSaveToNormal=function(e){t.prototype._innerSaveToNormal.call(this,e);var n=this._normalState;e.shape&&!n.shape&&(n.shape=_n({},this.shape))},e.prototype._applyStateObj=function(e,n,i,r,o,a){t.prototype._applyStateObj.call(this,e,n,i,r,o,a);var s,l=!(n&&r);if(n&&n.shape?o?r?s=n.shape:(s=_n({},i.shape),_n(s,n.shape)):(s=_n({},r?this.shape:i.shape),_n(s,n.shape)):l&&(s=i.shape),s)if(o){this.shape=_n({},this.shape);for(var c={},d=In(s),u=0;uc&&(n*=c/(a=n+i),i*=c/a),r+o>c&&(r*=c/(a=r+o),o*=c/a),i+r>d&&(i*=d/(a=i+r),r*=d/a),n+o>d&&(n*=d/(a=n+o),o*=d/a),t.moveTo(s+n,l),t.lineTo(s+c-i,l),0!==i&&t.arc(s+c-i,l+i,i,-Math.PI/2,0),t.lineTo(s+c,l+d-r),0!==r&&t.arc(s+c-r,l+d-r,r,0,Math.PI/2),t.lineTo(s+o,l+d),0!==o&&t.arc(s+o,l+d-o,o,Math.PI/2,Math.PI),t.lineTo(s,l+n),0!==n&&t.arc(s+n,l+n,n,Math.PI,1.5*Math.PI)}(t,e):t.rect(n,i,r,o)},e.prototype.isZeroArea=function(){return!this.shape.width||!this.shape.height},e}(Yc);sd.prototype.type="rect";var ld={fill:"#000"},cd={},dd={style:bn({fill:!0,stroke:!0,fillOpacity:!0,strokeOpacity:!0,lineWidth:!0,fontSize:!0,lineHeight:!0,width:!0,height:!0,textShadowColor:!0,textShadowBlur:!0,textShadowOffsetX:!0,textShadowOffsetY:!0,backgroundColor:!0,padding:!0,borderColor:!0,borderWidth:!0,borderRadius:!0},Gl.style)},ud=function(t){function e(e){var n=t.call(this)||this;return n.type="text",n._children=[],n._defaultStyle=ld,n.attr(e),n}return y(e,t),e.prototype.childrenRef=function(){return this._children},e.prototype.update=function(){t.prototype.update.call(this),this.styleChanged()&&this._updateSubTexts();for(var e=0;em&&p){var _=Math.floor(m/h);f=f||v.length>_,y=(v=v.slice(0,_)).length*h}if(r&&d&&null!=g)for(var b=Tl(g,c,e.ellipsis,{minChar:e.truncateMinChar,placeholder:e.placeholder}),x={},w=0;w0,k=0;kg&&El(o,a.substring(g,v),e,f),El(o,h[2],e,f,h[1]),g=kl.lastIndex}gu){var z=o.lines.length;I>0?(k.tokens=k.tokens.slice(0,I),S(k,T,M),o.lines=o.lines.slice(0,C+1)):o.lines=o.lines.slice(0,C),o.isTruncated=o.isTruncated||o.lines.length=0&&"right"===(T=_[M]).align;)this._placeToken(T,t,x,f,k,"right",v),w-=T.width,k-=T.width,M--;for(C+=(s-(C-p)-(g-k)-w)/2;S<=M;)T=_[S],this._placeToken(T,t,x,f,C+T.width/2,"center",v),C+=T.width,S++;f+=x}},e.prototype._placeToken=function(t,e,n,i,r,o,a){var s=e.rich[t.styleName]||{};s.text=t.text;var l=t.verticalAlign,c=i+n/2;"top"===l?c=i+t.height/2:"bottom"===l&&(c=i+n-t.height/2),!t.isLineHolder&&Sd(s)&&this._renderBackground(s,e,"right"===o?r-t.width:"center"===o?r-t.width/2:r,c-t.height/2,t.width,t.height);var d=!!s.backgroundColor,u=t.textPadding;u&&(r=xd(r,o,u),c-=t.height/2-u[0]-t.innerHeight/2);var h=this._getOrCreateChild(Kc),p=h.createStyle();h.useStyle(p);var f=this._defaultStyle,g=!1,v=0,m=!1,y=bd("fill"in s?s.fill:"fill"in e?e.fill:(g=!0,f.fill)),_=_d("stroke"in s?s.stroke:"stroke"in e?e.stroke:d||a||f.autoStroke&&!g?null:(v=2,m=!0,f.stroke)),b=s.textShadowBlur>0||e.textShadowBlur>0;p.text=t.text,p.x=r,p.y=c,b&&(p.shadowBlur=s.textShadowBlur||e.textShadowBlur||0,p.shadowColor=s.textShadowColor||e.textShadowColor||"transparent",p.shadowOffsetX=s.textShadowOffsetX||e.textShadowOffsetX||0,p.shadowOffsetY=s.textShadowOffsetY||e.textShadowOffsetY||0),p.textAlign=o,p.textBaseline="middle",p.font=t.font||Ke,p.opacity=Un(s.opacity,e.opacity,1),vd(p,s),_&&(p.lineWidth=Un(s.lineWidth,e.lineWidth,v),p.lineDash=Wn(s.lineDash,e.lineDash),p.lineDashOffset=e.lineDashOffset||0,p.stroke=_),y&&(p.fill=y),h.setBoundingRect(Bl(p,t.contentWidth,t.contentHeight,m?0:null))},e.prototype._renderBackground=function(t,e,n,i,r,o){var a,s,l,c=t.backgroundColor,d=t.borderWidth,u=t.borderColor,h=c&&c.image,p=c&&!h,f=t.borderRadius,g=this;if(p||t.lineHeight||d&&u){(a=this._getOrCreateChild(sd)).useStyle(a.createStyle()),a.style.fill=null;var v=a.shape;v.x=n,v.y=i,v.width=r,v.height=o,v.r=f,a.dirtyShape()}if(p)(l=a.style).fill=c||null,l.fillOpacity=Wn(t.fillOpacity,1);else if(h){(s=this._getOrCreateChild(td)).onload=function(){g.dirtyStyle()};var m=s.style;m.image=c.image,m.x=n,m.y=i,m.width=r,m.height=o}d&&u&&((l=a.style).lineWidth=d,l.stroke=u,l.strokeOpacity=Wn(t.strokeOpacity,1),l.lineDash=t.borderDash,l.lineDashOffset=t.borderDashOffset||0,a.strokeContainThreshold=0,a.hasFill()&&a.hasStroke()&&(l.strokeFirst=!0,l.lineWidth*=2));var y=(a||s).style;y.shadowBlur=t.shadowBlur||0,y.shadowColor=t.shadowColor||"transparent",y.shadowOffsetX=t.shadowOffsetX||0,y.shadowOffsetY=t.shadowOffsetY||0,y.opacity=Un(t.opacity,e.opacity,1)},e.makeFont=function(t){var e="";return md(t)&&(e=[t.fontStyle,t.fontWeight,gd(t.fontSize),t.fontFamily||"sans-serif"].join(" ")),e&&Xn(e)||t.textFont||t.font},e}(Xl),hd={left:!0,right:1,center:1},pd={top:1,bottom:1,middle:1},fd=["fontStyle","fontWeight","fontSize","fontFamily"];function gd(t){return"string"!=typeof t||-1===t.indexOf("px")&&-1===t.indexOf("rem")&&-1===t.indexOf("em")?isNaN(+t)?"12px":t+"px":t}function vd(t,e){for(var n=0;n=0,o=!1;if(t instanceof Yc){var a=Td(t),s=r&&a.selectFill||a.normalFill,l=r&&a.selectStroke||a.normalStroke;if($d(s)||$d(l)){var c=(i=i||{}).style||{};"inherit"===c.fill?(o=!0,i=_n({},i),(c=_n({},c)).fill=s):!$d(c.fill)&&$d(s)?(o=!0,i=_n({},i),(c=_n({},c)).fill=Ao(s)):!$d(c.stroke)&&$d(l)&&(o||(i=_n({},i),c=_n({},c)),c.stroke=Ao(l)),i.style=c}}if(i&&null==i.z2){o||(i=_n({},i));var d=t.z2EmphasisLift;i.z2=t.z2+(null!=d?d:10)}return i}(this,0,e,n);if("blur"===t)return function(t,e,n){var i=xn(t.currentStates,e)>=0,r=t.style.opacity,o=i?null:function(t,e,n,i){for(var r=t.style,o={},a=0;a0){var o={dataIndex:r,seriesIndex:t.seriesIndex};null!=i&&(o.dataType=i),e.push(o)}})}),e}function du(t,e,n){gu(t,!0),qd(t,Yd),function(t,e,n){var i=Cd(t);null!=e?(i.focus=e,i.blurScope=n):i.focus&&(i.focus=null)}(t,e,n)}function uu(t,e,n,i){i?function(t){gu(t,!1)}(t):du(t,e,n)}var hu=["emphasis","blur","select"],pu={itemStyle:"getItemStyle",lineStyle:"getLineStyle",areaStyle:"getAreaStyle"};function fu(t,e,n,i){n=n||"itemStyle";for(var r=0;r1&&(a*=Su(f),s*=Su(f));var g=(r===o?-1:1)*Su((a*a*(s*s)-a*a*(p*p)-s*s*(h*h))/(a*a*(p*p)+s*s*(h*h)))||0,v=g*a*p/s,m=g*-s*h/a,y=(t+n)/2+ku(u)*v-Cu(u)*m,_=(e+i)/2+Cu(u)*v+ku(u)*m,b=Du([1,0],[(h-v)/a,(p-m)/s]),x=[(h-v)/a,(p-m)/s],w=[(-1*h-v)/a,(-1*p-m)/s],S=Du(x,w);if(Iu(x,w)<=-1&&(S=Mu),Iu(x,w)>=1&&(S=0),S<0){var C=Math.round(S/Mu*1e6)/1e6;S=2*Mu+C%2*Mu}d.addData(c,y,_,a,s,b,S,u,o)}var Pu=/([mlvhzcqtsa])([^mlvhzcqtsa]*)/gi,Lu=/-?([0-9]*\.)?[0-9]+([eE]-?[0-9]+)?/g;var Eu=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return y(e,t),e.prototype.applyTransform=function(t){},e}(Yc);function zu(t){return null!=t.setData}function Nu(t,e){var n=function(t){var e=new Ic;if(!t)return e;var n,i=0,r=0,o=i,a=r,s=Ic.CMD,l=t.match(Pu);if(!l)return e;for(var c=0;cA*A+P*P&&(C=M,k=T),{cx:C,cy:k,x0:-d,y0:-u,x1:C*(r/x-1),y1:k*(r/x-1)}}function Qu(t,e){var n,i=Xu(e.r,0),r=Xu(e.r0||0,0),o=i>0;if(o||r>0){if(o||(i=r,r=0),r>i){var a=i;i=r,r=a}var s=e.startAngle,l=e.endAngle;if(!isNaN(s)&&!isNaN(l)){var c=e.cx,d=e.cy,u=!!e.clockwise,h=qu(l-s),p=h>Bu&&h%Bu;if(p>Zu&&(h=p),i>Zu)if(h>Bu-Zu)t.moveTo(c+i*Wu(s),d+i*Vu(s)),t.arc(c,d,i,s,l,!u),r>Zu&&(t.moveTo(c+r*Wu(l),d+r*Vu(l)),t.arc(c,d,r,l,s,u));else{var f=void 0,g=void 0,v=void 0,m=void 0,y=void 0,_=void 0,b=void 0,x=void 0,w=void 0,S=void 0,C=void 0,k=void 0,M=void 0,T=void 0,I=void 0,D=void 0,A=i*Wu(s),P=i*Vu(s),L=r*Wu(l),E=r*Vu(l),z=h>Zu;if(z){var N=e.cornerRadius;N&&(n=function(t){var e;if(Pn(t)){var n=t.length;if(!n)return t;e=1===n?[t[0],t[0],0,0]:2===n?[t[0],t[0],t[1],t[1]]:3===n?t.concat(t[2]):t}else e=[t,t,t,t];return e}(N),f=n[0],g=n[1],v=n[2],m=n[3]);var O=qu(i-r)/2;if(y=Yu(O,v),_=Yu(O,m),b=Yu(O,f),x=Yu(O,g),C=w=Xu(y,_),k=S=Xu(b,x),(w>Zu||S>Zu)&&(M=i*Wu(l),T=i*Vu(l),I=r*Wu(s),D=r*Vu(s),hZu){var U=Yu(v,C),G=Yu(m,C),q=Ku(I,D,A,P,i,U,u),j=Ku(M,T,L,E,i,G,u);t.moveTo(c+q.cx+q.x0,d+q.cy+q.y0),C0&&t.arc(c+q.cx,d+q.cy,U,Gu(q.y0,q.x0),Gu(q.y1,q.x1),!u),t.arc(c,d,i,Gu(q.cy+q.y1,q.cx+q.x1),Gu(j.cy+j.y1,j.cx+j.x1),!u),G>0&&t.arc(c+j.cx,d+j.cy,G,Gu(j.y1,j.x1),Gu(j.y0,j.x0),!u))}else t.moveTo(c+A,d+P),t.arc(c,d,i,s,l,!u);else t.moveTo(c+A,d+P);if(r>Zu&&z)if(k>Zu){U=Yu(f,k),q=Ku(L,E,M,T,r,-(G=Yu(g,k)),u),j=Ku(A,P,I,D,r,-U,u);t.lineTo(c+q.cx+q.x0,d+q.cy+q.y0),k0&&t.arc(c+q.cx,d+q.cy,G,Gu(q.y0,q.x0),Gu(q.y1,q.x1),!u),t.arc(c,d,r,Gu(q.cy+q.y1,q.cx+q.x1),Gu(j.cy+j.y1,j.cx+j.x1),u),U>0&&t.arc(c+j.cx,d+j.cy,U,Gu(j.y1,j.x1),Gu(j.y0,j.x0),!u))}else t.lineTo(c+L,d+E),t.arc(c,d,r,l,s,u);else t.lineTo(c+L,d+E)}else t.moveTo(c,d);t.closePath()}}}var Ju=function(){this.cx=0,this.cy=0,this.r0=0,this.r=0,this.startAngle=0,this.endAngle=2*Math.PI,this.clockwise=!0,this.cornerRadius=0},th=function(t){function e(e){return t.call(this,e)||this}return y(e,t),e.prototype.getDefaultShape=function(){return new Ju},e.prototype.buildPath=function(t,e){Qu(t,e)},e.prototype.isZeroArea=function(){return this.shape.startAngle===this.shape.endAngle||this.shape.r===this.shape.r0},e}(Yc);th.prototype.type="sector";var eh=function(){this.cx=0,this.cy=0,this.r=0,this.r0=0},nh=function(t){function e(e){return t.call(this,e)||this}return y(e,t),e.prototype.getDefaultShape=function(){return new eh},e.prototype.buildPath=function(t,e){var n=e.cx,i=e.cy,r=2*Math.PI;t.moveTo(n+e.r,i),t.arc(n,i,e.r,0,r,!1),t.moveTo(n+e.r0,i),t.arc(n,i,e.r0,0,r,!0)},e}(Yc);function ih(t,e,n){var i=e.smooth,r=e.points;if(r&&r.length>=2){if(i){var o=function(t,e,n,i){var r,o,a,s,l=[],c=[],d=[],u=[];if(i){a=[1/0,1/0],s=[-1/0,-1/0];for(var h=0,p=t.length;hkh[1]){if(r=!1,Mh.negativeSize||n)return r;var s=Sh(kh[0]-Ch[1]),l=Sh(Ch[0]-kh[1]);xh(s,l)>Ih.len()&&(s=l||!Mh.bidirectional)&&(Yi.scale(Th,a,-l*i),Mh.useDir&&Mh.calcDirMTV()))}}return r},t.prototype._getProjMinMaxOnAxis=function(t,e,n){for(var i=this._axes[t],r=this._origin,o=e[0].dot(i)+r[t],a=o,s=o,l=1;l0){var u={duration:d.duration,delay:d.delay||0,easing:d.easing,done:o,force:!!o||!!a,setToFinal:!c,scope:t,during:a};l?e.animateFrom(n,u):e.animateTo(n,u)}else e.stopAnimation(),!l&&e.attr(n),a&&a(1),o&&o()}function zh(t,e,n,i,r,o){Eh("update",t,e,n,i,r,o)}function Nh(t,e,n,i,r,o){Eh("enter",t,e,n,i,r,o)}function Oh(t){if(!t.__zr)return!0;for(var e=0;e=-1e-6)return!1;var f=t-r,g=e-o,v=np(f,g,c,d)/p;if(v<0||v>1)return!1;var m=np(f,g,u,h)/p;return!(m<0||m>1)}function np(t,e,n,i){return t*i-n*e}function ip(t,e,n,i,r){return null==e||(Nn(e)?rp[0]=rp[1]=rp[2]=rp[3]=e:(rp[0]=e[0],rp[1]=e[1],rp[2]=e[2],rp[3]=e[3]),i&&(rp[0]=bs(0,rp[0]),rp[1]=bs(0,rp[1]),rp[2]=bs(0,rp[2]),rp[3]=bs(0,rp[3])),n&&(rp[0]=-rp[0],rp[1]=-rp[1],rp[2]=-rp[2],rp[3]=-rp[3]),op(t,rp,"x","width",3,1,r&&r[0]||0),op(t,rp,"y","height",0,2,r&&r[1]||0)),t}var rp=[0,0,0,0];function op(t,e,n,i,r,o,a){var s=e[o]+e[r],l=t[i];t[i]+=s,a=bs(0,_s(a,l)),t[i]=0?-e[r]:e[o]>=0?l+e[o]:xs(s)>1e-8?(l-a)*e[r]/s:0):t[n]-=e[r]}function ap(t){var e=t.itemTooltipOption,n=t.componentModel,i=t.itemName,r=En(e)?{formatter:e}:e,o=n.mainType,a=n.componentIndex,s={componentType:o,name:i,$vars:["name"]};s[o+"Index"]=a;var l=t.formatterParamsExtra;l&&Cn(In(l),function(t){ii(s,t)||(s[t]=l[t],s.$vars.push(t))});var c=Cd(t.el);c.componentMainType=o,c.componentIndex=a,c.tooltipConfig={name:i,option:bn({content:i,encodeHTMLContent:!0,formatterParams:s},r)}}function sp(t,e){var n;t.isGroup&&(n=e(t)),n||t.traverse(e)}function lp(t,e){if(t)if(Pn(t))for(var n=0;ne&&(e=i),ie&&(n=e=0),{min:n,max:e}},clipPointsByRect:function(t,e){return kn(t,function(t){var n=t[0];n=bs(n,e.x),n=_s(n,e.x+e.width);var i=t[1];return i=bs(i,e.y),[n,i=_s(i,e.y+e.height)]})},clipRectByRect:function(t,e){var n=bs(t.x,e.x),i=_s(t.x+t.width,e.x+e.width),r=bs(t.y,e.y),o=_s(t.y+t.height,e.y+e.height);if(i>=n&&o>=r)return{x:n,y:r,width:i-n,height:o-r}},createIcon:tp,ensureCopyRect:up,ensureCopyTransform:hp,expandOrShrinkRect:ip,extendPath:function(t,e){return Vh(t,e)},extendShape:function(t){return Yc.extend(t)},getShapeClass:function(t){if(Hh.hasOwnProperty(t))return Hh[t]},getTransform:function(t,e){for(var n=Wi([]);t&&t!==e;)Gi(n,t.getLocalTransform(),n),t=t.parent;return n},groupTransition:Jh,initProps:Nh,isBoundingRectAxisAligned:cp,isElementRemoved:Oh,lineLineIntersect:ep,linePolygonIntersect:function(t,e,n,i,r){for(var o=0,a=r[r.length-1];oxs(o[1])?o[0]>0?"right":"left":o[1]>0?"bottom":"top"},traverseElements:lp,traverseUpdateZ:fp,updateProps:zh}),mp={};function yp(t,e,n){var i,r=t.labelFetcher,o=t.labelDataIndex,a=t.labelDimIndex,s=e.normal;r&&(i=r.getFormattedLabel(o,"normal",null,a,s&&s.get("formatter"),null!=n?{interpolatedValue:n}:null)),null==i&&(i=Ln(t.defaultText)?t.defaultText(o,t,n):t.defaultText);for(var l={normal:i},c=0;c-1?Up:qp;function Zp(t,e){t=t.toUpperCase(),Xp[t]=new Bp(e),jp[t]=e}Zp(Gp,{time:{month:["January","February","March","April","May","June","July","August","September","October","November","December"],monthAbbr:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],dayOfWeek:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],dayOfWeekAbbr:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"]},legend:{selector:{all:"All",inverse:"Inv"}},toolbox:{brush:{title:{rect:"Box Select",polygon:"Lasso Select",lineX:"Horizontally Select",lineY:"Vertically Select",keep:"Keep Selections",clear:"Clear Selections"}},dataView:{title:"Data View",lang:["Data View","Close","Refresh"]},dataZoom:{title:{zoom:"Zoom",back:"Zoom Reset"}},magicType:{title:{line:"Switch to Line Chart",bar:"Switch to Bar Chart",stack:"Stack",tiled:"Tile"}},restore:{title:"Restore"},saveAsImage:{title:"Save as Image",lang:["Right Click to Save Image"]}},series:{typeNames:{pie:"Pie chart",bar:"Bar chart",line:"Line chart",scatter:"Scatter plot",effectScatter:"Ripple scatter plot",radar:"Radar chart",tree:"Tree",treemap:"Treemap",boxplot:"Boxplot",candlestick:"Candlestick",k:"K line chart",heatmap:"Heat map",map:"Map",parallel:"Parallel coordinate map",lines:"Line graph",graph:"Relationship graph",sankey:"Sankey diagram",funnel:"Funnel chart",gauge:"Gauge",pictorialBar:"Pictorial bar",themeRiver:"Theme River Map",sunburst:"Sunburst",custom:"Custom chart",chart:"Chart"}},aria:{general:{withTitle:'This is a chart about "{title}"',withoutTitle:"This is a chart"},series:{single:{prefix:"",withName:" with type {seriesType} named {seriesName}.",withoutName:" with type {seriesType}."},multiple:{prefix:". It consists of {seriesCount} series count.",withName:" The {seriesId} series is a {seriesType} representing {seriesName}.",withoutName:" The {seriesId} series is a {seriesType}.",separator:{middle:"",end:""}}},data:{allData:"The data is as follows: ",partialData:"The first {displayCnt} items are: ",withName:"the data for {name} is {value}",withoutName:"{value}",separator:{middle:", ",end:". "}}}}),Zp(Up,{time:{month:["一月","二月","三月","四月","五月","六月","七月","八月","九月","十月","十一月","十二月"],monthAbbr:["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"],dayOfWeek:["星期日","星期一","星期二","星期三","星期四","星期五","星期六"],dayOfWeekAbbr:["日","一","二","三","四","五","六"]},legend:{selector:{all:"全选",inverse:"反选"}},toolbox:{brush:{title:{rect:"矩形选择",polygon:"圈选",lineX:"横向选择",lineY:"纵向选择",keep:"保持选择",clear:"清除选择"}},dataView:{title:"数据视图",lang:["数据视图","关闭","刷新"]},dataZoom:{title:{zoom:"区域缩放",back:"区域缩放还原"}},magicType:{title:{line:"切换为折线图",bar:"切换为柱状图",stack:"切换为堆叠",tiled:"切换为平铺"}},restore:{title:"还原"},saveAsImage:{title:"保存为图片",lang:["右键另存为图片"]}},series:{typeNames:{pie:"饼图",bar:"柱状图",line:"折线图",scatter:"散点图",effectScatter:"涟漪散点图",radar:"雷达图",tree:"树图",treemap:"矩形树图",boxplot:"箱型图",candlestick:"K线图",k:"K线图",heatmap:"热力图",map:"地图",parallel:"平行坐标图",lines:"线图",graph:"关系图",sankey:"桑基图",funnel:"漏斗图",gauge:"仪表盘图",pictorialBar:"象形柱图",themeRiver:"主题河流图",sunburst:"旭日图",custom:"自定义图表",chart:"图表"}},aria:{general:{withTitle:"这是一个关于“{title}”的图表。",withoutTitle:"这是一个图表,"},series:{single:{prefix:"",withName:"图表类型是{seriesType},表示{seriesName}。",withoutName:"图表类型是{seriesType}。"},multiple:{prefix:"它由{seriesCount}个图表系列组成。",withName:"第{seriesId}个系列是一个表示{seriesName}的{seriesType},",withoutName:"第{seriesId}个系列是一个{seriesType},",separator:{middle:";",end:"。"}}},data:{allData:"其数据是——",partialData:"其中,前{displayCnt}项是——",withName:"{name}的数据是{value}",withoutName:"{value}",separator:{middle:",",end:""}}}});function Kp(){return null}var Qp=1e3,Jp=6e4,tf=36e5,ef=864e5,nf=31536e6,rf={year:/({yyyy}|{yy})/,month:/({MMMM}|{MMM}|{MM}|{M})/,day:/({dd}|{d})/,hour:/({HH}|{H}|{hh}|{h})/,minute:/({mm}|{m})/,second:/({ss}|{s})/,millisecond:/({SSS}|{S})/},of={year:"{yyyy}",month:"{MMM}",day:"{d}",hour:"{HH}:{mm}",minute:"{HH}:{mm}",second:"{HH}:{mm}:{ss}",millisecond:"{HH}:{mm}:{ss} {SSS}"},af="{yyyy}-{MM}-{dd}",sf={year:"{yyyy}",month:"{yyyy}-{MM}",day:af,hour:af+" "+of.hour,minute:af+" "+of.minute,second:af+" "+of.second,millisecond:"{yyyy}-{MM}-{dd} {HH}:{mm}:{ss} {SSS}"},lf=["year","month","day","hour","minute","second","millisecond"],cf=["year","half-year","quarter","month","week","half-week","day","half-day","quarter-day","hour","minute","second","millisecond"];function df(t){return En(t)||Ln(t)?t:function(t){t=t||{};var e={},n=!0;return Cn(lf,function(e){n&&(n=null==t[e])}),Cn(lf,function(i,r){var o=t[i];e[i]={};for(var a=null,s=r;s>=0;s--){var l=lf[s],c=On(o)&&!Pn(o)?o[l]:o,d=void 0;Pn(c)?a=(d=c.slice())[0]||"":En(c)?d=[a=c]:(null==a?a=of[i]:rf[l].test(a)||(a=e[l][l][0]+" "+a),d=[a],n&&(d[1]="{primary|"+a+"}")),e[i][l]=d}}),e}(t)}function uf(t,e){return"0000".substr(0,e-(t+="").length)+t}function hf(t){switch(t){case"half-year":case"quarter":return"month";case"week":case"half-week":return"day";case"half-day":case"quarter-day":return"hour";default:return t}}function pf(t){return t===hf(t)}function ff(t,e,n,i){var r=Ps(t),o=r[mf(n)](),a=r[yf(n)]()+1,s=Math.floor((a-1)/3)+1,l=r[_f(n)](),c=r["get"+(n?"UTC":"")+"Day"](),d=r[bf(n)](),u=(d-1)%12+1,h=r[xf(n)](),p=r[wf(n)](),f=r[Sf(n)](),g=d>=12?"pm":"am",v=g.toUpperCase(),m=i instanceof Bp?i:function(t){return Xp[t]}(i||Yp)||Xp[qp],y=m.getModel("time"),_=y.get("month"),b=y.get("monthAbbr"),x=y.get("dayOfWeek"),w=y.get("dayOfWeekAbbr");return(e||"").replace(/{a}/g,g+"").replace(/{A}/g,v+"").replace(/{yyyy}/g,o+"").replace(/{yy}/g,uf(o%100+"",2)).replace(/{Q}/g,s+"").replace(/{MMMM}/g,_[a-1]).replace(/{MMM}/g,b[a-1]).replace(/{MM}/g,uf(a,2)).replace(/{M}/g,a+"").replace(/{dd}/g,uf(l,2)).replace(/{d}/g,l+"").replace(/{eeee}/g,x[c]).replace(/{ee}/g,w[c]).replace(/{e}/g,c+"").replace(/{HH}/g,uf(d,2)).replace(/{H}/g,d+"").replace(/{hh}/g,uf(u+"",2)).replace(/{h}/g,u+"").replace(/{mm}/g,uf(h,2)).replace(/{m}/g,h+"").replace(/{ss}/g,uf(p,2)).replace(/{s}/g,p+"").replace(/{SSS}/g,uf(f,3)).replace(/{S}/g,f+"")}function gf(t,e){var n=Ps(t),i=n[yf(e)]()+1,r=n[_f(e)](),o=n[bf(e)](),a=n[xf(e)](),s=n[wf(e)](),l=0===n[Sf(e)](),c=l&&0===s,d=c&&0===a,u=d&&0===o,h=u&&1===r;return h&&1===i?"year":h?"month":u?"day":d?"hour":c?"minute":l?"second":"millisecond"}function vf(t,e,n){switch(e){case"year":t[kf(n)](0);case"month":t[Mf(n)](1);case"day":t[Tf(n)](0);case"hour":t[If(n)](0);case"minute":t[Df(n)](0);case"second":t[Af(n)](0)}return t}function mf(t){return t?"getUTCFullYear":"getFullYear"}function yf(t){return t?"getUTCMonth":"getMonth"}function _f(t){return t?"getUTCDate":"getDate"}function bf(t){return t?"getUTCHours":"getHours"}function xf(t){return t?"getUTCMinutes":"getMinutes"}function wf(t){return t?"getUTCSeconds":"getSeconds"}function Sf(t){return t?"getUTCMilliseconds":"getMilliseconds"}function Cf(t){return t?"setUTCFullYear":"setFullYear"}function kf(t){return t?"setUTCMonth":"setMonth"}function Mf(t){return t?"setUTCDate":"setDate"}function Tf(t){return t?"setUTCHours":"setHours"}function If(t){return t?"setUTCMinutes":"setMinutes"}function Df(t){return t?"setUTCSeconds":"setSeconds"}function Af(t){return t?"setUTCMilliseconds":"setMilliseconds"}function Pf(t){if(isNaN(zs(t)))return En(t)?t:"-";var e=(t+"").split(".");return e[0].replace(/(\d{1,3})(?=(?:\d{3})+(?!\d))/g,"$1,")+(e.length>1?"."+e[1]:"")}function Lf(t,e){return t=(t||"").toLowerCase().replace(/-(.)/g,function(t,e){return e.toUpperCase()}),e&&t&&(t=t.charAt(0).toUpperCase()+t.slice(1)),t}var Ef=qn;function zf(t,e,n){function i(t){return t&&Xn(t)?t:"-"}function r(t){return!(null==t||isNaN(t)||!isFinite(t))}var o="time"===e,a=t instanceof Date;if(o||a){var s=o?Ps(t):t;if(!isNaN(+s))return ff(s,"{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}",n);if(a)return"-"}if("ordinal"===e)return zn(t)?i(t):Nn(t)&&r(t)?t+"":"-";var l=zs(t);return r(l)?Pf(l):zn(t)?i(t):"boolean"==typeof t?t+"":"-"}var Nf=["a","b","c","d","e","f","g"],Of=function(t,e){return"{"+t+(null==e?"":e)+"}"};function $f(t,e,n){Pn(e)||(e=[e]);var i=e.length;if(!i)return"";for(var r=e[0].$vars||[],o=0;oi||l.newline?(o=0,d=g,a+=s+n,s=h.height):s=Math.max(s,h.height)}else{var v=h.height+(f?-f.y+h.y:0);(u=a+v)>r||l.newline?(o+=s+n,a=0,u=v,s=h.width):s=Math.max(s,h.width)}l.newline||(l.x=o,l.y=a,l.markRedraw(),"horizontal"===t?o=d+n:a=u+n)})}function Jf(t,e,n){n=Ef(n||0);var i=e.width,r=e.height,o=Ss(t.left,i),a=Ss(t.top,r),s=Ss(t.right,i),l=Ss(t.bottom,r),c=Ss(t.width,i),d=Ss(t.height,r),u=n[2]+n[0],h=n[1]+n[3],p=t.aspect;switch(isNaN(c)&&(c=i-s-h-o),isNaN(d)&&(d=r-l-u-a),null!=p&&(isNaN(c)&&isNaN(d)&&(p>i/r?c=.8*i:d=.8*r),isNaN(c)&&(c=p*d),isNaN(d)&&(d=c/p)),isNaN(o)&&(o=i-s-c-h),isNaN(a)&&(a=r-l-d-u),t.left||t.right){case"center":o=i/2-c/2-n[3];break;case"right":o=i-c-h}switch(t.top||t.bottom){case"middle":case"center":a=r/2-d/2-n[0];break;case"bottom":a=r-d-u}o=o||0,a=a||0,isNaN(c)&&(c=i-h-o-(s||0)),isNaN(d)&&(d=r-u-a-(l||0));var f=new cr((e.x||0)+o+n[3],(e.y||0)+a+n[0],c,d);return f.margin=n,f}An(Qf,"vertical"),An(Qf,"horizontal");var tg=1;function eg(t,e,n){var i,r,o,a,s=t.boxCoordinateSystem;if(s){var l=function(t){var e=t.getShallow("coord",!0),n=Vf;if(null==e){var i=Uf.get(t.type);i&&i.getCoord2&&(n=Wf,e=i.getCoord2(t))}return{coord:e,from:n}}(t),c=l.coord,d=l.from;if(s.dataToLayout){o=tg,a=d;var u=s.dataToLayout(c);i=u.contentRect||u.rect}}return null==o&&(o=tg),o===tg&&(i||(i={x:0,y:0,width:e.getWidth(),height:e.getHeight()}),r=[i.x+i.width/2,i.y+i.height/2]),{type:o,refContainer:i,refPoint:r,boxCoordFrom:a}}function ng(t){var e=t.layoutMode||t.constructor.layoutMode;return On(e)?e:e?{type:e}:null}function ig(t,e,n){var i=n&&n.ignoreSize;!Pn(i)&&(i=[i,i]);var r=a(Kf[0],0),o=a(Kf[1],1);function a(n,r){var o={},a=0,l={},c=0;if(Yf(n,function(e){l[e]=t[e]}),Yf(n,function(t){ii(e,t)&&(o[t]=l[t]=e[t]),s(o,t)&&a++,s(l,t)&&c++}),i[r])return s(e,n[1])?l[n[2]]=null:s(e,n[2])&&(l[n[1]]=null),l;if(2!==c&&a){if(a>=2)return o;for(var d=0;d=0;a--)o=yn(o,n[a],!0);e.defaultOption=o}return e.defaultOption},e.prototype.getReferringComponents=function(t,e){var n=t+"Index",i=t+"Id";return al(this.ecModel,t,{index:this.get(n,!0),id:this.get(i,!0)},e)},e.prototype.getBoxLayoutParams=function(){return e=!1,{left:(t=this).getShallow("left",e),top:t.getShallow("top",e),right:t.getShallow("right",e),bottom:t.getShallow("bottom",e),width:t.getShallow("width",e),height:t.getShallow("height",e)};var t,e},e.prototype.getZLevelKey=function(){return""},e.prototype.setZLevel=function(t){this.option.zlevel=t},e.protoInitialize=((n=e.prototype).type="component",n.id="",n.name="",n.mainType="",n.subType="",void(n.componentIndex=0)),e}(Bp);hl(ag,Bp),vl(ag),function(t){var e={};t.registerSubTypeDefaulter=function(t,n){var i=dl(t);e[i.main]=n},t.determineSubType=function(n,i){var r=i.type;if(!r){var o=dl(n).main;t.hasSubTypes(n)&&e[o]&&(r=e[o](i))}return r}}(ag),function(t,e){function n(t,e){return t[e]||(t[e]={predecessor:[],successor:[]}),t[e]}t.topologicalTravel=function(t,i,r,o){if(t.length){var a=function(t){var i={},r=[];return Cn(t,function(o){var a=n(i,o),s=function(t,e){var n=[];return Cn(t,function(t){xn(e,t)>=0&&n.push(t)}),n}(a.originalDeps=e(o),t);a.entryCount=s.length,0===a.entryCount&&r.push(o),Cn(s,function(t){xn(a.predecessor,t)<0&&a.predecessor.push(t);var e=n(i,t);xn(e.successor,t)<0&&e.successor.push(o)})}),{graph:i,noEntryList:r}}(i),s=a.graph,l=a.noEntryList,c={};for(Cn(t,function(t){c[t]=!0});l.length;){var d=l.pop(),u=s[d],h=!!c[d];h&&(r.call(o,d,u.originalDeps.slice()),delete c[d]),Cn(u.successor,h?f:p)}Cn(c,function(){throw new Error("")})}function p(t){s[t].entryCount--,0===s[t].entryCount&&l.push(t)}function f(t){c[t]=!0,p(t)}}}(ag,function(t){var e=[];Cn(ag.getClassesByMainType(t),function(t){e=e.concat(t.dependencies||t.prototype.dependencies||[])}),e=kn(e,function(t){return dl(t).main}),"dataset"!==t&&xn(e,"dataset")<=0&&e.unshift("dataset");return e});var sg={color:{},darkColor:{},size:{}},lg=sg.color={theme:["#5070dd","#b6d634","#505372","#ff994d","#0ca8df","#ffd10a","#fb628b","#785db0","#3fbe95"],neutral00:"#fff",neutral05:"#f4f7fd",neutral10:"#e8ebf0",neutral15:"#dbdee4",neutral20:"#cfd2d7",neutral25:"#c3c5cb",neutral30:"#b7b9be",neutral35:"#aaacb2",neutral40:"#9ea0a5",neutral45:"#929399",neutral50:"#86878c",neutral55:"#797b7f",neutral60:"#6d6e73",neutral65:"#616266",neutral70:"#54555a",neutral75:"#48494d",neutral80:"#3c3c41",neutral85:"#303034",neutral90:"#232328",neutral95:"#17171b",neutral99:"#000",accent05:"#eff1f9",accent10:"#e0e4f2",accent15:"#d0d6ec",accent20:"#c0c9e6",accent25:"#b1bbdf",accent30:"#a1aed9",accent35:"#91a0d3",accent40:"#8292cc",accent45:"#7285c6",accent50:"#6578ba",accent55:"#5c6da9",accent60:"#536298",accent65:"#4a5787",accent70:"#404c76",accent75:"#374165",accent80:"#2e3654",accent85:"#252b43",accent90:"#1b2032",accent95:"#121521",transparent:"rgba(0,0,0,0)",highlight:"rgba(255,231,130,0.8)"};for(var cg in _n(lg,{primary:lg.neutral80,secondary:lg.neutral70,tertiary:lg.neutral60,quaternary:lg.neutral50,disabled:lg.neutral20,border:lg.neutral30,borderTint:lg.neutral20,borderShade:lg.neutral40,background:lg.neutral05,backgroundTint:"rgba(234,237,245,0.5)",backgroundTransparent:"rgba(255,255,255,0)",backgroundShade:lg.neutral10,shadow:"rgba(0,0,0,0.2)",shadowTint:"rgba(129,130,136,0.2)",axisLine:lg.neutral70,axisLineTint:lg.neutral40,axisTick:lg.neutral70,axisTickMinor:lg.neutral60,axisLabel:lg.neutral70,axisSplitLine:lg.neutral15,axisMinorSplitLine:lg.neutral05}),lg)if(lg.hasOwnProperty(cg)){var dg=lg[cg];"theme"===cg?sg.darkColor.theme=lg.theme.slice():"highlight"===cg?sg.darkColor.highlight="rgba(255,231,130,0.4)":0===cg.indexOf("accent")?sg.darkColor[cg]=Mo(dg,0,function(t){return.5*t},function(t){return Math.min(1,1.3-t)}):sg.darkColor[cg]=Mo(dg,0,function(t){return.9*t},function(t){return 1-Math.pow(t,1.5)})}sg.size={xxs:2,xs:5,s:10,m:15,l:20,xl:30,xxl:40,xxxl:50};var ug="";"undefined"!=typeof navigator&&(ug=navigator.platform||"");var hg="rgba(0, 0, 0, 0.2)",pg=sg.color.theme[0],fg=Mo(pg,0,null,.9),gg={darkMode:"auto",colorBy:"series",color:sg.color.theme,gradientColor:[fg,pg],aria:{decal:{decals:[{color:hg,dashArrayX:[1,0],dashArrayY:[2,5],symbolSize:1,rotation:Math.PI/6},{color:hg,symbol:"circle",dashArrayX:[[8,8],[0,8,8,0]],dashArrayY:[6,0],symbolSize:.8},{color:hg,dashArrayX:[1,0],dashArrayY:[4,3],rotation:-Math.PI/4},{color:hg,dashArrayX:[[6,6],[0,6,6,0]],dashArrayY:[6,0]},{color:hg,dashArrayX:[[1,0],[1,6]],dashArrayY:[1,0,6,0],rotation:Math.PI/4},{color:hg,symbol:"triangle",dashArrayX:[[9,9],[0,9,9,0]],dashArrayY:[7,2],symbolSize:.75}]}},textStyle:{fontFamily:ug.match(/^Win/)?"Microsoft YaHei":"sans-serif",fontSize:12,fontStyle:"normal",fontWeight:"normal"},blendMode:null,stateAnimation:{duration:300,easing:"cubicOut"},animation:"auto",animationDuration:1e3,animationDurationUpdate:500,animationEasing:"cubicInOut",animationEasingUpdate:"cubicInOut",animationThreshold:2e3,progressiveThreshold:3e3,progressive:400,hoverLayerThreshold:3e3,useUTC:!1},vg=ei(["tooltip","label","itemName","itemId","itemGroupId","itemChildGroupId","seriesName"]),mg="original",yg="arrayRows",_g="objectRows",bg="keyedColumns",xg="typedArray",wg="unknown",Sg="column",Cg="row",kg=1,Mg=2,Tg=3,Ig=el();function Dg(t,e,n){var i={},r=Ag(e);if(!r||!t)return i;var o,a,s=[],l=[],c=e.ecModel,d=Ig(c).datasetMap,u=r.uid+"_"+n.seriesLayoutBy;Cn(t=t.slice(),function(e,n){var r=On(e)?e:t[n]={name:e};"ordinal"===r.type&&null==o&&(o=n,a=f(r)),i[r.name]=[]});var h=d.get(u)||d.set(u,{categoryWayDim:a,valueWayDim:0});function p(t,e,n){for(var i=0;ie)return t[i];return t[n-1]}(i,a):n;if(d=d||n,!d||!d.length)return;var u=d[l];r&&(c[r]=u);return s.paletteIdx=(l+1)%d.length,u}(this,Eg,i,r,t,e,n)},t.prototype.clearColorPalette=function(){!function(t,e){e(t).paletteIdx=0,e(t).paletteNameMap={}}(this,Eg)},t}();var Rg="\0_ec_inner",Hg=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return y(e,t),e.prototype.init=function(t,e,n,i,r,o){i=i||{},this.option=null,this._theme=new Bp(i),this._locale=new Bp(r),this._optionManager=o},e.prototype.setOption=function(t,e,n){var i=Vg(e);this._optionManager.setOption(t,n,i),this._resetOption(null,i)},e.prototype.resetOption=function(t,e){return this._resetOption(t,Vg(e))},e.prototype._resetOption=function(t,e){var n=!1,i=this._optionManager;if(!t||"recreate"===t){var r=i.mountOption("recreate"===t);this.option&&"recreate"!==t?(this.restoreData(),this._mergeOption(r,e)):Og(this,r),n=!0}if("timeline"!==t&&"media"!==t||this.restoreData(),!t||"recreate"===t||"timeline"===t){var o=i.getTimelineOption(this);o&&(n=!0,this._mergeOption(o,e))}if(!t||"recreate"===t||"media"===t){var a=i.getMediaOption(this);a.length&&Cn(a,function(t){n=!0,this._mergeOption(t,e)},this)}return n},e.prototype.mergeOption=function(t){this._mergeOption(t,null)},e.prototype._mergeOption=function(t,e){var n=this.option,i=this._componentsMap,r=this._componentsCount,o=[],a=ei(),s=e&&e.replaceMergeMainTypeMap;Ig(this).datasetMap=ei(),Cn(t,function(t,e){null!=t&&(ag.hasClass(e)?e&&(o.push(e),a.set(e,!0)):n[e]=null==n[e]?mn(t):yn(n[e],t,!0))}),s&&s.each(function(t,e){ag.hasClass(e)&&!a.get(e)&&(o.push(e),a.set(e,!0))}),ag.topologicalTravel(o,ag.getAllClassMainTypes(),function(e){var o=function(t,e,n){var i=Lg.get(e);if(!i)return n;var r=i(t);return r?n.concat(r):n}(this,e,Ws(t[e])),a=i.get(e),l=a?s&&s.get(e)?"replaceMerge":"normalMerge":"replaceAll",c=Xs(a,o,l);(function(t,e,n){Cn(t,function(t){var i=t.newOption;On(i)&&(t.keyInfo.mainType=e,t.keyInfo.subType=function(t,e,n,i){return e.type?e.type:n?n.subType:i.determineSubType(t,e)}(e,i,t.existing,n))})})(c,e,ag),n[e]=null,i.set(e,null),r.set(e,0);var d,u=[],h=[],p=0;Cn(c,function(t,n){var i=t.existing,r=t.newOption;if(r){var o="series"===e,a=ag.getClass(e,t.keyInfo.subType,!o);if(!a)return;if("tooltip"===e){if(d)return;d=!0}if(i&&i.constructor===a)i.name=t.keyInfo.name,i.mergeOption(r,this),i.optionUpdated(r,!1);else{var s=_n({componentIndex:n},t.keyInfo);_n(i=new a(r,this,this,s),s),t.brandNew&&(i.__requireNewView=!0),i.init(r,this,this),i.optionUpdated(null,!0)}}else i&&(i.mergeOption({},this),i.optionUpdated({},!1));i?(u.push(i.option),h.push(i),p++):(u.push(void 0),h.push(void 0))},this),n[e]=u,i.set(e,h),r.set(e,p),"series"===e&&zg(this)},this),this._seriesIndices||zg(this)},e.prototype.getOption=function(){var t=mn(this.option);return Cn(t,function(e,n){if(ag.hasClass(n)){for(var i=Ws(e),r=i.length,o=!1,a=r-1;a>=0;a--)i[a]&&!Js(i[a])?o=!0:(i[a]=null,!o&&r--);i.length=r,t[n]=i}}),delete t[Rg],t},e.prototype.setTheme=function(t){this._theme=new Bp(t),this._resetOption("recreate",null)},e.prototype.getTheme=function(){return this._theme},e.prototype.getLocaleModel=function(){return this._locale},e.prototype.setUpdatePayload=function(t){this._payload=t},e.prototype.getUpdatePayload=function(){return this._payload},e.prototype.getComponent=function(t,e){var n=this._componentsMap.get(t);if(n){var i=n[e||0];if(i)return i;if(null==e)for(var r=0;r=e:"max"===n?t<=e:t===e})(i[a],t,o)||(r=!1)}}),r}var Xg=Cn,Yg=On,Zg=["areaStyle","lineStyle","nodeStyle","linkStyle","chordStyle","label","labelLine"];function Kg(t){var e=t&&t.itemStyle;if(e)for(var n=0,i=Zg.length;nc&&(c=p)}s[0]=l,s[1]=c}},i=function(){return this._data?this._data.length/this._dimSize:0};function r(t){for(var e=0;e=0&&(s=o.interpolatedValue[l])}return null!=s?s+"":""}):void 0},t.prototype.getRawValue=function(t,e){return Fv(this.getData(e),t)},t.prototype.formatTooltip=function(t,e,n){},t}();function Wv(t){var e,n;return On(t)?t.type&&(n=t):e=t,{text:e,frag:n}}function Uv(t){return new Gv(t)}var Gv=function(){function t(t){t=t||{},this._reset=t.reset,this._plan=t.plan,this._count=t.count,this._onDirty=t.onDirty,this._dirty=!0}return t.prototype.perform=function(t){var e,n=this._upstream,i=t&&t.skip;if(this._dirty&&n){var r=this.context;r.data=r.outputData=n.context.outputData}this.__pipeline&&(this.__pipeline.currentTask=this),this._plan&&!i&&(e=this._plan(this.context));var o,a=d(this._modBy),s=this._modDataCount||0,l=d(t&&t.modBy),c=t&&t.modDataCount||0;function d(t){return!(t>=1)&&(t=1),t}a===l&&s===c||(e="reset"),(this._dirty||"reset"===e)&&(this._dirty=!1,o=this._doReset(i)),this._modBy=l,this._modDataCount=c;var u=t&&t.step;if(this._dueEnd=n?n._outputDueEnd:this._count?this._count(this.context):1/0,this._progress){var h=this._dueIndex,p=Math.min(null!=u?this._dueIndex+u:1/0,this._dueEnd);if(!i&&(o||h1&&i>0?s:a}};return o;function a(){return e=t?null:oi?-this._resultLT:0},t}(),Yv=function(){function t(){}return t.prototype.getRawData=function(){throw new Error("not supported")},t.prototype.getRawDataItem=function(t){throw new Error("not supported")},t.prototype.cloneRawData=function(){},t.prototype.getDimensionInfo=function(t){},t.prototype.cloneAllDimensionInfo=function(){},t.prototype.count=function(){},t.prototype.retrieveValue=function(t,e){},t.prototype.retrieveValueFromItem=function(t,e){},t.prototype.convertValue=function(t,e){return jv(t,e)},t}();function Zv(t){if(!nm(t.sourceFormat)){Fs("")}return t.data}function Kv(t){var e=t.sourceFormat,n=t.data;if(!nm(e)){Fs("")}if(e===yg){for(var i=[],r=0,o=n.length;r65535?om:am}function um(){return[1/0,-1/0]}function hm(t){var e=t.constructor;return e===Array?t.slice():new e(t)}function pm(t,e,n,i,r){var o=cm[n||"float"];if(r){var a=t[e],s=a&&a.length;if(s!==i){for(var l=new o(i),c=0;cg[1]&&(g[1]=f)}return this._rawCount=this._count=s,{start:a,end:s}},t.prototype._initDataFromProvider=function(t,e,n){for(var i=this._provider,r=this._chunks,o=this._dimensions,a=o.length,s=this._rawExtent,l=kn(o,function(t){return t.property}),c=0;cv[1]&&(v[1]=g)}}!i.persistent&&i.clean&&i.clean(),this._rawCount=this._count=e,this._extent=[]},t.prototype.count=function(){return this._count},t.prototype.get=function(t,e){if(!(e>=0&&e=0&&e=this._rawCount||t<0)return-1;if(!this._indices)return t;var e=this._indices,n=e[t];if(null!=n&&nt))return o;r=o-1}}return-1},t.prototype.getIndices=function(){var t,e=this._indices;if(e){var n=e.constructor,i=this._count;if(n===Array){t=new n(i);for(var r=0;r=c&&_<=d||isNaN(_))&&(a[s++]=p),p++}h=!0}else if(2===r){f=u[i[0]];var v=u[i[1]],m=t[i[1]][0],y=t[i[1]][1];for(g=0;g=c&&_<=d||isNaN(_))&&(b>=m&&b<=y||isNaN(b))&&(a[s++]=p),p++}h=!0}}if(!h)if(1===r)for(g=0;g=c&&_<=d||isNaN(_))&&(a[s++]=x)}else for(g=0;gt[C][1])&&(w=!1)}w&&(a[s++]=e.getRawIndex(g))}return sv[1]&&(v[1]=g)}}}},t.prototype.lttbDownSample=function(t,e){var n,i,r,o=this.clone([t],!0),a=o._chunks[t],s=this.count(),l=0,c=Math.floor(1/e),d=this.getRawIndex(0),u=new(dm(this._rawCount))(Math.min(2*(Math.ceil(s/c)+2),s));u[l++]=d;for(var h=1;hn&&(n=i,r=k)}C>0&&Ca&&(f=a-c);for(var g=0;gp&&(p=v,h=c+g)}var m=this.getRawIndex(d),y=this.getRawIndex(h);dc-p&&(s=c-p,a.length=s);for(var f=0;fd[1]&&(d[1]=v),u[h++]=m}return r._count=h,r._indices=u,r._updateGetRawIdx(),r},t.prototype.each=function(t,e){if(this._count)for(var n=t.length,i=this._chunks,r=0,o=this.count();ra&&(a=l)}return i=[o,a],this._extent[t]=i,i},t.prototype.getRawDataItem=function(t){var e=this.getRawIndex(t);if(this._provider.persistent)return this._provider.getItem(e);for(var n=[],i=this._chunks,r=0;r=0?this._indices[t]:-1},t.prototype._updateGetRawIdx=function(){this.getRawIndex=this._indices?this._getRawIdx:this._getRawIdxIdentity},t.internalField=function(){function t(t,e,n,i){return jv(t[i],this._dimensions[i])}im={arrayRows:t,objectRows:function(t,e,n,i){return jv(t[e],this._dimensions[i])},keyedColumns:t,original:function(t,e,n,i){var r=t&&(null==t.value?t:t.value);return jv(r instanceof Array?r[i]:r,this._dimensions[i])},typedArray:function(t,e,n,i){return t[i]}}}(),t}(),gm=function(){function t(t){this._sourceList=[],this._storeList=[],this._upstreamSignList=[],this._versionSignBase=0,this._dirty=!0,this._sourceHost=t}return t.prototype.dirty=function(){this._setLocalSource([],[]),this._storeList=[],this._dirty=!0},t.prototype._setLocalSource=function(t,e){this._sourceList=t,this._upstreamSignList=e,this._versionSignBase++,this._versionSignBase>9e10&&(this._versionSignBase=0)},t.prototype._getVersionSign=function(){return this._sourceHost.uid+"_"+this._versionSignBase},t.prototype.prepareSource=function(){this._isDirty()&&(this._createSource(),this._dirty=!1)},t.prototype._createSource=function(){this._setLocalSource([],[]);var t,e,n=this._sourceHost,i=this._getUpstreamSourceManagers(),r=!!i.length;if(vm(n)){var o=n,a=void 0,s=void 0,l=void 0;if(r){var c=i[0];c.prepareSource(),a=(l=c.getSource()).data,s=l.sourceFormat,e=[c._getVersionSign()]}else s=Rn(a=o.get("data",!0))?xg:mg,e=[];var d=this._getSourceMetaRawOption()||{},u=l&&l.metaRawOption||{},h=Wn(d.seriesLayoutBy,u.seriesLayoutBy)||null,p=Wn(d.sourceHeader,u.sourceHeader),f=Wn(d.dimensions,u.dimensions);t=h!==u.seriesLayoutBy||!!p!=!!u.sourceHeader||f?[wv(a,{seriesLayoutBy:h,sourceHeader:p,dimensions:f},s)]:[]}else{var g=n;if(r){var v=this._applyTransform(i);t=v.sourceList,e=v.upstreamSignList}else{t=[wv(g.get("source",!0),this._getSourceMetaRawOption(),null)],e=[]}}this._setLocalSource(t,e)},t.prototype._applyTransform=function(t){var e,n=this._sourceHost,i=n.get("transform",!0),r=n.get("fromTransformResult",!0);if(null!=r){1!==t.length&&mm("")}var o,a=[],s=[];return Cn(t,function(t){t.prepareSource();var e=t.getSource(r||0);null==r||e||mm(""),a.push(e),s.push(t._getVersionSign())}),i?e=function(t,e){var n=Ws(t),i=n.length;i||Fs("");for(var r=0,o=i;r1||n>0&&!t.noHeader;return Cn(t.blocks,function(t){var n=km(t);n>=e&&(e=n+ +(i&&(!n||Sm(t)&&!t.noHeader)))}),e}return 0}function Mm(t,e,n,i){var r,o=e.noHeader,a=(r=km(e),{html:bm[r],richText:xm[r]}),s=[],l=e.blocks||[];jn(!l||Pn(l)),l=l||[];var c=t.orderMode;if(e.sortBlocks&&c){l=l.slice();var d={valueAsc:"asc",valueDesc:"desc"};if(ii(d,c)){var u=new Xv(d[c],null);l.sort(function(t,e){return u.evaluate(t.sortParam,e.sortParam)})}else"seriesDesc"===c&&l.reverse()}Cn(l,function(n,r){var o=e.valueFormatter,l=Cm(n)(o?_n(_n({},t),{valueFormatter:o}):t,n,r>0?a.html:0,i);null!=l&&s.push(l)});var h="richText"===t.renderMode?s.join(a.richText):Dm(i,s.join(""),o?n:a.html);if(o)return h;var p=zf(e.header,"ordinal",t.useUTC),f=_m(i,t.renderMode).nameStyle,g=ym(i);return"richText"===t.renderMode?Am(t,p,f)+a.richText+h:Dm(i,'
'+Ai(p)+"
"+h,n)}function Tm(t,e,n,i){var r=t.renderMode,o=e.noName,a=e.noValue,s=!e.markerType,l=e.name,c=t.useUTC,d=e.valueFormatter||t.valueFormatter||function(t){return kn(t=Pn(t)?t:[t],function(t,e){return zf(t,Pn(p)?p[e]:p,c)})};if(!o||!a){var u=s?"":t.markupStyleCreator.makeTooltipMarker(e.markerType,e.markerColor||sg.color.secondary,r),h=o?"":zf(l,"ordinal",c),p=e.valueType,f=a?[]:d(e.value,e.dataIndex),g=!s||!o,v=!s&&o,m=_m(i,r),y=m.nameStyle,_=m.valueStyle;return"richText"===r?(s?"":u)+(o?"":Am(t,h,y))+(a?"":function(t,e,n,i,r){var o=[r],a=i?10:20;return n&&o.push({padding:[0,0,0,a],align:"right"}),t.markupStyleCreator.wrapRichTextStyle(Pn(e)?e.join(" "):e,o)}(t,f,g,v,_)):Dm(i,(s?"":u)+(o?"":function(t,e,n){return''+Ai(t)+""}(h,!s,y))+(a?"":function(t,e,n,i){var r=n?"10px":"20px",o=e?"float:right;margin-left:"+r:"";return t=Pn(t)?t:[t],''+kn(t,function(t){return Ai(t)}).join("  ")+""}(f,g,v,_)),n)}}function Im(t,e,n,i,r,o){if(t)return Cm(t)({useUTC:r,renderMode:n,orderMode:i,markupStyleCreator:e,valueFormatter:t.valueFormatter},t,0,o)}function Dm(t,e,n){return'
'+e+'
'}function Am(t,e,n){return t.markupStyleCreator.wrapRichTextStyle(e,n)}function Pm(t,e){var n=t.get("padding");return null!=n?n:"richText"===e?[8,10]:10}var Lm=function(){function t(){this.richTextStyles={},this._nextStyleNameId=Ns()}return t.prototype._generateStyleName=function(){return"__EC_aUTo_"+this._nextStyleNameId++},t.prototype.makeTooltipMarker=function(t,e,n){var i="richText"===n?this._generateStyleName():null,r=function(t,e){var n=En(t)?{color:t,extraCssText:e}:t||{},i=n.color,r=n.type;e=n.extraCssText;var o=n.renderMode||"html";return i?"html"===o?"subItem"===r?'':'':{renderMode:o,content:"{"+(n.markerId||"markerX")+"|} ",style:"subItem"===r?{width:4,height:4,borderRadius:2,backgroundColor:i}:{width:10,height:10,borderRadius:5,backgroundColor:i}}:""}({color:e,type:t,renderMode:n,markerId:i});return En(r)?r:(this.richTextStyles[i]=r.style,r.content)},t.prototype.wrapRichTextStyle=function(t,e){var n={};Pn(e)?Cn(e,function(t){return _n(n,t)}):_n(n,e);var i=this._generateStyleName();return this.richTextStyles[i]=n,"{"+i+"|"+t+"}"},t}();function Em(t){var e,n,i,r,o=t.series,a=t.dataIndex,s=t.multipleSeries,l=o.getData(),c=l.mapDimensionsAll("defaultedTooltip"),d=c.length,u=o.getRawValue(a),h=Pn(u),p=function(t,e){return Rf(t.getData().getItemVisual(e,"style")[t.visualDrawType])}(o,a);if(d>1||h&&!d){var f=function(t,e,n,i,r){var o=e.getData(),a=Mn(t,function(t,e,n){var i=o.getDimensionInfo(n);return t||i&&!1!==i.tooltip&&null!=i.displayName},!1),s=[],l=[],c=[];function d(t,e){var n=o.getDimensionInfo(e);n&&!1!==n.otherDims.tooltip&&(a?c.push(wm("nameValue",{markerType:"subItem",markerColor:r,name:n.displayName,value:t,valueType:n.type})):(s.push(t),l.push(n.type)))}return i.length?Cn(i,function(t){d(Fv(o,n,t),t)}):Cn(t,d),{inlineValues:s,inlineValueTypes:l,blocks:c}}(u,o,a,c,p);e=f.inlineValues,n=f.inlineValueTypes,i=f.blocks,r=f.inlineValues[0]}else if(d){var g=l.getDimensionInfo(c[0]);r=e=Fv(l,a,c[0]),n=g.type}else r=e=h?u[0]:u;var v=Qs(o),m=v&&o.name||"",y=l.getName(a),_=s?m:y;return wm("section",{header:m,noHeader:s||!v,sortParam:r,blocks:[wm("nameValue",{markerType:"item",markerColor:p,name:_,noName:!Xn(_),value:e,valueType:n,dataIndex:a})].concat(i||[])})}var zm=el();function Nm(t,e){return t.getName(e)||t.getId(e)}var Om=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e._selectedDataIndicesMap={},e}var n;return y(e,t),e.prototype.init=function(t,e,n){this.seriesIndex=this.componentIndex,this.dataTask=Uv({count:Rm,reset:Hm}),this.dataTask.context={model:this},this.mergeDefaultAndTheme(t,n),(zm(this).sourceManager=new gm(this)).prepareSource();var i=this.getInitialData(t,n);Bm(i,this),this.dataTask.context.data=i,zm(this).dataBeforeProcessed=i,$m(this),this._initSelectedMapFromData(i)},e.prototype.mergeDefaultAndTheme=function(t,e){var n=ng(this),i=n?rg(t):{},r=this.subType;ag.hasClass(r)&&(r+="Series"),yn(t,e.getTheme().get(this.subType)),yn(t,this.getDefaultOption()),Us(t,"label",["show"]),this.fillDataTextStyle(t.data),n&&ig(t,i,n)},e.prototype.mergeOption=function(t,e){t=yn(this.option,t,!0),this.fillDataTextStyle(t.data);var n=ng(this);n&&ig(this.option,t,n);var i=zm(this).sourceManager;i.dirty(),i.prepareSource();var r=this.getInitialData(t,e);Bm(r,this),this.dataTask.dirty(),this.dataTask.context.data=r,zm(this).dataBeforeProcessed=r,$m(this),this._initSelectedMapFromData(r)},e.prototype.fillDataTextStyle=function(t){if(t&&!Rn(t))for(var e=["show"],n=0;n=0&&d<0)&&(c=o,d=r,u=0),r===d&&(l[u++]=e))}),l.length=u,l},e.prototype.formatTooltip=function(t,e,n){return Em({series:this,dataIndex:t,multipleSeries:e})},e.prototype.isAnimationEnabled=function(){var t=this.ecModel;if(Ye.node&&(!t||!t.ssr))return!1;var e=this.getShallow("animation");return e&&this.getData().count()>this.getShallow("animationThreshold")&&(e=!1),!!e},e.prototype.restoreData=function(){this.dataTask.dirty()},e.prototype.getColorFromPalette=function(t,e,n){var i=this.ecModel,r=$g.prototype.getColorFromPalette.call(this,t,e,n);return r||(r=i.getColorFromPalette(t,e,n)),r},e.prototype.coordDimToDataDim=function(t){return this.getRawData().mapDimensionsAll(t)},e.prototype.getProgressive=function(){return this.get("progressive")},e.prototype.getProgressiveThreshold=function(){return this.get("progressiveThreshold")},e.prototype.select=function(t,e){this._innerSelect(this.getData(e),t)},e.prototype.unselect=function(t,e){var n=this.option.selectedMap;if(n){var i=this.option.selectedMode,r=this.getData(e);if("series"===i||"all"===n)return this.option.selectedMap={},void(this._selectedDataIndicesMap={});for(var o=0;o=0&&n.push(r)}return n},e.prototype.isSelected=function(t,e){var n=this.option.selectedMap;if(!n)return!1;var i=this.getData(e);return("all"===n||n[Nm(i,t)])&&!i.getItemModel(t).get(["select","disabled"])},e.prototype.isUniversalTransitionEnabled=function(){if(this.__universalTransitionEnabled)return!0;var t=this.option.universalTransition;return!!t&&(!0===t||t&&t.enabled)},e.prototype._innerSelect=function(t,e){var n,i,r=this.option,o=r.selectedMode,a=e.length;if(o&&a)if("series"===o)r.selectedMap="all";else if("multiple"===o){On(r.selectedMap)||(r.selectedMap={});for(var s=r.selectedMap,l=0;l0&&this._innerSelect(t,e)}},e.registerClass=function(t){return ag.registerClass(t)},e.protoInitialize=((n=e.prototype).type="series.__base__",n.seriesIndex=0,n.ignoreStyleOnData=!1,n.hasSymbolVisual=!1,n.defaultSymbol="circle",n.visualStyleAccessPath="itemStyle",void(n.visualDrawType="fill")),e}(ag);function $m(t){var e=t.name;Qs(t)||(t.name=function(t){var e=t.getRawData(),n=e.mapDimensionsAll("seriesName"),i=[];return Cn(n,function(t){var n=e.getDimensionInfo(t);n.displayName&&i.push(n.displayName)}),i.join(" ")}(t)||e)}function Rm(t){return t.model.getRawData().count()}function Hm(t){var e=t.model;return e.setData(e.getRawData().cloneShallow()),Fm}function Fm(t,e){e.outputData&&t.end>e.outputData.count()&&e.model.getRawData().cloneShallow(e.outputData)}function Bm(t,e){Cn(function(t,e){for(var n=new t.constructor(t.length+e.length),i=0;i=0?u():d=setTimeout(u,-r),l=i};return h.clear=function(){d&&(clearTimeout(d),d=null)},h.debounceNextCall=function(t){s=t},h}function ry(t,e,n,i){var r=t[e];if(r){var o=r[ty]||r,a=r[ny];if(r[ey]!==n||a!==i){if(null==n||!i)return t[e]=o;(r=t[e]=iy(o,n,"debounce"===i))[ty]=o,r[ny]=i,r[ey]=n}return r}}function oy(t,e){var n=t[e];n&&n[ty]&&(n.clear&&n.clear(),t[e]=n[ty])}var ay=el(),sy={itemStyle:ml(Rp,!0),lineStyle:ml(Np,!0)},ly={lineStyle:"stroke",itemStyle:"fill"};function cy(t,e){var n=t.visualStyleMapper||sy[e];return n||(console.warn("Unknown style type '"+e+"'."),sy.itemStyle)}function dy(t,e){var n=t.visualDrawType||ly[e];return n||(console.warn("Unknown style type '"+e+"'."),"fill")}var uy={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){var n=t.getData(),i=t.visualStyleAccessPath||"itemStyle",r=t.getModel(i),o=cy(t,i)(r),a=r.getShallow("decal");a&&(n.setVisual("decal",a),a.dirty=!0);var s=dy(t,i),l=o[s],c=Ln(l)?l:null,d="auto"===o.fill||"auto"===o.stroke;if(!o[s]||c||d){var u=t.getColorFromPalette(t.name,null,e.getSeriesCount());o[s]||(o[s]=u,n.setVisual("colorFromPalette",!0)),o.fill="auto"===o.fill||Ln(o.fill)?u:o.fill,o.stroke="auto"===o.stroke||Ln(o.stroke)?u:o.stroke}if(n.setVisual("style",o),n.setVisual("drawType",s),!e.isSeriesFiltered(t)&&c)return n.setVisual("colorFromPalette",!1),{dataEach:function(e,n){var i=t.getDataParams(n),r=_n({},o);r[s]=c(i),e.setItemVisual(n,"style",r)}}}},hy=new Bp,py={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){if(!t.ignoreStyleOnData&&!e.isSeriesFiltered(t)){var n=t.getData(),i=t.visualStyleAccessPath||"itemStyle",r=cy(t,i),o=n.getVisual("drawType");return{dataEach:n.hasItemOption?function(t,e){var n=t.getRawDataItem(e);if(n&&n[i]){hy.option=n[i];var a=r(hy);_n(t.ensureUniqueItemVisual(e,"style"),a),hy.option.decal&&(t.setItemVisual(e,"decal",hy.option.decal),hy.option.decal.dirty=!0),o in a&&t.setItemVisual(e,"colorFromPalette",!1)}}:null}}}},fy={performRawSeries:!0,overallReset:function(t){var e=ei();t.eachSeries(function(t){var n=t.getColorBy();if(!t.isColorBySeries()){var i=t.type+"-"+n,r=e.get(i);r||(r={},e.set(i,r)),ay(t).scope=r}}),t.eachSeries(function(e){if(!e.isColorBySeries()&&!t.isSeriesFiltered(e)){var n=e.getRawData(),i={},r=e.getData(),o=ay(e).scope,a=e.visualStyleAccessPath||"itemStyle",s=dy(e,a);r.each(function(t){var e=r.getRawIndex(t);i[e]=t}),n.each(function(t){var a=i[t];if(r.getItemVisual(a,"colorFromPalette")){var l=r.ensureUniqueItemVisual(a,"style"),c=n.getName(t)||t+"",d=n.count();l[s]=e.getColorFromPalette(c,o,d)}})}})}},gy=Math.PI;var vy=function(){function t(t,e,n,i){this._stageTaskMap=ei(),this.ecInstance=t,this.api=e,n=this._dataProcessorHandlers=n.slice(),i=this._visualHandlers=i.slice(),this._allHandlers=n.concat(i)}return t.prototype.restoreData=function(t,e){t.restoreData(e),this._stageTaskMap.each(function(t){var e=t.overallTask;e&&e.dirty()})},t.prototype.getPerformArgs=function(t,e){if(t.__pipeline){var n=this._pipelineMap.get(t.__pipeline.id),i=n.context,r=!e&&n.progressiveEnabled&&(!i||i.progressiveRender)&&t.__idxInPipeline>n.blockIndex?n.step:null,o=i&&i.modDataCount;return{step:r,modBy:null!=o?Math.ceil(o/r):null,modDataCount:o}}},t.prototype.getPipeline=function(t){return this._pipelineMap.get(t)},t.prototype.updateStreamModes=function(t,e){var n=this._pipelineMap.get(t.uid),i=t.getData().count(),r=n.progressiveEnabled&&e.incrementalPrepareRender&&i>=n.threshold,o=t.get("large")&&i>=t.get("largeThreshold"),a="mod"===t.get("progressiveChunkMode")?i:null;t.pipelineContext=n.context={progressiveRender:r,modDataCount:a,large:o}},t.prototype.restorePipelines=function(t){var e=this,n=e._pipelineMap=ei();t.eachSeries(function(t){var i=t.getProgressive(),r=t.uid;n.set(r,{id:r,head:null,tail:null,threshold:t.getProgressiveThreshold(),progressiveEnabled:i&&!(t.preventIncremental&&t.preventIncremental()),blockIndex:-1,step:Math.round(i||700),count:0}),e._pipe(t,t.dataTask)})},t.prototype.prepareStageTasks=function(){var t=this._stageTaskMap,e=this.api.getModel(),n=this.api;Cn(this._allHandlers,function(i){var r=t.get(i.uid)||t.set(i.uid,{});jn(!(i.reset&&i.overallReset),""),i.reset&&this._createSeriesStageTask(i,r,e,n),i.overallReset&&this._createOverallStageTask(i,r,e,n)},this)},t.prototype.prepareView=function(t,e,n,i){var r=t.renderTask,o=r.context;o.model=e,o.ecModel=n,o.api=i,r.__block=!t.incrementalPrepareRender,this._pipe(e,r)},t.prototype.performDataProcessorTasks=function(t,e){this._performStageTasks(this._dataProcessorHandlers,t,e,{block:!0})},t.prototype.performVisualTasks=function(t,e,n){this._performStageTasks(this._visualHandlers,t,e,n)},t.prototype._performStageTasks=function(t,e,n,i){i=i||{};var r=!1,o=this;function a(t,e){return t.setDirty&&(!t.dirtyMap||t.dirtyMap.get(e.__pipeline.id))}Cn(t,function(t,s){if(!i.visualType||i.visualType===t.visualType){var l=o._stageTaskMap.get(t.uid),c=l.seriesTaskMap,d=l.overallTask;if(d){var u,h=d.agentStubMap;h.each(function(t){a(i,t)&&(t.dirty(),u=!0)}),u&&d.dirty(),o.updatePayload(d,n);var p=o.getPerformArgs(d,i.block);h.each(function(t){t.perform(p)}),d.perform(p)&&(r=!0)}else c&&c.each(function(s,l){a(i,s)&&s.dirty();var c=o.getPerformArgs(s,i.block);c.skip=!t.performRawSeries&&e.isSeriesFiltered(s.context.model),o.updatePayload(s,n),s.perform(c)&&(r=!0)})}}),this.unfinished=r||this.unfinished},t.prototype.performSeriesTasks=function(t){var e;t.eachSeries(function(t){e=t.dataTask.perform()||e}),this.unfinished=e||this.unfinished},t.prototype.plan=function(){this._pipelineMap.each(function(t){var e=t.tail;do{if(e.__block){t.blockIndex=e.__idxInPipeline;break}e=e.getUpstream()}while(e)})},t.prototype.updatePayload=function(t,e){"remain"!==e&&(t.context.payload=e)},t.prototype._createSeriesStageTask=function(t,e,n,i){var r=this,o=e.seriesTaskMap,a=e.seriesTaskMap=ei(),s=t.seriesType,l=t.getTargetSeries;function c(e){var s=e.uid,l=a.set(s,o&&o.get(s)||Uv({plan:xy,reset:wy,count:ky}));l.context={model:e,ecModel:n,api:i,useClearVisual:t.isVisual&&!t.isLayout,plan:t.plan,reset:t.reset,scheduler:r},r._pipe(e,l)}t.createOnAllSeries?n.eachRawSeries(c):s?n.eachRawSeriesByType(s,c):l&&l(n,i).each(c)},t.prototype._createOverallStageTask=function(t,e,n,i){var r=this,o=e.overallTask=e.overallTask||Uv({reset:my});o.context={ecModel:n,api:i,overallReset:t.overallReset,scheduler:r};var a=o.agentStubMap,s=o.agentStubMap=ei(),l=t.seriesType,c=t.getTargetSeries,d=!0,u=!1;function h(t){var e=t.uid,n=s.set(e,a&&a.get(e)||(u=!0,Uv({reset:yy,onDirty:by})));n.context={model:t,overallProgress:d},n.agent=o,n.__block=d,r._pipe(t,n)}jn(!t.createOnAllSeries,""),l?n.eachRawSeriesByType(l,h):c?c(n,i).each(h):(d=!1,Cn(n.getSeries(),h)),u&&o.dirty()},t.prototype._pipe=function(t,e){var n=t.uid,i=this._pipelineMap.get(n);!i.head&&(i.head=e),i.tail&&i.tail.pipe(e),i.tail=e,e.__idxInPipeline=i.count++,e.__pipeline=i},t.wrapStageHandler=function(t,e){return Ln(t)&&(t={overallReset:t,seriesType:My(t)}),t.uid=Wp("stageHandler"),e&&(t.visualType=e),t},t}();function my(t){t.overallReset(t.ecModel,t.api,t.payload)}function yy(t){return t.overallProgress&&_y}function _y(){this.agent.dirty(),this.getDownstream().dirty()}function by(){this.agent&&this.agent.dirty()}function xy(t){return t.plan?t.plan(t.model,t.ecModel,t.api,t.payload):null}function wy(t){t.useClearVisual&&t.data.clearAllVisual();var e=t.resetDefines=Ws(t.reset(t.model,t.ecModel,t.api,t.payload));return e.length>1?kn(e,function(t,e){return Cy(e)}):Sy}var Sy=Cy(0);function Cy(t){return function(e,n){var i=n.data,r=n.resetDefines[t];if(r&&r.dataEach)for(var o=e.start;o0&&d===r.length-c.length){var u=r.slice(0,d);"data"!==u&&(e.mainType=u,e[c.toLowerCase()]=t,s=!0)}}a.hasOwnProperty(r)&&(n[r]=t,s=!0),s||(i[r]=t)})}return{cptQuery:e,dataQuery:n,otherQuery:i}},t.prototype.filter=function(t,e){var n=this.eventInfo;if(!n)return!0;var i=n.targetEl,r=n.packedEvent,o=n.model,a=n.view;if(!o||!a)return!0;var s=e.cptQuery,l=e.dataQuery;return c(s,o,"mainType")&&c(s,o,"subType")&&c(s,o,"index","componentIndex")&&c(s,o,"name")&&c(s,o,"id")&&c(l,r,"name")&&c(l,r,"dataIndex")&&c(l,r,"dataType")&&(!a.filterForExposedEvent||a.filterForExposedEvent(t,e.otherQuery,i,r));function c(t,e,n,i){return null==t[n]||e[i||n]===t[n]}},t.prototype.afterTrigger=function(){this.eventInfo=null},t}(),Ry=["symbol","symbolSize","symbolRotate","symbolOffset"],Hy=Ry.concat(["symbolKeepAspect"]),Fy={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){var n=t.getData();if(t.legendIcon&&n.setVisual("legendIcon",t.legendIcon),t.hasSymbolVisual){for(var i={},r={},o=!1,a=0;a=0&&i_(l)?l:.5,t.createRadialGradient(a,s,0,a,s,l)}(t,e,n):function(t,e,n){var i=null==e.x?0:e.x,r=null==e.x2?1:e.x2,o=null==e.y?0:e.y,a=null==e.y2?0:e.y2;return e.global||(i=i*n.width+n.x,r=r*n.width+n.x,o=o*n.height+n.y,a=a*n.height+n.y),i=i_(i)?i:0,r=i_(r)?r:1,o=i_(o)?o:0,a=i_(a)?a:0,t.createLinearGradient(i,o,r,a)}(t,e,n),r=e.colorStops,o=0;o0&&(e=i.lineDash,n=i.lineWidth,e&&"solid"!==e&&n>0?"dashed"===e?[4*n,2*n]:"dotted"===e?[n]:Nn(e)?[e]:Pn(e)?e:null:null),o=i.lineDashOffset;if(r){var a=i.strokeNoScale&&t.getLineScale?t.getLineScale():1;a&&1!==a&&(r=kn(r,function(t){return t/a}),o/=a)}return[r,o]}var l_=new Ic(!0);function c_(t){var e=t.stroke;return!(null==e||"none"===e||!(t.lineWidth>0))}function d_(t){return"string"==typeof t&&"none"!==t}function u_(t){var e=t.fill;return null!=e&&"none"!==e}function h_(t,e){if(null!=e.fillOpacity&&1!==e.fillOpacity){var n=t.globalAlpha;t.globalAlpha=e.fillOpacity*e.opacity,t.fill(),t.globalAlpha=n}else t.fill()}function p_(t,e){if(null!=e.strokeOpacity&&1!==e.strokeOpacity){var n=t.globalAlpha;t.globalAlpha=e.strokeOpacity*e.opacity,t.stroke(),t.globalAlpha=n}else t.stroke()}function f_(t,e,n){var i=wl(e.image,e.__image,n);if(Cl(i)){var r=t.createPattern(i,e.repeat||"repeat");if("function"==typeof DOMMatrix&&r&&r.setTransform){var o=new DOMMatrix;o.translateSelf(e.x||0,e.y||0),o.rotateSelf(0,0,(e.rotation||0)*oi),o.scaleSelf(e.scaleX||1,e.scaleY||1),r.setTransform(o)}return r}}var g_=["shadowBlur","shadowOffsetX","shadowOffsetY"],v_=[["lineCap","butt"],["lineJoin","miter"],["miterLimit",10]];function m_(t,e,n,i,r){var o=!1;if(!i&&e===(n=n||{}))return!1;if(i||e.opacity!==n.opacity){b_(t,r),o=!0;var a=Math.max(Math.min(e.opacity,1),0);t.globalAlpha=isNaN(a)?Ul.opacity:a}(i||e.blend!==n.blend)&&(o||(b_(t,r),o=!0),t.globalCompositeOperation=e.blend||Ul.blend);for(var s=0;s0&&t.unfinished);t.unfinished||this._zr.flush()}}},e.prototype.getDom=function(){return this._dom},e.prototype.getId=function(){return this.id},e.prototype.getZr=function(){return this._zr},e.prototype.isSSR=function(){return this._ssr},e.prototype.setOption=function(t,e,n){if(!this[N_])if(this._disposed)this.id;else{var i,r,o;if(On(e)&&(n=e.lazyUpdate,i=e.silent,r=e.replaceMerge,o=e.transition,e=e.notMerge),this[N_]=!0,lb(this),!this._model||e){var a=new qg(this._api),s=this._theme,l=this._model=new Hg;l.scheduler=this._scheduler,l.ssr=this._ssr,l.init(null,null,null,s,this._locale,a)}this._model.setOption(t,{replaceMerge:r},yb);var c={seriesTransition:o,optionChanged:!0};if(n)this[$_]={silent:i,updateParams:c},this[N_]=!1,this.getZr().wakeUp();else{try{U_(this),j_.update.call(this,null,c)}catch(t){throw this[$_]=null,this[N_]=!1,t}this._ssr||this._zr.flush(),this[$_]=null,this[N_]=!1,K_.call(this,i),Q_.call(this,i)}}},e.prototype.setTheme=function(t,e){if(!this[N_])if(this._disposed)this.id;else{var n=this._model;if(n){var i=e&&e.silent,r=null;this[$_]&&(null==i&&(i=this[$_].silent),r=this[$_].updateParams,this[$_]=null),this[N_]=!0,lb(this);try{this._updateTheme(t),n.setTheme(this._theme),U_(this),j_.update.call(this,{type:"setTheme"},r)}catch(t){throw this[N_]=!1,t}this[N_]=!1,K_.call(this,i),Q_.call(this,i)}}},e.prototype._updateTheme=function(t){En(t)&&(t=bb[t]),t&&((t=mn(t))&&pv(t,!0),this._theme=t)},e.prototype.getModel=function(){return this._model},e.prototype.getOption=function(){return this._model&&this._model.getOption()},e.prototype.getWidth=function(){return this._zr.getWidth()},e.prototype.getHeight=function(){return this._zr.getHeight()},e.prototype.getDevicePixelRatio=function(){return this._zr.painter.dpr||Ye.hasGlobalWindow&&window.devicePixelRatio||1},e.prototype.getRenderedCanvas=function(t){return this.renderToCanvas(t)},e.prototype.renderToCanvas=function(t){return t=t||{},this._zr.painter.getRenderedCanvas({backgroundColor:t.backgroundColor||this._model.get("backgroundColor"),pixelRatio:t.pixelRatio||this.getDevicePixelRatio()})},e.prototype.renderToSVGString=function(t){return t=t||{},this._zr.painter.renderToString({useViewBox:t.useViewBox})},e.prototype.getSvgDataURL=function(){var t=this._zr;return Cn(t.storage.getDisplayList(),function(t){t.stopAnimation(null,!0)}),t.painter.toDataURL()},e.prototype.getDataURL=function(t){if(!this._disposed){var e=(t=t||{}).excludeComponents,n=this._model,i=[],r=this;Cn(e,function(t){n.eachComponent({mainType:t},function(t){var e=r._componentsMap[t.__viewId];e.group.ignore||(i.push(e),e.group.ignore=!0)})});var o="svg"===this._zr.painter.getType()?this.getSvgDataURL():this.renderToCanvas(t).toDataURL("image/"+(t&&t.type||"png"));return Cn(i,function(t){t.group.ignore=!1}),o}this.id},e.prototype.getConnectedDataURL=function(t){if(!this._disposed){var e="svg"===t.type,n=this.group,i=Math.min,r=Math.max,o=1/0;if(Sb[n]){var a=o,s=o,l=-1/0,c=-1/0,d=[],u=t&&t.pixelRatio||this.getDevicePixelRatio();Cn(wb,function(o,u){if(o.group===n){var h=e?o.getZr().painter.getSvgDom().innerHTML:o.renderToCanvas(mn(t)),p=o.getDom().getBoundingClientRect();a=i(p.left,a),s=i(p.top,s),l=r(p.right,l),c=r(p.bottom,c),d.push({dom:h,left:p.left,top:p.top})}});var h=(l*=u)-(a*=u),p=(c*=u)-(s*=u),f=en.createCanvas(),g=ms(f,{renderer:e?"svg":"canvas"});if(g.resize({width:h,height:p}),e){var v="";return Cn(d,function(t){var e=t.left-a,n=t.top-s;v+=''+t.dom+""}),g.painter.getSvgRoot().innerHTML=v,t.connectedBackgroundColor&&g.painter.setBackgroundColor(t.connectedBackgroundColor),g.refreshImmediately(),g.painter.toDataURL()}return t.connectedBackgroundColor&&g.add(new sd({shape:{x:0,y:0,width:h,height:p},style:{fill:t.connectedBackgroundColor}})),Cn(d,function(t){var e=new td({style:{x:t.left*u-a,y:t.top*u-s,image:t.dom}});g.add(e)}),g.refreshImmediately(),f.toDataURL("image/"+(t&&t.type||"png"))}return this.getDataURL(t)}this.id},e.prototype.convertToPixel=function(t,e,n){return X_(this,"convertToPixel",t,e,n)},e.prototype.convertToLayout=function(t,e,n){return X_(this,"convertToLayout",t,e,n)},e.prototype.convertFromPixel=function(t,e,n){return X_(this,"convertFromPixel",t,e,n)},e.prototype.containPixel=function(t,e){var n;if(!this._disposed)return Cn(il(this._model,t),function(t,i){i.indexOf("Models")>=0&&Cn(t,function(t){var r=t.coordinateSystem;if(r&&r.containPoint)n=n||!!r.containPoint(e);else if("seriesModels"===i){var o=this._chartsMap[t.__viewId];o&&o.containPoint&&(n=n||o.containPoint(e,t))}},this)},this),!!n;this.id},e.prototype.getVisual=function(t,e){var n=il(this._model,t,{defaultMainType:"series"}),i=n.seriesModel.getData(),r=n.hasOwnProperty("dataIndexInside")?n.dataIndexInside:n.hasOwnProperty("dataIndex")?i.indexOfRawIndex(n.dataIndex):null;return null!=r?function(t,e,n){switch(n){case"color":return t.getItemVisual(e,"style")[t.getVisual("drawType")];case"opacity":return t.getItemVisual(e,"style").opacity;case"symbol":case"symbolSize":case"liftZ":return t.getItemVisual(e,n)}}(i,r,e):function(t,e){switch(e){case"color":return t.getVisual("style")[t.getVisual("drawType")];case"opacity":return t.getVisual("style").opacity;case"symbol":case"symbolSize":case"liftZ":return t.getVisual(e)}}(i,e)},e.prototype.getViewOfComponentModel=function(t){return this._componentsMap[t.__viewId]},e.prototype.getViewOfSeriesModel=function(t){return this._chartsMap[t.__viewId]},e.prototype._initEvents=function(){var t=this;Cn(pb,function(e){var n=function(n){var i,r=t.getModel(),o=n.target;if("globalout"===e?i={}:o&&Wy(o,function(t){var e=Cd(t);if(e&&null!=e.dataIndex){var n=e.dataModel||r.getSeriesByIndex(e.seriesIndex);return i=n&&n.getDataParams(e.dataIndex,e.dataType,o)||{},!0}if(e.eventData)return i=_n({},e.eventData),!0},!0),i){var a=i.componentType,s=i.componentIndex;"markLine"!==a&&"markPoint"!==a&&"markArea"!==a||(a="series",s=i.seriesIndex);var l=a&&null!=s&&r.getComponent(a,s),c=l&&t["series"===l.mainType?"_chartsMap":"_componentsMap"][l.__viewId];i.event=n,i.type=e,t._$eventProcessor.eventInfo={targetEl:o,packedEvent:i,model:l,view:c},t.trigger(e,i)}};n.zrEventfulCallAtLast=!0,t._zr.on(e,n,t)});var e=this._messageCenter;Cn(vb,function(n,i){e.on(i,function(e){t.trigger(i,e)})}),function(t,e,n){t.on("selectchanged",function(t){var i=n.getModel();t.isFromClick?(Vy("map","selectchanged",e,i,t),Vy("pie","selectchanged",e,i,t)):"select"===t.fromAction?(Vy("map","selected",e,i,t),Vy("pie","selected",e,i,t)):"unselect"===t.fromAction&&(Vy("map","unselected",e,i,t),Vy("pie","unselected",e,i,t))})}(e,this,this._api)},e.prototype.isDisposed=function(){return this._disposed},e.prototype.clear=function(){this._disposed?this.id:this.setOption({series:[]},!0)},e.prototype.dispose=function(){if(this._disposed)this.id;else{this._disposed=!0,this.getDom()&&sl(this.getDom(),kb,"");var t=this,e=t._api,n=t._model;Cn(t._componentsViews,function(t){t.dispose(n,e)}),Cn(t._chartsViews,function(t){t.dispose(n,e)}),t._zr.dispose(),t._dom=t._model=t._chartsMap=t._componentsMap=t._chartsViews=t._componentsViews=t._scheduler=t._api=t._zr=t._throttledZrFlush=t._theme=t._coordSysMgr=t._messageCenter=null,delete wb[t.id]}},e.prototype.resize=function(t){if(!this[N_])if(this._disposed)this.id;else{this._zr.resize(t);var e=this._model;if(this._loadingFX&&this._loadingFX.resize(),e){var n=e.resetOption("media"),i=t&&t.silent;this[$_]&&(null==i&&(i=this[$_].silent),n=!0,this[$_]=null),this[N_]=!0,lb(this);try{n&&U_(this),j_.update.call(this,{type:"resize",animation:_n({duration:0},t&&t.animation)})}catch(t){throw this[N_]=!1,t}this[N_]=!1,K_.call(this,i),Q_.call(this,i)}}},e.prototype.showLoading=function(t,e){if(this._disposed)this.id;else if(On(t)&&(e=t,t=""),t=t||"default",this.hideLoading(),xb[t]){var n=xb[t](this._api,e),i=this._zr;this._loadingFX=n,i.add(n)}},e.prototype.hideLoading=function(){this._disposed?this.id:(this._loadingFX&&this._zr.remove(this._loadingFX),this._loadingFX=null)},e.prototype.makeActionFromEvent=function(t){var e=_n({},t);return e.type=gb[t.type],e},e.prototype.dispatchAction=function(t,e){if(this._disposed)this.id;else if(On(e)||(e={silent:!!e}),fb[t.type]&&this._model)if(this[N_])this._pendingActions.push(t);else{var n=e.silent;Z_.call(this,t,n);var i=e.flush;i?this._zr.flush():!1!==i&&Ye.browser.weChat&&this._throttledZrFlush(),K_.call(this,n),Q_.call(this,n)}},e.prototype.updateLabelLayout=function(){A_.trigger("series:layoutlabels",this._model,this._api,{updatedSeries:[]})},e.prototype.appendData=function(t){if(this._disposed)this.id;else{var e=t.seriesIndex;this.getModel().getSeriesByIndex(e).appendData(t),this._scheduler.unfinished=!0,this.getZr().wakeUp()}},e.internalField=function(){function t(t){t.clearColorPalette(),t.eachSeries(function(t){t.clearColorPalette()})}function e(t){for(var e=[],n=t.currentStates,i=0;i0?{duration:o,delay:i.get("delay"),easing:i.get("easing")}:null;n.eachRendered(function(t){if(t.states&&t.states.emphasis){if(Oh(t))return;if(t instanceof Yc&&function(t){var e=Td(t);e.normalFill=t.style.fill,e.normalStroke=t.style.stroke;var n=t.states.select||{};e.selectFill=n.style&&n.style.fill||null,e.selectStroke=n.style&&n.style.stroke||null}(t),t.__dirty){var n=t.prevStates;n&&t.useStates(n)}if(r){t.stateTransition=a;var i=t.getTextContent(),o=t.getTextGuideLine();i&&(i.stateTransition=a),o&&(o.stateTransition=a)}t.__dirty&&e(t)}})}U_=function(t){var e=t._scheduler;e.restorePipelines(t._model),e.prepareStageTasks(),G_(t,!0),G_(t,!1),e.plan()},G_=function(t,e){for(var n=t._model,i=t._scheduler,r=e?t._componentsViews:t._chartsViews,o=e?t._componentsMap:t._chartsMap,a=t._zr,s=t._api,l=0;le.get("hoverLayerThreshold")&&!Ye.node&&!Ye.worker&&e.eachSeries(function(e){if(!e.preventUsingHoverLayer){var n=t._chartsMap[e.__viewId];n.__alive&&n.eachRendered(function(t){t.states.emphasis&&(t.states.emphasis.hoverLayer=!0)})}})}(t,e),A_.trigger("series:afterupdate",e,o,s)},ab=function(t){t[R_]=!0,t.getZr().wakeUp()},lb=function(t){t[O_]=(t[O_]+1)%1e3},sb=function(t){t[R_]&&(t.getZr().storage.traverse(function(t){Oh(t)||e(t)}),t[R_]=!1)},rb=function(t){return new(function(e){function n(){return null!==e&&e.apply(this,arguments)||this}return y(n,e),n.prototype.getCoordinateSystems=function(){return t._coordSysMgr.getCoordinateSystems()},n.prototype.getComponentByElement=function(e){for(;e;){var n=e.__ecComponentInfo;if(null!=n)return t._model.getComponent(n.mainType,n.index);e=e.parent}},n.prototype.enterEmphasis=function(e,n){Qd(e,n),ab(t)},n.prototype.leaveEmphasis=function(e,n){Jd(e,n),ab(t)},n.prototype.enterBlur=function(e){!function(t){qd(t,Bd)}(e),ab(t)},n.prototype.leaveBlur=function(e){tu(e),ab(t)},n.prototype.enterSelect=function(e){eu(e),ab(t)},n.prototype.leaveSelect=function(e){nu(e),ab(t)},n.prototype.getModel=function(){return t.getModel()},n.prototype.getViewOfComponentModel=function(e){return t.getViewOfComponentModel(e)},n.prototype.getViewOfSeriesModel=function(e){return t.getViewOfSeriesModel(e)},n.prototype.getMainProcessVersion=function(){return t[O_]},n}(Ug))(t)},ob=function(t){function e(t,e){for(var n=0;n=0)){Eb.push(n);var o=vy.wrapStageHandler(n,r);o.__prio=e,o.__raw=n,t.push(o)}}function Nb(t,e){xb[t]=e}var Ob=function(t){var e=(t=mn(t)).type;e||Fs("");var n=e.split(":");2!==n.length&&Fs("");var i=!1;"echarts"===n[0]&&(e=n[1],i=!0),t.__isBuiltIn=i,tm.set(e,t)};function $b(t,e,n,i){return{eventContent:{selected:cu(n),isFromClick:e.isFromClick||!1}}}function Rb(t){return null==t?0:t.length||1}function Hb(t){return t}Lb(L_,uy),Lb(E_,py),Lb(E_,fy),Lb(L_,Fy),Lb(E_,By),Lb(7e3,function(t,e){t.eachRawSeries(function(n){if(!t.isSeriesFiltered(n)){var i=n.getData();i.hasItemVisual()&&i.each(function(t){var n=i.getItemVisual(t,"decal");n&&(i.ensureUniqueItemVisual(t,"style").decal=M_(n,e))});var r=i.getVisual("decal");if(r)i.getVisual("style").decal=M_(r,e)}})}),Ib(pv),Db(900,function(t){var e=ei();t.eachSeries(function(t){var n=t.get("stack");if(n){var i=e.get(n)||e.set(n,[]),r=t.getData(),o={stackResultDimension:r.getCalculationInfo("stackResultDimension"),stackedOverDimension:r.getCalculationInfo("stackedOverDimension"),stackedDimension:r.getCalculationInfo("stackedDimension"),stackedByDimension:r.getCalculationInfo("stackedByDimension"),isStackedByIndex:r.getCalculationInfo("isStackedByIndex"),data:r,seriesModel:t};if(!o.stackedDimension||!o.isStackedByIndex&&!o.stackedByDimension)return;i.push(o)}}),e.each(function(t){0!==t.length&&("seriesDesc"===(t[0].seriesModel.get("stackOrder")||"seriesAsc")&&t.reverse(),Cn(t,function(e,n){e.data.setCalculationInfo("stackedOnSeries",n>0?t[n-1].seriesModel:null)}),function(t){Cn(t,function(e,n){var i=[],r=[NaN,NaN],o=[e.stackResultDimension,e.stackedOverDimension],a=e.data,s=e.isStackedByIndex,l=e.seriesModel.get("stackStrategy")||"samesign";a.modify(o,function(o,c,d){var u,h,p=a.get(e.stackedDimension,d);if(isNaN(p))return r;s?h=a.getRawIndex(d):u=a.get(e.stackedByDimension,d);for(var f=NaN,g=n-1;g>=0;g--){var v=t[g];if(s||(h=v.data.rawIndexOf(v.stackedByDimension,u)),h>=0){var m=v.data.getByRawIndex(v.stackResultDimension,h);if("all"===l||"positive"===l&&m>0||"negative"===l&&m<0||"samesign"===l&&p>=0&&m>0||"samesign"===l&&p<=0&&m<0){p=Ts(p,m),f=m;break}}}return i[0]=p,i[1]=f,i})})}(t))})}),Nb("default",function(t,e){bn(e=e||{},{text:"loading",textColor:sg.color.primary,fontSize:12,fontWeight:"normal",fontStyle:"normal",fontFamily:"sans-serif",maskColor:"rgba(255,255,255,0.8)",showSpinner:!0,color:sg.color.theme[0],spinnerRadius:10,lineWidth:5,zlevel:0});var n=new hs,i=new sd({style:{fill:e.maskColor},zlevel:e.zlevel,z:1e4});n.add(i);var r,o=new ud({style:{text:e.text,fill:e.textColor,fontSize:e.fontSize,fontWeight:e.fontWeight,fontStyle:e.fontStyle,fontFamily:e.fontFamily},zlevel:e.zlevel,z:10001}),a=new sd({style:{fill:"none"},textContent:o,textConfig:{position:"right",distance:10},zlevel:e.zlevel,z:10001});return n.add(a),e.showSpinner&&((r=new vh({shape:{startAngle:-gy/2,endAngle:-gy/2+.1,r:e.spinnerRadius},style:{stroke:e.color,lineCap:"round",lineWidth:e.lineWidth},zlevel:e.zlevel,z:10001})).animateShape(!0).when(1e3,{endAngle:3*gy/2}).start("circularInOut"),r.animateShape(!0).when(1e3,{startAngle:3*gy/2}).delay(300).start("circularInOut"),n.add(r)),n.resize=function(){var n=o.getBoundingRect().width,s=e.showSpinner?e.spinnerRadius:0,l=(t.getWidth()-2*s-(e.showSpinner&&n?10:0)-n)/2-(e.showSpinner&&n?0:5+n/2)+(e.showSpinner?0:n/2)+(n?0:s),c=t.getHeight()/2;e.showSpinner&&r.setShape({cx:l,cy:c}),a.setShape({x:l-s,y:c-s,width:2*s,height:2*s}),i.setShape({x:0,y:0,width:t.getWidth(),height:t.getHeight()})},n.resize(),n}),Pb({type:Pd,event:Pd,update:Pd},ri),Pb({type:Ld,event:Ld,update:Ld},ri),Pb({type:Ed,event:Od,update:Ed,action:ri,refineEvent:$b,publishNonRefinedEvent:!0}),Pb({type:zd,event:Od,update:zd,action:ri,refineEvent:$b,publishNonRefinedEvent:!0}),Pb({type:Nd,event:Od,update:Nd,action:ri,refineEvent:$b,publishNonRefinedEvent:!0}),Tb("default",{}),Tb("dark",Oy);var Fb=function(){function t(t,e,n,i,r,o){this._old=t,this._new=e,this._oldKeyGetter=n||Hb,this._newKeyGetter=i||Hb,this.context=r,this._diffModeMultiple="multiple"===o}return t.prototype.add=function(t){return this._add=t,this},t.prototype.update=function(t){return this._update=t,this},t.prototype.updateManyToOne=function(t){return this._updateManyToOne=t,this},t.prototype.updateOneToMany=function(t){return this._updateOneToMany=t,this},t.prototype.updateManyToMany=function(t){return this._updateManyToMany=t,this},t.prototype.remove=function(t){return this._remove=t,this},t.prototype.execute=function(){this[this._diffModeMultiple?"_executeMultiple":"_executeOneToOne"]()},t.prototype._executeOneToOne=function(){var t=this._old,e=this._new,n={},i=new Array(t.length),r=new Array(e.length);this._initIndexMap(t,null,i,"_oldKeyGetter"),this._initIndexMap(e,n,r,"_newKeyGetter");for(var o=0;o1){var c=s.shift();1===s.length&&(n[a]=s[0]),this._update&&this._update(c,o)}else 1===l?(n[a]=null,this._update&&this._update(s,o)):this._remove&&this._remove(o)}this._performRestAdd(r,n)},t.prototype._executeMultiple=function(){var t=this._old,e=this._new,n={},i={},r=[],o=[];this._initIndexMap(t,n,r,"_oldKeyGetter"),this._initIndexMap(e,i,o,"_newKeyGetter");for(var a=0;a1&&1===u)this._updateManyToOne&&this._updateManyToOne(c,l),i[s]=null;else if(1===d&&u>1)this._updateOneToMany&&this._updateOneToMany(c,l),i[s]=null;else if(1===d&&1===u)this._update&&this._update(c,l),i[s]=null;else if(d>1&&u>1)this._updateManyToMany&&this._updateManyToMany(c,l),i[s]=null;else if(d>1)for(var h=0;h1)for(var a=0;a30}var Kb,Qb,Jb,tx,ex,nx,ix,rx=On,ox=kn,ax="undefined"==typeof Int32Array?Array:Int32Array,sx=["hasItemOption","_nameList","_idList","_invertedIndicesMap","_dimSummary","userOutput","_rawData","_dimValueGetter","_nameDimIdx","_idDimIdx","_nameRepeatCount"],lx=["_approximateExtent"],cx=function(){function t(t,e){var n;this.type="list",this._dimOmitted=!1,this._nameList=[],this._idList=[],this._visual={},this._layout={},this._itemVisuals=[],this._itemLayouts=[],this._graphicEls=[],this._approximateExtent={},this._calculationInfo={},this.hasItemOption=!1,this.TRANSFERABLE_METHODS=["cloneShallow","downSample","minmaxDownSample","lttbDownSample","map"],this.CHANGABLE_METHODS=["filterSelf","selectRange"],this.DOWNSAMPLE_METHODS=["downSample","minmaxDownSample","lttbDownSample"];var i=!1;jb(t)?(n=t.dimensions,this._dimOmitted=t.isDimensionOmitted(),this._schema=t):(i=!0,n=t),n=n||["x","y"];for(var r={},o=[],a={},s=!1,l={},c=0;c=e)){var n=this._store.getProvider();this._updateOrdinalMeta();var i=this._nameList,r=this._idList;if(n.getSource().sourceFormat===mg&&!n.pure)for(var o=[],a=t;a0},t.prototype.ensureUniqueItemVisual=function(t,e){var n=this._itemVisuals,i=n[t];i||(i=n[t]={});var r=i[e];return null==r&&(Pn(r=this.getVisual(e))?r=r.slice():rx(r)&&(r=_n({},r)),i[e]=r),r},t.prototype.setItemVisual=function(t,e,n){var i=this._itemVisuals[t]||{};this._itemVisuals[t]=i,rx(e)?_n(i,e):i[e]=n},t.prototype.clearAllVisual=function(){this._visual={},this._itemVisuals=[]},t.prototype.setLayout=function(t,e){rx(t)?_n(this._layout,t):this._layout[t]=e},t.prototype.getLayout=function(t){return this._layout[t]},t.prototype.getItemLayout=function(t){return this._itemLayouts[t]},t.prototype.setItemLayout=function(t,e,n){this._itemLayouts[t]=n?_n(this._itemLayouts[t]||{},e):e},t.prototype.clearItemLayouts=function(){this._itemLayouts.length=0},t.prototype.setItemGraphicEl=function(t,e){!function(t,e,n,i){if(i){var r=Cd(i);r.dataIndex=n,r.dataType=e,r.seriesIndex=t,r.ssrType="chart","group"===i.type&&i.traverse(function(i){var r=Cd(i);r.seriesIndex=t,r.dataIndex=n,r.dataType=e,r.ssrType="chart"})}}(this.hostModel&&this.hostModel.seriesIndex,this.dataType,t,e),this._graphicEls[t]=e},t.prototype.getItemGraphicEl=function(t){return this._graphicEls[t]},t.prototype.eachItemGraphicEl=function(t,e){Cn(this._graphicEls,function(n,i){n&&t&&t.call(e,n,i)})},t.prototype.cloneShallow=function(e){return e||(e=new t(this._schema?this._schema:ox(this.dimensions,this._getDimInfo,this),this.hostModel)),ex(e,this),e._store=this._store,e},t.prototype.wrapMethod=function(t,e){var n=this[t];Ln(n)&&(this.__wrappedMethods=this.__wrappedMethods||[],this.__wrappedMethods.push(t),this[t]=function(){var t=n.apply(this,arguments);return e.apply(this,[t].concat(Gn(arguments)))})},t.internalField=(Kb=function(t){var e=t._invertedIndicesMap;Cn(e,function(n,i){var r=t._dimInfos[i],o=r.ordinalMeta,a=t._store;if(o){n=e[i]=new ax(o.categories.length);for(var s=0;s1&&(s+="__ec__"+c),i[e]=s}})),t}();function dx(t,e){xv(t)||(t=Sv(t));var n=(e=e||{}).coordDimensions||[],i=e.dimensionsDefine||t.dimensionsDefine||[],r=ei(),o=[],a=function(t,e,n,i){var r=Math.max(t.dimensionsDetectedCount||1,e.length,n.length,i||0);return Cn(e,function(t){var e;On(t)&&(e=t.dimsDef)&&(r=Math.max(r,e.length))}),r}(t,n,i,e.dimensionsCount),s=e.canOmitUnusedDimensions&&Zb(a),l=i===t.dimensionsDefine,c=l?Yb(t):Xb(i),d=e.encodeDefine;!d&&e.encodeDefaulter&&(d=e.encodeDefaulter(t,a));for(var u=ei(d),h=new sm(a),p=0;p0&&(i.name=r+(o-1)),o++,e.set(r,o)}}(o),new qb({source:t,dimensions:o,fullDimensionCount:a,dimensionOmitted:s})}function ux(t,e,n){if(n||e.hasKey(t)){for(var i=0;e.hasKey(t+i);)i++;t+=i}return e.set(t,!0),t}var hx=function(t){this.coordSysDims=[],this.axisMap=ei(),this.categoryAxisMap=ei(),this.coordSysName=t};var px={cartesian2d:function(t,e,n,i){var r=t.getReferringComponents("xAxis",ol).models[0],o=t.getReferringComponents("yAxis",ol).models[0];e.coordSysDims=["x","y"],n.set("x",r),n.set("y",o),fx(r)&&(i.set("x",r),e.firstCategoryDimIndex=0),fx(o)&&(i.set("y",o),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=1))},singleAxis:function(t,e,n,i){var r=t.getReferringComponents("singleAxis",ol).models[0];e.coordSysDims=["single"],n.set("single",r),fx(r)&&(i.set("single",r),e.firstCategoryDimIndex=0)},polar:function(t,e,n,i){var r=t.getReferringComponents("polar",ol).models[0],o=r.findAxisModel("radiusAxis"),a=r.findAxisModel("angleAxis");e.coordSysDims=["radius","angle"],n.set("radius",o),n.set("angle",a),fx(o)&&(i.set("radius",o),e.firstCategoryDimIndex=0),fx(a)&&(i.set("angle",a),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=1))},geo:function(t,e,n,i){e.coordSysDims=["lng","lat"]},parallel:function(t,e,n,i){var r=t.ecModel,o=r.getComponent("parallel",t.get("parallelIndex")),a=e.coordSysDims=o.dimensions.slice();Cn(o.parallelAxisIndex,function(t,o){var s=r.getComponent("parallelAxis",t),l=a[o];n.set(l,s),fx(s)&&(i.set(l,s),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=o))})},matrix:function(t,e,n,i){var r=t.getReferringComponents("matrix",ol).models[0];e.coordSysDims=["x","y"];var o=r.getDimensionModel("x"),a=r.getDimensionModel("y");n.set("x",o),n.set("y",a),i.set("x",o),i.set("y",a)}};function fx(t){return"category"===t.get("type")}function gx(t,e,n){var i,r,o,a=(n=n||{}).byIndex,s=n.stackedCoordDimension;!function(t){return!jb(t.schema)}(e)?(r=e.schema,i=r.dimensions,o=e.store):i=e;var l,c,d,u,h=!(!t||!t.get("stack"));if(Cn(i,function(t,e){En(t)&&(i[e]=t={name:t}),h&&!t.isExtraCoord&&(a||l||!t.ordinalMeta||(l=t),c||"ordinal"===t.type||"time"===t.type||s&&s!==t.coordDim||(c=t))}),!c||a||l||(a=!0),c){d="__\0ecstackresult_"+t.id,u="__\0ecstackedover_"+t.id,l&&(l.createInvertedIndices=!0);var p=c.coordDim,f=c.type,g=0;Cn(i,function(t){t.coordDim===p&&g++});var v={name:d,coordDim:p,coordDimIndex:g,type:f,isExtraCoord:!0,isCalculationCoord:!0,storeDimIndex:i.length},m={name:u,coordDim:u,coordDimIndex:g+1,type:f,isExtraCoord:!0,isCalculationCoord:!0,storeDimIndex:i.length+1};r?(o&&(v.storeDimIndex=o.ensureCalculationDimension(u,f),m.storeDimIndex=o.ensureCalculationDimension(d,f)),r.appendCalculationDimension(v),r.appendCalculationDimension(m)):(i.push(v),i.push(m))}return{stackedDimension:c&&c.name,stackedByDimension:l&&l.name,isStackedByIndex:a,stackedOverDimension:u,stackResultDimension:d}}function vx(t,e){return!!e&&e===t.getCalculationInfo("stackedDimension")}function mx(t,e,n){n=n||{};var i,r,o=e.getSourceManager();r=(i=o.getSource()).sourceFormat===mg;var a=function(t){var e=t.get("coordinateSystem"),n=new hx(e),i=px[e];if(i)return i(t,n,n.axisMap,n.categoryAxisMap),n}(e),s=function(t,e){var n,i=t.get("coordinateSystem"),r=Bf.get(i);return e&&e.coordSysDims&&(n=kn(e.coordSysDims,function(t){var n={name:t},i=e.axisMap.get(t);if(i){var r=i.get("type");n.type=function(t){return"category"===t?"ordinal":"time"===t?"time":"float"}(r)}return n})),n||(n=r&&(r.getDimensionsInfo?r.getDimensionsInfo():r.dimensions.slice())||["x","y"]),n}(e,a),l=n.useEncodeDefaulter,c=Ln(l)?l:l?An(Dg,s,e):null,d=dx(i,{coordDimensions:s,generateCoord:n.generateCoord,encodeDefine:e.getEncode(),encodeDefaulter:c,canOmitUnusedDimensions:!r}),u=function(t,e,n){var i,r;return n&&Cn(t,function(t,o){var a=t.coordDim,s=n.categoryAxisMap.get(a);s&&(null==i&&(i=o),t.ordinalMeta=s.getOrdinalMeta(),e&&(t.createInvertedIndices=!0)),null!=t.otherDims.itemName&&(r=!0)}),r||null==i||(t[i].otherDims.itemName=0),i}(d.dimensions,n.createInvertedIndices,a),h=r?null:o.getSharedDataStore(d),p=gx(e,{schema:d,store:h}),f=new cx(d,e);f.setCalculationInfo(p);var g=null!=u&&function(t){if(t.sourceFormat===mg){var e=function(t){var e=0;for(;er&&(a=o.interval=r);var s=o.intervalPrecision=xx(a);return function(t,e){!isFinite(t[0])&&(t[0]=e[0]),!isFinite(t[1])&&(t[1]=e[1]),Sx(t,0,e),Sx(t,1,e),t[0]>t[1]&&(t[0]=t[1])}(o.niceTickExtent=[ks(Math.ceil(t[0]/a)*a,s),ks(Math.floor(t[1]/a)*a,s)],t),o}function bx(t){var e=Math.pow(10,Ls(t)),n=t/e;return n?2===n?n=3:3===n?n=5:n*=2:n=1,ks(n*e)}function xx(t){return Ms(t)+2}function Sx(t,e,n){t[e]=Math.max(Math.min(t[e],n[1]),n[0])}function Cx(t,e){return t>=e[0]&&t<=e[1]}var kx=function(){function t(){this.normalize=Mx,this.scale=Tx}return t.prototype.updateMethods=function(t){t.hasBreaks()?(this.normalize=Dn(t.normalize,t),this.scale=Dn(t.scale,t)):(this.normalize=Mx,this.scale=Tx)},t}();function Mx(t,e){return e[1]===e[0]?.5:(t-e[0])/(e[1]-e[0])}function Tx(t,e){return t*(e[1]-e[0])+e[0]}function Ix(t,e,n){var i=Math.log(t);return[Math.log(n?e[0]:Math.max(0,e[0]))/i,Math.log(n?e[1]:Math.max(0,e[1]))/i]}var Dx=function(){function t(t){this._calculator=new kx,this._setting=t||{},this._extent=[1/0,-1/0]}return t.prototype.getSetting=function(t){return this._setting[t]},t.prototype._innerUnionExtent=function(t){var e=this._extent;this._innerSetExtent(t[0]e[1]?t[1]:e[1])},t.prototype.unionExtentFromData=function(t,e){this._innerUnionExtent(t.getApproximateExtent(e))},t.prototype.getExtent=function(){return this._extent.slice()},t.prototype.setExtent=function(t,e){this._innerSetExtent(t,e)},t.prototype._innerSetExtent=function(t,e){var n=this._extent;isNaN(t)||(n[0]=t),isNaN(e)||(n[1]=e),this._brkCtx&&this._brkCtx.update(n)},t.prototype.setBreaksFromOption=function(t){},t.prototype._innerSetBreak=function(t){this._brkCtx&&(this._brkCtx.setBreaks(t),this._calculator.updateMethods(this._brkCtx),this._brkCtx.update(this._extent))},t.prototype._innerGetBreaks=function(){return this._brkCtx?this._brkCtx.breaks:[]},t.prototype.hasBreaks=function(){return!!this._brkCtx&&this._brkCtx.hasBreaks()},t.prototype._getExtentSpanWithBreaks=function(){return this._brkCtx&&this._brkCtx.hasBreaks()?this._brkCtx.getExtentSpan():this._extent[1]-this._extent[0]},t.prototype.isInExtentRange=function(t){return this._extent[0]<=t&&this._extent[1]>=t},t.prototype.isBlank=function(){return this._isBlank},t.prototype.setBlank=function(t){this._isBlank=t},t}();vl(Dx);var Ax=0,Px=function(){function t(t){this.categories=t.categories||[],this._needCollect=t.needCollect,this._deduplication=t.deduplication,this.uid=++Ax,this._onCollect=t.onCollect}return t.createByAxisModel=function(e){var n=e.option,i=n.data,r=i&&kn(i,Lx);return new t({categories:r,needCollect:!r,deduplication:!1!==n.dedplication})},t.prototype.getOrdinal=function(t){return this._getOrCreateMap().get(t)},t.prototype.parseAndCollect=function(t){var e,n=this._needCollect;if(!En(t)&&!n)return t;if(n&&!this._deduplication)return e=this.categories.length,this.categories[e]=t,this._onCollect&&this._onCollect(t,e),e;var i=this._getOrCreateMap();return null==(e=i.get(t))&&(n?(e=this.categories.length,this.categories[e]=t,i.set(t,e),this._onCollect&&this._onCollect(t,e)):e=NaN),e},t.prototype._getOrCreateMap=function(){return this._map||(this._map=ei(this.categories))},t}();function Lx(t){return On(t)&&null!=t.value?t.value:t+""}var Ex=function(t){function e(e){var n=t.call(this,e)||this;n.type="ordinal";var i=n.getSetting("ordinalMeta");return i||(i=new Px({})),Pn(i)&&(i=new Px({categories:kn(i,function(t){return On(t)?t.value:t})})),n._ordinalMeta=i,n._extent=n.getSetting("extent")||[0,i.categories.length-1],n}return y(e,t),e.prototype.parse=function(t){return null==t?NaN:En(t)?this._ordinalMeta.getOrdinal(t):Math.round(t)},e.prototype.contain=function(t){return Cx(t,this._extent)&&t>=0&&t=0&&t=0&&t=t},e.prototype.getOrdinalMeta=function(){return this._ordinalMeta},e.prototype.calcNiceTicks=function(){},e.prototype.calcNiceExtent=function(){},e.type="ordinal",e}(Dx);Dx.registerClass(Ex);var zx=ks,Nx=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.type="interval",e._interval=0,e._intervalPrecision=2,e}return y(e,t),e.prototype.parse=function(t){return null==t||""===t?NaN:Number(t)},e.prototype.contain=function(t){return Cx(t,this._extent)},e.prototype.normalize=function(t){return this._calculator.normalize(t,this._extent)},e.prototype.scale=function(t){return this._calculator.scale(t,this._extent)},e.prototype.getInterval=function(){return this._interval},e.prototype.setInterval=function(t){this._interval=t,this._niceExtent=this._extent.slice(),this._intervalPrecision=xx(t)},e.prototype.getTicks=function(t){t=t||{};var e=this._interval,n=this._extent,i=this._niceExtent,r=this._intervalPrecision,o=[];if(!e)return o;t.breakTicks;n[0]=0&&(s=zx(s+l*e,r))}if(o.length>0&&s===o[o.length-1].value)break;if(o.length>1e4)return[]}var c=o.length?o[o.length-1].value:i[1];return n[1]>c&&(t.expandToNicedExtent?o.push({value:zx(c+e,r)}):o.push({value:n[1]})),t.breakTicks,o},e.prototype.getMinorTicks=function(t){for(var e=this.getTicks({expandToNicedExtent:!0}),n=[],i=this.getExtent(),r=1;ri[0]&&u0&&(o=null===o?s:Math.min(o,s))}n[i]=o}}return n}(t),n=[];return Cn(t,function(t){var i,r=t.coordinateSystem.getBaseAxis(),o=r.getExtent();if("category"===r.type)i=r.getBandWidth();else if("value"===r.type||"time"===r.type){var a=r.dim+"_"+r.index,s=e[a],l=Math.abs(o[1]-o[0]),c=r.scale.getExtent(),d=Math.abs(c[1]-c[0]);i=s?l/d*s:l}else{var u=t.getData();i=Math.abs(o[1]-o[0])/u.count()}var h=Ss(t.get("barWidth"),i),p=Ss(t.get("barMaxWidth"),i),f=Ss(t.get("barMinWidth")||(function(t){return t.pipelineContext&&t.pipelineContext.large}(t)?.5:1),i),g=t.get("barGap"),v=t.get("barCategoryGap"),m=t.get("defaultBarGap");n.push({bandWidth:i,barWidth:h,barMaxWidth:p,barMinWidth:f,barGap:g,barCategoryGap:v,defaultBarGap:m,axisKey:Fx(r),stackId:Hx(t)})}),function(t){var e={};Cn(t,function(t,n){var i=t.axisKey,r=t.bandWidth,o=e[i]||{bandWidth:r,remainedWidth:r,autoWidthCount:0,categoryGap:null,gap:t.defaultBarGap||0,stacks:{}},a=o.stacks;e[i]=o;var s=t.stackId;a[s]||o.autoWidthCount++,a[s]=a[s]||{width:0,maxWidth:0};var l=t.barWidth;l&&!a[s].width&&(a[s].width=l,l=Math.min(o.remainedWidth,l),o.remainedWidth-=l);var c=t.barMaxWidth;c&&(a[s].maxWidth=c);var d=t.barMinWidth;d&&(a[s].minWidth=d);var u=t.barGap;null!=u&&(o.gap=u);var h=t.barCategoryGap;null!=h&&(o.categoryGap=h)});var n={};return Cn(e,function(t,e){n[e]={};var i=t.stacks,r=t.bandWidth,o=t.categoryGap;if(null==o){var a=In(i).length;o=Math.max(35-4*a,15)+"%"}var s=Ss(o,r),l=Ss(t.gap,1),c=t.remainedWidth,d=t.autoWidthCount,u=(c-s)/(d+(d-1)*l);u=Math.max(u,0),Cn(i,function(t){var e=t.maxWidth,n=t.minWidth;if(t.width){i=t.width;e&&(i=Math.min(i,e)),n&&(i=Math.max(i,n)),t.width=i,c-=i+l*i,d--}else{var i=u;e&&ei&&(i=n),i!==u&&(t.width=i,c-=i+l*i,d--)}}),u=(c-s)/(d+(d-1)*l),u=Math.max(u,0);var h,p=0;Cn(i,function(t,e){t.width||(t.width=u),h=t,p+=t.width*(1+l)}),h&&(p-=h.width*l);var f=-p/2;Cn(i,function(t,i){n[e][i]=n[e][i]||{bandWidth:r,offset:f,width:t.width},f+=t.width*(1+l)})}),n}(n)}var Vx=function(t){function e(e){var n=t.call(this,e)||this;return n.type="time",n}return y(e,t),e.prototype.getLabel=function(t){var e=this.getSetting("useUTC");return ff(t.value,sf[function(t){switch(t){case"year":case"month":return"day";case"millisecond":return"millisecond";default:return"second"}}(hf(this._minLevelUnit))]||sf.second,e,this.getSetting("locale"))},e.prototype.getFormattedLabel=function(t,e,n){var i=this.getSetting("useUTC");return function(t,e,n,i,r){var o=null;if(En(n))o=n;else if(Ln(n)){var a={time:t.time,level:t.time.level},s=null;s&&s.makeAxisLabelFormatterParamBreak(a,t.break),o=n(t.value,e,a)}else{var l=t.time;if(l){var c=n[l.lowerTimeUnit][l.upperTimeUnit];o=c[Math.min(l.level,c.length-1)]||""}else{var d=gf(t.value,r);o=n[d][d][0]}}return ff(new Date(t.value),o,r,i)}(t,e,n,this.getSetting("locale"),i)},e.prototype.getTicks=function(t){var e=this._interval,n=this._extent,i=[];if(!e)return i;var r=this.getSetting("useUTC"),o=gf(n[1],r);i.push({value:n[0],time:{level:0,upperTimeUnit:o,lowerTimeUnit:o}});var a=function(t,e,n,i,r,o){var a=1e4,s=cf,l=0;function c(t,e,n,r,s,c,d){for(var u=function(t,e){var n=new Date(0);n[t](1);var i=n.getTime();n[t](1+e);var r=n.getTime()-i;return function(t,e){return Math.max(0,Math.round((e-t)/r))}}(s,t),h=e,p=new Date(h);ha));)if(p[s](p[r]()+t),h=p.getTime(),o){var f=o.calcNiceTickMultiple(h,u);f>0&&(p[s](p[r]()+f*t),h=p.getTime())}d.push({value:h,notAdd:!0})}function d(t,r,o){var a=[],s=!r.length;if(!function(t,e,n,i){return vf(new Date(e),t,i).getTime()===vf(new Date(n),t,i).getTime()}(hf(t),i[0],i[1],n)){s&&(r=[{value:Yx(i[0],t,n)},{value:i[1]}]);for(var l=0;l=i[0]&&d<=i[1]&&c(h,d,u,p,f,g,a),"year"===t&&o.length>1&&0===l&&o.unshift({value:o[0].value-h})}}for(l=0;l=i[0]&&_<=i[1]&&p++)}var b=r/e;if(p>1.5*b&&f>b/1.5)break;if(u.push(m),p>b||t===s[g])break}h=[]}}var x=Tn(kn(u,function(t){return Tn(t,function(t){return t.value>=i[0]&&t.value<=i[1]&&!t.notAdd})}),function(t){return t.length>0}),w=[],S=x.length-1;for(g=0;gn&&(this._approxInterval=n);var r=Wx.length,o=Math.min(function(t,e,n,i){for(;n>>1;t[r][1]16?16:t>7.5?7:t>3.5?4:t>1.5?2:1}function Gx(t){return(t/=2592e6)>6?6:t>3?3:t>2?2:1}function qx(t){return(t/=tf)>12?12:t>6?6:t>3.5?4:t>2?2:1}function jx(t,e){return(t/=e?Jp:Qp)>30?30:t>20?20:t>15?15:t>10?10:t>5?5:t>2?2:1}function Xx(t){return Es(t)}function Yx(t,e,n){var i=Math.max(0,xn(lf,e)-1);return vf(new Date(t),lf[i],n).getTime()}Dx.registerClass(Vx);var Zx=ks,Kx=Math.floor,Qx=Math.ceil,Jx=Math.pow,tw=Math.log,ew=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.type="log",e.base=10,e._originalScale=new Nx,e}return y(e,t),e.prototype.getTicks=function(e){e=e||{};var n=this._extent.slice(),i=this._originalScale.getExtent(),r=t.prototype.getTicks.call(this,e),o=this.base;return this._originalScale._innerGetBreaks(),kn(r,function(t){var e=t.value,r=null,a=Jx(o,e);return e===n[0]&&this._fixMin?r=i[0]:e===n[1]&&this._fixMax&&(r=i[1]),null!=r&&(a=nw(a,r)),{value:a,break:void 0}},this)},e.prototype._getNonTransBreaks=function(){return this._originalScale._innerGetBreaks()},e.prototype.setExtent=function(e,n){this._originalScale.setExtent(e,n);var i=Ix(this.base,[e,n]);t.prototype.setExtent.call(this,i[0],i[1])},e.prototype.getExtent=function(){var e=this.base,n=t.prototype.getExtent.call(this);n[0]=Jx(e,n[0]),n[1]=Jx(e,n[1]);var i=this._originalScale.getExtent();return this._fixMin&&(n[0]=nw(n[0],i[0])),this._fixMax&&(n[1]=nw(n[1],i[1])),n},e.prototype.unionExtentFromData=function(t,e){this._originalScale.unionExtentFromData(t,e);var n=Ix(this.base,t.getApproximateExtent(e),!0);this._innerUnionExtent(n)},e.prototype.calcNiceTicks=function(t){t=t||10;var e=this._extent.slice(),n=this._getExtentSpanWithBreaks();if(isFinite(n)&&!(n<=0)){var i,r=(i=n,Math.pow(10,Ls(i)));for(t/n*r<=.5&&(r*=10);!isNaN(r)&&Math.abs(r)<1&&Math.abs(r)>0;)r*=10;var o=[Zx(Qx(e[0]/r)*r),Zx(Kx(e[1]/r)*r)];this._interval=r,this._intervalPrecision=xx(r),this._niceExtent=o}},e.prototype.calcNiceExtent=function(e){t.prototype.calcNiceExtent.call(this,e),this._fixMin=e.fixMin,this._fixMax=e.fixMax},e.prototype.contain=function(e){return e=tw(e)/tw(this.base),t.prototype.contain.call(this,e)},e.prototype.normalize=function(e){return e=tw(e)/tw(this.base),t.prototype.normalize.call(this,e)},e.prototype.scale=function(e){return e=t.prototype.scale.call(this,e),Jx(this.base,e)},e.prototype.setBreaksFromOption=function(t){},e.type="log",e}(Nx);function nw(t,e){return Zx(t,Ms(e))}Dx.registerClass(ew);var iw=function(){function t(t,e,n){this._prepareParams(t,e,n)}return t.prototype._prepareParams=function(t,e,n){n[1]0&&s>0&&!l&&(a=0),a<0&&s<0&&!c&&(s=0));var u=this._determinedMin,h=this._determinedMax;return null!=u&&(a=u,l=!0),null!=h&&(s=h,c=!0),{min:a,max:s,minFixed:l,maxFixed:c,isBlank:d}},t.prototype.modifyDataMinMax=function(t,e){this[ow[t]]=e},t.prototype.setDeterminedMinMax=function(t,e){this[rw[t]]=e},t.prototype.freeze=function(){this.frozen=!0},t}(),rw={min:"_determinedMin",max:"_determinedMax"},ow={min:"_dataMin",max:"_dataMax"};function aw(t,e){return null==e?null:Bn(e)?NaN:t.parse(e)}function sw(t,e){var n=t.type,i=function(t,e,n){var i=t.rawExtentInfo;return i||(i=new iw(t,e,n),t.rawExtentInfo=i,i)}(t,e,t.getExtent()).calculate();t.setBlank(i.isBlank);var r=i.min,o=i.max,a=e.ecModel;if(a&&"time"===n){var s=function(t,e){var n=[];return e.eachSeriesByType(t,function(t){(function(t){return t.coordinateSystem&&"cartesian2d"===t.coordinateSystem.type})(t)&&n.push(t)}),n}("bar",a),l=!1;if(Cn(s,function(t){l=l||t.getBaseAxis()===e.axis}),l){var c=Bx(s),d=function(t,e,n,i){var r=n.axis.getExtent(),o=Math.abs(r[1]-r[0]),a=function(t,e){if(t&&e)return t[Fx(e)]}(i,n.axis);if(void 0===a)return{min:t,max:e};var s=1/0;Cn(a,function(t){s=Math.min(t.offset,s)});var l=-1/0;Cn(a,function(t){l=Math.max(t.offset+t.width,l)}),s=Math.abs(s),l=Math.abs(l);var c=s+l,d=e-t,u=d/(1-(s+l)/o)-d;return e+=u*(l/c),t-=u*(s/c),{min:t,max:e}}(r,o,e,c);r=d.min,o=d.max}}return{extent:[r,o],fixMin:i.minFixed,fixMax:i.maxFixed}}function lw(t,e){var n=e,i=sw(t,n),r=i.extent,o=n.get("splitNumber");t instanceof ew&&(t.base=n.get("logBase"));var a=t.type,s=n.get("interval"),l="interval"===a||"time"===a;t.setBreaksFromOption(vw(n)),t.setExtent(r[0],r[1]),t.calcNiceExtent({splitNumber:o,fixMin:i.fixMin,fixMax:i.fixMax,minInterval:l?n.get("minInterval"):null,maxInterval:l?n.get("maxInterval"):null}),null!=s&&t.setInterval&&t.setInterval(s)}function cw(t){var e=t.getLabelModel().get("formatter");if("time"===t.type){var n=df(e);return function(e,i){return t.scale.getFormattedLabel(e,i,n)}}if(En(e))return function(n){var i=t.scale.getLabel(n);return e.replace("{value}",null!=i?i:"")};if(Ln(e)){if("category"===t.type)return function(n,i){return e(dw(t,n),n.value-t.scale.getExtent()[0],null)};var i=null;return function(n,r){var o=null;return i&&(o=i.makeAxisLabelFormatterParamBreak(o,n.break)),e(dw(t,n),r,o)}}return function(e){return t.scale.getLabel(e)}}function dw(t,e){return"category"===t.type?t.scale.getLabel(e):e.value}function uw(t){var e=t.get("interval");return null==e?"auto":e}function hw(t){return"category"===t.type&&0===uw(t.getLabelModel())}function pw(t,e){var n={};return Cn(t.mapDimensionsAll(e),function(e){n[function(t,e){return vx(t,e)?t.getCalculationInfo("stackResultDimension"):e}(t,e)]=!0}),In(n)}function fw(t){return"middle"===t||"center"===t}function gw(t){return t.getShallow("show")}function vw(t){t.get("breaks",!0)}var mw=function(){function t(){}return t.prototype.getNeedCrossZero=function(){return!this.option.scale},t.prototype.getCoordSysModel=function(){},t}(),yw=[],_w={registerPreprocessor:Ib,registerProcessor:Db,registerPostInit:function(t){Ab("afterinit",t)},registerPostUpdate:function(t){Ab("afterupdate",t)},registerUpdateLifecycle:Ab,registerAction:Pb,registerCoordinateSystem:function(t,e){Bf.register(t,e)},registerLayout:function(t,e){zb(_b,t,e,1e3,"layout")},registerVisual:Lb,registerTransform:Ob,registerLoading:Nb,registerMap:function(t,e,n){var i=P_["registerMap"];i&&i(t,e,n)},registerImpl:function(t,e){P_[t]=e},PRIORITY:z_,ComponentModel:ag,ComponentView:Um,SeriesModel:Om,ChartView:Xm,registerComponentModel:function(t){ag.registerClass(t)},registerComponentView:function(t){Um.registerClass(t)},registerSeriesModel:function(t){Om.registerClass(t)},registerChartView:function(t){Xm.registerClass(t)},registerCustomSeries:function(t,e){},registerSubTypeDefaulter:function(t,e){ag.registerSubTypeDefaulter(t,e)},registerPainter:function(t,e){var n;n=e,ps[t]=n}};function bw(t){Pn(t)?Cn(t,function(t){bw(t)}):xn(yw,t)>=0||(yw.push(t),Ln(t)&&(t={install:t}),t.install(_w))}var xw=el(),ww=el(),Sw=1,Cw=2;function kw(t){return{out:{noPxChangeTryDetermine:[]},kind:t}}function Mw(t,e){var n=kn(e,function(e){return t.scale.parse(e)});return"time"===t.type&&n.length>0&&(n.sort(),n.unshift(n[0]),n.push(n[n.length-1])),n}function Tw(t,e){var n=t.getLabelModel().get("customValues");if(n){var i=cw(t),r=t.scale.getExtent();return{labels:kn(Tn(Mw(t,n),function(t){return t>=r[0]&&t<=r[1]}),function(e){var n={value:e};return{formattedLabel:i(n),rawLabel:t.scale.getLabel(n),tickValue:e,time:void 0,break:void 0}})}}return"category"===t.type?function(t,e){var n=t.getLabelModel(),i=Dw(t,n,e);return!n.get("show")||t.scale.isBlank()?{labels:[]}:i}(t,e):function(t){var e=t.scale.getTicks(),n=cw(t);return{labels:kn(e,function(e,i){return{formattedLabel:n(e,i),rawLabel:t.scale.getLabel(e),tickValue:e.value,time:e.time,break:e.break}})}}(t)}function Iw(t,e,n){var i=t.getTickModel().get("customValues");if(i){var r=t.scale.getExtent();return{ticks:Tn(Mw(t,i),function(t){return t>=r[0]&&t<=r[1]})}}return"category"===t.type?function(t,e){var n,i,r=Aw(t),o=uw(e),a=Ew(r,o);if(a)return a;e.get("show")&&!t.scale.isBlank()||(n=[]);if(Ln(o))n=Rw(t,o,!0);else if("auto"===o){var s=Dw(t,t.getLabelModel(),kw(Cw));i=s.labelCategoryInterval,n=kn(s.labels,function(t){return t.tickValue})}else n=$w(t,i=o,!0);return zw(r,o,{ticks:n,tickCategoryInterval:i})}(t,e):{ticks:kn(t.scale.getTicks(n),function(t){return t.value})}}function Dw(t,e,n){var i,r,o=Pw(t),a=uw(e),s=n.kind===Sw;if(!s){var l=Ew(o,a);if(l)return l}Ln(a)?i=Rw(t,a):(r="auto"===a?function(t,e){if(e.kind===Sw){var n=t.calculateCategoryInterval(e);return e.out.noPxChangeTryDetermine.push(function(){return ww(t).autoInterval=n,!0}),n}var i=ww(t).autoInterval;return null!=i?i:ww(t).autoInterval=t.calculateCategoryInterval(e)}(t,n):a,i=$w(t,r));var c={labels:i,labelCategoryInterval:r};return s?n.out.noPxChangeTryDetermine.push(function(){return zw(o,a,c),!0}):zw(o,a,c),c}var Aw=Lw("axisTick"),Pw=Lw("axisLabel");function Lw(t){return function(e){return ww(e)[t]||(ww(e)[t]={list:[]})}}function Ew(t,e){for(var n=0;ne&&i.axisExtent0===r[0]&&i.axisExtent1===r[1])return o;i.lastTickCount=n,i.lastAutoInterval=e,i.axisExtent0=r[0],i.axisExtent1=r[1]}function $w(t,e,n){var i=cw(t),r=t.scale,o=r.getExtent(),a=t.getLabelModel(),s=[],l=Math.max((e||0)+1,1),c=o[0],d=r.count();0!==c&&l>1&&d/l>2&&(c=Math.round(Math.ceil(c/l)*l));var u=hw(t),h=a.get("showMinLabel")||u,p=a.get("showMaxLabel")||u;h&&c!==o[0]&&g(o[0]);for(var f=c;f<=o[1];f+=l)g(f);function g(t){var e={value:t};s.push(n?t:{formattedLabel:i(e),rawLabel:r.getLabel(e),tickValue:t,time:void 0,break:void 0})}return p&&f-l!==o[1]&&g(o[1]),s}function Rw(t,e,n){var i=t.scale,r=cw(t),o=[];return Cn(i.getTicks(),function(t){var a=i.getLabel(t),s=t.value;e(t.value,a)&&o.push(n?s:{formattedLabel:r(t),rawLabel:a,tickValue:s,time:void 0,break:void 0})}),o}var Hw=[0,1],Fw=function(){function t(t,e,n){this.onBand=!1,this.inverse=!1,this.dim=t,this.scale=e,this._extent=n||[0,0]}return t.prototype.contain=function(t){var e=this._extent,n=Math.min(e[0],e[1]),i=Math.max(e[0],e[1]);return t>=n&&t<=i},t.prototype.containData=function(t){return this.scale.contain(this.scale.parse(t))},t.prototype.getExtent=function(){return this._extent.slice()},t.prototype.getPixelPrecision=function(t){return function(t,e){var n=Math.log,i=Math.LN10,r=Math.floor(n(t[1]-t[0])/i),o=Math.round(n(xs(e[1]-e[0]))/i),a=Math.min(Math.max(-r+o,0),20);return isFinite(a)?a:20}(t||this.scale.getExtent(),this._extent)},t.prototype.setExtent=function(t,e){var n=this._extent;n[0]=t,n[1]=e},t.prototype.dataToCoord=function(t,e){var n=this._extent,i=this.scale;return t=i.normalize(i.parse(t)),this.onBand&&"ordinal"===i.type&&Bw(n=n.slice(),i.count()),ws(t,Hw,n,e)},t.prototype.coordToData=function(t,e){var n=this._extent,i=this.scale;this.onBand&&"ordinal"===i.type&&Bw(n=n.slice(),i.count());var r=ws(t,n,Hw,e);return this.scale.scale(r)},t.prototype.pointToData=function(t,e){},t.prototype.getTicksCoords=function(t){var e=(t=t||{}).tickModel||this.getTickModel(),n=kn(Iw(this,e,{breakTicks:t.breakTicks,pruneByBreak:t.pruneByBreak}).ticks,function(t){return{coord:this.dataToCoord("ordinal"===this.scale.type?this.scale.getRawOrdinalNumber(t):t),tickValue:t}},this);return function(t,e,n,i){var r=e.length;if(!t.onBand||n||!r)return;var o,a,s=t.getExtent();if(1===r)e[0].coord=s[0],e[0].onBand=!0,o=e[1]={coord:s[1],tickValue:e[0].tickValue,onBand:!0};else{var l=e[r-1].tickValue-e[0].tickValue,c=(e[r-1].coord-e[0].coord)/l;Cn(e,function(t){t.coord-=c/2,t.onBand=!0});var d=t.scale.getExtent();a=1+d[1]-e[r-1].tickValue,o={coord:e[r-1].coord+c*a,tickValue:d[1]+1,onBand:!0},e.push(o)}var u=s[0]>s[1];h(e[0].coord,s[0])&&(i?e[0].coord=s[0]:e.shift());i&&h(s[0],e[0].coord)&&e.unshift({coord:s[0],onBand:!0});h(s[1],o.coord)&&(i?o.coord=s[1]:e.pop());i&&h(o.coord,s[1])&&e.push({coord:s[1],onBand:!0});function h(t,e){return t=ks(t),e=ks(e),u?t>e:t0&&t<100||(t=5),kn(this.scale.getMinorTicks(t),function(t){return kn(t,function(t){return{coord:this.dataToCoord(t),tickValue:t}},this)},this)},t.prototype.getViewLabels=function(t){return Tw(this,t=t||kw(Cw)).labels},t.prototype.getLabelModel=function(){return this.model.getModel("axisLabel")},t.prototype.getTickModel=function(){return this.model.getModel("axisTick")},t.prototype.getBandWidth=function(){var t=this._extent,e=this.scale.getExtent(),n=e[1]-e[0]+(this.onBand?1:0);0===n&&(n=1);var i=Math.abs(t[1]-t[0]);return Math.abs(i)/n},t.prototype.calculateCategoryInterval=function(t){return function(t,e){var n=e.kind,i=function(t){var e=t.getLabelModel();return{axisRotate:t.getRotate?t.getRotate():t.isHorizontal&&!t.isHorizontal()?90:0,labelRotate:e.get("rotate")||0,font:e.getFont()}}(t),r=cw(t),o=(i.axisRotate-i.labelRotate)/180*Math.PI,a=t.scale,s=a.getExtent(),l=a.count();if(s[1]-s[0]<1)return 0;var c=1;l>40&&(c=Math.max(1,Math.floor(l/40)));for(var d=s[0],u=t.dataToCoord(d+1)-t.dataToCoord(d),h=Math.abs(u*Math.cos(o)),p=Math.abs(u*Math.sin(o)),f=0,g=0;d<=s[1];d+=c){var v,m,y=Ya(r({value:d}),i.font,"center","top");v=1.3*y.width,m=1.3*y.height,f=Math.max(f,v,7),g=Math.max(g,m,7)}var _=f/h,b=g/p;isNaN(_)&&(_=1/0),isNaN(b)&&(b=1/0);var x=Math.max(0,Math.floor(Math.min(_,b)));if(n===Sw)return e.out.noPxChangeTryDetermine.push(Dn(Nw,null,t,x,l)),x;var w=Ow(t,x,l);return null!=w?w:x}(this,t=t||kw(Cw))},t}();function Bw(t,e){var n=(t[1]-t[0])/e/2;t[0]+=n,t[1]-=n}var Vw=["label","labelLine","layoutOption","priority","defaultAttr","marginForce","minMarginForce","marginDefault","suggestIgnore"];function Ww(t,e,n){n=n||3,e?t.dirty|=n:t.dirty&=~n}function Uw(t,e){return e=e||3,null==t.dirty||!!(t.dirty&e)}function Gw(t){if(t)return Uw(t)&&function(t,e,n){var i=e.getComputedTransform();t.transform=hp(t.transform,i);var r=t.localRect=up(t.localRect,e.getBoundingRect()),o=e.style,a=o.margin,s=n&&n.marginForce,l=n&&n.minMarginForce,c=n&&n.marginDefault,d=o.__marginType;null==d&&c&&(a=c,d=Ap.textMargin);for(var u=0;u<4;u++)qw[u]=d===Ap.minMargin&&l&&null!=l[u]?l[u]:s&&null!=s[u]?s[u]:a?a[u]:0;d===Ap.textMargin&&ip(r,qw,!1,!1);var h=t.rect=up(t.rect,r);i&&h.applyTransform(i);d===Ap.minMargin&&ip(h,qw,!1,!1);t.axisAligned=cp(i),(t.label=t.label||{}).ignore=e.ignore,Ww(t,!1),Ww(t,!0,2)}(t,t.label,t),t}var qw=[0,0,0,0];function jw(t,e){for(var n=0;n-1&&(s.style.stroke=s.style.fill,s.style.fill=sg.color.neutral00,s.style.lineWidth=2),e},e.type="series.line",e.dependencies=["grid","polar"],e.defaultOption={z:3,coordinateSystem:"cartesian2d",legendHoverLink:!0,clip:!0,label:{position:"top"},endLabel:{show:!1,valueAnimation:!0,distance:8},lineStyle:{width:2,type:"solid"},emphasis:{scale:!0},step:!1,smooth:!1,smoothMonotone:null,symbol:"emptyCircle",symbolSize:6,symbolRotate:null,showSymbol:!0,showAllSymbol:"auto",connectNulls:!1,sampling:"none",animationEasing:"linear",progressive:0,hoverLayerThreshold:1/0,universalTransition:{divideShape:"clone"},triggerLineEvent:!1},e}(Om);function Kw(t,e){var n=t.mapDimensionsAll("defaultedLabel"),i=n.length;if(1===i){var r=Fv(t,e,n[0]);return null!=r?r+"":null}if(i){for(var o=[],a=0;a0?+h:1;M.scaleX=this._sizeX*T,M.scaleY=this._sizeY*T,this.setSymbolScale(1),uu(this,l,c,d)},e.prototype.setSymbolScale=function(t){this.scaleX=this.scaleY=t},e.prototype.fadeOut=function(t,e,n){var i=this.childAt(0),r=Cd(this).dataIndex,o=n&&n.animation;if(this.silent=i.silent=!0,n&&n.fadeLabel){var a=i.getTextContent();a&&$h(a,{style:{opacity:0}},e,{dataIndex:r,removeOpt:o,cb:function(){i.removeTextContent()}})}else i.removeTextContent();$h(i,{style:{opacity:0},scaleX:0,scaleY:0},e,{dataIndex:r,cb:t,removeOpt:o})},e.getSymbolSize=function(t,e){return Pn(n=t.getItemVisual(e,"symbolSize"))||(n=[+n,+n]),[n[0]||0,n[1]||0];var n},e.getSymbolZ2=function(t,e){return t.getItemVisual(e,"z2")},e}(hs);function Jw(t,e){this.parent.drift(t,e)}function tS(t,e,n,i){return e&&!isNaN(e[0])&&!isNaN(e[1])&&!(i.isIgnore&&i.isIgnore(n))&&!(i.clipShape&&!i.clipShape.contain(e[0],e[1]))&&"none"!==t.getItemVisual(n,"symbol")}function eS(t){return null==t||On(t)||(t={isIgnore:t}),t||{}}function nS(t){var e=t.hostModel,n=e.getModel("emphasis");return{emphasisItemStyle:n.getModel("itemStyle").getItemStyle(),blurItemStyle:e.getModel(["blur","itemStyle"]).getItemStyle(),selectItemStyle:e.getModel(["select","itemStyle"]).getItemStyle(),focus:n.get("focus"),blurScope:n.get("blurScope"),emphasisDisabled:n.get("disabled"),hoverScale:n.get("scale"),labelStatesModels:bp(e),cursorStyle:e.get("cursor")}}var iS=function(){function t(t){this.group=new hs,this._SymbolCtor=t||Qw}return t.prototype.updateData=function(t,e){this._progressiveEls=null,e=eS(e);var n=this.group,i=t.hostModel,r=this._data,o=this._SymbolCtor,a=e.disableAnimation,s=nS(t),l={disableAnimation:a},c=e.getSymbolPoint||function(e){return t.getItemLayout(e)};r||n.removeAll(),t.diff(r).add(function(i){var r=c(i);if(tS(t,r,i,e)){var a=new o(t,i,s,l);a.setPosition(r),t.setItemGraphicEl(i,a),n.add(a)}}).update(function(d,u){var h=r.getItemGraphicEl(u),p=c(d);if(tS(t,p,d,e)){var f=t.getItemVisual(d,"symbol")||"circle",g=h&&h.getSymbolType&&h.getSymbolType();if(!h||g&&g!==f)n.remove(h),(h=new o(t,d,s,l)).setPosition(p);else{h.updateData(t,d,s,l);var v={x:p[0],y:p[1]};a?h.attr(v):zh(h,v,i)}n.add(h),t.setItemGraphicEl(d,h)}else n.remove(h)}).remove(function(t){var e=r.getItemGraphicEl(t);e&&e.fadeOut(function(){n.remove(e)},i)}).execute(),this._getSymbolPoint=c,this._data=t},t.prototype.updateLayout=function(){var t=this,e=this._data;e&&e.eachItemGraphicEl(function(e,n){var i=t._getSymbolPoint(n);e.setPosition(i),e.markRedraw()})},t.prototype.incrementalPrepareUpdate=function(t){this._seriesScope=nS(t),this._data=null,this.group.removeAll()},t.prototype.incrementalUpdate=function(t,e,n){function i(t){t.isGroup||(t.incremental=!0,t.ensureState("emphasis").hoverLayer=!0)}this._progressiveEls=[],n=eS(n);for(var r=t.start;r0?n=i[0]:i[1]<0&&(n=i[1]);return n}(r,n),a=i.dim,s=r.dim,l=e.mapDimension(s),c=e.mapDimension(a),d="x"===s||"radius"===s?1:0,u=kn(t.dimensions,function(t){return e.mapDimension(t)}),h=!1,p=e.getCalculationInfo("stackResultDimension");return vx(e,u[0])&&(h=!0,u[0]=p),vx(e,u[1])&&(h=!0,u[1]=p),{dataDimsForPoint:u,valueStart:o,valueAxisDim:s,baseAxisDim:a,stacked:!!h,valueDim:l,baseDim:c,baseDataOffset:d,stackedOverDimension:e.getCalculationInfo("stackedOverDimension")}}function oS(t,e,n,i){var r=NaN;t.stacked&&(r=n.get(n.getCalculationInfo("stackedOverDimension"),i)),isNaN(r)&&(r=t.valueStart);var o=t.baseDataOffset,a=[];return a[o]=n.get(t.baseDim,i),a[1-o]=r,e.dataToPoint(a)}var aS=Math.min,sS=Math.max;function lS(t,e){return isNaN(t)||isNaN(e)}function cS(t,e,n,i,r,o,a,s,l){for(var c,d,u,h,p,f,g=n,v=0;v=r||g<0)break;if(lS(m,y)){if(l){g+=o;continue}break}if(g===n)t[o>0?"moveTo":"lineTo"](m,y),u=m,h=y;else{var _=m-c,b=y-d;if(_*_+b*b<.5){g+=o;continue}if(a>0){for(var x=g+o,w=e[2*x],S=e[2*x+1];w===m&&S===y&&v=i||lS(w,S))p=m,f=y;else{M=w-c,T=S-d;var A=m-c,P=w-m,L=y-d,E=S-y,z=void 0,N=void 0;if("x"===s){var O=M>0?1:-1;p=m-O*(z=Math.abs(A))*a,f=y,I=m+O*(N=Math.abs(P))*a,D=y}else if("y"===s){var $=T>0?1:-1;p=m,f=y-$*(z=Math.abs(L))*a,I=m,D=y+$*(N=Math.abs(E))*a}else z=Math.sqrt(A*A+L*L),p=m-M*a*(1-(k=(N=Math.sqrt(P*P+E*E))/(N+z))),f=y-T*a*(1-k),D=y+T*a*k,I=aS(I=m+M*a*k,sS(w,m)),D=aS(D,sS(S,y)),I=sS(I,aS(w,m)),f=y-(T=(D=sS(D,aS(S,y)))-y)*z/N,p=aS(p=m-(M=I-m)*z/N,sS(c,m)),f=aS(f,sS(d,y)),I=m+(M=m-(p=sS(p,aS(c,m))))*N/z,D=y+(T=y-(f=sS(f,aS(d,y))))*N/z}t.bezierCurveTo(u,h,p,f,m,y),u=I,h=D}else t.lineTo(m,y)}c=m,d=y,g+=o}return v}var dS=function(){this.smooth=0,this.smoothConstraint=!0},uS=function(t){function e(e){var n=t.call(this,e)||this;return n.type="ec-polyline",n}return y(e,t),e.prototype.getDefaultStyle=function(){return{stroke:sg.color.neutral99,fill:null}},e.prototype.getDefaultShape=function(){return new dS},e.prototype.buildPath=function(t,e){var n=e.points,i=0,r=n.length/2;if(e.connectNulls){for(;r>0&&lS(n[2*r-2],n[2*r-1]);r--);for(;i=0){var v=a?(d-i)*g+i:(c-n)*g+n;return a?[t,v]:[v,t]}n=c,i=d;break;case o.C:c=r[l++],d=r[l++],u=r[l++],h=r[l++],p=r[l++],f=r[l++];var m=a?Yr(n,c,u,p,t,s):Yr(i,d,h,f,t,s);if(m>0)for(var y=0;y=0){v=a?jr(i,d,h,f,_):jr(n,c,u,p,_);return a?[t,v]:[v,t]}}n=p,i=f}}},e}(Yc),hS=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return y(e,t),e}(dS),pS=function(t){function e(e){var n=t.call(this,e)||this;return n.type="ec-polygon",n}return y(e,t),e.prototype.getDefaultShape=function(){return new hS},e.prototype.buildPath=function(t,e){var n=e.points,i=e.stackedOnPoints,r=0,o=n.length/2,a=e.smoothMonotone;if(e.connectNulls){for(;o>0&&lS(n[2*o-2],n[2*o-1]);o--);for(;r=0&&t<=1){var i=t*(e.length-1),r=Math.floor(i),o=Math.ceil(i),a=So(e[r]),s=So(e[o]),l=i-r,c=To([ho(mo(a[0],s[0],l)),ho(mo(a[1],s[1],l)),ho(mo(a[2],s[2],l)),po(mo(a[3],s[3],l))],"rgba");return n?{color:c,leftIndex:r,rightIndex:o,value:i}:c}}((n-i)/(e.coord-i),[t.color,e.color])}}for(var s=0;se){i?r.push(a(i,l,e)):n&&r.push(a(n,l,0),a(n,l,e));break}n&&(r.push(a(n,l,0)),n=null),r.push(l),i=l}}return r}function bS(t,e,n){var i=t.get("showAllSymbol"),r="auto"===i;if(!i||r){var o=n.getAxesByScale("ordinal")[0];if(o&&(!r||!function(t,e){var n=t.getExtent(),i=Math.abs(n[1]-n[0])/t.scale.count();isNaN(i)&&(i=0);for(var r=e.count(),o=Math.max(1,Math.round(r/5)),a=0;ai)return!1;return!0}(o,e))){var a=e.mapDimension(o.dim),s={};return Cn(o.getViewLabels(),function(t){var e=o.scale.getRawOrdinalNumber(t.tickValue);s[e]=1}),function(t){return!s.hasOwnProperty(e.get(a,t))}}}}function xS(t,e){return isNaN(t)||isNaN(e)}function wS(t,e){return[t[2*e],t[2*e+1]]}function SS(t){if(t.get(["endLabel","show"]))return!0;for(var e=0;e=0;a--){var s=t.getDimensionInfo(i[a].dimension);if("x"===(r=s&&s.coordDim)||"y"===r){o=i[a];break}}if(o){var l=e.getAxis(r),c=kn(o.stops,function(t){return{coord:l.toGlobalCoord(l.dataToCoord(t.value)),color:t.color}}),d=c.length,u=o.outerColors.slice();d&&c[0].coord>c[d-1].coord&&(c.reverse(),u.reverse());var h=_S(c,"x"===r?n.getWidth():n.getHeight()),p=h.length;if(!p&&d)return c[0].coord<0?u[1]?u[1]:c[d-1].color:u[0]?u[0]:c[0].color;var f=h[0].coord-10,g=h[p-1].coord+10,v=g-f;if(v<.001)return"transparent";Cn(h,function(t){t.offset=(t.coord-f)/v}),h.push({offset:p?h[p-1].offset:.5,color:u[1]||"transparent"}),h.unshift({offset:p?h[0].offset:.5,color:u[0]||"transparent"});var m=new _h(0,0,0,0,h,!0);return m[r]=f,m[r+"2"]=g,m}}}(o,i,n)||o.getVisual("style")[o.getVisual("drawType")];if(h&&d.type===i.type&&k===this._step){v&&!p?p=this._newPolygon(l,_):p&&!v&&(f.remove(p),p=this._polygon=null),c||this._initOrUpdateEndLabel(t,i,Rf(M));var T=f.getClipPath();if(T)Nh(T,{shape:CS(this,i,!1,t).shape},t);else f.setClipPath(CS(this,i,!0,t));b&&u.updateData(o,{isIgnore:w,clipShape:C,disableAnimation:!0,getSymbolPoint:function(t){return[l[2*t],l[2*t+1]]}}),fS(this._stackedOnPoints,_)&&fS(this._points,l)||(g?this._doUpdateAnimation(o,_,i,n,k,m,x):(k&&(_&&(_=yS(_,l,i,k,x)),l=yS(l,null,i,k,x)),h.setShape({points:l}),p&&p.setShape({points:l,stackedOnPoints:_})))}else b&&u.updateData(o,{isIgnore:w,clipShape:C,disableAnimation:!0,getSymbolPoint:function(t){return[l[2*t],l[2*t+1]]}}),g&&this._initSymbolLabelAnimation(o,i,C),k&&(_&&(_=yS(_,l,i,k,x)),l=yS(l,null,i,k,x)),h=this._newPolyline(l),v?p=this._newPolygon(l,_):p&&(f.remove(p),p=this._polygon=null),c||this._initOrUpdateEndLabel(t,i,Rf(M)),f.setClipPath(CS(this,i,!0,t));var I=t.getModel("emphasis"),D=I.get("focus"),A=I.get("blurScope"),P=I.get("disabled");(h.useStyle(bn(a.getLineStyle(),{fill:"none",stroke:M,lineJoin:"bevel"})),fu(h,t,"lineStyle"),h.style.lineWidth>0&&"bolder"===t.get(["emphasis","lineStyle","width"]))&&(h.getState("emphasis").style.lineWidth=+h.style.lineWidth+1);Cd(h).seriesIndex=t.seriesIndex,uu(h,D,A,P);var L=mS(t.get("smooth")),E=t.get("smoothMonotone");if(h.setShape({smooth:L,smoothMonotone:E,connectNulls:x}),p){var z=o.getCalculationInfo("stackedOnSeries"),N=0;p.useStyle(bn(s.getAreaStyle(),{fill:M,opacity:.7,lineJoin:"bevel",decal:o.getVisual("style").decal})),z&&(N=mS(z.get("smooth"))),p.setShape({smooth:L,stackedOnSmooth:N,smoothMonotone:E,connectNulls:x}),fu(p,t,"areaStyle"),Cd(p).seriesIndex=t.seriesIndex,uu(p,D,A,P)}var O=this._changePolyState;o.eachItemGraphicEl(function(t){t&&(t.onHoverStateChange=O)}),this._polyline.onHoverStateChange=O,this._data=o,this._coordSys=i,this._stackedOnPoints=_,this._points=l,this._step=k,this._valueOrigin=m,t.get("triggerLineEvent")&&(this.packEventData(t,h),p&&this.packEventData(t,p))},e.prototype.packEventData=function(t,e){Cd(e).eventData={componentType:"series",componentSubType:"line",componentIndex:t.componentIndex,seriesIndex:t.seriesIndex,seriesName:t.name,seriesType:"line"}},e.prototype.highlight=function(t,e,n,i){var r=t.getData(),o=tl(r,i);if(this._changePolyState("emphasis"),!(o instanceof Array)&&null!=o&&o>=0){var a=r.getLayout("points"),s=r.getItemGraphicEl(o);if(!s){var l=a[2*o],c=a[2*o+1];if(isNaN(l)||isNaN(c))return;if(this._clipShapeForSymbol&&!this._clipShapeForSymbol.contain(l,c))return;var d=t.get("zlevel")||0,u=t.get("z")||0;(s=new Qw(r,o)).x=l,s.y=c,s.setZ(d,u);var h=s.getSymbolPath().getTextContent();h&&(h.zlevel=d,h.z=u,h.z2=this._polyline.z2+1),s.__temp=!0,r.setItemGraphicEl(o,s),s.stopSymbolAnimation(!0),this.group.add(s)}s.highlight()}else Xm.prototype.highlight.call(this,t,e,n,i)},e.prototype.downplay=function(t,e,n,i){var r=t.getData(),o=tl(r,i);if(this._changePolyState("normal"),null!=o&&o>=0){var a=r.getItemGraphicEl(o);a&&(a.__temp?(r.setItemGraphicEl(o,null),this.group.remove(a)):a.downplay())}else Xm.prototype.downplay.call(this,t,e,n,i)},e.prototype._changePolyState=function(t){var e=this._polygon;jd(this._polyline,t),e&&jd(e,t)},e.prototype._newPolyline=function(t){var e=this._polyline;return e&&this._lineGroup.remove(e),e=new uS({shape:{points:t},segmentIgnoreThreshold:2,z2:10}),this._lineGroup.add(e),this._polyline=e,e},e.prototype._newPolygon=function(t,e){var n=this._polygon;return n&&this._lineGroup.remove(n),n=new pS({shape:{points:t,stackedOnPoints:e},segmentIgnoreThreshold:2}),this._lineGroup.add(n),this._polygon=n,n},e.prototype._initSymbolLabelAnimation=function(t,e,n){var i,r,o=e.getBaseAxis(),a=o.inverse;"cartesian2d"===e.type?(i=o.isHorizontal(),r=!1):"polar"===e.type&&(i="angle"===o.dim,r=!0);var s=t.hostModel,l=s.get("animationDuration");Ln(l)&&(l=l(null));var c=s.get("animationDelay")||0,d=Ln(c)?c(null):c;t.eachItemGraphicEl(function(t,o){var s=t;if(s){var u=[t.x,t.y],h=void 0,p=void 0,f=void 0;if(n)if(r){var g=n,v=e.pointToCoord(u);i?(h=g.startAngle,p=g.endAngle,f=-v[1]/180*Math.PI):(h=g.r0,p=g.r,f=v[0])}else{var m=n;i?(h=m.x,p=m.x+m.width,f=t.x):(h=m.y+m.height,p=m.y,f=t.y)}var y=p===h?0:(f-h)/(p-h);a&&(y=1-y);var _=Ln(c)?c(o):l*y+d,b=s.getSymbolPath(),x=b.getTextContent();s.attr({scaleX:0,scaleY:0}),s.animateTo({scaleX:1,scaleY:1},{duration:200,setToFinal:!0,delay:_}),x&&x.animateFrom({style:{opacity:0}},{duration:300,delay:_}),b.disableLabelAnimation=!0}})},e.prototype._initOrUpdateEndLabel=function(t,e,n){var i=t.getModel("endLabel");if(SS(t)){var r=t.getData(),o=this._polyline,a=r.getLayout("points");if(!a)return o.removeTextContent(),void(this._endLabel=null);var s=this._endLabel;s||((s=this._endLabel=new ud({z2:200})).ignoreClip=!0,o.setTextContent(this._endLabel),o.disableLabelAnimation=!0);var l=function(t){for(var e=t.length/2;e>0&&xS(t[2*e-2],t[2*e-1]);e--);return e-1}(a);l>=0&&(_p(o,bp(t,"endLabel"),{inheritColor:n,labelFetcher:t,labelDataIndex:l,defaultText:function(t,e,n){return null!=n?function(t,e){var n=t.mapDimensionsAll("defaultedLabel");if(!Pn(e))return e+"";for(var i=[],r=0;r=0&&i.push(e[o])}return i.join(" ")}(r,n):Kw(r,t)},enableTextSetter:!0},function(t,e){var n=e.getBaseAxis(),i=n.isHorizontal(),r=n.inverse,o=i?r?"right":"left":"center",a=i?"middle":r?"top":"bottom";return{normal:{align:t.get("align")||o,verticalAlign:t.get("verticalAlign")||a}}}(i,e)),o.textConfig.position=null)}else this._endLabel&&(this._polyline.removeTextContent(),this._endLabel=null)},e.prototype._endLabelOnDuring=function(t,e,n,i,r,o,a){var s=this._endLabel,l=this._polyline;if(s){t<1&&null==i.originalX&&(i.originalX=s.x,i.originalY=s.y);var c=n.getLayout("points"),d=n.hostModel,u=d.get("connectNulls"),h=o.get("precision"),p=o.get("distance")||0,f=a.getBaseAxis(),g=f.isHorizontal(),v=f.inverse,m=e.shape,y=v?g?m.x:m.y+m.height:g?m.x+m.width:m.y,_=(g?p:0)*(v?-1:1),b=(g?0:-p)*(v?-1:1),x=g?"x":"y",w=function(t,e,n){for(var i,r,o=t.length/2,a="x"===n?0:1,s=0,l=-1,c=0;c=e||i>=e&&r<=e){l=c;break}s=c,i=r}else i=r;return{range:[s,l],t:(e-i)/(r-i)}}(c,y,x),S=w.range,C=S[1]-S[0],k=void 0;if(C>=1){if(C>1&&!u){var M=wS(c,S[0]);s.attr({x:M[0]+_,y:M[1]+b}),r&&(k=d.getRawValue(S[0]))}else{(M=l.getPointOn(y,x))&&s.attr({x:M[0]+_,y:M[1]+b});var T=d.getRawValue(S[0]),I=d.getRawValue(S[1]);r&&(k=function(t,e,n,i,r){var o=null==e||"auto"===e;if(null==i)return i;if(Nn(i))return ks(f=Bs(n||0,i,r),o?Math.max(Ms(n||0),Ms(i)):e);if(En(i))return r<1?n:i;for(var a=[],s=n,l=i,c=Math.max(s?s.length:0,l.length),d=0;d0?S[0]:0;M=wS(c,D);r&&(k=d.getRawValue(D)),s.attr({x:M[0]+_,y:M[1]+b})}if(r){var A=Dp(s);"function"==typeof A.setLabelText&&A.setLabelText(k)}}},e.prototype._doUpdateAnimation=function(t,e,n,i,r,o,a){var s=this._polyline,l=this._polygon,c=t.hostModel,d=function(t,e,n,i,r,o,a){for(var s=function(t,e){var n=[];return e.diff(t).add(function(t){n.push({cmd:"+",idx:t})}).update(function(t,e){n.push({cmd:"=",idx:e,idx1:t})}).remove(function(t){n.push({cmd:"-",idx:t})}).execute(),n}(t,e),l=[],c=[],d=[],u=[],h=[],p=[],f=[],g=rS(r,e,a),v=t.getLayout("points")||[],m=e.getLayout("points")||[],y=0;y3e3||l&&vS(h,f)>3e3)return s.stopAnimation(),s.setShape({points:p}),void(l&&(l.stopAnimation(),l.setShape({points:p,stackedOnPoints:f})));s.shape.__points=d.current,s.shape.points=u;var g={shape:{points:p}};d.current!==u&&(g.shape.__points=d.next),s.stopAnimation(),zh(s,g,c),l&&(l.setShape({points:u,stackedOnPoints:h}),l.stopAnimation(),zh(l,{shape:{stackedOnPoints:f}},c),s.shape.points!==l.shape.points&&(l.shape.points=s.shape.points));for(var v=[],m=d.status,y=0;ye&&(e=t[n]);return isFinite(e)?e:NaN},min:function(t){for(var e=1/0,n=0;ne[1]&&e.reverse(),e},e.prototype.pointToData=function(t,e){return this.coordToData(this.toLocalCoord(t["x"===this.dim?0:1]),e)},e.prototype.setCategorySortInfo=function(t){if("category"!==this.type)return!1;this.model.option.categorySortInfo=t,this.scale.setSortInfo(t)},e}(Fw),US="expandAxisBreak",GS=Math.PI,qS=[[1,2,1,2],[5,3,5,3],[8,3,8,3]],jS=[[0,1,0,1],[0,3,0,3],[0,3,0,3]],XS=el(),YS=el(),ZS=function(){function t(t){this.recordMap={},this.resolveAxisNameOverlap=t}return t.prototype.ensureRecord=function(t){var e=t.axis.dim,n=t.componentIndex,i=this.recordMap,r=i[e]||(i[e]=[]);return r[n]||(r[n]={ready:{}})},t}();var KS=[1,0,0,1,0,0],QS=new cr(0,0,0,0),JS=function(t,e,n,i,r,o){if(fw(t.nameLocation)){var a=o.stOccupiedRect;a&&tC(function(t,e,n){return t.transform=hp(t.transform,n),t.localRect=up(t.localRect,e),t.rect=up(t.rect,e),n&&t.rect.applyTransform(n),t.axisAligned=cp(n),t.obb=void 0,(t.label=t.label||{}).ignore=!1,t}({},a,o.transGroup.transform),i,r)}else eC(o.labelInfoList,o.dirVec,i,r)};function tC(t,e,n){var i=new Yi;Yw(t,e,i,{direction:Math.atan2(n.y,n.x),bidirectional:!1,touchThreshold:.05})&&function(t,e){if(t){t.label.x+=e.x,t.label.y+=e.y,t.label.markRedraw();var n=t.transform;n&&(n[4]+=e.x,n[5]+=e.y);var i=t.rect;i&&(i.x+=e.x,i.y+=e.y);var r=t.obb;r&&r.fromBoundingRect(t.localRect,n)}}(e,i)}function eC(t,e,n,i){for(var r=Yi.dot(i,e)>=0,o=0,a=t.length;o0?"top":"bottom",i="center"):Ds(o-GS)?(r=n>0?"bottom":"top",i="center"):(r="middle",i=o>0&&o0?"right":"left":n>0?"left":"right"),{rotation:o,textAlign:i,textVerticalAlign:r}},t.makeAxisEventDataBase=function(t){var e={componentType:t.mainType,componentIndex:t.componentIndex};return e[t.mainType+"Index"]=t.componentIndex,e},t.isLabelSilent=function(t){var e=t.get("tooltip");return t.get("silent")||!(t.get("triggerEvent")||e&&e.show)},t}(),iC=["axisLine","axisTickLabelEstimate","axisTickLabelDetermine","axisName"],rC={axisLine:function(t,e,n,i,r,o,a){var s=i.get(["axisLine","show"]);if("auto"===s&&(s=!0,null!=t.raw.axisLineAutoShow&&(s=!!t.raw.axisLineAutoShow)),s){var l=i.axis.getExtent(),c=o.transform,d=[l[0],0],u=[l[1],0],h=d[0]>u[0];c&&(gi(d,d,c),gi(u,u,c));var p=_n({lineCap:"round"},i.getModel(["axisLine","lineStyle"]).getLineStyle()),f={strokeContainThreshold:t.raw.strokeContainThreshold||5,silent:!0,z2:1,style:p};if(i.get(["axisLine","breakLine"])&&i.axis.scale.hasBreaks())null.buildAxisBreakLine(i,r,o,f);else{var g=new dh(_n({shape:{x1:d[0],y1:d[1],x2:u[0],y2:u[1]}},f));Yh(g.shape,g.style.lineWidth),g.anid="line",r.add(g)}var v=i.get(["axisLine","symbol"]);if(null!=v){var m=i.get(["axisLine","symbolSize"]);En(v)&&(v=[v,v]),(En(m)||Nn(m))&&(m=[m,m]);var y=n_(i.get(["axisLine","symbolOffset"])||0,m),_=m[0],b=m[1];Cn([{rotate:t.rotation+Math.PI/2,offset:y[0],r:0},{rotate:t.rotation-Math.PI/2,offset:y[1],r:Math.sqrt((d[0]-u[0])*(d[0]-u[0])+(d[1]-u[1])*(d[1]-u[1]))}],function(e,n){if("none"!==v[n]&&null!=v[n]){var i=e_(v[n],-_/2,-b/2,_,b,p.stroke,!0),o=e.r+e.offset,a=h?u:d;i.attr({rotation:e.rotate,x:a[0]+o*Math.cos(t.rotation),y:a[1]-o*Math.sin(t.rotation),silent:!0,z2:11}),r.add(i)}})}}},axisTickLabelEstimate:function(t,e,n,i,r,o,a,s){lC(e,r,s)&&oC(t,e,n,i,r,o,a,Sw)},axisTickLabelDetermine:function(t,e,n,i,r,o,a,s){lC(e,r,s)&&oC(t,e,n,i,r,o,a,Cw);var l=function(t,e,n,i){var r=i.axis,o=i.getModel("axisTick"),a=o.get("show");"auto"===a&&(a=!0,null!=t.raw.axisTickAutoShow&&(a=!!t.raw.axisTickAutoShow));if(!a||r.scale.isBlank())return[];for(var s=o.getModel("lineStyle"),l=t.tickDirection*o.get("length"),c=sC(r.getTicksCoords(),n.transform,l,bn(s.getLineStyle(),{stroke:i.get(["axisLine","lineStyle","color"])}),"ticks"),d=0;di[1],l="start"===e&&!s||"start"!==e&&s;Ds(a-GS/2)?(o=l?"bottom":"top",r="center"):Ds(a-1.5*GS)?(o=l?"top":"bottom",r="center"):(o="middle",r=a<1.5*GS&&a>GS/2?l?"left":"right":l?"right":"left");return{rotation:a,textAlign:r,textVerticalAlign:o}}(t.rotation,d,x||0,f),null!=(b=t.raw.axisNameAvailableWidth)&&(b=Math.abs(b/Math.sin(_.rotation)),!isFinite(b)&&(b=null)));var w=h.getFont(),S=i.get("nameTruncate",!0)||{},C=S.ellipsis,k=Vn(t.raw.nameTruncateMaxWidth,S.maxWidth,b),M=s.nameMarginLevel||0,T=new ud({x:v.x,y:v.y,rotation:_.rotation,silent:nC.isLabelSilent(i),style:xp(h,{text:c,font:w,overflow:"truncate",width:k,ellipsis:C,fill:h.getTextColor()||i.get(["axisLine","lineStyle","color"]),align:h.get("align")||_.textAlign,verticalAlign:h.get("verticalAlign")||_.textVerticalAlign}),z2:1});if(ap({el:T,componentModel:i,itemName:c}),T.__fullText=c,T.anid="name",i.get("triggerEvent")){var I=nC.makeAxisEventDataBase(i);I.targetType="axisName",I.name=c,Cd(T).eventData=I}o.add(T),T.updateTransform(),e.nameEl=T;var D=l.nameLayout=Gw({label:T,priority:T.z2,defaultAttr:{ignore:T.ignore},marginDefault:fw(d)?qS[M]:jS[M]});if(l.nameLocation=d,r.add(T),T.decomposeTransform(),t.shouldNameMoveOverlap&&D){var A=n.ensureRecord(i);n.resolveAxisNameOverlap(t,n,i,D,m,A)}}}};function oC(t,e,n,i,r,o,a,s){cC(e)||function(t,e,n,i,r,o){var a=r.axis,s=Vn(t.raw.axisLabelShow,r.get(["axisLabel","show"])),l=new hs;n.add(l);var c=kw(i);if(!s||a.scale.isBlank())return void dC(e,[],l,c);var d=r.getModel("axisLabel"),u=a.getViewLabels(c),h=(Vn(t.raw.labelRotate,d.get("rotate"))||0)*GS/180,p=nC.innerTextLayout(t.rotation,h,t.labelDirection),f=r.getCategories&&r.getCategories(!0),g=[],v=r.get("triggerEvent"),m=1/0,y=-1/0;Cn(u,function(t,e){var n,i="ordinal"===a.scale.type?a.scale.getRawOrdinalNumber(t.tickValue):t.tickValue,s=t.formattedLabel,c=t.rawLabel,h=d;if(f&&f[i]){var _=f[i];On(_)&&_.textStyle&&(h=new Bp(_.textStyle,d,r.ecModel))}var b=h.getTextColor()||r.get(["axisLine","lineStyle","color"]),x=h.getShallow("align",!0)||p.textAlign,w=Wn(h.getShallow("alignMinLabel",!0),x),S=Wn(h.getShallow("alignMaxLabel",!0),x),C=h.getShallow("verticalAlign",!0)||h.getShallow("baseline",!0)||p.textVerticalAlign,k=Wn(h.getShallow("verticalAlignMinLabel",!0),C),M=Wn(h.getShallow("verticalAlignMaxLabel",!0),C),T=10+((null===(n=t.time)||void 0===n?void 0:n.level)||0);m=Math.min(m,T),y=Math.max(y,T);var I=new ud({x:0,y:0,rotation:0,silent:nC.isLabelSilent(r),z2:T,style:xp(h,{text:s,align:0===e?w:e===u.length-1?S:x,verticalAlign:0===e?k:e===u.length-1?M:C,fill:Ln(b)?b("category"===a.type?c:"value"===a.type?i+"":i,e):b})});I.anid="label_"+i;var D=XS(I);if(D.break=t.break,D.tickValue=i,D.layoutRotation=p.rotation,ap({el:I,componentModel:r,itemName:s,formatterParamsExtra:{isTruncated:function(){return I.isTruncated},value:c,tickIndex:e}}),v){var A=nC.makeAxisEventDataBase(r);A.targetType="axisLabel",A.value=c,A.tickIndex=e,t.break&&(A.break={start:t.break.parsedBreak.vmin,end:t.break.parsedBreak.vmax}),"category"===a.type&&(A.dataIndex=i),Cd(I).eventData=A,t.break&&function(t,e,n,i){n.on("click",function(n){var r={type:US,breaks:[{start:i.parsedBreak.breakOption.start,end:i.parsedBreak.breakOption.end}]};r[t.axis.dim+"AxisIndex"]=t.componentIndex,e.dispatchAction(r)})}(r,o,I,t.break)}g.push(I),l.add(I)});var _=kn(g,function(t){return{label:t,priority:XS(t).break?t.z2+(y-m+1):t.z2,defaultAttr:{ignore:t.ignore}}});dC(e,_,l,c)}(t,e,r,s,i,a);var l=e.labelLayoutList;!function(t,e,n,i){var r=e.get(["axisLabel","margin"]);Cn(n,function(n,o){var a=Gw(n);if(a){var s=a.label,l=XS(s);a.suggestIgnore=s.ignore,s.ignore=!1,Va(uC,hC),uC.x=e.axis.dataToCoord(l.tickValue),uC.y=t.labelOffset+t.labelDirection*r,uC.rotation=l.layoutRotation,i.add(uC),uC.updateTransform(),i.remove(uC),uC.decomposeTransform(),Va(s,uC),s.markRedraw(),Ww(a,!0),Gw(a)}})}(t,i,l,o),t.rotation;var c=t.optionHideOverlap;!function(t,e,n){if(hw(t.axis))return;function i(t,i,r){var o=Gw(e[i]),a=Gw(e[r]);if(o&&a)if(!1===t||o.suggestIgnore)aC(o.label);else if(a.suggestIgnore)aC(a.label);else{var s=.1;if(!n){var l=[0,0,0,0];o=jw({marginForce:l},o),a=jw({marginForce:l},a)}Yw(o,a,null,{touchThreshold:s})&&aC(t?a.label:o.label)}}var r=t.get(["axisLabel","showMinLabel"]),o=t.get(["axisLabel","showMaxLabel"]),a=e.length;i(r,0,1),i(o,a-1,a-2)}(i,l,c),c&&function(t){var e=[];function n(t){if(!t.ignore){var e=t.ensureState("emphasis");null==e.ignore&&(e.ignore=!1)}t.ignore=!0}t.sort(function(t,e){return(e.suggestIgnore?1:0)-(t.suggestIgnore?1:0)||e.priority-t.priority});for(var i=0;i.1?"x":"y",d=a.transGroup[c];if(s.sort(function(t,e){return Math.abs(t.label[c]-d)-Math.abs(e.label[c]-d)}),l&&r){var u=o.getExtent(),h=Math.min(u[0],u[1]),p=Math.max(u[0],u[1])-h;r.union(new cr(h,0,p,1))}a.stOccupiedRect=r,a.labelInfoList=s}(t,n,i,l)}function aC(t){t&&(t.ignore=!0)}function sC(t,e,n,i,r){for(var o=[],a=[],s=[],l=0;l=0;o--){var a=t[+n[o]],s=a.model,l=a.scale;yx(l)&&s.get("alignTicks")&&null==s.get("interval")?r.push(a):(lw(l,s),yx(l)&&(e=a))}r.length&&(e||lw((e=r.pop()).scale,e.model),Cn(r,function(t){!function(t,e,n){var i=Nx.prototype,r=i.getTicks.call(n),o=i.getTicks.call(n,{expandToNicedExtent:!0}),a=r.length-1,s=i.getInterval.call(n),l=sw(t,e),c=l.extent,d=l.fixMin,u=l.fixMax;"log"===t.type&&(c=Ix(t.base,c,!0)),t.setBreaksFromOption(vw(e)),t.setExtent(c[0],c[1]),t.calcNiceExtent({splitNumber:a,fixMin:d,fixMax:u});var h=i.getExtent.call(t);d&&(c[0]=h[0]),u&&(c[1]=h[1]);var p=i.getInterval.call(t),f=c[0],g=c[1];if(d&&u)p=(g-f)/a;else if(d)for(g=c[0]+p*a;gc[0]&&isFinite(f)&&isFinite(c[0]);)p=bx(p),f=c[1]-p*a;else{t.getTicks().length-1>a&&(p=bx(p));var v=p*a;(f=ks((g=Math.ceil(c[1]/p)*p)-v))<0&&c[0]>=0?(f=0,g=ks(v)):g>0&&c[1]<=0&&(g=0,f=-ks(v))}var m=(r[0].value-o[0].value)/s,y=(r[a].value-o[a].value)/s;i.setExtent.call(t,f+p*m,g+p*y),i.setInterval.call(t,p),(m||y)&&i.setNiceExtent.call(t,f+p,g-p)}(t.scale,t.model,e.scale)}))}}this._updateScale(t,this.model),i(n.x),i(n.y);var r={};Cn(n.x,function(t){_C(n,"y",t,r)}),Cn(n.y,function(t){_C(n,"x",t,r)}),this.resize(this.model,e)},t.prototype.resize=function(t,e,n){var i=eg(t,e),r=this._rect=Jf(t.getBoxLayoutParams(),i.refContainer),o=this._axesMap,a=this._coordsList,s=t.get("containLabel");if(xC(o,r),!n){var l=function(t,e,n,i,r){var o=new ZS(kC);return Cn(n,function(n){return Cn(n,function(n){if(gw(n.model)){var a=!i;n.axisBuilder=function(t,e,n,i,r,o){for(var a=fC(t,n),s=!1,l=!1,c=0;c0&&i>0||n<0&&i<0)}(t)}function xC(t,e){Cn(t.x,function(t){return wC(t,e.x,e.width)}),Cn(t.y,function(t){return wC(t,e.y,e.height)})}function wC(t,e,n){var i=[0,n],r=t.inverse?1:0;t.setExtent(i[r],i[1-r]),function(t,e){var n=t.getExtent(),i=n[0]+n[1];t.toGlobalCoord="x"===t.dim?function(t){return t+e}:function(t){return i-t+e},t.toLocalCoord="x"===t.dim?function(t){return t-e}:function(t){return i-t+e}}(t,e)}function SC(t,e,n,i,r,o,a){CC(i,r,Sw,e,!1,a);var s=[0,0,0,0];c(0),c(1),d(i,0,NaN),d(i,1,NaN);var l=null==function(t,e,n){if(t&&e)for(var i=0,r=t.length;i0});return ip(i,s,!0,!0,n),xC(r,i),l;function c(t){Cn(r[Fh[t]],function(e){if(gw(e.model)){var n=o.ensureRecord(e.model),i=n.labelInfoList;if(i)for(var r=0;r0&&!Bn(e)&&e>1e-4&&(t/=e),t}}function CC(t,e,n,i,r,o){var a=n===Cw;Cn(e,function(e){return Cn(e,function(e){gw(e.model)&&(!function(t,e,n){var i=fC(e,n);t.updateCfg(i)}(e.axisBuilder,t,e.model),e.axisBuilder.build(a?{axisTickLabelDetermine:!0}:{axisTickLabelEstimate:!0},{noPxChange:r}))})});var s={x:0,y:0};function l(e){s[Fh[1-e]]=t[Bh[e]]<=.5*o.refContainer[Bh[e]]?0:1-e==1?2:1}l(0),l(1),Cn(e,function(t,e){return Cn(t,function(t){gw(t.model)&&(("all"===i||a)&&t.axisBuilder.build({axisName:!0},{nameMarginLevel:s[e]}),a&&t.axisBuilder.build({axisLine:!0}))})})}var kC=function(t,e,n,i,r,o){var a="x"===n.axis.dim?"y":"x";JS(t,0,0,i,r,o),fw(t.nameLocation)||Cn(e.recordMap[a],function(t){t&&t.labelInfoList&&t.dirVec&&eC(t.labelInfoList,t.dirVec,i,r)})};function MC(t,e){var n={axesInfo:{},seriesInvolved:!1,coordSysAxesInfo:{},coordSysMap:{}};return function(t,e,n){var i=e.getComponent("tooltip"),r=e.getComponent("axisPointer"),o=r.get("link",!0)||[],a=[];Cn(n.getCoordinateSystems(),function(n){if(n.axisPointerEnabled){var s=AC(n.model),l=t.coordSysAxesInfo[s]={};t.coordSysMap[s]=n;var c=n.model.getModel("tooltip",i);if(Cn(n.getAxes(),An(p,!1,null)),n.getTooltipAxes&&i&&c.get("show")){var d="axis"===c.get("trigger"),u="cross"===c.get(["axisPointer","type"]),h=n.getTooltipAxes(c.get(["axisPointer","axis"]));(d||u)&&Cn(h.baseAxes,An(p,!u||"cross",d)),u&&Cn(h.otherAxes,An(p,"cross",!1))}}function p(i,s,d){var u=d.model.getModel("axisPointer",r),h=u.get("show");if(h&&("auto"!==h||i||DC(u))){null==s&&(s=u.get("triggerTooltip")),u=i?function(t,e,n,i,r,o){var a=e.getModel("axisPointer"),s={};Cn(["type","snap","lineStyle","shadowStyle","label","animation","animationDurationUpdate","animationEasingUpdate","z"],function(t){s[t]=mn(a.get(t))}),s.snap="category"!==t.type&&!!o,"cross"===a.get("type")&&(s.type="line");var l=s.label||(s.label={});if(null==l.show&&(l.show=!1),"cross"===r){var c=a.get(["label","show"]);if(l.show=null==c||c,!o){var d=s.lineStyle=a.get("crossStyle");d&&bn(l,d.textStyle)}}return t.model.getModel("axisPointer",new Bp(s,n,i))}(d,c,r,e,i,s):u;var p=u.get("snap"),f=u.get("triggerEmphasis"),g=AC(d.model),v=s||p||"category"===d.type,m=t.axesInfo[g]={key:g,axis:d,coordSys:n,axisPointerModel:u,triggerTooltip:s,triggerEmphasis:f,involveSeries:v,snap:p,useHandle:DC(u),seriesModels:[],linkGroup:null};l[g]=m,t.seriesInvolved=t.seriesInvolved||v;var y=function(t,e){for(var n=e.model,i=e.dim,r=0;r=0||t===e}function IC(t){var e=(t.ecModel.getComponent("axisPointer")||{}).coordSysAxesInfo;return e&&e.axesInfo[AC(t)]}function DC(t){return!!t.get(["handle","show"])}function AC(t){return t.type+"||"+t.id}var PC={},LC=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return y(e,t),e.prototype.render=function(e,n,i,r){this.axisPointerClass&&function(t){var e=IC(t);if(e){var n=e.axisPointerModel,i=e.axis.scale,r=n.option,o=n.get("status"),a=n.get("value");null!=a&&(a=i.parse(a));var s=DC(n);null==o&&(r.status=s?"show":"hide");var l=i.getExtent().slice();l[0]>l[1]&&l.reverse(),(null==a||a>l[1])&&(a=l[1]),aa)return!0;if(o){var s=IC(t).seriesDataCount,l=i.getExtent();return Math.abs(l[0]-l[1])/s>a}return!1}return!0===n},t.prototype.makeElOption=function(t,e,n,i,r){},t.prototype.createPointerEl=function(t,e,n,i){var r=e.pointer;if(r){var o=VC(t).pointerEl=new vp[r.type](WC(e.pointer));t.add(o)}},t.prototype.createLabelEl=function(t,e,n,i){if(e.label){var r=VC(t).labelEl=new ud(WC(e.label));t.add(r),XC(r,i)}},t.prototype.updatePointerEl=function(t,e,n){var i=VC(t).pointerEl;i&&e.pointer&&(i.setStyle(e.pointer.style),n(i,{shape:e.pointer.shape}))},t.prototype.updateLabelEl=function(t,e,n,i){var r=VC(t).labelEl;r&&(r.setStyle(e.label.style),n(r,{x:e.label.x,y:e.label.y}),XC(r,i))},t.prototype._renderHandle=function(t){if(!this._dragging&&this.updateHandleTransform){var e,n=this._axisPointerModel,i=this._api.getZr(),r=this._handle,o=n.getModel("handle"),a=n.get("status");if(!o.get("show")||!a||"hide"===a)return r&&i.remove(r),void(this._handle=null);this._handle||(e=!0,r=this._handle=tp(o.get("icon"),{cursor:"move",draggable:!0,onmousemove:function(t){Hi(t.event)},onmousedown:UC(this._onHandleDragMove,this,0,0),drift:UC(this._onHandleDragMove,this),ondragend:UC(this._onHandleDragEnd,this)}),i.add(r)),ZC(r,n,!1),r.setStyle(o.getItemStyle(null,["color","borderColor","borderWidth","opacity","shadowColor","shadowBlur","shadowOffsetX","shadowOffsetY"]));var s=o.get("size");Pn(s)||(s=[s,s]),r.scaleX=s[0]/2,r.scaleY=s[1]/2,ry(this,"_doDispatchAxisPointer",o.get("throttle")||0,"fixRate"),this._moveHandleToValue(t,e)}},t.prototype._moveHandleToValue=function(t,e){qC(this._axisPointerModel,!e&&this._moveAnimation,this._handle,YC(this.getHandleTransform(t,this._axisModel,this._axisPointerModel)))},t.prototype._onHandleDragMove=function(t,e){var n=this._handle;if(n){this._dragging=!0;var i=this.updateHandleTransform(YC(n),[t,e],this._axisModel,this._axisPointerModel);this._payloadInfo=i,n.stopAnimation(),n.attr(YC(i)),VC(n).lastProp=null,this._doDispatchAxisPointer()}},t.prototype._doDispatchAxisPointer=function(){if(this._handle){var t=this._payloadInfo,e=this._axisModel;this._api.dispatchAction({type:"updateAxisPointer",x:t.cursorPoint[0],y:t.cursorPoint[1],tooltipOption:t.tooltipOption,axesInfo:[{axisDim:e.axis.dim,axisIndex:e.componentIndex}]})}},t.prototype._onHandleDragEnd=function(){if(this._dragging=!1,this._handle){var t=this._axisPointerModel.get("value");this._moveHandleToValue(t),this._api.dispatchAction({type:"hideTip"})}},t.prototype.clear=function(t){this._lastValue=null,this._lastStatus=null;var e=t.getZr(),n=this._group,i=this._handle;e&&n&&(this._lastGraphicKey=null,n&&e.remove(n),i&&e.remove(i),this._group=null,this._handle=null,this._payloadInfo=null),oy(this,"_doDispatchAxisPointer")},t.prototype.doClear=function(){},t.prototype.buildLabel=function(t,e,n){return{x:t[n=n||0],y:t[1-n],width:e[n],height:e[1-n]}},t}();function qC(t,e,n,i){jC(VC(n).lastProp,i)||(VC(n).lastProp=i,e?zh(n,i,t):(n.stopAnimation(),n.attr(i)))}function jC(t,e){if(On(t)&&On(e)){var n=!0;return Cn(e,function(e,i){n=n&&jC(t[i],e)}),!!n}return t===e}function XC(t,e){t[e.get(["label","show"])?"show":"hide"]()}function YC(t){return{x:t.x||0,y:t.y||0,rotation:t.rotation||0}}function ZC(t,e,n){var i=e.get("z"),r=e.get("zlevel");t&&t.traverse(function(t){"group"!==t.type&&(null!=i&&(t.z=i),null!=r&&(t.zlevel=r),t.silent=n)})}function KC(t,e,n,i,r){var o=QC(n.get("value"),e.axis,e.ecModel,n.get("seriesDataIndices"),{precision:n.get(["label","precision"]),formatter:n.get(["label","formatter"])}),a=n.getModel("label"),s=Ef(a.get("padding")||0),l=a.getFont(),c=Ya(o,l),d=r.position,u=c.width+s[1]+s[3],h=c.height+s[0]+s[2],p=r.align;"right"===p&&(d[0]-=u),"center"===p&&(d[0]-=u/2);var f=r.verticalAlign;"bottom"===f&&(d[1]-=h),"middle"===f&&(d[1]-=h/2),function(t,e,n,i){var r=i.getWidth(),o=i.getHeight();t[0]=Math.min(t[0]+e,r)-e,t[1]=Math.min(t[1]+n,o)-n,t[0]=Math.max(t[0],0),t[1]=Math.max(t[1],0)}(d,u,h,i);var g=a.get("backgroundColor");g&&"auto"!==g||(g=e.get(["axisLine","lineStyle","color"])),t.label={x:d[0],y:d[1],style:xp(a,{text:o,font:l,fill:a.getTextColor(),padding:s,backgroundColor:g}),z2:10}}function QC(t,e,n,i,r){t=e.scale.parse(t);var o=e.scale.getLabel({value:t},{precision:r.precision}),a=r.formatter;if(a){var s={value:dw(e,{value:t}),axisDimension:e.dim,axisIndex:e.index,seriesData:[]};Cn(i,function(t){var e=n.getSeriesByIndex(t.seriesIndex),i=t.dataIndexInside,r=e&&e.getDataParams(i);r&&s.seriesData.push(r)}),En(a)?o=a.replace("{value}",o):Ln(a)&&(o=a(s))}return o}function JC(t,e,n){var i=[1,0,0,1,0,0];return ji(i,i,n.rotation),qi(i,i,n.position),Kh([t.dataToCoord(e),(n.labelOffset||0)+(n.labelDirection||1)*(n.labelMargin||0)],i)}var tk=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return y(e,t),e.prototype.makeElOption=function(t,e,n,i,r){var o=n.axis,a=o.grid,s=i.get("type"),l=ek(a,o).getOtherAxis(o).getGlobalExtent(),c=o.toGlobalCoord(o.dataToCoord(e,!0));if(s&&"none"!==s){var d=function(t){var e,n=t.get("type"),i=t.getModel(n+"Style");return"line"===n?(e=i.getLineStyle()).fill=null:"shadow"===n&&((e=i.getAreaStyle()).stroke=null),e}(i),u=nk[s](o,c,l);u.style=d,t.graphicKey=u.type,t.pointer=u}!function(t,e,n,i,r,o){var a=nC.innerTextLayout(n.rotation,0,n.labelDirection);n.labelMargin=r.get(["label","margin"]),KC(e,i,r,o,{position:JC(i.axis,t,n),align:a.textAlign,verticalAlign:a.textVerticalAlign})}(e,t,fC(a.getRect(),n),n,i,r)},e.prototype.getHandleTransform=function(t,e,n){var i=fC(e.axis.grid.getRect(),e,{labelInside:!1});i.labelMargin=n.get(["handle","margin"]);var r=JC(e.axis,t,i);return{x:r[0],y:r[1],rotation:i.rotation+(i.labelDirection<0?Math.PI:0)}},e.prototype.updateHandleTransform=function(t,e,n,i){var r=n.axis,o=r.grid,a=r.getGlobalExtent(!0),s=ek(o,r).getOtherAxis(r).getGlobalExtent(),l="x"===r.dim?0:1,c=[t.x,t.y];c[l]+=e[l],c[l]=Math.min(a[1],c[l]),c[l]=Math.max(a[0],c[l]);var d=(s[1]+s[0])/2,u=[d,d];u[l]=c[l];return{x:c[0],y:c[1],rotation:t.rotation,cursorPoint:u,tooltipOption:[{verticalAlign:"middle"},{align:"center"}][l]}},e}(GC);function ek(t,e){var n={};return n[e.dim+"AxisIndex"]=e.index,t.getCartesian(n)}var nk={line:function(t,e,n){var i,r,o;return{type:"Line",subPixelOptimize:!0,shape:(i=[e,n[0]],r=[e,n[1]],o=ik(t),{x1:i[o=o||0],y1:i[1-o],x2:r[o],y2:r[1-o]})}},shadow:function(t,e,n){var i,r,o,a=Math.max(1,t.getBandWidth()),s=n[1]-n[0];return{type:"Rect",shape:(i=[e-a/2,n[0]],r=[a,s],o=ik(t),{x:i[o=o||0],y:i[1-o],width:r[o],height:r[1-o]})}}};function ik(t){return"x"===t.dim?0:1}var rk=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return y(e,t),e.type="axisPointer",e.defaultOption={show:"auto",z:50,type:"line",snap:!1,triggerTooltip:!0,triggerEmphasis:!0,value:null,status:null,link:[],animation:null,animationDurationUpdate:200,lineStyle:{color:sg.color.border,width:1,type:"dashed"},shadowStyle:{color:sg.color.shadowTint},label:{show:!0,formatter:null,precision:"auto",margin:3,color:sg.color.neutral00,padding:[5,7,5,7],backgroundColor:sg.color.accent60,borderColor:null,borderWidth:0,borderRadius:3},handle:{show:!1,icon:"M10.7,11.9v-1.3H9.3v1.3c-4.9,0.3-8.8,4.4-8.8,9.4c0,5,3.9,9.1,8.8,9.4h1.3c4.9-0.3,8.8-4.4,8.8-9.4C19.5,16.3,15.6,12.2,10.7,11.9z M13.3,24.4H6.7v-1.2h6.6z M13.3,22H6.7v-1.2h6.6z M13.3,19.6H6.7v-1.2h6.6z",size:45,margin:50,color:sg.color.accent40,throttle:40}},e}(ag),ok=el(),ak=Cn;function sk(t,e,n){if(!Ye.node){var i=e.getZr();ok(i).records||(ok(i).records={}),function(t,e){if(ok(t).initialized)return;function n(n,i){t.on(n,function(n){var r=function(t){var e={showTip:[],hideTip:[]},n=function(i){var r=e[i.type];r?r.push(i):(i.dispatchAction=n,t.dispatchAction(i))};return{dispatchAction:n,pendings:e}}(e);ak(ok(t).records,function(t){t&&i(t,n,r.dispatchAction)}),function(t,e){var n,i=t.showTip.length,r=t.hideTip.length;i?n=t.showTip[i-1]:r&&(n=t.hideTip[r-1]);n&&(n.dispatchAction=null,e.dispatchAction(n))}(r.pendings,e)})}ok(t).initialized=!0,n("click",An(ck,"click")),n("mousemove",An(ck,"mousemove")),n("globalout",lk)}(i,e),(ok(i).records[t]||(ok(i).records[t]={})).handler=n}}function lk(t,e,n){t.handler("leave",null,n)}function ck(t,e,n,i){e.handler(t,n,i)}function dk(t,e){if(!Ye.node){var n=e.getZr();(ok(n).records||{})[t]&&(ok(n).records[t]=null)}}var uk=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return y(e,t),e.prototype.render=function(t,e,n){var i=e.getComponent("tooltip"),r=t.get("triggerOn")||i&&i.get("triggerOn")||"mousemove|click";sk("axisPointer",n,function(t,e,n){"none"!==r&&("leave"===t||r.indexOf(t)>=0)&&n({type:"updateAxisPointer",currTrigger:t,x:e&&e.offsetX,y:e&&e.offsetY})})},e.prototype.remove=function(t,e){dk("axisPointer",e)},e.prototype.dispose=function(t,e){dk("axisPointer",e)},e.type="axisPointer",e}(Um);function hk(t,e){var n,i=[],r=t.seriesIndex;if(null==r||!(n=e.getSeriesByIndex(r)))return{point:[]};var o=n.getData(),a=tl(o,t);if(null==a||a<0||Pn(a))return{point:[]};var s=o.getItemGraphicEl(a),l=n.coordinateSystem;if(n.getTooltipPosition)i=n.getTooltipPosition(a)||[];else if(l&&l.dataToPoint)if(t.isStacked){var c=l.getBaseAxis(),d=l.getOtherAxis(c).dim,u=c.dim,h="x"===d||"radius"===d?1:0,p=o.mapDimension(u),f=[];f[h]=o.get(p,a),f[1-h]=o.get(o.getCalculationInfo("stackResultDimension"),a),i=l.dataToPoint(f)||[]}else i=l.dataToPoint(o.getValues(kn(l.dimensions,function(t){return o.mapDimension(t)}),a))||[];else if(s){var g=s.getBoundingRect().clone();g.applyTransform(s.transform),i=[g.x+g.width/2,g.y+g.height/2]}return{point:i,el:s}}var pk=el();function fk(t,e,n){var i=t.currTrigger,r=[t.x,t.y],o=t,a=t.dispatchAction||Dn(n.dispatchAction,n),s=e.getComponent("axisPointer").coordSysAxesInfo;if(s){_k(r)&&(r=hk({seriesIndex:o.seriesIndex,dataIndex:o.dataIndex},e).point);var l=_k(r),c=o.axesInfo,d=s.axesInfo,u="leave"===i||_k(r),h={},p={},f={list:[],map:{}},g={showPointer:An(vk,p),showTooltip:An(mk,f)};Cn(s.coordSysMap,function(t,e){var n=l||t.containPoint(r);Cn(s.coordSysAxesInfo[e],function(t,e){var i=t.axis,o=function(t,e){for(var n=0;n<(t||[]).length;n++){var i=t[n];if(e.axis.dim===i.axisDim&&e.axis.model.componentIndex===i.axisIndex)return i}}(c,t);if(!u&&n&&(!c||o)){var a=o&&o.value;null!=a||l||(a=i.pointToData(r)),null!=a&&gk(t,a,g,!1,h)}})});var v={};return Cn(d,function(t,e){var n=t.linkGroup;n&&!p[e]&&Cn(n.axesInfo,function(e,i){var r=p[i];if(e!==t&&r){var o=r.value;n.mapper&&(o=t.axis.scale.parse(n.mapper(o,yk(e),yk(t)))),v[t.key]=o}})}),Cn(v,function(t,e){gk(d[e],t,g,!0,h)}),function(t,e,n){var i=n.axesInfo=[];Cn(e,function(e,n){var r=e.axisPointerModel.option,o=t[n];o?(!e.useHandle&&(r.status="show"),r.value=o.value,r.seriesDataIndices=(o.payloadBatch||[]).slice()):!e.useHandle&&(r.status="hide"),"show"===r.status&&i.push({axisDim:e.axis.dim,axisIndex:e.axis.model.componentIndex,value:r.value})})}(p,d,h),function(t,e,n,i){if(_k(e)||!t.list.length)return void i({type:"hideTip"});var r=((t.list[0].dataByAxis[0]||{}).seriesDataIndices||[])[0]||{};i({type:"showTip",escapeConnect:!0,x:e[0],y:e[1],tooltipOption:n.tooltipOption,position:n.position,dataIndexInside:r.dataIndexInside,dataIndex:r.dataIndex,seriesIndex:r.seriesIndex,dataByCoordSys:t.list})}(f,r,t,a),function(t,e,n){var i=n.getZr(),r="axisPointerLastHighlights",o=pk(i)[r]||{},a=pk(i)[r]={};Cn(t,function(t,e){var n=t.axisPointerModel.option;"show"===n.status&&t.triggerEmphasis&&Cn(n.seriesDataIndices,function(t){var e=t.seriesIndex+" | "+t.dataIndex;a[e]=t})});var s=[],l=[];Cn(o,function(t,e){!a[e]&&l.push(t)}),Cn(a,function(t,e){!o[e]&&s.push(t)}),l.length&&n.dispatchAction({type:"downplay",escapeConnect:!0,notBlur:!0,batch:l}),s.length&&n.dispatchAction({type:"highlight",escapeConnect:!0,notBlur:!0,batch:s})}(d,0,n),h}}function gk(t,e,n,i,r){var o=t.axis;if(!o.scale.isBlank()&&o.containData(e))if(t.involveSeries){var a=function(t,e){var n=e.axis,i=n.dim,r=t,o=[],a=Number.MAX_VALUE,s=-1;return Cn(e.seriesModels,function(e,l){var c,d,u=e.getData().mapDimensionsAll(i);if(e.getAxisTooltipData){var h=e.getAxisTooltipData(u,t,n);d=h.dataIndices,c=h.nestestValue}else{if(!(d=e.indicesOfNearest(i,u[0],t,"category"===n.type?.5:null)).length)return;c=e.getData().get(u[0],d[0])}if(null!=c&&isFinite(c)){var p=t-c,f=Math.abs(p);f<=a&&((f=0&&s<0)&&(a=f,s=p,r=c,o.length=0),Cn(d,function(t){o.push({seriesIndex:e.seriesIndex,dataIndexInside:t,dataIndex:e.getData().getRawIndex(t)})}))}}),{payloadBatch:o,snapToValue:r}}(e,t),s=a.payloadBatch,l=a.snapToValue;s[0]&&null==r.seriesIndex&&_n(r,s[0]),!i&&t.snap&&o.containData(l)&&null!=l&&(e=l),n.showPointer(t,e,s),n.showTooltip(t,a,l)}else n.showPointer(t,e)}function vk(t,e,n,i){t[e.key]={value:n,payloadBatch:i}}function mk(t,e,n,i){var r=n.payloadBatch,o=e.axis,a=o.model,s=e.axisPointerModel;if(e.triggerTooltip&&r.length){var l=e.coordSys.model,c=AC(l),d=t.map[c];d||(d=t.map[c]={coordSysId:l.id,coordSysIndex:l.componentIndex,coordSysType:l.type,coordSysMainType:l.mainType,dataByAxis:[]},t.list.push(d)),d.dataByAxis.push({axisDim:o.dim,axisIndex:a.componentIndex,axisType:a.type,axisId:a.id,value:i,valueLabelOpt:{precision:s.get(["label","precision"]),formatter:s.get(["label","formatter"])},seriesDataIndices:r.slice()})}}function yk(t){var e=t.axis.model,n={},i=n.axisDim=t.axis.dim;return n.axisIndex=n[i+"AxisIndex"]=e.componentIndex,n.axisName=n[i+"AxisName"]=e.name,n.axisId=n[i+"AxisId"]=e.id,n}function _k(t){return!t||null==t[0]||isNaN(t[0])||null==t[1]||isNaN(t[1])}function bk(t){LC.registerAxisPointerClass("CartesianAxisPointer",tk),t.registerComponentModel(rk),t.registerComponentView(uk),t.registerPreprocessor(function(t){if(t){(!t.axisPointer||0===t.axisPointer.length)&&(t.axisPointer={});var e=t.axisPointer.link;e&&!Pn(e)&&(t.axisPointer.link=[e])}}),t.registerProcessor(t.PRIORITY.PROCESSOR.STATISTIC,function(t,e){t.getComponent("axisPointer").coordSysAxesInfo=MC(t,e)}),t.registerAction({type:"updateAxisPointer",event:"updateAxisPointer",update:":updateAxisPointer"},fk)}var xk=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return y(e,t),e.type="tooltip",e.dependencies=["axisPointer"],e.defaultOption={z:60,show:!0,showContent:!0,trigger:"item",triggerOn:"mousemove|click",alwaysShowContent:!1,renderMode:"auto",confine:null,showDelay:0,hideDelay:100,transitionDuration:.4,displayTransition:!0,enterable:!1,backgroundColor:sg.color.neutral00,shadowBlur:10,shadowColor:"rgba(0, 0, 0, .2)",shadowOffsetX:1,shadowOffsetY:2,borderRadius:4,borderWidth:1,defaultBorderColor:sg.color.border,padding:null,extraCssText:"",axisPointer:{type:"line",axis:"auto",animation:"auto",animationDurationUpdate:200,animationEasingUpdate:"exponentialOut",crossStyle:{color:sg.color.borderShade,width:1,type:"dashed",textStyle:{}}},textStyle:{color:sg.color.tertiary,fontSize:14}},e}(ag);function wk(t){var e=t.get("confine");return null!=e?!!e:"richText"===t.get("renderMode")}function Sk(t){if(Ye.domSupported)for(var e=document.documentElement.style,n=0,i=t.length;n0&&r.push(function(t,e,n){var i="cubic-bezier(0.23,1,0.32,1)",r="",o="";return n&&(o="opacity"+(r=" "+t/2+"s "+i)+",visibility"+r),e||(r=" "+t+"s "+i,o+=(o.length?",":"")+(Ye.transformSupported?""+Tk+r:",left"+r+",top"+r)),Mk+":"+o}(o,n,i)),a&&r.push("background-color:"+a),Cn(["width","color","radius"],function(e){var n="border-"+e,i=Lf(n),o=t.get(i);null!=o&&r.push(n+":"+o+("color"===e?"":"px"))}),r.push(function(t){var e=[],n=t.get("fontSize"),i=t.getTextColor();i&&e.push("color:"+i),e.push("font:"+t.getFont());var r=Wn(t.get("lineHeight"),Math.round(3*n/2));n&&e.push("line-height:"+r+"px");var o=t.get("textShadowColor"),a=t.get("textShadowBlur")||0,s=t.get("textShadowOffsetX")||0,l=t.get("textShadowOffsetY")||0;return o&&a&&e.push("text-shadow:"+s+"px "+l+"px "+a+"px "+o),Cn(["decoration","align"],function(n){var i=t.get(n);i&&e.push("text-"+n+":"+i)}),e.join(";")}(u)),null!=h&&r.push("padding:"+Ef(h).join("px ")+"px"),r.join(";")+";"}function Pk(t,e,n,i,r){var o=e&&e.painter;if(n){var a=o&&o.getViewportRoot();a&&function(t,e,n,i,r){Mi(ki,e,i,r,!0)&&Mi(t,n,ki[0],ki[1])}(t,a,n,i,r)}else{t[0]=i,t[1]=r;var s=o&&o.getViewportRootOffset();s&&(t[0]+=s.offsetLeft,t[1]+=s.offsetTop)}t[2]=t[0]/e.getWidth(),t[3]=t[1]/e.getHeight()}var Lk=function(){function t(t,e){if(this._show=!1,this._styleCoord=[0,0,0,0],this._enterable=!0,this._alwaysShowContent=!1,this._firstShow=!0,this._longHide=!0,Ye.wxa)return null;var n=document.createElement("div");n.domBelongToZr=!0,this.el=n;var i=this._zr=t.getZr(),r=e.appendTo,o=r&&(En(r)?document.querySelector(r):Hn(r)?r:Ln(r)&&r(t.getDom()));Pk(this._styleCoord,i,o,t.getWidth()/2,t.getHeight()/2),(o||t.getDom()).appendChild(n),this._api=t,this._container=o;var a=this;n.onmouseenter=function(){a._enterable&&(clearTimeout(a._hideTimeout),a._show=!0),a._inContent=!0},n.onmousemove=function(t){if(t=t||window.event,!a._enterable){var e=i.handler;$i(i.painter.getViewportRoot(),t,!0),e.dispatch("mousemove",t)}},n.onmouseleave=function(){a._inContent=!1,a._enterable&&a._show&&a.hideLater(a._hideDelay)}}return t.prototype.update=function(t){if(!this._container){var e=this._api.getDom(),n=(o="position",(a=(r=e).currentStyle||document.defaultView&&document.defaultView.getComputedStyle(r))?a[o]:null),i=e.style;"absolute"!==i.position&&"absolute"!==n&&(i.position="relative")}var r,o,a,s=t.get("alwaysShowContent");s&&this._moveIfResized(),this._alwaysShowContent=s,this._enableDisplayTransition=t.get("displayTransition")&&t.get("transitionDuration")>0,this.el.className=t.get("className")||""},t.prototype.show=function(t,e){clearTimeout(this._hideTimeout),clearTimeout(this._longHideTimeout);var n=this.el,i=n.style,r=this._styleCoord;n.innerHTML?i.cssText=Ik+Ak(t,!this._firstShow,this._longHide,this._enableDisplayTransition)+Dk(r[0],r[1],!0)+"border-color:"+Rf(e)+";"+(t.get("extraCssText")||"")+";pointer-events:"+(this._enterable?"auto":"none"):i.display="none",this._show=!0,this._firstShow=!1,this._longHide=!1},t.prototype.setContent=function(t,e,n,i,r){var o=this.el;if(null!=t){var a="";if(En(r)&&"item"===n.get("trigger")&&!wk(n)&&(a=function(t,e,n){if(!En(n)||"inside"===n)return"";var i=t.get("backgroundColor"),r=t.get("borderWidth");e=Rf(e);var o,a,s="left"===(o=n)?"right":"right"===o?"left":"top"===o?"bottom":"top",l=Math.max(1.5*Math.round(r),6),c="",d=Tk+":";xn(["left","right"],s)>-1?(c+="top:50%",d+="translateY(-50%) rotate("+(a="left"===s?-225:-45)+"deg)"):(c+="left:50%",d+="translateX(-50%) rotate("+(a="top"===s?225:45)+"deg)");var u=a*Math.PI/180,h=l+r,p=h*Math.abs(Math.cos(u))+h*Math.abs(Math.sin(u)),f=e+" solid "+r+"px;";return'
'}(n,i,r)),En(t))o.innerHTML=t+a;else if(t){o.innerHTML="",Pn(t)||(t=[t]);for(var s=0;s=0?this._tryShow(n,i):"leave"===e&&this._hide(i))},this))},e.prototype._keepShow=function(){var t=this._tooltipModel,e=this._ecModel,n=this._api,i=t.get("triggerOn");if(null!=this._lastX&&null!=this._lastY&&"none"!==i&&"click"!==i){var r=this;clearTimeout(this._refreshUpdateTimeout),this._refreshUpdateTimeout=setTimeout(function(){!n.isDisposed()&&r.manuallyShowTip(t,e,n,{x:r._lastX,y:r._lastY,dataByCoordSys:r._lastDataByCoordSys})})}},e.prototype.manuallyShowTip=function(t,e,n,i){if(i.from!==this.uid&&!Ye.node&&n.getDom()){var r=Fk(i,n);this._ticket="";var o=i.dataByCoordSys,a=function(t,e,n){var i=rl(t).queryOptionMap,r=i.keys()[0];if(!r||"series"===r)return;var o=al(e,r,i.get(r),{useDefault:!1,enableAll:!1,enableNone:!1}),a=o.models[0];if(!a)return;var s,l=n.getViewOfComponentModel(a);if(l.group.traverse(function(e){var n=Cd(e).tooltipConfig;if(n&&n.name===t.name)return s=e,!0}),s)return{componentMainType:r,componentIndex:a.componentIndex,el:s}}(i,e,n);if(a){var s=a.el.getBoundingRect().clone();s.applyTransform(a.el.transform),this._tryShow({offsetX:s.x+s.width/2,offsetY:s.y+s.height/2,target:a.el,position:i.position,positionDefault:"bottom"},r)}else if(i.tooltip&&null!=i.x&&null!=i.y){var l=$k;l.x=i.x,l.y=i.y,l.update(),Cd(l).tooltipConfig={name:null,option:i.tooltip},this._tryShow({offsetX:i.x,offsetY:i.y,target:l},r)}else if(o)this._tryShow({offsetX:i.x,offsetY:i.y,position:i.position,dataByCoordSys:o,tooltipOption:i.tooltipOption},r);else if(null!=i.seriesIndex){if(this._manuallyAxisShowTip(t,e,n,i))return;var c=hk(i,e),d=c.point[0],u=c.point[1];null!=d&&null!=u&&this._tryShow({offsetX:d,offsetY:u,target:c.el,position:i.position,positionDefault:"bottom"},r)}else null!=i.x&&null!=i.y&&(n.dispatchAction({type:"updateAxisPointer",x:i.x,y:i.y}),this._tryShow({offsetX:i.x,offsetY:i.y,position:i.position,target:n.getZr().findHover(i.x,i.y).target},r))}},e.prototype.manuallyHideTip=function(t,e,n,i){var r=this._tooltipContent;this._tooltipModel&&r.hideLater(this._tooltipModel.get("hideDelay")),this._lastX=this._lastY=this._lastDataByCoordSys=null,i.from!==this.uid&&this._hide(Fk(i,n))},e.prototype._manuallyAxisShowTip=function(t,e,n,i){var r=i.seriesIndex,o=i.dataIndex,a=e.getComponent("axisPointer").coordSysAxesInfo;if(null!=r&&null!=o&&null!=a){var s=e.getSeriesByIndex(r);if(s)if("axis"===Hk([s.getData().getItemModel(o),s,(s.coordinateSystem||{}).model],this._tooltipModel).get("trigger"))return n.dispatchAction({type:"updateAxisPointer",seriesIndex:r,dataIndex:o,position:i.position}),!0}},e.prototype._tryShow=function(t,e){var n=t.target;if(this._tooltipModel){this._lastX=t.offsetX,this._lastY=t.offsetY;var i=t.dataByCoordSys;if(i&&i.length)this._showAxisTooltip(i,t);else if(n){var r,o;if("legend"===Cd(n).ssrType)return;this._lastDataByCoordSys=null,Wy(n,function(t){if(t.tooltipDisabled)return r=o=null,!0;r||o||(null!=Cd(t).dataIndex?r=t:null!=Cd(t).tooltipConfig&&(o=t))},!0),r?this._showSeriesItemTooltip(t,r,e):o?this._showComponentItemTooltip(t,o,e):this._hide(e)}else this._lastDataByCoordSys=null,this._hide(e)}},e.prototype._showOrMove=function(t,e){var n=t.get("showDelay");e=Dn(e,this),clearTimeout(this._showTimout),n>0?this._showTimout=setTimeout(e,n):e()},e.prototype._showAxisTooltip=function(t,e){var n=this._ecModel,i=this._tooltipModel,r=[e.offsetX,e.offsetY],o=Hk([e.tooltipOption],i),a=this._renderMode,s=[],l=wm("section",{blocks:[],noHeader:!0}),c=[],d=new Lm;Cn(t,function(t){Cn(t.dataByAxis,function(t){var e=n.getComponent(t.axisDim+"Axis",t.axisIndex),r=t.value;if(e&&null!=r){var o=QC(r,e.axis,n,t.seriesDataIndices,t.valueLabelOpt),u=wm("section",{header:o,noHeader:!Xn(o),sortBlocks:!0,blocks:[]});l.blocks.push(u),Cn(t.seriesDataIndices,function(l){var h=n.getSeriesByIndex(l.seriesIndex),p=l.dataIndexInside,f=h.getDataParams(p);if(!(f.dataIndex<0)){f.axisDim=t.axisDim,f.axisIndex=t.axisIndex,f.axisType=t.axisType,f.axisId=t.axisId,f.axisValue=dw(e.axis,{value:r}),f.axisValueLabel=o,f.marker=d.makeTooltipMarker("item",Rf(f.color),a);var g=Wv(h.formatTooltip(p,!0,null)),v=g.frag;if(v){var m=Hk([h],i).get("valueFormatter");u.blocks.push(m?_n({valueFormatter:m},v):v)}g.text&&c.push(g.text),s.push(f)}})}})}),l.blocks.reverse(),c.reverse();var u=e.position,h=o.get("order"),p=Im(l,d,a,h,n.get("useUTC"),o.get("textStyle"));p&&c.unshift(p);var f="richText"===a?"\n\n":"
",g=c.join(f);this._showOrMove(o,function(){this._updateContentNotChangedOnAxis(t,s)?this._updatePosition(o,u,r[0],r[1],this._tooltipContent,s):this._showTooltipContent(o,g,s,Math.random()+"",r[0],r[1],u,null,d)})},e.prototype._showSeriesItemTooltip=function(t,e,n){var i=this._ecModel,r=Cd(e),o=r.seriesIndex,a=i.getSeriesByIndex(o),s=r.dataModel||a,l=r.dataIndex,c=r.dataType,d=s.getData(c),u=this._renderMode,h=t.positionDefault,p=Hk([d.getItemModel(l),s,a&&(a.coordinateSystem||{}).model],this._tooltipModel,h?{position:h}:null),f=p.get("trigger");if(null==f||"item"===f){var g=s.getDataParams(l,c),v=new Lm;g.marker=v.makeTooltipMarker("item",Rf(g.color),u);var m=Wv(s.formatTooltip(l,!1,c)),y=p.get("order"),_=p.get("valueFormatter"),b=m.frag,x=b?Im(_?_n({valueFormatter:_},b):b,v,u,y,i.get("useUTC"),p.get("textStyle")):m.text,w="item_"+s.name+"_"+l;this._showOrMove(p,function(){this._showTooltipContent(p,x,g,w,t.offsetX,t.offsetY,t.position,t.target,v)}),n({type:"showTip",dataIndexInside:l,dataIndex:d.getRawIndex(l),seriesIndex:o,from:this.uid})}},e.prototype._showComponentItemTooltip=function(t,e,n){var i="html"===this._renderMode,r=Cd(e),o=r.tooltipConfig.option||{},a=o.encodeHTMLContent;if(En(o)){o={content:o,formatter:o},a=!0}a&&i&&o.content&&((o=mn(o)).content=Ai(o.content));var s=[o],l=this._ecModel.getComponent(r.componentMainType,r.componentIndex);l&&s.push(l),s.push({formatter:o.content});var c=t.positionDefault,d=Hk(s,this._tooltipModel,c?{position:c}:null),u=d.get("content"),h=Math.random()+"",p=new Lm;this._showOrMove(d,function(){var n=mn(d.get("formatterParams")||{});this._showTooltipContent(d,u,n,h,t.offsetX,t.offsetY,t.position,e,p)}),n({type:"showTip",from:this.uid})},e.prototype._showTooltipContent=function(t,e,n,i,r,o,a,s,l){if(this._ticket="",t.get("showContent")&&t.get("show")){var c=this._tooltipContent;c.setEnterable(t.get("enterable"));var d=t.get("formatter");a=a||t.get("position");var u=e,h=this._getNearestPoint([r,o],n,t.get("trigger"),t.get("borderColor"),t.get("defaultBorderColor",!0)).color;if(d)if(En(d)){var p=t.ecModel.get("useUTC"),f=Pn(n)?n[0]:n;u=d,f&&f.axisType&&f.axisType.indexOf("time")>=0&&(u=ff(f.axisValue,u,p)),u=$f(u,n,!0)}else if(Ln(d)){var g=Dn(function(e,i){e===this._ticket&&(c.setContent(i,l,t,h,a),this._updatePosition(t,a,r,o,c,n,s))},this);this._ticket=i,u=d(n,i,g)}else u=d;c.setContent(u,l,t,h,a),c.show(t,h),this._updatePosition(t,a,r,o,c,n,s)}},e.prototype._getNearestPoint=function(t,e,n,i,r){return"axis"===n||Pn(e)?{color:i||r}:Pn(e)?void 0:{color:i||e.color||e.borderColor}},e.prototype._updatePosition=function(t,e,n,i,r,o,a){var s=this._api.getWidth(),l=this._api.getHeight();e=e||t.get("position");var c=r.getSize(),d=t.get("align"),u=t.get("verticalAlign"),h=a&&a.getBoundingRect().clone();if(a&&h.applyTransform(a.transform),Ln(e)&&(e=e([n,i],o,r.el,h,{viewSize:[s,l],contentSize:c.slice()})),Pn(e))n=Ss(e[0],s),i=Ss(e[1],l);else if(On(e)){var p=e;p.width=c[0],p.height=c[1];var f=Jf(p,{width:s,height:l});n=f.x,i=f.y,d=null,u=null}else if(En(e)&&a){var g=function(t,e,n,i){var r=n[0],o=n[1],a=Math.ceil(Math.SQRT2*i)+8,s=0,l=0,c=e.width,d=e.height;switch(t){case"inside":s=e.x+c/2-r/2,l=e.y+d/2-o/2;break;case"top":s=e.x+c/2-r/2,l=e.y-o-a;break;case"bottom":s=e.x+c/2-r/2,l=e.y+d+a;break;case"left":s=e.x-r-a,l=e.y+d/2-o/2;break;case"right":s=e.x+c+a,l=e.y+d/2-o/2}return[s,l]}(e,h,c,t.get("borderWidth"));n=g[0],i=g[1]}else{g=function(t,e,n,i,r,o,a){var s=n.getSize(),l=s[0],c=s[1];null!=o&&(t+l+o+2>i?t-=l+o:t+=o);null!=a&&(e+c+a>r?e-=c+a:e+=a);return[t,e]}(n,i,r,s,l,d?null:20,u?null:20);n=g[0],i=g[1]}if(d&&(n-=Bk(d)?c[0]/2:"right"===d?c[0]:0),u&&(i-=Bk(u)?c[1]/2:"bottom"===u?c[1]:0),wk(t)){g=function(t,e,n,i,r){var o=n.getSize(),a=o[0],s=o[1];return t=Math.min(t+a,i)-a,e=Math.min(e+s,r)-s,t=Math.max(t,0),e=Math.max(e,0),[t,e]}(n,i,r,s,l);n=g[0],i=g[1]}r.moveTo(n,i)},e.prototype._updateContentNotChangedOnAxis=function(t,e){var n=this._lastDataByCoordSys,i=this._cbParamsList,r=!!n&&n.length===t.length;return r&&Cn(n,function(n,o){var a=n.dataByAxis||[],s=(t[o]||{}).dataByAxis||[];(r=r&&a.length===s.length)&&Cn(a,function(t,n){var o=s[n]||{},a=t.seriesDataIndices||[],l=o.seriesDataIndices||[];(r=r&&t.value===o.value&&t.axisType===o.axisType&&t.axisId===o.axisId&&a.length===l.length)&&Cn(a,function(t,e){var n=l[e];r=r&&t.seriesIndex===n.seriesIndex&&t.dataIndex===n.dataIndex}),i&&Cn(t.seriesDataIndices,function(t){var n=t.seriesIndex,o=e[n],a=i[n];o&&a&&a.data!==o.data&&(r=!1)})})}),this._lastDataByCoordSys=t,this._cbParamsList=e,!!r},e.prototype._hide=function(t){this._lastDataByCoordSys=null,t({type:"hideTip",from:this.uid})},e.prototype.dispose=function(t,e){!Ye.node&&e.getDom()&&(oy(this,"_updatePosition"),this._tooltipContent.dispose(),dk("itemTooltip",e))},e.type="tooltip",e}(Um);function Hk(t,e,n){var i,r=e.ecModel;n?(i=new Bp(n,r,r),i=new Bp(e.option,i,r)):i=e;for(var o=t.length-1;o>=0;o--){var a=t[o];a&&(a instanceof Bp&&(a=a.get("tooltip",!0)),En(a)&&(a={formatter:a}),a&&(i=new Bp(a,i,r)))}return i}function Fk(t,e){return t.dispatchAction||Dn(e.dispatchAction,e)}function Bk(t){return"center"===t||"middle"===t}var Vk=Math.sin,Wk=Math.cos,Uk=Math.PI,Gk=2*Math.PI,qk=180/Uk,jk=function(){function t(){}return t.prototype.reset=function(t){this._start=!0,this._d=[],this._str="",this._p=Math.pow(10,t||4)},t.prototype.moveTo=function(t,e){this._add("M",t,e)},t.prototype.lineTo=function(t,e){this._add("L",t,e)},t.prototype.bezierCurveTo=function(t,e,n,i,r,o){this._add("C",t,e,n,i,r,o)},t.prototype.quadraticCurveTo=function(t,e,n,i){this._add("Q",t,e,n,i)},t.prototype.arc=function(t,e,n,i,r,o){this.ellipse(t,e,n,n,0,i,r,o)},t.prototype.ellipse=function(t,e,n,i,r,o,a,s){var l=a-o,c=!s,d=Math.abs(l),u=zo(d-Gk)||(c?l>=Gk:-l>=Gk),h=l>0?l%Gk:l%Gk+Gk,p=!1;p=!!u||!zo(d)&&h>=Uk==!!c;var f=t+n*Wk(o),g=e+i*Vk(o);this._start&&this._add("M",f,g);var v=Math.round(r*qk);if(u){var m=1/this._p,y=(c?1:-1)*(Gk-m);this._add("A",n,i,v,1,+c,t+n*Wk(o+y),e+i*Vk(o+y)),m>.01&&this._add("A",n,i,v,0,+c,f,g)}else{var _=t+n*Wk(a),b=e+i*Vk(a);this._add("A",n,i,v,+p,+c,_,b)}},t.prototype.rect=function(t,e,n,i){this._add("M",t,e),this._add("l",n,0),this._add("l",0,i),this._add("l",-n,0),this._add("Z")},t.prototype.closePath=function(){this._d.length>0&&this._add("Z")},t.prototype._add=function(t,e,n,i,r,o,a,s,l){for(var c=[],d=this._p,u=1;u"}(r,o)+("style"!==r?Ai(a):a||"")+(i?""+n+kn(i,function(e){return t(e)}).join(n)+n:"")+("")}(t)}function oM(t){return{zrId:t,shadowCache:{},patternCache:{},gradientCache:{},clipPathCache:{},defs:{},cssNodes:{},cssAnims:{},cssStyleCache:{},cssAnimIdx:0,shadowIdx:0,gradientIdx:0,patternIdx:0,clipPathIdx:0}}function aM(t,e,n,i){return iM("svg","root",{width:t,height:e,xmlns:Jk,"xmlns:xlink":tM,version:"1.1",baseProfile:"full",viewBox:!!i&&"0 0 "+t+" "+e},n)}var sM=0;function lM(){return sM++}var cM={cubicIn:"0.32,0,0.67,0",cubicOut:"0.33,1,0.68,1",cubicInOut:"0.65,0,0.35,1",quadraticIn:"0.11,0,0.5,0",quadraticOut:"0.5,1,0.89,1",quadraticInOut:"0.45,0,0.55,1",quarticIn:"0.5,0,0.75,0",quarticOut:"0.25,1,0.5,1",quarticInOut:"0.76,0,0.24,1",quinticIn:"0.64,0,0.78,0",quinticOut:"0.22,1,0.36,1",quinticInOut:"0.83,0,0.17,1",sinusoidalIn:"0.12,0,0.39,0",sinusoidalOut:"0.61,1,0.88,1",sinusoidalInOut:"0.37,0,0.63,1",exponentialIn:"0.7,0,0.84,0",exponentialOut:"0.16,1,0.3,1",exponentialInOut:"0.87,0,0.13,1",circularIn:"0.55,0,1,0.45",circularOut:"0,0.55,0.45,1",circularInOut:"0.85,0,0.15,1"},dM="transform-origin";function uM(t,e,n){var i=_n({},t.shape);_n(i,e),t.buildPath(n,i);var r=new jk;return r.reset(Uo(t)),n.rebuildPath(r,1),r.generateStr(),r.getStr()}function hM(t,e){var n=e.originX,i=e.originY;(n||i)&&(t[dM]=n+"px "+i+"px")}var pM={fill:"fill",opacity:"opacity",lineWidth:"stroke-width",lineDashOffset:"stroke-dashoffset"};function fM(t,e){var n=e.zrId+"-ani-"+e.cssAnimIdx++;return e.cssAnims[n]=t,n}function gM(t){return En(t)?cM[t]?"cubic-bezier("+cM[t]+")":oo(t)?t:"":""}function vM(t,e,n,i){var r=t.animators,o=r.length,a=[];if(t instanceof mh){var s=function(t,e,n){var i,r,o=t.shape.paths,a={};if(Cn(o,function(t){var e=oM(n.zrId);e.animation=!0,vM(t,{},e,!0);var o=e.cssAnims,s=e.cssNodes,l=In(o),c=l.length;if(c){var d=o[r=l[c-1]];for(var u in d){var h=d[u];a[u]=a[u]||{d:""},a[u].d+=h.d||""}for(var p in s){var f=s[p].animation;f.indexOf(r)>=0&&(i=f)}}}),i){e.d=!1;var s=fM(a,n);return i.replace(r,s)}}(t,e,n);if(s)a.push(s);else if(!o)return}else if(!o)return;for(var l={},c=0;c0}).length)return fM(d,n)+" "+r[0]+" both"}for(var v in l){(s=g(l[v]))&&a.push(s)}if(a.length){var m=n.zrId+"-cls-"+lM();n.cssNodes["."+m]={animation:a.join(",")},e.class=m}}function mM(t,e,n,i){var r=JSON.stringify(t),o=n.cssStyleCache[r];o||(o=n.zrId+"-cls-"+lM(),n.cssStyleCache[r]=o,n.cssNodes["."+o+":hover"]=t),e.class=e.class?e.class+" "+o:o}var yM=Math.round;function _M(t){return t&&En(t.src)}function bM(t){return t&&Ln(t.toDataURL)}function xM(t,e,n,i){Qk(function(r,o){var a="fill"===r||"stroke"===r;a&&Vo(o)?LM(e,t,r,i):a&&Ho(o)?EM(n,t,r,i):t[r]=o,a&&i.ssr&&"none"===o&&(t["pointer-events"]="visible")},e,n,!1),function(t,e,n){var i=t.style;if(function(t){return t&&(t.shadowBlur||t.shadowOffsetX||t.shadowOffsetY)}(i)){var r=function(t){var e=t.style,n=t.getGlobalScale();return[e.shadowColor,(e.shadowBlur||0).toFixed(2),(e.shadowOffsetX||0).toFixed(2),(e.shadowOffsetY||0).toFixed(2),n[0],n[1]].join(",")}(t),o=n.shadowCache,a=o[r];if(!a){var s=t.getGlobalScale(),l=s[0],c=s[1];if(!l||!c)return;var d=i.shadowOffsetX||0,u=i.shadowOffsetY||0,h=i.shadowBlur,p=Lo(i.shadowColor),f=p.opacity,g=p.color,v=h/2/l+" "+h/2/c;a=n.zrId+"-s"+n.shadowIdx++,n.defs[a]=iM("filter",a,{id:a,x:"-100%",y:"-100%",width:"300%",height:"300%"},[iM("feDropShadow","",{dx:d/l,dy:u/c,stdDeviation:v,"flood-color":g,"flood-opacity":f})]),o[r]=a}e.filter=Wo(a)}}(n,t,i)}function wM(t,e){var n=function(t){if("function"==typeof gs)return gs(t)}(e);n&&(n.each(function(e,n){null!=e&&(t[(eM+n).toLowerCase()]=e+"")}),e.isSilent()&&(t[eM+"silent"]="true"))}function SM(t){return zo(t[0]-1)&&zo(t[1])&&zo(t[2])&&zo(t[3]-1)}function CM(t,e,n){if(e&&(!function(t){return zo(t[4])&&zo(t[5])}(e)||!SM(e))){var i=1e4;t.transform=SM(e)?"translate("+yM(e[4]*i)/i+" "+yM(e[5]*i)/i+")":function(t){return"matrix("+No(t[0])+","+No(t[1])+","+No(t[2])+","+No(t[3])+","+Oo(t[4])+","+Oo(t[5])+")"}(e)}}function kM(t,e,n){for(var i=t.points,r=[],o=0;o=0&&a||o;s&&(r=Ao(s))}var l=i.lineWidth;l&&(l/=!i.strokeNoScale&&t.transform?t.transform[0]:1);var c={cursor:"pointer"};r&&(c.fill=r),i.stroke&&(c.stroke=i.stroke),l&&(c["stroke-width"]=l),mM(c,e,n)}}(t,o,e),iM(s,t.id+"",o)}function PM(t,e){return t instanceof Yc?AM(t,e):t instanceof td?function(t,e){var n=t.style,i=n.image;if(i&&!En(i)&&(_M(i)?i=i.src:bM(i)&&(i=i.toDataURL())),i){var r=n.x||0,o=n.y||0,a={href:i,width:n.width,height:n.height};return r&&(a.x=r),o&&(a.y=o),CM(a,t.transform),xM(a,n,t,e),wM(a,t),e.animation&&vM(t,a,e),iM("image",t.id+"",a)}}(t,e):t instanceof Kc?function(t,e){var n=t.style,i=n.text;if(null!=i&&(i+=""),i&&!isNaN(n.x)&&!isNaN(n.y)){var r=n.font||Ke,o=n.x||0,a=function(t,e,n){return"top"===n?t+=e/2:"bottom"===n&&(t-=e/2),t}(n.y||0,Qa(r),n.textBaseline),s={"dominant-baseline":"central","text-anchor":$o[n.textAlign]||n.textAlign};if(md(n)){var l="",c=n.fontStyle,d=gd(n.fontSize);if(!parseFloat(d))return;var u=n.fontFamily||Ze,h=n.fontWeight;l+="font-size:"+d+";font-family:"+u+";",c&&"normal"!==c&&(l+="font-style:"+c+";"),h&&"normal"!==h&&(l+="font-weight:"+h+";"),s.style=l}else s.style="font: "+r;return i.match(/\s/)&&(s["xml:space"]="preserve"),o&&(s.x=o),a&&(s.y=a),CM(s,t.transform),xM(s,n,t,e),wM(s,t),e.animation&&vM(t,s,e),iM("text",t.id+"",s,void 0,i)}}(t,e):void 0}function LM(t,e,n,i){var r,o=t[n],a={gradientUnits:o.global?"userSpaceOnUse":"objectBoundingBox"};if(Fo(o))r="linearGradient",a.x1=o.x,a.y1=o.y,a.x2=o.x2,a.y2=o.y2;else{if(!Bo(o))return;r="radialGradient",a.cx=Wn(o.x,.5),a.cy=Wn(o.y,.5),a.r=Wn(o.r,.5)}for(var s=o.colorStops,l=[],c=0,d=s.length;cl?XM(t,null==n[u+1]?null:n[u+1].elm,n,s,u):YM(t,e,a,l))}(n,i,r):UM(r)?(UM(t.text)&&BM(n,""),XM(n,null,r,0,r.length-1)):UM(i)?YM(n,i,0,i.length-1):UM(t.text)&&BM(n,""):t.text!==e.text&&(UM(i)&&YM(n,i,0,i.length-1),BM(n,e.text)))}var QM=0,JM=function(){function t(t,e,n){if(this.type="svg",this.refreshHover=function(){},this.configLayer=function(){},this.storage=e,this._opts=n=_n({},n),this.root=t,this._id="zr"+QM++,this._oldVNode=aM(n.width,n.height),t&&!n.ssr){var i=this._viewport=document.createElement("div");i.style.cssText="position:relative;overflow:hidden";var r=this._svgDom=this._oldVNode.elm=nM("svg");ZM(null,this._oldVNode),i.appendChild(r),t.appendChild(i)}this.resize(n.width,n.height)}return t.prototype.getType=function(){return this.type},t.prototype.getViewportRoot=function(){return this._viewport},t.prototype.getViewportRootOffset=function(){var t=this.getViewportRoot();if(t)return{offsetLeft:t.offsetLeft||0,offsetTop:t.offsetTop||0}},t.prototype.getSvgDom=function(){return this._svgDom},t.prototype.refresh=function(){if(this.root){var t=this.renderToVNode({willUpdate:!0});t.attrs.style="position:absolute;left:0;top:0;user-select:none",function(t,e){if(qM(t,e))KM(t,e);else{var n=t.elm,i=HM(n);jM(e),null!==i&&(OM(i,e.elm,FM(n)),YM(i,[t],0,0))}}(this._oldVNode,t),this._oldVNode=t}},t.prototype.renderOneToVNode=function(t){return PM(t,oM(this._id))},t.prototype.renderToVNode=function(t){t=t||{};var e=this.storage.getDisplayList(!0),n=this._width,i=this._height,r=oM(this._id);r.animation=t.animation,r.willUpdate=t.willUpdate,r.compress=t.compress,r.emphasis=t.emphasis,r.ssr=this._opts.ssr;var o=[],a=this._bgVNode=function(t,e,n,i){var r;if(n&&"none"!==n)if(r=iM("rect","bg",{width:t,height:e,x:"0",y:"0"}),Vo(n))LM({fill:n},r.attrs,"fill",i);else if(Ho(n))EM({style:{fill:n},dirty:ri,getBoundingRect:function(){return{width:t,height:e}}},r.attrs,"fill",i);else{var o=Lo(n),a=o.color,s=o.opacity;r.attrs.fill=a,s<1&&(r.attrs["fill-opacity"]=s)}return r}(n,i,this._backgroundColor,r);a&&o.push(a);var s=t.compress?null:this._mainVNode=iM("g","main",{},[]);this._paintList(e,r,s?s.children:o),s&&o.push(s);var l=kn(In(r.defs),function(t){return r.defs[t]});if(l.length&&o.push(iM("defs","defs",{},l)),t.animation){var c=function(t,e,n){var i=(n=n||{}).newline?"\n":"",r=" {"+i,o=i+"}",a=kn(In(t),function(e){return e+r+kn(In(t[e]),function(n){return n+":"+t[e][n]+";"}).join(i)+o}).join(i),s=kn(In(e),function(t){return"@keyframes "+t+r+kn(In(e[t]),function(n){return n+r+kn(In(e[t][n]),function(i){var r=e[t][n][i];return"d"===i&&(r='path("'+r+'")'),i+":"+r+";"}).join(i)+o}).join(i)+o}).join(i);return a||s?[""].join(i):""}(r.cssNodes,r.cssAnims,{newline:!0});if(c){var d=iM("style","stl",{},[],c);o.push(d)}}return aM(n,i,o,t.useViewBox)},t.prototype.renderToString=function(t){return t=t||{},rM(this.renderToVNode({animation:Wn(t.cssAnimation,!0),emphasis:Wn(t.cssEmphasis,!0),willUpdate:!1,compress:!0,useViewBox:Wn(t.useViewBox,!0)}),{newline:!0})},t.prototype.setBackgroundColor=function(t){this._backgroundColor=t},t.prototype.getSvgRoot=function(){return this._mainVNode&&this._mainVNode.elm},t.prototype._paintList=function(t,e,n){for(var i,r,o=t.length,a=[],s=0,l=0,c=0;c=0&&(!u||!r||u[f]!==r[f]);f--);for(var g=p-1;g>f;g--)i=a[--s-1];for(var v=f+1;v10&&"cartesian2d"===o.type&&r){var s=o.getBaseAxis(),l=o.getOtherAxis(s),c=s.getExtent(),d=n.getDevicePixelRatio(),u=Math.abs(c[1]-c[0])*(d||1),h=Math.round(a/u);if(isFinite(h)&&h>1){"lttb"===r?t.setData(i.lttbDownSample(i.mapDimension(l.dim),1/h)):"minmax"===r&&t.setData(i.minmaxDownSample(i.mapDimension(l.dim),1/h));var p=void 0;En(r)?p=MS[r]:Ln(r)&&(p=r),p&&t.setData(i.downSample(i.mapDimension(l.dim),1/h,p,TS))}}}}}("line"))},function(t){bw(BC),bw(bk)},function(t){bw(bk),t.registerComponentModel(xk),t.registerComponentView(Rk),t.registerAction({type:"showTip",event:"showTip",update:"tooltip:manuallyShowTip"},ri),t.registerAction({type:"hideTip",event:"hideTip",update:"tooltip:manuallyHideTip"},ri)},function(t){t.registerPainter("svg",JM)}]);class tT extends Dt{constructor(){super(...arguments),this.options=null,this.data=[],this.height="120px",this._chart=null,this._resizeObserver=null}render(){return dt`
`}connectedCallback(){super.connectedCallback(),this.hasUpdated&&!this._chart&&this.options&&(this._mount(),this._observeResize())}firstUpdated(){this._mount(),this._observeResize()}updated(t){(t.has("options")||t.has("data"))&&(this._chart&&this.options?this._chart.setOption(this._mergedOptions(),!0):!this._chart&&this.options&&this._mount()),t.has("height")&&this._chart&&this._chart.resize()}disconnectedCallback(){this._destroy(),super.disconnectedCallback()}_mount(){if(!this.options)return;const t=this.shadowRoot?.querySelector(".chart-host");t&&(this._chart=Mb(t,void 0,{renderer:"svg"}),this._chart.setOption(this._mergedOptions(),!0))}_mergedOptions(){if(!this.options)throw new Error("span-chart: options not set");return{...this.options,series:this.data}}_observeResize(){if("undefined"==typeof ResizeObserver)return;this._resizeObserver=new ResizeObserver(()=>{this._chart&&this._chart.resize()});const t=this.shadowRoot?.querySelector(".chart-host");t&&this._resizeObserver.observe(t)}_destroy(){this._resizeObserver&&(this._resizeObserver.disconnect(),this._resizeObserver=null),this._chart&&(this._chart.dispose(),this._chart=null)}}tT.styles=M` :host { display: block; width: 100%; @@ -233,7 +233,7 @@ var ps={},fs={};var gs,vs=function(){function t(t,e,n){var i=this;this._sleepAft width: 100%; height: 100%; } - `,_([Et({attribute:!1})],tT.prototype,"options",void 0),_([Et({attribute:!1})],tT.prototype,"data",void 0),_([Et({type:String})],tT.prototype,"height",void 0);try{customElements.get("span-chart")||customElements.define("span-chart",tT)}catch{}function eT(t,e,n,i,o,a,s,l,c){const{options:u,series:h}=function(t,e,n,i,o,a=!1){n||(n=f[r]);const s=i?"140, 160, 220":"77, 217, 175",l=`rgb(${s})`,c=Date.now(),u=c-e,h=void 0!==n.fixedMin&&void 0!==n.fixedMax,d=(t??[]).filter(t=>t.time>=u).map(t=>[t.time,Math.abs(t.value)]),p=[{type:"line",data:d,showSymbol:!1,smooth:!1,...a?{}:{step:"end"},lineStyle:{width:1.5,color:l},areaStyle:{color:{type:"linear",x:0,y:0,x2:0,y2:1,colorStops:[{offset:0,color:`rgba(${s}, 0.18)`},{offset:1,color:`rgba(${s}, 0.18)`}]}},itemStyle:{color:l}}],g=d.length>0?function(t){let e=0;for(const n of t)n[1]>e&&(e=n[1]);return e}(d):0,v={type:"value",splitNumber:4,axisLabel:{fontSize:10,formatter:g<10?t=>0===t?"0":t.toFixed(1):t=>n.format(t)},splitLine:{lineStyle:{opacity:.15}}};h?(v.min=n.fixedMin,v.max=n.fixedMax):g<1&&(v.min=0,v.max=1),o&&"current"===n.entityRole&&(v.min=0,v.max=Math.ceil(1.25*o),p.push({type:"line",data:[[u,.8*o],[c,.8*o]],showSymbol:!1,lineStyle:{width:1,color:"rgba(255, 200, 40, 0.6)",type:"dashed"},itemStyle:{color:"transparent"},tooltip:{show:!1}}),p.push({type:"line",data:[[u,o],[c,o]],showSymbol:!1,lineStyle:{width:1.5,color:"rgba(255, 60, 60, 0.7)",type:"solid"},itemStyle:{color:"transparent"},tooltip:{show:!1}}));const m={xAxis:{type:"time",min:u,max:c,axisLabel:{fontSize:10},splitLine:{show:!1}},yAxis:v,grid:{top:8,right:4,bottom:0,left:0,containLabel:!0},tooltip:{trigger:"axis",axisPointer:{type:"line",lineStyle:{type:"dashed"}},formatter:t=>{if(!t||0===t.length)return"";const e=t[0],i=new Date(e.value[0]).toLocaleString(void 0,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit"}),r=parseFloat(e.value[1].toFixed(2));return`
${i}
${n.format(r)} ${n.unit(r)}
`}},animation:!1};return{options:m,series:p}}(n,i,o,a,l,c),d=s??120;t.style.minHeight=d+"px";let p=t.querySelector("span-chart");p||(p=document.createElement("span-chart"),p.style.display="block",p.style.width="100%",t.innerHTML="",t.appendChild(p));const g=t.clientHeight;p.height=(g>0?g:d)+"px",p.options=u,p.data=h}function nT(t){return"function"==typeof globalThis.CSS?.escape?CSS.escape(t):t.replace(/["\\]/g,"\\$&")}function iT(t,e,n,i,r){const o="current"===(i.chart_metric||"power"),a=t.querySelector(".stat-consumption .stat-value"),s=t.querySelector(".stat-consumption .stat-unit");if(o){const t=n.panel_entities?.site_power,i=t?e.states[t]:null,r=i?parseFloat(i.attributes?.amperage):NaN;a&&(a.textContent=Number.isFinite(r)?Math.abs(r).toFixed(1):"--"),s&&(s.textContent="A")}else{let t=r;const i=n.panel_entities?.site_power;if(i){const n=e.states[i];n&&(t=Math.abs(parseFloat(n.state)||0))}a&&(a.textContent=fe(t)),s&&(s.textContent="kW")}const l=t.querySelector(".stat-upstream .stat-value"),c=t.querySelector(".stat-upstream .stat-unit");if(l){const t=n.panel_entities?.current_power,i=t?e.states[t]:null;if(o){const t=i?parseFloat(i.attributes?.amperage):NaN;l.textContent=Number.isFinite(t)?Math.abs(t).toFixed(1):"--",c&&(c.textContent="A")}else{const t=i?Math.abs(parseFloat(i.state)||0):0;l.textContent=fe(t),c&&(c.textContent="kW")}}const u=t.querySelector(".stat-downstream .stat-value"),h=t.querySelector(".stat-downstream .stat-unit");if(u){const t=n.panel_entities?.feedthrough_power,i=t?e.states[t]:null;if(o){const t=i?parseFloat(i.attributes?.amperage):NaN;u.textContent=Number.isFinite(t)?Math.abs(t).toFixed(1):"--",h&&(h.textContent="A")}else{const t=i?Math.abs(parseFloat(i.state)||0):0;u.textContent=fe(t),h&&(h.textContent="kW")}}const d=t.querySelector(".stat-solar .stat-value"),p=t.querySelector(".stat-solar .stat-unit");if(d){const t=n.panel_entities?.pv_power,i=t?e.states[t]:null;if(o){const t=i?parseFloat(i.attributes?.amperage):NaN;d.textContent=Number.isFinite(t)?Math.abs(t).toFixed(1):"--",p&&(p.textContent="A")}else{if(i){const t=Math.abs(parseFloat(i.state)||0);d.textContent=fe(t)}else d.textContent="--";p&&(p.textContent="kW")}}const f=t.querySelector(".stat-battery .stat-value");if(f){const t=n.panel_entities?.battery_level,i=t?e.states[t]:null;i&&(f.textContent=`${Math.round(parseFloat(i.state)||0)}`)}const g=t.querySelector(".stat-grid-state .stat-value");if(g){const t=n.panel_entities?.dsm_state,i=t?e.states[t]:null;g.textContent=i?e.formatEntityState?.(i)||i.state:"--"}}function rT(t,e,i,r,o,a){if(!t||!i||!e)return;const s=$e(r);let u=0;for(const[,t]of Object.entries(i.circuits)){const n=t.entities?.power;if(!n)continue;const i=e.states[n],r=i&&parseFloat(i.state)||0;t.device_type!==c&&(u+=Math.abs(r))}!function(t,e,n,i,r){const o=t.querySelector(".panel-stats");o&&iT(o,e,n,i,r)}(t,e,i,r,u);const h=ye(r),d="current"===h.entityRole;for(const[r,u]of Object.entries(i.circuits)){const i=t.querySelector(`.circuit-slot[data-uuid="${nT(r)}"]`);if(!i)continue;const p=u.entities?.power,f=p?e.states[p]:null,g=f&&parseFloat(f.state)||0,m=u.device_type===c||g<0,y=u.entities?.switch,_=y?e.states[y]:null,b=_?"on"===_.state:(f?.attributes?.relay_state||u.relay_state)===l,x=i.querySelector(".power-value");if(x)if(d){const t=u.entities?.current,n=t?e.states[t]:null,i=n&&parseFloat(n.state)||0;x.innerHTML=`${h.format(i)}A`}else x.innerHTML=`${pe(g)}${de(g)}`;const w=i.querySelector(".toggle-pill");if(w){w.className="toggle-pill "+(b?"toggle-on":"toggle-off");const t=w.querySelector(".toggle-label");t&&(t.textContent=n(b?"grid.on":"grid.off"))}let S;if(i.classList.toggle("circuit-off",!b),i.classList.toggle("circuit-producer",m),u.always_on)S="always_on";else{const t=u.entities?.select,n=t?e.states[t]:null;S=n?n.state:"unknown"}const C=v[S]??v.unknown,k=i.querySelector(".shedding-icon");k&&(k.setAttribute("icon",C.icon),k.style.color=C.color,k.title=C.label());const M=i.querySelector(".shedding-icon-secondary");M&&(C.icon2?(M.setAttribute("icon",C.icon2),M.style.color=C.color,M.style.display=""):M.style.display="none");const T=i.querySelector(".shedding-label");T&&(C.textLabel?(T.textContent=C.textLabel,T.style.color=C.color,T.style.display=""):T.style.display="none");const I=i.querySelector(".chart-container");if(I){const t=o.get(r)||[],e=i.classList.contains("circuit-col-span")?200:100,n=a?.has(r)?He(a.get(r)):s,l=u.device_type===c;eT(I,0,t,n,h,m,e,u.breaker_rating_a??void 0,l)}}}class oT{get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t,this._retry=t?new qt(t):null}constructor(){this._errorStore=null,this._retry=null,this._settings=null,this._lastFetch=0,this._fetching=!1}async fetch(t,e){const i=Date.now();if(this._fetching)return this._settings;if(this._settings&&i-this._lastFetch<3e4)return this._settings;this._fetching=!0;try{const i={};e&&(i.config_entry_id=e);const r={type:"call_service",domain:s,service:"get_graph_settings",service_data:i,return_response:!0},o=this._retry?await this._retry.callWS(t,r,{errorId:"fetch:graph_settings",errorMessage:n("error.graph_settings_failed")}):await t.callWS(r);this._settings=o?.response??null,this._lastFetch=Date.now()}catch(t){console.warn("SPAN Panel: graph settings fetch failed",t),this._settings=null,this._retry||this._errorStore?.add({key:"fetch:graph_settings",level:"warning",message:n("error.graph_settings_failed"),persistent:!1})}finally{this._fetching=!1}return this._settings}invalidate(){this._lastFetch=0}get settings(){return this._settings}clear(){this._settings=null,this._lastFetch=0}}function aT(t,e){if(!t)return o;const n=t.circuits?.[e];return n?.has_override?n.horizon:t.global_horizon??o}function sT(t,e){if(!t)return o;const n=t.sub_devices?.[e];return n?.has_override?n.horizon:t.global_horizon??o}class lT{constructor(){this.powerHistory=new Map,this.horizonMap=new Map,this.subDeviceHorizonMap=new Map,this.monitoringCache=new be,this.monitoringMultiCache=new xe,this.graphSettingsCache=new oT,this._errorStore=null,this._hass=null,this._topology=null,this._config=null,this._configEntryId=null,this._favRefs=null,this._perPanelInfo=new Map,this._panelFavorites=null,this._showMonitoring=!1,this._updateInterval=null,this._recorderRefreshInterval=null,this._resizeObserver=null,this._lastWidth=0,this._resizeDebounce=null}get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t,this.monitoringCache.errorStore=t,this.graphSettingsCache.errorStore=t,this.monitoringMultiCache.errorStore=t}get hass(){return this._hass}set hass(t){this._hass=t}get topology(){return this._topology}get config(){return this._config}set showMonitoring(t){this._showMonitoring=t}init(t,e,n,i){this._topology=t,this._config=e,this._hass=n,this._configEntryId=i}setFavoriteRefs(t){this._favRefs=t}clearFavoriteRefs(){this._favRefs=null}setPanelFavorites(t){this._panelFavorites=t}setFavoritesPerPanelInfo(t){this._perPanelInfo=t??new Map}get _inFavoritesView(){return null!==this._favRefs}setConfig(t){this._config=t}buildHorizonMaps(t){if(this.horizonMap.clear(),this.subDeviceHorizonMap.clear(),t&&this._topology?.circuits)for(const e of Object.keys(this._topology.circuits))this.horizonMap.set(e,aT(t,e));if(t&&this._topology?.sub_devices)for(const e of Object.keys(this._topology.sub_devices))this.subDeviceHorizonMap.set(e,sT(t,e))}async fetchAndBuildHorizonMaps(){try{this._favRefs?await this._buildFavoritesHorizonMaps():(await this.graphSettingsCache.fetch(this._hass,this._configEntryId),this.buildHorizonMaps(this.graphSettingsCache.settings))}catch(t){console.warn("SPAN Panel: graph settings fetch failed",t),this.graphSettingsCache.errorStore||this._errorStore?.add({key:"fetch:graph_settings",level:"warning",message:n("error.graph_settings_failed"),persistent:!1})}}async fetchMergedMonitoringStatus(t){if(!this._hass||0===t.length)return null;const e=this._hass;return function(t){let e=!1;const n={},i={};for(const r of t)r&&(e=!0,r.circuits&&Object.assign(n,r.circuits),r.mains&&Object.assign(i,r.mains));return e?{circuits:n,mains:i}:null}(await Promise.all(t.map(t=>this.monitoringMultiCache.fetchOne(e,t))))}async _buildFavoritesHorizonMaps(){if(!this._hass||!this._favRefs||!this._topology)return;const t=new Set;for(const e of Object.values(this._favRefs))e.configEntryId&&t.add(e.configEntryId);const e=new Map;await Promise.all(Array.from(t).map(async t=>{e.set(t,await this._fetchGraphSettingsFresh(t))})),this.horizonMap.clear(),this.subDeviceHorizonMap.clear();for(const t of Object.keys(this._topology.circuits)){const n=this._favRefs[t],i=n?.configEntryId?e.get(n.configEntryId)??null:null,r=n?.targetId??t;this.horizonMap.set(t,aT(i,r))}if(this._topology.sub_devices)for(const t of Object.keys(this._topology.sub_devices)){const n=this._favRefs[t],i=n?.configEntryId?e.get(n.configEntryId)??null:null,r=n?.targetId??t;this.subDeviceHorizonMap.set(t,sT(i,r))}}async loadHistory(){await je(this._hass,this._topology,this._config,this.powerHistory,this.horizonMap,this.subDeviceHorizonMap)}recordSamples(){if(!this._topology||!this._hass||!this._config)return;const t=Date.now();for(const[e,n]of Object.entries(this._topology.circuits)){const i=this.horizonMap.get(e)??o;if(!a[i]?.useRealtime)continue;const r=_e(n,this._config);if(!r)continue;const s=this._hass.states[r];if(!s)continue;const l=parseFloat(s.state);if(isNaN(l))continue;const c=He(i),u=Fe(c),h=Be(c),d=t-c,p=this.powerHistory.get(e)??[];p.length>0&&t-p[p.length-1].time0&&t-p[p.length-1].time0&&this._topology)for(const{key:t,devId:e}of qe(this._topology))i.has(e)&&r.add(t);const o=new Map;try{await je(this._hass,this._topology,this._config,o,e,i);for(const t of e.keys()){const e=o.get(t);e?this.powerHistory.set(t,e):this.powerHistory.delete(t)}for(const t of r){const e=o.get(t);e?this.powerHistory.set(t,e):this.powerHistory.delete(t)}this.updateDOM(t)}catch(t){console.warn("SPAN Panel: history refresh failed",t),this._errorStore?.add({key:"fetch:history",level:"warning",message:n("error.history_failed"),persistent:!1})}}updateDOM(t){this._hass&&this._topology&&this._config&&(rT(t,this._hass,this._topology,this._config,this.powerHistory,this.horizonMap),function(t,e,n,i,r,o){if(!n.sub_devices)return;const a=$e(i);for(const[i,s]of Object.entries(n.sub_devices)){const n=t.querySelector(`[data-subdev="${nT(i)}"]`);if(!n)continue;const l=Pe(s);if(l){const t=e.states[l],i=t&&parseFloat(t.state)||0,r=n.querySelector(".sub-power-value");r&&(r.innerHTML=`${pe(i)} ${de(i)}`)}const c=n.querySelectorAll("[data-chart-key]");for(const t of c){const e=t.dataset.chartKey;if(!e)continue;const n=r.get(e)||[];let s=g.power;e.endsWith("_soc")?s=g.soc:e.endsWith("_soe")&&(s=g.soe);const l=!!t.closest(".bess-chart-col");eT(t,0,n,o?.has(i)?He(o.get(i)):a,s,!1,l?120:150,void 0,e.endsWith("_soc")||e.endsWith("_soe"))}for(const t of Object.keys(s.entities||{})){const i=n.querySelector(`[data-eid="${nT(t)}"]`);if(!i)continue;const r=e.states[t];if(r){let t;if(e.formatEntityState)t=e.formatEntityState(r);else{t=r.state;const e=r.attributes.unit_of_measurement||"";e&&(t+=" "+e)}if("Wh"===(r.attributes.unit_of_measurement||"")){const e=parseFloat(r.state);isNaN(e)||(t=(e/1e3).toFixed(1)+" kWh")}i.textContent=t}}}}(t,this._hass,this._topology,this._config,this.powerHistory,this.subDeviceHorizonMap))}async onGraphSettingsChanged(t){if(this._hass){this._favRefs?await this._buildFavoritesHorizonMaps():(this.graphSettingsCache.invalidate(),await this.graphSettingsCache.fetch(this._hass,this._configEntryId),this.buildHorizonMaps(this.graphSettingsCache.settings)),this.powerHistory.clear();try{await this.loadHistory()}catch{}this.updateDOM(t)}}onToggleClick(t,e){const i=t.target,r=i?.closest(".toggle-pill");if(!r)return;const o=e.querySelector(".slide-confirm");if(!o||!o.classList.contains("confirmed"))return;t.stopPropagation(),t.preventDefault();const a=r.closest("[data-uuid]");if(!a||!this._topology||!this._hass)return;const s=a.dataset.uuid;if(!s)return;const l=this._topology.circuits[s];if(!l)return;const c=l.entities?.switch;if(!c)return;const u=this._hass.states[c];if(!u)return void console.warn("SPAN Panel: switch entity not found:",c);const h="on"===u.state?"turn_off":"turn_on";this._hass.callService("switch",h,{},{entity_id:c}).catch(t=>{console.warn("SPAN Panel: switch service call failed",t),this._errorStore?.add({key:"service:relay",level:"error",message:n("error.relay_failed"),persistent:!1})})}async onGearClick(t,e){const n=t.target,i=n?.closest(".gear-icon");if(!i)return;const r=e.querySelector("span-side-panel");if(!r||!this._hass)return;if(r.hass=this._hass,r.errorStore=this.errorStore,i.classList.contains("panel-gear")){if(this._inFavoritesView){const t=await this._buildFavoritesSections();if(0===t.length)return;return void r.open({favoritesMode:!0,perPanelSections:t})}return await this.graphSettingsCache.fetch(this._hass,this._configEntryId),void r.open({panelMode:!0,topology:this._topology,graphSettings:this.graphSettingsCache.settings,showFavorites:null!==this._panelFavorites,favoritePanelDeviceId:this._panelFavorites?.panelDeviceId,favoriteCircuitUuids:this._panelFavorites?.circuitUuids,favoriteSubDeviceIds:this._panelFavorites?.subDeviceIds,configEntryId:this._configEntryId})}const a=i.dataset.uuid;if(a&&this._topology){const t=this._topology.circuits[a];if(t){const e=this._favRefs?.[a]??null,n=e&&"circuit"===e.kind?e.targetId:a,i=e?.configEntryId??this._configEntryId;let s,l;e?[s,l]=await Promise.all([this._fetchGraphSettingsFresh(i),this._fetchMonitoringStatusFresh(i)]):(await Promise.all([this.graphSettingsCache.fetch(this._hass,i),this.monitoringCache.fetch(this._hass,i)]),s=this.graphSettingsCache.settings,l=this.monitoringCache.status);const c=t.entities?.current??t.entities?.power,u=c?l?.circuits?.[c]??null:null,h=s?.global_horizon??o,d=s?.circuits?.[n],p=d?{...d,globalHorizon:h}:{horizon:h,has_override:!1,globalHorizon:h},f=e?.panelDeviceId??this._panelFavorites?.panelDeviceId,g=null!==e||(this._panelFavorites?.circuitUuids.has(n)??!1),v=this._inFavoritesView||null!==this._panelFavorites;return void r.open({...t,uuid:n,monitoringInfo:u,showMonitoring:this._showMonitoring,graphHorizonInfo:p,showFavorites:v,favoritePanelDeviceId:f,isFavorite:g,configEntryId:i})}}const s=i.dataset.subdevId;if(s&&this._topology?.sub_devices?.[s]){const t=this._topology.sub_devices[s],e=this._favRefs?.[s]??null,n=e&&"sub_device"===e.kind?e.targetId:s,i=e?.configEntryId??this._configEntryId;let a;e?a=await this._fetchGraphSettingsFresh(i):(await this.graphSettingsCache.fetch(this._hass,i),a=this.graphSettingsCache.settings);const l=a?.global_horizon??o,c=a?.sub_devices?.[n],u=c?{...c,globalHorizon:l}:{horizon:l,has_override:!1,globalHorizon:l},h=e?.panelDeviceId??this._panelFavorites?.panelDeviceId,d=null!==e||(this._panelFavorites?.subDeviceIds.has(n)??!1),p=this._inFavoritesView||null!==this._panelFavorites;r.open({subDeviceMode:!0,subDeviceId:n,name:t.name??n,deviceType:t.type??"",entities:t.entities,graphHorizonInfo:u,showFavorites:p,favoritePanelDeviceId:h,isFavorite:d,configEntryId:i})}}async _buildFavoritesSections(){if(!this._hass||!this._favRefs)return[];const t=function(t,e){const n=new Map;for(const i of Object.values(t)){if("circuit"!==i.kind)continue;const t=e.get(i.panelDeviceId);if(void 0===t)continue;let r=n.get(i.panelDeviceId);void 0===r&&(r={panelDeviceId:i.panelDeviceId,panelName:t.panelName,topology:t.topology,configEntryId:t.configEntryId,favoriteCircuitUuids:new Set},n.set(i.panelDeviceId,r)),r.favoriteCircuitUuids.add(i.targetId)}return Array.from(n.values()).sort((t,e)=>t.panelName.localeCompare(e.panelName))}(this._favRefs,this._perPanelInfo);if(0===t.length)return[];return await Promise.all(t.map(async t=>({panelDeviceId:t.panelDeviceId,panelName:t.panelName,topology:t.topology,graphSettings:await this._fetchGraphSettingsFresh(t.configEntryId),favoriteCircuitUuids:t.favoriteCircuitUuids,configEntryId:t.configEntryId})))}async _fetchGraphSettingsFresh(t){if(!this._hass)return null;try{const e={};t&&(e.config_entry_id=t);const i={type:"call_service",domain:s,service:"get_graph_settings",service_data:e,return_response:!0},r=this._errorStore?new qt(this._errorStore):null,o=r?await r.callWS(this._hass,i,{errorId:"fetch:graph_settings",errorMessage:n("error.graph_settings_failed")}):await this._hass.callWS(i);return o?.response??null}catch(t){return console.warn("SPAN Panel: fresh graph settings fetch failed",t),null}}async _fetchMonitoringStatusFresh(t){if(!this._hass)return null;try{const e={};t&&(e.config_entry_id=t);const i={type:"call_service",domain:s,service:"get_monitoring_status",service_data:e,return_response:!0},r=this._errorStore?new qt(this._errorStore):null,o=r?await r.callWS(this._hass,i,{errorId:"fetch:monitoring",errorMessage:n("error.monitoring_failed")}):await this._hass.callWS(i),a=o?.response;return a?{circuits:a.circuits,mains:a.mains}:null}catch(t){return console.warn("SPAN Panel: fresh monitoring status fetch failed",t),null}}bindSlideConfirm(t,e){const n=t.querySelector(".slide-confirm-knob"),i=t.querySelector(".slide-confirm-text");if(!n||!i)return;let r=!1,o=0,a=0;const s=e=>{t.classList.contains("confirmed")||(r=!0,o=e-n.offsetLeft,a=t.offsetWidth-n.offsetWidth-4,n.classList.remove("snapping"))},l=t=>{if(!r)return;const e=Math.max(2,Math.min(t-o,a));n.style.left=e+"px"},c=()=>{if(!r)return;r=!1;(n.offsetLeft-2)/a>=.9?(n.style.left=a+"px",t.classList.add("confirmed"),n.querySelector("span-icon")?.setAttribute("icon","mdi:lock-open"),i.textContent=t.dataset.textOn??"",e&&e.classList.remove("switches-disabled")):(n.classList.add("snapping"),n.style.left="2px")};n.addEventListener("mousedown",t=>{t.preventDefault(),s(t.clientX)}),t.addEventListener("mousemove",t=>l(t.clientX)),t.addEventListener("mouseup",c),t.addEventListener("mouseleave",c),n.addEventListener("touchstart",t=>{t.preventDefault(),s(t.touches[0].clientX)},{passive:!1}),t.addEventListener("touchmove",t=>l(t.touches[0].clientX),{passive:!0}),t.addEventListener("touchend",c),t.addEventListener("touchcancel",c),t.addEventListener("click",()=>{t.classList.contains("confirmed")&&(t.classList.remove("confirmed"),n.classList.add("snapping"),n.style.left="2px",n.querySelector("span-icon")?.setAttribute("icon","mdi:lock"),i.textContent=t.dataset.textOff??"",e&&e.classList.add("switches-disabled"))})}startIntervals(t,e){this._updateInterval=setInterval(()=>{this.recordSamples(),this.updateDOM(t),e&&e()},1e3),this._recorderRefreshInterval=setInterval(()=>{this.refreshRecorderData(t)},3e4)}stopIntervals(){this._updateInterval&&(clearInterval(this._updateInterval),this._updateInterval=null),this._recorderRefreshInterval&&(clearInterval(this._recorderRefreshInterval),this._recorderRefreshInterval=null),this.cleanupResizeObserver()}setupResizeObserver(t,e){this.cleanupResizeObserver(),e&&(this._lastWidth=e.clientWidth,this._resizeObserver=new ResizeObserver(e=>{const n=e[0];if(!n)return;const i=n.contentRect.width;Math.abs(i-this._lastWidth)<5||(this._lastWidth=i,this._resizeDebounce&&clearTimeout(this._resizeDebounce),this._resizeDebounce=setTimeout(()=>{for(const e of t.querySelectorAll(".chart-container")){const t=e.querySelector("span-chart");t&&t.remove()}this.updateDOM(t)},150))}),this._resizeObserver.observe(e))}cleanupResizeObserver(){this._resizeObserver&&(this._resizeObserver.disconnect(),this._resizeObserver=null),this._resizeDebounce&&(clearTimeout(this._resizeDebounce),this._resizeDebounce=null)}reset(){this.powerHistory.clear(),this.horizonMap.clear(),this.subDeviceHorizonMap.clear(),this.monitoringCache.clear(),this.monitoringMultiCache.clear(),this.graphSettingsCache.clear()}}function cT(t,e){const n=e.hysteresisPx??48,i=new WeakSet;let r=null;const o=t=>{const n=t.querySelector(e.nameSelector);return!!n&&(0!==t.clientWidth&&(n.clientWidth<1||n.scrollWidth>n.clientWidth+1))},a=()=>{const i=t.querySelectorAll(e.rowSelector);if(0===i.length)return;const a=i[0].clientWidth;if(0===a)return;if(null!==r){if(a>r+n){for(const t of i)t.classList.remove(e.foldClass);r=null}else for(const t of i)t.classList.add(e.foldClass);return}let s=!1;for(const t of i)if(o(t)){s=!0;break}if(s){for(const t of i)t.classList.add(e.foldClass);r=a}},s=new ResizeObserver(()=>a()),l=()=>{const n=t.querySelectorAll(e.rowSelector);for(const t of n)i.has(t)||(i.add(t),s.observe(t));requestAnimationFrame(()=>a())};l();const c=new MutationObserver(t=>{(t=>{const n=t=>{for(const n of t)if(n instanceof Element){if(n.matches(e.rowSelector))return!0;if(n.querySelector(e.rowSelector))return!0}return!1};for(const e of t)if(n(e.addedNodes)||n(e.removedNodes))return!0;return!1})(t)&&l()});return c.observe(t,{childList:!0,subtree:!0}),()=>{s.disconnect(),c.disconnect()}}const uT='\n :host {\n --span-accent: var(--primary-color, #4dd9af);\n }\n\n /* Card shell — replaces . Theme variables (--ha-card-*) are\n stable HA contracts (not the deprecated component APIs flagged by the\n 2026.4 frontend blog), so they stay in place to keep visual parity\n with the rest of HA\'s dashboards. */\n .span-card {\n display: block;\n padding: 24px;\n background: var(--card-background-color, #1c1c1c);\n color: var(--primary-text-color, #e0e0e0);\n border-radius: var(--ha-card-border-radius, 12px);\n border: var(--ha-card-border-width, 1px) solid var(--ha-card-border-color, var(--divider-color, #333));\n box-shadow: var(--ha-card-box-shadow, none);\n }\n\n .panel-header {\n display: flex;\n flex-wrap: wrap;\n justify-content: space-between;\n align-items: flex-start;\n gap: 8px 16px;\n margin-bottom: 20px;\n padding-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n .header-left { flex: 1 1 300px; min-width: 0; }\n .header-center { flex: 0 0 auto; }\n .header-right { flex: 0 1 auto; min-width: 0; }\n\n .panel-identity {\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 8px 12px;\n margin-bottom: 12px;\n }\n\n .panel-title {\n font-size: 1.8em;\n font-weight: 700;\n margin: 0;\n color: var(--primary-text-color, #fff);\n }\n\n .panel-serial {\n font-size: 0.85em;\n color: var(--secondary-text-color, #999);\n font-family: monospace;\n }\n\n .panel-stats {\n display: flex;\n flex-wrap: wrap;\n gap: 16px 32px;\n }\n\n /* Favorites view header: gear + slide-to-arm + right-anchored legend/W-A cluster. */\n .favorites-summary {\n padding: 8px 24px;\n border-bottom: 1px solid var(--divider-color, #e0e0e0);\n display: flex;\n align-items: center;\n gap: 12px;\n }\n /* Override the generic .gear-icon { margin-left: auto } rule so the\n favorites gear stays flush-left instead of floating to the right of\n the flex row (same idea as .panel-identity .panel-gear does for\n real-panel headers). */\n .favorites-summary .favorites-gear {\n margin-left: 0;\n }\n /* Right-anchored cluster wrapping the shedding legend + W/A unit toggle.\n margin-left:auto moved here from .favorites-summary-unit-toggle so the\n legend and toggle cluster together, matching the real-panel header\n layout. */\n .favorites-summary-right {\n margin-left: auto;\n display: flex;\n align-items: center;\n gap: 16px;\n }\n .favorites-subdevices-section {\n padding: 8px 16px 0;\n }\n\n /* Favorites view: responsive grid of per-contributing-panel status cards. */\n .favorites-panel-stats-grid {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));\n gap: 12px;\n padding: 12px 24px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n .favorites-panel-card {\n background: var(--secondary-background-color, rgba(255, 255, 255, 0.04));\n border: 1px solid var(--divider-color, #333);\n border-radius: 8px;\n padding: 10px 14px;\n display: flex;\n flex-direction: column;\n gap: 6px;\n }\n .favorites-panel-card-title {\n font-size: 0.85em;\n font-weight: 600;\n color: var(--primary-text-color);\n opacity: 0.85;\n }\n .favorites-panel-card .panel-stats {\n gap: 10px 20px;\n }\n .favorites-panel-card .stat-value {\n font-size: 1.15em;\n }\n\n .stat { display: flex; flex-direction: column; }\n .stat-label { font-size: 0.8em; color: var(--secondary-text-color, #999); margin-bottom: 2px; }\n .stat-row { display: flex; align-items: baseline; gap: 2px; }\n .stat-value { font-size: 1.5em; font-weight: 700; color: var(--primary-text-color, #fff); }\n .stat-unit { font-size: 0.7em; font-weight: 400; color: var(--secondary-text-color, #999); }\n\n .header-right { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; padding-top: 8px; }\n .header-right-top { display: flex; gap: 20px; align-items: center; }\n .meta-item { font-size: 0.8em; color: var(--secondary-text-color, #999); }\n\n .shedding-legend { display: flex; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }\n .shedding-legend-item { display: inline-flex; align-items: center; gap: 3px; }\n .shedding-legend-item span-icon { --mdc-icon-size: 16px; }\n .shedding-legend-secondary { --mdc-icon-size: 12px; opacity: 0.8; }\n .shedding-legend-text { font-size: 9px; font-weight: 600; }\n .shedding-legend-label { font-size: 0.7em; color: var(--secondary-text-color, #999); }\n\n .panel-gear {\n background: none;\n border: none;\n cursor: pointer;\n color: var(--secondary-text-color);\n opacity: 0.6;\n padding: 4px;\n margin-left: 8px;\n vertical-align: middle;\n }\n .panel-gear:hover { opacity: 1; }\n .header-center {\n display: flex;\n align-items: flex-start;\n justify-content: center;\n padding-top: 8px;\n }\n .panel-identity .panel-gear {\n margin-left: 0;\n }\n .slide-confirm {\n position: relative;\n display: inline-flex;\n align-items: center;\n width: 160px;\n height: 28px;\n border-radius: 14px;\n background: color-mix(in srgb, var(--primary-color, #4dd9af) 20%, var(--secondary-background-color, #333));\n vertical-align: middle;\n overflow: hidden;\n user-select: none;\n touch-action: none;\n }\n .slide-confirm-text {\n position: absolute;\n width: 100%;\n text-align: center;\n font-size: 0.65em;\n font-weight: 600;\n color: var(--secondary-text-color, #999);\n pointer-events: none;\n z-index: 0;\n }\n .slide-confirm-knob {\n position: absolute;\n left: 2px;\n top: 2px;\n width: 24px;\n height: 24px;\n border-radius: 50%;\n background: var(--secondary-text-color, #666);\n display: flex;\n align-items: center;\n justify-content: center;\n cursor: grab;\n z-index: 1;\n transition: none;\n }\n .slide-confirm-knob span-icon {\n --mdc-icon-size: 14px;\n color: var(--card-background-color, #1c1c1c);\n }\n .slide-confirm-knob.snapping {\n transition: left 0.25s ease;\n }\n .slide-confirm.confirmed {\n background: color-mix(in srgb, var(--state-active-color, var(--span-accent)) 25%, transparent);\n }\n .slide-confirm.confirmed .slide-confirm-text {\n color: var(--state-active-color, var(--span-accent));\n }\n .slide-confirm.confirmed .slide-confirm-knob {\n background: var(--state-active-color, var(--span-accent));\n }\n .switches-disabled .toggle-pill {\n opacity: 0.3;\n pointer-events: none;\n }\n .unit-toggle {\n display: inline-flex;\n background: var(--secondary-background-color, #333);\n border-radius: 6px;\n overflow: hidden;\n margin-left: 8px;\n }\n .unit-btn {\n padding: 4px 10px;\n border: none;\n background: none;\n color: var(--secondary-text-color);\n font-size: 0.75em;\n font-weight: 600;\n cursor: pointer;\n }\n .unit-btn.unit-active {\n background: var(--primary-color, #4dd9af);\n color: var(--text-primary-color, #000);\n }\n\n .monitoring-summary {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 6px 16px;\n font-size: 0.8em;\n background: rgba(76, 175, 80, 0.1);\n border: 1px solid var(--divider-color, #333);\n border-top: none;\n }\n .monitoring-active { color: #4caf50; }\n .monitoring-counts { display: flex; gap: 12px; }\n .count-warning { color: #ff9800; }\n .count-alert { color: #f44336; }\n .count-overrides { color: var(--secondary-text-color); }\n\n .panel-grid {\n display: grid;\n /* Five columns: left tab label, left cell, explicit 8px spacer,\n right cell, right tab label. Spacer is in-band rather than a\n column-gap so we can keep inter-cell space without paying an\n equal gap between each cell and its tab label. The tab columns\n are sized to fit a 2-digit breaker number (the font is 0.85em\n of the panel body ≈ 14px glyph width). */\n grid-template-columns: 14px 1fr 8px 1fr 14px;\n column-gap: 0;\n row-gap: 8px;\n align-items: stretch;\n }\n\n .tab-label {\n display: flex;\n align-items: center;\n font-size: 0.85em;\n font-weight: 600;\n color: var(--secondary-text-color, #999);\n user-select: none;\n }\n .tab-left { justify-content: flex-start; }\n .tab-right { justify-content: flex-end; }\n\n .circuit-slot {\n background: var(--secondary-background-color, var(--card-background-color, #2a2a2a));\n border: 1px solid var(--divider-color, #333);\n border-radius: 12px;\n padding: 14px 16px 20px;\n min-height: 140px;\n transition: opacity 0.3s;\n position: relative;\n overflow: hidden;\n }\n\n .circuit-col-span { min-height: 280px; }\n .circuit-row-span { border-left: 3px solid var(--span-accent); }\n .circuit-off .circuit-name,\n .circuit-off .breaker-badge,\n .circuit-off .power-value,\n .circuit-off .chart-container { opacity: 0.35; }\n .circuit-off .toggle-pill,\n .circuit-off .gear-icon { opacity: 1; }\n\n .circuit-empty {\n opacity: 0.2;\n min-height: 60px;\n display: flex;\n align-items: center;\n justify-content: center;\n border-style: dashed;\n }\n .empty-label { color: var(--secondary-text-color, #999); font-size: 0.85em; }\n\n .circuit-header {\n display: flex;\n justify-content: space-between;\n align-items: flex-start;\n margin-bottom: 6px;\n gap: 8px;\n }\n\n .circuit-info { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }\n\n .breaker-badge {\n background: color-mix(in srgb, var(--span-accent) 15%, transparent);\n color: var(--span-accent);\n font-size: 0.7em;\n font-weight: 700;\n padding: 2px 3px;\n border-radius: 4px;\n white-space: nowrap;\n border: 1px solid color-mix(in srgb, var(--span-accent) 25%, transparent);\n flex-shrink: 0;\n }\n\n .circuit-name {\n font-size: 0.9em;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n color: var(--primary-text-color, #e0e0e0);\n }\n\n .circuit-controls { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }\n\n /* Truncation-driven fold for By Panel breaker cells. The .is-folded\n class is added/removed by the JS observer in\n src/core/truncation-fold.ts when the .circuit-name actually\n ellipsizes. Pixel thresholds can\'t get this right because name\n length varies wildly per circuit (e.g. "Spa" vs\n "Commissioned PV System") — only measuring the live name vs its\n container catches the exact moment of truncation.\n\n When folded the nested flex wrappers (.circuit-header,\n .circuit-info, .circuit-controls, .circuit-status) collapse via\n \'display: contents\' so the leaf elements participate directly in\n the outer grid: name gets the whole first row, readings/controls/\n gear drop to a second row, chart stays as the full-width third. */\n .circuit-slot.is-folded {\n display: grid;\n /* Columns: badges + relay-toggle pack tight on the left, slack\n absorbed by the 1fr column between the relay and the power\n reading, keeping power + gear pinned to the right edge. The\n previous layout placed the slack between the shedding icon and\n the relay, which read as wasted padding the user pointed out. */\n grid-template-columns: auto auto auto auto 1fr auto auto;\n /* Rows: name and controls sized to content; chart absorbs any\n extra cell height. Without the explicit 1fr on row 3, a tall\n cell (e.g. .circuit-col-span\'s 280px min-height for 240V\n double-pole breakers) distributes excess space equally across\n all three rows via the default align-content:stretch, which\n pushes the chart down and vertically inflates the badge and\n relay toggle to fill the controls row. */\n grid-template-rows: auto auto 1fr;\n grid-template-areas:\n "name name name name name name name"\n "badge util shed status . power gear"\n "chart chart chart chart chart chart chart";\n row-gap: 6px;\n column-gap: 8px;\n }\n .circuit-slot.is-folded > .circuit-header,\n .circuit-slot.is-folded > .circuit-status,\n .circuit-slot.is-folded > .circuit-header > .circuit-info,\n .circuit-slot.is-folded > .circuit-header > .circuit-controls {\n display: contents;\n }\n .circuit-slot.is-folded .circuit-name {\n grid-area: name;\n justify-self: start;\n }\n .circuit-slot.is-folded .breaker-badge {\n grid-area: badge;\n }\n .circuit-slot.is-folded .utilization {\n grid-area: util;\n }\n .circuit-slot.is-folded .shedding-icon,\n .circuit-slot.is-folded .shedding-composite {\n grid-area: shed;\n }\n .circuit-slot.is-folded .toggle-pill {\n grid-area: status;\n justify-self: end;\n }\n .circuit-slot.is-folded .power-value {\n grid-area: power;\n justify-self: end;\n }\n .circuit-slot.is-folded .gear-icon.circuit-gear {\n grid-area: gear;\n justify-self: end;\n }\n .circuit-slot.is-folded > .chart-container {\n grid-area: chart;\n }\n\n .power-value { font-size: 0.9em; color: var(--primary-text-color, #fff); white-space: nowrap; }\n .power-value strong { font-weight: 700; font-size: 1.1em; }\n .power-unit { font-size: 0.8em; font-weight: 400; color: var(--secondary-text-color, #999); margin-left: 1px; }\n .circuit-producer .power-value strong { color: var(--info-color, #4fc3f7); }\n\n .toggle-pill {\n display: flex;\n align-items: center;\n gap: 3px;\n padding: 2px 4px;\n border-radius: 10px;\n cursor: pointer;\n font-size: 0.65em;\n font-weight: 600;\n transition: background 0.2s;\n user-select: none;\n min-width: 40px;\n }\n .toggle-on {\n padding-left: 6px;\n background: color-mix(in srgb, var(--state-active-color, var(--span-accent)) 25%, transparent);\n color: var(--state-active-color, var(--span-accent));\n }\n .toggle-off {\n padding-right: 6px;\n background: color-mix(in srgb, var(--secondary-text-color) 15%, transparent);\n color: var(--secondary-text-color, #999);\n }\n .toggle-knob {\n width: 14px;\n height: 14px;\n border-radius: 50%;\n transition: background 0.2s, margin 0.2s;\n }\n .toggle-on .toggle-knob {\n background: var(--state-active-color, var(--span-accent));\n margin-left: auto;\n }\n .toggle-off .toggle-knob {\n background: var(--secondary-text-color, #999);\n margin-right: auto;\n order: -1;\n }\n\n .circuit-status {\n display: flex;\n align-items: center;\n gap: 4px;\n margin-top: 4px;\n padding: 0 4px;\n }\n .shedding-icon { opacity: 0.8; cursor: default; }\n .shedding-composite {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n }\n .shedding-icon-secondary { opacity: 0.8; }\n .shedding-label {\n font-size: 10px;\n font-weight: 600;\n opacity: 0.8;\n }\n .gear-icon {\n background: none;\n border: none;\n cursor: pointer;\n padding: 2px;\n opacity: 0.6;\n transition: opacity 0.2s;\n margin-left: auto;\n }\n .gear-icon:hover { opacity: 1; }\n .utilization {\n font-size: 0.75em;\n font-weight: 600;\n }\n .utilization-normal { color: #4caf50; }\n .utilization-warning { color: #ff9800; }\n .utilization-alert { color: #f44336; }\n .circuit-alert {\n border-color: #f44336 !important;\n box-shadow: 0 0 8px rgba(244, 67, 54, 0.3);\n }\n .chart-container {\n width: 100%;\n aspect-ratio: 4 / 1;\n margin-top: 4px;\n overflow: hidden;\n min-width: 0;\n }\n\n .sub-devices {\n display: grid;\n grid-template-columns: repeat(2, 1fr);\n gap: 12px;\n margin-bottom: 20px;\n padding-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n\n .sub-device {\n background: var(--secondary-background-color, var(--card-background-color, #2a2a2a));\n border: 1px solid var(--divider-color, #333);\n border-radius: 12px;\n padding: 14px 16px;\n }\n .sub-device-bess,\n .sub-device-full {\n grid-column: 1 / -1;\n }\n\n .sub-device-header { display: flex; gap: 10px; align-items: baseline; margin-bottom: 8px; }\n .sub-device-type { font-size: 0.7em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--span-accent); }\n .sub-device-name { font-size: 0.85em; color: var(--secondary-text-color, #999); flex: 1; }\n .sub-power-value { font-size: 0.9em; color: var(--primary-text-color, #fff); white-space: nowrap; }\n .sub-power-value strong { font-weight: 700; font-size: 1.1em; }\n .sub-device .chart-container { margin-bottom: 8px; aspect-ratio: auto; }\n\n .bess-charts {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(0, 1fr));\n gap: 12px;\n margin-bottom: 10px;\n }\n .bess-chart-col { min-width: 0; }\n .bess-chart-title {\n font-size: 0.75em;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n color: var(--secondary-text-color, #999);\n margin-bottom: 4px;\n }\n .bess-chart-col .chart-container { aspect-ratio: auto; }\n .sub-entity { display: flex; gap: 6px; padding: 3px 0; font-size: 0.85em; }\n .sub-entity-name { color: var(--secondary-text-color, #999); }\n .sub-entity-value { font-weight: 500; color: var(--primary-text-color, #e0e0e0); }\n\n /* ── Shared tab bar ────────────────────────────────────── */\n\n .shared-tab-bar {\n display: flex;\n gap: 0;\n margin-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n\n .shared-tab {\n padding: 8px 16px;\n cursor: pointer;\n font-size: 0.9em;\n font-weight: 500;\n color: var(--primary-text-color);\n opacity: 0.6;\n border: none;\n border-bottom: 2px solid transparent;\n background: none;\n transition: opacity 0.15s;\n }\n\n .shared-tab:hover {\n opacity: 0.85;\n }\n\n .shared-tab.active {\n opacity: 1;\n border-bottom-color: var(--span-accent);\n }\n\n /* ── List view search ──────────────────────────────────── */\n\n .list-search-container {\n margin-bottom: 12px;\n position: relative;\n }\n\n .list-search {\n width: 100%;\n padding: 8px 36px 8px 12px;\n border-radius: 8px;\n border: 1px solid var(--divider-color, #333);\n background: var(--secondary-background-color, #2a2a2a);\n color: var(--primary-text-color);\n font-size: 0.9em;\n box-sizing: border-box;\n outline: none;\n }\n\n .list-search:focus {\n border-color: var(--span-accent);\n }\n\n .list-search-clear {\n position: absolute;\n right: 8px;\n top: 50%;\n transform: translateY(-50%);\n background: none;\n border: none;\n color: var(--secondary-text-color);\n cursor: pointer;\n padding: 2px;\n display: flex;\n align-items: center;\n opacity: 0.7;\n }\n\n .list-search-clear:hover {\n opacity: 1;\n }\n\n .list-unit-toggle {\n display: inline-flex;\n margin-bottom: 12px;\n }\n\n /* ── List rows ─────────────────────────────────────────── */\n\n .list-view {\n display: flex;\n flex-direction: column;\n gap: 6px;\n }\n /* Each circuit is wrapped in a .list-cell so the row + its optional\n expanded chart stay together. In single-column flex mode the cell\n just stacks naturally. In multi-column grid mode the cell becomes\n one grid item, so the chart is always in the same column as its\n row. Area headers (rendered as siblings, not inside a cell) span\n all columns via their inline "grid-column: 1 / -1". */\n .list-cell {\n display: flex;\n flex-direction: column;\n min-width: 0;\n }\n .list-view[data-columns="2"],\n .list-view[data-columns="3"] {\n display: grid;\n grid-template-columns: repeat(var(--list-cols), minmax(0, 1fr));\n gap: 6px 8px;\n flex-direction: initial;\n }\n /* On narrow viewports a 2/3-column list would squeeze rows into an\n unreadable shape, so force stacking regardless of user preference. */\n @media (max-width: 599px) {\n .list-view[data-columns="2"],\n .list-view[data-columns="3"] {\n display: flex;\n flex-direction: column;\n }\n }\n\n .list-row {\n display: flex;\n align-items: center;\n padding: 12px 16px;\n gap: 10px;\n /* min-width: 0 lets the row shrink below the sum of its\n non-shrinking children when its parent .list-cell is in a\n narrow CSS-grid track (multi-column list mode). Without this\n the row would maintain its intrinsic min-content width and\n overflow the cell, leaving the name unshrunk and the\n truncation-fold observer with no signal to react to. */\n min-width: 0;\n background: var(--card-background-color, #1c1c1c);\n border: 1px solid var(--divider-color, #333);\n border-radius: 8px;\n cursor: pointer;\n transition: background 0.15s;\n }\n\n .list-row:hover {\n background: var(--secondary-background-color, #2a2a2a);\n }\n\n .list-row.circuit-off {\n opacity: 0.5;\n }\n\n .list-row.list-row-expanded {\n border-bottom-left-radius: 0;\n border-bottom-right-radius: 0;\n border-bottom-color: transparent;\n }\n\n .list-circuit-name {\n flex: 1;\n color: var(--primary-text-color);\n font-size: 0.9em;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .list-status-badge {\n font-size: 0.75em;\n font-weight: 600;\n padding: 2px 8px;\n border-radius: 4px;\n flex-shrink: 0;\n }\n\n .list-status-on {\n color: #4dd9af;\n }\n\n .list-status-off {\n color: #f44336;\n }\n\n .list-power-value {\n font-size: 0.9em;\n font-weight: 600;\n flex-shrink: 0;\n /* No min-width / text-align:right: the old 70px right-aligned\n cell left a visible blank column for short readings (e.g.\n "1.3A" in a 70px slot), which robbed horizontal space from\n .list-circuit-name on narrow rows. Let the value hug the\n preceding relay control and size to its content so the freed\n width flows back into the flex:1 name column. */\n }\n\n .list-expand-toggle {\n background: none;\n border: none;\n color: var(--secondary-text-color);\n cursor: pointer;\n padding: 4px;\n transition: transform 0.2s;\n display: flex;\n align-items: center;\n flex-shrink: 0;\n }\n\n .list-expand-toggle.expanded {\n transform: rotate(180deg);\n }\n\n .list-row .gear-icon {\n background: transparent;\n border: none;\n padding: 2px;\n cursor: pointer;\n color: #555;\n display: inline-flex;\n align-items: center;\n }\n .list-row .gear-icon:hover {\n color: var(--primary-text-color);\n }\n\n /* Truncation-driven fold for list rows. The .is-folded class is\n added/removed by the JS observer in src/core/truncation-fold.ts\n when the .list-circuit-name actually ellipsizes — pixel breakpoints\n can\'t track this because name length varies wildly per circuit\n ("Spa" vs "Commissioned PV System") and any single threshold\n misfires for the other end of the range. Switch to a two-row grid\n so the name gets the full width (paired only with the expand\n chevron) and the badges/controls/reading/gear drop to a secondary\n row underneath. Named areas keep the CSS readable despite the flat\n HTML child order. */\n .list-row.is-folded {\n display: grid;\n /* Row 1: name spans the row up to the chevron at the trailing\n column. Row 2: badge + util + shed + relay-toggle pack left,\n the 1fr column absorbs slack between the relay and the power\n reading, power + gear stay pinned to the right edge. The\n earlier layout placed the slack between the shedding icon and\n the relay, which the user flagged as wasted padding. */\n grid-template-columns: auto auto auto auto 1fr auto auto;\n grid-template-areas:\n "name name name name name name chevron"\n "badge util shed status . power gear";\n row-gap: 6px;\n column-gap: 8px;\n }\n .list-row.is-folded > .list-circuit-name {\n grid-area: name;\n justify-self: start;\n }\n .list-row.is-folded > .list-expand-toggle {\n grid-area: chevron;\n }\n .list-row.is-folded > .breaker-badge {\n grid-area: badge;\n }\n .list-row.is-folded > .utilization {\n grid-area: util;\n }\n .list-row.is-folded > .shedding-icon,\n .list-row.is-folded > .shedding-composite {\n grid-area: shed;\n }\n .list-row.is-folded > .toggle-pill,\n .list-row.is-folded > .list-status-badge {\n grid-area: status;\n }\n .list-row.is-folded > .list-power-value {\n grid-area: power;\n justify-self: end;\n }\n .list-row.is-folded > .gear-icon.circuit-gear {\n grid-area: gear;\n justify-self: end;\n }\n\n /* ── Expanded circuit content ──────────────────────────── */\n\n .list-expanded-content {\n padding: 0;\n background: var(--card-background-color, #1c1c1c);\n border: 1px solid var(--divider-color, #333);\n border-top: none;\n border-radius: 0 0 8px 8px;\n margin-top: -6px;\n margin-bottom: 2px;\n }\n\n .circuit-slot.circuit-chart-only {\n border: none;\n margin: 0;\n background: none;\n padding: 8px 12px;\n min-height: 0;\n }\n\n /* ── Area headers ──────────────────────────────────────── */\n\n .area-header {\n padding: 16px 12px 6px;\n font-weight: 600;\n font-size: 0.85em;\n color: var(--secondary-text-color);\n text-transform: uppercase;\n letter-spacing: 0.05em;\n }\n\n /* ── No results ────────────────────────────────────────── */\n\n .list-no-results {\n padding: 24px;\n text-align: center;\n color: var(--secondary-text-color);\n }\n\n';class hT{constructor(){this._ctrl=new lT,this._container=null,this._onGearClick=null,this._onToggleClick=null,this._onSidePanelClosed=null,this._onGraphSettingsChanged=null,this._foldUnobserve=null}get hass(){return this._ctrl.hass}set hass(t){this._ctrl.hass=t}set errorStore(t){this._ctrl.errorStore=t}setPanelFavorites(t){this._ctrl.setPanelFavorites(t)}async render(t,e,i,r,o){let a,s;this.stop(),this._ctrl.reset(),this._ctrl.showMonitoring=!0,this._container=t,this._ctrl.hass=e;try{const t=await se(e,i);a=t.topology,s=t.panelSize}catch(e){return void(t.innerHTML=`

${Bt(e.message)}

`)}this._ctrl.init(a,r,e,o??null),await this._ctrl.monitoringCache.fetch(e,o??null),await this._ctrl.fetchAndBuildHorizonMaps();const l=Math.ceil(s/2),c=this._ctrl.monitoringCache.status,u=ue(a,r),h=function(t){if(!t)return"";const e=Object.values(t.circuits??{}),i=Object.values(t.mains??{}),r=[...e,...i],o=r.filter(t=>void 0!==t.utilization_pct&&t.utilization_pct>=80&&t.utilization_pct<100).length,a=r.filter(t=>void 0!==t.utilization_pct&&t.utilization_pct>=100).length,s=r.filter(t=>t.has_override).length;return`\n
\n ✓ ${n("status.monitoring")} · ${e.length} ${n("status.circuits")} · ${i.length} ${n("status.mains")}\n \n ${o>0?`${o} ${n(o>1?"status.warnings":"status.warning")}`:""}\n ${a>0?`${a} ${n(a>1?"status.alerts":"status.alert")}`:""}\n ${s>0?`${s} ${n(s>1?"status.overrides":"status.override")}`:""}\n \n
\n `}(c),d=function(t,e,n,i,r){const o=new Map,a=new Set;for(const[e,n]of Object.entries(t.circuits)){const t=n.tabs;if(!t||0===t.length)continue;const i=Math.min(...t),r=1===t.length?"single":me(t)??"single";o.set(i,{uuid:e,circuit:n,layout:r});for(const e of t)a.add(e)}const s=new Set,l=new Set;for(const[t,e]of o)if("col-span"===e.layout){const n=e.circuit.tabs,i=ge(Math.max(...n));0===ve(t)?s.add(i):l.add(i)}function c(t){const e=t.circuit.entities?.current??t.circuit.entities?.power,i=r?we(r,e??""):null;let o;if(t.circuit.always_on)o="always_on";else{const e=t.circuit.entities?.select;o=e&&n.states[e]?n.states[e].state:"unknown"}return{monInfo:i,sheddingPriority:o}}let u="";for(let t=1;t<=e;t++){const e=2*t-1,r=2*t,h=o.get(e),d=o.get(r);if(u+=`
${e}
`,h&&"row-span"===h.layout){const{monInfo:e,sheddingPriority:o}=c(h);u+=Ce(h.uuid,h.circuit,t,"2 / 5","row-span",n,i,e,o),u+=`
${r}
`;continue}if(!s.has(t))if(!h||"col-span"!==h.layout&&"single"!==h.layout)a.has(e)||(u+=ke(t,"2"));else{const{monInfo:e,sheddingPriority:r}=c(h);u+=Ce(h.uuid,h.circuit,t,"2",h.layout,n,i,e,r)}if(!l.has(t))if(!d||"col-span"!==d.layout&&"single"!==d.layout)a.has(r)||(u+=ke(t,"4"));else{const{monInfo:e,sheddingPriority:r}=c(d);u+=Ce(d.uuid,d.circuit,t,"4",d.layout,n,i,e,r)}u+=`
${r}
`}return u}(a,l,e,r,c),p=Ne(a,e,r);t.innerHTML=`\n \n ${u}\n ${h}\n ${p?`
${p}
`:""}\n ${!1!==r.show_panel?`\n
\n ${d}\n
\n `:""}\n \n `,this._onGearClick=e=>{this._ctrl.onGearClick(e,t)},this._onToggleClick=e=>{this._ctrl.onToggleClick(e,t)},t.addEventListener("click",this._onGearClick),t.addEventListener("click",this._onToggleClick),this._onSidePanelClosed=()=>{this._ctrl.monitoringCache.invalidate(),this._ctrl.graphSettingsCache.invalidate()},t.addEventListener("side-panel-closed",this._onSidePanelClosed),this._onGraphSettingsChanged=()=>this._ctrl.onGraphSettingsChanged(t),t.addEventListener("graph-settings-changed",this._onGraphSettingsChanged);try{await this._ctrl.loadHistory()}catch{}this._ctrl.updateDOM(t);const f=t.querySelector(".slide-confirm");f&&(this._ctrl.bindSlideConfirm(f,t),t.classList.add("switches-disabled")),this._ctrl.setupResizeObserver(t,t),this._ctrl.startIntervals(t),this._foldUnobserve&&this._foldUnobserve(),this._foldUnobserve=cT(t,{rowSelector:".circuit-slot",nameSelector:".circuit-name",foldClass:"is-folded"})}stop(){this._ctrl.stopIntervals(),this._foldUnobserve&&(this._foldUnobserve(),this._foldUnobserve=null),this._container&&(this._onGearClick&&(this._container.removeEventListener("click",this._onGearClick),this._onGearClick=null),this._onToggleClick&&(this._container.removeEventListener("click",this._onToggleClick),this._onToggleClick=null),this._onSidePanelClosed&&(this._container.removeEventListener("side-panel-closed",this._onSidePanelClosed),this._onSidePanelClosed=null),this._onGraphSettingsChanged&&(this._container.removeEventListener("graph-settings-changed",this._onGraphSettingsChanged),this._onGraphSettingsChanged=null))}}const dT="\n display:flex;align-items:center;gap:8px;margin-bottom:8px;\n",pT="\n background:var(--secondary-background-color,#333);\n border:1px solid var(--divider-color);\n color:var(--primary-text-color);\n border-radius:4px;padding:6px 10px;width:80px;font-size:0.85em;\n",fT="\n min-width:130px;font-size:0.85em;color:var(--secondary-text-color);\n",gT="\n min-width:160px;font-size:0.85em;color:var(--secondary-text-color);\n",vT="\n background:var(--secondary-background-color,#333);\n border:1px solid var(--divider-color);\n color:var(--primary-text-color);\n border-radius:4px;padding:6px 10px;flex:1;font-size:0.85em;\n font-family:monospace;\n";function mT(t,e,n,i,r){return`\n ${i}\n `}class yT{constructor(){this.errorStore=null,this._debounceTimer=null,this._configEntryId=null,this._notifyCloseHandler=null,this._headerHTML=""}stop(){this._notifyCloseHandler&&(document.removeEventListener("click",this._notifyCloseHandler),this._notifyCloseHandler=null),this._debounceTimer&&(clearTimeout(this._debounceTimer),this._debounceTimer=null)}async render(t,e,i,r=""){let o;void 0!==i&&(this._configEntryId=i),this._headerHTML=r,this._notifyCloseHandler&&(document.removeEventListener("click",this._notifyCloseHandler),this._notifyCloseHandler=null);try{const t={};this._configEntryId&&(t.config_entry_id=this._configEntryId);const n=await e.callWS({type:"call_service",domain:s,service:"get_monitoring_status",service_data:t,return_response:!0});o=function(t){if(!t||"object"!=typeof t)return null;const e=t,n={};return"boolean"==typeof e.enabled&&(n.enabled=e.enabled),e.global_settings&&"object"==typeof e.global_settings&&(n.global_settings=e.global_settings),e.circuits&&"object"==typeof e.circuits&&(n.circuits=e.circuits),e.mains&&"object"==typeof e.mains&&(n.mains=e.mains),n}(n?.response)}catch(t){console.warn("SPAN Panel: monitoring status fetch failed",t),o=null}const a=o?.global_settings??{},l=!0===o?.enabled,c=o?.circuits??{},u=o?.mains??{},h=new Set;for(const t of Object.keys(e.states||{}))t.startsWith("notify.")&&h.add(t);const d=new Set(["notify","send_message"]);for(const t of Object.keys(e.services?.notify||{}))d.has(t)||h.add(`notify.${t}`);h.add("event_bus");const p=[...h].sort(),f=a.notify_targets??"",g=("string"==typeof f?f.split(","):f).map(t=>t.trim()).filter(Boolean),v=p.length>0&&p.every(t=>g.includes(t)),m=a.notification_title_template??"SPAN: {name} {alert_type}",y=a.notification_message_template??"{name} at {current_a}A ({utilization_pct}% of {breaker_rating_a}A rating)",_=a.notification_priority??"default",b=Object.entries(c).sort(([,t],[,e])=>(t.name??"").localeCompare(e.name??"")),x=Object.entries(u),w=[...b,...x],S=w.length>0&&w.every(([,t])=>!1!==t.monitoring_enabled),C=w.some(([,t])=>!1!==t.monitoring_enabled),k=b.map(([t,e])=>{const i=Bt(e.name??t),r=!1!==e.monitoring_enabled,o=!0===e.has_override,a=r?"":"opacity:0.4;",s=Bt(t);return`\n \n \n \n \n ${mT(s,"continuous_threshold_pct",e.continuous_threshold_pct,"%","circuit")}\n ${mT(s,"spike_threshold_pct",e.spike_threshold_pct,"%","circuit")}\n ${mT(s,"window_duration_m",e.window_duration_m,"m","circuit")}\n ${mT(s,"cooldown_duration_m",e.cooldown_duration_m,"m","circuit")}\n \n ${o?``:""}\n \n \n `}).join(""),M=Object.entries(u).map(([t,e])=>{const i=Bt(e.name??t),r=!1!==e.monitoring_enabled,o=!0===e.has_override,a=r?"":"opacity:0.4;",s=Bt(t);return`\n \n \n \n \n ${mT(s,"continuous_threshold_pct",e.continuous_threshold_pct,"%","mains")}\n ${mT(s,"spike_threshold_pct",e.spike_threshold_pct,"%","mains")}\n ${mT(s,"window_duration_m",e.window_duration_m,"m","mains")}\n ${mT(s,"cooldown_duration_m",e.cooldown_duration_m,"m","mains")}\n \n ${o?``:""}\n \n \n `}).join("");t.innerHTML=`\n ${this._headerHTML}\n
\n

${n("monitoring.heading")}

\n\n
\n
\n

${n("monitoring.global_settings")}

\n \n
\n\n
\n
\n ${n("monitoring.continuous")}\n \n
\n
\n ${n("monitoring.spike")}\n \n
\n
\n ${n("monitoring.window")}\n \n
\n
\n ${n("monitoring.cooldown")}\n \n
\n\n
\n

${n("notification.heading")}

\n\n
\n ${n("notification.targets")}\n \n
\n \n
\n ${0===p.length?`
${n("notification.no_targets")}
`:p.map(t=>{const i=g.includes(t),r="event_bus"===t,o=r?null:e.states[t],a=o?.attributes?.friendly_name,s=r?n("notification.event_bus_target"):a?`${Bt(a)} (${Bt(t)})`:Bt(t);return``}).join("")}\n
\n
\n
\n\n
\n ${n("notification.priority")}\n \n \n ${"critical"===_?n("notification.hint.critical"):"time-sensitive"===_?n("notification.hint.time_sensitive"):"passive"===_?n("notification.hint.passive"):"active"===_?n("notification.hint.active"):""}\n \n
\n\n
\n ${n("notification.title_template")}\n \n
\n\n
\n ${n("notification.message_template")}\n \n
\n\n
\n ${n("notification.placeholders")} {name} {entity_id} {alert_type}\n {current_a} {breaker_rating_a} {threshold_pct}\n {utilization_pct} {window_m} {local_time}\n
\n
\n ${n("notification.event_bus_help")} span_panel_current_alert\n ${n("notification.event_bus_payload")} alert_source alert_id\n alert_name alert_type current_a\n breaker_rating_a threshold_pct utilization_pct\n panel_serial window_duration_s local_time\n
\n\n
\n ${n("notification.test_label")}\n \n \n
\n
\n\n
\n
\n\n

${n("monitoring.monitored_points")}

\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n ${M}\n ${k}\n \n
${n("monitoring.col.name")}${n("monitoring.col.continuous")}${n("monitoring.col.spike")}${n("monitoring.col.window")}${n("monitoring.col.cooldown")}
\n \n
\n
\n `;const T=t.querySelector("#toggle-all-circuits");T&&!S&&C&&(T.indeterminate=!0);const I=t.querySelector("#notify-all-targets");if(I&&p.length>0){const t=g.length>0;!v&&t&&(I.indeterminate=!0)}this._bindGlobalControls(t,e),this._bindNotifyTargetSelect(t,e),this._bindNotificationSettings(t,e),this._bindToggleAll(t,e,c,u),this._bindCircuitToggles(t,e),this._bindMainsToggles(t,e),this._bindThresholdInputs(t,e),this._bindResetButtons(t,e)}_serviceData(t){return this._configEntryId&&(t.config_entry_id=this._configEntryId),t}_callSetGlobal(t,e){return t.callWS({type:"call_service",domain:s,service:"set_global_monitoring",service_data:this._serviceData({...e})})}_bindGlobalControls(t,e){const i=t.querySelector("#monitoring-enabled"),r=t.querySelector("#global-fields"),o=t.querySelector("#global-status"),a=()=>{const e=[["continuous_threshold_pct","#g-continuous"],["spike_threshold_pct","#g-spike"],["window_duration_m","#g-window"],["cooldown_duration_m","#g-cooldown"]],n={};for(const[i,r]of e){const e=t.querySelector(r);if(!e)return null;const o=parseInt(e.value,10);if(Number.isNaN(o))return null;n[i]=o}return n},s=(t,e,i)=>{if(!t)return;const r=e instanceof Error?e.message:i;t.textContent=`${n("error.prefix")} ${r}`,t.style.color="var(--error-color, #f44336)"},l=()=>{this._debounceTimer&&clearTimeout(this._debounceTimer),this._debounceTimer=setTimeout(async()=>{const i=a();if(i)try{await this._callSetGlobal(e,i),await this.render(t,e)}catch(t){s(o,t,n("error.failed_save"))}else s(o,null,n("error.failed_save"))},p)};i&&i.addEventListener("change",async()=>{const o=i.checked;r&&(r.style.opacity=o?"":"0.4",r.style.pointerEvents=o?"":"none");const l=t.querySelector("#global-status");try{if(o){const t=a();if(!t)return void s(l,null,n("error.failed"));await this._callSetGlobal(e,t)}else await this._callSetGlobal(e,{enabled:!1})}catch(t){return void s(l,t,n("error.failed"))}await this.render(t,e)});for(const e of t.querySelectorAll("#global-fields input[type=number]"))e.addEventListener("input",l)}_bindNotifyTargetSelect(t,e){const i=t.querySelector("#notify-target-btn"),r=t.querySelector("#notify-target-dropdown"),o=t.querySelector("#notify-target-label");if(!i||!r)return;i.addEventListener("click",t=>{t.stopPropagation();const e="none"!==r.style.display;r.style.display=e?"none":"block"});const a=e=>{const n=t.querySelector("#notify-target-select");n&&!n.contains(e.target)&&(r.style.display="none")};document.addEventListener("click",a),this._notifyCloseHandler=a;const s=()=>{const i=[...t.querySelectorAll(".notify-target-cb:checked")].map(t=>t.value);if(o){const t=i.map(t=>"event_bus"===t?n("notification.event_bus_target"):t);o.textContent=t.length?t.join(", "):n("notification.none_selected")}const r=t.querySelector("#notify-all-targets");if(r){const e=[...t.querySelectorAll(".notify-target-cb")];r.checked=e.length>0&&e.every(t=>t.checked),r.indeterminate=!r.checked&&e.some(t=>t.checked)}this._debounceTimer&&clearTimeout(this._debounceTimer),this._debounceTimer=setTimeout(async()=>{try{await this._callSetGlobal(e,{notify_targets:i.join(", ")})}catch(t){console.warn("SPAN Panel: notification targets save failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})}},p)},l=t.querySelector("#notify-all-targets");l&&l.addEventListener("change",()=>{for(const e of t.querySelectorAll(".notify-target-cb"))e.checked=l.checked;const e=t.querySelector("#notify-target-btn");e&&(e.style.opacity=l.checked?"0.4":"",e.style.pointerEvents=l.checked?"none":""),l.checked&&(r.style.display="none"),s()});for(const e of t.querySelectorAll(".notify-target-cb"))e.addEventListener("change",()=>{s()})}_bindNotificationSettings(t,e){const i=t.querySelector("#g-priority"),r=t.querySelector("#g-title-template"),o=t.querySelector("#g-message-template"),a=(t,i)=>{this._debounceTimer&&clearTimeout(this._debounceTimer),this._debounceTimer=setTimeout(async()=>{try{await this._callSetGlobal(e,{[t]:i})}catch(t){console.warn("SPAN Panel: notification settings save failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})}},p)};i&&i.addEventListener("change",async()=>{try{await this._callSetGlobal(e,{notification_priority:i.value}),await this.render(t,e)}catch(t){console.warn("SPAN Panel: notification priority change failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})}}),r&&r.addEventListener("input",()=>{a("notification_title_template",r.value)}),o&&o.addEventListener("input",()=>{a("notification_message_template",o.value)});const l=t.querySelector("#test-notification-btn"),c=t.querySelector("#test-notification-status");l&&l.addEventListener("click",async()=>{l.disabled=!0,c&&(c.textContent=n("notification.test_sending"),c.style.color="var(--secondary-text-color)");try{this._debounceTimer&&(clearTimeout(this._debounceTimer),this._debounceTimer=null);const i=[...t.querySelectorAll(".notify-target-cb:checked")].map(t=>t.value).join(", ");await this._callSetGlobal(e,{notify_targets:i});const r={};this._configEntryId&&(r.config_entry_id=this._configEntryId),await e.callWS({type:"call_service",domain:s,service:"test_notification",service_data:r}),c&&(c.textContent=n("notification.test_sent"),c.style.color="var(--success-color, #4caf50)")}catch(t){if(c){const e=t instanceof Error?t.message:n("error.failed");c.textContent=`${n("error.prefix")} ${e}`,c.style.color="var(--error-color, #f44336)"}}finally{l.disabled=!1}})}_bindToggleAll(t,e,i,r){const o=t.querySelector("#toggle-all-circuits");o&&o.addEventListener("change",async()=>{const a=o.checked,l=[...Object.keys(i).map(t=>e.callWS({type:"call_service",domain:s,service:"set_circuit_threshold",service_data:this._serviceData({circuit_id:t,monitoring_enabled:a})}).catch(t=>{console.warn("SPAN Panel: circuit monitoring toggle failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})})),...Object.keys(r).map(t=>e.callWS({type:"call_service",domain:s,service:"set_mains_threshold",service_data:this._serviceData({leg:t,monitoring_enabled:a})}).catch(t=>{console.warn("SPAN Panel: mains monitoring toggle failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})}))];await Promise.all(l),await this.render(t,e)})}_bindMainsToggles(t,e){for(const i of t.querySelectorAll(".mains-toggle"))i.addEventListener("change",async()=>{const r=i.dataset.entity,o=i.checked;try{await e.callWS({type:"call_service",domain:s,service:"set_mains_threshold",service_data:this._serviceData({leg:r,monitoring_enabled:o})})}catch(t){return console.warn("SPAN Panel: mains threshold toggle failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1}),void(i.checked=!o)}await this.render(t,e)})}_bindCircuitToggles(t,e){for(const i of t.querySelectorAll(".circuit-toggle"))i.addEventListener("change",async()=>{const r=i.dataset.entity,o=i.checked;try{await e.callWS({type:"call_service",domain:s,service:"set_circuit_threshold",service_data:this._serviceData({circuit_id:r,monitoring_enabled:o})})}catch(t){return console.warn("SPAN Panel: circuit threshold toggle failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1}),void(i.checked=!o)}await this.render(t,e)})}_bindThresholdInputs(t,e){const i=new Map;for(const r of t.querySelectorAll(".threshold-input"))r.addEventListener("input",()=>{const o=`${r.dataset.entity}-${r.dataset.field}`,a=i.get(o);a&&clearTimeout(a),i.set(o,setTimeout(async()=>{const i=parseInt(r.value,10);if(!i||i<1)return;const o=r.dataset.entity,a=r.dataset.field,l=r.dataset.type,c="mains"===l?"set_mains_threshold":"set_circuit_threshold",u="mains"===l?"leg":"circuit_id";try{await e.callWS({type:"call_service",domain:s,service:c,service_data:this._serviceData({[u]:o,[a]:i})}),await this.render(t,e)}catch(t){console.warn("SPAN Panel: threshold input save failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1}),r.style.borderColor="var(--error-color, #f44336)"}},800))})}_bindResetButtons(t,e){for(const n of t.querySelectorAll(".reset-btn"))n.addEventListener("click",async()=>{const i=n.dataset.entity;if(!i)return;const r=n.dataset.type,o="mains"===r?"clear_mains_threshold":"clear_circuit_threshold",a=this._serviceData("mains"===r?{leg:i}:{circuit_id:i});await e.callService(s,o,a),await this.render(t,e)})}}function _T(t=""){const e=t?` value="${Bt(t)}"`:"",i=t?"":"display:none;";return`\n
\n \n \n
\n `}function bT(t,e,i,r,o,a,s){const c=e.entities?.power,u=c?i.states[c]:null,h=u&&parseFloat(u.state)||0,d=e.entities?.switch,p=d?i.states[d]:null,f=p?"on"===p.state:(u?.attributes?.relay_state||e.relay_state)===l,g=e.breaker_rating_a,m=g?`${Math.round(g)}A`:"",y=Bt(e.name||n("grid.unknown")),_=ye(r),b="current"===_.entityRole;let x;if(f)if(b){const t=e.entities?.current,n=t?i.states[t]:null,r=n&&parseFloat(n.state)||0;x=`${_.format(r)}A`}else x=`${pe(h)}${de(h)}`;else x="";const w=a||"unknown";let S="";if("unknown"!==w){const t=v[w]??v.unknown??{icon:"mdi:help",color:"#999",label:()=>"Unknown"};S=t.icon2?`\n \n \n `:t.textLabel?`\n \n ${t.textLabel}\n `:``}let C="",k=o?.utilization_pct??null;if(null==k&&e.breaker_rating_a){const t=e.entities?.current,n=t?i.states[t]:null,r=n?Math.abs(parseFloat(n.state)||0):0;k=Math.round(r/e.breaker_rating_a*1e3)/10}if(null!=k){C=`=80?"utilization-warning":"utilization-normal"}">${Math.round(k)}%`}const M=``,T=!1!==e.is_user_controllable&&!!e.entities?.switch?`
\n ${n(f?"grid.on":"grid.off")}\n \n
`:`${f?"ON":"OFF"}`;return`\n
\n ${m?`${m}`:""}\n ${C}\n ${y}\n ${S}\n ${T}\n \n ${x}\n \n ${M}\n \n
\n `}function xT(t,e,n,i,r){const o=e.entities?.power,a=o?n.states[o]:null,s=a&&parseFloat(a.state)||0,u=e.device_type===c||s<0,h=e.entities?.switch,d=h?n.states[h]:null,p=Se(0,r,d?"on"===d.state:(a?.attributes?.relay_state||e.relay_state)===l,u),f=Bt(t);return`\n
\n
\n
\n
\n
\n `}function wT(t){return`
${Bt(t)}
`}function ST(t,e,n){const i=t.entities?.switch,r=i?e.states[i]:null,o=t.entities?.power,a=o?e.states[o]:null,s=r?"on"===r.state:(a?.attributes?.relay_state||t.relay_state)===l;let c;if("current"===(n.chart_metric||"power")){const n=t.entities?.current,i=n?e.states[n]:null;c=i?Math.abs(parseFloat(i.state)||0):0}else c=a?Math.abs(parseFloat(a.state)||0):0;return{isOn:s,value:c}}function CT(t,e){if(t.always_on)return"always_on";const n=t.entities?.select,i=n?e.states[n]:null;return i?i.state:"unknown"}function kT(t,e,n,i){const r=ST(t,n,i),o=ST(e,n,i);return r.isOn&&!o.isOn?-1:!r.isOn&&o.isOn?1:o.value-r.value}function MT(t,e,n){return t.sort((t,i)=>kT(t[1],i[1],e,n))}function TT(t){return t.entities?.current??t.entities?.power??""}class IT{constructor(t){this._expandedUuids=new Set,this._searchQuery="",this._container=null,this._clickHandler=null,this._inputHandler=null,this._graphSettingsHandler=null,this._hass=null,this._topology=null,this._config=null,this._monitoringStatus=null,this._viewName=null,this._columns=1,this._foldUnobserve=null,this._ctrl=t}setColumns(t){const e=Math.max(1,Math.min(3,Math.floor(t)));this._columns=e}setInitialExpansion(t){this._expandedUuids=new Set(t)}setInitialSearchQuery(t){this._searchQuery=t}setViewName(t){this._viewName=t}renderActivityView(t,e,n,i,r,o){this._unbindEvents(),this._hass=e,this._topology=n,this._config=i,this._monitoringStatus=r;const a=MT(Object.entries(n.circuits),e,i);let s=o+_T(this._searchQuery);s+=`
`;for(const[t,n]of a){const o=we(r,TT(n)),a=CT(n,e),l=this._expandedUuids.has(t);s+=`
`,s+=bT(t,n,e,i,o,a,l),l&&(s+=xT(t,n,e,0,o)),s+="
"}s+="
",s+="",t.innerHTML=s;const l=t.querySelector("span-side-panel");l&&(l.hass=e,l.errorStore=this._ctrl.errorStore),this._bindEvents(t),this._searchQuery&&this._applyFilter(t),this._ctrl.updateDOM(t),this._attachFoldObserver(t)}renderAreaView(t,e,i,r,o,a){this._unbindEvents(),this._hass=e,this._topology=i,this._config=r,this._monitoringStatus=o;const s=n("list.unassigned_area"),l=new Map;for(const[t,e]of Object.entries(i.circuits)){const n=e.area??s,i=l.get(n);i?i.push([t,e]):l.set(n,[[t,e]])}const c=[...l.keys()].sort((t,e)=>t===s?1:e===s?-1:t.localeCompare(e));let u=a+_T(this._searchQuery);u+=`
`;for(const t of c){const n=l.get(t);if(!n)continue;const i=MT(n,e,r);u+=wT(t);for(const[t,n]of i){const i=we(o,TT(n)),a=CT(n,e),s=this._expandedUuids.has(t);u+=`
`,u+=bT(t,n,e,r,i,a,s),s&&(u+=xT(t,n,e,0,i)),u+="
"}}u+="
",u+="",t.innerHTML=u;const h=t.querySelector("span-side-panel");h&&(h.hass=e,h.errorStore=this._ctrl.errorStore),this._bindEvents(t),this._searchQuery&&this._applyFilter(t),this._ctrl.updateDOM(t),this._attachFoldObserver(t)}updateCollapsedRows(t,e,i,r){const o=ye(r),a="current"===o.entityRole,s=t.querySelectorAll(".list-row[data-row-uuid]");for(const t of s){const s=t.dataset.rowUuid;if(!s)continue;const l=i.circuits[s];if(!l)continue;const{isOn:c,value:u}=ST(l,e,r),h=t.querySelector(".list-power-value");if(h)if(c)if(a)h.innerHTML=`${o.format(u)}A`;else{const t=l.entities?.power,n=t?e.states[t]:null,i=n&&parseFloat(n.state)||0;h.innerHTML=`${pe(i)}${de(i)}`}else h.innerHTML="";const d=t.querySelector(".toggle-pill");if(d){d.classList.toggle("toggle-on",c),d.classList.toggle("toggle-off",!c);const t=d.querySelector(".toggle-label");t&&(t.textContent=n(c?"grid.on":"grid.off"))}const p=t.querySelector(".list-status-badge");p&&(p.textContent=c?"ON":"OFF",p.classList.toggle("list-status-on",c),p.classList.toggle("list-status-off",!c)),t.classList.toggle("circuit-off",!c)}!function(t,e,n,i){const r=t.querySelector(".list-view");if(r)for(const t of function(t,e){let n={anchor:null,units:[]};const i=[n];for(const r of[...t.children])if(r.classList.contains("area-header"))n={anchor:r,units:[]},i.push(n);else if(r.classList.contains("list-cell")){const t=r.dataset.cellUuid,i=t?e.circuits[t]:void 0;t&&i&&n.units.push({cell:r,uuid:t,circuit:i})}return i}(r,n)){if(t.units.length<2)continue;const n=[...t.units].sort((t,n)=>kT(t.circuit,n.circuit,e,i));if(!n.some((e,n)=>e.uuid!==t.units[n].uuid))continue;let o=t.anchor;for(const t of n)o?o.after(t.cell):r.prepend(t.cell),o=t.cell}}(t,e,i,r)}stop(){this._unbindEvents(),null===this._viewName&&(this._expandedUuids.clear(),this._searchQuery=""),this._hass=null,this._topology=null,this._config=null,this._monitoringStatus=null}_dispatchFavoritesViewState(){if(!this._viewName||!this._container)return;const t={view:this._viewName,expanded:[...this._expandedUuids],searchQuery:this._searchQuery};this._container.dispatchEvent(new CustomEvent("favorites-view-state-changed",{detail:t,bubbles:!0,composed:!0}))}_bindEvents(t){this._container=t,this._clickHandler=e=>{const n=e.target;if(!n)return;const i=n.closest(".list-expand-toggle");if(i){const t=i.dataset.expandUuid;return void(t&&this._toggleExpand(t))}if(n.closest(".gear-icon"))return void this._ctrl.onGearClick(e,t);if(n.closest(".toggle-pill"))return void this._ctrl.onToggleClick(e,t);if(n.closest(".list-search-clear")){const e=t.querySelector(".list-search");return void(e&&(e.value="",e.dispatchEvent(new Event("input",{bubbles:!0}))))}const r=n.closest(".unit-btn");if(r){const e=r.dataset.unit;e&&t.dispatchEvent(new CustomEvent("unit-changed",{detail:e,bubbles:!0,composed:!0}))}},this._inputHandler=e=>{const n=e.target;n&&n.classList.contains("list-search")&&(this._searchQuery=n.value.toLowerCase(),this._applyFilter(t),this._dispatchFavoritesViewState())},this._graphSettingsHandler=()=>{this._ctrl.onGraphSettingsChanged(t).then(()=>{this._ctrl.updateDOM(t)}).catch(()=>{})},t.addEventListener("click",this._clickHandler),t.addEventListener("input",this._inputHandler),t.addEventListener("graph-settings-changed",this._graphSettingsHandler);const e=t.querySelector(".slide-confirm");e&&(this._ctrl.bindSlideConfirm(e,t),t.classList.add("switches-disabled"))}_unbindEvents(){this._container&&(this._clickHandler&&this._container.removeEventListener("click",this._clickHandler),this._inputHandler&&this._container.removeEventListener("input",this._inputHandler),this._graphSettingsHandler&&this._container.removeEventListener("graph-settings-changed",this._graphSettingsHandler)),this._foldUnobserve&&(this._foldUnobserve(),this._foldUnobserve=null),this._container=null,this._clickHandler=null,this._inputHandler=null,this._graphSettingsHandler=null}_attachFoldObserver(t){this._foldUnobserve&&(this._foldUnobserve(),this._foldUnobserve=null),this._foldUnobserve=cT(t,{rowSelector:".list-row",nameSelector:".list-circuit-name",foldClass:"is-folded"})}_applyFilter(t){const e=t.querySelector(".list-search-clear");e&&(e.style.display=this._searchQuery?"":"none");const n=t.querySelectorAll(".list-cell[data-cell-uuid]");for(const t of n){const e=t.querySelector(".list-circuit-name"),n=(e?.textContent?.toLowerCase()??"").includes(this._searchQuery);t.style.display=n?"":"none"}const i=t.querySelectorAll(".area-header");for(const t of i){let e=!1,n=t.nextElementSibling;for(;n&&!n.classList.contains("area-header");){if(n.classList.contains("list-cell")&&"none"!==n.style.display){e=!0;break}n=n.nextElementSibling}t.style.display=e?"":"none"}}_toggleExpand(t){if(!(this._container&&this._hass&&this._topology&&this._config))return;const e=nT(t),n=this._container.querySelector(`.list-cell[data-cell-uuid="${e}"]`);if(!n)return;const i=n.querySelector(`.list-row[data-row-uuid="${e}"]`),r=n.querySelector(`.list-expand-toggle[data-expand-uuid="${e}"]`);if(i){if(this._expandedUuids.has(t)){this._expandedUuids.delete(t);const o=n.querySelector(`.list-expanded-content[data-expanded-uuid="${e}"]`);o&&o.remove(),r&&r.classList.remove("expanded"),i.classList.remove("list-row-expanded")}else{this._expandedUuids.add(t);const e=this._topology.circuits[t];if(!e)return;const n=we(this._monitoringStatus,TT(e)),o=xT(t,e,this._hass,this._config,n);i.insertAdjacentHTML("afterend",o),r&&r.classList.add("expanded"),i.classList.add("list-row-expanded"),this._ctrl.updateDOM(this._container)}this._dispatchFavoritesViewState()}}}function DT(t,e){return`${t}|${e}`}class AT{async build(t,e,n,i){const r=new Map;for(const t of n)r.set(t.id,t);const o=i?new qt(i):null,a=[];for(const[n,i]of Object.entries(e)){if(!((i?.circuits?.length??0)>0||(i?.sub_devices?.length??0)>0))continue;const e=r.get(n);e&&a.push((async()=>{try{const i=await se(t,n,o);return{panelDeviceId:n,panel:e,topology:i.topology}}catch(t){return console.warn("SPAN Panel: favorites topology fetch failed",n,t),{panelDeviceId:n,panel:e,topology:null}}})())}const s=(await Promise.all(a)).filter(t=>null!==t.topology),l=s.length>1,c={},u={},h={},d=new Set,p=[];for(const{panelDeviceId:t,panel:n,topology:i}of s){if(!i)continue;const r=n.config_entries?.[0]??null;r&&d.add(r);const o=n.name_by_user??n.name??i.device_name??"";p.push({panelDeviceId:t,panelName:o,topology:i});const a=e[t],s=a?.circuits??[],f=a?.sub_devices??[];for(const e of s){const n=i.circuits?.[e];if(!n)continue;const a=DT(t,e),s=l&&o?`${o} · ${n.name}`:n.name;c[a]={...n,name:s},h[a]={panelDeviceId:t,kind:"circuit",targetId:e,configEntryId:r}}for(const e of f){const n=i.sub_devices?.[e];if(!n)continue;const a=DT(t,e),s=l&&o&&n.name?`${o} · ${n.name}`:n.name??e;u[a]={...n,name:s},h[a]={panelDeviceId:t,kind:"sub_device",targetId:e,configEntryId:r}}}return{topology:{circuits:c,sub_devices:u,panel_entities:{},device_name:"",_favoriteRefs:h},entryIds:Array.from(d),perPanelStats:p}}}const PT="span_panel_favorites_view_state";function LT(t){try{localStorage.setItem(PT,JSON.stringify(t))}catch{}}var ET;const zT="favorites";let NT=ET=class extends Dt{constructor(){super(...arguments),this.narrow=!1,this._panels=[],this._selectedPanelId=null,this._activeTab="dashboard",this._discovered=!1,this._listColumns=Wt(),this._favorites={},this._favoritesViewState={expanded:{activity:[],area:[]}},this._favoritesPanelStats=[],this._dashboardTab=new hT,this._monitoringTab=new yT,this._listDashCtrl=new lT,this._listCtrl=new IT(this._listDashCtrl),this._favCache=new Yt,this._favCtrl=new AT,this._favoritesMonitoringTabs=new Map,this._errorStore=new Ht,this._watchedPanelId=null,this._discovering=!1,this._refreshSeq=0,this._areaUnsub=null,this._areaSubscribing=!1,this._onFavoritesChanged=null,this._deviceRegistryUnsub=null,this._pendingTabRender=!1,this._persistFavoritesViewStateTimer=null,this._tabRenderScheduler=function(t){let e=null,n=!1;return async function i(){if(e)return n=!0,void await e.catch(()=>{});const r=(async()=>{try{await t()}finally{e=null,n&&(n=!1,await i())}})();e=r,await r}}(async()=>this._renderTab()),this._beginRender=function(){let t=0;return()=>{t+=1;const e=t;return()=>t!==e}}()}get _root(){const t=this.shadowRoot;if(!t)throw new Error("span-panel: shadow root is not available");return t}connectedCallback(){super.connectedCallback(),this._dashboardTab.errorStore=this._errorStore,this._listDashCtrl.errorStore=this._errorStore,this._favCache.errorStore=this._errorStore,this._monitoringTab.errorStore=this._errorStore,this._onFavoritesChanged=()=>{this._refreshFavorites()},document.addEventListener(jt,this._onFavoritesChanged),this._subscribeDeviceRegistry()}disconnectedCallback(){this._dashboardTab.stop(),this._monitoringTab.stop(),this._listCtrl.stop(),this._listDashCtrl.stopIntervals();for(const t of this._favoritesMonitoringTabs.values())t.stop();this._favoritesMonitoringTabs.clear(),this._areaSubscribing=!1,this._areaUnsub&&(this._areaUnsub(),this._areaUnsub=null),this._onFavoritesChanged&&(document.removeEventListener(jt,this._onFavoritesChanged),this._onFavoritesChanged=null),this._unsubscribeDeviceRegistry(),this._persistFavoritesViewStateTimer&&(clearTimeout(this._persistFavoritesViewStateTimer),this._persistFavoritesViewStateTimer=null),this._errorStore.dispose(),super.disconnectedCallback()}firstUpdated(){this.hass&&!this._discovered&&this._discoverPanels()}updated(t){if(t.has("hass")){const e=t.get("hass");this._dashboardTab.hass=this.hass,this._listDashCtrl.hass=this.hass,this._errorStore.updateHass(this.hass),this._discovered?this._root.getElementById("tab-content")||this._scheduleTabRender():this._discoverPanels(),!e&&this.hass&&this._subscribeDeviceRegistry()}if(this._discovered&&(t.has("_discovered")||t.has("_activeTab")||t.has("_selectedPanelId")||t.has("_chartMetric")||t.has("_listColumns"))){if(this._isFavoritesView&&"dashboard"===this._activeTab)return void(this._activeTab="activity");this._scheduleTabRender()}if(t.has("_selectedPanelId")&&(this._selectedPanelId!==zT&&this._selectedPanelId?(this._updatePanelStatusWatch(),this._listDashCtrl.setFavoritesPerPanelInfo(null)):(this._errorStore.clearPanelStatusWatch(),this._watchedPanelId=null)),this._discovered&&(t.has("_panels")||t.has("_selectedPanelId"))){const t=this.shadowRoot?.getElementById("panel-select");t&&null!==this._selectedPanelId&&t.value!==this._selectedPanelId&&(t.value=this._selectedPanelId)}if(t.has("hass")&&this._discovered&&("activity"===this._activeTab||"area"===this._activeTab)){const t=this._root.getElementById("tab-content"),e=this._listDashCtrl.topology;if(t&&e){this._listCtrl.updateCollapsedRows(t,this.hass,e,this._buildDashboardConfig());const n=t.querySelector("span-side-panel");n&&(n.hass=this.hass,n.errorStore=this._errorStore)}}}setConfig(t){}render(){var i,r,o;if(i=this.hass?.language,t=i&&e[i]?i:"en",!this.hass)return ut` + `,_([Et({attribute:!1})],tT.prototype,"options",void 0),_([Et({attribute:!1})],tT.prototype,"data",void 0),_([Et({type:String})],tT.prototype,"height",void 0);try{customElements.get("span-chart")||customElements.define("span-chart",tT)}catch{}function eT(t,e,n,i,o,a,s,l,c){const{options:d,series:u}=function(t,e,n,i,o,a=!1){n||(n=f[r]);const s=i?"140, 160, 220":"77, 217, 175",l=`rgb(${s})`,c=Date.now(),d=c-e,u=void 0!==n.fixedMin&&void 0!==n.fixedMax,h=(t??[]).filter(t=>t.time>=d).map(t=>[t.time,Math.abs(t.value)]),p=[{type:"line",data:h,showSymbol:!1,smooth:!1,...a?{}:{step:"end"},lineStyle:{width:1.5,color:l},areaStyle:{color:{type:"linear",x:0,y:0,x2:0,y2:1,colorStops:[{offset:0,color:`rgba(${s}, 0.18)`},{offset:1,color:`rgba(${s}, 0.18)`}]}},itemStyle:{color:l}}],g=h.length>0?function(t){let e=0;for(const n of t)n[1]>e&&(e=n[1]);return e}(h):0,v={type:"value",splitNumber:4,axisLabel:{fontSize:10,formatter:g<10?t=>0===t?"0":t.toFixed(1):t=>n.format(t)},splitLine:{lineStyle:{opacity:.15}}};u?(v.min=n.fixedMin,v.max=n.fixedMax):g<1&&(v.min=0,v.max=1),o&&"current"===n.entityRole&&(v.min=0,v.max=Math.ceil(1.25*o),p.push({type:"line",data:[[d,.8*o],[c,.8*o]],showSymbol:!1,lineStyle:{width:1,color:"rgba(255, 200, 40, 0.6)",type:"dashed"},itemStyle:{color:"transparent"},tooltip:{show:!1}}),p.push({type:"line",data:[[d,o],[c,o]],showSymbol:!1,lineStyle:{width:1.5,color:"rgba(255, 60, 60, 0.7)",type:"solid"},itemStyle:{color:"transparent"},tooltip:{show:!1}}));const m={xAxis:{type:"time",min:d,max:c,axisLabel:{fontSize:10},splitLine:{show:!1}},yAxis:v,grid:{top:8,right:4,bottom:0,left:0,containLabel:!0},tooltip:{trigger:"axis",axisPointer:{type:"line",lineStyle:{type:"dashed"}},formatter:t=>{if(!t||0===t.length)return"";const e=t[0],i=new Date(e.value[0]).toLocaleString(void 0,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit"}),r=parseFloat(e.value[1].toFixed(2));return`
${i}
${n.format(r)} ${n.unit(r)}
`}},animation:!1};return{options:m,series:p}}(n,i,o,a,l,c),h=s??120;t.style.minHeight=h+"px";let p=t.querySelector("span-chart");p||(p=document.createElement("span-chart"),p.style.display="block",p.style.width="100%",t.innerHTML="",t.appendChild(p));const g=t.clientHeight;p.height=(g>0?g:h)+"px",p.options=d,p.data=u}function nT(t){return"function"==typeof globalThis.CSS?.escape?CSS.escape(t):t.replace(/["\\]/g,"\\$&")}function iT(t,e,n,i,r){const o="current"===(i.chart_metric||"power"),a=t.querySelector(".stat-consumption .stat-value"),s=t.querySelector(".stat-consumption .stat-unit");if(o){const t=n.panel_entities?.site_power,i=t?e.states[t]:null,r=i?parseFloat(i.attributes?.amperage):NaN;a&&(a.textContent=Number.isFinite(r)?Math.abs(r).toFixed(1):"--"),s&&(s.textContent="A")}else{let t=r;const i=n.panel_entities?.site_power;if(i){const n=e.states[i];n&&(t=Math.abs(parseFloat(n.state)||0))}a&&(a.textContent=fe(t)),s&&(s.textContent="kW")}const l=t.querySelector(".stat-upstream .stat-value"),c=t.querySelector(".stat-upstream .stat-unit");if(l){const t=n.panel_entities?.current_power,i=t?e.states[t]:null;if(o){const t=i?parseFloat(i.attributes?.amperage):NaN;l.textContent=Number.isFinite(t)?Math.abs(t).toFixed(1):"--",c&&(c.textContent="A")}else{const t=i?Math.abs(parseFloat(i.state)||0):0;l.textContent=fe(t),c&&(c.textContent="kW")}}const d=t.querySelector(".stat-downstream .stat-value"),u=t.querySelector(".stat-downstream .stat-unit");if(d){const t=n.panel_entities?.feedthrough_power,i=t?e.states[t]:null;if(o){const t=i?parseFloat(i.attributes?.amperage):NaN;d.textContent=Number.isFinite(t)?Math.abs(t).toFixed(1):"--",u&&(u.textContent="A")}else{const t=i?Math.abs(parseFloat(i.state)||0):0;d.textContent=fe(t),u&&(u.textContent="kW")}}const h=t.querySelector(".stat-solar .stat-value"),p=t.querySelector(".stat-solar .stat-unit");if(h){const t=n.panel_entities?.pv_power,i=t?e.states[t]:null;if(o){const t=i?parseFloat(i.attributes?.amperage):NaN;h.textContent=Number.isFinite(t)?Math.abs(t).toFixed(1):"--",p&&(p.textContent="A")}else{if(i){const t=Math.abs(parseFloat(i.state)||0);h.textContent=fe(t)}else h.textContent="--";p&&(p.textContent="kW")}}const f=t.querySelector(".stat-battery .stat-value");if(f){const t=n.panel_entities?.battery_level,i=t?e.states[t]:null;i&&(f.textContent=`${Math.round(parseFloat(i.state)||0)}`)}const g=t.querySelector(".stat-grid-state .stat-value");if(g){const t=n.panel_entities?.dsm_state,i=t?e.states[t]:null;g.textContent=i?e.formatEntityState?.(i)||i.state:"--"}}function rT(t,e,i,r,o,a){if(!t||!i||!e)return;const s=Re(r);let d=0;for(const[,t]of Object.entries(i.circuits)){const n=t.entities?.power;if(!n)continue;const i=e.states[n],r=i&&parseFloat(i.state)||0;t.device_type!==c&&(d+=Math.abs(r))}!function(t,e,n,i,r){const o=t.querySelector(".panel-stats");o&&iT(o,e,n,i,r)}(t,e,i,r,d);const u=ye(r),h="current"===u.entityRole;for(const[r,d]of Object.entries(i.circuits)){const i=t.querySelector(`.circuit-slot[data-uuid="${nT(r)}"]`);if(!i)continue;const p=d.entities?.power,f=p?e.states[p]:null,g=f&&parseFloat(f.state)||0,m=d.device_type===c||g<0,y=d.entities?.switch,_=y?e.states[y]:null,b=_?"on"===_.state:(f?.attributes?.relay_state||d.relay_state)===l,x=i.querySelector(".power-value");if(x)if(h){const t=d.entities?.current,n=t?e.states[t]:null,i=n&&parseFloat(n.state)||0;x.innerHTML=`${u.format(i)}A`}else x.innerHTML=`${pe(g)}${he(g)}`;const w=i.querySelector(".toggle-pill");if(w){w.className="toggle-pill "+(b?"toggle-on":"toggle-off");const t=w.querySelector(".toggle-label");t&&(t.textContent=n(b?"grid.on":"grid.off"))}let S;if(i.classList.toggle("circuit-off",!b),i.classList.toggle("circuit-producer",m),d.always_on)S="always_on";else{const t=d.entities?.select,n=t?e.states[t]:null;S=n?n.state:"unknown"}const C=v[S]??v.unknown,k=i.querySelector(".shedding-icon");k&&(k.setAttribute("icon",C.icon),k.style.color=C.color,k.title=C.label());const M=i.querySelector(".shedding-icon-secondary");M&&(C.icon2?(M.setAttribute("icon",C.icon2),M.style.color=C.color,M.style.display=""):M.style.display="none");const T=i.querySelector(".shedding-label");T&&(C.textLabel?(T.textContent=C.textLabel,T.style.color=C.color,T.style.display=""):T.style.display="none");const I=i.querySelector(".chart-container");if(I){const t=o.get(r)||[],e=i.classList.contains("circuit-col-span")?200:100,n=a?.has(r)?He(a.get(r)):s,l=d.device_type===c;eT(I,0,t,n,u,m,e,d.breaker_rating_a??void 0,l)}}}class oT{get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t,this._retry=t?new qt(t):null}constructor(){this._errorStore=null,this._retry=null,this._settings=null,this._lastFetch=0,this._fetching=!1}async fetch(t,e){const i=Date.now();if(this._fetching)return this._settings;if(this._settings&&i-this._lastFetch<3e4)return this._settings;this._fetching=!0;try{const i={};e&&(i.config_entry_id=e);const r={type:"call_service",domain:s,service:"get_graph_settings",service_data:i,return_response:!0},o=this._retry?await this._retry.callWS(t,r,{errorId:"fetch:graph_settings",errorMessage:n("error.graph_settings_failed")}):await t.callWS(r);this._settings=o?.response??null,this._lastFetch=Date.now()}catch(t){console.warn("SPAN Panel: graph settings fetch failed",t),this._settings=null,this._retry||this._errorStore?.add({key:"fetch:graph_settings",level:"warning",message:n("error.graph_settings_failed"),persistent:!1})}finally{this._fetching=!1}return this._settings}invalidate(){this._lastFetch=0}get settings(){return this._settings}clear(){this._settings=null,this._lastFetch=0}}function aT(t,e){if(!t)return o;const n=t.circuits?.[e];return n?.has_override?n.horizon:t.global_horizon??o}function sT(t,e){if(!t)return o;const n=t.sub_devices?.[e];return n?.has_override?n.horizon:t.global_horizon??o}class lT{constructor(){this.powerHistory=new Map,this.horizonMap=new Map,this.subDeviceHorizonMap=new Map,this.monitoringCache=new be,this.monitoringMultiCache=new xe,this.graphSettingsCache=new oT,this._errorStore=null,this._hass=null,this._topology=null,this._config=null,this._configEntryId=null,this._favRefs=null,this._perPanelInfo=new Map,this._panelFavorites=null,this._showMonitoring=!1,this._updateInterval=null,this._recorderRefreshInterval=null,this._resizeObserver=null,this._lastWidth=0,this._resizeDebounce=null}get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t,this.monitoringCache.errorStore=t,this.graphSettingsCache.errorStore=t,this.monitoringMultiCache.errorStore=t}get hass(){return this._hass}set hass(t){this._hass=t}get topology(){return this._topology}get config(){return this._config}set showMonitoring(t){this._showMonitoring=t}init(t,e,n,i){this._topology=t,this._config=e,this._hass=n,this._configEntryId=i}setFavoriteRefs(t){this._favRefs=t}clearFavoriteRefs(){this._favRefs=null}setPanelFavorites(t){this._panelFavorites=t}setFavoritesPerPanelInfo(t){this._perPanelInfo=t??new Map}get _inFavoritesView(){return null!==this._favRefs}setConfig(t){this._config=t}buildHorizonMaps(t){if(this.horizonMap.clear(),this.subDeviceHorizonMap.clear(),t&&this._topology?.circuits)for(const e of Object.keys(this._topology.circuits))this.horizonMap.set(e,aT(t,e));if(t&&this._topology?.sub_devices)for(const e of Object.keys(this._topology.sub_devices))this.subDeviceHorizonMap.set(e,sT(t,e))}async fetchAndBuildHorizonMaps(){try{this._favRefs?await this._buildFavoritesHorizonMaps():(await this.graphSettingsCache.fetch(this._hass,this._configEntryId),this.buildHorizonMaps(this.graphSettingsCache.settings))}catch(t){console.warn("SPAN Panel: graph settings fetch failed",t),this.graphSettingsCache.errorStore||this._errorStore?.add({key:"fetch:graph_settings",level:"warning",message:n("error.graph_settings_failed"),persistent:!1})}}async fetchMergedMonitoringStatus(t){if(!this._hass||0===t.length)return null;const e=this._hass;return function(t){let e=!1;const n={},i={};for(const r of t)r&&(e=!0,r.circuits&&Object.assign(n,r.circuits),r.mains&&Object.assign(i,r.mains));return e?{circuits:n,mains:i}:null}(await Promise.all(t.map(t=>this.monitoringMultiCache.fetchOne(e,t))))}async _buildFavoritesHorizonMaps(){if(!this._hass||!this._favRefs||!this._topology)return;const t=new Set;for(const e of Object.values(this._favRefs))e.configEntryId&&t.add(e.configEntryId);const e=new Map;await Promise.all(Array.from(t).map(async t=>{e.set(t,await this._fetchGraphSettingsFresh(t))})),this.horizonMap.clear(),this.subDeviceHorizonMap.clear();for(const t of Object.keys(this._topology.circuits)){const n=this._favRefs[t],i=n?.configEntryId?e.get(n.configEntryId)??null:null,r=n?.targetId??t;this.horizonMap.set(t,aT(i,r))}if(this._topology.sub_devices)for(const t of Object.keys(this._topology.sub_devices)){const n=this._favRefs[t],i=n?.configEntryId?e.get(n.configEntryId)??null:null,r=n?.targetId??t;this.subDeviceHorizonMap.set(t,sT(i,r))}}async loadHistory(){await je(this._hass,this._topology,this._config,this.powerHistory,this.horizonMap,this.subDeviceHorizonMap)}recordSamples(){if(!this._topology||!this._hass||!this._config)return;const t=Date.now();for(const[e,n]of Object.entries(this._topology.circuits)){const i=this.horizonMap.get(e)??o;if(!a[i]?.useRealtime)continue;const r=_e(n,this._config);if(!r)continue;const s=this._hass.states[r];if(!s)continue;const l=parseFloat(s.state);if(isNaN(l))continue;const c=He(i),d=Fe(c),u=Be(c),h=t-c,p=this.powerHistory.get(e)??[];p.length>0&&t-p[p.length-1].time0&&t-p[p.length-1].time0&&this._topology)for(const{key:t,devId:e}of qe(this._topology))i.has(e)&&r.add(t);const o=new Map;try{await je(this._hass,this._topology,this._config,o,e,i);for(const t of e.keys()){const e=o.get(t);e?this.powerHistory.set(t,e):this.powerHistory.delete(t)}for(const t of r){const e=o.get(t);e?this.powerHistory.set(t,e):this.powerHistory.delete(t)}this.updateDOM(t)}catch(t){console.warn("SPAN Panel: history refresh failed",t),this._errorStore?.add({key:"fetch:history",level:"warning",message:n("error.history_failed"),persistent:!1})}}updateDOM(t){this._hass&&this._topology&&this._config&&(rT(t,this._hass,this._topology,this._config,this.powerHistory,this.horizonMap),function(t,e,n,i,r,o){if(!n.sub_devices)return;const a=Re(i);for(const[i,s]of Object.entries(n.sub_devices)){const n=t.querySelector(`[data-subdev="${nT(i)}"]`);if(!n)continue;const l=Pe(s);if(l){const t=e.states[l],i=t&&parseFloat(t.state)||0,r=n.querySelector(".sub-power-value");r&&(r.innerHTML=`${pe(i)} ${he(i)}`)}const c=n.querySelectorAll("[data-chart-key]");for(const t of c){const e=t.dataset.chartKey;if(!e)continue;const n=r.get(e)||[];let s=g.power;e.endsWith("_soc")?s=g.soc:e.endsWith("_soe")&&(s=g.soe);const l=!!t.closest(".bess-chart-col");eT(t,0,n,o?.has(i)?He(o.get(i)):a,s,!1,l?120:150,void 0,e.endsWith("_soc")||e.endsWith("_soe"))}for(const t of Object.keys(s.entities||{})){const i=n.querySelector(`[data-eid="${nT(t)}"]`);if(!i)continue;const r=e.states[t];if(r){let t;if(e.formatEntityState)t=e.formatEntityState(r);else{t=r.state;const e=r.attributes.unit_of_measurement||"";e&&(t+=" "+e)}if("Wh"===(r.attributes.unit_of_measurement||"")){const e=parseFloat(r.state);isNaN(e)||(t=(e/1e3).toFixed(1)+" kWh")}i.textContent=t}}}}(t,this._hass,this._topology,this._config,this.powerHistory,this.subDeviceHorizonMap))}async onGraphSettingsChanged(t){if(this._hass){this._favRefs?await this._buildFavoritesHorizonMaps():(this.graphSettingsCache.invalidate(),await this.graphSettingsCache.fetch(this._hass,this._configEntryId),this.buildHorizonMaps(this.graphSettingsCache.settings)),this.powerHistory.clear();try{await this.loadHistory()}catch{}this.updateDOM(t)}}onToggleClick(t,e){const i=t.target,r=i?.closest(".toggle-pill");if(!r)return;const o=e.querySelector(".slide-confirm");if(!o||!o.classList.contains("confirmed"))return;t.stopPropagation(),t.preventDefault();const a=r.closest("[data-uuid]");if(!a||!this._topology||!this._hass)return;const s=a.dataset.uuid;if(!s)return;const l=this._topology.circuits[s];if(!l)return;const c=l.entities?.switch;if(!c)return;const d=this._hass.states[c];if(!d)return void console.warn("SPAN Panel: switch entity not found:",c);const u="on"===d.state?"turn_off":"turn_on";this._hass.callService("switch",u,{},{entity_id:c}).catch(t=>{console.warn("SPAN Panel: switch service call failed",t),this._errorStore?.add({key:"service:relay",level:"error",message:n("error.relay_failed"),persistent:!1})})}async onGearClick(t,e){const n=t.target,i=n?.closest(".gear-icon");if(!i)return;const r=e.querySelector("span-side-panel");if(!r||!this._hass)return;if(r.hass=this._hass,r.errorStore=this.errorStore,i.classList.contains("panel-gear")){if(this._inFavoritesView){const t=await this._buildFavoritesSections();if(0===t.length)return;return void r.open({favoritesMode:!0,perPanelSections:t})}return await this.graphSettingsCache.fetch(this._hass,this._configEntryId),void r.open({panelMode:!0,topology:this._topology,graphSettings:this.graphSettingsCache.settings,showFavorites:null!==this._panelFavorites,favoritePanelDeviceId:this._panelFavorites?.panelDeviceId,favoriteCircuitUuids:this._panelFavorites?.circuitUuids,favoriteSubDeviceIds:this._panelFavorites?.subDeviceIds,configEntryId:this._configEntryId})}const a=i.dataset.uuid;if(a&&this._topology){const t=this._topology.circuits[a];if(t){const e=this._favRefs?.[a]??null,n=e&&"circuit"===e.kind?e.targetId:a,i=e?.configEntryId??this._configEntryId;let s,l;e?[s,l]=await Promise.all([this._fetchGraphSettingsFresh(i),this._fetchMonitoringStatusFresh(i)]):(await Promise.all([this.graphSettingsCache.fetch(this._hass,i),this.monitoringCache.fetch(this._hass,i)]),s=this.graphSettingsCache.settings,l=this.monitoringCache.status);const c=t.entities?.current??t.entities?.power,d=c?l?.circuits?.[c]??null:null,u=s?.global_horizon??o,h=s?.circuits?.[n],p=h?{...h,globalHorizon:u}:{horizon:u,has_override:!1,globalHorizon:u},f=e?.panelDeviceId??this._panelFavorites?.panelDeviceId,g=null!==e||(this._panelFavorites?.circuitUuids.has(n)??!1),v=this._inFavoritesView||null!==this._panelFavorites;return void r.open({...t,uuid:n,monitoringInfo:d,showMonitoring:this._showMonitoring,graphHorizonInfo:p,showFavorites:v,favoritePanelDeviceId:f,isFavorite:g,configEntryId:i})}}const s=i.dataset.subdevId;if(s&&this._topology?.sub_devices?.[s]){const t=this._topology.sub_devices[s],e=this._favRefs?.[s]??null,n=e&&"sub_device"===e.kind?e.targetId:s,i=e?.configEntryId??this._configEntryId;let a;e?a=await this._fetchGraphSettingsFresh(i):(await this.graphSettingsCache.fetch(this._hass,i),a=this.graphSettingsCache.settings);const l=a?.global_horizon??o,c=a?.sub_devices?.[n],d=c?{...c,globalHorizon:l}:{horizon:l,has_override:!1,globalHorizon:l},u=e?.panelDeviceId??this._panelFavorites?.panelDeviceId,h=null!==e||(this._panelFavorites?.subDeviceIds.has(n)??!1),p=this._inFavoritesView||null!==this._panelFavorites;r.open({subDeviceMode:!0,subDeviceId:n,name:t.name??n,deviceType:t.type??"",entities:t.entities,graphHorizonInfo:d,showFavorites:p,favoritePanelDeviceId:u,isFavorite:h,configEntryId:i})}}async _buildFavoritesSections(){if(!this._hass||!this._favRefs)return[];const t=function(t,e){const n=new Map;for(const i of Object.values(t)){if("circuit"!==i.kind)continue;const t=e.get(i.panelDeviceId);if(void 0===t)continue;let r=n.get(i.panelDeviceId);void 0===r&&(r={panelDeviceId:i.panelDeviceId,panelName:t.panelName,topology:t.topology,configEntryId:t.configEntryId,favoriteCircuitUuids:new Set},n.set(i.panelDeviceId,r)),r.favoriteCircuitUuids.add(i.targetId)}return Array.from(n.values()).sort((t,e)=>t.panelName.localeCompare(e.panelName))}(this._favRefs,this._perPanelInfo);if(0===t.length)return[];return await Promise.all(t.map(async t=>({panelDeviceId:t.panelDeviceId,panelName:t.panelName,topology:t.topology,graphSettings:await this._fetchGraphSettingsFresh(t.configEntryId),favoriteCircuitUuids:t.favoriteCircuitUuids,configEntryId:t.configEntryId})))}async _fetchGraphSettingsFresh(t){if(!this._hass)return null;try{const e={};t&&(e.config_entry_id=t);const i={type:"call_service",domain:s,service:"get_graph_settings",service_data:e,return_response:!0},r=this._errorStore?new qt(this._errorStore):null,o=r?await r.callWS(this._hass,i,{errorId:"fetch:graph_settings",errorMessage:n("error.graph_settings_failed")}):await this._hass.callWS(i);return o?.response??null}catch(t){return console.warn("SPAN Panel: fresh graph settings fetch failed",t),null}}async _fetchMonitoringStatusFresh(t){if(!this._hass)return null;try{const e={};t&&(e.config_entry_id=t);const i={type:"call_service",domain:s,service:"get_monitoring_status",service_data:e,return_response:!0},r=this._errorStore?new qt(this._errorStore):null,o=r?await r.callWS(this._hass,i,{errorId:"fetch:monitoring",errorMessage:n("error.monitoring_failed")}):await this._hass.callWS(i),a=o?.response;return a?{circuits:a.circuits,mains:a.mains}:null}catch(t){return console.warn("SPAN Panel: fresh monitoring status fetch failed",t),null}}bindSlideConfirm(t,e){const n=t.querySelector(".slide-confirm-knob"),i=t.querySelector(".slide-confirm-text");if(!n||!i)return;let r=!1,o=0,a=0;const s=e=>{t.classList.contains("confirmed")||(r=!0,o=e-n.offsetLeft,a=t.offsetWidth-n.offsetWidth-4,n.classList.remove("snapping"))},l=t=>{if(!r)return;const e=Math.max(2,Math.min(t-o,a));n.style.left=e+"px"},c=()=>{if(!r)return;r=!1;(n.offsetLeft-2)/a>=.9?(n.style.left=a+"px",t.classList.add("confirmed"),n.querySelector("span-icon")?.setAttribute("icon","mdi:lock-open"),i.textContent=t.dataset.textOn??"",e&&e.classList.remove("switches-disabled")):(n.classList.add("snapping"),n.style.left="2px")};n.addEventListener("mousedown",t=>{t.preventDefault(),s(t.clientX)}),t.addEventListener("mousemove",t=>l(t.clientX)),t.addEventListener("mouseup",c),t.addEventListener("mouseleave",c),n.addEventListener("touchstart",t=>{t.preventDefault(),s(t.touches[0].clientX)},{passive:!1}),t.addEventListener("touchmove",t=>l(t.touches[0].clientX),{passive:!0}),t.addEventListener("touchend",c),t.addEventListener("touchcancel",c),t.addEventListener("click",()=>{t.classList.contains("confirmed")&&(t.classList.remove("confirmed"),n.classList.add("snapping"),n.style.left="2px",n.querySelector("span-icon")?.setAttribute("icon","mdi:lock"),i.textContent=t.dataset.textOff??"",e&&e.classList.add("switches-disabled"))})}startIntervals(t,e){this._updateInterval=setInterval(()=>{this.recordSamples(),this.updateDOM(t),e&&e()},1e3),this._recorderRefreshInterval=setInterval(()=>{this.refreshRecorderData(t)},3e4)}stopIntervals(){this._updateInterval&&(clearInterval(this._updateInterval),this._updateInterval=null),this._recorderRefreshInterval&&(clearInterval(this._recorderRefreshInterval),this._recorderRefreshInterval=null),this.cleanupResizeObserver()}setupResizeObserver(t,e){this.cleanupResizeObserver(),e&&(this._lastWidth=e.clientWidth,this._resizeObserver=new ResizeObserver(e=>{const n=e[0];if(!n)return;const i=n.contentRect.width;Math.abs(i-this._lastWidth)<5||(this._lastWidth=i,this._resizeDebounce&&clearTimeout(this._resizeDebounce),this._resizeDebounce=setTimeout(()=>{for(const e of t.querySelectorAll(".chart-container")){const t=e.querySelector("span-chart");t&&t.remove()}this.updateDOM(t)},150))}),this._resizeObserver.observe(e))}cleanupResizeObserver(){this._resizeObserver&&(this._resizeObserver.disconnect(),this._resizeObserver=null),this._resizeDebounce&&(clearTimeout(this._resizeDebounce),this._resizeDebounce=null)}reset(){this.powerHistory.clear(),this.horizonMap.clear(),this.subDeviceHorizonMap.clear(),this.monitoringCache.clear(),this.monitoringMultiCache.clear(),this.graphSettingsCache.clear()}}function cT(t,e){const n=e.hysteresisPx??48,i=new WeakSet;let r=null;const o=t=>{const n=t.querySelector(e.nameSelector);return!!n&&(0!==t.clientWidth&&(n.clientWidth<1||n.scrollWidth>n.clientWidth+1))},a=()=>{const i=t.querySelectorAll(e.rowSelector);if(0===i.length)return;const a=i[0].clientWidth;if(0===a)return;if(null!==r){if(a>r+n){for(const t of i)t.classList.remove(e.foldClass);r=null}else for(const t of i)t.classList.add(e.foldClass);return}let s=!1;for(const t of i)if(o(t)){s=!0;break}if(s){for(const t of i)t.classList.add(e.foldClass);r=a}},s=new ResizeObserver(()=>a()),l=()=>{const n=t.querySelectorAll(e.rowSelector);for(const t of n)i.has(t)||(i.add(t),s.observe(t));requestAnimationFrame(()=>a())};l();const c=new MutationObserver(t=>{(t=>{const n=t=>{for(const n of t)if(n instanceof Element){if(n.matches(e.rowSelector))return!0;if(n.querySelector(e.rowSelector))return!0}return!1};for(const e of t)if(n(e.addedNodes)||n(e.removedNodes))return!0;return!1})(t)&&l()});return c.observe(t,{childList:!0,subtree:!0}),()=>{s.disconnect(),c.disconnect()}}const dT='\n :host {\n --span-accent: var(--primary-color, #4dd9af);\n }\n\n /* Card shell — replaces . Theme variables (--ha-card-*) are\n stable HA contracts (not the deprecated component APIs flagged by the\n 2026.4 frontend blog), so they stay in place to keep visual parity\n with the rest of HA\'s dashboards. */\n .span-card {\n display: block;\n padding: 24px;\n background: var(--card-background-color, #1c1c1c);\n color: var(--primary-text-color, #e0e0e0);\n border-radius: var(--ha-card-border-radius, 12px);\n border: var(--ha-card-border-width, 1px) solid var(--ha-card-border-color, var(--divider-color, #333));\n box-shadow: var(--ha-card-box-shadow, none);\n }\n\n .panel-header {\n display: flex;\n flex-wrap: wrap;\n justify-content: space-between;\n align-items: flex-start;\n gap: 8px 16px;\n margin-bottom: 20px;\n padding-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n .header-left { flex: 1 1 300px; min-width: 0; }\n .header-center { flex: 0 0 auto; }\n .header-right { flex: 0 1 auto; min-width: 0; }\n\n .panel-identity {\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 8px 12px;\n margin-bottom: 12px;\n }\n\n .panel-title {\n font-size: 1.8em;\n font-weight: 700;\n margin: 0;\n color: var(--primary-text-color, #fff);\n }\n\n .panel-serial {\n font-size: 0.85em;\n color: var(--secondary-text-color, #999);\n font-family: monospace;\n }\n\n .panel-stats {\n display: flex;\n flex-wrap: wrap;\n gap: 16px 32px;\n }\n\n /* Favorites view header: gear + slide-to-arm + right-anchored legend/W-A cluster. */\n .favorites-summary {\n padding: 8px 24px;\n border-bottom: 1px solid var(--divider-color, #e0e0e0);\n display: flex;\n align-items: center;\n gap: 12px;\n }\n /* Override the generic .gear-icon { margin-left: auto } rule so the\n favorites gear stays flush-left instead of floating to the right of\n the flex row (same idea as .panel-identity .panel-gear does for\n real-panel headers). */\n .favorites-summary .favorites-gear {\n margin-left: 0;\n }\n /* Right-anchored cluster wrapping the shedding legend + W/A unit toggle.\n margin-left:auto moved here from .favorites-summary-unit-toggle so the\n legend and toggle cluster together, matching the real-panel header\n layout. */\n .favorites-summary-right {\n margin-left: auto;\n display: flex;\n align-items: center;\n gap: 16px;\n }\n .favorites-subdevices-section {\n padding: 8px 16px 0;\n }\n\n /* Favorites view: responsive grid of per-contributing-panel status cards. */\n .favorites-panel-stats-grid {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));\n gap: 12px;\n padding: 12px 24px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n .favorites-panel-card {\n background: var(--secondary-background-color, rgba(255, 255, 255, 0.04));\n border: 1px solid var(--divider-color, #333);\n border-radius: 8px;\n padding: 10px 14px;\n display: flex;\n flex-direction: column;\n gap: 6px;\n }\n .favorites-panel-card-title {\n font-size: 0.85em;\n font-weight: 600;\n color: var(--primary-text-color);\n opacity: 0.85;\n }\n .favorites-panel-card .panel-stats {\n gap: 10px 20px;\n }\n .favorites-panel-card .stat-value {\n font-size: 1.15em;\n }\n\n .stat { display: flex; flex-direction: column; }\n .stat-label { font-size: 0.8em; color: var(--secondary-text-color, #999); margin-bottom: 2px; }\n .stat-row { display: flex; align-items: baseline; gap: 2px; }\n .stat-value { font-size: 1.5em; font-weight: 700; color: var(--primary-text-color, #fff); }\n .stat-unit { font-size: 0.7em; font-weight: 400; color: var(--secondary-text-color, #999); }\n\n .header-right { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; padding-top: 8px; }\n .header-right-top { display: flex; gap: 20px; align-items: center; }\n .meta-item { font-size: 0.8em; color: var(--secondary-text-color, #999); }\n\n .shedding-legend { display: flex; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }\n .shedding-legend-item { display: inline-flex; align-items: center; gap: 3px; }\n .shedding-legend-item span-icon { --mdc-icon-size: 16px; }\n .shedding-legend-secondary { --mdc-icon-size: 12px; opacity: 0.8; }\n .shedding-legend-text { font-size: 9px; font-weight: 600; }\n .shedding-legend-label { font-size: 0.7em; color: var(--secondary-text-color, #999); }\n\n .panel-gear {\n background: none;\n border: none;\n cursor: pointer;\n color: var(--secondary-text-color);\n opacity: 0.6;\n padding: 4px;\n margin-left: 8px;\n vertical-align: middle;\n }\n .panel-gear:hover { opacity: 1; }\n .header-center {\n display: flex;\n align-items: flex-start;\n justify-content: center;\n padding-top: 8px;\n }\n .panel-identity .panel-gear {\n margin-left: 0;\n }\n .slide-confirm {\n position: relative;\n display: inline-flex;\n align-items: center;\n width: 160px;\n height: 28px;\n border-radius: 14px;\n background: color-mix(in srgb, var(--primary-color, #4dd9af) 20%, var(--secondary-background-color, #333));\n vertical-align: middle;\n overflow: hidden;\n user-select: none;\n touch-action: none;\n }\n .slide-confirm-text {\n position: absolute;\n width: 100%;\n text-align: center;\n font-size: 0.65em;\n font-weight: 600;\n color: var(--secondary-text-color, #999);\n pointer-events: none;\n z-index: 0;\n }\n .slide-confirm-knob {\n position: absolute;\n left: 2px;\n top: 2px;\n width: 24px;\n height: 24px;\n border-radius: 50%;\n background: var(--secondary-text-color, #666);\n display: flex;\n align-items: center;\n justify-content: center;\n cursor: grab;\n z-index: 1;\n transition: none;\n }\n .slide-confirm-knob span-icon {\n --mdc-icon-size: 14px;\n color: var(--card-background-color, #1c1c1c);\n }\n .slide-confirm-knob.snapping {\n transition: left 0.25s ease;\n }\n .slide-confirm.confirmed {\n background: color-mix(in srgb, var(--state-active-color, var(--span-accent)) 25%, transparent);\n }\n .slide-confirm.confirmed .slide-confirm-text {\n color: var(--state-active-color, var(--span-accent));\n }\n .slide-confirm.confirmed .slide-confirm-knob {\n background: var(--state-active-color, var(--span-accent));\n }\n .switches-disabled .toggle-pill {\n opacity: 0.3;\n pointer-events: none;\n }\n .unit-toggle {\n display: inline-flex;\n background: var(--secondary-background-color, #333);\n border-radius: 6px;\n overflow: hidden;\n margin-left: 8px;\n }\n .unit-btn {\n padding: 4px 10px;\n border: none;\n background: none;\n color: var(--secondary-text-color);\n font-size: 0.75em;\n font-weight: 600;\n cursor: pointer;\n }\n .unit-btn.unit-active {\n background: var(--primary-color, #4dd9af);\n color: var(--text-primary-color, #000);\n }\n\n .monitoring-summary {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 6px 16px;\n font-size: 0.8em;\n background: rgba(76, 175, 80, 0.1);\n border: 1px solid var(--divider-color, #333);\n border-top: none;\n }\n .monitoring-active { color: #4caf50; }\n .monitoring-counts { display: flex; gap: 12px; }\n .count-warning { color: #ff9800; }\n .count-alert { color: #f44336; }\n .count-overrides { color: var(--secondary-text-color); }\n\n .panel-grid {\n display: grid;\n /* Five columns: left tab label, left cell, explicit 8px spacer,\n right cell, right tab label. Spacer is in-band rather than a\n column-gap so we can keep inter-cell space without paying an\n equal gap between each cell and its tab label. The tab columns\n are sized to fit a 2-digit breaker number (the font is 0.85em\n of the panel body ≈ 14px glyph width). */\n grid-template-columns: 14px 1fr 8px 1fr 14px;\n column-gap: 0;\n row-gap: 8px;\n align-items: stretch;\n }\n\n .tab-label {\n display: flex;\n align-items: center;\n font-size: 0.85em;\n font-weight: 600;\n color: var(--secondary-text-color, #999);\n user-select: none;\n }\n .tab-left { justify-content: flex-start; }\n .tab-right { justify-content: flex-end; }\n\n .circuit-slot {\n background: var(--secondary-background-color, var(--card-background-color, #2a2a2a));\n border: 1px solid var(--divider-color, #333);\n border-radius: 12px;\n padding: 14px 16px 20px;\n min-height: 140px;\n transition: opacity 0.3s;\n position: relative;\n overflow: hidden;\n }\n\n .circuit-col-span { min-height: 280px; }\n .circuit-row-span { border-left: 3px solid var(--span-accent); }\n .circuit-off .circuit-name,\n .circuit-off .breaker-badge,\n .circuit-off .power-value,\n .circuit-off .chart-container { opacity: 0.35; }\n .circuit-off .toggle-pill,\n .circuit-off .gear-icon { opacity: 1; }\n\n .circuit-empty {\n opacity: 0.2;\n min-height: 60px;\n display: flex;\n align-items: center;\n justify-content: center;\n border-style: dashed;\n }\n .empty-label { color: var(--secondary-text-color, #999); font-size: 0.85em; }\n\n .circuit-header {\n display: flex;\n justify-content: space-between;\n align-items: flex-start;\n margin-bottom: 6px;\n gap: 8px;\n }\n\n .circuit-info { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }\n\n .breaker-badge {\n background: color-mix(in srgb, var(--span-accent) 15%, transparent);\n color: var(--span-accent);\n font-size: 0.7em;\n font-weight: 700;\n padding: 2px 3px;\n border-radius: 4px;\n white-space: nowrap;\n border: 1px solid color-mix(in srgb, var(--span-accent) 25%, transparent);\n flex-shrink: 0;\n }\n\n .circuit-name {\n font-size: 0.9em;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n color: var(--primary-text-color, #e0e0e0);\n }\n\n .circuit-controls { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }\n\n /* Truncation-driven fold for By Panel breaker cells. The .is-folded\n class is added/removed by the JS observer in\n src/core/truncation-fold.ts when the .circuit-name actually\n ellipsizes. Pixel thresholds can\'t get this right because name\n length varies wildly per circuit (e.g. "Spa" vs\n "Commissioned PV System") — only measuring the live name vs its\n container catches the exact moment of truncation.\n\n When folded the nested flex wrappers (.circuit-header,\n .circuit-info, .circuit-controls, .circuit-status) collapse via\n \'display: contents\' so the leaf elements participate directly in\n the outer grid: name gets the whole first row, readings/controls/\n gear drop to a second row, chart stays as the full-width third. */\n .circuit-slot.is-folded {\n display: grid;\n /* Columns: badges + relay-toggle pack tight on the left, slack\n absorbed by the 1fr column between the relay and the power\n reading, keeping power + gear pinned to the right edge. The\n previous layout placed the slack between the shedding icon and\n the relay, which read as wasted padding the user pointed out. */\n grid-template-columns: auto auto auto auto 1fr auto auto;\n /* Rows: name and controls sized to content; chart absorbs any\n extra cell height. Without the explicit 1fr on row 3, a tall\n cell (e.g. .circuit-col-span\'s 280px min-height for 240V\n double-pole breakers) distributes excess space equally across\n all three rows via the default align-content:stretch, which\n pushes the chart down and vertically inflates the badge and\n relay toggle to fill the controls row. */\n grid-template-rows: auto auto 1fr;\n grid-template-areas:\n "name name name name name name name"\n "badge util shed status . power gear"\n "chart chart chart chart chart chart chart";\n row-gap: 6px;\n column-gap: 8px;\n }\n .circuit-slot.is-folded > .circuit-header,\n .circuit-slot.is-folded > .circuit-status,\n .circuit-slot.is-folded > .circuit-header > .circuit-info,\n .circuit-slot.is-folded > .circuit-header > .circuit-controls {\n display: contents;\n }\n .circuit-slot.is-folded .circuit-name {\n grid-area: name;\n justify-self: start;\n }\n .circuit-slot.is-folded .breaker-badge {\n grid-area: badge;\n }\n .circuit-slot.is-folded .utilization {\n grid-area: util;\n }\n .circuit-slot.is-folded .shedding-icon,\n .circuit-slot.is-folded .shedding-composite {\n grid-area: shed;\n }\n .circuit-slot.is-folded .toggle-pill {\n grid-area: status;\n justify-self: end;\n }\n .circuit-slot.is-folded .power-value {\n grid-area: power;\n justify-self: end;\n }\n .circuit-slot.is-folded .gear-icon.circuit-gear {\n grid-area: gear;\n justify-self: end;\n }\n .circuit-slot.is-folded > .chart-container {\n grid-area: chart;\n }\n\n .power-value { font-size: 0.9em; color: var(--primary-text-color, #fff); white-space: nowrap; }\n .power-value strong { font-weight: 700; font-size: 1.1em; }\n .power-unit { font-size: 0.8em; font-weight: 400; color: var(--secondary-text-color, #999); margin-left: 1px; }\n .circuit-producer .power-value strong { color: var(--info-color, #4fc3f7); }\n\n .toggle-pill {\n display: flex;\n align-items: center;\n gap: 3px;\n padding: 2px 4px;\n border-radius: 10px;\n cursor: pointer;\n font-size: 0.65em;\n font-weight: 600;\n transition: background 0.2s;\n user-select: none;\n min-width: 40px;\n }\n .toggle-on {\n padding-left: 6px;\n background: color-mix(in srgb, var(--state-active-color, var(--span-accent)) 25%, transparent);\n color: var(--state-active-color, var(--span-accent));\n }\n .toggle-off {\n padding-right: 6px;\n background: color-mix(in srgb, var(--secondary-text-color) 15%, transparent);\n color: var(--secondary-text-color, #999);\n }\n .toggle-knob {\n width: 14px;\n height: 14px;\n border-radius: 50%;\n transition: background 0.2s, margin 0.2s;\n }\n .toggle-on .toggle-knob {\n background: var(--state-active-color, var(--span-accent));\n margin-left: auto;\n }\n .toggle-off .toggle-knob {\n background: var(--secondary-text-color, #999);\n margin-right: auto;\n order: -1;\n }\n\n .circuit-status {\n display: flex;\n align-items: center;\n gap: 4px;\n margin-top: 4px;\n padding: 0 4px;\n }\n .shedding-icon { opacity: 0.8; cursor: default; }\n .shedding-composite {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n }\n .shedding-icon-secondary { opacity: 0.8; }\n .shedding-label {\n font-size: 10px;\n font-weight: 600;\n opacity: 0.8;\n }\n .gear-icon {\n background: none;\n border: none;\n cursor: pointer;\n padding: 2px;\n opacity: 0.6;\n transition: opacity 0.2s;\n margin-left: auto;\n }\n .gear-icon:hover { opacity: 1; }\n .utilization {\n font-size: 0.75em;\n font-weight: 600;\n }\n .utilization-normal { color: #4caf50; }\n .utilization-warning { color: #ff9800; }\n .utilization-alert { color: #f44336; }\n .circuit-alert {\n border-color: #f44336 !important;\n box-shadow: 0 0 8px rgba(244, 67, 54, 0.3);\n }\n .chart-container {\n width: 100%;\n aspect-ratio: 4 / 1;\n margin-top: 4px;\n overflow: hidden;\n min-width: 0;\n }\n\n .sub-devices {\n display: grid;\n grid-template-columns: repeat(2, 1fr);\n gap: 12px;\n margin-bottom: 20px;\n padding-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n\n .sub-device {\n background: var(--secondary-background-color, var(--card-background-color, #2a2a2a));\n border: 1px solid var(--divider-color, #333);\n border-radius: 12px;\n padding: 14px 16px;\n }\n .sub-device-bess,\n .sub-device-full {\n grid-column: 1 / -1;\n }\n\n .sub-device-header { display: flex; gap: 10px; align-items: baseline; margin-bottom: 8px; }\n .sub-device-type { font-size: 0.7em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--span-accent); }\n .sub-device-name { font-size: 0.85em; color: var(--secondary-text-color, #999); flex: 1; }\n .sub-power-value { font-size: 0.9em; color: var(--primary-text-color, #fff); white-space: nowrap; }\n .sub-power-value strong { font-weight: 700; font-size: 1.1em; }\n .sub-device .chart-container { margin-bottom: 8px; aspect-ratio: auto; }\n\n .bess-charts {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(0, 1fr));\n gap: 12px;\n margin-bottom: 10px;\n }\n .bess-chart-col { min-width: 0; }\n .bess-chart-title {\n font-size: 0.75em;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n color: var(--secondary-text-color, #999);\n margin-bottom: 4px;\n }\n .bess-chart-col .chart-container { aspect-ratio: auto; }\n .sub-entity { display: flex; gap: 6px; padding: 3px 0; font-size: 0.85em; }\n .sub-entity-name { color: var(--secondary-text-color, #999); }\n .sub-entity-value { font-weight: 500; color: var(--primary-text-color, #e0e0e0); }\n\n /* ── Shared tab bar ────────────────────────────────────── */\n\n .shared-tab-bar {\n display: flex;\n gap: 0;\n margin-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n\n .shared-tab {\n padding: 8px 16px;\n cursor: pointer;\n font-size: 0.9em;\n font-weight: 500;\n color: var(--primary-text-color);\n opacity: 0.6;\n border: none;\n border-bottom: 2px solid transparent;\n background: none;\n transition: opacity 0.15s;\n }\n\n .shared-tab:hover {\n opacity: 0.85;\n }\n\n .shared-tab.active {\n opacity: 1;\n border-bottom-color: var(--span-accent);\n }\n\n /* ── List view search ──────────────────────────────────── */\n\n .list-search-container {\n margin-bottom: 12px;\n position: relative;\n }\n\n .list-search {\n width: 100%;\n padding: 8px 36px 8px 12px;\n border-radius: 8px;\n border: 1px solid var(--divider-color, #333);\n background: var(--secondary-background-color, #2a2a2a);\n color: var(--primary-text-color);\n font-size: 0.9em;\n box-sizing: border-box;\n outline: none;\n }\n\n .list-search:focus {\n border-color: var(--span-accent);\n }\n\n .list-search-clear {\n position: absolute;\n right: 8px;\n top: 50%;\n transform: translateY(-50%);\n background: none;\n border: none;\n color: var(--secondary-text-color);\n cursor: pointer;\n padding: 2px;\n display: flex;\n align-items: center;\n opacity: 0.7;\n }\n\n .list-search-clear:hover {\n opacity: 1;\n }\n\n .list-unit-toggle {\n display: inline-flex;\n margin-bottom: 12px;\n }\n\n /* ── List rows ─────────────────────────────────────────── */\n\n .list-view {\n display: flex;\n flex-direction: column;\n gap: 6px;\n }\n /* Each circuit is wrapped in a .list-cell so the row + its optional\n expanded chart stay together. In single-column flex mode the cell\n just stacks naturally. In multi-column grid mode the cell becomes\n one grid item, so the chart is always in the same column as its\n row. Area headers (rendered as siblings, not inside a cell) span\n all columns via their inline "grid-column: 1 / -1". */\n .list-cell {\n display: flex;\n flex-direction: column;\n min-width: 0;\n }\n .list-view[data-columns="2"],\n .list-view[data-columns="3"] {\n display: grid;\n grid-template-columns: repeat(var(--list-cols), minmax(0, 1fr));\n gap: 6px 8px;\n flex-direction: initial;\n }\n /* On narrow viewports a 2/3-column list would squeeze rows into an\n unreadable shape, so force stacking regardless of user preference. */\n @media (max-width: 599px) {\n .list-view[data-columns="2"],\n .list-view[data-columns="3"] {\n display: flex;\n flex-direction: column;\n }\n }\n\n .list-row {\n display: flex;\n align-items: center;\n padding: 12px 16px;\n gap: 10px;\n /* min-width: 0 lets the row shrink below the sum of its\n non-shrinking children when its parent .list-cell is in a\n narrow CSS-grid track (multi-column list mode). Without this\n the row would maintain its intrinsic min-content width and\n overflow the cell, leaving the name unshrunk and the\n truncation-fold observer with no signal to react to. */\n min-width: 0;\n background: var(--card-background-color, #1c1c1c);\n border: 1px solid var(--divider-color, #333);\n border-radius: 8px;\n cursor: pointer;\n transition: background 0.15s;\n }\n\n .list-row:hover {\n background: var(--secondary-background-color, #2a2a2a);\n }\n\n .list-row.circuit-off {\n opacity: 0.5;\n }\n\n .list-row.list-row-expanded {\n border-bottom-left-radius: 0;\n border-bottom-right-radius: 0;\n border-bottom-color: transparent;\n }\n\n .list-circuit-name {\n flex: 1;\n color: var(--primary-text-color);\n font-size: 0.9em;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .list-status-badge {\n font-size: 0.75em;\n font-weight: 600;\n padding: 2px 8px;\n border-radius: 4px;\n flex-shrink: 0;\n }\n\n .list-status-on {\n color: #4dd9af;\n }\n\n .list-status-off {\n color: #f44336;\n }\n\n .list-power-value {\n font-size: 0.9em;\n font-weight: 600;\n flex-shrink: 0;\n /* No min-width / text-align:right: the old 70px right-aligned\n cell left a visible blank column for short readings (e.g.\n "1.3A" in a 70px slot), which robbed horizontal space from\n .list-circuit-name on narrow rows. Let the value hug the\n preceding relay control and size to its content so the freed\n width flows back into the flex:1 name column. */\n }\n\n .list-expand-toggle {\n background: none;\n border: none;\n color: var(--secondary-text-color);\n cursor: pointer;\n padding: 4px;\n transition: transform 0.2s;\n display: flex;\n align-items: center;\n flex-shrink: 0;\n }\n\n .list-expand-toggle.expanded {\n transform: rotate(180deg);\n }\n\n .list-row .gear-icon {\n background: transparent;\n border: none;\n padding: 2px;\n cursor: pointer;\n color: #555;\n display: inline-flex;\n align-items: center;\n }\n .list-row .gear-icon:hover {\n color: var(--primary-text-color);\n }\n\n /* Truncation-driven fold for list rows. The .is-folded class is\n added/removed by the JS observer in src/core/truncation-fold.ts\n when the .list-circuit-name actually ellipsizes — pixel breakpoints\n can\'t track this because name length varies wildly per circuit\n ("Spa" vs "Commissioned PV System") and any single threshold\n misfires for the other end of the range. Switch to a two-row grid\n so the name gets the full width (paired only with the expand\n chevron) and the badges/controls/reading/gear drop to a secondary\n row underneath. Named areas keep the CSS readable despite the flat\n HTML child order. */\n .list-row.is-folded {\n display: grid;\n /* Row 1: name spans the row up to the chevron at the trailing\n column. Row 2: badge + util + shed + relay-toggle pack left,\n the 1fr column absorbs slack between the relay and the power\n reading, power + gear stay pinned to the right edge. The\n earlier layout placed the slack between the shedding icon and\n the relay, which the user flagged as wasted padding. */\n grid-template-columns: auto auto auto auto 1fr auto auto;\n grid-template-areas:\n "name name name name name name chevron"\n "badge util shed status . power gear";\n row-gap: 6px;\n column-gap: 8px;\n }\n .list-row.is-folded > .list-circuit-name {\n grid-area: name;\n justify-self: start;\n }\n .list-row.is-folded > .list-expand-toggle {\n grid-area: chevron;\n }\n .list-row.is-folded > .breaker-badge {\n grid-area: badge;\n }\n .list-row.is-folded > .utilization {\n grid-area: util;\n }\n .list-row.is-folded > .shedding-icon,\n .list-row.is-folded > .shedding-composite {\n grid-area: shed;\n }\n .list-row.is-folded > .toggle-pill,\n .list-row.is-folded > .list-status-badge {\n grid-area: status;\n }\n .list-row.is-folded > .list-power-value {\n grid-area: power;\n justify-self: end;\n }\n .list-row.is-folded > .gear-icon.circuit-gear {\n grid-area: gear;\n justify-self: end;\n }\n\n /* ── Expanded circuit content ──────────────────────────── */\n\n .list-expanded-content {\n padding: 0;\n background: var(--card-background-color, #1c1c1c);\n border: 1px solid var(--divider-color, #333);\n border-top: none;\n border-radius: 0 0 8px 8px;\n margin-top: -6px;\n margin-bottom: 2px;\n }\n\n .circuit-slot.circuit-chart-only {\n border: none;\n margin: 0;\n background: none;\n padding: 8px 12px;\n min-height: 0;\n }\n\n /* ── Area headers ──────────────────────────────────────── */\n\n .area-header {\n padding: 16px 12px 6px;\n font-weight: 600;\n font-size: 0.85em;\n color: var(--secondary-text-color);\n text-transform: uppercase;\n letter-spacing: 0.05em;\n }\n\n /* ── No results ────────────────────────────────────────── */\n\n .list-no-results {\n padding: 24px;\n text-align: center;\n color: var(--secondary-text-color);\n }\n\n';class uT{constructor(){this._ctrl=new lT,this._container=null,this._onGearClick=null,this._onToggleClick=null,this._onSidePanelClosed=null,this._onGraphSettingsChanged=null,this._foldUnobserve=null}get hass(){return this._ctrl.hass}set hass(t){this._ctrl.hass=t}set errorStore(t){this._ctrl.errorStore=t}setPanelFavorites(t){this._ctrl.setPanelFavorites(t)}async render(t,e,i,r,o){let a,s;this.stop(),this._ctrl.reset(),this._ctrl.showMonitoring=!0,this._container=t,this._ctrl.hass=e;try{const t=await se(e,i);a=t.topology,s=t.panelSize}catch(e){return void(t.innerHTML=`

${Bt(e.message)}

`)}this._ctrl.init(a,r,e,o??null),await this._ctrl.monitoringCache.fetch(e,o??null),await this._ctrl.fetchAndBuildHorizonMaps();const l=Math.ceil(s/2),c=this._ctrl.monitoringCache.status,d=de(a,r),u=function(t){if(!t)return"";const e=Object.values(t.circuits??{}),i=Object.values(t.mains??{}),r=[...e,...i],o=r.filter(t=>void 0!==t.utilization_pct&&t.utilization_pct>=80&&t.utilization_pct<100).length,a=r.filter(t=>void 0!==t.utilization_pct&&t.utilization_pct>=100).length,s=r.filter(t=>t.has_override).length;return`\n
\n ✓ ${n("status.monitoring")} · ${e.length} ${n("status.circuits")} · ${i.length} ${n("status.mains")}\n \n ${o>0?`${o} ${n(o>1?"status.warnings":"status.warning")}`:""}\n ${a>0?`${a} ${n(a>1?"status.alerts":"status.alert")}`:""}\n ${s>0?`${s} ${n(s>1?"status.overrides":"status.override")}`:""}\n \n
\n `}(c),h=function(t,e,n,i,r){const o=new Map,a=new Set;for(const[e,n]of Object.entries(t.circuits)){const t=n.tabs;if(!t||0===t.length)continue;const i=Math.min(...t),r=1===t.length?"single":me(t)??"single";o.set(i,{uuid:e,circuit:n,layout:r});for(const e of t)a.add(e)}const s=new Set,l=new Set;for(const[t,e]of o)if("col-span"===e.layout){const n=e.circuit.tabs,i=ge(Math.max(...n));0===ve(t)?s.add(i):l.add(i)}function c(t){const e=t.circuit.entities?.current??t.circuit.entities?.power,i=r?we(r,e??""):null;let o;if(t.circuit.always_on)o="always_on";else{const e=t.circuit.entities?.select;o=e&&n.states[e]?n.states[e].state:"unknown"}return{monInfo:i,sheddingPriority:o}}let d="";for(let t=1;t<=e;t++){const e=2*t-1,r=2*t,u=o.get(e),h=o.get(r);if(d+=`
${e}
`,u&&"row-span"===u.layout){const{monInfo:e,sheddingPriority:o}=c(u);d+=Ce(u.uuid,u.circuit,t,"2 / 5","row-span",n,i,e,o),d+=`
${r}
`;continue}if(!s.has(t))if(!u||"col-span"!==u.layout&&"single"!==u.layout)a.has(e)||(d+=ke(t,"2"));else{const{monInfo:e,sheddingPriority:r}=c(u);d+=Ce(u.uuid,u.circuit,t,"2",u.layout,n,i,e,r)}if(!l.has(t))if(!h||"col-span"!==h.layout&&"single"!==h.layout)a.has(r)||(d+=ke(t,"4"));else{const{monInfo:e,sheddingPriority:r}=c(h);d+=Ce(h.uuid,h.circuit,t,"4",h.layout,n,i,e,r)}d+=`
${r}
`}return d}(a,l,e,r,c),p=Ne(a,e,r);t.innerHTML=`\n \n ${d}\n ${u}\n ${p?`
${p}
`:""}\n ${!1!==r.show_panel?`\n
\n ${h}\n
\n `:""}\n \n `,this._onGearClick=e=>{this._ctrl.onGearClick(e,t)},this._onToggleClick=e=>{this._ctrl.onToggleClick(e,t)},t.addEventListener("click",this._onGearClick),t.addEventListener("click",this._onToggleClick),this._onSidePanelClosed=()=>{this._ctrl.monitoringCache.invalidate(),this._ctrl.graphSettingsCache.invalidate()},t.addEventListener("side-panel-closed",this._onSidePanelClosed),this._onGraphSettingsChanged=()=>this._ctrl.onGraphSettingsChanged(t),t.addEventListener("graph-settings-changed",this._onGraphSettingsChanged);try{await this._ctrl.loadHistory()}catch{}this._ctrl.updateDOM(t);const f=t.querySelector(".slide-confirm");f&&(this._ctrl.bindSlideConfirm(f,t),t.classList.add("switches-disabled")),this._ctrl.setupResizeObserver(t,t),this._ctrl.startIntervals(t),this._foldUnobserve&&this._foldUnobserve(),this._foldUnobserve=cT(t,{rowSelector:".circuit-slot",nameSelector:".circuit-name",foldClass:"is-folded"})}stop(){this._ctrl.stopIntervals(),this._foldUnobserve&&(this._foldUnobserve(),this._foldUnobserve=null),this._container&&(this._onGearClick&&(this._container.removeEventListener("click",this._onGearClick),this._onGearClick=null),this._onToggleClick&&(this._container.removeEventListener("click",this._onToggleClick),this._onToggleClick=null),this._onSidePanelClosed&&(this._container.removeEventListener("side-panel-closed",this._onSidePanelClosed),this._onSidePanelClosed=null),this._onGraphSettingsChanged&&(this._container.removeEventListener("graph-settings-changed",this._onGraphSettingsChanged),this._onGraphSettingsChanged=null))}}const hT="\n display:flex;align-items:center;gap:8px;margin-bottom:8px;\n",pT="\n background:var(--secondary-background-color,#333);\n border:1px solid var(--divider-color);\n color:var(--primary-text-color);\n border-radius:4px;padding:6px 10px;width:80px;font-size:0.85em;\n",fT="\n min-width:130px;font-size:0.85em;color:var(--secondary-text-color);\n",gT="\n min-width:160px;font-size:0.85em;color:var(--secondary-text-color);\n",vT="\n background:var(--secondary-background-color,#333);\n border:1px solid var(--divider-color);\n color:var(--primary-text-color);\n border-radius:4px;padding:6px 10px;flex:1;font-size:0.85em;\n font-family:monospace;\n";function mT(t,e,n,i,r){return`\n ${i}\n `}class yT{constructor(){this.errorStore=null,this._debounceTimer=null,this._configEntryId=null,this._notifyCloseHandler=null,this._headerHTML=""}stop(){this._notifyCloseHandler&&(document.removeEventListener("click",this._notifyCloseHandler),this._notifyCloseHandler=null),this._debounceTimer&&(clearTimeout(this._debounceTimer),this._debounceTimer=null)}async render(t,e,i,r=""){let o;void 0!==i&&(this._configEntryId=i),this._headerHTML=r,this._notifyCloseHandler&&(document.removeEventListener("click",this._notifyCloseHandler),this._notifyCloseHandler=null);try{const t={};this._configEntryId&&(t.config_entry_id=this._configEntryId);const n=await e.callWS({type:"call_service",domain:s,service:"get_monitoring_status",service_data:t,return_response:!0});o=function(t){if(!t||"object"!=typeof t)return null;const e=t,n={};return"boolean"==typeof e.enabled&&(n.enabled=e.enabled),e.global_settings&&"object"==typeof e.global_settings&&(n.global_settings=e.global_settings),e.circuits&&"object"==typeof e.circuits&&(n.circuits=e.circuits),e.mains&&"object"==typeof e.mains&&(n.mains=e.mains),n}(n?.response)}catch(t){console.warn("SPAN Panel: monitoring status fetch failed",t),o=null}const a=o?.global_settings??{},l=!0===o?.enabled,c=o?.circuits??{},d=o?.mains??{},u=new Set;for(const t of Object.keys(e.states||{}))t.startsWith("notify.")&&u.add(t);const h=new Set(["notify","send_message"]);for(const t of Object.keys(e.services?.notify||{}))h.has(t)||u.add(`notify.${t}`);u.add("event_bus");const p=[...u].sort(),f=a.notify_targets??"",g=("string"==typeof f?f.split(","):f).map(t=>t.trim()).filter(Boolean),v=p.length>0&&p.every(t=>g.includes(t)),m=a.notification_title_template??"SPAN: {name} {alert_type}",y=a.notification_message_template??"{name} at {current_a}A ({utilization_pct}% of {breaker_rating_a}A rating)",_=a.notification_priority??"default",b=Object.entries(c).sort(([,t],[,e])=>(t.name??"").localeCompare(e.name??"")),x=Object.entries(d),w=[...b,...x],S=w.length>0&&w.every(([,t])=>!1!==t.monitoring_enabled),C=w.some(([,t])=>!1!==t.monitoring_enabled),k=b.map(([t,e])=>{const i=Bt(e.name??t),r=!1!==e.monitoring_enabled,o=!0===e.has_override,a=r?"":"opacity:0.4;",s=Bt(t);return`\n \n \n \n \n ${mT(s,"continuous_threshold_pct",e.continuous_threshold_pct,"%","circuit")}\n ${mT(s,"spike_threshold_pct",e.spike_threshold_pct,"%","circuit")}\n ${mT(s,"window_duration_m",e.window_duration_m,"m","circuit")}\n ${mT(s,"cooldown_duration_m",e.cooldown_duration_m,"m","circuit")}\n \n ${o?``:""}\n \n \n `}).join(""),M=Object.entries(d).map(([t,e])=>{const i=Bt(e.name??t),r=!1!==e.monitoring_enabled,o=!0===e.has_override,a=r?"":"opacity:0.4;",s=Bt(t);return`\n \n \n \n \n ${mT(s,"continuous_threshold_pct",e.continuous_threshold_pct,"%","mains")}\n ${mT(s,"spike_threshold_pct",e.spike_threshold_pct,"%","mains")}\n ${mT(s,"window_duration_m",e.window_duration_m,"m","mains")}\n ${mT(s,"cooldown_duration_m",e.cooldown_duration_m,"m","mains")}\n \n ${o?``:""}\n \n \n `}).join("");t.innerHTML=`\n ${this._headerHTML}\n
\n

${n("monitoring.heading")}

\n\n
\n
\n

${n("monitoring.global_settings")}

\n \n
\n\n
\n
\n ${n("monitoring.continuous")}\n \n
\n
\n ${n("monitoring.spike")}\n \n
\n
\n ${n("monitoring.window")}\n \n
\n
\n ${n("monitoring.cooldown")}\n \n
\n\n
\n

${n("notification.heading")}

\n\n
\n ${n("notification.targets")}\n \n
\n \n
\n ${0===p.length?`
${n("notification.no_targets")}
`:p.map(t=>{const i=g.includes(t),r="event_bus"===t,o=r?null:e.states[t],a=o?.attributes?.friendly_name,s=r?n("notification.event_bus_target"):a?`${Bt(a)} (${Bt(t)})`:Bt(t);return``}).join("")}\n
\n
\n
\n\n
\n ${n("notification.priority")}\n \n \n ${"critical"===_?n("notification.hint.critical"):"time-sensitive"===_?n("notification.hint.time_sensitive"):"passive"===_?n("notification.hint.passive"):"active"===_?n("notification.hint.active"):""}\n \n
\n\n
\n ${n("notification.title_template")}\n \n
\n\n
\n ${n("notification.message_template")}\n \n
\n\n
\n ${n("notification.placeholders")} {name} {entity_id} {alert_type}\n {current_a} {breaker_rating_a} {threshold_pct}\n {utilization_pct} {window_m} {local_time}\n
\n
\n ${n("notification.event_bus_help")} span_panel_current_alert\n ${n("notification.event_bus_payload")} alert_source alert_id\n alert_name alert_type current_a\n breaker_rating_a threshold_pct utilization_pct\n panel_serial window_duration_s local_time\n
\n\n
\n ${n("notification.test_label")}\n \n \n
\n
\n\n
\n
\n\n

${n("monitoring.monitored_points")}

\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n ${M}\n ${k}\n \n
${n("monitoring.col.name")}${n("monitoring.col.continuous")}${n("monitoring.col.spike")}${n("monitoring.col.window")}${n("monitoring.col.cooldown")}
\n \n
\n
\n `;const T=t.querySelector("#toggle-all-circuits");T&&!S&&C&&(T.indeterminate=!0);const I=t.querySelector("#notify-all-targets");if(I&&p.length>0){const t=g.length>0;!v&&t&&(I.indeterminate=!0)}this._bindGlobalControls(t,e),this._bindNotifyTargetSelect(t,e),this._bindNotificationSettings(t,e),this._bindToggleAll(t,e,c,d),this._bindCircuitToggles(t,e),this._bindMainsToggles(t,e),this._bindThresholdInputs(t,e),this._bindResetButtons(t,e)}_serviceData(t){return this._configEntryId&&(t.config_entry_id=this._configEntryId),t}_callSetGlobal(t,e){return t.callWS({type:"call_service",domain:s,service:"set_global_monitoring",service_data:this._serviceData({...e})})}_bindGlobalControls(t,e){const i=t.querySelector("#monitoring-enabled"),r=t.querySelector("#global-fields"),o=t.querySelector("#global-status"),a=()=>{const e=[["continuous_threshold_pct","#g-continuous"],["spike_threshold_pct","#g-spike"],["window_duration_m","#g-window"],["cooldown_duration_m","#g-cooldown"]],n={};for(const[i,r]of e){const e=t.querySelector(r);if(!e)return null;const o=parseInt(e.value,10);if(Number.isNaN(o))return null;n[i]=o}return n},s=(t,e,i)=>{if(!t)return;const r=e instanceof Error?e.message:i;t.textContent=`${n("error.prefix")} ${r}`,t.style.color="var(--error-color, #f44336)"},l=()=>{this._debounceTimer&&clearTimeout(this._debounceTimer),this._debounceTimer=setTimeout(async()=>{const i=a();if(i)try{await this._callSetGlobal(e,i),await this.render(t,e)}catch(t){s(o,t,n("error.failed_save"))}else s(o,null,n("error.failed_save"))},p)};i&&i.addEventListener("change",async()=>{const o=i.checked;r&&(r.style.opacity=o?"":"0.4",r.style.pointerEvents=o?"":"none");const l=t.querySelector("#global-status");try{if(o){const t=a();if(!t)return void s(l,null,n("error.failed"));await this._callSetGlobal(e,t)}else await this._callSetGlobal(e,{enabled:!1})}catch(t){return void s(l,t,n("error.failed"))}await this.render(t,e)});for(const e of t.querySelectorAll("#global-fields input[type=number]"))e.addEventListener("input",l)}_bindNotifyTargetSelect(t,e){const i=t.querySelector("#notify-target-btn"),r=t.querySelector("#notify-target-dropdown"),o=t.querySelector("#notify-target-label");if(!i||!r)return;i.addEventListener("click",t=>{t.stopPropagation();const e="none"!==r.style.display;r.style.display=e?"none":"block"});const a=e=>{const n=t.querySelector("#notify-target-select");n&&!n.contains(e.target)&&(r.style.display="none")};document.addEventListener("click",a),this._notifyCloseHandler=a;const s=()=>{const i=[...t.querySelectorAll(".notify-target-cb:checked")].map(t=>t.value);if(o){const t=i.map(t=>"event_bus"===t?n("notification.event_bus_target"):t);o.textContent=t.length?t.join(", "):n("notification.none_selected")}const r=t.querySelector("#notify-all-targets");if(r){const e=[...t.querySelectorAll(".notify-target-cb")];r.checked=e.length>0&&e.every(t=>t.checked),r.indeterminate=!r.checked&&e.some(t=>t.checked)}this._debounceTimer&&clearTimeout(this._debounceTimer),this._debounceTimer=setTimeout(async()=>{try{await this._callSetGlobal(e,{notify_targets:i.join(", ")})}catch(t){console.warn("SPAN Panel: notification targets save failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})}},p)},l=t.querySelector("#notify-all-targets");l&&l.addEventListener("change",()=>{for(const e of t.querySelectorAll(".notify-target-cb"))e.checked=l.checked;const e=t.querySelector("#notify-target-btn");e&&(e.style.opacity=l.checked?"0.4":"",e.style.pointerEvents=l.checked?"none":""),l.checked&&(r.style.display="none"),s()});for(const e of t.querySelectorAll(".notify-target-cb"))e.addEventListener("change",()=>{s()})}_bindNotificationSettings(t,e){const i=t.querySelector("#g-priority"),r=t.querySelector("#g-title-template"),o=t.querySelector("#g-message-template"),a=(t,i)=>{this._debounceTimer&&clearTimeout(this._debounceTimer),this._debounceTimer=setTimeout(async()=>{try{await this._callSetGlobal(e,{[t]:i})}catch(t){console.warn("SPAN Panel: notification settings save failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})}},p)};i&&i.addEventListener("change",async()=>{try{await this._callSetGlobal(e,{notification_priority:i.value}),await this.render(t,e)}catch(t){console.warn("SPAN Panel: notification priority change failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})}}),r&&r.addEventListener("input",()=>{a("notification_title_template",r.value)}),o&&o.addEventListener("input",()=>{a("notification_message_template",o.value)});const l=t.querySelector("#test-notification-btn"),c=t.querySelector("#test-notification-status");l&&l.addEventListener("click",async()=>{l.disabled=!0,c&&(c.textContent=n("notification.test_sending"),c.style.color="var(--secondary-text-color)");try{this._debounceTimer&&(clearTimeout(this._debounceTimer),this._debounceTimer=null);const i=[...t.querySelectorAll(".notify-target-cb:checked")].map(t=>t.value).join(", ");await this._callSetGlobal(e,{notify_targets:i});const r={};this._configEntryId&&(r.config_entry_id=this._configEntryId),await e.callWS({type:"call_service",domain:s,service:"test_notification",service_data:r}),c&&(c.textContent=n("notification.test_sent"),c.style.color="var(--success-color, #4caf50)")}catch(t){if(c){const e=t instanceof Error?t.message:n("error.failed");c.textContent=`${n("error.prefix")} ${e}`,c.style.color="var(--error-color, #f44336)"}}finally{l.disabled=!1}})}_bindToggleAll(t,e,i,r){const o=t.querySelector("#toggle-all-circuits");o&&o.addEventListener("change",async()=>{const a=o.checked,l=[...Object.keys(i).map(t=>e.callWS({type:"call_service",domain:s,service:"set_circuit_threshold",service_data:this._serviceData({circuit_id:t,monitoring_enabled:a})}).catch(t=>{console.warn("SPAN Panel: circuit monitoring toggle failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})})),...Object.keys(r).map(t=>e.callWS({type:"call_service",domain:s,service:"set_mains_threshold",service_data:this._serviceData({leg:t,monitoring_enabled:a})}).catch(t=>{console.warn("SPAN Panel: mains monitoring toggle failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})}))];await Promise.all(l),await this.render(t,e)})}_bindMainsToggles(t,e){for(const i of t.querySelectorAll(".mains-toggle"))i.addEventListener("change",async()=>{const r=i.dataset.entity,o=i.checked;try{await e.callWS({type:"call_service",domain:s,service:"set_mains_threshold",service_data:this._serviceData({leg:r,monitoring_enabled:o})})}catch(t){return console.warn("SPAN Panel: mains threshold toggle failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1}),void(i.checked=!o)}await this.render(t,e)})}_bindCircuitToggles(t,e){for(const i of t.querySelectorAll(".circuit-toggle"))i.addEventListener("change",async()=>{const r=i.dataset.entity,o=i.checked;try{await e.callWS({type:"call_service",domain:s,service:"set_circuit_threshold",service_data:this._serviceData({circuit_id:r,monitoring_enabled:o})})}catch(t){return console.warn("SPAN Panel: circuit threshold toggle failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1}),void(i.checked=!o)}await this.render(t,e)})}_bindThresholdInputs(t,e){const i=new Map;for(const r of t.querySelectorAll(".threshold-input"))r.addEventListener("input",()=>{const o=`${r.dataset.entity}-${r.dataset.field}`,a=i.get(o);a&&clearTimeout(a),i.set(o,setTimeout(async()=>{const i=parseInt(r.value,10);if(!i||i<1)return;const o=r.dataset.entity,a=r.dataset.field,l=r.dataset.type,c="mains"===l?"set_mains_threshold":"set_circuit_threshold",d="mains"===l?"leg":"circuit_id";try{await e.callWS({type:"call_service",domain:s,service:c,service_data:this._serviceData({[d]:o,[a]:i})}),await this.render(t,e)}catch(t){console.warn("SPAN Panel: threshold input save failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1}),r.style.borderColor="var(--error-color, #f44336)"}},800))})}_bindResetButtons(t,e){for(const n of t.querySelectorAll(".reset-btn"))n.addEventListener("click",async()=>{const i=n.dataset.entity;if(!i)return;const r=n.dataset.type,o="mains"===r?"clear_mains_threshold":"clear_circuit_threshold",a=this._serviceData("mains"===r?{leg:i}:{circuit_id:i});await e.callService(s,o,a),await this.render(t,e)})}}function _T(t,e){return null!==t&&t.toLowerCase().includes(e)}function bT(t,e){const n=t[e];return"string"==typeof n?n:""}function xT(t,e){return{enabled:null!==e&&null===e.disabledBy,name:null===e?"":e.name,icon:null===e?"":e.icon,deviceClass:t.curation.device_class??"",stateClass:t.curation.state_class??"",promote:"none"===t.curation.entity_category}}function wT(t,e){const n=Number(e);return{unit_of_measurement:t||null,display_precision:""!==e&&Number.isFinite(n)?n:null}}function ST(t,e,n){return""!==t&&e.includes(t)&&n.length>0}function CT(t){if(!t)return"";const e=t.replace(/_/g," ");return e.charAt(0).toUpperCase()+e.slice(1)}const kT=["0","1","2","3","4","5","6"],MT="var(--secondary-text-color,#999)",TT="var(--primary-color,#4dd9af)",IT="var(--warning-color,#ff9800)",DT="var(--card-background-color,#1c1c1c)",AT="var(--secondary-background-color,#232323)",PT="var(--divider-color,#333)",LT=`background:${AT};border:1px solid ${PT};color:var(--primary-text-color);border-radius:4px;padding:4px 8px;font-size:0.85em;`,ET=`${LT}min-width:0;`,zT="display:flex;align-items:center;gap:12px;flex-wrap:wrap;",NT=`font-size:0.85em;color:${MT};min-width:130px;`,OT=`color:${MT};font-size:0.75em;`,$T=`background:none;border:1px solid ${PT};color:var(--primary-text-color);border-radius:6px;padding:6px 12px;font-size:0.8em;cursor:pointer;`;function RT(t,e){const n=e?TT:MT;return`${Bt(t)}`}function HT(t,e,n){const i=e.map(t=>``).join("");return``}function FT(t){return"total_increasing"===t?n("adopted.warn_total_increasing"):"statistics_removed"===t?n("adopted.warn_statistics_removed"):t}function BT(t){return t instanceof Error||t&&"object"==typeof t&&"string"==typeof t.message?t.message:String(t)}class VT{constructor(){this._container=null,this._hass=null,this._deviceId=null,this._groups=[],this._loadError=null,this._query="",this._expandedKey=null,this._editor=null,this._unitsCache=new Map,this._refreshTimer=null,this._onClick=t=>{this._handleClick(t)},this._onInput=t=>{this._handleInput(t)},this._onChange=t=>{this._handleChange(t)}}stop(){this._refreshTimer&&(clearTimeout(this._refreshTimer),this._refreshTimer=null),this._unbind()}async render(t,e,n){const i=void 0===n?this._deviceId:n||null;i!==this._deviceId&&(this._deviceId=i,this._expandedKey=null,this._editor=null,this._query=""),this._unbind(),this._container=t,this._hass=e,await this._fetchList(e,!1),this._paint()}async _fetchList(t,e){if(!this._deviceId)return this._groups=[],void(this._loadError=null);try{const e=await t.callWS({type:`${s}/adopted/list`,device_id:this._deviceId});this._groups=Array.isArray(e?.devices)?e.devices:[],this._loadError=null}catch(t){if(e)return;this._groups=[],this._loadError=BT(t)}}async _fetchSeed(t,e){try{return function(t){if(!t||"object"!=typeof t)return null;const e=t;if("string"!=typeof e.entity_id)return null;const n=e.options&&"object"==typeof e.options?e.options:{},i=n.sensor&&"object"==typeof n.sensor?n.sensor:{},r=i.display_precision;return{disabledBy:"string"==typeof e.disabled_by?e.disabled_by:null,name:bT(e,"name"),icon:bT(e,"icon"),unit:bT(i,"unit_of_measurement"),precision:"number"==typeof r?String(r):""}}(await t.callWS({type:"config/entity_registry/get",entity_id:e}))}catch{return null}}async _fetchUnits(t,e){if(!e)return[];const n=this._unitsCache.get(e);if(n)return n;try{const n=await t.callWS({type:"sensor/device_class_convertible_units",device_class:e}),i=Array.isArray(n?.units)?n.units.filter(t=>"string"==typeof t):[];return this._unitsCache.set(e,i),i}catch{return[]}}_findRow(t){for(const e of this._groups)for(const n of e.rows)if(n.key===t)return n;return null}_paint(){const t=this._container;t&&(t.innerHTML=`\n
\n

${n("adopted.heading")}

\n

${n("adopted.description")}

\n\n
\n \n \n
\n\n
${this._groupsHTML()}
\n
\n `,this._bind())}_paintGroups(){const t=this._container?.querySelector("#adopted-groups");t&&(t.innerHTML=this._groupsHTML())}_groupsHTML(){if(null!==this._loadError)return`

${Bt(n("adopted.load_failed"))} — ${Bt(this._loadError)}

`;if(0===this._groups.length)return`

${n("adopted.none")}

`;const t=function(t,e){const n=e.trim().toLowerCase();if(!n)return t;const i=[];for(const e of t){if(_T(e.name,n)){i.push(e);continue}const t=e.rows.filter(t=>_T(t.name,n));t.length>0&&i.push({...e,rows:t})}return i}(this._groups,this._query);return 0===t.length?`

${n("adopted.no_results")}

`:t.map(t=>this._groupHTML(t)).join("")}_groupHTML(t){const e=t.adopted_device?RT(n("adopted.adopted_device"),!1):RT(n("adopted.vendor_readings"),!0),r=i("adopted.count",{count:String(t.rows.length)}),o=t.adopted_device?`${n("adopted.via_panel")} · ${r}`:r;return`\n
\n ${Bt(t.name??"")}\n ${e}\n ${Bt(o)}\n
\n
\n ${t.rows.map(t=>this._rowHTML(t)).join("")}\n
\n `}_rowHTML(t){const e=this._expandedKey===t.key,i=e&&this._editor?this._editorHTML(t,this._editor):"",r=e?`display:flex;align-items:center;padding:12px 16px;gap:10px;background:${AT};border:1px solid ${PT};border-bottom-color:transparent;border-radius:8px 8px 0 0;cursor:pointer;`:`display:flex;align-items:center;padding:12px 16px;gap:10px;background:${DT};border:1px solid ${PT};border-radius:8px;cursor:pointer;`;return`\n
\n
\n ${Bt(t.name)}\n ${Bt(function(t){return t.unit?`${t.platform} · ${t.unit}`:t.platform}(t))}\n ${Object.keys(t.curation).length>0?RT(n("adopted.curated"),!0):""}\n ${t.stale_fields.length>0?RT(n("adopted.stale"),!1):""}\n ${function(t){return``}(e)}\n
\n ${i}\n
\n `}_editorHTML(t,e){const r=[];null!==e.seed?(r.push(this._enableHTML(e)),r.push(this._identityHTML(e))):r.push(`
${n("adopted.registry_unavailable")}
`),t.allowed_device_classes.length>0&&r.push(this._deviceClassHTML(t,e)),t.allowed_state_classes.length>0&&r.push(this._stateClassHTML(t,e)),r.push(this._prominenceHTML(e)),null!==e.seed&&ST(e.form.deviceClass,t.allowed_device_classes,e.units)&&r.push(this._displayHTML(t,e)),t.stale_fields.length>0&&r.push(`
${Bt(i("adopted.stale_note",{fields:t.stale_fields.join(", ")}))}
`),r.push(this._actionsHTML(e)),null!==e.confirm&&r.push(this._confirmHTML(t,e)),null!==e.error&&r.push(`
${Bt(e.error)}
`);for(const t of e.notices)r.push(`
${Bt(t)}
`);return r.push(`
${Bt(function(t){const e=[t.path,t.datatype];return t.unit&&e.push(t.unit),e.push(t.settable?n("adopted.settable"):n("adopted.read_only")),e.join(" · ")}(t))}
`),`\n
\n ${r.join("")}\n
\n `}_enableHTML(t){const e=t.form.enabled,i=e?TT:MT;return`\n
\n ${n("adopted.enable_entity")}\n \n ${n("adopted.enable_note")}\n
\n `}_identityHTML(t){return`\n
\n ${n("adopted.name")}\n \n ${n("adopted.icon")}\n \n
\n `}_deviceClassHTML(t,e){const r=[{value:"",label:n("adopted.no_device_class")},...t.allowed_device_classes.map(t=>({value:t,label:CT(t)}))],o=t.unit?i("adopted.device_class_note",{unit:t.unit}):n("adopted.device_class_note_unitless");return`\n
\n ${n("adopted.device_class")}\n ${HT("device_class",r,e.form.deviceClass)}\n ${Bt(o)}\n
\n `}_stateClassHTML(t,e){const i=[{value:"",label:n("adopted.no_statistics")},...t.allowed_state_classes.map(t=>({value:t,label:CT(t)}))];return`\n
\n ${n("adopted.statistics_class")}\n ${HT("state_class",i,e.form.stateClass)}\n ${n("adopted.statistics_note")}\n
\n `}_prominenceHTML(t){const e=(t,e,n)=>``;return`\n
\n ${n("adopted.prominence")}\n \n ${e("diagnostic",n("adopted.diagnostic"),!t.form.promote)}\n ${e("standard",n("adopted.standard"),t.form.promote)}\n \n
\n `}_displayHTML(t,e){const i=[{value:"",label:t.unit?`${t.unit} — ${n("adopted.unit_as_published")}`:n("adopted.unit_as_published")},...e.units.map(t=>({value:t,label:t}))],r=[{value:"",label:n("adopted.precision_default")},...kT.map(t=>({value:t,label:t}))];return`\n
\n ${n("adopted.display_unit")}\n ${HT("unit",i,e.unit)}\n ${n("adopted.precision")}\n ${HT("precision",r,e.precision)}\n
\n `}_actionsHTML(t){const e=t.busy?" disabled":"";return`\n
\n \n \n ${n("adopted.reload_note")}\n
\n `}_confirmHTML(t,e){const r="clearing"===e.confirm,o=r?i("adopted.confirm_clearing_subject",{name:t.name}):i("adopted.confirm_setting",{name:t.name,value:CT(e.form.stateClass)}),a=n(r?"adopted.confirm_clearing":"adopted.confirm_total_increasing");return`\n
\n
\n \n ${n("adopted.confirm_heading")}\n
\n

${Bt(o)}

\n

${a}

\n
\n \n \n
\n
\n `}_bind(){const t=this._container;t&&(t.addEventListener("click",this._onClick),t.addEventListener("input",this._onInput),t.addEventListener("change",this._onChange))}_unbind(){const t=this._container;t&&(t.removeEventListener("click",this._onClick),t.removeEventListener("input",this._onInput),t.removeEventListener("change",this._onChange))}async _handleClick(t){const e=t.target;if(!e)return;const n=e.closest("[data-action]");if(n)return void await this._handleAction(n);const i=e.closest(".adopted-row-header"),r=i?.dataset.key;return r?this._expandedKey===r?(this._expandedKey=null,this._editor=null,void this._paintGroups()):void await this._openEditor(r):void 0}async _handleAction(t){const e=this._editor;if(e)switch(t.dataset.action){case"toggle-enable":return e.form={...e.form,enabled:!e.form.enabled},void this._paintGroups();case"prominence":return e.form={...e.form,promote:"standard"===t.dataset.value},void this._paintGroups();case"save":return void(e.busy||await this._save(!1));case"clear":return void(e.busy||await this._save(!0));case"confirm-cancel":return e.confirm=null,void this._paintGroups();case"confirm-save":{const t=e.confirmClears;return e.confirm=null,void await this._commit(t)}default:return}}_handleInput(t){const e=t.target;if(!e)return;if("adopted-filter"===e.id)return this._query=e.value,void this._paintGroups();const n=this._editor,i=e.dataset.field;if(!n||!i)return;const r=e.value;"name"===i&&(n.form={...n.form,name:r}),"icon"===i&&(n.form={...n.form,icon:r})}async _handleChange(t){const e=t.target,n=this._editor,i=e?.dataset.field;if(e&&n&&i)switch(i){case"device_class":return n.form={...n.form,deviceClass:e.value},n.units=this._hass?await this._fetchUnits(this._hass,e.value):[],void this._paintGroups();case"state_class":return void(n.form={...n.form,stateClass:e.value});case"unit":return void(n.unit=e.value);case"precision":return void(n.precision=e.value);default:return}}async _openEditor(t){const e=this._findRow(t),n=this._hass;if(!e||!n)return;this._expandedKey=t,this._editor={form:xT(e,null),seed:null,units:[],unit:"",precision:"",confirm:null,confirmClears:!1,busy:!0,error:null,notices:[]},this._paintGroups();const i=null===e.entity_id?null:await this._fetchSeed(n,e.entity_id);if(this._expandedKey!==t)return;const r=xT(e,i),o=await this._fetchUnits(n,r.deviceClass);this._expandedKey===t&&(this._editor={form:r,seed:i,units:o,unit:i?.unit??"",precision:i?.precision??"",confirm:null,confirmClears:!1,busy:!1,error:null,notices:[]},this._paintGroups())}async _save(t){const e=this._editor,n=null===this._expandedKey?null:this._findRow(this._expandedKey);if(!e||!n)return;const i=function(t,e){return"total_increasing"===e.stateClass?"total_increasing":t.curation.state_class&&""===e.stateClass?"clearing":null}(n,this._formFor(e,t));if(null!==i)return e.confirm=i,e.confirmClears=t,e.error=null,e.notices=[],void this._paintGroups();await this._commit(t)}_formFor(t,e){return e?{...t.form,deviceClass:"",stateClass:"",promote:!1}:t.form}async _commit(t){const e=this._editor,i=this._hass,r=null===this._expandedKey?null:this._findRow(this._expandedKey);if(!(e&&i&&r&&this._deviceId))return;const o=this._formFor(e,t),a=function(t,e){const n={};return e.deviceClass&&(n.device_class=e.deviceClass),e.stateClass&&(n.state_class=e.stateClass),e.promote&&(n.entity_category="none"),{registryUpdate:null===t.entity_id?null:{type:"config/entity_registry/update",entity_id:t.entity_id,name:e.name||null,icon:e.icon||null,disabled_by:e.enabled?null:"user"},curate:{key:t.key,record:n}}}(r,o);e.busy=!0,e.error=null,e.notices=[],this._paintGroups();let l=e.seed;try{const c=t||null===l?null:function(t,e,n){if(null===t.registryUpdate||null===e||!function(t,e){return null!==t&&(e.enabled!==(null===t.disabledBy)||e.name!==t.name||e.icon!==t.icon)}(e,n))return null;const i={...t.registryUpdate};return function(t,e){return e.enabled===(null===t.disabledBy)}(e,n)&&delete i.disabled_by,i}(a,l,o);null!==c&&null!==l&&(await i.callWS(c),l=function(t,e){return{...t,disabledBy:e.enabled?null:t.disabledBy??"user",name:e.name,icon:e.icon}}(l,o),e.seed=l),!t&&null!==l&&null!==r.entity_id&&ST(o.deviceClass,r.allowed_device_classes,e.units)&&function(t,e,n){return null!==t&&(e!==t.unit||n!==t.precision)}(l,e.unit,e.precision)&&(await i.callWS({type:"config/entity_registry/update",entity_id:r.entity_id,options_domain:"sensor",options:wT(e.unit,e.precision)}),l={...l,unit:e.unit,precision:e.precision},e.seed=l);const d=await i.callWS({type:`${s}/adopted/curate`,device_id:this._deviceId,key:a.curate.key,record:a.curate.record});r.curation=d?.record??{},e.form=o,e.notices=[n("adopted.saved"),...Array.isArray(d?.warnings)?d.warnings.map(FT):[]]}catch(t){return e.error=BT(t),e.busy=!1,void this._paintGroups()}e.busy=!1,this._paintGroups(),this._scheduleRefresh()}_scheduleRefresh(){this._refreshTimer&&clearTimeout(this._refreshTimer),this._refreshTimer=setTimeout(()=>{this._refreshTimer=null;const t=this._hass;t&&this._fetchList(t,!0).then(()=>this._paintGroups())},2e3)}}function WT(t=""){const e=t?` value="${Bt(t)}"`:"",i=t?"":"display:none;";return`\n
\n \n \n
\n `}function UT(t,e,i,r,o,a,s){const c=e.entities?.power,d=c?i.states[c]:null,u=d&&parseFloat(d.state)||0,h=e.entities?.switch,p=h?i.states[h]:null,f=p?"on"===p.state:(d?.attributes?.relay_state||e.relay_state)===l,g=e.breaker_rating_a,m=g?`${Math.round(g)}A`:"",y=Bt(e.name||n("grid.unknown")),_=ye(r),b="current"===_.entityRole;let x;if(f)if(b){const t=e.entities?.current,n=t?i.states[t]:null,r=n&&parseFloat(n.state)||0;x=`${_.format(r)}A`}else x=`${pe(u)}${he(u)}`;else x="";const w=a||"unknown";let S="";if("unknown"!==w){const t=v[w]??v.unknown??{icon:"mdi:help",color:"#999",label:()=>"Unknown"};S=t.icon2?`\n \n \n `:t.textLabel?`\n \n ${t.textLabel}\n `:``}let C="",k=o?.utilization_pct??null;if(null==k&&e.breaker_rating_a){const t=e.entities?.current,n=t?i.states[t]:null,r=n?Math.abs(parseFloat(n.state)||0):0;k=Math.round(r/e.breaker_rating_a*1e3)/10}if(null!=k){C=`=80?"utilization-warning":"utilization-normal"}">${Math.round(k)}%`}const M=``,T=!1!==e.is_user_controllable&&!!e.entities?.switch?`
\n ${n(f?"grid.on":"grid.off")}\n \n
`:`${f?"ON":"OFF"}`;return`\n
\n ${m?`${m}`:""}\n ${C}\n ${y}\n ${S}\n ${T}\n \n ${x}\n \n ${M}\n \n
\n `}function GT(t,e,n,i,r){const o=e.entities?.power,a=o?n.states[o]:null,s=a&&parseFloat(a.state)||0,d=e.device_type===c||s<0,u=e.entities?.switch,h=u?n.states[u]:null,p=Se(0,r,h?"on"===h.state:(a?.attributes?.relay_state||e.relay_state)===l,d),f=Bt(t);return`\n
\n
\n
\n
\n
\n `}function qT(t){return`
${Bt(t)}
`}function jT(t,e,n){const i=t.entities?.switch,r=i?e.states[i]:null,o=t.entities?.power,a=o?e.states[o]:null,s=r?"on"===r.state:(a?.attributes?.relay_state||t.relay_state)===l;let c;if("current"===(n.chart_metric||"power")){const n=t.entities?.current,i=n?e.states[n]:null;c=i?Math.abs(parseFloat(i.state)||0):0}else c=a?Math.abs(parseFloat(a.state)||0):0;return{isOn:s,value:c}}function XT(t,e){if(t.always_on)return"always_on";const n=t.entities?.select,i=n?e.states[n]:null;return i?i.state:"unknown"}function YT(t,e,n,i){const r=jT(t,n,i),o=jT(e,n,i);return r.isOn&&!o.isOn?-1:!r.isOn&&o.isOn?1:o.value-r.value}function ZT(t,e,n){return t.sort((t,i)=>YT(t[1],i[1],e,n))}function KT(t){return t.entities?.current??t.entities?.power??""}class QT{constructor(t){this._expandedUuids=new Set,this._searchQuery="",this._container=null,this._clickHandler=null,this._inputHandler=null,this._graphSettingsHandler=null,this._hass=null,this._topology=null,this._config=null,this._monitoringStatus=null,this._viewName=null,this._columns=1,this._foldUnobserve=null,this._ctrl=t}setColumns(t){const e=Math.max(1,Math.min(3,Math.floor(t)));this._columns=e}setInitialExpansion(t){this._expandedUuids=new Set(t)}setInitialSearchQuery(t){this._searchQuery=t}setViewName(t){this._viewName=t}renderActivityView(t,e,n,i,r,o){this._unbindEvents(),this._hass=e,this._topology=n,this._config=i,this._monitoringStatus=r;const a=ZT(Object.entries(n.circuits),e,i);let s=o+WT(this._searchQuery);s+=`
`;for(const[t,n]of a){const o=we(r,KT(n)),a=XT(n,e),l=this._expandedUuids.has(t);s+=`
`,s+=UT(t,n,e,i,o,a,l),l&&(s+=GT(t,n,e,0,o)),s+="
"}s+="
",s+="",t.innerHTML=s;const l=t.querySelector("span-side-panel");l&&(l.hass=e,l.errorStore=this._ctrl.errorStore),this._bindEvents(t),this._searchQuery&&this._applyFilter(t),this._ctrl.updateDOM(t),this._attachFoldObserver(t)}renderAreaView(t,e,i,r,o,a){this._unbindEvents(),this._hass=e,this._topology=i,this._config=r,this._monitoringStatus=o;const s=n("list.unassigned_area"),l=new Map;for(const[t,e]of Object.entries(i.circuits)){const n=e.area??s,i=l.get(n);i?i.push([t,e]):l.set(n,[[t,e]])}const c=[...l.keys()].sort((t,e)=>t===s?1:e===s?-1:t.localeCompare(e));let d=a+WT(this._searchQuery);d+=`
`;for(const t of c){const n=l.get(t);if(!n)continue;const i=ZT(n,e,r);d+=qT(t);for(const[t,n]of i){const i=we(o,KT(n)),a=XT(n,e),s=this._expandedUuids.has(t);d+=`
`,d+=UT(t,n,e,r,i,a,s),s&&(d+=GT(t,n,e,0,i)),d+="
"}}d+="
",d+="",t.innerHTML=d;const u=t.querySelector("span-side-panel");u&&(u.hass=e,u.errorStore=this._ctrl.errorStore),this._bindEvents(t),this._searchQuery&&this._applyFilter(t),this._ctrl.updateDOM(t),this._attachFoldObserver(t)}updateCollapsedRows(t,e,i,r){const o=ye(r),a="current"===o.entityRole,s=t.querySelectorAll(".list-row[data-row-uuid]");for(const t of s){const s=t.dataset.rowUuid;if(!s)continue;const l=i.circuits[s];if(!l)continue;const{isOn:c,value:d}=jT(l,e,r),u=t.querySelector(".list-power-value");if(u)if(c)if(a)u.innerHTML=`${o.format(d)}A`;else{const t=l.entities?.power,n=t?e.states[t]:null,i=n&&parseFloat(n.state)||0;u.innerHTML=`${pe(i)}${he(i)}`}else u.innerHTML="";const h=t.querySelector(".toggle-pill");if(h){h.classList.toggle("toggle-on",c),h.classList.toggle("toggle-off",!c);const t=h.querySelector(".toggle-label");t&&(t.textContent=n(c?"grid.on":"grid.off"))}const p=t.querySelector(".list-status-badge");p&&(p.textContent=c?"ON":"OFF",p.classList.toggle("list-status-on",c),p.classList.toggle("list-status-off",!c)),t.classList.toggle("circuit-off",!c)}!function(t,e,n,i){const r=t.querySelector(".list-view");if(r)for(const t of function(t,e){let n={anchor:null,units:[]};const i=[n];for(const r of[...t.children])if(r.classList.contains("area-header"))n={anchor:r,units:[]},i.push(n);else if(r.classList.contains("list-cell")){const t=r.dataset.cellUuid,i=t?e.circuits[t]:void 0;t&&i&&n.units.push({cell:r,uuid:t,circuit:i})}return i}(r,n)){if(t.units.length<2)continue;const n=[...t.units].sort((t,n)=>YT(t.circuit,n.circuit,e,i));if(!n.some((e,n)=>e.uuid!==t.units[n].uuid))continue;let o=t.anchor;for(const t of n)o?o.after(t.cell):r.prepend(t.cell),o=t.cell}}(t,e,i,r)}stop(){this._unbindEvents(),null===this._viewName&&(this._expandedUuids.clear(),this._searchQuery=""),this._hass=null,this._topology=null,this._config=null,this._monitoringStatus=null}_dispatchFavoritesViewState(){if(!this._viewName||!this._container)return;const t={view:this._viewName,expanded:[...this._expandedUuids],searchQuery:this._searchQuery};this._container.dispatchEvent(new CustomEvent("favorites-view-state-changed",{detail:t,bubbles:!0,composed:!0}))}_bindEvents(t){this._container=t,this._clickHandler=e=>{const n=e.target;if(!n)return;const i=n.closest(".list-expand-toggle");if(i){const t=i.dataset.expandUuid;return void(t&&this._toggleExpand(t))}if(n.closest(".gear-icon"))return void this._ctrl.onGearClick(e,t);if(n.closest(".toggle-pill"))return void this._ctrl.onToggleClick(e,t);if(n.closest(".list-search-clear")){const e=t.querySelector(".list-search");return void(e&&(e.value="",e.dispatchEvent(new Event("input",{bubbles:!0}))))}const r=n.closest(".unit-btn");if(r){const e=r.dataset.unit;e&&t.dispatchEvent(new CustomEvent("unit-changed",{detail:e,bubbles:!0,composed:!0}))}},this._inputHandler=e=>{const n=e.target;n&&n.classList.contains("list-search")&&(this._searchQuery=n.value.toLowerCase(),this._applyFilter(t),this._dispatchFavoritesViewState())},this._graphSettingsHandler=()=>{this._ctrl.onGraphSettingsChanged(t).then(()=>{this._ctrl.updateDOM(t)}).catch(()=>{})},t.addEventListener("click",this._clickHandler),t.addEventListener("input",this._inputHandler),t.addEventListener("graph-settings-changed",this._graphSettingsHandler);const e=t.querySelector(".slide-confirm");e&&(this._ctrl.bindSlideConfirm(e,t),t.classList.add("switches-disabled"))}_unbindEvents(){this._container&&(this._clickHandler&&this._container.removeEventListener("click",this._clickHandler),this._inputHandler&&this._container.removeEventListener("input",this._inputHandler),this._graphSettingsHandler&&this._container.removeEventListener("graph-settings-changed",this._graphSettingsHandler)),this._foldUnobserve&&(this._foldUnobserve(),this._foldUnobserve=null),this._container=null,this._clickHandler=null,this._inputHandler=null,this._graphSettingsHandler=null}_attachFoldObserver(t){this._foldUnobserve&&(this._foldUnobserve(),this._foldUnobserve=null),this._foldUnobserve=cT(t,{rowSelector:".list-row",nameSelector:".list-circuit-name",foldClass:"is-folded"})}_applyFilter(t){const e=t.querySelector(".list-search-clear");e&&(e.style.display=this._searchQuery?"":"none");const n=t.querySelectorAll(".list-cell[data-cell-uuid]");for(const t of n){const e=t.querySelector(".list-circuit-name"),n=(e?.textContent?.toLowerCase()??"").includes(this._searchQuery);t.style.display=n?"":"none"}const i=t.querySelectorAll(".area-header");for(const t of i){let e=!1,n=t.nextElementSibling;for(;n&&!n.classList.contains("area-header");){if(n.classList.contains("list-cell")&&"none"!==n.style.display){e=!0;break}n=n.nextElementSibling}t.style.display=e?"":"none"}}_toggleExpand(t){if(!(this._container&&this._hass&&this._topology&&this._config))return;const e=nT(t),n=this._container.querySelector(`.list-cell[data-cell-uuid="${e}"]`);if(!n)return;const i=n.querySelector(`.list-row[data-row-uuid="${e}"]`),r=n.querySelector(`.list-expand-toggle[data-expand-uuid="${e}"]`);if(i){if(this._expandedUuids.has(t)){this._expandedUuids.delete(t);const o=n.querySelector(`.list-expanded-content[data-expanded-uuid="${e}"]`);o&&o.remove(),r&&r.classList.remove("expanded"),i.classList.remove("list-row-expanded")}else{this._expandedUuids.add(t);const e=this._topology.circuits[t];if(!e)return;const n=we(this._monitoringStatus,KT(e)),o=GT(t,e,this._hass,this._config,n);i.insertAdjacentHTML("afterend",o),r&&r.classList.add("expanded"),i.classList.add("list-row-expanded"),this._ctrl.updateDOM(this._container)}this._dispatchFavoritesViewState()}}}function JT(t,e){return`${t}|${e}`}class tI{async build(t,e,n,i){const r=new Map;for(const t of n)r.set(t.id,t);const o=i?new qt(i):null,a=[];for(const[n,i]of Object.entries(e)){if(!((i?.circuits?.length??0)>0||(i?.sub_devices?.length??0)>0))continue;const e=r.get(n);e&&a.push((async()=>{try{const i=await se(t,n,o);return{panelDeviceId:n,panel:e,topology:i.topology}}catch(t){return console.warn("SPAN Panel: favorites topology fetch failed",n,t),{panelDeviceId:n,panel:e,topology:null}}})())}const s=(await Promise.all(a)).filter(t=>null!==t.topology),l=s.length>1,c={},d={},u={},h=new Set,p=[];for(const{panelDeviceId:t,panel:n,topology:i}of s){if(!i)continue;const r=n.config_entries?.[0]??null;r&&h.add(r);const o=n.name_by_user??n.name??i.device_name??"";p.push({panelDeviceId:t,panelName:o,topology:i});const a=e[t],s=a?.circuits??[],f=a?.sub_devices??[];for(const e of s){const n=i.circuits?.[e];if(!n)continue;const a=JT(t,e),s=l&&o?`${o} · ${n.name}`:n.name;c[a]={...n,name:s},u[a]={panelDeviceId:t,kind:"circuit",targetId:e,configEntryId:r}}for(const e of f){const n=i.sub_devices?.[e];if(!n)continue;const a=JT(t,e),s=l&&o&&n.name?`${o} · ${n.name}`:n.name??e;d[a]={...n,name:s},u[a]={panelDeviceId:t,kind:"sub_device",targetId:e,configEntryId:r}}}return{topology:{circuits:c,sub_devices:d,panel_entities:{},device_name:"",_favoriteRefs:u},entryIds:Array.from(h),perPanelStats:p}}}const eI="span_panel_favorites_view_state";function nI(t){try{localStorage.setItem(eI,JSON.stringify(t))}catch{}}var iI;const rI="favorites";function oI(t){return"dashboard"===t||"adopted"===t}let aI=iI=class extends Dt{constructor(){super(...arguments),this.narrow=!1,this._panels=[],this._selectedPanelId=null,this._activeTab="dashboard",this._discovered=!1,this._listColumns=Wt(),this._favorites={},this._favoritesViewState={expanded:{activity:[],area:[]}},this._favoritesPanelStats=[],this._dashboardTab=new uT,this._monitoringTab=new yT,this._adoptedTab=new VT,this._listDashCtrl=new lT,this._listCtrl=new QT(this._listDashCtrl),this._favCache=new Yt,this._favCtrl=new tI,this._favoritesMonitoringTabs=new Map,this._errorStore=new Ht,this._watchedPanelId=null,this._discovering=!1,this._refreshSeq=0,this._areaUnsub=null,this._areaSubscribing=!1,this._onFavoritesChanged=null,this._deviceRegistryUnsub=null,this._pendingTabRender=!1,this._persistFavoritesViewStateTimer=null,this._tabRenderScheduler=function(t){let e=null,n=!1;return async function i(){if(e)return n=!0,void await e.catch(()=>{});const r=(async()=>{try{await t()}finally{e=null,n&&(n=!1,await i())}})();e=r,await r}}(async()=>this._renderTab()),this._beginRender=function(){let t=0;return()=>{t+=1;const e=t;return()=>t!==e}}()}get _root(){const t=this.shadowRoot;if(!t)throw new Error("span-panel: shadow root is not available");return t}connectedCallback(){super.connectedCallback(),this._dashboardTab.errorStore=this._errorStore,this._listDashCtrl.errorStore=this._errorStore,this._favCache.errorStore=this._errorStore,this._monitoringTab.errorStore=this._errorStore,this._onFavoritesChanged=()=>{this._refreshFavorites()},document.addEventListener(jt,this._onFavoritesChanged),this._subscribeDeviceRegistry()}disconnectedCallback(){this._dashboardTab.stop(),this._monitoringTab.stop(),this._adoptedTab.stop(),this._listCtrl.stop(),this._listDashCtrl.stopIntervals();for(const t of this._favoritesMonitoringTabs.values())t.stop();this._favoritesMonitoringTabs.clear(),this._areaSubscribing=!1,this._areaUnsub&&(this._areaUnsub(),this._areaUnsub=null),this._onFavoritesChanged&&(document.removeEventListener(jt,this._onFavoritesChanged),this._onFavoritesChanged=null),this._unsubscribeDeviceRegistry(),this._persistFavoritesViewStateTimer&&(clearTimeout(this._persistFavoritesViewStateTimer),this._persistFavoritesViewStateTimer=null),this._errorStore.dispose(),super.disconnectedCallback()}firstUpdated(){this.hass&&!this._discovered&&this._discoverPanels()}updated(t){if(t.has("hass")){const e=t.get("hass");this._dashboardTab.hass=this.hass,this._listDashCtrl.hass=this.hass,this._errorStore.updateHass(this.hass),this._discovered?this._root.getElementById("tab-content")||this._scheduleTabRender():this._discoverPanels(),!e&&this.hass&&this._subscribeDeviceRegistry()}if(this._discovered&&(t.has("_discovered")||t.has("_activeTab")||t.has("_selectedPanelId")||t.has("_chartMetric")||t.has("_listColumns"))){if(this._isFavoritesView&&oI(this._activeTab))return void(this._activeTab="activity");this._scheduleTabRender()}if(t.has("_selectedPanelId")&&(this._selectedPanelId!==rI&&this._selectedPanelId?(this._updatePanelStatusWatch(),this._listDashCtrl.setFavoritesPerPanelInfo(null)):(this._errorStore.clearPanelStatusWatch(),this._watchedPanelId=null)),this._discovered&&(t.has("_panels")||t.has("_selectedPanelId"))){const t=this.shadowRoot?.getElementById("panel-select");t&&null!==this._selectedPanelId&&t.value!==this._selectedPanelId&&(t.value=this._selectedPanelId)}if(t.has("hass")&&this._discovered&&("activity"===this._activeTab||"area"===this._activeTab)){const t=this._root.getElementById("tab-content"),e=this._listDashCtrl.topology;if(t&&e){this._listCtrl.updateCollapsedRows(t,this.hass,e,this._buildDashboardConfig());const n=t.querySelector("span-side-panel");n&&(n.hass=this.hass,n.errorStore=this._errorStore)}}}setConfig(t){}render(){var i,r,o;if(i=this.hass?.language,t=i&&e[i]?i:"en",!this.hass)return dt`
Span Panel
@@ -242,7 +242,7 @@ var ps={},fs={};var gs,vs=function(){function t(t,e,n){var i=this;this._sleepAft
${n("card.connecting")}
- `;if(!this._discovered){const t=this._errorStore.hasPersistent("discovery-failed");return ut` + `;if(!this._discovered){const t=this._errorStore.hasPersistent("discovery-failed");return dt`
@@ -251,19 +251,19 @@ var ps={},fs={};var gs,vs=function(){function t(t,e,n){var i=this;this._sleepAft
- ${t?pt:ut`
${n("card.connecting")}
`} + ${t?pt:dt`
${n("card.connecting")}
`}
- `}return ut` + `}return dt`
-
${$t((r=this._buildTabList(),o=this._activeTab,`
${r.map(t=>``).join("")}
`))}
+
${Rt((r=this._buildTabList(),o=this._activeTab,`
${r.map(t=>``).join("")}
`))}
@@ -284,7 +284,7 @@ var ps={},fs={};var gs,vs=function(){function t(t,e,n){var i=this;this._sleepAft >
- `}_onPanelChange(t){const e=t.target;this._selectedPanelId=e.value,localStorage.setItem("span_panel_selected",e.value),this._isFavoritesView&&"dashboard"===this._activeTab&&(this._activeTab="activity"),this._areaSubscribing=!1,this._areaUnsub&&(this._areaUnsub(),this._areaUnsub=null)}get _isFavoritesView(){return this._selectedPanelId===zT}_onTabClick(t){const e=t.target.closest(".shared-tab");if(!e)return;const n=e.dataset.tab;n&&n!==this._activeTab&&(this._activeTab=n,this._isFavoritesView&&"dashboard"!==n&&(this._favoritesViewState.activeTab=n,LT(this._favoritesViewState)))}_onTabContentClick(t){const e=t.target.closest(".unit-btn");if(e){const t=e.dataset.unit;if(!t||t===this._chartMetric)return;return this._chartMetric=t,void localStorage.setItem("span_panel_metric",t)}}_onSidePanelClosed(){if("dashboard"===this._activeTab){const t=this._dashboardTab._ctrl;t.monitoringCache.invalidate(),t.graphSettingsCache.invalidate()}this._listDashCtrl.monitoringMultiCache.invalidate(),this._pendingTabRender&&(this._pendingTabRender=!1,this._scheduleTabRender())}_onUnitChanged(t){const e=t.detail;e&&e!==this._chartMetric&&(this._chartMetric=e,localStorage.setItem("span_panel_metric",e))}_onListColumnsChanged(t){const e=t.detail;"number"!=typeof e||1!==e&&2!==e&&3!==e||e===this._listColumns||(this._listColumns=e,Ut(e))}_onGraphSettingsChanged(){if("dashboard"===this._activeTab){const t=this._root.getElementById("tab-content");if(t){this._dashboardTab._ctrl.onGraphSettingsChanged(t)}}}_onNavigateTab(t){const e=t.detail;e&&(this._activeTab=e)}_onFavoritesViewStateChangedEvent(t){if(!this._isFavoritesView)return;const e=t.detail;if(!e)return;const n=this._favoritesViewState;n.activeTab=e.view;const i=this._listDashCtrl.topology,r=i?.circuits;r&&Object.keys(r).length>0?n.expanded[e.view]=e.expanded.filter(t=>t in r):n.expanded[e.view]=e.expanded,n.searchQuery=e.searchQuery,this._persistFavoritesViewStateTimer&&clearTimeout(this._persistFavoritesViewStateTimer),this._persistFavoritesViewStateTimer=setTimeout(()=>{this._persistFavoritesViewStateTimer=null,LT(n)},250)}_subscribeDeviceRegistry(){!this._deviceRegistryUnsub&&this.hass?.connection&&(this._deviceRegistryUnsub=this.hass.connection.subscribeEvents(()=>this._refreshPanels(),"device_registry_updated"))}_unsubscribeDeviceRegistry(){this._deviceRegistryUnsub&&(this._deviceRegistryUnsub.then(t=>t()),this._deviceRegistryUnsub=null)}async _refreshPanels(){if(!this.hass||!this._discovered)return;const t=(await this.hass.callWS({type:"config/device_registry/list"})).filter(t=>t.identifiers?.some(t=>t[0]===s)&&!t.via_device_id),e=this._panels.filter(t=>t.id!==zT),n=new Map(e.map(t=>[t.id,t])),i=new Set(t.map(t=>t.id)),r=n.size!==i.size||[...n.keys()].some(t=>!i.has(t)),o=!r&&t.some(t=>{const e=n.get(t.id);return!!e&&(e.name!==t.name||e.name_by_user!==t.name_by_user)});if((r||o)&&(this._panels=this._buildPanelList(t,this._favorites),!this._panels.some(t=>t.id===this._selectedPanelId)&&this._panels.length>0)){const e=t[0];e&&(this._selectedPanelId=e.id,localStorage.setItem("span_panel_selected",this._selectedPanelId))}}async _updatePanelStatusWatch(){if(!this.hass||!this._selectedPanelId)return;if(this._selectedPanelId===zT)return;if(this._watchedPanelId===this._selectedPanelId)return;const t=this._selectedPanelId;this._watchedPanelId=t;try{const e=new qt(this._errorStore),n=await se(this.hass,t,e);if(this._selectedPanelId!==t)return;const i=n.topology?.panel_entities?.panel_status;i&&(this._errorStore.watchPanelStatus(i),this._errorStore.updateHass(this.hass))}catch(e){console.warn("SPAN Panel: unable to fetch topology for panel status watching",e),this._watchedPanelId===t&&(this._watchedPanelId=null)}}async _discoverPanels(){if(!this._discovering&&this.hass){this._discovering=!0;try{let t;try{const e=new qt(this._errorStore);t=(await e.callWS(this.hass,{type:"config/device_registry/list"},{errorId:"fetch:topology"})).filter(t=>t.identifiers?.some(t=>t[0]===s)&&!t.via_device_id)}catch(t){return console.error("SPAN Panel: device discovery failed",t),void this._errorStore.add({key:"discovery-failed",level:"error",message:n("error.discovery_failed"),persistent:!0,retryFn:()=>{this._errorStore.remove("discovery-failed"),this._discoverPanels()}})}this._favorites=await this._loadFavorites(),this._panels=this._buildPanelList(t,this._favorites),this._favoritesViewState=function(){try{const t=localStorage.getItem(PT);if(!t)return{expanded:{activity:[],area:[]}};const e=JSON.parse(t);if(!e||"object"!=typeof e)return{expanded:{activity:[],area:[]}};const n=e.expanded??{activity:[],area:[]};return{activeTab:e.activeTab,expanded:{activity:Array.isArray(n.activity)?n.activity:[],area:Array.isArray(n.area)?n.area:[]},searchQuery:"string"==typeof e.searchQuery?e.searchQuery:void 0}}catch{return{expanded:{activity:[],area:[]}}}}(),this._discovered=!0;const e=localStorage.getItem("span_panel_selected");if(e&&this._panels.some(t=>t.id===e)?this._selectedPanelId=e:t.length>0&&(this._selectedPanelId=t[0].id),this._selectedPanelId===zT){const t=this._favoritesViewState.activeTab;"activity"===t||"area"===t||"monitoring"===t?this._activeTab=t:"dashboard"===this._activeTab&&(this._activeTab="activity")}this._chartMetric=localStorage.getItem("span_panel_metric")||"power"}finally{this._discovering=!1}}}_buildPanelList(t,e){if(!Zt(e))return t;return[{id:zT,name:n("panel.favorites"),model:"__favorites__"},...t]}async _loadFavorites(){return this.hass?this._favCache.fetch(this.hass):{}}async _refreshFavorites(){const t=++this._refreshSeq;this._favCache.invalidate();const e=await this._loadFavorites();if(t!==this._refreshSeq)return;const n=this._selectedPanelId===zT;this._favorites=e;const i=this._panels.filter(t=>t.id!==zT);if(this._panels=this._buildPanelList(i,e),n&&!Zt(e)){!function(){try{localStorage.removeItem(PT)}catch{}}(),this._favoritesViewState={expanded:{activity:[],area:[]}};const t=i[0];t?(this._selectedPanelId=t.id,localStorage.setItem("span_panel_selected",t.id)):this._selectedPanelId=null}else this._isFavoritesView?this._scheduleTabRender():this._applyPanelFavorites()}_buildTabList(){const t=[];return this._isFavoritesView||t.push({id:"dashboard",label:n("tab.by_panel"),icon:"mdi:view-dashboard"}),t.push({id:"activity",label:n("tab.by_activity"),icon:"mdi:sort-descending"},{id:"area",label:n("tab.by_area"),icon:"mdi:home-group"},{id:"monitoring",label:n("tab.monitoring"),icon:"mdi:monitor-eye"}),t}_buildFavoritesSummaryHTML(){return function(t){return`\n
\n \n
\n ${Bt(n("header.enable_switches"))}\n
\n \n
\n
\n
\n ${le()}\n
\n \n \n
\n
\n
\n `}("current"===(this._chartMetric||"power"))}_buildFavoritesPanelStatsGridHTML(t,e){if(0===t.length)return"";return`
${t.map(t=>`\n
\n
${Bt(t.panelName||t.topology.device_name||"")}
\n ${ce(t.topology,e,t.panelDeviceId)}\n
\n `).join("")}
`}_updateFavoritesPanelStats(t,e){if(this.hass&&0!==this._favoritesPanelStats.length)for(const n of this._favoritesPanelStats){const i=t.querySelector(`.panel-stats[data-stats-panel-id="${nT(n.panelDeviceId)}"]`);i&&iT(i,this.hass,n.topology,e,0)}}_buildDashboardConfig(){return{chart_metric:this._chartMetric,history_minutes:5,show_panel:!0,show_battery:!0,show_evse:!0}}async _scheduleTabRender(){await this.updateComplete,this._sidePanelOpen()?this._pendingTabRender=!0:await this._tabRenderScheduler()}_sidePanelOpen(){const t=this.shadowRoot?.getElementById("tab-content");return!!t?.querySelector("span-side-panel[open]")}async _renderTab(){const t=this._beginRender();this._dashboardTab.stop(),this._monitoringTab.stop(),this._listCtrl.stop(),this._listDashCtrl.stopIntervals();for(const t of this._favoritesMonitoringTabs.values())t.stop();this._favoritesMonitoringTabs.clear(),this._favoritesPanelStats=[];const e=this._root.getElementById("tab-content");if(e)if(this._isFavoritesView)await this._renderFavoritesTab(e,t);else switch(this._listDashCtrl.clearFavoriteRefs(),this._listCtrl.setViewName(null),this._applyPanelFavorites(),this._activeTab){case"dashboard":{e.innerHTML="";const t=this._buildDashboardConfig(),n=this._panels.find(t=>t.id===this._selectedPanelId),i=n?.config_entries?.[0]??null;await this._dashboardTab.render(e,this.hass,this._selectedPanelId??"",t,i);break}case"activity":{e.innerHTML="";const n=this._panels.find(t=>t.id===this._selectedPanelId),i=n?.config_entries?.[0]??null;try{const n=new qt(this._errorStore),r=await se(this.hass,this._selectedPanelId??void 0,n);if(t())return;const o=this._buildDashboardConfig();if(this._listDashCtrl.init(r.topology,o,this.hass,i),this._listDashCtrl.powerHistory.clear(),await this._listDashCtrl.monitoringCache.fetch(this.hass,i),t())return;if(await this._listDashCtrl.fetchAndBuildHorizonMaps(),t())return;const a=r.topology?ue(r.topology,o):"";if(this._listCtrl.setColumns(this._listColumns),this._listCtrl.renderActivityView(e,this.hass,r.topology,o,this._listDashCtrl.monitoringCache.status,a),await this._listDashCtrl.loadHistory(),t())return;this._listDashCtrl.updateDOM(e),this._listDashCtrl.startIntervals(e)}catch(n){if(t())return;const i=document.createElement("p");i.style.color="var(--error-color)",i.textContent=n.message,e.appendChild(i)}break}case"area":{e.innerHTML="";const i=this._panels.find(t=>t.id===this._selectedPanelId),r=i?.config_entries?.[0]??null;try{const i=new qt(this._errorStore),o=await se(this.hass,this._selectedPanelId??void 0,i);if(t())return;const a=this._buildDashboardConfig();if(this._listDashCtrl.init(o.topology,a,this.hass,r),this._listDashCtrl.powerHistory.clear(),await this._listDashCtrl.monitoringCache.fetch(this.hass,r),t())return;if(await this._listDashCtrl.fetchAndBuildHorizonMaps(),t())return;const s=o.topology?ue(o.topology,a):"";if(this._listCtrl.setColumns(this._listColumns),this._listCtrl.renderAreaView(e,this.hass,o.topology,a,this._listDashCtrl.monitoringCache.status,s),await this._listDashCtrl.loadHistory(),t())return;this._listDashCtrl.updateDOM(e),this._listDashCtrl.startIntervals(e),this._areaUnsub||this._areaSubscribing||(this._areaSubscribing=!0,async function(t,e,i,r){if(!t.connection)return()=>{};const o=async()=>{try{const n=new Map;for(const[t,i]of Object.entries(e.circuits))n.set(t,i.area);await ae(t,e);for(const[t,r]of Object.entries(e.circuits))if(r.area!==n.get(t))return void i()}catch(t){console.warn("[span-panel] area registry update failed:",t),r?.add({key:"fetch:areas",level:"warning",message:n("error.areas_failed"),persistent:!1})}},[a,s]=await Promise.all([t.connection.subscribeEvents(o,"entity_registry_updated"),t.connection.subscribeEvents(o,"area_registry_updated")]);return()=>{a(),s()}}(this.hass,o.topology,()=>{"area"===this._activeTab&&this._scheduleTabRender()},this._errorStore).then(t=>{this._areaSubscribing?this._areaUnsub=t:t()}).catch(t=>{this._areaSubscribing=!1,console.warn("SPAN Panel: area subscription failed",t),this._errorStore.add({key:"subscribe:area",level:"warning",message:n("error.areas_failed"),persistent:!1})}))}catch(t){const n=document.createElement("p");n.style.color="var(--error-color)",n.textContent=t instanceof Error?t.message:String(t),e.appendChild(n)}break}case"monitoring":{e.innerHTML="";const t=this._panels.find(t=>t.id===this._selectedPanelId),n=t?.config_entries?.[0]??null;await this._monitoringTab.render(e,this.hass,n??void 0);break}}}async _renderFavoritesTab(t,e){if(t.innerHTML="",!this.hass)return;const i=this._panels.filter(t=>t.id!==zT),r=await this._favCtrl.build(this.hass,this._favorites,i,this._errorStore);if(e())return;const o=r.perPanelStats.map(t=>{const e=t.topology.panel_entities?.panel_status;return"string"==typeof e?{entityId:e,panelName:t.panelName}:null}).filter(t=>null!==t);this._errorStore.watchPanelStatuses(o),this._errorStore.updateHass(this.hass);const a=new Map;for(const t of r.perPanelStats){const e=i.find(e=>e.id===t.panelDeviceId);a.set(t.panelDeviceId,{panelName:t.panelName,topology:t.topology,configEntryId:e?.config_entries?.[0]??null})}this._listDashCtrl.setFavoritesPerPanelInfo(a);const s=r.topology,l=r.entryIds[0]??null,c=Object.keys(s.circuits).length>0,u=Object.keys(s.sub_devices??{}).length>0;if(!c&&!u){const e=document.createElement("p");return e.style.color="var(--secondary-text-color)",e.style.padding="24px",e.textContent=n("list.no_results"),void t.appendChild(e)}if(this._listDashCtrl.setFavoriteRefs(s._favoriteRefs),this._listDashCtrl.setPanelFavorites(null),"monitoring"===this._activeTab)return this._listCtrl.setViewName(null),void await this._renderFavoritesMonitoring(t,r.entryIds,i);const h=this._activeTab,d=new Set(Object.keys(s.circuits)),p=this._favoritesViewState.expanded[h].filter(t=>d.has(t));this._listCtrl.setViewName(h),this._listCtrl.setInitialExpansion(p),this._listCtrl.setInitialSearchQuery(this._favoritesViewState.searchQuery??""),this._listCtrl.setColumns(this._listColumns);const f=this._buildDashboardConfig();if(this._listDashCtrl.init(s,f,this.hass,l),this._listDashCtrl.powerHistory.clear(),await this._listDashCtrl.fetchAndBuildHorizonMaps(),e())return;const g=await this._listDashCtrl.fetchMergedMonitoringStatus(r.entryIds);if(!e()){this._favoritesPanelStats=r.perPanelStats;try{if(await this._listDashCtrl.loadHistory(),e())return;const n=this._buildFavoritesSummaryHTML(),i=this._buildFavoritesPanelStatsGridHTML(r.perPanelStats,f),o=n+i+(u?`
\n
${Ne(s,this.hass,f)}
\n
`:"");"activity"===h?this._listCtrl.renderActivityView(t,this.hass,s,f,g,o):this._listCtrl.renderAreaView(t,this.hass,s,f,g,o),this._updateFavoritesPanelStats(t,f),this._listDashCtrl.setupResizeObserver(t,t),this._listDashCtrl.startIntervals(t,()=>{this._updateFavoritesPanelStats(t,f)})}catch(n){if(e())return;const i=document.createElement("p");i.style.color="var(--error-color)",i.textContent=n.message,t.appendChild(i)}}}async _renderFavoritesMonitoring(t,e,n){if(!this.hass)return;const i=document.createElement("div");i.className="favorites-monitoring-stack",t.appendChild(i);const r=new Map;for(const t of n){const e=t.config_entries?.[0];e&&r.set(e,t)}const o=new Map;for(const t of e){const e=r.get(t),n=document.createElement("div");n.className="favorites-monitoring-block",n.style.marginBottom="24px";const a=document.createElement("h2");a.style.margin="8px 0 12px",a.style.fontSize="1em",a.textContent=e?.name_by_user??e?.name??t,n.appendChild(a);const s=document.createElement("div");n.appendChild(s),i.appendChild(n);const l=new yT;l.errorStore=this._errorStore,o.set(t,l);try{await l.render(s,this.hass,t)}catch(e){console.warn("SPAN Panel: favorites monitoring render failed",t,e);const n=document.createElement("p");n.style.color="var(--error-color)",n.textContent=e.message??String(e),s.appendChild(n)}}this._favoritesMonitoringTabs=o}_applyPanelFavorites(){if(!this._selectedPanelId||this._isFavoritesView)return this._listDashCtrl.setPanelFavorites(null),void this._dashboardTab.setPanelFavorites(null);const t=this._favorites[this._selectedPanelId],e={panelDeviceId:this._selectedPanelId,circuitUuids:new Set(t?.circuits??[]),subDeviceIds:new Set(t?.sub_devices??[])};this._listDashCtrl.setPanelFavorites(e),this._dashboardTab.setPanelFavorites(e)}};NT._shellStyles=M` + `}_onPanelChange(t){const e=t.target;this._selectedPanelId=e.value,localStorage.setItem("span_panel_selected",e.value),this._isFavoritesView&&oI(this._activeTab)&&(this._activeTab="activity"),this._areaSubscribing=!1,this._areaUnsub&&(this._areaUnsub(),this._areaUnsub=null)}get _isFavoritesView(){return this._selectedPanelId===rI}_onTabClick(t){const e=t.target.closest(".shared-tab");if(!e)return;const n=e.dataset.tab;n&&n!==this._activeTab&&(this._activeTab=n,this._isFavoritesView&&!oI(n)&&(this._favoritesViewState.activeTab=n,nI(this._favoritesViewState)))}_onTabContentClick(t){const e=t.target.closest(".unit-btn");if(e){const t=e.dataset.unit;if(!t||t===this._chartMetric)return;return this._chartMetric=t,void localStorage.setItem("span_panel_metric",t)}}_onSidePanelClosed(){if("dashboard"===this._activeTab){const t=this._dashboardTab._ctrl;t.monitoringCache.invalidate(),t.graphSettingsCache.invalidate()}this._listDashCtrl.monitoringMultiCache.invalidate(),this._pendingTabRender&&(this._pendingTabRender=!1,this._scheduleTabRender())}_onUnitChanged(t){const e=t.detail;e&&e!==this._chartMetric&&(this._chartMetric=e,localStorage.setItem("span_panel_metric",e))}_onListColumnsChanged(t){const e=t.detail;"number"!=typeof e||1!==e&&2!==e&&3!==e||e===this._listColumns||(this._listColumns=e,Ut(e))}_onGraphSettingsChanged(){if("dashboard"===this._activeTab){const t=this._root.getElementById("tab-content");if(t){this._dashboardTab._ctrl.onGraphSettingsChanged(t)}}}_onNavigateTab(t){const e=t.detail;e&&(this._activeTab=e)}_onFavoritesViewStateChangedEvent(t){if(!this._isFavoritesView)return;const e=t.detail;if(!e)return;const n=this._favoritesViewState;n.activeTab=e.view;const i=this._listDashCtrl.topology,r=i?.circuits;r&&Object.keys(r).length>0?n.expanded[e.view]=e.expanded.filter(t=>t in r):n.expanded[e.view]=e.expanded,n.searchQuery=e.searchQuery,this._persistFavoritesViewStateTimer&&clearTimeout(this._persistFavoritesViewStateTimer),this._persistFavoritesViewStateTimer=setTimeout(()=>{this._persistFavoritesViewStateTimer=null,nI(n)},250)}_subscribeDeviceRegistry(){!this._deviceRegistryUnsub&&this.hass?.connection&&(this._deviceRegistryUnsub=this.hass.connection.subscribeEvents(()=>this._refreshPanels(),"device_registry_updated"))}_unsubscribeDeviceRegistry(){this._deviceRegistryUnsub&&(this._deviceRegistryUnsub.then(t=>t()),this._deviceRegistryUnsub=null)}async _refreshPanels(){if(!this.hass||!this._discovered)return;const t=(await this.hass.callWS({type:"config/device_registry/list"})).filter(t=>t.identifiers?.some(t=>t[0]===s)&&!t.via_device_id),e=this._panels.filter(t=>t.id!==rI),n=new Map(e.map(t=>[t.id,t])),i=new Set(t.map(t=>t.id)),r=n.size!==i.size||[...n.keys()].some(t=>!i.has(t)),o=!r&&t.some(t=>{const e=n.get(t.id);return!!e&&(e.name!==t.name||e.name_by_user!==t.name_by_user)});if((r||o)&&(this._panels=this._buildPanelList(t,this._favorites),!this._panels.some(t=>t.id===this._selectedPanelId)&&this._panels.length>0)){const e=t[0];e&&(this._selectedPanelId=e.id,localStorage.setItem("span_panel_selected",this._selectedPanelId))}}async _updatePanelStatusWatch(){if(!this.hass||!this._selectedPanelId)return;if(this._selectedPanelId===rI)return;if(this._watchedPanelId===this._selectedPanelId)return;const t=this._selectedPanelId;this._watchedPanelId=t;try{const e=new qt(this._errorStore),n=await se(this.hass,t,e);if(this._selectedPanelId!==t)return;const i=n.topology?.panel_entities?.panel_status;i&&(this._errorStore.watchPanelStatus(i),this._errorStore.updateHass(this.hass))}catch(e){console.warn("SPAN Panel: unable to fetch topology for panel status watching",e),this._watchedPanelId===t&&(this._watchedPanelId=null)}}async _discoverPanels(){if(!this._discovering&&this.hass){this._discovering=!0;try{let t;try{const e=new qt(this._errorStore);t=(await e.callWS(this.hass,{type:"config/device_registry/list"},{errorId:"fetch:topology"})).filter(t=>t.identifiers?.some(t=>t[0]===s)&&!t.via_device_id)}catch(t){return console.error("SPAN Panel: device discovery failed",t),void this._errorStore.add({key:"discovery-failed",level:"error",message:n("error.discovery_failed"),persistent:!0,retryFn:()=>{this._errorStore.remove("discovery-failed"),this._discoverPanels()}})}this._favorites=await this._loadFavorites(),this._panels=this._buildPanelList(t,this._favorites),this._favoritesViewState=function(){try{const t=localStorage.getItem(eI);if(!t)return{expanded:{activity:[],area:[]}};const e=JSON.parse(t);if(!e||"object"!=typeof e)return{expanded:{activity:[],area:[]}};const n=e.expanded??{activity:[],area:[]};return{activeTab:e.activeTab,expanded:{activity:Array.isArray(n.activity)?n.activity:[],area:Array.isArray(n.area)?n.area:[]},searchQuery:"string"==typeof e.searchQuery?e.searchQuery:void 0}}catch{return{expanded:{activity:[],area:[]}}}}(),this._discovered=!0;const e=localStorage.getItem("span_panel_selected");if(e&&this._panels.some(t=>t.id===e)?this._selectedPanelId=e:t.length>0&&(this._selectedPanelId=t[0].id),this._selectedPanelId===rI){const t=this._favoritesViewState.activeTab;"activity"===t||"area"===t||"monitoring"===t?this._activeTab=t:oI(this._activeTab)&&(this._activeTab="activity")}this._chartMetric=localStorage.getItem("span_panel_metric")||"power"}finally{this._discovering=!1}}}_buildPanelList(t,e){if(!Zt(e))return t;return[{id:rI,name:n("panel.favorites"),model:"__favorites__"},...t]}async _loadFavorites(){return this.hass?this._favCache.fetch(this.hass):{}}async _refreshFavorites(){const t=++this._refreshSeq;this._favCache.invalidate();const e=await this._loadFavorites();if(t!==this._refreshSeq)return;const n=this._selectedPanelId===rI;this._favorites=e;const i=this._panels.filter(t=>t.id!==rI);if(this._panels=this._buildPanelList(i,e),n&&!Zt(e)){!function(){try{localStorage.removeItem(eI)}catch{}}(),this._favoritesViewState={expanded:{activity:[],area:[]}};const t=i[0];t?(this._selectedPanelId=t.id,localStorage.setItem("span_panel_selected",t.id)):this._selectedPanelId=null}else this._isFavoritesView?this._scheduleTabRender():this._applyPanelFavorites()}_buildTabList(){const t=[];return this._isFavoritesView||t.push({id:"dashboard",label:n("tab.by_panel"),icon:"mdi:view-dashboard"}),t.push({id:"activity",label:n("tab.by_activity"),icon:"mdi:sort-descending"},{id:"area",label:n("tab.by_area"),icon:"mdi:home-group"},{id:"monitoring",label:n("tab.monitoring"),icon:"mdi:monitor-eye"}),this._isFavoritesView||!0!==this.hass?.user?.is_admin||t.push({id:"adopted",label:n("tab.adopted"),icon:"mdi:tune"}),t}_buildFavoritesSummaryHTML(){return function(t){return`\n
\n \n
\n ${Bt(n("header.enable_switches"))}\n
\n \n
\n
\n
\n ${le()}\n
\n \n \n
\n
\n
\n `}("current"===(this._chartMetric||"power"))}_buildFavoritesPanelStatsGridHTML(t,e){if(0===t.length)return"";return`
${t.map(t=>`\n
\n
${Bt(t.panelName||t.topology.device_name||"")}
\n ${ce(t.topology,e,t.panelDeviceId)}\n
\n `).join("")}
`}_updateFavoritesPanelStats(t,e){if(this.hass&&0!==this._favoritesPanelStats.length)for(const n of this._favoritesPanelStats){const i=t.querySelector(`.panel-stats[data-stats-panel-id="${nT(n.panelDeviceId)}"]`);i&&iT(i,this.hass,n.topology,e,0)}}_buildDashboardConfig(){return{chart_metric:this._chartMetric,history_minutes:5,show_panel:!0,show_battery:!0,show_evse:!0}}async _scheduleTabRender(){await this.updateComplete,this._sidePanelOpen()?this._pendingTabRender=!0:await this._tabRenderScheduler()}_sidePanelOpen(){const t=this.shadowRoot?.getElementById("tab-content");return!!t?.querySelector("span-side-panel[open]")}async _renderTab(){const t=this._beginRender();this._dashboardTab.stop(),this._monitoringTab.stop(),this._adoptedTab.stop(),this._listCtrl.stop(),this._listDashCtrl.stopIntervals();for(const t of this._favoritesMonitoringTabs.values())t.stop();this._favoritesMonitoringTabs.clear(),this._favoritesPanelStats=[];const e=this._root.getElementById("tab-content");if(e)if(this._isFavoritesView)await this._renderFavoritesTab(e,t);else switch(this._listDashCtrl.clearFavoriteRefs(),this._listCtrl.setViewName(null),this._applyPanelFavorites(),this._activeTab){case"dashboard":{e.innerHTML="";const t=this._buildDashboardConfig(),n=this._panels.find(t=>t.id===this._selectedPanelId),i=n?.config_entries?.[0]??null;await this._dashboardTab.render(e,this.hass,this._selectedPanelId??"",t,i);break}case"activity":{e.innerHTML="";const n=this._panels.find(t=>t.id===this._selectedPanelId),i=n?.config_entries?.[0]??null;try{const n=new qt(this._errorStore),r=await se(this.hass,this._selectedPanelId??void 0,n);if(t())return;const o=this._buildDashboardConfig();if(this._listDashCtrl.init(r.topology,o,this.hass,i),this._listDashCtrl.powerHistory.clear(),await this._listDashCtrl.monitoringCache.fetch(this.hass,i),t())return;if(await this._listDashCtrl.fetchAndBuildHorizonMaps(),t())return;const a=r.topology?de(r.topology,o):"";if(this._listCtrl.setColumns(this._listColumns),this._listCtrl.renderActivityView(e,this.hass,r.topology,o,this._listDashCtrl.monitoringCache.status,a),await this._listDashCtrl.loadHistory(),t())return;this._listDashCtrl.updateDOM(e),this._listDashCtrl.startIntervals(e)}catch(n){if(t())return;const i=document.createElement("p");i.style.color="var(--error-color)",i.textContent=n.message,e.appendChild(i)}break}case"area":{e.innerHTML="";const i=this._panels.find(t=>t.id===this._selectedPanelId),r=i?.config_entries?.[0]??null;try{const i=new qt(this._errorStore),o=await se(this.hass,this._selectedPanelId??void 0,i);if(t())return;const a=this._buildDashboardConfig();if(this._listDashCtrl.init(o.topology,a,this.hass,r),this._listDashCtrl.powerHistory.clear(),await this._listDashCtrl.monitoringCache.fetch(this.hass,r),t())return;if(await this._listDashCtrl.fetchAndBuildHorizonMaps(),t())return;const s=o.topology?de(o.topology,a):"";if(this._listCtrl.setColumns(this._listColumns),this._listCtrl.renderAreaView(e,this.hass,o.topology,a,this._listDashCtrl.monitoringCache.status,s),await this._listDashCtrl.loadHistory(),t())return;this._listDashCtrl.updateDOM(e),this._listDashCtrl.startIntervals(e),this._areaUnsub||this._areaSubscribing||(this._areaSubscribing=!0,async function(t,e,i,r){if(!t.connection)return()=>{};const o=async()=>{try{const n=new Map;for(const[t,i]of Object.entries(e.circuits))n.set(t,i.area);await ae(t,e);for(const[t,r]of Object.entries(e.circuits))if(r.area!==n.get(t))return void i()}catch(t){console.warn("[span-panel] area registry update failed:",t),r?.add({key:"fetch:areas",level:"warning",message:n("error.areas_failed"),persistent:!1})}},[a,s]=await Promise.all([t.connection.subscribeEvents(o,"entity_registry_updated"),t.connection.subscribeEvents(o,"area_registry_updated")]);return()=>{a(),s()}}(this.hass,o.topology,()=>{"area"===this._activeTab&&this._scheduleTabRender()},this._errorStore).then(t=>{this._areaSubscribing?this._areaUnsub=t:t()}).catch(t=>{this._areaSubscribing=!1,console.warn("SPAN Panel: area subscription failed",t),this._errorStore.add({key:"subscribe:area",level:"warning",message:n("error.areas_failed"),persistent:!1})}))}catch(t){const n=document.createElement("p");n.style.color="var(--error-color)",n.textContent=t instanceof Error?t.message:String(t),e.appendChild(n)}break}case"monitoring":{e.innerHTML="";const t=this._panels.find(t=>t.id===this._selectedPanelId),n=t?.config_entries?.[0]??null;await this._monitoringTab.render(e,this.hass,n??void 0);break}case"adopted":e.innerHTML="",await this._adoptedTab.render(e,this.hass,this._selectedPanelId??"")}}async _renderFavoritesTab(t,e){if(t.innerHTML="",!this.hass)return;const i=this._panels.filter(t=>t.id!==rI),r=await this._favCtrl.build(this.hass,this._favorites,i,this._errorStore);if(e())return;const o=r.perPanelStats.map(t=>{const e=t.topology.panel_entities?.panel_status;return"string"==typeof e?{entityId:e,panelName:t.panelName}:null}).filter(t=>null!==t);this._errorStore.watchPanelStatuses(o),this._errorStore.updateHass(this.hass);const a=new Map;for(const t of r.perPanelStats){const e=i.find(e=>e.id===t.panelDeviceId);a.set(t.panelDeviceId,{panelName:t.panelName,topology:t.topology,configEntryId:e?.config_entries?.[0]??null})}this._listDashCtrl.setFavoritesPerPanelInfo(a);const s=r.topology,l=r.entryIds[0]??null,c=Object.keys(s.circuits).length>0,d=Object.keys(s.sub_devices??{}).length>0;if(!c&&!d){const e=document.createElement("p");return e.style.color="var(--secondary-text-color)",e.style.padding="24px",e.textContent=n("list.no_results"),void t.appendChild(e)}if(this._listDashCtrl.setFavoriteRefs(s._favoriteRefs),this._listDashCtrl.setPanelFavorites(null),"monitoring"===this._activeTab)return this._listCtrl.setViewName(null),void await this._renderFavoritesMonitoring(t,r.entryIds,i);const u=this._activeTab,h=new Set(Object.keys(s.circuits)),p=this._favoritesViewState.expanded[u].filter(t=>h.has(t));this._listCtrl.setViewName(u),this._listCtrl.setInitialExpansion(p),this._listCtrl.setInitialSearchQuery(this._favoritesViewState.searchQuery??""),this._listCtrl.setColumns(this._listColumns);const f=this._buildDashboardConfig();if(this._listDashCtrl.init(s,f,this.hass,l),this._listDashCtrl.powerHistory.clear(),await this._listDashCtrl.fetchAndBuildHorizonMaps(),e())return;const g=await this._listDashCtrl.fetchMergedMonitoringStatus(r.entryIds);if(!e()){this._favoritesPanelStats=r.perPanelStats;try{if(await this._listDashCtrl.loadHistory(),e())return;const n=this._buildFavoritesSummaryHTML(),i=this._buildFavoritesPanelStatsGridHTML(r.perPanelStats,f),o=n+i+(d?`
\n
${Ne(s,this.hass,f)}
\n
`:"");"activity"===u?this._listCtrl.renderActivityView(t,this.hass,s,f,g,o):this._listCtrl.renderAreaView(t,this.hass,s,f,g,o),this._updateFavoritesPanelStats(t,f),this._listDashCtrl.setupResizeObserver(t,t),this._listDashCtrl.startIntervals(t,()=>{this._updateFavoritesPanelStats(t,f)})}catch(n){if(e())return;const i=document.createElement("p");i.style.color="var(--error-color)",i.textContent=n.message,t.appendChild(i)}}}async _renderFavoritesMonitoring(t,e,n){if(!this.hass)return;const i=document.createElement("div");i.className="favorites-monitoring-stack",t.appendChild(i);const r=new Map;for(const t of n){const e=t.config_entries?.[0];e&&r.set(e,t)}const o=new Map;for(const t of e){const e=r.get(t),n=document.createElement("div");n.className="favorites-monitoring-block",n.style.marginBottom="24px";const a=document.createElement("h2");a.style.margin="8px 0 12px",a.style.fontSize="1em",a.textContent=e?.name_by_user??e?.name??t,n.appendChild(a);const s=document.createElement("div");n.appendChild(s),i.appendChild(n);const l=new yT;l.errorStore=this._errorStore,o.set(t,l);try{await l.render(s,this.hass,t)}catch(e){console.warn("SPAN Panel: favorites monitoring render failed",t,e);const n=document.createElement("p");n.style.color="var(--error-color)",n.textContent=e.message??String(e),s.appendChild(n)}}this._favoritesMonitoringTabs=o}_applyPanelFavorites(){if(!this._selectedPanelId||this._isFavoritesView)return this._listDashCtrl.setPanelFavorites(null),void this._dashboardTab.setPanelFavorites(null);const t=this._favorites[this._selectedPanelId],e={panelDeviceId:this._selectedPanelId,circuitUuids:new Set(t?.circuits??[]),subDeviceIds:new Set(t?.sub_devices??[])};this._listDashCtrl.setPanelFavorites(e),this._dashboardTab.setPanelFavorites(e)}};aI._shellStyles=M` :host { color: var(--primary-text-color); } @@ -381,4 +381,4 @@ var ps={},fs={};var gs,vs=function(){function t(t,e,n){var i=this;this._sleepAft opacity: 1; border-bottom-color: var(--app-header-text-color, white); } - `,NT.styles=[ET._shellStyles,k(uT)],_([Et({attribute:!1})],NT.prototype,"hass",void 0),_([Et({type:Boolean,reflect:!0})],NT.prototype,"narrow",void 0),_([zt()],NT.prototype,"_panels",void 0),_([zt()],NT.prototype,"_selectedPanelId",void 0),_([zt()],NT.prototype,"_activeTab",void 0),_([zt()],NT.prototype,"_discovered",void 0),_([zt()],NT.prototype,"_chartMetric",void 0),_([zt()],NT.prototype,"_listColumns",void 0),_([zt()],NT.prototype,"_favorites",void 0),NT=ET=_([(t=>(e,n)=>{void 0!==n?n.addInitializer(()=>{customElements.define(t,e)}):customElements.define(t,e)})("span-panel")],NT),console.warn("%c SPAN-PANEL %c v0.9.4 ","background: var(--primary-color, #4dd9af); color: #000; font-weight: 700; padding: 2px 6px; border-radius: 4px 0 0 4px;","background: var(--secondary-background-color, #333); color: var(--primary-text-color, #fff); padding: 2px 6px; border-radius: 0 4px 4px 0;"); + `,aI.styles=[iI._shellStyles,k(dT)],_([Et({attribute:!1})],aI.prototype,"hass",void 0),_([Et({type:Boolean,reflect:!0})],aI.prototype,"narrow",void 0),_([zt()],aI.prototype,"_panels",void 0),_([zt()],aI.prototype,"_selectedPanelId",void 0),_([zt()],aI.prototype,"_activeTab",void 0),_([zt()],aI.prototype,"_discovered",void 0),_([zt()],aI.prototype,"_chartMetric",void 0),_([zt()],aI.prototype,"_listColumns",void 0),_([zt()],aI.prototype,"_favorites",void 0),aI=iI=_([(t=>(e,n)=>{void 0!==n?n.addInitializer(()=>{customElements.define(t,e)}):customElements.define(t,e)})("span-panel")],aI),console.warn("%c SPAN-PANEL %c v0.9.4 ","background: var(--primary-color, #4dd9af); color: #000; font-weight: 700; padding: 2px 6px; border-radius: 4px 0 0 4px;","background: var(--secondary-background-color, #333); color: var(--primary-text-color, #fff); padding: 2px 6px; border-radius: 0 4px 4px 0;"); diff --git a/frontend.md b/frontend.md index 25788d71..2568cb36 100644 --- a/frontend.md +++ b/frontend.md @@ -93,6 +93,33 @@ values. ![Monitoring Configuration](images/monitoring.png) +## Adopted View + +The Adopted tab is where you tell the integration what an adopted entity means. Your panel's schema is vendor-extensible, so it can publish devices and readings +this integration has never modelled; those arrive carrying only what the wire says about them, and the rest is yours to assert. The tab is shown to +administrator accounts only, and on a panel rather than in the Favorites view. + +Rows are grouped by the device card they render on — one group per adopted device, one per modelled device carrying vendor readings — and each row expands into +a small form. A badge marks a row you have already curated, and another marks one whose saved values the panel has stopped supporting. + +Three of the fields are the integration's own, because Home Assistant has nowhere to keep them for an entity built from a vendor declaration: + +- **Device class** — what kind of quantity the reading is, which gives it a sensible icon and, for the classes Home Assistant knows how to convert, a choice of + display unit. +- **Statistics class** — Home Assistant's `state_class`, which is what enrolls the reading in long-term statistics and makes it usable on an Energy dashboard. +- **Prominence** — whether the entity stays filed under Diagnostics or is promoted out of it. + +The rest of the form — **name**, **icon**, **display unit** and **precision**, and whether the entity is **enabled** — is Home Assistant's own entity settings, +shown here so you do not have to go elsewhere, and written straight into Home Assistant's registry exactly as if you had edited the entity there. Those fields +appear once the entity exists in the registry; until then the row offers the integration's three and says so. + +You are offered only what your panel's declaration allows: a statistics class on numeric readings, and the device classes compatible with the unit the panel +publishes. Setting or clearing a statistics class asks you to confirm first, because that choice is written into recorded history and correcting the class +afterwards does not repair what was already recorded. Saving reloads the integration, which is how the setting takes effect — the reload rebuilds the entity +already carrying what you asserted. + +See [The Adopted Tab](README.md#the-adopted-tab) in the README for the fuller account of what gets adopted and why. + ## Favorites View The dashboard supports a cross-panel **Favorites** view that lets you curate a single workspace from circuits and sub-devices (BESS, EVSE) belonging to any of From 738d09c9c1b7265e60cb98a40ad22905c11f84f1 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:33:48 -0700 Subject: [PATCH 246/345] chore(deps): span-panel-api 3.4.1 The release that stops a merely advertising, unconfigured panel from producing the plaintext-transport warning at every boot when discovery probes it. Pairs with the Supervisor-discovery probe moving onto the pin in the previous commit, which that release's warning exemption motivated. --- custom_components/span_panel/manifest.json | 2 +- pyproject.toml | 2 +- requirements_test.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 47cb63d3..93111afe 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -23,7 +23,7 @@ ], "quality_scale": "gold", "requirements": [ - "span-panel-api==3.4.0", + "span-panel-api==3.4.1", "span-panel-api-schema-0==1.1.2", "span-panel-api-schema-1==1.1.3" ], diff --git a/pyproject.toml b/pyproject.toml index 363c8e6a..a13cfc1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ readme = "README.md" requires-python = ">=3.14.3,<3.15" dependencies = [ "homeassistant==2026.8.3", - "span-panel-api==3.4.0", + "span-panel-api==3.4.1", # 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, diff --git a/requirements_test.txt b/requirements_test.txt index f18d9bdb..a934f73e 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -3,6 +3,6 @@ pytest-asyncio>=1.4.0 setuptools>=65.7.0 pytest-homeassistant-custom-component==0.13.357 homeassistant==2026.8.3 -span-panel-api==3.4.0 +span-panel-api==3.4.1 span-panel-api-schema-0==1.1.2 span-panel-api-schema-1==1.1.3 From 7f7c84025a51d103368567d312dfd9fe34c37f30 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:37:37 -0700 Subject: [PATCH 247/345] chore(release): 2.1.1b2 on span-panel-api 3.4.1 --- custom_components/span_panel/manifest.json | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 93111afe..15decb8c 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -27,7 +27,7 @@ "span-panel-api-schema-0==1.1.2", "span-panel-api-schema-1==1.1.3" ], - "version": "2.1.1b1", + "version": "2.1.1b2", "zeroconf": [ { "type": "_span._tcp.local." diff --git a/pyproject.toml b/pyproject.toml index a13cfc1a..48efad8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span" -version = "2.1.1b1" +version = "2.1.1b2" description = "Span Panel Custom Integration for Home Assistant" authors = [{name = "SpanPanel"}] license = {text = "MIT"} From b6443df216dd91d958bb9cdf0adb6960b5316f38 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:54:56 -0700 Subject: [PATCH 248/345] docs: the Adopted tab edits unit and precision, not area --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b65d2c7b..15c1641f 100644 --- a/README.md +++ b/README.md @@ -517,9 +517,9 @@ Three of those fields are the integration's own, and they are the ones Home Assi in a long-range history graph. This is the piece nothing else in Home Assistant can set for you. - **Prominence** — whether the entity stays filed under Diagnostics or is promoted out of it. -The rest of the form — **name, icon, area, and whether the entity is enabled** — is Home Assistant's own entity settings, shown here so you do not have to go -somewhere else, and saved into Home Assistant's registry exactly as if you had edited the entity directly. The integration never changes any of it on your -behalf; enabling an adopted entity is always something you do. +The rest of the form — **name, icon, display unit and precision, and whether the entity is enabled** — is Home Assistant's own entity settings, shown here so +you do not have to go somewhere else, and saved into Home Assistant's registry exactly as if you had edited the entity directly. The integration never changes +any of it on your behalf; enabling an adopted entity is always something you do. You are only offered choices your panel's own declaration allows. A statistics class is offered only on numeric readings; the device classes listed are the ones compatible with the unit your panel publishes, and the unit itself stays whatever the publisher sends. From aff2e30e51ab0a16b0a3af82047e0eec86e190ee Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:15:51 -0700 Subject: [PATCH 249/345] docs: one changelog sentence for #264, as a user reads it --- CHANGELOG.md | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5f83664..a0f0cf58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,23 +6,8 @@ All notable changes to this project will be documented in this file. ### Fixed -- **The startup warning that panel traffic is unencrypted is resolved, not silenced** — a pinned entry now reads the panel's schema over HTTPS verified against - its pinned certificate authority, instead of plaintext HTTP (#264). - -### Added - -- **A repair when the HTTPS port serves a certificate the pin does not validate** — a wrong HTTPS port, a proxy terminating TLS in front of the panel, or a - panel whose clock reset in an outage; the integration keeps retrying without connecting and never falls back to plaintext, so the clock case heals on its own. -- **A repair when the stored certificate authority can no longer be read**, guiding you through re-acquiring and confirming the panel's certificate authority. -- **Reconfigure now offers the HTTPS port on any panel with a pinned certificate authority**, for installs whose TLS lives behind a proxy or port forward and - were never asked. - -### Changed - -- **A panel whose certificate authority rotated is now also detected at startup's first read**, raising the same guided re-pin repair it already had. -- **A moved panel detected at startup's first read raises the existing address repair and keeps retrying**, exactly as it promises. -- **When a pinned panel cannot be reached at all, the retry message names the HTTPS port and the Reconfigure remedy** instead of only reporting the panel as not - ready. +- **The startup warning that panel traffic is unencrypted is resolved** — a panel with a pinned certificate authority is now read over verified HTTPS, with + repairs and a Reconfigure HTTPS-port option covering the rare setups where the certificate or the port needs attention (#264). ## [2.1.0] - 8/2026 From 95c89974060a0584fcf5cd5b5c6cdfc03d67392c Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:21:38 -0700 Subject: [PATCH 250/345] chore(release): 2.1.1 --- custom_components/span_panel/manifest.json | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 15decb8c..8978f814 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -27,7 +27,7 @@ "span-panel-api-schema-0==1.1.2", "span-panel-api-schema-1==1.1.3" ], - "version": "2.1.1b2", + "version": "2.1.1", "zeroconf": [ { "type": "_span._tcp.local." diff --git a/pyproject.toml b/pyproject.toml index 48efad8d..fd661c3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span" -version = "2.1.1b2" +version = "2.1.1" description = "Span Panel Custom Integration for Home Assistant" authors = [{name = "SpanPanel"}] license = {text = "MIT"} From 234cd408301ab07c646883afdca7ca1f993d3964 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:32:03 -0700 Subject: [PATCH 251/345] chore(release): 2.1.1b3 --- custom_components/span_panel/manifest.json | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 8978f814..22c9aac7 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -27,7 +27,7 @@ "span-panel-api-schema-0==1.1.2", "span-panel-api-schema-1==1.1.3" ], - "version": "2.1.1", + "version": "2.1.1b3", "zeroconf": [ { "type": "_span._tcp.local." diff --git a/pyproject.toml b/pyproject.toml index fd661c3c..f5cad0ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span" -version = "2.1.1" +version = "2.1.1b3" description = "Span Panel Custom Integration for Home Assistant" authors = [{name = "SpanPanel"}] license = {text = "MIT"} From 262a491162b4485ee61597d3b116f07dc4111ead Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:59:25 -0700 Subject: [PATCH 252/345] fix(curation): numeric readings parse by declared datatype, and device classes match it A unit-less reading was published as text however the wire declared it: the unit stood in for "this is numeric", so a bare count arrived as the string "42". That was harmless while an uncurated row asserted nothing about itself, and stopped being harmless once its owner could put a `measurement` on exactly that row -- the recorder would be handed a string under a numeric state class. `declares_a_number` now answers the question once, in `util`, for the union of a declared unit and a numeric `$datatype`, so nothing that parses today stops parsing and a bare count parses too. The device-class offer was gated on the declared unit alone, which let a text row be offered `power_factor`, `aqi` and `monetary` -- classes constraining no unit, and so passing vacuously -- each of which reads unknown for the life of the install. Core's own `NON_NUMERIC_DEVICE_CLASSES` partitions the vocabulary and `declares_a_number` says which side a row falls on, so the editor offers one half or the other and the validator refuses the crossing with `incompatible_device_class`. Sharing the predicate with the parse is what keeps a row read as a float from being offered nothing but `enum`. --- custom_components/span_panel/adoption.py | 29 ++++++--- custom_components/span_panel/curation.py | 51 +++++++++++++-- custom_components/span_panel/extension.py | 25 ++++++-- custom_components/span_panel/util.py | 19 ++++++ tests/test_adoption.py | 71 +++++++++++++++++++++ tests/test_curation.py | 36 +++++++++++ tests/test_extension_entities.py | 75 +++++++++++++++++++++++ 7 files changed, 287 insertions(+), 19 deletions(-) diff --git a/custom_components/span_panel/adoption.py b/custom_components/span_panel/adoption.py index 594b93df..a2a4060d 100644 --- a/custom_components/span_panel/adoption.py +++ b/custom_components/span_panel/adoption.py @@ -73,6 +73,7 @@ BOOLEAN_DATATYPE, ENUM_DATATYPE, NUMERIC_DATATYPES, + declares_a_number, ) if TYPE_CHECKING: @@ -494,24 +495,36 @@ def __init__( DEVICE_CLASS_BY_UNIT.get(declaration.unit or ""), record, ) + self._numeric = declares_a_number(declaration.datatype, declaration.unit) @property def native_value(self) -> str | float | None: - """Return the published value, parsed to a number only where one is declared. + """Return the published value, parsed to a number where the declaration says it is one. + + The declared `$datatype` is what says so, and a declared unit is taken as + saying so too. The unit alone used to decide it, as a proxy: a property + carrying `W` is a number whatever else it says. But a bare count declares + no unit and is numeric all the same, and the proxy read one as text -- + harmless while an uncurated reading asserted nothing about itself, and + not harmless once the owner of the device could put a `measurement` on + exactly that row and have the recorder handed a string under it. + + The union rather than the datatype alone, because a publisher that omits + a `$datatype` still declares a unit, and nothing that parses today may + stop parsing. A declared numeric that arrives unparseable is reported as `None` rather - than as its raw text: the entity has a unit and a device class, and - putting a string behind those would be a worse lie than reporting - nothing. + than as its raw text: putting a string behind a unit and a device class + would be a worse lie than reporting nothing. - An undeclared one is text, and text off a vendor device is unbounded, so - it goes through `clamp_state` -- see there for why truncating beats - letting core refuse it. + Anything else is text, and text off a vendor device is unbounded, so it + goes through `clamp_state` -- see there for why truncating beats letting + core refuse it. """ raw = self._published() if raw is None: return None - if self.entity_description.native_unit_of_measurement is None: + if not self._numeric: return clamp_state(raw, f"Adopted {self._declaration_path}") try: return float(raw) diff --git a/custom_components/span_panel/curation.py b/custom_components/span_panel/curation.py index 34e91cb9..16356d85 100644 --- a/custom_components/span_panel/curation.py +++ b/custom_components/span_panel/curation.py @@ -27,15 +27,19 @@ SensorStateClass, ) -# `homeassistant.components.sensor` re-exports this at runtime but leaves it out -# of its `__all__`, so the package-level import is an `attr-defined` error under -# mypy. `.const` is where it is actually defined and is the path that type-checks. -from homeassistant.components.sensor.const import DEVICE_CLASS_UNITS +# `homeassistant.components.sensor` re-exports both at runtime but leaves them +# out of its `__all__`, so the package-level import is an `attr-defined` error +# under mypy. `.const` is where they are actually defined and is the path that +# type-checks. +from homeassistant.components.sensor.const import ( + DEVICE_CLASS_UNITS, + NON_NUMERIC_DEVICE_CLASSES, +) from homeassistant.const import EntityCategory, Platform from homeassistant.helpers.storage import Store from .const import DOMAIN -from .util import NUMERIC_DATATYPES +from .util import NUMERIC_DATATYPES, declares_a_number if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry @@ -91,6 +95,30 @@ def _validate_state_class(value: object, context: RowContext) -> SensorStateClas raise CurationError("invalid_state_class", f"unknown state class {value!r}") from err +def _admits_datatype(device_class: SensorDeviceClass, context: RowContext) -> bool: + """Say whether a sensor device class can describe a value of this row's kind. + + Core splits its own sensor device classes in two -- the four in + `NON_NUMERIC_DEVICE_CLASSES` describe a date, an option or a moment, and + every other one describes a number -- and the declaration says which kind of + value the row carries. Crossing the two produces an entity that reads + `unknown` for the life of the install: `power_factor` behind a text reading + has no number to show, and `enum` behind a float has no option list any + publisher could supply. + + Which side a row falls on is `declares_a_number`'s answer, not the datatype + alone, because that is the same question `AdoptedSensor.native_value` asks + when it decides whether to parse. A row read as a float whose only offered + device classes were the non-numeric four would be offered nothing it could + use -- and core refuses to render a state carrying both a unit and a + non-numeric device class at all, so every such offer would raise. + """ + numeric_row = declares_a_number(context.datatype, context.unit) + if device_class in NON_NUMERIC_DEVICE_CLASSES: + return not numeric_row + return numeric_row + + def _validate_device_class(value: object, context: RowContext) -> str: if context.platform is Platform.BINARY_SENSOR: try: @@ -108,6 +136,11 @@ def _validate_device_class(value: object, context: RowContext) -> str: device_class = SensorDeviceClass(str(value)) except ValueError as err: raise CurationError("invalid_device_class", f"unknown device class {value!r}") from err + if not _admits_datatype(device_class, context): + raise CurationError( + "incompatible_device_class", + f"{device_class.value} does not admit the declared datatype {context.datatype!r}", + ) constrained = DEVICE_CLASS_UNITS.get(device_class) if constrained is not None and context.unit not in constrained: raise CurationError( @@ -208,13 +241,19 @@ def allowed_state_classes(context: RowContext) -> list[str]: def allowed_device_classes(context: RowContext) -> list[str]: - """Return the device classes compatible with this row's platform and declared unit.""" + """Return the device classes this row's platform, datatype and declared unit admit. + + The same two checks `_validate_device_class` applies, in the same order, so + the editor never offers a class the validator would then refuse. + """ if context.platform is Platform.BINARY_SENSOR: return [cls.value for cls in BinarySensorDeviceClass] if context.platform is not Platform.SENSOR: return [] allowed: list[str] = [] for device_class in SensorDeviceClass: + if not _admits_datatype(device_class, context): + continue constrained = DEVICE_CLASS_UNITS.get(device_class) if constrained is None or context.unit in constrained: allowed.append(device_class.value) diff --git a/custom_components/span_panel/extension.py b/custom_components/span_panel/extension.py index f252834a..5c949a20 100644 --- a/custom_components/span_panel/extension.py +++ b/custom_components/span_panel/extension.py @@ -71,6 +71,7 @@ SUB_DEVICE_EVSE, SUB_DEVICE_MID, SUB_DEVICE_PV, + declares_a_number, ) _LOGGER = logging.getLogger(__name__) @@ -451,19 +452,33 @@ def __init__( self.entity_description = sensor_description( row.path, row.unit, DEVICE_CLASS_BY_UNIT.get(row.unit or ""), record ) + self._numeric = declares_a_number(row.datatype, row.unit) @property def native_value(self) -> str | float | None: - """Return the published value, parsed to a number only where one is declared. - - An undeclared one is text, and a vendor string is unbounded on the wire, - so it goes through the clamp `adoption` holds for both halves of vendor + """Return the published value, parsed to a number where the declaration says it is one. + + The declared `$datatype` is what says so, and a declared unit is taken as + saying so too. The unit alone used to decide it, as a proxy: a property + carrying `V` is a number whatever else it says. But a vendor count + declares no unit and is numeric all the same, and the proxy read one as + text -- harmless while an uncurated reading asserted nothing about + itself, and not harmless once the owner of the device could put a + `measurement` on exactly that row and have the recorder handed a string + under it. + + The union rather than the datatype alone, because a publisher that omits + a `$datatype` still declares a unit, and nothing that parses today may + stop parsing. + + Anything else is text, and a vendor string is unbounded on the wire, so + it goes through the clamp `adoption` holds for both halves of vendor extensibility. """ raw = self._published() if raw is None: return None - if self.entity_description.native_unit_of_measurement is None: + if not self._numeric: return clamp_state(raw, f"Extension {self._declaration_path}") try: return float(raw) diff --git a/custom_components/span_panel/util.py b/custom_components/span_panel/util.py index 210795bc..4ecbac97 100644 --- a/custom_components/span_panel/util.py +++ b/custom_components/span_panel/util.py @@ -51,6 +51,25 @@ make a reader of one feature import the other. """ + +def declares_a_number(datatype: str, unit: str | None) -> bool: + """Say whether a declaration describes a row whose value is a number. + + Either half is enough. The declared `$datatype` is the truth of it; a + declared unit is taken as saying the same thing, because a publisher that + omits a `$datatype` still declares `W`, and a property carrying a unit is a + number whatever else it says. + + The unit alone used to answer this, as a proxy, and read a bare count -- an + `integer` with no unit -- as text. Here rather than in each caller because + two of them decide different things from the same question and must not + answer it differently: `adoption` and `extension` parse a published value + with it, and `curation` offers the device classes that go behind that value. + A row whose reading is a float and whose only offered device class is `enum` + is the incoherence this being one function prevents. + """ + return unit is not None or datatype in NUMERIC_DATATYPES + ADOPTED_IDENTIFIER_TOKEN: Final = "adopted" """The infix marking a sub-device identifier as adopted rather than curated. diff --git a/tests/test_adoption.py b/tests/test_adoption.py index 9fcad6b3..b7f76889 100644 --- a/tests/test_adoption.py +++ b/tests/test_adoption.py @@ -866,6 +866,77 @@ def test_a_string_within_the_limit_is_passed_through_untouched(hass: HomeAssista assert sensor.native_value == "ok" +# -- What a value is parsed as is what the declaration says it is ------------- + + +def test_a_unit_less_numeric_declaration_still_publishes_a_number(hass: HomeAssistant) -> None: + """A bare count declares no unit and is a number all the same. + + The unit used to stand in for "this is numeric", which was true enough while + an uncurated row could assert nothing: a count published as `"42"` read as + the string `"42"` and nobody could put a state class behind it. Curation + makes that consequential -- the datatype admits `measurement`, so the + recorder would be handed text under a numeric state class. + """ + declaration = _property( + node_id="meter", property_id="cycle-count", datatype="integer", unit=None, value="42" + ) + snapshot = _snapshot(_device(properties=(declaration,))) + + (sensor,) = create_adopted_sensors( + MagicMock(data=snapshot), + snapshot, + dr.async_get(hass), + panel_device_id="panel-device-id", + overlay=CurationOverlay.empty(), + ) + + assert sensor.native_value == 42.0 + + +def test_a_curated_unit_less_numeric_reports_a_float_under_its_state_class( + hass: HomeAssistant, +) -> None: + """The gap this closes, stated through the record that opens it.""" + declaration = _property( + node_id="meter", property_id="cycle-count", datatype="integer", unit=None, value="42" + ) + snapshot = _snapshot(_device(properties=(declaration,))) + identifier = resolve_identifier( + dr.async_get(hass), snapshot.serial_number, snapshot.adopted_devices[0] + ) + record = CurationRecord(state_class=SensorStateClass.MEASUREMENT) + + (sensor,) = create_adopted_sensors( + MagicMock(data=snapshot), + snapshot, + dr.async_get(hass), + panel_device_id="panel-device-id", + overlay=_overlay_for(declaration.path, record, identifier), + ) + + assert sensor.state_class is SensorStateClass.MEASUREMENT + assert sensor.native_value == 42.0 + + +def test_a_unit_less_numeric_that_publishes_text_reports_nothing(hass: HomeAssistant) -> None: + """Declared numeric is declared numeric: unparseable text is not a fallback to text.""" + declaration = _property( + node_id="meter", property_id="cycle-count", datatype="integer", unit=None, value="lots" + ) + snapshot = _snapshot(_device(properties=(declaration,))) + + (sensor,) = create_adopted_sensors( + MagicMock(data=snapshot), + snapshot, + dr.async_get(hass), + panel_device_id="panel-device-id", + overlay=CurationOverlay.empty(), + ) + + assert sensor.native_value is None + + # -- Diagnostics report the proxy relationship, never the parent's id --------- diff --git a/tests/test_curation.py b/tests/test_curation.py index 606aba11..11b53097 100644 --- a/tests/test_curation.py +++ b/tests/test_curation.py @@ -114,6 +114,42 @@ def test_allowed_device_classes_respect_the_wire_unit() -> None: assert allowed_device_classes(SWITCH) == [] +def test_allowed_device_classes_respect_the_wire_datatype() -> None: + """A device class the row's datatype cannot satisfy is not an offer worth making. + + The unit gate alone let a `string` row be offered `power_factor` and `aqi` -- + classes that constrain no unit, and so passed -- which reads unknown forever + because the value behind them is text. + """ + numeric = allowed_device_classes(SENSOR_FLOAT_V) + assert "enum" not in numeric + assert "date" not in numeric + assert "timestamp" not in numeric + assert "voltage" in numeric + + text = allowed_device_classes(SENSOR_STRING) + assert "power_factor" not in text + assert "aqi" not in text + assert {"enum", "timestamp"} <= set(text) + + +def test_a_device_class_the_datatype_cannot_satisfy_is_refused() -> None: + """Both directions, because the partition has two halves and each is a real mistake.""" + with pytest.raises(CurationError) as err: + validate_record({"device_class": "enum"}, SENSOR_FLOAT_V) + assert err.value.code == "incompatible_device_class" + assert "float" in str(err.value) + + with pytest.raises(CurationError) as err: + validate_record({"device_class": "power_factor"}, SENSOR_STRING) + assert err.value.code == "incompatible_device_class" + assert "string" in str(err.value) + + +def test_a_device_class_the_datatype_does_satisfy_is_accepted() -> None: + assert validate_record({"device_class": "enum"}, SENSOR_STRING).device_class == "enum" + + def test_record_round_trips_through_its_dict_form() -> None: record = CurationRecord( state_class=SensorStateClass.TOTAL_INCREASING, device_class="energy", promote=True diff --git a/tests/test_extension_entities.py b/tests/test_extension_entities.py index 713ac696..0fd9d0ce 100644 --- a/tests/test_extension_entities.py +++ b/tests/test_extension_entities.py @@ -407,6 +407,63 @@ def test_an_unparseable_number_is_reported_as_nothing_rather_than_as_text( assert sensor.native_value is None +# --- what a value is parsed as is what the declaration says it is ----------- + + +def test_a_unit_less_numeric_row_still_publishes_a_number( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """A vendor count declares no unit and is a number all the same. + + The unit stood in for "this is numeric" while an uncurated row could assert + nothing. Curation makes the gap consequential: the datatype admits a state + class, so the recorder would be handed the string `"42"` under one. + """ + snapshot = _snapshot( + _row(property_id="cycle-count", datatype="integer", unit=None, value="42") + ) + (sensor,) = create_extension_sensors( + _coordinator(snapshot), + snapshot, + dr.async_get(hass), + er.async_get(hass), + overlay=CurationOverlay.empty(), + ) + assert sensor.native_value == 42.0 + + +def test_a_unit_less_numeric_row_that_publishes_text_reports_nothing( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """Declared numeric is declared numeric: unparseable text is not a fallback to text.""" + snapshot = _snapshot( + _row(property_id="cycle-count", datatype="integer", unit=None, value="lots") + ) + (sensor,) = create_extension_sensors( + _coordinator(snapshot), + snapshot, + dr.async_get(hass), + er.async_get(hass), + overlay=CurationOverlay.empty(), + ) + assert sensor.native_value is None + + +def test_a_unit_less_string_row_is_still_text( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """The half of the rule that must not move: a declared string stays a string.""" + snapshot = _snapshot(_row(property_id="mode", datatype="string", unit=None, value="idle")) + (sensor,) = create_extension_sensors( + _coordinator(snapshot), + snapshot, + dr.async_get(hass), + er.async_get(hass), + overlay=CurationOverlay.empty(), + ) + assert sensor.native_value == "idle" + + # --- what the owner of the device is allowed to assert ---------------------- @@ -470,6 +527,24 @@ def test_a_curated_record_shapes_the_extension_sensor( assert entity.entity_registry_enabled_default is False +def test_a_curated_unit_less_numeric_reports_a_float_under_its_state_class( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """The gap the datatype rule closes, stated through the record that opens it.""" + row = _row(property_id="cycle-count", datatype="integer", unit=None, value="42") + snapshot = _snapshot(row) + record = CurationRecord(state_class=SensorStateClass.MEASUREMENT) + (entity,) = create_extension_sensors( + _coordinator(snapshot), + snapshot, + dr.async_get(hass), + er.async_get(hass), + overlay=_overlay_keyed(row, record), + ) + assert entity.state_class is SensorStateClass.MEASUREMENT + assert entity.native_value == 42.0 + + def test_an_uncurated_extension_row_is_exactly_todays_entity( hass: HomeAssistant, registered_panel: tuple[str, str] ) -> None: From 3b35f14b6766b3818b4d0be287e3aef07f85827d Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:06:56 -0700 Subject: [PATCH 253/345] feat(curation): sync the icon-less editor; docs follow The Adopted tab no longer offers an icon: it wrote through the same registry command Core's own entity settings dialog issues, so it was a second place to set one thing. The field lists in the README and frontend.md drop it and say where the icon and the area are set instead. developer.md's validation section gains the datatype half of the device-class gate, and the reason `declares_a_number` is one predicate rather than two: the same answer decides whether a reading is parsed as a number and which half of Core's device-class vocabulary the row is offered. --- README.md | 10 ++++++---- .../span_panel/frontend/dist/span-panel-card.js | 16 ++++++++-------- .../span_panel/frontend/dist/span-panel.js | 4 ++-- developer.md | 14 +++++++++++++- frontend.md | 17 +++++++++-------- 5 files changed, 38 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 53fefb62..a2abc1ce 100644 --- a/README.md +++ b/README.md @@ -517,12 +517,14 @@ Three of those fields are the integration's own, and they are the ones Home Assi in a long-range history graph. This is the piece nothing else in Home Assistant can set for you. - **Prominence** — whether the entity stays filed under Diagnostics or is promoted out of it. -The rest of the form — **name, icon, display unit and precision, and whether the entity is enabled** — is Home Assistant's own entity settings, shown here so -you do not have to go somewhere else, and saved into Home Assistant's registry exactly as if you had edited the entity directly. The integration never changes -any of it on your behalf; enabling an adopted entity is always something you do. +The rest of the form — **name, display unit and precision, and whether the entity is enabled** — is Home Assistant's own entity settings, shown here so you do +not have to go somewhere else, and saved into Home Assistant's registry exactly as if you had edited the entity directly. The integration never changes any of +it on your behalf; enabling an adopted entity is always something you do. Anything else Home Assistant's own entity settings already cover — the icon, the area, +the labels — you set there, not here. You are only offered choices your panel's own declaration allows. A statistics class is offered only on numeric readings; the device classes listed are the ones -compatible with the unit your panel publishes, and the unit itself stays whatever the publisher sends. +that fit both what the panel says the reading is and the unit it publishes, so a text reading is never offered a class that expects a number, and the unit +itself stays whatever the publisher sends. **Saving reloads the integration.** That is not a formality — it is how the setting takes effect. An entity's type information is fixed at the moment the entity is built, so the reload is what rebuilds it already carrying what you asserted, rather than attaching a statistics class to an entity that has been recording diff --git a/custom_components/span_panel/frontend/dist/span-panel-card.js b/custom_components/span_panel/frontend/dist/span-panel-card.js index 702096de..3eee3e88 100644 --- a/custom_components/span_panel/frontend/dist/span-panel-card.js +++ b/custom_components/span_panel/frontend/dist/span-panel-card.js @@ -1,26 +1,26 @@ -let t="en";const e={en:{"tab.panel":"Panel","tab.by_panel":"By Panel","tab.by_activity":"By Activity","tab.by_area":"By Area","tab.monitoring":"Monitoring","tab.settings":"Settings","tab.adopted":"Adopted","list.search_placeholder":"Search circuits...","list.unassigned_area":"Unassigned","list.no_results":"No circuits found","monitoring.heading":"Monitoring","monitoring.global_settings":"Global Settings","monitoring.enabled":"Enabled","monitoring.continuous":"Continuous (%)","monitoring.spike":"Spike (%)","monitoring.window":"Window (min)","monitoring.cooldown":"Cooldown (min)","monitoring.monitored_points":"Monitored Points","monitoring.col.name":"Name","monitoring.col.continuous":"Continuous","monitoring.col.spike":"Spike","monitoring.col.window":"Window","monitoring.col.cooldown":"Cooldown","monitoring.all_none":"All / None","monitoring.reset":"Reset","notification.heading":"Notification Settings","notification.targets":"Notify Targets","notification.none_selected":"None selected","notification.no_targets":"No notify targets found","notification.all_targets":"All","notification.event_bus_target":"Event Bus (HA event bus)","notification.priority":"Priority","notification.priority.default":"Default","notification.priority.passive":"Passive","notification.priority.active":"Active","notification.priority.time_sensitive":"Time-sensitive","notification.priority.critical":"Critical","notification.hint.critical":"Overrides silent/DND","notification.hint.time_sensitive":"Breaks through Focus","notification.hint.passive":"Delivers silently","notification.hint.active":"Standard delivery","notification.title_template":"Title Template","notification.message_template":"Message Template","notification.placeholders":"Placeholders:","notification.event_bus_help":"Event Bus fires event type","notification.event_bus_payload":"with payload:","notification.test_label":"Test Notification","notification.test_button":"Send Test","notification.test_sending":"Sending...","notification.test_sent":"Test notification sent","error.prefix":"Error:","error.failed_save":"Failed to save","error.failed":"Failed","error.panel_offline":"SPAN Panel unreachable","error.panel_reconnected":"SPAN Panel reconnected","error.panel_offline_named":"{name} unreachable","error.panel_reconnected_named":"{name} reconnected","error.discovery_failed":"Unable to connect to SPAN Panel","error.relay_failed":"Unable to toggle relay","error.shedding_failed":"Unable to update shedding priority","error.threshold_failed":"Unable to save threshold","error.graph_horizon_failed":"Unable to update graph time horizon","error.favorites_fetch_failed":"Unable to load favorites","error.favorites_toggle_failed":"Unable to update favorite","error.history_failed":"Unable to load historical data","error.monitoring_failed":"Unable to load monitoring status","error.graph_settings_failed":"Unable to load graph settings","error.areas_failed":"Area assignments may be out of sync","error.retry":"Retry","card.connecting":"Connecting to SPAN Panel...","settings.heading":"Settings","settings.description":"General integration settings (entity naming, device prefix, circuit numbers) are managed through the integration's options flow.","settings.open_link":"Open SPAN Panel Integration Settings","adopted.heading":"Adopted entities","adopted.description":"Vendor readings and adopted devices arrive with minimal metadata. Curate one to set its device class, statistics class, and prominence — saved changes reload the integration and apply from the next startup on.","adopted.filter_placeholder":"Filter by entity or device name","adopted.no_results":"No adopted entities match this filter","adopted.none":"This panel publishes nothing to curate.","adopted.load_failed":"Unable to load adopted entities","adopted.count":"{count} adopted","adopted.vendor_readings":"VENDOR READINGS","adopted.adopted_device":"ADOPTED DEVICE","adopted.via_panel":"via SPAN Panel","adopted.curated":"CURATED","adopted.stale":"STALE","adopted.stale_note":"The panel no longer supports what was saved for: {fields}","adopted.enable_entity":"Enable entity","adopted.enabled":"ENABLED","adopted.disabled":"DISABLED","adopted.enable_note":"Enabled when saved — curating never enables on its own","adopted.registry_unavailable":"Enable, name, and icon become available once this entity exists in the registry.","adopted.name":"Name","adopted.icon":"Icon","adopted.device_class":"Device class","adopted.device_class_note":"choices limited by the panel's unit ({unit})","adopted.device_class_note_unitless":"choices limited by what the panel publishes","adopted.no_device_class":"No device class","adopted.statistics_class":"Statistics class","adopted.statistics_note":"long-term statistics begin after the next reload","adopted.no_statistics":"No statistics","adopted.prominence":"Prominence","adopted.diagnostic":"Diagnostic","adopted.standard":"Standard","adopted.display_unit":"Display unit","adopted.unit_as_published":"As published","adopted.precision":"Precision","adopted.precision_default":"Default","adopted.read_only":"read-only","adopted.settable":"settable","adopted.save":"Save","adopted.clear":"Clear curation","adopted.reload_note":"Saving reloads the SPAN Panel integration","adopted.saved":"Saved — the integration is reloading","adopted.confirm_heading":"Confirm statistics class","adopted.confirm_setting":"You are setting {name} to {value}.","adopted.confirm_clearing_subject":"You are clearing the statistics class on {name}.","adopted.confirm_total_increasing":"Total increasing treats every drop in the value as a meter reset. If this reading can decrease for any other reason, long-term statistics will be permanently corrupted — fixing the class later does not repair history already written.","adopted.confirm_clearing":"Long-term statistics stop being compiled for this entity, and Home Assistant raises a repair against the statistics already collected under the class you are removing.","adopted.save_anyway":"Save anyway","adopted.cancel":"Cancel","adopted.warn_total_increasing":"Saved as total increasing — every drop in the reading now counts as a meter reset.","adopted.warn_statistics_removed":"This entity has no statistics class any more; Home Assistant will raise a repair against the statistics already collected.","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Panel monitoring settings","header.graph_settings":"Graph time horizon settings","header.site":"Site","header.grid":"Grid","header.upstream":"Upstream","header.downstream":"Downstream","header.solar":"Solar","header.battery":"Battery","header.toggle_units":"Toggle Watts / Amps","header.enable_switches":"Enable Switches","header.switches_enabled":"Switches Enabled","grid.unknown":"Unknown","grid.configure":"Configure circuit","grid.configure_subdevice":"Configure device","grid.on":"On","grid.off":"Off","subdevice.ev_charger":"EV Charger","subdevice.battery":"Battery","subdevice.fallback":"Sub-device","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Power","sidepanel.graph_settings":"Graph Settings","sidepanel.global_defaults":"Global defaults for all circuits","sidepanel.favorites_subtitle":"Favorites","sidepanel.global_default":"Global Default","sidepanel.list_view_columns":"List View Columns","sidepanel.columns":"Columns","sidepanel.circuit_scales":"Circuit Graph Scales","sidepanel.subdevice_scales":"Sub-Device Graph Scales","sidepanel.reset_to_global":"Reset to global default","sidepanel.relay":"Relay","sidepanel.breaker":"Breaker","sidepanel.shedding_priority":"Shedding Priority","sidepanel.priority_label":"Priority","sidepanel.monitoring":"Monitoring","sidepanel.global":"Global","sidepanel.custom":"Custom","sidepanel.continuous_pct":"Continuous %","sidepanel.spike_pct":"Spike %","sidepanel.window_duration":"Window duration","sidepanel.cooldown":"Cooldown","sidepanel.favorite":"Favorite","sidepanel.save_to_favorites":"Save to favorites","panel.favorites":"Favorites","status.monitoring":"Monitoring","status.circuits":"circuits","status.mains":"mains","status.warning":"warning","status.warnings":"warnings","status.alert":"alert","status.alerts":"alerts","status.override":"override","status.overrides":"overrides","card.no_device":"Open the card editor and select your SPAN Panel device.","card.device_not_found":"Panel device not found. Check device_id in card config.","card.topology_error":"Topology response missing panel_size and no circuits found. Update the SPAN Panel integration.","card.panel_size_error":"Could not determine panel_size. No circuits found and no panel_size attribute. Update the SPAN Panel integration.","editor.panel_label":"SPAN Panel","editor.select_panel":"Select a panel...","editor.chart_window":"Chart time window","editor.days":"days","editor.hours":"hours","editor.minutes":"minutes","editor.chart_metric":"Chart metric","editor.visible_sections":"Visible sections","editor.panel_circuits":"Panel circuits","editor.battery_bess":"Battery (BESS)","editor.ev_charger_evse":"EV Charger (EVSE)","editor.tab_style":"Tab Style","editor.tab_style_text":"Text","editor.tab_style_icon":"Icon","metric.power":"Power","metric.current":"Current","metric.soc":"State of Charge","metric.soe":"State of Energy","shedding.always_on":"Critical","shedding.never":"Non-sheddable","shedding.soc_threshold":"SoC Threshold","shedding.off_grid":"Sheddable","shedding.unknown":"Unknown","shedding.select.never":"Stays on in an outage","shedding.select.soc_threshold":"Stays on until battery threshold","shedding.select.off_grid":"Turns off in an outage"},es:{"tab.panel":"Panel","tab.by_panel":"Por Panel","tab.by_activity":"Por Actividad","tab.by_area":"Por Área","tab.monitoring":"Monitoreo","tab.settings":"Configuración","tab.adopted":"Adoptadas","list.search_placeholder":"Buscar circuitos...","list.unassigned_area":"Sin asignar","list.no_results":"No se encontraron circuitos","monitoring.heading":"Monitoreo","monitoring.global_settings":"Configuración Global","monitoring.enabled":"Activado","monitoring.continuous":"Continuo (%)","monitoring.spike":"Pico (%)","monitoring.window":"Ventana (min)","monitoring.cooldown":"Enfriamiento (min)","monitoring.monitored_points":"Puntos Monitoreados","monitoring.col.name":"Nombre","monitoring.col.continuous":"Continuo","monitoring.col.spike":"Pico","monitoring.col.window":"Ventana","monitoring.col.cooldown":"Enfriamiento","monitoring.all_none":"Todos / Ninguno","monitoring.reset":"Restablecer","notification.heading":"Configuración de Notificaciones","notification.targets":"Destinos de Notificación","notification.none_selected":"Ninguno seleccionado","notification.no_targets":"No se encontraron destinos de notificación","notification.all_targets":"Todos","notification.event_bus_target":"Bus de Eventos (bus de eventos de HA)","notification.priority":"Prioridad","notification.priority.default":"Predeterminado","notification.priority.passive":"Pasivo","notification.priority.active":"Activo","notification.priority.time_sensitive":"Urgente","notification.priority.critical":"Crítico","notification.hint.critical":"Anula silencio/No molestar","notification.hint.time_sensitive":"Atraviesa el modo Concentración","notification.hint.passive":"Entrega silenciosa","notification.hint.active":"Entrega estándar","notification.title_template":"Plantilla de Título","notification.message_template":"Plantilla de Mensaje","notification.placeholders":"Variables:","notification.event_bus_help":"El Bus de Eventos dispara el tipo de evento","notification.event_bus_payload":"con datos:","notification.test_label":"Notificación de prueba","notification.test_button":"Enviar prueba","notification.test_sending":"Enviando...","notification.test_sent":"Notificación de prueba enviada","error.prefix":"Error:","error.failed_save":"Error al guardar","error.failed":"Falló","error.panel_offline":"SPAN Panel inaccesible","error.panel_reconnected":"SPAN Panel reconectado","error.panel_offline_named":"{name} inaccesible","error.panel_reconnected_named":"{name} reconectado","error.discovery_failed":"No se puede conectar al SPAN Panel","error.relay_failed":"No se pudo cambiar el relé","error.shedding_failed":"No se pudo actualizar la prioridad de desconexión","error.threshold_failed":"No se pudo guardar el umbral","error.graph_horizon_failed":"No se pudo actualizar el horizonte temporal del gráfico","error.favorites_fetch_failed":"No se pudieron cargar los favoritos","error.favorites_toggle_failed":"No se pudo actualizar el favorito","error.history_failed":"No se pudieron cargar los datos históricos","error.monitoring_failed":"No se pudo cargar el estado de monitoreo","error.graph_settings_failed":"No se pudo cargar la configuración del gráfico","error.areas_failed":"Las asignaciones de áreas pueden estar desincronizadas","error.retry":"Reintentar","card.connecting":"Conectando al SPAN Panel...","settings.heading":"Configuración","settings.description":"La configuración general de la integración (nombres de entidades, prefijo de dispositivo, números de circuito) se administra a través del flujo de opciones de la integración.","settings.open_link":"Abrir Configuración de Integración SPAN Panel","adopted.heading":"Entidades adoptadas","adopted.description":"Las lecturas del proveedor y los dispositivos adoptados llegan con metadatos mínimos. Cura una para definir su clase de dispositivo, clase de estadísticas y prominencia — los cambios guardados recargan la integración y se aplican desde el siguiente arranque.","adopted.filter_placeholder":"Filtrar por nombre de entidad o dispositivo","adopted.no_results":"Ninguna entidad adoptada coincide con este filtro","adopted.none":"Este panel no publica nada que curar.","adopted.load_failed":"No se pudieron cargar las entidades adoptadas","adopted.count":"{count} adoptadas","adopted.vendor_readings":"LECTURAS DEL PROVEEDOR","adopted.adopted_device":"DISPOSITIVO ADOPTADO","adopted.via_panel":"vía SPAN Panel","adopted.curated":"CURADA","adopted.stale":"OBSOLETA","adopted.stale_note":"El panel ya no admite lo guardado para: {fields}","adopted.enable_entity":"Activar entidad","adopted.enabled":"ACTIVADA","adopted.disabled":"DESACTIVADA","adopted.enable_note":"Se activa al guardar — curar nunca activa por sí solo","adopted.registry_unavailable":"Activación, nombre e icono estarán disponibles cuando esta entidad exista en el registro.","adopted.name":"Nombre","adopted.icon":"Icono","adopted.device_class":"Clase de dispositivo","adopted.device_class_note":"opciones limitadas por la unidad del panel ({unit})","adopted.device_class_note_unitless":"opciones limitadas por lo que publica el panel","adopted.no_device_class":"Sin clase de dispositivo","adopted.statistics_class":"Clase de estadísticas","adopted.statistics_note":"las estadísticas a largo plazo comienzan tras la próxima recarga","adopted.no_statistics":"Sin estadísticas","adopted.prominence":"Prominencia","adopted.diagnostic":"Diagnóstico","adopted.standard":"Estándar","adopted.display_unit":"Unidad mostrada","adopted.unit_as_published":"Tal como se publica","adopted.precision":"Precisión","adopted.precision_default":"Predeterminada","adopted.read_only":"solo lectura","adopted.settable":"editable","adopted.save":"Guardar","adopted.clear":"Borrar curación","adopted.reload_note":"Guardar recarga la integración SPAN Panel","adopted.saved":"Guardado — la integración se está recargando","adopted.confirm_heading":"Confirmar clase de estadísticas","adopted.confirm_setting":"Vas a definir {name} como {value}.","adopted.confirm_clearing_subject":"Vas a borrar la clase de estadísticas de {name}.","adopted.confirm_total_increasing":"Total creciente interpreta cada caída del valor como un reinicio del contador. Si esta lectura puede disminuir por cualquier otro motivo, las estadísticas a largo plazo quedarán corrompidas de forma permanente — corregir la clase más tarde no repara el historial ya escrito.","adopted.confirm_clearing":"Dejarán de compilarse estadísticas a largo plazo para esta entidad, y Home Assistant abrirá una reparación sobre las estadísticas ya recogidas bajo la clase que estás quitando.","adopted.save_anyway":"Guardar de todos modos","adopted.cancel":"Cancelar","adopted.warn_total_increasing":"Guardado como total creciente — cada caída de la lectura cuenta ahora como un reinicio del contador.","adopted.warn_statistics_removed":"Esta entidad ya no tiene clase de estadísticas; Home Assistant abrirá una reparación sobre las estadísticas ya recogidas.","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Configuración de monitoreo del panel","header.graph_settings":"Configuración del horizonte temporal del gráfico","header.site":"Sitio","header.grid":"Red","header.upstream":"Aguas arriba","header.downstream":"Aguas abajo","header.solar":"Solar","header.battery":"Batería","header.toggle_units":"Alternar Watts / Amperios","header.enable_switches":"Habilitar Interruptores","header.switches_enabled":"Interruptores Habilitados","grid.unknown":"Desconocido","grid.configure":"Configurar circuito","grid.configure_subdevice":"Configurar dispositivo","grid.on":"Enc","grid.off":"Apag","subdevice.ev_charger":"Cargador EV","subdevice.battery":"Batería","subdevice.fallback":"Sub-dispositivo","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Potencia","sidepanel.graph_settings":"Configuración de Gráficos","sidepanel.global_defaults":"Valores predeterminados globales para todos los circuitos","sidepanel.favorites_subtitle":"Favoritos","sidepanel.global_default":"Predeterminado Global","sidepanel.list_view_columns":"Columnas de la lista","sidepanel.columns":"Columnas","sidepanel.circuit_scales":"Escalas de Gráficos de Circuitos","sidepanel.subdevice_scales":"Escalas de Gráficos de Sub-Dispositivos","sidepanel.reset_to_global":"Restablecer al valor global","sidepanel.relay":"Relé","sidepanel.breaker":"Interruptor","sidepanel.shedding_priority":"Prioridad de Desconexción","sidepanel.priority_label":"Prioridad","sidepanel.monitoring":"Monitoreo","sidepanel.global":"Global","sidepanel.custom":"Personalizado","sidepanel.continuous_pct":"Continuo %","sidepanel.spike_pct":"Pico %","sidepanel.window_duration":"Duración de ventana","sidepanel.cooldown":"Enfriamiento","sidepanel.favorite":"Favorito","sidepanel.save_to_favorites":"Guardar en favoritos","panel.favorites":"Favoritos","status.monitoring":"Monitoreo","status.circuits":"circuitos","status.mains":"alimentación","status.warning":"advertencia","status.warnings":"advertencias","status.alert":"alerta","status.alerts":"alertas","status.override":"anulación","status.overrides":"anulaciones","card.no_device":"Abra el editor de tarjeta y seleccione su dispositivo SPAN Panel.","card.device_not_found":"Dispositivo de panel no encontrado. Verifique device_id en la configuración de la tarjeta.","card.topology_error":"La respuesta de topología no contiene panel_size y no se encontraron circuitos. Actualice la integración SPAN Panel.","card.panel_size_error":"No se pudo determinar panel_size. No se encontraron circuitos ni atributo panel_size. Actualice la integración SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Seleccione un panel...","editor.chart_window":"Ventana de tiempo del gráfico","editor.days":"días","editor.hours":"horas","editor.minutes":"minutos","editor.chart_metric":"Métrica del gráfico","editor.visible_sections":"Secciones visibles","editor.panel_circuits":"Circuitos del panel","editor.battery_bess":"Batería (BESS)","editor.ev_charger_evse":"Cargador EV (EVSE)","editor.tab_style":"Estilo de pestañas","editor.tab_style_text":"Texto","editor.tab_style_icon":"Ícono","metric.power":"Potencia","metric.current":"Corriente","metric.soc":"Estado de Carga","metric.soe":"Estado de Energía","shedding.always_on":"Crítico","shedding.never":"No desconectable","shedding.soc_threshold":"Umbral SoC","shedding.off_grid":"Desconectable","shedding.unknown":"Desconocido","shedding.select.never":"Permanece encendido en un corte","shedding.select.soc_threshold":"Encendido hasta umbral de batería","shedding.select.off_grid":"Se apaga en un corte"},fr:{"tab.panel":"Panneau","tab.by_panel":"Par Panneau","tab.by_activity":"Par Activité","tab.by_area":"Par Zone","tab.monitoring":"Surveillance","tab.settings":"Paramètres","tab.adopted":"Adoptées","list.search_placeholder":"Rechercher des circuits...","list.unassigned_area":"Non attribué","list.no_results":"Aucun circuit trouvé","monitoring.heading":"Surveillance","monitoring.global_settings":"Paramètres Globaux","monitoring.enabled":"Activé","monitoring.continuous":"Continu (%)","monitoring.spike":"Pic (%)","monitoring.window":"Fenêtre (min)","monitoring.cooldown":"Refroidissement (min)","monitoring.monitored_points":"Points Surveillés","monitoring.col.name":"Nom","monitoring.col.continuous":"Continu","monitoring.col.spike":"Pic","monitoring.col.window":"Fenêtre","monitoring.col.cooldown":"Refroidissement","monitoring.all_none":"Tous / Aucun","monitoring.reset":"Réinitialiser","notification.heading":"Paramètres de Notification","notification.targets":"Cibles de Notification","notification.none_selected":"Aucune sélection","notification.no_targets":"Aucune cible de notification trouvée","notification.all_targets":"Tous","notification.event_bus_target":"Bus d'événements (bus d'événements HA)","notification.priority":"Priorité","notification.priority.default":"Par défaut","notification.priority.passive":"Passif","notification.priority.active":"Actif","notification.priority.time_sensitive":"Urgent","notification.priority.critical":"Critique","notification.hint.critical":"Outrepasse silencieux/NPD","notification.hint.time_sensitive":"Traverse le mode Concentration","notification.hint.passive":"Livraison silencieuse","notification.hint.active":"Livraison standard","notification.title_template":"Modèle de Titre","notification.message_template":"Modèle de Message","notification.placeholders":"Variables :","notification.event_bus_help":"Le Bus d'événements déclenche le type d'événement","notification.event_bus_payload":"avec les données :","notification.test_label":"Notification de test","notification.test_button":"Envoyer un test","notification.test_sending":"Envoi...","notification.test_sent":"Notification de test envoyée","error.prefix":"Erreur :","error.failed_save":"Échec de la sauvegarde","error.failed":"Échoué","error.panel_offline":"SPAN Panel inaccessible","error.panel_reconnected":"SPAN Panel reconnecté","error.panel_offline_named":"{name} inaccessible","error.panel_reconnected_named":"{name} reconnecté","error.discovery_failed":"Impossible de se connecter au SPAN Panel","error.relay_failed":"Impossible de basculer le relais","error.shedding_failed":"Impossible de mettre à jour la priorité de délestage","error.threshold_failed":"Impossible d'enregistrer le seuil","error.graph_horizon_failed":"Impossible de mettre à jour l'horizon temporel du graphique","error.favorites_fetch_failed":"Impossible de charger les favoris","error.favorites_toggle_failed":"Impossible de mettre à jour le favori","error.history_failed":"Impossible de charger les données historiques","error.monitoring_failed":"Impossible de charger l'état de surveillance","error.graph_settings_failed":"Impossible de charger les paramètres du graphique","error.areas_failed":"Les affectations de zones peuvent être désynchronisées","error.retry":"Réessayer","card.connecting":"Connexion au SPAN Panel...","settings.heading":"Paramètres","settings.description":"Les paramètres généraux de l'intégration (noms d'entités, préfixe de l'appareil, numéros de circuit) sont gérés via le flux d'options de l'intégration.","settings.open_link":"Ouvrir les Paramètres d'Intégration SPAN Panel","adopted.heading":"Entités adoptées","adopted.description":"Les relevés du fournisseur et les appareils adoptés arrivent avec des métadonnées minimales. Curez-en un pour définir sa classe d'appareil, sa classe de statistiques et sa proéminence — les modifications enregistrées rechargent l'intégration et s'appliquent dès le prochain démarrage.","adopted.filter_placeholder":"Filtrer par nom d'entité ou d'appareil","adopted.no_results":"Aucune entité adoptée ne correspond à ce filtre","adopted.none":"Ce panneau ne publie rien à curer.","adopted.load_failed":"Impossible de charger les entités adoptées","adopted.count":"{count} adoptées","adopted.vendor_readings":"RELEVÉS DU FOURNISSEUR","adopted.adopted_device":"APPAREIL ADOPTÉ","adopted.via_panel":"via SPAN Panel","adopted.curated":"CURÉE","adopted.stale":"OBSOLÈTE","adopted.stale_note":"Le panneau ne prend plus en charge ce qui a été enregistré pour : {fields}","adopted.enable_entity":"Activer l'entité","adopted.enabled":"ACTIVÉE","adopted.disabled":"DÉSACTIVÉE","adopted.enable_note":"Activée à l'enregistrement — la curation n'active jamais d'elle-même","adopted.registry_unavailable":"L'activation, le nom et l'icône seront disponibles dès que cette entité existera dans le registre.","adopted.name":"Nom","adopted.icon":"Icône","adopted.device_class":"Classe d'appareil","adopted.device_class_note":"choix limités par l'unité du panneau ({unit})","adopted.device_class_note_unitless":"choix limités par ce que le panneau publie","adopted.no_device_class":"Aucune classe d'appareil","adopted.statistics_class":"Classe de statistiques","adopted.statistics_note":"les statistiques à long terme commencent après le prochain rechargement","adopted.no_statistics":"Aucune statistique","adopted.prominence":"Proéminence","adopted.diagnostic":"Diagnostic","adopted.standard":"Standard","adopted.display_unit":"Unité affichée","adopted.unit_as_published":"Telle que publiée","adopted.precision":"Précision","adopted.precision_default":"Par défaut","adopted.read_only":"lecture seule","adopted.settable":"modifiable","adopted.save":"Enregistrer","adopted.clear":"Effacer la curation","adopted.reload_note":"L'enregistrement recharge l'intégration SPAN Panel","adopted.saved":"Enregistré — l'intégration se recharge","adopted.confirm_heading":"Confirmer la classe de statistiques","adopted.confirm_setting":"Vous définissez {name} sur {value}.","adopted.confirm_clearing_subject":"Vous effacez la classe de statistiques de {name}.","adopted.confirm_total_increasing":"Total croissant interprète chaque baisse de la valeur comme une remise à zéro du compteur. Si ce relevé peut diminuer pour une autre raison, les statistiques à long terme seront corrompues de façon permanente — corriger la classe plus tard ne répare pas l'historique déjà écrit.","adopted.confirm_clearing":"Les statistiques à long terme cesseront d'être compilées pour cette entité, et Home Assistant ouvrira une réparation sur les statistiques déjà collectées sous la classe que vous retirez.","adopted.save_anyway":"Enregistrer quand même","adopted.cancel":"Annuler","adopted.warn_total_increasing":"Enregistré en total croissant — chaque baisse du relevé compte désormais comme une remise à zéro du compteur.","adopted.warn_statistics_removed":"Cette entité n'a plus de classe de statistiques ; Home Assistant ouvrira une réparation sur les statistiques déjà collectées.","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Paramètres de surveillance du panneau","header.graph_settings":"Paramètres d'horizon temporel du graphique","header.site":"Site","header.grid":"Réseau","header.upstream":"Amont","header.downstream":"Aval","header.solar":"Solaire","header.battery":"Batterie","header.toggle_units":"Basculer Watts / Ampères","header.enable_switches":"Activer les interrupteurs","header.switches_enabled":"Interrupteurs activés","grid.unknown":"Inconnu","grid.configure":"Configurer le circuit","grid.configure_subdevice":"Configurer l'appareil","grid.on":"On","grid.off":"Off","subdevice.ev_charger":"Chargeur VE","subdevice.battery":"Batterie","subdevice.fallback":"Sous-appareil","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Puissance","sidepanel.graph_settings":"Paramètres des Graphiques","sidepanel.global_defaults":"Valeurs par défaut globales pour tous les circuits","sidepanel.favorites_subtitle":"Favoris","sidepanel.global_default":"Défaut Global","sidepanel.list_view_columns":"Colonnes de la liste","sidepanel.columns":"Colonnes","sidepanel.circuit_scales":"Échelles des Graphiques de Circuits","sidepanel.subdevice_scales":"Échelles des Graphiques de Sous-Appareils","sidepanel.reset_to_global":"Réinitialiser à la valeur globale","sidepanel.relay":"Relais","sidepanel.breaker":"Disjoncteur","sidepanel.shedding_priority":"Priorité de Délestage","sidepanel.priority_label":"Priorité","sidepanel.monitoring":"Surveillance","sidepanel.global":"Global","sidepanel.custom":"Personnalisé","sidepanel.continuous_pct":"Continu %","sidepanel.spike_pct":"Pic %","sidepanel.window_duration":"Durée de fenêtre","sidepanel.cooldown":"Refroidissement","sidepanel.favorite":"Favori","sidepanel.save_to_favorites":"Enregistrer dans les favoris","panel.favorites":"Favoris","status.monitoring":"Surveillance","status.circuits":"circuits","status.mains":"alimentation","status.warning":"avertissement","status.warnings":"avertissements","status.alert":"alerte","status.alerts":"alertes","status.override":"remplacement","status.overrides":"remplacements","card.no_device":"Ouvrez l'éditeur de carte et sélectionnez votre appareil SPAN Panel.","card.device_not_found":"Appareil de panneau introuvable. Vérifiez device_id dans la configuration de la carte.","card.topology_error":"La réponse de topologie ne contient pas panel_size et aucun circuit trouvé. Mettez à jour l'intégration SPAN Panel.","card.panel_size_error":"Impossible de déterminer panel_size. Aucun circuit trouvé et aucun attribut panel_size. Mettez à jour l'intégration SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Sélectionnez un panneau...","editor.chart_window":"Fenêtre de temps du graphique","editor.days":"jours","editor.hours":"heures","editor.minutes":"minutes","editor.chart_metric":"Métrique du graphique","editor.visible_sections":"Sections visibles","editor.panel_circuits":"Circuits du panneau","editor.battery_bess":"Batterie (BESS)","editor.ev_charger_evse":"Chargeur VE (EVSE)","editor.tab_style":"Style des onglets","editor.tab_style_text":"Texte","editor.tab_style_icon":"Icône","metric.power":"Puissance","metric.current":"Courant","metric.soc":"État de Charge","metric.soe":"État d'Énergie","shedding.always_on":"Critique","shedding.never":"Non délestable","shedding.soc_threshold":"Seuil SoC","shedding.off_grid":"Délestable","shedding.unknown":"Inconnu","shedding.select.never":"Reste allumé en cas de coupure","shedding.select.soc_threshold":"Allumé jusqu'au seuil batterie","shedding.select.off_grid":"S'éteint en cas de coupure"},ja:{"tab.panel":"パネル","tab.by_panel":"パネル別","tab.by_activity":"活動別","tab.by_area":"エリア別","tab.monitoring":"モニタリング","tab.settings":"設定","tab.adopted":"採用済み","list.search_placeholder":"回路を検索...","list.unassigned_area":"未割り当て","list.no_results":"回路が見つかりません","monitoring.heading":"モニタリング","monitoring.global_settings":"グローバル設定","monitoring.enabled":"有効","monitoring.continuous":"継続 (%)","monitoring.spike":"スパイク (%)","monitoring.window":"ウィンドウ (分)","monitoring.cooldown":"クールダウン (分)","monitoring.monitored_points":"監視ポイント","monitoring.col.name":"名前","monitoring.col.continuous":"継続","monitoring.col.spike":"スパイク","monitoring.col.window":"ウィンドウ","monitoring.col.cooldown":"クールダウン","monitoring.all_none":"全選択 / 全解除","monitoring.reset":"リセット","notification.heading":"通知設定","notification.targets":"通知先","notification.none_selected":"未選択","notification.no_targets":"通知先が見つかりません","notification.all_targets":"すべて","notification.event_bus_target":"イベントバス (HAイベントバス)","notification.priority":"優先度","notification.priority.default":"デフォルト","notification.priority.passive":"パッシブ","notification.priority.active":"アクティブ","notification.priority.time_sensitive":"緊急","notification.priority.critical":"重大","notification.hint.critical":"サイレント/おやすみモードを無視","notification.hint.time_sensitive":"集中モードを突破","notification.hint.passive":"サイレント配信","notification.hint.active":"標準配信","notification.title_template":"タイトルテンプレート","notification.message_template":"メッセージテンプレート","notification.placeholders":"プレースホルダー:","notification.event_bus_help":"イベントバスが発行するイベントタイプ","notification.event_bus_payload":"ペイロード:","notification.test_label":"テスト通知","notification.test_button":"テスト送信","notification.test_sending":"送信中...","notification.test_sent":"テスト通知を送信しました","error.prefix":"エラー:","error.failed_save":"保存に失敗","error.failed":"失敗","error.panel_offline":"SPANパネルに接続できません","error.panel_reconnected":"SPANパネルが再接続されました","error.panel_offline_named":"{name}に接続できません","error.panel_reconnected_named":"{name}が再接続されました","error.discovery_failed":"SPANパネルへの接続に失敗しました","error.relay_failed":"リレーの切り替えに失敗しました","error.shedding_failed":"シェディング優先度の更新に失敗しました","error.threshold_failed":"しきい値の保存に失敗しました","error.graph_horizon_failed":"グラフの時間範囲の更新に失敗しました","error.favorites_fetch_failed":"お気に入りの読み込みに失敗しました","error.favorites_toggle_failed":"お気に入りの更新に失敗しました","error.history_failed":"履歴データの読み込みに失敗しました","error.monitoring_failed":"監視ステータスの読み込みに失敗しました","error.graph_settings_failed":"グラフ設定の読み込みに失敗しました","error.areas_failed":"エリア割り当てが同期されていない可能性があります","error.retry":"再試行","card.connecting":"SPANパネルに接続中...","settings.heading":"設定","settings.description":"統合の一般設定(エンティティ名、デバイスプレフィックス、回路番号)は統合のオプションフローで管理されます。","settings.open_link":"SPAN Panel統合設定を開く","adopted.heading":"採用済みエンティティ","adopted.description":"ベンダーの測定値と採用済みデバイスは最小限のメタデータで登録されます。キュレーションでデバイスクラス、統計クラス、表示区分を設定できます。保存すると統合が再読み込みされ、次回の起動から適用されます。","adopted.filter_placeholder":"エンティティ名またはデバイス名で絞り込み","adopted.no_results":"この条件に一致する採用済みエンティティはありません","adopted.none":"このパネルにキュレーション対象はありません。","adopted.load_failed":"採用済みエンティティを読み込めません","adopted.count":"{count} 件","adopted.vendor_readings":"ベンダー測定値","adopted.adopted_device":"採用済みデバイス","adopted.via_panel":"SPAN Panel 経由","adopted.curated":"キュレーション済み","adopted.stale":"無効","adopted.stale_note":"パネルは保存された次の項目をサポートしなくなりました: {fields}","adopted.enable_entity":"エンティティを有効化","adopted.enabled":"有効","adopted.disabled":"無効","adopted.enable_note":"保存時に有効化されます — キュレーション自体が有効化することはありません","adopted.registry_unavailable":"有効化・名前・アイコンは、このエンティティがレジストリに登録された後に利用できます。","adopted.name":"名前","adopted.icon":"アイコン","adopted.device_class":"デバイスクラス","adopted.device_class_note":"パネルの単位({unit})により選択肢が制限されます","adopted.device_class_note_unitless":"パネルが公開する内容により選択肢が制限されます","adopted.no_device_class":"デバイスクラスなし","adopted.statistics_class":"統計クラス","adopted.statistics_note":"長期統計は次回の再読み込み後に開始されます","adopted.no_statistics":"統計なし","adopted.prominence":"表示区分","adopted.diagnostic":"診断","adopted.standard":"標準","adopted.display_unit":"表示単位","adopted.unit_as_published":"公開されたまま","adopted.precision":"小数点以下桁数","adopted.precision_default":"既定","adopted.read_only":"読み取り専用","adopted.settable":"書き込み可","adopted.save":"保存","adopted.clear":"キュレーションを消去","adopted.reload_note":"保存すると SPAN Panel 統合が再読み込みされます","adopted.saved":"保存しました — 統合を再読み込みしています","adopted.confirm_heading":"統計クラスの確認","adopted.confirm_setting":"{name} を {value} に設定しようとしています。","adopted.confirm_clearing_subject":"{name} の統計クラスを消去しようとしています。","adopted.confirm_total_increasing":"積算増加は値の低下をすべてメーターのリセットとして扱います。この測定値が他の理由でも下がる場合、長期統計は恒久的に破損します。後からクラスを直しても、既に書き込まれた履歴は修復されません。","adopted.confirm_clearing":"このエンティティの長期統計は収集されなくなり、削除するクラスの下で既に収集された統計について Home Assistant が修復項目を作成します。","adopted.save_anyway":"それでも保存","adopted.cancel":"キャンセル","adopted.warn_total_increasing":"積算増加として保存しました — 測定値の低下はすべてメーターのリセットとして数えられます。","adopted.warn_statistics_removed":"このエンティティに統計クラスはなくなりました。既に収集された統計について Home Assistant が修復項目を作成します。","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"パネルモニタリング設定","header.graph_settings":"グラフ時間範囲設定","header.site":"サイト","header.grid":"グリッド","header.upstream":"上流","header.downstream":"下流","header.solar":"ソーラー","header.battery":"バッテリー","header.toggle_units":"ワット/アンペア切り替え","header.enable_switches":"スイッチを有効化","header.switches_enabled":"スイッチ有効","grid.unknown":"不明","grid.configure":"回路を設定","grid.configure_subdevice":"デバイスを設定","grid.on":"オン","grid.off":"オフ","subdevice.ev_charger":"EV充電器","subdevice.battery":"バッテリー","subdevice.fallback":"サブデバイス","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"電力","sidepanel.graph_settings":"グラフ設定","sidepanel.global_defaults":"全回路のグローバルデフォルト","sidepanel.favorites_subtitle":"お気に入り","sidepanel.global_default":"グローバルデフォルト","sidepanel.list_view_columns":"リスト表示の列数","sidepanel.columns":"列","sidepanel.circuit_scales":"回路グラフスケール","sidepanel.subdevice_scales":"サブデバイスグラフスケール","sidepanel.reset_to_global":"グローバルにリセット","sidepanel.relay":"リレー","sidepanel.breaker":"ブレーカー","sidepanel.shedding_priority":"シェディング優先度","sidepanel.priority_label":"優先度","sidepanel.monitoring":"モニタリング","sidepanel.global":"グローバル","sidepanel.custom":"カスタム","sidepanel.continuous_pct":"継続 %","sidepanel.spike_pct":"スパイク %","sidepanel.window_duration":"ウィンドウ時間","sidepanel.cooldown":"クールダウン","sidepanel.favorite":"お気に入り","sidepanel.save_to_favorites":"お気に入りに保存","panel.favorites":"お気に入り","status.monitoring":"モニタリング","status.circuits":"回路","status.mains":"主電源","status.warning":"警告","status.warnings":"警告","status.alert":"アラート","status.alerts":"アラート","status.override":"上書き","status.overrides":"上書き","card.no_device":"カードエディタを開いてSPAN Panelデバイスを選択してください。","card.device_not_found":"パネルデバイスが見つかりません。カード設定のdevice_idを確認してください。","card.topology_error":"トポロジー応答にpanel_sizeがなく、回路が見つかりません。SPAN Panel統合を更新してください。","card.panel_size_error":"panel_sizeを判定できません。回路がpanel_size属性が見つかりません。SPAN Panel統合を更新してください。","editor.panel_label":"SPAN Panel","editor.select_panel":"パネルを選択...","editor.chart_window":"グラフ時間ウィンドウ","editor.days":"日","editor.hours":"時間","editor.minutes":"分","editor.chart_metric":"グラフ指標","editor.visible_sections":"表示セクション","editor.panel_circuits":"パネル回路","editor.battery_bess":"バッテリー (BESS)","editor.ev_charger_evse":"EV充電器 (EVSE)","editor.tab_style":"タブスタイル","editor.tab_style_text":"テキスト","editor.tab_style_icon":"アイコン","metric.power":"電力","metric.current":"電流","metric.soc":"充電状態","metric.soe":"エネルギー状態","shedding.always_on":"重要","shedding.never":"切断不可","shedding.soc_threshold":"SoCしきい値","shedding.off_grid":"切断可能","shedding.unknown":"不明","shedding.select.never":"停電時もオンを維持","shedding.select.soc_threshold":"バッテリーしきい値までオン","shedding.select.off_grid":"停電時にオフ"},pt:{"tab.panel":"Painel","tab.by_panel":"Por Painel","tab.by_activity":"Por Atividade","tab.by_area":"Por Área","tab.monitoring":"Monitoramento","tab.settings":"Configurações","tab.adopted":"Adotadas","list.search_placeholder":"Pesquisar circuitos...","list.unassigned_area":"Não atribuído","list.no_results":"Nenhum circuito encontrado","monitoring.heading":"Monitoramento","monitoring.global_settings":"Configurações Globais","monitoring.enabled":"Ativado","monitoring.continuous":"Contínuo (%)","monitoring.spike":"Pico (%)","monitoring.window":"Janela (min)","monitoring.cooldown":"Resfriamento (min)","monitoring.monitored_points":"Pontos Monitorados","monitoring.col.name":"Nome","monitoring.col.continuous":"Contínuo","monitoring.col.spike":"Pico","monitoring.col.window":"Janela","monitoring.col.cooldown":"Resfriamento","monitoring.all_none":"Todos / Nenhum","monitoring.reset":"Redefinir","notification.heading":"Configurações de Notificação","notification.targets":"Destinos de Notificação","notification.none_selected":"Nenhum selecionado","notification.no_targets":"Nenhum destino de notificação encontrado","notification.all_targets":"Todos","notification.event_bus_target":"Barramento de Eventos (barramento de eventos do HA)","notification.priority":"Prioridade","notification.priority.default":"Padrão","notification.priority.passive":"Passivo","notification.priority.active":"Ativo","notification.priority.time_sensitive":"Urgente","notification.priority.critical":"Crítico","notification.hint.critical":"Substitui silencioso/Não perturbar","notification.hint.time_sensitive":"Atravessa o modo Foco","notification.hint.passive":"Entrega silenciosa","notification.hint.active":"Entrega padrão","notification.title_template":"Modelo de Título","notification.message_template":"Modelo de Mensagem","notification.placeholders":"Variáveis:","notification.event_bus_help":"O Barramento de Eventos dispara o tipo de evento","notification.event_bus_payload":"com dados:","notification.test_label":"Notificação de teste","notification.test_button":"Enviar teste","notification.test_sending":"Enviando...","notification.test_sent":"Notificação de teste enviada","error.prefix":"Erro:","error.failed_save":"Falha ao salvar","error.failed":"Falhou","error.panel_offline":"SPAN Panel inacessível","error.panel_reconnected":"SPAN Panel reconectado","error.panel_offline_named":"{name} inacessível","error.panel_reconnected_named":"{name} reconectado","error.discovery_failed":"Não foi possível conectar ao SPAN Panel","error.relay_failed":"Não foi possível alternar o relé","error.shedding_failed":"Não foi possível atualizar a prioridade de desligamento","error.threshold_failed":"Não foi possível salvar o limite","error.graph_horizon_failed":"Não foi possível atualizar o horizonte temporal do gráfico","error.favorites_fetch_failed":"Não foi possível carregar os favoritos","error.favorites_toggle_failed":"Não foi possível atualizar o favorito","error.history_failed":"Não foi possível carregar os dados históricos","error.monitoring_failed":"Não foi possível carregar o status de monitoramento","error.graph_settings_failed":"Não foi possível carregar as configurações do gráfico","error.areas_failed":"As atribuições de áreas podem estar fora de sincronização","error.retry":"Tentar novamente","card.connecting":"Conectando ao SPAN Panel...","settings.heading":"Configurações","settings.description":"As configurações gerais da integração (nomes de entidades, prefixo do dispositivo, números de circuito) são gerenciadas através do fluxo de opções da integração.","settings.open_link":"Abrir Configurações de Integração SPAN Panel","adopted.heading":"Entidades adotadas","adopted.description":"As leituras do fornecedor e os dispositivos adotados chegam com metadados mínimos. Faça a curadoria de uma para definir sua classe de dispositivo, classe de estatísticas e proeminência — alterações salvas recarregam a integração e valem a partir da próxima inicialização.","adopted.filter_placeholder":"Filtrar por nome de entidade ou dispositivo","adopted.no_results":"Nenhuma entidade adotada corresponde a este filtro","adopted.none":"Este painel não publica nada para curadoria.","adopted.load_failed":"Não foi possível carregar as entidades adotadas","adopted.count":"{count} adotadas","adopted.vendor_readings":"LEITURAS DO FORNECEDOR","adopted.adopted_device":"DISPOSITIVO ADOTADO","adopted.via_panel":"via SPAN Panel","adopted.curated":"CURADA","adopted.stale":"OBSOLETA","adopted.stale_note":"O painel não suporta mais o que foi salvo para: {fields}","adopted.enable_entity":"Ativar entidade","adopted.enabled":"ATIVADA","adopted.disabled":"DESATIVADA","adopted.enable_note":"Ativada ao salvar — a curadoria nunca ativa por conta própria","adopted.registry_unavailable":"Ativação, nome e ícone ficam disponíveis assim que esta entidade existir no registro.","adopted.name":"Nome","adopted.icon":"Ícone","adopted.device_class":"Classe de dispositivo","adopted.device_class_note":"opções limitadas pela unidade do painel ({unit})","adopted.device_class_note_unitless":"opções limitadas pelo que o painel publica","adopted.no_device_class":"Sem classe de dispositivo","adopted.statistics_class":"Classe de estatísticas","adopted.statistics_note":"as estatísticas de longo prazo começam após a próxima recarga","adopted.no_statistics":"Sem estatísticas","adopted.prominence":"Proeminência","adopted.diagnostic":"Diagnóstico","adopted.standard":"Padrão","adopted.display_unit":"Unidade exibida","adopted.unit_as_published":"Como publicada","adopted.precision":"Precisão","adopted.precision_default":"Padrão","adopted.read_only":"somente leitura","adopted.settable":"editável","adopted.save":"Salvar","adopted.clear":"Limpar curadoria","adopted.reload_note":"Salvar recarrega a integração SPAN Panel","adopted.saved":"Salvo — a integração está recarregando","adopted.confirm_heading":"Confirmar classe de estatísticas","adopted.confirm_setting":"Você está definindo {name} como {value}.","adopted.confirm_clearing_subject":"Você está limpando a classe de estatísticas de {name}.","adopted.confirm_total_increasing":"Total crescente trata toda queda do valor como uma reinicialização do medidor. Se esta leitura puder diminuir por qualquer outro motivo, as estatísticas de longo prazo ficarão permanentemente corrompidas — corrigir a classe depois não repara o histórico já gravado.","adopted.confirm_clearing":"As estatísticas de longo prazo deixarão de ser compiladas para esta entidade, e o Home Assistant abrirá um reparo sobre as estatísticas já coletadas na classe que você está removendo.","adopted.save_anyway":"Salvar mesmo assim","adopted.cancel":"Cancelar","adopted.warn_total_increasing":"Salvo como total crescente — cada queda da leitura agora conta como uma reinicialização do medidor.","adopted.warn_statistics_removed":"Esta entidade não tem mais classe de estatísticas; o Home Assistant abrirá um reparo sobre as estatísticas já coletadas.","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Configurações de monitoramento do painel","header.graph_settings":"Configurações do horizonte temporal do gráfico","header.site":"Local","header.grid":"Rede","header.upstream":"Montante","header.downstream":"Jusante","header.solar":"Solar","header.battery":"Bateria","header.toggle_units":"Alternar Watts / Amperes","header.enable_switches":"Ativar Interruptores","header.switches_enabled":"Interruptores Ativados","grid.unknown":"Desconhecido","grid.configure":"Configurar circuito","grid.configure_subdevice":"Configurar dispositivo","grid.on":"Lig","grid.off":"Des","subdevice.ev_charger":"Carregador VE","subdevice.battery":"Bateria","subdevice.fallback":"Sub-dispositivo","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Potência","sidepanel.graph_settings":"Configurações de Gráficos","sidepanel.global_defaults":"Padrões globais para todos os circuitos","sidepanel.favorites_subtitle":"Favoritos","sidepanel.global_default":"Padrão Global","sidepanel.list_view_columns":"Colunas da Lista","sidepanel.columns":"Colunas","sidepanel.circuit_scales":"Escalas de Gráficos de Circuitos","sidepanel.subdevice_scales":"Escalas de Gráficos de Sub-Dispositivos","sidepanel.reset_to_global":"Redefinir para o padrão global","sidepanel.relay":"Relé","sidepanel.breaker":"Disjuntor","sidepanel.shedding_priority":"Prioridade de Desligamento","sidepanel.priority_label":"Prioridade","sidepanel.monitoring":"Monitoramento","sidepanel.global":"Global","sidepanel.custom":"Personalizado","sidepanel.continuous_pct":"Contínuo %","sidepanel.spike_pct":"Pico %","sidepanel.window_duration":"Duração da janela","sidepanel.cooldown":"Resfriamento","sidepanel.favorite":"Favorito","sidepanel.save_to_favorites":"Salvar nos favoritos","panel.favorites":"Favoritos","status.monitoring":"Monitoramento","status.circuits":"circuitos","status.mains":"alimentação","status.warning":"aviso","status.warnings":"avisos","status.alert":"alerta","status.alerts":"alertas","status.override":"substituição","status.overrides":"substituições","card.no_device":"Abra o editor do cartão e selecione seu dispositivo SPAN Panel.","card.device_not_found":"Dispositivo do painel não encontrado. Verifique device_id na configuração do cartão.","card.topology_error":"A resposta de topologia não contém panel_size e nenhum circuito encontrado. Atualize a integração SPAN Panel.","card.panel_size_error":"Não foi possível determinar panel_size. Nenhum circuito encontrado e nenhum atributo panel_size. Atualize a integração SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Selecione um painel...","editor.chart_window":"Janela de tempo do gráfico","editor.days":"dias","editor.hours":"horas","editor.minutes":"minutos","editor.chart_metric":"Métrica do gráfico","editor.visible_sections":"Seções visíveis","editor.panel_circuits":"Circuitos do painel","editor.battery_bess":"Bateria (BESS)","editor.ev_charger_evse":"Carregador VE (EVSE)","editor.tab_style":"Estilo das abas","editor.tab_style_text":"Texto","editor.tab_style_icon":"Ícone","metric.power":"Potência","metric.current":"Corrente","metric.soc":"Estado de Carga","metric.soe":"Estado de Energia","shedding.always_on":"Crítico","shedding.never":"Não desligável","shedding.soc_threshold":"Limite SoC","shedding.off_grid":"Desligável","shedding.unknown":"Desconhecido","shedding.select.never":"Permanece ligado em uma queda","shedding.select.soc_threshold":"Ligado até limite da bateria","shedding.select.off_grid":"Desliga em uma queda"}};function n(n){t=n&&e[n]?n:"en"}function i(n){return e[t]?.[n]??e.en?.[n]??n}function r(n,i){return(e[t]?.[n]??e.en?.[n]??n).replace(/\{(\w+)\}/g,(t,e)=>Object.prototype.hasOwnProperty.call(i,e)?i[e]:`{${e}}`)}const o="power",a="5m",s={"5m":{ms:3e5,refreshMs:1e3,useRealtime:!0},"1h":{ms:36e5,refreshMs:3e4,useRealtime:!1},"1d":{ms:864e5,refreshMs:6e4,useRealtime:!1},"1w":{ms:6048e5,refreshMs:6e4,useRealtime:!1},"1M":{ms:2592e6,refreshMs:6e4,useRealtime:!1}},l="span_panel",c="CLOSED",u="pv",d="bess",h="evse",p="sub_",f=500,g={power:{entityRole:"power",label:()=>i("metric.power"),unit:t=>Math.abs(t)>=1e3?"kW":"W",format:t=>{const e=Math.abs(t);return e>=1e3?(e/1e3).toFixed(1):e<10&&e>0?e.toFixed(1):String(Math.round(e))}},current:{entityRole:"current",label:()=>i("metric.current"),unit:()=>"A",format:t=>Math.abs(t).toFixed(1)}},v={soc:{entityRole:"soc",label:()=>i("metric.soc"),unit:()=>"%",format:t=>String(Math.round(t)),fixedMin:0,fixedMax:100},soe:{entityRole:"soe",label:()=>i("metric.soe"),unit:()=>"kWh",format:t=>t.toFixed(1)},power:g.power},m={always_on:{icon:"mdi:battery",icon2:"mdi:router-wireless",color:"#4caf50",label:()=>i("shedding.always_on")},never:{icon:"mdi:battery",color:"#4caf50",label:()=>i("shedding.never")},soc_threshold:{icon:"mdi:battery-alert-variant-outline",color:"#9c27b0",label:()=>i("shedding.soc_threshold"),textLabel:"SoC"},off_grid:{icon:"mdi:transmission-tower",color:"#ff9800",label:()=>i("shedding.off_grid")},unknown:{icon:"mdi:help-circle-outline",color:"#888",label:()=>i("shedding.unknown")}};var y=function(t,e){return y=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&(t[n]=e[n])},y(t,e)};function _(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function n(){this.constructor=t}y(t,e),t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)}function b(t,e,n,i){var r,o=arguments.length,a=o<3?e:null===i?i=Object.getOwnPropertyDescriptor(e,n):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)a=Reflect.decorate(t,e,n,i);else for(var s=t.length-1;s>=0;s--)(r=t[s])&&(a=(o<3?r(a):o>3?r(e,n,a):r(e,n))||a);return o>3&&a&&Object.defineProperty(e,n,a),a}"function"==typeof SuppressedError&&SuppressedError; +let t="en";const e={en:{"tab.panel":"Panel","tab.by_panel":"By Panel","tab.by_activity":"By Activity","tab.by_area":"By Area","tab.monitoring":"Monitoring","tab.settings":"Settings","tab.adopted":"Adopted","list.search_placeholder":"Search circuits...","list.unassigned_area":"Unassigned","list.no_results":"No circuits found","monitoring.heading":"Monitoring","monitoring.global_settings":"Global Settings","monitoring.enabled":"Enabled","monitoring.continuous":"Continuous (%)","monitoring.spike":"Spike (%)","monitoring.window":"Window (min)","monitoring.cooldown":"Cooldown (min)","monitoring.monitored_points":"Monitored Points","monitoring.col.name":"Name","monitoring.col.continuous":"Continuous","monitoring.col.spike":"Spike","monitoring.col.window":"Window","monitoring.col.cooldown":"Cooldown","monitoring.all_none":"All / None","monitoring.reset":"Reset","notification.heading":"Notification Settings","notification.targets":"Notify Targets","notification.none_selected":"None selected","notification.no_targets":"No notify targets found","notification.all_targets":"All","notification.event_bus_target":"Event Bus (HA event bus)","notification.priority":"Priority","notification.priority.default":"Default","notification.priority.passive":"Passive","notification.priority.active":"Active","notification.priority.time_sensitive":"Time-sensitive","notification.priority.critical":"Critical","notification.hint.critical":"Overrides silent/DND","notification.hint.time_sensitive":"Breaks through Focus","notification.hint.passive":"Delivers silently","notification.hint.active":"Standard delivery","notification.title_template":"Title Template","notification.message_template":"Message Template","notification.placeholders":"Placeholders:","notification.event_bus_help":"Event Bus fires event type","notification.event_bus_payload":"with payload:","notification.test_label":"Test Notification","notification.test_button":"Send Test","notification.test_sending":"Sending...","notification.test_sent":"Test notification sent","error.prefix":"Error:","error.failed_save":"Failed to save","error.failed":"Failed","error.panel_offline":"SPAN Panel unreachable","error.panel_reconnected":"SPAN Panel reconnected","error.panel_offline_named":"{name} unreachable","error.panel_reconnected_named":"{name} reconnected","error.discovery_failed":"Unable to connect to SPAN Panel","error.relay_failed":"Unable to toggle relay","error.shedding_failed":"Unable to update shedding priority","error.threshold_failed":"Unable to save threshold","error.graph_horizon_failed":"Unable to update graph time horizon","error.favorites_fetch_failed":"Unable to load favorites","error.favorites_toggle_failed":"Unable to update favorite","error.history_failed":"Unable to load historical data","error.monitoring_failed":"Unable to load monitoring status","error.graph_settings_failed":"Unable to load graph settings","error.areas_failed":"Area assignments may be out of sync","error.retry":"Retry","card.connecting":"Connecting to SPAN Panel...","settings.heading":"Settings","settings.description":"General integration settings (entity naming, device prefix, circuit numbers) are managed through the integration's options flow.","settings.open_link":"Open SPAN Panel Integration Settings","adopted.heading":"Adopted entities","adopted.description":"Vendor readings and adopted devices arrive with minimal metadata. Curate one to set its device class, statistics class, and prominence — saved changes reload the integration and apply from the next startup on.","adopted.filter_placeholder":"Filter by entity or device name","adopted.no_results":"No adopted entities match this filter","adopted.none":"This panel publishes nothing to curate.","adopted.load_failed":"Unable to load adopted entities","adopted.count":"{count} adopted","adopted.vendor_readings":"VENDOR READINGS","adopted.adopted_device":"ADOPTED DEVICE","adopted.via_panel":"via SPAN Panel","adopted.curated":"CURATED","adopted.stale":"STALE","adopted.stale_note":"The panel no longer supports what was saved for: {fields}","adopted.enable_entity":"Enable entity","adopted.enabled":"ENABLED","adopted.disabled":"DISABLED","adopted.enable_note":"Enabled when saved — curating never enables on its own","adopted.registry_unavailable":"Enable and name become available once this entity exists in the registry.","adopted.name":"Name","adopted.device_class":"Device class","adopted.device_class_note":"choices limited by the panel's unit ({unit})","adopted.device_class_note_unitless":"choices limited by what the panel publishes","adopted.no_device_class":"No device class","adopted.statistics_class":"Statistics class","adopted.statistics_note":"long-term statistics begin after the next reload","adopted.no_statistics":"No statistics","adopted.prominence":"Prominence","adopted.diagnostic":"Diagnostic","adopted.standard":"Standard","adopted.display_unit":"Display unit","adopted.unit_as_published":"As published","adopted.precision":"Precision","adopted.precision_default":"Default","adopted.read_only":"read-only","adopted.settable":"settable","adopted.save":"Save","adopted.clear":"Clear curation","adopted.reload_note":"Saving reloads the SPAN Panel integration","adopted.saved":"Saved — the integration is reloading","adopted.confirm_heading":"Confirm statistics class","adopted.confirm_setting":"You are setting {name} to {value}.","adopted.confirm_clearing_subject":"You are clearing the statistics class on {name}.","adopted.confirm_total_increasing":"Total increasing treats every drop in the value as a meter reset. If this reading can decrease for any other reason, long-term statistics will be permanently corrupted — fixing the class later does not repair history already written.","adopted.confirm_clearing":"Long-term statistics stop being compiled for this entity, and Home Assistant raises a repair against the statistics already collected under the class you are removing.","adopted.save_anyway":"Save anyway","adopted.cancel":"Cancel","adopted.warn_total_increasing":"Saved as total increasing — every drop in the reading now counts as a meter reset.","adopted.warn_statistics_removed":"This entity has no statistics class any more; Home Assistant will raise a repair against the statistics already collected.","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Panel monitoring settings","header.graph_settings":"Graph time horizon settings","header.site":"Site","header.grid":"Grid","header.upstream":"Upstream","header.downstream":"Downstream","header.solar":"Solar","header.battery":"Battery","header.toggle_units":"Toggle Watts / Amps","header.enable_switches":"Enable Switches","header.switches_enabled":"Switches Enabled","grid.unknown":"Unknown","grid.configure":"Configure circuit","grid.configure_subdevice":"Configure device","grid.on":"On","grid.off":"Off","subdevice.ev_charger":"EV Charger","subdevice.battery":"Battery","subdevice.fallback":"Sub-device","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Power","sidepanel.graph_settings":"Graph Settings","sidepanel.global_defaults":"Global defaults for all circuits","sidepanel.favorites_subtitle":"Favorites","sidepanel.global_default":"Global Default","sidepanel.list_view_columns":"List View Columns","sidepanel.columns":"Columns","sidepanel.circuit_scales":"Circuit Graph Scales","sidepanel.subdevice_scales":"Sub-Device Graph Scales","sidepanel.reset_to_global":"Reset to global default","sidepanel.relay":"Relay","sidepanel.breaker":"Breaker","sidepanel.shedding_priority":"Shedding Priority","sidepanel.priority_label":"Priority","sidepanel.monitoring":"Monitoring","sidepanel.global":"Global","sidepanel.custom":"Custom","sidepanel.continuous_pct":"Continuous %","sidepanel.spike_pct":"Spike %","sidepanel.window_duration":"Window duration","sidepanel.cooldown":"Cooldown","sidepanel.favorite":"Favorite","sidepanel.save_to_favorites":"Save to favorites","panel.favorites":"Favorites","status.monitoring":"Monitoring","status.circuits":"circuits","status.mains":"mains","status.warning":"warning","status.warnings":"warnings","status.alert":"alert","status.alerts":"alerts","status.override":"override","status.overrides":"overrides","card.no_device":"Open the card editor and select your SPAN Panel device.","card.device_not_found":"Panel device not found. Check device_id in card config.","card.topology_error":"Topology response missing panel_size and no circuits found. Update the SPAN Panel integration.","card.panel_size_error":"Could not determine panel_size. No circuits found and no panel_size attribute. Update the SPAN Panel integration.","editor.panel_label":"SPAN Panel","editor.select_panel":"Select a panel...","editor.chart_window":"Chart time window","editor.days":"days","editor.hours":"hours","editor.minutes":"minutes","editor.chart_metric":"Chart metric","editor.visible_sections":"Visible sections","editor.panel_circuits":"Panel circuits","editor.battery_bess":"Battery (BESS)","editor.ev_charger_evse":"EV Charger (EVSE)","editor.tab_style":"Tab Style","editor.tab_style_text":"Text","editor.tab_style_icon":"Icon","metric.power":"Power","metric.current":"Current","metric.soc":"State of Charge","metric.soe":"State of Energy","shedding.always_on":"Critical","shedding.never":"Non-sheddable","shedding.soc_threshold":"SoC Threshold","shedding.off_grid":"Sheddable","shedding.unknown":"Unknown","shedding.select.never":"Stays on in an outage","shedding.select.soc_threshold":"Stays on until battery threshold","shedding.select.off_grid":"Turns off in an outage"},es:{"tab.panel":"Panel","tab.by_panel":"Por Panel","tab.by_activity":"Por Actividad","tab.by_area":"Por Área","tab.monitoring":"Monitoreo","tab.settings":"Configuración","tab.adopted":"Adoptadas","list.search_placeholder":"Buscar circuitos...","list.unassigned_area":"Sin asignar","list.no_results":"No se encontraron circuitos","monitoring.heading":"Monitoreo","monitoring.global_settings":"Configuración Global","monitoring.enabled":"Activado","monitoring.continuous":"Continuo (%)","monitoring.spike":"Pico (%)","monitoring.window":"Ventana (min)","monitoring.cooldown":"Enfriamiento (min)","monitoring.monitored_points":"Puntos Monitoreados","monitoring.col.name":"Nombre","monitoring.col.continuous":"Continuo","monitoring.col.spike":"Pico","monitoring.col.window":"Ventana","monitoring.col.cooldown":"Enfriamiento","monitoring.all_none":"Todos / Ninguno","monitoring.reset":"Restablecer","notification.heading":"Configuración de Notificaciones","notification.targets":"Destinos de Notificación","notification.none_selected":"Ninguno seleccionado","notification.no_targets":"No se encontraron destinos de notificación","notification.all_targets":"Todos","notification.event_bus_target":"Bus de Eventos (bus de eventos de HA)","notification.priority":"Prioridad","notification.priority.default":"Predeterminado","notification.priority.passive":"Pasivo","notification.priority.active":"Activo","notification.priority.time_sensitive":"Urgente","notification.priority.critical":"Crítico","notification.hint.critical":"Anula silencio/No molestar","notification.hint.time_sensitive":"Atraviesa el modo Concentración","notification.hint.passive":"Entrega silenciosa","notification.hint.active":"Entrega estándar","notification.title_template":"Plantilla de Título","notification.message_template":"Plantilla de Mensaje","notification.placeholders":"Variables:","notification.event_bus_help":"El Bus de Eventos dispara el tipo de evento","notification.event_bus_payload":"con datos:","notification.test_label":"Notificación de prueba","notification.test_button":"Enviar prueba","notification.test_sending":"Enviando...","notification.test_sent":"Notificación de prueba enviada","error.prefix":"Error:","error.failed_save":"Error al guardar","error.failed":"Falló","error.panel_offline":"SPAN Panel inaccesible","error.panel_reconnected":"SPAN Panel reconectado","error.panel_offline_named":"{name} inaccesible","error.panel_reconnected_named":"{name} reconectado","error.discovery_failed":"No se puede conectar al SPAN Panel","error.relay_failed":"No se pudo cambiar el relé","error.shedding_failed":"No se pudo actualizar la prioridad de desconexión","error.threshold_failed":"No se pudo guardar el umbral","error.graph_horizon_failed":"No se pudo actualizar el horizonte temporal del gráfico","error.favorites_fetch_failed":"No se pudieron cargar los favoritos","error.favorites_toggle_failed":"No se pudo actualizar el favorito","error.history_failed":"No se pudieron cargar los datos históricos","error.monitoring_failed":"No se pudo cargar el estado de monitoreo","error.graph_settings_failed":"No se pudo cargar la configuración del gráfico","error.areas_failed":"Las asignaciones de áreas pueden estar desincronizadas","error.retry":"Reintentar","card.connecting":"Conectando al SPAN Panel...","settings.heading":"Configuración","settings.description":"La configuración general de la integración (nombres de entidades, prefijo de dispositivo, números de circuito) se administra a través del flujo de opciones de la integración.","settings.open_link":"Abrir Configuración de Integración SPAN Panel","adopted.heading":"Entidades adoptadas","adopted.description":"Las lecturas del proveedor y los dispositivos adoptados llegan con metadatos mínimos. Cura una para definir su clase de dispositivo, clase de estadísticas y prominencia — los cambios guardados recargan la integración y se aplican desde el siguiente arranque.","adopted.filter_placeholder":"Filtrar por nombre de entidad o dispositivo","adopted.no_results":"Ninguna entidad adoptada coincide con este filtro","adopted.none":"Este panel no publica nada que curar.","adopted.load_failed":"No se pudieron cargar las entidades adoptadas","adopted.count":"{count} adoptadas","adopted.vendor_readings":"LECTURAS DEL PROVEEDOR","adopted.adopted_device":"DISPOSITIVO ADOPTADO","adopted.via_panel":"vía SPAN Panel","adopted.curated":"CURADA","adopted.stale":"OBSOLETA","adopted.stale_note":"El panel ya no admite lo guardado para: {fields}","adopted.enable_entity":"Activar entidad","adopted.enabled":"ACTIVADA","adopted.disabled":"DESACTIVADA","adopted.enable_note":"Se activa al guardar — curar nunca activa por sí solo","adopted.registry_unavailable":"La activación y el nombre estarán disponibles cuando esta entidad exista en el registro.","adopted.name":"Nombre","adopted.device_class":"Clase de dispositivo","adopted.device_class_note":"opciones limitadas por la unidad del panel ({unit})","adopted.device_class_note_unitless":"opciones limitadas por lo que publica el panel","adopted.no_device_class":"Sin clase de dispositivo","adopted.statistics_class":"Clase de estadísticas","adopted.statistics_note":"las estadísticas a largo plazo comienzan tras la próxima recarga","adopted.no_statistics":"Sin estadísticas","adopted.prominence":"Prominencia","adopted.diagnostic":"Diagnóstico","adopted.standard":"Estándar","adopted.display_unit":"Unidad mostrada","adopted.unit_as_published":"Tal como se publica","adopted.precision":"Precisión","adopted.precision_default":"Predeterminada","adopted.read_only":"solo lectura","adopted.settable":"editable","adopted.save":"Guardar","adopted.clear":"Borrar curación","adopted.reload_note":"Guardar recarga la integración SPAN Panel","adopted.saved":"Guardado — la integración se está recargando","adopted.confirm_heading":"Confirmar clase de estadísticas","adopted.confirm_setting":"Vas a definir {name} como {value}.","adopted.confirm_clearing_subject":"Vas a borrar la clase de estadísticas de {name}.","adopted.confirm_total_increasing":"Total creciente interpreta cada caída del valor como un reinicio del contador. Si esta lectura puede disminuir por cualquier otro motivo, las estadísticas a largo plazo quedarán corrompidas de forma permanente — corregir la clase más tarde no repara el historial ya escrito.","adopted.confirm_clearing":"Dejarán de compilarse estadísticas a largo plazo para esta entidad, y Home Assistant abrirá una reparación sobre las estadísticas ya recogidas bajo la clase que estás quitando.","adopted.save_anyway":"Guardar de todos modos","adopted.cancel":"Cancelar","adopted.warn_total_increasing":"Guardado como total creciente — cada caída de la lectura cuenta ahora como un reinicio del contador.","adopted.warn_statistics_removed":"Esta entidad ya no tiene clase de estadísticas; Home Assistant abrirá una reparación sobre las estadísticas ya recogidas.","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Configuración de monitoreo del panel","header.graph_settings":"Configuración del horizonte temporal del gráfico","header.site":"Sitio","header.grid":"Red","header.upstream":"Aguas arriba","header.downstream":"Aguas abajo","header.solar":"Solar","header.battery":"Batería","header.toggle_units":"Alternar Watts / Amperios","header.enable_switches":"Habilitar Interruptores","header.switches_enabled":"Interruptores Habilitados","grid.unknown":"Desconocido","grid.configure":"Configurar circuito","grid.configure_subdevice":"Configurar dispositivo","grid.on":"Enc","grid.off":"Apag","subdevice.ev_charger":"Cargador EV","subdevice.battery":"Batería","subdevice.fallback":"Sub-dispositivo","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Potencia","sidepanel.graph_settings":"Configuración de Gráficos","sidepanel.global_defaults":"Valores predeterminados globales para todos los circuitos","sidepanel.favorites_subtitle":"Favoritos","sidepanel.global_default":"Predeterminado Global","sidepanel.list_view_columns":"Columnas de la lista","sidepanel.columns":"Columnas","sidepanel.circuit_scales":"Escalas de Gráficos de Circuitos","sidepanel.subdevice_scales":"Escalas de Gráficos de Sub-Dispositivos","sidepanel.reset_to_global":"Restablecer al valor global","sidepanel.relay":"Relé","sidepanel.breaker":"Interruptor","sidepanel.shedding_priority":"Prioridad de Desconexción","sidepanel.priority_label":"Prioridad","sidepanel.monitoring":"Monitoreo","sidepanel.global":"Global","sidepanel.custom":"Personalizado","sidepanel.continuous_pct":"Continuo %","sidepanel.spike_pct":"Pico %","sidepanel.window_duration":"Duración de ventana","sidepanel.cooldown":"Enfriamiento","sidepanel.favorite":"Favorito","sidepanel.save_to_favorites":"Guardar en favoritos","panel.favorites":"Favoritos","status.monitoring":"Monitoreo","status.circuits":"circuitos","status.mains":"alimentación","status.warning":"advertencia","status.warnings":"advertencias","status.alert":"alerta","status.alerts":"alertas","status.override":"anulación","status.overrides":"anulaciones","card.no_device":"Abra el editor de tarjeta y seleccione su dispositivo SPAN Panel.","card.device_not_found":"Dispositivo de panel no encontrado. Verifique device_id en la configuración de la tarjeta.","card.topology_error":"La respuesta de topología no contiene panel_size y no se encontraron circuitos. Actualice la integración SPAN Panel.","card.panel_size_error":"No se pudo determinar panel_size. No se encontraron circuitos ni atributo panel_size. Actualice la integración SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Seleccione un panel...","editor.chart_window":"Ventana de tiempo del gráfico","editor.days":"días","editor.hours":"horas","editor.minutes":"minutos","editor.chart_metric":"Métrica del gráfico","editor.visible_sections":"Secciones visibles","editor.panel_circuits":"Circuitos del panel","editor.battery_bess":"Batería (BESS)","editor.ev_charger_evse":"Cargador EV (EVSE)","editor.tab_style":"Estilo de pestañas","editor.tab_style_text":"Texto","editor.tab_style_icon":"Ícono","metric.power":"Potencia","metric.current":"Corriente","metric.soc":"Estado de Carga","metric.soe":"Estado de Energía","shedding.always_on":"Crítico","shedding.never":"No desconectable","shedding.soc_threshold":"Umbral SoC","shedding.off_grid":"Desconectable","shedding.unknown":"Desconocido","shedding.select.never":"Permanece encendido en un corte","shedding.select.soc_threshold":"Encendido hasta umbral de batería","shedding.select.off_grid":"Se apaga en un corte"},fr:{"tab.panel":"Panneau","tab.by_panel":"Par Panneau","tab.by_activity":"Par Activité","tab.by_area":"Par Zone","tab.monitoring":"Surveillance","tab.settings":"Paramètres","tab.adopted":"Adoptées","list.search_placeholder":"Rechercher des circuits...","list.unassigned_area":"Non attribué","list.no_results":"Aucun circuit trouvé","monitoring.heading":"Surveillance","monitoring.global_settings":"Paramètres Globaux","monitoring.enabled":"Activé","monitoring.continuous":"Continu (%)","monitoring.spike":"Pic (%)","monitoring.window":"Fenêtre (min)","monitoring.cooldown":"Refroidissement (min)","monitoring.monitored_points":"Points Surveillés","monitoring.col.name":"Nom","monitoring.col.continuous":"Continu","monitoring.col.spike":"Pic","monitoring.col.window":"Fenêtre","monitoring.col.cooldown":"Refroidissement","monitoring.all_none":"Tous / Aucun","monitoring.reset":"Réinitialiser","notification.heading":"Paramètres de Notification","notification.targets":"Cibles de Notification","notification.none_selected":"Aucune sélection","notification.no_targets":"Aucune cible de notification trouvée","notification.all_targets":"Tous","notification.event_bus_target":"Bus d'événements (bus d'événements HA)","notification.priority":"Priorité","notification.priority.default":"Par défaut","notification.priority.passive":"Passif","notification.priority.active":"Actif","notification.priority.time_sensitive":"Urgent","notification.priority.critical":"Critique","notification.hint.critical":"Outrepasse silencieux/NPD","notification.hint.time_sensitive":"Traverse le mode Concentration","notification.hint.passive":"Livraison silencieuse","notification.hint.active":"Livraison standard","notification.title_template":"Modèle de Titre","notification.message_template":"Modèle de Message","notification.placeholders":"Variables :","notification.event_bus_help":"Le Bus d'événements déclenche le type d'événement","notification.event_bus_payload":"avec les données :","notification.test_label":"Notification de test","notification.test_button":"Envoyer un test","notification.test_sending":"Envoi...","notification.test_sent":"Notification de test envoyée","error.prefix":"Erreur :","error.failed_save":"Échec de la sauvegarde","error.failed":"Échoué","error.panel_offline":"SPAN Panel inaccessible","error.panel_reconnected":"SPAN Panel reconnecté","error.panel_offline_named":"{name} inaccessible","error.panel_reconnected_named":"{name} reconnecté","error.discovery_failed":"Impossible de se connecter au SPAN Panel","error.relay_failed":"Impossible de basculer le relais","error.shedding_failed":"Impossible de mettre à jour la priorité de délestage","error.threshold_failed":"Impossible d'enregistrer le seuil","error.graph_horizon_failed":"Impossible de mettre à jour l'horizon temporel du graphique","error.favorites_fetch_failed":"Impossible de charger les favoris","error.favorites_toggle_failed":"Impossible de mettre à jour le favori","error.history_failed":"Impossible de charger les données historiques","error.monitoring_failed":"Impossible de charger l'état de surveillance","error.graph_settings_failed":"Impossible de charger les paramètres du graphique","error.areas_failed":"Les affectations de zones peuvent être désynchronisées","error.retry":"Réessayer","card.connecting":"Connexion au SPAN Panel...","settings.heading":"Paramètres","settings.description":"Les paramètres généraux de l'intégration (noms d'entités, préfixe de l'appareil, numéros de circuit) sont gérés via le flux d'options de l'intégration.","settings.open_link":"Ouvrir les Paramètres d'Intégration SPAN Panel","adopted.heading":"Entités adoptées","adopted.description":"Les relevés du fournisseur et les appareils adoptés arrivent avec des métadonnées minimales. Curez-en un pour définir sa classe d'appareil, sa classe de statistiques et sa proéminence — les modifications enregistrées rechargent l'intégration et s'appliquent dès le prochain démarrage.","adopted.filter_placeholder":"Filtrer par nom d'entité ou d'appareil","adopted.no_results":"Aucune entité adoptée ne correspond à ce filtre","adopted.none":"Ce panneau ne publie rien à curer.","adopted.load_failed":"Impossible de charger les entités adoptées","adopted.count":"{count} adoptées","adopted.vendor_readings":"RELEVÉS DU FOURNISSEUR","adopted.adopted_device":"APPAREIL ADOPTÉ","adopted.via_panel":"via SPAN Panel","adopted.curated":"CURÉE","adopted.stale":"OBSOLÈTE","adopted.stale_note":"Le panneau ne prend plus en charge ce qui a été enregistré pour : {fields}","adopted.enable_entity":"Activer l'entité","adopted.enabled":"ACTIVÉE","adopted.disabled":"DÉSACTIVÉE","adopted.enable_note":"Activée à l'enregistrement — la curation n'active jamais d'elle-même","adopted.registry_unavailable":"L'activation et le nom seront disponibles dès que cette entité existera dans le registre.","adopted.name":"Nom","adopted.device_class":"Classe d'appareil","adopted.device_class_note":"choix limités par l'unité du panneau ({unit})","adopted.device_class_note_unitless":"choix limités par ce que le panneau publie","adopted.no_device_class":"Aucune classe d'appareil","adopted.statistics_class":"Classe de statistiques","adopted.statistics_note":"les statistiques à long terme commencent après le prochain rechargement","adopted.no_statistics":"Aucune statistique","adopted.prominence":"Proéminence","adopted.diagnostic":"Diagnostic","adopted.standard":"Standard","adopted.display_unit":"Unité affichée","adopted.unit_as_published":"Telle que publiée","adopted.precision":"Précision","adopted.precision_default":"Par défaut","adopted.read_only":"lecture seule","adopted.settable":"modifiable","adopted.save":"Enregistrer","adopted.clear":"Effacer la curation","adopted.reload_note":"L'enregistrement recharge l'intégration SPAN Panel","adopted.saved":"Enregistré — l'intégration se recharge","adopted.confirm_heading":"Confirmer la classe de statistiques","adopted.confirm_setting":"Vous définissez {name} sur {value}.","adopted.confirm_clearing_subject":"Vous effacez la classe de statistiques de {name}.","adopted.confirm_total_increasing":"Total croissant interprète chaque baisse de la valeur comme une remise à zéro du compteur. Si ce relevé peut diminuer pour une autre raison, les statistiques à long terme seront corrompues de façon permanente — corriger la classe plus tard ne répare pas l'historique déjà écrit.","adopted.confirm_clearing":"Les statistiques à long terme cesseront d'être compilées pour cette entité, et Home Assistant ouvrira une réparation sur les statistiques déjà collectées sous la classe que vous retirez.","adopted.save_anyway":"Enregistrer quand même","adopted.cancel":"Annuler","adopted.warn_total_increasing":"Enregistré en total croissant — chaque baisse du relevé compte désormais comme une remise à zéro du compteur.","adopted.warn_statistics_removed":"Cette entité n'a plus de classe de statistiques ; Home Assistant ouvrira une réparation sur les statistiques déjà collectées.","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Paramètres de surveillance du panneau","header.graph_settings":"Paramètres d'horizon temporel du graphique","header.site":"Site","header.grid":"Réseau","header.upstream":"Amont","header.downstream":"Aval","header.solar":"Solaire","header.battery":"Batterie","header.toggle_units":"Basculer Watts / Ampères","header.enable_switches":"Activer les interrupteurs","header.switches_enabled":"Interrupteurs activés","grid.unknown":"Inconnu","grid.configure":"Configurer le circuit","grid.configure_subdevice":"Configurer l'appareil","grid.on":"On","grid.off":"Off","subdevice.ev_charger":"Chargeur VE","subdevice.battery":"Batterie","subdevice.fallback":"Sous-appareil","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Puissance","sidepanel.graph_settings":"Paramètres des Graphiques","sidepanel.global_defaults":"Valeurs par défaut globales pour tous les circuits","sidepanel.favorites_subtitle":"Favoris","sidepanel.global_default":"Défaut Global","sidepanel.list_view_columns":"Colonnes de la liste","sidepanel.columns":"Colonnes","sidepanel.circuit_scales":"Échelles des Graphiques de Circuits","sidepanel.subdevice_scales":"Échelles des Graphiques de Sous-Appareils","sidepanel.reset_to_global":"Réinitialiser à la valeur globale","sidepanel.relay":"Relais","sidepanel.breaker":"Disjoncteur","sidepanel.shedding_priority":"Priorité de Délestage","sidepanel.priority_label":"Priorité","sidepanel.monitoring":"Surveillance","sidepanel.global":"Global","sidepanel.custom":"Personnalisé","sidepanel.continuous_pct":"Continu %","sidepanel.spike_pct":"Pic %","sidepanel.window_duration":"Durée de fenêtre","sidepanel.cooldown":"Refroidissement","sidepanel.favorite":"Favori","sidepanel.save_to_favorites":"Enregistrer dans les favoris","panel.favorites":"Favoris","status.monitoring":"Surveillance","status.circuits":"circuits","status.mains":"alimentation","status.warning":"avertissement","status.warnings":"avertissements","status.alert":"alerte","status.alerts":"alertes","status.override":"remplacement","status.overrides":"remplacements","card.no_device":"Ouvrez l'éditeur de carte et sélectionnez votre appareil SPAN Panel.","card.device_not_found":"Appareil de panneau introuvable. Vérifiez device_id dans la configuration de la carte.","card.topology_error":"La réponse de topologie ne contient pas panel_size et aucun circuit trouvé. Mettez à jour l'intégration SPAN Panel.","card.panel_size_error":"Impossible de déterminer panel_size. Aucun circuit trouvé et aucun attribut panel_size. Mettez à jour l'intégration SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Sélectionnez un panneau...","editor.chart_window":"Fenêtre de temps du graphique","editor.days":"jours","editor.hours":"heures","editor.minutes":"minutes","editor.chart_metric":"Métrique du graphique","editor.visible_sections":"Sections visibles","editor.panel_circuits":"Circuits du panneau","editor.battery_bess":"Batterie (BESS)","editor.ev_charger_evse":"Chargeur VE (EVSE)","editor.tab_style":"Style des onglets","editor.tab_style_text":"Texte","editor.tab_style_icon":"Icône","metric.power":"Puissance","metric.current":"Courant","metric.soc":"État de Charge","metric.soe":"État d'Énergie","shedding.always_on":"Critique","shedding.never":"Non délestable","shedding.soc_threshold":"Seuil SoC","shedding.off_grid":"Délestable","shedding.unknown":"Inconnu","shedding.select.never":"Reste allumé en cas de coupure","shedding.select.soc_threshold":"Allumé jusqu'au seuil batterie","shedding.select.off_grid":"S'éteint en cas de coupure"},ja:{"tab.panel":"パネル","tab.by_panel":"パネル別","tab.by_activity":"活動別","tab.by_area":"エリア別","tab.monitoring":"モニタリング","tab.settings":"設定","tab.adopted":"採用済み","list.search_placeholder":"回路を検索...","list.unassigned_area":"未割り当て","list.no_results":"回路が見つかりません","monitoring.heading":"モニタリング","monitoring.global_settings":"グローバル設定","monitoring.enabled":"有効","monitoring.continuous":"継続 (%)","monitoring.spike":"スパイク (%)","monitoring.window":"ウィンドウ (分)","monitoring.cooldown":"クールダウン (分)","monitoring.monitored_points":"監視ポイント","monitoring.col.name":"名前","monitoring.col.continuous":"継続","monitoring.col.spike":"スパイク","monitoring.col.window":"ウィンドウ","monitoring.col.cooldown":"クールダウン","monitoring.all_none":"全選択 / 全解除","monitoring.reset":"リセット","notification.heading":"通知設定","notification.targets":"通知先","notification.none_selected":"未選択","notification.no_targets":"通知先が見つかりません","notification.all_targets":"すべて","notification.event_bus_target":"イベントバス (HAイベントバス)","notification.priority":"優先度","notification.priority.default":"デフォルト","notification.priority.passive":"パッシブ","notification.priority.active":"アクティブ","notification.priority.time_sensitive":"緊急","notification.priority.critical":"重大","notification.hint.critical":"サイレント/おやすみモードを無視","notification.hint.time_sensitive":"集中モードを突破","notification.hint.passive":"サイレント配信","notification.hint.active":"標準配信","notification.title_template":"タイトルテンプレート","notification.message_template":"メッセージテンプレート","notification.placeholders":"プレースホルダー:","notification.event_bus_help":"イベントバスが発行するイベントタイプ","notification.event_bus_payload":"ペイロード:","notification.test_label":"テスト通知","notification.test_button":"テスト送信","notification.test_sending":"送信中...","notification.test_sent":"テスト通知を送信しました","error.prefix":"エラー:","error.failed_save":"保存に失敗","error.failed":"失敗","error.panel_offline":"SPANパネルに接続できません","error.panel_reconnected":"SPANパネルが再接続されました","error.panel_offline_named":"{name}に接続できません","error.panel_reconnected_named":"{name}が再接続されました","error.discovery_failed":"SPANパネルへの接続に失敗しました","error.relay_failed":"リレーの切り替えに失敗しました","error.shedding_failed":"シェディング優先度の更新に失敗しました","error.threshold_failed":"しきい値の保存に失敗しました","error.graph_horizon_failed":"グラフの時間範囲の更新に失敗しました","error.favorites_fetch_failed":"お気に入りの読み込みに失敗しました","error.favorites_toggle_failed":"お気に入りの更新に失敗しました","error.history_failed":"履歴データの読み込みに失敗しました","error.monitoring_failed":"監視ステータスの読み込みに失敗しました","error.graph_settings_failed":"グラフ設定の読み込みに失敗しました","error.areas_failed":"エリア割り当てが同期されていない可能性があります","error.retry":"再試行","card.connecting":"SPANパネルに接続中...","settings.heading":"設定","settings.description":"統合の一般設定(エンティティ名、デバイスプレフィックス、回路番号)は統合のオプションフローで管理されます。","settings.open_link":"SPAN Panel統合設定を開く","adopted.heading":"採用済みエンティティ","adopted.description":"ベンダーの測定値と採用済みデバイスは最小限のメタデータで登録されます。キュレーションでデバイスクラス、統計クラス、表示区分を設定できます。保存すると統合が再読み込みされ、次回の起動から適用されます。","adopted.filter_placeholder":"エンティティ名またはデバイス名で絞り込み","adopted.no_results":"この条件に一致する採用済みエンティティはありません","adopted.none":"このパネルにキュレーション対象はありません。","adopted.load_failed":"採用済みエンティティを読み込めません","adopted.count":"{count} 件","adopted.vendor_readings":"ベンダー測定値","adopted.adopted_device":"採用済みデバイス","adopted.via_panel":"SPAN Panel 経由","adopted.curated":"キュレーション済み","adopted.stale":"無効","adopted.stale_note":"パネルは保存された次の項目をサポートしなくなりました: {fields}","adopted.enable_entity":"エンティティを有効化","adopted.enabled":"有効","adopted.disabled":"無効","adopted.enable_note":"保存時に有効化されます — キュレーション自体が有効化することはありません","adopted.registry_unavailable":"有効化・名前は、このエンティティがレジストリに登録された後に利用できます。","adopted.name":"名前","adopted.device_class":"デバイスクラス","adopted.device_class_note":"パネルの単位({unit})により選択肢が制限されます","adopted.device_class_note_unitless":"パネルが公開する内容により選択肢が制限されます","adopted.no_device_class":"デバイスクラスなし","adopted.statistics_class":"統計クラス","adopted.statistics_note":"長期統計は次回の再読み込み後に開始されます","adopted.no_statistics":"統計なし","adopted.prominence":"表示区分","adopted.diagnostic":"診断","adopted.standard":"標準","adopted.display_unit":"表示単位","adopted.unit_as_published":"公開されたまま","adopted.precision":"小数点以下桁数","adopted.precision_default":"既定","adopted.read_only":"読み取り専用","adopted.settable":"書き込み可","adopted.save":"保存","adopted.clear":"キュレーションを消去","adopted.reload_note":"保存すると SPAN Panel 統合が再読み込みされます","adopted.saved":"保存しました — 統合を再読み込みしています","adopted.confirm_heading":"統計クラスの確認","adopted.confirm_setting":"{name} を {value} に設定しようとしています。","adopted.confirm_clearing_subject":"{name} の統計クラスを消去しようとしています。","adopted.confirm_total_increasing":"積算増加は値の低下をすべてメーターのリセットとして扱います。この測定値が他の理由でも下がる場合、長期統計は恒久的に破損します。後からクラスを直しても、既に書き込まれた履歴は修復されません。","adopted.confirm_clearing":"このエンティティの長期統計は収集されなくなり、削除するクラスの下で既に収集された統計について Home Assistant が修復項目を作成します。","adopted.save_anyway":"それでも保存","adopted.cancel":"キャンセル","adopted.warn_total_increasing":"積算増加として保存しました — 測定値の低下はすべてメーターのリセットとして数えられます。","adopted.warn_statistics_removed":"このエンティティに統計クラスはなくなりました。既に収集された統計について Home Assistant が修復項目を作成します。","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"パネルモニタリング設定","header.graph_settings":"グラフ時間範囲設定","header.site":"サイト","header.grid":"グリッド","header.upstream":"上流","header.downstream":"下流","header.solar":"ソーラー","header.battery":"バッテリー","header.toggle_units":"ワット/アンペア切り替え","header.enable_switches":"スイッチを有効化","header.switches_enabled":"スイッチ有効","grid.unknown":"不明","grid.configure":"回路を設定","grid.configure_subdevice":"デバイスを設定","grid.on":"オン","grid.off":"オフ","subdevice.ev_charger":"EV充電器","subdevice.battery":"バッテリー","subdevice.fallback":"サブデバイス","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"電力","sidepanel.graph_settings":"グラフ設定","sidepanel.global_defaults":"全回路のグローバルデフォルト","sidepanel.favorites_subtitle":"お気に入り","sidepanel.global_default":"グローバルデフォルト","sidepanel.list_view_columns":"リスト表示の列数","sidepanel.columns":"列","sidepanel.circuit_scales":"回路グラフスケール","sidepanel.subdevice_scales":"サブデバイスグラフスケール","sidepanel.reset_to_global":"グローバルにリセット","sidepanel.relay":"リレー","sidepanel.breaker":"ブレーカー","sidepanel.shedding_priority":"シェディング優先度","sidepanel.priority_label":"優先度","sidepanel.monitoring":"モニタリング","sidepanel.global":"グローバル","sidepanel.custom":"カスタム","sidepanel.continuous_pct":"継続 %","sidepanel.spike_pct":"スパイク %","sidepanel.window_duration":"ウィンドウ時間","sidepanel.cooldown":"クールダウン","sidepanel.favorite":"お気に入り","sidepanel.save_to_favorites":"お気に入りに保存","panel.favorites":"お気に入り","status.monitoring":"モニタリング","status.circuits":"回路","status.mains":"主電源","status.warning":"警告","status.warnings":"警告","status.alert":"アラート","status.alerts":"アラート","status.override":"上書き","status.overrides":"上書き","card.no_device":"カードエディタを開いてSPAN Panelデバイスを選択してください。","card.device_not_found":"パネルデバイスが見つかりません。カード設定のdevice_idを確認してください。","card.topology_error":"トポロジー応答にpanel_sizeがなく、回路が見つかりません。SPAN Panel統合を更新してください。","card.panel_size_error":"panel_sizeを判定できません。回路がpanel_size属性が見つかりません。SPAN Panel統合を更新してください。","editor.panel_label":"SPAN Panel","editor.select_panel":"パネルを選択...","editor.chart_window":"グラフ時間ウィンドウ","editor.days":"日","editor.hours":"時間","editor.minutes":"分","editor.chart_metric":"グラフ指標","editor.visible_sections":"表示セクション","editor.panel_circuits":"パネル回路","editor.battery_bess":"バッテリー (BESS)","editor.ev_charger_evse":"EV充電器 (EVSE)","editor.tab_style":"タブスタイル","editor.tab_style_text":"テキスト","editor.tab_style_icon":"アイコン","metric.power":"電力","metric.current":"電流","metric.soc":"充電状態","metric.soe":"エネルギー状態","shedding.always_on":"重要","shedding.never":"切断不可","shedding.soc_threshold":"SoCしきい値","shedding.off_grid":"切断可能","shedding.unknown":"不明","shedding.select.never":"停電時もオンを維持","shedding.select.soc_threshold":"バッテリーしきい値までオン","shedding.select.off_grid":"停電時にオフ"},pt:{"tab.panel":"Painel","tab.by_panel":"Por Painel","tab.by_activity":"Por Atividade","tab.by_area":"Por Área","tab.monitoring":"Monitoramento","tab.settings":"Configurações","tab.adopted":"Adotadas","list.search_placeholder":"Pesquisar circuitos...","list.unassigned_area":"Não atribuído","list.no_results":"Nenhum circuito encontrado","monitoring.heading":"Monitoramento","monitoring.global_settings":"Configurações Globais","monitoring.enabled":"Ativado","monitoring.continuous":"Contínuo (%)","monitoring.spike":"Pico (%)","monitoring.window":"Janela (min)","monitoring.cooldown":"Resfriamento (min)","monitoring.monitored_points":"Pontos Monitorados","monitoring.col.name":"Nome","monitoring.col.continuous":"Contínuo","monitoring.col.spike":"Pico","monitoring.col.window":"Janela","monitoring.col.cooldown":"Resfriamento","monitoring.all_none":"Todos / Nenhum","monitoring.reset":"Redefinir","notification.heading":"Configurações de Notificação","notification.targets":"Destinos de Notificação","notification.none_selected":"Nenhum selecionado","notification.no_targets":"Nenhum destino de notificação encontrado","notification.all_targets":"Todos","notification.event_bus_target":"Barramento de Eventos (barramento de eventos do HA)","notification.priority":"Prioridade","notification.priority.default":"Padrão","notification.priority.passive":"Passivo","notification.priority.active":"Ativo","notification.priority.time_sensitive":"Urgente","notification.priority.critical":"Crítico","notification.hint.critical":"Substitui silencioso/Não perturbar","notification.hint.time_sensitive":"Atravessa o modo Foco","notification.hint.passive":"Entrega silenciosa","notification.hint.active":"Entrega padrão","notification.title_template":"Modelo de Título","notification.message_template":"Modelo de Mensagem","notification.placeholders":"Variáveis:","notification.event_bus_help":"O Barramento de Eventos dispara o tipo de evento","notification.event_bus_payload":"com dados:","notification.test_label":"Notificação de teste","notification.test_button":"Enviar teste","notification.test_sending":"Enviando...","notification.test_sent":"Notificação de teste enviada","error.prefix":"Erro:","error.failed_save":"Falha ao salvar","error.failed":"Falhou","error.panel_offline":"SPAN Panel inacessível","error.panel_reconnected":"SPAN Panel reconectado","error.panel_offline_named":"{name} inacessível","error.panel_reconnected_named":"{name} reconectado","error.discovery_failed":"Não foi possível conectar ao SPAN Panel","error.relay_failed":"Não foi possível alternar o relé","error.shedding_failed":"Não foi possível atualizar a prioridade de desligamento","error.threshold_failed":"Não foi possível salvar o limite","error.graph_horizon_failed":"Não foi possível atualizar o horizonte temporal do gráfico","error.favorites_fetch_failed":"Não foi possível carregar os favoritos","error.favorites_toggle_failed":"Não foi possível atualizar o favorito","error.history_failed":"Não foi possível carregar os dados históricos","error.monitoring_failed":"Não foi possível carregar o status de monitoramento","error.graph_settings_failed":"Não foi possível carregar as configurações do gráfico","error.areas_failed":"As atribuições de áreas podem estar fora de sincronização","error.retry":"Tentar novamente","card.connecting":"Conectando ao SPAN Panel...","settings.heading":"Configurações","settings.description":"As configurações gerais da integração (nomes de entidades, prefixo do dispositivo, números de circuito) são gerenciadas através do fluxo de opções da integração.","settings.open_link":"Abrir Configurações de Integração SPAN Panel","adopted.heading":"Entidades adotadas","adopted.description":"As leituras do fornecedor e os dispositivos adotados chegam com metadados mínimos. Faça a curadoria de uma para definir sua classe de dispositivo, classe de estatísticas e proeminência — alterações salvas recarregam a integração e valem a partir da próxima inicialização.","adopted.filter_placeholder":"Filtrar por nome de entidade ou dispositivo","adopted.no_results":"Nenhuma entidade adotada corresponde a este filtro","adopted.none":"Este painel não publica nada para curadoria.","adopted.load_failed":"Não foi possível carregar as entidades adotadas","adopted.count":"{count} adotadas","adopted.vendor_readings":"LEITURAS DO FORNECEDOR","adopted.adopted_device":"DISPOSITIVO ADOTADO","adopted.via_panel":"via SPAN Panel","adopted.curated":"CURADA","adopted.stale":"OBSOLETA","adopted.stale_note":"O painel não suporta mais o que foi salvo para: {fields}","adopted.enable_entity":"Ativar entidade","adopted.enabled":"ATIVADA","adopted.disabled":"DESATIVADA","adopted.enable_note":"Ativada ao salvar — a curadoria nunca ativa por conta própria","adopted.registry_unavailable":"Ativação e nome ficam disponíveis assim que esta entidade existir no registro.","adopted.name":"Nome","adopted.device_class":"Classe de dispositivo","adopted.device_class_note":"opções limitadas pela unidade do painel ({unit})","adopted.device_class_note_unitless":"opções limitadas pelo que o painel publica","adopted.no_device_class":"Sem classe de dispositivo","adopted.statistics_class":"Classe de estatísticas","adopted.statistics_note":"as estatísticas de longo prazo começam após a próxima recarga","adopted.no_statistics":"Sem estatísticas","adopted.prominence":"Proeminência","adopted.diagnostic":"Diagnóstico","adopted.standard":"Padrão","adopted.display_unit":"Unidade exibida","adopted.unit_as_published":"Como publicada","adopted.precision":"Precisão","adopted.precision_default":"Padrão","adopted.read_only":"somente leitura","adopted.settable":"editável","adopted.save":"Salvar","adopted.clear":"Limpar curadoria","adopted.reload_note":"Salvar recarrega a integração SPAN Panel","adopted.saved":"Salvo — a integração está recarregando","adopted.confirm_heading":"Confirmar classe de estatísticas","adopted.confirm_setting":"Você está definindo {name} como {value}.","adopted.confirm_clearing_subject":"Você está limpando a classe de estatísticas de {name}.","adopted.confirm_total_increasing":"Total crescente trata toda queda do valor como uma reinicialização do medidor. Se esta leitura puder diminuir por qualquer outro motivo, as estatísticas de longo prazo ficarão permanentemente corrompidas — corrigir a classe depois não repara o histórico já gravado.","adopted.confirm_clearing":"As estatísticas de longo prazo deixarão de ser compiladas para esta entidade, e o Home Assistant abrirá um reparo sobre as estatísticas já coletadas na classe que você está removendo.","adopted.save_anyway":"Salvar mesmo assim","adopted.cancel":"Cancelar","adopted.warn_total_increasing":"Salvo como total crescente — cada queda da leitura agora conta como uma reinicialização do medidor.","adopted.warn_statistics_removed":"Esta entidade não tem mais classe de estatísticas; o Home Assistant abrirá um reparo sobre as estatísticas já coletadas.","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Configurações de monitoramento do painel","header.graph_settings":"Configurações do horizonte temporal do gráfico","header.site":"Local","header.grid":"Rede","header.upstream":"Montante","header.downstream":"Jusante","header.solar":"Solar","header.battery":"Bateria","header.toggle_units":"Alternar Watts / Amperes","header.enable_switches":"Ativar Interruptores","header.switches_enabled":"Interruptores Ativados","grid.unknown":"Desconhecido","grid.configure":"Configurar circuito","grid.configure_subdevice":"Configurar dispositivo","grid.on":"Lig","grid.off":"Des","subdevice.ev_charger":"Carregador VE","subdevice.battery":"Bateria","subdevice.fallback":"Sub-dispositivo","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Potência","sidepanel.graph_settings":"Configurações de Gráficos","sidepanel.global_defaults":"Padrões globais para todos os circuitos","sidepanel.favorites_subtitle":"Favoritos","sidepanel.global_default":"Padrão Global","sidepanel.list_view_columns":"Colunas da Lista","sidepanel.columns":"Colunas","sidepanel.circuit_scales":"Escalas de Gráficos de Circuitos","sidepanel.subdevice_scales":"Escalas de Gráficos de Sub-Dispositivos","sidepanel.reset_to_global":"Redefinir para o padrão global","sidepanel.relay":"Relé","sidepanel.breaker":"Disjuntor","sidepanel.shedding_priority":"Prioridade de Desligamento","sidepanel.priority_label":"Prioridade","sidepanel.monitoring":"Monitoramento","sidepanel.global":"Global","sidepanel.custom":"Personalizado","sidepanel.continuous_pct":"Contínuo %","sidepanel.spike_pct":"Pico %","sidepanel.window_duration":"Duração da janela","sidepanel.cooldown":"Resfriamento","sidepanel.favorite":"Favorito","sidepanel.save_to_favorites":"Salvar nos favoritos","panel.favorites":"Favoritos","status.monitoring":"Monitoramento","status.circuits":"circuitos","status.mains":"alimentação","status.warning":"aviso","status.warnings":"avisos","status.alert":"alerta","status.alerts":"alertas","status.override":"substituição","status.overrides":"substituições","card.no_device":"Abra o editor do cartão e selecione seu dispositivo SPAN Panel.","card.device_not_found":"Dispositivo do painel não encontrado. Verifique device_id na configuração do cartão.","card.topology_error":"A resposta de topologia não contém panel_size e nenhum circuito encontrado. Atualize a integração SPAN Panel.","card.panel_size_error":"Não foi possível determinar panel_size. Nenhum circuito encontrado e nenhum atributo panel_size. Atualize a integração SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Selecione um painel...","editor.chart_window":"Janela de tempo do gráfico","editor.days":"dias","editor.hours":"horas","editor.minutes":"minutos","editor.chart_metric":"Métrica do gráfico","editor.visible_sections":"Seções visíveis","editor.panel_circuits":"Circuitos do painel","editor.battery_bess":"Bateria (BESS)","editor.ev_charger_evse":"Carregador VE (EVSE)","editor.tab_style":"Estilo das abas","editor.tab_style_text":"Texto","editor.tab_style_icon":"Ícone","metric.power":"Potência","metric.current":"Corrente","metric.soc":"Estado de Carga","metric.soe":"Estado de Energia","shedding.always_on":"Crítico","shedding.never":"Não desligável","shedding.soc_threshold":"Limite SoC","shedding.off_grid":"Desligável","shedding.unknown":"Desconhecido","shedding.select.never":"Permanece ligado em uma queda","shedding.select.soc_threshold":"Ligado até limite da bateria","shedding.select.off_grid":"Desliga em uma queda"}};function n(n){t=n&&e[n]?n:"en"}function i(n){return e[t]?.[n]??e.en?.[n]??n}function r(n,i){return(e[t]?.[n]??e.en?.[n]??n).replace(/\{(\w+)\}/g,(t,e)=>Object.prototype.hasOwnProperty.call(i,e)?i[e]:`{${e}}`)}const o="power",a="5m",s={"5m":{ms:3e5,refreshMs:1e3,useRealtime:!0},"1h":{ms:36e5,refreshMs:3e4,useRealtime:!1},"1d":{ms:864e5,refreshMs:6e4,useRealtime:!1},"1w":{ms:6048e5,refreshMs:6e4,useRealtime:!1},"1M":{ms:2592e6,refreshMs:6e4,useRealtime:!1}},l="span_panel",c="CLOSED",u="pv",d="bess",h="evse",p="sub_",f=500,g={power:{entityRole:"power",label:()=>i("metric.power"),unit:t=>Math.abs(t)>=1e3?"kW":"W",format:t=>{const e=Math.abs(t);return e>=1e3?(e/1e3).toFixed(1):e<10&&e>0?e.toFixed(1):String(Math.round(e))}},current:{entityRole:"current",label:()=>i("metric.current"),unit:()=>"A",format:t=>Math.abs(t).toFixed(1)}},v={soc:{entityRole:"soc",label:()=>i("metric.soc"),unit:()=>"%",format:t=>String(Math.round(t)),fixedMin:0,fixedMax:100},soe:{entityRole:"soe",label:()=>i("metric.soe"),unit:()=>"kWh",format:t=>t.toFixed(1)},power:g.power},m={always_on:{icon:"mdi:battery",icon2:"mdi:router-wireless",color:"#4caf50",label:()=>i("shedding.always_on")},never:{icon:"mdi:battery",color:"#4caf50",label:()=>i("shedding.never")},soc_threshold:{icon:"mdi:battery-alert-variant-outline",color:"#9c27b0",label:()=>i("shedding.soc_threshold"),textLabel:"SoC"},off_grid:{icon:"mdi:transmission-tower",color:"#ff9800",label:()=>i("shedding.off_grid")},unknown:{icon:"mdi:help-circle-outline",color:"#888",label:()=>i("shedding.unknown")}};var y=function(t,e){return y=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&(t[n]=e[n])},y(t,e)};function _(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function n(){this.constructor=t}y(t,e),t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)}function b(t,e,n,i){var r,o=arguments.length,a=o<3?e:null===i?i=Object.getOwnPropertyDescriptor(e,n):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)a=Reflect.decorate(t,e,n,i);else for(var s=t.length-1;s>=0;s--)(r=t[s])&&(a=(o<3?r(a):o>3?r(e,n,a):r(e,n))||a);return o>3&&a&&Object.defineProperty(e,n,a),a}"function"==typeof SuppressedError&&SuppressedError; /** * @license * Copyright 2019 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const x=globalThis,w=x.ShadowRoot&&(void 0===x.ShadyCSS||x.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,S=Symbol(),C=new WeakMap;let M=class{constructor(t,e,n){if(this._$cssResult$=!0,n!==S)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e}get styleSheet(){let t=this.o;const e=this.t;if(w&&void 0===t){const n=void 0!==e&&1===e.length;n&&(t=C.get(e)),void 0===t&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),n&&C.set(e,t))}return t}toString(){return this.cssText}};const k=t=>new M("string"==typeof t?t:t+"",void 0,S),T=(t,...e)=>{const n=1===t.length?t[0]:e.reduce((e,n,i)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(n)+t[i+1],t[0]);return new M(n,t,S)},A=w?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const n of t.cssRules)e+=n.cssText;return k(e)})(t):t,{is:D,defineProperty:I,getOwnPropertyDescriptor:P,getOwnPropertyNames:L,getOwnPropertySymbols:E,getPrototypeOf:O}=Object,z=globalThis,N=z.trustedTypes,R=N?N.emptyScript:"",H=z.reactiveElementPolyfillSupport,B=(t,e)=>t,F={toAttribute(t,e){switch(e){case Boolean:t=t?R:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,e){let n=t;switch(e){case Boolean:n=null!==t;break;case Number:n=null===t?null:Number(t);break;case Object:case Array:try{n=JSON.parse(t)}catch(t){n=null}}return n}},$=(t,e)=>!D(t,e),V={attribute:!0,type:String,converter:F,reflect:!1,useDefault:!1,hasChanged:$}; +const x=globalThis,w=x.ShadowRoot&&(void 0===x.ShadyCSS||x.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,S=Symbol(),C=new WeakMap;let M=class{constructor(t,e,n){if(this._$cssResult$=!0,n!==S)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e}get styleSheet(){let t=this.o;const e=this.t;if(w&&void 0===t){const n=void 0!==e&&1===e.length;n&&(t=C.get(e)),void 0===t&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),n&&C.set(e,t))}return t}toString(){return this.cssText}};const k=t=>new M("string"==typeof t?t:t+"",void 0,S),T=(t,...e)=>{const n=1===t.length?t[0]:e.reduce((e,n,i)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(n)+t[i+1],t[0]);return new M(n,t,S)},D=w?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const n of t.cssRules)e+=n.cssText;return k(e)})(t):t,{is:A,defineProperty:I,getOwnPropertyDescriptor:P,getOwnPropertyNames:L,getOwnPropertySymbols:E,getPrototypeOf:O}=Object,z=globalThis,N=z.trustedTypes,R=N?N.emptyScript:"",H=z.reactiveElementPolyfillSupport,B=(t,e)=>t,F={toAttribute(t,e){switch(e){case Boolean:t=t?R:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,e){let n=t;switch(e){case Boolean:n=null!==t;break;case Number:n=null===t?null:Number(t);break;case Object:case Array:try{n=JSON.parse(t)}catch(t){n=null}}return n}},$=(t,e)=>!A(t,e),V={attribute:!0,type:String,converter:F,reflect:!1,useDefault:!1,hasChanged:$}; /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */Symbol.metadata??=Symbol("metadata"),z.litPropertyMetadata??=new WeakMap;let W=class extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,e=V){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(t,e),!e.noAccessor){const n=Symbol(),i=this.getPropertyDescriptor(t,n,e);void 0!==i&&I(this.prototype,t,i)}}static getPropertyDescriptor(t,e,n){const{get:i,set:r}=P(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:i,set(e){const o=i?.call(this);r?.call(this,e),this.requestUpdate(t,o,n)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??V}static _$Ei(){if(this.hasOwnProperty(B("elementProperties")))return;const t=O(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty(B("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(B("properties"))){const t=this.properties,e=[...L(t),...E(t)];for(const n of e)this.createProperty(n,t[n])}const t=this[Symbol.metadata];if(null!==t){const e=litPropertyMetadata.get(t);if(void 0!==e)for(const[t,n]of e)this.elementProperties.set(t,n)}this._$Eh=new Map;for(const[t,e]of this.elementProperties){const n=this._$Eu(t,e);void 0!==n&&this._$Eh.set(n,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(t){const e=[];if(Array.isArray(t)){const n=new Set(t.flat(1/0).reverse());for(const t of n)e.unshift(A(t))}else void 0!==t&&e.push(A(t));return e}static _$Eu(t,e){const n=e.attribute;return!1===n?void 0:"string"==typeof n?n:"string"==typeof t?t.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this))}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){const t=new Map,e=this.constructor.elementProperties;for(const n of e.keys())this.hasOwnProperty(n)&&(t.set(n,this[n]),delete this[n]);t.size>0&&(this._$Ep=t)}createRenderRoot(){const t=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((t,e)=>{if(w)t.adoptedStyleSheets=e.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(const n of e){const e=document.createElement("style"),i=x.litNonce;void 0!==i&&e.setAttribute("nonce",i),e.textContent=n.cssText,t.appendChild(e)}})(t,this.constructor.elementStyles),t}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(t=>t.hostConnected?.())}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.())}attributeChangedCallback(t,e,n){this._$AK(t,n)}_$ET(t,e){const n=this.constructor.elementProperties.get(t),i=this.constructor._$Eu(t,n);if(void 0!==i&&!0===n.reflect){const r=(void 0!==n.converter?.toAttribute?n.converter:F).toAttribute(e,n.type);this._$Em=t,null==r?this.removeAttribute(i):this.setAttribute(i,r),this._$Em=null}}_$AK(t,e){const n=this.constructor,i=n._$Eh.get(t);if(void 0!==i&&this._$Em!==i){const t=n.getPropertyOptions(i),r="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:F;this._$Em=i;const o=r.fromAttribute(e,t.type);this[i]=o??this._$Ej?.get(i)??o,this._$Em=null}}requestUpdate(t,e,n,i=!1,r){if(void 0!==t){const o=this.constructor;if(!1===i&&(r=this[t]),n??=o.getPropertyOptions(t),!((n.hasChanged??$)(r,e)||n.useDefault&&n.reflect&&r===this._$Ej?.get(t)&&!this.hasAttribute(o._$Eu(t,n))))return;this.C(t,e,n)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:n,reflect:i,wrapped:r},o){n&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,o??e??this[t]),!0!==r||void 0!==o)||(this._$AL.has(t)||(this.hasUpdated||n||(e=void 0),this._$AL.set(t,e)),!0===i&&this._$Em!==t&&(this._$Eq??=new Set).add(t))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,e]of this._$Ep)this[t]=e;this._$Ep=void 0}const t=this.constructor.elementProperties;if(t.size>0)for(const[e,n]of t){const{wrapped:t}=n,i=this[e];!0!==t||this._$AL.has(e)||void 0===i||this.C(e,void 0,n,i)}}let t=!1;const e=this._$AL;try{t=this.shouldUpdate(e),t?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(e){throw t=!1,this._$EM(),e}t&&this._$AE(e)}willUpdate(t){}_$AE(t){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return!0}update(t){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM()}updated(t){}firstUpdated(t){}};W.elementStyles=[],W.shadowRootOptions={mode:"open"},W[B("elementProperties")]=new Map,W[B("finalized")]=new Map,H?.({ReactiveElement:W}),(z.reactiveElementVersions??=[]).push("2.1.2"); + */Symbol.metadata??=Symbol("metadata"),z.litPropertyMetadata??=new WeakMap;let W=class extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,e=V){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(t,e),!e.noAccessor){const n=Symbol(),i=this.getPropertyDescriptor(t,n,e);void 0!==i&&I(this.prototype,t,i)}}static getPropertyDescriptor(t,e,n){const{get:i,set:r}=P(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:i,set(e){const o=i?.call(this);r?.call(this,e),this.requestUpdate(t,o,n)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??V}static _$Ei(){if(this.hasOwnProperty(B("elementProperties")))return;const t=O(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty(B("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(B("properties"))){const t=this.properties,e=[...L(t),...E(t)];for(const n of e)this.createProperty(n,t[n])}const t=this[Symbol.metadata];if(null!==t){const e=litPropertyMetadata.get(t);if(void 0!==e)for(const[t,n]of e)this.elementProperties.set(t,n)}this._$Eh=new Map;for(const[t,e]of this.elementProperties){const n=this._$Eu(t,e);void 0!==n&&this._$Eh.set(n,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(t){const e=[];if(Array.isArray(t)){const n=new Set(t.flat(1/0).reverse());for(const t of n)e.unshift(D(t))}else void 0!==t&&e.push(D(t));return e}static _$Eu(t,e){const n=e.attribute;return!1===n?void 0:"string"==typeof n?n:"string"==typeof t?t.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this))}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){const t=new Map,e=this.constructor.elementProperties;for(const n of e.keys())this.hasOwnProperty(n)&&(t.set(n,this[n]),delete this[n]);t.size>0&&(this._$Ep=t)}createRenderRoot(){const t=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((t,e)=>{if(w)t.adoptedStyleSheets=e.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(const n of e){const e=document.createElement("style"),i=x.litNonce;void 0!==i&&e.setAttribute("nonce",i),e.textContent=n.cssText,t.appendChild(e)}})(t,this.constructor.elementStyles),t}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(t=>t.hostConnected?.())}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.())}attributeChangedCallback(t,e,n){this._$AK(t,n)}_$ET(t,e){const n=this.constructor.elementProperties.get(t),i=this.constructor._$Eu(t,n);if(void 0!==i&&!0===n.reflect){const r=(void 0!==n.converter?.toAttribute?n.converter:F).toAttribute(e,n.type);this._$Em=t,null==r?this.removeAttribute(i):this.setAttribute(i,r),this._$Em=null}}_$AK(t,e){const n=this.constructor,i=n._$Eh.get(t);if(void 0!==i&&this._$Em!==i){const t=n.getPropertyOptions(i),r="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:F;this._$Em=i;const o=r.fromAttribute(e,t.type);this[i]=o??this._$Ej?.get(i)??o,this._$Em=null}}requestUpdate(t,e,n,i=!1,r){if(void 0!==t){const o=this.constructor;if(!1===i&&(r=this[t]),n??=o.getPropertyOptions(t),!((n.hasChanged??$)(r,e)||n.useDefault&&n.reflect&&r===this._$Ej?.get(t)&&!this.hasAttribute(o._$Eu(t,n))))return;this.C(t,e,n)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:n,reflect:i,wrapped:r},o){n&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,o??e??this[t]),!0!==r||void 0!==o)||(this._$AL.has(t)||(this.hasUpdated||n||(e=void 0),this._$AL.set(t,e)),!0===i&&this._$Em!==t&&(this._$Eq??=new Set).add(t))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,e]of this._$Ep)this[t]=e;this._$Ep=void 0}const t=this.constructor.elementProperties;if(t.size>0)for(const[e,n]of t){const{wrapped:t}=n,i=this[e];!0!==t||this._$AL.has(e)||void 0===i||this.C(e,void 0,n,i)}}let t=!1;const e=this._$AL;try{t=this.shouldUpdate(e),t?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(e){throw t=!1,this._$EM(),e}t&&this._$AE(e)}willUpdate(t){}_$AE(t){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return!0}update(t){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM()}updated(t){}firstUpdated(t){}};W.elementStyles=[],W.shadowRootOptions={mode:"open"},W[B("elementProperties")]=new Map,W[B("finalized")]=new Map,H?.({ReactiveElement:W}),(z.reactiveElementVersions??=[]).push("2.1.2"); /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const U=globalThis,G=t=>t,q=U.trustedTypes,j=q?q.createPolicy("lit-html",{createHTML:t=>t}):void 0,X="$lit$",Y=`lit$${Math.random().toFixed(9).slice(2)}$`,Z="?"+Y,K=`<${Z}>`,Q=document,J=()=>Q.createComment(""),tt=t=>null===t||"object"!=typeof t&&"function"!=typeof t,et=Array.isArray,nt="[ \t\n\f\r]",it=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,rt=/-->/g,ot=/>/g,at=RegExp(`>|${nt}(?:([^\\s"'>=/]+)(${nt}*=${nt}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),st=/'/g,lt=/"/g,ct=/^(?:script|style|textarea|title)$/i,ut=t=>(e,...n)=>({_$litType$:t,strings:e,values:n}),dt=ut(1),ht=ut(2),pt=Symbol.for("lit-noChange"),ft=Symbol.for("lit-nothing"),gt=new WeakMap,vt=Q.createTreeWalker(Q,129);function mt(t,e){if(!et(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==j?j.createHTML(e):e}const yt=(t,e)=>{const n=t.length-1,i=[];let r,o=2===e?"":3===e?"":"",a=it;for(let e=0;e"===l[0]?(a=r??it,c=-1):void 0===l[1]?c=-2:(c=a.lastIndex-l[2].length,s=l[1],a=void 0===l[3]?at:'"'===l[3]?lt:st):a===lt||a===st?a=at:a===rt||a===ot?a=it:(a=at,r=void 0);const d=a===at&&t[e+1].startsWith("/>")?" ":"";o+=a===it?n+K:c>=0?(i.push(s),n.slice(0,c)+X+n.slice(c)+Y+d):n+Y+(-2===c?e:d)}return[mt(t,o+(t[n]||"")+(2===e?"":3===e?"":"")),i]};class _t{constructor({strings:t,_$litType$:e},n){let i;this.parts=[];let r=0,o=0;const a=t.length-1,s=this.parts,[l,c]=yt(t,e);if(this.el=_t.createElement(l,n),vt.currentNode=this.el.content,2===e||3===e){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(i=vt.nextNode())&&s.length0){i.textContent=q?q.emptyScript:"";for(let n=0;net(t)||"function"==typeof t?.[Symbol.iterator])(t)?this.k(t):this._(t)}O(t){return this._$AA.parentNode.insertBefore(t,this._$AB)}T(t){this._$AH!==t&&(this._$AR(),this._$AH=this.O(t))}_(t){this._$AH!==ft&&tt(this._$AH)?this._$AA.nextSibling.data=t:this.T(Q.createTextNode(t)),this._$AH=t}$(t){const{values:e,_$litType$:n}=t,i="number"==typeof n?this._$AC(t):(void 0===n.el&&(n.el=_t.createElement(mt(n.h,n.h[0]),this.options)),n);if(this._$AH?._$AD===i)this._$AH.p(e);else{const t=new xt(i,this),n=t.u(this.options);t.p(e),this.T(n),this._$AH=t}}_$AC(t){let e=gt.get(t.strings);return void 0===e&>.set(t.strings,e=new _t(t)),e}k(t){et(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let n,i=0;for(const r of t)i===e.length?e.push(n=new wt(this.O(J()),this.O(J()),this,this.options)):n=e[i],n._$AI(r),i++;i2||""!==n[0]||""!==n[1]?(this._$AH=Array(n.length-1).fill(new String),this.strings=n):this._$AH=ft}_$AI(t,e=this,n,i){const r=this.strings;let o=!1;if(void 0===r)t=bt(this,t,e,0),o=!tt(t)||t!==this._$AH&&t!==pt,o&&(this._$AH=t);else{const i=t;let a,s;for(t=r[0],a=0;at,q=U.trustedTypes,j=q?q.createPolicy("lit-html",{createHTML:t=>t}):void 0,X="$lit$",Y=`lit$${Math.random().toFixed(9).slice(2)}$`,Z="?"+Y,K=`<${Z}>`,Q=document,J=()=>Q.createComment(""),tt=t=>null===t||"object"!=typeof t&&"function"!=typeof t,et=Array.isArray,nt="[ \t\n\f\r]",it=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,rt=/-->/g,ot=/>/g,at=RegExp(`>|${nt}(?:([^\\s"'>=/]+)(${nt}*=${nt}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),st=/'/g,lt=/"/g,ct=/^(?:script|style|textarea|title)$/i,ut=t=>(e,...n)=>({_$litType$:t,strings:e,values:n}),dt=ut(1),ht=ut(2),pt=Symbol.for("lit-noChange"),ft=Symbol.for("lit-nothing"),gt=new WeakMap,vt=Q.createTreeWalker(Q,129);function mt(t,e){if(!et(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==j?j.createHTML(e):e}const yt=(t,e)=>{const n=t.length-1,i=[];let r,o=2===e?"":3===e?"":"",a=it;for(let e=0;e"===l[0]?(a=r??it,c=-1):void 0===l[1]?c=-2:(c=a.lastIndex-l[2].length,s=l[1],a=void 0===l[3]?at:'"'===l[3]?lt:st):a===lt||a===st?a=at:a===rt||a===ot?a=it:(a=at,r=void 0);const d=a===at&&t[e+1].startsWith("/>")?" ":"";o+=a===it?n+K:c>=0?(i.push(s),n.slice(0,c)+X+n.slice(c)+Y+d):n+Y+(-2===c?e:d)}return[mt(t,o+(t[n]||"")+(2===e?"":3===e?"":"")),i]};class _t{constructor({strings:t,_$litType$:e},n){let i;this.parts=[];let r=0,o=0;const a=t.length-1,s=this.parts,[l,c]=yt(t,e);if(this.el=_t.createElement(l,n),vt.currentNode=this.el.content,2===e||3===e){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(i=vt.nextNode())&&s.length0){i.textContent=q?q.emptyScript:"";for(let n=0;net(t)||"function"==typeof t?.[Symbol.iterator])(t)?this.k(t):this._(t)}O(t){return this._$AA.parentNode.insertBefore(t,this._$AB)}T(t){this._$AH!==t&&(this._$AR(),this._$AH=this.O(t))}_(t){this._$AH!==ft&&tt(this._$AH)?this._$AA.nextSibling.data=t:this.T(Q.createTextNode(t)),this._$AH=t}$(t){const{values:e,_$litType$:n}=t,i="number"==typeof n?this._$AC(t):(void 0===n.el&&(n.el=_t.createElement(mt(n.h,n.h[0]),this.options)),n);if(this._$AH?._$AD===i)this._$AH.p(e);else{const t=new xt(i,this),n=t.u(this.options);t.p(e),this.T(n),this._$AH=t}}_$AC(t){let e=gt.get(t.strings);return void 0===e&>.set(t.strings,e=new _t(t)),e}k(t){et(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let n,i=0;for(const r of t)i===e.length?e.push(n=new wt(this.O(J()),this.O(J()),this,this.options)):n=e[i],n._$AI(r),i++;i2||""!==n[0]||""!==n[1]?(this._$AH=Array(n.length-1).fill(new String),this.strings=n):this._$AH=ft}_$AI(t,e=this,n,i){const r=this.strings;let o=!1;if(void 0===r)t=bt(this,t,e,0),o=!tt(t)||t!==this._$AH&&t!==pt,o&&(this._$AH=t);else{const i=t;let a,s;for(t=r[0],a=0;a{const i=n?.renderBefore??e;let r=i._$litPart$;if(void 0===r){const t=n?.renderBefore??null;i._$litPart$=r=new wt(e.insertBefore(J(),t),t,void 0,n??{})}return r._$AI(t),r})(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return pt}}It._$litElement$=!0,It.finalized=!0,Dt.litElementHydrateSupport?.({LitElement:It});const Pt=Dt.litElementPolyfillSupport;Pt?.({LitElement:It}),(Dt.litElementVersions??=[]).push("4.2.2"); + */class It extends W{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){const t=super.createRenderRoot();return this.renderOptions.renderBefore??=t.firstChild,t}update(t){const e=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(t),this._$Do=((t,e,n)=>{const i=n?.renderBefore??e;let r=i._$litPart$;if(void 0===r){const t=n?.renderBefore??null;i._$litPart$=r=new wt(e.insertBefore(J(),t),t,void 0,n??{})}return r._$AI(t),r})(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return pt}}It._$litElement$=!0,It.finalized=!0,At.litElementHydrateSupport?.({LitElement:It});const Pt=At.litElementPolyfillSupport;Pt?.({LitElement:It}),(At.litElementVersions??=[]).push("4.2.2"); /** * @license * Copyright 2017 Google LLC @@ -36,7 +36,7 @@ const Lt={attribute:!0,type:String,converter:F,reflect:!1,hasChanged:$},Et=(t=Lt * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */function zt(t){return Ot({...t,state:!0,attribute:!1})}const Nt={"&":"&","<":"<",">":">",'"':""","'":"'"};function Rt(t){return String(t).replace(/[&<>"']/g,t=>Nt[t]??t)}const Ht="span_panel_list_columns";function Bt(){try{const t=localStorage.getItem(Ht);if(!t)return 1;const e=parseInt(t,10);return 1===e||2===e||3===e?e:1}catch{return 1}}function Ft(t){try{localStorage.setItem(Ht,String(t))}catch{}}function $t(t,e,n={}){const r=Rt(t.device_name||i("header.default_name")),o=Rt(t.serial||""),a=Rt(t.firmware||""),s="current"===(e.chart_metric||"power"),l=!1!==n.showSwitches;return`\n
\n
\n
\n

${r}

\n ${o}\n \n ${l?`
\n ${Rt(i("header.enable_switches"))}\n
\n \n
\n
`:""}\n
\n ${function(t,e){const n="current"===(e.chart_metric||"power"),r=!!t.panel_entities?.site_power,o=!!t.panel_entities?.dsm_state,a=!!t.panel_entities?.current_power,s=!!t.panel_entities?.feedthrough_power,l=!!t.panel_entities?.pv_power,c=!!t.panel_entities?.battery_level;return`\n
\n ${r?`\n
\n ${i("header.site")}\n
\n 0\n ${n?"A":"kW"}\n
\n
`:""}\n ${o?`\n
\n ${i("header.grid")}\n
\n --\n
\n
`:""}\n ${a?`\n
\n ${i("header.upstream")}\n
\n --\n ${n?"A":"kW"}\n
\n
`:""}\n ${s?`\n
\n ${i("header.downstream")}\n
\n --\n ${n?"A":"kW"}\n
\n
`:""}\n ${l?`\n
\n ${i("header.solar")}\n
\n --\n ${n?"A":"kW"}\n
\n
`:""}\n ${c?`\n
\n ${i("header.battery")}\n
\n —\n %\n
\n
`:""}\n
\n `}(t,e)}\n
\n
\n
\n ${a}\n
\n \n \n
\n
\n
\n ${Object.entries(m).filter(([t])=>"unknown"!==t).map(([,t])=>{const e=Rt(t.icon),n=Rt(t.color),i=Rt(t.label());let r;return r=t.icon2?``:t.textLabel?`${Rt(t.textLabel)}`:``,`
${r}${i}
`}).join("")}\n
\n
\n
\n `}const Vt=g.power;function Wt(t){return Vt.unit(t)}function Ut(t){return(t<0?"-":"")+Vt.format(t)}function Gt(t){return(Math.abs(t)/1e3).toFixed(1)}function qt(t){return Math.ceil(t/2)}function jt(t){return t%2==0?1:0}function Xt(t){if(2!==t.length)return null;const[e,n]=[Math.min(...t),Math.max(...t)];return qt(e)===qt(n)?"row-span":jt(e)===jt(n)?"col-span":"row-span"}function Yt(t){const e=t.chart_metric??o;return g[e]??g[o]}function Zt(t,e){const n=function(t){return Yt(t).entityRole}(e);return t.entities?.[n]??t.entities?.power??null}function Kt(t){return new Promise(e=>setTimeout(e,t))}class Qt{constructor(t){this._store=t}async callWS(t,e,n){const i=n?.retries??3,r=n?.errorId??`ws:${String(e.type??"unknown")}`;return this._withRetry(()=>t.callWS(e),i,r,n?.errorMessage)}async callService(t,e,n,i,r,o){const a=o?.retries??3,s=o?.errorId??`svc:${e}.${n}`;return this._withRetry(()=>t.callService(e,n,i,r),a,s,o?.errorMessage)}async _withRetry(t,e,n,r){if(this._store.hasAnyPanelOffline())try{const e=await t();return this._store.remove(n),e}catch(t){const e=t instanceof Error?t:new Error(String(t));throw this._store.add({key:n,level:"error",message:r??i("error.panel_offline"),persistent:!1}),e}let o;for(let i=0;i<=e;i++)try{const e=await t();return this._store.remove(n),e}catch(t){if(o=t instanceof Error?t:new Error(String(t)),i{try{const n={};e&&(n.config_entry_id=e);const o={type:"call_service",domain:l,service:"get_monitoring_status",service_data:n,return_response:!0},a=this._retry?await this._retry.callWS(t,o,{errorId:"fetch:monitoring",errorMessage:i("error.monitoring_failed")}):await t.callWS(o),s=a?.response??null;return r===this._generation&&(this._status=s,this._lastFetch=Date.now()),s}catch(t){return console.warn("SPAN Panel: monitoring status fetch failed",t),r===this._generation&&(this._status=null),this._retry||this._errorStore?.add({key:"fetch:monitoring",level:"warning",message:i("error.monitoring_failed"),persistent:!1}),null}finally{this._inflight?.gen===r&&(this._inflight=null)}})();return this._inflight={gen:r,promise:o},o}invalidate(){this._lastFetch=0,this._generation++}get status(){return this._status}clear(){this._status=null,this._lastFetch=0,this._generation++}}class te{constructor(){this._caches=new Map,this._errorStore=null}get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t;for(const e of this._caches.values())e.errorStore=t}async fetchOne(t,e){let n=this._caches.get(e);return n||(n=new Jt,n.errorStore=this._errorStore,this._caches.set(e,n)),n.fetch(t,e)}invalidate(){for(const t of this._caches.values())t.invalidate()}clear(){this._caches.clear()}}function ee(t,e){return t?.circuits?t.circuits[e]??null:null}function ne(t,e,n,i){const r=[];return n||r.push("circuit-off"),i&&r.push("circuit-producer"),function(t){return!!t&&null!=t.over_threshold_since}(e)&&r.push("circuit-alert"),r.join(" ")}function ie(t,e,n,r,o,a,s,l,d,h=!1){const p=e.entities?.power,f=p?a.states[p]:null,g=f&&parseFloat(f.state)||0,v=e.device_type===u||g<0,y=e.entities?.switch,_=y?a.states[y]:null,b=_?"on"===_.state:(f?.attributes?.relay_state||e.relay_state)===c,x=e.breaker_rating_a,w=x?`${Math.round(x)}A`:"",S=Rt(e.name||i("grid.unknown")),C=Yt(s);let M;if("current"===C.entityRole){const t=e.entities?.current,n=t?a.states[t]:null,i=n&&parseFloat(n.state)||0;M=`${C.format(i)}A`}else M=`${Ut(g)}${Wt(g)}`;const k=d||"unknown";let T="";if("unknown"!==k){const t=m[k]??m.unknown??{icon:"mdi:help",color:"#999",label:()=>"Unknown"},e=Rt(t.label()),n=Rt(t.icon),i=Rt(t.color);if(t.icon2){T=`\n \n \n `}else if(t.textLabel){T=`\n \n ${Rt(t.textLabel)}\n `}else T=``}const A=``;let D="",I=l?.utilization_pct??null;if(null==I&&e.breaker_rating_a){const t=e.entities?.current,n=t?a.states[t]:null,i=n?Math.abs(parseFloat(n.state)||0):0;I=Math.round(i/e.breaker_rating_a*1e3)/10}if(null!=I){D=`=80?"utilization-warning":"utilization-normal"}">${Math.round(I)}%`}return`\n
\n
\n
\n ${w?`${w}`:""}\n ${D}\n ${S}\n
\n
\n \n ${M}\n \n ${!1!==e.is_user_controllable&&e.entities?.switch?`\n
\n ${i(b?"grid.on":"grid.off")}\n \n
\n `:""}\n
\n
\n
\n ${T}\n ${A}\n
\n
\n
\n `}function re(t,e){return`\n
\n —\n
\n `}const oe={names:["power","battery power"],suffixes:["_power"]},ae={names:["battery level","battery percentage"],suffixes:["_battery_level","_battery_percentage"]},se={names:["state of energy"],suffixes:["_soe_kwh"]},le={names:["nameplate capacity"],suffixes:["_nameplate_capacity"]};function ce(t,e){if(!t.entities)return null;for(const[n,i]of Object.entries(t.entities)){if("sensor"!==i.domain)continue;const t=(i.original_name??"").toLowerCase();if(e.names.some(e=>t===e))return n;if(i.unique_id&&e.suffixes.some(t=>i.unique_id.endsWith(t)))return n}return null}function ue(t){return ce(t,oe)}function de(t){return ce(t,ae)}function he(t){return ce(t,se)}function pe(t){return ce(t,le)}function fe(t,e,n,i){const r=n.visible_sub_entities||{};let o="";if(!t.entities)return o;for(const[n,a]of Object.entries(t.entities)){if(i.has(n))continue;if(!0!==r[n])continue;const s=e.states[n];if(!s)continue;let l=a.original_name||s.attributes.friendly_name||n;const c=t.name||"";let u;if(l.startsWith(c+" ")&&(l=l.slice(c.length+1)),e.formatEntityState)u=e.formatEntityState(s);else{u=s.state;const t=s.attributes.unit_of_measurement||"";t&&(u+=" "+t)}if("Wh"===(s.attributes.unit_of_measurement||"")){const t=parseFloat(s.state);isNaN(t)||(u=(t/1e3).toFixed(1)+" kWh")}o+=`\n
\n ${Rt(l)}:\n ${Rt(u)}\n
\n `}return o}function ge(t,e,n,r,o,a){if(n){const e=[{key:`${p}${t}_soc`,title:i("subdevice.soc"),available:!!o},{key:`${p}${t}_soe`,title:i("subdevice.soe"),available:!!a},{key:`${p}${t}_power`,title:i("subdevice.power"),available:!!r}].filter(t=>t.available);return`\n
\n ${e.map(t=>`\n
\n
${Rt(t.title)}
\n
\n
\n `).join("")}\n
\n `}return r?`
`:""}function ve(t){const e=void 0!==t.history_days||void 0!==t.history_hours||void 0!==t.history_minutes,n=60*(60*(24*(e&&parseInt(String(t.history_days))||0)+(e&&parseInt(String(t.history_hours))||0))+(e?parseInt(String(t.history_minutes))||0:5))*1e3;return Math.max(n,6e4)}function me(t){const e=s[t];return e?e.ms:s[a].ms}function ye(t){const e=t/1e3;return e<=600?Math.ceil(e):Math.min(5e3,Math.ceil(e/5))}function _e(t){return Math.max(500,Math.floor(t/5e3))}function be(t,e,n,i,r,o){t.has(e)||t.set(e,[]);const a=t.get(e);a.push({time:i,value:n});const s=a.findIndex(t=>t.time>=r);s>0?a.splice(0,s):-1===s&&(a.length=0),a.length>o&&a.splice(0,a.length-o)}function xe(t,e,n=500){if(0===t.length)return t;t.sort((t,e)=>t.time-e.time);const i=[t[0]];for(let e=1;e=n&&i.push(t[e]);return i.length>e&&i.splice(0,i.length-e),i}async function we(t,e,n,i,r){const o=new Date(Date.now()-i).toISOString(),a=i/36e5>72?"hour":"5minute",s=await t.callWS({type:"recorder/statistics_during_period",start_time:o,statistic_ids:e,period:a,types:["mean"]});for(const[t,e]of Object.entries(s)){const i=n.get(t);if(!i||!e)continue;const o=[];for(const t of e){const e=t.mean;if(null==e||!Number.isFinite(e))continue;const n=t.start;n>0&&o.push({time:n,value:e})}if(o.length>0){const t=r.get(i)||[],e=[...o,...t];e.sort((t,e)=>t.time-e.time),r.set(i,e)}}}async function Se(t,e,n,i,r){const o=new Date(Date.now()-i).toISOString(),a=await t.callWS({type:"history/history_during_period",start_time:o,entity_ids:e,minimal_response:!0,significant_changes_only:!0,no_attributes:!0}),s=ye(i),l=_e(i);for(const[t,e]of Object.entries(a)){const i=n.get(t);if(!i||!e)continue;const o=[];for(const t of e){const e=parseFloat(t.s);if(!Number.isFinite(e))continue;const n=1e3*(t.lu||t.lc||0);n>0&&o.push({time:n,value:e})}if(o.length>0){const t=r.get(i)||[],e=[...o,...t];r.set(i,xe(e,s,l))}}}function Ce(t){if(!t.sub_devices)return[];const e=[];for(const[n,i]of Object.entries(t.sub_devices)){const t={power:ue(i)};i.type===d&&(t.soc=de(i),t.soe=he(i));for(const[i,r]of Object.entries(t))r&&e.push({entityId:r,key:`${p}${n}_${i}`,devId:n})}return e}async function Me(t,e,n,i,r,o){if(!e||!t)return;const a=new Map;for(const[t,i]of Object.entries(e.circuits)){const e=Zt(i,n);if(!e)continue;let o;o=r&&r.has(t)?me(r.get(t)):ve(n),a.has(o)||a.set(o,{entityIds:[],uuidByEntity:new Map});const s=a.get(o);s.entityIds.push(e),s.uuidByEntity.set(e,t)}for(const{entityId:t,key:i,devId:r}of Ce(e)){let e;e=o&&o.has(r)?me(o.get(r)):ve(n),a.has(e)||a.set(e,{entityIds:[],uuidByEntity:new Map});const s=a.get(e);s.entityIds.push(t),s.uuidByEntity.set(t,i)}const s=[];for(const[e,n]of a){if(0===n.entityIds.length)continue;e>2592e5?s.push(we(t,n.entityIds,n.uuidByEntity,e,i)):s.push(Se(t,n.entityIds,n.uuidByEntity,e,i))}await Promise.all(s)}var ke=function(){this.firefox=!1,this.ie=!1,this.edge=!1,this.newEdge=!1,this.weChat=!1},Te=new function(){this.browser=new ke,this.node=!1,this.wxa=!1,this.worker=!1,this.svgSupported=!1,this.touchEventsSupported=!1,this.pointerEventsSupported=!1,this.domSupported=!1,this.transformSupported=!1,this.transform3dSupported=!1,this.hasGlobalWindow="undefined"!=typeof window};"object"==typeof wx&&"function"==typeof wx.getSystemInfoSync?(Te.wxa=!0,Te.touchEventsSupported=!0):"undefined"==typeof document&&"undefined"!=typeof self?Te.worker=!0:!Te.hasGlobalWindow||"Deno"in window||"undefined"!=typeof navigator&&"string"==typeof navigator.userAgent&&navigator.userAgent.indexOf("Node.js")>-1?(Te.node=!0,Te.svgSupported=!0):function(t,e){var n=e.browser,i=t.match(/Firefox\/([\d.]+)/),r=t.match(/MSIE\s([\d.]+)/)||t.match(/Trident\/.+?rv:(([\d.]+))/),o=t.match(/Edge?\/([\d.]+)/),a=/micromessenger/i.test(t);i&&(n.firefox=!0,n.version=i[1]);r&&(n.ie=!0,n.version=r[1]);o&&(n.edge=!0,n.version=o[1],n.newEdge=+o[1].split(".")[0]>18);a&&(n.weChat=!0);e.svgSupported="undefined"!=typeof SVGRect,e.touchEventsSupported="ontouchstart"in window&&!n.ie&&!n.edge,e.pointerEventsSupported="onpointerdown"in window&&(n.edge||n.ie&&+n.version>=11);var s=e.domSupported="undefined"!=typeof document;if(s){var l=document.documentElement.style;e.transform3dSupported=(n.ie&&"transition"in l||n.edge||"WebKitCSSMatrix"in window&&"m11"in new WebKitCSSMatrix||"MozPerspective"in l)&&!("OTransition"in l),e.transformSupported=e.transform3dSupported||n.ie&&+n.version>=9}}(navigator.userAgent,Te);var Ae="sans-serif",De="12px "+Ae;var Ie,Pe,Le=function(t){var e={};if("undefined"==typeof JSON)return e;for(var n=0;n=0)o=r*t.length;else for(var a=0;a>1)%2;a.style.cssText=["position: absolute","visibility: hidden","padding: 0","margin: 0","border-width: 0","user-select: none","width:0","height:0",i[s]+":0",r[l]+":0",i[1-s]+":auto",r[1-l]+":auto",""].join("!important;"),t.appendChild(a),n.push(a)}return e.clearMarkers=function(){en(n,function(t){t.parentNode&&t.parentNode.removeChild(t)})},n}(e,o),s=function(t,e,n){for(var i=n?"invTrans":"trans",r=e[i],o=e.srcCoords,a=[],s=[],l=!0,c=0;c<4;c++){var u=t[c].getBoundingClientRect(),d=2*c,h=u.left,p=u.top;a.push(h,p),l=l&&o&&h===o[d]&&p===o[d+1],s.push(t[c].offsetLeft,t[c].offsetTop)}return l&&r?r:(e.srcCoords=a,e[i]=n?ei(s,a):ei(a,s))}(a,o,r);if(s)return s(t,n,i),!0}return!1}function oi(t){return"CANVAS"===t.nodeName.toUpperCase()}var ai=/([&<>"'])/g,si={"&":"&","<":"<",">":">",'"':""","'":"'"};function li(t){return null==t?"":(t+"").replace(ai,function(t,e){return si[e]})}var ci=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,ui=[],di=Te.browser.firefox&&+Te.browser.version.split(".")[0]<39;function hi(t,e,n,i){return n=n||{},i?pi(t,e,n):di&&null!=e.layerX&&e.layerX!==e.offsetX?(n.zrX=e.layerX,n.zrY=e.layerY):null!=e.offsetX?(n.zrX=e.offsetX,n.zrY=e.offsetY):pi(t,e,n),n}function pi(t,e,n){if(Te.domSupported&&t.getBoundingClientRect){var i=e.clientX,r=e.clientY;if(oi(t)){var o=t.getBoundingClientRect();return n.zrX=i-o.left,void(n.zrY=r-o.top)}if(ri(ui,t,i,r))return n.zrX=ui[0],void(n.zrY=ui[1])}n.zrX=n.zrY=0}function fi(t){return t||window.event}function gi(t,e,n){if(null!=(e=fi(e)).zrX)return e;var i=e.type;if(i&&i.indexOf("touch")>=0){var r="touchend"!==i?e.targetTouches[0]:e.changedTouches[0];r&&hi(t,r,e,n)}else{hi(t,e,e,n);var o=function(t){var e=t.wheelDelta;if(e)return e;var n=t.deltaX,i=t.deltaY;if(null==n||null==i)return e;return 3*(0!==i?Math.abs(i):Math.abs(n))*(i>0?-1:i<0?1:n>0?-1:1)}(e);e.zrDelta=o?o/120:-(e.detail||0)/3}var a=e.button;return null==e.which&&void 0!==a&&ci.test(e.type)&&(e.which=1&a?1:2&a?3:4&a?2:0),e}function vi(t,e,n,i){t.removeEventListener(e,n,i)}var mi=function(t){t.preventDefault(),t.stopPropagation(),t.cancelBubble=!0},yi=function(){function t(){this._track=[]}return t.prototype.recognize=function(t,e,n){return this._doTrack(t,e,n),this._recognize(t)},t.prototype.clear=function(){return this._track.length=0,this},t.prototype._doTrack=function(t,e,n){var i=t.touches;if(i){for(var r={points:[],touches:[],target:e,event:t},o=0,a=i.length;o1&&r&&r.length>1){var a=_i(r)/_i(o);!isFinite(a)&&(a=1),e.pinchScale=a;var s=[((i=r)[0][0]+i[1][0])/2,(i[0][1]+i[1][1])/2];return e.pinchX=s[0],e.pinchY=s[1],{type:"pinch",target:t[0].target,event:e}}}}};function xi(t){return t[0]=1,t[1]=0,t[2]=0,t[3]=1,t[4]=0,t[5]=0,t}function wi(t,e){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t[4]=e[4],t[5]=e[5],t}function Si(t,e,n){var i=e[0]*n[0]+e[2]*n[1],r=e[1]*n[0]+e[3]*n[1],o=e[0]*n[2]+e[2]*n[3],a=e[1]*n[2]+e[3]*n[3],s=e[0]*n[4]+e[2]*n[5]+e[4],l=e[1]*n[4]+e[3]*n[5]+e[5];return t[0]=i,t[1]=r,t[2]=o,t[3]=a,t[4]=s,t[5]=l,t}function Ci(t,e,n){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t[4]=e[4]+n[0],t[5]=e[5]+n[1],t}function Mi(t,e,n,i){void 0===i&&(i=[0,0]);var r=e[0],o=e[2],a=e[4],s=e[1],l=e[3],c=e[5],u=Math.sin(n),d=Math.cos(n);return t[0]=r*d+s*u,t[1]=-r*u+s*d,t[2]=o*d+l*u,t[3]=-o*u+d*l,t[4]=d*(a-i[0])+u*(c-i[1])+i[0],t[5]=d*(c-i[1])-u*(a-i[0])+i[1],t}function ki(t,e){var n=e[0],i=e[2],r=e[4],o=e[1],a=e[3],s=e[5],l=n*a-o*i;return l?(l=1/l,t[0]=a*l,t[1]=-o*l,t[2]=-i*l,t[3]=n*l,t[4]=(i*s-a*r)*l,t[5]=(o*r-n*s)*l,t):null}var Ti=function(){function t(t,e){this.x=t||0,this.y=e||0}return t.prototype.copy=function(t){return this.x=t.x,this.y=t.y,this},t.prototype.clone=function(){return new t(this.x,this.y)},t.prototype.set=function(t,e){return this.x=t,this.y=e,this},t.prototype.equal=function(t){return t.x===this.x&&t.y===this.y},t.prototype.add=function(t){return this.x+=t.x,this.y+=t.y,this},t.prototype.scale=function(t){this.x*=t,this.y*=t},t.prototype.scaleAndAdd=function(t,e){this.x+=t.x*e,this.y+=t.y*e},t.prototype.sub=function(t){return this.x-=t.x,this.y-=t.y,this},t.prototype.dot=function(t){return this.x*t.x+this.y*t.y},t.prototype.len=function(){return Math.sqrt(this.x*this.x+this.y*this.y)},t.prototype.lenSquare=function(){return this.x*this.x+this.y*this.y},t.prototype.normalize=function(){var t=this.len();return this.x/=t,this.y/=t,this},t.prototype.distance=function(t){var e=this.x-t.x,n=this.y-t.y;return Math.sqrt(e*e+n*n)},t.prototype.distanceSquare=function(t){var e=this.x-t.x,n=this.y-t.y;return e*e+n*n},t.prototype.negate=function(){return this.x=-this.x,this.y=-this.y,this},t.prototype.transform=function(t){if(t){var e=this.x,n=this.y;return this.x=t[0]*e+t[2]*n+t[4],this.y=t[1]*e+t[3]*n+t[5],this}},t.prototype.toArray=function(t){return t[0]=this.x,t[1]=this.y,t},t.prototype.fromArray=function(t){this.x=t[0],this.y=t[1]},t.set=function(t,e,n){t.x=e,t.y=n},t.copy=function(t,e){t.x=e.x,t.y=e.y},t.len=function(t){return Math.sqrt(t.x*t.x+t.y*t.y)},t.lenSquare=function(t){return t.x*t.x+t.y*t.y},t.dot=function(t,e){return t.x*e.x+t.y*e.y},t.add=function(t,e,n){t.x=e.x+n.x,t.y=e.y+n.y},t.sub=function(t,e,n){t.x=e.x-n.x,t.y=e.y-n.y},t.scale=function(t,e,n){t.x=e.x*n,t.y=e.y*n},t.scaleAndAdd=function(t,e,n,i){t.x=e.x+n.x*i,t.y=e.y+n.y*i},t.lerp=function(t,e,n,i){var r=1-i;t.x=r*e.x+i*n.x,t.y=r*e.y+i*n.y},t}(),Ai=Math.min,Di=Math.max,Ii=Math.abs,Pi=["x","y"],Li=["width","height"],Ei=new Ti,Oi=new Ti,zi=new Ti,Ni=new Ti,Ri=Gi(),Hi=Ri.minTv,Bi=Ri.maxTv,Fi=[0,0],$i=function(){function t(e,n,i,r){t.set(this,e,n,i,r)}return t.set=function(t,e,n,i,r){return i<0&&(e+=i,i=-i),r<0&&(n+=r,r=-r),t.x=e,t.y=n,t.width=i,t.height=r,t},t.prototype.union=function(t){var e=Ai(t.x,this.x),n=Ai(t.y,this.y);isFinite(this.x)&&isFinite(this.width)?this.width=Di(t.x+t.width,this.x+this.width)-e:this.width=t.width,isFinite(this.y)&&isFinite(this.height)?this.height=Di(t.y+t.height,this.y+this.height)-n:this.height=t.height,this.x=e,this.y=n},t.prototype.applyTransform=function(e){t.applyTransform(this,this,e)},t.prototype.calculateTransform=function(t){var e=this,n=t.width/e.width,i=t.height/e.height,r=[1,0,0,1,0,0];return Ci(r,r,[-e.x,-e.y]),function(t,e,n){var i=n[0],r=n[1];t[0]=e[0]*i,t[1]=e[1]*r,t[2]=e[2]*i,t[3]=e[3]*r,t[4]=e[4]*i,t[5]=e[5]*r}(r,r,[n,i]),Ci(r,r,[t.x,t.y]),r},t.prototype.intersect=function(e,n,i){return t.intersect(this,e,n,i)},t.intersect=function(e,n,i,r){i&&Ti.set(i,0,0);var o=r&&r.outIntersectRect||null,a=r&&r.clamp;if(o&&(o.x=o.y=o.width=o.height=NaN),!e||!n)return!1;e instanceof t||(e=t.set(Vi,e.x,e.y,e.width,e.height)),n instanceof t||(n=t.set(Wi,n.x,n.y,n.width,n.height));var s=!!i;Ri.reset(r,s);var l=Ri.touchThreshold,c=e.x+l,u=e.x+e.width-l,d=e.y+l,h=e.y+e.height-l,p=n.x+l,f=n.x+n.width-l,g=n.y+l,v=n.y+n.height-l;if(c>u||d>h||p>f||g>v)return!1;var m=!(u=t.x&&e<=t.x+t.width&&n>=t.y&&n<=t.y+t.height},t.prototype.contain=function(e,n){return t.contain(this,e,n)},t.prototype.clone=function(){return new t(this.x,this.y,this.width,this.height)},t.prototype.copy=function(e){t.copy(this,e)},t.prototype.plain=function(){return{x:this.x,y:this.y,width:this.width,height:this.height}},t.prototype.isFinite=function(){return isFinite(this.x)&&isFinite(this.y)&&isFinite(this.width)&&isFinite(this.height)},t.prototype.isZero=function(){return 0===this.width||0===this.height},t.create=function(e){return new t(e.x,e.y,e.width,e.height)},t.copy=function(t,e){return t.x=e.x,t.y=e.y,t.width=e.width,t.height=e.height,t},t.applyTransform=function(e,n,i){if(i){if(i[1]<1e-5&&i[1]>-1e-5&&i[2]<1e-5&&i[2]>-1e-5){var r=i[0],o=i[3],a=i[4],s=i[5];return e.x=n.x*r+a,e.y=n.y*o+s,e.width=n.width*r,e.height=n.height*o,e.width<0&&(e.x+=e.width,e.width=-e.width),void(e.height<0&&(e.y+=e.height,e.height=-e.height))}Ei.x=zi.x=n.x,Ei.y=Ni.y=n.y,Oi.x=Ni.x=n.x+n.width,Oi.y=zi.y=n.y+n.height,Ei.transform(i),Ni.transform(i),Oi.transform(i),zi.transform(i),e.x=Ai(Ei.x,Oi.x,zi.x,Ni.x),e.y=Ai(Ei.y,Oi.y,zi.y,Ni.y);var l=Di(Ei.x,Oi.x,zi.x,Ni.x),c=Di(Ei.y,Oi.y,zi.y,Ni.y);e.width=l-e.x,e.height=c-e.y}else e!==n&&t.copy(e,n)},t}(),Vi=new $i(0,0,0,0),Wi=new $i(0,0,0,0);function Ui(t,e,n,i,r,o,a,s){var l=Ii(e-n),c=Ii(i-t),u=Ai(l,c),d=Pi[r],h=Pi[1-r],p=Li[r];e=c||!Ri.bidirectional)&&(Hi[d]=-c,Hi[h]=0,Ri.useDir&&Ri.calcDirMTV())))}function Gi(){var t=0,e=new Ti,n=new Ti,i={minTv:new Ti,maxTv:new Ti,useDir:!1,dirMinTv:new Ti,touchThreshold:0,bidirectional:!0,negativeSize:!1,reset:function(r,o){i.touchThreshold=0,r&&null!=r.touchThreshold&&(i.touchThreshold=Di(0,r.touchThreshold)),i.negativeSize=!1,o&&(i.minTv.set(1/0,1/0),i.maxTv.set(0,0),i.useDir=!1,r&&null!=r.direction&&(i.useDir=!0,i.dirMinTv.copy(i.minTv),n.copy(i.minTv),t=r.direction,i.bidirectional=null==r.bidirectional||!!r.bidirectional,i.bidirectional||e.set(Math.cos(t),Math.sin(t))))},calcDirMTV:function(){var o=i.minTv,a=i.dirMinTv,s=o.y*o.y+o.x*o.x,l=Math.sin(t),c=Math.cos(t),u=l*o.y+c*o.x;r(u)?r(o.x)&&r(o.y)&&a.set(0,0):(n.x=s*c/u,n.y=s*l/u,r(n.x)&&r(n.y)?a.set(0,0):(i.bidirectional||e.dot(n)>0)&&n.len()=0;c--){var u=i[c];u===n||u.ignore||u.ignoreCoarsePointer||u.parent&&u.parent.ignoreCoarsePointer||(Ki.copy(u.getBoundingRect()),u.transform&&Ki.applyTransform(u.transform),Ki.intersect(l)&&o.push(u))}if(o.length)for(var d=Math.PI/12,h=2*Math.PI,p=0;p=0;o--){var a=t[o],s=void 0;if(a!==r&&!a.ignore&&(s=Ji(a,n,i))&&(!e.topTarget&&(e.topTarget=a),s!==qi)){e.target=a;break}}}function er(t,e,n){var i=t.painter;return e<0||e>i.getWidth()||n<0||n>i.getHeight()}en(["click","mousedown","mouseup","mousewheel","dblclick","contextmenu"],function(t){Qi.prototype[t]=function(e){var n,i,r=e.zrX,o=e.zrY,a=er(this,r,o);if("mouseup"===t&&a||(i=(n=this.findHover(r,o)).target),"mousedown"===t)this._downEl=i,this._downPoint=[e.zrX,e.zrY],this._upEl=i;else if("mouseup"===t)this._upEl=i;else if("click"===t){if(this._downEl!==this._upEl||!this._downPoint||Gn(this._downPoint,[e.zrX,e.zrY])>4)return;this._downPoint=null}this.dispatchToElement(n,t,e)}});function nr(t,e,n,i){var r=e+1;if(r===n)return 1;if(i(t[r++],t[e])<0){for(;r=0;)r++;return r-e}function ir(t,e,n,i,r){for(i===e&&i++;i>>1])<0?l=o:s=o+1;var c=i-s;switch(c){case 3:t[s+3]=t[s+2];case 2:t[s+2]=t[s+1];case 1:t[s+1]=t[s];break;default:for(;c>0;)t[s+c]=t[s+c-1],c--}t[s]=a}}function rr(t,e,n,i,r,o){var a=0,s=0,l=1;if(o(t,e[n+r])>0){for(s=i-r;l0;)a=l,(l=1+(l<<1))<=0&&(l=s);l>s&&(l=s),a+=r,l+=r}else{for(s=r+1;ls&&(l=s);var c=a;a=r-l,l=r-c}for(a++;a>>1);o(t,e[n+u])>0?a=u+1:l=u}return l}function or(t,e,n,i,r,o){var a=0,s=0,l=1;if(o(t,e[n+r])<0){for(s=r+1;ls&&(l=s);var c=a;a=r-l,l=r-c}else{for(s=i-r;l=0;)a=l,(l=1+(l<<1))<=0&&(l=s);l>s&&(l=s),a+=r,l+=r}for(a++;a>>1);o(t,e[n+u])<0?l=u:a=u+1}return l}function ar(t,e){var n,i,r=7,o=0,a=[];function s(s){var l=n[s],c=i[s],u=n[s+1],d=i[s+1];i[s]=c+d,s===o-3&&(n[s+1]=n[s+2],i[s+1]=i[s+2]),o--;var h=or(t[u],t,l,c,0,e);l+=h,0!==(c-=h)&&0!==(d=rr(t[l+c-1],t,u,d,d-1,e))&&(c<=d?function(n,i,o,s){var l=0;for(l=0;l=7||p>=7);if(f)break;g<0&&(g=0),g+=2}if((r=g)<1&&(r=1),1===i){for(l=0;l=0;l--)t[p+l]=t[h+l];return void(t[d]=a[u])}var f=r;for(;;){var g=0,v=0,m=!1;do{if(e(a[u],t[c])<0){if(t[d--]=t[c--],g++,v=0,0===--i){m=!0;break}}else if(t[d--]=a[u--],v++,g=0,1===--s){m=!0;break}}while((g|v)=0;l--)t[p+l]=t[h+l];if(0===i){m=!0;break}}if(t[d--]=a[u--],1===--s){m=!0;break}if(0!==(v=s-rr(t[c],a,0,s,s-1,e))){for(s-=v,p=(d-=v)+1,h=(u-=v)+1,l=0;l=7||v>=7);if(m)break;f<0&&(f=0),f+=2}(r=f)<1&&(r=1);if(1===s){for(p=(d-=i)+1,h=(c-=i)+1,l=i-1;l>=0;l--)t[p+l]=t[h+l];t[d]=a[u]}else{if(0===s)throw new Error;for(h=d-(s-1),l=0;l1;){var t=o-2;if(t>=1&&i[t-1]<=i[t]+i[t+1]||t>=2&&i[t-2]<=i[t]+i[t-1])i[t-1]i[t+1])break;s(t)}},forceMergeRuns:function(){for(;o>1;){var t=o-2;t>0&&i[t-1]=32;)e|=1&t,t>>=1;return t+e}(r);do{if((o=nr(t,n,i,e))s&&(l=s),ir(t,n,n+l,n+o,e),o=l}a.pushRun(n,o),a.mergeRuns(),r-=o,n+=o}while(0!==r);a.forceMergeRuns()}}}var lr=!1;function cr(){lr||(lr=!0,console.warn("z / z2 / zlevel of displayable is invalid, which may cause unexpected errors"))}function ur(t,e){return t.zlevel===e.zlevel?t.z===e.z?t.z2-e.z2:t.z-e.z:t.zlevel-e.zlevel}var dr,hr=function(){function t(){this._roots=[],this._displayList=[],this._displayListLen=0,this.displayableSortFunc=ur}return t.prototype.traverse=function(t,e){for(var n=0;n=0&&this._roots.splice(i,1)}},t.prototype.delAllRoots=function(){this._roots=[],this._displayList=[],this._displayListLen=0},t.prototype.getRoots=function(){return this._roots},t.prototype.dispose=function(){this._displayList=null,this._roots=null},t}();dr=Te.hasGlobalWindow&&(window.requestAnimationFrame&&window.requestAnimationFrame.bind(window)||window.msRequestAnimationFrame&&window.msRequestAnimationFrame.bind(window)||window.mozRequestAnimationFrame||window.webkitRequestAnimationFrame)||function(t){return setTimeout(t,16)};var pr={linear:function(t){return t},quadraticIn:function(t){return t*t},quadraticOut:function(t){return t*(2-t)},quadraticInOut:function(t){return(t*=2)<1?.5*t*t:-.5*(--t*(t-2)-1)},cubicIn:function(t){return t*t*t},cubicOut:function(t){return--t*t*t+1},cubicInOut:function(t){return(t*=2)<1?.5*t*t*t:.5*((t-=2)*t*t+2)},quarticIn:function(t){return t*t*t*t},quarticOut:function(t){return 1- --t*t*t*t},quarticInOut:function(t){return(t*=2)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2)},quinticIn:function(t){return t*t*t*t*t},quinticOut:function(t){return--t*t*t*t*t+1},quinticInOut:function(t){return(t*=2)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2)},sinusoidalIn:function(t){return 1-Math.cos(t*Math.PI/2)},sinusoidalOut:function(t){return Math.sin(t*Math.PI/2)},sinusoidalInOut:function(t){return.5*(1-Math.cos(Math.PI*t))},exponentialIn:function(t){return 0===t?0:Math.pow(1024,t-1)},exponentialOut:function(t){return 1===t?1:1-Math.pow(2,-10*t)},exponentialInOut:function(t){return 0===t?0:1===t?1:(t*=2)<1?.5*Math.pow(1024,t-1):.5*(2-Math.pow(2,-10*(t-1)))},circularIn:function(t){return 1-Math.sqrt(1-t*t)},circularOut:function(t){return Math.sqrt(1- --t*t)},circularInOut:function(t){return(t*=2)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1)},elasticIn:function(t){var e,n=.1;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=.4*Math.asin(1/n)/(2*Math.PI),-n*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/.4))},elasticOut:function(t){var e,n=.1;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=.4*Math.asin(1/n)/(2*Math.PI),n*Math.pow(2,-10*t)*Math.sin((t-e)*(2*Math.PI)/.4)+1)},elasticInOut:function(t){var e,n=.1,i=.4;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=i*Math.asin(1/n)/(2*Math.PI),(t*=2)<1?n*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/i)*-.5:n*Math.pow(2,-10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/i)*.5+1)},backIn:function(t){var e=1.70158;return t*t*((e+1)*t-e)},backOut:function(t){var e=1.70158;return--t*t*((e+1)*t+e)+1},backInOut:function(t){var e=2.5949095;return(t*=2)<1?t*t*((e+1)*t-e)*.5:.5*((t-=2)*t*((e+1)*t+e)+2)},bounceIn:function(t){return 1-pr.bounceOut(1-t)},bounceOut:function(t){return t<1/2.75?7.5625*t*t:t<2/2.75?7.5625*(t-=1.5/2.75)*t+.75:t<2.5/2.75?7.5625*(t-=2.25/2.75)*t+.9375:7.5625*(t-=2.625/2.75)*t+.984375},bounceInOut:function(t){return t<.5?.5*pr.bounceIn(2*t):.5*pr.bounceOut(2*t-1)+.5}},fr=Math.pow,gr=Math.sqrt,vr=1e-8,mr=1e-4,yr=gr(3),_r=1/3,br=Hn(),xr=Hn(),wr=Hn();function Sr(t){return t>-1e-8&&tvr||t<-1e-8}function Mr(t,e,n,i,r){var o=1-r;return o*o*(o*t+3*r*e)+r*r*(r*i+3*o*n)}function kr(t,e,n,i,r){var o=1-r;return 3*(((e-t)*o+2*(n-e)*r)*o+(i-n)*r*r)}function Tr(t,e,n,i,r,o){var a=i+3*(e-n)-t,s=3*(n-2*e+t),l=3*(e-t),c=t-r,u=s*s-3*a*l,d=s*l-9*a*c,h=l*l-3*s*c,p=0;if(Sr(u)&&Sr(d)){if(Sr(s))o[0]=0;else(C=-l/s)>=0&&C<=1&&(o[p++]=C)}else{var f=d*d-4*u*h;if(Sr(f)){var g=d/u,v=-g/2;(C=-s/a+g)>=0&&C<=1&&(o[p++]=C),v>=0&&v<=1&&(o[p++]=v)}else if(f>0){var m=gr(f),y=u*s+1.5*a*(-d+m),_=u*s+1.5*a*(-d-m);(C=(-s-((y=y<0?-fr(-y,_r):fr(y,_r))+(_=_<0?-fr(-_,_r):fr(_,_r))))/(3*a))>=0&&C<=1&&(o[p++]=C)}else{var b=(2*u*s-3*a*d)/(2*gr(u*u*u)),x=Math.acos(b)/3,w=gr(u),S=Math.cos(x),C=(-s-2*w*S)/(3*a),M=(v=(-s+w*(S+yr*Math.sin(x)))/(3*a),(-s+w*(S-yr*Math.sin(x)))/(3*a));C>=0&&C<=1&&(o[p++]=C),v>=0&&v<=1&&(o[p++]=v),M>=0&&M<=1&&(o[p++]=M)}}return p}function Ar(t,e,n,i,r){var o=6*n-12*e+6*t,a=9*e+3*i-3*t-9*n,s=3*e-3*t,l=0;if(Sr(a)){if(Cr(o))(u=-s/o)>=0&&u<=1&&(r[l++]=u)}else{var c=o*o-4*a*s;if(Sr(c))r[0]=-o/(2*a);else if(c>0){var u,d=gr(c),h=(-o-d)/(2*a);(u=(-o+d)/(2*a))>=0&&u<=1&&(r[l++]=u),h>=0&&h<=1&&(r[l++]=h)}}return l}function Dr(t,e,n,i,r,o){var a=(e-t)*r+t,s=(n-e)*r+e,l=(i-n)*r+n,c=(s-a)*r+a,u=(l-s)*r+s,d=(u-c)*r+c;o[0]=t,o[1]=a,o[2]=c,o[3]=d,o[4]=d,o[5]=u,o[6]=l,o[7]=i}function Ir(t,e,n,i,r,o,a,s,l){for(var c=t,u=e,d=0,h=1/l,p=1;p<=l;p++){var f=p*h,g=Mr(t,n,r,a,f),v=Mr(e,i,o,s,f),m=g-c,y=v-u;d+=Math.sqrt(m*m+y*y),c=g,u=v}return d}function Pr(t,e,n,i){var r=1-i;return r*(r*t+2*i*e)+i*i*n}function Lr(t,e,n,i){return 2*((1-i)*(e-t)+i*(n-e))}function Er(t,e,n){var i=t+n-2*e;return 0===i?.5:(t-e)/i}function Or(t,e,n,i,r){var o=(e-t)*i+t,a=(n-e)*i+e,s=(a-o)*i+o;r[0]=t,r[1]=o,r[2]=s,r[3]=s,r[4]=a,r[5]=n}function zr(t,e,n,i,r,o,a){for(var s=t,l=e,c=0,u=1/a,d=1;d<=a;d++){var h=d*u,p=Pr(t,n,r,h),f=Pr(e,i,o,h),g=p-s,v=f-l;c+=Math.sqrt(g*g+v*v),s=p,l=f}return c}var Nr=/cubic-bezier\(([0-9,\.e ]+)\)/;function Rr(t){var e=t&&Nr.exec(t);if(e){var n=e[1].split(","),i=+kn(n[0]),r=+kn(n[1]),o=+kn(n[2]),a=+kn(n[3]);if(isNaN(i+r+o+a))return;var s=[];return function(t){return t<=0?0:t>=1?1:Tr(0,i,o,1,t,s)&&Mr(0,r,a,1,s[0])}}}var Hr=function(){function t(t){this._inited=!1,this._startTime=0,this._pausedTime=0,this._paused=!1,this._life=t.life||1e3,this._delay=t.delay||0,this.loop=t.loop||!1,this.onframe=t.onframe||Nn,this.ondestroy=t.ondestroy||Nn,this.onrestart=t.onrestart||Nn,t.easing&&this.setEasing(t.easing)}return t.prototype.step=function(t,e){if(this._inited||(this._startTime=t+this._delay,this._inited=!0),!this._paused){var n=this._life,i=t-this._startTime-this._pausedTime,r=i/n;r<0&&(r=0),r=Math.min(r,1);var o=this.easingFunc,a=o?o(r):r;if(this.onframe(a),1===r){if(!this.loop)return!0;var s=i%n;this._startTime=t-s,this._pausedTime=0,this.onrestart()}return!1}this._pausedTime+=e},t.prototype.pause=function(){this._paused=!0},t.prototype.resume=function(){this._paused=!1},t.prototype.setEasing=function(t){this.easing=t,this.easingFunc=un(t)?t:pr[t]||Rr(t)},t}(),Br=function(t){this.value=t},Fr=function(){function t(){this._len=0}return t.prototype.insert=function(t){var e=new Br(t);return this.insertEntry(e),e},t.prototype.insertEntry=function(t){this.head?(this.tail.next=t,t.prev=this.tail,t.next=null,this.tail=t):this.head=this.tail=t,this._len++},t.prototype.remove=function(t){var e=t.prev,n=t.next;e?e.next=n:this.head=n,n?n.prev=e:this.tail=e,t.next=t.prev=null,this._len--},t.prototype.len=function(){return this._len},t.prototype.clear=function(){this.head=this.tail=null,this._len=0},t}(),$r=function(){function t(t){this._list=new Fr,this._maxSize=10,this._map={},this._maxSize=t}return t.prototype.put=function(t,e){var n=this._list,i=this._map,r=null;if(null==i[t]){var o=n.len(),a=this._lastRemovedEntry;if(o>=this._maxSize&&o>0){var s=n.head;n.remove(s),delete i[s.key],r=s.value,this._lastRemovedEntry=s}a?a.value=e:a=new Br(e),a.key=t,n.insertEntry(a),i[t]=a}return r},t.prototype.get=function(t){var e=this._map[t],n=this._list;if(null!=e)return e!==n.tail&&(n.remove(e),n.insertEntry(e)),e.value},t.prototype.clear=function(){this._list.clear(),this._map={}},t.prototype.len=function(){return this._list.len()},t}(),Vr={transparent:[0,0,0,0],aliceblue:[240,248,255,1],antiquewhite:[250,235,215,1],aqua:[0,255,255,1],aquamarine:[127,255,212,1],azure:[240,255,255,1],beige:[245,245,220,1],bisque:[255,228,196,1],black:[0,0,0,1],blanchedalmond:[255,235,205,1],blue:[0,0,255,1],blueviolet:[138,43,226,1],brown:[165,42,42,1],burlywood:[222,184,135,1],cadetblue:[95,158,160,1],chartreuse:[127,255,0,1],chocolate:[210,105,30,1],coral:[255,127,80,1],cornflowerblue:[100,149,237,1],cornsilk:[255,248,220,1],crimson:[220,20,60,1],cyan:[0,255,255,1],darkblue:[0,0,139,1],darkcyan:[0,139,139,1],darkgoldenrod:[184,134,11,1],darkgray:[169,169,169,1],darkgreen:[0,100,0,1],darkgrey:[169,169,169,1],darkkhaki:[189,183,107,1],darkmagenta:[139,0,139,1],darkolivegreen:[85,107,47,1],darkorange:[255,140,0,1],darkorchid:[153,50,204,1],darkred:[139,0,0,1],darksalmon:[233,150,122,1],darkseagreen:[143,188,143,1],darkslateblue:[72,61,139,1],darkslategray:[47,79,79,1],darkslategrey:[47,79,79,1],darkturquoise:[0,206,209,1],darkviolet:[148,0,211,1],deeppink:[255,20,147,1],deepskyblue:[0,191,255,1],dimgray:[105,105,105,1],dimgrey:[105,105,105,1],dodgerblue:[30,144,255,1],firebrick:[178,34,34,1],floralwhite:[255,250,240,1],forestgreen:[34,139,34,1],fuchsia:[255,0,255,1],gainsboro:[220,220,220,1],ghostwhite:[248,248,255,1],gold:[255,215,0,1],goldenrod:[218,165,32,1],gray:[128,128,128,1],green:[0,128,0,1],greenyellow:[173,255,47,1],grey:[128,128,128,1],honeydew:[240,255,240,1],hotpink:[255,105,180,1],indianred:[205,92,92,1],indigo:[75,0,130,1],ivory:[255,255,240,1],khaki:[240,230,140,1],lavender:[230,230,250,1],lavenderblush:[255,240,245,1],lawngreen:[124,252,0,1],lemonchiffon:[255,250,205,1],lightblue:[173,216,230,1],lightcoral:[240,128,128,1],lightcyan:[224,255,255,1],lightgoldenrodyellow:[250,250,210,1],lightgray:[211,211,211,1],lightgreen:[144,238,144,1],lightgrey:[211,211,211,1],lightpink:[255,182,193,1],lightsalmon:[255,160,122,1],lightseagreen:[32,178,170,1],lightskyblue:[135,206,250,1],lightslategray:[119,136,153,1],lightslategrey:[119,136,153,1],lightsteelblue:[176,196,222,1],lightyellow:[255,255,224,1],lime:[0,255,0,1],limegreen:[50,205,50,1],linen:[250,240,230,1],magenta:[255,0,255,1],maroon:[128,0,0,1],mediumaquamarine:[102,205,170,1],mediumblue:[0,0,205,1],mediumorchid:[186,85,211,1],mediumpurple:[147,112,219,1],mediumseagreen:[60,179,113,1],mediumslateblue:[123,104,238,1],mediumspringgreen:[0,250,154,1],mediumturquoise:[72,209,204,1],mediumvioletred:[199,21,133,1],midnightblue:[25,25,112,1],mintcream:[245,255,250,1],mistyrose:[255,228,225,1],moccasin:[255,228,181,1],navajowhite:[255,222,173,1],navy:[0,0,128,1],oldlace:[253,245,230,1],olive:[128,128,0,1],olivedrab:[107,142,35,1],orange:[255,165,0,1],orangered:[255,69,0,1],orchid:[218,112,214,1],palegoldenrod:[238,232,170,1],palegreen:[152,251,152,1],paleturquoise:[175,238,238,1],palevioletred:[219,112,147,1],papayawhip:[255,239,213,1],peachpuff:[255,218,185,1],peru:[205,133,63,1],pink:[255,192,203,1],plum:[221,160,221,1],powderblue:[176,224,230,1],purple:[128,0,128,1],red:[255,0,0,1],rosybrown:[188,143,143,1],royalblue:[65,105,225,1],saddlebrown:[139,69,19,1],salmon:[250,128,114,1],sandybrown:[244,164,96,1],seagreen:[46,139,87,1],seashell:[255,245,238,1],sienna:[160,82,45,1],silver:[192,192,192,1],skyblue:[135,206,235,1],slateblue:[106,90,205,1],slategray:[112,128,144,1],slategrey:[112,128,144,1],snow:[255,250,250,1],springgreen:[0,255,127,1],steelblue:[70,130,180,1],tan:[210,180,140,1],teal:[0,128,128,1],thistle:[216,191,216,1],tomato:[255,99,71,1],turquoise:[64,224,208,1],violet:[238,130,238,1],wheat:[245,222,179,1],white:[255,255,255,1],whitesmoke:[245,245,245,1],yellow:[255,255,0,1],yellowgreen:[154,205,50,1]};function Wr(t){return(t=Math.round(t))<0?0:t>255?255:t}function Ur(t){return t<0?0:t>1?1:t}function Gr(t){var e=t;return e.length&&"%"===e.charAt(e.length-1)?Wr(parseFloat(e)/100*255):Wr(parseInt(e,10))}function qr(t){var e=t;return e.length&&"%"===e.charAt(e.length-1)?Ur(parseFloat(e)/100):Ur(parseFloat(e))}function jr(t,e,n){return n<0?n+=1:n>1&&(n-=1),6*n<1?t+(e-t)*n*6:2*n<1?e:3*n<2?t+(e-t)*(2/3-n)*6:t}function Xr(t,e,n){return t+(e-t)*n}function Yr(t,e,n,i,r){return t[0]=e,t[1]=n,t[2]=i,t[3]=r,t}function Zr(t,e){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t}var Kr=new $r(20),Qr=null;function Jr(t,e){Qr&&Zr(Qr,e),Qr=Kr.put(t,Qr||e.slice())}function to(t,e){if(t){e=e||[];var n=Kr.get(t);if(n)return Zr(e,n);var i=(t+="").replace(/ /g,"").toLowerCase();if(i in Vr)return Zr(e,Vr[i]),Jr(t,e),e;var r,o=i.length;if("#"===i.charAt(0))return 4===o||5===o?(r=parseInt(i.slice(1,4),16))>=0&&r<=4095?(Yr(e,(3840&r)>>4|(3840&r)>>8,240&r|(240&r)>>4,15&r|(15&r)<<4,5===o?parseInt(i.slice(4),16)/15:1),Jr(t,e),e):void Yr(e,0,0,0,1):7===o||9===o?(r=parseInt(i.slice(1,7),16))>=0&&r<=16777215?(Yr(e,(16711680&r)>>16,(65280&r)>>8,255&r,9===o?parseInt(i.slice(7),16)/255:1),Jr(t,e),e):void Yr(e,0,0,0,1):void 0;var a=i.indexOf("("),s=i.indexOf(")");if(-1!==a&&s+1===o){var l=i.substr(0,a),c=i.substr(a+1,s-(a+1)).split(","),u=1;switch(l){case"rgba":if(4!==c.length)return 3===c.length?Yr(e,+c[0],+c[1],+c[2],1):Yr(e,0,0,0,1);u=qr(c.pop());case"rgb":return c.length>=3?(Yr(e,Gr(c[0]),Gr(c[1]),Gr(c[2]),3===c.length?u:qr(c[3])),Jr(t,e),e):void Yr(e,0,0,0,1);case"hsla":return 4!==c.length?void Yr(e,0,0,0,1):(c[3]=qr(c[3]),eo(c,e),Jr(t,e),e);case"hsl":return 3!==c.length?void Yr(e,0,0,0,1):(eo(c,e),Jr(t,e),e);default:return}}Yr(e,0,0,0,1)}}function eo(t,e){var n=(parseFloat(t[0])%360+360)%360/360,i=qr(t[1]),r=qr(t[2]),o=r<=.5?r*(i+1):r+i-r*i,a=2*r-o;return Yr(e=e||[],Wr(255*jr(a,o,n+1/3)),Wr(255*jr(a,o,n)),Wr(255*jr(a,o,n-1/3)),1),4===t.length&&(e[3]=t[3]),e}function no(t,e){var n=to(t);if(n){for(var i=0;i<3;i++)n[i]=n[i]*(1-e)|0,n[i]>255?n[i]=255:n[i]<0&&(n[i]=0);return ro(n,4===n.length?"rgba":"rgb")}}function io(t,e,n,i){var r=to(t);if(t)return r=function(t){if(t){var e,n,i=t[0]/255,r=t[1]/255,o=t[2]/255,a=Math.min(i,r,o),s=Math.max(i,r,o),l=s-a,c=(s+a)/2;if(0===l)e=0,n=0;else{n=c<.5?l/(s+a):l/(2-s-a);var u=((s-i)/6+l/2)/l,d=((s-r)/6+l/2)/l,h=((s-o)/6+l/2)/l;i===s?e=h-d:r===s?e=1/3+u-h:o===s&&(e=2/3+d-u),e<0&&(e+=1),e>1&&(e-=1)}var p=[360*e,n,c];return null!=t[3]&&p.push(t[3]),p}}(r),null!=n&&(r[1]=qr(un(n)?n(r[1]):n)),null!=i&&(r[2]=qr(un(i)?i(r[2]):i)),ro(eo(r),"rgba")}function ro(t,e){if(t&&t.length){var n=t[0]+","+t[1]+","+t[2];return"rgba"!==e&&"hsva"!==e&&"hsla"!==e||(n+=","+t[3]),e+"("+n+")"}}function oo(t,e){var n=to(t);return n?(.299*n[0]+.587*n[1]+.114*n[2])*n[3]/255+(1-n[3])*e:0}var ao=new $r(100);function so(t){if(dn(t)){var e=ao.get(t);return e||(e=no(t,-.1),ao.put(t,e)),e}if(yn(t)){var n=Ze({},t);return n.colorStops=nn(t.colorStops,function(t){return{offset:t.offset,color:no(t.color,-.1)}}),n}return t}var lo=Math.round;function co(t){var e;if(t&&"transparent"!==t){if("string"==typeof t&&t.indexOf("rgba")>-1){var n=to(t);n&&(t="rgb("+n[0]+","+n[1]+","+n[2]+")",e=n[3])}}else t="none";return{color:t,opacity:null==e?1:e}}var uo=1e-4;function ho(t){return t-1e-4}function po(t){return lo(1e3*t)/1e3}function fo(t){return lo(1e4*t)/1e4}var go={left:"start",right:"end",center:"middle",middle:"middle"};function vo(t){return t&&!!t.image}function mo(t){return vo(t)||function(t){return t&&!!t.svgElement}(t)}function yo(t){return"linear"===t.type}function _o(t){return"radial"===t.type}function bo(t){return t&&("linear"===t.type||"radial"===t.type)}function xo(t){return"url(#"+t+")"}function wo(t){var e=t.getGlobalScale(),n=Math.max(e[0],e[1]);return Math.max(Math.ceil(Math.log(n)/Math.log(10)),1)}function So(t){var e=t.x||0,n=t.y||0,i=(t.rotation||0)*Rn,r=xn(t.scaleX,1),o=xn(t.scaleY,1),a=t.skewX||0,s=t.skewY||0,l=[];return(e||n)&&l.push("translate("+e+"px,"+n+"px)"),i&&l.push("rotate("+i+")"),1===r&&1===o||l.push("scale("+r+","+o+")"),(a||s)&&l.push("skew("+lo(a*Rn)+"deg, "+lo(s*Rn)+"deg)"),l.join(" ")}var Co=Te.hasGlobalWindow&&un(window.btoa)?function(t){return window.btoa(unescape(encodeURIComponent(t)))}:"undefined"!=typeof Buffer?function(t){return Buffer.from(t).toString("base64")}:function(t){return null},Mo=Array.prototype.slice;function ko(t,e,n){return(e-t)*n+t}function To(t,e,n,i){for(var r=e.length,o=0;oi?e:t,o=Math.min(n,i),a=r[o-1]||{color:[0,0,0,0],offset:0},s=o;sa)i.length=a;else for(var s=o;s=1},t.prototype.getAdditiveTrack=function(){return this._additiveTrack},t.prototype.addKeyframe=function(t,e,n){this._needsSort=!0;var i=this.keyframes,r=i.length,o=!1,a=6,s=e;if(tn(e)){var l=function(t){return tn(t&&t[0])?2:1}(e);a=l,(1===l&&!pn(e[0])||2===l&&!pn(e[0][0]))&&(o=!0)}else if(pn(e)&&!_n(e))a=0;else if(dn(e))if(isNaN(+e)){var c=to(e);c&&(s=c,a=3)}else a=0;else if(yn(e)){var u=Ze({},s);u.colorStops=nn(e.colorStops,function(t){return{offset:t.offset,color:to(t.color)}}),yo(e)?a=4:_o(e)&&(a=5),s=u}0===r?this.valType=a:a===this.valType&&6!==a||(o=!0),this.discrete=this.discrete||o;var d={time:t,value:s,rawValue:e,percent:0};return n&&(d.easing=n,d.easingFunc=un(n)?n:pr[n]||Rr(n)),i.push(d),d},t.prototype.prepare=function(t,e){var n=this.keyframes;this._needsSort&&n.sort(function(t,e){return t.time-e.time});for(var i=this.valType,r=n.length,o=n[r-1],a=this.discrete,s=zo(i),l=Oo(i),c=0;c=0&&!(l[n].percent<=e);n--);n=p(n,c-2)}else{for(n=h;ne);n++);n=p(n-1,c-2)}r=l[n+1],i=l[n]}if(i&&r){this._lastFr=n,this._lastFrP=e;var f=r.percent-i.percent,g=0===f?1:p((e-i.percent)/f,1);r.easingFunc&&(g=r.easingFunc(g));var v=o?this._additiveValue:d?No:t[u];if(!zo(s)&&!d||v||(v=this._additiveValue=[]),this.discrete)t[u]=g<1?i.rawValue:r.rawValue;else if(zo(s))1===s?To(v,i[a],r[a],g):function(t,e,n,i){for(var r=e.length,o=r&&e[0].length,a=0;a0&&s.addKeyframe(0,Lo(l),i),this._trackKeys.push(a)}s.addKeyframe(t,Lo(e[a]),i)}return this._maxTime=Math.max(this._maxTime,t),this},t.prototype.pause=function(){this._clip.pause(),this._paused=!0},t.prototype.resume=function(){this._clip.resume(),this._paused=!1},t.prototype.isPaused=function(){return!!this._paused},t.prototype.duration=function(t){return this._maxTime=t,this._force=!0,this},t.prototype._doneCallback=function(){this._setTracksFinished(),this._clip=null;var t=this._doneCbs;if(t)for(var e=t.length,n=0;n0)){this._started=1;for(var e=this,n=[],i=this._maxTime||0,r=0;r1){var a=o.pop();r.addKeyframe(a.time,t[i]),r.prepare(this._maxTime,r.getAdditiveTrack())}}}},t}();function Bo(){return(new Date).getTime()}var Fo,$o,Vo=function(t){function e(e){var n=t.call(this)||this;return n._running=!1,n._time=0,n._pausedTime=0,n._pauseStart=0,n._paused=!1,e=e||{},n.stage=e.stage||{},n}return _(e,t),e.prototype.addClip=function(t){t.animation&&this.removeClip(t),this._head?(this._tail.next=t,t.prev=this._tail,t.next=null,this._tail=t):this._head=this._tail=t,t.animation=this},e.prototype.addAnimator=function(t){t.animation=this;var e=t.getClip();e&&this.addClip(e)},e.prototype.removeClip=function(t){if(t.animation){var e=t.prev,n=t.next;e?e.next=n:this._head=n,n?n.prev=e:this._tail=e,t.next=t.prev=t.animation=null}},e.prototype.removeAnimator=function(t){var e=t.getClip();e&&this.removeClip(e),t.animation=null},e.prototype.update=function(t){for(var e=Bo()-this._pausedTime,n=e-this._time,i=this._head;i;){var r=i.next;i.step(e,n)?(i.ondestroy(),this.removeClip(i),i=r):i=r}this._time=e,t||(this.trigger("frame",n),this.stage.update&&this.stage.update())},e.prototype._startLoop=function(){var t=this;this._running=!0,dr(function e(){t._running&&(dr(e),!t._paused&&t.update())})},e.prototype.start=function(){this._running||(this._time=Bo(),this._pausedTime=0,this._startLoop())},e.prototype.stop=function(){this._running=!1},e.prototype.pause=function(){this._paused||(this._pauseStart=Bo(),this._paused=!0)},e.prototype.resume=function(){this._paused&&(this._pausedTime+=Bo()-this._pauseStart,this._paused=!1)},e.prototype.clear=function(){for(var t=this._head;t;){var e=t.next;t.prev=t.next=t.animation=null,t=e}this._head=this._tail=null},e.prototype.isFinished=function(){return null==this._head},e.prototype.animate=function(t,e){e=e||{},this.start();var n=new Ho(t,e.loop);return this.addAnimator(n),n},e}(Qn),Wo=Te.domSupported,Uo=($o={pointerdown:1,pointerup:1,pointermove:1,pointerout:1},{mouse:Fo=["click","dblclick","mousewheel","wheel","mouseout","mouseup","mousedown","mousemove","contextmenu"],touch:["touchstart","touchend","touchmove"],pointer:nn(Fo,function(t){var e=t.replace("mouse","pointer");return $o.hasOwnProperty(e)?e:t})}),Go=["mousemove","mouseup"],qo=["pointermove","pointerup"],jo=!1;function Xo(t){var e=t.pointerType;return"pen"===e||"touch"===e}function Yo(t){t&&(t.zrByTouch=!0)}function Zo(t,e){for(var n=e,i=!1;n&&9!==n.nodeType&&!(i=n.domBelongToZr||n!==e&&n===t.painterRoot);)n=n.parentNode;return i}var Ko=function(t,e){this.stopPropagation=Nn,this.stopImmediatePropagation=Nn,this.preventDefault=Nn,this.type=e.type,this.target=this.currentTarget=t.dom,this.pointerType=e.pointerType,this.clientX=e.clientX,this.clientY=e.clientY},Qo={mousedown:function(t){t=gi(this.dom,t),this.__mayPointerCapture=[t.zrX,t.zrY],this.trigger("mousedown",t)},mousemove:function(t){t=gi(this.dom,t);var e=this.__mayPointerCapture;!e||t.zrX===e[0]&&t.zrY===e[1]||this.__togglePointerCapture(!0),this.trigger("mousemove",t)},mouseup:function(t){t=gi(this.dom,t),this.__togglePointerCapture(!1),this.trigger("mouseup",t)},mouseout:function(t){Zo(this,(t=gi(this.dom,t)).toElement||t.relatedTarget)||(this.__pointerCapturing&&(t.zrEventControl="no_globalout"),this.trigger("mouseout",t))},wheel:function(t){jo=!0,t=gi(this.dom,t),this.trigger("mousewheel",t)},mousewheel:function(t){jo||(t=gi(this.dom,t),this.trigger("mousewheel",t))},touchstart:function(t){Yo(t=gi(this.dom,t)),this.__lastTouchMoment=new Date,this.handler.processGesture(t,"start"),Qo.mousemove.call(this,t),Qo.mousedown.call(this,t)},touchmove:function(t){Yo(t=gi(this.dom,t)),this.handler.processGesture(t,"change"),Qo.mousemove.call(this,t)},touchend:function(t){Yo(t=gi(this.dom,t)),this.handler.processGesture(t,"end"),Qo.mouseup.call(this,t),+new Date-+this.__lastTouchMoment<300&&Qo.click.call(this,t)},pointerdown:function(t){Qo.mousedown.call(this,t)},pointermove:function(t){Xo(t)||Qo.mousemove.call(this,t)},pointerup:function(t){Qo.mouseup.call(this,t)},pointerout:function(t){Xo(t)||Qo.mouseout.call(this,t)}};en(["click","dblclick","contextmenu"],function(t){Qo[t]=function(e){e=gi(this.dom,e),this.trigger(t,e)}});var Jo={pointermove:function(t){Xo(t)||Jo.mousemove.call(this,t)},pointerup:function(t){Jo.mouseup.call(this,t)},mousemove:function(t){this.trigger("mousemove",t)},mouseup:function(t){var e=this.__pointerCapturing;this.__togglePointerCapture(!1),this.trigger("mouseup",t),e&&(t.zrEventControl="only_globalout",this.trigger("mouseout",t))}};function ta(t,e){var n=e.domHandlers;Te.pointerEventsSupported?en(Uo.pointer,function(i){na(e,i,function(e){n[i].call(t,e)})}):(Te.touchEventsSupported&&en(Uo.touch,function(i){na(e,i,function(r){n[i].call(t,r),function(t){t.touching=!0,null!=t.touchTimer&&(clearTimeout(t.touchTimer),t.touchTimer=null),t.touchTimer=setTimeout(function(){t.touching=!1,t.touchTimer=null},700)}(e)})}),en(Uo.mouse,function(i){na(e,i,function(r){r=fi(r),e.touching||n[i].call(t,r)})}))}function ea(t,e){function n(n){na(e,n,function(i){i=fi(i),Zo(t,i.target)||(i=function(t,e){return gi(t.dom,new Ko(t,e),!0)}(t,i),e.domHandlers[n].call(t,i))},{capture:!0})}Te.pointerEventsSupported?en(qo,n):Te.touchEventsSupported||en(Go,n)}function na(t,e,n,i){t.mounted[e]=n,t.listenerOpts[e]=i,function(t,e,n,i){t.addEventListener(e,n,i)}(t.domTarget,e,n,i)}function ia(t){var e=t.mounted;for(var n in e)e.hasOwnProperty(n)&&vi(t.domTarget,n,e[n],t.listenerOpts[n]);t.mounted={}}var ra=function(t,e){this.mounted={},this.listenerOpts={},this.touching=!1,this.domTarget=t,this.domHandlers=e},oa=function(t){function e(e,n){var i=t.call(this)||this;return i.__pointerCapturing=!1,i.dom=e,i.painterRoot=n,i._localHandlerScope=new ra(e,Qo),Wo&&(i._globalHandlerScope=new ra(document,Jo)),ta(i,i._localHandlerScope),i}return _(e,t),e.prototype.dispose=function(){ia(this._localHandlerScope),Wo&&ia(this._globalHandlerScope)},e.prototype.setCursor=function(t){this.dom.style&&(this.dom.style.cursor=t||"default")},e.prototype.__togglePointerCapture=function(t){if(this.__mayPointerCapture=null,Wo&&+this.__pointerCapturing^+t){this.__pointerCapturing=t;var e=this._globalHandlerScope;t?ea(this,e):ia(e)}},e}(Qn),aa=1;Te.hasGlobalWindow&&(aa=Math.max(window.devicePixelRatio||window.screen&&window.screen.deviceXDPI/window.screen.logicalXDPI||1,1));var sa=aa,la="#333",ca="#ccc",ua=xi,da=5e-5;function ha(t){return t>da||t<-5e-5}var pa,fa=[],ga=[],va=[1,0,0,1,0,0],ma=Math.abs,ya=function(){function t(){}var e;return t.prototype.getLocalTransform=function(e){return t.getLocalTransform(this,e)},t.prototype.setPosition=function(t){this.x=t[0],this.y=t[1]},t.prototype.setScale=function(t){this.scaleX=t[0],this.scaleY=t[1]},t.prototype.setSkew=function(t){this.skewX=t[0],this.skewY=t[1]},t.prototype.setOrigin=function(t){this.originX=t[0],this.originY=t[1]},t.prototype.needLocalTransform=function(){return ha(this.rotation)||ha(this.x)||ha(this.y)||ha(this.scaleX-1)||ha(this.scaleY-1)||ha(this.skewX)||ha(this.skewY)},t.prototype.updateTransform=function(){var t=this.parent&&this.parent.transform,e=this.needLocalTransform(),n=this.transform;e||t?(n=n||[1,0,0,1,0,0],e?this.getLocalTransform(n):ua(n),t&&(e?Si(n,t,n):wi(n,t)),this.transform=n,this._resolveGlobalScaleRatio(n)):n&&(ua(n),this.invTransform=null)},t.prototype._resolveGlobalScaleRatio=function(t){var e=this.globalScaleRatio;if(null!=e&&1!==e){this.getGlobalScale(fa);var n=fa[0]<0?-1:1,i=fa[1]<0?-1:1,r=((fa[0]-n)*e+n)/fa[0]||0,o=((fa[1]-i)*e+i)/fa[1]||0;t[0]*=r,t[1]*=r,t[2]*=o,t[3]*=o}this.invTransform=this.invTransform||[1,0,0,1,0,0],ki(this.invTransform,t)},t.prototype.getComputedTransform=function(){for(var t=this,e=[];t;)e.push(t),t=t.parent;for(;t=e.pop();)t.updateTransform();return this.transform},t.prototype.setLocalTransform=function(t){if(t){var e=t[0]*t[0]+t[1]*t[1],n=t[2]*t[2]+t[3]*t[3],i=Math.atan2(t[1],t[0]),r=Math.PI/2+i-Math.atan2(t[3],t[2]);n=Math.sqrt(n)*Math.cos(r),e=Math.sqrt(e),this.skewX=r,this.skewY=0,this.rotation=-i,this.x=+t[4],this.y=+t[5],this.scaleX=e,this.scaleY=n,this.originX=0,this.originY=0}},t.prototype.decomposeTransform=function(){if(this.transform){var t=this.parent,e=this.transform;t&&t.transform&&(t.invTransform=t.invTransform||[1,0,0,1,0,0],Si(ga,t.invTransform,e),e=ga);var n=this.originX,i=this.originY;(n||i)&&(va[4]=n,va[5]=i,Si(ga,e,va),ga[4]-=n,ga[5]-=i,e=ga),this.setLocalTransform(e)}},t.prototype.getGlobalScale=function(t){var e=this.transform;return t=t||[],e?(t[0]=Math.sqrt(e[0]*e[0]+e[1]*e[1]),t[1]=Math.sqrt(e[2]*e[2]+e[3]*e[3]),e[0]<0&&(t[0]=-t[0]),e[3]<0&&(t[1]=-t[1]),t):(t[0]=1,t[1]=1,t)},t.prototype.transformCoordToLocal=function(t,e){var n=[t,e],i=this.invTransform;return i&&jn(n,n,i),n},t.prototype.transformCoordToGlobal=function(t,e){var n=[t,e],i=this.transform;return i&&jn(n,n,i),n},t.prototype.getLineScale=function(){var t=this.transform;return t&&ma(t[0]-1)>1e-10&&ma(t[3]-1)>1e-10?Math.sqrt(ma(t[0]*t[3]-t[2]*t[1])):1},t.prototype.copyTransform=function(t){ba(this,t)},t.getLocalTransform=function(t,e){e=e||[];var n=t.originX||0,i=t.originY||0,r=t.scaleX,o=t.scaleY,a=t.anchorX,s=t.anchorY,l=t.rotation||0,c=t.x,u=t.y,d=t.skewX?Math.tan(t.skewX):0,h=t.skewY?Math.tan(-t.skewY):0;if(n||i||a||s){var p=n+a,f=i+s;e[4]=-p*r-d*f*o,e[5]=-f*o-h*p*r}else e[4]=e[5]=0;return e[0]=r,e[3]=o,e[1]=h*r,e[2]=d*o,l&&Mi(e,e,l),e[4]+=n+c,e[5]+=i+u,e},t.initDefaultProps=((e=t.prototype).scaleX=e.scaleY=e.globalScaleRatio=1,void(e.x=e.y=e.originX=e.originY=e.skewX=e.skewY=e.rotation=e.anchorX=e.anchorY=0)),t}(),_a=["x","y","originX","originY","anchorX","anchorY","rotation","scaleX","scaleY","skewX","skewY"];function ba(t,e){for(var n=0;n<_a.length;n++){var i=_a[n];t[i]=e[i]}}function xa(t){pa||(pa=new $r(100)),t=t||De;var e=pa.get(t);return e||(e={font:t,strWidthCache:new $r(500),asciiWidthMap:null,asciiWidthMapTried:!1,stWideCharWidth:Ee.measureText("国",t).width,asciiCharWidth:Ee.measureText("a",t).width},pa.put(t,e)),e}var wa=0,Sa=5;function Ca(t,e){return t.asciiWidthMapTried||(t.asciiWidthMap=function(t){if(!(wa>=Sa)){t=t||De;for(var e=[],n=+new Date,i=0;i<=127;i++)e[i]=Ee.measureText(String.fromCharCode(i),t).width;var r=+new Date-n;return r>16?wa=Sa:r>2&&wa++,e}}(t.font),t.asciiWidthMapTried=!0),0<=e&&e<=127?null!=t.asciiWidthMap?t.asciiWidthMap[e]:t.asciiCharWidth:t.stWideCharWidth}function Ma(t,e){var n=t.strWidthCache,i=n.get(e);return null==i&&(i=Ee.measureText(e,t.font).width,n.put(e,i)),i}function ka(t,e,n,i){var r=Ma(xa(e),t),o=Ia(e),a=Aa(0,r,n),s=Da(0,o,i);return new $i(a,s,r,o)}function Ta(t,e,n,i){var r=((t||"")+"").split("\n");if(1===r.length)return ka(r[0],e,n,i);for(var o=new $i(0,0,0,0),a=0;a=0?parseFloat(t)/100*e:parseFloat(t):t}function La(t,e,n){var i=e.position||"inside",r=null!=e.distance?e.distance:5,o=n.height,a=n.width,s=o/2,l=n.x,c=n.y,u="left",d="top";if(i instanceof Array)l+=Pa(i[0],n.width),c+=Pa(i[1],n.height),u=null,d=null;else switch(i){case"left":l-=r,c+=s,u="right",d="middle";break;case"right":l+=r+a,c+=s,d="middle";break;case"top":l+=a/2,c-=r,u="center",d="bottom";break;case"bottom":l+=a/2,c+=o+r,u="center";break;case"inside":l+=a/2,c+=s,u="center",d="middle";break;case"insideLeft":l+=r,c+=s,d="middle";break;case"insideRight":l+=a-r,c+=s,u="right",d="middle";break;case"insideTop":l+=a/2,c+=r,u="center";break;case"insideBottom":l+=a/2,c+=o-r,u="center",d="bottom";break;case"insideTopLeft":l+=r,c+=r;break;case"insideTopRight":l+=a-r,c+=r,u="right";break;case"insideBottomLeft":l+=r,c+=o-r,d="bottom";break;case"insideBottomRight":l+=a-r,c+=o-r,u="right",d="bottom"}return(t=t||{}).x=l,t.y=c,t.align=u,t.verticalAlign=d,t}var Ea="__zr_normal__",Oa=_a.concat(["ignore"]),za=rn(_a,function(t,e){return t[e]=!0,t},{ignore:!1}),Na={},Ra=new $i(0,0,0,0),Ha=[],Ba=function(){function t(t){this.id=qe(),this.animators=[],this.currentStates=[],this.states={},this._init(t)}return t.prototype._init=function(t){this.attr(t)},t.prototype.drift=function(t,e,n){switch(this.draggable){case"horizontal":e=0;break;case"vertical":t=0}var i=this.transform;i||(i=this.transform=[1,0,0,1,0,0]),i[4]+=t,i[5]+=e,this.decomposeTransform(),this.markRedraw()},t.prototype.beforeUpdate=function(){},t.prototype.afterUpdate=function(){},t.prototype.update=function(){this.updateTransform(),this.__dirty&&this.updateInnerText()},t.prototype.updateInnerText=function(t){var e=this._textContent;if(e&&(!e.ignore||t)){this.textConfig||(this.textConfig={});var n=this.textConfig,i=n.local,r=e.innerTransformable,o=void 0,a=void 0,s=!1;r.parent=i?this:null;var l=!1;r.copyTransform(e);var c=null!=n.position,u=n.autoOverflowArea,d=void 0;if((u||c)&&(d=Ra,n.layoutRect?d.copy(n.layoutRect):d.copy(this.getBoundingRect()),i||d.applyTransform(this.transform)),c){this.calculateTextPosition?this.calculateTextPosition(Na,n,d):La(Na,n,d),r.x=Na.x,r.y=Na.y,o=Na.align,a=Na.verticalAlign;var h=n.origin;if(h&&null!=n.rotation){var p=void 0,f=void 0;"center"===h?(p=.5*d.width,f=.5*d.height):(p=Pa(h[0],d.width),f=Pa(h[1],d.height)),l=!0,r.originX=-r.x+p+(i?0:d.x),r.originY=-r.y+f+(i?0:d.y)}}null!=n.rotation&&(r.rotation=n.rotation);var g=n.offset;g&&(r.x+=g[0],r.y+=g[1],l||(r.originX=-g[0],r.originY=-g[1]));var v=this._innerTextDefaultStyle||(this._innerTextDefaultStyle={});if(u){var m=v.overflowRect=v.overflowRect||new $i(0,0,0,0);r.getLocalTransform(Ha),ki(Ha,Ha),$i.copy(m,d),m.applyTransform(Ha)}else v.overflowRect=null;var y=void 0,_=void 0,b=void 0;(null==n.inside?"string"==typeof n.position&&n.position.indexOf("inside")>=0:n.inside)&&this.canBeInsideText()?(y=n.insideFill,_=n.insideStroke,null!=y&&"auto"!==y||(y=this.getInsideTextFill()),null!=_&&"auto"!==_||(_=this.getInsideTextStroke(y),b=!0)):(y=n.outsideFill,_=n.outsideStroke,null!=y&&"auto"!==y||(y=this.getOutsideFill()),null!=_&&"auto"!==_||(_=this.getOutsideStroke(y),b=!0)),(y=y||"#000")===v.fill&&_===v.stroke&&b===v.autoStroke&&o===v.align&&a===v.verticalAlign||(s=!0,v.fill=y,v.stroke=_,v.autoStroke=b,v.align=o,v.verticalAlign=a,e.setDefaultTextStyle(v)),e.__dirty|=1,s&&e.dirtyStyle(!0)}},t.prototype.canBeInsideText=function(){return!0},t.prototype.getInsideTextFill=function(){return"#fff"},t.prototype.getInsideTextStroke=function(t){return"#000"},t.prototype.getOutsideFill=function(){return this.__zr&&this.__zr.isDarkMode()?ca:la},t.prototype.getOutsideStroke=function(t){var e=this.__zr&&this.__zr.getBackgroundColor(),n="string"==typeof e&&to(e);n||(n=[255,255,255,1]);for(var i=n[3],r=this.__zr.isDarkMode(),o=0;o<3;o++)n[o]=n[o]*i+(r?0:255)*(1-i);return n[3]=1,ro(n,"rgba")},t.prototype.traverse=function(t,e){},t.prototype.attrKV=function(t,e){"textConfig"===t?this.setTextConfig(e):"textContent"===t?this.setTextContent(e):"clipPath"===t?this.setClipPath(e):"extra"===t?(this.extra=this.extra||{},Ze(this.extra,e)):this[t]=e},t.prototype.hide=function(){this.ignore=!0,this.markRedraw()},t.prototype.show=function(){this.ignore=!1,this.markRedraw()},t.prototype.attr=function(t,e){if("string"==typeof t)this.attrKV(t,e);else if(fn(t))for(var n=an(t),i=0;i0},t.prototype.getState=function(t){return this.states[t]},t.prototype.ensureState=function(t){var e=this.states;return e[t]||(e[t]={}),e[t]},t.prototype.clearStates=function(t){this.useState(Ea,!1,t)},t.prototype.useState=function(t,e,n,i){var r=t===Ea;if(this.hasState()||!r){var o=this.currentStates,a=this.stateTransition;if(!(Qe(o,t)>=0)||!e&&1!==o.length){var s;if(this.stateProxy&&!r&&(s=this.stateProxy(t)),s||(s=this.states&&this.states[t]),s||r){r||this.saveCurrentToNormalState(s);var l=!!(s&&s.hoverLayer||i);l&&this._toggleHoverLayerFlag(!0),this._applyStateObj(t,s,this._normalState,e,!n&&!this.__inHover&&a&&a.duration>0,a);var c=this._textContent,u=this._textGuide;return c&&c.useState(t,e,n,l),u&&u.useState(t,e,n,l),r?(this.currentStates=[],this._normalState={}):e?this.currentStates.push(t):this.currentStates=[t],this._updateAnimationTargets(),this.markRedraw(),!l&&this.__inHover&&(this._toggleHoverLayerFlag(!1),this.__dirty&=-2),s}je("State "+t+" not exists.")}}},t.prototype.useStates=function(t,e,n){if(t.length){var i=[],r=this.currentStates,o=t.length,a=o===r.length;if(a)for(var s=0;s0,p);var f=this._textContent,g=this._textGuide;f&&f.useStates(t,e,d),g&&g.useStates(t,e,d),this._updateAnimationTargets(),this.currentStates=t.slice(),this.markRedraw(),!d&&this.__inHover&&(this._toggleHoverLayerFlag(!1),this.__dirty&=-2)}else this.clearStates()},t.prototype.isSilent=function(){for(var t=this;t;){if(t.silent)return!0;var e=t.__hostTarget;t=e?t.ignoreHostSilent?null:e:t.parent}return!1},t.prototype._updateAnimationTargets=function(){for(var t=0;t=0){var n=this.currentStates.slice();n.splice(e,1),this.useStates(n)}},t.prototype.replaceState=function(t,e,n){var i=this.currentStates.slice(),r=Qe(i,t),o=Qe(i,e)>=0;r>=0?o?i.splice(r,1):i[r]=e:n&&!o&&i.push(e),this.useStates(i)},t.prototype.toggleState=function(t,e){e?this.useState(t,!0):this.removeState(t)},t.prototype._mergeStates=function(t){for(var e,n={},i=0;i=0&&e.splice(n,1)}),this.animators.push(t),n&&n.animation.addAnimator(t),n&&n.wakeUp()},t.prototype.updateDuringAnimation=function(t){this.markRedraw()},t.prototype.stopAnimation=function(t,e){for(var n=this.animators,i=n.length,r=[],o=0;o0&&n.during&&o[0].during(function(t,e){n.during(e)});for(var h=0;h0||r.force&&!a.length){var w,S=void 0,C=void 0,M=void 0;if(s){C={},h&&(S={});for(b=0;b<_;b++){C[m=g[b]]=n[m],h?S[m]=i[m]:n[m]=i[m]}}else if(h){M={};for(b=0;b<_;b++){M[m=g[b]]=Lo(n[m]),Va(n,i,m)}}(w=new Ho(n,!1,!1,d?on(f,function(t){return t.targetName===e}):null)).targetName=e,r.scope&&(w.scope=r.scope),h&&S&&w.whenWithKeys(0,S,g),M&&w.whenWithKeys(0,M,g),w.whenWithKeys(null==c?500:c,s?C:i,g).delay(u||0),t.addAnimator(w,e),a.push(w)}}Je(Ba,Qn),Je(Ba,ya);var Ua=function(t){function e(e){var n=t.call(this)||this;return n.isGroup=!0,n._children=[],n.attr(e),n}return _(e,t),e.prototype.childrenRef=function(){return this._children},e.prototype.children=function(){return this._children.slice()},e.prototype.childAt=function(t){return this._children[t]},e.prototype.childOfName=function(t){for(var e=this._children,n=0;n=0&&(n.splice(i,0,t),this._doAdd(t))}return this},e.prototype.replace=function(t,e){var n=Qe(this._children,t);return n>=0&&this.replaceAt(e,n),this},e.prototype.replaceAt=function(t,e){var n=this._children,i=n[e];if(t&&t!==this&&t.parent!==this&&t!==i){n[e]=t,i.parent=null;var r=this.__zr;r&&i.removeSelfFromZr(r),this._doAdd(t)}return this},e.prototype._doAdd=function(t){t.parent&&t.parent.remove(t),t.parent=this;var e=this.__zr;e&&e!==t.__zr&&t.addSelfToZr(e),e&&e.refresh()},e.prototype.remove=function(t){var e=this.__zr,n=this._children,i=Qe(n,t);return i<0||(n.splice(i,1),t.parent=null,e&&t.removeSelfFromZr(e),e&&e.refresh()),this},e.prototype.removeAll=function(){for(var t=this._children,e=this.__zr,n=0;n":">",'"':""","'":"'"};function Rt(t){return String(t).replace(/[&<>"']/g,t=>Nt[t]??t)}const Ht="span_panel_list_columns";function Bt(){try{const t=localStorage.getItem(Ht);if(!t)return 1;const e=parseInt(t,10);return 1===e||2===e||3===e?e:1}catch{return 1}}function Ft(t){try{localStorage.setItem(Ht,String(t))}catch{}}function $t(t,e,n={}){const r=Rt(t.device_name||i("header.default_name")),o=Rt(t.serial||""),a=Rt(t.firmware||""),s="current"===(e.chart_metric||"power"),l=!1!==n.showSwitches;return`\n
\n
\n
\n

${r}

\n ${o}\n \n ${l?`
\n ${Rt(i("header.enable_switches"))}\n
\n \n
\n
`:""}\n
\n ${function(t,e){const n="current"===(e.chart_metric||"power"),r=!!t.panel_entities?.site_power,o=!!t.panel_entities?.dsm_state,a=!!t.panel_entities?.current_power,s=!!t.panel_entities?.feedthrough_power,l=!!t.panel_entities?.pv_power,c=!!t.panel_entities?.battery_level;return`\n
\n ${r?`\n
\n ${i("header.site")}\n
\n 0\n ${n?"A":"kW"}\n
\n
`:""}\n ${o?`\n
\n ${i("header.grid")}\n
\n --\n
\n
`:""}\n ${a?`\n
\n ${i("header.upstream")}\n
\n --\n ${n?"A":"kW"}\n
\n
`:""}\n ${s?`\n
\n ${i("header.downstream")}\n
\n --\n ${n?"A":"kW"}\n
\n
`:""}\n ${l?`\n
\n ${i("header.solar")}\n
\n --\n ${n?"A":"kW"}\n
\n
`:""}\n ${c?`\n
\n ${i("header.battery")}\n
\n —\n %\n
\n
`:""}\n
\n `}(t,e)}\n
\n
\n
\n ${a}\n
\n \n \n
\n
\n
\n ${Object.entries(m).filter(([t])=>"unknown"!==t).map(([,t])=>{const e=Rt(t.icon),n=Rt(t.color),i=Rt(t.label());let r;return r=t.icon2?``:t.textLabel?`${Rt(t.textLabel)}`:``,`
${r}${i}
`}).join("")}\n
\n
\n
\n `}const Vt=g.power;function Wt(t){return Vt.unit(t)}function Ut(t){return(t<0?"-":"")+Vt.format(t)}function Gt(t){return(Math.abs(t)/1e3).toFixed(1)}function qt(t){return Math.ceil(t/2)}function jt(t){return t%2==0?1:0}function Xt(t){if(2!==t.length)return null;const[e,n]=[Math.min(...t),Math.max(...t)];return qt(e)===qt(n)?"row-span":jt(e)===jt(n)?"col-span":"row-span"}function Yt(t){const e=t.chart_metric??o;return g[e]??g[o]}function Zt(t,e){const n=function(t){return Yt(t).entityRole}(e);return t.entities?.[n]??t.entities?.power??null}function Kt(t){return new Promise(e=>setTimeout(e,t))}class Qt{constructor(t){this._store=t}async callWS(t,e,n){const i=n?.retries??3,r=n?.errorId??`ws:${String(e.type??"unknown")}`;return this._withRetry(()=>t.callWS(e),i,r,n?.errorMessage)}async callService(t,e,n,i,r,o){const a=o?.retries??3,s=o?.errorId??`svc:${e}.${n}`;return this._withRetry(()=>t.callService(e,n,i,r),a,s,o?.errorMessage)}async _withRetry(t,e,n,r){if(this._store.hasAnyPanelOffline())try{const e=await t();return this._store.remove(n),e}catch(t){const e=t instanceof Error?t:new Error(String(t));throw this._store.add({key:n,level:"error",message:r??i("error.panel_offline"),persistent:!1}),e}let o;for(let i=0;i<=e;i++)try{const e=await t();return this._store.remove(n),e}catch(t){if(o=t instanceof Error?t:new Error(String(t)),i{try{const n={};e&&(n.config_entry_id=e);const o={type:"call_service",domain:l,service:"get_monitoring_status",service_data:n,return_response:!0},a=this._retry?await this._retry.callWS(t,o,{errorId:"fetch:monitoring",errorMessage:i("error.monitoring_failed")}):await t.callWS(o),s=a?.response??null;return r===this._generation&&(this._status=s,this._lastFetch=Date.now()),s}catch(t){return console.warn("SPAN Panel: monitoring status fetch failed",t),r===this._generation&&(this._status=null),this._retry||this._errorStore?.add({key:"fetch:monitoring",level:"warning",message:i("error.monitoring_failed"),persistent:!1}),null}finally{this._inflight?.gen===r&&(this._inflight=null)}})();return this._inflight={gen:r,promise:o},o}invalidate(){this._lastFetch=0,this._generation++}get status(){return this._status}clear(){this._status=null,this._lastFetch=0,this._generation++}}class te{constructor(){this._caches=new Map,this._errorStore=null}get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t;for(const e of this._caches.values())e.errorStore=t}async fetchOne(t,e){let n=this._caches.get(e);return n||(n=new Jt,n.errorStore=this._errorStore,this._caches.set(e,n)),n.fetch(t,e)}invalidate(){for(const t of this._caches.values())t.invalidate()}clear(){this._caches.clear()}}function ee(t,e){return t?.circuits?t.circuits[e]??null:null}function ne(t,e,n,i){const r=[];return n||r.push("circuit-off"),i&&r.push("circuit-producer"),function(t){return!!t&&null!=t.over_threshold_since}(e)&&r.push("circuit-alert"),r.join(" ")}function ie(t,e,n,r,o,a,s,l,d,h=!1){const p=e.entities?.power,f=p?a.states[p]:null,g=f&&parseFloat(f.state)||0,v=e.device_type===u||g<0,y=e.entities?.switch,_=y?a.states[y]:null,b=_?"on"===_.state:(f?.attributes?.relay_state||e.relay_state)===c,x=e.breaker_rating_a,w=x?`${Math.round(x)}A`:"",S=Rt(e.name||i("grid.unknown")),C=Yt(s);let M;if("current"===C.entityRole){const t=e.entities?.current,n=t?a.states[t]:null,i=n&&parseFloat(n.state)||0;M=`${C.format(i)}A`}else M=`${Ut(g)}${Wt(g)}`;const k=d||"unknown";let T="";if("unknown"!==k){const t=m[k]??m.unknown??{icon:"mdi:help",color:"#999",label:()=>"Unknown"},e=Rt(t.label()),n=Rt(t.icon),i=Rt(t.color);if(t.icon2){T=`\n \n \n `}else if(t.textLabel){T=`\n \n ${Rt(t.textLabel)}\n `}else T=``}const D=``;let A="",I=l?.utilization_pct??null;if(null==I&&e.breaker_rating_a){const t=e.entities?.current,n=t?a.states[t]:null,i=n?Math.abs(parseFloat(n.state)||0):0;I=Math.round(i/e.breaker_rating_a*1e3)/10}if(null!=I){A=`=80?"utilization-warning":"utilization-normal"}">${Math.round(I)}%`}return`\n
\n
\n
\n ${w?`${w}`:""}\n ${A}\n ${S}\n
\n
\n \n ${M}\n \n ${!1!==e.is_user_controllable&&e.entities?.switch?`\n
\n ${i(b?"grid.on":"grid.off")}\n \n
\n `:""}\n
\n
\n
\n ${T}\n ${D}\n
\n
\n
\n `}function re(t,e){return`\n
\n —\n
\n `}const oe={names:["power","battery power"],suffixes:["_power"]},ae={names:["battery level","battery percentage"],suffixes:["_battery_level","_battery_percentage"]},se={names:["state of energy"],suffixes:["_soe_kwh"]},le={names:["nameplate capacity"],suffixes:["_nameplate_capacity"]};function ce(t,e){if(!t.entities)return null;for(const[n,i]of Object.entries(t.entities)){if("sensor"!==i.domain)continue;const t=(i.original_name??"").toLowerCase();if(e.names.some(e=>t===e))return n;if(i.unique_id&&e.suffixes.some(t=>i.unique_id.endsWith(t)))return n}return null}function ue(t){return ce(t,oe)}function de(t){return ce(t,ae)}function he(t){return ce(t,se)}function pe(t){return ce(t,le)}function fe(t,e,n,i){const r=n.visible_sub_entities||{};let o="";if(!t.entities)return o;for(const[n,a]of Object.entries(t.entities)){if(i.has(n))continue;if(!0!==r[n])continue;const s=e.states[n];if(!s)continue;let l=a.original_name||s.attributes.friendly_name||n;const c=t.name||"";let u;if(l.startsWith(c+" ")&&(l=l.slice(c.length+1)),e.formatEntityState)u=e.formatEntityState(s);else{u=s.state;const t=s.attributes.unit_of_measurement||"";t&&(u+=" "+t)}if("Wh"===(s.attributes.unit_of_measurement||"")){const t=parseFloat(s.state);isNaN(t)||(u=(t/1e3).toFixed(1)+" kWh")}o+=`\n
\n ${Rt(l)}:\n ${Rt(u)}\n
\n `}return o}function ge(t,e,n,r,o,a){if(n){const e=[{key:`${p}${t}_soc`,title:i("subdevice.soc"),available:!!o},{key:`${p}${t}_soe`,title:i("subdevice.soe"),available:!!a},{key:`${p}${t}_power`,title:i("subdevice.power"),available:!!r}].filter(t=>t.available);return`\n
\n ${e.map(t=>`\n
\n
${Rt(t.title)}
\n
\n
\n `).join("")}\n
\n `}return r?`
`:""}function ve(t){const e=void 0!==t.history_days||void 0!==t.history_hours||void 0!==t.history_minutes,n=60*(60*(24*(e&&parseInt(String(t.history_days))||0)+(e&&parseInt(String(t.history_hours))||0))+(e?parseInt(String(t.history_minutes))||0:5))*1e3;return Math.max(n,6e4)}function me(t){const e=s[t];return e?e.ms:s[a].ms}function ye(t){const e=t/1e3;return e<=600?Math.ceil(e):Math.min(5e3,Math.ceil(e/5))}function _e(t){return Math.max(500,Math.floor(t/5e3))}function be(t,e,n,i,r,o){t.has(e)||t.set(e,[]);const a=t.get(e);a.push({time:i,value:n});const s=a.findIndex(t=>t.time>=r);s>0?a.splice(0,s):-1===s&&(a.length=0),a.length>o&&a.splice(0,a.length-o)}function xe(t,e,n=500){if(0===t.length)return t;t.sort((t,e)=>t.time-e.time);const i=[t[0]];for(let e=1;e=n&&i.push(t[e]);return i.length>e&&i.splice(0,i.length-e),i}async function we(t,e,n,i,r){const o=new Date(Date.now()-i).toISOString(),a=i/36e5>72?"hour":"5minute",s=await t.callWS({type:"recorder/statistics_during_period",start_time:o,statistic_ids:e,period:a,types:["mean"]});for(const[t,e]of Object.entries(s)){const i=n.get(t);if(!i||!e)continue;const o=[];for(const t of e){const e=t.mean;if(null==e||!Number.isFinite(e))continue;const n=t.start;n>0&&o.push({time:n,value:e})}if(o.length>0){const t=r.get(i)||[],e=[...o,...t];e.sort((t,e)=>t.time-e.time),r.set(i,e)}}}async function Se(t,e,n,i,r){const o=new Date(Date.now()-i).toISOString(),a=await t.callWS({type:"history/history_during_period",start_time:o,entity_ids:e,minimal_response:!0,significant_changes_only:!0,no_attributes:!0}),s=ye(i),l=_e(i);for(const[t,e]of Object.entries(a)){const i=n.get(t);if(!i||!e)continue;const o=[];for(const t of e){const e=parseFloat(t.s);if(!Number.isFinite(e))continue;const n=1e3*(t.lu||t.lc||0);n>0&&o.push({time:n,value:e})}if(o.length>0){const t=r.get(i)||[],e=[...o,...t];r.set(i,xe(e,s,l))}}}function Ce(t){if(!t.sub_devices)return[];const e=[];for(const[n,i]of Object.entries(t.sub_devices)){const t={power:ue(i)};i.type===d&&(t.soc=de(i),t.soe=he(i));for(const[i,r]of Object.entries(t))r&&e.push({entityId:r,key:`${p}${n}_${i}`,devId:n})}return e}async function Me(t,e,n,i,r,o){if(!e||!t)return;const a=new Map;for(const[t,i]of Object.entries(e.circuits)){const e=Zt(i,n);if(!e)continue;let o;o=r&&r.has(t)?me(r.get(t)):ve(n),a.has(o)||a.set(o,{entityIds:[],uuidByEntity:new Map});const s=a.get(o);s.entityIds.push(e),s.uuidByEntity.set(e,t)}for(const{entityId:t,key:i,devId:r}of Ce(e)){let e;e=o&&o.has(r)?me(o.get(r)):ve(n),a.has(e)||a.set(e,{entityIds:[],uuidByEntity:new Map});const s=a.get(e);s.entityIds.push(t),s.uuidByEntity.set(t,i)}const s=[];for(const[e,n]of a){if(0===n.entityIds.length)continue;e>2592e5?s.push(we(t,n.entityIds,n.uuidByEntity,e,i)):s.push(Se(t,n.entityIds,n.uuidByEntity,e,i))}await Promise.all(s)}var ke=function(){this.firefox=!1,this.ie=!1,this.edge=!1,this.newEdge=!1,this.weChat=!1},Te=new function(){this.browser=new ke,this.node=!1,this.wxa=!1,this.worker=!1,this.svgSupported=!1,this.touchEventsSupported=!1,this.pointerEventsSupported=!1,this.domSupported=!1,this.transformSupported=!1,this.transform3dSupported=!1,this.hasGlobalWindow="undefined"!=typeof window};"object"==typeof wx&&"function"==typeof wx.getSystemInfoSync?(Te.wxa=!0,Te.touchEventsSupported=!0):"undefined"==typeof document&&"undefined"!=typeof self?Te.worker=!0:!Te.hasGlobalWindow||"Deno"in window||"undefined"!=typeof navigator&&"string"==typeof navigator.userAgent&&navigator.userAgent.indexOf("Node.js")>-1?(Te.node=!0,Te.svgSupported=!0):function(t,e){var n=e.browser,i=t.match(/Firefox\/([\d.]+)/),r=t.match(/MSIE\s([\d.]+)/)||t.match(/Trident\/.+?rv:(([\d.]+))/),o=t.match(/Edge?\/([\d.]+)/),a=/micromessenger/i.test(t);i&&(n.firefox=!0,n.version=i[1]);r&&(n.ie=!0,n.version=r[1]);o&&(n.edge=!0,n.version=o[1],n.newEdge=+o[1].split(".")[0]>18);a&&(n.weChat=!0);e.svgSupported="undefined"!=typeof SVGRect,e.touchEventsSupported="ontouchstart"in window&&!n.ie&&!n.edge,e.pointerEventsSupported="onpointerdown"in window&&(n.edge||n.ie&&+n.version>=11);var s=e.domSupported="undefined"!=typeof document;if(s){var l=document.documentElement.style;e.transform3dSupported=(n.ie&&"transition"in l||n.edge||"WebKitCSSMatrix"in window&&"m11"in new WebKitCSSMatrix||"MozPerspective"in l)&&!("OTransition"in l),e.transformSupported=e.transform3dSupported||n.ie&&+n.version>=9}}(navigator.userAgent,Te);var De="sans-serif",Ae="12px "+De;var Ie,Pe,Le=function(t){var e={};if("undefined"==typeof JSON)return e;for(var n=0;n=0)o=r*t.length;else for(var a=0;a>1)%2;a.style.cssText=["position: absolute","visibility: hidden","padding: 0","margin: 0","border-width: 0","user-select: none","width:0","height:0",i[s]+":0",r[l]+":0",i[1-s]+":auto",r[1-l]+":auto",""].join("!important;"),t.appendChild(a),n.push(a)}return e.clearMarkers=function(){en(n,function(t){t.parentNode&&t.parentNode.removeChild(t)})},n}(e,o),s=function(t,e,n){for(var i=n?"invTrans":"trans",r=e[i],o=e.srcCoords,a=[],s=[],l=!0,c=0;c<4;c++){var u=t[c].getBoundingClientRect(),d=2*c,h=u.left,p=u.top;a.push(h,p),l=l&&o&&h===o[d]&&p===o[d+1],s.push(t[c].offsetLeft,t[c].offsetTop)}return l&&r?r:(e.srcCoords=a,e[i]=n?ei(s,a):ei(a,s))}(a,o,r);if(s)return s(t,n,i),!0}return!1}function oi(t){return"CANVAS"===t.nodeName.toUpperCase()}var ai=/([&<>"'])/g,si={"&":"&","<":"<",">":">",'"':""","'":"'"};function li(t){return null==t?"":(t+"").replace(ai,function(t,e){return si[e]})}var ci=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,ui=[],di=Te.browser.firefox&&+Te.browser.version.split(".")[0]<39;function hi(t,e,n,i){return n=n||{},i?pi(t,e,n):di&&null!=e.layerX&&e.layerX!==e.offsetX?(n.zrX=e.layerX,n.zrY=e.layerY):null!=e.offsetX?(n.zrX=e.offsetX,n.zrY=e.offsetY):pi(t,e,n),n}function pi(t,e,n){if(Te.domSupported&&t.getBoundingClientRect){var i=e.clientX,r=e.clientY;if(oi(t)){var o=t.getBoundingClientRect();return n.zrX=i-o.left,void(n.zrY=r-o.top)}if(ri(ui,t,i,r))return n.zrX=ui[0],void(n.zrY=ui[1])}n.zrX=n.zrY=0}function fi(t){return t||window.event}function gi(t,e,n){if(null!=(e=fi(e)).zrX)return e;var i=e.type;if(i&&i.indexOf("touch")>=0){var r="touchend"!==i?e.targetTouches[0]:e.changedTouches[0];r&&hi(t,r,e,n)}else{hi(t,e,e,n);var o=function(t){var e=t.wheelDelta;if(e)return e;var n=t.deltaX,i=t.deltaY;if(null==n||null==i)return e;return 3*(0!==i?Math.abs(i):Math.abs(n))*(i>0?-1:i<0?1:n>0?-1:1)}(e);e.zrDelta=o?o/120:-(e.detail||0)/3}var a=e.button;return null==e.which&&void 0!==a&&ci.test(e.type)&&(e.which=1&a?1:2&a?3:4&a?2:0),e}function vi(t,e,n,i){t.removeEventListener(e,n,i)}var mi=function(t){t.preventDefault(),t.stopPropagation(),t.cancelBubble=!0},yi=function(){function t(){this._track=[]}return t.prototype.recognize=function(t,e,n){return this._doTrack(t,e,n),this._recognize(t)},t.prototype.clear=function(){return this._track.length=0,this},t.prototype._doTrack=function(t,e,n){var i=t.touches;if(i){for(var r={points:[],touches:[],target:e,event:t},o=0,a=i.length;o1&&r&&r.length>1){var a=_i(r)/_i(o);!isFinite(a)&&(a=1),e.pinchScale=a;var s=[((i=r)[0][0]+i[1][0])/2,(i[0][1]+i[1][1])/2];return e.pinchX=s[0],e.pinchY=s[1],{type:"pinch",target:t[0].target,event:e}}}}};function xi(t){return t[0]=1,t[1]=0,t[2]=0,t[3]=1,t[4]=0,t[5]=0,t}function wi(t,e){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t[4]=e[4],t[5]=e[5],t}function Si(t,e,n){var i=e[0]*n[0]+e[2]*n[1],r=e[1]*n[0]+e[3]*n[1],o=e[0]*n[2]+e[2]*n[3],a=e[1]*n[2]+e[3]*n[3],s=e[0]*n[4]+e[2]*n[5]+e[4],l=e[1]*n[4]+e[3]*n[5]+e[5];return t[0]=i,t[1]=r,t[2]=o,t[3]=a,t[4]=s,t[5]=l,t}function Ci(t,e,n){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t[4]=e[4]+n[0],t[5]=e[5]+n[1],t}function Mi(t,e,n,i){void 0===i&&(i=[0,0]);var r=e[0],o=e[2],a=e[4],s=e[1],l=e[3],c=e[5],u=Math.sin(n),d=Math.cos(n);return t[0]=r*d+s*u,t[1]=-r*u+s*d,t[2]=o*d+l*u,t[3]=-o*u+d*l,t[4]=d*(a-i[0])+u*(c-i[1])+i[0],t[5]=d*(c-i[1])-u*(a-i[0])+i[1],t}function ki(t,e){var n=e[0],i=e[2],r=e[4],o=e[1],a=e[3],s=e[5],l=n*a-o*i;return l?(l=1/l,t[0]=a*l,t[1]=-o*l,t[2]=-i*l,t[3]=n*l,t[4]=(i*s-a*r)*l,t[5]=(o*r-n*s)*l,t):null}var Ti=function(){function t(t,e){this.x=t||0,this.y=e||0}return t.prototype.copy=function(t){return this.x=t.x,this.y=t.y,this},t.prototype.clone=function(){return new t(this.x,this.y)},t.prototype.set=function(t,e){return this.x=t,this.y=e,this},t.prototype.equal=function(t){return t.x===this.x&&t.y===this.y},t.prototype.add=function(t){return this.x+=t.x,this.y+=t.y,this},t.prototype.scale=function(t){this.x*=t,this.y*=t},t.prototype.scaleAndAdd=function(t,e){this.x+=t.x*e,this.y+=t.y*e},t.prototype.sub=function(t){return this.x-=t.x,this.y-=t.y,this},t.prototype.dot=function(t){return this.x*t.x+this.y*t.y},t.prototype.len=function(){return Math.sqrt(this.x*this.x+this.y*this.y)},t.prototype.lenSquare=function(){return this.x*this.x+this.y*this.y},t.prototype.normalize=function(){var t=this.len();return this.x/=t,this.y/=t,this},t.prototype.distance=function(t){var e=this.x-t.x,n=this.y-t.y;return Math.sqrt(e*e+n*n)},t.prototype.distanceSquare=function(t){var e=this.x-t.x,n=this.y-t.y;return e*e+n*n},t.prototype.negate=function(){return this.x=-this.x,this.y=-this.y,this},t.prototype.transform=function(t){if(t){var e=this.x,n=this.y;return this.x=t[0]*e+t[2]*n+t[4],this.y=t[1]*e+t[3]*n+t[5],this}},t.prototype.toArray=function(t){return t[0]=this.x,t[1]=this.y,t},t.prototype.fromArray=function(t){this.x=t[0],this.y=t[1]},t.set=function(t,e,n){t.x=e,t.y=n},t.copy=function(t,e){t.x=e.x,t.y=e.y},t.len=function(t){return Math.sqrt(t.x*t.x+t.y*t.y)},t.lenSquare=function(t){return t.x*t.x+t.y*t.y},t.dot=function(t,e){return t.x*e.x+t.y*e.y},t.add=function(t,e,n){t.x=e.x+n.x,t.y=e.y+n.y},t.sub=function(t,e,n){t.x=e.x-n.x,t.y=e.y-n.y},t.scale=function(t,e,n){t.x=e.x*n,t.y=e.y*n},t.scaleAndAdd=function(t,e,n,i){t.x=e.x+n.x*i,t.y=e.y+n.y*i},t.lerp=function(t,e,n,i){var r=1-i;t.x=r*e.x+i*n.x,t.y=r*e.y+i*n.y},t}(),Di=Math.min,Ai=Math.max,Ii=Math.abs,Pi=["x","y"],Li=["width","height"],Ei=new Ti,Oi=new Ti,zi=new Ti,Ni=new Ti,Ri=Gi(),Hi=Ri.minTv,Bi=Ri.maxTv,Fi=[0,0],$i=function(){function t(e,n,i,r){t.set(this,e,n,i,r)}return t.set=function(t,e,n,i,r){return i<0&&(e+=i,i=-i),r<0&&(n+=r,r=-r),t.x=e,t.y=n,t.width=i,t.height=r,t},t.prototype.union=function(t){var e=Di(t.x,this.x),n=Di(t.y,this.y);isFinite(this.x)&&isFinite(this.width)?this.width=Ai(t.x+t.width,this.x+this.width)-e:this.width=t.width,isFinite(this.y)&&isFinite(this.height)?this.height=Ai(t.y+t.height,this.y+this.height)-n:this.height=t.height,this.x=e,this.y=n},t.prototype.applyTransform=function(e){t.applyTransform(this,this,e)},t.prototype.calculateTransform=function(t){var e=this,n=t.width/e.width,i=t.height/e.height,r=[1,0,0,1,0,0];return Ci(r,r,[-e.x,-e.y]),function(t,e,n){var i=n[0],r=n[1];t[0]=e[0]*i,t[1]=e[1]*r,t[2]=e[2]*i,t[3]=e[3]*r,t[4]=e[4]*i,t[5]=e[5]*r}(r,r,[n,i]),Ci(r,r,[t.x,t.y]),r},t.prototype.intersect=function(e,n,i){return t.intersect(this,e,n,i)},t.intersect=function(e,n,i,r){i&&Ti.set(i,0,0);var o=r&&r.outIntersectRect||null,a=r&&r.clamp;if(o&&(o.x=o.y=o.width=o.height=NaN),!e||!n)return!1;e instanceof t||(e=t.set(Vi,e.x,e.y,e.width,e.height)),n instanceof t||(n=t.set(Wi,n.x,n.y,n.width,n.height));var s=!!i;Ri.reset(r,s);var l=Ri.touchThreshold,c=e.x+l,u=e.x+e.width-l,d=e.y+l,h=e.y+e.height-l,p=n.x+l,f=n.x+n.width-l,g=n.y+l,v=n.y+n.height-l;if(c>u||d>h||p>f||g>v)return!1;var m=!(u=t.x&&e<=t.x+t.width&&n>=t.y&&n<=t.y+t.height},t.prototype.contain=function(e,n){return t.contain(this,e,n)},t.prototype.clone=function(){return new t(this.x,this.y,this.width,this.height)},t.prototype.copy=function(e){t.copy(this,e)},t.prototype.plain=function(){return{x:this.x,y:this.y,width:this.width,height:this.height}},t.prototype.isFinite=function(){return isFinite(this.x)&&isFinite(this.y)&&isFinite(this.width)&&isFinite(this.height)},t.prototype.isZero=function(){return 0===this.width||0===this.height},t.create=function(e){return new t(e.x,e.y,e.width,e.height)},t.copy=function(t,e){return t.x=e.x,t.y=e.y,t.width=e.width,t.height=e.height,t},t.applyTransform=function(e,n,i){if(i){if(i[1]<1e-5&&i[1]>-1e-5&&i[2]<1e-5&&i[2]>-1e-5){var r=i[0],o=i[3],a=i[4],s=i[5];return e.x=n.x*r+a,e.y=n.y*o+s,e.width=n.width*r,e.height=n.height*o,e.width<0&&(e.x+=e.width,e.width=-e.width),void(e.height<0&&(e.y+=e.height,e.height=-e.height))}Ei.x=zi.x=n.x,Ei.y=Ni.y=n.y,Oi.x=Ni.x=n.x+n.width,Oi.y=zi.y=n.y+n.height,Ei.transform(i),Ni.transform(i),Oi.transform(i),zi.transform(i),e.x=Di(Ei.x,Oi.x,zi.x,Ni.x),e.y=Di(Ei.y,Oi.y,zi.y,Ni.y);var l=Ai(Ei.x,Oi.x,zi.x,Ni.x),c=Ai(Ei.y,Oi.y,zi.y,Ni.y);e.width=l-e.x,e.height=c-e.y}else e!==n&&t.copy(e,n)},t}(),Vi=new $i(0,0,0,0),Wi=new $i(0,0,0,0);function Ui(t,e,n,i,r,o,a,s){var l=Ii(e-n),c=Ii(i-t),u=Di(l,c),d=Pi[r],h=Pi[1-r],p=Li[r];e=c||!Ri.bidirectional)&&(Hi[d]=-c,Hi[h]=0,Ri.useDir&&Ri.calcDirMTV())))}function Gi(){var t=0,e=new Ti,n=new Ti,i={minTv:new Ti,maxTv:new Ti,useDir:!1,dirMinTv:new Ti,touchThreshold:0,bidirectional:!0,negativeSize:!1,reset:function(r,o){i.touchThreshold=0,r&&null!=r.touchThreshold&&(i.touchThreshold=Ai(0,r.touchThreshold)),i.negativeSize=!1,o&&(i.minTv.set(1/0,1/0),i.maxTv.set(0,0),i.useDir=!1,r&&null!=r.direction&&(i.useDir=!0,i.dirMinTv.copy(i.minTv),n.copy(i.minTv),t=r.direction,i.bidirectional=null==r.bidirectional||!!r.bidirectional,i.bidirectional||e.set(Math.cos(t),Math.sin(t))))},calcDirMTV:function(){var o=i.minTv,a=i.dirMinTv,s=o.y*o.y+o.x*o.x,l=Math.sin(t),c=Math.cos(t),u=l*o.y+c*o.x;r(u)?r(o.x)&&r(o.y)&&a.set(0,0):(n.x=s*c/u,n.y=s*l/u,r(n.x)&&r(n.y)?a.set(0,0):(i.bidirectional||e.dot(n)>0)&&n.len()=0;c--){var u=i[c];u===n||u.ignore||u.ignoreCoarsePointer||u.parent&&u.parent.ignoreCoarsePointer||(Ki.copy(u.getBoundingRect()),u.transform&&Ki.applyTransform(u.transform),Ki.intersect(l)&&o.push(u))}if(o.length)for(var d=Math.PI/12,h=2*Math.PI,p=0;p=0;o--){var a=t[o],s=void 0;if(a!==r&&!a.ignore&&(s=Ji(a,n,i))&&(!e.topTarget&&(e.topTarget=a),s!==qi)){e.target=a;break}}}function er(t,e,n){var i=t.painter;return e<0||e>i.getWidth()||n<0||n>i.getHeight()}en(["click","mousedown","mouseup","mousewheel","dblclick","contextmenu"],function(t){Qi.prototype[t]=function(e){var n,i,r=e.zrX,o=e.zrY,a=er(this,r,o);if("mouseup"===t&&a||(i=(n=this.findHover(r,o)).target),"mousedown"===t)this._downEl=i,this._downPoint=[e.zrX,e.zrY],this._upEl=i;else if("mouseup"===t)this._upEl=i;else if("click"===t){if(this._downEl!==this._upEl||!this._downPoint||Gn(this._downPoint,[e.zrX,e.zrY])>4)return;this._downPoint=null}this.dispatchToElement(n,t,e)}});function nr(t,e,n,i){var r=e+1;if(r===n)return 1;if(i(t[r++],t[e])<0){for(;r=0;)r++;return r-e}function ir(t,e,n,i,r){for(i===e&&i++;i>>1])<0?l=o:s=o+1;var c=i-s;switch(c){case 3:t[s+3]=t[s+2];case 2:t[s+2]=t[s+1];case 1:t[s+1]=t[s];break;default:for(;c>0;)t[s+c]=t[s+c-1],c--}t[s]=a}}function rr(t,e,n,i,r,o){var a=0,s=0,l=1;if(o(t,e[n+r])>0){for(s=i-r;l0;)a=l,(l=1+(l<<1))<=0&&(l=s);l>s&&(l=s),a+=r,l+=r}else{for(s=r+1;ls&&(l=s);var c=a;a=r-l,l=r-c}for(a++;a>>1);o(t,e[n+u])>0?a=u+1:l=u}return l}function or(t,e,n,i,r,o){var a=0,s=0,l=1;if(o(t,e[n+r])<0){for(s=r+1;ls&&(l=s);var c=a;a=r-l,l=r-c}else{for(s=i-r;l=0;)a=l,(l=1+(l<<1))<=0&&(l=s);l>s&&(l=s),a+=r,l+=r}for(a++;a>>1);o(t,e[n+u])<0?l=u:a=u+1}return l}function ar(t,e){var n,i,r=7,o=0,a=[];function s(s){var l=n[s],c=i[s],u=n[s+1],d=i[s+1];i[s]=c+d,s===o-3&&(n[s+1]=n[s+2],i[s+1]=i[s+2]),o--;var h=or(t[u],t,l,c,0,e);l+=h,0!==(c-=h)&&0!==(d=rr(t[l+c-1],t,u,d,d-1,e))&&(c<=d?function(n,i,o,s){var l=0;for(l=0;l=7||p>=7);if(f)break;g<0&&(g=0),g+=2}if((r=g)<1&&(r=1),1===i){for(l=0;l=0;l--)t[p+l]=t[h+l];return void(t[d]=a[u])}var f=r;for(;;){var g=0,v=0,m=!1;do{if(e(a[u],t[c])<0){if(t[d--]=t[c--],g++,v=0,0===--i){m=!0;break}}else if(t[d--]=a[u--],v++,g=0,1===--s){m=!0;break}}while((g|v)=0;l--)t[p+l]=t[h+l];if(0===i){m=!0;break}}if(t[d--]=a[u--],1===--s){m=!0;break}if(0!==(v=s-rr(t[c],a,0,s,s-1,e))){for(s-=v,p=(d-=v)+1,h=(u-=v)+1,l=0;l=7||v>=7);if(m)break;f<0&&(f=0),f+=2}(r=f)<1&&(r=1);if(1===s){for(p=(d-=i)+1,h=(c-=i)+1,l=i-1;l>=0;l--)t[p+l]=t[h+l];t[d]=a[u]}else{if(0===s)throw new Error;for(h=d-(s-1),l=0;l1;){var t=o-2;if(t>=1&&i[t-1]<=i[t]+i[t+1]||t>=2&&i[t-2]<=i[t]+i[t-1])i[t-1]i[t+1])break;s(t)}},forceMergeRuns:function(){for(;o>1;){var t=o-2;t>0&&i[t-1]=32;)e|=1&t,t>>=1;return t+e}(r);do{if((o=nr(t,n,i,e))s&&(l=s),ir(t,n,n+l,n+o,e),o=l}a.pushRun(n,o),a.mergeRuns(),r-=o,n+=o}while(0!==r);a.forceMergeRuns()}}}var lr=!1;function cr(){lr||(lr=!0,console.warn("z / z2 / zlevel of displayable is invalid, which may cause unexpected errors"))}function ur(t,e){return t.zlevel===e.zlevel?t.z===e.z?t.z2-e.z2:t.z-e.z:t.zlevel-e.zlevel}var dr,hr=function(){function t(){this._roots=[],this._displayList=[],this._displayListLen=0,this.displayableSortFunc=ur}return t.prototype.traverse=function(t,e){for(var n=0;n=0&&this._roots.splice(i,1)}},t.prototype.delAllRoots=function(){this._roots=[],this._displayList=[],this._displayListLen=0},t.prototype.getRoots=function(){return this._roots},t.prototype.dispose=function(){this._displayList=null,this._roots=null},t}();dr=Te.hasGlobalWindow&&(window.requestAnimationFrame&&window.requestAnimationFrame.bind(window)||window.msRequestAnimationFrame&&window.msRequestAnimationFrame.bind(window)||window.mozRequestAnimationFrame||window.webkitRequestAnimationFrame)||function(t){return setTimeout(t,16)};var pr={linear:function(t){return t},quadraticIn:function(t){return t*t},quadraticOut:function(t){return t*(2-t)},quadraticInOut:function(t){return(t*=2)<1?.5*t*t:-.5*(--t*(t-2)-1)},cubicIn:function(t){return t*t*t},cubicOut:function(t){return--t*t*t+1},cubicInOut:function(t){return(t*=2)<1?.5*t*t*t:.5*((t-=2)*t*t+2)},quarticIn:function(t){return t*t*t*t},quarticOut:function(t){return 1- --t*t*t*t},quarticInOut:function(t){return(t*=2)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2)},quinticIn:function(t){return t*t*t*t*t},quinticOut:function(t){return--t*t*t*t*t+1},quinticInOut:function(t){return(t*=2)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2)},sinusoidalIn:function(t){return 1-Math.cos(t*Math.PI/2)},sinusoidalOut:function(t){return Math.sin(t*Math.PI/2)},sinusoidalInOut:function(t){return.5*(1-Math.cos(Math.PI*t))},exponentialIn:function(t){return 0===t?0:Math.pow(1024,t-1)},exponentialOut:function(t){return 1===t?1:1-Math.pow(2,-10*t)},exponentialInOut:function(t){return 0===t?0:1===t?1:(t*=2)<1?.5*Math.pow(1024,t-1):.5*(2-Math.pow(2,-10*(t-1)))},circularIn:function(t){return 1-Math.sqrt(1-t*t)},circularOut:function(t){return Math.sqrt(1- --t*t)},circularInOut:function(t){return(t*=2)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1)},elasticIn:function(t){var e,n=.1;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=.4*Math.asin(1/n)/(2*Math.PI),-n*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/.4))},elasticOut:function(t){var e,n=.1;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=.4*Math.asin(1/n)/(2*Math.PI),n*Math.pow(2,-10*t)*Math.sin((t-e)*(2*Math.PI)/.4)+1)},elasticInOut:function(t){var e,n=.1,i=.4;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=i*Math.asin(1/n)/(2*Math.PI),(t*=2)<1?n*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/i)*-.5:n*Math.pow(2,-10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/i)*.5+1)},backIn:function(t){var e=1.70158;return t*t*((e+1)*t-e)},backOut:function(t){var e=1.70158;return--t*t*((e+1)*t+e)+1},backInOut:function(t){var e=2.5949095;return(t*=2)<1?t*t*((e+1)*t-e)*.5:.5*((t-=2)*t*((e+1)*t+e)+2)},bounceIn:function(t){return 1-pr.bounceOut(1-t)},bounceOut:function(t){return t<1/2.75?7.5625*t*t:t<2/2.75?7.5625*(t-=1.5/2.75)*t+.75:t<2.5/2.75?7.5625*(t-=2.25/2.75)*t+.9375:7.5625*(t-=2.625/2.75)*t+.984375},bounceInOut:function(t){return t<.5?.5*pr.bounceIn(2*t):.5*pr.bounceOut(2*t-1)+.5}},fr=Math.pow,gr=Math.sqrt,vr=1e-8,mr=1e-4,yr=gr(3),_r=1/3,br=Hn(),xr=Hn(),wr=Hn();function Sr(t){return t>-1e-8&&tvr||t<-1e-8}function Mr(t,e,n,i,r){var o=1-r;return o*o*(o*t+3*r*e)+r*r*(r*i+3*o*n)}function kr(t,e,n,i,r){var o=1-r;return 3*(((e-t)*o+2*(n-e)*r)*o+(i-n)*r*r)}function Tr(t,e,n,i,r,o){var a=i+3*(e-n)-t,s=3*(n-2*e+t),l=3*(e-t),c=t-r,u=s*s-3*a*l,d=s*l-9*a*c,h=l*l-3*s*c,p=0;if(Sr(u)&&Sr(d)){if(Sr(s))o[0]=0;else(C=-l/s)>=0&&C<=1&&(o[p++]=C)}else{var f=d*d-4*u*h;if(Sr(f)){var g=d/u,v=-g/2;(C=-s/a+g)>=0&&C<=1&&(o[p++]=C),v>=0&&v<=1&&(o[p++]=v)}else if(f>0){var m=gr(f),y=u*s+1.5*a*(-d+m),_=u*s+1.5*a*(-d-m);(C=(-s-((y=y<0?-fr(-y,_r):fr(y,_r))+(_=_<0?-fr(-_,_r):fr(_,_r))))/(3*a))>=0&&C<=1&&(o[p++]=C)}else{var b=(2*u*s-3*a*d)/(2*gr(u*u*u)),x=Math.acos(b)/3,w=gr(u),S=Math.cos(x),C=(-s-2*w*S)/(3*a),M=(v=(-s+w*(S+yr*Math.sin(x)))/(3*a),(-s+w*(S-yr*Math.sin(x)))/(3*a));C>=0&&C<=1&&(o[p++]=C),v>=0&&v<=1&&(o[p++]=v),M>=0&&M<=1&&(o[p++]=M)}}return p}function Dr(t,e,n,i,r){var o=6*n-12*e+6*t,a=9*e+3*i-3*t-9*n,s=3*e-3*t,l=0;if(Sr(a)){if(Cr(o))(u=-s/o)>=0&&u<=1&&(r[l++]=u)}else{var c=o*o-4*a*s;if(Sr(c))r[0]=-o/(2*a);else if(c>0){var u,d=gr(c),h=(-o-d)/(2*a);(u=(-o+d)/(2*a))>=0&&u<=1&&(r[l++]=u),h>=0&&h<=1&&(r[l++]=h)}}return l}function Ar(t,e,n,i,r,o){var a=(e-t)*r+t,s=(n-e)*r+e,l=(i-n)*r+n,c=(s-a)*r+a,u=(l-s)*r+s,d=(u-c)*r+c;o[0]=t,o[1]=a,o[2]=c,o[3]=d,o[4]=d,o[5]=u,o[6]=l,o[7]=i}function Ir(t,e,n,i,r,o,a,s,l){for(var c=t,u=e,d=0,h=1/l,p=1;p<=l;p++){var f=p*h,g=Mr(t,n,r,a,f),v=Mr(e,i,o,s,f),m=g-c,y=v-u;d+=Math.sqrt(m*m+y*y),c=g,u=v}return d}function Pr(t,e,n,i){var r=1-i;return r*(r*t+2*i*e)+i*i*n}function Lr(t,e,n,i){return 2*((1-i)*(e-t)+i*(n-e))}function Er(t,e,n){var i=t+n-2*e;return 0===i?.5:(t-e)/i}function Or(t,e,n,i,r){var o=(e-t)*i+t,a=(n-e)*i+e,s=(a-o)*i+o;r[0]=t,r[1]=o,r[2]=s,r[3]=s,r[4]=a,r[5]=n}function zr(t,e,n,i,r,o,a){for(var s=t,l=e,c=0,u=1/a,d=1;d<=a;d++){var h=d*u,p=Pr(t,n,r,h),f=Pr(e,i,o,h),g=p-s,v=f-l;c+=Math.sqrt(g*g+v*v),s=p,l=f}return c}var Nr=/cubic-bezier\(([0-9,\.e ]+)\)/;function Rr(t){var e=t&&Nr.exec(t);if(e){var n=e[1].split(","),i=+kn(n[0]),r=+kn(n[1]),o=+kn(n[2]),a=+kn(n[3]);if(isNaN(i+r+o+a))return;var s=[];return function(t){return t<=0?0:t>=1?1:Tr(0,i,o,1,t,s)&&Mr(0,r,a,1,s[0])}}}var Hr=function(){function t(t){this._inited=!1,this._startTime=0,this._pausedTime=0,this._paused=!1,this._life=t.life||1e3,this._delay=t.delay||0,this.loop=t.loop||!1,this.onframe=t.onframe||Nn,this.ondestroy=t.ondestroy||Nn,this.onrestart=t.onrestart||Nn,t.easing&&this.setEasing(t.easing)}return t.prototype.step=function(t,e){if(this._inited||(this._startTime=t+this._delay,this._inited=!0),!this._paused){var n=this._life,i=t-this._startTime-this._pausedTime,r=i/n;r<0&&(r=0),r=Math.min(r,1);var o=this.easingFunc,a=o?o(r):r;if(this.onframe(a),1===r){if(!this.loop)return!0;var s=i%n;this._startTime=t-s,this._pausedTime=0,this.onrestart()}return!1}this._pausedTime+=e},t.prototype.pause=function(){this._paused=!0},t.prototype.resume=function(){this._paused=!1},t.prototype.setEasing=function(t){this.easing=t,this.easingFunc=un(t)?t:pr[t]||Rr(t)},t}(),Br=function(t){this.value=t},Fr=function(){function t(){this._len=0}return t.prototype.insert=function(t){var e=new Br(t);return this.insertEntry(e),e},t.prototype.insertEntry=function(t){this.head?(this.tail.next=t,t.prev=this.tail,t.next=null,this.tail=t):this.head=this.tail=t,this._len++},t.prototype.remove=function(t){var e=t.prev,n=t.next;e?e.next=n:this.head=n,n?n.prev=e:this.tail=e,t.next=t.prev=null,this._len--},t.prototype.len=function(){return this._len},t.prototype.clear=function(){this.head=this.tail=null,this._len=0},t}(),$r=function(){function t(t){this._list=new Fr,this._maxSize=10,this._map={},this._maxSize=t}return t.prototype.put=function(t,e){var n=this._list,i=this._map,r=null;if(null==i[t]){var o=n.len(),a=this._lastRemovedEntry;if(o>=this._maxSize&&o>0){var s=n.head;n.remove(s),delete i[s.key],r=s.value,this._lastRemovedEntry=s}a?a.value=e:a=new Br(e),a.key=t,n.insertEntry(a),i[t]=a}return r},t.prototype.get=function(t){var e=this._map[t],n=this._list;if(null!=e)return e!==n.tail&&(n.remove(e),n.insertEntry(e)),e.value},t.prototype.clear=function(){this._list.clear(),this._map={}},t.prototype.len=function(){return this._list.len()},t}(),Vr={transparent:[0,0,0,0],aliceblue:[240,248,255,1],antiquewhite:[250,235,215,1],aqua:[0,255,255,1],aquamarine:[127,255,212,1],azure:[240,255,255,1],beige:[245,245,220,1],bisque:[255,228,196,1],black:[0,0,0,1],blanchedalmond:[255,235,205,1],blue:[0,0,255,1],blueviolet:[138,43,226,1],brown:[165,42,42,1],burlywood:[222,184,135,1],cadetblue:[95,158,160,1],chartreuse:[127,255,0,1],chocolate:[210,105,30,1],coral:[255,127,80,1],cornflowerblue:[100,149,237,1],cornsilk:[255,248,220,1],crimson:[220,20,60,1],cyan:[0,255,255,1],darkblue:[0,0,139,1],darkcyan:[0,139,139,1],darkgoldenrod:[184,134,11,1],darkgray:[169,169,169,1],darkgreen:[0,100,0,1],darkgrey:[169,169,169,1],darkkhaki:[189,183,107,1],darkmagenta:[139,0,139,1],darkolivegreen:[85,107,47,1],darkorange:[255,140,0,1],darkorchid:[153,50,204,1],darkred:[139,0,0,1],darksalmon:[233,150,122,1],darkseagreen:[143,188,143,1],darkslateblue:[72,61,139,1],darkslategray:[47,79,79,1],darkslategrey:[47,79,79,1],darkturquoise:[0,206,209,1],darkviolet:[148,0,211,1],deeppink:[255,20,147,1],deepskyblue:[0,191,255,1],dimgray:[105,105,105,1],dimgrey:[105,105,105,1],dodgerblue:[30,144,255,1],firebrick:[178,34,34,1],floralwhite:[255,250,240,1],forestgreen:[34,139,34,1],fuchsia:[255,0,255,1],gainsboro:[220,220,220,1],ghostwhite:[248,248,255,1],gold:[255,215,0,1],goldenrod:[218,165,32,1],gray:[128,128,128,1],green:[0,128,0,1],greenyellow:[173,255,47,1],grey:[128,128,128,1],honeydew:[240,255,240,1],hotpink:[255,105,180,1],indianred:[205,92,92,1],indigo:[75,0,130,1],ivory:[255,255,240,1],khaki:[240,230,140,1],lavender:[230,230,250,1],lavenderblush:[255,240,245,1],lawngreen:[124,252,0,1],lemonchiffon:[255,250,205,1],lightblue:[173,216,230,1],lightcoral:[240,128,128,1],lightcyan:[224,255,255,1],lightgoldenrodyellow:[250,250,210,1],lightgray:[211,211,211,1],lightgreen:[144,238,144,1],lightgrey:[211,211,211,1],lightpink:[255,182,193,1],lightsalmon:[255,160,122,1],lightseagreen:[32,178,170,1],lightskyblue:[135,206,250,1],lightslategray:[119,136,153,1],lightslategrey:[119,136,153,1],lightsteelblue:[176,196,222,1],lightyellow:[255,255,224,1],lime:[0,255,0,1],limegreen:[50,205,50,1],linen:[250,240,230,1],magenta:[255,0,255,1],maroon:[128,0,0,1],mediumaquamarine:[102,205,170,1],mediumblue:[0,0,205,1],mediumorchid:[186,85,211,1],mediumpurple:[147,112,219,1],mediumseagreen:[60,179,113,1],mediumslateblue:[123,104,238,1],mediumspringgreen:[0,250,154,1],mediumturquoise:[72,209,204,1],mediumvioletred:[199,21,133,1],midnightblue:[25,25,112,1],mintcream:[245,255,250,1],mistyrose:[255,228,225,1],moccasin:[255,228,181,1],navajowhite:[255,222,173,1],navy:[0,0,128,1],oldlace:[253,245,230,1],olive:[128,128,0,1],olivedrab:[107,142,35,1],orange:[255,165,0,1],orangered:[255,69,0,1],orchid:[218,112,214,1],palegoldenrod:[238,232,170,1],palegreen:[152,251,152,1],paleturquoise:[175,238,238,1],palevioletred:[219,112,147,1],papayawhip:[255,239,213,1],peachpuff:[255,218,185,1],peru:[205,133,63,1],pink:[255,192,203,1],plum:[221,160,221,1],powderblue:[176,224,230,1],purple:[128,0,128,1],red:[255,0,0,1],rosybrown:[188,143,143,1],royalblue:[65,105,225,1],saddlebrown:[139,69,19,1],salmon:[250,128,114,1],sandybrown:[244,164,96,1],seagreen:[46,139,87,1],seashell:[255,245,238,1],sienna:[160,82,45,1],silver:[192,192,192,1],skyblue:[135,206,235,1],slateblue:[106,90,205,1],slategray:[112,128,144,1],slategrey:[112,128,144,1],snow:[255,250,250,1],springgreen:[0,255,127,1],steelblue:[70,130,180,1],tan:[210,180,140,1],teal:[0,128,128,1],thistle:[216,191,216,1],tomato:[255,99,71,1],turquoise:[64,224,208,1],violet:[238,130,238,1],wheat:[245,222,179,1],white:[255,255,255,1],whitesmoke:[245,245,245,1],yellow:[255,255,0,1],yellowgreen:[154,205,50,1]};function Wr(t){return(t=Math.round(t))<0?0:t>255?255:t}function Ur(t){return t<0?0:t>1?1:t}function Gr(t){var e=t;return e.length&&"%"===e.charAt(e.length-1)?Wr(parseFloat(e)/100*255):Wr(parseInt(e,10))}function qr(t){var e=t;return e.length&&"%"===e.charAt(e.length-1)?Ur(parseFloat(e)/100):Ur(parseFloat(e))}function jr(t,e,n){return n<0?n+=1:n>1&&(n-=1),6*n<1?t+(e-t)*n*6:2*n<1?e:3*n<2?t+(e-t)*(2/3-n)*6:t}function Xr(t,e,n){return t+(e-t)*n}function Yr(t,e,n,i,r){return t[0]=e,t[1]=n,t[2]=i,t[3]=r,t}function Zr(t,e){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t}var Kr=new $r(20),Qr=null;function Jr(t,e){Qr&&Zr(Qr,e),Qr=Kr.put(t,Qr||e.slice())}function to(t,e){if(t){e=e||[];var n=Kr.get(t);if(n)return Zr(e,n);var i=(t+="").replace(/ /g,"").toLowerCase();if(i in Vr)return Zr(e,Vr[i]),Jr(t,e),e;var r,o=i.length;if("#"===i.charAt(0))return 4===o||5===o?(r=parseInt(i.slice(1,4),16))>=0&&r<=4095?(Yr(e,(3840&r)>>4|(3840&r)>>8,240&r|(240&r)>>4,15&r|(15&r)<<4,5===o?parseInt(i.slice(4),16)/15:1),Jr(t,e),e):void Yr(e,0,0,0,1):7===o||9===o?(r=parseInt(i.slice(1,7),16))>=0&&r<=16777215?(Yr(e,(16711680&r)>>16,(65280&r)>>8,255&r,9===o?parseInt(i.slice(7),16)/255:1),Jr(t,e),e):void Yr(e,0,0,0,1):void 0;var a=i.indexOf("("),s=i.indexOf(")");if(-1!==a&&s+1===o){var l=i.substr(0,a),c=i.substr(a+1,s-(a+1)).split(","),u=1;switch(l){case"rgba":if(4!==c.length)return 3===c.length?Yr(e,+c[0],+c[1],+c[2],1):Yr(e,0,0,0,1);u=qr(c.pop());case"rgb":return c.length>=3?(Yr(e,Gr(c[0]),Gr(c[1]),Gr(c[2]),3===c.length?u:qr(c[3])),Jr(t,e),e):void Yr(e,0,0,0,1);case"hsla":return 4!==c.length?void Yr(e,0,0,0,1):(c[3]=qr(c[3]),eo(c,e),Jr(t,e),e);case"hsl":return 3!==c.length?void Yr(e,0,0,0,1):(eo(c,e),Jr(t,e),e);default:return}}Yr(e,0,0,0,1)}}function eo(t,e){var n=(parseFloat(t[0])%360+360)%360/360,i=qr(t[1]),r=qr(t[2]),o=r<=.5?r*(i+1):r+i-r*i,a=2*r-o;return Yr(e=e||[],Wr(255*jr(a,o,n+1/3)),Wr(255*jr(a,o,n)),Wr(255*jr(a,o,n-1/3)),1),4===t.length&&(e[3]=t[3]),e}function no(t,e){var n=to(t);if(n){for(var i=0;i<3;i++)n[i]=n[i]*(1-e)|0,n[i]>255?n[i]=255:n[i]<0&&(n[i]=0);return ro(n,4===n.length?"rgba":"rgb")}}function io(t,e,n,i){var r=to(t);if(t)return r=function(t){if(t){var e,n,i=t[0]/255,r=t[1]/255,o=t[2]/255,a=Math.min(i,r,o),s=Math.max(i,r,o),l=s-a,c=(s+a)/2;if(0===l)e=0,n=0;else{n=c<.5?l/(s+a):l/(2-s-a);var u=((s-i)/6+l/2)/l,d=((s-r)/6+l/2)/l,h=((s-o)/6+l/2)/l;i===s?e=h-d:r===s?e=1/3+u-h:o===s&&(e=2/3+d-u),e<0&&(e+=1),e>1&&(e-=1)}var p=[360*e,n,c];return null!=t[3]&&p.push(t[3]),p}}(r),null!=n&&(r[1]=qr(un(n)?n(r[1]):n)),null!=i&&(r[2]=qr(un(i)?i(r[2]):i)),ro(eo(r),"rgba")}function ro(t,e){if(t&&t.length){var n=t[0]+","+t[1]+","+t[2];return"rgba"!==e&&"hsva"!==e&&"hsla"!==e||(n+=","+t[3]),e+"("+n+")"}}function oo(t,e){var n=to(t);return n?(.299*n[0]+.587*n[1]+.114*n[2])*n[3]/255+(1-n[3])*e:0}var ao=new $r(100);function so(t){if(dn(t)){var e=ao.get(t);return e||(e=no(t,-.1),ao.put(t,e)),e}if(yn(t)){var n=Ze({},t);return n.colorStops=nn(t.colorStops,function(t){return{offset:t.offset,color:no(t.color,-.1)}}),n}return t}var lo=Math.round;function co(t){var e;if(t&&"transparent"!==t){if("string"==typeof t&&t.indexOf("rgba")>-1){var n=to(t);n&&(t="rgb("+n[0]+","+n[1]+","+n[2]+")",e=n[3])}}else t="none";return{color:t,opacity:null==e?1:e}}var uo=1e-4;function ho(t){return t-1e-4}function po(t){return lo(1e3*t)/1e3}function fo(t){return lo(1e4*t)/1e4}var go={left:"start",right:"end",center:"middle",middle:"middle"};function vo(t){return t&&!!t.image}function mo(t){return vo(t)||function(t){return t&&!!t.svgElement}(t)}function yo(t){return"linear"===t.type}function _o(t){return"radial"===t.type}function bo(t){return t&&("linear"===t.type||"radial"===t.type)}function xo(t){return"url(#"+t+")"}function wo(t){var e=t.getGlobalScale(),n=Math.max(e[0],e[1]);return Math.max(Math.ceil(Math.log(n)/Math.log(10)),1)}function So(t){var e=t.x||0,n=t.y||0,i=(t.rotation||0)*Rn,r=xn(t.scaleX,1),o=xn(t.scaleY,1),a=t.skewX||0,s=t.skewY||0,l=[];return(e||n)&&l.push("translate("+e+"px,"+n+"px)"),i&&l.push("rotate("+i+")"),1===r&&1===o||l.push("scale("+r+","+o+")"),(a||s)&&l.push("skew("+lo(a*Rn)+"deg, "+lo(s*Rn)+"deg)"),l.join(" ")}var Co=Te.hasGlobalWindow&&un(window.btoa)?function(t){return window.btoa(unescape(encodeURIComponent(t)))}:"undefined"!=typeof Buffer?function(t){return Buffer.from(t).toString("base64")}:function(t){return null},Mo=Array.prototype.slice;function ko(t,e,n){return(e-t)*n+t}function To(t,e,n,i){for(var r=e.length,o=0;oi?e:t,o=Math.min(n,i),a=r[o-1]||{color:[0,0,0,0],offset:0},s=o;sa)i.length=a;else for(var s=o;s=1},t.prototype.getAdditiveTrack=function(){return this._additiveTrack},t.prototype.addKeyframe=function(t,e,n){this._needsSort=!0;var i=this.keyframes,r=i.length,o=!1,a=6,s=e;if(tn(e)){var l=function(t){return tn(t&&t[0])?2:1}(e);a=l,(1===l&&!pn(e[0])||2===l&&!pn(e[0][0]))&&(o=!0)}else if(pn(e)&&!_n(e))a=0;else if(dn(e))if(isNaN(+e)){var c=to(e);c&&(s=c,a=3)}else a=0;else if(yn(e)){var u=Ze({},s);u.colorStops=nn(e.colorStops,function(t){return{offset:t.offset,color:to(t.color)}}),yo(e)?a=4:_o(e)&&(a=5),s=u}0===r?this.valType=a:a===this.valType&&6!==a||(o=!0),this.discrete=this.discrete||o;var d={time:t,value:s,rawValue:e,percent:0};return n&&(d.easing=n,d.easingFunc=un(n)?n:pr[n]||Rr(n)),i.push(d),d},t.prototype.prepare=function(t,e){var n=this.keyframes;this._needsSort&&n.sort(function(t,e){return t.time-e.time});for(var i=this.valType,r=n.length,o=n[r-1],a=this.discrete,s=zo(i),l=Oo(i),c=0;c=0&&!(l[n].percent<=e);n--);n=p(n,c-2)}else{for(n=h;ne);n++);n=p(n-1,c-2)}r=l[n+1],i=l[n]}if(i&&r){this._lastFr=n,this._lastFrP=e;var f=r.percent-i.percent,g=0===f?1:p((e-i.percent)/f,1);r.easingFunc&&(g=r.easingFunc(g));var v=o?this._additiveValue:d?No:t[u];if(!zo(s)&&!d||v||(v=this._additiveValue=[]),this.discrete)t[u]=g<1?i.rawValue:r.rawValue;else if(zo(s))1===s?To(v,i[a],r[a],g):function(t,e,n,i){for(var r=e.length,o=r&&e[0].length,a=0;a0&&s.addKeyframe(0,Lo(l),i),this._trackKeys.push(a)}s.addKeyframe(t,Lo(e[a]),i)}return this._maxTime=Math.max(this._maxTime,t),this},t.prototype.pause=function(){this._clip.pause(),this._paused=!0},t.prototype.resume=function(){this._clip.resume(),this._paused=!1},t.prototype.isPaused=function(){return!!this._paused},t.prototype.duration=function(t){return this._maxTime=t,this._force=!0,this},t.prototype._doneCallback=function(){this._setTracksFinished(),this._clip=null;var t=this._doneCbs;if(t)for(var e=t.length,n=0;n0)){this._started=1;for(var e=this,n=[],i=this._maxTime||0,r=0;r1){var a=o.pop();r.addKeyframe(a.time,t[i]),r.prepare(this._maxTime,r.getAdditiveTrack())}}}},t}();function Bo(){return(new Date).getTime()}var Fo,$o,Vo=function(t){function e(e){var n=t.call(this)||this;return n._running=!1,n._time=0,n._pausedTime=0,n._pauseStart=0,n._paused=!1,e=e||{},n.stage=e.stage||{},n}return _(e,t),e.prototype.addClip=function(t){t.animation&&this.removeClip(t),this._head?(this._tail.next=t,t.prev=this._tail,t.next=null,this._tail=t):this._head=this._tail=t,t.animation=this},e.prototype.addAnimator=function(t){t.animation=this;var e=t.getClip();e&&this.addClip(e)},e.prototype.removeClip=function(t){if(t.animation){var e=t.prev,n=t.next;e?e.next=n:this._head=n,n?n.prev=e:this._tail=e,t.next=t.prev=t.animation=null}},e.prototype.removeAnimator=function(t){var e=t.getClip();e&&this.removeClip(e),t.animation=null},e.prototype.update=function(t){for(var e=Bo()-this._pausedTime,n=e-this._time,i=this._head;i;){var r=i.next;i.step(e,n)?(i.ondestroy(),this.removeClip(i),i=r):i=r}this._time=e,t||(this.trigger("frame",n),this.stage.update&&this.stage.update())},e.prototype._startLoop=function(){var t=this;this._running=!0,dr(function e(){t._running&&(dr(e),!t._paused&&t.update())})},e.prototype.start=function(){this._running||(this._time=Bo(),this._pausedTime=0,this._startLoop())},e.prototype.stop=function(){this._running=!1},e.prototype.pause=function(){this._paused||(this._pauseStart=Bo(),this._paused=!0)},e.prototype.resume=function(){this._paused&&(this._pausedTime+=Bo()-this._pauseStart,this._paused=!1)},e.prototype.clear=function(){for(var t=this._head;t;){var e=t.next;t.prev=t.next=t.animation=null,t=e}this._head=this._tail=null},e.prototype.isFinished=function(){return null==this._head},e.prototype.animate=function(t,e){e=e||{},this.start();var n=new Ho(t,e.loop);return this.addAnimator(n),n},e}(Qn),Wo=Te.domSupported,Uo=($o={pointerdown:1,pointerup:1,pointermove:1,pointerout:1},{mouse:Fo=["click","dblclick","mousewheel","wheel","mouseout","mouseup","mousedown","mousemove","contextmenu"],touch:["touchstart","touchend","touchmove"],pointer:nn(Fo,function(t){var e=t.replace("mouse","pointer");return $o.hasOwnProperty(e)?e:t})}),Go=["mousemove","mouseup"],qo=["pointermove","pointerup"],jo=!1;function Xo(t){var e=t.pointerType;return"pen"===e||"touch"===e}function Yo(t){t&&(t.zrByTouch=!0)}function Zo(t,e){for(var n=e,i=!1;n&&9!==n.nodeType&&!(i=n.domBelongToZr||n!==e&&n===t.painterRoot);)n=n.parentNode;return i}var Ko=function(t,e){this.stopPropagation=Nn,this.stopImmediatePropagation=Nn,this.preventDefault=Nn,this.type=e.type,this.target=this.currentTarget=t.dom,this.pointerType=e.pointerType,this.clientX=e.clientX,this.clientY=e.clientY},Qo={mousedown:function(t){t=gi(this.dom,t),this.__mayPointerCapture=[t.zrX,t.zrY],this.trigger("mousedown",t)},mousemove:function(t){t=gi(this.dom,t);var e=this.__mayPointerCapture;!e||t.zrX===e[0]&&t.zrY===e[1]||this.__togglePointerCapture(!0),this.trigger("mousemove",t)},mouseup:function(t){t=gi(this.dom,t),this.__togglePointerCapture(!1),this.trigger("mouseup",t)},mouseout:function(t){Zo(this,(t=gi(this.dom,t)).toElement||t.relatedTarget)||(this.__pointerCapturing&&(t.zrEventControl="no_globalout"),this.trigger("mouseout",t))},wheel:function(t){jo=!0,t=gi(this.dom,t),this.trigger("mousewheel",t)},mousewheel:function(t){jo||(t=gi(this.dom,t),this.trigger("mousewheel",t))},touchstart:function(t){Yo(t=gi(this.dom,t)),this.__lastTouchMoment=new Date,this.handler.processGesture(t,"start"),Qo.mousemove.call(this,t),Qo.mousedown.call(this,t)},touchmove:function(t){Yo(t=gi(this.dom,t)),this.handler.processGesture(t,"change"),Qo.mousemove.call(this,t)},touchend:function(t){Yo(t=gi(this.dom,t)),this.handler.processGesture(t,"end"),Qo.mouseup.call(this,t),+new Date-+this.__lastTouchMoment<300&&Qo.click.call(this,t)},pointerdown:function(t){Qo.mousedown.call(this,t)},pointermove:function(t){Xo(t)||Qo.mousemove.call(this,t)},pointerup:function(t){Qo.mouseup.call(this,t)},pointerout:function(t){Xo(t)||Qo.mouseout.call(this,t)}};en(["click","dblclick","contextmenu"],function(t){Qo[t]=function(e){e=gi(this.dom,e),this.trigger(t,e)}});var Jo={pointermove:function(t){Xo(t)||Jo.mousemove.call(this,t)},pointerup:function(t){Jo.mouseup.call(this,t)},mousemove:function(t){this.trigger("mousemove",t)},mouseup:function(t){var e=this.__pointerCapturing;this.__togglePointerCapture(!1),this.trigger("mouseup",t),e&&(t.zrEventControl="only_globalout",this.trigger("mouseout",t))}};function ta(t,e){var n=e.domHandlers;Te.pointerEventsSupported?en(Uo.pointer,function(i){na(e,i,function(e){n[i].call(t,e)})}):(Te.touchEventsSupported&&en(Uo.touch,function(i){na(e,i,function(r){n[i].call(t,r),function(t){t.touching=!0,null!=t.touchTimer&&(clearTimeout(t.touchTimer),t.touchTimer=null),t.touchTimer=setTimeout(function(){t.touching=!1,t.touchTimer=null},700)}(e)})}),en(Uo.mouse,function(i){na(e,i,function(r){r=fi(r),e.touching||n[i].call(t,r)})}))}function ea(t,e){function n(n){na(e,n,function(i){i=fi(i),Zo(t,i.target)||(i=function(t,e){return gi(t.dom,new Ko(t,e),!0)}(t,i),e.domHandlers[n].call(t,i))},{capture:!0})}Te.pointerEventsSupported?en(qo,n):Te.touchEventsSupported||en(Go,n)}function na(t,e,n,i){t.mounted[e]=n,t.listenerOpts[e]=i,function(t,e,n,i){t.addEventListener(e,n,i)}(t.domTarget,e,n,i)}function ia(t){var e=t.mounted;for(var n in e)e.hasOwnProperty(n)&&vi(t.domTarget,n,e[n],t.listenerOpts[n]);t.mounted={}}var ra=function(t,e){this.mounted={},this.listenerOpts={},this.touching=!1,this.domTarget=t,this.domHandlers=e},oa=function(t){function e(e,n){var i=t.call(this)||this;return i.__pointerCapturing=!1,i.dom=e,i.painterRoot=n,i._localHandlerScope=new ra(e,Qo),Wo&&(i._globalHandlerScope=new ra(document,Jo)),ta(i,i._localHandlerScope),i}return _(e,t),e.prototype.dispose=function(){ia(this._localHandlerScope),Wo&&ia(this._globalHandlerScope)},e.prototype.setCursor=function(t){this.dom.style&&(this.dom.style.cursor=t||"default")},e.prototype.__togglePointerCapture=function(t){if(this.__mayPointerCapture=null,Wo&&+this.__pointerCapturing^+t){this.__pointerCapturing=t;var e=this._globalHandlerScope;t?ea(this,e):ia(e)}},e}(Qn),aa=1;Te.hasGlobalWindow&&(aa=Math.max(window.devicePixelRatio||window.screen&&window.screen.deviceXDPI/window.screen.logicalXDPI||1,1));var sa=aa,la="#333",ca="#ccc",ua=xi,da=5e-5;function ha(t){return t>da||t<-5e-5}var pa,fa=[],ga=[],va=[1,0,0,1,0,0],ma=Math.abs,ya=function(){function t(){}var e;return t.prototype.getLocalTransform=function(e){return t.getLocalTransform(this,e)},t.prototype.setPosition=function(t){this.x=t[0],this.y=t[1]},t.prototype.setScale=function(t){this.scaleX=t[0],this.scaleY=t[1]},t.prototype.setSkew=function(t){this.skewX=t[0],this.skewY=t[1]},t.prototype.setOrigin=function(t){this.originX=t[0],this.originY=t[1]},t.prototype.needLocalTransform=function(){return ha(this.rotation)||ha(this.x)||ha(this.y)||ha(this.scaleX-1)||ha(this.scaleY-1)||ha(this.skewX)||ha(this.skewY)},t.prototype.updateTransform=function(){var t=this.parent&&this.parent.transform,e=this.needLocalTransform(),n=this.transform;e||t?(n=n||[1,0,0,1,0,0],e?this.getLocalTransform(n):ua(n),t&&(e?Si(n,t,n):wi(n,t)),this.transform=n,this._resolveGlobalScaleRatio(n)):n&&(ua(n),this.invTransform=null)},t.prototype._resolveGlobalScaleRatio=function(t){var e=this.globalScaleRatio;if(null!=e&&1!==e){this.getGlobalScale(fa);var n=fa[0]<0?-1:1,i=fa[1]<0?-1:1,r=((fa[0]-n)*e+n)/fa[0]||0,o=((fa[1]-i)*e+i)/fa[1]||0;t[0]*=r,t[1]*=r,t[2]*=o,t[3]*=o}this.invTransform=this.invTransform||[1,0,0,1,0,0],ki(this.invTransform,t)},t.prototype.getComputedTransform=function(){for(var t=this,e=[];t;)e.push(t),t=t.parent;for(;t=e.pop();)t.updateTransform();return this.transform},t.prototype.setLocalTransform=function(t){if(t){var e=t[0]*t[0]+t[1]*t[1],n=t[2]*t[2]+t[3]*t[3],i=Math.atan2(t[1],t[0]),r=Math.PI/2+i-Math.atan2(t[3],t[2]);n=Math.sqrt(n)*Math.cos(r),e=Math.sqrt(e),this.skewX=r,this.skewY=0,this.rotation=-i,this.x=+t[4],this.y=+t[5],this.scaleX=e,this.scaleY=n,this.originX=0,this.originY=0}},t.prototype.decomposeTransform=function(){if(this.transform){var t=this.parent,e=this.transform;t&&t.transform&&(t.invTransform=t.invTransform||[1,0,0,1,0,0],Si(ga,t.invTransform,e),e=ga);var n=this.originX,i=this.originY;(n||i)&&(va[4]=n,va[5]=i,Si(ga,e,va),ga[4]-=n,ga[5]-=i,e=ga),this.setLocalTransform(e)}},t.prototype.getGlobalScale=function(t){var e=this.transform;return t=t||[],e?(t[0]=Math.sqrt(e[0]*e[0]+e[1]*e[1]),t[1]=Math.sqrt(e[2]*e[2]+e[3]*e[3]),e[0]<0&&(t[0]=-t[0]),e[3]<0&&(t[1]=-t[1]),t):(t[0]=1,t[1]=1,t)},t.prototype.transformCoordToLocal=function(t,e){var n=[t,e],i=this.invTransform;return i&&jn(n,n,i),n},t.prototype.transformCoordToGlobal=function(t,e){var n=[t,e],i=this.transform;return i&&jn(n,n,i),n},t.prototype.getLineScale=function(){var t=this.transform;return t&&ma(t[0]-1)>1e-10&&ma(t[3]-1)>1e-10?Math.sqrt(ma(t[0]*t[3]-t[2]*t[1])):1},t.prototype.copyTransform=function(t){ba(this,t)},t.getLocalTransform=function(t,e){e=e||[];var n=t.originX||0,i=t.originY||0,r=t.scaleX,o=t.scaleY,a=t.anchorX,s=t.anchorY,l=t.rotation||0,c=t.x,u=t.y,d=t.skewX?Math.tan(t.skewX):0,h=t.skewY?Math.tan(-t.skewY):0;if(n||i||a||s){var p=n+a,f=i+s;e[4]=-p*r-d*f*o,e[5]=-f*o-h*p*r}else e[4]=e[5]=0;return e[0]=r,e[3]=o,e[1]=h*r,e[2]=d*o,l&&Mi(e,e,l),e[4]+=n+c,e[5]+=i+u,e},t.initDefaultProps=((e=t.prototype).scaleX=e.scaleY=e.globalScaleRatio=1,void(e.x=e.y=e.originX=e.originY=e.skewX=e.skewY=e.rotation=e.anchorX=e.anchorY=0)),t}(),_a=["x","y","originX","originY","anchorX","anchorY","rotation","scaleX","scaleY","skewX","skewY"];function ba(t,e){for(var n=0;n<_a.length;n++){var i=_a[n];t[i]=e[i]}}function xa(t){pa||(pa=new $r(100)),t=t||Ae;var e=pa.get(t);return e||(e={font:t,strWidthCache:new $r(500),asciiWidthMap:null,asciiWidthMapTried:!1,stWideCharWidth:Ee.measureText("国",t).width,asciiCharWidth:Ee.measureText("a",t).width},pa.put(t,e)),e}var wa=0,Sa=5;function Ca(t,e){return t.asciiWidthMapTried||(t.asciiWidthMap=function(t){if(!(wa>=Sa)){t=t||Ae;for(var e=[],n=+new Date,i=0;i<=127;i++)e[i]=Ee.measureText(String.fromCharCode(i),t).width;var r=+new Date-n;return r>16?wa=Sa:r>2&&wa++,e}}(t.font),t.asciiWidthMapTried=!0),0<=e&&e<=127?null!=t.asciiWidthMap?t.asciiWidthMap[e]:t.asciiCharWidth:t.stWideCharWidth}function Ma(t,e){var n=t.strWidthCache,i=n.get(e);return null==i&&(i=Ee.measureText(e,t.font).width,n.put(e,i)),i}function ka(t,e,n,i){var r=Ma(xa(e),t),o=Ia(e),a=Da(0,r,n),s=Aa(0,o,i);return new $i(a,s,r,o)}function Ta(t,e,n,i){var r=((t||"")+"").split("\n");if(1===r.length)return ka(r[0],e,n,i);for(var o=new $i(0,0,0,0),a=0;a=0?parseFloat(t)/100*e:parseFloat(t):t}function La(t,e,n){var i=e.position||"inside",r=null!=e.distance?e.distance:5,o=n.height,a=n.width,s=o/2,l=n.x,c=n.y,u="left",d="top";if(i instanceof Array)l+=Pa(i[0],n.width),c+=Pa(i[1],n.height),u=null,d=null;else switch(i){case"left":l-=r,c+=s,u="right",d="middle";break;case"right":l+=r+a,c+=s,d="middle";break;case"top":l+=a/2,c-=r,u="center",d="bottom";break;case"bottom":l+=a/2,c+=o+r,u="center";break;case"inside":l+=a/2,c+=s,u="center",d="middle";break;case"insideLeft":l+=r,c+=s,d="middle";break;case"insideRight":l+=a-r,c+=s,u="right",d="middle";break;case"insideTop":l+=a/2,c+=r,u="center";break;case"insideBottom":l+=a/2,c+=o-r,u="center",d="bottom";break;case"insideTopLeft":l+=r,c+=r;break;case"insideTopRight":l+=a-r,c+=r,u="right";break;case"insideBottomLeft":l+=r,c+=o-r,d="bottom";break;case"insideBottomRight":l+=a-r,c+=o-r,u="right",d="bottom"}return(t=t||{}).x=l,t.y=c,t.align=u,t.verticalAlign=d,t}var Ea="__zr_normal__",Oa=_a.concat(["ignore"]),za=rn(_a,function(t,e){return t[e]=!0,t},{ignore:!1}),Na={},Ra=new $i(0,0,0,0),Ha=[],Ba=function(){function t(t){this.id=qe(),this.animators=[],this.currentStates=[],this.states={},this._init(t)}return t.prototype._init=function(t){this.attr(t)},t.prototype.drift=function(t,e,n){switch(this.draggable){case"horizontal":e=0;break;case"vertical":t=0}var i=this.transform;i||(i=this.transform=[1,0,0,1,0,0]),i[4]+=t,i[5]+=e,this.decomposeTransform(),this.markRedraw()},t.prototype.beforeUpdate=function(){},t.prototype.afterUpdate=function(){},t.prototype.update=function(){this.updateTransform(),this.__dirty&&this.updateInnerText()},t.prototype.updateInnerText=function(t){var e=this._textContent;if(e&&(!e.ignore||t)){this.textConfig||(this.textConfig={});var n=this.textConfig,i=n.local,r=e.innerTransformable,o=void 0,a=void 0,s=!1;r.parent=i?this:null;var l=!1;r.copyTransform(e);var c=null!=n.position,u=n.autoOverflowArea,d=void 0;if((u||c)&&(d=Ra,n.layoutRect?d.copy(n.layoutRect):d.copy(this.getBoundingRect()),i||d.applyTransform(this.transform)),c){this.calculateTextPosition?this.calculateTextPosition(Na,n,d):La(Na,n,d),r.x=Na.x,r.y=Na.y,o=Na.align,a=Na.verticalAlign;var h=n.origin;if(h&&null!=n.rotation){var p=void 0,f=void 0;"center"===h?(p=.5*d.width,f=.5*d.height):(p=Pa(h[0],d.width),f=Pa(h[1],d.height)),l=!0,r.originX=-r.x+p+(i?0:d.x),r.originY=-r.y+f+(i?0:d.y)}}null!=n.rotation&&(r.rotation=n.rotation);var g=n.offset;g&&(r.x+=g[0],r.y+=g[1],l||(r.originX=-g[0],r.originY=-g[1]));var v=this._innerTextDefaultStyle||(this._innerTextDefaultStyle={});if(u){var m=v.overflowRect=v.overflowRect||new $i(0,0,0,0);r.getLocalTransform(Ha),ki(Ha,Ha),$i.copy(m,d),m.applyTransform(Ha)}else v.overflowRect=null;var y=void 0,_=void 0,b=void 0;(null==n.inside?"string"==typeof n.position&&n.position.indexOf("inside")>=0:n.inside)&&this.canBeInsideText()?(y=n.insideFill,_=n.insideStroke,null!=y&&"auto"!==y||(y=this.getInsideTextFill()),null!=_&&"auto"!==_||(_=this.getInsideTextStroke(y),b=!0)):(y=n.outsideFill,_=n.outsideStroke,null!=y&&"auto"!==y||(y=this.getOutsideFill()),null!=_&&"auto"!==_||(_=this.getOutsideStroke(y),b=!0)),(y=y||"#000")===v.fill&&_===v.stroke&&b===v.autoStroke&&o===v.align&&a===v.verticalAlign||(s=!0,v.fill=y,v.stroke=_,v.autoStroke=b,v.align=o,v.verticalAlign=a,e.setDefaultTextStyle(v)),e.__dirty|=1,s&&e.dirtyStyle(!0)}},t.prototype.canBeInsideText=function(){return!0},t.prototype.getInsideTextFill=function(){return"#fff"},t.prototype.getInsideTextStroke=function(t){return"#000"},t.prototype.getOutsideFill=function(){return this.__zr&&this.__zr.isDarkMode()?ca:la},t.prototype.getOutsideStroke=function(t){var e=this.__zr&&this.__zr.getBackgroundColor(),n="string"==typeof e&&to(e);n||(n=[255,255,255,1]);for(var i=n[3],r=this.__zr.isDarkMode(),o=0;o<3;o++)n[o]=n[o]*i+(r?0:255)*(1-i);return n[3]=1,ro(n,"rgba")},t.prototype.traverse=function(t,e){},t.prototype.attrKV=function(t,e){"textConfig"===t?this.setTextConfig(e):"textContent"===t?this.setTextContent(e):"clipPath"===t?this.setClipPath(e):"extra"===t?(this.extra=this.extra||{},Ze(this.extra,e)):this[t]=e},t.prototype.hide=function(){this.ignore=!0,this.markRedraw()},t.prototype.show=function(){this.ignore=!1,this.markRedraw()},t.prototype.attr=function(t,e){if("string"==typeof t)this.attrKV(t,e);else if(fn(t))for(var n=an(t),i=0;i0},t.prototype.getState=function(t){return this.states[t]},t.prototype.ensureState=function(t){var e=this.states;return e[t]||(e[t]={}),e[t]},t.prototype.clearStates=function(t){this.useState(Ea,!1,t)},t.prototype.useState=function(t,e,n,i){var r=t===Ea;if(this.hasState()||!r){var o=this.currentStates,a=this.stateTransition;if(!(Qe(o,t)>=0)||!e&&1!==o.length){var s;if(this.stateProxy&&!r&&(s=this.stateProxy(t)),s||(s=this.states&&this.states[t]),s||r){r||this.saveCurrentToNormalState(s);var l=!!(s&&s.hoverLayer||i);l&&this._toggleHoverLayerFlag(!0),this._applyStateObj(t,s,this._normalState,e,!n&&!this.__inHover&&a&&a.duration>0,a);var c=this._textContent,u=this._textGuide;return c&&c.useState(t,e,n,l),u&&u.useState(t,e,n,l),r?(this.currentStates=[],this._normalState={}):e?this.currentStates.push(t):this.currentStates=[t],this._updateAnimationTargets(),this.markRedraw(),!l&&this.__inHover&&(this._toggleHoverLayerFlag(!1),this.__dirty&=-2),s}je("State "+t+" not exists.")}}},t.prototype.useStates=function(t,e,n){if(t.length){var i=[],r=this.currentStates,o=t.length,a=o===r.length;if(a)for(var s=0;s0,p);var f=this._textContent,g=this._textGuide;f&&f.useStates(t,e,d),g&&g.useStates(t,e,d),this._updateAnimationTargets(),this.currentStates=t.slice(),this.markRedraw(),!d&&this.__inHover&&(this._toggleHoverLayerFlag(!1),this.__dirty&=-2)}else this.clearStates()},t.prototype.isSilent=function(){for(var t=this;t;){if(t.silent)return!0;var e=t.__hostTarget;t=e?t.ignoreHostSilent?null:e:t.parent}return!1},t.prototype._updateAnimationTargets=function(){for(var t=0;t=0){var n=this.currentStates.slice();n.splice(e,1),this.useStates(n)}},t.prototype.replaceState=function(t,e,n){var i=this.currentStates.slice(),r=Qe(i,t),o=Qe(i,e)>=0;r>=0?o?i.splice(r,1):i[r]=e:n&&!o&&i.push(e),this.useStates(i)},t.prototype.toggleState=function(t,e){e?this.useState(t,!0):this.removeState(t)},t.prototype._mergeStates=function(t){for(var e,n={},i=0;i=0&&e.splice(n,1)}),this.animators.push(t),n&&n.animation.addAnimator(t),n&&n.wakeUp()},t.prototype.updateDuringAnimation=function(t){this.markRedraw()},t.prototype.stopAnimation=function(t,e){for(var n=this.animators,i=n.length,r=[],o=0;o0&&n.during&&o[0].during(function(t,e){n.during(e)});for(var h=0;h0||r.force&&!a.length){var w,S=void 0,C=void 0,M=void 0;if(s){C={},h&&(S={});for(b=0;b<_;b++){C[m=g[b]]=n[m],h?S[m]=i[m]:n[m]=i[m]}}else if(h){M={};for(b=0;b<_;b++){M[m=g[b]]=Lo(n[m]),Va(n,i,m)}}(w=new Ho(n,!1,!1,d?on(f,function(t){return t.targetName===e}):null)).targetName=e,r.scope&&(w.scope=r.scope),h&&S&&w.whenWithKeys(0,S,g),M&&w.whenWithKeys(0,M,g),w.whenWithKeys(null==c?500:c,s?C:i,g).delay(u||0),t.addAnimator(w,e),a.push(w)}}Je(Ba,Qn),Je(Ba,ya);var Ua=function(t){function e(e){var n=t.call(this)||this;return n.isGroup=!0,n._children=[],n.attr(e),n}return _(e,t),e.prototype.childrenRef=function(){return this._children},e.prototype.children=function(){return this._children.slice()},e.prototype.childAt=function(t){return this._children[t]},e.prototype.childOfName=function(t){for(var e=this._children,n=0;n=0&&(n.splice(i,0,t),this._doAdd(t))}return this},e.prototype.replace=function(t,e){var n=Qe(this._children,t);return n>=0&&this.replaceAt(e,n),this},e.prototype.replaceAt=function(t,e){var n=this._children,i=n[e];if(t&&t!==this&&t.parent!==this&&t!==i){n[e]=t,i.parent=null;var r=this.__zr;r&&i.removeSelfFromZr(r),this._doAdd(t)}return this},e.prototype._doAdd=function(t){t.parent&&t.parent.remove(t),t.parent=this;var e=this.__zr;e&&e!==t.__zr&&t.addSelfToZr(e),e&&e.refresh()},e.prototype.remove=function(t){var e=this.__zr,n=this._children,i=Qe(n,t);return i<0||(n.splice(i,1),t.parent=null,e&&t.removeSelfFromZr(e),e&&e.refresh()),this},e.prototype.removeAll=function(){for(var t=this._children,e=this.__zr,n=0;n0&&(this._stillFrameAccum++,this._stillFrameAccum>this._sleepAfterStill&&this.animation.stop())},t.prototype.setSleepAfterStill=function(t){this._sleepAfterStill=t},t.prototype.wakeUp=function(){this._disposed||(this.animation.start(),this._stillFrameAccum=0)},t.prototype.refreshHover=function(){this._needsRefreshHover=!0},t.prototype.refreshHoverImmediately=function(){this._disposed||(this._needsRefreshHover=!1,this.painter.refreshHover&&"canvas"===this.painter.getType()&&this.painter.refreshHover())},t.prototype.resize=function(t){this._disposed||(t=t||{},this.painter.resize(t.width,t.height),this.handler.resize())},t.prototype.clearAnimation=function(){this._disposed||this.animation.clear()},t.prototype.getWidth=function(){if(!this._disposed)return this.painter.getWidth()},t.prototype.getHeight=function(){if(!this._disposed)return this.painter.getHeight()},t.prototype.setCursorStyle=function(t){this._disposed||this.handler.setCursorStyle(t)},t.prototype.findHover=function(t,e){if(!this._disposed)return this.handler.findHover(t,e)},t.prototype.on=function(t,e,n){return this._disposed||this.handler.on(t,e,n),this},t.prototype.off=function(t,e){this._disposed||this.handler.off(t,e)},t.prototype.trigger=function(t,e){this._disposed||this.handler.trigger(t,e)},t.prototype.clear=function(){if(!this._disposed){for(var t=this.storage.getRoots(),e=0;e0){if(t<=r)return a;if(t>=o)return s}else{if(t>=r)return a;if(t<=o)return s}else{if(t===r)return a;if(t===o)return s}return(t-r)/l*c+a}var es=function(t,e,n){switch(t){case"center":case"middle":t="50%";break;case"left":case"top":t="0%";break;case"right":case"bottom":t="100%"}return ns(t,e,n)};function ns(t,e,n){return dn(t)?(i=t,i.replace(/^\s+|\s+$/g,"")).match(/%$/)?parseFloat(t)/100*e+(n||0):parseFloat(t):null==t?NaN:+t;var i}function is(t,e,n){return null==e&&(e=10),e=Math.min(Math.max(0,e),20),t=(+t).toFixed(e),n?t:+t}function rs(t){if(t=+t,isNaN(t))return 0;if(t>1e-14)for(var e=1,n=0;n<15;n++,e*=10)if(Math.round(t*e)/e===t)return n;return function(t){var e=t.toString().toLowerCase(),n=e.indexOf("e"),i=n>0?+e.slice(n+1):0,r=n>0?n:e.length,o=e.indexOf("."),a=o<0?0:r-1-o;return Math.max(0,a-i)}(t)}function os(t,e){var n=Math.max(rs(t),rs(e)),i=t+e;return n>20?i:is(i,n)}function as(t){var e=2*Math.PI;return(t%e+e)%e}function ss(t){return t>-1e-4&&t=10&&e++,e}function ds(t,e){var n=us(t),i=Math.pow(10,n),r=t/i;return t=(r<1.5?1:r<2.5?2:r<4?3:r<7?5:10)*i,n>=-20?+t.toFixed(n<0?-n:0):t}function hs(t){var e=parseFloat(t);return e==t&&(0!==e||!dn(t)||t.indexOf("x")<=0)?e:NaN}function ps(){return Math.round(9*Math.random())}function fs(t,e){return 0===e?t:fs(e,t%e)}function gs(t,e){return null==t?e:null==e?t:t*e/fs(t,e)}var vs="undefined"!=typeof console&&console.warn&&console.log;function ms(t,e){!function(t,e){vs&&console[t]("[ECharts] "+e)}("error",t)}function ys(t){throw new Error(t)}function _s(t,e,n){return(e-t)*n+t}var bs="series\0";function xs(t){return t instanceof Array?t:null==t?[]:[t]}function ws(t,e,n){if(t){t[e]=t[e]||{},t.emphasis=t.emphasis||{},t.emphasis[e]=t.emphasis[e]||{};for(var i=0,r=n.length;i=0||r&&Qe(r,s)<0)){var l=n.getShallow(s,e);null!=l&&(o[t[a][0]]=l)}}return o}}var Zs=Ys([["fill","color"],["shadowBlur"],["shadowOffsetX"],["shadowOffsetY"],["opacity"],["shadowColor"]]),Ks=function(){function t(){}return t.prototype.getAreaStyle=function(t,e){return Zs(this,t,e)},t}(),Qs=new $r(50);function Js(t){if("string"==typeof t){var e=Qs.get(t);return e&&e.image}return t}function tl(t,e,n,i,r){if(t){if("string"==typeof t){if(e&&e.__zrImageSrc===t||!n)return e;var o=Qs.get(t),a={hostEl:n,cb:i,cbPayload:r};return o?!nl(e=o.image)&&o.pending.push(a):((e=Ee.loadImage(t,el,el)).__zrImageSrc=t,Qs.put(t,e.__cachedImgObj={image:e,pending:[a]})),e}return t}return e}function el(){var t=this.__cachedImgObj;this.onload=this.onerror=this.__cachedImgObj=null;for(var e=0;e=s;c++)l-=s;var u=Ma(a,n);return u>l&&(n="",u=0),l=t-u,r.ellipsis=n,r.ellipsisWidth=u,r.contentWidth=l,r.containerWidth=t,r}function al(t,e,n){var i=n.containerWidth,r=n.contentWidth,o=n.fontMeasureInfo;if(!i)return t.textLine="",void(t.isTruncated=!1);var a=Ma(o,e);if(a<=i)return t.textLine=e,void(t.isTruncated=!1);for(var s=0;;s++){if(a<=r||s>=n.maxIterations){e+=n.ellipsis;break}var l=0===s?sl(e,r,o):a>0?Math.floor(e.length*r/a):0;a=Ma(o,e=e.substr(0,l))}""===e&&(e=n.placeholder),t.textLine=e,t.isTruncated=!0}function sl(t,e,n){for(var i=0,r=0,o=t.length;r0&&f+i.accumWidth>i.width&&(o=e.split("\n"),d=!0),i.accumWidth=f}else{var g=fl(e,u,i.width,i.breakAll,i.accumWidth);i.accumWidth=g.accumWidth+p,a=g.linesWidths,o=g.lines}}o||(o=e.split("\n"));for(var v=xa(u),m=0;m=32&&e<=591||e>=880&&e<=4351||e>=4608&&e<=5119||e>=7680&&e<=8303}(t)||!!hl[t]}function fl(t,e,n,i,r){for(var o=[],a=[],s="",l="",c=0,u=0,d=xa(e),h=0;hn:r+u+f>n)?u?(s||l)&&(g?(s||(s=l,l="",u=c=0),o.push(s),a.push(u-c),l+=p,s="",u=c+=f):(l&&(s+=l,l="",c=0),o.push(s),a.push(u),s=p,u=f)):g?(o.push(l),a.push(c),l=p,c=f):(o.push(p),a.push(f)):(u+=f,g?(l+=p,c+=f):(l&&(s+=l,l="",c=0),s+=p))}else l&&(s+=l,u+=c),o.push(s),a.push(u),s="",l="",c=0,u=0}return l&&(s+=l),s&&(o.push(s),a.push(u)),1===o.length&&(u+=r),{accumWidth:u,lines:o,linesWidths:a}}function gl(t,e,n,i,r,o){if(t.baseX=n,t.baseY=i,t.outerWidth=t.outerHeight=null,e){var a=2*e.width,s=2*e.height;$i.set(vl,Aa(n,a,r),Da(i,s,o),a,s),$i.intersect(e,vl,null,ml);var l=ml.outIntersectRect;t.outerWidth=l.width,t.outerHeight=l.height,t.baseX=Aa(l.x,l.width,r,!0),t.baseY=Da(l.y,l.height,o,!0)}}var vl=new $i(0,0,0,0),ml={outIntersectRect:{},clamp:!0};function yl(t){return null!=t?t+="":t=""}function _l(t,e,n,i){var r=new $i(Aa(t.x||0,e,t.textAlign),Da(t.y||0,n,t.textBaseline),e,n),o=null!=i?i:bl(t)?t.lineWidth:0;return o>0&&(r.x-=o/2,r.y-=o/2,r.width+=o,r.height+=o),r}function bl(t){var e=t.stroke;return null!=e&&"none"!==e&&t.lineWidth>0}var xl="__zr_style_"+Math.round(10*Math.random()),wl={shadowBlur:0,shadowOffsetX:0,shadowOffsetY:0,shadowColor:"#000",opacity:1,blend:"source-over"},Sl={style:{shadowBlur:!0,shadowOffsetX:!0,shadowOffsetY:!0,shadowColor:!0,opacity:!0}};wl[xl]=!0;var Cl=["z","z2","invisible"],Ml=["invisible"],kl=function(t){function e(e){return t.call(this,e)||this}var n;return _(e,t),e.prototype._init=function(e){for(var n=an(e),i=0;i1e-4)return s[0]=t-n,s[1]=e-i,l[0]=t+n,void(l[1]=e+i);if(Ol[0]=Ll(r)*n+t,Ol[1]=Pl(r)*i+e,zl[0]=Ll(o)*n+t,zl[1]=Pl(o)*i+e,c(s,Ol,zl),u(l,Ol,zl),(r%=El)<0&&(r+=El),(o%=El)<0&&(o+=El),r>o&&!a?o+=El:rr&&(Nl[0]=Ll(p)*n+t,Nl[1]=Pl(p)*i+e,c(s,Nl,s),u(l,Nl,l))}var Wl={M:1,L:2,C:3,Q:4,A:5,Z:6,R:7},Ul=[],Gl=[],ql=[],jl=[],Xl=[],Yl=[],Zl=Math.min,Kl=Math.max,Ql=Math.cos,Jl=Math.sin,tc=Math.abs,ec=Math.PI,nc=2*ec,ic="undefined"!=typeof Float32Array,rc=[];function oc(t){return Math.round(t/ec*1e8)/1e8%2*ec}var ac=function(){function t(t){this.dpr=1,this._xi=0,this._yi=0,this._x0=0,this._y0=0,this._len=0,t&&(this._saveData=!1),this._saveData&&(this.data=[])}var e;return t.prototype.increaseVersion=function(){this._version++},t.prototype.getVersion=function(){return this._version},t.prototype.setScale=function(t,e,n){(n=n||0)>0&&(this._ux=tc(n/sa/t)||0,this._uy=tc(n/sa/e)||0)},t.prototype.setDPR=function(t){this.dpr=t},t.prototype.setContext=function(t){this._ctx=t},t.prototype.getContext=function(){return this._ctx},t.prototype.beginPath=function(){return this._ctx&&this._ctx.beginPath(),this.reset(),this},t.prototype.reset=function(){this._saveData&&(this._len=0),this._pathSegLen&&(this._pathSegLen=null,this._pathLen=0),this._version++},t.prototype.moveTo=function(t,e){return this._drawPendingPt(),this.addData(Wl.M,t,e),this._ctx&&this._ctx.moveTo(t,e),this._x0=t,this._y0=e,this._xi=t,this._yi=e,this},t.prototype.lineTo=function(t,e){var n=tc(t-this._xi),i=tc(e-this._yi),r=n>this._ux||i>this._uy;if(this.addData(Wl.L,t,e),this._ctx&&r&&this._ctx.lineTo(t,e),r)this._xi=t,this._yi=e,this._pendingPtDist=0;else{var o=n*n+i*i;o>this._pendingPtDist&&(this._pendingPtX=t,this._pendingPtY=e,this._pendingPtDist=o)}return this},t.prototype.bezierCurveTo=function(t,e,n,i,r,o){return this._drawPendingPt(),this.addData(Wl.C,t,e,n,i,r,o),this._ctx&&this._ctx.bezierCurveTo(t,e,n,i,r,o),this._xi=r,this._yi=o,this},t.prototype.quadraticCurveTo=function(t,e,n,i){return this._drawPendingPt(),this.addData(Wl.Q,t,e,n,i),this._ctx&&this._ctx.quadraticCurveTo(t,e,n,i),this._xi=n,this._yi=i,this},t.prototype.arc=function(t,e,n,i,r,o){this._drawPendingPt(),rc[0]=i,rc[1]=r,function(t,e){var n=oc(t[0]);n<0&&(n+=nc);var i=n-t[0],r=t[1];r+=i,!e&&r-n>=nc?r=n+nc:e&&n-r>=nc?r=n-nc:!e&&n>r?r=n+(nc-oc(n-r)):e&&n0&&o))for(var a=0;ac.length&&(this._expandData(),c=this.data);for(var u=0;u0&&(this._ctx&&this._ctx.lineTo(this._pendingPtX,this._pendingPtY),this._pendingPtDist=0)},t.prototype._expandData=function(){if(!(this.data instanceof Array)){for(var t=[],e=0;e11&&(this.data=new Float32Array(t)))}},t.prototype.getBoundingRect=function(){ql[0]=ql[1]=Xl[0]=Xl[1]=Number.MAX_VALUE,jl[0]=jl[1]=Yl[0]=Yl[1]=-Number.MAX_VALUE;var t,e=this.data,n=0,i=0,r=0,o=0;for(t=0;tn||tc(v)>i||d===e-1)&&(f=Math.sqrt(D*D+v*v),r=g,o=_);break;case Wl.C:var m=t[d++],y=t[d++],_=(g=t[d++],t[d++]),b=t[d++],x=t[d++];f=Ir(r,o,m,y,g,_,b,x,10),r=b,o=x;break;case Wl.Q:f=zr(r,o,m=t[d++],y=t[d++],g=t[d++],_=t[d++],10),r=g,o=_;break;case Wl.A:var w=t[d++],S=t[d++],C=t[d++],M=t[d++],k=t[d++],T=t[d++],A=T+k;d+=1,p&&(a=Ql(k)*C+w,s=Jl(k)*M+S),f=Kl(C,M)*Zl(nc,Math.abs(T)),r=Ql(A)*C+w,o=Jl(A)*M+S;break;case Wl.R:a=r=t[d++],s=o=t[d++],f=2*t[d++]+2*t[d++];break;case Wl.Z:var D=a-r;v=s-o;f=Math.sqrt(D*D+v*v),r=a,o=s}f>=0&&(l[u++]=f,c+=f)}return this._pathLen=c,c},t.prototype.rebuildPath=function(t,e){var n,i,r,o,a,s,l,c,u,d,h=this.data,p=this._ux,f=this._uy,g=this._len,v=e<1,m=0,y=0,_=0;if(!v||(this._pathSegLen||this._calculateLength(),l=this._pathSegLen,c=e*this._pathLen))t:for(var b=0;b0&&(t.lineTo(u,d),_=0),x){case Wl.M:n=r=h[b++],i=o=h[b++],t.moveTo(r,o);break;case Wl.L:a=h[b++],s=h[b++];var S=tc(a-r),C=tc(s-o);if(S>p||C>f){if(v){if(m+(X=l[y++])>c){var M=(c-m)/X;t.lineTo(r*(1-M)+a*M,o*(1-M)+s*M);break t}m+=X}t.lineTo(a,s),r=a,o=s,_=0}else{var k=S*S+C*C;k>_&&(u=a,d=s,_=k)}break;case Wl.C:var T=h[b++],A=h[b++],D=h[b++],I=h[b++],P=h[b++],L=h[b++];if(v){if(m+(X=l[y++])>c){Dr(r,T,D,P,M=(c-m)/X,Ul),Dr(o,A,I,L,M,Gl),t.bezierCurveTo(Ul[1],Gl[1],Ul[2],Gl[2],Ul[3],Gl[3]);break t}m+=X}t.bezierCurveTo(T,A,D,I,P,L),r=P,o=L;break;case Wl.Q:T=h[b++],A=h[b++],D=h[b++],I=h[b++];if(v){if(m+(X=l[y++])>c){Or(r,T,D,M=(c-m)/X,Ul),Or(o,A,I,M,Gl),t.quadraticCurveTo(Ul[1],Gl[1],Ul[2],Gl[2]);break t}m+=X}t.quadraticCurveTo(T,A,D,I),r=D,o=I;break;case Wl.A:var E=h[b++],O=h[b++],z=h[b++],N=h[b++],R=h[b++],H=h[b++],B=h[b++],F=!h[b++],$=z>N?z:N,V=tc(z-N)>.001,W=R+H,U=!1;if(v)m+(X=l[y++])>c&&(W=R+H*(c-m)/X,U=!0),m+=X;if(V&&t.ellipse?t.ellipse(E,O,z,N,B,R,W,F):t.arc(E,O,$,R,W,F),U)break t;w&&(n=Ql(R)*z+E,i=Jl(R)*N+O),r=Ql(W)*z+E,o=Jl(W)*N+O;break;case Wl.R:n=r=h[b],i=o=h[b+1],a=h[b++],s=h[b++];var G=h[b++],q=h[b++];if(v){if(m+(X=l[y++])>c){var j=c-m;t.moveTo(a,s),t.lineTo(a+Zl(j,G),s),(j-=G)>0&&t.lineTo(a+G,s+Zl(j,q)),(j-=q)>0&&t.lineTo(a+Kl(G-j,0),s+q),(j-=G)>0&&t.lineTo(a,s+Kl(q-j,0));break t}m+=X}t.rect(a,s,G,q);break;case Wl.Z:if(v){var X;if(m+(X=l[y++])>c){M=(c-m)/X;t.lineTo(r*(1-M)+n*M,o*(1-M)+i*M);break t}m+=X}t.closePath(),r=n,o=i}}},t.prototype.clone=function(){var e=new t,n=this.data;return e.data=n.slice?n.slice():Array.prototype.slice.call(n),e._len=this._len,e},t.prototype.canSave=function(){return!!this._saveData},t.CMD=Wl,t.initDefaultProps=((e=t.prototype)._saveData=!0,e._ux=0,e._uy=0,e._pendingPtDist=0,void(e._version=0)),t}();function sc(t,e,n,i,r,o,a){if(0===r)return!1;var s=r,l=0;if(a>e+s&&a>i+s||at+s&&o>n+s||oe+d&&u>i+d&&u>o+d&&u>s+d||ut+d&&c>n+d&&c>r+d&&c>a+d||c=0&&pe+c&&l>i+c&&l>o+c||lt+c&&s>n+c&&s>r+c||s=0&&gn||u+cr&&(r+=hc);var h=Math.atan2(l,s);return h<0&&(h+=hc),h>=i&&h<=r||h+hc>=i&&h+hc<=r}function fc(t,e,n,i,r,o){if(o>e&&o>i||or?s:0}var gc=ac.CMD,vc=2*Math.PI;var mc=[-1,-1,-1],yc=[-1,-1];function _c(){var t=yc[0];yc[0]=yc[1],yc[1]=t}function bc(t,e,n,i,r,o,a,s,l,c){if(c>e&&c>i&&c>o&&c>s||c1&&_c(),p=Mr(e,i,o,s,yc[0]),h>1&&(f=Mr(e,i,o,s,yc[1]))),2===h?ve&&s>i&&s>o||s=0&&u<=1&&(r[l++]=u);else{var c=a*a-4*o*s;if(Sr(c))(u=-a/(2*o))>=0&&u<=1&&(r[l++]=u);else if(c>0){var u,d=gr(c),h=(-a-d)/(2*o);(u=(-a+d)/(2*o))>=0&&u<=1&&(r[l++]=u),h>=0&&h<=1&&(r[l++]=h)}}return l}(e,i,o,s,mc);if(0===l)return 0;var c=Er(e,i,o);if(c>=0&&c<=1){for(var u=0,d=Pr(e,i,o,c),h=0;hn||s<-n)return 0;var l=Math.sqrt(n*n-s*s);mc[0]=-l,mc[1]=l;var c=Math.abs(i-r);if(c<1e-4)return 0;if(c>=vc-1e-4){i=0,r=vc;var u=o?1:-1;return a>=mc[0]+t&&a<=mc[1]+t?u:0}if(i>r){var d=i;i=r,r=d}i<0&&(i+=vc,r+=vc);for(var h=0,p=0;p<2;p++){var f=mc[p];if(f+t>a){var g=Math.atan2(s,f);u=o?1:-1;g<0&&(g=vc+g),(g>=i&&g<=r||g+vc>=i&&g+vc<=r)&&(g>Math.PI/2&&g<1.5*Math.PI&&(u=-u),h+=u)}}return h}function Sc(t,e,n,i,r){for(var o,a,s=t.data,l=t.len(),c=0,u=0,d=0,h=0,p=0,f=0;f1&&(n||(c+=fc(u,d,h,p,i,r))),v&&(h=u=s[f],p=d=s[f+1]),g){case gc.M:u=h=s[f++],d=p=s[f++];break;case gc.L:if(n){if(sc(u,d,s[f],s[f+1],e,i,r))return!0}else c+=fc(u,d,s[f],s[f+1],i,r)||0;u=s[f++],d=s[f++];break;case gc.C:if(n){if(lc(u,d,s[f++],s[f++],s[f++],s[f++],s[f],s[f+1],e,i,r))return!0}else c+=bc(u,d,s[f++],s[f++],s[f++],s[f++],s[f],s[f+1],i,r)||0;u=s[f++],d=s[f++];break;case gc.Q:if(n){if(cc(u,d,s[f++],s[f++],s[f],s[f+1],e,i,r))return!0}else c+=xc(u,d,s[f++],s[f++],s[f],s[f+1],i,r)||0;u=s[f++],d=s[f++];break;case gc.A:var m=s[f++],y=s[f++],_=s[f++],b=s[f++],x=s[f++],w=s[f++];f+=1;var S=!!(1-s[f++]);o=Math.cos(x)*_+m,a=Math.sin(x)*b+y,v?(h=o,p=a):c+=fc(u,d,o,a,i,r);var C=(i-m)*b/_+m;if(n){if(pc(m,y,b,x,x+w,S,e,C,r))return!0}else c+=wc(m,y,b,x,x+w,S,C,r);u=Math.cos(x+w)*_+m,d=Math.sin(x+w)*b+y;break;case gc.R:if(h=u=s[f++],p=d=s[f++],o=h+s[f++],a=p+s[f++],n){if(sc(h,p,o,p,e,i,r)||sc(o,p,o,a,e,i,r)||sc(o,a,h,a,e,i,r)||sc(h,a,h,p,e,i,r))return!0}else c+=fc(o,p,o,a,i,r),c+=fc(h,a,h,p,i,r);break;case gc.Z:if(n){if(sc(u,d,h,p,e,i,r))return!0}else c+=fc(u,d,h,p,i,r);u=h,d=p}}return n||function(t,e){return Math.abs(t-e)<1e-4}(d,p)||(c+=fc(u,d,h,p,i,r)||0),0!==c}var Cc=Ke({fill:"#000",stroke:null,strokePercent:1,fillOpacity:1,strokeOpacity:1,lineDashOffset:0,lineWidth:1,lineCap:"butt",miterLimit:10,strokeNoScale:!1,strokeFirst:!1},wl),Mc={style:Ke({fill:!0,stroke:!0,strokePercent:!0,fillOpacity:!0,strokeOpacity:!0,lineDashOffset:!0,lineWidth:!0,miterLimit:!0},Sl.style)},kc=_a.concat(["invisible","culling","z","z2","zlevel","parent"]),Tc=function(t){function e(e){return t.call(this,e)||this}var n;return _(e,t),e.prototype.update=function(){var n=this;t.prototype.update.call(this);var i=this.style;if(i.decal){var r=this._decalEl=this._decalEl||new e;r.buildPath===e.prototype.buildPath&&(r.buildPath=function(t){n.buildPath(t,n.shape)}),r.silent=!0;var o=r.style;for(var a in i)o[a]!==i[a]&&(o[a]=i[a]);o.fill=i.fill?i.decal:null,o.decal=null,o.shadowColor=null,i.strokeFirst&&(o.stroke=null);for(var s=0;s.5?la:e>.2?"#eee":ca}if(t)return ca}return la},e.prototype.getInsideTextStroke=function(t){var e=this.style.fill;if(dn(e)){var n=this.__zr;if(!(!n||!n.isDarkMode())===oo(t,0)<.4)return e}},e.prototype.buildPath=function(t,e,n){},e.prototype.pathUpdated=function(){this.__dirty&=-5},e.prototype.getUpdatedPathProxy=function(t){return!this.path&&this.createPathProxy(),this.path.beginPath(),this.buildPath(this.path,this.shape,t),this.path},e.prototype.createPathProxy=function(){this.path=new ac(!1)},e.prototype.hasStroke=function(){var t=this.style,e=t.stroke;return!(null==e||"none"===e||!(t.lineWidth>0))},e.prototype.hasFill=function(){var t=this.style.fill;return null!=t&&"none"!==t},e.prototype.getBoundingRect=function(){var t=this._rect,e=this.style,n=!t;if(n){var i=!1;this.path||(i=!0,this.createPathProxy());var r=this.path;(i||4&this.__dirty)&&(r.beginPath(),this.buildPath(r,this.shape,!1),this.pathUpdated()),t=r.getBoundingRect()}if(this._rect=t,this.hasStroke()&&this.path&&this.path.len()>0){var o=this._rectStroke||(this._rectStroke=t.clone());if(this.__dirty||n){o.copy(t);var a=e.strokeNoScale?this.getLineScale():1,s=e.lineWidth;if(!this.hasFill()){var l=this.strokeContainThreshold;s=Math.max(s,null==l?4:l)}a>1e-10&&(o.width+=s/a,o.height+=s/a,o.x-=s/a/2,o.y-=s/a/2)}return o}return t},e.prototype.contain=function(t,e){var n=this.transformCoordToLocal(t,e),i=this.getBoundingRect(),r=this.style;if(t=n[0],e=n[1],i.contain(t,e)){var o=this.path;if(this.hasStroke()){var a=r.lineWidth,s=r.strokeNoScale?this.getLineScale():1;if(s>1e-10&&(this.hasFill()||(a=Math.max(a,this.strokeContainThreshold)),function(t,e,n,i){return Sc(t,e,!0,n,i)}(o,a/s,t,e)))return!0}if(this.hasFill())return function(t,e,n){return Sc(t,0,!1,e,n)}(o,t,e)}return!1},e.prototype.dirtyShape=function(){this.__dirty|=4,this._rect&&(this._rect=null),this._decalEl&&this._decalEl.dirtyShape(),this.markRedraw()},e.prototype.dirty=function(){this.dirtyStyle(),this.dirtyShape()},e.prototype.animateShape=function(t){return this.animate("shape",t)},e.prototype.updateDuringAnimation=function(t){"style"===t?this.dirtyStyle():"shape"===t?this.dirtyShape():this.markRedraw()},e.prototype.attrKV=function(e,n){"shape"===e?this.setShape(n):t.prototype.attrKV.call(this,e,n)},e.prototype.setShape=function(t,e){var n=this.shape;return n||(n=this.shape={}),"string"==typeof t?n[t]=e:Ze(n,t),this.dirtyShape(),this},e.prototype.shapeChanged=function(){return!!(4&this.__dirty)},e.prototype.createStyle=function(t){return On(Cc,t)},e.prototype._innerSaveToNormal=function(e){t.prototype._innerSaveToNormal.call(this,e);var n=this._normalState;e.shape&&!n.shape&&(n.shape=Ze({},this.shape))},e.prototype._applyStateObj=function(e,n,i,r,o,a){t.prototype._applyStateObj.call(this,e,n,i,r,o,a);var s,l=!(n&&r);if(n&&n.shape?o?r?s=n.shape:(s=Ze({},i.shape),Ze(s,n.shape)):(s=Ze({},r?this.shape:i.shape),Ze(s,n.shape)):l&&(s=i.shape),s)if(o){this.shape=Ze({},this.shape);for(var c={},u=an(s),d=0;dc&&(n*=c/(a=n+i),i*=c/a),r+o>c&&(r*=c/(a=r+o),o*=c/a),i+r>u&&(i*=u/(a=i+r),r*=u/a),n+o>u&&(n*=u/(a=n+o),o*=u/a),t.moveTo(s+n,l),t.lineTo(s+c-i,l),0!==i&&t.arc(s+c-i,l+i,i,-Math.PI/2,0),t.lineTo(s+c,l+u-r),0!==r&&t.arc(s+c-r,l+u-r,r,0,Math.PI/2),t.lineTo(s+o,l+u),0!==o&&t.arc(s+o,l+u-o,o,Math.PI/2,Math.PI),t.lineTo(s,l+n),0!==n&&t.arc(s+n,l+n,n,Math.PI,1.5*Math.PI)}(t,e):t.rect(n,i,r,o)},e.prototype.isZeroArea=function(){return!this.shape.width||!this.shape.height},e}(Tc);Bc.prototype.type="rect";var Fc={fill:"#000"},$c={},Vc={style:Ke({fill:!0,stroke:!0,fillOpacity:!0,strokeOpacity:!0,lineWidth:!0,fontSize:!0,lineHeight:!0,width:!0,height:!0,textShadowColor:!0,textShadowBlur:!0,textShadowOffsetX:!0,textShadowOffsetY:!0,backgroundColor:!0,padding:!0,borderColor:!0,borderWidth:!0,borderRadius:!0},Sl.style)},Wc=function(t){function e(e){var n=t.call(this)||this;return n.type="text",n._children=[],n._defaultStyle=Fc,n.attr(e),n}return _(e,t),e.prototype.childrenRef=function(){return this._children},e.prototype.update=function(){t.prototype.update.call(this),this.styleChanged()&&this._updateSubTexts();for(var e=0;em&&p){var _=Math.floor(m/h);f=f||v.length>_,y=(v=v.slice(0,_)).length*h}if(r&&u&&null!=g)for(var b=ol(g,c,e.ellipsis,{minChar:e.truncateMinChar,placeholder:e.placeholder}),x={},w=0;w0,M=0;Mg&&dl(o,a.substring(g,v),e,f),dl(o,h[2],e,f,h[1]),g=il.lastIndex}gd){var O=o.lines.length;A>0?(M.tokens=M.tokens.slice(0,A),S(M,T,k),o.lines=o.lines.slice(0,C+1)):o.lines=o.lines.slice(0,C),o.isTruncated=o.isTruncated||o.lines.length=0&&"right"===(T=_[k]).align;)this._placeToken(T,t,x,f,M,"right",v),w-=T.width,M-=T.width,k--;for(C+=(s-(C-p)-(g-M)-w)/2;S<=k;)T=_[S],this._placeToken(T,t,x,f,C+T.width/2,"center",v),C+=T.width,S++;f+=x}},e.prototype._placeToken=function(t,e,n,i,r,o,a){var s=e.rich[t.styleName]||{};s.text=t.text;var l=t.verticalAlign,c=i+n/2;"top"===l?c=i+t.height/2:"bottom"===l&&(c=i+n-t.height/2),!t.isLineHolder&&eu(s)&&this._renderBackground(s,e,"right"===o?r-t.width:"center"===o?r-t.width/2:r,c-t.height/2,t.width,t.height);var u=!!s.backgroundColor,d=t.textPadding;d&&(r=Jc(r,o,d),c-=t.height/2-d[0]-t.innerHeight/2);var h=this._getOrCreateChild(Dc),p=h.createStyle();h.useStyle(p);var f=this._defaultStyle,g=!1,v=0,m=!1,y=Qc("fill"in s?s.fill:"fill"in e?e.fill:(g=!0,f.fill)),_=Kc("stroke"in s?s.stroke:"stroke"in e?e.stroke:u||a||f.autoStroke&&!g?null:(v=2,m=!0,f.stroke)),b=s.textShadowBlur>0||e.textShadowBlur>0;p.text=t.text,p.x=r,p.y=c,b&&(p.shadowBlur=s.textShadowBlur||e.textShadowBlur||0,p.shadowColor=s.textShadowColor||e.textShadowColor||"transparent",p.shadowOffsetX=s.textShadowOffsetX||e.textShadowOffsetX||0,p.shadowOffsetY=s.textShadowOffsetY||e.textShadowOffsetY||0),p.textAlign=o,p.textBaseline="middle",p.font=t.font||De,p.opacity=wn(s.opacity,e.opacity,1),Xc(p,s),_&&(p.lineWidth=wn(s.lineWidth,e.lineWidth,v),p.lineDash=xn(s.lineDash,e.lineDash),p.lineDashOffset=e.lineDashOffset||0,p.stroke=_),y&&(p.fill=y),h.setBoundingRect(_l(p,t.contentWidth,t.contentHeight,m?0:null))},e.prototype._renderBackground=function(t,e,n,i,r,o){var a,s,l,c=t.backgroundColor,u=t.borderWidth,d=t.borderColor,h=c&&c.image,p=c&&!h,f=t.borderRadius,g=this;if(p||t.lineHeight||u&&d){(a=this._getOrCreateChild(Bc)).useStyle(a.createStyle()),a.style.fill=null;var v=a.shape;v.x=n,v.y=i,v.width=r,v.height=o,v.r=f,a.dirtyShape()}if(p)(l=a.style).fill=c||null,l.fillOpacity=xn(t.fillOpacity,1);else if(h){(s=this._getOrCreateChild(Lc)).onload=function(){g.dirtyStyle()};var m=s.style;m.image=c.image,m.x=n,m.y=i,m.width=r,m.height=o}u&&d&&((l=a.style).lineWidth=u,l.stroke=d,l.strokeOpacity=xn(t.strokeOpacity,1),l.lineDash=t.borderDash,l.lineDashOffset=t.borderDashOffset||0,a.strokeContainThreshold=0,a.hasFill()&&a.hasStroke()&&(l.strokeFirst=!0,l.lineWidth*=2));var y=(a||s).style;y.shadowBlur=t.shadowBlur||0,y.shadowColor=t.shadowColor||"transparent",y.shadowOffsetX=t.shadowOffsetX||0,y.shadowOffsetY=t.shadowOffsetY||0,y.opacity=wn(t.opacity,e.opacity,1)},e.makeFont=function(t){var e="";return Yc(t)&&(e=[t.fontStyle,t.fontWeight,jc(t.fontSize),t.fontFamily||"sans-serif"].join(" ")),e&&kn(e)||t.textFont||t.font},e}(kl),Uc={left:!0,right:1,center:1},Gc={top:1,bottom:1,middle:1},qc=["fontStyle","fontWeight","fontSize","fontFamily"];function jc(t){return"string"!=typeof t||-1===t.indexOf("px")&&-1===t.indexOf("rem")&&-1===t.indexOf("em")?isNaN(+t)?"12px":t+"px":t}function Xc(t,e){for(var n=0;n=0,o=!1;if(t instanceof Tc){var a=ou(t),s=r&&a.selectFill||a.normalFill,l=r&&a.selectStroke||a.normalStroke;if(gu(s)||gu(l)){var c=(i=i||{}).style||{};"inherit"===c.fill?(o=!0,i=Ze({},i),(c=Ze({},c)).fill=s):!gu(c.fill)&&gu(s)?(o=!0,i=Ze({},i),(c=Ze({},c)).fill=so(s)):!gu(c.stroke)&&gu(l)&&(o||(i=Ze({},i),c=Ze({},c)),c.stroke=so(l)),i.style=c}}if(i&&null==i.z2){o||(i=Ze({},i));var u=t.z2EmphasisLift;i.z2=t.z2+(null!=u?u:10)}return i}(this,0,e,n);if("blur"===t)return function(t,e,n){var i=Qe(t.currentStates,e)>=0,r=t.style.opacity,o=i?null:function(t,e,n,i){for(var r=t.style,o={},a=0;a0){var o={dataIndex:r,seriesIndex:t.seriesIndex};null!=i&&(o.dataType=i),e.push(o)}})}),e}function Vu(t,e,n){ju(t,!0),Cu(t,Tu),function(t,e,n){var i=nu(t);null!=e?(i.focus=e,i.blurScope=n):i.focus&&(i.focus=null)}(t,e,n)}function Wu(t,e,n,i){i?function(t){ju(t,!1)}(t):Vu(t,e,n)}var Uu=["emphasis","blur","select"],Gu={itemStyle:"getItemStyle",lineStyle:"getLineStyle",areaStyle:"getAreaStyle"};function qu(t,e,n,i){n=n||"itemStyle";for(var r=0;r1&&(a*=ed(f),s*=ed(f));var g=(r===o?-1:1)*ed((a*a*(s*s)-a*a*(p*p)-s*s*(h*h))/(a*a*(p*p)+s*s*(h*h)))||0,v=g*a*p/s,m=g*-s*h/a,y=(t+n)/2+id(d)*v-nd(d)*m,_=(e+i)/2+nd(d)*v+id(d)*m,b=sd([1,0],[(h-v)/a,(p-m)/s]),x=[(h-v)/a,(p-m)/s],w=[(-1*h-v)/a,(-1*p-m)/s],S=sd(x,w);if(ad(x,w)<=-1&&(S=rd),ad(x,w)>=1&&(S=0),S<0){var C=Math.round(S/rd*1e6)/1e6;S=2*rd+C%2*rd}u.addData(c,y,_,a,s,b,S,d,o)}var cd=/([mlvhzcqtsa])([^mlvhzcqtsa]*)/gi,ud=/-?([0-9]*\.)?[0-9]+([eE]-?[0-9]+)?/g;var dd=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return _(e,t),e.prototype.applyTransform=function(t){},e}(Tc);function hd(t){return null!=t.setData}function pd(t,e){var n=function(t){var e=new ac;if(!t)return e;var n,i=0,r=0,o=i,a=r,s=ac.CMD,l=t.match(cd);if(!l)return e;for(var c=0;cI*I+P*P&&(C=k,M=T),{cx:C,cy:M,x0:-u,y0:-d,x1:C*(r/x-1),y1:M*(r/x-1)}}function Id(t,e){var n,i=kd(e.r,0),r=kd(e.r0||0,0),o=i>0;if(o||r>0){if(o||(i=r,r=0),r>i){var a=i;i=r,r=a}var s=e.startAngle,l=e.endAngle;if(!isNaN(s)&&!isNaN(l)){var c=e.cx,u=e.cy,d=!!e.clockwise,h=Cd(l-s),p=h>_d&&h%_d;if(p>Ad&&(h=p),i>Ad)if(h>_d-Ad)t.moveTo(c+i*xd(s),u+i*bd(s)),t.arc(c,u,i,s,l,!d),r>Ad&&(t.moveTo(c+r*xd(l),u+r*bd(l)),t.arc(c,u,r,l,s,d));else{var f=void 0,g=void 0,v=void 0,m=void 0,y=void 0,_=void 0,b=void 0,x=void 0,w=void 0,S=void 0,C=void 0,M=void 0,k=void 0,T=void 0,A=void 0,D=void 0,I=i*xd(s),P=i*bd(s),L=r*xd(l),E=r*bd(l),O=h>Ad;if(O){var z=e.cornerRadius;z&&(n=function(t){var e;if(cn(t)){var n=t.length;if(!n)return t;e=1===n?[t[0],t[0],0,0]:2===n?[t[0],t[0],t[1],t[1]]:3===n?t.concat(t[2]):t}else e=[t,t,t,t];return e}(z),f=n[0],g=n[1],v=n[2],m=n[3]);var N=Cd(i-r)/2;if(y=Td(N,v),_=Td(N,m),b=Td(N,f),x=Td(N,g),C=w=kd(y,_),M=S=kd(b,x),(w>Ad||S>Ad)&&(k=i*xd(l),T=i*bd(l),A=r*xd(s),D=r*bd(s),hAd){var U=Td(v,C),G=Td(m,C),q=Dd(A,D,I,P,i,U,d),j=Dd(k,T,L,E,i,G,d);t.moveTo(c+q.cx+q.x0,u+q.cy+q.y0),C0&&t.arc(c+q.cx,u+q.cy,U,Sd(q.y0,q.x0),Sd(q.y1,q.x1),!d),t.arc(c,u,i,Sd(q.cy+q.y1,q.cx+q.x1),Sd(j.cy+j.y1,j.cx+j.x1),!d),G>0&&t.arc(c+j.cx,u+j.cy,G,Sd(j.y1,j.x1),Sd(j.y0,j.x0),!d))}else t.moveTo(c+I,u+P),t.arc(c,u,i,s,l,!d);else t.moveTo(c+I,u+P);if(r>Ad&&O)if(M>Ad){U=Td(f,M),q=Dd(L,E,k,T,r,-(G=Td(g,M)),d),j=Dd(I,P,A,D,r,-U,d);t.lineTo(c+q.cx+q.x0,u+q.cy+q.y0),M0&&t.arc(c+q.cx,u+q.cy,G,Sd(q.y0,q.x0),Sd(q.y1,q.x1),!d),t.arc(c,u,r,Sd(q.cy+q.y1,q.cx+q.x1),Sd(j.cy+j.y1,j.cx+j.x1),d),U>0&&t.arc(c+j.cx,u+j.cy,U,Sd(j.y1,j.x1),Sd(j.y0,j.x0),!d))}else t.lineTo(c+L,u+E),t.arc(c,u,r,l,s,d);else t.lineTo(c+L,u+E)}else t.moveTo(c,u);t.closePath()}}}var Pd=function(){this.cx=0,this.cy=0,this.r0=0,this.r=0,this.startAngle=0,this.endAngle=2*Math.PI,this.clockwise=!0,this.cornerRadius=0},Ld=function(t){function e(e){return t.call(this,e)||this}return _(e,t),e.prototype.getDefaultShape=function(){return new Pd},e.prototype.buildPath=function(t,e){Id(t,e)},e.prototype.isZeroArea=function(){return this.shape.startAngle===this.shape.endAngle||this.shape.r===this.shape.r0},e}(Tc);Ld.prototype.type="sector";var Ed=function(){this.cx=0,this.cy=0,this.r=0,this.r0=0},Od=function(t){function e(e){return t.call(this,e)||this}return _(e,t),e.prototype.getDefaultShape=function(){return new Ed},e.prototype.buildPath=function(t,e){var n=e.cx,i=e.cy,r=2*Math.PI;t.moveTo(n+e.r,i),t.arc(n,i,e.r,0,r,!1),t.moveTo(n+e.r0,i),t.arc(n,i,e.r0,0,r,!0)},e}(Tc);function zd(t,e,n){var i=e.smooth,r=e.points;if(r&&r.length>=2){if(i){var o=function(t,e,n,i){var r,o,a,s,l=[],c=[],u=[],d=[];if(i){a=[1/0,1/0],s=[-1/0,-1/0];for(var h=0,p=t.length;hih[1]){if(r=!1,rh.negativeSize||n)return r;var s=eh(ih[0]-nh[1]),l=eh(nh[0]-ih[1]);Jd(s,l)>ah.len()&&(s=l||!rh.bidirectional)&&(Ti.scale(oh,a,-l*i),rh.useDir&&rh.calcDirMTV()))}}return r},t.prototype._getProjMinMaxOnAxis=function(t,e,n){for(var i=this._axes[t],r=this._origin,o=e[0].dot(i)+r[t],a=o,s=o,l=1;l0){var d={duration:u.duration,delay:u.delay||0,easing:u.easing,done:o,force:!!o||!!a,setToFinal:!c,scope:t,during:a};l?e.animateFrom(n,d):e.animateTo(n,d)}else e.stopAnimation(),!l&&e.attr(n),a&&a(1),o&&o()}function hh(t,e,n,i,r,o){dh("update",t,e,n,i,r,o)}function ph(t,e,n,i,r,o){dh("enter",t,e,n,i,r,o)}function fh(t){if(!t.__zr)return!0;for(var e=0;e=-1e-6)return!1;var f=t-r,g=e-o,v=Oh(f,g,c,u)/p;if(v<0||v>1)return!1;var m=Oh(f,g,d,h)/p;return!(m<0||m>1)}function Oh(t,e,n,i){return t*i-n*e}function zh(t,e,n,i,r){return null==e||(pn(e)?Nh[0]=Nh[1]=Nh[2]=Nh[3]=e:(Nh[0]=e[0],Nh[1]=e[1],Nh[2]=e[2],Nh[3]=e[3]),i&&(Nh[0]=Qa(0,Nh[0]),Nh[1]=Qa(0,Nh[1]),Nh[2]=Qa(0,Nh[2]),Nh[3]=Qa(0,Nh[3])),n&&(Nh[0]=-Nh[0],Nh[1]=-Nh[1],Nh[2]=-Nh[2],Nh[3]=-Nh[3]),Rh(t,Nh,"x","width",3,1,r&&r[0]||0),Rh(t,Nh,"y","height",0,2,r&&r[1]||0)),t}var Nh=[0,0,0,0];function Rh(t,e,n,i,r,o,a){var s=e[o]+e[r],l=t[i];t[i]+=s,a=Qa(0,Ka(a,l)),t[i]=0?-e[r]:e[o]>=0?l+e[o]:Ja(s)>1e-8?(l-a)*e[r]/s:0):t[n]-=e[r]}function Hh(t){var e=t.itemTooltipOption,n=t.componentModel,i=t.itemName,r=dn(e)?{formatter:e}:e,o=n.mainType,a=n.componentIndex,s={componentType:o,name:i,$vars:["name"]};s[o+"Index"]=a;var l=t.formatterParamsExtra;l&&en(an(l),function(t){zn(s,t)||(s[t]=l[t],s.$vars.push(t))});var c=nu(t.el);c.componentMainType=o,c.componentIndex=a,c.tooltipConfig={name:i,option:Ke({content:i,encodeHTMLContent:!0,formatterParams:s},r)}}function Bh(t,e){var n;t.isGroup&&(n=e(t)),n||t.traverse(e)}function Fh(t,e){if(t)if(cn(t))for(var n=0;ne&&(e=i),ie&&(n=e=0),{min:n,max:e}},clipPointsByRect:function(t,e){return nn(t,function(t){var n=t[0];n=Qa(n,e.x),n=Ka(n,e.x+e.width);var i=t[1];return i=Qa(i,e.y),[n,i=Ka(i,e.y+e.height)]})},clipRectByRect:function(t,e){var n=Qa(t.x,e.x),i=Ka(t.x+t.width,e.x+e.width),r=Qa(t.y,e.y),o=Ka(t.y+t.height,e.y+e.height);if(i>=n&&o>=r)return{x:n,y:r,width:i-n,height:o-r}},createIcon:Lh,ensureCopyRect:Wh,ensureCopyTransform:Uh,expandOrShrinkRect:zh,extendPath:function(t,e){return bh(t,e)},extendShape:function(t){return Tc.extend(t)},getShapeClass:function(t){if(mh.hasOwnProperty(t))return mh[t]},getTransform:function(t,e){for(var n=xi([]);t&&t!==e;)Si(n,t.getLocalTransform(),n),t=t.parent;return n},groupTransition:Ph,initProps:ph,isBoundingRectAxisAligned:$h,isElementRemoved:fh,lineLineIntersect:Eh,linePolygonIntersect:function(t,e,n,i,r){for(var o=0,a=r[r.length-1];oJa(o[1])?o[0]>0?"right":"left":o[1]>0?"bottom":"top"},traverseElements:Fh,traverseUpdateZ:qh,updateProps:hh}),Yh={};function Zh(t,e,n){var i,r=t.labelFetcher,o=t.labelDataIndex,a=t.labelDimIndex,s=e.normal;r&&(i=r.getFormattedLabel(o,"normal",null,a,s&&s.get("formatter"),null!=n?{interpolatedValue:n}:null)),null==i&&(i=un(t.defaultText)?t.defaultText(o,t,n):t.defaultText);for(var l={normal:i},c=0;c-1?wp:Cp;function Ap(t,e){t=t.toUpperCase(),kp[t]=new _p(e),Mp[t]=e}Ap(Sp,{time:{month:["January","February","March","April","May","June","July","August","September","October","November","December"],monthAbbr:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],dayOfWeek:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],dayOfWeekAbbr:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"]},legend:{selector:{all:"All",inverse:"Inv"}},toolbox:{brush:{title:{rect:"Box Select",polygon:"Lasso Select",lineX:"Horizontally Select",lineY:"Vertically Select",keep:"Keep Selections",clear:"Clear Selections"}},dataView:{title:"Data View",lang:["Data View","Close","Refresh"]},dataZoom:{title:{zoom:"Zoom",back:"Zoom Reset"}},magicType:{title:{line:"Switch to Line Chart",bar:"Switch to Bar Chart",stack:"Stack",tiled:"Tile"}},restore:{title:"Restore"},saveAsImage:{title:"Save as Image",lang:["Right Click to Save Image"]}},series:{typeNames:{pie:"Pie chart",bar:"Bar chart",line:"Line chart",scatter:"Scatter plot",effectScatter:"Ripple scatter plot",radar:"Radar chart",tree:"Tree",treemap:"Treemap",boxplot:"Boxplot",candlestick:"Candlestick",k:"K line chart",heatmap:"Heat map",map:"Map",parallel:"Parallel coordinate map",lines:"Line graph",graph:"Relationship graph",sankey:"Sankey diagram",funnel:"Funnel chart",gauge:"Gauge",pictorialBar:"Pictorial bar",themeRiver:"Theme River Map",sunburst:"Sunburst",custom:"Custom chart",chart:"Chart"}},aria:{general:{withTitle:'This is a chart about "{title}"',withoutTitle:"This is a chart"},series:{single:{prefix:"",withName:" with type {seriesType} named {seriesName}.",withoutName:" with type {seriesType}."},multiple:{prefix:". It consists of {seriesCount} series count.",withName:" The {seriesId} series is a {seriesType} representing {seriesName}.",withoutName:" The {seriesId} series is a {seriesType}.",separator:{middle:"",end:""}}},data:{allData:"The data is as follows: ",partialData:"The first {displayCnt} items are: ",withName:"the data for {name} is {value}",withoutName:"{value}",separator:{middle:", ",end:". "}}}}),Ap(wp,{time:{month:["一月","二月","三月","四月","五月","六月","七月","八月","九月","十月","十一月","十二月"],monthAbbr:["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"],dayOfWeek:["星期日","星期一","星期二","星期三","星期四","星期五","星期六"],dayOfWeekAbbr:["日","一","二","三","四","五","六"]},legend:{selector:{all:"全选",inverse:"反选"}},toolbox:{brush:{title:{rect:"矩形选择",polygon:"圈选",lineX:"横向选择",lineY:"纵向选择",keep:"保持选择",clear:"清除选择"}},dataView:{title:"数据视图",lang:["数据视图","关闭","刷新"]},dataZoom:{title:{zoom:"区域缩放",back:"区域缩放还原"}},magicType:{title:{line:"切换为折线图",bar:"切换为柱状图",stack:"切换为堆叠",tiled:"切换为平铺"}},restore:{title:"还原"},saveAsImage:{title:"保存为图片",lang:["右键另存为图片"]}},series:{typeNames:{pie:"饼图",bar:"柱状图",line:"折线图",scatter:"散点图",effectScatter:"涟漪散点图",radar:"雷达图",tree:"树图",treemap:"矩形树图",boxplot:"箱型图",candlestick:"K线图",k:"K线图",heatmap:"热力图",map:"地图",parallel:"平行坐标图",lines:"线图",graph:"关系图",sankey:"桑基图",funnel:"漏斗图",gauge:"仪表盘图",pictorialBar:"象形柱图",themeRiver:"主题河流图",sunburst:"旭日图",custom:"自定义图表",chart:"图表"}},aria:{general:{withTitle:"这是一个关于“{title}”的图表。",withoutTitle:"这是一个图表,"},series:{single:{prefix:"",withName:"图表类型是{seriesType},表示{seriesName}。",withoutName:"图表类型是{seriesType}。"},multiple:{prefix:"它由{seriesCount}个图表系列组成。",withName:"第{seriesId}个系列是一个表示{seriesName}的{seriesType},",withoutName:"第{seriesId}个系列是一个{seriesType},",separator:{middle:";",end:"。"}}},data:{allData:"其数据是——",partialData:"其中,前{displayCnt}项是——",withName:"{name}的数据是{value}",withoutName:"{value}",separator:{middle:",",end:""}}}});function Dp(){return null}var Ip=1e3,Pp=6e4,Lp=36e5,Ep=864e5,Op=31536e6,zp={year:/({yyyy}|{yy})/,month:/({MMMM}|{MMM}|{MM}|{M})/,day:/({dd}|{d})/,hour:/({HH}|{H}|{hh}|{h})/,minute:/({mm}|{m})/,second:/({ss}|{s})/,millisecond:/({SSS}|{S})/},Np={year:"{yyyy}",month:"{MMM}",day:"{d}",hour:"{HH}:{mm}",minute:"{HH}:{mm}",second:"{HH}:{mm}:{ss}",millisecond:"{HH}:{mm}:{ss} {SSS}"},Rp="{yyyy}-{MM}-{dd}",Hp={year:"{yyyy}",month:"{yyyy}-{MM}",day:Rp,hour:Rp+" "+Np.hour,minute:Rp+" "+Np.minute,second:Rp+" "+Np.second,millisecond:"{yyyy}-{MM}-{dd} {HH}:{mm}:{ss} {SSS}"},Bp=["year","month","day","hour","minute","second","millisecond"],Fp=["year","half-year","quarter","month","week","half-week","day","half-day","quarter-day","hour","minute","second","millisecond"];function $p(t){return dn(t)||un(t)?t:function(t){t=t||{};var e={},n=!0;return en(Bp,function(e){n&&(n=null==t[e])}),en(Bp,function(i,r){var o=t[i];e[i]={};for(var a=null,s=r;s>=0;s--){var l=Bp[s],c=fn(o)&&!cn(o)?o[l]:o,u=void 0;cn(c)?a=(u=c.slice())[0]||"":dn(c)?u=[a=c]:(null==a?a=Np[i]:zp[l].test(a)||(a=e[l][l][0]+" "+a),u=[a],n&&(u[1]="{primary|"+a+"}")),e[i][l]=u}}),e}(t)}function Vp(t,e){return"0000".substr(0,e-(t+="").length)+t}function Wp(t){switch(t){case"half-year":case"quarter":return"month";case"week":case"half-week":return"day";case"half-day":case"quarter-day":return"hour";default:return t}}function Up(t){return t===Wp(t)}function Gp(t,e,n,i){var r=cs(t),o=r[Xp(n)](),a=r[Yp(n)]()+1,s=Math.floor((a-1)/3)+1,l=r[Zp(n)](),c=r["get"+(n?"UTC":"")+"Day"](),u=r[Kp(n)](),d=(u-1)%12+1,h=r[Qp(n)](),p=r[Jp(n)](),f=r[tf(n)](),g=u>=12?"pm":"am",v=g.toUpperCase(),m=i instanceof _p?i:function(t){return kp[t]}(i||Tp)||kp[Cp],y=m.getModel("time"),_=y.get("month"),b=y.get("monthAbbr"),x=y.get("dayOfWeek"),w=y.get("dayOfWeekAbbr");return(e||"").replace(/{a}/g,g+"").replace(/{A}/g,v+"").replace(/{yyyy}/g,o+"").replace(/{yy}/g,Vp(o%100+"",2)).replace(/{Q}/g,s+"").replace(/{MMMM}/g,_[a-1]).replace(/{MMM}/g,b[a-1]).replace(/{MM}/g,Vp(a,2)).replace(/{M}/g,a+"").replace(/{dd}/g,Vp(l,2)).replace(/{d}/g,l+"").replace(/{eeee}/g,x[c]).replace(/{ee}/g,w[c]).replace(/{e}/g,c+"").replace(/{HH}/g,Vp(u,2)).replace(/{H}/g,u+"").replace(/{hh}/g,Vp(d+"",2)).replace(/{h}/g,d+"").replace(/{mm}/g,Vp(h,2)).replace(/{m}/g,h+"").replace(/{ss}/g,Vp(p,2)).replace(/{s}/g,p+"").replace(/{SSS}/g,Vp(f,3)).replace(/{S}/g,f+"")}function qp(t,e){var n=cs(t),i=n[Yp(e)]()+1,r=n[Zp(e)](),o=n[Kp(e)](),a=n[Qp(e)](),s=n[Jp(e)](),l=0===n[tf(e)](),c=l&&0===s,u=c&&0===a,d=u&&0===o,h=d&&1===r;return h&&1===i?"year":h?"month":d?"day":u?"hour":c?"minute":l?"second":"millisecond"}function jp(t,e,n){switch(e){case"year":t[nf(n)](0);case"month":t[rf(n)](1);case"day":t[of(n)](0);case"hour":t[af(n)](0);case"minute":t[sf(n)](0);case"second":t[lf(n)](0)}return t}function Xp(t){return t?"getUTCFullYear":"getFullYear"}function Yp(t){return t?"getUTCMonth":"getMonth"}function Zp(t){return t?"getUTCDate":"getDate"}function Kp(t){return t?"getUTCHours":"getHours"}function Qp(t){return t?"getUTCMinutes":"getMinutes"}function Jp(t){return t?"getUTCSeconds":"getSeconds"}function tf(t){return t?"getUTCMilliseconds":"getMilliseconds"}function ef(t){return t?"setUTCFullYear":"setFullYear"}function nf(t){return t?"setUTCMonth":"setMonth"}function rf(t){return t?"setUTCDate":"setDate"}function of(t){return t?"setUTCHours":"setHours"}function af(t){return t?"setUTCMinutes":"setMinutes"}function sf(t){return t?"setUTCSeconds":"setSeconds"}function lf(t){return t?"setUTCMilliseconds":"setMilliseconds"}function cf(t){if(isNaN(hs(t)))return dn(t)?t:"-";var e=(t+"").split(".");return e[0].replace(/(\d{1,3})(?=(?:\d{3})+(?!\d))/g,"$1,")+(e.length>1?"."+e[1]:"")}function uf(t,e){return t=(t||"").toLowerCase().replace(/-(.)/g,function(t,e){return e.toUpperCase()}),e&&t&&(t=t.charAt(0).toUpperCase()+t.slice(1)),t}var df=Cn;function hf(t,e,n){function i(t){return t&&kn(t)?t:"-"}function r(t){return!(null==t||isNaN(t)||!isFinite(t))}var o="time"===e,a=t instanceof Date;if(o||a){var s=o?cs(t):t;if(!isNaN(+s))return Gp(s,"{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}",n);if(a)return"-"}if("ordinal"===e)return hn(t)?i(t):pn(t)&&r(t)?t+"":"-";var l=hs(t);return r(l)?cf(l):hn(t)?i(t):"boolean"==typeof t?t+"":"-"}var pf=["a","b","c","d","e","f","g"],ff=function(t,e){return"{"+t+(null==e?"":e)+"}"};function gf(t,e,n){cn(e)||(e=[e]);var i=e.length;if(!i)return"";for(var r=e[0].$vars||[],o=0;oi||l.newline?(o=0,u=g,a+=s+n,s=h.height):s=Math.max(s,h.height)}else{var v=h.height+(f?-f.y+h.y:0);(d=a+v)>r||l.newline?(o+=s+n,a=0,d=v,s=h.width):s=Math.max(s,h.width)}l.newline||(l.x=o,l.y=a,l.markRedraw(),"horizontal"===t?o=u+n:a=d+n)})}function Pf(t,e,n){n=df(n||0);var i=e.width,r=e.height,o=es(t.left,i),a=es(t.top,r),s=es(t.right,i),l=es(t.bottom,r),c=es(t.width,i),u=es(t.height,r),d=n[2]+n[0],h=n[1]+n[3],p=t.aspect;switch(isNaN(c)&&(c=i-s-h-o),isNaN(u)&&(u=r-l-d-a),null!=p&&(isNaN(c)&&isNaN(u)&&(p>i/r?c=.8*i:u=.8*r),isNaN(c)&&(c=p*u),isNaN(u)&&(u=c/p)),isNaN(o)&&(o=i-s-c-h),isNaN(a)&&(a=r-l-u-d),t.left||t.right){case"center":o=i/2-c/2-n[3];break;case"right":o=i-c-h}switch(t.top||t.bottom){case"middle":case"center":a=r/2-u/2-n[0];break;case"bottom":a=r-u-d}o=o||0,a=a||0,isNaN(c)&&(c=i-h-o-(s||0)),isNaN(u)&&(u=r-d-a-(l||0));var f=new $i((e.x||0)+o+n[3],(e.y||0)+a+n[0],c,u);return f.margin=n,f}ln(If,"vertical"),ln(If,"horizontal");var Lf=1;function Ef(t,e,n){var i,r,o,a,s=t.boxCoordinateSystem;if(s){var l=function(t){var e=t.getShallow("coord",!0),n=bf;if(null==e){var i=wf.get(t.type);i&&i.getCoord2&&(n=xf,e=i.getCoord2(t))}return{coord:e,from:n}}(t),c=l.coord,u=l.from;if(s.dataToLayout){o=Lf,a=u;var d=s.dataToLayout(c);i=d.contentRect||d.rect}}return null==o&&(o=Lf),o===Lf&&(i||(i={x:0,y:0,width:e.getWidth(),height:e.getHeight()}),r=[i.x+i.width/2,i.y+i.height/2]),{type:o,refContainer:i,refPoint:r,boxCoordFrom:a}}function Of(t){var e=t.layoutMode||t.constructor.layoutMode;return fn(e)?e:e?{type:e}:null}function zf(t,e,n){var i=n&&n.ignoreSize;!cn(i)&&(i=[i,i]);var r=a(Df[0],0),o=a(Df[1],1);function a(n,r){var o={},a=0,l={},c=0;if(Tf(n,function(e){l[e]=t[e]}),Tf(n,function(t){zn(e,t)&&(o[t]=l[t]=e[t]),s(o,t)&&a++,s(l,t)&&c++}),i[r])return s(e,n[1])?l[n[2]]=null:s(e,n[2])&&(l[n[1]]=null),l;if(2!==c&&a){if(a>=2)return o;for(var u=0;u=0;a--)o=Ye(o,n[a],!0);e.defaultOption=o}return e.defaultOption},e.prototype.getReferringComponents=function(t,e){var n=t+"Index",i=t+"Id";return Hs(this.ecModel,t,{index:this.get(n,!0),id:this.get(i,!0)},e)},e.prototype.getBoxLayoutParams=function(){return e=!1,{left:(t=this).getShallow("left",e),top:t.getShallow("top",e),right:t.getShallow("right",e),bottom:t.getShallow("bottom",e),width:t.getShallow("width",e),height:t.getShallow("height",e)};var t,e},e.prototype.getZLevelKey=function(){return""},e.prototype.setZLevel=function(t){this.option.zlevel=t},e.protoInitialize=((n=e.prototype).type="component",n.id="",n.name="",n.mainType="",n.subType="",void(n.componentIndex=0)),e}(_p);Us(Hf,_p),Xs(Hf),function(t){var e={};t.registerSubTypeDefaulter=function(t,n){var i=Vs(t);e[i.main]=n},t.determineSubType=function(n,i){var r=i.type;if(!r){var o=Vs(n).main;t.hasSubTypes(n)&&e[o]&&(r=e[o](i))}return r}}(Hf),function(t,e){function n(t,e){return t[e]||(t[e]={predecessor:[],successor:[]}),t[e]}t.topologicalTravel=function(t,i,r,o){if(t.length){var a=function(t){var i={},r=[];return en(t,function(o){var a=n(i,o),s=function(t,e){var n=[];return en(t,function(t){Qe(e,t)>=0&&n.push(t)}),n}(a.originalDeps=e(o),t);a.entryCount=s.length,0===a.entryCount&&r.push(o),en(s,function(t){Qe(a.predecessor,t)<0&&a.predecessor.push(t);var e=n(i,t);Qe(e.successor,t)<0&&e.successor.push(o)})}),{graph:i,noEntryList:r}}(i),s=a.graph,l=a.noEntryList,c={};for(en(t,function(t){c[t]=!0});l.length;){var u=l.pop(),d=s[u],h=!!c[u];h&&(r.call(o,u,d.originalDeps.slice()),delete c[u]),en(d.successor,h?f:p)}en(c,function(){throw new Error("")})}function p(t){s[t].entryCount--,0===s[t].entryCount&&l.push(t)}function f(t){c[t]=!0,p(t)}}}(Hf,function(t){var e=[];en(Hf.getClassesByMainType(t),function(t){e=e.concat(t.dependencies||t.prototype.dependencies||[])}),e=nn(e,function(t){return Vs(t).main}),"dataset"!==t&&Qe(e,"dataset")<=0&&e.unshift("dataset");return e});var Bf={color:{},darkColor:{},size:{}},Ff=Bf.color={theme:["#5070dd","#b6d634","#505372","#ff994d","#0ca8df","#ffd10a","#fb628b","#785db0","#3fbe95"],neutral00:"#fff",neutral05:"#f4f7fd",neutral10:"#e8ebf0",neutral15:"#dbdee4",neutral20:"#cfd2d7",neutral25:"#c3c5cb",neutral30:"#b7b9be",neutral35:"#aaacb2",neutral40:"#9ea0a5",neutral45:"#929399",neutral50:"#86878c",neutral55:"#797b7f",neutral60:"#6d6e73",neutral65:"#616266",neutral70:"#54555a",neutral75:"#48494d",neutral80:"#3c3c41",neutral85:"#303034",neutral90:"#232328",neutral95:"#17171b",neutral99:"#000",accent05:"#eff1f9",accent10:"#e0e4f2",accent15:"#d0d6ec",accent20:"#c0c9e6",accent25:"#b1bbdf",accent30:"#a1aed9",accent35:"#91a0d3",accent40:"#8292cc",accent45:"#7285c6",accent50:"#6578ba",accent55:"#5c6da9",accent60:"#536298",accent65:"#4a5787",accent70:"#404c76",accent75:"#374165",accent80:"#2e3654",accent85:"#252b43",accent90:"#1b2032",accent95:"#121521",transparent:"rgba(0,0,0,0)",highlight:"rgba(255,231,130,0.8)"};for(var $f in Ze(Ff,{primary:Ff.neutral80,secondary:Ff.neutral70,tertiary:Ff.neutral60,quaternary:Ff.neutral50,disabled:Ff.neutral20,border:Ff.neutral30,borderTint:Ff.neutral20,borderShade:Ff.neutral40,background:Ff.neutral05,backgroundTint:"rgba(234,237,245,0.5)",backgroundTransparent:"rgba(255,255,255,0)",backgroundShade:Ff.neutral10,shadow:"rgba(0,0,0,0.2)",shadowTint:"rgba(129,130,136,0.2)",axisLine:Ff.neutral70,axisLineTint:Ff.neutral40,axisTick:Ff.neutral70,axisTickMinor:Ff.neutral60,axisLabel:Ff.neutral70,axisSplitLine:Ff.neutral15,axisMinorSplitLine:Ff.neutral05}),Ff)if(Ff.hasOwnProperty($f)){var Vf=Ff[$f];"theme"===$f?Bf.darkColor.theme=Ff.theme.slice():"highlight"===$f?Bf.darkColor.highlight="rgba(255,231,130,0.4)":0===$f.indexOf("accent")?Bf.darkColor[$f]=io(Vf,0,function(t){return.5*t},function(t){return Math.min(1,1.3-t)}):Bf.darkColor[$f]=io(Vf,0,function(t){return.9*t},function(t){return 1-Math.pow(t,1.5)})}Bf.size={xxs:2,xs:5,s:10,m:15,l:20,xl:30,xxl:40,xxxl:50};var Wf="";"undefined"!=typeof navigator&&(Wf=navigator.platform||"");var Uf="rgba(0, 0, 0, 0.2)",Gf=Bf.color.theme[0],qf=io(Gf,0,null,.9),jf={darkMode:"auto",colorBy:"series",color:Bf.color.theme,gradientColor:[qf,Gf],aria:{decal:{decals:[{color:Uf,dashArrayX:[1,0],dashArrayY:[2,5],symbolSize:1,rotation:Math.PI/6},{color:Uf,symbol:"circle",dashArrayX:[[8,8],[0,8,8,0]],dashArrayY:[6,0],symbolSize:.8},{color:Uf,dashArrayX:[1,0],dashArrayY:[4,3],rotation:-Math.PI/4},{color:Uf,dashArrayX:[[6,6],[0,6,6,0]],dashArrayY:[6,0]},{color:Uf,dashArrayX:[[1,0],[1,6]],dashArrayY:[1,0,6,0],rotation:Math.PI/4},{color:Uf,symbol:"triangle",dashArrayX:[[9,9],[0,9,9,0]],dashArrayY:[7,2],symbolSize:.75}]}},textStyle:{fontFamily:Wf.match(/^Win/)?"Microsoft YaHei":"sans-serif",fontSize:12,fontStyle:"normal",fontWeight:"normal"},blendMode:null,stateAnimation:{duration:300,easing:"cubicOut"},animation:"auto",animationDuration:1e3,animationDurationUpdate:500,animationEasing:"cubicInOut",animationEasingUpdate:"cubicInOut",animationThreshold:2e3,progressiveThreshold:3e3,progressive:400,hoverLayerThreshold:3e3,useUTC:!1},Xf=En(["tooltip","label","itemName","itemId","itemGroupId","itemChildGroupId","seriesName"]),Yf="original",Zf="arrayRows",Kf="objectRows",Qf="keyedColumns",Jf="typedArray",tg="unknown",eg="column",ng="row",ig=1,rg=2,og=3,ag=Es();function sg(t,e,n){var i={},r=lg(e);if(!r||!t)return i;var o,a,s=[],l=[],c=e.ecModel,u=ag(c).datasetMap,d=r.uid+"_"+n.seriesLayoutBy;en(t=t.slice(),function(e,n){var r=fn(e)?e:t[n]={name:e};"ordinal"===r.type&&null==o&&(o=n,a=f(r)),i[r.name]=[]});var h=u.get(d)||u.set(d,{categoryWayDim:a,valueWayDim:0});function p(t,e,n){for(var i=0;ie)return t[i];return t[n-1]}(i,a):n;if(u=u||n,!u||!u.length)return;var d=u[l];r&&(c[r]=d);return s.paletteIdx=(l+1)%u.length,d}(this,dg,i,r,t,e,n)},t.prototype.clearColorPalette=function(){!function(t,e){e(t).paletteIdx=0,e(t).paletteNameMap={}}(this,dg)},t}();var vg="\0_ec_inner",mg=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return _(e,t),e.prototype.init=function(t,e,n,i,r,o){i=i||{},this.option=null,this._theme=new _p(i),this._locale=new _p(r),this._optionManager=o},e.prototype.setOption=function(t,e,n){var i=bg(e);this._optionManager.setOption(t,n,i),this._resetOption(null,i)},e.prototype.resetOption=function(t,e){return this._resetOption(t,bg(e))},e.prototype._resetOption=function(t,e){var n=!1,i=this._optionManager;if(!t||"recreate"===t){var r=i.mountOption("recreate"===t);this.option&&"recreate"!==t?(this.restoreData(),this._mergeOption(r,e)):fg(this,r),n=!0}if("timeline"!==t&&"media"!==t||this.restoreData(),!t||"recreate"===t||"timeline"===t){var o=i.getTimelineOption(this);o&&(n=!0,this._mergeOption(o,e))}if(!t||"recreate"===t||"media"===t){var a=i.getMediaOption(this);a.length&&en(a,function(t){n=!0,this._mergeOption(t,e)},this)}return n},e.prototype.mergeOption=function(t){this._mergeOption(t,null)},e.prototype._mergeOption=function(t,e){var n=this.option,i=this._componentsMap,r=this._componentsCount,o=[],a=En(),s=e&&e.replaceMergeMainTypeMap;ag(this).datasetMap=En(),en(t,function(t,e){null!=t&&(Hf.hasClass(e)?e&&(o.push(e),a.set(e,!0)):n[e]=null==n[e]?Xe(t):Ye(n[e],t,!0))}),s&&s.each(function(t,e){Hf.hasClass(e)&&!a.get(e)&&(o.push(e),a.set(e,!0))}),Hf.topologicalTravel(o,Hf.getAllClassMainTypes(),function(e){var o=function(t,e,n){var i=ug.get(e);if(!i)return n;var r=i(t);return r?n.concat(r):n}(this,e,xs(t[e])),a=i.get(e),l=a?s&&s.get(e)?"replaceMerge":"normalMerge":"replaceAll",c=ks(a,o,l);(function(t,e,n){en(t,function(t){var i=t.newOption;fn(i)&&(t.keyInfo.mainType=e,t.keyInfo.subType=function(t,e,n,i){return e.type?e.type:n?n.subType:i.determineSubType(t,e)}(e,i,t.existing,n))})})(c,e,Hf),n[e]=null,i.set(e,null),r.set(e,0);var u,d=[],h=[],p=0;en(c,function(t,n){var i=t.existing,r=t.newOption;if(r){var o="series"===e,a=Hf.getClass(e,t.keyInfo.subType,!o);if(!a)return;if("tooltip"===e){if(u)return;u=!0}if(i&&i.constructor===a)i.name=t.keyInfo.name,i.mergeOption(r,this),i.optionUpdated(r,!1);else{var s=Ze({componentIndex:n},t.keyInfo);Ze(i=new a(r,this,this,s),s),t.brandNew&&(i.__requireNewView=!0),i.init(r,this,this),i.optionUpdated(null,!0)}}else i&&(i.mergeOption({},this),i.optionUpdated({},!1));i?(d.push(i.option),h.push(i),p++):(d.push(void 0),h.push(void 0))},this),n[e]=d,i.set(e,h),r.set(e,p),"series"===e&&hg(this)},this),this._seriesIndices||hg(this)},e.prototype.getOption=function(){var t=Xe(this.option);return en(t,function(e,n){if(Hf.hasClass(n)){for(var i=xs(e),r=i.length,o=!1,a=r-1;a>=0;a--)i[a]&&!Ps(i[a])?o=!0:(i[a]=null,!o&&r--);i.length=r,t[n]=i}}),delete t[vg],t},e.prototype.setTheme=function(t){this._theme=new _p(t),this._resetOption("recreate",null)},e.prototype.getTheme=function(){return this._theme},e.prototype.getLocaleModel=function(){return this._locale},e.prototype.setUpdatePayload=function(t){this._payload=t},e.prototype.getUpdatePayload=function(){return this._payload},e.prototype.getComponent=function(t,e){var n=this._componentsMap.get(t);if(n){var i=n[e||0];if(i)return i;if(null==e)for(var r=0;r=e:"max"===n?t<=e:t===e})(i[a],t,o)||(r=!1)}}),r}var kg=en,Tg=fn,Ag=["areaStyle","lineStyle","nodeStyle","linkStyle","chordStyle","label","labelLine"];function Dg(t){var e=t&&t.itemStyle;if(e)for(var n=0,i=Ag.length;nc&&(c=p)}s[0]=l,s[1]=c}},i=function(){return this._data?this._data.length/this._dimSize:0};function r(t){for(var e=0;e=0&&(s=o.interpolatedValue[l])}return null!=s?s+"":""}):void 0},t.prototype.getRawValue=function(t,e){return yv(this.getData(e),t)},t.prototype.formatTooltip=function(t,e,n){},t}();function xv(t){var e,n;return fn(t)?t.type&&(n=t):e=t,{text:e,frag:n}}function wv(t){return new Sv(t)}var Sv=function(){function t(t){t=t||{},this._reset=t.reset,this._plan=t.plan,this._count=t.count,this._onDirty=t.onDirty,this._dirty=!0}return t.prototype.perform=function(t){var e,n=this._upstream,i=t&&t.skip;if(this._dirty&&n){var r=this.context;r.data=r.outputData=n.context.outputData}this.__pipeline&&(this.__pipeline.currentTask=this),this._plan&&!i&&(e=this._plan(this.context));var o,a=u(this._modBy),s=this._modDataCount||0,l=u(t&&t.modBy),c=t&&t.modDataCount||0;function u(t){return!(t>=1)&&(t=1),t}a===l&&s===c||(e="reset"),(this._dirty||"reset"===e)&&(this._dirty=!1,o=this._doReset(i)),this._modBy=l,this._modDataCount=c;var d=t&&t.step;if(this._dueEnd=n?n._outputDueEnd:this._count?this._count(this.context):1/0,this._progress){var h=this._dueIndex,p=Math.min(null!=d?this._dueIndex+d:1/0,this._dueEnd);if(!i&&(o||h1&&i>0?s:a}};return o;function a(){return e=t?null:oi?-this._resultLT:0},t}(),Tv=function(){function t(){}return t.prototype.getRawData=function(){throw new Error("not supported")},t.prototype.getRawDataItem=function(t){throw new Error("not supported")},t.prototype.cloneRawData=function(){},t.prototype.getDimensionInfo=function(t){},t.prototype.cloneAllDimensionInfo=function(){},t.prototype.count=function(){},t.prototype.retrieveValue=function(t,e){},t.prototype.retrieveValueFromItem=function(t,e){},t.prototype.convertValue=function(t,e){return Mv(t,e)},t}();function Av(t){if(!Ov(t.sourceFormat)){ys("")}return t.data}function Dv(t){var e=t.sourceFormat,n=t.data;if(!Ov(e)){ys("")}if(e===Zf){for(var i=[],r=0,o=n.length;r65535?Rv:Hv}function Wv(){return[1/0,-1/0]}function Uv(t){var e=t.constructor;return e===Array?t.slice():new e(t)}function Gv(t,e,n,i,r){var o=$v[n||"float"];if(r){var a=t[e],s=a&&a.length;if(s!==i){for(var l=new o(i),c=0;cg[1]&&(g[1]=f)}return this._rawCount=this._count=s,{start:a,end:s}},t.prototype._initDataFromProvider=function(t,e,n){for(var i=this._provider,r=this._chunks,o=this._dimensions,a=o.length,s=this._rawExtent,l=nn(o,function(t){return t.property}),c=0;cv[1]&&(v[1]=g)}}!i.persistent&&i.clean&&i.clean(),this._rawCount=this._count=e,this._extent=[]},t.prototype.count=function(){return this._count},t.prototype.get=function(t,e){if(!(e>=0&&e=0&&e=this._rawCount||t<0)return-1;if(!this._indices)return t;var e=this._indices,n=e[t];if(null!=n&&nt))return o;r=o-1}}return-1},t.prototype.getIndices=function(){var t,e=this._indices;if(e){var n=e.constructor,i=this._count;if(n===Array){t=new n(i);for(var r=0;r=c&&_<=u||isNaN(_))&&(a[s++]=p),p++}h=!0}else if(2===r){f=d[i[0]];var v=d[i[1]],m=t[i[1]][0],y=t[i[1]][1];for(g=0;g=c&&_<=u||isNaN(_))&&(b>=m&&b<=y||isNaN(b))&&(a[s++]=p),p++}h=!0}}if(!h)if(1===r)for(g=0;g=c&&_<=u||isNaN(_))&&(a[s++]=x)}else for(g=0;gt[C][1])&&(w=!1)}w&&(a[s++]=e.getRawIndex(g))}return sv[1]&&(v[1]=g)}}}},t.prototype.lttbDownSample=function(t,e){var n,i,r,o=this.clone([t],!0),a=o._chunks[t],s=this.count(),l=0,c=Math.floor(1/e),u=this.getRawIndex(0),d=new(Vv(this._rawCount))(Math.min(2*(Math.ceil(s/c)+2),s));d[l++]=u;for(var h=1;hn&&(n=i,r=M)}C>0&&Ca&&(f=a-c);for(var g=0;gp&&(p=v,h=c+g)}var m=this.getRawIndex(u),y=this.getRawIndex(h);uc-p&&(s=c-p,a.length=s);for(var f=0;fu[1]&&(u[1]=v),d[h++]=m}return r._count=h,r._indices=d,r._updateGetRawIdx(),r},t.prototype.each=function(t,e){if(this._count)for(var n=t.length,i=this._chunks,r=0,o=this.count();ra&&(a=l)}return i=[o,a],this._extent[t]=i,i},t.prototype.getRawDataItem=function(t){var e=this.getRawIndex(t);if(this._provider.persistent)return this._provider.getItem(e);for(var n=[],i=this._chunks,r=0;r=0?this._indices[t]:-1},t.prototype._updateGetRawIdx=function(){this.getRawIndex=this._indices?this._getRawIdx:this._getRawIdxIdentity},t.internalField=function(){function t(t,e,n,i){return Mv(t[i],this._dimensions[i])}zv={arrayRows:t,objectRows:function(t,e,n,i){return Mv(t[e],this._dimensions[i])},keyedColumns:t,original:function(t,e,n,i){var r=t&&(null==t.value?t:t.value);return Mv(r instanceof Array?r[i]:r,this._dimensions[i])},typedArray:function(t,e,n,i){return t[i]}}}(),t}(),jv=function(){function t(t){this._sourceList=[],this._storeList=[],this._upstreamSignList=[],this._versionSignBase=0,this._dirty=!0,this._sourceHost=t}return t.prototype.dirty=function(){this._setLocalSource([],[]),this._storeList=[],this._dirty=!0},t.prototype._setLocalSource=function(t,e){this._sourceList=t,this._upstreamSignList=e,this._versionSignBase++,this._versionSignBase>9e10&&(this._versionSignBase=0)},t.prototype._getVersionSign=function(){return this._sourceHost.uid+"_"+this._versionSignBase},t.prototype.prepareSource=function(){this._isDirty()&&(this._createSource(),this._dirty=!1)},t.prototype._createSource=function(){this._setLocalSource([],[]);var t,e,n=this._sourceHost,i=this._getUpstreamSourceManagers(),r=!!i.length;if(Xv(n)){var o=n,a=void 0,s=void 0,l=void 0;if(r){var c=i[0];c.prepareSource(),a=(l=c.getSource()).data,s=l.sourceFormat,e=[c._getVersionSign()]}else s=vn(a=o.get("data",!0))?Jf:Yf,e=[];var u=this._getSourceMetaRawOption()||{},d=l&&l.metaRawOption||{},h=xn(u.seriesLayoutBy,d.seriesLayoutBy)||null,p=xn(u.sourceHeader,d.sourceHeader),f=xn(u.dimensions,d.dimensions);t=h!==d.seriesLayoutBy||!!p!=!!d.sourceHeader||f?[tv(a,{seriesLayoutBy:h,sourceHeader:p,dimensions:f},s)]:[]}else{var g=n;if(r){var v=this._applyTransform(i);t=v.sourceList,e=v.upstreamSignList}else{t=[tv(g.get("source",!0),this._getSourceMetaRawOption(),null)],e=[]}}this._setLocalSource(t,e)},t.prototype._applyTransform=function(t){var e,n=this._sourceHost,i=n.get("transform",!0),r=n.get("fromTransformResult",!0);if(null!=r){1!==t.length&&Yv("")}var o,a=[],s=[];return en(t,function(t){t.prepareSource();var e=t.getSource(r||0);null==r||e||Yv(""),a.push(e),s.push(t._getVersionSign())}),i?e=function(t,e){var n=xs(t),i=n.length;i||ys("");for(var r=0,o=i;r1||n>0&&!t.noHeader;return en(t.blocks,function(t){var n=im(t);n>=e&&(e=n+ +(i&&(!n||em(t)&&!t.noHeader)))}),e}return 0}function rm(t,e,n,i){var r,o=e.noHeader,a=(r=im(e),{html:Qv[r],richText:Jv[r]}),s=[],l=e.blocks||[];Mn(!l||cn(l)),l=l||[];var c=t.orderMode;if(e.sortBlocks&&c){l=l.slice();var u={valueAsc:"asc",valueDesc:"desc"};if(zn(u,c)){var d=new kv(u[c],null);l.sort(function(t,e){return d.evaluate(t.sortParam,e.sortParam)})}else"seriesDesc"===c&&l.reverse()}en(l,function(n,r){var o=e.valueFormatter,l=nm(n)(o?Ze(Ze({},t),{valueFormatter:o}):t,n,r>0?a.html:0,i);null!=l&&s.push(l)});var h="richText"===t.renderMode?s.join(a.richText):sm(i,s.join(""),o?n:a.html);if(o)return h;var p=hf(e.header,"ordinal",t.useUTC),f=Kv(i,t.renderMode).nameStyle,g=Zv(i);return"richText"===t.renderMode?lm(t,p,f)+a.richText+h:sm(i,'
'+li(p)+"
"+h,n)}function om(t,e,n,i){var r=t.renderMode,o=e.noName,a=e.noValue,s=!e.markerType,l=e.name,c=t.useUTC,u=e.valueFormatter||t.valueFormatter||function(t){return nn(t=cn(t)?t:[t],function(t,e){return hf(t,cn(p)?p[e]:p,c)})};if(!o||!a){var d=s?"":t.markupStyleCreator.makeTooltipMarker(e.markerType,e.markerColor||Bf.color.secondary,r),h=o?"":hf(l,"ordinal",c),p=e.valueType,f=a?[]:u(e.value,e.dataIndex),g=!s||!o,v=!s&&o,m=Kv(i,r),y=m.nameStyle,_=m.valueStyle;return"richText"===r?(s?"":d)+(o?"":lm(t,h,y))+(a?"":function(t,e,n,i,r){var o=[r],a=i?10:20;return n&&o.push({padding:[0,0,0,a],align:"right"}),t.markupStyleCreator.wrapRichTextStyle(cn(e)?e.join(" "):e,o)}(t,f,g,v,_)):sm(i,(s?"":d)+(o?"":function(t,e,n){return''+li(t)+""}(h,!s,y))+(a?"":function(t,e,n,i){var r=n?"10px":"20px",o=e?"float:right;margin-left:"+r:"";return t=cn(t)?t:[t],''+nn(t,function(t){return li(t)}).join("  ")+""}(f,g,v,_)),n)}}function am(t,e,n,i,r,o){if(t)return nm(t)({useUTC:r,renderMode:n,orderMode:i,markupStyleCreator:e,valueFormatter:t.valueFormatter},t,0,o)}function sm(t,e,n){return'
'+e+'
'}function lm(t,e,n){return t.markupStyleCreator.wrapRichTextStyle(e,n)}function cm(t,e){var n=t.get("padding");return null!=n?n:"richText"===e?[8,10]:10}var um=function(){function t(){this.richTextStyles={},this._nextStyleNameId=ps()}return t.prototype._generateStyleName=function(){return"__EC_aUTo_"+this._nextStyleNameId++},t.prototype.makeTooltipMarker=function(t,e,n){var i="richText"===n?this._generateStyleName():null,r=function(t,e){var n=dn(t)?{color:t,extraCssText:e}:t||{},i=n.color,r=n.type;e=n.extraCssText;var o=n.renderMode||"html";return i?"html"===o?"subItem"===r?'':'':{renderMode:o,content:"{"+(n.markerId||"markerX")+"|} ",style:"subItem"===r?{width:4,height:4,borderRadius:2,backgroundColor:i}:{width:10,height:10,borderRadius:5,backgroundColor:i}}:""}({color:e,type:t,renderMode:n,markerId:i});return dn(r)?r:(this.richTextStyles[i]=r.style,r.content)},t.prototype.wrapRichTextStyle=function(t,e){var n={};cn(e)?en(e,function(t){return Ze(n,t)}):Ze(n,e);var i=this._generateStyleName();return this.richTextStyles[i]=n,"{"+i+"|"+t+"}"},t}();function dm(t){var e,n,i,r,o=t.series,a=t.dataIndex,s=t.multipleSeries,l=o.getData(),c=l.mapDimensionsAll("defaultedTooltip"),u=c.length,d=o.getRawValue(a),h=cn(d),p=function(t,e){return vf(t.getData().getItemVisual(e,"style")[t.visualDrawType])}(o,a);if(u>1||h&&!u){var f=function(t,e,n,i,r){var o=e.getData(),a=rn(t,function(t,e,n){var i=o.getDimensionInfo(n);return t||i&&!1!==i.tooltip&&null!=i.displayName},!1),s=[],l=[],c=[];function u(t,e){var n=o.getDimensionInfo(e);n&&!1!==n.otherDims.tooltip&&(a?c.push(tm("nameValue",{markerType:"subItem",markerColor:r,name:n.displayName,value:t,valueType:n.type})):(s.push(t),l.push(n.type)))}return i.length?en(i,function(t){u(yv(o,n,t),t)}):en(t,u),{inlineValues:s,inlineValueTypes:l,blocks:c}}(d,o,a,c,p);e=f.inlineValues,n=f.inlineValueTypes,i=f.blocks,r=f.inlineValues[0]}else if(u){var g=l.getDimensionInfo(c[0]);r=e=yv(l,a,c[0]),n=g.type}else r=e=h?d[0]:d;var v=Is(o),m=v&&o.name||"",y=l.getName(a),_=s?m:y;return tm("section",{header:m,noHeader:s||!v,sortParam:r,blocks:[tm("nameValue",{markerType:"item",markerColor:p,name:_,noName:!kn(_),value:e,valueType:n,dataIndex:a})].concat(i||[])})}var hm=Es();function pm(t,e){return t.getName(e)||t.getId(e)}var fm=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e._selectedDataIndicesMap={},e}var n;return _(e,t),e.prototype.init=function(t,e,n){this.seriesIndex=this.componentIndex,this.dataTask=wv({count:vm,reset:mm}),this.dataTask.context={model:this},this.mergeDefaultAndTheme(t,n),(hm(this).sourceManager=new jv(this)).prepareSource();var i=this.getInitialData(t,n);_m(i,this),this.dataTask.context.data=i,hm(this).dataBeforeProcessed=i,gm(this),this._initSelectedMapFromData(i)},e.prototype.mergeDefaultAndTheme=function(t,e){var n=Of(this),i=n?Nf(t):{},r=this.subType;Hf.hasClass(r)&&(r+="Series"),Ye(t,e.getTheme().get(this.subType)),Ye(t,this.getDefaultOption()),ws(t,"label",["show"]),this.fillDataTextStyle(t.data),n&&zf(t,i,n)},e.prototype.mergeOption=function(t,e){t=Ye(this.option,t,!0),this.fillDataTextStyle(t.data);var n=Of(this);n&&zf(this.option,t,n);var i=hm(this).sourceManager;i.dirty(),i.prepareSource();var r=this.getInitialData(t,e);_m(r,this),this.dataTask.dirty(),this.dataTask.context.data=r,hm(this).dataBeforeProcessed=r,gm(this),this._initSelectedMapFromData(r)},e.prototype.fillDataTextStyle=function(t){if(t&&!vn(t))for(var e=["show"],n=0;n=0&&u<0)&&(c=o,u=r,d=0),r===u&&(l[d++]=e))}),l.length=d,l},e.prototype.formatTooltip=function(t,e,n){return dm({series:this,dataIndex:t,multipleSeries:e})},e.prototype.isAnimationEnabled=function(){var t=this.ecModel;if(Te.node&&(!t||!t.ssr))return!1;var e=this.getShallow("animation");return e&&this.getData().count()>this.getShallow("animationThreshold")&&(e=!1),!!e},e.prototype.restoreData=function(){this.dataTask.dirty()},e.prototype.getColorFromPalette=function(t,e,n){var i=this.ecModel,r=gg.prototype.getColorFromPalette.call(this,t,e,n);return r||(r=i.getColorFromPalette(t,e,n)),r},e.prototype.coordDimToDataDim=function(t){return this.getRawData().mapDimensionsAll(t)},e.prototype.getProgressive=function(){return this.get("progressive")},e.prototype.getProgressiveThreshold=function(){return this.get("progressiveThreshold")},e.prototype.select=function(t,e){this._innerSelect(this.getData(e),t)},e.prototype.unselect=function(t,e){var n=this.option.selectedMap;if(n){var i=this.option.selectedMode,r=this.getData(e);if("series"===i||"all"===n)return this.option.selectedMap={},void(this._selectedDataIndicesMap={});for(var o=0;o=0&&n.push(r)}return n},e.prototype.isSelected=function(t,e){var n=this.option.selectedMap;if(!n)return!1;var i=this.getData(e);return("all"===n||n[pm(i,t)])&&!i.getItemModel(t).get(["select","disabled"])},e.prototype.isUniversalTransitionEnabled=function(){if(this.__universalTransitionEnabled)return!0;var t=this.option.universalTransition;return!!t&&(!0===t||t&&t.enabled)},e.prototype._innerSelect=function(t,e){var n,i,r=this.option,o=r.selectedMode,a=e.length;if(o&&a)if("series"===o)r.selectedMap="all";else if("multiple"===o){fn(r.selectedMap)||(r.selectedMap={});for(var s=r.selectedMap,l=0;l0&&this._innerSelect(t,e)}},e.registerClass=function(t){return Hf.registerClass(t)},e.protoInitialize=((n=e.prototype).type="series.__base__",n.seriesIndex=0,n.ignoreStyleOnData=!1,n.hasSymbolVisual=!1,n.defaultSymbol="circle",n.visualStyleAccessPath="itemStyle",void(n.visualDrawType="fill")),e}(Hf);function gm(t){var e=t.name;Is(t)||(t.name=function(t){var e=t.getRawData(),n=e.mapDimensionsAll("seriesName"),i=[];return en(n,function(t){var n=e.getDimensionInfo(t);n.displayName&&i.push(n.displayName)}),i.join(" ")}(t)||e)}function vm(t){return t.model.getRawData().count()}function mm(t){var e=t.model;return e.setData(e.getRawData().cloneShallow()),ym}function ym(t,e){e.outputData&&t.end>e.outputData.count()&&e.model.getRawData().cloneShallow(e.outputData)}function _m(t,e){en(function(t,e){for(var n=new t.constructor(t.length+e.length),i=0;i=0?d():u=setTimeout(d,-r),l=i};return h.clear=function(){u&&(clearTimeout(u),u=null)},h.debounceNextCall=function(t){s=t},h}function Nm(t,e,n,i){var r=t[e];if(r){var o=r[Lm]||r,a=r[Om];if(r[Em]!==n||a!==i){if(null==n||!i)return t[e]=o;(r=t[e]=zm(o,n,"debounce"===i))[Lm]=o,r[Om]=i,r[Em]=n}return r}}function Rm(t,e){var n=t[e];n&&n[Lm]&&(n.clear&&n.clear(),t[e]=n[Lm])}var Hm=Es(),Bm={itemStyle:Ys(vp,!0),lineStyle:Ys(pp,!0)},Fm={lineStyle:"stroke",itemStyle:"fill"};function $m(t,e){var n=t.visualStyleMapper||Bm[e];return n||(console.warn("Unknown style type '"+e+"'."),Bm.itemStyle)}function Vm(t,e){var n=t.visualDrawType||Fm[e];return n||(console.warn("Unknown style type '"+e+"'."),"fill")}var Wm={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){var n=t.getData(),i=t.visualStyleAccessPath||"itemStyle",r=t.getModel(i),o=$m(t,i)(r),a=r.getShallow("decal");a&&(n.setVisual("decal",a),a.dirty=!0);var s=Vm(t,i),l=o[s],c=un(l)?l:null,u="auto"===o.fill||"auto"===o.stroke;if(!o[s]||c||u){var d=t.getColorFromPalette(t.name,null,e.getSeriesCount());o[s]||(o[s]=d,n.setVisual("colorFromPalette",!0)),o.fill="auto"===o.fill||un(o.fill)?d:o.fill,o.stroke="auto"===o.stroke||un(o.stroke)?d:o.stroke}if(n.setVisual("style",o),n.setVisual("drawType",s),!e.isSeriesFiltered(t)&&c)return n.setVisual("colorFromPalette",!1),{dataEach:function(e,n){var i=t.getDataParams(n),r=Ze({},o);r[s]=c(i),e.setItemVisual(n,"style",r)}}}},Um=new _p,Gm={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){if(!t.ignoreStyleOnData&&!e.isSeriesFiltered(t)){var n=t.getData(),i=t.visualStyleAccessPath||"itemStyle",r=$m(t,i),o=n.getVisual("drawType");return{dataEach:n.hasItemOption?function(t,e){var n=t.getRawDataItem(e);if(n&&n[i]){Um.option=n[i];var a=r(Um);Ze(t.ensureUniqueItemVisual(e,"style"),a),Um.option.decal&&(t.setItemVisual(e,"decal",Um.option.decal),Um.option.decal.dirty=!0),o in a&&t.setItemVisual(e,"colorFromPalette",!1)}}:null}}}},qm={performRawSeries:!0,overallReset:function(t){var e=En();t.eachSeries(function(t){var n=t.getColorBy();if(!t.isColorBySeries()){var i=t.type+"-"+n,r=e.get(i);r||(r={},e.set(i,r)),Hm(t).scope=r}}),t.eachSeries(function(e){if(!e.isColorBySeries()&&!t.isSeriesFiltered(e)){var n=e.getRawData(),i={},r=e.getData(),o=Hm(e).scope,a=e.visualStyleAccessPath||"itemStyle",s=Vm(e,a);r.each(function(t){var e=r.getRawIndex(t);i[e]=t}),n.each(function(t){var a=i[t];if(r.getItemVisual(a,"colorFromPalette")){var l=r.ensureUniqueItemVisual(a,"style"),c=n.getName(t)||t+"",u=n.count();l[s]=e.getColorFromPalette(c,o,u)}})}})}},jm=Math.PI;var Xm=function(){function t(t,e,n,i){this._stageTaskMap=En(),this.ecInstance=t,this.api=e,n=this._dataProcessorHandlers=n.slice(),i=this._visualHandlers=i.slice(),this._allHandlers=n.concat(i)}return t.prototype.restoreData=function(t,e){t.restoreData(e),this._stageTaskMap.each(function(t){var e=t.overallTask;e&&e.dirty()})},t.prototype.getPerformArgs=function(t,e){if(t.__pipeline){var n=this._pipelineMap.get(t.__pipeline.id),i=n.context,r=!e&&n.progressiveEnabled&&(!i||i.progressiveRender)&&t.__idxInPipeline>n.blockIndex?n.step:null,o=i&&i.modDataCount;return{step:r,modBy:null!=o?Math.ceil(o/r):null,modDataCount:o}}},t.prototype.getPipeline=function(t){return this._pipelineMap.get(t)},t.prototype.updateStreamModes=function(t,e){var n=this._pipelineMap.get(t.uid),i=t.getData().count(),r=n.progressiveEnabled&&e.incrementalPrepareRender&&i>=n.threshold,o=t.get("large")&&i>=t.get("largeThreshold"),a="mod"===t.get("progressiveChunkMode")?i:null;t.pipelineContext=n.context={progressiveRender:r,modDataCount:a,large:o}},t.prototype.restorePipelines=function(t){var e=this,n=e._pipelineMap=En();t.eachSeries(function(t){var i=t.getProgressive(),r=t.uid;n.set(r,{id:r,head:null,tail:null,threshold:t.getProgressiveThreshold(),progressiveEnabled:i&&!(t.preventIncremental&&t.preventIncremental()),blockIndex:-1,step:Math.round(i||700),count:0}),e._pipe(t,t.dataTask)})},t.prototype.prepareStageTasks=function(){var t=this._stageTaskMap,e=this.api.getModel(),n=this.api;en(this._allHandlers,function(i){var r=t.get(i.uid)||t.set(i.uid,{});Mn(!(i.reset&&i.overallReset),""),i.reset&&this._createSeriesStageTask(i,r,e,n),i.overallReset&&this._createOverallStageTask(i,r,e,n)},this)},t.prototype.prepareView=function(t,e,n,i){var r=t.renderTask,o=r.context;o.model=e,o.ecModel=n,o.api=i,r.__block=!t.incrementalPrepareRender,this._pipe(e,r)},t.prototype.performDataProcessorTasks=function(t,e){this._performStageTasks(this._dataProcessorHandlers,t,e,{block:!0})},t.prototype.performVisualTasks=function(t,e,n){this._performStageTasks(this._visualHandlers,t,e,n)},t.prototype._performStageTasks=function(t,e,n,i){i=i||{};var r=!1,o=this;function a(t,e){return t.setDirty&&(!t.dirtyMap||t.dirtyMap.get(e.__pipeline.id))}en(t,function(t,s){if(!i.visualType||i.visualType===t.visualType){var l=o._stageTaskMap.get(t.uid),c=l.seriesTaskMap,u=l.overallTask;if(u){var d,h=u.agentStubMap;h.each(function(t){a(i,t)&&(t.dirty(),d=!0)}),d&&u.dirty(),o.updatePayload(u,n);var p=o.getPerformArgs(u,i.block);h.each(function(t){t.perform(p)}),u.perform(p)&&(r=!0)}else c&&c.each(function(s,l){a(i,s)&&s.dirty();var c=o.getPerformArgs(s,i.block);c.skip=!t.performRawSeries&&e.isSeriesFiltered(s.context.model),o.updatePayload(s,n),s.perform(c)&&(r=!0)})}}),this.unfinished=r||this.unfinished},t.prototype.performSeriesTasks=function(t){var e;t.eachSeries(function(t){e=t.dataTask.perform()||e}),this.unfinished=e||this.unfinished},t.prototype.plan=function(){this._pipelineMap.each(function(t){var e=t.tail;do{if(e.__block){t.blockIndex=e.__idxInPipeline;break}e=e.getUpstream()}while(e)})},t.prototype.updatePayload=function(t,e){"remain"!==e&&(t.context.payload=e)},t.prototype._createSeriesStageTask=function(t,e,n,i){var r=this,o=e.seriesTaskMap,a=e.seriesTaskMap=En(),s=t.seriesType,l=t.getTargetSeries;function c(e){var s=e.uid,l=a.set(s,o&&o.get(s)||wv({plan:Jm,reset:ty,count:iy}));l.context={model:e,ecModel:n,api:i,useClearVisual:t.isVisual&&!t.isLayout,plan:t.plan,reset:t.reset,scheduler:r},r._pipe(e,l)}t.createOnAllSeries?n.eachRawSeries(c):s?n.eachRawSeriesByType(s,c):l&&l(n,i).each(c)},t.prototype._createOverallStageTask=function(t,e,n,i){var r=this,o=e.overallTask=e.overallTask||wv({reset:Ym});o.context={ecModel:n,api:i,overallReset:t.overallReset,scheduler:r};var a=o.agentStubMap,s=o.agentStubMap=En(),l=t.seriesType,c=t.getTargetSeries,u=!0,d=!1;function h(t){var e=t.uid,n=s.set(e,a&&a.get(e)||(d=!0,wv({reset:Zm,onDirty:Qm})));n.context={model:t,overallProgress:u},n.agent=o,n.__block=u,r._pipe(t,n)}Mn(!t.createOnAllSeries,""),l?n.eachRawSeriesByType(l,h):c?c(n,i).each(h):(u=!1,en(n.getSeries(),h)),d&&o.dirty()},t.prototype._pipe=function(t,e){var n=t.uid,i=this._pipelineMap.get(n);!i.head&&(i.head=e),i.tail&&i.tail.pipe(e),i.tail=e,e.__idxInPipeline=i.count++,e.__pipeline=i},t.wrapStageHandler=function(t,e){return un(t)&&(t={overallReset:t,seriesType:ry(t)}),t.uid=xp("stageHandler"),e&&(t.visualType=e),t},t}();function Ym(t){t.overallReset(t.ecModel,t.api,t.payload)}function Zm(t){return t.overallProgress&&Km}function Km(){this.agent.dirty(),this.getDownstream().dirty()}function Qm(){this.agent&&this.agent.dirty()}function Jm(t){return t.plan?t.plan(t.model,t.ecModel,t.api,t.payload):null}function ty(t){t.useClearVisual&&t.data.clearAllVisual();var e=t.resetDefines=xs(t.reset(t.model,t.ecModel,t.api,t.payload));return e.length>1?nn(e,function(t,e){return ny(e)}):ey}var ey=ny(0);function ny(t){return function(e,n){var i=n.data,r=n.resetDefines[t];if(r&&r.dataEach)for(var o=e.start;o0&&u===r.length-c.length){var d=r.slice(0,u);"data"!==d&&(e.mainType=d,e[c.toLowerCase()]=t,s=!0)}}a.hasOwnProperty(r)&&(n[r]=t,s=!0),s||(i[r]=t)})}return{cptQuery:e,dataQuery:n,otherQuery:i}},t.prototype.filter=function(t,e){var n=this.eventInfo;if(!n)return!0;var i=n.targetEl,r=n.packedEvent,o=n.model,a=n.view;if(!o||!a)return!0;var s=e.cptQuery,l=e.dataQuery;return c(s,o,"mainType")&&c(s,o,"subType")&&c(s,o,"index","componentIndex")&&c(s,o,"name")&&c(s,o,"id")&&c(l,r,"name")&&c(l,r,"dataIndex")&&c(l,r,"dataType")&&(!a.filterForExposedEvent||a.filterForExposedEvent(t,e.otherQuery,i,r));function c(t,e,n,i){return null==t[n]||e[i||n]===t[n]}},t.prototype.afterTrigger=function(){this.eventInfo=null},t}(),vy=["symbol","symbolSize","symbolRotate","symbolOffset"],my=vy.concat(["symbolKeepAspect"]),yy={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){var n=t.getData();if(t.legendIcon&&n.setVisual("legendIcon",t.legendIcon),t.hasSymbolVisual){for(var i={},r={},o=!1,a=0;a=0&&zy(l)?l:.5,t.createRadialGradient(a,s,0,a,s,l)}(t,e,n):function(t,e,n){var i=null==e.x?0:e.x,r=null==e.x2?1:e.x2,o=null==e.y?0:e.y,a=null==e.y2?0:e.y2;return e.global||(i=i*n.width+n.x,r=r*n.width+n.x,o=o*n.height+n.y,a=a*n.height+n.y),i=zy(i)?i:0,r=zy(r)?r:1,o=zy(o)?o:0,a=zy(a)?a:0,t.createLinearGradient(i,o,r,a)}(t,e,n),r=e.colorStops,o=0;o0&&(e=i.lineDash,n=i.lineWidth,e&&"solid"!==e&&n>0?"dashed"===e?[4*n,2*n]:"dotted"===e?[n]:pn(e)?[e]:cn(e)?e:null:null),o=i.lineDashOffset;if(r){var a=i.strokeNoScale&&t.getLineScale?t.getLineScale():1;a&&1!==a&&(r=nn(r,function(t){return t/a}),o/=a)}return[r,o]}var Fy=new ac(!0);function $y(t){var e=t.stroke;return!(null==e||"none"===e||!(t.lineWidth>0))}function Vy(t){return"string"==typeof t&&"none"!==t}function Wy(t){var e=t.fill;return null!=e&&"none"!==e}function Uy(t,e){if(null!=e.fillOpacity&&1!==e.fillOpacity){var n=t.globalAlpha;t.globalAlpha=e.fillOpacity*e.opacity,t.fill(),t.globalAlpha=n}else t.fill()}function Gy(t,e){if(null!=e.strokeOpacity&&1!==e.strokeOpacity){var n=t.globalAlpha;t.globalAlpha=e.strokeOpacity*e.opacity,t.stroke(),t.globalAlpha=n}else t.stroke()}function qy(t,e,n){var i=tl(e.image,e.__image,n);if(nl(i)){var r=t.createPattern(i,e.repeat||"repeat");if("function"==typeof DOMMatrix&&r&&r.setTransform){var o=new DOMMatrix;o.translateSelf(e.x||0,e.y||0),o.rotateSelf(0,0,(e.rotation||0)*Rn),o.scaleSelf(e.scaleX||1,e.scaleY||1),r.setTransform(o)}return r}}var jy=["shadowBlur","shadowOffsetX","shadowOffsetY"],Xy=[["lineCap","butt"],["lineJoin","miter"],["miterLimit",10]];function Yy(t,e,n,i,r){var o=!1;if(!i&&e===(n=n||{}))return!1;if(i||e.opacity!==n.opacity){Qy(t,r),o=!0;var a=Math.max(Math.min(e.opacity,1),0);t.globalAlpha=isNaN(a)?wl.opacity:a}(i||e.blend!==n.blend)&&(o||(Qy(t,r),o=!0),t.globalCompositeOperation=e.blend||wl.blend);for(var s=0;s0&&t.unfinished);t.unfinished||this._zr.flush()}}},e.prototype.getDom=function(){return this._dom},e.prototype.getId=function(){return this.id},e.prototype.getZr=function(){return this._zr},e.prototype.isSSR=function(){return this._ssr},e.prototype.setOption=function(t,e,n){if(!this[p_])if(this._disposed)this.id;else{var i,r,o;if(fn(e)&&(n=e.lazyUpdate,i=e.silent,r=e.replaceMerge,o=e.transition,e=e.notMerge),this[p_]=!0,F_(this),!this._model||e){var a=new Cg(this._api),s=this._theme,l=this._model=new mg;l.scheduler=this._scheduler,l.ssr=this._ssr,l.init(null,null,null,s,this._locale,a)}this._model.setOption(t,{replaceMerge:r},Z_);var c={seriesTransition:o,optionChanged:!0};if(n)this[g_]={silent:i,updateParams:c},this[p_]=!1,this.getZr().wakeUp();else{try{w_(this),M_.update.call(this,null,c)}catch(t){throw this[g_]=null,this[p_]=!1,t}this._ssr||this._zr.flush(),this[g_]=null,this[p_]=!1,D_.call(this,i),I_.call(this,i)}}},e.prototype.setTheme=function(t,e){if(!this[p_])if(this._disposed)this.id;else{var n=this._model;if(n){var i=e&&e.silent,r=null;this[g_]&&(null==i&&(i=this[g_].silent),r=this[g_].updateParams,this[g_]=null),this[p_]=!0,F_(this);try{this._updateTheme(t),n.setTheme(this._theme),w_(this),M_.update.call(this,{type:"setTheme"},r)}catch(t){throw this[p_]=!1,t}this[p_]=!1,D_.call(this,i),I_.call(this,i)}}},e.prototype._updateTheme=function(t){dn(t)&&(t=Q_[t]),t&&((t=Xe(t))&&Gg(t,!0),this._theme=t)},e.prototype.getModel=function(){return this._model},e.prototype.getOption=function(){return this._model&&this._model.getOption()},e.prototype.getWidth=function(){return this._zr.getWidth()},e.prototype.getHeight=function(){return this._zr.getHeight()},e.prototype.getDevicePixelRatio=function(){return this._zr.painter.dpr||Te.hasGlobalWindow&&window.devicePixelRatio||1},e.prototype.getRenderedCanvas=function(t){return this.renderToCanvas(t)},e.prototype.renderToCanvas=function(t){return t=t||{},this._zr.painter.getRenderedCanvas({backgroundColor:t.backgroundColor||this._model.get("backgroundColor"),pixelRatio:t.pixelRatio||this.getDevicePixelRatio()})},e.prototype.renderToSVGString=function(t){return t=t||{},this._zr.painter.renderToString({useViewBox:t.useViewBox})},e.prototype.getSvgDataURL=function(){var t=this._zr;return en(t.storage.getDisplayList(),function(t){t.stopAnimation(null,!0)}),t.painter.toDataURL()},e.prototype.getDataURL=function(t){if(!this._disposed){var e=(t=t||{}).excludeComponents,n=this._model,i=[],r=this;en(e,function(t){n.eachComponent({mainType:t},function(t){var e=r._componentsMap[t.__viewId];e.group.ignore||(i.push(e),e.group.ignore=!0)})});var o="svg"===this._zr.painter.getType()?this.getSvgDataURL():this.renderToCanvas(t).toDataURL("image/"+(t&&t.type||"png"));return en(i,function(t){t.group.ignore=!1}),o}this.id},e.prototype.getConnectedDataURL=function(t){if(!this._disposed){var e="svg"===t.type,n=this.group,i=Math.min,r=Math.max,o=1/0;if(eb[n]){var a=o,s=o,l=-1/0,c=-1/0,u=[],d=t&&t.pixelRatio||this.getDevicePixelRatio();en(tb,function(o,d){if(o.group===n){var h=e?o.getZr().painter.getSvgDom().innerHTML:o.renderToCanvas(Xe(t)),p=o.getDom().getBoundingClientRect();a=i(p.left,a),s=i(p.top,s),l=r(p.right,l),c=r(p.bottom,c),u.push({dom:h,left:p.left,top:p.top})}});var h=(l*=d)-(a*=d),p=(c*=d)-(s*=d),f=Ee.createCanvas(),g=Ya(f,{renderer:e?"svg":"canvas"});if(g.resize({width:h,height:p}),e){var v="";return en(u,function(t){var e=t.left-a,n=t.top-s;v+=''+t.dom+""}),g.painter.getSvgRoot().innerHTML=v,t.connectedBackgroundColor&&g.painter.setBackgroundColor(t.connectedBackgroundColor),g.refreshImmediately(),g.painter.toDataURL()}return t.connectedBackgroundColor&&g.add(new Bc({shape:{x:0,y:0,width:h,height:p},style:{fill:t.connectedBackgroundColor}})),en(u,function(t){var e=new Lc({style:{x:t.left*d-a,y:t.top*d-s,image:t.dom}});g.add(e)}),g.refreshImmediately(),f.toDataURL("image/"+(t&&t.type||"png"))}return this.getDataURL(t)}this.id},e.prototype.convertToPixel=function(t,e,n){return k_(this,"convertToPixel",t,e,n)},e.prototype.convertToLayout=function(t,e,n){return k_(this,"convertToLayout",t,e,n)},e.prototype.convertFromPixel=function(t,e,n){return k_(this,"convertFromPixel",t,e,n)},e.prototype.containPixel=function(t,e){var n;if(!this._disposed)return en(zs(this._model,t),function(t,i){i.indexOf("Models")>=0&&en(t,function(t){var r=t.coordinateSystem;if(r&&r.containPoint)n=n||!!r.containPoint(e);else if("seriesModels"===i){var o=this._chartsMap[t.__viewId];o&&o.containPoint&&(n=n||o.containPoint(e,t))}},this)},this),!!n;this.id},e.prototype.getVisual=function(t,e){var n=zs(this._model,t,{defaultMainType:"series"}),i=n.seriesModel.getData(),r=n.hasOwnProperty("dataIndexInside")?n.dataIndexInside:n.hasOwnProperty("dataIndex")?i.indexOfRawIndex(n.dataIndex):null;return null!=r?function(t,e,n){switch(n){case"color":return t.getItemVisual(e,"style")[t.getVisual("drawType")];case"opacity":return t.getItemVisual(e,"style").opacity;case"symbol":case"symbolSize":case"liftZ":return t.getItemVisual(e,n)}}(i,r,e):function(t,e){switch(e){case"color":return t.getVisual("style")[t.getVisual("drawType")];case"opacity":return t.getVisual("style").opacity;case"symbol":case"symbolSize":case"liftZ":return t.getVisual(e)}}(i,e)},e.prototype.getViewOfComponentModel=function(t){return this._componentsMap[t.__viewId]},e.prototype.getViewOfSeriesModel=function(t){return this._chartsMap[t.__viewId]},e.prototype._initEvents=function(){var t=this;en(G_,function(e){var n=function(n){var i,r=t.getModel(),o=n.target;if("globalout"===e?i={}:o&&xy(o,function(t){var e=nu(t);if(e&&null!=e.dataIndex){var n=e.dataModel||r.getSeriesByIndex(e.seriesIndex);return i=n&&n.getDataParams(e.dataIndex,e.dataType,o)||{},!0}if(e.eventData)return i=Ze({},e.eventData),!0},!0),i){var a=i.componentType,s=i.componentIndex;"markLine"!==a&&"markPoint"!==a&&"markArea"!==a||(a="series",s=i.seriesIndex);var l=a&&null!=s&&r.getComponent(a,s),c=l&&t["series"===l.mainType?"_chartsMap":"_componentsMap"][l.__viewId];i.event=n,i.type=e,t._$eventProcessor.eventInfo={targetEl:o,packedEvent:i,model:l,view:c},t.trigger(e,i)}};n.zrEventfulCallAtLast=!0,t._zr.on(e,n,t)});var e=this._messageCenter;en(X_,function(n,i){e.on(i,function(e){t.trigger(i,e)})}),function(t,e,n){t.on("selectchanged",function(t){var i=n.getModel();t.isFromClick?(by("map","selectchanged",e,i,t),by("pie","selectchanged",e,i,t)):"select"===t.fromAction?(by("map","selected",e,i,t),by("pie","selected",e,i,t)):"unselect"===t.fromAction&&(by("map","unselected",e,i,t),by("pie","unselected",e,i,t))})}(e,this,this._api)},e.prototype.isDisposed=function(){return this._disposed},e.prototype.clear=function(){this._disposed?this.id:this.setOption({series:[]},!0)},e.prototype.dispose=function(){if(this._disposed)this.id;else{this._disposed=!0,this.getDom()&&Bs(this.getDom(),ib,"");var t=this,e=t._api,n=t._model;en(t._componentsViews,function(t){t.dispose(n,e)}),en(t._chartsViews,function(t){t.dispose(n,e)}),t._zr.dispose(),t._dom=t._model=t._chartsMap=t._componentsMap=t._chartsViews=t._componentsViews=t._scheduler=t._api=t._zr=t._throttledZrFlush=t._theme=t._coordSysMgr=t._messageCenter=null,delete tb[t.id]}},e.prototype.resize=function(t){if(!this[p_])if(this._disposed)this.id;else{this._zr.resize(t);var e=this._model;if(this._loadingFX&&this._loadingFX.resize(),e){var n=e.resetOption("media"),i=t&&t.silent;this[g_]&&(null==i&&(i=this[g_].silent),n=!0,this[g_]=null),this[p_]=!0,F_(this);try{n&&w_(this),M_.update.call(this,{type:"resize",animation:Ze({duration:0},t&&t.animation)})}catch(t){throw this[p_]=!1,t}this[p_]=!1,D_.call(this,i),I_.call(this,i)}}},e.prototype.showLoading=function(t,e){if(this._disposed)this.id;else if(fn(t)&&(e=t,t=""),t=t||"default",this.hideLoading(),J_[t]){var n=J_[t](this._api,e),i=this._zr;this._loadingFX=n,i.add(n)}},e.prototype.hideLoading=function(){this._disposed?this.id:(this._loadingFX&&this._zr.remove(this._loadingFX),this._loadingFX=null)},e.prototype.makeActionFromEvent=function(t){var e=Ze({},t);return e.type=j_[t.type],e},e.prototype.dispatchAction=function(t,e){if(this._disposed)this.id;else if(fn(e)||(e={silent:!!e}),q_[t.type]&&this._model)if(this[p_])this._pendingActions.push(t);else{var n=e.silent;A_.call(this,t,n);var i=e.flush;i?this._zr.flush():!1!==i&&Te.browser.weChat&&this._throttledZrFlush(),D_.call(this,n),I_.call(this,n)}},e.prototype.updateLabelLayout=function(){l_.trigger("series:layoutlabels",this._model,this._api,{updatedSeries:[]})},e.prototype.appendData=function(t){if(this._disposed)this.id;else{var e=t.seriesIndex;this.getModel().getSeriesByIndex(e).appendData(t),this._scheduler.unfinished=!0,this.getZr().wakeUp()}},e.internalField=function(){function t(t){t.clearColorPalette(),t.eachSeries(function(t){t.clearColorPalette()})}function e(t){for(var e=[],n=t.currentStates,i=0;i0?{duration:o,delay:i.get("delay"),easing:i.get("easing")}:null;n.eachRendered(function(t){if(t.states&&t.states.emphasis){if(fh(t))return;if(t instanceof Tc&&function(t){var e=ou(t);e.normalFill=t.style.fill,e.normalStroke=t.style.stroke;var n=t.states.select||{};e.selectFill=n.style&&n.style.fill||null,e.selectStroke=n.style&&n.style.stroke||null}(t),t.__dirty){var n=t.prevStates;n&&t.useStates(n)}if(r){t.stateTransition=a;var i=t.getTextContent(),o=t.getTextGuideLine();i&&(i.stateTransition=a),o&&(o.stateTransition=a)}t.__dirty&&e(t)}})}w_=function(t){var e=t._scheduler;e.restorePipelines(t._model),e.prepareStageTasks(),S_(t,!0),S_(t,!1),e.plan()},S_=function(t,e){for(var n=t._model,i=t._scheduler,r=e?t._componentsViews:t._chartsViews,o=e?t._componentsMap:t._chartsMap,a=t._zr,s=t._api,l=0;le.get("hoverLayerThreshold")&&!Te.node&&!Te.worker&&e.eachSeries(function(e){if(!e.preventUsingHoverLayer){var n=t._chartsMap[e.__viewId];n.__alive&&n.eachRendered(function(t){t.states.emphasis&&(t.states.emphasis.hoverLayer=!0)})}})}(t,e),l_.trigger("series:afterupdate",e,o,s)},H_=function(t){t[v_]=!0,t.getZr().wakeUp()},F_=function(t){t[f_]=(t[f_]+1)%1e3},B_=function(t){t[v_]&&(t.getZr().storage.traverse(function(t){fh(t)||e(t)}),t[v_]=!1)},N_=function(t){return new(function(e){function n(){return null!==e&&e.apply(this,arguments)||this}return _(n,e),n.prototype.getCoordinateSystems=function(){return t._coordSysMgr.getCoordinateSystems()},n.prototype.getComponentByElement=function(e){for(;e;){var n=e.__ecComponentInfo;if(null!=n)return t._model.getComponent(n.mainType,n.index);e=e.parent}},n.prototype.enterEmphasis=function(e,n){Iu(e,n),H_(t)},n.prototype.leaveEmphasis=function(e,n){Pu(e,n),H_(t)},n.prototype.enterBlur=function(e){!function(t){Cu(t,_u)}(e),H_(t)},n.prototype.leaveBlur=function(e){Lu(e),H_(t)},n.prototype.enterSelect=function(e){Eu(e),H_(t)},n.prototype.leaveSelect=function(e){Ou(e),H_(t)},n.prototype.getModel=function(){return t.getModel()},n.prototype.getViewOfComponentModel=function(e){return t.getViewOfComponentModel(e)},n.prototype.getViewOfSeriesModel=function(e){return t.getViewOfSeriesModel(e)},n.prototype.getMainProcessVersion=function(){return t[f_]},n}(wg))(t)},R_=function(t){function e(t,e){for(var n=0;n=0)){db.push(n);var o=Xm.wrapStageHandler(n,r);o.__prio=e,o.__raw=n,t.push(o)}}function pb(t,e){J_[t]=e}var fb=function(t){var e=(t=Xe(t)).type;e||ys("");var n=e.split(":");2!==n.length&&ys("");var i=!1;"echarts"===n[0]&&(e=n[1],i=!0),t.__isBuiltIn=i,Lv.set(e,t)};function gb(t,e,n,i){return{eventContent:{selected:$u(n),isFromClick:e.isFromClick||!1}}}function vb(t){return null==t?0:t.length||1}function mb(t){return t}ub(u_,Wm),ub(d_,Gm),ub(d_,qm),ub(u_,yy),ub(d_,_y),ub(7e3,function(t,e){t.eachRawSeries(function(n){if(!t.isSeriesFiltered(n)){var i=n.getData();i.hasItemVisual()&&i.each(function(t){var n=i.getItemVisual(t,"decal");n&&(i.ensureUniqueItemVisual(t,"style").decal=r_(n,e))});var r=i.getVisual("decal");if(r)i.getVisual("style").decal=r_(r,e)}})}),ab(Gg),sb(900,function(t){var e=En();t.eachSeries(function(t){var n=t.get("stack");if(n){var i=e.get(n)||e.set(n,[]),r=t.getData(),o={stackResultDimension:r.getCalculationInfo("stackResultDimension"),stackedOverDimension:r.getCalculationInfo("stackedOverDimension"),stackedDimension:r.getCalculationInfo("stackedDimension"),stackedByDimension:r.getCalculationInfo("stackedByDimension"),isStackedByIndex:r.getCalculationInfo("isStackedByIndex"),data:r,seriesModel:t};if(!o.stackedDimension||!o.isStackedByIndex&&!o.stackedByDimension)return;i.push(o)}}),e.each(function(t){0!==t.length&&("seriesDesc"===(t[0].seriesModel.get("stackOrder")||"seriesAsc")&&t.reverse(),en(t,function(e,n){e.data.setCalculationInfo("stackedOnSeries",n>0?t[n-1].seriesModel:null)}),function(t){en(t,function(e,n){var i=[],r=[NaN,NaN],o=[e.stackResultDimension,e.stackedOverDimension],a=e.data,s=e.isStackedByIndex,l=e.seriesModel.get("stackStrategy")||"samesign";a.modify(o,function(o,c,u){var d,h,p=a.get(e.stackedDimension,u);if(isNaN(p))return r;s?h=a.getRawIndex(u):d=a.get(e.stackedByDimension,u);for(var f=NaN,g=n-1;g>=0;g--){var v=t[g];if(s||(h=v.data.rawIndexOf(v.stackedByDimension,d)),h>=0){var m=v.data.getByRawIndex(v.stackResultDimension,h);if("all"===l||"positive"===l&&m>0||"negative"===l&&m<0||"samesign"===l&&p>=0&&m>0||"samesign"===l&&p<=0&&m<0){p=os(p,m),f=m;break}}}return i[0]=p,i[1]=f,i})})}(t))})}),pb("default",function(t,e){Ke(e=e||{},{text:"loading",textColor:Bf.color.primary,fontSize:12,fontWeight:"normal",fontStyle:"normal",fontFamily:"sans-serif",maskColor:"rgba(255,255,255,0.8)",showSpinner:!0,color:Bf.color.theme[0],spinnerRadius:10,lineWidth:5,zlevel:0});var n=new Ua,i=new Bc({style:{fill:e.maskColor},zlevel:e.zlevel,z:1e4});n.add(i);var r,o=new Wc({style:{text:e.text,fill:e.textColor,fontSize:e.fontSize,fontWeight:e.fontWeight,fontStyle:e.fontStyle,fontFamily:e.fontFamily},zlevel:e.zlevel,z:10001}),a=new Bc({style:{fill:"none"},textContent:o,textConfig:{position:"right",distance:10},zlevel:e.zlevel,z:10001});return n.add(a),e.showSpinner&&((r=new Xd({shape:{startAngle:-jm/2,endAngle:-jm/2+.1,r:e.spinnerRadius},style:{stroke:e.color,lineCap:"round",lineWidth:e.lineWidth},zlevel:e.zlevel,z:10001})).animateShape(!0).when(1e3,{endAngle:3*jm/2}).start("circularInOut"),r.animateShape(!0).when(1e3,{startAngle:3*jm/2}).delay(300).start("circularInOut"),n.add(r)),n.resize=function(){var n=o.getBoundingRect().width,s=e.showSpinner?e.spinnerRadius:0,l=(t.getWidth()-2*s-(e.showSpinner&&n?10:0)-n)/2-(e.showSpinner&&n?0:5+n/2)+(e.showSpinner?0:n/2)+(n?0:s),c=t.getHeight()/2;e.showSpinner&&r.setShape({cx:l,cy:c}),a.setShape({x:l-s,y:c-s,width:2*s,height:2*s}),i.setShape({x:0,y:0,width:t.getWidth(),height:t.getHeight()})},n.resize(),n}),cb({type:cu,event:cu,update:cu},Nn),cb({type:uu,event:uu,update:uu},Nn),cb({type:du,event:fu,update:du,action:Nn,refineEvent:gb,publishNonRefinedEvent:!0}),cb({type:hu,event:fu,update:hu,action:Nn,refineEvent:gb,publishNonRefinedEvent:!0}),cb({type:pu,event:fu,update:pu,action:Nn,refineEvent:gb,publishNonRefinedEvent:!0}),ob("default",{}),ob("dark",fy);var yb=function(){function t(t,e,n,i,r,o){this._old=t,this._new=e,this._oldKeyGetter=n||mb,this._newKeyGetter=i||mb,this.context=r,this._diffModeMultiple="multiple"===o}return t.prototype.add=function(t){return this._add=t,this},t.prototype.update=function(t){return this._update=t,this},t.prototype.updateManyToOne=function(t){return this._updateManyToOne=t,this},t.prototype.updateOneToMany=function(t){return this._updateOneToMany=t,this},t.prototype.updateManyToMany=function(t){return this._updateManyToMany=t,this},t.prototype.remove=function(t){return this._remove=t,this},t.prototype.execute=function(){this[this._diffModeMultiple?"_executeMultiple":"_executeOneToOne"]()},t.prototype._executeOneToOne=function(){var t=this._old,e=this._new,n={},i=new Array(t.length),r=new Array(e.length);this._initIndexMap(t,null,i,"_oldKeyGetter"),this._initIndexMap(e,n,r,"_newKeyGetter");for(var o=0;o1){var c=s.shift();1===s.length&&(n[a]=s[0]),this._update&&this._update(c,o)}else 1===l?(n[a]=null,this._update&&this._update(s,o)):this._remove&&this._remove(o)}this._performRestAdd(r,n)},t.prototype._executeMultiple=function(){var t=this._old,e=this._new,n={},i={},r=[],o=[];this._initIndexMap(t,n,r,"_oldKeyGetter"),this._initIndexMap(e,i,o,"_newKeyGetter");for(var a=0;a1&&1===d)this._updateManyToOne&&this._updateManyToOne(c,l),i[s]=null;else if(1===u&&d>1)this._updateOneToMany&&this._updateOneToMany(c,l),i[s]=null;else if(1===u&&1===d)this._update&&this._update(c,l),i[s]=null;else if(u>1&&d>1)this._updateManyToMany&&this._updateManyToMany(c,l),i[s]=null;else if(u>1)for(var h=0;h1)for(var a=0;a30}var Db,Ib,Pb,Lb,Eb,Ob,zb,Nb=fn,Rb=nn,Hb="undefined"==typeof Int32Array?Array:Int32Array,Bb=["hasItemOption","_nameList","_idList","_invertedIndicesMap","_dimSummary","userOutput","_rawData","_dimValueGetter","_nameDimIdx","_idDimIdx","_nameRepeatCount"],Fb=["_approximateExtent"],$b=function(){function t(t,e){var n;this.type="list",this._dimOmitted=!1,this._nameList=[],this._idList=[],this._visual={},this._layout={},this._itemVisuals=[],this._itemLayouts=[],this._graphicEls=[],this._approximateExtent={},this._calculationInfo={},this.hasItemOption=!1,this.TRANSFERABLE_METHODS=["cloneShallow","downSample","minmaxDownSample","lttbDownSample","map"],this.CHANGABLE_METHODS=["filterSelf","selectRange"],this.DOWNSAMPLE_METHODS=["downSample","minmaxDownSample","lttbDownSample"];var i=!1;Mb(t)?(n=t.dimensions,this._dimOmitted=t.isDimensionOmitted(),this._schema=t):(i=!0,n=t),n=n||["x","y"];for(var r={},o=[],a={},s=!1,l={},c=0;c=e)){var n=this._store.getProvider();this._updateOrdinalMeta();var i=this._nameList,r=this._idList;if(n.getSource().sourceFormat===Yf&&!n.pure)for(var o=[],a=t;a0},t.prototype.ensureUniqueItemVisual=function(t,e){var n=this._itemVisuals,i=n[t];i||(i=n[t]={});var r=i[e];return null==r&&(cn(r=this.getVisual(e))?r=r.slice():Nb(r)&&(r=Ze({},r)),i[e]=r),r},t.prototype.setItemVisual=function(t,e,n){var i=this._itemVisuals[t]||{};this._itemVisuals[t]=i,Nb(e)?Ze(i,e):i[e]=n},t.prototype.clearAllVisual=function(){this._visual={},this._itemVisuals=[]},t.prototype.setLayout=function(t,e){Nb(t)?Ze(this._layout,t):this._layout[t]=e},t.prototype.getLayout=function(t){return this._layout[t]},t.prototype.getItemLayout=function(t){return this._itemLayouts[t]},t.prototype.setItemLayout=function(t,e,n){this._itemLayouts[t]=n?Ze(this._itemLayouts[t]||{},e):e},t.prototype.clearItemLayouts=function(){this._itemLayouts.length=0},t.prototype.setItemGraphicEl=function(t,e){!function(t,e,n,i){if(i){var r=nu(i);r.dataIndex=n,r.dataType=e,r.seriesIndex=t,r.ssrType="chart","group"===i.type&&i.traverse(function(i){var r=nu(i);r.seriesIndex=t,r.dataIndex=n,r.dataType=e,r.ssrType="chart"})}}(this.hostModel&&this.hostModel.seriesIndex,this.dataType,t,e),this._graphicEls[t]=e},t.prototype.getItemGraphicEl=function(t){return this._graphicEls[t]},t.prototype.eachItemGraphicEl=function(t,e){en(this._graphicEls,function(n,i){n&&t&&t.call(e,n,i)})},t.prototype.cloneShallow=function(e){return e||(e=new t(this._schema?this._schema:Rb(this.dimensions,this._getDimInfo,this),this.hostModel)),Eb(e,this),e._store=this._store,e},t.prototype.wrapMethod=function(t,e){var n=this[t];un(n)&&(this.__wrappedMethods=this.__wrappedMethods||[],this.__wrappedMethods.push(t),this[t]=function(){var t=n.apply(this,arguments);return e.apply(this,[t].concat(Sn(arguments)))})},t.internalField=(Db=function(t){var e=t._invertedIndicesMap;en(e,function(n,i){var r=t._dimInfos[i],o=r.ordinalMeta,a=t._store;if(o){n=e[i]=new Hb(o.categories.length);for(var s=0;s1&&(s+="__ec__"+c),i[e]=s}})),t}();function Vb(t,e){Jg(t)||(t=ev(t));var n=(e=e||{}).coordDimensions||[],i=e.dimensionsDefine||t.dimensionsDefine||[],r=En(),o=[],a=function(t,e,n,i){var r=Math.max(t.dimensionsDetectedCount||1,e.length,n.length,i||0);return en(e,function(t){var e;fn(t)&&(e=t.dimsDef)&&(r=Math.max(r,e.length))}),r}(t,n,i,e.dimensionsCount),s=e.canOmitUnusedDimensions&&Ab(a),l=i===t.dimensionsDefine,c=l?Tb(t):kb(i),u=e.encodeDefine;!u&&e.encodeDefaulter&&(u=e.encodeDefaulter(t,a));for(var d=En(u),h=new Bv(a),p=0;p0&&(i.name=r+(o-1)),o++,e.set(r,o)}}(o),new Cb({source:t,dimensions:o,fullDimensionCount:a,dimensionOmitted:s})}function Wb(t,e,n){if(n||e.hasKey(t)){for(var i=0;e.hasKey(t+i);)i++;t+=i}return e.set(t,!0),t}var Ub=function(t){this.coordSysDims=[],this.axisMap=En(),this.categoryAxisMap=En(),this.coordSysName=t};var Gb={cartesian2d:function(t,e,n,i){var r=t.getReferringComponents("xAxis",Rs).models[0],o=t.getReferringComponents("yAxis",Rs).models[0];e.coordSysDims=["x","y"],n.set("x",r),n.set("y",o),qb(r)&&(i.set("x",r),e.firstCategoryDimIndex=0),qb(o)&&(i.set("y",o),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=1))},singleAxis:function(t,e,n,i){var r=t.getReferringComponents("singleAxis",Rs).models[0];e.coordSysDims=["single"],n.set("single",r),qb(r)&&(i.set("single",r),e.firstCategoryDimIndex=0)},polar:function(t,e,n,i){var r=t.getReferringComponents("polar",Rs).models[0],o=r.findAxisModel("radiusAxis"),a=r.findAxisModel("angleAxis");e.coordSysDims=["radius","angle"],n.set("radius",o),n.set("angle",a),qb(o)&&(i.set("radius",o),e.firstCategoryDimIndex=0),qb(a)&&(i.set("angle",a),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=1))},geo:function(t,e,n,i){e.coordSysDims=["lng","lat"]},parallel:function(t,e,n,i){var r=t.ecModel,o=r.getComponent("parallel",t.get("parallelIndex")),a=e.coordSysDims=o.dimensions.slice();en(o.parallelAxisIndex,function(t,o){var s=r.getComponent("parallelAxis",t),l=a[o];n.set(l,s),qb(s)&&(i.set(l,s),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=o))})},matrix:function(t,e,n,i){var r=t.getReferringComponents("matrix",Rs).models[0];e.coordSysDims=["x","y"];var o=r.getDimensionModel("x"),a=r.getDimensionModel("y");n.set("x",o),n.set("y",a),i.set("x",o),i.set("y",a)}};function qb(t){return"category"===t.get("type")}function jb(t,e,n){var i,r,o,a=(n=n||{}).byIndex,s=n.stackedCoordDimension;!function(t){return!Mb(t.schema)}(e)?(r=e.schema,i=r.dimensions,o=e.store):i=e;var l,c,u,d,h=!(!t||!t.get("stack"));if(en(i,function(t,e){dn(t)&&(i[e]=t={name:t}),h&&!t.isExtraCoord&&(a||l||!t.ordinalMeta||(l=t),c||"ordinal"===t.type||"time"===t.type||s&&s!==t.coordDim||(c=t))}),!c||a||l||(a=!0),c){u="__\0ecstackresult_"+t.id,d="__\0ecstackedover_"+t.id,l&&(l.createInvertedIndices=!0);var p=c.coordDim,f=c.type,g=0;en(i,function(t){t.coordDim===p&&g++});var v={name:u,coordDim:p,coordDimIndex:g,type:f,isExtraCoord:!0,isCalculationCoord:!0,storeDimIndex:i.length},m={name:d,coordDim:d,coordDimIndex:g+1,type:f,isExtraCoord:!0,isCalculationCoord:!0,storeDimIndex:i.length+1};r?(o&&(v.storeDimIndex=o.ensureCalculationDimension(d,f),m.storeDimIndex=o.ensureCalculationDimension(u,f)),r.appendCalculationDimension(v),r.appendCalculationDimension(m)):(i.push(v),i.push(m))}return{stackedDimension:c&&c.name,stackedByDimension:l&&l.name,isStackedByIndex:a,stackedOverDimension:d,stackResultDimension:u}}function Xb(t,e){return!!e&&e===t.getCalculationInfo("stackedDimension")}function Yb(t,e,n){n=n||{};var i,r,o=e.getSourceManager();r=(i=o.getSource()).sourceFormat===Yf;var a=function(t){var e=t.get("coordinateSystem"),n=new Ub(e),i=Gb[e];if(i)return i(t,n,n.axisMap,n.categoryAxisMap),n}(e),s=function(t,e){var n,i=t.get("coordinateSystem"),r=_f.get(i);return e&&e.coordSysDims&&(n=nn(e.coordSysDims,function(t){var n={name:t},i=e.axisMap.get(t);if(i){var r=i.get("type");n.type=function(t){return"category"===t?"ordinal":"time"===t?"time":"float"}(r)}return n})),n||(n=r&&(r.getDimensionsInfo?r.getDimensionsInfo():r.dimensions.slice())||["x","y"]),n}(e,a),l=n.useEncodeDefaulter,c=un(l)?l:l?ln(sg,s,e):null,u=Vb(i,{coordDimensions:s,generateCoord:n.generateCoord,encodeDefine:e.getEncode(),encodeDefaulter:c,canOmitUnusedDimensions:!r}),d=function(t,e,n){var i,r;return n&&en(t,function(t,o){var a=t.coordDim,s=n.categoryAxisMap.get(a);s&&(null==i&&(i=o),t.ordinalMeta=s.getOrdinalMeta(),e&&(t.createInvertedIndices=!0)),null!=t.otherDims.itemName&&(r=!0)}),r||null==i||(t[i].otherDims.itemName=0),i}(u.dimensions,n.createInvertedIndices,a),h=r?null:o.getSharedDataStore(u),p=jb(e,{schema:u,store:h}),f=new $b(u,e);f.setCalculationInfo(p);var g=null!=d&&function(t){if(t.sourceFormat===Yf){var e=function(t){var e=0;for(;er&&(a=o.interval=r);var s=o.intervalPrecision=Jb(a);return function(t,e){!isFinite(t[0])&&(t[0]=e[0]),!isFinite(t[1])&&(t[1]=e[1]),tx(t,0,e),tx(t,1,e),t[0]>t[1]&&(t[0]=t[1])}(o.niceTickExtent=[is(Math.ceil(t[0]/a)*a,s),is(Math.floor(t[1]/a)*a,s)],t),o}function Qb(t){var e=Math.pow(10,us(t)),n=t/e;return n?2===n?n=3:3===n?n=5:n*=2:n=1,is(n*e)}function Jb(t){return rs(t)+2}function tx(t,e,n){t[e]=Math.max(Math.min(t[e],n[1]),n[0])}function ex(t,e){return t>=e[0]&&t<=e[1]}var nx=function(){function t(){this.normalize=ix,this.scale=rx}return t.prototype.updateMethods=function(t){t.hasBreaks()?(this.normalize=sn(t.normalize,t),this.scale=sn(t.scale,t)):(this.normalize=ix,this.scale=rx)},t}();function ix(t,e){return e[1]===e[0]?.5:(t-e[0])/(e[1]-e[0])}function rx(t,e){return t*(e[1]-e[0])+e[0]}function ox(t,e,n){var i=Math.log(t);return[Math.log(n?e[0]:Math.max(0,e[0]))/i,Math.log(n?e[1]:Math.max(0,e[1]))/i]}var ax=function(){function t(t){this._calculator=new nx,this._setting=t||{},this._extent=[1/0,-1/0]}return t.prototype.getSetting=function(t){return this._setting[t]},t.prototype._innerUnionExtent=function(t){var e=this._extent;this._innerSetExtent(t[0]e[1]?t[1]:e[1])},t.prototype.unionExtentFromData=function(t,e){this._innerUnionExtent(t.getApproximateExtent(e))},t.prototype.getExtent=function(){return this._extent.slice()},t.prototype.setExtent=function(t,e){this._innerSetExtent(t,e)},t.prototype._innerSetExtent=function(t,e){var n=this._extent;isNaN(t)||(n[0]=t),isNaN(e)||(n[1]=e),this._brkCtx&&this._brkCtx.update(n)},t.prototype.setBreaksFromOption=function(t){},t.prototype._innerSetBreak=function(t){this._brkCtx&&(this._brkCtx.setBreaks(t),this._calculator.updateMethods(this._brkCtx),this._brkCtx.update(this._extent))},t.prototype._innerGetBreaks=function(){return this._brkCtx?this._brkCtx.breaks:[]},t.prototype.hasBreaks=function(){return!!this._brkCtx&&this._brkCtx.hasBreaks()},t.prototype._getExtentSpanWithBreaks=function(){return this._brkCtx&&this._brkCtx.hasBreaks()?this._brkCtx.getExtentSpan():this._extent[1]-this._extent[0]},t.prototype.isInExtentRange=function(t){return this._extent[0]<=t&&this._extent[1]>=t},t.prototype.isBlank=function(){return this._isBlank},t.prototype.setBlank=function(t){this._isBlank=t},t}();Xs(ax);var sx=0,lx=function(){function t(t){this.categories=t.categories||[],this._needCollect=t.needCollect,this._deduplication=t.deduplication,this.uid=++sx,this._onCollect=t.onCollect}return t.createByAxisModel=function(e){var n=e.option,i=n.data,r=i&&nn(i,cx);return new t({categories:r,needCollect:!r,deduplication:!1!==n.dedplication})},t.prototype.getOrdinal=function(t){return this._getOrCreateMap().get(t)},t.prototype.parseAndCollect=function(t){var e,n=this._needCollect;if(!dn(t)&&!n)return t;if(n&&!this._deduplication)return e=this.categories.length,this.categories[e]=t,this._onCollect&&this._onCollect(t,e),e;var i=this._getOrCreateMap();return null==(e=i.get(t))&&(n?(e=this.categories.length,this.categories[e]=t,i.set(t,e),this._onCollect&&this._onCollect(t,e)):e=NaN),e},t.prototype._getOrCreateMap=function(){return this._map||(this._map=En(this.categories))},t}();function cx(t){return fn(t)&&null!=t.value?t.value:t+""}var ux=function(t){function e(e){var n=t.call(this,e)||this;n.type="ordinal";var i=n.getSetting("ordinalMeta");return i||(i=new lx({})),cn(i)&&(i=new lx({categories:nn(i,function(t){return fn(t)?t.value:t})})),n._ordinalMeta=i,n._extent=n.getSetting("extent")||[0,i.categories.length-1],n}return _(e,t),e.prototype.parse=function(t){return null==t?NaN:dn(t)?this._ordinalMeta.getOrdinal(t):Math.round(t)},e.prototype.contain=function(t){return ex(t,this._extent)&&t>=0&&t=0&&t=0&&t=t},e.prototype.getOrdinalMeta=function(){return this._ordinalMeta},e.prototype.calcNiceTicks=function(){},e.prototype.calcNiceExtent=function(){},e.type="ordinal",e}(ax);ax.registerClass(ux);var dx=is,hx=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.type="interval",e._interval=0,e._intervalPrecision=2,e}return _(e,t),e.prototype.parse=function(t){return null==t||""===t?NaN:Number(t)},e.prototype.contain=function(t){return ex(t,this._extent)},e.prototype.normalize=function(t){return this._calculator.normalize(t,this._extent)},e.prototype.scale=function(t){return this._calculator.scale(t,this._extent)},e.prototype.getInterval=function(){return this._interval},e.prototype.setInterval=function(t){this._interval=t,this._niceExtent=this._extent.slice(),this._intervalPrecision=Jb(t)},e.prototype.getTicks=function(t){t=t||{};var e=this._interval,n=this._extent,i=this._niceExtent,r=this._intervalPrecision,o=[];if(!e)return o;t.breakTicks;n[0]=0&&(s=dx(s+l*e,r))}if(o.length>0&&s===o[o.length-1].value)break;if(o.length>1e4)return[]}var c=o.length?o[o.length-1].value:i[1];return n[1]>c&&(t.expandToNicedExtent?o.push({value:dx(c+e,r)}):o.push({value:n[1]})),t.breakTicks,o},e.prototype.getMinorTicks=function(t){for(var e=this.getTicks({expandToNicedExtent:!0}),n=[],i=this.getExtent(),r=1;ri[0]&&d0&&(o=null===o?s:Math.min(o,s))}n[i]=o}}return n}(t),n=[];return en(t,function(t){var i,r=t.coordinateSystem.getBaseAxis(),o=r.getExtent();if("category"===r.type)i=r.getBandWidth();else if("value"===r.type||"time"===r.type){var a=r.dim+"_"+r.index,s=e[a],l=Math.abs(o[1]-o[0]),c=r.scale.getExtent(),u=Math.abs(c[1]-c[0]);i=s?l/u*s:l}else{var d=t.getData();i=Math.abs(o[1]-o[0])/d.count()}var h=es(t.get("barWidth"),i),p=es(t.get("barMaxWidth"),i),f=es(t.get("barMinWidth")||(function(t){return t.pipelineContext&&t.pipelineContext.large}(t)?.5:1),i),g=t.get("barGap"),v=t.get("barCategoryGap"),m=t.get("defaultBarGap");n.push({bandWidth:i,barWidth:h,barMaxWidth:p,barMinWidth:f,barGap:g,barCategoryGap:v,defaultBarGap:m,axisKey:mx(r),stackId:vx(t)})}),function(t){var e={};en(t,function(t,n){var i=t.axisKey,r=t.bandWidth,o=e[i]||{bandWidth:r,remainedWidth:r,autoWidthCount:0,categoryGap:null,gap:t.defaultBarGap||0,stacks:{}},a=o.stacks;e[i]=o;var s=t.stackId;a[s]||o.autoWidthCount++,a[s]=a[s]||{width:0,maxWidth:0};var l=t.barWidth;l&&!a[s].width&&(a[s].width=l,l=Math.min(o.remainedWidth,l),o.remainedWidth-=l);var c=t.barMaxWidth;c&&(a[s].maxWidth=c);var u=t.barMinWidth;u&&(a[s].minWidth=u);var d=t.barGap;null!=d&&(o.gap=d);var h=t.barCategoryGap;null!=h&&(o.categoryGap=h)});var n={};return en(e,function(t,e){n[e]={};var i=t.stacks,r=t.bandWidth,o=t.categoryGap;if(null==o){var a=an(i).length;o=Math.max(35-4*a,15)+"%"}var s=es(o,r),l=es(t.gap,1),c=t.remainedWidth,u=t.autoWidthCount,d=(c-s)/(u+(u-1)*l);d=Math.max(d,0),en(i,function(t){var e=t.maxWidth,n=t.minWidth;if(t.width){i=t.width;e&&(i=Math.min(i,e)),n&&(i=Math.max(i,n)),t.width=i,c-=i+l*i,u--}else{var i=d;e&&ei&&(i=n),i!==d&&(t.width=i,c-=i+l*i,u--)}}),d=(c-s)/(u+(u-1)*l),d=Math.max(d,0);var h,p=0;en(i,function(t,e){t.width||(t.width=d),h=t,p+=t.width*(1+l)}),h&&(p-=h.width*l);var f=-p/2;en(i,function(t,i){n[e][i]=n[e][i]||{bandWidth:r,offset:f,width:t.width},f+=t.width*(1+l)})}),n}(n)}var _x=function(t){function e(e){var n=t.call(this,e)||this;return n.type="time",n}return _(e,t),e.prototype.getLabel=function(t){var e=this.getSetting("useUTC");return Gp(t.value,Hp[function(t){switch(t){case"year":case"month":return"day";case"millisecond":return"millisecond";default:return"second"}}(Wp(this._minLevelUnit))]||Hp.second,e,this.getSetting("locale"))},e.prototype.getFormattedLabel=function(t,e,n){var i=this.getSetting("useUTC");return function(t,e,n,i,r){var o=null;if(dn(n))o=n;else if(un(n)){var a={time:t.time,level:t.time.level},s=null;s&&s.makeAxisLabelFormatterParamBreak(a,t.break),o=n(t.value,e,a)}else{var l=t.time;if(l){var c=n[l.lowerTimeUnit][l.upperTimeUnit];o=c[Math.min(l.level,c.length-1)]||""}else{var u=qp(t.value,r);o=n[u][u][0]}}return Gp(new Date(t.value),o,r,i)}(t,e,n,this.getSetting("locale"),i)},e.prototype.getTicks=function(t){var e=this._interval,n=this._extent,i=[];if(!e)return i;var r=this.getSetting("useUTC"),o=qp(n[1],r);i.push({value:n[0],time:{level:0,upperTimeUnit:o,lowerTimeUnit:o}});var a=function(t,e,n,i,r,o){var a=1e4,s=Fp,l=0;function c(t,e,n,r,s,c,u){for(var d=function(t,e){var n=new Date(0);n[t](1);var i=n.getTime();n[t](1+e);var r=n.getTime()-i;return function(t,e){return Math.max(0,Math.round((e-t)/r))}}(s,t),h=e,p=new Date(h);ha));)if(p[s](p[r]()+t),h=p.getTime(),o){var f=o.calcNiceTickMultiple(h,d);f>0&&(p[s](p[r]()+f*t),h=p.getTime())}u.push({value:h,notAdd:!0})}function u(t,r,o){var a=[],s=!r.length;if(!function(t,e,n,i){return jp(new Date(e),t,i).getTime()===jp(new Date(n),t,i).getTime()}(Wp(t),i[0],i[1],n)){s&&(r=[{value:Tx(i[0],t,n)},{value:i[1]}]);for(var l=0;l=i[0]&&u<=i[1]&&c(h,u,d,p,f,g,a),"year"===t&&o.length>1&&0===l&&o.unshift({value:o[0].value-h})}}for(l=0;l=i[0]&&_<=i[1]&&p++)}var b=r/e;if(p>1.5*b&&f>b/1.5)break;if(d.push(m),p>b||t===s[g])break}h=[]}}var x=on(nn(d,function(t){return on(t,function(t){return t.value>=i[0]&&t.value<=i[1]&&!t.notAdd})}),function(t){return t.length>0}),w=[],S=x.length-1;for(g=0;gn&&(this._approxInterval=n);var r=bx.length,o=Math.min(function(t,e,n,i){for(;n>>1;t[r][1]16?16:t>7.5?7:t>3.5?4:t>1.5?2:1}function Sx(t){return(t/=2592e6)>6?6:t>3?3:t>2?2:1}function Cx(t){return(t/=Lp)>12?12:t>6?6:t>3.5?4:t>2?2:1}function Mx(t,e){return(t/=e?Pp:Ip)>30?30:t>20?20:t>15?15:t>10?10:t>5?5:t>2?2:1}function kx(t){return ds(t)}function Tx(t,e,n){var i=Math.max(0,Qe(Bp,e)-1);return jp(new Date(t),Bp[i],n).getTime()}ax.registerClass(_x);var Ax=is,Dx=Math.floor,Ix=Math.ceil,Px=Math.pow,Lx=Math.log,Ex=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.type="log",e.base=10,e._originalScale=new hx,e}return _(e,t),e.prototype.getTicks=function(e){e=e||{};var n=this._extent.slice(),i=this._originalScale.getExtent(),r=t.prototype.getTicks.call(this,e),o=this.base;return this._originalScale._innerGetBreaks(),nn(r,function(t){var e=t.value,r=null,a=Px(o,e);return e===n[0]&&this._fixMin?r=i[0]:e===n[1]&&this._fixMax&&(r=i[1]),null!=r&&(a=Ox(a,r)),{value:a,break:void 0}},this)},e.prototype._getNonTransBreaks=function(){return this._originalScale._innerGetBreaks()},e.prototype.setExtent=function(e,n){this._originalScale.setExtent(e,n);var i=ox(this.base,[e,n]);t.prototype.setExtent.call(this,i[0],i[1])},e.prototype.getExtent=function(){var e=this.base,n=t.prototype.getExtent.call(this);n[0]=Px(e,n[0]),n[1]=Px(e,n[1]);var i=this._originalScale.getExtent();return this._fixMin&&(n[0]=Ox(n[0],i[0])),this._fixMax&&(n[1]=Ox(n[1],i[1])),n},e.prototype.unionExtentFromData=function(t,e){this._originalScale.unionExtentFromData(t,e);var n=ox(this.base,t.getApproximateExtent(e),!0);this._innerUnionExtent(n)},e.prototype.calcNiceTicks=function(t){t=t||10;var e=this._extent.slice(),n=this._getExtentSpanWithBreaks();if(isFinite(n)&&!(n<=0)){var i,r=(i=n,Math.pow(10,us(i)));for(t/n*r<=.5&&(r*=10);!isNaN(r)&&Math.abs(r)<1&&Math.abs(r)>0;)r*=10;var o=[Ax(Ix(e[0]/r)*r),Ax(Dx(e[1]/r)*r)];this._interval=r,this._intervalPrecision=Jb(r),this._niceExtent=o}},e.prototype.calcNiceExtent=function(e){t.prototype.calcNiceExtent.call(this,e),this._fixMin=e.fixMin,this._fixMax=e.fixMax},e.prototype.contain=function(e){return e=Lx(e)/Lx(this.base),t.prototype.contain.call(this,e)},e.prototype.normalize=function(e){return e=Lx(e)/Lx(this.base),t.prototype.normalize.call(this,e)},e.prototype.scale=function(e){return e=t.prototype.scale.call(this,e),Px(this.base,e)},e.prototype.setBreaksFromOption=function(t){},e.type="log",e}(hx);function Ox(t,e){return Ax(t,rs(e))}ax.registerClass(Ex);var zx=function(){function t(t,e,n){this._prepareParams(t,e,n)}return t.prototype._prepareParams=function(t,e,n){n[1]0&&s>0&&!l&&(a=0),a<0&&s<0&&!c&&(s=0));var d=this._determinedMin,h=this._determinedMax;return null!=d&&(a=d,l=!0),null!=h&&(s=h,c=!0),{min:a,max:s,minFixed:l,maxFixed:c,isBlank:u}},t.prototype.modifyDataMinMax=function(t,e){this[Rx[t]]=e},t.prototype.setDeterminedMinMax=function(t,e){this[Nx[t]]=e},t.prototype.freeze=function(){this.frozen=!0},t}(),Nx={min:"_determinedMin",max:"_determinedMax"},Rx={min:"_dataMin",max:"_dataMax"};function Hx(t,e){return null==e?null:_n(e)?NaN:t.parse(e)}function Bx(t,e){var n=t.type,i=function(t,e,n){var i=t.rawExtentInfo;return i||(i=new zx(t,e,n),t.rawExtentInfo=i,i)}(t,e,t.getExtent()).calculate();t.setBlank(i.isBlank);var r=i.min,o=i.max,a=e.ecModel;if(a&&"time"===n){var s=function(t,e){var n=[];return e.eachSeriesByType(t,function(t){(function(t){return t.coordinateSystem&&"cartesian2d"===t.coordinateSystem.type})(t)&&n.push(t)}),n}("bar",a),l=!1;if(en(s,function(t){l=l||t.getBaseAxis()===e.axis}),l){var c=yx(s),u=function(t,e,n,i){var r=n.axis.getExtent(),o=Math.abs(r[1]-r[0]),a=function(t,e){if(t&&e)return t[mx(e)]}(i,n.axis);if(void 0===a)return{min:t,max:e};var s=1/0;en(a,function(t){s=Math.min(t.offset,s)});var l=-1/0;en(a,function(t){l=Math.max(t.offset+t.width,l)}),s=Math.abs(s),l=Math.abs(l);var c=s+l,u=e-t,d=u/(1-(s+l)/o)-u;return e+=d*(l/c),t-=d*(s/c),{min:t,max:e}}(r,o,e,c);r=u.min,o=u.max}}return{extent:[r,o],fixMin:i.minFixed,fixMax:i.maxFixed}}function Fx(t,e){var n=e,i=Bx(t,n),r=i.extent,o=n.get("splitNumber");t instanceof Ex&&(t.base=n.get("logBase"));var a=t.type,s=n.get("interval"),l="interval"===a||"time"===a;t.setBreaksFromOption(Xx(n)),t.setExtent(r[0],r[1]),t.calcNiceExtent({splitNumber:o,fixMin:i.fixMin,fixMax:i.fixMax,minInterval:l?n.get("minInterval"):null,maxInterval:l?n.get("maxInterval"):null}),null!=s&&t.setInterval&&t.setInterval(s)}function $x(t){var e=t.getLabelModel().get("formatter");if("time"===t.type){var n=$p(e);return function(e,i){return t.scale.getFormattedLabel(e,i,n)}}if(dn(e))return function(n){var i=t.scale.getLabel(n);return e.replace("{value}",null!=i?i:"")};if(un(e)){if("category"===t.type)return function(n,i){return e(Vx(t,n),n.value-t.scale.getExtent()[0],null)};var i=null;return function(n,r){var o=null;return i&&(o=i.makeAxisLabelFormatterParamBreak(o,n.break)),e(Vx(t,n),r,o)}}return function(e){return t.scale.getLabel(e)}}function Vx(t,e){return"category"===t.type?t.scale.getLabel(e):e.value}function Wx(t){var e=t.get("interval");return null==e?"auto":e}function Ux(t){return"category"===t.type&&0===Wx(t.getLabelModel())}function Gx(t,e){var n={};return en(t.mapDimensionsAll(e),function(e){n[function(t,e){return Xb(t,e)?t.getCalculationInfo("stackResultDimension"):e}(t,e)]=!0}),an(n)}function qx(t){return"middle"===t||"center"===t}function jx(t){return t.getShallow("show")}function Xx(t){t.get("breaks",!0)}var Yx=function(){function t(){}return t.prototype.getNeedCrossZero=function(){return!this.option.scale},t.prototype.getCoordSysModel=function(){},t}(),Zx=[],Kx={registerPreprocessor:ab,registerProcessor:sb,registerPostInit:function(t){lb("afterinit",t)},registerPostUpdate:function(t){lb("afterupdate",t)},registerUpdateLifecycle:lb,registerAction:cb,registerCoordinateSystem:function(t,e){_f.register(t,e)},registerLayout:function(t,e){hb(K_,t,e,1e3,"layout")},registerVisual:ub,registerTransform:fb,registerLoading:pb,registerMap:function(t,e,n){var i=c_["registerMap"];i&&i(t,e,n)},registerImpl:function(t,e){c_[t]=e},PRIORITY:h_,ComponentModel:Hf,ComponentView:wm,SeriesModel:fm,ChartView:km,registerComponentModel:function(t){Hf.registerClass(t)},registerComponentView:function(t){wm.registerClass(t)},registerSeriesModel:function(t){fm.registerClass(t)},registerChartView:function(t){km.registerClass(t)},registerCustomSeries:function(t,e){},registerSubTypeDefaulter:function(t,e){Hf.registerSubTypeDefaulter(t,e)},registerPainter:function(t,e){var n;n=e,Ga[t]=n}};function Qx(t){cn(t)?en(t,function(t){Qx(t)}):Qe(Zx,t)>=0||(Zx.push(t),un(t)&&(t={install:t}),t.install(Kx))}var Jx=Es(),tw=Es(),ew=1,nw=2;function iw(t){return{out:{noPxChangeTryDetermine:[]},kind:t}}function rw(t,e){var n=nn(e,function(e){return t.scale.parse(e)});return"time"===t.type&&n.length>0&&(n.sort(),n.unshift(n[0]),n.push(n[n.length-1])),n}function ow(t,e){var n=t.getLabelModel().get("customValues");if(n){var i=$x(t),r=t.scale.getExtent();return{labels:nn(on(rw(t,n),function(t){return t>=r[0]&&t<=r[1]}),function(e){var n={value:e};return{formattedLabel:i(n),rawLabel:t.scale.getLabel(n),tickValue:e,time:void 0,break:void 0}})}}return"category"===t.type?function(t,e){var n=t.getLabelModel(),i=sw(t,n,e);return!n.get("show")||t.scale.isBlank()?{labels:[]}:i}(t,e):function(t){var e=t.scale.getTicks(),n=$x(t);return{labels:nn(e,function(e,i){return{formattedLabel:n(e,i),rawLabel:t.scale.getLabel(e),tickValue:e.value,time:e.time,break:e.break}})}}(t)}function aw(t,e,n){var i=t.getTickModel().get("customValues");if(i){var r=t.scale.getExtent();return{ticks:on(rw(t,i),function(t){return t>=r[0]&&t<=r[1]})}}return"category"===t.type?function(t,e){var n,i,r=lw(t),o=Wx(e),a=dw(r,o);if(a)return a;e.get("show")&&!t.scale.isBlank()||(n=[]);if(un(o))n=vw(t,o,!0);else if("auto"===o){var s=sw(t,t.getLabelModel(),iw(nw));i=s.labelCategoryInterval,n=nn(s.labels,function(t){return t.tickValue})}else n=gw(t,i=o,!0);return hw(r,o,{ticks:n,tickCategoryInterval:i})}(t,e):{ticks:nn(t.scale.getTicks(n),function(t){return t.value})}}function sw(t,e,n){var i,r,o=cw(t),a=Wx(e),s=n.kind===ew;if(!s){var l=dw(o,a);if(l)return l}un(a)?i=vw(t,a):(r="auto"===a?function(t,e){if(e.kind===ew){var n=t.calculateCategoryInterval(e);return e.out.noPxChangeTryDetermine.push(function(){return tw(t).autoInterval=n,!0}),n}var i=tw(t).autoInterval;return null!=i?i:tw(t).autoInterval=t.calculateCategoryInterval(e)}(t,n):a,i=gw(t,r));var c={labels:i,labelCategoryInterval:r};return s?n.out.noPxChangeTryDetermine.push(function(){return hw(o,a,c),!0}):hw(o,a,c),c}var lw=uw("axisTick"),cw=uw("axisLabel");function uw(t){return function(e){return tw(e)[t]||(tw(e)[t]={list:[]})}}function dw(t,e){for(var n=0;ne&&i.axisExtent0===r[0]&&i.axisExtent1===r[1])return o;i.lastTickCount=n,i.lastAutoInterval=e,i.axisExtent0=r[0],i.axisExtent1=r[1]}function gw(t,e,n){var i=$x(t),r=t.scale,o=r.getExtent(),a=t.getLabelModel(),s=[],l=Math.max((e||0)+1,1),c=o[0],u=r.count();0!==c&&l>1&&u/l>2&&(c=Math.round(Math.ceil(c/l)*l));var d=Ux(t),h=a.get("showMinLabel")||d,p=a.get("showMaxLabel")||d;h&&c!==o[0]&&g(o[0]);for(var f=c;f<=o[1];f+=l)g(f);function g(t){var e={value:t};s.push(n?t:{formattedLabel:i(e),rawLabel:r.getLabel(e),tickValue:t,time:void 0,break:void 0})}return p&&f-l!==o[1]&&g(o[1]),s}function vw(t,e,n){var i=t.scale,r=$x(t),o=[];return en(i.getTicks(),function(t){var a=i.getLabel(t),s=t.value;e(t.value,a)&&o.push(n?s:{formattedLabel:r(t),rawLabel:a,tickValue:s,time:void 0,break:void 0})}),o}var mw=[0,1],yw=function(){function t(t,e,n){this.onBand=!1,this.inverse=!1,this.dim=t,this.scale=e,this._extent=n||[0,0]}return t.prototype.contain=function(t){var e=this._extent,n=Math.min(e[0],e[1]),i=Math.max(e[0],e[1]);return t>=n&&t<=i},t.prototype.containData=function(t){return this.scale.contain(this.scale.parse(t))},t.prototype.getExtent=function(){return this._extent.slice()},t.prototype.getPixelPrecision=function(t){return function(t,e){var n=Math.log,i=Math.LN10,r=Math.floor(n(t[1]-t[0])/i),o=Math.round(n(Ja(e[1]-e[0]))/i),a=Math.min(Math.max(-r+o,0),20);return isFinite(a)?a:20}(t||this.scale.getExtent(),this._extent)},t.prototype.setExtent=function(t,e){var n=this._extent;n[0]=t,n[1]=e},t.prototype.dataToCoord=function(t,e){var n=this._extent,i=this.scale;return t=i.normalize(i.parse(t)),this.onBand&&"ordinal"===i.type&&_w(n=n.slice(),i.count()),ts(t,mw,n,e)},t.prototype.coordToData=function(t,e){var n=this._extent,i=this.scale;this.onBand&&"ordinal"===i.type&&_w(n=n.slice(),i.count());var r=ts(t,n,mw,e);return this.scale.scale(r)},t.prototype.pointToData=function(t,e){},t.prototype.getTicksCoords=function(t){var e=(t=t||{}).tickModel||this.getTickModel(),n=nn(aw(this,e,{breakTicks:t.breakTicks,pruneByBreak:t.pruneByBreak}).ticks,function(t){return{coord:this.dataToCoord("ordinal"===this.scale.type?this.scale.getRawOrdinalNumber(t):t),tickValue:t}},this);return function(t,e,n,i){var r=e.length;if(!t.onBand||n||!r)return;var o,a,s=t.getExtent();if(1===r)e[0].coord=s[0],e[0].onBand=!0,o=e[1]={coord:s[1],tickValue:e[0].tickValue,onBand:!0};else{var l=e[r-1].tickValue-e[0].tickValue,c=(e[r-1].coord-e[0].coord)/l;en(e,function(t){t.coord-=c/2,t.onBand=!0});var u=t.scale.getExtent();a=1+u[1]-e[r-1].tickValue,o={coord:e[r-1].coord+c*a,tickValue:u[1]+1,onBand:!0},e.push(o)}var d=s[0]>s[1];h(e[0].coord,s[0])&&(i?e[0].coord=s[0]:e.shift());i&&h(s[0],e[0].coord)&&e.unshift({coord:s[0],onBand:!0});h(s[1],o.coord)&&(i?o.coord=s[1]:e.pop());i&&h(o.coord,s[1])&&e.push({coord:s[1],onBand:!0});function h(t,e){return t=is(t),e=is(e),d?t>e:t0&&t<100||(t=5),nn(this.scale.getMinorTicks(t),function(t){return nn(t,function(t){return{coord:this.dataToCoord(t),tickValue:t}},this)},this)},t.prototype.getViewLabels=function(t){return ow(this,t=t||iw(nw)).labels},t.prototype.getLabelModel=function(){return this.model.getModel("axisLabel")},t.prototype.getTickModel=function(){return this.model.getModel("axisTick")},t.prototype.getBandWidth=function(){var t=this._extent,e=this.scale.getExtent(),n=e[1]-e[0]+(this.onBand?1:0);0===n&&(n=1);var i=Math.abs(t[1]-t[0]);return Math.abs(i)/n},t.prototype.calculateCategoryInterval=function(t){return function(t,e){var n=e.kind,i=function(t){var e=t.getLabelModel();return{axisRotate:t.getRotate?t.getRotate():t.isHorizontal&&!t.isHorizontal()?90:0,labelRotate:e.get("rotate")||0,font:e.getFont()}}(t),r=$x(t),o=(i.axisRotate-i.labelRotate)/180*Math.PI,a=t.scale,s=a.getExtent(),l=a.count();if(s[1]-s[0]<1)return 0;var c=1;l>40&&(c=Math.max(1,Math.floor(l/40)));for(var u=s[0],d=t.dataToCoord(u+1)-t.dataToCoord(u),h=Math.abs(d*Math.cos(o)),p=Math.abs(d*Math.sin(o)),f=0,g=0;u<=s[1];u+=c){var v,m,y=Ta(r({value:u}),i.font,"center","top");v=1.3*y.width,m=1.3*y.height,f=Math.max(f,v,7),g=Math.max(g,m,7)}var _=f/h,b=g/p;isNaN(_)&&(_=1/0),isNaN(b)&&(b=1/0);var x=Math.max(0,Math.floor(Math.min(_,b)));if(n===ew)return e.out.noPxChangeTryDetermine.push(sn(pw,null,t,x,l)),x;var w=fw(t,x,l);return null!=w?w:x}(this,t=t||iw(nw))},t}();function _w(t,e){var n=(t[1]-t[0])/e/2;t[0]+=n,t[1]-=n}var bw=["label","labelLine","layoutOption","priority","defaultAttr","marginForce","minMarginForce","marginDefault","suggestIgnore"];function xw(t,e,n){n=n||3,e?t.dirty|=n:t.dirty&=~n}function ww(t,e){return e=e||3,null==t.dirty||!!(t.dirty&e)}function Sw(t){if(t)return ww(t)&&function(t,e,n){var i=e.getComputedTransform();t.transform=Uh(t.transform,i);var r=t.localRect=Wh(t.localRect,e.getBoundingRect()),o=e.style,a=o.margin,s=n&&n.marginForce,l=n&&n.minMarginForce,c=n&&n.marginDefault,u=o.__marginType;null==u&&c&&(a=c,u=lp.textMargin);for(var d=0;d<4;d++)Cw[d]=u===lp.minMargin&&l&&null!=l[d]?l[d]:s&&null!=s[d]?s[d]:a?a[d]:0;u===lp.textMargin&&zh(r,Cw,!1,!1);var h=t.rect=Wh(t.rect,r);i&&h.applyTransform(i);u===lp.minMargin&&zh(h,Cw,!1,!1);t.axisAligned=$h(i),(t.label=t.label||{}).ignore=e.ignore,xw(t,!1),xw(t,!0,2)}(t,t.label,t),t}var Cw=[0,0,0,0];function Mw(t,e){for(var n=0;n-1&&(s.style.stroke=s.style.fill,s.style.fill=Bf.color.neutral00,s.style.lineWidth=2),e},e.type="series.line",e.dependencies=["grid","polar"],e.defaultOption={z:3,coordinateSystem:"cartesian2d",legendHoverLink:!0,clip:!0,label:{position:"top"},endLabel:{show:!1,valueAnimation:!0,distance:8},lineStyle:{width:2,type:"solid"},emphasis:{scale:!0},step:!1,smooth:!1,smoothMonotone:null,symbol:"emptyCircle",symbolSize:6,symbolRotate:null,showSymbol:!0,showAllSymbol:"auto",connectNulls:!1,sampling:"none",animationEasing:"linear",progressive:0,hoverLayerThreshold:1/0,universalTransition:{divideShape:"clone"},triggerLineEvent:!1},e}(fm);function Dw(t,e){var n=t.mapDimensionsAll("defaultedLabel"),i=n.length;if(1===i){var r=yv(t,e,n[0]);return null!=r?r+"":null}if(i){for(var o=[],a=0;a0?+h:1;k.scaleX=this._sizeX*T,k.scaleY=this._sizeY*T,this.setSymbolScale(1),Wu(this,l,c,u)},e.prototype.setSymbolScale=function(t){this.scaleX=this.scaleY=t},e.prototype.fadeOut=function(t,e,n){var i=this.childAt(0),r=nu(this).dataIndex,o=n&&n.animation;if(this.silent=i.silent=!0,n&&n.fadeLabel){var a=i.getTextContent();a&&gh(a,{style:{opacity:0}},e,{dataIndex:r,removeOpt:o,cb:function(){i.removeTextContent()}})}else i.removeTextContent();gh(i,{style:{opacity:0},scaleX:0,scaleY:0},e,{dataIndex:r,cb:t,removeOpt:o})},e.getSymbolSize=function(t,e){return cn(n=t.getItemVisual(e,"symbolSize"))||(n=[+n,+n]),[n[0]||0,n[1]||0];var n},e.getSymbolZ2=function(t,e){return t.getItemVisual(e,"z2")},e}(Ua);function Pw(t,e){this.parent.drift(t,e)}function Lw(t,e,n,i){return e&&!isNaN(e[0])&&!isNaN(e[1])&&!(i.isIgnore&&i.isIgnore(n))&&!(i.clipShape&&!i.clipShape.contain(e[0],e[1]))&&"none"!==t.getItemVisual(n,"symbol")}function Ew(t){return null==t||fn(t)||(t={isIgnore:t}),t||{}}function Ow(t){var e=t.hostModel,n=e.getModel("emphasis");return{emphasisItemStyle:n.getModel("itemStyle").getItemStyle(),blurItemStyle:e.getModel(["blur","itemStyle"]).getItemStyle(),selectItemStyle:e.getModel(["select","itemStyle"]).getItemStyle(),focus:n.get("focus"),blurScope:n.get("blurScope"),emphasisDisabled:n.get("disabled"),hoverScale:n.get("scale"),labelStatesModels:Qh(e),cursorStyle:e.get("cursor")}}var zw=function(){function t(t){this.group=new Ua,this._SymbolCtor=t||Iw}return t.prototype.updateData=function(t,e){this._progressiveEls=null,e=Ew(e);var n=this.group,i=t.hostModel,r=this._data,o=this._SymbolCtor,a=e.disableAnimation,s=Ow(t),l={disableAnimation:a},c=e.getSymbolPoint||function(e){return t.getItemLayout(e)};r||n.removeAll(),t.diff(r).add(function(i){var r=c(i);if(Lw(t,r,i,e)){var a=new o(t,i,s,l);a.setPosition(r),t.setItemGraphicEl(i,a),n.add(a)}}).update(function(u,d){var h=r.getItemGraphicEl(d),p=c(u);if(Lw(t,p,u,e)){var f=t.getItemVisual(u,"symbol")||"circle",g=h&&h.getSymbolType&&h.getSymbolType();if(!h||g&&g!==f)n.remove(h),(h=new o(t,u,s,l)).setPosition(p);else{h.updateData(t,u,s,l);var v={x:p[0],y:p[1]};a?h.attr(v):hh(h,v,i)}n.add(h),t.setItemGraphicEl(u,h)}else n.remove(h)}).remove(function(t){var e=r.getItemGraphicEl(t);e&&e.fadeOut(function(){n.remove(e)},i)}).execute(),this._getSymbolPoint=c,this._data=t},t.prototype.updateLayout=function(){var t=this,e=this._data;e&&e.eachItemGraphicEl(function(e,n){var i=t._getSymbolPoint(n);e.setPosition(i),e.markRedraw()})},t.prototype.incrementalPrepareUpdate=function(t){this._seriesScope=Ow(t),this._data=null,this.group.removeAll()},t.prototype.incrementalUpdate=function(t,e,n){function i(t){t.isGroup||(t.incremental=!0,t.ensureState("emphasis").hoverLayer=!0)}this._progressiveEls=[],n=Ew(n);for(var r=t.start;r0?n=i[0]:i[1]<0&&(n=i[1]);return n}(r,n),a=i.dim,s=r.dim,l=e.mapDimension(s),c=e.mapDimension(a),u="x"===s||"radius"===s?1:0,d=nn(t.dimensions,function(t){return e.mapDimension(t)}),h=!1,p=e.getCalculationInfo("stackResultDimension");return Xb(e,d[0])&&(h=!0,d[0]=p),Xb(e,d[1])&&(h=!0,d[1]=p),{dataDimsForPoint:d,valueStart:o,valueAxisDim:s,baseAxisDim:a,stacked:!!h,valueDim:l,baseDim:c,baseDataOffset:u,stackedOverDimension:e.getCalculationInfo("stackedOverDimension")}}function Rw(t,e,n,i){var r=NaN;t.stacked&&(r=n.get(n.getCalculationInfo("stackedOverDimension"),i)),isNaN(r)&&(r=t.valueStart);var o=t.baseDataOffset,a=[];return a[o]=n.get(t.baseDim,i),a[1-o]=r,e.dataToPoint(a)}var Hw=Math.min,Bw=Math.max;function Fw(t,e){return isNaN(t)||isNaN(e)}function $w(t,e,n,i,r,o,a,s,l){for(var c,u,d,h,p,f,g=n,v=0;v=r||g<0)break;if(Fw(m,y)){if(l){g+=o;continue}break}if(g===n)t[o>0?"moveTo":"lineTo"](m,y),d=m,h=y;else{var _=m-c,b=y-u;if(_*_+b*b<.5){g+=o;continue}if(a>0){for(var x=g+o,w=e[2*x],S=e[2*x+1];w===m&&S===y&&v=i||Fw(w,S))p=m,f=y;else{k=w-c,T=S-u;var I=m-c,P=w-m,L=y-u,E=S-y,O=void 0,z=void 0;if("x"===s){var N=k>0?1:-1;p=m-N*(O=Math.abs(I))*a,f=y,A=m+N*(z=Math.abs(P))*a,D=y}else if("y"===s){var R=T>0?1:-1;p=m,f=y-R*(O=Math.abs(L))*a,A=m,D=y+R*(z=Math.abs(E))*a}else O=Math.sqrt(I*I+L*L),p=m-k*a*(1-(M=(z=Math.sqrt(P*P+E*E))/(z+O))),f=y-T*a*(1-M),D=y+T*a*M,A=Hw(A=m+k*a*M,Bw(w,m)),D=Hw(D,Bw(S,y)),A=Bw(A,Hw(w,m)),f=y-(T=(D=Bw(D,Hw(S,y)))-y)*O/z,p=Hw(p=m-(k=A-m)*O/z,Bw(c,m)),f=Hw(f,Bw(u,y)),A=m+(k=m-(p=Bw(p,Hw(c,m))))*z/O,D=y+(T=y-(f=Bw(f,Hw(u,y))))*z/O}t.bezierCurveTo(d,h,p,f,m,y),d=A,h=D}else t.lineTo(m,y)}c=m,u=y,g+=o}return v}var Vw=function(){this.smooth=0,this.smoothConstraint=!0},Ww=function(t){function e(e){var n=t.call(this,e)||this;return n.type="ec-polyline",n}return _(e,t),e.prototype.getDefaultStyle=function(){return{stroke:Bf.color.neutral99,fill:null}},e.prototype.getDefaultShape=function(){return new Vw},e.prototype.buildPath=function(t,e){var n=e.points,i=0,r=n.length/2;if(e.connectNulls){for(;r>0&&Fw(n[2*r-2],n[2*r-1]);r--);for(;i=0){var v=a?(u-i)*g+i:(c-n)*g+n;return a?[t,v]:[v,t]}n=c,i=u;break;case o.C:c=r[l++],u=r[l++],d=r[l++],h=r[l++],p=r[l++],f=r[l++];var m=a?Tr(n,c,d,p,t,s):Tr(i,u,h,f,t,s);if(m>0)for(var y=0;y=0){v=a?Mr(i,u,h,f,_):Mr(n,c,d,p,_);return a?[t,v]:[v,t]}}n=p,i=f}}},e}(Tc),Uw=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return _(e,t),e}(Vw),Gw=function(t){function e(e){var n=t.call(this,e)||this;return n.type="ec-polygon",n}return _(e,t),e.prototype.getDefaultShape=function(){return new Uw},e.prototype.buildPath=function(t,e){var n=e.points,i=e.stackedOnPoints,r=0,o=n.length/2,a=e.smoothMonotone;if(e.connectNulls){for(;o>0&&Fw(n[2*o-2],n[2*o-1]);o--);for(;r=0&&t<=1){var i=t*(e.length-1),r=Math.floor(i),o=Math.ceil(i),a=to(e[r]),s=to(e[o]),l=i-r,c=ro([Wr(Xr(a[0],s[0],l)),Wr(Xr(a[1],s[1],l)),Wr(Xr(a[2],s[2],l)),Ur(Xr(a[3],s[3],l))],"rgba");return n?{color:c,leftIndex:r,rightIndex:o,value:i}:c}}((n-i)/(e.coord-i),[t.color,e.color])}}for(var s=0;se){i?r.push(a(i,l,e)):n&&r.push(a(n,l,0),a(n,l,e));break}n&&(r.push(a(n,l,0)),n=null),r.push(l),i=l}}return r}function Qw(t,e,n){var i=t.get("showAllSymbol"),r="auto"===i;if(!i||r){var o=n.getAxesByScale("ordinal")[0];if(o&&(!r||!function(t,e){var n=t.getExtent(),i=Math.abs(n[1]-n[0])/t.scale.count();isNaN(i)&&(i=0);for(var r=e.count(),o=Math.max(1,Math.round(r/5)),a=0;ai)return!1;return!0}(o,e))){var a=e.mapDimension(o.dim),s={};return en(o.getViewLabels(),function(t){var e=o.scale.getRawOrdinalNumber(t.tickValue);s[e]=1}),function(t){return!s.hasOwnProperty(e.get(a,t))}}}}function Jw(t,e){return isNaN(t)||isNaN(e)}function tS(t,e){return[t[2*e],t[2*e+1]]}function eS(t){if(t.get(["endLabel","show"]))return!0;for(var e=0;e=0;a--){var s=t.getDimensionInfo(i[a].dimension);if("x"===(r=s&&s.coordDim)||"y"===r){o=i[a];break}}if(o){var l=e.getAxis(r),c=nn(o.stops,function(t){return{coord:l.toGlobalCoord(l.dataToCoord(t.value)),color:t.color}}),u=c.length,d=o.outerColors.slice();u&&c[0].coord>c[u-1].coord&&(c.reverse(),d.reverse());var h=Kw(c,"x"===r?n.getWidth():n.getHeight()),p=h.length;if(!p&&u)return c[0].coord<0?d[1]?d[1]:c[u-1].color:d[0]?d[0]:c[0].color;var f=h[0].coord-10,g=h[p-1].coord+10,v=g-f;if(v<.001)return"transparent";en(h,function(t){t.offset=(t.coord-f)/v}),h.push({offset:p?h[p-1].offset:.5,color:d[1]||"transparent"}),h.unshift({offset:p?h[0].offset:.5,color:d[0]||"transparent"});var m=new Kd(0,0,0,0,h,!0);return m[r]=f,m[r+"2"]=g,m}}}(o,i,n)||o.getVisual("style")[o.getVisual("drawType")];if(h&&u.type===i.type&&M===this._step){v&&!p?p=this._newPolygon(l,_):p&&!v&&(f.remove(p),p=this._polygon=null),c||this._initOrUpdateEndLabel(t,i,vf(k));var T=f.getClipPath();if(T)ph(T,{shape:nS(this,i,!1,t).shape},t);else f.setClipPath(nS(this,i,!0,t));b&&d.updateData(o,{isIgnore:w,clipShape:C,disableAnimation:!0,getSymbolPoint:function(t){return[l[2*t],l[2*t+1]]}}),qw(this._stackedOnPoints,_)&&qw(this._points,l)||(g?this._doUpdateAnimation(o,_,i,n,M,m,x):(M&&(_&&(_=Zw(_,l,i,M,x)),l=Zw(l,null,i,M,x)),h.setShape({points:l}),p&&p.setShape({points:l,stackedOnPoints:_})))}else b&&d.updateData(o,{isIgnore:w,clipShape:C,disableAnimation:!0,getSymbolPoint:function(t){return[l[2*t],l[2*t+1]]}}),g&&this._initSymbolLabelAnimation(o,i,C),M&&(_&&(_=Zw(_,l,i,M,x)),l=Zw(l,null,i,M,x)),h=this._newPolyline(l),v?p=this._newPolygon(l,_):p&&(f.remove(p),p=this._polygon=null),c||this._initOrUpdateEndLabel(t,i,vf(k)),f.setClipPath(nS(this,i,!0,t));var A=t.getModel("emphasis"),D=A.get("focus"),I=A.get("blurScope"),P=A.get("disabled");(h.useStyle(Ke(a.getLineStyle(),{fill:"none",stroke:k,lineJoin:"bevel"})),qu(h,t,"lineStyle"),h.style.lineWidth>0&&"bolder"===t.get(["emphasis","lineStyle","width"]))&&(h.getState("emphasis").style.lineWidth=+h.style.lineWidth+1);nu(h).seriesIndex=t.seriesIndex,Wu(h,D,I,P);var L=Yw(t.get("smooth")),E=t.get("smoothMonotone");if(h.setShape({smooth:L,smoothMonotone:E,connectNulls:x}),p){var O=o.getCalculationInfo("stackedOnSeries"),z=0;p.useStyle(Ke(s.getAreaStyle(),{fill:k,opacity:.7,lineJoin:"bevel",decal:o.getVisual("style").decal})),O&&(z=Yw(O.get("smooth"))),p.setShape({smooth:L,stackedOnSmooth:z,smoothMonotone:E,connectNulls:x}),qu(p,t,"areaStyle"),nu(p).seriesIndex=t.seriesIndex,Wu(p,D,I,P)}var N=this._changePolyState;o.eachItemGraphicEl(function(t){t&&(t.onHoverStateChange=N)}),this._polyline.onHoverStateChange=N,this._data=o,this._coordSys=i,this._stackedOnPoints=_,this._points=l,this._step=M,this._valueOrigin=m,t.get("triggerLineEvent")&&(this.packEventData(t,h),p&&this.packEventData(t,p))},e.prototype.packEventData=function(t,e){nu(e).eventData={componentType:"series",componentSubType:"line",componentIndex:t.componentIndex,seriesIndex:t.seriesIndex,seriesName:t.name,seriesType:"line"}},e.prototype.highlight=function(t,e,n,i){var r=t.getData(),o=Ls(r,i);if(this._changePolyState("emphasis"),!(o instanceof Array)&&null!=o&&o>=0){var a=r.getLayout("points"),s=r.getItemGraphicEl(o);if(!s){var l=a[2*o],c=a[2*o+1];if(isNaN(l)||isNaN(c))return;if(this._clipShapeForSymbol&&!this._clipShapeForSymbol.contain(l,c))return;var u=t.get("zlevel")||0,d=t.get("z")||0;(s=new Iw(r,o)).x=l,s.y=c,s.setZ(u,d);var h=s.getSymbolPath().getTextContent();h&&(h.zlevel=u,h.z=d,h.z2=this._polyline.z2+1),s.__temp=!0,r.setItemGraphicEl(o,s),s.stopSymbolAnimation(!0),this.group.add(s)}s.highlight()}else km.prototype.highlight.call(this,t,e,n,i)},e.prototype.downplay=function(t,e,n,i){var r=t.getData(),o=Ls(r,i);if(this._changePolyState("normal"),null!=o&&o>=0){var a=r.getItemGraphicEl(o);a&&(a.__temp?(r.setItemGraphicEl(o,null),this.group.remove(a)):a.downplay())}else km.prototype.downplay.call(this,t,e,n,i)},e.prototype._changePolyState=function(t){var e=this._polygon;Mu(this._polyline,t),e&&Mu(e,t)},e.prototype._newPolyline=function(t){var e=this._polyline;return e&&this._lineGroup.remove(e),e=new Ww({shape:{points:t},segmentIgnoreThreshold:2,z2:10}),this._lineGroup.add(e),this._polyline=e,e},e.prototype._newPolygon=function(t,e){var n=this._polygon;return n&&this._lineGroup.remove(n),n=new Gw({shape:{points:t,stackedOnPoints:e},segmentIgnoreThreshold:2}),this._lineGroup.add(n),this._polygon=n,n},e.prototype._initSymbolLabelAnimation=function(t,e,n){var i,r,o=e.getBaseAxis(),a=o.inverse;"cartesian2d"===e.type?(i=o.isHorizontal(),r=!1):"polar"===e.type&&(i="angle"===o.dim,r=!0);var s=t.hostModel,l=s.get("animationDuration");un(l)&&(l=l(null));var c=s.get("animationDelay")||0,u=un(c)?c(null):c;t.eachItemGraphicEl(function(t,o){var s=t;if(s){var d=[t.x,t.y],h=void 0,p=void 0,f=void 0;if(n)if(r){var g=n,v=e.pointToCoord(d);i?(h=g.startAngle,p=g.endAngle,f=-v[1]/180*Math.PI):(h=g.r0,p=g.r,f=v[0])}else{var m=n;i?(h=m.x,p=m.x+m.width,f=t.x):(h=m.y+m.height,p=m.y,f=t.y)}var y=p===h?0:(f-h)/(p-h);a&&(y=1-y);var _=un(c)?c(o):l*y+u,b=s.getSymbolPath(),x=b.getTextContent();s.attr({scaleX:0,scaleY:0}),s.animateTo({scaleX:1,scaleY:1},{duration:200,setToFinal:!0,delay:_}),x&&x.animateFrom({style:{opacity:0}},{duration:300,delay:_}),b.disableLabelAnimation=!0}})},e.prototype._initOrUpdateEndLabel=function(t,e,n){var i=t.getModel("endLabel");if(eS(t)){var r=t.getData(),o=this._polyline,a=r.getLayout("points");if(!a)return o.removeTextContent(),void(this._endLabel=null);var s=this._endLabel;s||((s=this._endLabel=new Wc({z2:200})).ignoreClip=!0,o.setTextContent(this._endLabel),o.disableLabelAnimation=!0);var l=function(t){for(var e=t.length/2;e>0&&Jw(t[2*e-2],t[2*e-1]);e--);return e-1}(a);l>=0&&(Kh(o,Qh(t,"endLabel"),{inheritColor:n,labelFetcher:t,labelDataIndex:l,defaultText:function(t,e,n){return null!=n?function(t,e){var n=t.mapDimensionsAll("defaultedLabel");if(!cn(e))return e+"";for(var i=[],r=0;r=0&&i.push(e[o])}return i.join(" ")}(r,n):Dw(r,t)},enableTextSetter:!0},function(t,e){var n=e.getBaseAxis(),i=n.isHorizontal(),r=n.inverse,o=i?r?"right":"left":"center",a=i?"middle":r?"top":"bottom";return{normal:{align:t.get("align")||o,verticalAlign:t.get("verticalAlign")||a}}}(i,e)),o.textConfig.position=null)}else this._endLabel&&(this._polyline.removeTextContent(),this._endLabel=null)},e.prototype._endLabelOnDuring=function(t,e,n,i,r,o,a){var s=this._endLabel,l=this._polyline;if(s){t<1&&null==i.originalX&&(i.originalX=s.x,i.originalY=s.y);var c=n.getLayout("points"),u=n.hostModel,d=u.get("connectNulls"),h=o.get("precision"),p=o.get("distance")||0,f=a.getBaseAxis(),g=f.isHorizontal(),v=f.inverse,m=e.shape,y=v?g?m.x:m.y+m.height:g?m.x+m.width:m.y,_=(g?p:0)*(v?-1:1),b=(g?0:-p)*(v?-1:1),x=g?"x":"y",w=function(t,e,n){for(var i,r,o=t.length/2,a="x"===n?0:1,s=0,l=-1,c=0;c=e||i>=e&&r<=e){l=c;break}s=c,i=r}else i=r;return{range:[s,l],t:(e-i)/(r-i)}}(c,y,x),S=w.range,C=S[1]-S[0],M=void 0;if(C>=1){if(C>1&&!d){var k=tS(c,S[0]);s.attr({x:k[0]+_,y:k[1]+b}),r&&(M=u.getRawValue(S[0]))}else{(k=l.getPointOn(y,x))&&s.attr({x:k[0]+_,y:k[1]+b});var T=u.getRawValue(S[0]),A=u.getRawValue(S[1]);r&&(M=function(t,e,n,i,r){var o=null==e||"auto"===e;if(null==i)return i;if(pn(i))return is(f=_s(n||0,i,r),o?Math.max(rs(n||0),rs(i)):e);if(dn(i))return r<1?n:i;for(var a=[],s=n,l=i,c=Math.max(s?s.length:0,l.length),u=0;u0?S[0]:0;k=tS(c,D);r&&(M=u.getRawValue(D)),s.attr({x:k[0]+_,y:k[1]+b})}if(r){var I=sp(s);"function"==typeof I.setLabelText&&I.setLabelText(M)}}},e.prototype._doUpdateAnimation=function(t,e,n,i,r,o,a){var s=this._polyline,l=this._polygon,c=t.hostModel,u=function(t,e,n,i,r,o,a){for(var s=function(t,e){var n=[];return e.diff(t).add(function(t){n.push({cmd:"+",idx:t})}).update(function(t,e){n.push({cmd:"=",idx:e,idx1:t})}).remove(function(t){n.push({cmd:"-",idx:t})}).execute(),n}(t,e),l=[],c=[],u=[],d=[],h=[],p=[],f=[],g=Nw(r,e,a),v=t.getLayout("points")||[],m=e.getLayout("points")||[],y=0;y3e3||l&&Xw(h,f)>3e3)return s.stopAnimation(),s.setShape({points:p}),void(l&&(l.stopAnimation(),l.setShape({points:p,stackedOnPoints:f})));s.shape.__points=u.current,s.shape.points=d;var g={shape:{points:p}};u.current!==d&&(g.shape.__points=u.next),s.stopAnimation(),hh(s,g,c),l&&(l.setShape({points:d,stackedOnPoints:h}),l.stopAnimation(),hh(l,{shape:{stackedOnPoints:f}},c),s.shape.points!==l.shape.points&&(l.shape.points=s.shape.points));for(var v=[],m=u.status,y=0;ye&&(e=t[n]);return isFinite(e)?e:NaN},min:function(t){for(var e=1/0,n=0;ne[1]&&e.reverse(),e},e.prototype.pointToData=function(t,e){return this.coordToData(this.toLocalCoord(t["x"===this.dim?0:1]),e)},e.prototype.setCategorySortInfo=function(t){if("category"!==this.type)return!1;this.model.option.categorySortInfo=t,this.scale.setSortInfo(t)},e}(yw),wS="expandAxisBreak",SS=Math.PI,CS=[[1,2,1,2],[5,3,5,3],[8,3,8,3]],MS=[[0,1,0,1],[0,3,0,3],[0,3,0,3]],kS=Es(),TS=Es(),AS=function(){function t(t){this.recordMap={},this.resolveAxisNameOverlap=t}return t.prototype.ensureRecord=function(t){var e=t.axis.dim,n=t.componentIndex,i=this.recordMap,r=i[e]||(i[e]=[]);return r[n]||(r[n]={ready:{}})},t}();var DS=[1,0,0,1,0,0],IS=new $i(0,0,0,0),PS=function(t,e,n,i,r,o){if(qx(t.nameLocation)){var a=o.stOccupiedRect;a&&LS(function(t,e,n){return t.transform=Uh(t.transform,n),t.localRect=Wh(t.localRect,e),t.rect=Wh(t.rect,e),n&&t.rect.applyTransform(n),t.axisAligned=$h(n),t.obb=void 0,(t.label=t.label||{}).ignore=!1,t}({},a,o.transGroup.transform),i,r)}else ES(o.labelInfoList,o.dirVec,i,r)};function LS(t,e,n){var i=new Ti;Tw(t,e,i,{direction:Math.atan2(n.y,n.x),bidirectional:!1,touchThreshold:.05})&&function(t,e){if(t){t.label.x+=e.x,t.label.y+=e.y,t.label.markRedraw();var n=t.transform;n&&(n[4]+=e.x,n[5]+=e.y);var i=t.rect;i&&(i.x+=e.x,i.y+=e.y);var r=t.obb;r&&r.fromBoundingRect(t.localRect,n)}}(e,i)}function ES(t,e,n,i){for(var r=Ti.dot(i,e)>=0,o=0,a=t.length;o0?"top":"bottom",i="center"):ss(o-SS)?(r=n>0?"bottom":"top",i="center"):(r="middle",i=o>0&&o0?"right":"left":n>0?"left":"right"),{rotation:o,textAlign:i,textVerticalAlign:r}},t.makeAxisEventDataBase=function(t){var e={componentType:t.mainType,componentIndex:t.componentIndex};return e[t.mainType+"Index"]=t.componentIndex,e},t.isLabelSilent=function(t){var e=t.get("tooltip");return t.get("silent")||!(t.get("triggerEvent")||e&&e.show)},t}(),zS=["axisLine","axisTickLabelEstimate","axisTickLabelDetermine","axisName"],NS={axisLine:function(t,e,n,i,r,o,a){var s=i.get(["axisLine","show"]);if("auto"===s&&(s=!0,null!=t.raw.axisLineAutoShow&&(s=!!t.raw.axisLineAutoShow)),s){var l=i.axis.getExtent(),c=o.transform,u=[l[0],0],d=[l[1],0],h=u[0]>d[0];c&&(jn(u,u,c),jn(d,d,c));var p=Ze({lineCap:"round"},i.getModel(["axisLine","lineStyle"]).getLineStyle()),f={strokeContainThreshold:t.raw.strokeContainThreshold||5,silent:!0,z2:1,style:p};if(i.get(["axisLine","breakLine"])&&i.axis.scale.hasBreaks())null.buildAxisBreakLine(i,r,o,f);else{var g=new Vd(Ze({shape:{x1:u[0],y1:u[1],x2:d[0],y2:d[1]}},f));Th(g.shape,g.style.lineWidth),g.anid="line",r.add(g)}var v=i.get(["axisLine","symbol"]);if(null!=v){var m=i.get(["axisLine","symbolSize"]);dn(v)&&(v=[v,v]),(dn(m)||pn(m))&&(m=[m,m]);var y=Oy(i.get(["axisLine","symbolOffset"])||0,m),_=m[0],b=m[1];en([{rotate:t.rotation+Math.PI/2,offset:y[0],r:0},{rotate:t.rotation-Math.PI/2,offset:y[1],r:Math.sqrt((u[0]-d[0])*(u[0]-d[0])+(u[1]-d[1])*(u[1]-d[1]))}],function(e,n){if("none"!==v[n]&&null!=v[n]){var i=Ey(v[n],-_/2,-b/2,_,b,p.stroke,!0),o=e.r+e.offset,a=h?d:u;i.attr({rotation:e.rotate,x:a[0]+o*Math.cos(t.rotation),y:a[1]-o*Math.sin(t.rotation),silent:!0,z2:11}),r.add(i)}})}}},axisTickLabelEstimate:function(t,e,n,i,r,o,a,s){FS(e,r,s)&&RS(t,e,n,i,r,o,a,ew)},axisTickLabelDetermine:function(t,e,n,i,r,o,a,s){FS(e,r,s)&&RS(t,e,n,i,r,o,a,nw);var l=function(t,e,n,i){var r=i.axis,o=i.getModel("axisTick"),a=o.get("show");"auto"===a&&(a=!0,null!=t.raw.axisTickAutoShow&&(a=!!t.raw.axisTickAutoShow));if(!a||r.scale.isBlank())return[];for(var s=o.getModel("lineStyle"),l=t.tickDirection*o.get("length"),c=BS(r.getTicksCoords(),n.transform,l,Ke(s.getLineStyle(),{stroke:i.get(["axisLine","lineStyle","color"])}),"ticks"),u=0;ui[1],l="start"===e&&!s||"start"!==e&&s;ss(a-SS/2)?(o=l?"bottom":"top",r="center"):ss(a-1.5*SS)?(o=l?"top":"bottom",r="center"):(o="middle",r=a<1.5*SS&&a>SS/2?l?"left":"right":l?"right":"left");return{rotation:a,textAlign:r,textVerticalAlign:o}}(t.rotation,u,x||0,f),null!=(b=t.raw.axisNameAvailableWidth)&&(b=Math.abs(b/Math.sin(_.rotation)),!isFinite(b)&&(b=null)));var w=h.getFont(),S=i.get("nameTruncate",!0)||{},C=S.ellipsis,M=bn(t.raw.nameTruncateMaxWidth,S.maxWidth,b),k=s.nameMarginLevel||0,T=new Wc({x:v.x,y:v.y,rotation:_.rotation,silent:OS.isLabelSilent(i),style:Jh(h,{text:c,font:w,overflow:"truncate",width:M,ellipsis:C,fill:h.getTextColor()||i.get(["axisLine","lineStyle","color"]),align:h.get("align")||_.textAlign,verticalAlign:h.get("verticalAlign")||_.textVerticalAlign}),z2:1});if(Hh({el:T,componentModel:i,itemName:c}),T.__fullText=c,T.anid="name",i.get("triggerEvent")){var A=OS.makeAxisEventDataBase(i);A.targetType="axisName",A.name=c,nu(T).eventData=A}o.add(T),T.updateTransform(),e.nameEl=T;var D=l.nameLayout=Sw({label:T,priority:T.z2,defaultAttr:{ignore:T.ignore},marginDefault:qx(u)?CS[k]:MS[k]});if(l.nameLocation=u,r.add(T),T.decomposeTransform(),t.shouldNameMoveOverlap&&D){var I=n.ensureRecord(i);n.resolveAxisNameOverlap(t,n,i,D,m,I)}}}};function RS(t,e,n,i,r,o,a,s){$S(e)||function(t,e,n,i,r,o){var a=r.axis,s=bn(t.raw.axisLabelShow,r.get(["axisLabel","show"])),l=new Ua;n.add(l);var c=iw(i);if(!s||a.scale.isBlank())return void VS(e,[],l,c);var u=r.getModel("axisLabel"),d=a.getViewLabels(c),h=(bn(t.raw.labelRotate,u.get("rotate"))||0)*SS/180,p=OS.innerTextLayout(t.rotation,h,t.labelDirection),f=r.getCategories&&r.getCategories(!0),g=[],v=r.get("triggerEvent"),m=1/0,y=-1/0;en(d,function(t,e){var n,i="ordinal"===a.scale.type?a.scale.getRawOrdinalNumber(t.tickValue):t.tickValue,s=t.formattedLabel,c=t.rawLabel,h=u;if(f&&f[i]){var _=f[i];fn(_)&&_.textStyle&&(h=new _p(_.textStyle,u,r.ecModel))}var b=h.getTextColor()||r.get(["axisLine","lineStyle","color"]),x=h.getShallow("align",!0)||p.textAlign,w=xn(h.getShallow("alignMinLabel",!0),x),S=xn(h.getShallow("alignMaxLabel",!0),x),C=h.getShallow("verticalAlign",!0)||h.getShallow("baseline",!0)||p.textVerticalAlign,M=xn(h.getShallow("verticalAlignMinLabel",!0),C),k=xn(h.getShallow("verticalAlignMaxLabel",!0),C),T=10+((null===(n=t.time)||void 0===n?void 0:n.level)||0);m=Math.min(m,T),y=Math.max(y,T);var A=new Wc({x:0,y:0,rotation:0,silent:OS.isLabelSilent(r),z2:T,style:Jh(h,{text:s,align:0===e?w:e===d.length-1?S:x,verticalAlign:0===e?M:e===d.length-1?k:C,fill:un(b)?b("category"===a.type?c:"value"===a.type?i+"":i,e):b})});A.anid="label_"+i;var D=kS(A);if(D.break=t.break,D.tickValue=i,D.layoutRotation=p.rotation,Hh({el:A,componentModel:r,itemName:s,formatterParamsExtra:{isTruncated:function(){return A.isTruncated},value:c,tickIndex:e}}),v){var I=OS.makeAxisEventDataBase(r);I.targetType="axisLabel",I.value=c,I.tickIndex=e,t.break&&(I.break={start:t.break.parsedBreak.vmin,end:t.break.parsedBreak.vmax}),"category"===a.type&&(I.dataIndex=i),nu(A).eventData=I,t.break&&function(t,e,n,i){n.on("click",function(n){var r={type:wS,breaks:[{start:i.parsedBreak.breakOption.start,end:i.parsedBreak.breakOption.end}]};r[t.axis.dim+"AxisIndex"]=t.componentIndex,e.dispatchAction(r)})}(r,o,A,t.break)}g.push(A),l.add(A)});var _=nn(g,function(t){return{label:t,priority:kS(t).break?t.z2+(y-m+1):t.z2,defaultAttr:{ignore:t.ignore}}});VS(e,_,l,c)}(t,e,r,s,i,a);var l=e.labelLayoutList;!function(t,e,n,i){var r=e.get(["axisLabel","margin"]);en(n,function(n,o){var a=Sw(n);if(a){var s=a.label,l=kS(s);a.suggestIgnore=s.ignore,s.ignore=!1,ba(WS,US),WS.x=e.axis.dataToCoord(l.tickValue),WS.y=t.labelOffset+t.labelDirection*r,WS.rotation=l.layoutRotation,i.add(WS),WS.updateTransform(),i.remove(WS),WS.decomposeTransform(),ba(s,WS),s.markRedraw(),xw(a,!0),Sw(a)}})}(t,i,l,o),t.rotation;var c=t.optionHideOverlap;!function(t,e,n){if(Ux(t.axis))return;function i(t,i,r){var o=Sw(e[i]),a=Sw(e[r]);if(o&&a)if(!1===t||o.suggestIgnore)HS(o.label);else if(a.suggestIgnore)HS(a.label);else{var s=.1;if(!n){var l=[0,0,0,0];o=Mw({marginForce:l},o),a=Mw({marginForce:l},a)}Tw(o,a,null,{touchThreshold:s})&&HS(t?a.label:o.label)}}var r=t.get(["axisLabel","showMinLabel"]),o=t.get(["axisLabel","showMaxLabel"]),a=e.length;i(r,0,1),i(o,a-1,a-2)}(i,l,c),c&&function(t){var e=[];function n(t){if(!t.ignore){var e=t.ensureState("emphasis");null==e.ignore&&(e.ignore=!1)}t.ignore=!0}t.sort(function(t,e){return(e.suggestIgnore?1:0)-(t.suggestIgnore?1:0)||e.priority-t.priority});for(var i=0;i.1?"x":"y",u=a.transGroup[c];if(s.sort(function(t,e){return Math.abs(t.label[c]-u)-Math.abs(e.label[c]-u)}),l&&r){var d=o.getExtent(),h=Math.min(d[0],d[1]),p=Math.max(d[0],d[1])-h;r.union(new $i(h,0,p,1))}a.stOccupiedRect=r,a.labelInfoList=s}(t,n,i,l)}function HS(t){t&&(t.ignore=!0)}function BS(t,e,n,i,r){for(var o=[],a=[],s=[],l=0;l=0;o--){var a=t[+n[o]],s=a.model,l=a.scale;Zb(l)&&s.get("alignTicks")&&null==s.get("interval")?r.push(a):(Fx(l,s),Zb(l)&&(e=a))}r.length&&(e||Fx((e=r.pop()).scale,e.model),en(r,function(t){!function(t,e,n){var i=hx.prototype,r=i.getTicks.call(n),o=i.getTicks.call(n,{expandToNicedExtent:!0}),a=r.length-1,s=i.getInterval.call(n),l=Bx(t,e),c=l.extent,u=l.fixMin,d=l.fixMax;"log"===t.type&&(c=ox(t.base,c,!0)),t.setBreaksFromOption(Xx(e)),t.setExtent(c[0],c[1]),t.calcNiceExtent({splitNumber:a,fixMin:u,fixMax:d});var h=i.getExtent.call(t);u&&(c[0]=h[0]),d&&(c[1]=h[1]);var p=i.getInterval.call(t),f=c[0],g=c[1];if(u&&d)p=(g-f)/a;else if(u)for(g=c[0]+p*a;gc[0]&&isFinite(f)&&isFinite(c[0]);)p=Qb(p),f=c[1]-p*a;else{t.getTicks().length-1>a&&(p=Qb(p));var v=p*a;(f=is((g=Math.ceil(c[1]/p)*p)-v))<0&&c[0]>=0?(f=0,g=is(v)):g>0&&c[1]<=0&&(g=0,f=-is(v))}var m=(r[0].value-o[0].value)/s,y=(r[a].value-o[a].value)/s;i.setExtent.call(t,f+p*m,g+p*y),i.setInterval.call(t,p),(m||y)&&i.setNiceExtent.call(t,f+p,g-p)}(t.scale,t.model,e.scale)}))}}this._updateScale(t,this.model),i(n.x),i(n.y);var r={};en(n.x,function(t){KS(n,"y",t,r)}),en(n.y,function(t){KS(n,"x",t,r)}),this.resize(this.model,e)},t.prototype.resize=function(t,e,n){var i=Ef(t,e),r=this._rect=Pf(t.getBoxLayoutParams(),i.refContainer),o=this._axesMap,a=this._coordsList,s=t.get("containLabel");if(JS(o,r),!n){var l=function(t,e,n,i,r){var o=new AS(iC);return en(n,function(n){return en(n,function(n){if(jx(n.model)){var a=!i;n.axisBuilder=function(t,e,n,i,r,o){for(var a=qS(t,n),s=!1,l=!1,c=0;c0&&i>0||n<0&&i<0)}(t)}function JS(t,e){en(t.x,function(t){return tC(t,e.x,e.width)}),en(t.y,function(t){return tC(t,e.y,e.height)})}function tC(t,e,n){var i=[0,n],r=t.inverse?1:0;t.setExtent(i[r],i[1-r]),function(t,e){var n=t.getExtent(),i=n[0]+n[1];t.toGlobalCoord="x"===t.dim?function(t){return t+e}:function(t){return i-t+e},t.toLocalCoord="x"===t.dim?function(t){return t-e}:function(t){return i-t+e}}(t,e)}function eC(t,e,n,i,r,o,a){nC(i,r,ew,e,!1,a);var s=[0,0,0,0];c(0),c(1),u(i,0,NaN),u(i,1,NaN);var l=null==function(t,e,n){if(t&&e)for(var i=0,r=t.length;i0});return zh(i,s,!0,!0,n),JS(r,i),l;function c(t){en(r[yh[t]],function(e){if(jx(e.model)){var n=o.ensureRecord(e.model),i=n.labelInfoList;if(i)for(var r=0;r0&&!_n(e)&&e>1e-4&&(t/=e),t}}function nC(t,e,n,i,r,o){var a=n===nw;en(e,function(e){return en(e,function(e){jx(e.model)&&(!function(t,e,n){var i=qS(e,n);t.updateCfg(i)}(e.axisBuilder,t,e.model),e.axisBuilder.build(a?{axisTickLabelDetermine:!0}:{axisTickLabelEstimate:!0},{noPxChange:r}))})});var s={x:0,y:0};function l(e){s[yh[1-e]]=t[_h[e]]<=.5*o.refContainer[_h[e]]?0:1-e==1?2:1}l(0),l(1),en(e,function(t,e){return en(t,function(t){jx(t.model)&&(("all"===i||a)&&t.axisBuilder.build({axisName:!0},{nameMarginLevel:s[e]}),a&&t.axisBuilder.build({axisLine:!0}))})})}var iC=function(t,e,n,i,r,o){var a="x"===n.axis.dim?"y":"x";PS(t,0,0,i,r,o),qx(t.nameLocation)||en(e.recordMap[a],function(t){t&&t.labelInfoList&&t.dirVec&&ES(t.labelInfoList,t.dirVec,i,r)})};function rC(t,e){var n={axesInfo:{},seriesInvolved:!1,coordSysAxesInfo:{},coordSysMap:{}};return function(t,e,n){var i=e.getComponent("tooltip"),r=e.getComponent("axisPointer"),o=r.get("link",!0)||[],a=[];en(n.getCoordinateSystems(),function(n){if(n.axisPointerEnabled){var s=lC(n.model),l=t.coordSysAxesInfo[s]={};t.coordSysMap[s]=n;var c=n.model.getModel("tooltip",i);if(en(n.getAxes(),ln(p,!1,null)),n.getTooltipAxes&&i&&c.get("show")){var u="axis"===c.get("trigger"),d="cross"===c.get(["axisPointer","type"]),h=n.getTooltipAxes(c.get(["axisPointer","axis"]));(u||d)&&en(h.baseAxes,ln(p,!d||"cross",u)),d&&en(h.otherAxes,ln(p,"cross",!1))}}function p(i,s,u){var d=u.model.getModel("axisPointer",r),h=d.get("show");if(h&&("auto"!==h||i||sC(d))){null==s&&(s=d.get("triggerTooltip")),d=i?function(t,e,n,i,r,o){var a=e.getModel("axisPointer"),s={};en(["type","snap","lineStyle","shadowStyle","label","animation","animationDurationUpdate","animationEasingUpdate","z"],function(t){s[t]=Xe(a.get(t))}),s.snap="category"!==t.type&&!!o,"cross"===a.get("type")&&(s.type="line");var l=s.label||(s.label={});if(null==l.show&&(l.show=!1),"cross"===r){var c=a.get(["label","show"]);if(l.show=null==c||c,!o){var u=s.lineStyle=a.get("crossStyle");u&&Ke(l,u.textStyle)}}return t.model.getModel("axisPointer",new _p(s,n,i))}(u,c,r,e,i,s):d;var p=d.get("snap"),f=d.get("triggerEmphasis"),g=lC(u.model),v=s||p||"category"===u.type,m=t.axesInfo[g]={key:g,axis:u,coordSys:n,axisPointerModel:d,triggerTooltip:s,triggerEmphasis:f,involveSeries:v,snap:p,useHandle:sC(d),seriesModels:[],linkGroup:null};l[g]=m,t.seriesInvolved=t.seriesInvolved||v;var y=function(t,e){for(var n=e.model,i=e.dim,r=0;r=0||t===e}function aC(t){var e=(t.ecModel.getComponent("axisPointer")||{}).coordSysAxesInfo;return e&&e.axesInfo[lC(t)]}function sC(t){return!!t.get(["handle","show"])}function lC(t){return t.type+"||"+t.id}var cC={},uC=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return _(e,t),e.prototype.render=function(e,n,i,r){this.axisPointerClass&&function(t){var e=aC(t);if(e){var n=e.axisPointerModel,i=e.axis.scale,r=n.option,o=n.get("status"),a=n.get("value");null!=a&&(a=i.parse(a));var s=sC(n);null==o&&(r.status=s?"show":"hide");var l=i.getExtent().slice();l[0]>l[1]&&l.reverse(),(null==a||a>l[1])&&(a=l[1]),aa)return!0;if(o){var s=aC(t).seriesDataCount,l=i.getExtent();return Math.abs(l[0]-l[1])/s>a}return!1}return!0===n},t.prototype.makeElOption=function(t,e,n,i,r){},t.prototype.createPointerEl=function(t,e,n,i){var r=e.pointer;if(r){var o=bC(t).pointerEl=new Xh[r.type](xC(e.pointer));t.add(o)}},t.prototype.createLabelEl=function(t,e,n,i){if(e.label){var r=bC(t).labelEl=new Wc(xC(e.label));t.add(r),kC(r,i)}},t.prototype.updatePointerEl=function(t,e,n){var i=bC(t).pointerEl;i&&e.pointer&&(i.setStyle(e.pointer.style),n(i,{shape:e.pointer.shape}))},t.prototype.updateLabelEl=function(t,e,n,i){var r=bC(t).labelEl;r&&(r.setStyle(e.label.style),n(r,{x:e.label.x,y:e.label.y}),kC(r,i))},t.prototype._renderHandle=function(t){if(!this._dragging&&this.updateHandleTransform){var e,n=this._axisPointerModel,i=this._api.getZr(),r=this._handle,o=n.getModel("handle"),a=n.get("status");if(!o.get("show")||!a||"hide"===a)return r&&i.remove(r),void(this._handle=null);this._handle||(e=!0,r=this._handle=Lh(o.get("icon"),{cursor:"move",draggable:!0,onmousemove:function(t){mi(t.event)},onmousedown:wC(this._onHandleDragMove,this,0,0),drift:wC(this._onHandleDragMove,this),ondragend:wC(this._onHandleDragEnd,this)}),i.add(r)),AC(r,n,!1),r.setStyle(o.getItemStyle(null,["color","borderColor","borderWidth","opacity","shadowColor","shadowBlur","shadowOffsetX","shadowOffsetY"]));var s=o.get("size");cn(s)||(s=[s,s]),r.scaleX=s[0]/2,r.scaleY=s[1]/2,Nm(this,"_doDispatchAxisPointer",o.get("throttle")||0,"fixRate"),this._moveHandleToValue(t,e)}},t.prototype._moveHandleToValue=function(t,e){CC(this._axisPointerModel,!e&&this._moveAnimation,this._handle,TC(this.getHandleTransform(t,this._axisModel,this._axisPointerModel)))},t.prototype._onHandleDragMove=function(t,e){var n=this._handle;if(n){this._dragging=!0;var i=this.updateHandleTransform(TC(n),[t,e],this._axisModel,this._axisPointerModel);this._payloadInfo=i,n.stopAnimation(),n.attr(TC(i)),bC(n).lastProp=null,this._doDispatchAxisPointer()}},t.prototype._doDispatchAxisPointer=function(){if(this._handle){var t=this._payloadInfo,e=this._axisModel;this._api.dispatchAction({type:"updateAxisPointer",x:t.cursorPoint[0],y:t.cursorPoint[1],tooltipOption:t.tooltipOption,axesInfo:[{axisDim:e.axis.dim,axisIndex:e.componentIndex}]})}},t.prototype._onHandleDragEnd=function(){if(this._dragging=!1,this._handle){var t=this._axisPointerModel.get("value");this._moveHandleToValue(t),this._api.dispatchAction({type:"hideTip"})}},t.prototype.clear=function(t){this._lastValue=null,this._lastStatus=null;var e=t.getZr(),n=this._group,i=this._handle;e&&n&&(this._lastGraphicKey=null,n&&e.remove(n),i&&e.remove(i),this._group=null,this._handle=null,this._payloadInfo=null),Rm(this,"_doDispatchAxisPointer")},t.prototype.doClear=function(){},t.prototype.buildLabel=function(t,e,n){return{x:t[n=n||0],y:t[1-n],width:e[n],height:e[1-n]}},t}();function CC(t,e,n,i){MC(bC(n).lastProp,i)||(bC(n).lastProp=i,e?hh(n,i,t):(n.stopAnimation(),n.attr(i)))}function MC(t,e){if(fn(t)&&fn(e)){var n=!0;return en(e,function(e,i){n=n&&MC(t[i],e)}),!!n}return t===e}function kC(t,e){t[e.get(["label","show"])?"show":"hide"]()}function TC(t){return{x:t.x||0,y:t.y||0,rotation:t.rotation||0}}function AC(t,e,n){var i=e.get("z"),r=e.get("zlevel");t&&t.traverse(function(t){"group"!==t.type&&(null!=i&&(t.z=i),null!=r&&(t.zlevel=r),t.silent=n)})}function DC(t,e,n,i,r){var o=IC(n.get("value"),e.axis,e.ecModel,n.get("seriesDataIndices"),{precision:n.get(["label","precision"]),formatter:n.get(["label","formatter"])}),a=n.getModel("label"),s=df(a.get("padding")||0),l=a.getFont(),c=Ta(o,l),u=r.position,d=c.width+s[1]+s[3],h=c.height+s[0]+s[2],p=r.align;"right"===p&&(u[0]-=d),"center"===p&&(u[0]-=d/2);var f=r.verticalAlign;"bottom"===f&&(u[1]-=h),"middle"===f&&(u[1]-=h/2),function(t,e,n,i){var r=i.getWidth(),o=i.getHeight();t[0]=Math.min(t[0]+e,r)-e,t[1]=Math.min(t[1]+n,o)-n,t[0]=Math.max(t[0],0),t[1]=Math.max(t[1],0)}(u,d,h,i);var g=a.get("backgroundColor");g&&"auto"!==g||(g=e.get(["axisLine","lineStyle","color"])),t.label={x:u[0],y:u[1],style:Jh(a,{text:o,font:l,fill:a.getTextColor(),padding:s,backgroundColor:g}),z2:10}}function IC(t,e,n,i,r){t=e.scale.parse(t);var o=e.scale.getLabel({value:t},{precision:r.precision}),a=r.formatter;if(a){var s={value:Vx(e,{value:t}),axisDimension:e.dim,axisIndex:e.index,seriesData:[]};en(i,function(t){var e=n.getSeriesByIndex(t.seriesIndex),i=t.dataIndexInside,r=e&&e.getDataParams(i);r&&s.seriesData.push(r)}),dn(a)?o=a.replace("{value}",o):un(a)&&(o=a(s))}return o}function PC(t,e,n){var i=[1,0,0,1,0,0];return Mi(i,i,n.rotation),Ci(i,i,n.position),Dh([t.dataToCoord(e),(n.labelOffset||0)+(n.labelDirection||1)*(n.labelMargin||0)],i)}var LC=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return _(e,t),e.prototype.makeElOption=function(t,e,n,i,r){var o=n.axis,a=o.grid,s=i.get("type"),l=EC(a,o).getOtherAxis(o).getGlobalExtent(),c=o.toGlobalCoord(o.dataToCoord(e,!0));if(s&&"none"!==s){var u=function(t){var e,n=t.get("type"),i=t.getModel(n+"Style");return"line"===n?(e=i.getLineStyle()).fill=null:"shadow"===n&&((e=i.getAreaStyle()).stroke=null),e}(i),d=OC[s](o,c,l);d.style=u,t.graphicKey=d.type,t.pointer=d}!function(t,e,n,i,r,o){var a=OS.innerTextLayout(n.rotation,0,n.labelDirection);n.labelMargin=r.get(["label","margin"]),DC(e,i,r,o,{position:PC(i.axis,t,n),align:a.textAlign,verticalAlign:a.textVerticalAlign})}(e,t,qS(a.getRect(),n),n,i,r)},e.prototype.getHandleTransform=function(t,e,n){var i=qS(e.axis.grid.getRect(),e,{labelInside:!1});i.labelMargin=n.get(["handle","margin"]);var r=PC(e.axis,t,i);return{x:r[0],y:r[1],rotation:i.rotation+(i.labelDirection<0?Math.PI:0)}},e.prototype.updateHandleTransform=function(t,e,n,i){var r=n.axis,o=r.grid,a=r.getGlobalExtent(!0),s=EC(o,r).getOtherAxis(r).getGlobalExtent(),l="x"===r.dim?0:1,c=[t.x,t.y];c[l]+=e[l],c[l]=Math.min(a[1],c[l]),c[l]=Math.max(a[0],c[l]);var u=(s[1]+s[0])/2,d=[u,u];d[l]=c[l];return{x:c[0],y:c[1],rotation:t.rotation,cursorPoint:d,tooltipOption:[{verticalAlign:"middle"},{align:"center"}][l]}},e}(SC);function EC(t,e){var n={};return n[e.dim+"AxisIndex"]=e.index,t.getCartesian(n)}var OC={line:function(t,e,n){var i,r,o;return{type:"Line",subPixelOptimize:!0,shape:(i=[e,n[0]],r=[e,n[1]],o=zC(t),{x1:i[o=o||0],y1:i[1-o],x2:r[o],y2:r[1-o]})}},shadow:function(t,e,n){var i,r,o,a=Math.max(1,t.getBandWidth()),s=n[1]-n[0];return{type:"Rect",shape:(i=[e-a/2,n[0]],r=[a,s],o=zC(t),{x:i[o=o||0],y:i[1-o],width:r[o],height:r[1-o]})}}};function zC(t){return"x"===t.dim?0:1}var NC=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return _(e,t),e.type="axisPointer",e.defaultOption={show:"auto",z:50,type:"line",snap:!1,triggerTooltip:!0,triggerEmphasis:!0,value:null,status:null,link:[],animation:null,animationDurationUpdate:200,lineStyle:{color:Bf.color.border,width:1,type:"dashed"},shadowStyle:{color:Bf.color.shadowTint},label:{show:!0,formatter:null,precision:"auto",margin:3,color:Bf.color.neutral00,padding:[5,7,5,7],backgroundColor:Bf.color.accent60,borderColor:null,borderWidth:0,borderRadius:3},handle:{show:!1,icon:"M10.7,11.9v-1.3H9.3v1.3c-4.9,0.3-8.8,4.4-8.8,9.4c0,5,3.9,9.1,8.8,9.4h1.3c4.9-0.3,8.8-4.4,8.8-9.4C19.5,16.3,15.6,12.2,10.7,11.9z M13.3,24.4H6.7v-1.2h6.6z M13.3,22H6.7v-1.2h6.6z M13.3,19.6H6.7v-1.2h6.6z",size:45,margin:50,color:Bf.color.accent40,throttle:40}},e}(Hf),RC=Es(),HC=en;function BC(t,e,n){if(!Te.node){var i=e.getZr();RC(i).records||(RC(i).records={}),function(t,e){if(RC(t).initialized)return;function n(n,i){t.on(n,function(n){var r=function(t){var e={showTip:[],hideTip:[]},n=function(i){var r=e[i.type];r?r.push(i):(i.dispatchAction=n,t.dispatchAction(i))};return{dispatchAction:n,pendings:e}}(e);HC(RC(t).records,function(t){t&&i(t,n,r.dispatchAction)}),function(t,e){var n,i=t.showTip.length,r=t.hideTip.length;i?n=t.showTip[i-1]:r&&(n=t.hideTip[r-1]);n&&(n.dispatchAction=null,e.dispatchAction(n))}(r.pendings,e)})}RC(t).initialized=!0,n("click",ln($C,"click")),n("mousemove",ln($C,"mousemove")),n("globalout",FC)}(i,e),(RC(i).records[t]||(RC(i).records[t]={})).handler=n}}function FC(t,e,n){t.handler("leave",null,n)}function $C(t,e,n,i){e.handler(t,n,i)}function VC(t,e){if(!Te.node){var n=e.getZr();(RC(n).records||{})[t]&&(RC(n).records[t]=null)}}var WC=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return _(e,t),e.prototype.render=function(t,e,n){var i=e.getComponent("tooltip"),r=t.get("triggerOn")||i&&i.get("triggerOn")||"mousemove|click";BC("axisPointer",n,function(t,e,n){"none"!==r&&("leave"===t||r.indexOf(t)>=0)&&n({type:"updateAxisPointer",currTrigger:t,x:e&&e.offsetX,y:e&&e.offsetY})})},e.prototype.remove=function(t,e){VC("axisPointer",e)},e.prototype.dispose=function(t,e){VC("axisPointer",e)},e.type="axisPointer",e}(wm);function UC(t,e){var n,i=[],r=t.seriesIndex;if(null==r||!(n=e.getSeriesByIndex(r)))return{point:[]};var o=n.getData(),a=Ls(o,t);if(null==a||a<0||cn(a))return{point:[]};var s=o.getItemGraphicEl(a),l=n.coordinateSystem;if(n.getTooltipPosition)i=n.getTooltipPosition(a)||[];else if(l&&l.dataToPoint)if(t.isStacked){var c=l.getBaseAxis(),u=l.getOtherAxis(c).dim,d=c.dim,h="x"===u||"radius"===u?1:0,p=o.mapDimension(d),f=[];f[h]=o.get(p,a),f[1-h]=o.get(o.getCalculationInfo("stackResultDimension"),a),i=l.dataToPoint(f)||[]}else i=l.dataToPoint(o.getValues(nn(l.dimensions,function(t){return o.mapDimension(t)}),a))||[];else if(s){var g=s.getBoundingRect().clone();g.applyTransform(s.transform),i=[g.x+g.width/2,g.y+g.height/2]}return{point:i,el:s}}var GC=Es();function qC(t,e,n){var i=t.currTrigger,r=[t.x,t.y],o=t,a=t.dispatchAction||sn(n.dispatchAction,n),s=e.getComponent("axisPointer").coordSysAxesInfo;if(s){KC(r)&&(r=UC({seriesIndex:o.seriesIndex,dataIndex:o.dataIndex},e).point);var l=KC(r),c=o.axesInfo,u=s.axesInfo,d="leave"===i||KC(r),h={},p={},f={list:[],map:{}},g={showPointer:ln(XC,p),showTooltip:ln(YC,f)};en(s.coordSysMap,function(t,e){var n=l||t.containPoint(r);en(s.coordSysAxesInfo[e],function(t,e){var i=t.axis,o=function(t,e){for(var n=0;n<(t||[]).length;n++){var i=t[n];if(e.axis.dim===i.axisDim&&e.axis.model.componentIndex===i.axisIndex)return i}}(c,t);if(!d&&n&&(!c||o)){var a=o&&o.value;null!=a||l||(a=i.pointToData(r)),null!=a&&jC(t,a,g,!1,h)}})});var v={};return en(u,function(t,e){var n=t.linkGroup;n&&!p[e]&&en(n.axesInfo,function(e,i){var r=p[i];if(e!==t&&r){var o=r.value;n.mapper&&(o=t.axis.scale.parse(n.mapper(o,ZC(e),ZC(t)))),v[t.key]=o}})}),en(v,function(t,e){jC(u[e],t,g,!0,h)}),function(t,e,n){var i=n.axesInfo=[];en(e,function(e,n){var r=e.axisPointerModel.option,o=t[n];o?(!e.useHandle&&(r.status="show"),r.value=o.value,r.seriesDataIndices=(o.payloadBatch||[]).slice()):!e.useHandle&&(r.status="hide"),"show"===r.status&&i.push({axisDim:e.axis.dim,axisIndex:e.axis.model.componentIndex,value:r.value})})}(p,u,h),function(t,e,n,i){if(KC(e)||!t.list.length)return void i({type:"hideTip"});var r=((t.list[0].dataByAxis[0]||{}).seriesDataIndices||[])[0]||{};i({type:"showTip",escapeConnect:!0,x:e[0],y:e[1],tooltipOption:n.tooltipOption,position:n.position,dataIndexInside:r.dataIndexInside,dataIndex:r.dataIndex,seriesIndex:r.seriesIndex,dataByCoordSys:t.list})}(f,r,t,a),function(t,e,n){var i=n.getZr(),r="axisPointerLastHighlights",o=GC(i)[r]||{},a=GC(i)[r]={};en(t,function(t,e){var n=t.axisPointerModel.option;"show"===n.status&&t.triggerEmphasis&&en(n.seriesDataIndices,function(t){var e=t.seriesIndex+" | "+t.dataIndex;a[e]=t})});var s=[],l=[];en(o,function(t,e){!a[e]&&l.push(t)}),en(a,function(t,e){!o[e]&&s.push(t)}),l.length&&n.dispatchAction({type:"downplay",escapeConnect:!0,notBlur:!0,batch:l}),s.length&&n.dispatchAction({type:"highlight",escapeConnect:!0,notBlur:!0,batch:s})}(u,0,n),h}}function jC(t,e,n,i,r){var o=t.axis;if(!o.scale.isBlank()&&o.containData(e))if(t.involveSeries){var a=function(t,e){var n=e.axis,i=n.dim,r=t,o=[],a=Number.MAX_VALUE,s=-1;return en(e.seriesModels,function(e,l){var c,u,d=e.getData().mapDimensionsAll(i);if(e.getAxisTooltipData){var h=e.getAxisTooltipData(d,t,n);u=h.dataIndices,c=h.nestestValue}else{if(!(u=e.indicesOfNearest(i,d[0],t,"category"===n.type?.5:null)).length)return;c=e.getData().get(d[0],u[0])}if(null!=c&&isFinite(c)){var p=t-c,f=Math.abs(p);f<=a&&((f=0&&s<0)&&(a=f,s=p,r=c,o.length=0),en(u,function(t){o.push({seriesIndex:e.seriesIndex,dataIndexInside:t,dataIndex:e.getData().getRawIndex(t)})}))}}),{payloadBatch:o,snapToValue:r}}(e,t),s=a.payloadBatch,l=a.snapToValue;s[0]&&null==r.seriesIndex&&Ze(r,s[0]),!i&&t.snap&&o.containData(l)&&null!=l&&(e=l),n.showPointer(t,e,s),n.showTooltip(t,a,l)}else n.showPointer(t,e)}function XC(t,e,n,i){t[e.key]={value:n,payloadBatch:i}}function YC(t,e,n,i){var r=n.payloadBatch,o=e.axis,a=o.model,s=e.axisPointerModel;if(e.triggerTooltip&&r.length){var l=e.coordSys.model,c=lC(l),u=t.map[c];u||(u=t.map[c]={coordSysId:l.id,coordSysIndex:l.componentIndex,coordSysType:l.type,coordSysMainType:l.mainType,dataByAxis:[]},t.list.push(u)),u.dataByAxis.push({axisDim:o.dim,axisIndex:a.componentIndex,axisType:a.type,axisId:a.id,value:i,valueLabelOpt:{precision:s.get(["label","precision"]),formatter:s.get(["label","formatter"])},seriesDataIndices:r.slice()})}}function ZC(t){var e=t.axis.model,n={},i=n.axisDim=t.axis.dim;return n.axisIndex=n[i+"AxisIndex"]=e.componentIndex,n.axisName=n[i+"AxisName"]=e.name,n.axisId=n[i+"AxisId"]=e.id,n}function KC(t){return!t||null==t[0]||isNaN(t[0])||null==t[1]||isNaN(t[1])}function QC(t){uC.registerAxisPointerClass("CartesianAxisPointer",LC),t.registerComponentModel(NC),t.registerComponentView(WC),t.registerPreprocessor(function(t){if(t){(!t.axisPointer||0===t.axisPointer.length)&&(t.axisPointer={});var e=t.axisPointer.link;e&&!cn(e)&&(t.axisPointer.link=[e])}}),t.registerProcessor(t.PRIORITY.PROCESSOR.STATISTIC,function(t,e){t.getComponent("axisPointer").coordSysAxesInfo=rC(t,e)}),t.registerAction({type:"updateAxisPointer",event:"updateAxisPointer",update:":updateAxisPointer"},qC)}var JC=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return _(e,t),e.type="tooltip",e.dependencies=["axisPointer"],e.defaultOption={z:60,show:!0,showContent:!0,trigger:"item",triggerOn:"mousemove|click",alwaysShowContent:!1,renderMode:"auto",confine:null,showDelay:0,hideDelay:100,transitionDuration:.4,displayTransition:!0,enterable:!1,backgroundColor:Bf.color.neutral00,shadowBlur:10,shadowColor:"rgba(0, 0, 0, .2)",shadowOffsetX:1,shadowOffsetY:2,borderRadius:4,borderWidth:1,defaultBorderColor:Bf.color.border,padding:null,extraCssText:"",axisPointer:{type:"line",axis:"auto",animation:"auto",animationDurationUpdate:200,animationEasingUpdate:"exponentialOut",crossStyle:{color:Bf.color.borderShade,width:1,type:"dashed",textStyle:{}}},textStyle:{color:Bf.color.tertiary,fontSize:14}},e}(Hf);function tM(t){var e=t.get("confine");return null!=e?!!e:"richText"===t.get("renderMode")}function eM(t){if(Te.domSupported)for(var e=document.documentElement.style,n=0,i=t.length;n0&&r.push(function(t,e,n){var i="cubic-bezier(0.23,1,0.32,1)",r="",o="";return n&&(o="opacity"+(r=" "+t/2+"s "+i)+",visibility"+r),e||(r=" "+t+"s "+i,o+=(o.length?",":"")+(Te.transformSupported?""+oM+r:",left"+r+",top"+r)),rM+":"+o}(o,n,i)),a&&r.push("background-color:"+a),en(["width","color","radius"],function(e){var n="border-"+e,i=uf(n),o=t.get(i);null!=o&&r.push(n+":"+o+("color"===e?"":"px"))}),r.push(function(t){var e=[],n=t.get("fontSize"),i=t.getTextColor();i&&e.push("color:"+i),e.push("font:"+t.getFont());var r=xn(t.get("lineHeight"),Math.round(3*n/2));n&&e.push("line-height:"+r+"px");var o=t.get("textShadowColor"),a=t.get("textShadowBlur")||0,s=t.get("textShadowOffsetX")||0,l=t.get("textShadowOffsetY")||0;return o&&a&&e.push("text-shadow:"+s+"px "+l+"px "+a+"px "+o),en(["decoration","align"],function(n){var i=t.get(n);i&&e.push("text-"+n+":"+i)}),e.join(";")}(d)),null!=h&&r.push("padding:"+df(h).join("px ")+"px"),r.join(";")+";"}function cM(t,e,n,i,r){var o=e&&e.painter;if(n){var a=o&&o.getViewportRoot();a&&function(t,e,n,i,r){ri(ii,e,i,r,!0)&&ri(t,n,ii[0],ii[1])}(t,a,n,i,r)}else{t[0]=i,t[1]=r;var s=o&&o.getViewportRootOffset();s&&(t[0]+=s.offsetLeft,t[1]+=s.offsetTop)}t[2]=t[0]/e.getWidth(),t[3]=t[1]/e.getHeight()}var uM=function(){function t(t,e){if(this._show=!1,this._styleCoord=[0,0,0,0],this._enterable=!0,this._alwaysShowContent=!1,this._firstShow=!0,this._longHide=!0,Te.wxa)return null;var n=document.createElement("div");n.domBelongToZr=!0,this.el=n;var i=this._zr=t.getZr(),r=e.appendTo,o=r&&(dn(r)?document.querySelector(r):mn(r)?r:un(r)&&r(t.getDom()));cM(this._styleCoord,i,o,t.getWidth()/2,t.getHeight()/2),(o||t.getDom()).appendChild(n),this._api=t,this._container=o;var a=this;n.onmouseenter=function(){a._enterable&&(clearTimeout(a._hideTimeout),a._show=!0),a._inContent=!0},n.onmousemove=function(t){if(t=t||window.event,!a._enterable){var e=i.handler;gi(i.painter.getViewportRoot(),t,!0),e.dispatch("mousemove",t)}},n.onmouseleave=function(){a._inContent=!1,a._enterable&&a._show&&a.hideLater(a._hideDelay)}}return t.prototype.update=function(t){if(!this._container){var e=this._api.getDom(),n=(o="position",(a=(r=e).currentStyle||document.defaultView&&document.defaultView.getComputedStyle(r))?a[o]:null),i=e.style;"absolute"!==i.position&&"absolute"!==n&&(i.position="relative")}var r,o,a,s=t.get("alwaysShowContent");s&&this._moveIfResized(),this._alwaysShowContent=s,this._enableDisplayTransition=t.get("displayTransition")&&t.get("transitionDuration")>0,this.el.className=t.get("className")||""},t.prototype.show=function(t,e){clearTimeout(this._hideTimeout),clearTimeout(this._longHideTimeout);var n=this.el,i=n.style,r=this._styleCoord;n.innerHTML?i.cssText=aM+lM(t,!this._firstShow,this._longHide,this._enableDisplayTransition)+sM(r[0],r[1],!0)+"border-color:"+vf(e)+";"+(t.get("extraCssText")||"")+";pointer-events:"+(this._enterable?"auto":"none"):i.display="none",this._show=!0,this._firstShow=!1,this._longHide=!1},t.prototype.setContent=function(t,e,n,i,r){var o=this.el;if(null!=t){var a="";if(dn(r)&&"item"===n.get("trigger")&&!tM(n)&&(a=function(t,e,n){if(!dn(n)||"inside"===n)return"";var i=t.get("backgroundColor"),r=t.get("borderWidth");e=vf(e);var o,a,s="left"===(o=n)?"right":"right"===o?"left":"top"===o?"bottom":"top",l=Math.max(1.5*Math.round(r),6),c="",u=oM+":";Qe(["left","right"],s)>-1?(c+="top:50%",u+="translateY(-50%) rotate("+(a="left"===s?-225:-45)+"deg)"):(c+="left:50%",u+="translateX(-50%) rotate("+(a="top"===s?225:45)+"deg)");var d=a*Math.PI/180,h=l+r,p=h*Math.abs(Math.cos(d))+h*Math.abs(Math.sin(d)),f=e+" solid "+r+"px;";return'
'}(n,i,r)),dn(t))o.innerHTML=t+a;else if(t){o.innerHTML="",cn(t)||(t=[t]);for(var s=0;s=0?this._tryShow(n,i):"leave"===e&&this._hide(i))},this))},e.prototype._keepShow=function(){var t=this._tooltipModel,e=this._ecModel,n=this._api,i=t.get("triggerOn");if(null!=this._lastX&&null!=this._lastY&&"none"!==i&&"click"!==i){var r=this;clearTimeout(this._refreshUpdateTimeout),this._refreshUpdateTimeout=setTimeout(function(){!n.isDisposed()&&r.manuallyShowTip(t,e,n,{x:r._lastX,y:r._lastY,dataByCoordSys:r._lastDataByCoordSys})})}},e.prototype.manuallyShowTip=function(t,e,n,i){if(i.from!==this.uid&&!Te.node&&n.getDom()){var r=yM(i,n);this._ticket="";var o=i.dataByCoordSys,a=function(t,e,n){var i=Ns(t).queryOptionMap,r=i.keys()[0];if(!r||"series"===r)return;var o=Hs(e,r,i.get(r),{useDefault:!1,enableAll:!1,enableNone:!1}),a=o.models[0];if(!a)return;var s,l=n.getViewOfComponentModel(a);if(l.group.traverse(function(e){var n=nu(e).tooltipConfig;if(n&&n.name===t.name)return s=e,!0}),s)return{componentMainType:r,componentIndex:a.componentIndex,el:s}}(i,e,n);if(a){var s=a.el.getBoundingRect().clone();s.applyTransform(a.el.transform),this._tryShow({offsetX:s.x+s.width/2,offsetY:s.y+s.height/2,target:a.el,position:i.position,positionDefault:"bottom"},r)}else if(i.tooltip&&null!=i.x&&null!=i.y){var l=gM;l.x=i.x,l.y=i.y,l.update(),nu(l).tooltipConfig={name:null,option:i.tooltip},this._tryShow({offsetX:i.x,offsetY:i.y,target:l},r)}else if(o)this._tryShow({offsetX:i.x,offsetY:i.y,position:i.position,dataByCoordSys:o,tooltipOption:i.tooltipOption},r);else if(null!=i.seriesIndex){if(this._manuallyAxisShowTip(t,e,n,i))return;var c=UC(i,e),u=c.point[0],d=c.point[1];null!=u&&null!=d&&this._tryShow({offsetX:u,offsetY:d,target:c.el,position:i.position,positionDefault:"bottom"},r)}else null!=i.x&&null!=i.y&&(n.dispatchAction({type:"updateAxisPointer",x:i.x,y:i.y}),this._tryShow({offsetX:i.x,offsetY:i.y,position:i.position,target:n.getZr().findHover(i.x,i.y).target},r))}},e.prototype.manuallyHideTip=function(t,e,n,i){var r=this._tooltipContent;this._tooltipModel&&r.hideLater(this._tooltipModel.get("hideDelay")),this._lastX=this._lastY=this._lastDataByCoordSys=null,i.from!==this.uid&&this._hide(yM(i,n))},e.prototype._manuallyAxisShowTip=function(t,e,n,i){var r=i.seriesIndex,o=i.dataIndex,a=e.getComponent("axisPointer").coordSysAxesInfo;if(null!=r&&null!=o&&null!=a){var s=e.getSeriesByIndex(r);if(s)if("axis"===mM([s.getData().getItemModel(o),s,(s.coordinateSystem||{}).model],this._tooltipModel).get("trigger"))return n.dispatchAction({type:"updateAxisPointer",seriesIndex:r,dataIndex:o,position:i.position}),!0}},e.prototype._tryShow=function(t,e){var n=t.target;if(this._tooltipModel){this._lastX=t.offsetX,this._lastY=t.offsetY;var i=t.dataByCoordSys;if(i&&i.length)this._showAxisTooltip(i,t);else if(n){var r,o;if("legend"===nu(n).ssrType)return;this._lastDataByCoordSys=null,xy(n,function(t){if(t.tooltipDisabled)return r=o=null,!0;r||o||(null!=nu(t).dataIndex?r=t:null!=nu(t).tooltipConfig&&(o=t))},!0),r?this._showSeriesItemTooltip(t,r,e):o?this._showComponentItemTooltip(t,o,e):this._hide(e)}else this._lastDataByCoordSys=null,this._hide(e)}},e.prototype._showOrMove=function(t,e){var n=t.get("showDelay");e=sn(e,this),clearTimeout(this._showTimout),n>0?this._showTimout=setTimeout(e,n):e()},e.prototype._showAxisTooltip=function(t,e){var n=this._ecModel,i=this._tooltipModel,r=[e.offsetX,e.offsetY],o=mM([e.tooltipOption],i),a=this._renderMode,s=[],l=tm("section",{blocks:[],noHeader:!0}),c=[],u=new um;en(t,function(t){en(t.dataByAxis,function(t){var e=n.getComponent(t.axisDim+"Axis",t.axisIndex),r=t.value;if(e&&null!=r){var o=IC(r,e.axis,n,t.seriesDataIndices,t.valueLabelOpt),d=tm("section",{header:o,noHeader:!kn(o),sortBlocks:!0,blocks:[]});l.blocks.push(d),en(t.seriesDataIndices,function(l){var h=n.getSeriesByIndex(l.seriesIndex),p=l.dataIndexInside,f=h.getDataParams(p);if(!(f.dataIndex<0)){f.axisDim=t.axisDim,f.axisIndex=t.axisIndex,f.axisType=t.axisType,f.axisId=t.axisId,f.axisValue=Vx(e.axis,{value:r}),f.axisValueLabel=o,f.marker=u.makeTooltipMarker("item",vf(f.color),a);var g=xv(h.formatTooltip(p,!0,null)),v=g.frag;if(v){var m=mM([h],i).get("valueFormatter");d.blocks.push(m?Ze({valueFormatter:m},v):v)}g.text&&c.push(g.text),s.push(f)}})}})}),l.blocks.reverse(),c.reverse();var d=e.position,h=o.get("order"),p=am(l,u,a,h,n.get("useUTC"),o.get("textStyle"));p&&c.unshift(p);var f="richText"===a?"\n\n":"
",g=c.join(f);this._showOrMove(o,function(){this._updateContentNotChangedOnAxis(t,s)?this._updatePosition(o,d,r[0],r[1],this._tooltipContent,s):this._showTooltipContent(o,g,s,Math.random()+"",r[0],r[1],d,null,u)})},e.prototype._showSeriesItemTooltip=function(t,e,n){var i=this._ecModel,r=nu(e),o=r.seriesIndex,a=i.getSeriesByIndex(o),s=r.dataModel||a,l=r.dataIndex,c=r.dataType,u=s.getData(c),d=this._renderMode,h=t.positionDefault,p=mM([u.getItemModel(l),s,a&&(a.coordinateSystem||{}).model],this._tooltipModel,h?{position:h}:null),f=p.get("trigger");if(null==f||"item"===f){var g=s.getDataParams(l,c),v=new um;g.marker=v.makeTooltipMarker("item",vf(g.color),d);var m=xv(s.formatTooltip(l,!1,c)),y=p.get("order"),_=p.get("valueFormatter"),b=m.frag,x=b?am(_?Ze({valueFormatter:_},b):b,v,d,y,i.get("useUTC"),p.get("textStyle")):m.text,w="item_"+s.name+"_"+l;this._showOrMove(p,function(){this._showTooltipContent(p,x,g,w,t.offsetX,t.offsetY,t.position,t.target,v)}),n({type:"showTip",dataIndexInside:l,dataIndex:u.getRawIndex(l),seriesIndex:o,from:this.uid})}},e.prototype._showComponentItemTooltip=function(t,e,n){var i="html"===this._renderMode,r=nu(e),o=r.tooltipConfig.option||{},a=o.encodeHTMLContent;if(dn(o)){o={content:o,formatter:o},a=!0}a&&i&&o.content&&((o=Xe(o)).content=li(o.content));var s=[o],l=this._ecModel.getComponent(r.componentMainType,r.componentIndex);l&&s.push(l),s.push({formatter:o.content});var c=t.positionDefault,u=mM(s,this._tooltipModel,c?{position:c}:null),d=u.get("content"),h=Math.random()+"",p=new um;this._showOrMove(u,function(){var n=Xe(u.get("formatterParams")||{});this._showTooltipContent(u,d,n,h,t.offsetX,t.offsetY,t.position,e,p)}),n({type:"showTip",from:this.uid})},e.prototype._showTooltipContent=function(t,e,n,i,r,o,a,s,l){if(this._ticket="",t.get("showContent")&&t.get("show")){var c=this._tooltipContent;c.setEnterable(t.get("enterable"));var u=t.get("formatter");a=a||t.get("position");var d=e,h=this._getNearestPoint([r,o],n,t.get("trigger"),t.get("borderColor"),t.get("defaultBorderColor",!0)).color;if(u)if(dn(u)){var p=t.ecModel.get("useUTC"),f=cn(n)?n[0]:n;d=u,f&&f.axisType&&f.axisType.indexOf("time")>=0&&(d=Gp(f.axisValue,d,p)),d=gf(d,n,!0)}else if(un(u)){var g=sn(function(e,i){e===this._ticket&&(c.setContent(i,l,t,h,a),this._updatePosition(t,a,r,o,c,n,s))},this);this._ticket=i,d=u(n,i,g)}else d=u;c.setContent(d,l,t,h,a),c.show(t,h),this._updatePosition(t,a,r,o,c,n,s)}},e.prototype._getNearestPoint=function(t,e,n,i,r){return"axis"===n||cn(e)?{color:i||r}:cn(e)?void 0:{color:i||e.color||e.borderColor}},e.prototype._updatePosition=function(t,e,n,i,r,o,a){var s=this._api.getWidth(),l=this._api.getHeight();e=e||t.get("position");var c=r.getSize(),u=t.get("align"),d=t.get("verticalAlign"),h=a&&a.getBoundingRect().clone();if(a&&h.applyTransform(a.transform),un(e)&&(e=e([n,i],o,r.el,h,{viewSize:[s,l],contentSize:c.slice()})),cn(e))n=es(e[0],s),i=es(e[1],l);else if(fn(e)){var p=e;p.width=c[0],p.height=c[1];var f=Pf(p,{width:s,height:l});n=f.x,i=f.y,u=null,d=null}else if(dn(e)&&a){var g=function(t,e,n,i){var r=n[0],o=n[1],a=Math.ceil(Math.SQRT2*i)+8,s=0,l=0,c=e.width,u=e.height;switch(t){case"inside":s=e.x+c/2-r/2,l=e.y+u/2-o/2;break;case"top":s=e.x+c/2-r/2,l=e.y-o-a;break;case"bottom":s=e.x+c/2-r/2,l=e.y+u+a;break;case"left":s=e.x-r-a,l=e.y+u/2-o/2;break;case"right":s=e.x+c+a,l=e.y+u/2-o/2}return[s,l]}(e,h,c,t.get("borderWidth"));n=g[0],i=g[1]}else{g=function(t,e,n,i,r,o,a){var s=n.getSize(),l=s[0],c=s[1];null!=o&&(t+l+o+2>i?t-=l+o:t+=o);null!=a&&(e+c+a>r?e-=c+a:e+=a);return[t,e]}(n,i,r,s,l,u?null:20,d?null:20);n=g[0],i=g[1]}if(u&&(n-=_M(u)?c[0]/2:"right"===u?c[0]:0),d&&(i-=_M(d)?c[1]/2:"bottom"===d?c[1]:0),tM(t)){g=function(t,e,n,i,r){var o=n.getSize(),a=o[0],s=o[1];return t=Math.min(t+a,i)-a,e=Math.min(e+s,r)-s,t=Math.max(t,0),e=Math.max(e,0),[t,e]}(n,i,r,s,l);n=g[0],i=g[1]}r.moveTo(n,i)},e.prototype._updateContentNotChangedOnAxis=function(t,e){var n=this._lastDataByCoordSys,i=this._cbParamsList,r=!!n&&n.length===t.length;return r&&en(n,function(n,o){var a=n.dataByAxis||[],s=(t[o]||{}).dataByAxis||[];(r=r&&a.length===s.length)&&en(a,function(t,n){var o=s[n]||{},a=t.seriesDataIndices||[],l=o.seriesDataIndices||[];(r=r&&t.value===o.value&&t.axisType===o.axisType&&t.axisId===o.axisId&&a.length===l.length)&&en(a,function(t,e){var n=l[e];r=r&&t.seriesIndex===n.seriesIndex&&t.dataIndex===n.dataIndex}),i&&en(t.seriesDataIndices,function(t){var n=t.seriesIndex,o=e[n],a=i[n];o&&a&&a.data!==o.data&&(r=!1)})})}),this._lastDataByCoordSys=t,this._cbParamsList=e,!!r},e.prototype._hide=function(t){this._lastDataByCoordSys=null,t({type:"hideTip",from:this.uid})},e.prototype.dispose=function(t,e){!Te.node&&e.getDom()&&(Rm(this,"_updatePosition"),this._tooltipContent.dispose(),VC("itemTooltip",e))},e.type="tooltip",e}(wm);function mM(t,e,n){var i,r=e.ecModel;n?(i=new _p(n,r,r),i=new _p(e.option,i,r)):i=e;for(var o=t.length-1;o>=0;o--){var a=t[o];a&&(a instanceof _p&&(a=a.get("tooltip",!0)),dn(a)&&(a={formatter:a}),a&&(i=new _p(a,i,r)))}return i}function yM(t,e){return t.dispatchAction||sn(e.dispatchAction,e)}function _M(t){return"center"===t||"middle"===t}var bM=Math.sin,xM=Math.cos,wM=Math.PI,SM=2*Math.PI,CM=180/wM,MM=function(){function t(){}return t.prototype.reset=function(t){this._start=!0,this._d=[],this._str="",this._p=Math.pow(10,t||4)},t.prototype.moveTo=function(t,e){this._add("M",t,e)},t.prototype.lineTo=function(t,e){this._add("L",t,e)},t.prototype.bezierCurveTo=function(t,e,n,i,r,o){this._add("C",t,e,n,i,r,o)},t.prototype.quadraticCurveTo=function(t,e,n,i){this._add("Q",t,e,n,i)},t.prototype.arc=function(t,e,n,i,r,o){this.ellipse(t,e,n,n,0,i,r,o)},t.prototype.ellipse=function(t,e,n,i,r,o,a,s){var l=a-o,c=!s,u=Math.abs(l),d=ho(u-SM)||(c?l>=SM:-l>=SM),h=l>0?l%SM:l%SM+SM,p=!1;p=!!d||!ho(u)&&h>=wM==!!c;var f=t+n*xM(o),g=e+i*bM(o);this._start&&this._add("M",f,g);var v=Math.round(r*CM);if(d){var m=1/this._p,y=(c?1:-1)*(SM-m);this._add("A",n,i,v,1,+c,t+n*xM(o+y),e+i*bM(o+y)),m>.01&&this._add("A",n,i,v,0,+c,f,g)}else{var _=t+n*xM(a),b=e+i*bM(a);this._add("A",n,i,v,+p,+c,_,b)}},t.prototype.rect=function(t,e,n,i){this._add("M",t,e),this._add("l",n,0),this._add("l",0,i),this._add("l",-n,0),this._add("Z")},t.prototype.closePath=function(){this._d.length>0&&this._add("Z")},t.prototype._add=function(t,e,n,i,r,o,a,s,l){for(var c=[],u=this._p,d=1;d"}(r,o)+("style"!==r?li(a):a||"")+(i?""+n+nn(i,function(e){return t(e)}).join(n)+n:"")+("")}(t)}function RM(t){return{zrId:t,shadowCache:{},patternCache:{},gradientCache:{},clipPathCache:{},defs:{},cssNodes:{},cssAnims:{},cssStyleCache:{},cssAnimIdx:0,shadowIdx:0,gradientIdx:0,patternIdx:0,clipPathIdx:0}}function HM(t,e,n,i){return zM("svg","root",{width:t,height:e,xmlns:PM,"xmlns:xlink":LM,version:"1.1",baseProfile:"full",viewBox:!!i&&"0 0 "+t+" "+e},n)}var BM=0;function FM(){return BM++}var $M={cubicIn:"0.32,0,0.67,0",cubicOut:"0.33,1,0.68,1",cubicInOut:"0.65,0,0.35,1",quadraticIn:"0.11,0,0.5,0",quadraticOut:"0.5,1,0.89,1",quadraticInOut:"0.45,0,0.55,1",quarticIn:"0.5,0,0.75,0",quarticOut:"0.25,1,0.5,1",quarticInOut:"0.76,0,0.24,1",quinticIn:"0.64,0,0.78,0",quinticOut:"0.22,1,0.36,1",quinticInOut:"0.83,0,0.17,1",sinusoidalIn:"0.12,0,0.39,0",sinusoidalOut:"0.61,1,0.88,1",sinusoidalInOut:"0.37,0,0.63,1",exponentialIn:"0.7,0,0.84,0",exponentialOut:"0.16,1,0.3,1",exponentialInOut:"0.87,0,0.13,1",circularIn:"0.55,0,1,0.45",circularOut:"0,0.55,0.45,1",circularInOut:"0.85,0,0.15,1"},VM="transform-origin";function WM(t,e,n){var i=Ze({},t.shape);Ze(i,e),t.buildPath(n,i);var r=new MM;return r.reset(wo(t)),n.rebuildPath(r,1),r.generateStr(),r.getStr()}function UM(t,e){var n=e.originX,i=e.originY;(n||i)&&(t[VM]=n+"px "+i+"px")}var GM={fill:"fill",opacity:"opacity",lineWidth:"stroke-width",lineDashOffset:"stroke-dashoffset"};function qM(t,e){var n=e.zrId+"-ani-"+e.cssAnimIdx++;return e.cssAnims[n]=t,n}function jM(t){return dn(t)?$M[t]?"cubic-bezier("+$M[t]+")":Rr(t)?t:"":""}function XM(t,e,n,i){var r=t.animators,o=r.length,a=[];if(t instanceof Yd){var s=function(t,e,n){var i,r,o=t.shape.paths,a={};if(en(o,function(t){var e=RM(n.zrId);e.animation=!0,XM(t,{},e,!0);var o=e.cssAnims,s=e.cssNodes,l=an(o),c=l.length;if(c){var u=o[r=l[c-1]];for(var d in u){var h=u[d];a[d]=a[d]||{d:""},a[d].d+=h.d||""}for(var p in s){var f=s[p].animation;f.indexOf(r)>=0&&(i=f)}}}),i){e.d=!1;var s=qM(a,n);return i.replace(r,s)}}(t,e,n);if(s)a.push(s);else if(!o)return}else if(!o)return;for(var l={},c=0;c0}).length)return qM(u,n)+" "+r[0]+" both"}for(var v in l){(s=g(l[v]))&&a.push(s)}if(a.length){var m=n.zrId+"-cls-"+FM();n.cssNodes["."+m]={animation:a.join(",")},e.class=m}}function YM(t,e,n,i){var r=JSON.stringify(t),o=n.cssStyleCache[r];o||(o=n.zrId+"-cls-"+FM(),n.cssStyleCache[r]=o,n.cssNodes["."+o+":hover"]=t),e.class=e.class?e.class+" "+o:o}var ZM=Math.round;function KM(t){return t&&dn(t.src)}function QM(t){return t&&un(t.toDataURL)}function JM(t,e,n,i){IM(function(r,o){var a="fill"===r||"stroke"===r;a&&bo(o)?uk(e,t,r,i):a&&mo(o)?dk(n,t,r,i):t[r]=o,a&&i.ssr&&"none"===o&&(t["pointer-events"]="visible")},e,n,!1),function(t,e,n){var i=t.style;if(function(t){return t&&(t.shadowBlur||t.shadowOffsetX||t.shadowOffsetY)}(i)){var r=function(t){var e=t.style,n=t.getGlobalScale();return[e.shadowColor,(e.shadowBlur||0).toFixed(2),(e.shadowOffsetX||0).toFixed(2),(e.shadowOffsetY||0).toFixed(2),n[0],n[1]].join(",")}(t),o=n.shadowCache,a=o[r];if(!a){var s=t.getGlobalScale(),l=s[0],c=s[1];if(!l||!c)return;var u=i.shadowOffsetX||0,d=i.shadowOffsetY||0,h=i.shadowBlur,p=co(i.shadowColor),f=p.opacity,g=p.color,v=h/2/l+" "+h/2/c;a=n.zrId+"-s"+n.shadowIdx++,n.defs[a]=zM("filter",a,{id:a,x:"-100%",y:"-100%",width:"300%",height:"300%"},[zM("feDropShadow","",{dx:u/l,dy:d/c,stdDeviation:v,"flood-color":g,"flood-opacity":f})]),o[r]=a}e.filter=xo(a)}}(n,t,i)}function tk(t,e){var n=function(t){if("function"==typeof ja)return ja(t)}(e);n&&(n.each(function(e,n){null!=e&&(t[(EM+n).toLowerCase()]=e+"")}),e.isSilent()&&(t[EM+"silent"]="true"))}function ek(t){return ho(t[0]-1)&&ho(t[1])&&ho(t[2])&&ho(t[3]-1)}function nk(t,e,n){if(e&&(!function(t){return ho(t[4])&&ho(t[5])}(e)||!ek(e))){var i=1e4;t.transform=ek(e)?"translate("+ZM(e[4]*i)/i+" "+ZM(e[5]*i)/i+")":function(t){return"matrix("+po(t[0])+","+po(t[1])+","+po(t[2])+","+po(t[3])+","+fo(t[4])+","+fo(t[5])+")"}(e)}}function ik(t,e,n){for(var i=t.points,r=[],o=0;o=0&&a||o;s&&(r=so(s))}var l=i.lineWidth;l&&(l/=!i.strokeNoScale&&t.transform?t.transform[0]:1);var c={cursor:"pointer"};r&&(c.fill=r),i.stroke&&(c.stroke=i.stroke),l&&(c["stroke-width"]=l),YM(c,e,n)}}(t,o,e),zM(s,t.id+"",o)}function ck(t,e){return t instanceof Tc?lk(t,e):t instanceof Lc?function(t,e){var n=t.style,i=n.image;if(i&&!dn(i)&&(KM(i)?i=i.src:QM(i)&&(i=i.toDataURL())),i){var r=n.x||0,o=n.y||0,a={href:i,width:n.width,height:n.height};return r&&(a.x=r),o&&(a.y=o),nk(a,t.transform),JM(a,n,t,e),tk(a,t),e.animation&&XM(t,a,e),zM("image",t.id+"",a)}}(t,e):t instanceof Dc?function(t,e){var n=t.style,i=n.text;if(null!=i&&(i+=""),i&&!isNaN(n.x)&&!isNaN(n.y)){var r=n.font||De,o=n.x||0,a=function(t,e,n){return"top"===n?t+=e/2:"bottom"===n&&(t-=e/2),t}(n.y||0,Ia(r),n.textBaseline),s={"dominant-baseline":"central","text-anchor":go[n.textAlign]||n.textAlign};if(Yc(n)){var l="",c=n.fontStyle,u=jc(n.fontSize);if(!parseFloat(u))return;var d=n.fontFamily||Ae,h=n.fontWeight;l+="font-size:"+u+";font-family:"+d+";",c&&"normal"!==c&&(l+="font-style:"+c+";"),h&&"normal"!==h&&(l+="font-weight:"+h+";"),s.style=l}else s.style="font: "+r;return i.match(/\s/)&&(s["xml:space"]="preserve"),o&&(s.x=o),a&&(s.y=a),nk(s,t.transform),JM(s,n,t,e),tk(s,t),e.animation&&XM(t,s,e),zM("text",t.id+"",s,void 0,i)}}(t,e):void 0}function uk(t,e,n,i){var r,o=t[n],a={gradientUnits:o.global?"userSpaceOnUse":"objectBoundingBox"};if(yo(o))r="linearGradient",a.x1=o.x,a.y1=o.y,a.x2=o.x2,a.y2=o.y2;else{if(!_o(o))return;r="radialGradient",a.cx=xn(o.x,.5),a.cy=xn(o.y,.5),a.r=xn(o.r,.5)}for(var s=o.colorStops,l=[],c=0,u=s.length;cl?kk(t,null==n[d+1]?null:n[d+1].elm,n,s,d):Tk(t,e,a,l))}(n,i,r):wk(r)?(wk(t.text)&&_k(n,""),kk(n,null,r,0,r.length-1)):wk(i)?Tk(n,i,0,i.length-1):wk(t.text)&&_k(n,""):t.text!==e.text&&(wk(i)&&Tk(n,i,0,i.length-1),_k(n,e.text)))}var Ik=0,Pk=function(){function t(t,e,n){if(this.type="svg",this.refreshHover=function(){},this.configLayer=function(){},this.storage=e,this._opts=n=Ze({},n),this.root=t,this._id="zr"+Ik++,this._oldVNode=HM(n.width,n.height),t&&!n.ssr){var i=this._viewport=document.createElement("div");i.style.cssText="position:relative;overflow:hidden";var r=this._svgDom=this._oldVNode.elm=OM("svg");Ak(null,this._oldVNode),i.appendChild(r),t.appendChild(i)}this.resize(n.width,n.height)}return t.prototype.getType=function(){return this.type},t.prototype.getViewportRoot=function(){return this._viewport},t.prototype.getViewportRootOffset=function(){var t=this.getViewportRoot();if(t)return{offsetLeft:t.offsetLeft||0,offsetTop:t.offsetTop||0}},t.prototype.getSvgDom=function(){return this._svgDom},t.prototype.refresh=function(){if(this.root){var t=this.renderToVNode({willUpdate:!0});t.attrs.style="position:absolute;left:0;top:0;user-select:none",function(t,e){if(Ck(t,e))Dk(t,e);else{var n=t.elm,i=mk(n);Mk(e),null!==i&&(fk(i,e.elm,yk(n)),Tk(i,[t],0,0))}}(this._oldVNode,t),this._oldVNode=t}},t.prototype.renderOneToVNode=function(t){return ck(t,RM(this._id))},t.prototype.renderToVNode=function(t){t=t||{};var e=this.storage.getDisplayList(!0),n=this._width,i=this._height,r=RM(this._id);r.animation=t.animation,r.willUpdate=t.willUpdate,r.compress=t.compress,r.emphasis=t.emphasis,r.ssr=this._opts.ssr;var o=[],a=this._bgVNode=function(t,e,n,i){var r;if(n&&"none"!==n)if(r=zM("rect","bg",{width:t,height:e,x:"0",y:"0"}),bo(n))uk({fill:n},r.attrs,"fill",i);else if(mo(n))dk({style:{fill:n},dirty:Nn,getBoundingRect:function(){return{width:t,height:e}}},r.attrs,"fill",i);else{var o=co(n),a=o.color,s=o.opacity;r.attrs.fill=a,s<1&&(r.attrs["fill-opacity"]=s)}return r}(n,i,this._backgroundColor,r);a&&o.push(a);var s=t.compress?null:this._mainVNode=zM("g","main",{},[]);this._paintList(e,r,s?s.children:o),s&&o.push(s);var l=nn(an(r.defs),function(t){return r.defs[t]});if(l.length&&o.push(zM("defs","defs",{},l)),t.animation){var c=function(t,e,n){var i=(n=n||{}).newline?"\n":"",r=" {"+i,o=i+"}",a=nn(an(t),function(e){return e+r+nn(an(t[e]),function(n){return n+":"+t[e][n]+";"}).join(i)+o}).join(i),s=nn(an(e),function(t){return"@keyframes "+t+r+nn(an(e[t]),function(n){return n+r+nn(an(e[t][n]),function(i){var r=e[t][n][i];return"d"===i&&(r='path("'+r+'")'),i+":"+r+";"}).join(i)+o}).join(i)+o}).join(i);return a||s?[""].join(i):""}(r.cssNodes,r.cssAnims,{newline:!0});if(c){var u=zM("style","stl",{},[],c);o.push(u)}}return HM(n,i,o,t.useViewBox)},t.prototype.renderToString=function(t){return t=t||{},NM(this.renderToVNode({animation:xn(t.cssAnimation,!0),emphasis:xn(t.cssEmphasis,!0),willUpdate:!1,compress:!0,useViewBox:xn(t.useViewBox,!0)}),{newline:!0})},t.prototype.setBackgroundColor=function(t){this._backgroundColor=t},t.prototype.getSvgRoot=function(){return this._mainVNode&&this._mainVNode.elm},t.prototype._paintList=function(t,e,n){for(var i,r,o=t.length,a=[],s=0,l=0,c=0;c=0&&(!d||!r||d[f]!==r[f]);f--);for(var g=p-1;g>f;g--)i=a[--s-1];for(var v=f+1;v10&&"cartesian2d"===o.type&&r){var s=o.getBaseAxis(),l=o.getOtherAxis(s),c=s.getExtent(),u=n.getDevicePixelRatio(),d=Math.abs(c[1]-c[0])*(u||1),h=Math.round(a/d);if(isFinite(h)&&h>1){"lttb"===r?t.setData(i.lttbDownSample(i.mapDimension(l.dim),1/h)):"minmax"===r&&t.setData(i.minmaxDownSample(i.mapDimension(l.dim),1/h));var p=void 0;dn(r)?p=rS[r]:un(r)&&(p=r),p&&t.setData(i.downSample(i.mapDimension(l.dim),1/h,p,oS))}}}}}("line"))},function(t){Qx(_C),Qx(QC)},function(t){Qx(QC),t.registerComponentModel(JC),t.registerComponentView(vM),t.registerAction({type:"showTip",event:"showTip",update:"tooltip:manuallyShowTip"},Nn),t.registerAction({type:"hideTip",event:"hideTip",update:"tooltip:manuallyHideTip"},Nn)},function(t){t.registerPainter("svg",Pk)}]);class Lk extends It{constructor(){super(...arguments),this.options=null,this.data=[],this.height="120px",this._chart=null,this._resizeObserver=null}render(){return dt`
`}connectedCallback(){super.connectedCallback(),this.hasUpdated&&!this._chart&&this.options&&(this._mount(),this._observeResize())}firstUpdated(){this._mount(),this._observeResize()}updated(t){(t.has("options")||t.has("data"))&&(this._chart&&this.options?this._chart.setOption(this._mergedOptions(),!0):!this._chart&&this.options&&this._mount()),t.has("height")&&this._chart&&this._chart.resize()}disconnectedCallback(){this._destroy(),super.disconnectedCallback()}_mount(){if(!this.options)return;const t=this.shadowRoot?.querySelector(".chart-host");t&&(this._chart=rb(t,void 0,{renderer:"svg"}),this._chart.setOption(this._mergedOptions(),!0))}_mergedOptions(){if(!this.options)throw new Error("span-chart: options not set");return{...this.options,series:this.data}}_observeResize(){if("undefined"==typeof ResizeObserver)return;this._resizeObserver=new ResizeObserver(()=>{this._chart&&this._chart.resize()});const t=this.shadowRoot?.querySelector(".chart-host");t&&this._resizeObserver.observe(t)}_destroy(){this._resizeObserver&&(this._resizeObserver.disconnect(),this._resizeObserver=null),this._chart&&(this._chart.dispose(),this._chart=null)}}Lk.styles=T` +var Ga={},qa={};var ja,Xa=function(){function t(t,e,n){var i=this;this._sleepAfterStill=10,this._stillFrameAccum=0,this._needsRefresh=!0,this._needsRefreshHover=!0,this._darkMode=!1,n=n||{},this.dom=e,this.id=t;var r=new hr,o=n.renderer||"canvas";Ga[o]||(o=an(Ga)[0]),n.useDirtyRect=null!=n.useDirtyRect&&n.useDirtyRect;var a=new Ga[o](e,r,n,t),s=n.ssr||a.ssrOnly;this.storage=r,this.painter=a;var l,c=Te.node||Te.worker||s?null:new oa(a.getViewportRoot(),a.root),u=n.useCoarsePointer;(null==u||"auto"===u?Te.touchEventsSupported:!!u)&&(l=xn(n.pointerSize,44)),this.handler=new Qi(r,a,c,a.root,l),this.animation=new Vo({stage:{update:s?null:function(){return i._flush(!0)}}}),s||this.animation.start()}return t.prototype.add=function(t){!this._disposed&&t&&(this.storage.addRoot(t),t.addSelfToZr(this),this.refresh())},t.prototype.remove=function(t){!this._disposed&&t&&(this.storage.delRoot(t),t.removeSelfFromZr(this),this.refresh())},t.prototype.configLayer=function(t,e){this._disposed||(this.painter.configLayer&&this.painter.configLayer(t,e),this.refresh())},t.prototype.setBackgroundColor=function(t){this._disposed||(this.painter.setBackgroundColor&&this.painter.setBackgroundColor(t),this.refresh(),this._backgroundColor=t,this._darkMode=function(t){if(!t)return!1;if("string"==typeof t)return oo(t,1)<.4;if(t.colorStops){for(var e=t.colorStops,n=0,i=e.length,r=0;r0&&(this._stillFrameAccum++,this._stillFrameAccum>this._sleepAfterStill&&this.animation.stop())},t.prototype.setSleepAfterStill=function(t){this._sleepAfterStill=t},t.prototype.wakeUp=function(){this._disposed||(this.animation.start(),this._stillFrameAccum=0)},t.prototype.refreshHover=function(){this._needsRefreshHover=!0},t.prototype.refreshHoverImmediately=function(){this._disposed||(this._needsRefreshHover=!1,this.painter.refreshHover&&"canvas"===this.painter.getType()&&this.painter.refreshHover())},t.prototype.resize=function(t){this._disposed||(t=t||{},this.painter.resize(t.width,t.height),this.handler.resize())},t.prototype.clearAnimation=function(){this._disposed||this.animation.clear()},t.prototype.getWidth=function(){if(!this._disposed)return this.painter.getWidth()},t.prototype.getHeight=function(){if(!this._disposed)return this.painter.getHeight()},t.prototype.setCursorStyle=function(t){this._disposed||this.handler.setCursorStyle(t)},t.prototype.findHover=function(t,e){if(!this._disposed)return this.handler.findHover(t,e)},t.prototype.on=function(t,e,n){return this._disposed||this.handler.on(t,e,n),this},t.prototype.off=function(t,e){this._disposed||this.handler.off(t,e)},t.prototype.trigger=function(t,e){this._disposed||this.handler.trigger(t,e)},t.prototype.clear=function(){if(!this._disposed){for(var t=this.storage.getRoots(),e=0;e0){if(t<=r)return a;if(t>=o)return s}else{if(t>=r)return a;if(t<=o)return s}else{if(t===r)return a;if(t===o)return s}return(t-r)/l*c+a}var es=function(t,e,n){switch(t){case"center":case"middle":t="50%";break;case"left":case"top":t="0%";break;case"right":case"bottom":t="100%"}return ns(t,e,n)};function ns(t,e,n){return dn(t)?(i=t,i.replace(/^\s+|\s+$/g,"")).match(/%$/)?parseFloat(t)/100*e+(n||0):parseFloat(t):null==t?NaN:+t;var i}function is(t,e,n){return null==e&&(e=10),e=Math.min(Math.max(0,e),20),t=(+t).toFixed(e),n?t:+t}function rs(t){if(t=+t,isNaN(t))return 0;if(t>1e-14)for(var e=1,n=0;n<15;n++,e*=10)if(Math.round(t*e)/e===t)return n;return function(t){var e=t.toString().toLowerCase(),n=e.indexOf("e"),i=n>0?+e.slice(n+1):0,r=n>0?n:e.length,o=e.indexOf("."),a=o<0?0:r-1-o;return Math.max(0,a-i)}(t)}function os(t,e){var n=Math.max(rs(t),rs(e)),i=t+e;return n>20?i:is(i,n)}function as(t){var e=2*Math.PI;return(t%e+e)%e}function ss(t){return t>-1e-4&&t=10&&e++,e}function ds(t,e){var n=us(t),i=Math.pow(10,n),r=t/i;return t=(r<1.5?1:r<2.5?2:r<4?3:r<7?5:10)*i,n>=-20?+t.toFixed(n<0?-n:0):t}function hs(t){var e=parseFloat(t);return e==t&&(0!==e||!dn(t)||t.indexOf("x")<=0)?e:NaN}function ps(){return Math.round(9*Math.random())}function fs(t,e){return 0===e?t:fs(e,t%e)}function gs(t,e){return null==t?e:null==e?t:t*e/fs(t,e)}var vs="undefined"!=typeof console&&console.warn&&console.log;function ms(t,e){!function(t,e){vs&&console[t]("[ECharts] "+e)}("error",t)}function ys(t){throw new Error(t)}function _s(t,e,n){return(e-t)*n+t}var bs="series\0";function xs(t){return t instanceof Array?t:null==t?[]:[t]}function ws(t,e,n){if(t){t[e]=t[e]||{},t.emphasis=t.emphasis||{},t.emphasis[e]=t.emphasis[e]||{};for(var i=0,r=n.length;i=0||r&&Qe(r,s)<0)){var l=n.getShallow(s,e);null!=l&&(o[t[a][0]]=l)}}return o}}var Zs=Ys([["fill","color"],["shadowBlur"],["shadowOffsetX"],["shadowOffsetY"],["opacity"],["shadowColor"]]),Ks=function(){function t(){}return t.prototype.getAreaStyle=function(t,e){return Zs(this,t,e)},t}(),Qs=new $r(50);function Js(t){if("string"==typeof t){var e=Qs.get(t);return e&&e.image}return t}function tl(t,e,n,i,r){if(t){if("string"==typeof t){if(e&&e.__zrImageSrc===t||!n)return e;var o=Qs.get(t),a={hostEl:n,cb:i,cbPayload:r};return o?!nl(e=o.image)&&o.pending.push(a):((e=Ee.loadImage(t,el,el)).__zrImageSrc=t,Qs.put(t,e.__cachedImgObj={image:e,pending:[a]})),e}return t}return e}function el(){var t=this.__cachedImgObj;this.onload=this.onerror=this.__cachedImgObj=null;for(var e=0;e=s;c++)l-=s;var u=Ma(a,n);return u>l&&(n="",u=0),l=t-u,r.ellipsis=n,r.ellipsisWidth=u,r.contentWidth=l,r.containerWidth=t,r}function al(t,e,n){var i=n.containerWidth,r=n.contentWidth,o=n.fontMeasureInfo;if(!i)return t.textLine="",void(t.isTruncated=!1);var a=Ma(o,e);if(a<=i)return t.textLine=e,void(t.isTruncated=!1);for(var s=0;;s++){if(a<=r||s>=n.maxIterations){e+=n.ellipsis;break}var l=0===s?sl(e,r,o):a>0?Math.floor(e.length*r/a):0;a=Ma(o,e=e.substr(0,l))}""===e&&(e=n.placeholder),t.textLine=e,t.isTruncated=!0}function sl(t,e,n){for(var i=0,r=0,o=t.length;r0&&f+i.accumWidth>i.width&&(o=e.split("\n"),d=!0),i.accumWidth=f}else{var g=fl(e,u,i.width,i.breakAll,i.accumWidth);i.accumWidth=g.accumWidth+p,a=g.linesWidths,o=g.lines}}o||(o=e.split("\n"));for(var v=xa(u),m=0;m=32&&e<=591||e>=880&&e<=4351||e>=4608&&e<=5119||e>=7680&&e<=8303}(t)||!!hl[t]}function fl(t,e,n,i,r){for(var o=[],a=[],s="",l="",c=0,u=0,d=xa(e),h=0;hn:r+u+f>n)?u?(s||l)&&(g?(s||(s=l,l="",u=c=0),o.push(s),a.push(u-c),l+=p,s="",u=c+=f):(l&&(s+=l,l="",c=0),o.push(s),a.push(u),s=p,u=f)):g?(o.push(l),a.push(c),l=p,c=f):(o.push(p),a.push(f)):(u+=f,g?(l+=p,c+=f):(l&&(s+=l,l="",c=0),s+=p))}else l&&(s+=l,u+=c),o.push(s),a.push(u),s="",l="",c=0,u=0}return l&&(s+=l),s&&(o.push(s),a.push(u)),1===o.length&&(u+=r),{accumWidth:u,lines:o,linesWidths:a}}function gl(t,e,n,i,r,o){if(t.baseX=n,t.baseY=i,t.outerWidth=t.outerHeight=null,e){var a=2*e.width,s=2*e.height;$i.set(vl,Da(n,a,r),Aa(i,s,o),a,s),$i.intersect(e,vl,null,ml);var l=ml.outIntersectRect;t.outerWidth=l.width,t.outerHeight=l.height,t.baseX=Da(l.x,l.width,r,!0),t.baseY=Aa(l.y,l.height,o,!0)}}var vl=new $i(0,0,0,0),ml={outIntersectRect:{},clamp:!0};function yl(t){return null!=t?t+="":t=""}function _l(t,e,n,i){var r=new $i(Da(t.x||0,e,t.textAlign),Aa(t.y||0,n,t.textBaseline),e,n),o=null!=i?i:bl(t)?t.lineWidth:0;return o>0&&(r.x-=o/2,r.y-=o/2,r.width+=o,r.height+=o),r}function bl(t){var e=t.stroke;return null!=e&&"none"!==e&&t.lineWidth>0}var xl="__zr_style_"+Math.round(10*Math.random()),wl={shadowBlur:0,shadowOffsetX:0,shadowOffsetY:0,shadowColor:"#000",opacity:1,blend:"source-over"},Sl={style:{shadowBlur:!0,shadowOffsetX:!0,shadowOffsetY:!0,shadowColor:!0,opacity:!0}};wl[xl]=!0;var Cl=["z","z2","invisible"],Ml=["invisible"],kl=function(t){function e(e){return t.call(this,e)||this}var n;return _(e,t),e.prototype._init=function(e){for(var n=an(e),i=0;i1e-4)return s[0]=t-n,s[1]=e-i,l[0]=t+n,void(l[1]=e+i);if(Ol[0]=Ll(r)*n+t,Ol[1]=Pl(r)*i+e,zl[0]=Ll(o)*n+t,zl[1]=Pl(o)*i+e,c(s,Ol,zl),u(l,Ol,zl),(r%=El)<0&&(r+=El),(o%=El)<0&&(o+=El),r>o&&!a?o+=El:rr&&(Nl[0]=Ll(p)*n+t,Nl[1]=Pl(p)*i+e,c(s,Nl,s),u(l,Nl,l))}var Wl={M:1,L:2,C:3,Q:4,A:5,Z:6,R:7},Ul=[],Gl=[],ql=[],jl=[],Xl=[],Yl=[],Zl=Math.min,Kl=Math.max,Ql=Math.cos,Jl=Math.sin,tc=Math.abs,ec=Math.PI,nc=2*ec,ic="undefined"!=typeof Float32Array,rc=[];function oc(t){return Math.round(t/ec*1e8)/1e8%2*ec}var ac=function(){function t(t){this.dpr=1,this._xi=0,this._yi=0,this._x0=0,this._y0=0,this._len=0,t&&(this._saveData=!1),this._saveData&&(this.data=[])}var e;return t.prototype.increaseVersion=function(){this._version++},t.prototype.getVersion=function(){return this._version},t.prototype.setScale=function(t,e,n){(n=n||0)>0&&(this._ux=tc(n/sa/t)||0,this._uy=tc(n/sa/e)||0)},t.prototype.setDPR=function(t){this.dpr=t},t.prototype.setContext=function(t){this._ctx=t},t.prototype.getContext=function(){return this._ctx},t.prototype.beginPath=function(){return this._ctx&&this._ctx.beginPath(),this.reset(),this},t.prototype.reset=function(){this._saveData&&(this._len=0),this._pathSegLen&&(this._pathSegLen=null,this._pathLen=0),this._version++},t.prototype.moveTo=function(t,e){return this._drawPendingPt(),this.addData(Wl.M,t,e),this._ctx&&this._ctx.moveTo(t,e),this._x0=t,this._y0=e,this._xi=t,this._yi=e,this},t.prototype.lineTo=function(t,e){var n=tc(t-this._xi),i=tc(e-this._yi),r=n>this._ux||i>this._uy;if(this.addData(Wl.L,t,e),this._ctx&&r&&this._ctx.lineTo(t,e),r)this._xi=t,this._yi=e,this._pendingPtDist=0;else{var o=n*n+i*i;o>this._pendingPtDist&&(this._pendingPtX=t,this._pendingPtY=e,this._pendingPtDist=o)}return this},t.prototype.bezierCurveTo=function(t,e,n,i,r,o){return this._drawPendingPt(),this.addData(Wl.C,t,e,n,i,r,o),this._ctx&&this._ctx.bezierCurveTo(t,e,n,i,r,o),this._xi=r,this._yi=o,this},t.prototype.quadraticCurveTo=function(t,e,n,i){return this._drawPendingPt(),this.addData(Wl.Q,t,e,n,i),this._ctx&&this._ctx.quadraticCurveTo(t,e,n,i),this._xi=n,this._yi=i,this},t.prototype.arc=function(t,e,n,i,r,o){this._drawPendingPt(),rc[0]=i,rc[1]=r,function(t,e){var n=oc(t[0]);n<0&&(n+=nc);var i=n-t[0],r=t[1];r+=i,!e&&r-n>=nc?r=n+nc:e&&n-r>=nc?r=n-nc:!e&&n>r?r=n+(nc-oc(n-r)):e&&n0&&o))for(var a=0;ac.length&&(this._expandData(),c=this.data);for(var u=0;u0&&(this._ctx&&this._ctx.lineTo(this._pendingPtX,this._pendingPtY),this._pendingPtDist=0)},t.prototype._expandData=function(){if(!(this.data instanceof Array)){for(var t=[],e=0;e11&&(this.data=new Float32Array(t)))}},t.prototype.getBoundingRect=function(){ql[0]=ql[1]=Xl[0]=Xl[1]=Number.MAX_VALUE,jl[0]=jl[1]=Yl[0]=Yl[1]=-Number.MAX_VALUE;var t,e=this.data,n=0,i=0,r=0,o=0;for(t=0;tn||tc(v)>i||d===e-1)&&(f=Math.sqrt(A*A+v*v),r=g,o=_);break;case Wl.C:var m=t[d++],y=t[d++],_=(g=t[d++],t[d++]),b=t[d++],x=t[d++];f=Ir(r,o,m,y,g,_,b,x,10),r=b,o=x;break;case Wl.Q:f=zr(r,o,m=t[d++],y=t[d++],g=t[d++],_=t[d++],10),r=g,o=_;break;case Wl.A:var w=t[d++],S=t[d++],C=t[d++],M=t[d++],k=t[d++],T=t[d++],D=T+k;d+=1,p&&(a=Ql(k)*C+w,s=Jl(k)*M+S),f=Kl(C,M)*Zl(nc,Math.abs(T)),r=Ql(D)*C+w,o=Jl(D)*M+S;break;case Wl.R:a=r=t[d++],s=o=t[d++],f=2*t[d++]+2*t[d++];break;case Wl.Z:var A=a-r;v=s-o;f=Math.sqrt(A*A+v*v),r=a,o=s}f>=0&&(l[u++]=f,c+=f)}return this._pathLen=c,c},t.prototype.rebuildPath=function(t,e){var n,i,r,o,a,s,l,c,u,d,h=this.data,p=this._ux,f=this._uy,g=this._len,v=e<1,m=0,y=0,_=0;if(!v||(this._pathSegLen||this._calculateLength(),l=this._pathSegLen,c=e*this._pathLen))t:for(var b=0;b0&&(t.lineTo(u,d),_=0),x){case Wl.M:n=r=h[b++],i=o=h[b++],t.moveTo(r,o);break;case Wl.L:a=h[b++],s=h[b++];var S=tc(a-r),C=tc(s-o);if(S>p||C>f){if(v){if(m+(X=l[y++])>c){var M=(c-m)/X;t.lineTo(r*(1-M)+a*M,o*(1-M)+s*M);break t}m+=X}t.lineTo(a,s),r=a,o=s,_=0}else{var k=S*S+C*C;k>_&&(u=a,d=s,_=k)}break;case Wl.C:var T=h[b++],D=h[b++],A=h[b++],I=h[b++],P=h[b++],L=h[b++];if(v){if(m+(X=l[y++])>c){Ar(r,T,A,P,M=(c-m)/X,Ul),Ar(o,D,I,L,M,Gl),t.bezierCurveTo(Ul[1],Gl[1],Ul[2],Gl[2],Ul[3],Gl[3]);break t}m+=X}t.bezierCurveTo(T,D,A,I,P,L),r=P,o=L;break;case Wl.Q:T=h[b++],D=h[b++],A=h[b++],I=h[b++];if(v){if(m+(X=l[y++])>c){Or(r,T,A,M=(c-m)/X,Ul),Or(o,D,I,M,Gl),t.quadraticCurveTo(Ul[1],Gl[1],Ul[2],Gl[2]);break t}m+=X}t.quadraticCurveTo(T,D,A,I),r=A,o=I;break;case Wl.A:var E=h[b++],O=h[b++],z=h[b++],N=h[b++],R=h[b++],H=h[b++],B=h[b++],F=!h[b++],$=z>N?z:N,V=tc(z-N)>.001,W=R+H,U=!1;if(v)m+(X=l[y++])>c&&(W=R+H*(c-m)/X,U=!0),m+=X;if(V&&t.ellipse?t.ellipse(E,O,z,N,B,R,W,F):t.arc(E,O,$,R,W,F),U)break t;w&&(n=Ql(R)*z+E,i=Jl(R)*N+O),r=Ql(W)*z+E,o=Jl(W)*N+O;break;case Wl.R:n=r=h[b],i=o=h[b+1],a=h[b++],s=h[b++];var G=h[b++],q=h[b++];if(v){if(m+(X=l[y++])>c){var j=c-m;t.moveTo(a,s),t.lineTo(a+Zl(j,G),s),(j-=G)>0&&t.lineTo(a+G,s+Zl(j,q)),(j-=q)>0&&t.lineTo(a+Kl(G-j,0),s+q),(j-=G)>0&&t.lineTo(a,s+Kl(q-j,0));break t}m+=X}t.rect(a,s,G,q);break;case Wl.Z:if(v){var X;if(m+(X=l[y++])>c){M=(c-m)/X;t.lineTo(r*(1-M)+n*M,o*(1-M)+i*M);break t}m+=X}t.closePath(),r=n,o=i}}},t.prototype.clone=function(){var e=new t,n=this.data;return e.data=n.slice?n.slice():Array.prototype.slice.call(n),e._len=this._len,e},t.prototype.canSave=function(){return!!this._saveData},t.CMD=Wl,t.initDefaultProps=((e=t.prototype)._saveData=!0,e._ux=0,e._uy=0,e._pendingPtDist=0,void(e._version=0)),t}();function sc(t,e,n,i,r,o,a){if(0===r)return!1;var s=r,l=0;if(a>e+s&&a>i+s||at+s&&o>n+s||oe+d&&u>i+d&&u>o+d&&u>s+d||ut+d&&c>n+d&&c>r+d&&c>a+d||c=0&&pe+c&&l>i+c&&l>o+c||lt+c&&s>n+c&&s>r+c||s=0&&gn||u+cr&&(r+=hc);var h=Math.atan2(l,s);return h<0&&(h+=hc),h>=i&&h<=r||h+hc>=i&&h+hc<=r}function fc(t,e,n,i,r,o){if(o>e&&o>i||or?s:0}var gc=ac.CMD,vc=2*Math.PI;var mc=[-1,-1,-1],yc=[-1,-1];function _c(){var t=yc[0];yc[0]=yc[1],yc[1]=t}function bc(t,e,n,i,r,o,a,s,l,c){if(c>e&&c>i&&c>o&&c>s||c1&&_c(),p=Mr(e,i,o,s,yc[0]),h>1&&(f=Mr(e,i,o,s,yc[1]))),2===h?ve&&s>i&&s>o||s=0&&u<=1&&(r[l++]=u);else{var c=a*a-4*o*s;if(Sr(c))(u=-a/(2*o))>=0&&u<=1&&(r[l++]=u);else if(c>0){var u,d=gr(c),h=(-a-d)/(2*o);(u=(-a+d)/(2*o))>=0&&u<=1&&(r[l++]=u),h>=0&&h<=1&&(r[l++]=h)}}return l}(e,i,o,s,mc);if(0===l)return 0;var c=Er(e,i,o);if(c>=0&&c<=1){for(var u=0,d=Pr(e,i,o,c),h=0;hn||s<-n)return 0;var l=Math.sqrt(n*n-s*s);mc[0]=-l,mc[1]=l;var c=Math.abs(i-r);if(c<1e-4)return 0;if(c>=vc-1e-4){i=0,r=vc;var u=o?1:-1;return a>=mc[0]+t&&a<=mc[1]+t?u:0}if(i>r){var d=i;i=r,r=d}i<0&&(i+=vc,r+=vc);for(var h=0,p=0;p<2;p++){var f=mc[p];if(f+t>a){var g=Math.atan2(s,f);u=o?1:-1;g<0&&(g=vc+g),(g>=i&&g<=r||g+vc>=i&&g+vc<=r)&&(g>Math.PI/2&&g<1.5*Math.PI&&(u=-u),h+=u)}}return h}function Sc(t,e,n,i,r){for(var o,a,s=t.data,l=t.len(),c=0,u=0,d=0,h=0,p=0,f=0;f1&&(n||(c+=fc(u,d,h,p,i,r))),v&&(h=u=s[f],p=d=s[f+1]),g){case gc.M:u=h=s[f++],d=p=s[f++];break;case gc.L:if(n){if(sc(u,d,s[f],s[f+1],e,i,r))return!0}else c+=fc(u,d,s[f],s[f+1],i,r)||0;u=s[f++],d=s[f++];break;case gc.C:if(n){if(lc(u,d,s[f++],s[f++],s[f++],s[f++],s[f],s[f+1],e,i,r))return!0}else c+=bc(u,d,s[f++],s[f++],s[f++],s[f++],s[f],s[f+1],i,r)||0;u=s[f++],d=s[f++];break;case gc.Q:if(n){if(cc(u,d,s[f++],s[f++],s[f],s[f+1],e,i,r))return!0}else c+=xc(u,d,s[f++],s[f++],s[f],s[f+1],i,r)||0;u=s[f++],d=s[f++];break;case gc.A:var m=s[f++],y=s[f++],_=s[f++],b=s[f++],x=s[f++],w=s[f++];f+=1;var S=!!(1-s[f++]);o=Math.cos(x)*_+m,a=Math.sin(x)*b+y,v?(h=o,p=a):c+=fc(u,d,o,a,i,r);var C=(i-m)*b/_+m;if(n){if(pc(m,y,b,x,x+w,S,e,C,r))return!0}else c+=wc(m,y,b,x,x+w,S,C,r);u=Math.cos(x+w)*_+m,d=Math.sin(x+w)*b+y;break;case gc.R:if(h=u=s[f++],p=d=s[f++],o=h+s[f++],a=p+s[f++],n){if(sc(h,p,o,p,e,i,r)||sc(o,p,o,a,e,i,r)||sc(o,a,h,a,e,i,r)||sc(h,a,h,p,e,i,r))return!0}else c+=fc(o,p,o,a,i,r),c+=fc(h,a,h,p,i,r);break;case gc.Z:if(n){if(sc(u,d,h,p,e,i,r))return!0}else c+=fc(u,d,h,p,i,r);u=h,d=p}}return n||function(t,e){return Math.abs(t-e)<1e-4}(d,p)||(c+=fc(u,d,h,p,i,r)||0),0!==c}var Cc=Ke({fill:"#000",stroke:null,strokePercent:1,fillOpacity:1,strokeOpacity:1,lineDashOffset:0,lineWidth:1,lineCap:"butt",miterLimit:10,strokeNoScale:!1,strokeFirst:!1},wl),Mc={style:Ke({fill:!0,stroke:!0,strokePercent:!0,fillOpacity:!0,strokeOpacity:!0,lineDashOffset:!0,lineWidth:!0,miterLimit:!0},Sl.style)},kc=_a.concat(["invisible","culling","z","z2","zlevel","parent"]),Tc=function(t){function e(e){return t.call(this,e)||this}var n;return _(e,t),e.prototype.update=function(){var n=this;t.prototype.update.call(this);var i=this.style;if(i.decal){var r=this._decalEl=this._decalEl||new e;r.buildPath===e.prototype.buildPath&&(r.buildPath=function(t){n.buildPath(t,n.shape)}),r.silent=!0;var o=r.style;for(var a in i)o[a]!==i[a]&&(o[a]=i[a]);o.fill=i.fill?i.decal:null,o.decal=null,o.shadowColor=null,i.strokeFirst&&(o.stroke=null);for(var s=0;s.5?la:e>.2?"#eee":ca}if(t)return ca}return la},e.prototype.getInsideTextStroke=function(t){var e=this.style.fill;if(dn(e)){var n=this.__zr;if(!(!n||!n.isDarkMode())===oo(t,0)<.4)return e}},e.prototype.buildPath=function(t,e,n){},e.prototype.pathUpdated=function(){this.__dirty&=-5},e.prototype.getUpdatedPathProxy=function(t){return!this.path&&this.createPathProxy(),this.path.beginPath(),this.buildPath(this.path,this.shape,t),this.path},e.prototype.createPathProxy=function(){this.path=new ac(!1)},e.prototype.hasStroke=function(){var t=this.style,e=t.stroke;return!(null==e||"none"===e||!(t.lineWidth>0))},e.prototype.hasFill=function(){var t=this.style.fill;return null!=t&&"none"!==t},e.prototype.getBoundingRect=function(){var t=this._rect,e=this.style,n=!t;if(n){var i=!1;this.path||(i=!0,this.createPathProxy());var r=this.path;(i||4&this.__dirty)&&(r.beginPath(),this.buildPath(r,this.shape,!1),this.pathUpdated()),t=r.getBoundingRect()}if(this._rect=t,this.hasStroke()&&this.path&&this.path.len()>0){var o=this._rectStroke||(this._rectStroke=t.clone());if(this.__dirty||n){o.copy(t);var a=e.strokeNoScale?this.getLineScale():1,s=e.lineWidth;if(!this.hasFill()){var l=this.strokeContainThreshold;s=Math.max(s,null==l?4:l)}a>1e-10&&(o.width+=s/a,o.height+=s/a,o.x-=s/a/2,o.y-=s/a/2)}return o}return t},e.prototype.contain=function(t,e){var n=this.transformCoordToLocal(t,e),i=this.getBoundingRect(),r=this.style;if(t=n[0],e=n[1],i.contain(t,e)){var o=this.path;if(this.hasStroke()){var a=r.lineWidth,s=r.strokeNoScale?this.getLineScale():1;if(s>1e-10&&(this.hasFill()||(a=Math.max(a,this.strokeContainThreshold)),function(t,e,n,i){return Sc(t,e,!0,n,i)}(o,a/s,t,e)))return!0}if(this.hasFill())return function(t,e,n){return Sc(t,0,!1,e,n)}(o,t,e)}return!1},e.prototype.dirtyShape=function(){this.__dirty|=4,this._rect&&(this._rect=null),this._decalEl&&this._decalEl.dirtyShape(),this.markRedraw()},e.prototype.dirty=function(){this.dirtyStyle(),this.dirtyShape()},e.prototype.animateShape=function(t){return this.animate("shape",t)},e.prototype.updateDuringAnimation=function(t){"style"===t?this.dirtyStyle():"shape"===t?this.dirtyShape():this.markRedraw()},e.prototype.attrKV=function(e,n){"shape"===e?this.setShape(n):t.prototype.attrKV.call(this,e,n)},e.prototype.setShape=function(t,e){var n=this.shape;return n||(n=this.shape={}),"string"==typeof t?n[t]=e:Ze(n,t),this.dirtyShape(),this},e.prototype.shapeChanged=function(){return!!(4&this.__dirty)},e.prototype.createStyle=function(t){return On(Cc,t)},e.prototype._innerSaveToNormal=function(e){t.prototype._innerSaveToNormal.call(this,e);var n=this._normalState;e.shape&&!n.shape&&(n.shape=Ze({},this.shape))},e.prototype._applyStateObj=function(e,n,i,r,o,a){t.prototype._applyStateObj.call(this,e,n,i,r,o,a);var s,l=!(n&&r);if(n&&n.shape?o?r?s=n.shape:(s=Ze({},i.shape),Ze(s,n.shape)):(s=Ze({},r?this.shape:i.shape),Ze(s,n.shape)):l&&(s=i.shape),s)if(o){this.shape=Ze({},this.shape);for(var c={},u=an(s),d=0;dc&&(n*=c/(a=n+i),i*=c/a),r+o>c&&(r*=c/(a=r+o),o*=c/a),i+r>u&&(i*=u/(a=i+r),r*=u/a),n+o>u&&(n*=u/(a=n+o),o*=u/a),t.moveTo(s+n,l),t.lineTo(s+c-i,l),0!==i&&t.arc(s+c-i,l+i,i,-Math.PI/2,0),t.lineTo(s+c,l+u-r),0!==r&&t.arc(s+c-r,l+u-r,r,0,Math.PI/2),t.lineTo(s+o,l+u),0!==o&&t.arc(s+o,l+u-o,o,Math.PI/2,Math.PI),t.lineTo(s,l+n),0!==n&&t.arc(s+n,l+n,n,Math.PI,1.5*Math.PI)}(t,e):t.rect(n,i,r,o)},e.prototype.isZeroArea=function(){return!this.shape.width||!this.shape.height},e}(Tc);Bc.prototype.type="rect";var Fc={fill:"#000"},$c={},Vc={style:Ke({fill:!0,stroke:!0,fillOpacity:!0,strokeOpacity:!0,lineWidth:!0,fontSize:!0,lineHeight:!0,width:!0,height:!0,textShadowColor:!0,textShadowBlur:!0,textShadowOffsetX:!0,textShadowOffsetY:!0,backgroundColor:!0,padding:!0,borderColor:!0,borderWidth:!0,borderRadius:!0},Sl.style)},Wc=function(t){function e(e){var n=t.call(this)||this;return n.type="text",n._children=[],n._defaultStyle=Fc,n.attr(e),n}return _(e,t),e.prototype.childrenRef=function(){return this._children},e.prototype.update=function(){t.prototype.update.call(this),this.styleChanged()&&this._updateSubTexts();for(var e=0;em&&p){var _=Math.floor(m/h);f=f||v.length>_,y=(v=v.slice(0,_)).length*h}if(r&&u&&null!=g)for(var b=ol(g,c,e.ellipsis,{minChar:e.truncateMinChar,placeholder:e.placeholder}),x={},w=0;w0,M=0;Mg&&dl(o,a.substring(g,v),e,f),dl(o,h[2],e,f,h[1]),g=il.lastIndex}gd){var O=o.lines.length;D>0?(M.tokens=M.tokens.slice(0,D),S(M,T,k),o.lines=o.lines.slice(0,C+1)):o.lines=o.lines.slice(0,C),o.isTruncated=o.isTruncated||o.lines.length=0&&"right"===(T=_[k]).align;)this._placeToken(T,t,x,f,M,"right",v),w-=T.width,M-=T.width,k--;for(C+=(s-(C-p)-(g-M)-w)/2;S<=k;)T=_[S],this._placeToken(T,t,x,f,C+T.width/2,"center",v),C+=T.width,S++;f+=x}},e.prototype._placeToken=function(t,e,n,i,r,o,a){var s=e.rich[t.styleName]||{};s.text=t.text;var l=t.verticalAlign,c=i+n/2;"top"===l?c=i+t.height/2:"bottom"===l&&(c=i+n-t.height/2),!t.isLineHolder&&eu(s)&&this._renderBackground(s,e,"right"===o?r-t.width:"center"===o?r-t.width/2:r,c-t.height/2,t.width,t.height);var u=!!s.backgroundColor,d=t.textPadding;d&&(r=Jc(r,o,d),c-=t.height/2-d[0]-t.innerHeight/2);var h=this._getOrCreateChild(Ac),p=h.createStyle();h.useStyle(p);var f=this._defaultStyle,g=!1,v=0,m=!1,y=Qc("fill"in s?s.fill:"fill"in e?e.fill:(g=!0,f.fill)),_=Kc("stroke"in s?s.stroke:"stroke"in e?e.stroke:u||a||f.autoStroke&&!g?null:(v=2,m=!0,f.stroke)),b=s.textShadowBlur>0||e.textShadowBlur>0;p.text=t.text,p.x=r,p.y=c,b&&(p.shadowBlur=s.textShadowBlur||e.textShadowBlur||0,p.shadowColor=s.textShadowColor||e.textShadowColor||"transparent",p.shadowOffsetX=s.textShadowOffsetX||e.textShadowOffsetX||0,p.shadowOffsetY=s.textShadowOffsetY||e.textShadowOffsetY||0),p.textAlign=o,p.textBaseline="middle",p.font=t.font||Ae,p.opacity=wn(s.opacity,e.opacity,1),Xc(p,s),_&&(p.lineWidth=wn(s.lineWidth,e.lineWidth,v),p.lineDash=xn(s.lineDash,e.lineDash),p.lineDashOffset=e.lineDashOffset||0,p.stroke=_),y&&(p.fill=y),h.setBoundingRect(_l(p,t.contentWidth,t.contentHeight,m?0:null))},e.prototype._renderBackground=function(t,e,n,i,r,o){var a,s,l,c=t.backgroundColor,u=t.borderWidth,d=t.borderColor,h=c&&c.image,p=c&&!h,f=t.borderRadius,g=this;if(p||t.lineHeight||u&&d){(a=this._getOrCreateChild(Bc)).useStyle(a.createStyle()),a.style.fill=null;var v=a.shape;v.x=n,v.y=i,v.width=r,v.height=o,v.r=f,a.dirtyShape()}if(p)(l=a.style).fill=c||null,l.fillOpacity=xn(t.fillOpacity,1);else if(h){(s=this._getOrCreateChild(Lc)).onload=function(){g.dirtyStyle()};var m=s.style;m.image=c.image,m.x=n,m.y=i,m.width=r,m.height=o}u&&d&&((l=a.style).lineWidth=u,l.stroke=d,l.strokeOpacity=xn(t.strokeOpacity,1),l.lineDash=t.borderDash,l.lineDashOffset=t.borderDashOffset||0,a.strokeContainThreshold=0,a.hasFill()&&a.hasStroke()&&(l.strokeFirst=!0,l.lineWidth*=2));var y=(a||s).style;y.shadowBlur=t.shadowBlur||0,y.shadowColor=t.shadowColor||"transparent",y.shadowOffsetX=t.shadowOffsetX||0,y.shadowOffsetY=t.shadowOffsetY||0,y.opacity=wn(t.opacity,e.opacity,1)},e.makeFont=function(t){var e="";return Yc(t)&&(e=[t.fontStyle,t.fontWeight,jc(t.fontSize),t.fontFamily||"sans-serif"].join(" ")),e&&kn(e)||t.textFont||t.font},e}(kl),Uc={left:!0,right:1,center:1},Gc={top:1,bottom:1,middle:1},qc=["fontStyle","fontWeight","fontSize","fontFamily"];function jc(t){return"string"!=typeof t||-1===t.indexOf("px")&&-1===t.indexOf("rem")&&-1===t.indexOf("em")?isNaN(+t)?"12px":t+"px":t}function Xc(t,e){for(var n=0;n=0,o=!1;if(t instanceof Tc){var a=ou(t),s=r&&a.selectFill||a.normalFill,l=r&&a.selectStroke||a.normalStroke;if(gu(s)||gu(l)){var c=(i=i||{}).style||{};"inherit"===c.fill?(o=!0,i=Ze({},i),(c=Ze({},c)).fill=s):!gu(c.fill)&&gu(s)?(o=!0,i=Ze({},i),(c=Ze({},c)).fill=so(s)):!gu(c.stroke)&&gu(l)&&(o||(i=Ze({},i),c=Ze({},c)),c.stroke=so(l)),i.style=c}}if(i&&null==i.z2){o||(i=Ze({},i));var u=t.z2EmphasisLift;i.z2=t.z2+(null!=u?u:10)}return i}(this,0,e,n);if("blur"===t)return function(t,e,n){var i=Qe(t.currentStates,e)>=0,r=t.style.opacity,o=i?null:function(t,e,n,i){for(var r=t.style,o={},a=0;a0){var o={dataIndex:r,seriesIndex:t.seriesIndex};null!=i&&(o.dataType=i),e.push(o)}})}),e}function Vu(t,e,n){ju(t,!0),Cu(t,Tu),function(t,e,n){var i=nu(t);null!=e?(i.focus=e,i.blurScope=n):i.focus&&(i.focus=null)}(t,e,n)}function Wu(t,e,n,i){i?function(t){ju(t,!1)}(t):Vu(t,e,n)}var Uu=["emphasis","blur","select"],Gu={itemStyle:"getItemStyle",lineStyle:"getLineStyle",areaStyle:"getAreaStyle"};function qu(t,e,n,i){n=n||"itemStyle";for(var r=0;r1&&(a*=ed(f),s*=ed(f));var g=(r===o?-1:1)*ed((a*a*(s*s)-a*a*(p*p)-s*s*(h*h))/(a*a*(p*p)+s*s*(h*h)))||0,v=g*a*p/s,m=g*-s*h/a,y=(t+n)/2+id(d)*v-nd(d)*m,_=(e+i)/2+nd(d)*v+id(d)*m,b=sd([1,0],[(h-v)/a,(p-m)/s]),x=[(h-v)/a,(p-m)/s],w=[(-1*h-v)/a,(-1*p-m)/s],S=sd(x,w);if(ad(x,w)<=-1&&(S=rd),ad(x,w)>=1&&(S=0),S<0){var C=Math.round(S/rd*1e6)/1e6;S=2*rd+C%2*rd}u.addData(c,y,_,a,s,b,S,d,o)}var cd=/([mlvhzcqtsa])([^mlvhzcqtsa]*)/gi,ud=/-?([0-9]*\.)?[0-9]+([eE]-?[0-9]+)?/g;var dd=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return _(e,t),e.prototype.applyTransform=function(t){},e}(Tc);function hd(t){return null!=t.setData}function pd(t,e){var n=function(t){var e=new ac;if(!t)return e;var n,i=0,r=0,o=i,a=r,s=ac.CMD,l=t.match(cd);if(!l)return e;for(var c=0;cI*I+P*P&&(C=k,M=T),{cx:C,cy:M,x0:-u,y0:-d,x1:C*(r/x-1),y1:M*(r/x-1)}}function Id(t,e){var n,i=kd(e.r,0),r=kd(e.r0||0,0),o=i>0;if(o||r>0){if(o||(i=r,r=0),r>i){var a=i;i=r,r=a}var s=e.startAngle,l=e.endAngle;if(!isNaN(s)&&!isNaN(l)){var c=e.cx,u=e.cy,d=!!e.clockwise,h=Cd(l-s),p=h>_d&&h%_d;if(p>Dd&&(h=p),i>Dd)if(h>_d-Dd)t.moveTo(c+i*xd(s),u+i*bd(s)),t.arc(c,u,i,s,l,!d),r>Dd&&(t.moveTo(c+r*xd(l),u+r*bd(l)),t.arc(c,u,r,l,s,d));else{var f=void 0,g=void 0,v=void 0,m=void 0,y=void 0,_=void 0,b=void 0,x=void 0,w=void 0,S=void 0,C=void 0,M=void 0,k=void 0,T=void 0,D=void 0,A=void 0,I=i*xd(s),P=i*bd(s),L=r*xd(l),E=r*bd(l),O=h>Dd;if(O){var z=e.cornerRadius;z&&(n=function(t){var e;if(cn(t)){var n=t.length;if(!n)return t;e=1===n?[t[0],t[0],0,0]:2===n?[t[0],t[0],t[1],t[1]]:3===n?t.concat(t[2]):t}else e=[t,t,t,t];return e}(z),f=n[0],g=n[1],v=n[2],m=n[3]);var N=Cd(i-r)/2;if(y=Td(N,v),_=Td(N,m),b=Td(N,f),x=Td(N,g),C=w=kd(y,_),M=S=kd(b,x),(w>Dd||S>Dd)&&(k=i*xd(l),T=i*bd(l),D=r*xd(s),A=r*bd(s),hDd){var U=Td(v,C),G=Td(m,C),q=Ad(D,A,I,P,i,U,d),j=Ad(k,T,L,E,i,G,d);t.moveTo(c+q.cx+q.x0,u+q.cy+q.y0),C0&&t.arc(c+q.cx,u+q.cy,U,Sd(q.y0,q.x0),Sd(q.y1,q.x1),!d),t.arc(c,u,i,Sd(q.cy+q.y1,q.cx+q.x1),Sd(j.cy+j.y1,j.cx+j.x1),!d),G>0&&t.arc(c+j.cx,u+j.cy,G,Sd(j.y1,j.x1),Sd(j.y0,j.x0),!d))}else t.moveTo(c+I,u+P),t.arc(c,u,i,s,l,!d);else t.moveTo(c+I,u+P);if(r>Dd&&O)if(M>Dd){U=Td(f,M),q=Ad(L,E,k,T,r,-(G=Td(g,M)),d),j=Ad(I,P,D,A,r,-U,d);t.lineTo(c+q.cx+q.x0,u+q.cy+q.y0),M0&&t.arc(c+q.cx,u+q.cy,G,Sd(q.y0,q.x0),Sd(q.y1,q.x1),!d),t.arc(c,u,r,Sd(q.cy+q.y1,q.cx+q.x1),Sd(j.cy+j.y1,j.cx+j.x1),d),U>0&&t.arc(c+j.cx,u+j.cy,U,Sd(j.y1,j.x1),Sd(j.y0,j.x0),!d))}else t.lineTo(c+L,u+E),t.arc(c,u,r,l,s,d);else t.lineTo(c+L,u+E)}else t.moveTo(c,u);t.closePath()}}}var Pd=function(){this.cx=0,this.cy=0,this.r0=0,this.r=0,this.startAngle=0,this.endAngle=2*Math.PI,this.clockwise=!0,this.cornerRadius=0},Ld=function(t){function e(e){return t.call(this,e)||this}return _(e,t),e.prototype.getDefaultShape=function(){return new Pd},e.prototype.buildPath=function(t,e){Id(t,e)},e.prototype.isZeroArea=function(){return this.shape.startAngle===this.shape.endAngle||this.shape.r===this.shape.r0},e}(Tc);Ld.prototype.type="sector";var Ed=function(){this.cx=0,this.cy=0,this.r=0,this.r0=0},Od=function(t){function e(e){return t.call(this,e)||this}return _(e,t),e.prototype.getDefaultShape=function(){return new Ed},e.prototype.buildPath=function(t,e){var n=e.cx,i=e.cy,r=2*Math.PI;t.moveTo(n+e.r,i),t.arc(n,i,e.r,0,r,!1),t.moveTo(n+e.r0,i),t.arc(n,i,e.r0,0,r,!0)},e}(Tc);function zd(t,e,n){var i=e.smooth,r=e.points;if(r&&r.length>=2){if(i){var o=function(t,e,n,i){var r,o,a,s,l=[],c=[],u=[],d=[];if(i){a=[1/0,1/0],s=[-1/0,-1/0];for(var h=0,p=t.length;hih[1]){if(r=!1,rh.negativeSize||n)return r;var s=eh(ih[0]-nh[1]),l=eh(nh[0]-ih[1]);Jd(s,l)>ah.len()&&(s=l||!rh.bidirectional)&&(Ti.scale(oh,a,-l*i),rh.useDir&&rh.calcDirMTV()))}}return r},t.prototype._getProjMinMaxOnAxis=function(t,e,n){for(var i=this._axes[t],r=this._origin,o=e[0].dot(i)+r[t],a=o,s=o,l=1;l0){var d={duration:u.duration,delay:u.delay||0,easing:u.easing,done:o,force:!!o||!!a,setToFinal:!c,scope:t,during:a};l?e.animateFrom(n,d):e.animateTo(n,d)}else e.stopAnimation(),!l&&e.attr(n),a&&a(1),o&&o()}function hh(t,e,n,i,r,o){dh("update",t,e,n,i,r,o)}function ph(t,e,n,i,r,o){dh("enter",t,e,n,i,r,o)}function fh(t){if(!t.__zr)return!0;for(var e=0;e=-1e-6)return!1;var f=t-r,g=e-o,v=Oh(f,g,c,u)/p;if(v<0||v>1)return!1;var m=Oh(f,g,d,h)/p;return!(m<0||m>1)}function Oh(t,e,n,i){return t*i-n*e}function zh(t,e,n,i,r){return null==e||(pn(e)?Nh[0]=Nh[1]=Nh[2]=Nh[3]=e:(Nh[0]=e[0],Nh[1]=e[1],Nh[2]=e[2],Nh[3]=e[3]),i&&(Nh[0]=Qa(0,Nh[0]),Nh[1]=Qa(0,Nh[1]),Nh[2]=Qa(0,Nh[2]),Nh[3]=Qa(0,Nh[3])),n&&(Nh[0]=-Nh[0],Nh[1]=-Nh[1],Nh[2]=-Nh[2],Nh[3]=-Nh[3]),Rh(t,Nh,"x","width",3,1,r&&r[0]||0),Rh(t,Nh,"y","height",0,2,r&&r[1]||0)),t}var Nh=[0,0,0,0];function Rh(t,e,n,i,r,o,a){var s=e[o]+e[r],l=t[i];t[i]+=s,a=Qa(0,Ka(a,l)),t[i]=0?-e[r]:e[o]>=0?l+e[o]:Ja(s)>1e-8?(l-a)*e[r]/s:0):t[n]-=e[r]}function Hh(t){var e=t.itemTooltipOption,n=t.componentModel,i=t.itemName,r=dn(e)?{formatter:e}:e,o=n.mainType,a=n.componentIndex,s={componentType:o,name:i,$vars:["name"]};s[o+"Index"]=a;var l=t.formatterParamsExtra;l&&en(an(l),function(t){zn(s,t)||(s[t]=l[t],s.$vars.push(t))});var c=nu(t.el);c.componentMainType=o,c.componentIndex=a,c.tooltipConfig={name:i,option:Ke({content:i,encodeHTMLContent:!0,formatterParams:s},r)}}function Bh(t,e){var n;t.isGroup&&(n=e(t)),n||t.traverse(e)}function Fh(t,e){if(t)if(cn(t))for(var n=0;ne&&(e=i),ie&&(n=e=0),{min:n,max:e}},clipPointsByRect:function(t,e){return nn(t,function(t){var n=t[0];n=Qa(n,e.x),n=Ka(n,e.x+e.width);var i=t[1];return i=Qa(i,e.y),[n,i=Ka(i,e.y+e.height)]})},clipRectByRect:function(t,e){var n=Qa(t.x,e.x),i=Ka(t.x+t.width,e.x+e.width),r=Qa(t.y,e.y),o=Ka(t.y+t.height,e.y+e.height);if(i>=n&&o>=r)return{x:n,y:r,width:i-n,height:o-r}},createIcon:Lh,ensureCopyRect:Wh,ensureCopyTransform:Uh,expandOrShrinkRect:zh,extendPath:function(t,e){return bh(t,e)},extendShape:function(t){return Tc.extend(t)},getShapeClass:function(t){if(mh.hasOwnProperty(t))return mh[t]},getTransform:function(t,e){for(var n=xi([]);t&&t!==e;)Si(n,t.getLocalTransform(),n),t=t.parent;return n},groupTransition:Ph,initProps:ph,isBoundingRectAxisAligned:$h,isElementRemoved:fh,lineLineIntersect:Eh,linePolygonIntersect:function(t,e,n,i,r){for(var o=0,a=r[r.length-1];oJa(o[1])?o[0]>0?"right":"left":o[1]>0?"bottom":"top"},traverseElements:Fh,traverseUpdateZ:qh,updateProps:hh}),Yh={};function Zh(t,e,n){var i,r=t.labelFetcher,o=t.labelDataIndex,a=t.labelDimIndex,s=e.normal;r&&(i=r.getFormattedLabel(o,"normal",null,a,s&&s.get("formatter"),null!=n?{interpolatedValue:n}:null)),null==i&&(i=un(t.defaultText)?t.defaultText(o,t,n):t.defaultText);for(var l={normal:i},c=0;c-1?wp:Cp;function Dp(t,e){t=t.toUpperCase(),kp[t]=new _p(e),Mp[t]=e}Dp(Sp,{time:{month:["January","February","March","April","May","June","July","August","September","October","November","December"],monthAbbr:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],dayOfWeek:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],dayOfWeekAbbr:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"]},legend:{selector:{all:"All",inverse:"Inv"}},toolbox:{brush:{title:{rect:"Box Select",polygon:"Lasso Select",lineX:"Horizontally Select",lineY:"Vertically Select",keep:"Keep Selections",clear:"Clear Selections"}},dataView:{title:"Data View",lang:["Data View","Close","Refresh"]},dataZoom:{title:{zoom:"Zoom",back:"Zoom Reset"}},magicType:{title:{line:"Switch to Line Chart",bar:"Switch to Bar Chart",stack:"Stack",tiled:"Tile"}},restore:{title:"Restore"},saveAsImage:{title:"Save as Image",lang:["Right Click to Save Image"]}},series:{typeNames:{pie:"Pie chart",bar:"Bar chart",line:"Line chart",scatter:"Scatter plot",effectScatter:"Ripple scatter plot",radar:"Radar chart",tree:"Tree",treemap:"Treemap",boxplot:"Boxplot",candlestick:"Candlestick",k:"K line chart",heatmap:"Heat map",map:"Map",parallel:"Parallel coordinate map",lines:"Line graph",graph:"Relationship graph",sankey:"Sankey diagram",funnel:"Funnel chart",gauge:"Gauge",pictorialBar:"Pictorial bar",themeRiver:"Theme River Map",sunburst:"Sunburst",custom:"Custom chart",chart:"Chart"}},aria:{general:{withTitle:'This is a chart about "{title}"',withoutTitle:"This is a chart"},series:{single:{prefix:"",withName:" with type {seriesType} named {seriesName}.",withoutName:" with type {seriesType}."},multiple:{prefix:". It consists of {seriesCount} series count.",withName:" The {seriesId} series is a {seriesType} representing {seriesName}.",withoutName:" The {seriesId} series is a {seriesType}.",separator:{middle:"",end:""}}},data:{allData:"The data is as follows: ",partialData:"The first {displayCnt} items are: ",withName:"the data for {name} is {value}",withoutName:"{value}",separator:{middle:", ",end:". "}}}}),Dp(wp,{time:{month:["一月","二月","三月","四月","五月","六月","七月","八月","九月","十月","十一月","十二月"],monthAbbr:["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"],dayOfWeek:["星期日","星期一","星期二","星期三","星期四","星期五","星期六"],dayOfWeekAbbr:["日","一","二","三","四","五","六"]},legend:{selector:{all:"全选",inverse:"反选"}},toolbox:{brush:{title:{rect:"矩形选择",polygon:"圈选",lineX:"横向选择",lineY:"纵向选择",keep:"保持选择",clear:"清除选择"}},dataView:{title:"数据视图",lang:["数据视图","关闭","刷新"]},dataZoom:{title:{zoom:"区域缩放",back:"区域缩放还原"}},magicType:{title:{line:"切换为折线图",bar:"切换为柱状图",stack:"切换为堆叠",tiled:"切换为平铺"}},restore:{title:"还原"},saveAsImage:{title:"保存为图片",lang:["右键另存为图片"]}},series:{typeNames:{pie:"饼图",bar:"柱状图",line:"折线图",scatter:"散点图",effectScatter:"涟漪散点图",radar:"雷达图",tree:"树图",treemap:"矩形树图",boxplot:"箱型图",candlestick:"K线图",k:"K线图",heatmap:"热力图",map:"地图",parallel:"平行坐标图",lines:"线图",graph:"关系图",sankey:"桑基图",funnel:"漏斗图",gauge:"仪表盘图",pictorialBar:"象形柱图",themeRiver:"主题河流图",sunburst:"旭日图",custom:"自定义图表",chart:"图表"}},aria:{general:{withTitle:"这是一个关于“{title}”的图表。",withoutTitle:"这是一个图表,"},series:{single:{prefix:"",withName:"图表类型是{seriesType},表示{seriesName}。",withoutName:"图表类型是{seriesType}。"},multiple:{prefix:"它由{seriesCount}个图表系列组成。",withName:"第{seriesId}个系列是一个表示{seriesName}的{seriesType},",withoutName:"第{seriesId}个系列是一个{seriesType},",separator:{middle:";",end:"。"}}},data:{allData:"其数据是——",partialData:"其中,前{displayCnt}项是——",withName:"{name}的数据是{value}",withoutName:"{value}",separator:{middle:",",end:""}}}});function Ap(){return null}var Ip=1e3,Pp=6e4,Lp=36e5,Ep=864e5,Op=31536e6,zp={year:/({yyyy}|{yy})/,month:/({MMMM}|{MMM}|{MM}|{M})/,day:/({dd}|{d})/,hour:/({HH}|{H}|{hh}|{h})/,minute:/({mm}|{m})/,second:/({ss}|{s})/,millisecond:/({SSS}|{S})/},Np={year:"{yyyy}",month:"{MMM}",day:"{d}",hour:"{HH}:{mm}",minute:"{HH}:{mm}",second:"{HH}:{mm}:{ss}",millisecond:"{HH}:{mm}:{ss} {SSS}"},Rp="{yyyy}-{MM}-{dd}",Hp={year:"{yyyy}",month:"{yyyy}-{MM}",day:Rp,hour:Rp+" "+Np.hour,minute:Rp+" "+Np.minute,second:Rp+" "+Np.second,millisecond:"{yyyy}-{MM}-{dd} {HH}:{mm}:{ss} {SSS}"},Bp=["year","month","day","hour","minute","second","millisecond"],Fp=["year","half-year","quarter","month","week","half-week","day","half-day","quarter-day","hour","minute","second","millisecond"];function $p(t){return dn(t)||un(t)?t:function(t){t=t||{};var e={},n=!0;return en(Bp,function(e){n&&(n=null==t[e])}),en(Bp,function(i,r){var o=t[i];e[i]={};for(var a=null,s=r;s>=0;s--){var l=Bp[s],c=fn(o)&&!cn(o)?o[l]:o,u=void 0;cn(c)?a=(u=c.slice())[0]||"":dn(c)?u=[a=c]:(null==a?a=Np[i]:zp[l].test(a)||(a=e[l][l][0]+" "+a),u=[a],n&&(u[1]="{primary|"+a+"}")),e[i][l]=u}}),e}(t)}function Vp(t,e){return"0000".substr(0,e-(t+="").length)+t}function Wp(t){switch(t){case"half-year":case"quarter":return"month";case"week":case"half-week":return"day";case"half-day":case"quarter-day":return"hour";default:return t}}function Up(t){return t===Wp(t)}function Gp(t,e,n,i){var r=cs(t),o=r[Xp(n)](),a=r[Yp(n)]()+1,s=Math.floor((a-1)/3)+1,l=r[Zp(n)](),c=r["get"+(n?"UTC":"")+"Day"](),u=r[Kp(n)](),d=(u-1)%12+1,h=r[Qp(n)](),p=r[Jp(n)](),f=r[tf(n)](),g=u>=12?"pm":"am",v=g.toUpperCase(),m=i instanceof _p?i:function(t){return kp[t]}(i||Tp)||kp[Cp],y=m.getModel("time"),_=y.get("month"),b=y.get("monthAbbr"),x=y.get("dayOfWeek"),w=y.get("dayOfWeekAbbr");return(e||"").replace(/{a}/g,g+"").replace(/{A}/g,v+"").replace(/{yyyy}/g,o+"").replace(/{yy}/g,Vp(o%100+"",2)).replace(/{Q}/g,s+"").replace(/{MMMM}/g,_[a-1]).replace(/{MMM}/g,b[a-1]).replace(/{MM}/g,Vp(a,2)).replace(/{M}/g,a+"").replace(/{dd}/g,Vp(l,2)).replace(/{d}/g,l+"").replace(/{eeee}/g,x[c]).replace(/{ee}/g,w[c]).replace(/{e}/g,c+"").replace(/{HH}/g,Vp(u,2)).replace(/{H}/g,u+"").replace(/{hh}/g,Vp(d+"",2)).replace(/{h}/g,d+"").replace(/{mm}/g,Vp(h,2)).replace(/{m}/g,h+"").replace(/{ss}/g,Vp(p,2)).replace(/{s}/g,p+"").replace(/{SSS}/g,Vp(f,3)).replace(/{S}/g,f+"")}function qp(t,e){var n=cs(t),i=n[Yp(e)]()+1,r=n[Zp(e)](),o=n[Kp(e)](),a=n[Qp(e)](),s=n[Jp(e)](),l=0===n[tf(e)](),c=l&&0===s,u=c&&0===a,d=u&&0===o,h=d&&1===r;return h&&1===i?"year":h?"month":d?"day":u?"hour":c?"minute":l?"second":"millisecond"}function jp(t,e,n){switch(e){case"year":t[nf(n)](0);case"month":t[rf(n)](1);case"day":t[of(n)](0);case"hour":t[af(n)](0);case"minute":t[sf(n)](0);case"second":t[lf(n)](0)}return t}function Xp(t){return t?"getUTCFullYear":"getFullYear"}function Yp(t){return t?"getUTCMonth":"getMonth"}function Zp(t){return t?"getUTCDate":"getDate"}function Kp(t){return t?"getUTCHours":"getHours"}function Qp(t){return t?"getUTCMinutes":"getMinutes"}function Jp(t){return t?"getUTCSeconds":"getSeconds"}function tf(t){return t?"getUTCMilliseconds":"getMilliseconds"}function ef(t){return t?"setUTCFullYear":"setFullYear"}function nf(t){return t?"setUTCMonth":"setMonth"}function rf(t){return t?"setUTCDate":"setDate"}function of(t){return t?"setUTCHours":"setHours"}function af(t){return t?"setUTCMinutes":"setMinutes"}function sf(t){return t?"setUTCSeconds":"setSeconds"}function lf(t){return t?"setUTCMilliseconds":"setMilliseconds"}function cf(t){if(isNaN(hs(t)))return dn(t)?t:"-";var e=(t+"").split(".");return e[0].replace(/(\d{1,3})(?=(?:\d{3})+(?!\d))/g,"$1,")+(e.length>1?"."+e[1]:"")}function uf(t,e){return t=(t||"").toLowerCase().replace(/-(.)/g,function(t,e){return e.toUpperCase()}),e&&t&&(t=t.charAt(0).toUpperCase()+t.slice(1)),t}var df=Cn;function hf(t,e,n){function i(t){return t&&kn(t)?t:"-"}function r(t){return!(null==t||isNaN(t)||!isFinite(t))}var o="time"===e,a=t instanceof Date;if(o||a){var s=o?cs(t):t;if(!isNaN(+s))return Gp(s,"{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}",n);if(a)return"-"}if("ordinal"===e)return hn(t)?i(t):pn(t)&&r(t)?t+"":"-";var l=hs(t);return r(l)?cf(l):hn(t)?i(t):"boolean"==typeof t?t+"":"-"}var pf=["a","b","c","d","e","f","g"],ff=function(t,e){return"{"+t+(null==e?"":e)+"}"};function gf(t,e,n){cn(e)||(e=[e]);var i=e.length;if(!i)return"";for(var r=e[0].$vars||[],o=0;oi||l.newline?(o=0,u=g,a+=s+n,s=h.height):s=Math.max(s,h.height)}else{var v=h.height+(f?-f.y+h.y:0);(d=a+v)>r||l.newline?(o+=s+n,a=0,d=v,s=h.width):s=Math.max(s,h.width)}l.newline||(l.x=o,l.y=a,l.markRedraw(),"horizontal"===t?o=u+n:a=d+n)})}function Pf(t,e,n){n=df(n||0);var i=e.width,r=e.height,o=es(t.left,i),a=es(t.top,r),s=es(t.right,i),l=es(t.bottom,r),c=es(t.width,i),u=es(t.height,r),d=n[2]+n[0],h=n[1]+n[3],p=t.aspect;switch(isNaN(c)&&(c=i-s-h-o),isNaN(u)&&(u=r-l-d-a),null!=p&&(isNaN(c)&&isNaN(u)&&(p>i/r?c=.8*i:u=.8*r),isNaN(c)&&(c=p*u),isNaN(u)&&(u=c/p)),isNaN(o)&&(o=i-s-c-h),isNaN(a)&&(a=r-l-u-d),t.left||t.right){case"center":o=i/2-c/2-n[3];break;case"right":o=i-c-h}switch(t.top||t.bottom){case"middle":case"center":a=r/2-u/2-n[0];break;case"bottom":a=r-u-d}o=o||0,a=a||0,isNaN(c)&&(c=i-h-o-(s||0)),isNaN(u)&&(u=r-d-a-(l||0));var f=new $i((e.x||0)+o+n[3],(e.y||0)+a+n[0],c,u);return f.margin=n,f}ln(If,"vertical"),ln(If,"horizontal");var Lf=1;function Ef(t,e,n){var i,r,o,a,s=t.boxCoordinateSystem;if(s){var l=function(t){var e=t.getShallow("coord",!0),n=bf;if(null==e){var i=wf.get(t.type);i&&i.getCoord2&&(n=xf,e=i.getCoord2(t))}return{coord:e,from:n}}(t),c=l.coord,u=l.from;if(s.dataToLayout){o=Lf,a=u;var d=s.dataToLayout(c);i=d.contentRect||d.rect}}return null==o&&(o=Lf),o===Lf&&(i||(i={x:0,y:0,width:e.getWidth(),height:e.getHeight()}),r=[i.x+i.width/2,i.y+i.height/2]),{type:o,refContainer:i,refPoint:r,boxCoordFrom:a}}function Of(t){var e=t.layoutMode||t.constructor.layoutMode;return fn(e)?e:e?{type:e}:null}function zf(t,e,n){var i=n&&n.ignoreSize;!cn(i)&&(i=[i,i]);var r=a(Af[0],0),o=a(Af[1],1);function a(n,r){var o={},a=0,l={},c=0;if(Tf(n,function(e){l[e]=t[e]}),Tf(n,function(t){zn(e,t)&&(o[t]=l[t]=e[t]),s(o,t)&&a++,s(l,t)&&c++}),i[r])return s(e,n[1])?l[n[2]]=null:s(e,n[2])&&(l[n[1]]=null),l;if(2!==c&&a){if(a>=2)return o;for(var u=0;u=0;a--)o=Ye(o,n[a],!0);e.defaultOption=o}return e.defaultOption},e.prototype.getReferringComponents=function(t,e){var n=t+"Index",i=t+"Id";return Hs(this.ecModel,t,{index:this.get(n,!0),id:this.get(i,!0)},e)},e.prototype.getBoxLayoutParams=function(){return e=!1,{left:(t=this).getShallow("left",e),top:t.getShallow("top",e),right:t.getShallow("right",e),bottom:t.getShallow("bottom",e),width:t.getShallow("width",e),height:t.getShallow("height",e)};var t,e},e.prototype.getZLevelKey=function(){return""},e.prototype.setZLevel=function(t){this.option.zlevel=t},e.protoInitialize=((n=e.prototype).type="component",n.id="",n.name="",n.mainType="",n.subType="",void(n.componentIndex=0)),e}(_p);Us(Hf,_p),Xs(Hf),function(t){var e={};t.registerSubTypeDefaulter=function(t,n){var i=Vs(t);e[i.main]=n},t.determineSubType=function(n,i){var r=i.type;if(!r){var o=Vs(n).main;t.hasSubTypes(n)&&e[o]&&(r=e[o](i))}return r}}(Hf),function(t,e){function n(t,e){return t[e]||(t[e]={predecessor:[],successor:[]}),t[e]}t.topologicalTravel=function(t,i,r,o){if(t.length){var a=function(t){var i={},r=[];return en(t,function(o){var a=n(i,o),s=function(t,e){var n=[];return en(t,function(t){Qe(e,t)>=0&&n.push(t)}),n}(a.originalDeps=e(o),t);a.entryCount=s.length,0===a.entryCount&&r.push(o),en(s,function(t){Qe(a.predecessor,t)<0&&a.predecessor.push(t);var e=n(i,t);Qe(e.successor,t)<0&&e.successor.push(o)})}),{graph:i,noEntryList:r}}(i),s=a.graph,l=a.noEntryList,c={};for(en(t,function(t){c[t]=!0});l.length;){var u=l.pop(),d=s[u],h=!!c[u];h&&(r.call(o,u,d.originalDeps.slice()),delete c[u]),en(d.successor,h?f:p)}en(c,function(){throw new Error("")})}function p(t){s[t].entryCount--,0===s[t].entryCount&&l.push(t)}function f(t){c[t]=!0,p(t)}}}(Hf,function(t){var e=[];en(Hf.getClassesByMainType(t),function(t){e=e.concat(t.dependencies||t.prototype.dependencies||[])}),e=nn(e,function(t){return Vs(t).main}),"dataset"!==t&&Qe(e,"dataset")<=0&&e.unshift("dataset");return e});var Bf={color:{},darkColor:{},size:{}},Ff=Bf.color={theme:["#5070dd","#b6d634","#505372","#ff994d","#0ca8df","#ffd10a","#fb628b","#785db0","#3fbe95"],neutral00:"#fff",neutral05:"#f4f7fd",neutral10:"#e8ebf0",neutral15:"#dbdee4",neutral20:"#cfd2d7",neutral25:"#c3c5cb",neutral30:"#b7b9be",neutral35:"#aaacb2",neutral40:"#9ea0a5",neutral45:"#929399",neutral50:"#86878c",neutral55:"#797b7f",neutral60:"#6d6e73",neutral65:"#616266",neutral70:"#54555a",neutral75:"#48494d",neutral80:"#3c3c41",neutral85:"#303034",neutral90:"#232328",neutral95:"#17171b",neutral99:"#000",accent05:"#eff1f9",accent10:"#e0e4f2",accent15:"#d0d6ec",accent20:"#c0c9e6",accent25:"#b1bbdf",accent30:"#a1aed9",accent35:"#91a0d3",accent40:"#8292cc",accent45:"#7285c6",accent50:"#6578ba",accent55:"#5c6da9",accent60:"#536298",accent65:"#4a5787",accent70:"#404c76",accent75:"#374165",accent80:"#2e3654",accent85:"#252b43",accent90:"#1b2032",accent95:"#121521",transparent:"rgba(0,0,0,0)",highlight:"rgba(255,231,130,0.8)"};for(var $f in Ze(Ff,{primary:Ff.neutral80,secondary:Ff.neutral70,tertiary:Ff.neutral60,quaternary:Ff.neutral50,disabled:Ff.neutral20,border:Ff.neutral30,borderTint:Ff.neutral20,borderShade:Ff.neutral40,background:Ff.neutral05,backgroundTint:"rgba(234,237,245,0.5)",backgroundTransparent:"rgba(255,255,255,0)",backgroundShade:Ff.neutral10,shadow:"rgba(0,0,0,0.2)",shadowTint:"rgba(129,130,136,0.2)",axisLine:Ff.neutral70,axisLineTint:Ff.neutral40,axisTick:Ff.neutral70,axisTickMinor:Ff.neutral60,axisLabel:Ff.neutral70,axisSplitLine:Ff.neutral15,axisMinorSplitLine:Ff.neutral05}),Ff)if(Ff.hasOwnProperty($f)){var Vf=Ff[$f];"theme"===$f?Bf.darkColor.theme=Ff.theme.slice():"highlight"===$f?Bf.darkColor.highlight="rgba(255,231,130,0.4)":0===$f.indexOf("accent")?Bf.darkColor[$f]=io(Vf,0,function(t){return.5*t},function(t){return Math.min(1,1.3-t)}):Bf.darkColor[$f]=io(Vf,0,function(t){return.9*t},function(t){return 1-Math.pow(t,1.5)})}Bf.size={xxs:2,xs:5,s:10,m:15,l:20,xl:30,xxl:40,xxxl:50};var Wf="";"undefined"!=typeof navigator&&(Wf=navigator.platform||"");var Uf="rgba(0, 0, 0, 0.2)",Gf=Bf.color.theme[0],qf=io(Gf,0,null,.9),jf={darkMode:"auto",colorBy:"series",color:Bf.color.theme,gradientColor:[qf,Gf],aria:{decal:{decals:[{color:Uf,dashArrayX:[1,0],dashArrayY:[2,5],symbolSize:1,rotation:Math.PI/6},{color:Uf,symbol:"circle",dashArrayX:[[8,8],[0,8,8,0]],dashArrayY:[6,0],symbolSize:.8},{color:Uf,dashArrayX:[1,0],dashArrayY:[4,3],rotation:-Math.PI/4},{color:Uf,dashArrayX:[[6,6],[0,6,6,0]],dashArrayY:[6,0]},{color:Uf,dashArrayX:[[1,0],[1,6]],dashArrayY:[1,0,6,0],rotation:Math.PI/4},{color:Uf,symbol:"triangle",dashArrayX:[[9,9],[0,9,9,0]],dashArrayY:[7,2],symbolSize:.75}]}},textStyle:{fontFamily:Wf.match(/^Win/)?"Microsoft YaHei":"sans-serif",fontSize:12,fontStyle:"normal",fontWeight:"normal"},blendMode:null,stateAnimation:{duration:300,easing:"cubicOut"},animation:"auto",animationDuration:1e3,animationDurationUpdate:500,animationEasing:"cubicInOut",animationEasingUpdate:"cubicInOut",animationThreshold:2e3,progressiveThreshold:3e3,progressive:400,hoverLayerThreshold:3e3,useUTC:!1},Xf=En(["tooltip","label","itemName","itemId","itemGroupId","itemChildGroupId","seriesName"]),Yf="original",Zf="arrayRows",Kf="objectRows",Qf="keyedColumns",Jf="typedArray",tg="unknown",eg="column",ng="row",ig=1,rg=2,og=3,ag=Es();function sg(t,e,n){var i={},r=lg(e);if(!r||!t)return i;var o,a,s=[],l=[],c=e.ecModel,u=ag(c).datasetMap,d=r.uid+"_"+n.seriesLayoutBy;en(t=t.slice(),function(e,n){var r=fn(e)?e:t[n]={name:e};"ordinal"===r.type&&null==o&&(o=n,a=f(r)),i[r.name]=[]});var h=u.get(d)||u.set(d,{categoryWayDim:a,valueWayDim:0});function p(t,e,n){for(var i=0;ie)return t[i];return t[n-1]}(i,a):n;if(u=u||n,!u||!u.length)return;var d=u[l];r&&(c[r]=d);return s.paletteIdx=(l+1)%u.length,d}(this,dg,i,r,t,e,n)},t.prototype.clearColorPalette=function(){!function(t,e){e(t).paletteIdx=0,e(t).paletteNameMap={}}(this,dg)},t}();var vg="\0_ec_inner",mg=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return _(e,t),e.prototype.init=function(t,e,n,i,r,o){i=i||{},this.option=null,this._theme=new _p(i),this._locale=new _p(r),this._optionManager=o},e.prototype.setOption=function(t,e,n){var i=bg(e);this._optionManager.setOption(t,n,i),this._resetOption(null,i)},e.prototype.resetOption=function(t,e){return this._resetOption(t,bg(e))},e.prototype._resetOption=function(t,e){var n=!1,i=this._optionManager;if(!t||"recreate"===t){var r=i.mountOption("recreate"===t);this.option&&"recreate"!==t?(this.restoreData(),this._mergeOption(r,e)):fg(this,r),n=!0}if("timeline"!==t&&"media"!==t||this.restoreData(),!t||"recreate"===t||"timeline"===t){var o=i.getTimelineOption(this);o&&(n=!0,this._mergeOption(o,e))}if(!t||"recreate"===t||"media"===t){var a=i.getMediaOption(this);a.length&&en(a,function(t){n=!0,this._mergeOption(t,e)},this)}return n},e.prototype.mergeOption=function(t){this._mergeOption(t,null)},e.prototype._mergeOption=function(t,e){var n=this.option,i=this._componentsMap,r=this._componentsCount,o=[],a=En(),s=e&&e.replaceMergeMainTypeMap;ag(this).datasetMap=En(),en(t,function(t,e){null!=t&&(Hf.hasClass(e)?e&&(o.push(e),a.set(e,!0)):n[e]=null==n[e]?Xe(t):Ye(n[e],t,!0))}),s&&s.each(function(t,e){Hf.hasClass(e)&&!a.get(e)&&(o.push(e),a.set(e,!0))}),Hf.topologicalTravel(o,Hf.getAllClassMainTypes(),function(e){var o=function(t,e,n){var i=ug.get(e);if(!i)return n;var r=i(t);return r?n.concat(r):n}(this,e,xs(t[e])),a=i.get(e),l=a?s&&s.get(e)?"replaceMerge":"normalMerge":"replaceAll",c=ks(a,o,l);(function(t,e,n){en(t,function(t){var i=t.newOption;fn(i)&&(t.keyInfo.mainType=e,t.keyInfo.subType=function(t,e,n,i){return e.type?e.type:n?n.subType:i.determineSubType(t,e)}(e,i,t.existing,n))})})(c,e,Hf),n[e]=null,i.set(e,null),r.set(e,0);var u,d=[],h=[],p=0;en(c,function(t,n){var i=t.existing,r=t.newOption;if(r){var o="series"===e,a=Hf.getClass(e,t.keyInfo.subType,!o);if(!a)return;if("tooltip"===e){if(u)return;u=!0}if(i&&i.constructor===a)i.name=t.keyInfo.name,i.mergeOption(r,this),i.optionUpdated(r,!1);else{var s=Ze({componentIndex:n},t.keyInfo);Ze(i=new a(r,this,this,s),s),t.brandNew&&(i.__requireNewView=!0),i.init(r,this,this),i.optionUpdated(null,!0)}}else i&&(i.mergeOption({},this),i.optionUpdated({},!1));i?(d.push(i.option),h.push(i),p++):(d.push(void 0),h.push(void 0))},this),n[e]=d,i.set(e,h),r.set(e,p),"series"===e&&hg(this)},this),this._seriesIndices||hg(this)},e.prototype.getOption=function(){var t=Xe(this.option);return en(t,function(e,n){if(Hf.hasClass(n)){for(var i=xs(e),r=i.length,o=!1,a=r-1;a>=0;a--)i[a]&&!Ps(i[a])?o=!0:(i[a]=null,!o&&r--);i.length=r,t[n]=i}}),delete t[vg],t},e.prototype.setTheme=function(t){this._theme=new _p(t),this._resetOption("recreate",null)},e.prototype.getTheme=function(){return this._theme},e.prototype.getLocaleModel=function(){return this._locale},e.prototype.setUpdatePayload=function(t){this._payload=t},e.prototype.getUpdatePayload=function(){return this._payload},e.prototype.getComponent=function(t,e){var n=this._componentsMap.get(t);if(n){var i=n[e||0];if(i)return i;if(null==e)for(var r=0;r=e:"max"===n?t<=e:t===e})(i[a],t,o)||(r=!1)}}),r}var kg=en,Tg=fn,Dg=["areaStyle","lineStyle","nodeStyle","linkStyle","chordStyle","label","labelLine"];function Ag(t){var e=t&&t.itemStyle;if(e)for(var n=0,i=Dg.length;nc&&(c=p)}s[0]=l,s[1]=c}},i=function(){return this._data?this._data.length/this._dimSize:0};function r(t){for(var e=0;e=0&&(s=o.interpolatedValue[l])}return null!=s?s+"":""}):void 0},t.prototype.getRawValue=function(t,e){return yv(this.getData(e),t)},t.prototype.formatTooltip=function(t,e,n){},t}();function xv(t){var e,n;return fn(t)?t.type&&(n=t):e=t,{text:e,frag:n}}function wv(t){return new Sv(t)}var Sv=function(){function t(t){t=t||{},this._reset=t.reset,this._plan=t.plan,this._count=t.count,this._onDirty=t.onDirty,this._dirty=!0}return t.prototype.perform=function(t){var e,n=this._upstream,i=t&&t.skip;if(this._dirty&&n){var r=this.context;r.data=r.outputData=n.context.outputData}this.__pipeline&&(this.__pipeline.currentTask=this),this._plan&&!i&&(e=this._plan(this.context));var o,a=u(this._modBy),s=this._modDataCount||0,l=u(t&&t.modBy),c=t&&t.modDataCount||0;function u(t){return!(t>=1)&&(t=1),t}a===l&&s===c||(e="reset"),(this._dirty||"reset"===e)&&(this._dirty=!1,o=this._doReset(i)),this._modBy=l,this._modDataCount=c;var d=t&&t.step;if(this._dueEnd=n?n._outputDueEnd:this._count?this._count(this.context):1/0,this._progress){var h=this._dueIndex,p=Math.min(null!=d?this._dueIndex+d:1/0,this._dueEnd);if(!i&&(o||h1&&i>0?s:a}};return o;function a(){return e=t?null:oi?-this._resultLT:0},t}(),Tv=function(){function t(){}return t.prototype.getRawData=function(){throw new Error("not supported")},t.prototype.getRawDataItem=function(t){throw new Error("not supported")},t.prototype.cloneRawData=function(){},t.prototype.getDimensionInfo=function(t){},t.prototype.cloneAllDimensionInfo=function(){},t.prototype.count=function(){},t.prototype.retrieveValue=function(t,e){},t.prototype.retrieveValueFromItem=function(t,e){},t.prototype.convertValue=function(t,e){return Mv(t,e)},t}();function Dv(t){if(!Ov(t.sourceFormat)){ys("")}return t.data}function Av(t){var e=t.sourceFormat,n=t.data;if(!Ov(e)){ys("")}if(e===Zf){for(var i=[],r=0,o=n.length;r65535?Rv:Hv}function Wv(){return[1/0,-1/0]}function Uv(t){var e=t.constructor;return e===Array?t.slice():new e(t)}function Gv(t,e,n,i,r){var o=$v[n||"float"];if(r){var a=t[e],s=a&&a.length;if(s!==i){for(var l=new o(i),c=0;cg[1]&&(g[1]=f)}return this._rawCount=this._count=s,{start:a,end:s}},t.prototype._initDataFromProvider=function(t,e,n){for(var i=this._provider,r=this._chunks,o=this._dimensions,a=o.length,s=this._rawExtent,l=nn(o,function(t){return t.property}),c=0;cv[1]&&(v[1]=g)}}!i.persistent&&i.clean&&i.clean(),this._rawCount=this._count=e,this._extent=[]},t.prototype.count=function(){return this._count},t.prototype.get=function(t,e){if(!(e>=0&&e=0&&e=this._rawCount||t<0)return-1;if(!this._indices)return t;var e=this._indices,n=e[t];if(null!=n&&nt))return o;r=o-1}}return-1},t.prototype.getIndices=function(){var t,e=this._indices;if(e){var n=e.constructor,i=this._count;if(n===Array){t=new n(i);for(var r=0;r=c&&_<=u||isNaN(_))&&(a[s++]=p),p++}h=!0}else if(2===r){f=d[i[0]];var v=d[i[1]],m=t[i[1]][0],y=t[i[1]][1];for(g=0;g=c&&_<=u||isNaN(_))&&(b>=m&&b<=y||isNaN(b))&&(a[s++]=p),p++}h=!0}}if(!h)if(1===r)for(g=0;g=c&&_<=u||isNaN(_))&&(a[s++]=x)}else for(g=0;gt[C][1])&&(w=!1)}w&&(a[s++]=e.getRawIndex(g))}return sv[1]&&(v[1]=g)}}}},t.prototype.lttbDownSample=function(t,e){var n,i,r,o=this.clone([t],!0),a=o._chunks[t],s=this.count(),l=0,c=Math.floor(1/e),u=this.getRawIndex(0),d=new(Vv(this._rawCount))(Math.min(2*(Math.ceil(s/c)+2),s));d[l++]=u;for(var h=1;hn&&(n=i,r=M)}C>0&&Ca&&(f=a-c);for(var g=0;gp&&(p=v,h=c+g)}var m=this.getRawIndex(u),y=this.getRawIndex(h);uc-p&&(s=c-p,a.length=s);for(var f=0;fu[1]&&(u[1]=v),d[h++]=m}return r._count=h,r._indices=d,r._updateGetRawIdx(),r},t.prototype.each=function(t,e){if(this._count)for(var n=t.length,i=this._chunks,r=0,o=this.count();ra&&(a=l)}return i=[o,a],this._extent[t]=i,i},t.prototype.getRawDataItem=function(t){var e=this.getRawIndex(t);if(this._provider.persistent)return this._provider.getItem(e);for(var n=[],i=this._chunks,r=0;r=0?this._indices[t]:-1},t.prototype._updateGetRawIdx=function(){this.getRawIndex=this._indices?this._getRawIdx:this._getRawIdxIdentity},t.internalField=function(){function t(t,e,n,i){return Mv(t[i],this._dimensions[i])}zv={arrayRows:t,objectRows:function(t,e,n,i){return Mv(t[e],this._dimensions[i])},keyedColumns:t,original:function(t,e,n,i){var r=t&&(null==t.value?t:t.value);return Mv(r instanceof Array?r[i]:r,this._dimensions[i])},typedArray:function(t,e,n,i){return t[i]}}}(),t}(),jv=function(){function t(t){this._sourceList=[],this._storeList=[],this._upstreamSignList=[],this._versionSignBase=0,this._dirty=!0,this._sourceHost=t}return t.prototype.dirty=function(){this._setLocalSource([],[]),this._storeList=[],this._dirty=!0},t.prototype._setLocalSource=function(t,e){this._sourceList=t,this._upstreamSignList=e,this._versionSignBase++,this._versionSignBase>9e10&&(this._versionSignBase=0)},t.prototype._getVersionSign=function(){return this._sourceHost.uid+"_"+this._versionSignBase},t.prototype.prepareSource=function(){this._isDirty()&&(this._createSource(),this._dirty=!1)},t.prototype._createSource=function(){this._setLocalSource([],[]);var t,e,n=this._sourceHost,i=this._getUpstreamSourceManagers(),r=!!i.length;if(Xv(n)){var o=n,a=void 0,s=void 0,l=void 0;if(r){var c=i[0];c.prepareSource(),a=(l=c.getSource()).data,s=l.sourceFormat,e=[c._getVersionSign()]}else s=vn(a=o.get("data",!0))?Jf:Yf,e=[];var u=this._getSourceMetaRawOption()||{},d=l&&l.metaRawOption||{},h=xn(u.seriesLayoutBy,d.seriesLayoutBy)||null,p=xn(u.sourceHeader,d.sourceHeader),f=xn(u.dimensions,d.dimensions);t=h!==d.seriesLayoutBy||!!p!=!!d.sourceHeader||f?[tv(a,{seriesLayoutBy:h,sourceHeader:p,dimensions:f},s)]:[]}else{var g=n;if(r){var v=this._applyTransform(i);t=v.sourceList,e=v.upstreamSignList}else{t=[tv(g.get("source",!0),this._getSourceMetaRawOption(),null)],e=[]}}this._setLocalSource(t,e)},t.prototype._applyTransform=function(t){var e,n=this._sourceHost,i=n.get("transform",!0),r=n.get("fromTransformResult",!0);if(null!=r){1!==t.length&&Yv("")}var o,a=[],s=[];return en(t,function(t){t.prepareSource();var e=t.getSource(r||0);null==r||e||Yv(""),a.push(e),s.push(t._getVersionSign())}),i?e=function(t,e){var n=xs(t),i=n.length;i||ys("");for(var r=0,o=i;r1||n>0&&!t.noHeader;return en(t.blocks,function(t){var n=im(t);n>=e&&(e=n+ +(i&&(!n||em(t)&&!t.noHeader)))}),e}return 0}function rm(t,e,n,i){var r,o=e.noHeader,a=(r=im(e),{html:Qv[r],richText:Jv[r]}),s=[],l=e.blocks||[];Mn(!l||cn(l)),l=l||[];var c=t.orderMode;if(e.sortBlocks&&c){l=l.slice();var u={valueAsc:"asc",valueDesc:"desc"};if(zn(u,c)){var d=new kv(u[c],null);l.sort(function(t,e){return d.evaluate(t.sortParam,e.sortParam)})}else"seriesDesc"===c&&l.reverse()}en(l,function(n,r){var o=e.valueFormatter,l=nm(n)(o?Ze(Ze({},t),{valueFormatter:o}):t,n,r>0?a.html:0,i);null!=l&&s.push(l)});var h="richText"===t.renderMode?s.join(a.richText):sm(i,s.join(""),o?n:a.html);if(o)return h;var p=hf(e.header,"ordinal",t.useUTC),f=Kv(i,t.renderMode).nameStyle,g=Zv(i);return"richText"===t.renderMode?lm(t,p,f)+a.richText+h:sm(i,'
'+li(p)+"
"+h,n)}function om(t,e,n,i){var r=t.renderMode,o=e.noName,a=e.noValue,s=!e.markerType,l=e.name,c=t.useUTC,u=e.valueFormatter||t.valueFormatter||function(t){return nn(t=cn(t)?t:[t],function(t,e){return hf(t,cn(p)?p[e]:p,c)})};if(!o||!a){var d=s?"":t.markupStyleCreator.makeTooltipMarker(e.markerType,e.markerColor||Bf.color.secondary,r),h=o?"":hf(l,"ordinal",c),p=e.valueType,f=a?[]:u(e.value,e.dataIndex),g=!s||!o,v=!s&&o,m=Kv(i,r),y=m.nameStyle,_=m.valueStyle;return"richText"===r?(s?"":d)+(o?"":lm(t,h,y))+(a?"":function(t,e,n,i,r){var o=[r],a=i?10:20;return n&&o.push({padding:[0,0,0,a],align:"right"}),t.markupStyleCreator.wrapRichTextStyle(cn(e)?e.join(" "):e,o)}(t,f,g,v,_)):sm(i,(s?"":d)+(o?"":function(t,e,n){return''+li(t)+""}(h,!s,y))+(a?"":function(t,e,n,i){var r=n?"10px":"20px",o=e?"float:right;margin-left:"+r:"";return t=cn(t)?t:[t],''+nn(t,function(t){return li(t)}).join("  ")+""}(f,g,v,_)),n)}}function am(t,e,n,i,r,o){if(t)return nm(t)({useUTC:r,renderMode:n,orderMode:i,markupStyleCreator:e,valueFormatter:t.valueFormatter},t,0,o)}function sm(t,e,n){return'
'+e+'
'}function lm(t,e,n){return t.markupStyleCreator.wrapRichTextStyle(e,n)}function cm(t,e){var n=t.get("padding");return null!=n?n:"richText"===e?[8,10]:10}var um=function(){function t(){this.richTextStyles={},this._nextStyleNameId=ps()}return t.prototype._generateStyleName=function(){return"__EC_aUTo_"+this._nextStyleNameId++},t.prototype.makeTooltipMarker=function(t,e,n){var i="richText"===n?this._generateStyleName():null,r=function(t,e){var n=dn(t)?{color:t,extraCssText:e}:t||{},i=n.color,r=n.type;e=n.extraCssText;var o=n.renderMode||"html";return i?"html"===o?"subItem"===r?'':'':{renderMode:o,content:"{"+(n.markerId||"markerX")+"|} ",style:"subItem"===r?{width:4,height:4,borderRadius:2,backgroundColor:i}:{width:10,height:10,borderRadius:5,backgroundColor:i}}:""}({color:e,type:t,renderMode:n,markerId:i});return dn(r)?r:(this.richTextStyles[i]=r.style,r.content)},t.prototype.wrapRichTextStyle=function(t,e){var n={};cn(e)?en(e,function(t){return Ze(n,t)}):Ze(n,e);var i=this._generateStyleName();return this.richTextStyles[i]=n,"{"+i+"|"+t+"}"},t}();function dm(t){var e,n,i,r,o=t.series,a=t.dataIndex,s=t.multipleSeries,l=o.getData(),c=l.mapDimensionsAll("defaultedTooltip"),u=c.length,d=o.getRawValue(a),h=cn(d),p=function(t,e){return vf(t.getData().getItemVisual(e,"style")[t.visualDrawType])}(o,a);if(u>1||h&&!u){var f=function(t,e,n,i,r){var o=e.getData(),a=rn(t,function(t,e,n){var i=o.getDimensionInfo(n);return t||i&&!1!==i.tooltip&&null!=i.displayName},!1),s=[],l=[],c=[];function u(t,e){var n=o.getDimensionInfo(e);n&&!1!==n.otherDims.tooltip&&(a?c.push(tm("nameValue",{markerType:"subItem",markerColor:r,name:n.displayName,value:t,valueType:n.type})):(s.push(t),l.push(n.type)))}return i.length?en(i,function(t){u(yv(o,n,t),t)}):en(t,u),{inlineValues:s,inlineValueTypes:l,blocks:c}}(d,o,a,c,p);e=f.inlineValues,n=f.inlineValueTypes,i=f.blocks,r=f.inlineValues[0]}else if(u){var g=l.getDimensionInfo(c[0]);r=e=yv(l,a,c[0]),n=g.type}else r=e=h?d[0]:d;var v=Is(o),m=v&&o.name||"",y=l.getName(a),_=s?m:y;return tm("section",{header:m,noHeader:s||!v,sortParam:r,blocks:[tm("nameValue",{markerType:"item",markerColor:p,name:_,noName:!kn(_),value:e,valueType:n,dataIndex:a})].concat(i||[])})}var hm=Es();function pm(t,e){return t.getName(e)||t.getId(e)}var fm=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e._selectedDataIndicesMap={},e}var n;return _(e,t),e.prototype.init=function(t,e,n){this.seriesIndex=this.componentIndex,this.dataTask=wv({count:vm,reset:mm}),this.dataTask.context={model:this},this.mergeDefaultAndTheme(t,n),(hm(this).sourceManager=new jv(this)).prepareSource();var i=this.getInitialData(t,n);_m(i,this),this.dataTask.context.data=i,hm(this).dataBeforeProcessed=i,gm(this),this._initSelectedMapFromData(i)},e.prototype.mergeDefaultAndTheme=function(t,e){var n=Of(this),i=n?Nf(t):{},r=this.subType;Hf.hasClass(r)&&(r+="Series"),Ye(t,e.getTheme().get(this.subType)),Ye(t,this.getDefaultOption()),ws(t,"label",["show"]),this.fillDataTextStyle(t.data),n&&zf(t,i,n)},e.prototype.mergeOption=function(t,e){t=Ye(this.option,t,!0),this.fillDataTextStyle(t.data);var n=Of(this);n&&zf(this.option,t,n);var i=hm(this).sourceManager;i.dirty(),i.prepareSource();var r=this.getInitialData(t,e);_m(r,this),this.dataTask.dirty(),this.dataTask.context.data=r,hm(this).dataBeforeProcessed=r,gm(this),this._initSelectedMapFromData(r)},e.prototype.fillDataTextStyle=function(t){if(t&&!vn(t))for(var e=["show"],n=0;n=0&&u<0)&&(c=o,u=r,d=0),r===u&&(l[d++]=e))}),l.length=d,l},e.prototype.formatTooltip=function(t,e,n){return dm({series:this,dataIndex:t,multipleSeries:e})},e.prototype.isAnimationEnabled=function(){var t=this.ecModel;if(Te.node&&(!t||!t.ssr))return!1;var e=this.getShallow("animation");return e&&this.getData().count()>this.getShallow("animationThreshold")&&(e=!1),!!e},e.prototype.restoreData=function(){this.dataTask.dirty()},e.prototype.getColorFromPalette=function(t,e,n){var i=this.ecModel,r=gg.prototype.getColorFromPalette.call(this,t,e,n);return r||(r=i.getColorFromPalette(t,e,n)),r},e.prototype.coordDimToDataDim=function(t){return this.getRawData().mapDimensionsAll(t)},e.prototype.getProgressive=function(){return this.get("progressive")},e.prototype.getProgressiveThreshold=function(){return this.get("progressiveThreshold")},e.prototype.select=function(t,e){this._innerSelect(this.getData(e),t)},e.prototype.unselect=function(t,e){var n=this.option.selectedMap;if(n){var i=this.option.selectedMode,r=this.getData(e);if("series"===i||"all"===n)return this.option.selectedMap={},void(this._selectedDataIndicesMap={});for(var o=0;o=0&&n.push(r)}return n},e.prototype.isSelected=function(t,e){var n=this.option.selectedMap;if(!n)return!1;var i=this.getData(e);return("all"===n||n[pm(i,t)])&&!i.getItemModel(t).get(["select","disabled"])},e.prototype.isUniversalTransitionEnabled=function(){if(this.__universalTransitionEnabled)return!0;var t=this.option.universalTransition;return!!t&&(!0===t||t&&t.enabled)},e.prototype._innerSelect=function(t,e){var n,i,r=this.option,o=r.selectedMode,a=e.length;if(o&&a)if("series"===o)r.selectedMap="all";else if("multiple"===o){fn(r.selectedMap)||(r.selectedMap={});for(var s=r.selectedMap,l=0;l0&&this._innerSelect(t,e)}},e.registerClass=function(t){return Hf.registerClass(t)},e.protoInitialize=((n=e.prototype).type="series.__base__",n.seriesIndex=0,n.ignoreStyleOnData=!1,n.hasSymbolVisual=!1,n.defaultSymbol="circle",n.visualStyleAccessPath="itemStyle",void(n.visualDrawType="fill")),e}(Hf);function gm(t){var e=t.name;Is(t)||(t.name=function(t){var e=t.getRawData(),n=e.mapDimensionsAll("seriesName"),i=[];return en(n,function(t){var n=e.getDimensionInfo(t);n.displayName&&i.push(n.displayName)}),i.join(" ")}(t)||e)}function vm(t){return t.model.getRawData().count()}function mm(t){var e=t.model;return e.setData(e.getRawData().cloneShallow()),ym}function ym(t,e){e.outputData&&t.end>e.outputData.count()&&e.model.getRawData().cloneShallow(e.outputData)}function _m(t,e){en(function(t,e){for(var n=new t.constructor(t.length+e.length),i=0;i=0?d():u=setTimeout(d,-r),l=i};return h.clear=function(){u&&(clearTimeout(u),u=null)},h.debounceNextCall=function(t){s=t},h}function Nm(t,e,n,i){var r=t[e];if(r){var o=r[Lm]||r,a=r[Om];if(r[Em]!==n||a!==i){if(null==n||!i)return t[e]=o;(r=t[e]=zm(o,n,"debounce"===i))[Lm]=o,r[Om]=i,r[Em]=n}return r}}function Rm(t,e){var n=t[e];n&&n[Lm]&&(n.clear&&n.clear(),t[e]=n[Lm])}var Hm=Es(),Bm={itemStyle:Ys(vp,!0),lineStyle:Ys(pp,!0)},Fm={lineStyle:"stroke",itemStyle:"fill"};function $m(t,e){var n=t.visualStyleMapper||Bm[e];return n||(console.warn("Unknown style type '"+e+"'."),Bm.itemStyle)}function Vm(t,e){var n=t.visualDrawType||Fm[e];return n||(console.warn("Unknown style type '"+e+"'."),"fill")}var Wm={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){var n=t.getData(),i=t.visualStyleAccessPath||"itemStyle",r=t.getModel(i),o=$m(t,i)(r),a=r.getShallow("decal");a&&(n.setVisual("decal",a),a.dirty=!0);var s=Vm(t,i),l=o[s],c=un(l)?l:null,u="auto"===o.fill||"auto"===o.stroke;if(!o[s]||c||u){var d=t.getColorFromPalette(t.name,null,e.getSeriesCount());o[s]||(o[s]=d,n.setVisual("colorFromPalette",!0)),o.fill="auto"===o.fill||un(o.fill)?d:o.fill,o.stroke="auto"===o.stroke||un(o.stroke)?d:o.stroke}if(n.setVisual("style",o),n.setVisual("drawType",s),!e.isSeriesFiltered(t)&&c)return n.setVisual("colorFromPalette",!1),{dataEach:function(e,n){var i=t.getDataParams(n),r=Ze({},o);r[s]=c(i),e.setItemVisual(n,"style",r)}}}},Um=new _p,Gm={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){if(!t.ignoreStyleOnData&&!e.isSeriesFiltered(t)){var n=t.getData(),i=t.visualStyleAccessPath||"itemStyle",r=$m(t,i),o=n.getVisual("drawType");return{dataEach:n.hasItemOption?function(t,e){var n=t.getRawDataItem(e);if(n&&n[i]){Um.option=n[i];var a=r(Um);Ze(t.ensureUniqueItemVisual(e,"style"),a),Um.option.decal&&(t.setItemVisual(e,"decal",Um.option.decal),Um.option.decal.dirty=!0),o in a&&t.setItemVisual(e,"colorFromPalette",!1)}}:null}}}},qm={performRawSeries:!0,overallReset:function(t){var e=En();t.eachSeries(function(t){var n=t.getColorBy();if(!t.isColorBySeries()){var i=t.type+"-"+n,r=e.get(i);r||(r={},e.set(i,r)),Hm(t).scope=r}}),t.eachSeries(function(e){if(!e.isColorBySeries()&&!t.isSeriesFiltered(e)){var n=e.getRawData(),i={},r=e.getData(),o=Hm(e).scope,a=e.visualStyleAccessPath||"itemStyle",s=Vm(e,a);r.each(function(t){var e=r.getRawIndex(t);i[e]=t}),n.each(function(t){var a=i[t];if(r.getItemVisual(a,"colorFromPalette")){var l=r.ensureUniqueItemVisual(a,"style"),c=n.getName(t)||t+"",u=n.count();l[s]=e.getColorFromPalette(c,o,u)}})}})}},jm=Math.PI;var Xm=function(){function t(t,e,n,i){this._stageTaskMap=En(),this.ecInstance=t,this.api=e,n=this._dataProcessorHandlers=n.slice(),i=this._visualHandlers=i.slice(),this._allHandlers=n.concat(i)}return t.prototype.restoreData=function(t,e){t.restoreData(e),this._stageTaskMap.each(function(t){var e=t.overallTask;e&&e.dirty()})},t.prototype.getPerformArgs=function(t,e){if(t.__pipeline){var n=this._pipelineMap.get(t.__pipeline.id),i=n.context,r=!e&&n.progressiveEnabled&&(!i||i.progressiveRender)&&t.__idxInPipeline>n.blockIndex?n.step:null,o=i&&i.modDataCount;return{step:r,modBy:null!=o?Math.ceil(o/r):null,modDataCount:o}}},t.prototype.getPipeline=function(t){return this._pipelineMap.get(t)},t.prototype.updateStreamModes=function(t,e){var n=this._pipelineMap.get(t.uid),i=t.getData().count(),r=n.progressiveEnabled&&e.incrementalPrepareRender&&i>=n.threshold,o=t.get("large")&&i>=t.get("largeThreshold"),a="mod"===t.get("progressiveChunkMode")?i:null;t.pipelineContext=n.context={progressiveRender:r,modDataCount:a,large:o}},t.prototype.restorePipelines=function(t){var e=this,n=e._pipelineMap=En();t.eachSeries(function(t){var i=t.getProgressive(),r=t.uid;n.set(r,{id:r,head:null,tail:null,threshold:t.getProgressiveThreshold(),progressiveEnabled:i&&!(t.preventIncremental&&t.preventIncremental()),blockIndex:-1,step:Math.round(i||700),count:0}),e._pipe(t,t.dataTask)})},t.prototype.prepareStageTasks=function(){var t=this._stageTaskMap,e=this.api.getModel(),n=this.api;en(this._allHandlers,function(i){var r=t.get(i.uid)||t.set(i.uid,{});Mn(!(i.reset&&i.overallReset),""),i.reset&&this._createSeriesStageTask(i,r,e,n),i.overallReset&&this._createOverallStageTask(i,r,e,n)},this)},t.prototype.prepareView=function(t,e,n,i){var r=t.renderTask,o=r.context;o.model=e,o.ecModel=n,o.api=i,r.__block=!t.incrementalPrepareRender,this._pipe(e,r)},t.prototype.performDataProcessorTasks=function(t,e){this._performStageTasks(this._dataProcessorHandlers,t,e,{block:!0})},t.prototype.performVisualTasks=function(t,e,n){this._performStageTasks(this._visualHandlers,t,e,n)},t.prototype._performStageTasks=function(t,e,n,i){i=i||{};var r=!1,o=this;function a(t,e){return t.setDirty&&(!t.dirtyMap||t.dirtyMap.get(e.__pipeline.id))}en(t,function(t,s){if(!i.visualType||i.visualType===t.visualType){var l=o._stageTaskMap.get(t.uid),c=l.seriesTaskMap,u=l.overallTask;if(u){var d,h=u.agentStubMap;h.each(function(t){a(i,t)&&(t.dirty(),d=!0)}),d&&u.dirty(),o.updatePayload(u,n);var p=o.getPerformArgs(u,i.block);h.each(function(t){t.perform(p)}),u.perform(p)&&(r=!0)}else c&&c.each(function(s,l){a(i,s)&&s.dirty();var c=o.getPerformArgs(s,i.block);c.skip=!t.performRawSeries&&e.isSeriesFiltered(s.context.model),o.updatePayload(s,n),s.perform(c)&&(r=!0)})}}),this.unfinished=r||this.unfinished},t.prototype.performSeriesTasks=function(t){var e;t.eachSeries(function(t){e=t.dataTask.perform()||e}),this.unfinished=e||this.unfinished},t.prototype.plan=function(){this._pipelineMap.each(function(t){var e=t.tail;do{if(e.__block){t.blockIndex=e.__idxInPipeline;break}e=e.getUpstream()}while(e)})},t.prototype.updatePayload=function(t,e){"remain"!==e&&(t.context.payload=e)},t.prototype._createSeriesStageTask=function(t,e,n,i){var r=this,o=e.seriesTaskMap,a=e.seriesTaskMap=En(),s=t.seriesType,l=t.getTargetSeries;function c(e){var s=e.uid,l=a.set(s,o&&o.get(s)||wv({plan:Jm,reset:ty,count:iy}));l.context={model:e,ecModel:n,api:i,useClearVisual:t.isVisual&&!t.isLayout,plan:t.plan,reset:t.reset,scheduler:r},r._pipe(e,l)}t.createOnAllSeries?n.eachRawSeries(c):s?n.eachRawSeriesByType(s,c):l&&l(n,i).each(c)},t.prototype._createOverallStageTask=function(t,e,n,i){var r=this,o=e.overallTask=e.overallTask||wv({reset:Ym});o.context={ecModel:n,api:i,overallReset:t.overallReset,scheduler:r};var a=o.agentStubMap,s=o.agentStubMap=En(),l=t.seriesType,c=t.getTargetSeries,u=!0,d=!1;function h(t){var e=t.uid,n=s.set(e,a&&a.get(e)||(d=!0,wv({reset:Zm,onDirty:Qm})));n.context={model:t,overallProgress:u},n.agent=o,n.__block=u,r._pipe(t,n)}Mn(!t.createOnAllSeries,""),l?n.eachRawSeriesByType(l,h):c?c(n,i).each(h):(u=!1,en(n.getSeries(),h)),d&&o.dirty()},t.prototype._pipe=function(t,e){var n=t.uid,i=this._pipelineMap.get(n);!i.head&&(i.head=e),i.tail&&i.tail.pipe(e),i.tail=e,e.__idxInPipeline=i.count++,e.__pipeline=i},t.wrapStageHandler=function(t,e){return un(t)&&(t={overallReset:t,seriesType:ry(t)}),t.uid=xp("stageHandler"),e&&(t.visualType=e),t},t}();function Ym(t){t.overallReset(t.ecModel,t.api,t.payload)}function Zm(t){return t.overallProgress&&Km}function Km(){this.agent.dirty(),this.getDownstream().dirty()}function Qm(){this.agent&&this.agent.dirty()}function Jm(t){return t.plan?t.plan(t.model,t.ecModel,t.api,t.payload):null}function ty(t){t.useClearVisual&&t.data.clearAllVisual();var e=t.resetDefines=xs(t.reset(t.model,t.ecModel,t.api,t.payload));return e.length>1?nn(e,function(t,e){return ny(e)}):ey}var ey=ny(0);function ny(t){return function(e,n){var i=n.data,r=n.resetDefines[t];if(r&&r.dataEach)for(var o=e.start;o0&&u===r.length-c.length){var d=r.slice(0,u);"data"!==d&&(e.mainType=d,e[c.toLowerCase()]=t,s=!0)}}a.hasOwnProperty(r)&&(n[r]=t,s=!0),s||(i[r]=t)})}return{cptQuery:e,dataQuery:n,otherQuery:i}},t.prototype.filter=function(t,e){var n=this.eventInfo;if(!n)return!0;var i=n.targetEl,r=n.packedEvent,o=n.model,a=n.view;if(!o||!a)return!0;var s=e.cptQuery,l=e.dataQuery;return c(s,o,"mainType")&&c(s,o,"subType")&&c(s,o,"index","componentIndex")&&c(s,o,"name")&&c(s,o,"id")&&c(l,r,"name")&&c(l,r,"dataIndex")&&c(l,r,"dataType")&&(!a.filterForExposedEvent||a.filterForExposedEvent(t,e.otherQuery,i,r));function c(t,e,n,i){return null==t[n]||e[i||n]===t[n]}},t.prototype.afterTrigger=function(){this.eventInfo=null},t}(),vy=["symbol","symbolSize","symbolRotate","symbolOffset"],my=vy.concat(["symbolKeepAspect"]),yy={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){var n=t.getData();if(t.legendIcon&&n.setVisual("legendIcon",t.legendIcon),t.hasSymbolVisual){for(var i={},r={},o=!1,a=0;a=0&&zy(l)?l:.5,t.createRadialGradient(a,s,0,a,s,l)}(t,e,n):function(t,e,n){var i=null==e.x?0:e.x,r=null==e.x2?1:e.x2,o=null==e.y?0:e.y,a=null==e.y2?0:e.y2;return e.global||(i=i*n.width+n.x,r=r*n.width+n.x,o=o*n.height+n.y,a=a*n.height+n.y),i=zy(i)?i:0,r=zy(r)?r:1,o=zy(o)?o:0,a=zy(a)?a:0,t.createLinearGradient(i,o,r,a)}(t,e,n),r=e.colorStops,o=0;o0&&(e=i.lineDash,n=i.lineWidth,e&&"solid"!==e&&n>0?"dashed"===e?[4*n,2*n]:"dotted"===e?[n]:pn(e)?[e]:cn(e)?e:null:null),o=i.lineDashOffset;if(r){var a=i.strokeNoScale&&t.getLineScale?t.getLineScale():1;a&&1!==a&&(r=nn(r,function(t){return t/a}),o/=a)}return[r,o]}var Fy=new ac(!0);function $y(t){var e=t.stroke;return!(null==e||"none"===e||!(t.lineWidth>0))}function Vy(t){return"string"==typeof t&&"none"!==t}function Wy(t){var e=t.fill;return null!=e&&"none"!==e}function Uy(t,e){if(null!=e.fillOpacity&&1!==e.fillOpacity){var n=t.globalAlpha;t.globalAlpha=e.fillOpacity*e.opacity,t.fill(),t.globalAlpha=n}else t.fill()}function Gy(t,e){if(null!=e.strokeOpacity&&1!==e.strokeOpacity){var n=t.globalAlpha;t.globalAlpha=e.strokeOpacity*e.opacity,t.stroke(),t.globalAlpha=n}else t.stroke()}function qy(t,e,n){var i=tl(e.image,e.__image,n);if(nl(i)){var r=t.createPattern(i,e.repeat||"repeat");if("function"==typeof DOMMatrix&&r&&r.setTransform){var o=new DOMMatrix;o.translateSelf(e.x||0,e.y||0),o.rotateSelf(0,0,(e.rotation||0)*Rn),o.scaleSelf(e.scaleX||1,e.scaleY||1),r.setTransform(o)}return r}}var jy=["shadowBlur","shadowOffsetX","shadowOffsetY"],Xy=[["lineCap","butt"],["lineJoin","miter"],["miterLimit",10]];function Yy(t,e,n,i,r){var o=!1;if(!i&&e===(n=n||{}))return!1;if(i||e.opacity!==n.opacity){Qy(t,r),o=!0;var a=Math.max(Math.min(e.opacity,1),0);t.globalAlpha=isNaN(a)?wl.opacity:a}(i||e.blend!==n.blend)&&(o||(Qy(t,r),o=!0),t.globalCompositeOperation=e.blend||wl.blend);for(var s=0;s0&&t.unfinished);t.unfinished||this._zr.flush()}}},e.prototype.getDom=function(){return this._dom},e.prototype.getId=function(){return this.id},e.prototype.getZr=function(){return this._zr},e.prototype.isSSR=function(){return this._ssr},e.prototype.setOption=function(t,e,n){if(!this[p_])if(this._disposed)this.id;else{var i,r,o;if(fn(e)&&(n=e.lazyUpdate,i=e.silent,r=e.replaceMerge,o=e.transition,e=e.notMerge),this[p_]=!0,F_(this),!this._model||e){var a=new Cg(this._api),s=this._theme,l=this._model=new mg;l.scheduler=this._scheduler,l.ssr=this._ssr,l.init(null,null,null,s,this._locale,a)}this._model.setOption(t,{replaceMerge:r},Z_);var c={seriesTransition:o,optionChanged:!0};if(n)this[g_]={silent:i,updateParams:c},this[p_]=!1,this.getZr().wakeUp();else{try{w_(this),M_.update.call(this,null,c)}catch(t){throw this[g_]=null,this[p_]=!1,t}this._ssr||this._zr.flush(),this[g_]=null,this[p_]=!1,A_.call(this,i),I_.call(this,i)}}},e.prototype.setTheme=function(t,e){if(!this[p_])if(this._disposed)this.id;else{var n=this._model;if(n){var i=e&&e.silent,r=null;this[g_]&&(null==i&&(i=this[g_].silent),r=this[g_].updateParams,this[g_]=null),this[p_]=!0,F_(this);try{this._updateTheme(t),n.setTheme(this._theme),w_(this),M_.update.call(this,{type:"setTheme"},r)}catch(t){throw this[p_]=!1,t}this[p_]=!1,A_.call(this,i),I_.call(this,i)}}},e.prototype._updateTheme=function(t){dn(t)&&(t=Q_[t]),t&&((t=Xe(t))&&Gg(t,!0),this._theme=t)},e.prototype.getModel=function(){return this._model},e.prototype.getOption=function(){return this._model&&this._model.getOption()},e.prototype.getWidth=function(){return this._zr.getWidth()},e.prototype.getHeight=function(){return this._zr.getHeight()},e.prototype.getDevicePixelRatio=function(){return this._zr.painter.dpr||Te.hasGlobalWindow&&window.devicePixelRatio||1},e.prototype.getRenderedCanvas=function(t){return this.renderToCanvas(t)},e.prototype.renderToCanvas=function(t){return t=t||{},this._zr.painter.getRenderedCanvas({backgroundColor:t.backgroundColor||this._model.get("backgroundColor"),pixelRatio:t.pixelRatio||this.getDevicePixelRatio()})},e.prototype.renderToSVGString=function(t){return t=t||{},this._zr.painter.renderToString({useViewBox:t.useViewBox})},e.prototype.getSvgDataURL=function(){var t=this._zr;return en(t.storage.getDisplayList(),function(t){t.stopAnimation(null,!0)}),t.painter.toDataURL()},e.prototype.getDataURL=function(t){if(!this._disposed){var e=(t=t||{}).excludeComponents,n=this._model,i=[],r=this;en(e,function(t){n.eachComponent({mainType:t},function(t){var e=r._componentsMap[t.__viewId];e.group.ignore||(i.push(e),e.group.ignore=!0)})});var o="svg"===this._zr.painter.getType()?this.getSvgDataURL():this.renderToCanvas(t).toDataURL("image/"+(t&&t.type||"png"));return en(i,function(t){t.group.ignore=!1}),o}this.id},e.prototype.getConnectedDataURL=function(t){if(!this._disposed){var e="svg"===t.type,n=this.group,i=Math.min,r=Math.max,o=1/0;if(eb[n]){var a=o,s=o,l=-1/0,c=-1/0,u=[],d=t&&t.pixelRatio||this.getDevicePixelRatio();en(tb,function(o,d){if(o.group===n){var h=e?o.getZr().painter.getSvgDom().innerHTML:o.renderToCanvas(Xe(t)),p=o.getDom().getBoundingClientRect();a=i(p.left,a),s=i(p.top,s),l=r(p.right,l),c=r(p.bottom,c),u.push({dom:h,left:p.left,top:p.top})}});var h=(l*=d)-(a*=d),p=(c*=d)-(s*=d),f=Ee.createCanvas(),g=Ya(f,{renderer:e?"svg":"canvas"});if(g.resize({width:h,height:p}),e){var v="";return en(u,function(t){var e=t.left-a,n=t.top-s;v+=''+t.dom+""}),g.painter.getSvgRoot().innerHTML=v,t.connectedBackgroundColor&&g.painter.setBackgroundColor(t.connectedBackgroundColor),g.refreshImmediately(),g.painter.toDataURL()}return t.connectedBackgroundColor&&g.add(new Bc({shape:{x:0,y:0,width:h,height:p},style:{fill:t.connectedBackgroundColor}})),en(u,function(t){var e=new Lc({style:{x:t.left*d-a,y:t.top*d-s,image:t.dom}});g.add(e)}),g.refreshImmediately(),f.toDataURL("image/"+(t&&t.type||"png"))}return this.getDataURL(t)}this.id},e.prototype.convertToPixel=function(t,e,n){return k_(this,"convertToPixel",t,e,n)},e.prototype.convertToLayout=function(t,e,n){return k_(this,"convertToLayout",t,e,n)},e.prototype.convertFromPixel=function(t,e,n){return k_(this,"convertFromPixel",t,e,n)},e.prototype.containPixel=function(t,e){var n;if(!this._disposed)return en(zs(this._model,t),function(t,i){i.indexOf("Models")>=0&&en(t,function(t){var r=t.coordinateSystem;if(r&&r.containPoint)n=n||!!r.containPoint(e);else if("seriesModels"===i){var o=this._chartsMap[t.__viewId];o&&o.containPoint&&(n=n||o.containPoint(e,t))}},this)},this),!!n;this.id},e.prototype.getVisual=function(t,e){var n=zs(this._model,t,{defaultMainType:"series"}),i=n.seriesModel.getData(),r=n.hasOwnProperty("dataIndexInside")?n.dataIndexInside:n.hasOwnProperty("dataIndex")?i.indexOfRawIndex(n.dataIndex):null;return null!=r?function(t,e,n){switch(n){case"color":return t.getItemVisual(e,"style")[t.getVisual("drawType")];case"opacity":return t.getItemVisual(e,"style").opacity;case"symbol":case"symbolSize":case"liftZ":return t.getItemVisual(e,n)}}(i,r,e):function(t,e){switch(e){case"color":return t.getVisual("style")[t.getVisual("drawType")];case"opacity":return t.getVisual("style").opacity;case"symbol":case"symbolSize":case"liftZ":return t.getVisual(e)}}(i,e)},e.prototype.getViewOfComponentModel=function(t){return this._componentsMap[t.__viewId]},e.prototype.getViewOfSeriesModel=function(t){return this._chartsMap[t.__viewId]},e.prototype._initEvents=function(){var t=this;en(G_,function(e){var n=function(n){var i,r=t.getModel(),o=n.target;if("globalout"===e?i={}:o&&xy(o,function(t){var e=nu(t);if(e&&null!=e.dataIndex){var n=e.dataModel||r.getSeriesByIndex(e.seriesIndex);return i=n&&n.getDataParams(e.dataIndex,e.dataType,o)||{},!0}if(e.eventData)return i=Ze({},e.eventData),!0},!0),i){var a=i.componentType,s=i.componentIndex;"markLine"!==a&&"markPoint"!==a&&"markArea"!==a||(a="series",s=i.seriesIndex);var l=a&&null!=s&&r.getComponent(a,s),c=l&&t["series"===l.mainType?"_chartsMap":"_componentsMap"][l.__viewId];i.event=n,i.type=e,t._$eventProcessor.eventInfo={targetEl:o,packedEvent:i,model:l,view:c},t.trigger(e,i)}};n.zrEventfulCallAtLast=!0,t._zr.on(e,n,t)});var e=this._messageCenter;en(X_,function(n,i){e.on(i,function(e){t.trigger(i,e)})}),function(t,e,n){t.on("selectchanged",function(t){var i=n.getModel();t.isFromClick?(by("map","selectchanged",e,i,t),by("pie","selectchanged",e,i,t)):"select"===t.fromAction?(by("map","selected",e,i,t),by("pie","selected",e,i,t)):"unselect"===t.fromAction&&(by("map","unselected",e,i,t),by("pie","unselected",e,i,t))})}(e,this,this._api)},e.prototype.isDisposed=function(){return this._disposed},e.prototype.clear=function(){this._disposed?this.id:this.setOption({series:[]},!0)},e.prototype.dispose=function(){if(this._disposed)this.id;else{this._disposed=!0,this.getDom()&&Bs(this.getDom(),ib,"");var t=this,e=t._api,n=t._model;en(t._componentsViews,function(t){t.dispose(n,e)}),en(t._chartsViews,function(t){t.dispose(n,e)}),t._zr.dispose(),t._dom=t._model=t._chartsMap=t._componentsMap=t._chartsViews=t._componentsViews=t._scheduler=t._api=t._zr=t._throttledZrFlush=t._theme=t._coordSysMgr=t._messageCenter=null,delete tb[t.id]}},e.prototype.resize=function(t){if(!this[p_])if(this._disposed)this.id;else{this._zr.resize(t);var e=this._model;if(this._loadingFX&&this._loadingFX.resize(),e){var n=e.resetOption("media"),i=t&&t.silent;this[g_]&&(null==i&&(i=this[g_].silent),n=!0,this[g_]=null),this[p_]=!0,F_(this);try{n&&w_(this),M_.update.call(this,{type:"resize",animation:Ze({duration:0},t&&t.animation)})}catch(t){throw this[p_]=!1,t}this[p_]=!1,A_.call(this,i),I_.call(this,i)}}},e.prototype.showLoading=function(t,e){if(this._disposed)this.id;else if(fn(t)&&(e=t,t=""),t=t||"default",this.hideLoading(),J_[t]){var n=J_[t](this._api,e),i=this._zr;this._loadingFX=n,i.add(n)}},e.prototype.hideLoading=function(){this._disposed?this.id:(this._loadingFX&&this._zr.remove(this._loadingFX),this._loadingFX=null)},e.prototype.makeActionFromEvent=function(t){var e=Ze({},t);return e.type=j_[t.type],e},e.prototype.dispatchAction=function(t,e){if(this._disposed)this.id;else if(fn(e)||(e={silent:!!e}),q_[t.type]&&this._model)if(this[p_])this._pendingActions.push(t);else{var n=e.silent;D_.call(this,t,n);var i=e.flush;i?this._zr.flush():!1!==i&&Te.browser.weChat&&this._throttledZrFlush(),A_.call(this,n),I_.call(this,n)}},e.prototype.updateLabelLayout=function(){l_.trigger("series:layoutlabels",this._model,this._api,{updatedSeries:[]})},e.prototype.appendData=function(t){if(this._disposed)this.id;else{var e=t.seriesIndex;this.getModel().getSeriesByIndex(e).appendData(t),this._scheduler.unfinished=!0,this.getZr().wakeUp()}},e.internalField=function(){function t(t){t.clearColorPalette(),t.eachSeries(function(t){t.clearColorPalette()})}function e(t){for(var e=[],n=t.currentStates,i=0;i0?{duration:o,delay:i.get("delay"),easing:i.get("easing")}:null;n.eachRendered(function(t){if(t.states&&t.states.emphasis){if(fh(t))return;if(t instanceof Tc&&function(t){var e=ou(t);e.normalFill=t.style.fill,e.normalStroke=t.style.stroke;var n=t.states.select||{};e.selectFill=n.style&&n.style.fill||null,e.selectStroke=n.style&&n.style.stroke||null}(t),t.__dirty){var n=t.prevStates;n&&t.useStates(n)}if(r){t.stateTransition=a;var i=t.getTextContent(),o=t.getTextGuideLine();i&&(i.stateTransition=a),o&&(o.stateTransition=a)}t.__dirty&&e(t)}})}w_=function(t){var e=t._scheduler;e.restorePipelines(t._model),e.prepareStageTasks(),S_(t,!0),S_(t,!1),e.plan()},S_=function(t,e){for(var n=t._model,i=t._scheduler,r=e?t._componentsViews:t._chartsViews,o=e?t._componentsMap:t._chartsMap,a=t._zr,s=t._api,l=0;le.get("hoverLayerThreshold")&&!Te.node&&!Te.worker&&e.eachSeries(function(e){if(!e.preventUsingHoverLayer){var n=t._chartsMap[e.__viewId];n.__alive&&n.eachRendered(function(t){t.states.emphasis&&(t.states.emphasis.hoverLayer=!0)})}})}(t,e),l_.trigger("series:afterupdate",e,o,s)},H_=function(t){t[v_]=!0,t.getZr().wakeUp()},F_=function(t){t[f_]=(t[f_]+1)%1e3},B_=function(t){t[v_]&&(t.getZr().storage.traverse(function(t){fh(t)||e(t)}),t[v_]=!1)},N_=function(t){return new(function(e){function n(){return null!==e&&e.apply(this,arguments)||this}return _(n,e),n.prototype.getCoordinateSystems=function(){return t._coordSysMgr.getCoordinateSystems()},n.prototype.getComponentByElement=function(e){for(;e;){var n=e.__ecComponentInfo;if(null!=n)return t._model.getComponent(n.mainType,n.index);e=e.parent}},n.prototype.enterEmphasis=function(e,n){Iu(e,n),H_(t)},n.prototype.leaveEmphasis=function(e,n){Pu(e,n),H_(t)},n.prototype.enterBlur=function(e){!function(t){Cu(t,_u)}(e),H_(t)},n.prototype.leaveBlur=function(e){Lu(e),H_(t)},n.prototype.enterSelect=function(e){Eu(e),H_(t)},n.prototype.leaveSelect=function(e){Ou(e),H_(t)},n.prototype.getModel=function(){return t.getModel()},n.prototype.getViewOfComponentModel=function(e){return t.getViewOfComponentModel(e)},n.prototype.getViewOfSeriesModel=function(e){return t.getViewOfSeriesModel(e)},n.prototype.getMainProcessVersion=function(){return t[f_]},n}(wg))(t)},R_=function(t){function e(t,e){for(var n=0;n=0)){db.push(n);var o=Xm.wrapStageHandler(n,r);o.__prio=e,o.__raw=n,t.push(o)}}function pb(t,e){J_[t]=e}var fb=function(t){var e=(t=Xe(t)).type;e||ys("");var n=e.split(":");2!==n.length&&ys("");var i=!1;"echarts"===n[0]&&(e=n[1],i=!0),t.__isBuiltIn=i,Lv.set(e,t)};function gb(t,e,n,i){return{eventContent:{selected:$u(n),isFromClick:e.isFromClick||!1}}}function vb(t){return null==t?0:t.length||1}function mb(t){return t}ub(u_,Wm),ub(d_,Gm),ub(d_,qm),ub(u_,yy),ub(d_,_y),ub(7e3,function(t,e){t.eachRawSeries(function(n){if(!t.isSeriesFiltered(n)){var i=n.getData();i.hasItemVisual()&&i.each(function(t){var n=i.getItemVisual(t,"decal");n&&(i.ensureUniqueItemVisual(t,"style").decal=r_(n,e))});var r=i.getVisual("decal");if(r)i.getVisual("style").decal=r_(r,e)}})}),ab(Gg),sb(900,function(t){var e=En();t.eachSeries(function(t){var n=t.get("stack");if(n){var i=e.get(n)||e.set(n,[]),r=t.getData(),o={stackResultDimension:r.getCalculationInfo("stackResultDimension"),stackedOverDimension:r.getCalculationInfo("stackedOverDimension"),stackedDimension:r.getCalculationInfo("stackedDimension"),stackedByDimension:r.getCalculationInfo("stackedByDimension"),isStackedByIndex:r.getCalculationInfo("isStackedByIndex"),data:r,seriesModel:t};if(!o.stackedDimension||!o.isStackedByIndex&&!o.stackedByDimension)return;i.push(o)}}),e.each(function(t){0!==t.length&&("seriesDesc"===(t[0].seriesModel.get("stackOrder")||"seriesAsc")&&t.reverse(),en(t,function(e,n){e.data.setCalculationInfo("stackedOnSeries",n>0?t[n-1].seriesModel:null)}),function(t){en(t,function(e,n){var i=[],r=[NaN,NaN],o=[e.stackResultDimension,e.stackedOverDimension],a=e.data,s=e.isStackedByIndex,l=e.seriesModel.get("stackStrategy")||"samesign";a.modify(o,function(o,c,u){var d,h,p=a.get(e.stackedDimension,u);if(isNaN(p))return r;s?h=a.getRawIndex(u):d=a.get(e.stackedByDimension,u);for(var f=NaN,g=n-1;g>=0;g--){var v=t[g];if(s||(h=v.data.rawIndexOf(v.stackedByDimension,d)),h>=0){var m=v.data.getByRawIndex(v.stackResultDimension,h);if("all"===l||"positive"===l&&m>0||"negative"===l&&m<0||"samesign"===l&&p>=0&&m>0||"samesign"===l&&p<=0&&m<0){p=os(p,m),f=m;break}}}return i[0]=p,i[1]=f,i})})}(t))})}),pb("default",function(t,e){Ke(e=e||{},{text:"loading",textColor:Bf.color.primary,fontSize:12,fontWeight:"normal",fontStyle:"normal",fontFamily:"sans-serif",maskColor:"rgba(255,255,255,0.8)",showSpinner:!0,color:Bf.color.theme[0],spinnerRadius:10,lineWidth:5,zlevel:0});var n=new Ua,i=new Bc({style:{fill:e.maskColor},zlevel:e.zlevel,z:1e4});n.add(i);var r,o=new Wc({style:{text:e.text,fill:e.textColor,fontSize:e.fontSize,fontWeight:e.fontWeight,fontStyle:e.fontStyle,fontFamily:e.fontFamily},zlevel:e.zlevel,z:10001}),a=new Bc({style:{fill:"none"},textContent:o,textConfig:{position:"right",distance:10},zlevel:e.zlevel,z:10001});return n.add(a),e.showSpinner&&((r=new Xd({shape:{startAngle:-jm/2,endAngle:-jm/2+.1,r:e.spinnerRadius},style:{stroke:e.color,lineCap:"round",lineWidth:e.lineWidth},zlevel:e.zlevel,z:10001})).animateShape(!0).when(1e3,{endAngle:3*jm/2}).start("circularInOut"),r.animateShape(!0).when(1e3,{startAngle:3*jm/2}).delay(300).start("circularInOut"),n.add(r)),n.resize=function(){var n=o.getBoundingRect().width,s=e.showSpinner?e.spinnerRadius:0,l=(t.getWidth()-2*s-(e.showSpinner&&n?10:0)-n)/2-(e.showSpinner&&n?0:5+n/2)+(e.showSpinner?0:n/2)+(n?0:s),c=t.getHeight()/2;e.showSpinner&&r.setShape({cx:l,cy:c}),a.setShape({x:l-s,y:c-s,width:2*s,height:2*s}),i.setShape({x:0,y:0,width:t.getWidth(),height:t.getHeight()})},n.resize(),n}),cb({type:cu,event:cu,update:cu},Nn),cb({type:uu,event:uu,update:uu},Nn),cb({type:du,event:fu,update:du,action:Nn,refineEvent:gb,publishNonRefinedEvent:!0}),cb({type:hu,event:fu,update:hu,action:Nn,refineEvent:gb,publishNonRefinedEvent:!0}),cb({type:pu,event:fu,update:pu,action:Nn,refineEvent:gb,publishNonRefinedEvent:!0}),ob("default",{}),ob("dark",fy);var yb=function(){function t(t,e,n,i,r,o){this._old=t,this._new=e,this._oldKeyGetter=n||mb,this._newKeyGetter=i||mb,this.context=r,this._diffModeMultiple="multiple"===o}return t.prototype.add=function(t){return this._add=t,this},t.prototype.update=function(t){return this._update=t,this},t.prototype.updateManyToOne=function(t){return this._updateManyToOne=t,this},t.prototype.updateOneToMany=function(t){return this._updateOneToMany=t,this},t.prototype.updateManyToMany=function(t){return this._updateManyToMany=t,this},t.prototype.remove=function(t){return this._remove=t,this},t.prototype.execute=function(){this[this._diffModeMultiple?"_executeMultiple":"_executeOneToOne"]()},t.prototype._executeOneToOne=function(){var t=this._old,e=this._new,n={},i=new Array(t.length),r=new Array(e.length);this._initIndexMap(t,null,i,"_oldKeyGetter"),this._initIndexMap(e,n,r,"_newKeyGetter");for(var o=0;o1){var c=s.shift();1===s.length&&(n[a]=s[0]),this._update&&this._update(c,o)}else 1===l?(n[a]=null,this._update&&this._update(s,o)):this._remove&&this._remove(o)}this._performRestAdd(r,n)},t.prototype._executeMultiple=function(){var t=this._old,e=this._new,n={},i={},r=[],o=[];this._initIndexMap(t,n,r,"_oldKeyGetter"),this._initIndexMap(e,i,o,"_newKeyGetter");for(var a=0;a1&&1===d)this._updateManyToOne&&this._updateManyToOne(c,l),i[s]=null;else if(1===u&&d>1)this._updateOneToMany&&this._updateOneToMany(c,l),i[s]=null;else if(1===u&&1===d)this._update&&this._update(c,l),i[s]=null;else if(u>1&&d>1)this._updateManyToMany&&this._updateManyToMany(c,l),i[s]=null;else if(u>1)for(var h=0;h1)for(var a=0;a30}var Ab,Ib,Pb,Lb,Eb,Ob,zb,Nb=fn,Rb=nn,Hb="undefined"==typeof Int32Array?Array:Int32Array,Bb=["hasItemOption","_nameList","_idList","_invertedIndicesMap","_dimSummary","userOutput","_rawData","_dimValueGetter","_nameDimIdx","_idDimIdx","_nameRepeatCount"],Fb=["_approximateExtent"],$b=function(){function t(t,e){var n;this.type="list",this._dimOmitted=!1,this._nameList=[],this._idList=[],this._visual={},this._layout={},this._itemVisuals=[],this._itemLayouts=[],this._graphicEls=[],this._approximateExtent={},this._calculationInfo={},this.hasItemOption=!1,this.TRANSFERABLE_METHODS=["cloneShallow","downSample","minmaxDownSample","lttbDownSample","map"],this.CHANGABLE_METHODS=["filterSelf","selectRange"],this.DOWNSAMPLE_METHODS=["downSample","minmaxDownSample","lttbDownSample"];var i=!1;Mb(t)?(n=t.dimensions,this._dimOmitted=t.isDimensionOmitted(),this._schema=t):(i=!0,n=t),n=n||["x","y"];for(var r={},o=[],a={},s=!1,l={},c=0;c=e)){var n=this._store.getProvider();this._updateOrdinalMeta();var i=this._nameList,r=this._idList;if(n.getSource().sourceFormat===Yf&&!n.pure)for(var o=[],a=t;a0},t.prototype.ensureUniqueItemVisual=function(t,e){var n=this._itemVisuals,i=n[t];i||(i=n[t]={});var r=i[e];return null==r&&(cn(r=this.getVisual(e))?r=r.slice():Nb(r)&&(r=Ze({},r)),i[e]=r),r},t.prototype.setItemVisual=function(t,e,n){var i=this._itemVisuals[t]||{};this._itemVisuals[t]=i,Nb(e)?Ze(i,e):i[e]=n},t.prototype.clearAllVisual=function(){this._visual={},this._itemVisuals=[]},t.prototype.setLayout=function(t,e){Nb(t)?Ze(this._layout,t):this._layout[t]=e},t.prototype.getLayout=function(t){return this._layout[t]},t.prototype.getItemLayout=function(t){return this._itemLayouts[t]},t.prototype.setItemLayout=function(t,e,n){this._itemLayouts[t]=n?Ze(this._itemLayouts[t]||{},e):e},t.prototype.clearItemLayouts=function(){this._itemLayouts.length=0},t.prototype.setItemGraphicEl=function(t,e){!function(t,e,n,i){if(i){var r=nu(i);r.dataIndex=n,r.dataType=e,r.seriesIndex=t,r.ssrType="chart","group"===i.type&&i.traverse(function(i){var r=nu(i);r.seriesIndex=t,r.dataIndex=n,r.dataType=e,r.ssrType="chart"})}}(this.hostModel&&this.hostModel.seriesIndex,this.dataType,t,e),this._graphicEls[t]=e},t.prototype.getItemGraphicEl=function(t){return this._graphicEls[t]},t.prototype.eachItemGraphicEl=function(t,e){en(this._graphicEls,function(n,i){n&&t&&t.call(e,n,i)})},t.prototype.cloneShallow=function(e){return e||(e=new t(this._schema?this._schema:Rb(this.dimensions,this._getDimInfo,this),this.hostModel)),Eb(e,this),e._store=this._store,e},t.prototype.wrapMethod=function(t,e){var n=this[t];un(n)&&(this.__wrappedMethods=this.__wrappedMethods||[],this.__wrappedMethods.push(t),this[t]=function(){var t=n.apply(this,arguments);return e.apply(this,[t].concat(Sn(arguments)))})},t.internalField=(Ab=function(t){var e=t._invertedIndicesMap;en(e,function(n,i){var r=t._dimInfos[i],o=r.ordinalMeta,a=t._store;if(o){n=e[i]=new Hb(o.categories.length);for(var s=0;s1&&(s+="__ec__"+c),i[e]=s}})),t}();function Vb(t,e){Jg(t)||(t=ev(t));var n=(e=e||{}).coordDimensions||[],i=e.dimensionsDefine||t.dimensionsDefine||[],r=En(),o=[],a=function(t,e,n,i){var r=Math.max(t.dimensionsDetectedCount||1,e.length,n.length,i||0);return en(e,function(t){var e;fn(t)&&(e=t.dimsDef)&&(r=Math.max(r,e.length))}),r}(t,n,i,e.dimensionsCount),s=e.canOmitUnusedDimensions&&Db(a),l=i===t.dimensionsDefine,c=l?Tb(t):kb(i),u=e.encodeDefine;!u&&e.encodeDefaulter&&(u=e.encodeDefaulter(t,a));for(var d=En(u),h=new Bv(a),p=0;p0&&(i.name=r+(o-1)),o++,e.set(r,o)}}(o),new Cb({source:t,dimensions:o,fullDimensionCount:a,dimensionOmitted:s})}function Wb(t,e,n){if(n||e.hasKey(t)){for(var i=0;e.hasKey(t+i);)i++;t+=i}return e.set(t,!0),t}var Ub=function(t){this.coordSysDims=[],this.axisMap=En(),this.categoryAxisMap=En(),this.coordSysName=t};var Gb={cartesian2d:function(t,e,n,i){var r=t.getReferringComponents("xAxis",Rs).models[0],o=t.getReferringComponents("yAxis",Rs).models[0];e.coordSysDims=["x","y"],n.set("x",r),n.set("y",o),qb(r)&&(i.set("x",r),e.firstCategoryDimIndex=0),qb(o)&&(i.set("y",o),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=1))},singleAxis:function(t,e,n,i){var r=t.getReferringComponents("singleAxis",Rs).models[0];e.coordSysDims=["single"],n.set("single",r),qb(r)&&(i.set("single",r),e.firstCategoryDimIndex=0)},polar:function(t,e,n,i){var r=t.getReferringComponents("polar",Rs).models[0],o=r.findAxisModel("radiusAxis"),a=r.findAxisModel("angleAxis");e.coordSysDims=["radius","angle"],n.set("radius",o),n.set("angle",a),qb(o)&&(i.set("radius",o),e.firstCategoryDimIndex=0),qb(a)&&(i.set("angle",a),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=1))},geo:function(t,e,n,i){e.coordSysDims=["lng","lat"]},parallel:function(t,e,n,i){var r=t.ecModel,o=r.getComponent("parallel",t.get("parallelIndex")),a=e.coordSysDims=o.dimensions.slice();en(o.parallelAxisIndex,function(t,o){var s=r.getComponent("parallelAxis",t),l=a[o];n.set(l,s),qb(s)&&(i.set(l,s),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=o))})},matrix:function(t,e,n,i){var r=t.getReferringComponents("matrix",Rs).models[0];e.coordSysDims=["x","y"];var o=r.getDimensionModel("x"),a=r.getDimensionModel("y");n.set("x",o),n.set("y",a),i.set("x",o),i.set("y",a)}};function qb(t){return"category"===t.get("type")}function jb(t,e,n){var i,r,o,a=(n=n||{}).byIndex,s=n.stackedCoordDimension;!function(t){return!Mb(t.schema)}(e)?(r=e.schema,i=r.dimensions,o=e.store):i=e;var l,c,u,d,h=!(!t||!t.get("stack"));if(en(i,function(t,e){dn(t)&&(i[e]=t={name:t}),h&&!t.isExtraCoord&&(a||l||!t.ordinalMeta||(l=t),c||"ordinal"===t.type||"time"===t.type||s&&s!==t.coordDim||(c=t))}),!c||a||l||(a=!0),c){u="__\0ecstackresult_"+t.id,d="__\0ecstackedover_"+t.id,l&&(l.createInvertedIndices=!0);var p=c.coordDim,f=c.type,g=0;en(i,function(t){t.coordDim===p&&g++});var v={name:u,coordDim:p,coordDimIndex:g,type:f,isExtraCoord:!0,isCalculationCoord:!0,storeDimIndex:i.length},m={name:d,coordDim:d,coordDimIndex:g+1,type:f,isExtraCoord:!0,isCalculationCoord:!0,storeDimIndex:i.length+1};r?(o&&(v.storeDimIndex=o.ensureCalculationDimension(d,f),m.storeDimIndex=o.ensureCalculationDimension(u,f)),r.appendCalculationDimension(v),r.appendCalculationDimension(m)):(i.push(v),i.push(m))}return{stackedDimension:c&&c.name,stackedByDimension:l&&l.name,isStackedByIndex:a,stackedOverDimension:d,stackResultDimension:u}}function Xb(t,e){return!!e&&e===t.getCalculationInfo("stackedDimension")}function Yb(t,e,n){n=n||{};var i,r,o=e.getSourceManager();r=(i=o.getSource()).sourceFormat===Yf;var a=function(t){var e=t.get("coordinateSystem"),n=new Ub(e),i=Gb[e];if(i)return i(t,n,n.axisMap,n.categoryAxisMap),n}(e),s=function(t,e){var n,i=t.get("coordinateSystem"),r=_f.get(i);return e&&e.coordSysDims&&(n=nn(e.coordSysDims,function(t){var n={name:t},i=e.axisMap.get(t);if(i){var r=i.get("type");n.type=function(t){return"category"===t?"ordinal":"time"===t?"time":"float"}(r)}return n})),n||(n=r&&(r.getDimensionsInfo?r.getDimensionsInfo():r.dimensions.slice())||["x","y"]),n}(e,a),l=n.useEncodeDefaulter,c=un(l)?l:l?ln(sg,s,e):null,u=Vb(i,{coordDimensions:s,generateCoord:n.generateCoord,encodeDefine:e.getEncode(),encodeDefaulter:c,canOmitUnusedDimensions:!r}),d=function(t,e,n){var i,r;return n&&en(t,function(t,o){var a=t.coordDim,s=n.categoryAxisMap.get(a);s&&(null==i&&(i=o),t.ordinalMeta=s.getOrdinalMeta(),e&&(t.createInvertedIndices=!0)),null!=t.otherDims.itemName&&(r=!0)}),r||null==i||(t[i].otherDims.itemName=0),i}(u.dimensions,n.createInvertedIndices,a),h=r?null:o.getSharedDataStore(u),p=jb(e,{schema:u,store:h}),f=new $b(u,e);f.setCalculationInfo(p);var g=null!=d&&function(t){if(t.sourceFormat===Yf){var e=function(t){var e=0;for(;er&&(a=o.interval=r);var s=o.intervalPrecision=Jb(a);return function(t,e){!isFinite(t[0])&&(t[0]=e[0]),!isFinite(t[1])&&(t[1]=e[1]),tx(t,0,e),tx(t,1,e),t[0]>t[1]&&(t[0]=t[1])}(o.niceTickExtent=[is(Math.ceil(t[0]/a)*a,s),is(Math.floor(t[1]/a)*a,s)],t),o}function Qb(t){var e=Math.pow(10,us(t)),n=t/e;return n?2===n?n=3:3===n?n=5:n*=2:n=1,is(n*e)}function Jb(t){return rs(t)+2}function tx(t,e,n){t[e]=Math.max(Math.min(t[e],n[1]),n[0])}function ex(t,e){return t>=e[0]&&t<=e[1]}var nx=function(){function t(){this.normalize=ix,this.scale=rx}return t.prototype.updateMethods=function(t){t.hasBreaks()?(this.normalize=sn(t.normalize,t),this.scale=sn(t.scale,t)):(this.normalize=ix,this.scale=rx)},t}();function ix(t,e){return e[1]===e[0]?.5:(t-e[0])/(e[1]-e[0])}function rx(t,e){return t*(e[1]-e[0])+e[0]}function ox(t,e,n){var i=Math.log(t);return[Math.log(n?e[0]:Math.max(0,e[0]))/i,Math.log(n?e[1]:Math.max(0,e[1]))/i]}var ax=function(){function t(t){this._calculator=new nx,this._setting=t||{},this._extent=[1/0,-1/0]}return t.prototype.getSetting=function(t){return this._setting[t]},t.prototype._innerUnionExtent=function(t){var e=this._extent;this._innerSetExtent(t[0]e[1]?t[1]:e[1])},t.prototype.unionExtentFromData=function(t,e){this._innerUnionExtent(t.getApproximateExtent(e))},t.prototype.getExtent=function(){return this._extent.slice()},t.prototype.setExtent=function(t,e){this._innerSetExtent(t,e)},t.prototype._innerSetExtent=function(t,e){var n=this._extent;isNaN(t)||(n[0]=t),isNaN(e)||(n[1]=e),this._brkCtx&&this._brkCtx.update(n)},t.prototype.setBreaksFromOption=function(t){},t.prototype._innerSetBreak=function(t){this._brkCtx&&(this._brkCtx.setBreaks(t),this._calculator.updateMethods(this._brkCtx),this._brkCtx.update(this._extent))},t.prototype._innerGetBreaks=function(){return this._brkCtx?this._brkCtx.breaks:[]},t.prototype.hasBreaks=function(){return!!this._brkCtx&&this._brkCtx.hasBreaks()},t.prototype._getExtentSpanWithBreaks=function(){return this._brkCtx&&this._brkCtx.hasBreaks()?this._brkCtx.getExtentSpan():this._extent[1]-this._extent[0]},t.prototype.isInExtentRange=function(t){return this._extent[0]<=t&&this._extent[1]>=t},t.prototype.isBlank=function(){return this._isBlank},t.prototype.setBlank=function(t){this._isBlank=t},t}();Xs(ax);var sx=0,lx=function(){function t(t){this.categories=t.categories||[],this._needCollect=t.needCollect,this._deduplication=t.deduplication,this.uid=++sx,this._onCollect=t.onCollect}return t.createByAxisModel=function(e){var n=e.option,i=n.data,r=i&&nn(i,cx);return new t({categories:r,needCollect:!r,deduplication:!1!==n.dedplication})},t.prototype.getOrdinal=function(t){return this._getOrCreateMap().get(t)},t.prototype.parseAndCollect=function(t){var e,n=this._needCollect;if(!dn(t)&&!n)return t;if(n&&!this._deduplication)return e=this.categories.length,this.categories[e]=t,this._onCollect&&this._onCollect(t,e),e;var i=this._getOrCreateMap();return null==(e=i.get(t))&&(n?(e=this.categories.length,this.categories[e]=t,i.set(t,e),this._onCollect&&this._onCollect(t,e)):e=NaN),e},t.prototype._getOrCreateMap=function(){return this._map||(this._map=En(this.categories))},t}();function cx(t){return fn(t)&&null!=t.value?t.value:t+""}var ux=function(t){function e(e){var n=t.call(this,e)||this;n.type="ordinal";var i=n.getSetting("ordinalMeta");return i||(i=new lx({})),cn(i)&&(i=new lx({categories:nn(i,function(t){return fn(t)?t.value:t})})),n._ordinalMeta=i,n._extent=n.getSetting("extent")||[0,i.categories.length-1],n}return _(e,t),e.prototype.parse=function(t){return null==t?NaN:dn(t)?this._ordinalMeta.getOrdinal(t):Math.round(t)},e.prototype.contain=function(t){return ex(t,this._extent)&&t>=0&&t=0&&t=0&&t=t},e.prototype.getOrdinalMeta=function(){return this._ordinalMeta},e.prototype.calcNiceTicks=function(){},e.prototype.calcNiceExtent=function(){},e.type="ordinal",e}(ax);ax.registerClass(ux);var dx=is,hx=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.type="interval",e._interval=0,e._intervalPrecision=2,e}return _(e,t),e.prototype.parse=function(t){return null==t||""===t?NaN:Number(t)},e.prototype.contain=function(t){return ex(t,this._extent)},e.prototype.normalize=function(t){return this._calculator.normalize(t,this._extent)},e.prototype.scale=function(t){return this._calculator.scale(t,this._extent)},e.prototype.getInterval=function(){return this._interval},e.prototype.setInterval=function(t){this._interval=t,this._niceExtent=this._extent.slice(),this._intervalPrecision=Jb(t)},e.prototype.getTicks=function(t){t=t||{};var e=this._interval,n=this._extent,i=this._niceExtent,r=this._intervalPrecision,o=[];if(!e)return o;t.breakTicks;n[0]=0&&(s=dx(s+l*e,r))}if(o.length>0&&s===o[o.length-1].value)break;if(o.length>1e4)return[]}var c=o.length?o[o.length-1].value:i[1];return n[1]>c&&(t.expandToNicedExtent?o.push({value:dx(c+e,r)}):o.push({value:n[1]})),t.breakTicks,o},e.prototype.getMinorTicks=function(t){for(var e=this.getTicks({expandToNicedExtent:!0}),n=[],i=this.getExtent(),r=1;ri[0]&&d0&&(o=null===o?s:Math.min(o,s))}n[i]=o}}return n}(t),n=[];return en(t,function(t){var i,r=t.coordinateSystem.getBaseAxis(),o=r.getExtent();if("category"===r.type)i=r.getBandWidth();else if("value"===r.type||"time"===r.type){var a=r.dim+"_"+r.index,s=e[a],l=Math.abs(o[1]-o[0]),c=r.scale.getExtent(),u=Math.abs(c[1]-c[0]);i=s?l/u*s:l}else{var d=t.getData();i=Math.abs(o[1]-o[0])/d.count()}var h=es(t.get("barWidth"),i),p=es(t.get("barMaxWidth"),i),f=es(t.get("barMinWidth")||(function(t){return t.pipelineContext&&t.pipelineContext.large}(t)?.5:1),i),g=t.get("barGap"),v=t.get("barCategoryGap"),m=t.get("defaultBarGap");n.push({bandWidth:i,barWidth:h,barMaxWidth:p,barMinWidth:f,barGap:g,barCategoryGap:v,defaultBarGap:m,axisKey:mx(r),stackId:vx(t)})}),function(t){var e={};en(t,function(t,n){var i=t.axisKey,r=t.bandWidth,o=e[i]||{bandWidth:r,remainedWidth:r,autoWidthCount:0,categoryGap:null,gap:t.defaultBarGap||0,stacks:{}},a=o.stacks;e[i]=o;var s=t.stackId;a[s]||o.autoWidthCount++,a[s]=a[s]||{width:0,maxWidth:0};var l=t.barWidth;l&&!a[s].width&&(a[s].width=l,l=Math.min(o.remainedWidth,l),o.remainedWidth-=l);var c=t.barMaxWidth;c&&(a[s].maxWidth=c);var u=t.barMinWidth;u&&(a[s].minWidth=u);var d=t.barGap;null!=d&&(o.gap=d);var h=t.barCategoryGap;null!=h&&(o.categoryGap=h)});var n={};return en(e,function(t,e){n[e]={};var i=t.stacks,r=t.bandWidth,o=t.categoryGap;if(null==o){var a=an(i).length;o=Math.max(35-4*a,15)+"%"}var s=es(o,r),l=es(t.gap,1),c=t.remainedWidth,u=t.autoWidthCount,d=(c-s)/(u+(u-1)*l);d=Math.max(d,0),en(i,function(t){var e=t.maxWidth,n=t.minWidth;if(t.width){i=t.width;e&&(i=Math.min(i,e)),n&&(i=Math.max(i,n)),t.width=i,c-=i+l*i,u--}else{var i=d;e&&ei&&(i=n),i!==d&&(t.width=i,c-=i+l*i,u--)}}),d=(c-s)/(u+(u-1)*l),d=Math.max(d,0);var h,p=0;en(i,function(t,e){t.width||(t.width=d),h=t,p+=t.width*(1+l)}),h&&(p-=h.width*l);var f=-p/2;en(i,function(t,i){n[e][i]=n[e][i]||{bandWidth:r,offset:f,width:t.width},f+=t.width*(1+l)})}),n}(n)}var _x=function(t){function e(e){var n=t.call(this,e)||this;return n.type="time",n}return _(e,t),e.prototype.getLabel=function(t){var e=this.getSetting("useUTC");return Gp(t.value,Hp[function(t){switch(t){case"year":case"month":return"day";case"millisecond":return"millisecond";default:return"second"}}(Wp(this._minLevelUnit))]||Hp.second,e,this.getSetting("locale"))},e.prototype.getFormattedLabel=function(t,e,n){var i=this.getSetting("useUTC");return function(t,e,n,i,r){var o=null;if(dn(n))o=n;else if(un(n)){var a={time:t.time,level:t.time.level},s=null;s&&s.makeAxisLabelFormatterParamBreak(a,t.break),o=n(t.value,e,a)}else{var l=t.time;if(l){var c=n[l.lowerTimeUnit][l.upperTimeUnit];o=c[Math.min(l.level,c.length-1)]||""}else{var u=qp(t.value,r);o=n[u][u][0]}}return Gp(new Date(t.value),o,r,i)}(t,e,n,this.getSetting("locale"),i)},e.prototype.getTicks=function(t){var e=this._interval,n=this._extent,i=[];if(!e)return i;var r=this.getSetting("useUTC"),o=qp(n[1],r);i.push({value:n[0],time:{level:0,upperTimeUnit:o,lowerTimeUnit:o}});var a=function(t,e,n,i,r,o){var a=1e4,s=Fp,l=0;function c(t,e,n,r,s,c,u){for(var d=function(t,e){var n=new Date(0);n[t](1);var i=n.getTime();n[t](1+e);var r=n.getTime()-i;return function(t,e){return Math.max(0,Math.round((e-t)/r))}}(s,t),h=e,p=new Date(h);ha));)if(p[s](p[r]()+t),h=p.getTime(),o){var f=o.calcNiceTickMultiple(h,d);f>0&&(p[s](p[r]()+f*t),h=p.getTime())}u.push({value:h,notAdd:!0})}function u(t,r,o){var a=[],s=!r.length;if(!function(t,e,n,i){return jp(new Date(e),t,i).getTime()===jp(new Date(n),t,i).getTime()}(Wp(t),i[0],i[1],n)){s&&(r=[{value:Tx(i[0],t,n)},{value:i[1]}]);for(var l=0;l=i[0]&&u<=i[1]&&c(h,u,d,p,f,g,a),"year"===t&&o.length>1&&0===l&&o.unshift({value:o[0].value-h})}}for(l=0;l=i[0]&&_<=i[1]&&p++)}var b=r/e;if(p>1.5*b&&f>b/1.5)break;if(d.push(m),p>b||t===s[g])break}h=[]}}var x=on(nn(d,function(t){return on(t,function(t){return t.value>=i[0]&&t.value<=i[1]&&!t.notAdd})}),function(t){return t.length>0}),w=[],S=x.length-1;for(g=0;gn&&(this._approxInterval=n);var r=bx.length,o=Math.min(function(t,e,n,i){for(;n>>1;t[r][1]16?16:t>7.5?7:t>3.5?4:t>1.5?2:1}function Sx(t){return(t/=2592e6)>6?6:t>3?3:t>2?2:1}function Cx(t){return(t/=Lp)>12?12:t>6?6:t>3.5?4:t>2?2:1}function Mx(t,e){return(t/=e?Pp:Ip)>30?30:t>20?20:t>15?15:t>10?10:t>5?5:t>2?2:1}function kx(t){return ds(t)}function Tx(t,e,n){var i=Math.max(0,Qe(Bp,e)-1);return jp(new Date(t),Bp[i],n).getTime()}ax.registerClass(_x);var Dx=is,Ax=Math.floor,Ix=Math.ceil,Px=Math.pow,Lx=Math.log,Ex=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.type="log",e.base=10,e._originalScale=new hx,e}return _(e,t),e.prototype.getTicks=function(e){e=e||{};var n=this._extent.slice(),i=this._originalScale.getExtent(),r=t.prototype.getTicks.call(this,e),o=this.base;return this._originalScale._innerGetBreaks(),nn(r,function(t){var e=t.value,r=null,a=Px(o,e);return e===n[0]&&this._fixMin?r=i[0]:e===n[1]&&this._fixMax&&(r=i[1]),null!=r&&(a=Ox(a,r)),{value:a,break:void 0}},this)},e.prototype._getNonTransBreaks=function(){return this._originalScale._innerGetBreaks()},e.prototype.setExtent=function(e,n){this._originalScale.setExtent(e,n);var i=ox(this.base,[e,n]);t.prototype.setExtent.call(this,i[0],i[1])},e.prototype.getExtent=function(){var e=this.base,n=t.prototype.getExtent.call(this);n[0]=Px(e,n[0]),n[1]=Px(e,n[1]);var i=this._originalScale.getExtent();return this._fixMin&&(n[0]=Ox(n[0],i[0])),this._fixMax&&(n[1]=Ox(n[1],i[1])),n},e.prototype.unionExtentFromData=function(t,e){this._originalScale.unionExtentFromData(t,e);var n=ox(this.base,t.getApproximateExtent(e),!0);this._innerUnionExtent(n)},e.prototype.calcNiceTicks=function(t){t=t||10;var e=this._extent.slice(),n=this._getExtentSpanWithBreaks();if(isFinite(n)&&!(n<=0)){var i,r=(i=n,Math.pow(10,us(i)));for(t/n*r<=.5&&(r*=10);!isNaN(r)&&Math.abs(r)<1&&Math.abs(r)>0;)r*=10;var o=[Dx(Ix(e[0]/r)*r),Dx(Ax(e[1]/r)*r)];this._interval=r,this._intervalPrecision=Jb(r),this._niceExtent=o}},e.prototype.calcNiceExtent=function(e){t.prototype.calcNiceExtent.call(this,e),this._fixMin=e.fixMin,this._fixMax=e.fixMax},e.prototype.contain=function(e){return e=Lx(e)/Lx(this.base),t.prototype.contain.call(this,e)},e.prototype.normalize=function(e){return e=Lx(e)/Lx(this.base),t.prototype.normalize.call(this,e)},e.prototype.scale=function(e){return e=t.prototype.scale.call(this,e),Px(this.base,e)},e.prototype.setBreaksFromOption=function(t){},e.type="log",e}(hx);function Ox(t,e){return Dx(t,rs(e))}ax.registerClass(Ex);var zx=function(){function t(t,e,n){this._prepareParams(t,e,n)}return t.prototype._prepareParams=function(t,e,n){n[1]0&&s>0&&!l&&(a=0),a<0&&s<0&&!c&&(s=0));var d=this._determinedMin,h=this._determinedMax;return null!=d&&(a=d,l=!0),null!=h&&(s=h,c=!0),{min:a,max:s,minFixed:l,maxFixed:c,isBlank:u}},t.prototype.modifyDataMinMax=function(t,e){this[Rx[t]]=e},t.prototype.setDeterminedMinMax=function(t,e){this[Nx[t]]=e},t.prototype.freeze=function(){this.frozen=!0},t}(),Nx={min:"_determinedMin",max:"_determinedMax"},Rx={min:"_dataMin",max:"_dataMax"};function Hx(t,e){return null==e?null:_n(e)?NaN:t.parse(e)}function Bx(t,e){var n=t.type,i=function(t,e,n){var i=t.rawExtentInfo;return i||(i=new zx(t,e,n),t.rawExtentInfo=i,i)}(t,e,t.getExtent()).calculate();t.setBlank(i.isBlank);var r=i.min,o=i.max,a=e.ecModel;if(a&&"time"===n){var s=function(t,e){var n=[];return e.eachSeriesByType(t,function(t){(function(t){return t.coordinateSystem&&"cartesian2d"===t.coordinateSystem.type})(t)&&n.push(t)}),n}("bar",a),l=!1;if(en(s,function(t){l=l||t.getBaseAxis()===e.axis}),l){var c=yx(s),u=function(t,e,n,i){var r=n.axis.getExtent(),o=Math.abs(r[1]-r[0]),a=function(t,e){if(t&&e)return t[mx(e)]}(i,n.axis);if(void 0===a)return{min:t,max:e};var s=1/0;en(a,function(t){s=Math.min(t.offset,s)});var l=-1/0;en(a,function(t){l=Math.max(t.offset+t.width,l)}),s=Math.abs(s),l=Math.abs(l);var c=s+l,u=e-t,d=u/(1-(s+l)/o)-u;return e+=d*(l/c),t-=d*(s/c),{min:t,max:e}}(r,o,e,c);r=u.min,o=u.max}}return{extent:[r,o],fixMin:i.minFixed,fixMax:i.maxFixed}}function Fx(t,e){var n=e,i=Bx(t,n),r=i.extent,o=n.get("splitNumber");t instanceof Ex&&(t.base=n.get("logBase"));var a=t.type,s=n.get("interval"),l="interval"===a||"time"===a;t.setBreaksFromOption(Xx(n)),t.setExtent(r[0],r[1]),t.calcNiceExtent({splitNumber:o,fixMin:i.fixMin,fixMax:i.fixMax,minInterval:l?n.get("minInterval"):null,maxInterval:l?n.get("maxInterval"):null}),null!=s&&t.setInterval&&t.setInterval(s)}function $x(t){var e=t.getLabelModel().get("formatter");if("time"===t.type){var n=$p(e);return function(e,i){return t.scale.getFormattedLabel(e,i,n)}}if(dn(e))return function(n){var i=t.scale.getLabel(n);return e.replace("{value}",null!=i?i:"")};if(un(e)){if("category"===t.type)return function(n,i){return e(Vx(t,n),n.value-t.scale.getExtent()[0],null)};var i=null;return function(n,r){var o=null;return i&&(o=i.makeAxisLabelFormatterParamBreak(o,n.break)),e(Vx(t,n),r,o)}}return function(e){return t.scale.getLabel(e)}}function Vx(t,e){return"category"===t.type?t.scale.getLabel(e):e.value}function Wx(t){var e=t.get("interval");return null==e?"auto":e}function Ux(t){return"category"===t.type&&0===Wx(t.getLabelModel())}function Gx(t,e){var n={};return en(t.mapDimensionsAll(e),function(e){n[function(t,e){return Xb(t,e)?t.getCalculationInfo("stackResultDimension"):e}(t,e)]=!0}),an(n)}function qx(t){return"middle"===t||"center"===t}function jx(t){return t.getShallow("show")}function Xx(t){t.get("breaks",!0)}var Yx=function(){function t(){}return t.prototype.getNeedCrossZero=function(){return!this.option.scale},t.prototype.getCoordSysModel=function(){},t}(),Zx=[],Kx={registerPreprocessor:ab,registerProcessor:sb,registerPostInit:function(t){lb("afterinit",t)},registerPostUpdate:function(t){lb("afterupdate",t)},registerUpdateLifecycle:lb,registerAction:cb,registerCoordinateSystem:function(t,e){_f.register(t,e)},registerLayout:function(t,e){hb(K_,t,e,1e3,"layout")},registerVisual:ub,registerTransform:fb,registerLoading:pb,registerMap:function(t,e,n){var i=c_["registerMap"];i&&i(t,e,n)},registerImpl:function(t,e){c_[t]=e},PRIORITY:h_,ComponentModel:Hf,ComponentView:wm,SeriesModel:fm,ChartView:km,registerComponentModel:function(t){Hf.registerClass(t)},registerComponentView:function(t){wm.registerClass(t)},registerSeriesModel:function(t){fm.registerClass(t)},registerChartView:function(t){km.registerClass(t)},registerCustomSeries:function(t,e){},registerSubTypeDefaulter:function(t,e){Hf.registerSubTypeDefaulter(t,e)},registerPainter:function(t,e){var n;n=e,Ga[t]=n}};function Qx(t){cn(t)?en(t,function(t){Qx(t)}):Qe(Zx,t)>=0||(Zx.push(t),un(t)&&(t={install:t}),t.install(Kx))}var Jx=Es(),tw=Es(),ew=1,nw=2;function iw(t){return{out:{noPxChangeTryDetermine:[]},kind:t}}function rw(t,e){var n=nn(e,function(e){return t.scale.parse(e)});return"time"===t.type&&n.length>0&&(n.sort(),n.unshift(n[0]),n.push(n[n.length-1])),n}function ow(t,e){var n=t.getLabelModel().get("customValues");if(n){var i=$x(t),r=t.scale.getExtent();return{labels:nn(on(rw(t,n),function(t){return t>=r[0]&&t<=r[1]}),function(e){var n={value:e};return{formattedLabel:i(n),rawLabel:t.scale.getLabel(n),tickValue:e,time:void 0,break:void 0}})}}return"category"===t.type?function(t,e){var n=t.getLabelModel(),i=sw(t,n,e);return!n.get("show")||t.scale.isBlank()?{labels:[]}:i}(t,e):function(t){var e=t.scale.getTicks(),n=$x(t);return{labels:nn(e,function(e,i){return{formattedLabel:n(e,i),rawLabel:t.scale.getLabel(e),tickValue:e.value,time:e.time,break:e.break}})}}(t)}function aw(t,e,n){var i=t.getTickModel().get("customValues");if(i){var r=t.scale.getExtent();return{ticks:on(rw(t,i),function(t){return t>=r[0]&&t<=r[1]})}}return"category"===t.type?function(t,e){var n,i,r=lw(t),o=Wx(e),a=dw(r,o);if(a)return a;e.get("show")&&!t.scale.isBlank()||(n=[]);if(un(o))n=vw(t,o,!0);else if("auto"===o){var s=sw(t,t.getLabelModel(),iw(nw));i=s.labelCategoryInterval,n=nn(s.labels,function(t){return t.tickValue})}else n=gw(t,i=o,!0);return hw(r,o,{ticks:n,tickCategoryInterval:i})}(t,e):{ticks:nn(t.scale.getTicks(n),function(t){return t.value})}}function sw(t,e,n){var i,r,o=cw(t),a=Wx(e),s=n.kind===ew;if(!s){var l=dw(o,a);if(l)return l}un(a)?i=vw(t,a):(r="auto"===a?function(t,e){if(e.kind===ew){var n=t.calculateCategoryInterval(e);return e.out.noPxChangeTryDetermine.push(function(){return tw(t).autoInterval=n,!0}),n}var i=tw(t).autoInterval;return null!=i?i:tw(t).autoInterval=t.calculateCategoryInterval(e)}(t,n):a,i=gw(t,r));var c={labels:i,labelCategoryInterval:r};return s?n.out.noPxChangeTryDetermine.push(function(){return hw(o,a,c),!0}):hw(o,a,c),c}var lw=uw("axisTick"),cw=uw("axisLabel");function uw(t){return function(e){return tw(e)[t]||(tw(e)[t]={list:[]})}}function dw(t,e){for(var n=0;ne&&i.axisExtent0===r[0]&&i.axisExtent1===r[1])return o;i.lastTickCount=n,i.lastAutoInterval=e,i.axisExtent0=r[0],i.axisExtent1=r[1]}function gw(t,e,n){var i=$x(t),r=t.scale,o=r.getExtent(),a=t.getLabelModel(),s=[],l=Math.max((e||0)+1,1),c=o[0],u=r.count();0!==c&&l>1&&u/l>2&&(c=Math.round(Math.ceil(c/l)*l));var d=Ux(t),h=a.get("showMinLabel")||d,p=a.get("showMaxLabel")||d;h&&c!==o[0]&&g(o[0]);for(var f=c;f<=o[1];f+=l)g(f);function g(t){var e={value:t};s.push(n?t:{formattedLabel:i(e),rawLabel:r.getLabel(e),tickValue:t,time:void 0,break:void 0})}return p&&f-l!==o[1]&&g(o[1]),s}function vw(t,e,n){var i=t.scale,r=$x(t),o=[];return en(i.getTicks(),function(t){var a=i.getLabel(t),s=t.value;e(t.value,a)&&o.push(n?s:{formattedLabel:r(t),rawLabel:a,tickValue:s,time:void 0,break:void 0})}),o}var mw=[0,1],yw=function(){function t(t,e,n){this.onBand=!1,this.inverse=!1,this.dim=t,this.scale=e,this._extent=n||[0,0]}return t.prototype.contain=function(t){var e=this._extent,n=Math.min(e[0],e[1]),i=Math.max(e[0],e[1]);return t>=n&&t<=i},t.prototype.containData=function(t){return this.scale.contain(this.scale.parse(t))},t.prototype.getExtent=function(){return this._extent.slice()},t.prototype.getPixelPrecision=function(t){return function(t,e){var n=Math.log,i=Math.LN10,r=Math.floor(n(t[1]-t[0])/i),o=Math.round(n(Ja(e[1]-e[0]))/i),a=Math.min(Math.max(-r+o,0),20);return isFinite(a)?a:20}(t||this.scale.getExtent(),this._extent)},t.prototype.setExtent=function(t,e){var n=this._extent;n[0]=t,n[1]=e},t.prototype.dataToCoord=function(t,e){var n=this._extent,i=this.scale;return t=i.normalize(i.parse(t)),this.onBand&&"ordinal"===i.type&&_w(n=n.slice(),i.count()),ts(t,mw,n,e)},t.prototype.coordToData=function(t,e){var n=this._extent,i=this.scale;this.onBand&&"ordinal"===i.type&&_w(n=n.slice(),i.count());var r=ts(t,n,mw,e);return this.scale.scale(r)},t.prototype.pointToData=function(t,e){},t.prototype.getTicksCoords=function(t){var e=(t=t||{}).tickModel||this.getTickModel(),n=nn(aw(this,e,{breakTicks:t.breakTicks,pruneByBreak:t.pruneByBreak}).ticks,function(t){return{coord:this.dataToCoord("ordinal"===this.scale.type?this.scale.getRawOrdinalNumber(t):t),tickValue:t}},this);return function(t,e,n,i){var r=e.length;if(!t.onBand||n||!r)return;var o,a,s=t.getExtent();if(1===r)e[0].coord=s[0],e[0].onBand=!0,o=e[1]={coord:s[1],tickValue:e[0].tickValue,onBand:!0};else{var l=e[r-1].tickValue-e[0].tickValue,c=(e[r-1].coord-e[0].coord)/l;en(e,function(t){t.coord-=c/2,t.onBand=!0});var u=t.scale.getExtent();a=1+u[1]-e[r-1].tickValue,o={coord:e[r-1].coord+c*a,tickValue:u[1]+1,onBand:!0},e.push(o)}var d=s[0]>s[1];h(e[0].coord,s[0])&&(i?e[0].coord=s[0]:e.shift());i&&h(s[0],e[0].coord)&&e.unshift({coord:s[0],onBand:!0});h(s[1],o.coord)&&(i?o.coord=s[1]:e.pop());i&&h(o.coord,s[1])&&e.push({coord:s[1],onBand:!0});function h(t,e){return t=is(t),e=is(e),d?t>e:t0&&t<100||(t=5),nn(this.scale.getMinorTicks(t),function(t){return nn(t,function(t){return{coord:this.dataToCoord(t),tickValue:t}},this)},this)},t.prototype.getViewLabels=function(t){return ow(this,t=t||iw(nw)).labels},t.prototype.getLabelModel=function(){return this.model.getModel("axisLabel")},t.prototype.getTickModel=function(){return this.model.getModel("axisTick")},t.prototype.getBandWidth=function(){var t=this._extent,e=this.scale.getExtent(),n=e[1]-e[0]+(this.onBand?1:0);0===n&&(n=1);var i=Math.abs(t[1]-t[0]);return Math.abs(i)/n},t.prototype.calculateCategoryInterval=function(t){return function(t,e){var n=e.kind,i=function(t){var e=t.getLabelModel();return{axisRotate:t.getRotate?t.getRotate():t.isHorizontal&&!t.isHorizontal()?90:0,labelRotate:e.get("rotate")||0,font:e.getFont()}}(t),r=$x(t),o=(i.axisRotate-i.labelRotate)/180*Math.PI,a=t.scale,s=a.getExtent(),l=a.count();if(s[1]-s[0]<1)return 0;var c=1;l>40&&(c=Math.max(1,Math.floor(l/40)));for(var u=s[0],d=t.dataToCoord(u+1)-t.dataToCoord(u),h=Math.abs(d*Math.cos(o)),p=Math.abs(d*Math.sin(o)),f=0,g=0;u<=s[1];u+=c){var v,m,y=Ta(r({value:u}),i.font,"center","top");v=1.3*y.width,m=1.3*y.height,f=Math.max(f,v,7),g=Math.max(g,m,7)}var _=f/h,b=g/p;isNaN(_)&&(_=1/0),isNaN(b)&&(b=1/0);var x=Math.max(0,Math.floor(Math.min(_,b)));if(n===ew)return e.out.noPxChangeTryDetermine.push(sn(pw,null,t,x,l)),x;var w=fw(t,x,l);return null!=w?w:x}(this,t=t||iw(nw))},t}();function _w(t,e){var n=(t[1]-t[0])/e/2;t[0]+=n,t[1]-=n}var bw=["label","labelLine","layoutOption","priority","defaultAttr","marginForce","minMarginForce","marginDefault","suggestIgnore"];function xw(t,e,n){n=n||3,e?t.dirty|=n:t.dirty&=~n}function ww(t,e){return e=e||3,null==t.dirty||!!(t.dirty&e)}function Sw(t){if(t)return ww(t)&&function(t,e,n){var i=e.getComputedTransform();t.transform=Uh(t.transform,i);var r=t.localRect=Wh(t.localRect,e.getBoundingRect()),o=e.style,a=o.margin,s=n&&n.marginForce,l=n&&n.minMarginForce,c=n&&n.marginDefault,u=o.__marginType;null==u&&c&&(a=c,u=lp.textMargin);for(var d=0;d<4;d++)Cw[d]=u===lp.minMargin&&l&&null!=l[d]?l[d]:s&&null!=s[d]?s[d]:a?a[d]:0;u===lp.textMargin&&zh(r,Cw,!1,!1);var h=t.rect=Wh(t.rect,r);i&&h.applyTransform(i);u===lp.minMargin&&zh(h,Cw,!1,!1);t.axisAligned=$h(i),(t.label=t.label||{}).ignore=e.ignore,xw(t,!1),xw(t,!0,2)}(t,t.label,t),t}var Cw=[0,0,0,0];function Mw(t,e){for(var n=0;n-1&&(s.style.stroke=s.style.fill,s.style.fill=Bf.color.neutral00,s.style.lineWidth=2),e},e.type="series.line",e.dependencies=["grid","polar"],e.defaultOption={z:3,coordinateSystem:"cartesian2d",legendHoverLink:!0,clip:!0,label:{position:"top"},endLabel:{show:!1,valueAnimation:!0,distance:8},lineStyle:{width:2,type:"solid"},emphasis:{scale:!0},step:!1,smooth:!1,smoothMonotone:null,symbol:"emptyCircle",symbolSize:6,symbolRotate:null,showSymbol:!0,showAllSymbol:"auto",connectNulls:!1,sampling:"none",animationEasing:"linear",progressive:0,hoverLayerThreshold:1/0,universalTransition:{divideShape:"clone"},triggerLineEvent:!1},e}(fm);function Aw(t,e){var n=t.mapDimensionsAll("defaultedLabel"),i=n.length;if(1===i){var r=yv(t,e,n[0]);return null!=r?r+"":null}if(i){for(var o=[],a=0;a0?+h:1;k.scaleX=this._sizeX*T,k.scaleY=this._sizeY*T,this.setSymbolScale(1),Wu(this,l,c,u)},e.prototype.setSymbolScale=function(t){this.scaleX=this.scaleY=t},e.prototype.fadeOut=function(t,e,n){var i=this.childAt(0),r=nu(this).dataIndex,o=n&&n.animation;if(this.silent=i.silent=!0,n&&n.fadeLabel){var a=i.getTextContent();a&&gh(a,{style:{opacity:0}},e,{dataIndex:r,removeOpt:o,cb:function(){i.removeTextContent()}})}else i.removeTextContent();gh(i,{style:{opacity:0},scaleX:0,scaleY:0},e,{dataIndex:r,cb:t,removeOpt:o})},e.getSymbolSize=function(t,e){return cn(n=t.getItemVisual(e,"symbolSize"))||(n=[+n,+n]),[n[0]||0,n[1]||0];var n},e.getSymbolZ2=function(t,e){return t.getItemVisual(e,"z2")},e}(Ua);function Pw(t,e){this.parent.drift(t,e)}function Lw(t,e,n,i){return e&&!isNaN(e[0])&&!isNaN(e[1])&&!(i.isIgnore&&i.isIgnore(n))&&!(i.clipShape&&!i.clipShape.contain(e[0],e[1]))&&"none"!==t.getItemVisual(n,"symbol")}function Ew(t){return null==t||fn(t)||(t={isIgnore:t}),t||{}}function Ow(t){var e=t.hostModel,n=e.getModel("emphasis");return{emphasisItemStyle:n.getModel("itemStyle").getItemStyle(),blurItemStyle:e.getModel(["blur","itemStyle"]).getItemStyle(),selectItemStyle:e.getModel(["select","itemStyle"]).getItemStyle(),focus:n.get("focus"),blurScope:n.get("blurScope"),emphasisDisabled:n.get("disabled"),hoverScale:n.get("scale"),labelStatesModels:Qh(e),cursorStyle:e.get("cursor")}}var zw=function(){function t(t){this.group=new Ua,this._SymbolCtor=t||Iw}return t.prototype.updateData=function(t,e){this._progressiveEls=null,e=Ew(e);var n=this.group,i=t.hostModel,r=this._data,o=this._SymbolCtor,a=e.disableAnimation,s=Ow(t),l={disableAnimation:a},c=e.getSymbolPoint||function(e){return t.getItemLayout(e)};r||n.removeAll(),t.diff(r).add(function(i){var r=c(i);if(Lw(t,r,i,e)){var a=new o(t,i,s,l);a.setPosition(r),t.setItemGraphicEl(i,a),n.add(a)}}).update(function(u,d){var h=r.getItemGraphicEl(d),p=c(u);if(Lw(t,p,u,e)){var f=t.getItemVisual(u,"symbol")||"circle",g=h&&h.getSymbolType&&h.getSymbolType();if(!h||g&&g!==f)n.remove(h),(h=new o(t,u,s,l)).setPosition(p);else{h.updateData(t,u,s,l);var v={x:p[0],y:p[1]};a?h.attr(v):hh(h,v,i)}n.add(h),t.setItemGraphicEl(u,h)}else n.remove(h)}).remove(function(t){var e=r.getItemGraphicEl(t);e&&e.fadeOut(function(){n.remove(e)},i)}).execute(),this._getSymbolPoint=c,this._data=t},t.prototype.updateLayout=function(){var t=this,e=this._data;e&&e.eachItemGraphicEl(function(e,n){var i=t._getSymbolPoint(n);e.setPosition(i),e.markRedraw()})},t.prototype.incrementalPrepareUpdate=function(t){this._seriesScope=Ow(t),this._data=null,this.group.removeAll()},t.prototype.incrementalUpdate=function(t,e,n){function i(t){t.isGroup||(t.incremental=!0,t.ensureState("emphasis").hoverLayer=!0)}this._progressiveEls=[],n=Ew(n);for(var r=t.start;r0?n=i[0]:i[1]<0&&(n=i[1]);return n}(r,n),a=i.dim,s=r.dim,l=e.mapDimension(s),c=e.mapDimension(a),u="x"===s||"radius"===s?1:0,d=nn(t.dimensions,function(t){return e.mapDimension(t)}),h=!1,p=e.getCalculationInfo("stackResultDimension");return Xb(e,d[0])&&(h=!0,d[0]=p),Xb(e,d[1])&&(h=!0,d[1]=p),{dataDimsForPoint:d,valueStart:o,valueAxisDim:s,baseAxisDim:a,stacked:!!h,valueDim:l,baseDim:c,baseDataOffset:u,stackedOverDimension:e.getCalculationInfo("stackedOverDimension")}}function Rw(t,e,n,i){var r=NaN;t.stacked&&(r=n.get(n.getCalculationInfo("stackedOverDimension"),i)),isNaN(r)&&(r=t.valueStart);var o=t.baseDataOffset,a=[];return a[o]=n.get(t.baseDim,i),a[1-o]=r,e.dataToPoint(a)}var Hw=Math.min,Bw=Math.max;function Fw(t,e){return isNaN(t)||isNaN(e)}function $w(t,e,n,i,r,o,a,s,l){for(var c,u,d,h,p,f,g=n,v=0;v=r||g<0)break;if(Fw(m,y)){if(l){g+=o;continue}break}if(g===n)t[o>0?"moveTo":"lineTo"](m,y),d=m,h=y;else{var _=m-c,b=y-u;if(_*_+b*b<.5){g+=o;continue}if(a>0){for(var x=g+o,w=e[2*x],S=e[2*x+1];w===m&&S===y&&v=i||Fw(w,S))p=m,f=y;else{k=w-c,T=S-u;var I=m-c,P=w-m,L=y-u,E=S-y,O=void 0,z=void 0;if("x"===s){var N=k>0?1:-1;p=m-N*(O=Math.abs(I))*a,f=y,D=m+N*(z=Math.abs(P))*a,A=y}else if("y"===s){var R=T>0?1:-1;p=m,f=y-R*(O=Math.abs(L))*a,D=m,A=y+R*(z=Math.abs(E))*a}else O=Math.sqrt(I*I+L*L),p=m-k*a*(1-(M=(z=Math.sqrt(P*P+E*E))/(z+O))),f=y-T*a*(1-M),A=y+T*a*M,D=Hw(D=m+k*a*M,Bw(w,m)),A=Hw(A,Bw(S,y)),D=Bw(D,Hw(w,m)),f=y-(T=(A=Bw(A,Hw(S,y)))-y)*O/z,p=Hw(p=m-(k=D-m)*O/z,Bw(c,m)),f=Hw(f,Bw(u,y)),D=m+(k=m-(p=Bw(p,Hw(c,m))))*z/O,A=y+(T=y-(f=Bw(f,Hw(u,y))))*z/O}t.bezierCurveTo(d,h,p,f,m,y),d=D,h=A}else t.lineTo(m,y)}c=m,u=y,g+=o}return v}var Vw=function(){this.smooth=0,this.smoothConstraint=!0},Ww=function(t){function e(e){var n=t.call(this,e)||this;return n.type="ec-polyline",n}return _(e,t),e.prototype.getDefaultStyle=function(){return{stroke:Bf.color.neutral99,fill:null}},e.prototype.getDefaultShape=function(){return new Vw},e.prototype.buildPath=function(t,e){var n=e.points,i=0,r=n.length/2;if(e.connectNulls){for(;r>0&&Fw(n[2*r-2],n[2*r-1]);r--);for(;i=0){var v=a?(u-i)*g+i:(c-n)*g+n;return a?[t,v]:[v,t]}n=c,i=u;break;case o.C:c=r[l++],u=r[l++],d=r[l++],h=r[l++],p=r[l++],f=r[l++];var m=a?Tr(n,c,d,p,t,s):Tr(i,u,h,f,t,s);if(m>0)for(var y=0;y=0){v=a?Mr(i,u,h,f,_):Mr(n,c,d,p,_);return a?[t,v]:[v,t]}}n=p,i=f}}},e}(Tc),Uw=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return _(e,t),e}(Vw),Gw=function(t){function e(e){var n=t.call(this,e)||this;return n.type="ec-polygon",n}return _(e,t),e.prototype.getDefaultShape=function(){return new Uw},e.prototype.buildPath=function(t,e){var n=e.points,i=e.stackedOnPoints,r=0,o=n.length/2,a=e.smoothMonotone;if(e.connectNulls){for(;o>0&&Fw(n[2*o-2],n[2*o-1]);o--);for(;r=0&&t<=1){var i=t*(e.length-1),r=Math.floor(i),o=Math.ceil(i),a=to(e[r]),s=to(e[o]),l=i-r,c=ro([Wr(Xr(a[0],s[0],l)),Wr(Xr(a[1],s[1],l)),Wr(Xr(a[2],s[2],l)),Ur(Xr(a[3],s[3],l))],"rgba");return n?{color:c,leftIndex:r,rightIndex:o,value:i}:c}}((n-i)/(e.coord-i),[t.color,e.color])}}for(var s=0;se){i?r.push(a(i,l,e)):n&&r.push(a(n,l,0),a(n,l,e));break}n&&(r.push(a(n,l,0)),n=null),r.push(l),i=l}}return r}function Qw(t,e,n){var i=t.get("showAllSymbol"),r="auto"===i;if(!i||r){var o=n.getAxesByScale("ordinal")[0];if(o&&(!r||!function(t,e){var n=t.getExtent(),i=Math.abs(n[1]-n[0])/t.scale.count();isNaN(i)&&(i=0);for(var r=e.count(),o=Math.max(1,Math.round(r/5)),a=0;ai)return!1;return!0}(o,e))){var a=e.mapDimension(o.dim),s={};return en(o.getViewLabels(),function(t){var e=o.scale.getRawOrdinalNumber(t.tickValue);s[e]=1}),function(t){return!s.hasOwnProperty(e.get(a,t))}}}}function Jw(t,e){return isNaN(t)||isNaN(e)}function tS(t,e){return[t[2*e],t[2*e+1]]}function eS(t){if(t.get(["endLabel","show"]))return!0;for(var e=0;e=0;a--){var s=t.getDimensionInfo(i[a].dimension);if("x"===(r=s&&s.coordDim)||"y"===r){o=i[a];break}}if(o){var l=e.getAxis(r),c=nn(o.stops,function(t){return{coord:l.toGlobalCoord(l.dataToCoord(t.value)),color:t.color}}),u=c.length,d=o.outerColors.slice();u&&c[0].coord>c[u-1].coord&&(c.reverse(),d.reverse());var h=Kw(c,"x"===r?n.getWidth():n.getHeight()),p=h.length;if(!p&&u)return c[0].coord<0?d[1]?d[1]:c[u-1].color:d[0]?d[0]:c[0].color;var f=h[0].coord-10,g=h[p-1].coord+10,v=g-f;if(v<.001)return"transparent";en(h,function(t){t.offset=(t.coord-f)/v}),h.push({offset:p?h[p-1].offset:.5,color:d[1]||"transparent"}),h.unshift({offset:p?h[0].offset:.5,color:d[0]||"transparent"});var m=new Kd(0,0,0,0,h,!0);return m[r]=f,m[r+"2"]=g,m}}}(o,i,n)||o.getVisual("style")[o.getVisual("drawType")];if(h&&u.type===i.type&&M===this._step){v&&!p?p=this._newPolygon(l,_):p&&!v&&(f.remove(p),p=this._polygon=null),c||this._initOrUpdateEndLabel(t,i,vf(k));var T=f.getClipPath();if(T)ph(T,{shape:nS(this,i,!1,t).shape},t);else f.setClipPath(nS(this,i,!0,t));b&&d.updateData(o,{isIgnore:w,clipShape:C,disableAnimation:!0,getSymbolPoint:function(t){return[l[2*t],l[2*t+1]]}}),qw(this._stackedOnPoints,_)&&qw(this._points,l)||(g?this._doUpdateAnimation(o,_,i,n,M,m,x):(M&&(_&&(_=Zw(_,l,i,M,x)),l=Zw(l,null,i,M,x)),h.setShape({points:l}),p&&p.setShape({points:l,stackedOnPoints:_})))}else b&&d.updateData(o,{isIgnore:w,clipShape:C,disableAnimation:!0,getSymbolPoint:function(t){return[l[2*t],l[2*t+1]]}}),g&&this._initSymbolLabelAnimation(o,i,C),M&&(_&&(_=Zw(_,l,i,M,x)),l=Zw(l,null,i,M,x)),h=this._newPolyline(l),v?p=this._newPolygon(l,_):p&&(f.remove(p),p=this._polygon=null),c||this._initOrUpdateEndLabel(t,i,vf(k)),f.setClipPath(nS(this,i,!0,t));var D=t.getModel("emphasis"),A=D.get("focus"),I=D.get("blurScope"),P=D.get("disabled");(h.useStyle(Ke(a.getLineStyle(),{fill:"none",stroke:k,lineJoin:"bevel"})),qu(h,t,"lineStyle"),h.style.lineWidth>0&&"bolder"===t.get(["emphasis","lineStyle","width"]))&&(h.getState("emphasis").style.lineWidth=+h.style.lineWidth+1);nu(h).seriesIndex=t.seriesIndex,Wu(h,A,I,P);var L=Yw(t.get("smooth")),E=t.get("smoothMonotone");if(h.setShape({smooth:L,smoothMonotone:E,connectNulls:x}),p){var O=o.getCalculationInfo("stackedOnSeries"),z=0;p.useStyle(Ke(s.getAreaStyle(),{fill:k,opacity:.7,lineJoin:"bevel",decal:o.getVisual("style").decal})),O&&(z=Yw(O.get("smooth"))),p.setShape({smooth:L,stackedOnSmooth:z,smoothMonotone:E,connectNulls:x}),qu(p,t,"areaStyle"),nu(p).seriesIndex=t.seriesIndex,Wu(p,A,I,P)}var N=this._changePolyState;o.eachItemGraphicEl(function(t){t&&(t.onHoverStateChange=N)}),this._polyline.onHoverStateChange=N,this._data=o,this._coordSys=i,this._stackedOnPoints=_,this._points=l,this._step=M,this._valueOrigin=m,t.get("triggerLineEvent")&&(this.packEventData(t,h),p&&this.packEventData(t,p))},e.prototype.packEventData=function(t,e){nu(e).eventData={componentType:"series",componentSubType:"line",componentIndex:t.componentIndex,seriesIndex:t.seriesIndex,seriesName:t.name,seriesType:"line"}},e.prototype.highlight=function(t,e,n,i){var r=t.getData(),o=Ls(r,i);if(this._changePolyState("emphasis"),!(o instanceof Array)&&null!=o&&o>=0){var a=r.getLayout("points"),s=r.getItemGraphicEl(o);if(!s){var l=a[2*o],c=a[2*o+1];if(isNaN(l)||isNaN(c))return;if(this._clipShapeForSymbol&&!this._clipShapeForSymbol.contain(l,c))return;var u=t.get("zlevel")||0,d=t.get("z")||0;(s=new Iw(r,o)).x=l,s.y=c,s.setZ(u,d);var h=s.getSymbolPath().getTextContent();h&&(h.zlevel=u,h.z=d,h.z2=this._polyline.z2+1),s.__temp=!0,r.setItemGraphicEl(o,s),s.stopSymbolAnimation(!0),this.group.add(s)}s.highlight()}else km.prototype.highlight.call(this,t,e,n,i)},e.prototype.downplay=function(t,e,n,i){var r=t.getData(),o=Ls(r,i);if(this._changePolyState("normal"),null!=o&&o>=0){var a=r.getItemGraphicEl(o);a&&(a.__temp?(r.setItemGraphicEl(o,null),this.group.remove(a)):a.downplay())}else km.prototype.downplay.call(this,t,e,n,i)},e.prototype._changePolyState=function(t){var e=this._polygon;Mu(this._polyline,t),e&&Mu(e,t)},e.prototype._newPolyline=function(t){var e=this._polyline;return e&&this._lineGroup.remove(e),e=new Ww({shape:{points:t},segmentIgnoreThreshold:2,z2:10}),this._lineGroup.add(e),this._polyline=e,e},e.prototype._newPolygon=function(t,e){var n=this._polygon;return n&&this._lineGroup.remove(n),n=new Gw({shape:{points:t,stackedOnPoints:e},segmentIgnoreThreshold:2}),this._lineGroup.add(n),this._polygon=n,n},e.prototype._initSymbolLabelAnimation=function(t,e,n){var i,r,o=e.getBaseAxis(),a=o.inverse;"cartesian2d"===e.type?(i=o.isHorizontal(),r=!1):"polar"===e.type&&(i="angle"===o.dim,r=!0);var s=t.hostModel,l=s.get("animationDuration");un(l)&&(l=l(null));var c=s.get("animationDelay")||0,u=un(c)?c(null):c;t.eachItemGraphicEl(function(t,o){var s=t;if(s){var d=[t.x,t.y],h=void 0,p=void 0,f=void 0;if(n)if(r){var g=n,v=e.pointToCoord(d);i?(h=g.startAngle,p=g.endAngle,f=-v[1]/180*Math.PI):(h=g.r0,p=g.r,f=v[0])}else{var m=n;i?(h=m.x,p=m.x+m.width,f=t.x):(h=m.y+m.height,p=m.y,f=t.y)}var y=p===h?0:(f-h)/(p-h);a&&(y=1-y);var _=un(c)?c(o):l*y+u,b=s.getSymbolPath(),x=b.getTextContent();s.attr({scaleX:0,scaleY:0}),s.animateTo({scaleX:1,scaleY:1},{duration:200,setToFinal:!0,delay:_}),x&&x.animateFrom({style:{opacity:0}},{duration:300,delay:_}),b.disableLabelAnimation=!0}})},e.prototype._initOrUpdateEndLabel=function(t,e,n){var i=t.getModel("endLabel");if(eS(t)){var r=t.getData(),o=this._polyline,a=r.getLayout("points");if(!a)return o.removeTextContent(),void(this._endLabel=null);var s=this._endLabel;s||((s=this._endLabel=new Wc({z2:200})).ignoreClip=!0,o.setTextContent(this._endLabel),o.disableLabelAnimation=!0);var l=function(t){for(var e=t.length/2;e>0&&Jw(t[2*e-2],t[2*e-1]);e--);return e-1}(a);l>=0&&(Kh(o,Qh(t,"endLabel"),{inheritColor:n,labelFetcher:t,labelDataIndex:l,defaultText:function(t,e,n){return null!=n?function(t,e){var n=t.mapDimensionsAll("defaultedLabel");if(!cn(e))return e+"";for(var i=[],r=0;r=0&&i.push(e[o])}return i.join(" ")}(r,n):Aw(r,t)},enableTextSetter:!0},function(t,e){var n=e.getBaseAxis(),i=n.isHorizontal(),r=n.inverse,o=i?r?"right":"left":"center",a=i?"middle":r?"top":"bottom";return{normal:{align:t.get("align")||o,verticalAlign:t.get("verticalAlign")||a}}}(i,e)),o.textConfig.position=null)}else this._endLabel&&(this._polyline.removeTextContent(),this._endLabel=null)},e.prototype._endLabelOnDuring=function(t,e,n,i,r,o,a){var s=this._endLabel,l=this._polyline;if(s){t<1&&null==i.originalX&&(i.originalX=s.x,i.originalY=s.y);var c=n.getLayout("points"),u=n.hostModel,d=u.get("connectNulls"),h=o.get("precision"),p=o.get("distance")||0,f=a.getBaseAxis(),g=f.isHorizontal(),v=f.inverse,m=e.shape,y=v?g?m.x:m.y+m.height:g?m.x+m.width:m.y,_=(g?p:0)*(v?-1:1),b=(g?0:-p)*(v?-1:1),x=g?"x":"y",w=function(t,e,n){for(var i,r,o=t.length/2,a="x"===n?0:1,s=0,l=-1,c=0;c=e||i>=e&&r<=e){l=c;break}s=c,i=r}else i=r;return{range:[s,l],t:(e-i)/(r-i)}}(c,y,x),S=w.range,C=S[1]-S[0],M=void 0;if(C>=1){if(C>1&&!d){var k=tS(c,S[0]);s.attr({x:k[0]+_,y:k[1]+b}),r&&(M=u.getRawValue(S[0]))}else{(k=l.getPointOn(y,x))&&s.attr({x:k[0]+_,y:k[1]+b});var T=u.getRawValue(S[0]),D=u.getRawValue(S[1]);r&&(M=function(t,e,n,i,r){var o=null==e||"auto"===e;if(null==i)return i;if(pn(i))return is(f=_s(n||0,i,r),o?Math.max(rs(n||0),rs(i)):e);if(dn(i))return r<1?n:i;for(var a=[],s=n,l=i,c=Math.max(s?s.length:0,l.length),u=0;u0?S[0]:0;k=tS(c,A);r&&(M=u.getRawValue(A)),s.attr({x:k[0]+_,y:k[1]+b})}if(r){var I=sp(s);"function"==typeof I.setLabelText&&I.setLabelText(M)}}},e.prototype._doUpdateAnimation=function(t,e,n,i,r,o,a){var s=this._polyline,l=this._polygon,c=t.hostModel,u=function(t,e,n,i,r,o,a){for(var s=function(t,e){var n=[];return e.diff(t).add(function(t){n.push({cmd:"+",idx:t})}).update(function(t,e){n.push({cmd:"=",idx:e,idx1:t})}).remove(function(t){n.push({cmd:"-",idx:t})}).execute(),n}(t,e),l=[],c=[],u=[],d=[],h=[],p=[],f=[],g=Nw(r,e,a),v=t.getLayout("points")||[],m=e.getLayout("points")||[],y=0;y3e3||l&&Xw(h,f)>3e3)return s.stopAnimation(),s.setShape({points:p}),void(l&&(l.stopAnimation(),l.setShape({points:p,stackedOnPoints:f})));s.shape.__points=u.current,s.shape.points=d;var g={shape:{points:p}};u.current!==d&&(g.shape.__points=u.next),s.stopAnimation(),hh(s,g,c),l&&(l.setShape({points:d,stackedOnPoints:h}),l.stopAnimation(),hh(l,{shape:{stackedOnPoints:f}},c),s.shape.points!==l.shape.points&&(l.shape.points=s.shape.points));for(var v=[],m=u.status,y=0;ye&&(e=t[n]);return isFinite(e)?e:NaN},min:function(t){for(var e=1/0,n=0;ne[1]&&e.reverse(),e},e.prototype.pointToData=function(t,e){return this.coordToData(this.toLocalCoord(t["x"===this.dim?0:1]),e)},e.prototype.setCategorySortInfo=function(t){if("category"!==this.type)return!1;this.model.option.categorySortInfo=t,this.scale.setSortInfo(t)},e}(yw),wS="expandAxisBreak",SS=Math.PI,CS=[[1,2,1,2],[5,3,5,3],[8,3,8,3]],MS=[[0,1,0,1],[0,3,0,3],[0,3,0,3]],kS=Es(),TS=Es(),DS=function(){function t(t){this.recordMap={},this.resolveAxisNameOverlap=t}return t.prototype.ensureRecord=function(t){var e=t.axis.dim,n=t.componentIndex,i=this.recordMap,r=i[e]||(i[e]=[]);return r[n]||(r[n]={ready:{}})},t}();var AS=[1,0,0,1,0,0],IS=new $i(0,0,0,0),PS=function(t,e,n,i,r,o){if(qx(t.nameLocation)){var a=o.stOccupiedRect;a&&LS(function(t,e,n){return t.transform=Uh(t.transform,n),t.localRect=Wh(t.localRect,e),t.rect=Wh(t.rect,e),n&&t.rect.applyTransform(n),t.axisAligned=$h(n),t.obb=void 0,(t.label=t.label||{}).ignore=!1,t}({},a,o.transGroup.transform),i,r)}else ES(o.labelInfoList,o.dirVec,i,r)};function LS(t,e,n){var i=new Ti;Tw(t,e,i,{direction:Math.atan2(n.y,n.x),bidirectional:!1,touchThreshold:.05})&&function(t,e){if(t){t.label.x+=e.x,t.label.y+=e.y,t.label.markRedraw();var n=t.transform;n&&(n[4]+=e.x,n[5]+=e.y);var i=t.rect;i&&(i.x+=e.x,i.y+=e.y);var r=t.obb;r&&r.fromBoundingRect(t.localRect,n)}}(e,i)}function ES(t,e,n,i){for(var r=Ti.dot(i,e)>=0,o=0,a=t.length;o0?"top":"bottom",i="center"):ss(o-SS)?(r=n>0?"bottom":"top",i="center"):(r="middle",i=o>0&&o0?"right":"left":n>0?"left":"right"),{rotation:o,textAlign:i,textVerticalAlign:r}},t.makeAxisEventDataBase=function(t){var e={componentType:t.mainType,componentIndex:t.componentIndex};return e[t.mainType+"Index"]=t.componentIndex,e},t.isLabelSilent=function(t){var e=t.get("tooltip");return t.get("silent")||!(t.get("triggerEvent")||e&&e.show)},t}(),zS=["axisLine","axisTickLabelEstimate","axisTickLabelDetermine","axisName"],NS={axisLine:function(t,e,n,i,r,o,a){var s=i.get(["axisLine","show"]);if("auto"===s&&(s=!0,null!=t.raw.axisLineAutoShow&&(s=!!t.raw.axisLineAutoShow)),s){var l=i.axis.getExtent(),c=o.transform,u=[l[0],0],d=[l[1],0],h=u[0]>d[0];c&&(jn(u,u,c),jn(d,d,c));var p=Ze({lineCap:"round"},i.getModel(["axisLine","lineStyle"]).getLineStyle()),f={strokeContainThreshold:t.raw.strokeContainThreshold||5,silent:!0,z2:1,style:p};if(i.get(["axisLine","breakLine"])&&i.axis.scale.hasBreaks())null.buildAxisBreakLine(i,r,o,f);else{var g=new Vd(Ze({shape:{x1:u[0],y1:u[1],x2:d[0],y2:d[1]}},f));Th(g.shape,g.style.lineWidth),g.anid="line",r.add(g)}var v=i.get(["axisLine","symbol"]);if(null!=v){var m=i.get(["axisLine","symbolSize"]);dn(v)&&(v=[v,v]),(dn(m)||pn(m))&&(m=[m,m]);var y=Oy(i.get(["axisLine","symbolOffset"])||0,m),_=m[0],b=m[1];en([{rotate:t.rotation+Math.PI/2,offset:y[0],r:0},{rotate:t.rotation-Math.PI/2,offset:y[1],r:Math.sqrt((u[0]-d[0])*(u[0]-d[0])+(u[1]-d[1])*(u[1]-d[1]))}],function(e,n){if("none"!==v[n]&&null!=v[n]){var i=Ey(v[n],-_/2,-b/2,_,b,p.stroke,!0),o=e.r+e.offset,a=h?d:u;i.attr({rotation:e.rotate,x:a[0]+o*Math.cos(t.rotation),y:a[1]-o*Math.sin(t.rotation),silent:!0,z2:11}),r.add(i)}})}}},axisTickLabelEstimate:function(t,e,n,i,r,o,a,s){FS(e,r,s)&&RS(t,e,n,i,r,o,a,ew)},axisTickLabelDetermine:function(t,e,n,i,r,o,a,s){FS(e,r,s)&&RS(t,e,n,i,r,o,a,nw);var l=function(t,e,n,i){var r=i.axis,o=i.getModel("axisTick"),a=o.get("show");"auto"===a&&(a=!0,null!=t.raw.axisTickAutoShow&&(a=!!t.raw.axisTickAutoShow));if(!a||r.scale.isBlank())return[];for(var s=o.getModel("lineStyle"),l=t.tickDirection*o.get("length"),c=BS(r.getTicksCoords(),n.transform,l,Ke(s.getLineStyle(),{stroke:i.get(["axisLine","lineStyle","color"])}),"ticks"),u=0;ui[1],l="start"===e&&!s||"start"!==e&&s;ss(a-SS/2)?(o=l?"bottom":"top",r="center"):ss(a-1.5*SS)?(o=l?"top":"bottom",r="center"):(o="middle",r=a<1.5*SS&&a>SS/2?l?"left":"right":l?"right":"left");return{rotation:a,textAlign:r,textVerticalAlign:o}}(t.rotation,u,x||0,f),null!=(b=t.raw.axisNameAvailableWidth)&&(b=Math.abs(b/Math.sin(_.rotation)),!isFinite(b)&&(b=null)));var w=h.getFont(),S=i.get("nameTruncate",!0)||{},C=S.ellipsis,M=bn(t.raw.nameTruncateMaxWidth,S.maxWidth,b),k=s.nameMarginLevel||0,T=new Wc({x:v.x,y:v.y,rotation:_.rotation,silent:OS.isLabelSilent(i),style:Jh(h,{text:c,font:w,overflow:"truncate",width:M,ellipsis:C,fill:h.getTextColor()||i.get(["axisLine","lineStyle","color"]),align:h.get("align")||_.textAlign,verticalAlign:h.get("verticalAlign")||_.textVerticalAlign}),z2:1});if(Hh({el:T,componentModel:i,itemName:c}),T.__fullText=c,T.anid="name",i.get("triggerEvent")){var D=OS.makeAxisEventDataBase(i);D.targetType="axisName",D.name=c,nu(T).eventData=D}o.add(T),T.updateTransform(),e.nameEl=T;var A=l.nameLayout=Sw({label:T,priority:T.z2,defaultAttr:{ignore:T.ignore},marginDefault:qx(u)?CS[k]:MS[k]});if(l.nameLocation=u,r.add(T),T.decomposeTransform(),t.shouldNameMoveOverlap&&A){var I=n.ensureRecord(i);n.resolveAxisNameOverlap(t,n,i,A,m,I)}}}};function RS(t,e,n,i,r,o,a,s){$S(e)||function(t,e,n,i,r,o){var a=r.axis,s=bn(t.raw.axisLabelShow,r.get(["axisLabel","show"])),l=new Ua;n.add(l);var c=iw(i);if(!s||a.scale.isBlank())return void VS(e,[],l,c);var u=r.getModel("axisLabel"),d=a.getViewLabels(c),h=(bn(t.raw.labelRotate,u.get("rotate"))||0)*SS/180,p=OS.innerTextLayout(t.rotation,h,t.labelDirection),f=r.getCategories&&r.getCategories(!0),g=[],v=r.get("triggerEvent"),m=1/0,y=-1/0;en(d,function(t,e){var n,i="ordinal"===a.scale.type?a.scale.getRawOrdinalNumber(t.tickValue):t.tickValue,s=t.formattedLabel,c=t.rawLabel,h=u;if(f&&f[i]){var _=f[i];fn(_)&&_.textStyle&&(h=new _p(_.textStyle,u,r.ecModel))}var b=h.getTextColor()||r.get(["axisLine","lineStyle","color"]),x=h.getShallow("align",!0)||p.textAlign,w=xn(h.getShallow("alignMinLabel",!0),x),S=xn(h.getShallow("alignMaxLabel",!0),x),C=h.getShallow("verticalAlign",!0)||h.getShallow("baseline",!0)||p.textVerticalAlign,M=xn(h.getShallow("verticalAlignMinLabel",!0),C),k=xn(h.getShallow("verticalAlignMaxLabel",!0),C),T=10+((null===(n=t.time)||void 0===n?void 0:n.level)||0);m=Math.min(m,T),y=Math.max(y,T);var D=new Wc({x:0,y:0,rotation:0,silent:OS.isLabelSilent(r),z2:T,style:Jh(h,{text:s,align:0===e?w:e===d.length-1?S:x,verticalAlign:0===e?M:e===d.length-1?k:C,fill:un(b)?b("category"===a.type?c:"value"===a.type?i+"":i,e):b})});D.anid="label_"+i;var A=kS(D);if(A.break=t.break,A.tickValue=i,A.layoutRotation=p.rotation,Hh({el:D,componentModel:r,itemName:s,formatterParamsExtra:{isTruncated:function(){return D.isTruncated},value:c,tickIndex:e}}),v){var I=OS.makeAxisEventDataBase(r);I.targetType="axisLabel",I.value=c,I.tickIndex=e,t.break&&(I.break={start:t.break.parsedBreak.vmin,end:t.break.parsedBreak.vmax}),"category"===a.type&&(I.dataIndex=i),nu(D).eventData=I,t.break&&function(t,e,n,i){n.on("click",function(n){var r={type:wS,breaks:[{start:i.parsedBreak.breakOption.start,end:i.parsedBreak.breakOption.end}]};r[t.axis.dim+"AxisIndex"]=t.componentIndex,e.dispatchAction(r)})}(r,o,D,t.break)}g.push(D),l.add(D)});var _=nn(g,function(t){return{label:t,priority:kS(t).break?t.z2+(y-m+1):t.z2,defaultAttr:{ignore:t.ignore}}});VS(e,_,l,c)}(t,e,r,s,i,a);var l=e.labelLayoutList;!function(t,e,n,i){var r=e.get(["axisLabel","margin"]);en(n,function(n,o){var a=Sw(n);if(a){var s=a.label,l=kS(s);a.suggestIgnore=s.ignore,s.ignore=!1,ba(WS,US),WS.x=e.axis.dataToCoord(l.tickValue),WS.y=t.labelOffset+t.labelDirection*r,WS.rotation=l.layoutRotation,i.add(WS),WS.updateTransform(),i.remove(WS),WS.decomposeTransform(),ba(s,WS),s.markRedraw(),xw(a,!0),Sw(a)}})}(t,i,l,o),t.rotation;var c=t.optionHideOverlap;!function(t,e,n){if(Ux(t.axis))return;function i(t,i,r){var o=Sw(e[i]),a=Sw(e[r]);if(o&&a)if(!1===t||o.suggestIgnore)HS(o.label);else if(a.suggestIgnore)HS(a.label);else{var s=.1;if(!n){var l=[0,0,0,0];o=Mw({marginForce:l},o),a=Mw({marginForce:l},a)}Tw(o,a,null,{touchThreshold:s})&&HS(t?a.label:o.label)}}var r=t.get(["axisLabel","showMinLabel"]),o=t.get(["axisLabel","showMaxLabel"]),a=e.length;i(r,0,1),i(o,a-1,a-2)}(i,l,c),c&&function(t){var e=[];function n(t){if(!t.ignore){var e=t.ensureState("emphasis");null==e.ignore&&(e.ignore=!1)}t.ignore=!0}t.sort(function(t,e){return(e.suggestIgnore?1:0)-(t.suggestIgnore?1:0)||e.priority-t.priority});for(var i=0;i.1?"x":"y",u=a.transGroup[c];if(s.sort(function(t,e){return Math.abs(t.label[c]-u)-Math.abs(e.label[c]-u)}),l&&r){var d=o.getExtent(),h=Math.min(d[0],d[1]),p=Math.max(d[0],d[1])-h;r.union(new $i(h,0,p,1))}a.stOccupiedRect=r,a.labelInfoList=s}(t,n,i,l)}function HS(t){t&&(t.ignore=!0)}function BS(t,e,n,i,r){for(var o=[],a=[],s=[],l=0;l=0;o--){var a=t[+n[o]],s=a.model,l=a.scale;Zb(l)&&s.get("alignTicks")&&null==s.get("interval")?r.push(a):(Fx(l,s),Zb(l)&&(e=a))}r.length&&(e||Fx((e=r.pop()).scale,e.model),en(r,function(t){!function(t,e,n){var i=hx.prototype,r=i.getTicks.call(n),o=i.getTicks.call(n,{expandToNicedExtent:!0}),a=r.length-1,s=i.getInterval.call(n),l=Bx(t,e),c=l.extent,u=l.fixMin,d=l.fixMax;"log"===t.type&&(c=ox(t.base,c,!0)),t.setBreaksFromOption(Xx(e)),t.setExtent(c[0],c[1]),t.calcNiceExtent({splitNumber:a,fixMin:u,fixMax:d});var h=i.getExtent.call(t);u&&(c[0]=h[0]),d&&(c[1]=h[1]);var p=i.getInterval.call(t),f=c[0],g=c[1];if(u&&d)p=(g-f)/a;else if(u)for(g=c[0]+p*a;gc[0]&&isFinite(f)&&isFinite(c[0]);)p=Qb(p),f=c[1]-p*a;else{t.getTicks().length-1>a&&(p=Qb(p));var v=p*a;(f=is((g=Math.ceil(c[1]/p)*p)-v))<0&&c[0]>=0?(f=0,g=is(v)):g>0&&c[1]<=0&&(g=0,f=-is(v))}var m=(r[0].value-o[0].value)/s,y=(r[a].value-o[a].value)/s;i.setExtent.call(t,f+p*m,g+p*y),i.setInterval.call(t,p),(m||y)&&i.setNiceExtent.call(t,f+p,g-p)}(t.scale,t.model,e.scale)}))}}this._updateScale(t,this.model),i(n.x),i(n.y);var r={};en(n.x,function(t){KS(n,"y",t,r)}),en(n.y,function(t){KS(n,"x",t,r)}),this.resize(this.model,e)},t.prototype.resize=function(t,e,n){var i=Ef(t,e),r=this._rect=Pf(t.getBoxLayoutParams(),i.refContainer),o=this._axesMap,a=this._coordsList,s=t.get("containLabel");if(JS(o,r),!n){var l=function(t,e,n,i,r){var o=new DS(iC);return en(n,function(n){return en(n,function(n){if(jx(n.model)){var a=!i;n.axisBuilder=function(t,e,n,i,r,o){for(var a=qS(t,n),s=!1,l=!1,c=0;c0&&i>0||n<0&&i<0)}(t)}function JS(t,e){en(t.x,function(t){return tC(t,e.x,e.width)}),en(t.y,function(t){return tC(t,e.y,e.height)})}function tC(t,e,n){var i=[0,n],r=t.inverse?1:0;t.setExtent(i[r],i[1-r]),function(t,e){var n=t.getExtent(),i=n[0]+n[1];t.toGlobalCoord="x"===t.dim?function(t){return t+e}:function(t){return i-t+e},t.toLocalCoord="x"===t.dim?function(t){return t-e}:function(t){return i-t+e}}(t,e)}function eC(t,e,n,i,r,o,a){nC(i,r,ew,e,!1,a);var s=[0,0,0,0];c(0),c(1),u(i,0,NaN),u(i,1,NaN);var l=null==function(t,e,n){if(t&&e)for(var i=0,r=t.length;i0});return zh(i,s,!0,!0,n),JS(r,i),l;function c(t){en(r[yh[t]],function(e){if(jx(e.model)){var n=o.ensureRecord(e.model),i=n.labelInfoList;if(i)for(var r=0;r0&&!_n(e)&&e>1e-4&&(t/=e),t}}function nC(t,e,n,i,r,o){var a=n===nw;en(e,function(e){return en(e,function(e){jx(e.model)&&(!function(t,e,n){var i=qS(e,n);t.updateCfg(i)}(e.axisBuilder,t,e.model),e.axisBuilder.build(a?{axisTickLabelDetermine:!0}:{axisTickLabelEstimate:!0},{noPxChange:r}))})});var s={x:0,y:0};function l(e){s[yh[1-e]]=t[_h[e]]<=.5*o.refContainer[_h[e]]?0:1-e==1?2:1}l(0),l(1),en(e,function(t,e){return en(t,function(t){jx(t.model)&&(("all"===i||a)&&t.axisBuilder.build({axisName:!0},{nameMarginLevel:s[e]}),a&&t.axisBuilder.build({axisLine:!0}))})})}var iC=function(t,e,n,i,r,o){var a="x"===n.axis.dim?"y":"x";PS(t,0,0,i,r,o),qx(t.nameLocation)||en(e.recordMap[a],function(t){t&&t.labelInfoList&&t.dirVec&&ES(t.labelInfoList,t.dirVec,i,r)})};function rC(t,e){var n={axesInfo:{},seriesInvolved:!1,coordSysAxesInfo:{},coordSysMap:{}};return function(t,e,n){var i=e.getComponent("tooltip"),r=e.getComponent("axisPointer"),o=r.get("link",!0)||[],a=[];en(n.getCoordinateSystems(),function(n){if(n.axisPointerEnabled){var s=lC(n.model),l=t.coordSysAxesInfo[s]={};t.coordSysMap[s]=n;var c=n.model.getModel("tooltip",i);if(en(n.getAxes(),ln(p,!1,null)),n.getTooltipAxes&&i&&c.get("show")){var u="axis"===c.get("trigger"),d="cross"===c.get(["axisPointer","type"]),h=n.getTooltipAxes(c.get(["axisPointer","axis"]));(u||d)&&en(h.baseAxes,ln(p,!d||"cross",u)),d&&en(h.otherAxes,ln(p,"cross",!1))}}function p(i,s,u){var d=u.model.getModel("axisPointer",r),h=d.get("show");if(h&&("auto"!==h||i||sC(d))){null==s&&(s=d.get("triggerTooltip")),d=i?function(t,e,n,i,r,o){var a=e.getModel("axisPointer"),s={};en(["type","snap","lineStyle","shadowStyle","label","animation","animationDurationUpdate","animationEasingUpdate","z"],function(t){s[t]=Xe(a.get(t))}),s.snap="category"!==t.type&&!!o,"cross"===a.get("type")&&(s.type="line");var l=s.label||(s.label={});if(null==l.show&&(l.show=!1),"cross"===r){var c=a.get(["label","show"]);if(l.show=null==c||c,!o){var u=s.lineStyle=a.get("crossStyle");u&&Ke(l,u.textStyle)}}return t.model.getModel("axisPointer",new _p(s,n,i))}(u,c,r,e,i,s):d;var p=d.get("snap"),f=d.get("triggerEmphasis"),g=lC(u.model),v=s||p||"category"===u.type,m=t.axesInfo[g]={key:g,axis:u,coordSys:n,axisPointerModel:d,triggerTooltip:s,triggerEmphasis:f,involveSeries:v,snap:p,useHandle:sC(d),seriesModels:[],linkGroup:null};l[g]=m,t.seriesInvolved=t.seriesInvolved||v;var y=function(t,e){for(var n=e.model,i=e.dim,r=0;r=0||t===e}function aC(t){var e=(t.ecModel.getComponent("axisPointer")||{}).coordSysAxesInfo;return e&&e.axesInfo[lC(t)]}function sC(t){return!!t.get(["handle","show"])}function lC(t){return t.type+"||"+t.id}var cC={},uC=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return _(e,t),e.prototype.render=function(e,n,i,r){this.axisPointerClass&&function(t){var e=aC(t);if(e){var n=e.axisPointerModel,i=e.axis.scale,r=n.option,o=n.get("status"),a=n.get("value");null!=a&&(a=i.parse(a));var s=sC(n);null==o&&(r.status=s?"show":"hide");var l=i.getExtent().slice();l[0]>l[1]&&l.reverse(),(null==a||a>l[1])&&(a=l[1]),aa)return!0;if(o){var s=aC(t).seriesDataCount,l=i.getExtent();return Math.abs(l[0]-l[1])/s>a}return!1}return!0===n},t.prototype.makeElOption=function(t,e,n,i,r){},t.prototype.createPointerEl=function(t,e,n,i){var r=e.pointer;if(r){var o=bC(t).pointerEl=new Xh[r.type](xC(e.pointer));t.add(o)}},t.prototype.createLabelEl=function(t,e,n,i){if(e.label){var r=bC(t).labelEl=new Wc(xC(e.label));t.add(r),kC(r,i)}},t.prototype.updatePointerEl=function(t,e,n){var i=bC(t).pointerEl;i&&e.pointer&&(i.setStyle(e.pointer.style),n(i,{shape:e.pointer.shape}))},t.prototype.updateLabelEl=function(t,e,n,i){var r=bC(t).labelEl;r&&(r.setStyle(e.label.style),n(r,{x:e.label.x,y:e.label.y}),kC(r,i))},t.prototype._renderHandle=function(t){if(!this._dragging&&this.updateHandleTransform){var e,n=this._axisPointerModel,i=this._api.getZr(),r=this._handle,o=n.getModel("handle"),a=n.get("status");if(!o.get("show")||!a||"hide"===a)return r&&i.remove(r),void(this._handle=null);this._handle||(e=!0,r=this._handle=Lh(o.get("icon"),{cursor:"move",draggable:!0,onmousemove:function(t){mi(t.event)},onmousedown:wC(this._onHandleDragMove,this,0,0),drift:wC(this._onHandleDragMove,this),ondragend:wC(this._onHandleDragEnd,this)}),i.add(r)),DC(r,n,!1),r.setStyle(o.getItemStyle(null,["color","borderColor","borderWidth","opacity","shadowColor","shadowBlur","shadowOffsetX","shadowOffsetY"]));var s=o.get("size");cn(s)||(s=[s,s]),r.scaleX=s[0]/2,r.scaleY=s[1]/2,Nm(this,"_doDispatchAxisPointer",o.get("throttle")||0,"fixRate"),this._moveHandleToValue(t,e)}},t.prototype._moveHandleToValue=function(t,e){CC(this._axisPointerModel,!e&&this._moveAnimation,this._handle,TC(this.getHandleTransform(t,this._axisModel,this._axisPointerModel)))},t.prototype._onHandleDragMove=function(t,e){var n=this._handle;if(n){this._dragging=!0;var i=this.updateHandleTransform(TC(n),[t,e],this._axisModel,this._axisPointerModel);this._payloadInfo=i,n.stopAnimation(),n.attr(TC(i)),bC(n).lastProp=null,this._doDispatchAxisPointer()}},t.prototype._doDispatchAxisPointer=function(){if(this._handle){var t=this._payloadInfo,e=this._axisModel;this._api.dispatchAction({type:"updateAxisPointer",x:t.cursorPoint[0],y:t.cursorPoint[1],tooltipOption:t.tooltipOption,axesInfo:[{axisDim:e.axis.dim,axisIndex:e.componentIndex}]})}},t.prototype._onHandleDragEnd=function(){if(this._dragging=!1,this._handle){var t=this._axisPointerModel.get("value");this._moveHandleToValue(t),this._api.dispatchAction({type:"hideTip"})}},t.prototype.clear=function(t){this._lastValue=null,this._lastStatus=null;var e=t.getZr(),n=this._group,i=this._handle;e&&n&&(this._lastGraphicKey=null,n&&e.remove(n),i&&e.remove(i),this._group=null,this._handle=null,this._payloadInfo=null),Rm(this,"_doDispatchAxisPointer")},t.prototype.doClear=function(){},t.prototype.buildLabel=function(t,e,n){return{x:t[n=n||0],y:t[1-n],width:e[n],height:e[1-n]}},t}();function CC(t,e,n,i){MC(bC(n).lastProp,i)||(bC(n).lastProp=i,e?hh(n,i,t):(n.stopAnimation(),n.attr(i)))}function MC(t,e){if(fn(t)&&fn(e)){var n=!0;return en(e,function(e,i){n=n&&MC(t[i],e)}),!!n}return t===e}function kC(t,e){t[e.get(["label","show"])?"show":"hide"]()}function TC(t){return{x:t.x||0,y:t.y||0,rotation:t.rotation||0}}function DC(t,e,n){var i=e.get("z"),r=e.get("zlevel");t&&t.traverse(function(t){"group"!==t.type&&(null!=i&&(t.z=i),null!=r&&(t.zlevel=r),t.silent=n)})}function AC(t,e,n,i,r){var o=IC(n.get("value"),e.axis,e.ecModel,n.get("seriesDataIndices"),{precision:n.get(["label","precision"]),formatter:n.get(["label","formatter"])}),a=n.getModel("label"),s=df(a.get("padding")||0),l=a.getFont(),c=Ta(o,l),u=r.position,d=c.width+s[1]+s[3],h=c.height+s[0]+s[2],p=r.align;"right"===p&&(u[0]-=d),"center"===p&&(u[0]-=d/2);var f=r.verticalAlign;"bottom"===f&&(u[1]-=h),"middle"===f&&(u[1]-=h/2),function(t,e,n,i){var r=i.getWidth(),o=i.getHeight();t[0]=Math.min(t[0]+e,r)-e,t[1]=Math.min(t[1]+n,o)-n,t[0]=Math.max(t[0],0),t[1]=Math.max(t[1],0)}(u,d,h,i);var g=a.get("backgroundColor");g&&"auto"!==g||(g=e.get(["axisLine","lineStyle","color"])),t.label={x:u[0],y:u[1],style:Jh(a,{text:o,font:l,fill:a.getTextColor(),padding:s,backgroundColor:g}),z2:10}}function IC(t,e,n,i,r){t=e.scale.parse(t);var o=e.scale.getLabel({value:t},{precision:r.precision}),a=r.formatter;if(a){var s={value:Vx(e,{value:t}),axisDimension:e.dim,axisIndex:e.index,seriesData:[]};en(i,function(t){var e=n.getSeriesByIndex(t.seriesIndex),i=t.dataIndexInside,r=e&&e.getDataParams(i);r&&s.seriesData.push(r)}),dn(a)?o=a.replace("{value}",o):un(a)&&(o=a(s))}return o}function PC(t,e,n){var i=[1,0,0,1,0,0];return Mi(i,i,n.rotation),Ci(i,i,n.position),Ah([t.dataToCoord(e),(n.labelOffset||0)+(n.labelDirection||1)*(n.labelMargin||0)],i)}var LC=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return _(e,t),e.prototype.makeElOption=function(t,e,n,i,r){var o=n.axis,a=o.grid,s=i.get("type"),l=EC(a,o).getOtherAxis(o).getGlobalExtent(),c=o.toGlobalCoord(o.dataToCoord(e,!0));if(s&&"none"!==s){var u=function(t){var e,n=t.get("type"),i=t.getModel(n+"Style");return"line"===n?(e=i.getLineStyle()).fill=null:"shadow"===n&&((e=i.getAreaStyle()).stroke=null),e}(i),d=OC[s](o,c,l);d.style=u,t.graphicKey=d.type,t.pointer=d}!function(t,e,n,i,r,o){var a=OS.innerTextLayout(n.rotation,0,n.labelDirection);n.labelMargin=r.get(["label","margin"]),AC(e,i,r,o,{position:PC(i.axis,t,n),align:a.textAlign,verticalAlign:a.textVerticalAlign})}(e,t,qS(a.getRect(),n),n,i,r)},e.prototype.getHandleTransform=function(t,e,n){var i=qS(e.axis.grid.getRect(),e,{labelInside:!1});i.labelMargin=n.get(["handle","margin"]);var r=PC(e.axis,t,i);return{x:r[0],y:r[1],rotation:i.rotation+(i.labelDirection<0?Math.PI:0)}},e.prototype.updateHandleTransform=function(t,e,n,i){var r=n.axis,o=r.grid,a=r.getGlobalExtent(!0),s=EC(o,r).getOtherAxis(r).getGlobalExtent(),l="x"===r.dim?0:1,c=[t.x,t.y];c[l]+=e[l],c[l]=Math.min(a[1],c[l]),c[l]=Math.max(a[0],c[l]);var u=(s[1]+s[0])/2,d=[u,u];d[l]=c[l];return{x:c[0],y:c[1],rotation:t.rotation,cursorPoint:d,tooltipOption:[{verticalAlign:"middle"},{align:"center"}][l]}},e}(SC);function EC(t,e){var n={};return n[e.dim+"AxisIndex"]=e.index,t.getCartesian(n)}var OC={line:function(t,e,n){var i,r,o;return{type:"Line",subPixelOptimize:!0,shape:(i=[e,n[0]],r=[e,n[1]],o=zC(t),{x1:i[o=o||0],y1:i[1-o],x2:r[o],y2:r[1-o]})}},shadow:function(t,e,n){var i,r,o,a=Math.max(1,t.getBandWidth()),s=n[1]-n[0];return{type:"Rect",shape:(i=[e-a/2,n[0]],r=[a,s],o=zC(t),{x:i[o=o||0],y:i[1-o],width:r[o],height:r[1-o]})}}};function zC(t){return"x"===t.dim?0:1}var NC=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return _(e,t),e.type="axisPointer",e.defaultOption={show:"auto",z:50,type:"line",snap:!1,triggerTooltip:!0,triggerEmphasis:!0,value:null,status:null,link:[],animation:null,animationDurationUpdate:200,lineStyle:{color:Bf.color.border,width:1,type:"dashed"},shadowStyle:{color:Bf.color.shadowTint},label:{show:!0,formatter:null,precision:"auto",margin:3,color:Bf.color.neutral00,padding:[5,7,5,7],backgroundColor:Bf.color.accent60,borderColor:null,borderWidth:0,borderRadius:3},handle:{show:!1,icon:"M10.7,11.9v-1.3H9.3v1.3c-4.9,0.3-8.8,4.4-8.8,9.4c0,5,3.9,9.1,8.8,9.4h1.3c4.9-0.3,8.8-4.4,8.8-9.4C19.5,16.3,15.6,12.2,10.7,11.9z M13.3,24.4H6.7v-1.2h6.6z M13.3,22H6.7v-1.2h6.6z M13.3,19.6H6.7v-1.2h6.6z",size:45,margin:50,color:Bf.color.accent40,throttle:40}},e}(Hf),RC=Es(),HC=en;function BC(t,e,n){if(!Te.node){var i=e.getZr();RC(i).records||(RC(i).records={}),function(t,e){if(RC(t).initialized)return;function n(n,i){t.on(n,function(n){var r=function(t){var e={showTip:[],hideTip:[]},n=function(i){var r=e[i.type];r?r.push(i):(i.dispatchAction=n,t.dispatchAction(i))};return{dispatchAction:n,pendings:e}}(e);HC(RC(t).records,function(t){t&&i(t,n,r.dispatchAction)}),function(t,e){var n,i=t.showTip.length,r=t.hideTip.length;i?n=t.showTip[i-1]:r&&(n=t.hideTip[r-1]);n&&(n.dispatchAction=null,e.dispatchAction(n))}(r.pendings,e)})}RC(t).initialized=!0,n("click",ln($C,"click")),n("mousemove",ln($C,"mousemove")),n("globalout",FC)}(i,e),(RC(i).records[t]||(RC(i).records[t]={})).handler=n}}function FC(t,e,n){t.handler("leave",null,n)}function $C(t,e,n,i){e.handler(t,n,i)}function VC(t,e){if(!Te.node){var n=e.getZr();(RC(n).records||{})[t]&&(RC(n).records[t]=null)}}var WC=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return _(e,t),e.prototype.render=function(t,e,n){var i=e.getComponent("tooltip"),r=t.get("triggerOn")||i&&i.get("triggerOn")||"mousemove|click";BC("axisPointer",n,function(t,e,n){"none"!==r&&("leave"===t||r.indexOf(t)>=0)&&n({type:"updateAxisPointer",currTrigger:t,x:e&&e.offsetX,y:e&&e.offsetY})})},e.prototype.remove=function(t,e){VC("axisPointer",e)},e.prototype.dispose=function(t,e){VC("axisPointer",e)},e.type="axisPointer",e}(wm);function UC(t,e){var n,i=[],r=t.seriesIndex;if(null==r||!(n=e.getSeriesByIndex(r)))return{point:[]};var o=n.getData(),a=Ls(o,t);if(null==a||a<0||cn(a))return{point:[]};var s=o.getItemGraphicEl(a),l=n.coordinateSystem;if(n.getTooltipPosition)i=n.getTooltipPosition(a)||[];else if(l&&l.dataToPoint)if(t.isStacked){var c=l.getBaseAxis(),u=l.getOtherAxis(c).dim,d=c.dim,h="x"===u||"radius"===u?1:0,p=o.mapDimension(d),f=[];f[h]=o.get(p,a),f[1-h]=o.get(o.getCalculationInfo("stackResultDimension"),a),i=l.dataToPoint(f)||[]}else i=l.dataToPoint(o.getValues(nn(l.dimensions,function(t){return o.mapDimension(t)}),a))||[];else if(s){var g=s.getBoundingRect().clone();g.applyTransform(s.transform),i=[g.x+g.width/2,g.y+g.height/2]}return{point:i,el:s}}var GC=Es();function qC(t,e,n){var i=t.currTrigger,r=[t.x,t.y],o=t,a=t.dispatchAction||sn(n.dispatchAction,n),s=e.getComponent("axisPointer").coordSysAxesInfo;if(s){KC(r)&&(r=UC({seriesIndex:o.seriesIndex,dataIndex:o.dataIndex},e).point);var l=KC(r),c=o.axesInfo,u=s.axesInfo,d="leave"===i||KC(r),h={},p={},f={list:[],map:{}},g={showPointer:ln(XC,p),showTooltip:ln(YC,f)};en(s.coordSysMap,function(t,e){var n=l||t.containPoint(r);en(s.coordSysAxesInfo[e],function(t,e){var i=t.axis,o=function(t,e){for(var n=0;n<(t||[]).length;n++){var i=t[n];if(e.axis.dim===i.axisDim&&e.axis.model.componentIndex===i.axisIndex)return i}}(c,t);if(!d&&n&&(!c||o)){var a=o&&o.value;null!=a||l||(a=i.pointToData(r)),null!=a&&jC(t,a,g,!1,h)}})});var v={};return en(u,function(t,e){var n=t.linkGroup;n&&!p[e]&&en(n.axesInfo,function(e,i){var r=p[i];if(e!==t&&r){var o=r.value;n.mapper&&(o=t.axis.scale.parse(n.mapper(o,ZC(e),ZC(t)))),v[t.key]=o}})}),en(v,function(t,e){jC(u[e],t,g,!0,h)}),function(t,e,n){var i=n.axesInfo=[];en(e,function(e,n){var r=e.axisPointerModel.option,o=t[n];o?(!e.useHandle&&(r.status="show"),r.value=o.value,r.seriesDataIndices=(o.payloadBatch||[]).slice()):!e.useHandle&&(r.status="hide"),"show"===r.status&&i.push({axisDim:e.axis.dim,axisIndex:e.axis.model.componentIndex,value:r.value})})}(p,u,h),function(t,e,n,i){if(KC(e)||!t.list.length)return void i({type:"hideTip"});var r=((t.list[0].dataByAxis[0]||{}).seriesDataIndices||[])[0]||{};i({type:"showTip",escapeConnect:!0,x:e[0],y:e[1],tooltipOption:n.tooltipOption,position:n.position,dataIndexInside:r.dataIndexInside,dataIndex:r.dataIndex,seriesIndex:r.seriesIndex,dataByCoordSys:t.list})}(f,r,t,a),function(t,e,n){var i=n.getZr(),r="axisPointerLastHighlights",o=GC(i)[r]||{},a=GC(i)[r]={};en(t,function(t,e){var n=t.axisPointerModel.option;"show"===n.status&&t.triggerEmphasis&&en(n.seriesDataIndices,function(t){var e=t.seriesIndex+" | "+t.dataIndex;a[e]=t})});var s=[],l=[];en(o,function(t,e){!a[e]&&l.push(t)}),en(a,function(t,e){!o[e]&&s.push(t)}),l.length&&n.dispatchAction({type:"downplay",escapeConnect:!0,notBlur:!0,batch:l}),s.length&&n.dispatchAction({type:"highlight",escapeConnect:!0,notBlur:!0,batch:s})}(u,0,n),h}}function jC(t,e,n,i,r){var o=t.axis;if(!o.scale.isBlank()&&o.containData(e))if(t.involveSeries){var a=function(t,e){var n=e.axis,i=n.dim,r=t,o=[],a=Number.MAX_VALUE,s=-1;return en(e.seriesModels,function(e,l){var c,u,d=e.getData().mapDimensionsAll(i);if(e.getAxisTooltipData){var h=e.getAxisTooltipData(d,t,n);u=h.dataIndices,c=h.nestestValue}else{if(!(u=e.indicesOfNearest(i,d[0],t,"category"===n.type?.5:null)).length)return;c=e.getData().get(d[0],u[0])}if(null!=c&&isFinite(c)){var p=t-c,f=Math.abs(p);f<=a&&((f=0&&s<0)&&(a=f,s=p,r=c,o.length=0),en(u,function(t){o.push({seriesIndex:e.seriesIndex,dataIndexInside:t,dataIndex:e.getData().getRawIndex(t)})}))}}),{payloadBatch:o,snapToValue:r}}(e,t),s=a.payloadBatch,l=a.snapToValue;s[0]&&null==r.seriesIndex&&Ze(r,s[0]),!i&&t.snap&&o.containData(l)&&null!=l&&(e=l),n.showPointer(t,e,s),n.showTooltip(t,a,l)}else n.showPointer(t,e)}function XC(t,e,n,i){t[e.key]={value:n,payloadBatch:i}}function YC(t,e,n,i){var r=n.payloadBatch,o=e.axis,a=o.model,s=e.axisPointerModel;if(e.triggerTooltip&&r.length){var l=e.coordSys.model,c=lC(l),u=t.map[c];u||(u=t.map[c]={coordSysId:l.id,coordSysIndex:l.componentIndex,coordSysType:l.type,coordSysMainType:l.mainType,dataByAxis:[]},t.list.push(u)),u.dataByAxis.push({axisDim:o.dim,axisIndex:a.componentIndex,axisType:a.type,axisId:a.id,value:i,valueLabelOpt:{precision:s.get(["label","precision"]),formatter:s.get(["label","formatter"])},seriesDataIndices:r.slice()})}}function ZC(t){var e=t.axis.model,n={},i=n.axisDim=t.axis.dim;return n.axisIndex=n[i+"AxisIndex"]=e.componentIndex,n.axisName=n[i+"AxisName"]=e.name,n.axisId=n[i+"AxisId"]=e.id,n}function KC(t){return!t||null==t[0]||isNaN(t[0])||null==t[1]||isNaN(t[1])}function QC(t){uC.registerAxisPointerClass("CartesianAxisPointer",LC),t.registerComponentModel(NC),t.registerComponentView(WC),t.registerPreprocessor(function(t){if(t){(!t.axisPointer||0===t.axisPointer.length)&&(t.axisPointer={});var e=t.axisPointer.link;e&&!cn(e)&&(t.axisPointer.link=[e])}}),t.registerProcessor(t.PRIORITY.PROCESSOR.STATISTIC,function(t,e){t.getComponent("axisPointer").coordSysAxesInfo=rC(t,e)}),t.registerAction({type:"updateAxisPointer",event:"updateAxisPointer",update:":updateAxisPointer"},qC)}var JC=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return _(e,t),e.type="tooltip",e.dependencies=["axisPointer"],e.defaultOption={z:60,show:!0,showContent:!0,trigger:"item",triggerOn:"mousemove|click",alwaysShowContent:!1,renderMode:"auto",confine:null,showDelay:0,hideDelay:100,transitionDuration:.4,displayTransition:!0,enterable:!1,backgroundColor:Bf.color.neutral00,shadowBlur:10,shadowColor:"rgba(0, 0, 0, .2)",shadowOffsetX:1,shadowOffsetY:2,borderRadius:4,borderWidth:1,defaultBorderColor:Bf.color.border,padding:null,extraCssText:"",axisPointer:{type:"line",axis:"auto",animation:"auto",animationDurationUpdate:200,animationEasingUpdate:"exponentialOut",crossStyle:{color:Bf.color.borderShade,width:1,type:"dashed",textStyle:{}}},textStyle:{color:Bf.color.tertiary,fontSize:14}},e}(Hf);function tM(t){var e=t.get("confine");return null!=e?!!e:"richText"===t.get("renderMode")}function eM(t){if(Te.domSupported)for(var e=document.documentElement.style,n=0,i=t.length;n0&&r.push(function(t,e,n){var i="cubic-bezier(0.23,1,0.32,1)",r="",o="";return n&&(o="opacity"+(r=" "+t/2+"s "+i)+",visibility"+r),e||(r=" "+t+"s "+i,o+=(o.length?",":"")+(Te.transformSupported?""+oM+r:",left"+r+",top"+r)),rM+":"+o}(o,n,i)),a&&r.push("background-color:"+a),en(["width","color","radius"],function(e){var n="border-"+e,i=uf(n),o=t.get(i);null!=o&&r.push(n+":"+o+("color"===e?"":"px"))}),r.push(function(t){var e=[],n=t.get("fontSize"),i=t.getTextColor();i&&e.push("color:"+i),e.push("font:"+t.getFont());var r=xn(t.get("lineHeight"),Math.round(3*n/2));n&&e.push("line-height:"+r+"px");var o=t.get("textShadowColor"),a=t.get("textShadowBlur")||0,s=t.get("textShadowOffsetX")||0,l=t.get("textShadowOffsetY")||0;return o&&a&&e.push("text-shadow:"+s+"px "+l+"px "+a+"px "+o),en(["decoration","align"],function(n){var i=t.get(n);i&&e.push("text-"+n+":"+i)}),e.join(";")}(d)),null!=h&&r.push("padding:"+df(h).join("px ")+"px"),r.join(";")+";"}function cM(t,e,n,i,r){var o=e&&e.painter;if(n){var a=o&&o.getViewportRoot();a&&function(t,e,n,i,r){ri(ii,e,i,r,!0)&&ri(t,n,ii[0],ii[1])}(t,a,n,i,r)}else{t[0]=i,t[1]=r;var s=o&&o.getViewportRootOffset();s&&(t[0]+=s.offsetLeft,t[1]+=s.offsetTop)}t[2]=t[0]/e.getWidth(),t[3]=t[1]/e.getHeight()}var uM=function(){function t(t,e){if(this._show=!1,this._styleCoord=[0,0,0,0],this._enterable=!0,this._alwaysShowContent=!1,this._firstShow=!0,this._longHide=!0,Te.wxa)return null;var n=document.createElement("div");n.domBelongToZr=!0,this.el=n;var i=this._zr=t.getZr(),r=e.appendTo,o=r&&(dn(r)?document.querySelector(r):mn(r)?r:un(r)&&r(t.getDom()));cM(this._styleCoord,i,o,t.getWidth()/2,t.getHeight()/2),(o||t.getDom()).appendChild(n),this._api=t,this._container=o;var a=this;n.onmouseenter=function(){a._enterable&&(clearTimeout(a._hideTimeout),a._show=!0),a._inContent=!0},n.onmousemove=function(t){if(t=t||window.event,!a._enterable){var e=i.handler;gi(i.painter.getViewportRoot(),t,!0),e.dispatch("mousemove",t)}},n.onmouseleave=function(){a._inContent=!1,a._enterable&&a._show&&a.hideLater(a._hideDelay)}}return t.prototype.update=function(t){if(!this._container){var e=this._api.getDom(),n=(o="position",(a=(r=e).currentStyle||document.defaultView&&document.defaultView.getComputedStyle(r))?a[o]:null),i=e.style;"absolute"!==i.position&&"absolute"!==n&&(i.position="relative")}var r,o,a,s=t.get("alwaysShowContent");s&&this._moveIfResized(),this._alwaysShowContent=s,this._enableDisplayTransition=t.get("displayTransition")&&t.get("transitionDuration")>0,this.el.className=t.get("className")||""},t.prototype.show=function(t,e){clearTimeout(this._hideTimeout),clearTimeout(this._longHideTimeout);var n=this.el,i=n.style,r=this._styleCoord;n.innerHTML?i.cssText=aM+lM(t,!this._firstShow,this._longHide,this._enableDisplayTransition)+sM(r[0],r[1],!0)+"border-color:"+vf(e)+";"+(t.get("extraCssText")||"")+";pointer-events:"+(this._enterable?"auto":"none"):i.display="none",this._show=!0,this._firstShow=!1,this._longHide=!1},t.prototype.setContent=function(t,e,n,i,r){var o=this.el;if(null!=t){var a="";if(dn(r)&&"item"===n.get("trigger")&&!tM(n)&&(a=function(t,e,n){if(!dn(n)||"inside"===n)return"";var i=t.get("backgroundColor"),r=t.get("borderWidth");e=vf(e);var o,a,s="left"===(o=n)?"right":"right"===o?"left":"top"===o?"bottom":"top",l=Math.max(1.5*Math.round(r),6),c="",u=oM+":";Qe(["left","right"],s)>-1?(c+="top:50%",u+="translateY(-50%) rotate("+(a="left"===s?-225:-45)+"deg)"):(c+="left:50%",u+="translateX(-50%) rotate("+(a="top"===s?225:45)+"deg)");var d=a*Math.PI/180,h=l+r,p=h*Math.abs(Math.cos(d))+h*Math.abs(Math.sin(d)),f=e+" solid "+r+"px;";return'
'}(n,i,r)),dn(t))o.innerHTML=t+a;else if(t){o.innerHTML="",cn(t)||(t=[t]);for(var s=0;s=0?this._tryShow(n,i):"leave"===e&&this._hide(i))},this))},e.prototype._keepShow=function(){var t=this._tooltipModel,e=this._ecModel,n=this._api,i=t.get("triggerOn");if(null!=this._lastX&&null!=this._lastY&&"none"!==i&&"click"!==i){var r=this;clearTimeout(this._refreshUpdateTimeout),this._refreshUpdateTimeout=setTimeout(function(){!n.isDisposed()&&r.manuallyShowTip(t,e,n,{x:r._lastX,y:r._lastY,dataByCoordSys:r._lastDataByCoordSys})})}},e.prototype.manuallyShowTip=function(t,e,n,i){if(i.from!==this.uid&&!Te.node&&n.getDom()){var r=yM(i,n);this._ticket="";var o=i.dataByCoordSys,a=function(t,e,n){var i=Ns(t).queryOptionMap,r=i.keys()[0];if(!r||"series"===r)return;var o=Hs(e,r,i.get(r),{useDefault:!1,enableAll:!1,enableNone:!1}),a=o.models[0];if(!a)return;var s,l=n.getViewOfComponentModel(a);if(l.group.traverse(function(e){var n=nu(e).tooltipConfig;if(n&&n.name===t.name)return s=e,!0}),s)return{componentMainType:r,componentIndex:a.componentIndex,el:s}}(i,e,n);if(a){var s=a.el.getBoundingRect().clone();s.applyTransform(a.el.transform),this._tryShow({offsetX:s.x+s.width/2,offsetY:s.y+s.height/2,target:a.el,position:i.position,positionDefault:"bottom"},r)}else if(i.tooltip&&null!=i.x&&null!=i.y){var l=gM;l.x=i.x,l.y=i.y,l.update(),nu(l).tooltipConfig={name:null,option:i.tooltip},this._tryShow({offsetX:i.x,offsetY:i.y,target:l},r)}else if(o)this._tryShow({offsetX:i.x,offsetY:i.y,position:i.position,dataByCoordSys:o,tooltipOption:i.tooltipOption},r);else if(null!=i.seriesIndex){if(this._manuallyAxisShowTip(t,e,n,i))return;var c=UC(i,e),u=c.point[0],d=c.point[1];null!=u&&null!=d&&this._tryShow({offsetX:u,offsetY:d,target:c.el,position:i.position,positionDefault:"bottom"},r)}else null!=i.x&&null!=i.y&&(n.dispatchAction({type:"updateAxisPointer",x:i.x,y:i.y}),this._tryShow({offsetX:i.x,offsetY:i.y,position:i.position,target:n.getZr().findHover(i.x,i.y).target},r))}},e.prototype.manuallyHideTip=function(t,e,n,i){var r=this._tooltipContent;this._tooltipModel&&r.hideLater(this._tooltipModel.get("hideDelay")),this._lastX=this._lastY=this._lastDataByCoordSys=null,i.from!==this.uid&&this._hide(yM(i,n))},e.prototype._manuallyAxisShowTip=function(t,e,n,i){var r=i.seriesIndex,o=i.dataIndex,a=e.getComponent("axisPointer").coordSysAxesInfo;if(null!=r&&null!=o&&null!=a){var s=e.getSeriesByIndex(r);if(s)if("axis"===mM([s.getData().getItemModel(o),s,(s.coordinateSystem||{}).model],this._tooltipModel).get("trigger"))return n.dispatchAction({type:"updateAxisPointer",seriesIndex:r,dataIndex:o,position:i.position}),!0}},e.prototype._tryShow=function(t,e){var n=t.target;if(this._tooltipModel){this._lastX=t.offsetX,this._lastY=t.offsetY;var i=t.dataByCoordSys;if(i&&i.length)this._showAxisTooltip(i,t);else if(n){var r,o;if("legend"===nu(n).ssrType)return;this._lastDataByCoordSys=null,xy(n,function(t){if(t.tooltipDisabled)return r=o=null,!0;r||o||(null!=nu(t).dataIndex?r=t:null!=nu(t).tooltipConfig&&(o=t))},!0),r?this._showSeriesItemTooltip(t,r,e):o?this._showComponentItemTooltip(t,o,e):this._hide(e)}else this._lastDataByCoordSys=null,this._hide(e)}},e.prototype._showOrMove=function(t,e){var n=t.get("showDelay");e=sn(e,this),clearTimeout(this._showTimout),n>0?this._showTimout=setTimeout(e,n):e()},e.prototype._showAxisTooltip=function(t,e){var n=this._ecModel,i=this._tooltipModel,r=[e.offsetX,e.offsetY],o=mM([e.tooltipOption],i),a=this._renderMode,s=[],l=tm("section",{blocks:[],noHeader:!0}),c=[],u=new um;en(t,function(t){en(t.dataByAxis,function(t){var e=n.getComponent(t.axisDim+"Axis",t.axisIndex),r=t.value;if(e&&null!=r){var o=IC(r,e.axis,n,t.seriesDataIndices,t.valueLabelOpt),d=tm("section",{header:o,noHeader:!kn(o),sortBlocks:!0,blocks:[]});l.blocks.push(d),en(t.seriesDataIndices,function(l){var h=n.getSeriesByIndex(l.seriesIndex),p=l.dataIndexInside,f=h.getDataParams(p);if(!(f.dataIndex<0)){f.axisDim=t.axisDim,f.axisIndex=t.axisIndex,f.axisType=t.axisType,f.axisId=t.axisId,f.axisValue=Vx(e.axis,{value:r}),f.axisValueLabel=o,f.marker=u.makeTooltipMarker("item",vf(f.color),a);var g=xv(h.formatTooltip(p,!0,null)),v=g.frag;if(v){var m=mM([h],i).get("valueFormatter");d.blocks.push(m?Ze({valueFormatter:m},v):v)}g.text&&c.push(g.text),s.push(f)}})}})}),l.blocks.reverse(),c.reverse();var d=e.position,h=o.get("order"),p=am(l,u,a,h,n.get("useUTC"),o.get("textStyle"));p&&c.unshift(p);var f="richText"===a?"\n\n":"
",g=c.join(f);this._showOrMove(o,function(){this._updateContentNotChangedOnAxis(t,s)?this._updatePosition(o,d,r[0],r[1],this._tooltipContent,s):this._showTooltipContent(o,g,s,Math.random()+"",r[0],r[1],d,null,u)})},e.prototype._showSeriesItemTooltip=function(t,e,n){var i=this._ecModel,r=nu(e),o=r.seriesIndex,a=i.getSeriesByIndex(o),s=r.dataModel||a,l=r.dataIndex,c=r.dataType,u=s.getData(c),d=this._renderMode,h=t.positionDefault,p=mM([u.getItemModel(l),s,a&&(a.coordinateSystem||{}).model],this._tooltipModel,h?{position:h}:null),f=p.get("trigger");if(null==f||"item"===f){var g=s.getDataParams(l,c),v=new um;g.marker=v.makeTooltipMarker("item",vf(g.color),d);var m=xv(s.formatTooltip(l,!1,c)),y=p.get("order"),_=p.get("valueFormatter"),b=m.frag,x=b?am(_?Ze({valueFormatter:_},b):b,v,d,y,i.get("useUTC"),p.get("textStyle")):m.text,w="item_"+s.name+"_"+l;this._showOrMove(p,function(){this._showTooltipContent(p,x,g,w,t.offsetX,t.offsetY,t.position,t.target,v)}),n({type:"showTip",dataIndexInside:l,dataIndex:u.getRawIndex(l),seriesIndex:o,from:this.uid})}},e.prototype._showComponentItemTooltip=function(t,e,n){var i="html"===this._renderMode,r=nu(e),o=r.tooltipConfig.option||{},a=o.encodeHTMLContent;if(dn(o)){o={content:o,formatter:o},a=!0}a&&i&&o.content&&((o=Xe(o)).content=li(o.content));var s=[o],l=this._ecModel.getComponent(r.componentMainType,r.componentIndex);l&&s.push(l),s.push({formatter:o.content});var c=t.positionDefault,u=mM(s,this._tooltipModel,c?{position:c}:null),d=u.get("content"),h=Math.random()+"",p=new um;this._showOrMove(u,function(){var n=Xe(u.get("formatterParams")||{});this._showTooltipContent(u,d,n,h,t.offsetX,t.offsetY,t.position,e,p)}),n({type:"showTip",from:this.uid})},e.prototype._showTooltipContent=function(t,e,n,i,r,o,a,s,l){if(this._ticket="",t.get("showContent")&&t.get("show")){var c=this._tooltipContent;c.setEnterable(t.get("enterable"));var u=t.get("formatter");a=a||t.get("position");var d=e,h=this._getNearestPoint([r,o],n,t.get("trigger"),t.get("borderColor"),t.get("defaultBorderColor",!0)).color;if(u)if(dn(u)){var p=t.ecModel.get("useUTC"),f=cn(n)?n[0]:n;d=u,f&&f.axisType&&f.axisType.indexOf("time")>=0&&(d=Gp(f.axisValue,d,p)),d=gf(d,n,!0)}else if(un(u)){var g=sn(function(e,i){e===this._ticket&&(c.setContent(i,l,t,h,a),this._updatePosition(t,a,r,o,c,n,s))},this);this._ticket=i,d=u(n,i,g)}else d=u;c.setContent(d,l,t,h,a),c.show(t,h),this._updatePosition(t,a,r,o,c,n,s)}},e.prototype._getNearestPoint=function(t,e,n,i,r){return"axis"===n||cn(e)?{color:i||r}:cn(e)?void 0:{color:i||e.color||e.borderColor}},e.prototype._updatePosition=function(t,e,n,i,r,o,a){var s=this._api.getWidth(),l=this._api.getHeight();e=e||t.get("position");var c=r.getSize(),u=t.get("align"),d=t.get("verticalAlign"),h=a&&a.getBoundingRect().clone();if(a&&h.applyTransform(a.transform),un(e)&&(e=e([n,i],o,r.el,h,{viewSize:[s,l],contentSize:c.slice()})),cn(e))n=es(e[0],s),i=es(e[1],l);else if(fn(e)){var p=e;p.width=c[0],p.height=c[1];var f=Pf(p,{width:s,height:l});n=f.x,i=f.y,u=null,d=null}else if(dn(e)&&a){var g=function(t,e,n,i){var r=n[0],o=n[1],a=Math.ceil(Math.SQRT2*i)+8,s=0,l=0,c=e.width,u=e.height;switch(t){case"inside":s=e.x+c/2-r/2,l=e.y+u/2-o/2;break;case"top":s=e.x+c/2-r/2,l=e.y-o-a;break;case"bottom":s=e.x+c/2-r/2,l=e.y+u+a;break;case"left":s=e.x-r-a,l=e.y+u/2-o/2;break;case"right":s=e.x+c+a,l=e.y+u/2-o/2}return[s,l]}(e,h,c,t.get("borderWidth"));n=g[0],i=g[1]}else{g=function(t,e,n,i,r,o,a){var s=n.getSize(),l=s[0],c=s[1];null!=o&&(t+l+o+2>i?t-=l+o:t+=o);null!=a&&(e+c+a>r?e-=c+a:e+=a);return[t,e]}(n,i,r,s,l,u?null:20,d?null:20);n=g[0],i=g[1]}if(u&&(n-=_M(u)?c[0]/2:"right"===u?c[0]:0),d&&(i-=_M(d)?c[1]/2:"bottom"===d?c[1]:0),tM(t)){g=function(t,e,n,i,r){var o=n.getSize(),a=o[0],s=o[1];return t=Math.min(t+a,i)-a,e=Math.min(e+s,r)-s,t=Math.max(t,0),e=Math.max(e,0),[t,e]}(n,i,r,s,l);n=g[0],i=g[1]}r.moveTo(n,i)},e.prototype._updateContentNotChangedOnAxis=function(t,e){var n=this._lastDataByCoordSys,i=this._cbParamsList,r=!!n&&n.length===t.length;return r&&en(n,function(n,o){var a=n.dataByAxis||[],s=(t[o]||{}).dataByAxis||[];(r=r&&a.length===s.length)&&en(a,function(t,n){var o=s[n]||{},a=t.seriesDataIndices||[],l=o.seriesDataIndices||[];(r=r&&t.value===o.value&&t.axisType===o.axisType&&t.axisId===o.axisId&&a.length===l.length)&&en(a,function(t,e){var n=l[e];r=r&&t.seriesIndex===n.seriesIndex&&t.dataIndex===n.dataIndex}),i&&en(t.seriesDataIndices,function(t){var n=t.seriesIndex,o=e[n],a=i[n];o&&a&&a.data!==o.data&&(r=!1)})})}),this._lastDataByCoordSys=t,this._cbParamsList=e,!!r},e.prototype._hide=function(t){this._lastDataByCoordSys=null,t({type:"hideTip",from:this.uid})},e.prototype.dispose=function(t,e){!Te.node&&e.getDom()&&(Rm(this,"_updatePosition"),this._tooltipContent.dispose(),VC("itemTooltip",e))},e.type="tooltip",e}(wm);function mM(t,e,n){var i,r=e.ecModel;n?(i=new _p(n,r,r),i=new _p(e.option,i,r)):i=e;for(var o=t.length-1;o>=0;o--){var a=t[o];a&&(a instanceof _p&&(a=a.get("tooltip",!0)),dn(a)&&(a={formatter:a}),a&&(i=new _p(a,i,r)))}return i}function yM(t,e){return t.dispatchAction||sn(e.dispatchAction,e)}function _M(t){return"center"===t||"middle"===t}var bM=Math.sin,xM=Math.cos,wM=Math.PI,SM=2*Math.PI,CM=180/wM,MM=function(){function t(){}return t.prototype.reset=function(t){this._start=!0,this._d=[],this._str="",this._p=Math.pow(10,t||4)},t.prototype.moveTo=function(t,e){this._add("M",t,e)},t.prototype.lineTo=function(t,e){this._add("L",t,e)},t.prototype.bezierCurveTo=function(t,e,n,i,r,o){this._add("C",t,e,n,i,r,o)},t.prototype.quadraticCurveTo=function(t,e,n,i){this._add("Q",t,e,n,i)},t.prototype.arc=function(t,e,n,i,r,o){this.ellipse(t,e,n,n,0,i,r,o)},t.prototype.ellipse=function(t,e,n,i,r,o,a,s){var l=a-o,c=!s,u=Math.abs(l),d=ho(u-SM)||(c?l>=SM:-l>=SM),h=l>0?l%SM:l%SM+SM,p=!1;p=!!d||!ho(u)&&h>=wM==!!c;var f=t+n*xM(o),g=e+i*bM(o);this._start&&this._add("M",f,g);var v=Math.round(r*CM);if(d){var m=1/this._p,y=(c?1:-1)*(SM-m);this._add("A",n,i,v,1,+c,t+n*xM(o+y),e+i*bM(o+y)),m>.01&&this._add("A",n,i,v,0,+c,f,g)}else{var _=t+n*xM(a),b=e+i*bM(a);this._add("A",n,i,v,+p,+c,_,b)}},t.prototype.rect=function(t,e,n,i){this._add("M",t,e),this._add("l",n,0),this._add("l",0,i),this._add("l",-n,0),this._add("Z")},t.prototype.closePath=function(){this._d.length>0&&this._add("Z")},t.prototype._add=function(t,e,n,i,r,o,a,s,l){for(var c=[],u=this._p,d=1;d"}(r,o)+("style"!==r?li(a):a||"")+(i?""+n+nn(i,function(e){return t(e)}).join(n)+n:"")+("")}(t)}function RM(t){return{zrId:t,shadowCache:{},patternCache:{},gradientCache:{},clipPathCache:{},defs:{},cssNodes:{},cssAnims:{},cssStyleCache:{},cssAnimIdx:0,shadowIdx:0,gradientIdx:0,patternIdx:0,clipPathIdx:0}}function HM(t,e,n,i){return zM("svg","root",{width:t,height:e,xmlns:PM,"xmlns:xlink":LM,version:"1.1",baseProfile:"full",viewBox:!!i&&"0 0 "+t+" "+e},n)}var BM=0;function FM(){return BM++}var $M={cubicIn:"0.32,0,0.67,0",cubicOut:"0.33,1,0.68,1",cubicInOut:"0.65,0,0.35,1",quadraticIn:"0.11,0,0.5,0",quadraticOut:"0.5,1,0.89,1",quadraticInOut:"0.45,0,0.55,1",quarticIn:"0.5,0,0.75,0",quarticOut:"0.25,1,0.5,1",quarticInOut:"0.76,0,0.24,1",quinticIn:"0.64,0,0.78,0",quinticOut:"0.22,1,0.36,1",quinticInOut:"0.83,0,0.17,1",sinusoidalIn:"0.12,0,0.39,0",sinusoidalOut:"0.61,1,0.88,1",sinusoidalInOut:"0.37,0,0.63,1",exponentialIn:"0.7,0,0.84,0",exponentialOut:"0.16,1,0.3,1",exponentialInOut:"0.87,0,0.13,1",circularIn:"0.55,0,1,0.45",circularOut:"0,0.55,0.45,1",circularInOut:"0.85,0,0.15,1"},VM="transform-origin";function WM(t,e,n){var i=Ze({},t.shape);Ze(i,e),t.buildPath(n,i);var r=new MM;return r.reset(wo(t)),n.rebuildPath(r,1),r.generateStr(),r.getStr()}function UM(t,e){var n=e.originX,i=e.originY;(n||i)&&(t[VM]=n+"px "+i+"px")}var GM={fill:"fill",opacity:"opacity",lineWidth:"stroke-width",lineDashOffset:"stroke-dashoffset"};function qM(t,e){var n=e.zrId+"-ani-"+e.cssAnimIdx++;return e.cssAnims[n]=t,n}function jM(t){return dn(t)?$M[t]?"cubic-bezier("+$M[t]+")":Rr(t)?t:"":""}function XM(t,e,n,i){var r=t.animators,o=r.length,a=[];if(t instanceof Yd){var s=function(t,e,n){var i,r,o=t.shape.paths,a={};if(en(o,function(t){var e=RM(n.zrId);e.animation=!0,XM(t,{},e,!0);var o=e.cssAnims,s=e.cssNodes,l=an(o),c=l.length;if(c){var u=o[r=l[c-1]];for(var d in u){var h=u[d];a[d]=a[d]||{d:""},a[d].d+=h.d||""}for(var p in s){var f=s[p].animation;f.indexOf(r)>=0&&(i=f)}}}),i){e.d=!1;var s=qM(a,n);return i.replace(r,s)}}(t,e,n);if(s)a.push(s);else if(!o)return}else if(!o)return;for(var l={},c=0;c0}).length)return qM(u,n)+" "+r[0]+" both"}for(var v in l){(s=g(l[v]))&&a.push(s)}if(a.length){var m=n.zrId+"-cls-"+FM();n.cssNodes["."+m]={animation:a.join(",")},e.class=m}}function YM(t,e,n,i){var r=JSON.stringify(t),o=n.cssStyleCache[r];o||(o=n.zrId+"-cls-"+FM(),n.cssStyleCache[r]=o,n.cssNodes["."+o+":hover"]=t),e.class=e.class?e.class+" "+o:o}var ZM=Math.round;function KM(t){return t&&dn(t.src)}function QM(t){return t&&un(t.toDataURL)}function JM(t,e,n,i){IM(function(r,o){var a="fill"===r||"stroke"===r;a&&bo(o)?uk(e,t,r,i):a&&mo(o)?dk(n,t,r,i):t[r]=o,a&&i.ssr&&"none"===o&&(t["pointer-events"]="visible")},e,n,!1),function(t,e,n){var i=t.style;if(function(t){return t&&(t.shadowBlur||t.shadowOffsetX||t.shadowOffsetY)}(i)){var r=function(t){var e=t.style,n=t.getGlobalScale();return[e.shadowColor,(e.shadowBlur||0).toFixed(2),(e.shadowOffsetX||0).toFixed(2),(e.shadowOffsetY||0).toFixed(2),n[0],n[1]].join(",")}(t),o=n.shadowCache,a=o[r];if(!a){var s=t.getGlobalScale(),l=s[0],c=s[1];if(!l||!c)return;var u=i.shadowOffsetX||0,d=i.shadowOffsetY||0,h=i.shadowBlur,p=co(i.shadowColor),f=p.opacity,g=p.color,v=h/2/l+" "+h/2/c;a=n.zrId+"-s"+n.shadowIdx++,n.defs[a]=zM("filter",a,{id:a,x:"-100%",y:"-100%",width:"300%",height:"300%"},[zM("feDropShadow","",{dx:u/l,dy:d/c,stdDeviation:v,"flood-color":g,"flood-opacity":f})]),o[r]=a}e.filter=xo(a)}}(n,t,i)}function tk(t,e){var n=function(t){if("function"==typeof ja)return ja(t)}(e);n&&(n.each(function(e,n){null!=e&&(t[(EM+n).toLowerCase()]=e+"")}),e.isSilent()&&(t[EM+"silent"]="true"))}function ek(t){return ho(t[0]-1)&&ho(t[1])&&ho(t[2])&&ho(t[3]-1)}function nk(t,e,n){if(e&&(!function(t){return ho(t[4])&&ho(t[5])}(e)||!ek(e))){var i=1e4;t.transform=ek(e)?"translate("+ZM(e[4]*i)/i+" "+ZM(e[5]*i)/i+")":function(t){return"matrix("+po(t[0])+","+po(t[1])+","+po(t[2])+","+po(t[3])+","+fo(t[4])+","+fo(t[5])+")"}(e)}}function ik(t,e,n){for(var i=t.points,r=[],o=0;o=0&&a||o;s&&(r=so(s))}var l=i.lineWidth;l&&(l/=!i.strokeNoScale&&t.transform?t.transform[0]:1);var c={cursor:"pointer"};r&&(c.fill=r),i.stroke&&(c.stroke=i.stroke),l&&(c["stroke-width"]=l),YM(c,e,n)}}(t,o,e),zM(s,t.id+"",o)}function ck(t,e){return t instanceof Tc?lk(t,e):t instanceof Lc?function(t,e){var n=t.style,i=n.image;if(i&&!dn(i)&&(KM(i)?i=i.src:QM(i)&&(i=i.toDataURL())),i){var r=n.x||0,o=n.y||0,a={href:i,width:n.width,height:n.height};return r&&(a.x=r),o&&(a.y=o),nk(a,t.transform),JM(a,n,t,e),tk(a,t),e.animation&&XM(t,a,e),zM("image",t.id+"",a)}}(t,e):t instanceof Ac?function(t,e){var n=t.style,i=n.text;if(null!=i&&(i+=""),i&&!isNaN(n.x)&&!isNaN(n.y)){var r=n.font||Ae,o=n.x||0,a=function(t,e,n){return"top"===n?t+=e/2:"bottom"===n&&(t-=e/2),t}(n.y||0,Ia(r),n.textBaseline),s={"dominant-baseline":"central","text-anchor":go[n.textAlign]||n.textAlign};if(Yc(n)){var l="",c=n.fontStyle,u=jc(n.fontSize);if(!parseFloat(u))return;var d=n.fontFamily||De,h=n.fontWeight;l+="font-size:"+u+";font-family:"+d+";",c&&"normal"!==c&&(l+="font-style:"+c+";"),h&&"normal"!==h&&(l+="font-weight:"+h+";"),s.style=l}else s.style="font: "+r;return i.match(/\s/)&&(s["xml:space"]="preserve"),o&&(s.x=o),a&&(s.y=a),nk(s,t.transform),JM(s,n,t,e),tk(s,t),e.animation&&XM(t,s,e),zM("text",t.id+"",s,void 0,i)}}(t,e):void 0}function uk(t,e,n,i){var r,o=t[n],a={gradientUnits:o.global?"userSpaceOnUse":"objectBoundingBox"};if(yo(o))r="linearGradient",a.x1=o.x,a.y1=o.y,a.x2=o.x2,a.y2=o.y2;else{if(!_o(o))return;r="radialGradient",a.cx=xn(o.x,.5),a.cy=xn(o.y,.5),a.r=xn(o.r,.5)}for(var s=o.colorStops,l=[],c=0,u=s.length;cl?kk(t,null==n[d+1]?null:n[d+1].elm,n,s,d):Tk(t,e,a,l))}(n,i,r):wk(r)?(wk(t.text)&&_k(n,""),kk(n,null,r,0,r.length-1)):wk(i)?Tk(n,i,0,i.length-1):wk(t.text)&&_k(n,""):t.text!==e.text&&(wk(i)&&Tk(n,i,0,i.length-1),_k(n,e.text)))}var Ik=0,Pk=function(){function t(t,e,n){if(this.type="svg",this.refreshHover=function(){},this.configLayer=function(){},this.storage=e,this._opts=n=Ze({},n),this.root=t,this._id="zr"+Ik++,this._oldVNode=HM(n.width,n.height),t&&!n.ssr){var i=this._viewport=document.createElement("div");i.style.cssText="position:relative;overflow:hidden";var r=this._svgDom=this._oldVNode.elm=OM("svg");Dk(null,this._oldVNode),i.appendChild(r),t.appendChild(i)}this.resize(n.width,n.height)}return t.prototype.getType=function(){return this.type},t.prototype.getViewportRoot=function(){return this._viewport},t.prototype.getViewportRootOffset=function(){var t=this.getViewportRoot();if(t)return{offsetLeft:t.offsetLeft||0,offsetTop:t.offsetTop||0}},t.prototype.getSvgDom=function(){return this._svgDom},t.prototype.refresh=function(){if(this.root){var t=this.renderToVNode({willUpdate:!0});t.attrs.style="position:absolute;left:0;top:0;user-select:none",function(t,e){if(Ck(t,e))Ak(t,e);else{var n=t.elm,i=mk(n);Mk(e),null!==i&&(fk(i,e.elm,yk(n)),Tk(i,[t],0,0))}}(this._oldVNode,t),this._oldVNode=t}},t.prototype.renderOneToVNode=function(t){return ck(t,RM(this._id))},t.prototype.renderToVNode=function(t){t=t||{};var e=this.storage.getDisplayList(!0),n=this._width,i=this._height,r=RM(this._id);r.animation=t.animation,r.willUpdate=t.willUpdate,r.compress=t.compress,r.emphasis=t.emphasis,r.ssr=this._opts.ssr;var o=[],a=this._bgVNode=function(t,e,n,i){var r;if(n&&"none"!==n)if(r=zM("rect","bg",{width:t,height:e,x:"0",y:"0"}),bo(n))uk({fill:n},r.attrs,"fill",i);else if(mo(n))dk({style:{fill:n},dirty:Nn,getBoundingRect:function(){return{width:t,height:e}}},r.attrs,"fill",i);else{var o=co(n),a=o.color,s=o.opacity;r.attrs.fill=a,s<1&&(r.attrs["fill-opacity"]=s)}return r}(n,i,this._backgroundColor,r);a&&o.push(a);var s=t.compress?null:this._mainVNode=zM("g","main",{},[]);this._paintList(e,r,s?s.children:o),s&&o.push(s);var l=nn(an(r.defs),function(t){return r.defs[t]});if(l.length&&o.push(zM("defs","defs",{},l)),t.animation){var c=function(t,e,n){var i=(n=n||{}).newline?"\n":"",r=" {"+i,o=i+"}",a=nn(an(t),function(e){return e+r+nn(an(t[e]),function(n){return n+":"+t[e][n]+";"}).join(i)+o}).join(i),s=nn(an(e),function(t){return"@keyframes "+t+r+nn(an(e[t]),function(n){return n+r+nn(an(e[t][n]),function(i){var r=e[t][n][i];return"d"===i&&(r='path("'+r+'")'),i+":"+r+";"}).join(i)+o}).join(i)+o}).join(i);return a||s?[""].join(i):""}(r.cssNodes,r.cssAnims,{newline:!0});if(c){var u=zM("style","stl",{},[],c);o.push(u)}}return HM(n,i,o,t.useViewBox)},t.prototype.renderToString=function(t){return t=t||{},NM(this.renderToVNode({animation:xn(t.cssAnimation,!0),emphasis:xn(t.cssEmphasis,!0),willUpdate:!1,compress:!0,useViewBox:xn(t.useViewBox,!0)}),{newline:!0})},t.prototype.setBackgroundColor=function(t){this._backgroundColor=t},t.prototype.getSvgRoot=function(){return this._mainVNode&&this._mainVNode.elm},t.prototype._paintList=function(t,e,n){for(var i,r,o=t.length,a=[],s=0,l=0,c=0;c=0&&(!d||!r||d[f]!==r[f]);f--);for(var g=p-1;g>f;g--)i=a[--s-1];for(var v=f+1;v10&&"cartesian2d"===o.type&&r){var s=o.getBaseAxis(),l=o.getOtherAxis(s),c=s.getExtent(),u=n.getDevicePixelRatio(),d=Math.abs(c[1]-c[0])*(u||1),h=Math.round(a/d);if(isFinite(h)&&h>1){"lttb"===r?t.setData(i.lttbDownSample(i.mapDimension(l.dim),1/h)):"minmax"===r&&t.setData(i.minmaxDownSample(i.mapDimension(l.dim),1/h));var p=void 0;dn(r)?p=rS[r]:un(r)&&(p=r),p&&t.setData(i.downSample(i.mapDimension(l.dim),1/h,p,oS))}}}}}("line"))},function(t){Qx(_C),Qx(QC)},function(t){Qx(QC),t.registerComponentModel(JC),t.registerComponentView(vM),t.registerAction({type:"showTip",event:"showTip",update:"tooltip:manuallyShowTip"},Nn),t.registerAction({type:"hideTip",event:"hideTip",update:"tooltip:manuallyHideTip"},Nn)},function(t){t.registerPainter("svg",Pk)}]);class Lk extends It{constructor(){super(...arguments),this.options=null,this.data=[],this.height="120px",this._chart=null,this._resizeObserver=null}render(){return dt`
`}connectedCallback(){super.connectedCallback(),this.hasUpdated&&!this._chart&&this.options&&(this._mount(),this._observeResize())}firstUpdated(){this._mount(),this._observeResize()}updated(t){(t.has("options")||t.has("data"))&&(this._chart&&this.options?this._chart.setOption(this._mergedOptions(),!0):!this._chart&&this.options&&this._mount()),t.has("height")&&this._chart&&this._chart.resize()}disconnectedCallback(){this._destroy(),super.disconnectedCallback()}_mount(){if(!this.options)return;const t=this.shadowRoot?.querySelector(".chart-host");t&&(this._chart=rb(t,void 0,{renderer:"svg"}),this._chart.setOption(this._mergedOptions(),!0))}_mergedOptions(){if(!this.options)throw new Error("span-chart: options not set");return{...this.options,series:this.data}}_observeResize(){if("undefined"==typeof ResizeObserver)return;this._resizeObserver=new ResizeObserver(()=>{this._chart&&this._chart.resize()});const t=this.shadowRoot?.querySelector(".chart-host");t&&this._resizeObserver.observe(t)}_destroy(){this._resizeObserver&&(this._resizeObserver.disconnect(),this._resizeObserver=null),this._chart&&(this._chart.dispose(),this._chart=null)}}Lk.styles=T` :host { display: block; width: 100%; @@ -55,7 +55,7 @@ var Ga={},qa={};var ja,Xa=function(){function t(t,e,n){var i=this;this._sleepAft width: 100%; height: 100%; } - `,b([Ot({attribute:!1})],Lk.prototype,"options",void 0),b([Ot({attribute:!1})],Lk.prototype,"data",void 0),b([Ot({type:String})],Lk.prototype,"height",void 0);try{customElements.get("span-chart")||customElements.define("span-chart",Lk)}catch{}function Ek(t,e,n,i,r,a,s,l,c){const{options:u,series:d}=function(t,e,n,i,r,a=!1){n||(n=g[o]);const s=i?"140, 160, 220":"77, 217, 175",l=`rgb(${s})`,c=Date.now(),u=c-e,d=void 0!==n.fixedMin&&void 0!==n.fixedMax,h=(t??[]).filter(t=>t.time>=u).map(t=>[t.time,Math.abs(t.value)]),p=[{type:"line",data:h,showSymbol:!1,smooth:!1,...a?{}:{step:"end"},lineStyle:{width:1.5,color:l},areaStyle:{color:{type:"linear",x:0,y:0,x2:0,y2:1,colorStops:[{offset:0,color:`rgba(${s}, 0.18)`},{offset:1,color:`rgba(${s}, 0.18)`}]}},itemStyle:{color:l}}],f=h.length>0?function(t){let e=0;for(const n of t)n[1]>e&&(e=n[1]);return e}(h):0,v={type:"value",splitNumber:4,axisLabel:{fontSize:10,formatter:f<10?t=>0===t?"0":t.toFixed(1):t=>n.format(t)},splitLine:{lineStyle:{opacity:.15}}};d?(v.min=n.fixedMin,v.max=n.fixedMax):f<1&&(v.min=0,v.max=1),r&&"current"===n.entityRole&&(v.min=0,v.max=Math.ceil(1.25*r),p.push({type:"line",data:[[u,.8*r],[c,.8*r]],showSymbol:!1,lineStyle:{width:1,color:"rgba(255, 200, 40, 0.6)",type:"dashed"},itemStyle:{color:"transparent"},tooltip:{show:!1}}),p.push({type:"line",data:[[u,r],[c,r]],showSymbol:!1,lineStyle:{width:1.5,color:"rgba(255, 60, 60, 0.7)",type:"solid"},itemStyle:{color:"transparent"},tooltip:{show:!1}}));const m={xAxis:{type:"time",min:u,max:c,axisLabel:{fontSize:10},splitLine:{show:!1}},yAxis:v,grid:{top:8,right:4,bottom:0,left:0,containLabel:!0},tooltip:{trigger:"axis",axisPointer:{type:"line",lineStyle:{type:"dashed"}},formatter:t=>{if(!t||0===t.length)return"";const e=t[0],i=new Date(e.value[0]).toLocaleString(void 0,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit"}),r=parseFloat(e.value[1].toFixed(2));return`
${i}
${n.format(r)} ${n.unit(r)}
`}},animation:!1};return{options:m,series:p}}(n,i,r,a,l,c),h=s??120;t.style.minHeight=h+"px";let p=t.querySelector("span-chart");p||(p=document.createElement("span-chart"),p.style.display="block",p.style.width="100%",t.innerHTML="",t.appendChild(p));const f=t.clientHeight;p.height=(f>0?f:h)+"px",p.options=u,p.data=d}function Ok(t){return"function"==typeof globalThis.CSS?.escape?CSS.escape(t):t.replace(/["\\]/g,"\\$&")}function zk(t,e,n,i,r){const o=t.querySelector(".panel-stats");o&&function(t,e,n,i,r){const o="current"===(i.chart_metric||"power"),a=t.querySelector(".stat-consumption .stat-value"),s=t.querySelector(".stat-consumption .stat-unit");if(o){const t=n.panel_entities?.site_power,i=t?e.states[t]:null,r=i?parseFloat(i.attributes?.amperage):NaN;a&&(a.textContent=Number.isFinite(r)?Math.abs(r).toFixed(1):"--"),s&&(s.textContent="A")}else{let t=r;const i=n.panel_entities?.site_power;if(i){const n=e.states[i];n&&(t=Math.abs(parseFloat(n.state)||0))}a&&(a.textContent=Gt(t)),s&&(s.textContent="kW")}const l=t.querySelector(".stat-upstream .stat-value"),c=t.querySelector(".stat-upstream .stat-unit");if(l){const t=n.panel_entities?.current_power,i=t?e.states[t]:null;if(o){const t=i?parseFloat(i.attributes?.amperage):NaN;l.textContent=Number.isFinite(t)?Math.abs(t).toFixed(1):"--",c&&(c.textContent="A")}else{const t=i?Math.abs(parseFloat(i.state)||0):0;l.textContent=Gt(t),c&&(c.textContent="kW")}}const u=t.querySelector(".stat-downstream .stat-value"),d=t.querySelector(".stat-downstream .stat-unit");if(u){const t=n.panel_entities?.feedthrough_power,i=t?e.states[t]:null;if(o){const t=i?parseFloat(i.attributes?.amperage):NaN;u.textContent=Number.isFinite(t)?Math.abs(t).toFixed(1):"--",d&&(d.textContent="A")}else{const t=i?Math.abs(parseFloat(i.state)||0):0;u.textContent=Gt(t),d&&(d.textContent="kW")}}const h=t.querySelector(".stat-solar .stat-value"),p=t.querySelector(".stat-solar .stat-unit");if(h){const t=n.panel_entities?.pv_power,i=t?e.states[t]:null;if(o){const t=i?parseFloat(i.attributes?.amperage):NaN;h.textContent=Number.isFinite(t)?Math.abs(t).toFixed(1):"--",p&&(p.textContent="A")}else{if(i){const t=Math.abs(parseFloat(i.state)||0);h.textContent=Gt(t)}else h.textContent="--";p&&(p.textContent="kW")}}const f=t.querySelector(".stat-battery .stat-value");if(f){const t=n.panel_entities?.battery_level,i=t?e.states[t]:null;i&&(f.textContent=`${Math.round(parseFloat(i.state)||0)}`)}const g=t.querySelector(".stat-grid-state .stat-value");if(g){const t=n.panel_entities?.dsm_state,i=t?e.states[t]:null;g.textContent=i?e.formatEntityState?.(i)||i.state:"--"}}(o,e,n,i,r)}class Nk{get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t,this._retry=t?new Qt(t):null}constructor(){this._errorStore=null,this._retry=null,this._settings=null,this._lastFetch=0,this._fetching=!1}async fetch(t,e){const n=Date.now();if(this._fetching)return this._settings;if(this._settings&&n-this._lastFetch<3e4)return this._settings;this._fetching=!0;try{const n={};e&&(n.config_entry_id=e);const r={type:"call_service",domain:l,service:"get_graph_settings",service_data:n,return_response:!0},o=this._retry?await this._retry.callWS(t,r,{errorId:"fetch:graph_settings",errorMessage:i("error.graph_settings_failed")}):await t.callWS(r);this._settings=o?.response??null,this._lastFetch=Date.now()}catch(t){console.warn("SPAN Panel: graph settings fetch failed",t),this._settings=null,this._retry||this._errorStore?.add({key:"fetch:graph_settings",level:"warning",message:i("error.graph_settings_failed"),persistent:!1})}finally{this._fetching=!1}return this._settings}invalidate(){this._lastFetch=0}get settings(){return this._settings}clear(){this._settings=null,this._lastFetch=0}}function Rk(t,e){if(!t)return a;const n=t.circuits?.[e];return n?.has_override?n.horizon:t.global_horizon??a}function Hk(t,e){if(!t)return a;const n=t.sub_devices?.[e];return n?.has_override?n.horizon:t.global_horizon??a}class Bk{constructor(){this.powerHistory=new Map,this.horizonMap=new Map,this.subDeviceHorizonMap=new Map,this.monitoringCache=new Jt,this.monitoringMultiCache=new te,this.graphSettingsCache=new Nk,this._errorStore=null,this._hass=null,this._topology=null,this._config=null,this._configEntryId=null,this._favRefs=null,this._perPanelInfo=new Map,this._panelFavorites=null,this._showMonitoring=!1,this._updateInterval=null,this._recorderRefreshInterval=null,this._resizeObserver=null,this._lastWidth=0,this._resizeDebounce=null}get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t,this.monitoringCache.errorStore=t,this.graphSettingsCache.errorStore=t,this.monitoringMultiCache.errorStore=t}get hass(){return this._hass}set hass(t){this._hass=t}get topology(){return this._topology}get config(){return this._config}set showMonitoring(t){this._showMonitoring=t}init(t,e,n,i){this._topology=t,this._config=e,this._hass=n,this._configEntryId=i}setFavoriteRefs(t){this._favRefs=t}clearFavoriteRefs(){this._favRefs=null}setPanelFavorites(t){this._panelFavorites=t}setFavoritesPerPanelInfo(t){this._perPanelInfo=t??new Map}get _inFavoritesView(){return null!==this._favRefs}setConfig(t){this._config=t}buildHorizonMaps(t){if(this.horizonMap.clear(),this.subDeviceHorizonMap.clear(),t&&this._topology?.circuits)for(const e of Object.keys(this._topology.circuits))this.horizonMap.set(e,Rk(t,e));if(t&&this._topology?.sub_devices)for(const e of Object.keys(this._topology.sub_devices))this.subDeviceHorizonMap.set(e,Hk(t,e))}async fetchAndBuildHorizonMaps(){try{this._favRefs?await this._buildFavoritesHorizonMaps():(await this.graphSettingsCache.fetch(this._hass,this._configEntryId),this.buildHorizonMaps(this.graphSettingsCache.settings))}catch(t){console.warn("SPAN Panel: graph settings fetch failed",t),this.graphSettingsCache.errorStore||this._errorStore?.add({key:"fetch:graph_settings",level:"warning",message:i("error.graph_settings_failed"),persistent:!1})}}async fetchMergedMonitoringStatus(t){if(!this._hass||0===t.length)return null;const e=this._hass;return function(t){let e=!1;const n={},i={};for(const r of t)r&&(e=!0,r.circuits&&Object.assign(n,r.circuits),r.mains&&Object.assign(i,r.mains));return e?{circuits:n,mains:i}:null}(await Promise.all(t.map(t=>this.monitoringMultiCache.fetchOne(e,t))))}async _buildFavoritesHorizonMaps(){if(!this._hass||!this._favRefs||!this._topology)return;const t=new Set;for(const e of Object.values(this._favRefs))e.configEntryId&&t.add(e.configEntryId);const e=new Map;await Promise.all(Array.from(t).map(async t=>{e.set(t,await this._fetchGraphSettingsFresh(t))})),this.horizonMap.clear(),this.subDeviceHorizonMap.clear();for(const t of Object.keys(this._topology.circuits)){const n=this._favRefs[t],i=n?.configEntryId?e.get(n.configEntryId)??null:null,r=n?.targetId??t;this.horizonMap.set(t,Rk(i,r))}if(this._topology.sub_devices)for(const t of Object.keys(this._topology.sub_devices)){const n=this._favRefs[t],i=n?.configEntryId?e.get(n.configEntryId)??null:null,r=n?.targetId??t;this.subDeviceHorizonMap.set(t,Hk(i,r))}}async loadHistory(){await Me(this._hass,this._topology,this._config,this.powerHistory,this.horizonMap,this.subDeviceHorizonMap)}recordSamples(){if(!this._topology||!this._hass||!this._config)return;const t=Date.now();for(const[e,n]of Object.entries(this._topology.circuits)){const i=this.horizonMap.get(e)??a;if(!s[i]?.useRealtime)continue;const r=Zt(n,this._config);if(!r)continue;const o=this._hass.states[r];if(!o)continue;const l=parseFloat(o.state);if(isNaN(l))continue;const c=me(i),u=ye(c),d=_e(c),h=t-c,p=this.powerHistory.get(e)??[];p.length>0&&t-p[p.length-1].time0&&t-p[p.length-1].time0&&this._topology)for(const{key:t,devId:e}of Ce(this._topology))n.has(e)&&r.add(t);const o=new Map;try{await Me(this._hass,this._topology,this._config,o,e,n);for(const t of e.keys()){const e=o.get(t);e?this.powerHistory.set(t,e):this.powerHistory.delete(t)}for(const t of r){const e=o.get(t);e?this.powerHistory.set(t,e):this.powerHistory.delete(t)}this.updateDOM(t)}catch(t){console.warn("SPAN Panel: history refresh failed",t),this._errorStore?.add({key:"fetch:history",level:"warning",message:i("error.history_failed"),persistent:!1})}}updateDOM(t){this._hass&&this._topology&&this._config&&(function(t,e,n,r,o,a){if(!t||!n||!e)return;const s=ve(r);let l=0;for(const[,t]of Object.entries(n.circuits)){const n=t.entities?.power;if(!n)continue;const i=e.states[n],r=i&&parseFloat(i.state)||0;t.device_type!==u&&(l+=Math.abs(r))}zk(t,e,n,r,l);const d=Yt(r),h="current"===d.entityRole;for(const[r,l]of Object.entries(n.circuits)){const n=t.querySelector(`.circuit-slot[data-uuid="${Ok(r)}"]`);if(!n)continue;const p=l.entities?.power,f=p?e.states[p]:null,g=f&&parseFloat(f.state)||0,v=l.device_type===u||g<0,y=l.entities?.switch,_=y?e.states[y]:null,b=_?"on"===_.state:(f?.attributes?.relay_state||l.relay_state)===c,x=n.querySelector(".power-value");if(x)if(h){const t=l.entities?.current,n=t?e.states[t]:null,i=n&&parseFloat(n.state)||0;x.innerHTML=`${d.format(i)}A`}else x.innerHTML=`${Ut(g)}${Wt(g)}`;const w=n.querySelector(".toggle-pill");if(w){w.className="toggle-pill "+(b?"toggle-on":"toggle-off");const t=w.querySelector(".toggle-label");t&&(t.textContent=i(b?"grid.on":"grid.off"))}let S;if(n.classList.toggle("circuit-off",!b),n.classList.toggle("circuit-producer",v),l.always_on)S="always_on";else{const t=l.entities?.select,n=t?e.states[t]:null;S=n?n.state:"unknown"}const C=m[S]??m.unknown,M=n.querySelector(".shedding-icon");M&&(M.setAttribute("icon",C.icon),M.style.color=C.color,M.title=C.label());const k=n.querySelector(".shedding-icon-secondary");k&&(C.icon2?(k.setAttribute("icon",C.icon2),k.style.color=C.color,k.style.display=""):k.style.display="none");const T=n.querySelector(".shedding-label");T&&(C.textLabel?(T.textContent=C.textLabel,T.style.color=C.color,T.style.display=""):T.style.display="none");const A=n.querySelector(".chart-container");if(A){const t=o.get(r)||[],e=n.classList.contains("circuit-col-span")?200:100,i=a?.has(r)?me(a.get(r)):s,c=l.device_type===u;Ek(A,0,t,i,d,v,e,l.breaker_rating_a??void 0,c)}}}(t,this._hass,this._topology,this._config,this.powerHistory,this.horizonMap),function(t,e,n,i,r,o){if(!n.sub_devices)return;const a=ve(i);for(const[i,s]of Object.entries(n.sub_devices)){const n=t.querySelector(`[data-subdev="${Ok(i)}"]`);if(!n)continue;const l=ue(s);if(l){const t=e.states[l],i=t&&parseFloat(t.state)||0,r=n.querySelector(".sub-power-value");r&&(r.innerHTML=`${Ut(i)} ${Wt(i)}`)}const c=n.querySelectorAll("[data-chart-key]");for(const t of c){const e=t.dataset.chartKey;if(!e)continue;const n=r.get(e)||[];let s=v.power;e.endsWith("_soc")?s=v.soc:e.endsWith("_soe")&&(s=v.soe);const l=!!t.closest(".bess-chart-col");Ek(t,0,n,o?.has(i)?me(o.get(i)):a,s,!1,l?120:150,void 0,e.endsWith("_soc")||e.endsWith("_soe"))}for(const t of Object.keys(s.entities||{})){const i=n.querySelector(`[data-eid="${Ok(t)}"]`);if(!i)continue;const r=e.states[t];if(r){let t;if(e.formatEntityState)t=e.formatEntityState(r);else{t=r.state;const e=r.attributes.unit_of_measurement||"";e&&(t+=" "+e)}if("Wh"===(r.attributes.unit_of_measurement||"")){const e=parseFloat(r.state);isNaN(e)||(t=(e/1e3).toFixed(1)+" kWh")}i.textContent=t}}}}(t,this._hass,this._topology,this._config,this.powerHistory,this.subDeviceHorizonMap))}async onGraphSettingsChanged(t){if(this._hass){this._favRefs?await this._buildFavoritesHorizonMaps():(this.graphSettingsCache.invalidate(),await this.graphSettingsCache.fetch(this._hass,this._configEntryId),this.buildHorizonMaps(this.graphSettingsCache.settings)),this.powerHistory.clear();try{await this.loadHistory()}catch{}this.updateDOM(t)}}onToggleClick(t,e){const n=t.target,r=n?.closest(".toggle-pill");if(!r)return;const o=e.querySelector(".slide-confirm");if(!o||!o.classList.contains("confirmed"))return;t.stopPropagation(),t.preventDefault();const a=r.closest("[data-uuid]");if(!a||!this._topology||!this._hass)return;const s=a.dataset.uuid;if(!s)return;const l=this._topology.circuits[s];if(!l)return;const c=l.entities?.switch;if(!c)return;const u=this._hass.states[c];if(!u)return void console.warn("SPAN Panel: switch entity not found:",c);const d="on"===u.state?"turn_off":"turn_on";this._hass.callService("switch",d,{},{entity_id:c}).catch(t=>{console.warn("SPAN Panel: switch service call failed",t),this._errorStore?.add({key:"service:relay",level:"error",message:i("error.relay_failed"),persistent:!1})})}async onGearClick(t,e){const n=t.target,i=n?.closest(".gear-icon");if(!i)return;const r=e.querySelector("span-side-panel");if(!r||!this._hass)return;if(r.hass=this._hass,r.errorStore=this.errorStore,i.classList.contains("panel-gear")){if(this._inFavoritesView){const t=await this._buildFavoritesSections();if(0===t.length)return;return void r.open({favoritesMode:!0,perPanelSections:t})}return await this.graphSettingsCache.fetch(this._hass,this._configEntryId),void r.open({panelMode:!0,topology:this._topology,graphSettings:this.graphSettingsCache.settings,showFavorites:null!==this._panelFavorites,favoritePanelDeviceId:this._panelFavorites?.panelDeviceId,favoriteCircuitUuids:this._panelFavorites?.circuitUuids,favoriteSubDeviceIds:this._panelFavorites?.subDeviceIds,configEntryId:this._configEntryId})}const o=i.dataset.uuid;if(o&&this._topology){const t=this._topology.circuits[o];if(t){const e=this._favRefs?.[o]??null,n=e&&"circuit"===e.kind?e.targetId:o,i=e?.configEntryId??this._configEntryId;let s,l;e?[s,l]=await Promise.all([this._fetchGraphSettingsFresh(i),this._fetchMonitoringStatusFresh(i)]):(await Promise.all([this.graphSettingsCache.fetch(this._hass,i),this.monitoringCache.fetch(this._hass,i)]),s=this.graphSettingsCache.settings,l=this.monitoringCache.status);const c=t.entities?.current??t.entities?.power,u=c?l?.circuits?.[c]??null:null,d=s?.global_horizon??a,h=s?.circuits?.[n],p=h?{...h,globalHorizon:d}:{horizon:d,has_override:!1,globalHorizon:d},f=e?.panelDeviceId??this._panelFavorites?.panelDeviceId,g=null!==e||(this._panelFavorites?.circuitUuids.has(n)??!1),v=this._inFavoritesView||null!==this._panelFavorites;return void r.open({...t,uuid:n,monitoringInfo:u,showMonitoring:this._showMonitoring,graphHorizonInfo:p,showFavorites:v,favoritePanelDeviceId:f,isFavorite:g,configEntryId:i})}}const s=i.dataset.subdevId;if(s&&this._topology?.sub_devices?.[s]){const t=this._topology.sub_devices[s],e=this._favRefs?.[s]??null,n=e&&"sub_device"===e.kind?e.targetId:s,i=e?.configEntryId??this._configEntryId;let o;e?o=await this._fetchGraphSettingsFresh(i):(await this.graphSettingsCache.fetch(this._hass,i),o=this.graphSettingsCache.settings);const l=o?.global_horizon??a,c=o?.sub_devices?.[n],u=c?{...c,globalHorizon:l}:{horizon:l,has_override:!1,globalHorizon:l},d=e?.panelDeviceId??this._panelFavorites?.panelDeviceId,h=null!==e||(this._panelFavorites?.subDeviceIds.has(n)??!1),p=this._inFavoritesView||null!==this._panelFavorites;r.open({subDeviceMode:!0,subDeviceId:n,name:t.name??n,deviceType:t.type??"",entities:t.entities,graphHorizonInfo:u,showFavorites:p,favoritePanelDeviceId:d,isFavorite:h,configEntryId:i})}}async _buildFavoritesSections(){if(!this._hass||!this._favRefs)return[];const t=function(t,e){const n=new Map;for(const i of Object.values(t)){if("circuit"!==i.kind)continue;const t=e.get(i.panelDeviceId);if(void 0===t)continue;let r=n.get(i.panelDeviceId);void 0===r&&(r={panelDeviceId:i.panelDeviceId,panelName:t.panelName,topology:t.topology,configEntryId:t.configEntryId,favoriteCircuitUuids:new Set},n.set(i.panelDeviceId,r)),r.favoriteCircuitUuids.add(i.targetId)}return Array.from(n.values()).sort((t,e)=>t.panelName.localeCompare(e.panelName))}(this._favRefs,this._perPanelInfo);if(0===t.length)return[];return await Promise.all(t.map(async t=>({panelDeviceId:t.panelDeviceId,panelName:t.panelName,topology:t.topology,graphSettings:await this._fetchGraphSettingsFresh(t.configEntryId),favoriteCircuitUuids:t.favoriteCircuitUuids,configEntryId:t.configEntryId})))}async _fetchGraphSettingsFresh(t){if(!this._hass)return null;try{const e={};t&&(e.config_entry_id=t);const n={type:"call_service",domain:l,service:"get_graph_settings",service_data:e,return_response:!0},r=this._errorStore?new Qt(this._errorStore):null,o=r?await r.callWS(this._hass,n,{errorId:"fetch:graph_settings",errorMessage:i("error.graph_settings_failed")}):await this._hass.callWS(n);return o?.response??null}catch(t){return console.warn("SPAN Panel: fresh graph settings fetch failed",t),null}}async _fetchMonitoringStatusFresh(t){if(!this._hass)return null;try{const e={};t&&(e.config_entry_id=t);const n={type:"call_service",domain:l,service:"get_monitoring_status",service_data:e,return_response:!0},r=this._errorStore?new Qt(this._errorStore):null,o=r?await r.callWS(this._hass,n,{errorId:"fetch:monitoring",errorMessage:i("error.monitoring_failed")}):await this._hass.callWS(n),a=o?.response;return a?{circuits:a.circuits,mains:a.mains}:null}catch(t){return console.warn("SPAN Panel: fresh monitoring status fetch failed",t),null}}bindSlideConfirm(t,e){const n=t.querySelector(".slide-confirm-knob"),i=t.querySelector(".slide-confirm-text");if(!n||!i)return;let r=!1,o=0,a=0;const s=e=>{t.classList.contains("confirmed")||(r=!0,o=e-n.offsetLeft,a=t.offsetWidth-n.offsetWidth-4,n.classList.remove("snapping"))},l=t=>{if(!r)return;const e=Math.max(2,Math.min(t-o,a));n.style.left=e+"px"},c=()=>{if(!r)return;r=!1;(n.offsetLeft-2)/a>=.9?(n.style.left=a+"px",t.classList.add("confirmed"),n.querySelector("span-icon")?.setAttribute("icon","mdi:lock-open"),i.textContent=t.dataset.textOn??"",e&&e.classList.remove("switches-disabled")):(n.classList.add("snapping"),n.style.left="2px")};n.addEventListener("mousedown",t=>{t.preventDefault(),s(t.clientX)}),t.addEventListener("mousemove",t=>l(t.clientX)),t.addEventListener("mouseup",c),t.addEventListener("mouseleave",c),n.addEventListener("touchstart",t=>{t.preventDefault(),s(t.touches[0].clientX)},{passive:!1}),t.addEventListener("touchmove",t=>l(t.touches[0].clientX),{passive:!0}),t.addEventListener("touchend",c),t.addEventListener("touchcancel",c),t.addEventListener("click",()=>{t.classList.contains("confirmed")&&(t.classList.remove("confirmed"),n.classList.add("snapping"),n.style.left="2px",n.querySelector("span-icon")?.setAttribute("icon","mdi:lock"),i.textContent=t.dataset.textOff??"",e&&e.classList.add("switches-disabled"))})}startIntervals(t,e){this._updateInterval=setInterval(()=>{this.recordSamples(),this.updateDOM(t),e&&e()},1e3),this._recorderRefreshInterval=setInterval(()=>{this.refreshRecorderData(t)},3e4)}stopIntervals(){this._updateInterval&&(clearInterval(this._updateInterval),this._updateInterval=null),this._recorderRefreshInterval&&(clearInterval(this._recorderRefreshInterval),this._recorderRefreshInterval=null),this.cleanupResizeObserver()}setupResizeObserver(t,e){this.cleanupResizeObserver(),e&&(this._lastWidth=e.clientWidth,this._resizeObserver=new ResizeObserver(e=>{const n=e[0];if(!n)return;const i=n.contentRect.width;Math.abs(i-this._lastWidth)<5||(this._lastWidth=i,this._resizeDebounce&&clearTimeout(this._resizeDebounce),this._resizeDebounce=setTimeout(()=>{for(const e of t.querySelectorAll(".chart-container")){const t=e.querySelector("span-chart");t&&t.remove()}this.updateDOM(t)},150))}),this._resizeObserver.observe(e))}cleanupResizeObserver(){this._resizeObserver&&(this._resizeObserver.disconnect(),this._resizeObserver=null),this._resizeDebounce&&(clearTimeout(this._resizeDebounce),this._resizeDebounce=null)}reset(){this.powerHistory.clear(),this.horizonMap.clear(),this.subDeviceHorizonMap.clear(),this.monitoringCache.clear(),this.monitoringMultiCache.clear(),this.graphSettingsCache.clear()}}function Fk(t=""){const e=t?` value="${Rt(t)}"`:"",n=t?"":"display:none;";return`\n
\n \n \n
\n `}function $k(t,e,n,r,o,a,s){const l=e.entities?.power,u=l?n.states[l]:null,d=u&&parseFloat(u.state)||0,h=e.entities?.switch,p=h?n.states[h]:null,f=p?"on"===p.state:(u?.attributes?.relay_state||e.relay_state)===c,g=e.breaker_rating_a,v=g?`${Math.round(g)}A`:"",y=Rt(e.name||i("grid.unknown")),_=Yt(r),b="current"===_.entityRole;let x;if(f)if(b){const t=e.entities?.current,i=t?n.states[t]:null,r=i&&parseFloat(i.state)||0;x=`${_.format(r)}A`}else x=`${Ut(d)}${Wt(d)}`;else x="";const w=a||"unknown";let S="";if("unknown"!==w){const t=m[w]??m.unknown??{icon:"mdi:help",color:"#999",label:()=>"Unknown"};S=t.icon2?`\n \n \n `:t.textLabel?`\n \n ${t.textLabel}\n `:``}let C="",M=o?.utilization_pct??null;if(null==M&&e.breaker_rating_a){const t=e.entities?.current,i=t?n.states[t]:null,r=i?Math.abs(parseFloat(i.state)||0):0;M=Math.round(r/e.breaker_rating_a*1e3)/10}if(null!=M){C=`=80?"utilization-warning":"utilization-normal"}">${Math.round(M)}%`}const k=``,T=!1!==e.is_user_controllable&&!!e.entities?.switch?`
\n ${i(f?"grid.on":"grid.off")}\n \n
`:`${f?"ON":"OFF"}`;return`\n
\n ${v?`${v}`:""}\n ${C}\n ${y}\n ${S}\n ${T}\n \n ${x}\n \n ${k}\n \n
\n `}function Vk(t,e,n,i,r){const o=e.entities?.power,a=o?n.states[o]:null,s=a&&parseFloat(a.state)||0,l=e.device_type===u||s<0,d=e.entities?.switch,h=d?n.states[d]:null,p=ne(0,r,h?"on"===h.state:(a?.attributes?.relay_state||e.relay_state)===c,l),f=Rt(t);return`\n
\n
\n
\n
\n
\n `}function Wk(t){return`
${Rt(t)}
`}function Uk(t,e){const n=e.hysteresisPx??48,i=new WeakSet;let r=null;const o=t=>{const n=t.querySelector(e.nameSelector);return!!n&&(0!==t.clientWidth&&(n.clientWidth<1||n.scrollWidth>n.clientWidth+1))},a=()=>{const i=t.querySelectorAll(e.rowSelector);if(0===i.length)return;const a=i[0].clientWidth;if(0===a)return;if(null!==r){if(a>r+n){for(const t of i)t.classList.remove(e.foldClass);r=null}else for(const t of i)t.classList.add(e.foldClass);return}let s=!1;for(const t of i)if(o(t)){s=!0;break}if(s){for(const t of i)t.classList.add(e.foldClass);r=a}},s=new ResizeObserver(()=>a()),l=()=>{const n=t.querySelectorAll(e.rowSelector);for(const t of n)i.has(t)||(i.add(t),s.observe(t));requestAnimationFrame(()=>a())};l();const c=new MutationObserver(t=>{(t=>{const n=t=>{for(const n of t)if(n instanceof Element){if(n.matches(e.rowSelector))return!0;if(n.querySelector(e.rowSelector))return!0}return!1};for(const e of t)if(n(e.addedNodes)||n(e.removedNodes))return!0;return!1})(t)&&l()});return c.observe(t,{childList:!0,subtree:!0}),()=>{s.disconnect(),c.disconnect()}}function Gk(t,e,n){const i=t.entities?.switch,r=i?e.states[i]:null,o=t.entities?.power,a=o?e.states[o]:null,s=r?"on"===r.state:(a?.attributes?.relay_state||t.relay_state)===c;let l;if("current"===(n.chart_metric||"power")){const n=t.entities?.current,i=n?e.states[n]:null;l=i?Math.abs(parseFloat(i.state)||0):0}else l=a?Math.abs(parseFloat(a.state)||0):0;return{isOn:s,value:l}}function qk(t,e){if(t.always_on)return"always_on";const n=t.entities?.select,i=n?e.states[n]:null;return i?i.state:"unknown"}function jk(t,e,n,i){const r=Gk(t,n,i),o=Gk(e,n,i);return r.isOn&&!o.isOn?-1:!r.isOn&&o.isOn?1:o.value-r.value}function Xk(t,e,n){return t.sort((t,i)=>jk(t[1],i[1],e,n))}function Yk(t){return t.entities?.current??t.entities?.power??""}class Zk{constructor(t){this._expandedUuids=new Set,this._searchQuery="",this._container=null,this._clickHandler=null,this._inputHandler=null,this._graphSettingsHandler=null,this._hass=null,this._topology=null,this._config=null,this._monitoringStatus=null,this._viewName=null,this._columns=1,this._foldUnobserve=null,this._ctrl=t}setColumns(t){const e=Math.max(1,Math.min(3,Math.floor(t)));this._columns=e}setInitialExpansion(t){this._expandedUuids=new Set(t)}setInitialSearchQuery(t){this._searchQuery=t}setViewName(t){this._viewName=t}renderActivityView(t,e,n,i,r,o){this._unbindEvents(),this._hass=e,this._topology=n,this._config=i,this._monitoringStatus=r;const a=Xk(Object.entries(n.circuits),e,i);let s=o+Fk(this._searchQuery);s+=`
`;for(const[t,n]of a){const o=ee(r,Yk(n)),a=qk(n,e),l=this._expandedUuids.has(t);s+=`
`,s+=$k(t,n,e,i,o,a,l),l&&(s+=Vk(t,n,e,0,o)),s+="
"}s+="
",s+="",t.innerHTML=s;const l=t.querySelector("span-side-panel");l&&(l.hass=e,l.errorStore=this._ctrl.errorStore),this._bindEvents(t),this._searchQuery&&this._applyFilter(t),this._ctrl.updateDOM(t),this._attachFoldObserver(t)}renderAreaView(t,e,n,r,o,a){this._unbindEvents(),this._hass=e,this._topology=n,this._config=r,this._monitoringStatus=o;const s=i("list.unassigned_area"),l=new Map;for(const[t,e]of Object.entries(n.circuits)){const n=e.area??s,i=l.get(n);i?i.push([t,e]):l.set(n,[[t,e]])}const c=[...l.keys()].sort((t,e)=>t===s?1:e===s?-1:t.localeCompare(e));let u=a+Fk(this._searchQuery);u+=`
`;for(const t of c){const n=l.get(t);if(!n)continue;const i=Xk(n,e,r);u+=Wk(t);for(const[t,n]of i){const i=ee(o,Yk(n)),a=qk(n,e),s=this._expandedUuids.has(t);u+=`
`,u+=$k(t,n,e,r,i,a,s),s&&(u+=Vk(t,n,e,0,i)),u+="
"}}u+="
",u+="",t.innerHTML=u;const d=t.querySelector("span-side-panel");d&&(d.hass=e,d.errorStore=this._ctrl.errorStore),this._bindEvents(t),this._searchQuery&&this._applyFilter(t),this._ctrl.updateDOM(t),this._attachFoldObserver(t)}updateCollapsedRows(t,e,n,r){const o=Yt(r),a="current"===o.entityRole,s=t.querySelectorAll(".list-row[data-row-uuid]");for(const t of s){const s=t.dataset.rowUuid;if(!s)continue;const l=n.circuits[s];if(!l)continue;const{isOn:c,value:u}=Gk(l,e,r),d=t.querySelector(".list-power-value");if(d)if(c)if(a)d.innerHTML=`${o.format(u)}A`;else{const t=l.entities?.power,n=t?e.states[t]:null,i=n&&parseFloat(n.state)||0;d.innerHTML=`${Ut(i)}${Wt(i)}`}else d.innerHTML="";const h=t.querySelector(".toggle-pill");if(h){h.classList.toggle("toggle-on",c),h.classList.toggle("toggle-off",!c);const t=h.querySelector(".toggle-label");t&&(t.textContent=i(c?"grid.on":"grid.off"))}const p=t.querySelector(".list-status-badge");p&&(p.textContent=c?"ON":"OFF",p.classList.toggle("list-status-on",c),p.classList.toggle("list-status-off",!c)),t.classList.toggle("circuit-off",!c)}!function(t,e,n,i){const r=t.querySelector(".list-view");if(r)for(const t of function(t,e){let n={anchor:null,units:[]};const i=[n];for(const r of[...t.children])if(r.classList.contains("area-header"))n={anchor:r,units:[]},i.push(n);else if(r.classList.contains("list-cell")){const t=r.dataset.cellUuid,i=t?e.circuits[t]:void 0;t&&i&&n.units.push({cell:r,uuid:t,circuit:i})}return i}(r,n)){if(t.units.length<2)continue;const n=[...t.units].sort((t,n)=>jk(t.circuit,n.circuit,e,i));if(!n.some((e,n)=>e.uuid!==t.units[n].uuid))continue;let o=t.anchor;for(const t of n)o?o.after(t.cell):r.prepend(t.cell),o=t.cell}}(t,e,n,r)}stop(){this._unbindEvents(),null===this._viewName&&(this._expandedUuids.clear(),this._searchQuery=""),this._hass=null,this._topology=null,this._config=null,this._monitoringStatus=null}_dispatchFavoritesViewState(){if(!this._viewName||!this._container)return;const t={view:this._viewName,expanded:[...this._expandedUuids],searchQuery:this._searchQuery};this._container.dispatchEvent(new CustomEvent("favorites-view-state-changed",{detail:t,bubbles:!0,composed:!0}))}_bindEvents(t){this._container=t,this._clickHandler=e=>{const n=e.target;if(!n)return;const i=n.closest(".list-expand-toggle");if(i){const t=i.dataset.expandUuid;return void(t&&this._toggleExpand(t))}if(n.closest(".gear-icon"))return void this._ctrl.onGearClick(e,t);if(n.closest(".toggle-pill"))return void this._ctrl.onToggleClick(e,t);if(n.closest(".list-search-clear")){const e=t.querySelector(".list-search");return void(e&&(e.value="",e.dispatchEvent(new Event("input",{bubbles:!0}))))}const r=n.closest(".unit-btn");if(r){const e=r.dataset.unit;e&&t.dispatchEvent(new CustomEvent("unit-changed",{detail:e,bubbles:!0,composed:!0}))}},this._inputHandler=e=>{const n=e.target;n&&n.classList.contains("list-search")&&(this._searchQuery=n.value.toLowerCase(),this._applyFilter(t),this._dispatchFavoritesViewState())},this._graphSettingsHandler=()=>{this._ctrl.onGraphSettingsChanged(t).then(()=>{this._ctrl.updateDOM(t)}).catch(()=>{})},t.addEventListener("click",this._clickHandler),t.addEventListener("input",this._inputHandler),t.addEventListener("graph-settings-changed",this._graphSettingsHandler);const e=t.querySelector(".slide-confirm");e&&(this._ctrl.bindSlideConfirm(e,t),t.classList.add("switches-disabled"))}_unbindEvents(){this._container&&(this._clickHandler&&this._container.removeEventListener("click",this._clickHandler),this._inputHandler&&this._container.removeEventListener("input",this._inputHandler),this._graphSettingsHandler&&this._container.removeEventListener("graph-settings-changed",this._graphSettingsHandler)),this._foldUnobserve&&(this._foldUnobserve(),this._foldUnobserve=null),this._container=null,this._clickHandler=null,this._inputHandler=null,this._graphSettingsHandler=null}_attachFoldObserver(t){this._foldUnobserve&&(this._foldUnobserve(),this._foldUnobserve=null),this._foldUnobserve=Uk(t,{rowSelector:".list-row",nameSelector:".list-circuit-name",foldClass:"is-folded"})}_applyFilter(t){const e=t.querySelector(".list-search-clear");e&&(e.style.display=this._searchQuery?"":"none");const n=t.querySelectorAll(".list-cell[data-cell-uuid]");for(const t of n){const e=t.querySelector(".list-circuit-name"),n=(e?.textContent?.toLowerCase()??"").includes(this._searchQuery);t.style.display=n?"":"none"}const i=t.querySelectorAll(".area-header");for(const t of i){let e=!1,n=t.nextElementSibling;for(;n&&!n.classList.contains("area-header");){if(n.classList.contains("list-cell")&&"none"!==n.style.display){e=!0;break}n=n.nextElementSibling}t.style.display=e?"":"none"}}_toggleExpand(t){if(!(this._container&&this._hass&&this._topology&&this._config))return;const e=Ok(t),n=this._container.querySelector(`.list-cell[data-cell-uuid="${e}"]`);if(!n)return;const i=n.querySelector(`.list-row[data-row-uuid="${e}"]`),r=n.querySelector(`.list-expand-toggle[data-expand-uuid="${e}"]`);if(i){if(this._expandedUuids.has(t)){this._expandedUuids.delete(t);const o=n.querySelector(`.list-expanded-content[data-expanded-uuid="${e}"]`);o&&o.remove(),r&&r.classList.remove("expanded"),i.classList.remove("list-row-expanded")}else{this._expandedUuids.add(t);const e=this._topology.circuits[t];if(!e)return;const n=ee(this._monitoringStatus,Yk(e)),o=Vk(t,e,this._hass,this._config,n);i.insertAdjacentHTML("afterend",o),r&&r.classList.add("expanded"),i.classList.add("list-row-expanded"),this._ctrl.updateDOM(this._container)}this._dispatchFavoritesViewState()}}}async function Kk(t,e){const[n,i,r]=await Promise.all([t.callWS({type:"config/area_registry/list"}),t.callWS({type:"config/entity_registry/list"}),t.callWS({type:"config/device_registry/list"})]),o=new Map;for(const t of n)o.set(t.area_id,t.name);const a=new Map;for(const t of i)t.area_id&&a.set(t.entity_id,t.area_id);const s=new Map;for(const t of r)s.set(t.id,t.area_id);let l;if(e.device_id){const t=s.get(e.device_id);t&&(l=o.get(t))}for(const t of Object.values(e.circuits)){let e;for(const n of Object.values(t.entities)){if(!n)continue;const t=a.get(n);if(t){e=o.get(t);break}}e||(e=l),t.area=e}}class Qk{constructor(){this._persistent=new Map,this._transient=null,this._transientTimer=null,this._subscribers=new Set,this._watchedPanels=new Map}add(t){const e={...t,timestamp:Date.now()};if(e.persistent)this._persistent.set(e.key,e);else{this._clearTransient(),this._transient=e;const t=e.ttl??5e3;this._transientTimer=setTimeout(()=>{this._transient=null,this._transientTimer=null,this._notify()},t)}this._notify()}remove(t){if(this._persistent.has(t))return this._persistent.delete(t),void this._notify();this._transient?.key===t&&(this._clearTransient(),this._notify())}clear(t){void 0===t?(this._persistent.clear(),this._clearTransient(),this._watchedPanels.clear()):!0===t.persistent?this._persistent.clear():!1===t.persistent&&this._clearTransient(),this._notify()}get active(){const t=[...this._persistent.values()];return null!==this._transient&&t.push(this._transient),t}hasPersistent(t){return this._persistent.has(t)}hasAnyPanelOffline(){for(const t of this._persistent.keys())if("panel-offline"===t||t.startsWith("panel-offline:"))return!0;return!1}subscribe(t){return this._subscribers.add(t),()=>{this._subscribers.delete(t)}}watchPanelStatus(t){this.watchPanelStatuses([{entityId:t,panelName:null}])}watchPanelStatuses(t){const e=this._watchedPanels,n=new Map;for(const i of t){const t=e.get(i.entityId);n.set(i.entityId,{panelName:i.panelName??null,wasOffline:t?.wasOffline??!1})}const i=this._isSingleUnnamed(e),r=this._isSingleUnnamed(n);for(const t of e.keys()){n.has(t)&&i===r||this._persistent.delete(this._offlineKey(t,i))}this._watchedPanels=n,this._notify()}clearPanelStatusWatch(){if(0===this._watchedPanels.size)return;const t=this._isSingleUnnamed(this._watchedPanels);for(const e of this._watchedPanels.keys())this._persistent.delete(this._offlineKey(e,t));this._watchedPanels.clear(),this._notify()}updateHass(t){if(0===this._watchedPanels.size)return;const e=this._isSingleUnnamed(this._watchedPanels);for(const[n,o]of this._watchedPanels){const a=t.states[n]?.state,s="on"===a,l=this._offlineKey(n,e),c=this._reconnectKey(n,e);if(s){const t=o.wasOffline;o.wasOffline=!1,this.remove(l),t&&this.add({key:c,level:"info",message:null===o.panelName?i("error.panel_reconnected"):r("error.panel_reconnected_named",{name:o.panelName}),persistent:!1})}else o.wasOffline=!0,this.hasPersistent(l)||this.add({key:l,level:"error",message:null===o.panelName?i("error.panel_offline"):r("error.panel_offline_named",{name:o.panelName}),persistent:!0})}}dispose(){this._clearTransient(),this._persistent.clear(),this._subscribers.clear(),this._watchedPanels.clear()}_isSingleUnnamed(t){if(1!==t.size)return!1;for(const e of t.values())return null===e.panelName;return!1}_offlineKey(t,e){return e?"panel-offline":`panel-offline:${t}`}_reconnectKey(t,e){return e?"panel-reconnected":`panel-reconnected:${t}`}_clearTransient(){null!==this._transientTimer&&(clearTimeout(this._transientTimer),this._transientTimer=null),this._transient=null}_notify(){for(const t of this._subscribers)try{t()}catch(t){console.warn("SPAN Panel: error-store subscriber threw",t)}}}function Jk(t){let e=0;for(const n of Object.values(t))if(n)for(const t of n.tabs)t>e&&(e=t);return e>0?e+e%2:0}function tT(t){return t?{id:t.id,name:t.name,name_by_user:t.name_by_user,config_entries:t.config_entries,identifiers:t.identifiers,via_device_id:t.via_device_id,sw_version:t.sw_version,model:t.model}:null}const eT="favorites-changed";async function nT(t,e,n={}){const i=await t.callWS({type:"call_service",domain:l,service:e,service_data:n,return_response:!0});return i?.response??null}const iT=Object.keys(m).filter(t=>"unknown"!==t&&"always_on"!==t);class rT extends HTMLElement{constructor(){super(),this.errorStore=null,this.attachShadow({mode:"open"}),this._hass=null,this._config=null,this._debounceTimers={}}set hass(t){this._hass=t,this.hasAttribute("open")&&this._config&&this._updateLiveState()}get hass(){return this._hass}disconnectedCallback(){this._clearDebounceTimers(),this._config=null}open(t){this._config=t,this._render(),this.offsetHeight,this.setAttribute("open",""),this.setAttribute("data-mode",this._modeFor(t))}close(){this._clearDebounceTimers(),this.removeAttribute("open"),this.removeAttribute("data-mode"),this._config=null,this.dispatchEvent(new CustomEvent("side-panel-closed",{bubbles:!0,composed:!0}))}_clearDebounceTimers(){for(const t of Object.keys(this._debounceTimers))clearTimeout(this._debounceTimers[t]);this._debounceTimers={}}_modeFor(t){return t.favoritesMode?"favorites":t.panelMode?"panel":t.subDeviceMode?"subDevice":"circuit"}_render(){const t=this._config;if(!t)return;const e=this.shadowRoot;if(!e)return;e.innerHTML="";const n=document.createElement("style");n.textContent='\n :host {\n display: block;\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n width: 360px;\n max-width: 90vw;\n z-index: 1000;\n transform: translateX(100%);\n transition: transform 0.3s ease;\n pointer-events: none;\n }\n :host([open]) {\n transform: translateX(0);\n pointer-events: auto;\n }\n\n .backdrop {\n display: none;\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba(0, 0, 0, 0.3);\n z-index: -1;\n }\n :host([open]) .backdrop {\n display: block;\n }\n\n .panel {\n height: 100%;\n background: var(--card-background-color, #fff);\n border-left: 1px solid var(--divider-color, #e0e0e0);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n .panel-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 16px;\n border-bottom: 1px solid var(--divider-color, #e0e0e0);\n }\n .panel-header .title {\n font-size: 18px;\n font-weight: 500;\n color: var(--primary-text-color, #212121);\n margin: 0;\n }\n .panel-header .subtitle {\n font-size: 13px;\n color: var(--secondary-text-color, #727272);\n margin: 2px 0 0 0;\n }\n .close-btn {\n background: none;\n border: none;\n cursor: pointer;\n color: var(--secondary-text-color, #727272);\n padding: 4px;\n line-height: 1;\n font-size: 20px;\n }\n\n .panel-body {\n flex: 1;\n overflow-y: auto;\n padding: 16px;\n }\n\n .section {\n margin-bottom: 20px;\n }\n .section-label {\n font-size: 12px;\n font-weight: 600;\n text-transform: uppercase;\n color: var(--secondary-text-color, #727272);\n margin: 0 0 8px 0;\n letter-spacing: 0.5px;\n }\n\n .field-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 8px 0;\n }\n .field-label {\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n }\n\n select {\n padding: 6px 8px;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 4px;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 14px;\n }\n\n input[type="number"] {\n width: 72px;\n padding: 6px 8px;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 4px;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 14px;\n text-align: right;\n }\n input[type="number"]:disabled {\n opacity: 0.5;\n }\n\n .radio-group {\n display: flex;\n gap: 16px;\n padding: 8px 0;\n }\n .radio-group label {\n display: flex;\n align-items: center;\n gap: 6px;\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n cursor: pointer;\n }\n\n .horizon-bar {\n display: flex;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 6px;\n overflow: hidden;\n margin-top: 4px;\n }\n .horizon-segment {\n flex: 1;\n padding: 6px 0;\n text-align: center;\n font-size: 13px;\n cursor: pointer;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n border: none;\n border-right: 1px solid var(--divider-color, #e0e0e0);\n transition: background 0.15s ease, color 0.15s ease;\n user-select: none;\n line-height: 1.4;\n }\n .horizon-segment:last-child {\n border-right: none;\n }\n .horizon-segment:hover:not(.active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .horizon-segment.active {\n background: var(--primary-color, #03a9f4);\n color: #fff;\n font-weight: 600;\n }\n .horizon-segment.referenced {\n box-shadow: inset 0 -3px 0 var(--primary-color, #03a9f4);\n }\n\n .unit-toggle {\n display: inline-flex;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 6px;\n overflow: hidden;\n }\n .unit-btn {\n padding: 4px 10px;\n border: none;\n border-right: 1px solid var(--divider-color, #e0e0e0);\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 13px;\n font-weight: 500;\n cursor: pointer;\n transition: background 0.15s ease, color 0.15s ease;\n }\n .unit-btn:last-child {\n border-right: none;\n }\n .unit-btn:hover:not(.unit-active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .unit-btn.unit-active {\n background: var(--primary-color, #03a9f4);\n color: #fff;\n font-weight: 600;\n }\n\n .monitoring-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n }\n\n .fav-heart {\n background: none;\n border: 1px solid var(--divider-color, #e0e0e0);\n color: var(--secondary-text-color, #727272);\n border-radius: 4px;\n padding: 2px 6px;\n cursor: pointer;\n font-size: 0.9em;\n margin-right: 6px;\n line-height: 1;\n display: inline-flex;\n align-items: center;\n }\n .fav-heart.active {\n color: var(--primary-color, #03a9f4);\n border-color: var(--primary-color, #03a9f4);\n }\n .fav-heart:hover:not(.active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .fav-heart span-icon {\n --mdc-icon-size: 16px;\n }\n\n .panel-mode-info {\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n line-height: 1.6;\n }\n .panel-mode-info p {\n margin: 0 0 12px 0;\n }\n\n',e.appendChild(n);const i=document.createElement("div");i.className="backdrop",i.addEventListener("click",()=>this.close()),e.appendChild(i);const r=document.createElement("div");r.className="panel",e.appendChild(r),t.favoritesMode?this._renderFavoritesMode(r):t.panelMode?this._renderPanelMode(r):t.subDeviceMode?this._renderSubDeviceMode(r,t):this._renderCircuitMode(r,t)}_renderPanelMode(t){const e=this._config,n=this._createHeader(i("sidepanel.graph_settings"),i("sidepanel.global_defaults"));t.appendChild(n);const r=document.createElement("div");r.className="panel-body";const o=e.graphSettings,l=e.topology,c=o?.global_horizon??a,u=o?.circuits??{};r.appendChild(this._buildListColumnsSection());const d=document.createElement("div");d.className="section";const h=document.createElement("div");h.className="section-label",h.textContent=i("sidepanel.graph_horizon"),d.appendChild(h);const p=document.createElement("div");p.className="field-row";const g=document.createElement("span");g.className="field-label",g.textContent=i("sidepanel.global_default"),p.appendChild(g);const v=document.createElement("select");for(const t of Object.keys(s)){const e=document.createElement("option");e.value=t;const n=`horizon.${t}`,r=i(n);e.textContent=r!==n?r:t,t===c&&(e.selected=!0),v.appendChild(e)}if(v.addEventListener("change",()=>{const t={horizon:v.value};e.configEntryId&&(t.config_entry_id=e.configEntryId),this._callDomainService("set_graph_time_horizon",t).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})}),p.appendChild(v),d.appendChild(p),r.appendChild(d),l?.circuits){const t=document.createElement("div");t.className="section";const n=document.createElement("div");n.className="section-label",n.textContent=i("sidepanel.circuit_scales"),t.appendChild(n);const o=Object.entries(l.circuits).sort(([,t],[,e])=>(t.name||"").localeCompare(e.name||""));for(const[n,i]of o){const r=this._buildPanelModeCircuitRow(n,i,u[n],c,e.configEntryId??null,e.showFavorites??!1,e.favoritePanelDeviceId,e.favoriteCircuitUuids);t.appendChild(r)}r.appendChild(t)}const m=o?.sub_devices??{};if(l?.sub_devices){const t=document.createElement("div");t.className="section";const n=document.createElement("div");n.className="section-label",n.textContent=i("sidepanel.subdevice_scales"),t.appendChild(n);const o=Object.entries(l.sub_devices).sort(([,t],[,e])=>(t.name||"").localeCompare(e.name||""));for(const[n,r]of o){const o=document.createElement("div");o.className="field-row";const a=document.createElement("span");if(a.className="field-label",a.textContent=r.name||n,a.style.cssText="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;",o.appendChild(a),e.showFavorites&&e.favoritePanelDeviceId){const t=this._buildSubDeviceFavoriteHeart(r.entities,e.favoriteSubDeviceIds?.has(n)??!1);t&&o.appendChild(t)}const l=m[n]||{horizon:c,has_override:!1},u=l.has_override?l.horizon:c,d=document.createElement("select");d.dataset.subdevId=n;for(const t of Object.keys(s)){const e=document.createElement("option");e.value=t;const n=`horizon.${t}`,r=i(n);e.textContent=r!==n?r:t,t===u&&(e.selected=!0),d.appendChild(e)}if(d.addEventListener("change",()=>{this._debounce(`subdev-${n}`,f,()=>{const t={subdevice_id:n,horizon:d.value};e.configEntryId&&(t.config_entry_id=e.configEntryId),this._callDomainService("set_subdevice_graph_horizon",t).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})})}),o.appendChild(d),l.has_override){const t=document.createElement("button");t.textContent="↺",t.title=i("sidepanel.reset_to_global"),Object.assign(t.style,{background:"none",border:"1px solid var(--divider-color, #e0e0e0)",color:"var(--primary-text-color)",borderRadius:"4px",padding:"3px 6px",cursor:"pointer",marginLeft:"4px",fontSize:"0.85em"}),t.addEventListener("click",()=>{const r={subdevice_id:n};e.configEntryId&&(r.config_entry_id=e.configEntryId),this._callDomainService("clear_subdevice_graph_horizon",r).then(()=>{d.value=c,t.remove(),this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})}),o.appendChild(t)}t.appendChild(o)}r.appendChild(t)}t.appendChild(r)}_buildPanelModeCircuitRow(t,e,n,r,o,a,l,c){const u=document.createElement("div");u.className="field-row";const d=document.createElement("span");if(d.className="field-label",d.textContent=e.name||t,d.style.cssText="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;",u.appendChild(d),a&&l){const n=this._buildFavoriteHeart(e.entities,c?.has(t)??!1);n&&u.appendChild(n)}const h=n||{horizon:r,has_override:!1},p=h.has_override?h.horizon:r,g=document.createElement("select");g.dataset.uuid=t;for(const t of Object.keys(s)){const e=document.createElement("option");e.value=t;const n=`horizon.${t}`,r=i(n);e.textContent=r!==n?r:t,t===p&&(e.selected=!0),g.appendChild(e)}if(g.addEventListener("change",()=>{this._debounce(`circuit-${t}`,f,()=>{const e={circuit_id:t,horizon:g.value};o&&(e.config_entry_id=o),this._callDomainService("set_circuit_graph_horizon",e).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})})}),u.appendChild(g),h.has_override){const e=document.createElement("button");e.textContent="↺",e.title=i("sidepanel.reset_to_global"),Object.assign(e.style,{background:"none",border:"1px solid var(--divider-color, #e0e0e0)",color:"var(--primary-text-color)",borderRadius:"4px",padding:"3px 6px",cursor:"pointer",marginLeft:"4px",fontSize:"0.85em"}),e.addEventListener("click",()=>{const n={circuit_id:t};o&&(n.config_entry_id=o),this._callDomainService("clear_circuit_graph_horizon",n).then(()=>{g.value=r,e.remove(),this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})}),u.appendChild(e)}return u}_renderFavoritesMode(t){const e=this._config,n=this._createHeader(i("sidepanel.graph_settings"),i("sidepanel.favorites_subtitle"));t.appendChild(n);const r=document.createElement("div");r.className="panel-body",r.appendChild(this._buildListColumnsSection());for(const t of e.perPanelSections)r.appendChild(this._buildFavoritesPanelSection(t));t.appendChild(r)}_buildFavoritesPanelSection(t){const e=document.createElement("div");e.className="section";const n=document.createElement("div");n.className="section-label",n.textContent=t.panelName,e.appendChild(n);const i=t.graphSettings?.global_horizon??a,r=t.graphSettings?.circuits??{},o=function(t){const e=t.circuits??{};return Object.entries(e).map(([t,e])=>({uuid:t,circuit:e})).sort((t,e)=>(t.circuit.name||"").localeCompare(e.circuit.name||""))}(t.topology);for(const{uuid:n,circuit:a}of o){const o=this._buildPanelModeCircuitRow(n,a,r[n],i,t.configEntryId,!0,t.panelDeviceId,t.favoriteCircuitUuids);e.appendChild(o)}return e}_renderCircuitMode(t,e){const n=`${Rt(String(e.breaker_rating_a))}A · ${Rt(String(e.voltage))}V · Tabs [${Rt(String(e.tabs))}]`,i=this._createHeader(Rt(e.name),n);t.appendChild(i);const r=document.createElement("div");r.className="panel-body",t.appendChild(r),this._renderRelaySection(r,e),e.showFavorites&&this._renderFavoriteSection(r,e),this._renderSheddingSection(r,e),this._renderGraphHorizonSection(r,e),e.showMonitoring&&this._renderMonitoringSection(r,e)}_favoriteEntityId(t){return t?.current??t?.power??null}_subDeviceFavoriteEntityId(t){if(!t)return null;let e=null;for(const[n,i]of Object.entries(t)){if("sensor"===i.domain)return n;e||(e=n)}return e}_buildSubDeviceFavoriteHeart(t,e){const n=this._subDeviceFavoriteEntityId(t);return n?this._buildHeartButton(n,e):null}_buildListColumnsSection(){const t=document.createElement("div");t.className="section";const e=document.createElement("div");e.className="section-label",e.textContent=i("sidepanel.list_view_columns"),t.appendChild(e);const n=document.createElement("div");n.className="field-row";const r=document.createElement("span");r.className="field-label",r.textContent=i("sidepanel.columns"),n.appendChild(r);const o=Bt(),a=document.createElement("div");a.className="unit-toggle";for(const t of[1,2,3]){const e=document.createElement("button");e.type="button",e.className="unit-btn"+(t===o?" unit-active":""),e.dataset.columns=String(t),e.textContent=String(t),e.addEventListener("click",()=>{Ft(t);for(const t of a.querySelectorAll(".unit-btn"))t.classList.toggle("unit-active",t===e);this.dispatchEvent(new CustomEvent("list-columns-changed",{detail:t,bubbles:!0,composed:!0}))}),a.appendChild(e)}return n.appendChild(a),t.appendChild(n),t}_buildFavoriteHeart(t,e){const n=this._favoriteEntityId(t);return n?this._buildHeartButton(n,e):(console.warn("SPAN Panel: circuit has no current/power sensor; favorite heart suppressed"),null)}_buildHeartButton(t,e){const n=document.createElement("button");n.type="button",n.className=e?"fav-heart active":"fav-heart",n.dataset.role="fav-heart",n.title=i("sidepanel.save_to_favorites"),n.setAttribute("role","switch"),n.setAttribute("aria-checked",String(e)),n.setAttribute("aria-label",i("sidepanel.save_to_favorites"));const r=document.createElement("span-icon");return r.setAttribute("icon",e?"mdi:heart":"mdi:heart-outline"),n.appendChild(r),n.addEventListener("click",e=>{e.stopPropagation(),this._toggleFavoriteEntity(n,r,t).catch(()=>{})}),n}async _toggleFavoriteEntity(t,e,n){if(!this._hass)return;const r=t.classList.contains("active"),o=!r;t.classList.toggle("active",o),e.setAttribute("icon",o?"mdi:heart":"mdi:heart-outline"),t.setAttribute("aria-checked",String(o));try{o?await async function(t,e){const n=await nT(t,"add_favorite",{entity_id:e});return document.dispatchEvent(new CustomEvent(eT)),n?.favorites??{}}(this._hass,n):await async function(t,e){const n=await nT(t,"remove_favorite",{entity_id:e});return document.dispatchEvent(new CustomEvent(eT)),n?.favorites??{}}(this._hass,n)}catch(n){throw t.classList.toggle("active",r),e.setAttribute("icon",r?"mdi:heart":"mdi:heart-outline"),t.setAttribute("aria-checked",String(r)),console.warn("SPAN Panel: favorite toggle failed",n),this.errorStore?.add({key:"service:favorites",level:"error",message:i("error.favorites_toggle_failed"),persistent:!1}),n}}_renderFavoriteSection(t,e){const n=this._favoriteEntityId(e.entities);n&&this._appendFavoriteHeartSection(t,n,!0===e.isFavorite)}_appendFavoriteHeartSection(t,e,n){const r=document.createElement("div");r.className="section",r.innerHTML=``;const o=document.createElement("div");o.className="field-row";const a=document.createElement("span");a.className="field-label",a.textContent=i("sidepanel.save_to_favorites"),o.appendChild(a),o.appendChild(this._buildHeartButton(e,n)),r.appendChild(o),t.appendChild(r)}_renderSubDeviceMode(t,e){const n=this._createHeader(Rt(e.name),Rt(e.deviceType));t.appendChild(n);const i=document.createElement("div");i.className="panel-body",t.appendChild(i),e.showFavorites&&this._renderSubDeviceFavoriteSection(i,e),this._renderSubDeviceHorizonSection(i,e)}_renderSubDeviceFavoriteSection(t,e){const n=this._subDeviceFavoriteEntityId(e.entities);n&&this._appendFavoriteHeartSection(t,n,!0===e.isFavorite)}_renderSubDeviceHorizonSection(t,e){const n=document.createElement("div");n.className="section";const r=document.createElement("div");r.className="section-label",r.textContent=i("sidepanel.graph_horizon"),n.appendChild(r);const o=e.graphHorizonInfo,l=!0===o?.has_override,c=o?.horizon||a,u=o?.globalHorizon||a,d=document.createElement("div");d.className="horizon-bar";const h=[{key:"global",label:i("sidepanel.global")}];for(const t of Object.keys(s))h.push({key:t,label:t});const p=l?c:"global",f=t=>{for(const e of d.querySelectorAll(".horizon-segment")){const n=e.dataset.horizon;e.classList.toggle("active",n===t),e.classList.toggle("referenced","global"===t&&n===u)}};for(const{key:t,label:n}of h){const r=document.createElement("button");r.type="button",r.className="horizon-segment",r.dataset.horizon=t,r.textContent=n,r.classList.toggle("active",t===p),r.classList.toggle("referenced","global"===p&&t===u),r.addEventListener("click",()=>{if(r.classList.contains("active"))return;const n={subdevice_id:e.subDeviceId};e.configEntryId&&(n.config_entry_id=e.configEntryId),"global"===t?(f("global"),this._callDomainService("clear_subdevice_graph_horizon",n).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})):(f(t),this._callDomainService("set_subdevice_graph_horizon",{...n,horizon:t}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})}))}),d.appendChild(r)}n.appendChild(d),t.appendChild(n)}_createHeader(t,e){const n=document.createElement("div");n.className="panel-header";const i=document.createElement("div"),r=Rt(t),o=Rt(e);i.innerHTML=`
${r}
`+(o?`
${o}
`:"");const a=document.createElement("button");return a.className="close-btn",a.innerHTML="✕",a.addEventListener("click",()=>this.close()),n.appendChild(i),n.appendChild(a),n}_renderRelaySection(t,e){if(!1===e.is_user_controllable||!e.entities?.switch)return;const n=document.createElement("div");n.className="section",n.innerHTML=``;const r=document.createElement("div");r.className="field-row";const o=document.createElement("span");o.className="field-label",o.textContent=i("sidepanel.breaker");const a=document.createElement("span-switch");a.dataset.role="relay-toggle";const s=e.entities.switch,l=this._hass?.states?.[s]?.state;"on"===l&&a.setAttribute("checked",""),a.addEventListener("change",()=>{const t=a.hasAttribute("checked")||a.checked;this._callService("switch",t?"turn_on":"turn_off",{entity_id:s}).catch(t=>{console.warn("SPAN Panel: relay toggle failed",t),this.errorStore?.add({key:"service:relay",level:"error",message:i("error.relay_failed"),persistent:!1})})}),r.appendChild(o),r.appendChild(a),n.appendChild(r),t.appendChild(n)}_renderSheddingSection(t,e){if(!e.entities?.select)return;const n=document.createElement("div");n.className="section",n.innerHTML=``;const r=document.createElement("div");r.className="field-row";const o=document.createElement("span");o.className="field-label",o.textContent=i("sidepanel.priority_label");const a=document.createElement("select");a.dataset.role="shedding-select";const s=e.entities.select,l=this._hass?.states?.[s]?.state||"";for(const t of iT){const e=m[t];if(!e)continue;const n=document.createElement("option");n.value=t,n.textContent=i(`shedding.select.${t}`)||e.label(),t===l&&(n.selected=!0),a.appendChild(n)}a.addEventListener("change",()=>{this._callService("select","select_option",{entity_id:s,option:a.value}).catch(t=>{console.warn("SPAN Panel: shedding update failed",t),this.errorStore?.add({key:"service:shedding",level:"error",message:i("error.shedding_failed"),persistent:!1})})}),r.appendChild(o),r.appendChild(a),n.appendChild(r),t.appendChild(n)}_renderGraphHorizonSection(t,e){const n=document.createElement("div");n.className="section";const r=document.createElement("div");r.className="section-label",r.textContent=i("sidepanel.graph_horizon"),n.appendChild(r);const o=e.graphHorizonInfo,l=!0===o?.has_override,c=o?.horizon||a,u=o?.globalHorizon||a,d=document.createElement("div");d.className="horizon-bar";const h=[{key:"global",label:i("sidepanel.global")}];for(const t of Object.keys(s))h.push({key:t,label:t});const p=l?c:"global",f=t=>{for(const e of d.querySelectorAll(".horizon-segment")){const n=e.dataset.horizon;e.classList.toggle("active",n===t),e.classList.toggle("referenced","global"===t&&n===u)}};for(const{key:t,label:n}of h){const r=document.createElement("button");r.type="button",r.className="horizon-segment",r.dataset.horizon=t,r.textContent=n,r.classList.toggle("active",t===p),r.classList.toggle("referenced","global"===p&&t===u),r.addEventListener("click",()=>{if(r.classList.contains("active"))return;const n={circuit_id:e.uuid};e.configEntryId&&(n.config_entry_id=e.configEntryId),"global"===t?(f("global"),this._callDomainService("clear_circuit_graph_horizon",n).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})):(f(t),this._callDomainService("set_circuit_graph_horizon",{...n,horizon:t}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})}))}),d.appendChild(r)}n.appendChild(d),t.appendChild(n)}_renderMonitoringSection(t,e){const n=document.createElement("div");n.className="section";const r=document.createElement("div");r.className="monitoring-header";const o=document.createElement("div");o.className="section-label",o.textContent=i("sidepanel.monitoring"),o.style.margin="0";const a=document.createElement("span-switch");a.dataset.role="monitoring-toggle";const s=e.monitoringInfo,l=null!=s&&!1!==s.monitoring_enabled;l&&a.setAttribute("checked",""),r.appendChild(o),r.appendChild(a),n.appendChild(r);const c=document.createElement("div");c.dataset.role="monitoring-details",c.style.display=l?"block":"none",n.appendChild(c);const u=!0===s?.has_override,d=document.createElement("div");d.className="radio-group",d.innerHTML=`\n \n \n `,c.appendChild(d);const h=document.createElement("div");h.dataset.role="threshold-fields",h.style.display=u?"block":"none";const p=s?.continuous_threshold_pct??80,f=s?.spike_threshold_pct??100,g=s?.window_duration_m??15,v=s?.cooldown_duration_m??15;h.appendChild(this._createThresholdRow(i("sidepanel.continuous_pct"),"continuous",p,e)),h.appendChild(this._createThresholdRow(i("sidepanel.spike_pct"),"spike",f,e)),h.appendChild(this._createDurationRow(i("sidepanel.window_duration"),"window-m",g,1,180,"m",e)),h.appendChild(this._createDurationRow(i("sidepanel.cooldown"),"cooldown-m",v,1,180,"m",e)),c.appendChild(h),a.addEventListener("change",()=>{const t=a.checked;c.style.display=t?"block":"none";const n={circuit_id:e.entities?.power||e.uuid,monitoring_enabled:t};e.configEntryId&&(n.config_entry_id=e.configEntryId),this._callDomainService("set_circuit_threshold",n).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:i("error.threshold_failed"),persistent:!1})})});const m=d.querySelectorAll('input[type="radio"]');for(const t of m)t.addEventListener("change",()=>{const n="custom"===t.value&&t.checked;if(h.style.display=n?"block":"none",!n&&t.checked){const t={circuit_id:e.entities?.power||e.uuid};e.configEntryId&&(t.config_entry_id=e.configEntryId),this._callDomainService("clear_circuit_threshold",t).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:i("error.threshold_failed"),persistent:!1})})}});t.appendChild(n)}_createThresholdRow(t,e,n,r){const o=document.createElement("div");o.className="field-row";const a=document.createElement("span");a.className="field-label",a.textContent=t;const s=document.createElement("input");return s.type="number",s.min="0",s.max="200",s.value=String(n),s.dataset.role=`threshold-${e}`,s.addEventListener("input",()=>{this._debounce(`threshold-${e}`,f,()=>{const t=this.shadowRoot;if(!t)return;const e=t.querySelector('[data-role="threshold-continuous"]'),n=t.querySelector('[data-role="threshold-spike"]'),o=t.querySelector('[data-role="threshold-window-m"]'),a=t.querySelector('[data-role="threshold-cooldown-m"]'),s={circuit_id:r.entities?.power||r.uuid,continuous_threshold_pct:e?Number(e.value):void 0,spike_threshold_pct:n?Number(n.value):void 0,window_duration_m:o?Number(o.value):void 0,cooldown_duration_m:a?Number(a.value):void 0};r.configEntryId&&(s.config_entry_id=r.configEntryId),this._callDomainService("set_circuit_threshold",s).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:i("error.threshold_failed"),persistent:!1})})})}),o.appendChild(a),o.appendChild(s),o}_createDurationRow(t,e,n,r,o,a,s,l=!1){const c=document.createElement("div");c.className="field-row";const u=document.createElement("span");u.className="field-label",u.textContent=t;const d=document.createElement("div"),h=document.createElement("input");h.type="number",h.min=String(r),h.max=String(o),h.value=String(n),h.dataset.role=`threshold-${e}`,l&&(h.disabled=!0);const p=document.createElement("span");return p.textContent=a,d.appendChild(h),d.appendChild(p),l||h.addEventListener("input",()=>{this._debounce(`threshold-${e}`,f,()=>{const t=this.shadowRoot;if(!t)return;const e=t.querySelector('[data-role="threshold-continuous"]'),n=t.querySelector('[data-role="threshold-spike"]'),r=t.querySelector('[data-role="threshold-window-m"]'),o={circuit_id:s.uuid,continuous_threshold_pct:e?Number(e.value):void 0,spike_threshold_pct:n?Number(n.value):void 0,window_duration_m:r?Number(r.value):void 0};s.configEntryId&&(o.config_entry_id=s.configEntryId),this._callDomainService("set_circuit_threshold",o).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:i("error.threshold_failed"),persistent:!1})})})}),c.appendChild(u),c.appendChild(d),c}_updateLiveState(){if(!this._config||this._config.panelMode)return;const t=this._config;if(!t.subDeviceMode&&!t.favoritesMode){if(t.entities?.switch){const e=this.shadowRoot?.querySelector('[data-role="relay-toggle"]');if(e){const n=this._hass?.states?.[t.entities.switch]?.state;"on"===n?e.setAttribute("checked",""):e.removeAttribute("checked")}}if(t.entities?.select){const e=this.shadowRoot?.querySelector('[data-role="shedding-select"]');if(e){const n=this._hass?.states?.[t.entities.select]?.state||"";e.value=n}}}}_callService(t,e,n){return this._hass?Promise.resolve(this._hass.callService(t,e,n)):Promise.resolve()}_callDomainService(t,e){return this._hass?this._hass.callWS({type:"call_service",domain:l,service:t,service_data:e}):Promise.resolve()}_debounce(t,e,n){this._debounceTimers[t]&&clearTimeout(this._debounceTimers[t]),this._debounceTimers[t]=setTimeout(()=>{delete this._debounceTimers[t],n()},e)}}try{customElements.get("span-side-panel")||customElements.define("span-side-panel",rT)}catch{}class oT extends It{constructor(){super(...arguments),this._store=null,this._unsub=null,this._errors=[]}set store(t){if(this._store===t)return;this._unsub?.(),this._unsub=null,this._store=t,this._errors=t.active;const e=t;this._unsub=t.subscribe(()=>{this._errors=e.active})}connectedCallback(){if(super.connectedCallback(),this._store&&!this._unsub){const t=this._store;this._errors=t.active,this._unsub=t.subscribe(()=>{this._errors=t.active})}}disconnectedCallback(){super.disconnectedCallback(),this._unsub?.(),this._unsub=null}render(){return 0===this._errors.length?ft:dt`${this._errors.map(t=>dt` + `,b([Ot({attribute:!1})],Lk.prototype,"options",void 0),b([Ot({attribute:!1})],Lk.prototype,"data",void 0),b([Ot({type:String})],Lk.prototype,"height",void 0);try{customElements.get("span-chart")||customElements.define("span-chart",Lk)}catch{}function Ek(t,e,n,i,r,a,s,l,c){const{options:u,series:d}=function(t,e,n,i,r,a=!1){n||(n=g[o]);const s=i?"140, 160, 220":"77, 217, 175",l=`rgb(${s})`,c=Date.now(),u=c-e,d=void 0!==n.fixedMin&&void 0!==n.fixedMax,h=(t??[]).filter(t=>t.time>=u).map(t=>[t.time,Math.abs(t.value)]),p=[{type:"line",data:h,showSymbol:!1,smooth:!1,...a?{}:{step:"end"},lineStyle:{width:1.5,color:l},areaStyle:{color:{type:"linear",x:0,y:0,x2:0,y2:1,colorStops:[{offset:0,color:`rgba(${s}, 0.18)`},{offset:1,color:`rgba(${s}, 0.18)`}]}},itemStyle:{color:l}}],f=h.length>0?function(t){let e=0;for(const n of t)n[1]>e&&(e=n[1]);return e}(h):0,v={type:"value",splitNumber:4,axisLabel:{fontSize:10,formatter:f<10?t=>0===t?"0":t.toFixed(1):t=>n.format(t)},splitLine:{lineStyle:{opacity:.15}}};d?(v.min=n.fixedMin,v.max=n.fixedMax):f<1&&(v.min=0,v.max=1),r&&"current"===n.entityRole&&(v.min=0,v.max=Math.ceil(1.25*r),p.push({type:"line",data:[[u,.8*r],[c,.8*r]],showSymbol:!1,lineStyle:{width:1,color:"rgba(255, 200, 40, 0.6)",type:"dashed"},itemStyle:{color:"transparent"},tooltip:{show:!1}}),p.push({type:"line",data:[[u,r],[c,r]],showSymbol:!1,lineStyle:{width:1.5,color:"rgba(255, 60, 60, 0.7)",type:"solid"},itemStyle:{color:"transparent"},tooltip:{show:!1}}));const m={xAxis:{type:"time",min:u,max:c,axisLabel:{fontSize:10},splitLine:{show:!1}},yAxis:v,grid:{top:8,right:4,bottom:0,left:0,containLabel:!0},tooltip:{trigger:"axis",axisPointer:{type:"line",lineStyle:{type:"dashed"}},formatter:t=>{if(!t||0===t.length)return"";const e=t[0],i=new Date(e.value[0]).toLocaleString(void 0,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit"}),r=parseFloat(e.value[1].toFixed(2));return`
${i}
${n.format(r)} ${n.unit(r)}
`}},animation:!1};return{options:m,series:p}}(n,i,r,a,l,c),h=s??120;t.style.minHeight=h+"px";let p=t.querySelector("span-chart");p||(p=document.createElement("span-chart"),p.style.display="block",p.style.width="100%",t.innerHTML="",t.appendChild(p));const f=t.clientHeight;p.height=(f>0?f:h)+"px",p.options=u,p.data=d}function Ok(t){return"function"==typeof globalThis.CSS?.escape?CSS.escape(t):t.replace(/["\\]/g,"\\$&")}function zk(t,e,n,i,r){const o=t.querySelector(".panel-stats");o&&function(t,e,n,i,r){const o="current"===(i.chart_metric||"power"),a=t.querySelector(".stat-consumption .stat-value"),s=t.querySelector(".stat-consumption .stat-unit");if(o){const t=n.panel_entities?.site_power,i=t?e.states[t]:null,r=i?parseFloat(i.attributes?.amperage):NaN;a&&(a.textContent=Number.isFinite(r)?Math.abs(r).toFixed(1):"--"),s&&(s.textContent="A")}else{let t=r;const i=n.panel_entities?.site_power;if(i){const n=e.states[i];n&&(t=Math.abs(parseFloat(n.state)||0))}a&&(a.textContent=Gt(t)),s&&(s.textContent="kW")}const l=t.querySelector(".stat-upstream .stat-value"),c=t.querySelector(".stat-upstream .stat-unit");if(l){const t=n.panel_entities?.current_power,i=t?e.states[t]:null;if(o){const t=i?parseFloat(i.attributes?.amperage):NaN;l.textContent=Number.isFinite(t)?Math.abs(t).toFixed(1):"--",c&&(c.textContent="A")}else{const t=i?Math.abs(parseFloat(i.state)||0):0;l.textContent=Gt(t),c&&(c.textContent="kW")}}const u=t.querySelector(".stat-downstream .stat-value"),d=t.querySelector(".stat-downstream .stat-unit");if(u){const t=n.panel_entities?.feedthrough_power,i=t?e.states[t]:null;if(o){const t=i?parseFloat(i.attributes?.amperage):NaN;u.textContent=Number.isFinite(t)?Math.abs(t).toFixed(1):"--",d&&(d.textContent="A")}else{const t=i?Math.abs(parseFloat(i.state)||0):0;u.textContent=Gt(t),d&&(d.textContent="kW")}}const h=t.querySelector(".stat-solar .stat-value"),p=t.querySelector(".stat-solar .stat-unit");if(h){const t=n.panel_entities?.pv_power,i=t?e.states[t]:null;if(o){const t=i?parseFloat(i.attributes?.amperage):NaN;h.textContent=Number.isFinite(t)?Math.abs(t).toFixed(1):"--",p&&(p.textContent="A")}else{if(i){const t=Math.abs(parseFloat(i.state)||0);h.textContent=Gt(t)}else h.textContent="--";p&&(p.textContent="kW")}}const f=t.querySelector(".stat-battery .stat-value");if(f){const t=n.panel_entities?.battery_level,i=t?e.states[t]:null;i&&(f.textContent=`${Math.round(parseFloat(i.state)||0)}`)}const g=t.querySelector(".stat-grid-state .stat-value");if(g){const t=n.panel_entities?.dsm_state,i=t?e.states[t]:null;g.textContent=i?e.formatEntityState?.(i)||i.state:"--"}}(o,e,n,i,r)}class Nk{get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t,this._retry=t?new Qt(t):null}constructor(){this._errorStore=null,this._retry=null,this._settings=null,this._lastFetch=0,this._fetching=!1}async fetch(t,e){const n=Date.now();if(this._fetching)return this._settings;if(this._settings&&n-this._lastFetch<3e4)return this._settings;this._fetching=!0;try{const n={};e&&(n.config_entry_id=e);const r={type:"call_service",domain:l,service:"get_graph_settings",service_data:n,return_response:!0},o=this._retry?await this._retry.callWS(t,r,{errorId:"fetch:graph_settings",errorMessage:i("error.graph_settings_failed")}):await t.callWS(r);this._settings=o?.response??null,this._lastFetch=Date.now()}catch(t){console.warn("SPAN Panel: graph settings fetch failed",t),this._settings=null,this._retry||this._errorStore?.add({key:"fetch:graph_settings",level:"warning",message:i("error.graph_settings_failed"),persistent:!1})}finally{this._fetching=!1}return this._settings}invalidate(){this._lastFetch=0}get settings(){return this._settings}clear(){this._settings=null,this._lastFetch=0}}function Rk(t,e){if(!t)return a;const n=t.circuits?.[e];return n?.has_override?n.horizon:t.global_horizon??a}function Hk(t,e){if(!t)return a;const n=t.sub_devices?.[e];return n?.has_override?n.horizon:t.global_horizon??a}class Bk{constructor(){this.powerHistory=new Map,this.horizonMap=new Map,this.subDeviceHorizonMap=new Map,this.monitoringCache=new Jt,this.monitoringMultiCache=new te,this.graphSettingsCache=new Nk,this._errorStore=null,this._hass=null,this._topology=null,this._config=null,this._configEntryId=null,this._favRefs=null,this._perPanelInfo=new Map,this._panelFavorites=null,this._showMonitoring=!1,this._updateInterval=null,this._recorderRefreshInterval=null,this._resizeObserver=null,this._lastWidth=0,this._resizeDebounce=null}get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t,this.monitoringCache.errorStore=t,this.graphSettingsCache.errorStore=t,this.monitoringMultiCache.errorStore=t}get hass(){return this._hass}set hass(t){this._hass=t}get topology(){return this._topology}get config(){return this._config}set showMonitoring(t){this._showMonitoring=t}init(t,e,n,i){this._topology=t,this._config=e,this._hass=n,this._configEntryId=i}setFavoriteRefs(t){this._favRefs=t}clearFavoriteRefs(){this._favRefs=null}setPanelFavorites(t){this._panelFavorites=t}setFavoritesPerPanelInfo(t){this._perPanelInfo=t??new Map}get _inFavoritesView(){return null!==this._favRefs}setConfig(t){this._config=t}buildHorizonMaps(t){if(this.horizonMap.clear(),this.subDeviceHorizonMap.clear(),t&&this._topology?.circuits)for(const e of Object.keys(this._topology.circuits))this.horizonMap.set(e,Rk(t,e));if(t&&this._topology?.sub_devices)for(const e of Object.keys(this._topology.sub_devices))this.subDeviceHorizonMap.set(e,Hk(t,e))}async fetchAndBuildHorizonMaps(){try{this._favRefs?await this._buildFavoritesHorizonMaps():(await this.graphSettingsCache.fetch(this._hass,this._configEntryId),this.buildHorizonMaps(this.graphSettingsCache.settings))}catch(t){console.warn("SPAN Panel: graph settings fetch failed",t),this.graphSettingsCache.errorStore||this._errorStore?.add({key:"fetch:graph_settings",level:"warning",message:i("error.graph_settings_failed"),persistent:!1})}}async fetchMergedMonitoringStatus(t){if(!this._hass||0===t.length)return null;const e=this._hass;return function(t){let e=!1;const n={},i={};for(const r of t)r&&(e=!0,r.circuits&&Object.assign(n,r.circuits),r.mains&&Object.assign(i,r.mains));return e?{circuits:n,mains:i}:null}(await Promise.all(t.map(t=>this.monitoringMultiCache.fetchOne(e,t))))}async _buildFavoritesHorizonMaps(){if(!this._hass||!this._favRefs||!this._topology)return;const t=new Set;for(const e of Object.values(this._favRefs))e.configEntryId&&t.add(e.configEntryId);const e=new Map;await Promise.all(Array.from(t).map(async t=>{e.set(t,await this._fetchGraphSettingsFresh(t))})),this.horizonMap.clear(),this.subDeviceHorizonMap.clear();for(const t of Object.keys(this._topology.circuits)){const n=this._favRefs[t],i=n?.configEntryId?e.get(n.configEntryId)??null:null,r=n?.targetId??t;this.horizonMap.set(t,Rk(i,r))}if(this._topology.sub_devices)for(const t of Object.keys(this._topology.sub_devices)){const n=this._favRefs[t],i=n?.configEntryId?e.get(n.configEntryId)??null:null,r=n?.targetId??t;this.subDeviceHorizonMap.set(t,Hk(i,r))}}async loadHistory(){await Me(this._hass,this._topology,this._config,this.powerHistory,this.horizonMap,this.subDeviceHorizonMap)}recordSamples(){if(!this._topology||!this._hass||!this._config)return;const t=Date.now();for(const[e,n]of Object.entries(this._topology.circuits)){const i=this.horizonMap.get(e)??a;if(!s[i]?.useRealtime)continue;const r=Zt(n,this._config);if(!r)continue;const o=this._hass.states[r];if(!o)continue;const l=parseFloat(o.state);if(isNaN(l))continue;const c=me(i),u=ye(c),d=_e(c),h=t-c,p=this.powerHistory.get(e)??[];p.length>0&&t-p[p.length-1].time0&&t-p[p.length-1].time0&&this._topology)for(const{key:t,devId:e}of Ce(this._topology))n.has(e)&&r.add(t);const o=new Map;try{await Me(this._hass,this._topology,this._config,o,e,n);for(const t of e.keys()){const e=o.get(t);e?this.powerHistory.set(t,e):this.powerHistory.delete(t)}for(const t of r){const e=o.get(t);e?this.powerHistory.set(t,e):this.powerHistory.delete(t)}this.updateDOM(t)}catch(t){console.warn("SPAN Panel: history refresh failed",t),this._errorStore?.add({key:"fetch:history",level:"warning",message:i("error.history_failed"),persistent:!1})}}updateDOM(t){this._hass&&this._topology&&this._config&&(function(t,e,n,r,o,a){if(!t||!n||!e)return;const s=ve(r);let l=0;for(const[,t]of Object.entries(n.circuits)){const n=t.entities?.power;if(!n)continue;const i=e.states[n],r=i&&parseFloat(i.state)||0;t.device_type!==u&&(l+=Math.abs(r))}zk(t,e,n,r,l);const d=Yt(r),h="current"===d.entityRole;for(const[r,l]of Object.entries(n.circuits)){const n=t.querySelector(`.circuit-slot[data-uuid="${Ok(r)}"]`);if(!n)continue;const p=l.entities?.power,f=p?e.states[p]:null,g=f&&parseFloat(f.state)||0,v=l.device_type===u||g<0,y=l.entities?.switch,_=y?e.states[y]:null,b=_?"on"===_.state:(f?.attributes?.relay_state||l.relay_state)===c,x=n.querySelector(".power-value");if(x)if(h){const t=l.entities?.current,n=t?e.states[t]:null,i=n&&parseFloat(n.state)||0;x.innerHTML=`${d.format(i)}A`}else x.innerHTML=`${Ut(g)}${Wt(g)}`;const w=n.querySelector(".toggle-pill");if(w){w.className="toggle-pill "+(b?"toggle-on":"toggle-off");const t=w.querySelector(".toggle-label");t&&(t.textContent=i(b?"grid.on":"grid.off"))}let S;if(n.classList.toggle("circuit-off",!b),n.classList.toggle("circuit-producer",v),l.always_on)S="always_on";else{const t=l.entities?.select,n=t?e.states[t]:null;S=n?n.state:"unknown"}const C=m[S]??m.unknown,M=n.querySelector(".shedding-icon");M&&(M.setAttribute("icon",C.icon),M.style.color=C.color,M.title=C.label());const k=n.querySelector(".shedding-icon-secondary");k&&(C.icon2?(k.setAttribute("icon",C.icon2),k.style.color=C.color,k.style.display=""):k.style.display="none");const T=n.querySelector(".shedding-label");T&&(C.textLabel?(T.textContent=C.textLabel,T.style.color=C.color,T.style.display=""):T.style.display="none");const D=n.querySelector(".chart-container");if(D){const t=o.get(r)||[],e=n.classList.contains("circuit-col-span")?200:100,i=a?.has(r)?me(a.get(r)):s,c=l.device_type===u;Ek(D,0,t,i,d,v,e,l.breaker_rating_a??void 0,c)}}}(t,this._hass,this._topology,this._config,this.powerHistory,this.horizonMap),function(t,e,n,i,r,o){if(!n.sub_devices)return;const a=ve(i);for(const[i,s]of Object.entries(n.sub_devices)){const n=t.querySelector(`[data-subdev="${Ok(i)}"]`);if(!n)continue;const l=ue(s);if(l){const t=e.states[l],i=t&&parseFloat(t.state)||0,r=n.querySelector(".sub-power-value");r&&(r.innerHTML=`${Ut(i)} ${Wt(i)}`)}const c=n.querySelectorAll("[data-chart-key]");for(const t of c){const e=t.dataset.chartKey;if(!e)continue;const n=r.get(e)||[];let s=v.power;e.endsWith("_soc")?s=v.soc:e.endsWith("_soe")&&(s=v.soe);const l=!!t.closest(".bess-chart-col");Ek(t,0,n,o?.has(i)?me(o.get(i)):a,s,!1,l?120:150,void 0,e.endsWith("_soc")||e.endsWith("_soe"))}for(const t of Object.keys(s.entities||{})){const i=n.querySelector(`[data-eid="${Ok(t)}"]`);if(!i)continue;const r=e.states[t];if(r){let t;if(e.formatEntityState)t=e.formatEntityState(r);else{t=r.state;const e=r.attributes.unit_of_measurement||"";e&&(t+=" "+e)}if("Wh"===(r.attributes.unit_of_measurement||"")){const e=parseFloat(r.state);isNaN(e)||(t=(e/1e3).toFixed(1)+" kWh")}i.textContent=t}}}}(t,this._hass,this._topology,this._config,this.powerHistory,this.subDeviceHorizonMap))}async onGraphSettingsChanged(t){if(this._hass){this._favRefs?await this._buildFavoritesHorizonMaps():(this.graphSettingsCache.invalidate(),await this.graphSettingsCache.fetch(this._hass,this._configEntryId),this.buildHorizonMaps(this.graphSettingsCache.settings)),this.powerHistory.clear();try{await this.loadHistory()}catch{}this.updateDOM(t)}}onToggleClick(t,e){const n=t.target,r=n?.closest(".toggle-pill");if(!r)return;const o=e.querySelector(".slide-confirm");if(!o||!o.classList.contains("confirmed"))return;t.stopPropagation(),t.preventDefault();const a=r.closest("[data-uuid]");if(!a||!this._topology||!this._hass)return;const s=a.dataset.uuid;if(!s)return;const l=this._topology.circuits[s];if(!l)return;const c=l.entities?.switch;if(!c)return;const u=this._hass.states[c];if(!u)return void console.warn("SPAN Panel: switch entity not found:",c);const d="on"===u.state?"turn_off":"turn_on";this._hass.callService("switch",d,{},{entity_id:c}).catch(t=>{console.warn("SPAN Panel: switch service call failed",t),this._errorStore?.add({key:"service:relay",level:"error",message:i("error.relay_failed"),persistent:!1})})}async onGearClick(t,e){const n=t.target,i=n?.closest(".gear-icon");if(!i)return;const r=e.querySelector("span-side-panel");if(!r||!this._hass)return;if(r.hass=this._hass,r.errorStore=this.errorStore,i.classList.contains("panel-gear")){if(this._inFavoritesView){const t=await this._buildFavoritesSections();if(0===t.length)return;return void r.open({favoritesMode:!0,perPanelSections:t})}return await this.graphSettingsCache.fetch(this._hass,this._configEntryId),void r.open({panelMode:!0,topology:this._topology,graphSettings:this.graphSettingsCache.settings,showFavorites:null!==this._panelFavorites,favoritePanelDeviceId:this._panelFavorites?.panelDeviceId,favoriteCircuitUuids:this._panelFavorites?.circuitUuids,favoriteSubDeviceIds:this._panelFavorites?.subDeviceIds,configEntryId:this._configEntryId})}const o=i.dataset.uuid;if(o&&this._topology){const t=this._topology.circuits[o];if(t){const e=this._favRefs?.[o]??null,n=e&&"circuit"===e.kind?e.targetId:o,i=e?.configEntryId??this._configEntryId;let s,l;e?[s,l]=await Promise.all([this._fetchGraphSettingsFresh(i),this._fetchMonitoringStatusFresh(i)]):(await Promise.all([this.graphSettingsCache.fetch(this._hass,i),this.monitoringCache.fetch(this._hass,i)]),s=this.graphSettingsCache.settings,l=this.monitoringCache.status);const c=t.entities?.current??t.entities?.power,u=c?l?.circuits?.[c]??null:null,d=s?.global_horizon??a,h=s?.circuits?.[n],p=h?{...h,globalHorizon:d}:{horizon:d,has_override:!1,globalHorizon:d},f=e?.panelDeviceId??this._panelFavorites?.panelDeviceId,g=null!==e||(this._panelFavorites?.circuitUuids.has(n)??!1),v=this._inFavoritesView||null!==this._panelFavorites;return void r.open({...t,uuid:n,monitoringInfo:u,showMonitoring:this._showMonitoring,graphHorizonInfo:p,showFavorites:v,favoritePanelDeviceId:f,isFavorite:g,configEntryId:i})}}const s=i.dataset.subdevId;if(s&&this._topology?.sub_devices?.[s]){const t=this._topology.sub_devices[s],e=this._favRefs?.[s]??null,n=e&&"sub_device"===e.kind?e.targetId:s,i=e?.configEntryId??this._configEntryId;let o;e?o=await this._fetchGraphSettingsFresh(i):(await this.graphSettingsCache.fetch(this._hass,i),o=this.graphSettingsCache.settings);const l=o?.global_horizon??a,c=o?.sub_devices?.[n],u=c?{...c,globalHorizon:l}:{horizon:l,has_override:!1,globalHorizon:l},d=e?.panelDeviceId??this._panelFavorites?.panelDeviceId,h=null!==e||(this._panelFavorites?.subDeviceIds.has(n)??!1),p=this._inFavoritesView||null!==this._panelFavorites;r.open({subDeviceMode:!0,subDeviceId:n,name:t.name??n,deviceType:t.type??"",entities:t.entities,graphHorizonInfo:u,showFavorites:p,favoritePanelDeviceId:d,isFavorite:h,configEntryId:i})}}async _buildFavoritesSections(){if(!this._hass||!this._favRefs)return[];const t=function(t,e){const n=new Map;for(const i of Object.values(t)){if("circuit"!==i.kind)continue;const t=e.get(i.panelDeviceId);if(void 0===t)continue;let r=n.get(i.panelDeviceId);void 0===r&&(r={panelDeviceId:i.panelDeviceId,panelName:t.panelName,topology:t.topology,configEntryId:t.configEntryId,favoriteCircuitUuids:new Set},n.set(i.panelDeviceId,r)),r.favoriteCircuitUuids.add(i.targetId)}return Array.from(n.values()).sort((t,e)=>t.panelName.localeCompare(e.panelName))}(this._favRefs,this._perPanelInfo);if(0===t.length)return[];return await Promise.all(t.map(async t=>({panelDeviceId:t.panelDeviceId,panelName:t.panelName,topology:t.topology,graphSettings:await this._fetchGraphSettingsFresh(t.configEntryId),favoriteCircuitUuids:t.favoriteCircuitUuids,configEntryId:t.configEntryId})))}async _fetchGraphSettingsFresh(t){if(!this._hass)return null;try{const e={};t&&(e.config_entry_id=t);const n={type:"call_service",domain:l,service:"get_graph_settings",service_data:e,return_response:!0},r=this._errorStore?new Qt(this._errorStore):null,o=r?await r.callWS(this._hass,n,{errorId:"fetch:graph_settings",errorMessage:i("error.graph_settings_failed")}):await this._hass.callWS(n);return o?.response??null}catch(t){return console.warn("SPAN Panel: fresh graph settings fetch failed",t),null}}async _fetchMonitoringStatusFresh(t){if(!this._hass)return null;try{const e={};t&&(e.config_entry_id=t);const n={type:"call_service",domain:l,service:"get_monitoring_status",service_data:e,return_response:!0},r=this._errorStore?new Qt(this._errorStore):null,o=r?await r.callWS(this._hass,n,{errorId:"fetch:monitoring",errorMessage:i("error.monitoring_failed")}):await this._hass.callWS(n),a=o?.response;return a?{circuits:a.circuits,mains:a.mains}:null}catch(t){return console.warn("SPAN Panel: fresh monitoring status fetch failed",t),null}}bindSlideConfirm(t,e){const n=t.querySelector(".slide-confirm-knob"),i=t.querySelector(".slide-confirm-text");if(!n||!i)return;let r=!1,o=0,a=0;const s=e=>{t.classList.contains("confirmed")||(r=!0,o=e-n.offsetLeft,a=t.offsetWidth-n.offsetWidth-4,n.classList.remove("snapping"))},l=t=>{if(!r)return;const e=Math.max(2,Math.min(t-o,a));n.style.left=e+"px"},c=()=>{if(!r)return;r=!1;(n.offsetLeft-2)/a>=.9?(n.style.left=a+"px",t.classList.add("confirmed"),n.querySelector("span-icon")?.setAttribute("icon","mdi:lock-open"),i.textContent=t.dataset.textOn??"",e&&e.classList.remove("switches-disabled")):(n.classList.add("snapping"),n.style.left="2px")};n.addEventListener("mousedown",t=>{t.preventDefault(),s(t.clientX)}),t.addEventListener("mousemove",t=>l(t.clientX)),t.addEventListener("mouseup",c),t.addEventListener("mouseleave",c),n.addEventListener("touchstart",t=>{t.preventDefault(),s(t.touches[0].clientX)},{passive:!1}),t.addEventListener("touchmove",t=>l(t.touches[0].clientX),{passive:!0}),t.addEventListener("touchend",c),t.addEventListener("touchcancel",c),t.addEventListener("click",()=>{t.classList.contains("confirmed")&&(t.classList.remove("confirmed"),n.classList.add("snapping"),n.style.left="2px",n.querySelector("span-icon")?.setAttribute("icon","mdi:lock"),i.textContent=t.dataset.textOff??"",e&&e.classList.add("switches-disabled"))})}startIntervals(t,e){this._updateInterval=setInterval(()=>{this.recordSamples(),this.updateDOM(t),e&&e()},1e3),this._recorderRefreshInterval=setInterval(()=>{this.refreshRecorderData(t)},3e4)}stopIntervals(){this._updateInterval&&(clearInterval(this._updateInterval),this._updateInterval=null),this._recorderRefreshInterval&&(clearInterval(this._recorderRefreshInterval),this._recorderRefreshInterval=null),this.cleanupResizeObserver()}setupResizeObserver(t,e){this.cleanupResizeObserver(),e&&(this._lastWidth=e.clientWidth,this._resizeObserver=new ResizeObserver(e=>{const n=e[0];if(!n)return;const i=n.contentRect.width;Math.abs(i-this._lastWidth)<5||(this._lastWidth=i,this._resizeDebounce&&clearTimeout(this._resizeDebounce),this._resizeDebounce=setTimeout(()=>{for(const e of t.querySelectorAll(".chart-container")){const t=e.querySelector("span-chart");t&&t.remove()}this.updateDOM(t)},150))}),this._resizeObserver.observe(e))}cleanupResizeObserver(){this._resizeObserver&&(this._resizeObserver.disconnect(),this._resizeObserver=null),this._resizeDebounce&&(clearTimeout(this._resizeDebounce),this._resizeDebounce=null)}reset(){this.powerHistory.clear(),this.horizonMap.clear(),this.subDeviceHorizonMap.clear(),this.monitoringCache.clear(),this.monitoringMultiCache.clear(),this.graphSettingsCache.clear()}}function Fk(t=""){const e=t?` value="${Rt(t)}"`:"",n=t?"":"display:none;";return`\n
\n \n \n
\n `}function $k(t,e,n,r,o,a,s){const l=e.entities?.power,u=l?n.states[l]:null,d=u&&parseFloat(u.state)||0,h=e.entities?.switch,p=h?n.states[h]:null,f=p?"on"===p.state:(u?.attributes?.relay_state||e.relay_state)===c,g=e.breaker_rating_a,v=g?`${Math.round(g)}A`:"",y=Rt(e.name||i("grid.unknown")),_=Yt(r),b="current"===_.entityRole;let x;if(f)if(b){const t=e.entities?.current,i=t?n.states[t]:null,r=i&&parseFloat(i.state)||0;x=`${_.format(r)}A`}else x=`${Ut(d)}${Wt(d)}`;else x="";const w=a||"unknown";let S="";if("unknown"!==w){const t=m[w]??m.unknown??{icon:"mdi:help",color:"#999",label:()=>"Unknown"};S=t.icon2?`\n \n \n `:t.textLabel?`\n \n ${t.textLabel}\n `:``}let C="",M=o?.utilization_pct??null;if(null==M&&e.breaker_rating_a){const t=e.entities?.current,i=t?n.states[t]:null,r=i?Math.abs(parseFloat(i.state)||0):0;M=Math.round(r/e.breaker_rating_a*1e3)/10}if(null!=M){C=`=80?"utilization-warning":"utilization-normal"}">${Math.round(M)}%`}const k=``,T=!1!==e.is_user_controllable&&!!e.entities?.switch?`
\n ${i(f?"grid.on":"grid.off")}\n \n
`:`${f?"ON":"OFF"}`;return`\n
\n ${v?`${v}`:""}\n ${C}\n ${y}\n ${S}\n ${T}\n \n ${x}\n \n ${k}\n \n
\n `}function Vk(t,e,n,i,r){const o=e.entities?.power,a=o?n.states[o]:null,s=a&&parseFloat(a.state)||0,l=e.device_type===u||s<0,d=e.entities?.switch,h=d?n.states[d]:null,p=ne(0,r,h?"on"===h.state:(a?.attributes?.relay_state||e.relay_state)===c,l),f=Rt(t);return`\n
\n
\n
\n
\n
\n `}function Wk(t){return`
${Rt(t)}
`}function Uk(t,e){const n=e.hysteresisPx??48,i=new WeakSet;let r=null;const o=t=>{const n=t.querySelector(e.nameSelector);return!!n&&(0!==t.clientWidth&&(n.clientWidth<1||n.scrollWidth>n.clientWidth+1))},a=()=>{const i=t.querySelectorAll(e.rowSelector);if(0===i.length)return;const a=i[0].clientWidth;if(0===a)return;if(null!==r){if(a>r+n){for(const t of i)t.classList.remove(e.foldClass);r=null}else for(const t of i)t.classList.add(e.foldClass);return}let s=!1;for(const t of i)if(o(t)){s=!0;break}if(s){for(const t of i)t.classList.add(e.foldClass);r=a}},s=new ResizeObserver(()=>a()),l=()=>{const n=t.querySelectorAll(e.rowSelector);for(const t of n)i.has(t)||(i.add(t),s.observe(t));requestAnimationFrame(()=>a())};l();const c=new MutationObserver(t=>{(t=>{const n=t=>{for(const n of t)if(n instanceof Element){if(n.matches(e.rowSelector))return!0;if(n.querySelector(e.rowSelector))return!0}return!1};for(const e of t)if(n(e.addedNodes)||n(e.removedNodes))return!0;return!1})(t)&&l()});return c.observe(t,{childList:!0,subtree:!0}),()=>{s.disconnect(),c.disconnect()}}function Gk(t,e,n){const i=t.entities?.switch,r=i?e.states[i]:null,o=t.entities?.power,a=o?e.states[o]:null,s=r?"on"===r.state:(a?.attributes?.relay_state||t.relay_state)===c;let l;if("current"===(n.chart_metric||"power")){const n=t.entities?.current,i=n?e.states[n]:null;l=i?Math.abs(parseFloat(i.state)||0):0}else l=a?Math.abs(parseFloat(a.state)||0):0;return{isOn:s,value:l}}function qk(t,e){if(t.always_on)return"always_on";const n=t.entities?.select,i=n?e.states[n]:null;return i?i.state:"unknown"}function jk(t,e,n,i){const r=Gk(t,n,i),o=Gk(e,n,i);return r.isOn&&!o.isOn?-1:!r.isOn&&o.isOn?1:o.value-r.value}function Xk(t,e,n){return t.sort((t,i)=>jk(t[1],i[1],e,n))}function Yk(t){return t.entities?.current??t.entities?.power??""}class Zk{constructor(t){this._expandedUuids=new Set,this._searchQuery="",this._container=null,this._clickHandler=null,this._inputHandler=null,this._graphSettingsHandler=null,this._hass=null,this._topology=null,this._config=null,this._monitoringStatus=null,this._viewName=null,this._columns=1,this._foldUnobserve=null,this._ctrl=t}setColumns(t){const e=Math.max(1,Math.min(3,Math.floor(t)));this._columns=e}setInitialExpansion(t){this._expandedUuids=new Set(t)}setInitialSearchQuery(t){this._searchQuery=t}setViewName(t){this._viewName=t}renderActivityView(t,e,n,i,r,o){this._unbindEvents(),this._hass=e,this._topology=n,this._config=i,this._monitoringStatus=r;const a=Xk(Object.entries(n.circuits),e,i);let s=o+Fk(this._searchQuery);s+=`
`;for(const[t,n]of a){const o=ee(r,Yk(n)),a=qk(n,e),l=this._expandedUuids.has(t);s+=`
`,s+=$k(t,n,e,i,o,a,l),l&&(s+=Vk(t,n,e,0,o)),s+="
"}s+="
",s+="",t.innerHTML=s;const l=t.querySelector("span-side-panel");l&&(l.hass=e,l.errorStore=this._ctrl.errorStore),this._bindEvents(t),this._searchQuery&&this._applyFilter(t),this._ctrl.updateDOM(t),this._attachFoldObserver(t)}renderAreaView(t,e,n,r,o,a){this._unbindEvents(),this._hass=e,this._topology=n,this._config=r,this._monitoringStatus=o;const s=i("list.unassigned_area"),l=new Map;for(const[t,e]of Object.entries(n.circuits)){const n=e.area??s,i=l.get(n);i?i.push([t,e]):l.set(n,[[t,e]])}const c=[...l.keys()].sort((t,e)=>t===s?1:e===s?-1:t.localeCompare(e));let u=a+Fk(this._searchQuery);u+=`
`;for(const t of c){const n=l.get(t);if(!n)continue;const i=Xk(n,e,r);u+=Wk(t);for(const[t,n]of i){const i=ee(o,Yk(n)),a=qk(n,e),s=this._expandedUuids.has(t);u+=`
`,u+=$k(t,n,e,r,i,a,s),s&&(u+=Vk(t,n,e,0,i)),u+="
"}}u+="
",u+="",t.innerHTML=u;const d=t.querySelector("span-side-panel");d&&(d.hass=e,d.errorStore=this._ctrl.errorStore),this._bindEvents(t),this._searchQuery&&this._applyFilter(t),this._ctrl.updateDOM(t),this._attachFoldObserver(t)}updateCollapsedRows(t,e,n,r){const o=Yt(r),a="current"===o.entityRole,s=t.querySelectorAll(".list-row[data-row-uuid]");for(const t of s){const s=t.dataset.rowUuid;if(!s)continue;const l=n.circuits[s];if(!l)continue;const{isOn:c,value:u}=Gk(l,e,r),d=t.querySelector(".list-power-value");if(d)if(c)if(a)d.innerHTML=`${o.format(u)}A`;else{const t=l.entities?.power,n=t?e.states[t]:null,i=n&&parseFloat(n.state)||0;d.innerHTML=`${Ut(i)}${Wt(i)}`}else d.innerHTML="";const h=t.querySelector(".toggle-pill");if(h){h.classList.toggle("toggle-on",c),h.classList.toggle("toggle-off",!c);const t=h.querySelector(".toggle-label");t&&(t.textContent=i(c?"grid.on":"grid.off"))}const p=t.querySelector(".list-status-badge");p&&(p.textContent=c?"ON":"OFF",p.classList.toggle("list-status-on",c),p.classList.toggle("list-status-off",!c)),t.classList.toggle("circuit-off",!c)}!function(t,e,n,i){const r=t.querySelector(".list-view");if(r)for(const t of function(t,e){let n={anchor:null,units:[]};const i=[n];for(const r of[...t.children])if(r.classList.contains("area-header"))n={anchor:r,units:[]},i.push(n);else if(r.classList.contains("list-cell")){const t=r.dataset.cellUuid,i=t?e.circuits[t]:void 0;t&&i&&n.units.push({cell:r,uuid:t,circuit:i})}return i}(r,n)){if(t.units.length<2)continue;const n=[...t.units].sort((t,n)=>jk(t.circuit,n.circuit,e,i));if(!n.some((e,n)=>e.uuid!==t.units[n].uuid))continue;let o=t.anchor;for(const t of n)o?o.after(t.cell):r.prepend(t.cell),o=t.cell}}(t,e,n,r)}stop(){this._unbindEvents(),null===this._viewName&&(this._expandedUuids.clear(),this._searchQuery=""),this._hass=null,this._topology=null,this._config=null,this._monitoringStatus=null}_dispatchFavoritesViewState(){if(!this._viewName||!this._container)return;const t={view:this._viewName,expanded:[...this._expandedUuids],searchQuery:this._searchQuery};this._container.dispatchEvent(new CustomEvent("favorites-view-state-changed",{detail:t,bubbles:!0,composed:!0}))}_bindEvents(t){this._container=t,this._clickHandler=e=>{const n=e.target;if(!n)return;const i=n.closest(".list-expand-toggle");if(i){const t=i.dataset.expandUuid;return void(t&&this._toggleExpand(t))}if(n.closest(".gear-icon"))return void this._ctrl.onGearClick(e,t);if(n.closest(".toggle-pill"))return void this._ctrl.onToggleClick(e,t);if(n.closest(".list-search-clear")){const e=t.querySelector(".list-search");return void(e&&(e.value="",e.dispatchEvent(new Event("input",{bubbles:!0}))))}const r=n.closest(".unit-btn");if(r){const e=r.dataset.unit;e&&t.dispatchEvent(new CustomEvent("unit-changed",{detail:e,bubbles:!0,composed:!0}))}},this._inputHandler=e=>{const n=e.target;n&&n.classList.contains("list-search")&&(this._searchQuery=n.value.toLowerCase(),this._applyFilter(t),this._dispatchFavoritesViewState())},this._graphSettingsHandler=()=>{this._ctrl.onGraphSettingsChanged(t).then(()=>{this._ctrl.updateDOM(t)}).catch(()=>{})},t.addEventListener("click",this._clickHandler),t.addEventListener("input",this._inputHandler),t.addEventListener("graph-settings-changed",this._graphSettingsHandler);const e=t.querySelector(".slide-confirm");e&&(this._ctrl.bindSlideConfirm(e,t),t.classList.add("switches-disabled"))}_unbindEvents(){this._container&&(this._clickHandler&&this._container.removeEventListener("click",this._clickHandler),this._inputHandler&&this._container.removeEventListener("input",this._inputHandler),this._graphSettingsHandler&&this._container.removeEventListener("graph-settings-changed",this._graphSettingsHandler)),this._foldUnobserve&&(this._foldUnobserve(),this._foldUnobserve=null),this._container=null,this._clickHandler=null,this._inputHandler=null,this._graphSettingsHandler=null}_attachFoldObserver(t){this._foldUnobserve&&(this._foldUnobserve(),this._foldUnobserve=null),this._foldUnobserve=Uk(t,{rowSelector:".list-row",nameSelector:".list-circuit-name",foldClass:"is-folded"})}_applyFilter(t){const e=t.querySelector(".list-search-clear");e&&(e.style.display=this._searchQuery?"":"none");const n=t.querySelectorAll(".list-cell[data-cell-uuid]");for(const t of n){const e=t.querySelector(".list-circuit-name"),n=(e?.textContent?.toLowerCase()??"").includes(this._searchQuery);t.style.display=n?"":"none"}const i=t.querySelectorAll(".area-header");for(const t of i){let e=!1,n=t.nextElementSibling;for(;n&&!n.classList.contains("area-header");){if(n.classList.contains("list-cell")&&"none"!==n.style.display){e=!0;break}n=n.nextElementSibling}t.style.display=e?"":"none"}}_toggleExpand(t){if(!(this._container&&this._hass&&this._topology&&this._config))return;const e=Ok(t),n=this._container.querySelector(`.list-cell[data-cell-uuid="${e}"]`);if(!n)return;const i=n.querySelector(`.list-row[data-row-uuid="${e}"]`),r=n.querySelector(`.list-expand-toggle[data-expand-uuid="${e}"]`);if(i){if(this._expandedUuids.has(t)){this._expandedUuids.delete(t);const o=n.querySelector(`.list-expanded-content[data-expanded-uuid="${e}"]`);o&&o.remove(),r&&r.classList.remove("expanded"),i.classList.remove("list-row-expanded")}else{this._expandedUuids.add(t);const e=this._topology.circuits[t];if(!e)return;const n=ee(this._monitoringStatus,Yk(e)),o=Vk(t,e,this._hass,this._config,n);i.insertAdjacentHTML("afterend",o),r&&r.classList.add("expanded"),i.classList.add("list-row-expanded"),this._ctrl.updateDOM(this._container)}this._dispatchFavoritesViewState()}}}async function Kk(t,e){const[n,i,r]=await Promise.all([t.callWS({type:"config/area_registry/list"}),t.callWS({type:"config/entity_registry/list"}),t.callWS({type:"config/device_registry/list"})]),o=new Map;for(const t of n)o.set(t.area_id,t.name);const a=new Map;for(const t of i)t.area_id&&a.set(t.entity_id,t.area_id);const s=new Map;for(const t of r)s.set(t.id,t.area_id);let l;if(e.device_id){const t=s.get(e.device_id);t&&(l=o.get(t))}for(const t of Object.values(e.circuits)){let e;for(const n of Object.values(t.entities)){if(!n)continue;const t=a.get(n);if(t){e=o.get(t);break}}e||(e=l),t.area=e}}class Qk{constructor(){this._persistent=new Map,this._transient=null,this._transientTimer=null,this._subscribers=new Set,this._watchedPanels=new Map}add(t){const e={...t,timestamp:Date.now()};if(e.persistent)this._persistent.set(e.key,e);else{this._clearTransient(),this._transient=e;const t=e.ttl??5e3;this._transientTimer=setTimeout(()=>{this._transient=null,this._transientTimer=null,this._notify()},t)}this._notify()}remove(t){if(this._persistent.has(t))return this._persistent.delete(t),void this._notify();this._transient?.key===t&&(this._clearTransient(),this._notify())}clear(t){void 0===t?(this._persistent.clear(),this._clearTransient(),this._watchedPanels.clear()):!0===t.persistent?this._persistent.clear():!1===t.persistent&&this._clearTransient(),this._notify()}get active(){const t=[...this._persistent.values()];return null!==this._transient&&t.push(this._transient),t}hasPersistent(t){return this._persistent.has(t)}hasAnyPanelOffline(){for(const t of this._persistent.keys())if("panel-offline"===t||t.startsWith("panel-offline:"))return!0;return!1}subscribe(t){return this._subscribers.add(t),()=>{this._subscribers.delete(t)}}watchPanelStatus(t){this.watchPanelStatuses([{entityId:t,panelName:null}])}watchPanelStatuses(t){const e=this._watchedPanels,n=new Map;for(const i of t){const t=e.get(i.entityId);n.set(i.entityId,{panelName:i.panelName??null,wasOffline:t?.wasOffline??!1})}const i=this._isSingleUnnamed(e),r=this._isSingleUnnamed(n);for(const t of e.keys()){n.has(t)&&i===r||this._persistent.delete(this._offlineKey(t,i))}this._watchedPanels=n,this._notify()}clearPanelStatusWatch(){if(0===this._watchedPanels.size)return;const t=this._isSingleUnnamed(this._watchedPanels);for(const e of this._watchedPanels.keys())this._persistent.delete(this._offlineKey(e,t));this._watchedPanels.clear(),this._notify()}updateHass(t){if(0===this._watchedPanels.size)return;const e=this._isSingleUnnamed(this._watchedPanels);for(const[n,o]of this._watchedPanels){const a=t.states[n]?.state,s="on"===a,l=this._offlineKey(n,e),c=this._reconnectKey(n,e);if(s){const t=o.wasOffline;o.wasOffline=!1,this.remove(l),t&&this.add({key:c,level:"info",message:null===o.panelName?i("error.panel_reconnected"):r("error.panel_reconnected_named",{name:o.panelName}),persistent:!1})}else o.wasOffline=!0,this.hasPersistent(l)||this.add({key:l,level:"error",message:null===o.panelName?i("error.panel_offline"):r("error.panel_offline_named",{name:o.panelName}),persistent:!0})}}dispose(){this._clearTransient(),this._persistent.clear(),this._subscribers.clear(),this._watchedPanels.clear()}_isSingleUnnamed(t){if(1!==t.size)return!1;for(const e of t.values())return null===e.panelName;return!1}_offlineKey(t,e){return e?"panel-offline":`panel-offline:${t}`}_reconnectKey(t,e){return e?"panel-reconnected":`panel-reconnected:${t}`}_clearTransient(){null!==this._transientTimer&&(clearTimeout(this._transientTimer),this._transientTimer=null),this._transient=null}_notify(){for(const t of this._subscribers)try{t()}catch(t){console.warn("SPAN Panel: error-store subscriber threw",t)}}}function Jk(t){let e=0;for(const n of Object.values(t))if(n)for(const t of n.tabs)t>e&&(e=t);return e>0?e+e%2:0}function tT(t){return t?{id:t.id,name:t.name,name_by_user:t.name_by_user,config_entries:t.config_entries,identifiers:t.identifiers,via_device_id:t.via_device_id,sw_version:t.sw_version,model:t.model}:null}const eT="favorites-changed";async function nT(t,e,n={}){const i=await t.callWS({type:"call_service",domain:l,service:e,service_data:n,return_response:!0});return i?.response??null}const iT=Object.keys(m).filter(t=>"unknown"!==t&&"always_on"!==t);class rT extends HTMLElement{constructor(){super(),this.errorStore=null,this.attachShadow({mode:"open"}),this._hass=null,this._config=null,this._debounceTimers={}}set hass(t){this._hass=t,this.hasAttribute("open")&&this._config&&this._updateLiveState()}get hass(){return this._hass}disconnectedCallback(){this._clearDebounceTimers(),this._config=null}open(t){this._config=t,this._render(),this.offsetHeight,this.setAttribute("open",""),this.setAttribute("data-mode",this._modeFor(t))}close(){this._clearDebounceTimers(),this.removeAttribute("open"),this.removeAttribute("data-mode"),this._config=null,this.dispatchEvent(new CustomEvent("side-panel-closed",{bubbles:!0,composed:!0}))}_clearDebounceTimers(){for(const t of Object.keys(this._debounceTimers))clearTimeout(this._debounceTimers[t]);this._debounceTimers={}}_modeFor(t){return t.favoritesMode?"favorites":t.panelMode?"panel":t.subDeviceMode?"subDevice":"circuit"}_render(){const t=this._config;if(!t)return;const e=this.shadowRoot;if(!e)return;e.innerHTML="";const n=document.createElement("style");n.textContent='\n :host {\n display: block;\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n width: 360px;\n max-width: 90vw;\n z-index: 1000;\n transform: translateX(100%);\n transition: transform 0.3s ease;\n pointer-events: none;\n }\n :host([open]) {\n transform: translateX(0);\n pointer-events: auto;\n }\n\n .backdrop {\n display: none;\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba(0, 0, 0, 0.3);\n z-index: -1;\n }\n :host([open]) .backdrop {\n display: block;\n }\n\n .panel {\n height: 100%;\n background: var(--card-background-color, #fff);\n border-left: 1px solid var(--divider-color, #e0e0e0);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n .panel-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 16px;\n border-bottom: 1px solid var(--divider-color, #e0e0e0);\n }\n .panel-header .title {\n font-size: 18px;\n font-weight: 500;\n color: var(--primary-text-color, #212121);\n margin: 0;\n }\n .panel-header .subtitle {\n font-size: 13px;\n color: var(--secondary-text-color, #727272);\n margin: 2px 0 0 0;\n }\n .close-btn {\n background: none;\n border: none;\n cursor: pointer;\n color: var(--secondary-text-color, #727272);\n padding: 4px;\n line-height: 1;\n font-size: 20px;\n }\n\n .panel-body {\n flex: 1;\n overflow-y: auto;\n padding: 16px;\n }\n\n .section {\n margin-bottom: 20px;\n }\n .section-label {\n font-size: 12px;\n font-weight: 600;\n text-transform: uppercase;\n color: var(--secondary-text-color, #727272);\n margin: 0 0 8px 0;\n letter-spacing: 0.5px;\n }\n\n .field-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 8px 0;\n }\n .field-label {\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n }\n\n select {\n padding: 6px 8px;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 4px;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 14px;\n }\n\n input[type="number"] {\n width: 72px;\n padding: 6px 8px;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 4px;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 14px;\n text-align: right;\n }\n input[type="number"]:disabled {\n opacity: 0.5;\n }\n\n .radio-group {\n display: flex;\n gap: 16px;\n padding: 8px 0;\n }\n .radio-group label {\n display: flex;\n align-items: center;\n gap: 6px;\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n cursor: pointer;\n }\n\n .horizon-bar {\n display: flex;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 6px;\n overflow: hidden;\n margin-top: 4px;\n }\n .horizon-segment {\n flex: 1;\n padding: 6px 0;\n text-align: center;\n font-size: 13px;\n cursor: pointer;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n border: none;\n border-right: 1px solid var(--divider-color, #e0e0e0);\n transition: background 0.15s ease, color 0.15s ease;\n user-select: none;\n line-height: 1.4;\n }\n .horizon-segment:last-child {\n border-right: none;\n }\n .horizon-segment:hover:not(.active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .horizon-segment.active {\n background: var(--primary-color, #03a9f4);\n color: #fff;\n font-weight: 600;\n }\n .horizon-segment.referenced {\n box-shadow: inset 0 -3px 0 var(--primary-color, #03a9f4);\n }\n\n .unit-toggle {\n display: inline-flex;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 6px;\n overflow: hidden;\n }\n .unit-btn {\n padding: 4px 10px;\n border: none;\n border-right: 1px solid var(--divider-color, #e0e0e0);\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 13px;\n font-weight: 500;\n cursor: pointer;\n transition: background 0.15s ease, color 0.15s ease;\n }\n .unit-btn:last-child {\n border-right: none;\n }\n .unit-btn:hover:not(.unit-active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .unit-btn.unit-active {\n background: var(--primary-color, #03a9f4);\n color: #fff;\n font-weight: 600;\n }\n\n .monitoring-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n }\n\n .fav-heart {\n background: none;\n border: 1px solid var(--divider-color, #e0e0e0);\n color: var(--secondary-text-color, #727272);\n border-radius: 4px;\n padding: 2px 6px;\n cursor: pointer;\n font-size: 0.9em;\n margin-right: 6px;\n line-height: 1;\n display: inline-flex;\n align-items: center;\n }\n .fav-heart.active {\n color: var(--primary-color, #03a9f4);\n border-color: var(--primary-color, #03a9f4);\n }\n .fav-heart:hover:not(.active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .fav-heart span-icon {\n --mdc-icon-size: 16px;\n }\n\n .panel-mode-info {\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n line-height: 1.6;\n }\n .panel-mode-info p {\n margin: 0 0 12px 0;\n }\n\n',e.appendChild(n);const i=document.createElement("div");i.className="backdrop",i.addEventListener("click",()=>this.close()),e.appendChild(i);const r=document.createElement("div");r.className="panel",e.appendChild(r),t.favoritesMode?this._renderFavoritesMode(r):t.panelMode?this._renderPanelMode(r):t.subDeviceMode?this._renderSubDeviceMode(r,t):this._renderCircuitMode(r,t)}_renderPanelMode(t){const e=this._config,n=this._createHeader(i("sidepanel.graph_settings"),i("sidepanel.global_defaults"));t.appendChild(n);const r=document.createElement("div");r.className="panel-body";const o=e.graphSettings,l=e.topology,c=o?.global_horizon??a,u=o?.circuits??{};r.appendChild(this._buildListColumnsSection());const d=document.createElement("div");d.className="section";const h=document.createElement("div");h.className="section-label",h.textContent=i("sidepanel.graph_horizon"),d.appendChild(h);const p=document.createElement("div");p.className="field-row";const g=document.createElement("span");g.className="field-label",g.textContent=i("sidepanel.global_default"),p.appendChild(g);const v=document.createElement("select");for(const t of Object.keys(s)){const e=document.createElement("option");e.value=t;const n=`horizon.${t}`,r=i(n);e.textContent=r!==n?r:t,t===c&&(e.selected=!0),v.appendChild(e)}if(v.addEventListener("change",()=>{const t={horizon:v.value};e.configEntryId&&(t.config_entry_id=e.configEntryId),this._callDomainService("set_graph_time_horizon",t).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})}),p.appendChild(v),d.appendChild(p),r.appendChild(d),l?.circuits){const t=document.createElement("div");t.className="section";const n=document.createElement("div");n.className="section-label",n.textContent=i("sidepanel.circuit_scales"),t.appendChild(n);const o=Object.entries(l.circuits).sort(([,t],[,e])=>(t.name||"").localeCompare(e.name||""));for(const[n,i]of o){const r=this._buildPanelModeCircuitRow(n,i,u[n],c,e.configEntryId??null,e.showFavorites??!1,e.favoritePanelDeviceId,e.favoriteCircuitUuids);t.appendChild(r)}r.appendChild(t)}const m=o?.sub_devices??{};if(l?.sub_devices){const t=document.createElement("div");t.className="section";const n=document.createElement("div");n.className="section-label",n.textContent=i("sidepanel.subdevice_scales"),t.appendChild(n);const o=Object.entries(l.sub_devices).sort(([,t],[,e])=>(t.name||"").localeCompare(e.name||""));for(const[n,r]of o){const o=document.createElement("div");o.className="field-row";const a=document.createElement("span");if(a.className="field-label",a.textContent=r.name||n,a.style.cssText="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;",o.appendChild(a),e.showFavorites&&e.favoritePanelDeviceId){const t=this._buildSubDeviceFavoriteHeart(r.entities,e.favoriteSubDeviceIds?.has(n)??!1);t&&o.appendChild(t)}const l=m[n]||{horizon:c,has_override:!1},u=l.has_override?l.horizon:c,d=document.createElement("select");d.dataset.subdevId=n;for(const t of Object.keys(s)){const e=document.createElement("option");e.value=t;const n=`horizon.${t}`,r=i(n);e.textContent=r!==n?r:t,t===u&&(e.selected=!0),d.appendChild(e)}if(d.addEventListener("change",()=>{this._debounce(`subdev-${n}`,f,()=>{const t={subdevice_id:n,horizon:d.value};e.configEntryId&&(t.config_entry_id=e.configEntryId),this._callDomainService("set_subdevice_graph_horizon",t).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})})}),o.appendChild(d),l.has_override){const t=document.createElement("button");t.textContent="↺",t.title=i("sidepanel.reset_to_global"),Object.assign(t.style,{background:"none",border:"1px solid var(--divider-color, #e0e0e0)",color:"var(--primary-text-color)",borderRadius:"4px",padding:"3px 6px",cursor:"pointer",marginLeft:"4px",fontSize:"0.85em"}),t.addEventListener("click",()=>{const r={subdevice_id:n};e.configEntryId&&(r.config_entry_id=e.configEntryId),this._callDomainService("clear_subdevice_graph_horizon",r).then(()=>{d.value=c,t.remove(),this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})}),o.appendChild(t)}t.appendChild(o)}r.appendChild(t)}t.appendChild(r)}_buildPanelModeCircuitRow(t,e,n,r,o,a,l,c){const u=document.createElement("div");u.className="field-row";const d=document.createElement("span");if(d.className="field-label",d.textContent=e.name||t,d.style.cssText="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;",u.appendChild(d),a&&l){const n=this._buildFavoriteHeart(e.entities,c?.has(t)??!1);n&&u.appendChild(n)}const h=n||{horizon:r,has_override:!1},p=h.has_override?h.horizon:r,g=document.createElement("select");g.dataset.uuid=t;for(const t of Object.keys(s)){const e=document.createElement("option");e.value=t;const n=`horizon.${t}`,r=i(n);e.textContent=r!==n?r:t,t===p&&(e.selected=!0),g.appendChild(e)}if(g.addEventListener("change",()=>{this._debounce(`circuit-${t}`,f,()=>{const e={circuit_id:t,horizon:g.value};o&&(e.config_entry_id=o),this._callDomainService("set_circuit_graph_horizon",e).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})})}),u.appendChild(g),h.has_override){const e=document.createElement("button");e.textContent="↺",e.title=i("sidepanel.reset_to_global"),Object.assign(e.style,{background:"none",border:"1px solid var(--divider-color, #e0e0e0)",color:"var(--primary-text-color)",borderRadius:"4px",padding:"3px 6px",cursor:"pointer",marginLeft:"4px",fontSize:"0.85em"}),e.addEventListener("click",()=>{const n={circuit_id:t};o&&(n.config_entry_id=o),this._callDomainService("clear_circuit_graph_horizon",n).then(()=>{g.value=r,e.remove(),this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})}),u.appendChild(e)}return u}_renderFavoritesMode(t){const e=this._config,n=this._createHeader(i("sidepanel.graph_settings"),i("sidepanel.favorites_subtitle"));t.appendChild(n);const r=document.createElement("div");r.className="panel-body",r.appendChild(this._buildListColumnsSection());for(const t of e.perPanelSections)r.appendChild(this._buildFavoritesPanelSection(t));t.appendChild(r)}_buildFavoritesPanelSection(t){const e=document.createElement("div");e.className="section";const n=document.createElement("div");n.className="section-label",n.textContent=t.panelName,e.appendChild(n);const i=t.graphSettings?.global_horizon??a,r=t.graphSettings?.circuits??{},o=function(t){const e=t.circuits??{};return Object.entries(e).map(([t,e])=>({uuid:t,circuit:e})).sort((t,e)=>(t.circuit.name||"").localeCompare(e.circuit.name||""))}(t.topology);for(const{uuid:n,circuit:a}of o){const o=this._buildPanelModeCircuitRow(n,a,r[n],i,t.configEntryId,!0,t.panelDeviceId,t.favoriteCircuitUuids);e.appendChild(o)}return e}_renderCircuitMode(t,e){const n=`${Rt(String(e.breaker_rating_a))}A · ${Rt(String(e.voltage))}V · Tabs [${Rt(String(e.tabs))}]`,i=this._createHeader(Rt(e.name),n);t.appendChild(i);const r=document.createElement("div");r.className="panel-body",t.appendChild(r),this._renderRelaySection(r,e),e.showFavorites&&this._renderFavoriteSection(r,e),this._renderSheddingSection(r,e),this._renderGraphHorizonSection(r,e),e.showMonitoring&&this._renderMonitoringSection(r,e)}_favoriteEntityId(t){return t?.current??t?.power??null}_subDeviceFavoriteEntityId(t){if(!t)return null;let e=null;for(const[n,i]of Object.entries(t)){if("sensor"===i.domain)return n;e||(e=n)}return e}_buildSubDeviceFavoriteHeart(t,e){const n=this._subDeviceFavoriteEntityId(t);return n?this._buildHeartButton(n,e):null}_buildListColumnsSection(){const t=document.createElement("div");t.className="section";const e=document.createElement("div");e.className="section-label",e.textContent=i("sidepanel.list_view_columns"),t.appendChild(e);const n=document.createElement("div");n.className="field-row";const r=document.createElement("span");r.className="field-label",r.textContent=i("sidepanel.columns"),n.appendChild(r);const o=Bt(),a=document.createElement("div");a.className="unit-toggle";for(const t of[1,2,3]){const e=document.createElement("button");e.type="button",e.className="unit-btn"+(t===o?" unit-active":""),e.dataset.columns=String(t),e.textContent=String(t),e.addEventListener("click",()=>{Ft(t);for(const t of a.querySelectorAll(".unit-btn"))t.classList.toggle("unit-active",t===e);this.dispatchEvent(new CustomEvent("list-columns-changed",{detail:t,bubbles:!0,composed:!0}))}),a.appendChild(e)}return n.appendChild(a),t.appendChild(n),t}_buildFavoriteHeart(t,e){const n=this._favoriteEntityId(t);return n?this._buildHeartButton(n,e):(console.warn("SPAN Panel: circuit has no current/power sensor; favorite heart suppressed"),null)}_buildHeartButton(t,e){const n=document.createElement("button");n.type="button",n.className=e?"fav-heart active":"fav-heart",n.dataset.role="fav-heart",n.title=i("sidepanel.save_to_favorites"),n.setAttribute("role","switch"),n.setAttribute("aria-checked",String(e)),n.setAttribute("aria-label",i("sidepanel.save_to_favorites"));const r=document.createElement("span-icon");return r.setAttribute("icon",e?"mdi:heart":"mdi:heart-outline"),n.appendChild(r),n.addEventListener("click",e=>{e.stopPropagation(),this._toggleFavoriteEntity(n,r,t).catch(()=>{})}),n}async _toggleFavoriteEntity(t,e,n){if(!this._hass)return;const r=t.classList.contains("active"),o=!r;t.classList.toggle("active",o),e.setAttribute("icon",o?"mdi:heart":"mdi:heart-outline"),t.setAttribute("aria-checked",String(o));try{o?await async function(t,e){const n=await nT(t,"add_favorite",{entity_id:e});return document.dispatchEvent(new CustomEvent(eT)),n?.favorites??{}}(this._hass,n):await async function(t,e){const n=await nT(t,"remove_favorite",{entity_id:e});return document.dispatchEvent(new CustomEvent(eT)),n?.favorites??{}}(this._hass,n)}catch(n){throw t.classList.toggle("active",r),e.setAttribute("icon",r?"mdi:heart":"mdi:heart-outline"),t.setAttribute("aria-checked",String(r)),console.warn("SPAN Panel: favorite toggle failed",n),this.errorStore?.add({key:"service:favorites",level:"error",message:i("error.favorites_toggle_failed"),persistent:!1}),n}}_renderFavoriteSection(t,e){const n=this._favoriteEntityId(e.entities);n&&this._appendFavoriteHeartSection(t,n,!0===e.isFavorite)}_appendFavoriteHeartSection(t,e,n){const r=document.createElement("div");r.className="section",r.innerHTML=``;const o=document.createElement("div");o.className="field-row";const a=document.createElement("span");a.className="field-label",a.textContent=i("sidepanel.save_to_favorites"),o.appendChild(a),o.appendChild(this._buildHeartButton(e,n)),r.appendChild(o),t.appendChild(r)}_renderSubDeviceMode(t,e){const n=this._createHeader(Rt(e.name),Rt(e.deviceType));t.appendChild(n);const i=document.createElement("div");i.className="panel-body",t.appendChild(i),e.showFavorites&&this._renderSubDeviceFavoriteSection(i,e),this._renderSubDeviceHorizonSection(i,e)}_renderSubDeviceFavoriteSection(t,e){const n=this._subDeviceFavoriteEntityId(e.entities);n&&this._appendFavoriteHeartSection(t,n,!0===e.isFavorite)}_renderSubDeviceHorizonSection(t,e){const n=document.createElement("div");n.className="section";const r=document.createElement("div");r.className="section-label",r.textContent=i("sidepanel.graph_horizon"),n.appendChild(r);const o=e.graphHorizonInfo,l=!0===o?.has_override,c=o?.horizon||a,u=o?.globalHorizon||a,d=document.createElement("div");d.className="horizon-bar";const h=[{key:"global",label:i("sidepanel.global")}];for(const t of Object.keys(s))h.push({key:t,label:t});const p=l?c:"global",f=t=>{for(const e of d.querySelectorAll(".horizon-segment")){const n=e.dataset.horizon;e.classList.toggle("active",n===t),e.classList.toggle("referenced","global"===t&&n===u)}};for(const{key:t,label:n}of h){const r=document.createElement("button");r.type="button",r.className="horizon-segment",r.dataset.horizon=t,r.textContent=n,r.classList.toggle("active",t===p),r.classList.toggle("referenced","global"===p&&t===u),r.addEventListener("click",()=>{if(r.classList.contains("active"))return;const n={subdevice_id:e.subDeviceId};e.configEntryId&&(n.config_entry_id=e.configEntryId),"global"===t?(f("global"),this._callDomainService("clear_subdevice_graph_horizon",n).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})):(f(t),this._callDomainService("set_subdevice_graph_horizon",{...n,horizon:t}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})}))}),d.appendChild(r)}n.appendChild(d),t.appendChild(n)}_createHeader(t,e){const n=document.createElement("div");n.className="panel-header";const i=document.createElement("div"),r=Rt(t),o=Rt(e);i.innerHTML=`
${r}
`+(o?`
${o}
`:"");const a=document.createElement("button");return a.className="close-btn",a.innerHTML="✕",a.addEventListener("click",()=>this.close()),n.appendChild(i),n.appendChild(a),n}_renderRelaySection(t,e){if(!1===e.is_user_controllable||!e.entities?.switch)return;const n=document.createElement("div");n.className="section",n.innerHTML=``;const r=document.createElement("div");r.className="field-row";const o=document.createElement("span");o.className="field-label",o.textContent=i("sidepanel.breaker");const a=document.createElement("span-switch");a.dataset.role="relay-toggle";const s=e.entities.switch,l=this._hass?.states?.[s]?.state;"on"===l&&a.setAttribute("checked",""),a.addEventListener("change",()=>{const t=a.hasAttribute("checked")||a.checked;this._callService("switch",t?"turn_on":"turn_off",{entity_id:s}).catch(t=>{console.warn("SPAN Panel: relay toggle failed",t),this.errorStore?.add({key:"service:relay",level:"error",message:i("error.relay_failed"),persistent:!1})})}),r.appendChild(o),r.appendChild(a),n.appendChild(r),t.appendChild(n)}_renderSheddingSection(t,e){if(!e.entities?.select)return;const n=document.createElement("div");n.className="section",n.innerHTML=``;const r=document.createElement("div");r.className="field-row";const o=document.createElement("span");o.className="field-label",o.textContent=i("sidepanel.priority_label");const a=document.createElement("select");a.dataset.role="shedding-select";const s=e.entities.select,l=this._hass?.states?.[s]?.state||"";for(const t of iT){const e=m[t];if(!e)continue;const n=document.createElement("option");n.value=t,n.textContent=i(`shedding.select.${t}`)||e.label(),t===l&&(n.selected=!0),a.appendChild(n)}a.addEventListener("change",()=>{this._callService("select","select_option",{entity_id:s,option:a.value}).catch(t=>{console.warn("SPAN Panel: shedding update failed",t),this.errorStore?.add({key:"service:shedding",level:"error",message:i("error.shedding_failed"),persistent:!1})})}),r.appendChild(o),r.appendChild(a),n.appendChild(r),t.appendChild(n)}_renderGraphHorizonSection(t,e){const n=document.createElement("div");n.className="section";const r=document.createElement("div");r.className="section-label",r.textContent=i("sidepanel.graph_horizon"),n.appendChild(r);const o=e.graphHorizonInfo,l=!0===o?.has_override,c=o?.horizon||a,u=o?.globalHorizon||a,d=document.createElement("div");d.className="horizon-bar";const h=[{key:"global",label:i("sidepanel.global")}];for(const t of Object.keys(s))h.push({key:t,label:t});const p=l?c:"global",f=t=>{for(const e of d.querySelectorAll(".horizon-segment")){const n=e.dataset.horizon;e.classList.toggle("active",n===t),e.classList.toggle("referenced","global"===t&&n===u)}};for(const{key:t,label:n}of h){const r=document.createElement("button");r.type="button",r.className="horizon-segment",r.dataset.horizon=t,r.textContent=n,r.classList.toggle("active",t===p),r.classList.toggle("referenced","global"===p&&t===u),r.addEventListener("click",()=>{if(r.classList.contains("active"))return;const n={circuit_id:e.uuid};e.configEntryId&&(n.config_entry_id=e.configEntryId),"global"===t?(f("global"),this._callDomainService("clear_circuit_graph_horizon",n).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})):(f(t),this._callDomainService("set_circuit_graph_horizon",{...n,horizon:t}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})}))}),d.appendChild(r)}n.appendChild(d),t.appendChild(n)}_renderMonitoringSection(t,e){const n=document.createElement("div");n.className="section";const r=document.createElement("div");r.className="monitoring-header";const o=document.createElement("div");o.className="section-label",o.textContent=i("sidepanel.monitoring"),o.style.margin="0";const a=document.createElement("span-switch");a.dataset.role="monitoring-toggle";const s=e.monitoringInfo,l=null!=s&&!1!==s.monitoring_enabled;l&&a.setAttribute("checked",""),r.appendChild(o),r.appendChild(a),n.appendChild(r);const c=document.createElement("div");c.dataset.role="monitoring-details",c.style.display=l?"block":"none",n.appendChild(c);const u=!0===s?.has_override,d=document.createElement("div");d.className="radio-group",d.innerHTML=`\n \n \n `,c.appendChild(d);const h=document.createElement("div");h.dataset.role="threshold-fields",h.style.display=u?"block":"none";const p=s?.continuous_threshold_pct??80,f=s?.spike_threshold_pct??100,g=s?.window_duration_m??15,v=s?.cooldown_duration_m??15;h.appendChild(this._createThresholdRow(i("sidepanel.continuous_pct"),"continuous",p,e)),h.appendChild(this._createThresholdRow(i("sidepanel.spike_pct"),"spike",f,e)),h.appendChild(this._createDurationRow(i("sidepanel.window_duration"),"window-m",g,1,180,"m",e)),h.appendChild(this._createDurationRow(i("sidepanel.cooldown"),"cooldown-m",v,1,180,"m",e)),c.appendChild(h),a.addEventListener("change",()=>{const t=a.checked;c.style.display=t?"block":"none";const n={circuit_id:e.entities?.power||e.uuid,monitoring_enabled:t};e.configEntryId&&(n.config_entry_id=e.configEntryId),this._callDomainService("set_circuit_threshold",n).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:i("error.threshold_failed"),persistent:!1})})});const m=d.querySelectorAll('input[type="radio"]');for(const t of m)t.addEventListener("change",()=>{const n="custom"===t.value&&t.checked;if(h.style.display=n?"block":"none",!n&&t.checked){const t={circuit_id:e.entities?.power||e.uuid};e.configEntryId&&(t.config_entry_id=e.configEntryId),this._callDomainService("clear_circuit_threshold",t).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:i("error.threshold_failed"),persistent:!1})})}});t.appendChild(n)}_createThresholdRow(t,e,n,r){const o=document.createElement("div");o.className="field-row";const a=document.createElement("span");a.className="field-label",a.textContent=t;const s=document.createElement("input");return s.type="number",s.min="0",s.max="200",s.value=String(n),s.dataset.role=`threshold-${e}`,s.addEventListener("input",()=>{this._debounce(`threshold-${e}`,f,()=>{const t=this.shadowRoot;if(!t)return;const e=t.querySelector('[data-role="threshold-continuous"]'),n=t.querySelector('[data-role="threshold-spike"]'),o=t.querySelector('[data-role="threshold-window-m"]'),a=t.querySelector('[data-role="threshold-cooldown-m"]'),s={circuit_id:r.entities?.power||r.uuid,continuous_threshold_pct:e?Number(e.value):void 0,spike_threshold_pct:n?Number(n.value):void 0,window_duration_m:o?Number(o.value):void 0,cooldown_duration_m:a?Number(a.value):void 0};r.configEntryId&&(s.config_entry_id=r.configEntryId),this._callDomainService("set_circuit_threshold",s).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:i("error.threshold_failed"),persistent:!1})})})}),o.appendChild(a),o.appendChild(s),o}_createDurationRow(t,e,n,r,o,a,s,l=!1){const c=document.createElement("div");c.className="field-row";const u=document.createElement("span");u.className="field-label",u.textContent=t;const d=document.createElement("div"),h=document.createElement("input");h.type="number",h.min=String(r),h.max=String(o),h.value=String(n),h.dataset.role=`threshold-${e}`,l&&(h.disabled=!0);const p=document.createElement("span");return p.textContent=a,d.appendChild(h),d.appendChild(p),l||h.addEventListener("input",()=>{this._debounce(`threshold-${e}`,f,()=>{const t=this.shadowRoot;if(!t)return;const e=t.querySelector('[data-role="threshold-continuous"]'),n=t.querySelector('[data-role="threshold-spike"]'),r=t.querySelector('[data-role="threshold-window-m"]'),o={circuit_id:s.uuid,continuous_threshold_pct:e?Number(e.value):void 0,spike_threshold_pct:n?Number(n.value):void 0,window_duration_m:r?Number(r.value):void 0};s.configEntryId&&(o.config_entry_id=s.configEntryId),this._callDomainService("set_circuit_threshold",o).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:i("error.threshold_failed"),persistent:!1})})})}),c.appendChild(u),c.appendChild(d),c}_updateLiveState(){if(!this._config||this._config.panelMode)return;const t=this._config;if(!t.subDeviceMode&&!t.favoritesMode){if(t.entities?.switch){const e=this.shadowRoot?.querySelector('[data-role="relay-toggle"]');if(e){const n=this._hass?.states?.[t.entities.switch]?.state;"on"===n?e.setAttribute("checked",""):e.removeAttribute("checked")}}if(t.entities?.select){const e=this.shadowRoot?.querySelector('[data-role="shedding-select"]');if(e){const n=this._hass?.states?.[t.entities.select]?.state||"";e.value=n}}}}_callService(t,e,n){return this._hass?Promise.resolve(this._hass.callService(t,e,n)):Promise.resolve()}_callDomainService(t,e){return this._hass?this._hass.callWS({type:"call_service",domain:l,service:t,service_data:e}):Promise.resolve()}_debounce(t,e,n){this._debounceTimers[t]&&clearTimeout(this._debounceTimers[t]),this._debounceTimers[t]=setTimeout(()=>{delete this._debounceTimers[t],n()},e)}}try{customElements.get("span-side-panel")||customElements.define("span-side-panel",rT)}catch{}class oT extends It{constructor(){super(...arguments),this._store=null,this._unsub=null,this._errors=[]}set store(t){if(this._store===t)return;this._unsub?.(),this._unsub=null,this._store=t,this._errors=t.active;const e=t;this._unsub=t.subscribe(()=>{this._errors=e.active})}connectedCallback(){if(super.connectedCallback(),this._store&&!this._unsub){const t=this._store;this._errors=t.active,this._unsub=t.subscribe(()=>{this._errors=t.active})}}disconnectedCallback(){super.disconnectedCallback(),this._unsub?.(),this._unsub=null}render(){return 0===this._errors.length?ft:dt`${this._errors.map(t=>dt`