Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions custom_components/span_panel/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
12 changes: 9 additions & 3 deletions custom_components/span_panel/select.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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()
Expand Down
18 changes: 12 additions & 6 deletions custom_components/span_panel/sensor_circuit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
28 changes: 28 additions & 0 deletions tests/test_select.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
4 changes: 2 additions & 2 deletions tests/test_sensor_entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.