diff --git a/AGENTS.md b/AGENTS.md index 1ceeb67..8b31bcd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1812,6 +1812,17 @@ When using `DataUpdateCoordinator`, entities get updates automatically. Only imp **4. Service Response Declaration:** Services returning data MUST declare `supports_response` parameter. Use `SupportsResponse.ONLY` for data-only services, `OPTIONAL` for dual-purpose, `NONE` for action-only. See `services.py` for examples. +**5. Entity Lifecycle & State Management:** +All entities MUST implement these patterns for proper HA integration: + +- **`available` property**: Indicates if entity can be read/controlled. Return `False` when coordinator has no data yet or last update failed. See `entity.py` for base implementation. Special cases (e.g., `connection` binary_sensor) override to always return `True`. + +- **State Restore**: Inherit from `RestoreSensor` (sensors) or `RestoreEntity` (binary_sensors) to restore state after HA restart. Eliminates "unavailable" gaps in history. Restore logic in `async_added_to_hass()` using `async_get_last_state()` and `async_get_last_sensor_data()`. See `sensor/core.py` and `binary_sensor/core.py` for implementation. + +- **`force_update` property**: Set to `True` for entities where every state change should be recorded, even if value unchanged (e.g., `connection` sensor tracking connectivity issues). Default is `False`. See `binary_sensor/core.py` for example. + +**Why this matters**: Without `available`, entities show stale data during errors. Without state restore, history has gaps after HA restart. Without `force_update`, repeated state changes aren't visible in history. + ## Code Quality Rules **CRITICAL: See "Linting Best Practices" section for comprehensive type checking (Pyright) and linting (Ruff) guidelines.** diff --git a/custom_components/tibber_prices/binary_sensor/core.py b/custom_components/tibber_prices/binary_sensor/core.py index aa30bdc..1f3fc9b 100644 --- a/custom_components/tibber_prices/binary_sensor/core.py +++ b/custom_components/tibber_prices/binary_sensor/core.py @@ -15,6 +15,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.restore_state import RestoreEntity from .attributes import ( build_async_extra_state_attributes, @@ -32,8 +33,8 @@ if TYPE_CHECKING: from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService -class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity): - """tibber_prices binary_sensor class.""" +class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEntity): + """tibber_prices binary_sensor class with state restoration.""" # Attributes excluded from recorder history # See: https://developers.home-assistant.io/docs/core/entity/#excluding-state-attributes-from-recorder-history @@ -83,6 +84,11 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity): """When entity is added to hass.""" await super().async_added_to_hass() + # Restore last state if available + if (last_state := await self.async_get_last_state()) is not None and last_state.state in ("on", "off"): + # Restore binary state (on/off) - will be used until first coordinator update + self._attr_is_on = last_state.state == "on" + # Register with coordinator for time-sensitive updates if applicable if self.entity_description.key in TIME_SENSITIVE_ENTITY_KEYS: self._time_sensitive_remove_listener = self.coordinator.async_add_time_sensitive_listener( @@ -180,6 +186,31 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity): return False return False + @property + def available(self) -> bool: + """ + Return if entity is available. + + Override base implementation for connection sensor which should + always be available to show connection state. + """ + # Connection sensor is always available (shows connection state) + if self.entity_description.key == "connection": + return True + + # All other binary sensors use base availability logic + return super().available + + @property + def force_update(self) -> bool: + """ + Force update for connection sensor to record all state changes. + + Connection sensor should write every state change to history, + even if the state (on/off) is the same, to track connectivity issues. + """ + return self.entity_description.key == "connection" + def _has_ventilation_system_state(self) -> bool | None: """Return True if the home has a ventilation system.""" if not self.coordinator.data: diff --git a/custom_components/tibber_prices/entity.py b/custom_components/tibber_prices/entity.py index 882d41d..0cbd2d5 100644 --- a/custom_components/tibber_prices/entity.py +++ b/custom_components/tibber_prices/entity.py @@ -44,6 +44,22 @@ class TibberPricesEntity(CoordinatorEntity[TibberPricesDataUpdateCoordinator]): configuration_url="https://developer.tibber.com/explorer", ) + @property + def available(self) -> bool: + """ + Return if entity is available. + + Entity is unavailable when: + - Coordinator has not completed first update (no data yet) + - Coordinator has encountered an error (last_update_success = False) + + Note: Auth failures are handled by coordinator's update method, + which raises ConfigEntryAuthFailed and triggers reauth flow. + """ + # Return False if coordinator not ready or has errors + # Return True if coordinator has data (bool conversion handles None/empty) + return self.coordinator.last_update_success and bool(self.coordinator.data) + def _get_device_info(self) -> tuple[str, str | None, str | None]: """Get device name, ID and type.""" user_profile = self.coordinator.get_user_profile() diff --git a/custom_components/tibber_prices/sensor/core.py b/custom_components/tibber_prices/sensor/core.py index 4e3fc6f..e93fe94 100644 --- a/custom_components/tibber_prices/sensor/core.py +++ b/custom_components/tibber_prices/sensor/core.py @@ -41,8 +41,8 @@ from custom_components.tibber_prices.utils.price import ( calculate_volatility_level, ) from homeassistant.components.sensor import ( + RestoreSensor, SensorDeviceClass, - SensorEntity, SensorEntityDescription, ) from homeassistant.const import EntityCategory @@ -92,8 +92,8 @@ MAX_FORECAST_INTERVALS = 8 # Show up to 8 future intervals (2 hours with 15-min MIN_HOURS_FOR_LATER_HALF = 3 # Minimum hours needed to calculate later half average -class TibberPricesSensor(TibberPricesEntity, SensorEntity): - """tibber_prices Sensor class.""" +class TibberPricesSensor(TibberPricesEntity, RestoreSensor): + """tibber_prices Sensor class with state restoration.""" # Attributes excluded from recorder history # See: https://developers.home-assistant.io/docs/core/entity/#excluding-state-attributes-from-recorder-history @@ -184,6 +184,29 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): """When entity is added to hass.""" await super().async_added_to_hass() + # Restore last state if available + if ( + (last_state := await self.async_get_last_state()) is not None + and last_state.state not in (None, "unknown", "unavailable", "") + and (last_sensor_data := await self.async_get_last_sensor_data()) is not None + ): + # Restore native_value from extra data (more reliable than state) + self._attr_native_value = last_sensor_data.native_value + + # For chart sensors, restore response data from attributes + if self.entity_description.key == "chart_data_export": + self._chart_data_response = last_state.attributes.get("data") + self._chart_data_last_update = last_state.attributes.get("last_update") + elif self.entity_description.key == "chart_metadata": + # Restore metadata response from attributes + metadata_attrs = {} + for key in ["title", "yaxis_min", "yaxis_max", "currency", "resolution"]: + if key in last_state.attributes: + metadata_attrs[key] = last_state.attributes[key] + if metadata_attrs: + self._chart_metadata_response = metadata_attrs + self._chart_metadata_last_update = last_state.attributes.get("last_update") + # Register with coordinator for time-sensitive updates if applicable if self.entity_description.key in TIME_SENSITIVE_ENTITY_KEYS: self._time_sensitive_remove_listener = self.coordinator.async_add_time_sensitive_listener( @@ -196,13 +219,15 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): self._handle_minute_update ) - # For chart_data_export, trigger initial service call + # For chart_data_export, trigger initial service call as background task + # (non-blocking to avoid delaying entity setup) if self.entity_description.key == "chart_data_export": - await self._refresh_chart_data() + self.hass.async_create_task(self._refresh_chart_data()) - # For chart_metadata, trigger initial service call + # For chart_metadata, trigger initial service call as background task + # (non-blocking to avoid delaying entity setup) if self.entity_description.key == "chart_metadata": - await self._refresh_chart_metadata() + self.hass.async_create_task(self._refresh_chart_metadata()) async def async_will_remove_from_hass(self) -> None: """When entity will be removed from hass."""