mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-03-29 21:03:40 +00:00
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:
parent
7d7784300d
commit
98512672ae
4 changed files with 92 additions and 9 deletions
11
AGENTS.md
11
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.**
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Reference in a new issue