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/manifest.json b/custom_components/span_panel/manifest.json index d8387bd8..f897e3cb 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==2.5.1" + "span-panel-api==2.5.2" ], - "version": "2.0.5", + "version": "2.0.6", "zeroconf": [ { "type": "_span._tcp.local." 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..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 ValueError(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,7 +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.""" - return snapshot.circuits[self.circuit_id] + return _get_circuit_data_source(self.circuit_id, snapshot) @property def extra_state_attributes(self) -> dict[str, Any] | None: @@ -471,4 +477,4 @@ 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] + return _get_circuit_data_source(self.circuit_id, snapshot) 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/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) 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" },