From dbbde94e22b049ad1803cc2f18cd18fb33aa1964 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 9 Mar 2026 14:49:45 -0700 Subject: [PATCH] Source panel_size from Homie schema unconditionally panel_size is now provided by span-panel-api>=2.2.3 from the Homie schema at connection time, replacing the circuit-count heuristic that undercounted when trailing breaker positions were empty. The sensor attribute and WebSocket topology field are now always populated. Add tests for extra_state_attributes (panel_size, wifi_ssid). Bump version to 2.0.2. --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 11 +++++ custom_components/span_panel/manifest.json | 4 +- custom_components/span_panel/sensor_panel.py | 3 +- tests/test_panel_sensors.py | 50 ++++++++++++++++++++ 5 files changed, 65 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8eb9f1e..20520c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: - name: Install dependencies run: | # Replace path dependencies with PyPI versions for CI - sed -i 's/span-panel-api = {path = "..\/span-panel-api", develop = true}/span-panel-api = ">=2.2.2"/' pyproject.toml + sed -i 's/span-panel-api = {path = "..\/span-panel-api", develop = true}/span-panel-api = ">=2.2.3"/' pyproject.toml sed -i 's/ha-synthetic-sensors = {path = "..\/ha-synthetic-sensors", develop = true}/ha-synthetic-sensors = "^1.1.13"/' pyproject.toml # Regenerate lock file with the modified dependencies poetry lock diff --git a/CHANGELOG.md b/CHANGELOG.md index b1c2e26..a96d923 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to this project will be documented in this file. +## [2.0.2] - 3/2026 + +### Changed + +- **Panel size always available** — `panel_size` is now sourced from the Homie schema by the underlying `span-panel-api` library during connection, + replacing a heuristic that could undercount when trailing breaker positions were empty. Previously, panels with unused trailing breaker slots + reported a smaller panel size, which understated the unmapped-sensor count and caused the + [span-card](https://github.com/SpanPanel/span-card) layout to show fewer slots than physically present. The `panel_size` sensor attribute and + WebSocket topology field are now unconditionally populated. +- Requires `span-panel-api>=2.2.3` + ## [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 diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 31f830c..3be5f5e 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -12,9 +12,9 @@ "iot_class": "local_push", "issue_tracker": "https://github.com/SpanPanel/span/issues", "requirements": [ - "span-panel-api>=2.2.2" + "span-panel-api>=2.2.3" ], - "version": "2.0.1", + "version": "2.0.2", "zeroconf": [ { "type": "_span._tcp.local." diff --git a/custom_components/span_panel/sensor_panel.py b/custom_components/span_panel/sensor_panel.py index 2b164f5..a7c1d4a 100644 --- a/custom_components/span_panel/sensor_panel.py +++ b/custom_components/span_panel/sensor_panel.py @@ -102,8 +102,7 @@ def extra_state_attributes(self) -> dict[str, Any] | None: snapshot = self.coordinator.data attributes: dict[str, Any] = {} - if snapshot.panel_size is not None: - attributes["panel_size"] = snapshot.panel_size + attributes["panel_size"] = snapshot.panel_size if snapshot.wifi_ssid is not None: attributes["wifi_ssid"] = snapshot.wifi_ssid diff --git a/tests/test_panel_sensors.py b/tests/test_panel_sensors.py index 4ab892a..a72f181 100644 --- a/tests/test_panel_sensors.py +++ b/tests/test_panel_sensors.py @@ -190,6 +190,56 @@ def test_dsm_state_with_real_data(self) -> None: snapshot_off = SpanPanelSnapshotFactory.create(dsm_state=DSM_OFF_GRID) assert dsm_description.value_fn(snapshot_off) == DSM_OFF_GRID + def test_software_version_extra_state_attributes_panel_size( + self, mock_coordinator: MagicMock + ) -> None: + """Test that panel_size is always present in extra_state_attributes.""" + from custom_components.span_panel.sensor import SpanPanelStatus + from custom_components.span_panel.sensor_definitions import STATUS_SENSORS + + description = next(d for d in STATUS_SENSORS if d.key == "software_version") + + snapshot_with_size = SpanPanelSnapshotFactory.create(panel_size=32) + mock_coordinator.data = snapshot_with_size + sensor = SpanPanelStatus(mock_coordinator, description, snapshot_with_size) + attrs = sensor.extra_state_attributes + assert attrs is not None + assert attrs["panel_size"] == 32 + + def test_software_version_extra_state_attributes_wifi_ssid( + self, mock_coordinator: MagicMock + ) -> None: + """Test that wifi_ssid appears in extra_state_attributes when present.""" + from custom_components.span_panel.sensor import SpanPanelStatus + from custom_components.span_panel.sensor_definitions import STATUS_SENSORS + + description = next(d for d in STATUS_SENSORS if d.key == "software_version") + + snapshot = SpanPanelSnapshotFactory.create(panel_size=24, wifi_ssid="MyNetwork") + mock_coordinator.data = snapshot + sensor = SpanPanelStatus(mock_coordinator, description, snapshot) + attrs = sensor.extra_state_attributes + assert attrs is not None + assert attrs["panel_size"] == 24 + assert attrs["wifi_ssid"] == "MyNetwork" + + def test_software_version_extra_state_attributes_no_wifi( + self, mock_coordinator: MagicMock + ) -> None: + """Test that wifi_ssid is omitted when None.""" + from custom_components.span_panel.sensor import SpanPanelStatus + from custom_components.span_panel.sensor_definitions import STATUS_SENSORS + + description = next(d for d in STATUS_SENSORS if d.key == "software_version") + + snapshot = SpanPanelSnapshotFactory.create(panel_size=16, wifi_ssid=None) + 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 + assert attrs["panel_size"] == 16 + def test_dsm_grid_state_deprecated_alias(self) -> None: """Test DSM Grid State reads from dsm_state (deprecated alias).""" from custom_components.span_panel.const import DSM_ON_GRID