From 5559e1e3cc79e25b21e56121030a81ab556624ee Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:43:04 -0700 Subject: [PATCH 1/4] fix: handle missing circuits in partial MQTT snapshots During panel reboots the SPAN panel can send incomplete MQTT snapshots with circuits temporarily absent. Select entities crashed with KeyError in _get_circuit(); sensor get_data_source used inconsistent error types. - select.py: _get_circuit() returns None for missing circuits; caller skips update cycle gracefully - sensor_circuit.py: all get_data_source() implementations use .get() and raise KeyError (matching the handler in _handle_online_state) - power sensor changed from ValueError to KeyError for consistency --- custom_components/span_panel/select.py | 12 ++++++-- .../span_panel/sensor_circuit.py | 12 ++++++-- tests/test_select.py | 28 +++++++++++++++++++ tests/test_sensor_entities.py | 4 +-- 4 files changed, 48 insertions(+), 8 deletions(-) diff --git a/custom_components/span_panel/select.py b/custom_components/span_panel/select.py index 4dd82daf..b115c91a 100644 --- a/custom_components/span_panel/select.py +++ b/custom_components/span_panel/select.py @@ -183,10 +183,10 @@ def __init__( circuit.name, ) - def _get_circuit(self) -> SpanCircuitSnapshot: - """Get the circuit for this entity.""" + def _get_circuit(self) -> SpanCircuitSnapshot | None: + """Get the circuit for this entity, or None if temporarily missing.""" snapshot: SpanPanelSnapshot = self.coordinator.data - return snapshot.circuits[self.id] + return snapshot.circuits.get(self.id) async def async_will_remove_from_hass(self) -> None: """Clean up when entity is removed.""" @@ -344,6 +344,12 @@ def _handle_coordinator_update(self) -> None: # Update options and current option based on coordinator data circuit = self._get_circuit() + if circuit is None: + _LOGGER.debug( + "Circuit %s temporarily missing from snapshot, skipping select update", + self.id, + ) + return self._attr_options = self.description_wrapper.options_fn(circuit) self._attr_current_option = self.description_wrapper.current_option_fn(circuit) super()._handle_coordinator_update() diff --git a/custom_components/span_panel/sensor_circuit.py b/custom_components/span_panel/sensor_circuit.py index fec15ae6..6f547479 100644 --- a/custom_components/span_panel/sensor_circuit.py +++ b/custom_components/span_panel/sensor_circuit.py @@ -204,7 +204,7 @@ def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanCircuitSnapshot: """Get the data source for the circuit power sensor.""" circuit = snapshot.circuits.get(self.circuit_id) if circuit is None: - raise ValueError(f"Circuit {self.circuit_id} not found in panel data") + raise KeyError(f"Circuit {self.circuit_id} not found in panel data") return circuit @property @@ -389,7 +389,10 @@ def _process_raw_value(self, raw_value: float | str | None) -> None: def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanCircuitSnapshot: """Get the data source for the circuit energy sensor.""" - return snapshot.circuits[self.circuit_id] + circuit = snapshot.circuits.get(self.circuit_id) + if circuit is None: + raise KeyError(f"Circuit {self.circuit_id} not found in panel data") + return circuit @property def extra_state_attributes(self) -> dict[str, Any] | None: @@ -471,4 +474,7 @@ def _generate_friendly_name( def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanCircuitSnapshot: """Get the data source for the unmapped circuit sensor.""" - return snapshot.circuits[self.circuit_id] + circuit = snapshot.circuits.get(self.circuit_id) + if circuit is None: + raise KeyError(f"Circuit {self.circuit_id} not found in panel data") + return circuit diff --git a/tests/test_select.py b/tests/test_select.py index c8ea370d..54af76ed 100644 --- a/tests/test_select.py +++ b/tests/test_select.py @@ -373,6 +373,34 @@ def test_handle_coordinator_update_requests_reload_on_name_change() -> None: assert select._previous_circuit_name == "Renamed Kitchen" +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") + 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 = None + mock_async_get.return_value = registry + + select = SpanPanelCircuitsSelect( + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "Kitchen", "SPAN Panel" + ) + + # Simulate a partial snapshot missing this circuit + coordinator.data = SpanPanelSnapshotFactory.create(circuits={}) + select.hass = MagicMock() + select.async_write_ha_state = MagicMock() + select.entity_id = "select.kitchen_circuit_priority" + + # Should not raise KeyError + select._handle_coordinator_update() + + # async_write_ha_state should NOT be called since we returned early + select.async_write_ha_state.assert_not_called() + + @pytest.mark.asyncio async def test_async_setup_entry_filters_supported_circuits() -> None: """Platform setup should only create selects for supported controllable circuits.""" diff --git a/tests/test_sensor_entities.py b/tests/test_sensor_entities.py index d362799f..3a4bfbd0 100644 --- a/tests/test_sensor_entities.py +++ b/tests/test_sensor_entities.py @@ -364,13 +364,13 @@ def test_circuit_power_sensor_missing_circuit_uses_unmapped_fallback_name() -> N def test_circuit_power_sensor_get_data_source_raises_for_missing_circuit() -> None: - """Missing circuit data should raise a clear ValueError.""" + """Missing circuit data should raise a clear KeyError.""" snapshot = SpanPanelSnapshotFactory.create(circuits={}) coordinator = _make_coordinator(snapshot) sensor = SpanCircuitPowerSensor(coordinator, CIRCUIT_CURRENT_SENSOR, snapshot, "c1") - with pytest.raises(ValueError, match="Circuit c1 not found"): + with pytest.raises(KeyError, match="Circuit c1 not found"): sensor.get_data_source(snapshot) From 7cf71a91c5df6291fd9d654e64c775fc93807d1d Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:07:44 -0700 Subject: [PATCH 2/4] refactor: extract shared circuit data source lookup and refine changelog --- CHANGELOG.md | 2 ++ .../span_panel/sensor_circuit.py | 24 +++++++++---------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 269431d3..d8fecdb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ All notable changes to this project will be documented in this file. - **Dashboard graph fidelity** — Circuit charts now use step interpolation instead of linear, eliminating misleading diagonal ramps between data points. Continuous signals (PV solar output, BESS SoC/SoE) retain linear interpolation to faithfully represent their gradual behavior. +- **Panel reboot stability** — Entities no longer become unavailable or error during a panel reboot when circuits are temporarily missing from MQTT data. + ## [2.0.5] - 4/2026 **Important** 2.0.x cautions still apply — read those carefully if not already on 2.0.x BEFORE proceeding: diff --git a/custom_components/span_panel/sensor_circuit.py b/custom_components/span_panel/sensor_circuit.py index 6f547479..8e12c30c 100644 --- a/custom_components/span_panel/sensor_circuit.py +++ b/custom_components/span_panel/sensor_circuit.py @@ -25,6 +25,15 @@ _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.""" + circuit = snapshot.circuits.get(circuit_id) + if circuit is None: + raise KeyError(f"Circuit {circuit_id} not found in panel data") + return circuit + + # Device types that use "Solar" as the fallback identifier when unnamed, # matching v1 naming conventions (e.g., "Solar Power", "Solar Produced Energy"). _SOLAR_DEVICE_TYPES: frozenset[str] = frozenset({"pv"}) @@ -202,10 +211,7 @@ def _construct_entity_id( def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanCircuitSnapshot: """Get the data source for the circuit power sensor.""" - circuit = snapshot.circuits.get(self.circuit_id) - if circuit is None: - raise KeyError(f"Circuit {self.circuit_id} not found in panel data") - return circuit + return _get_circuit_data_source(self.circuit_id, snapshot) @property def extra_state_attributes(self) -> dict[str, Any] | None: @@ -389,10 +395,7 @@ def _process_raw_value(self, raw_value: float | str | None) -> None: def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanCircuitSnapshot: """Get the data source for the circuit energy sensor.""" - circuit = snapshot.circuits.get(self.circuit_id) - if circuit is None: - raise KeyError(f"Circuit {self.circuit_id} not found in panel data") - return circuit + return _get_circuit_data_source(self.circuit_id, snapshot) @property def extra_state_attributes(self) -> dict[str, Any] | None: @@ -474,7 +477,4 @@ def _generate_friendly_name( def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanCircuitSnapshot: """Get the data source for the unmapped circuit sensor.""" - circuit = snapshot.circuits.get(self.circuit_id) - if circuit is None: - raise KeyError(f"Circuit {self.circuit_id} not found in panel data") - return circuit + return _get_circuit_data_source(self.circuit_id, snapshot) From f63ba3b96bd321b5a74ff29e3c25d1616be42f65 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:22:51 -0700 Subject: [PATCH 3/4] bump span-panel-api to 2.5.2 --- 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 d8387bd8..6a466c19 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==2.5.1" + "span-panel-api==2.5.2" ], "version": "2.0.5", "zeroconf": [ diff --git a/pyproject.toml b/pyproject.toml index 75f7ca66..36340875 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ readme = "README.md" requires-python = ">=3.14.2,<3.15" dependencies = [ "homeassistant==2026.4.0", - "span-panel-api==2.5.1", + "span-panel-api==2.5.2", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 765eea23..3f8d3fbe 100644 --- a/uv.lock +++ b/uv.lock @@ -2409,7 +2409,7 @@ dev = [ [[package]] name = "span-panel-api" -version = "2.5.1" +version = "2.5.2" source = { editable = "../span-panel-api" } dependencies = [ { name = "httpx" }, From 734c0b89d016ac13ffeebe4908abfe2d62bf6347 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:41:38 -0700 Subject: [PATCH 4/4] bump version to 2.0.6 --- 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 6a466c19..f897e3cb 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -24,7 +24,7 @@ "requirements": [ "span-panel-api==2.5.2" ], - "version": "2.0.5", + "version": "2.0.6", "zeroconf": [ { "type": "_span._tcp.local."