feat(lifecycle): implement HA entity best practices for state management

Implemented comprehensive entity lifecycle patterns following Home Assistant
best practices for proper state management and history tracking.
Changes:
- entity.py: Added available property to base class
  - Returns False when coordinator has no data or last_update_success=False
  - Prevents entities from showing stale data during errors
  - Auth failures trigger reauth flow via ConfigEntryAuthFailed

- sensor/core.py: Added state restore and background task handling
  - Changed inheritance: SensorEntity → RestoreSensor
  - Restore native_value from SensorExtraStoredData in async_added_to_hass()
  - Chart sensors restore response data from attributes
  - Converted blocking service calls to background tasks using hass.async_create_task()
  - Eliminates 194ms setup warning by making async_added_to_hass non-blocking

- binary_sensor/core.py: Added state restore and force_update
  - Changed inheritance: BinarySensorEntity → RestoreEntity + BinarySensorEntity
  - Restore is_on state in async_added_to_hass()
  - Added available property override for connection sensor (always True)
  - Added force_update property for connection sensor to track all state changes
  - Other binary sensors use base available logic

- AGENTS.md: Documented entity lifecycle patterns in Common Pitfalls
  - Added "Entity Lifecycle & State Management" section
  - Documents available, state restore, and force_update patterns
  - Explains why each pattern matters for proper HA integration

Impact: Entities no longer show stale data during errors, history has no gaps
after HA restart, connection state changes are properly tracked, and config
entry setup completes in <200ms (under HA threshold).

All patterns verified against HA developer documentation:
https://developers.home-assistant.io/docs/core/entity/
This commit is contained in:
Julian Pawlowski 2025-12-07 17:24:41 +00:00
parent 7d7784300d
commit 98512672ae
4 changed files with 92 additions and 9 deletions

View file

@ -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.**

View file

@ -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:

View file

@ -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()

View file

@ -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."""