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