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:**
|
**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.
|
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
|
## Code Quality Rules
|
||||||
|
|
||||||
**CRITICAL: See "Linting Best Practices" section for comprehensive type checking (Pyright) and linting (Ruff) guidelines.**
|
**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.core import callback
|
||||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||||
|
from homeassistant.helpers.restore_state import RestoreEntity
|
||||||
|
|
||||||
from .attributes import (
|
from .attributes import (
|
||||||
build_async_extra_state_attributes,
|
build_async_extra_state_attributes,
|
||||||
|
|
@ -32,8 +33,8 @@ if TYPE_CHECKING:
|
||||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||||
|
|
||||||
|
|
||||||
class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
|
class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEntity):
|
||||||
"""tibber_prices binary_sensor class."""
|
"""tibber_prices binary_sensor class with state restoration."""
|
||||||
|
|
||||||
# Attributes excluded from recorder history
|
# Attributes excluded from recorder history
|
||||||
# See: https://developers.home-assistant.io/docs/core/entity/#excluding-state-attributes-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."""
|
"""When entity is added to hass."""
|
||||||
await super().async_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
|
# Register with coordinator for time-sensitive updates if applicable
|
||||||
if self.entity_description.key in TIME_SENSITIVE_ENTITY_KEYS:
|
if self.entity_description.key in TIME_SENSITIVE_ENTITY_KEYS:
|
||||||
self._time_sensitive_remove_listener = self.coordinator.async_add_time_sensitive_listener(
|
self._time_sensitive_remove_listener = self.coordinator.async_add_time_sensitive_listener(
|
||||||
|
|
@ -180,6 +186,31 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
|
||||||
return False
|
return False
|
||||||
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:
|
def _has_ventilation_system_state(self) -> bool | None:
|
||||||
"""Return True if the home has a ventilation system."""
|
"""Return True if the home has a ventilation system."""
|
||||||
if not self.coordinator.data:
|
if not self.coordinator.data:
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,22 @@ class TibberPricesEntity(CoordinatorEntity[TibberPricesDataUpdateCoordinator]):
|
||||||
configuration_url="https://developer.tibber.com/explorer",
|
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]:
|
def _get_device_info(self) -> tuple[str, str | None, str | None]:
|
||||||
"""Get device name, ID and type."""
|
"""Get device name, ID and type."""
|
||||||
user_profile = self.coordinator.get_user_profile()
|
user_profile = self.coordinator.get_user_profile()
|
||||||
|
|
|
||||||
|
|
@ -41,8 +41,8 @@ from custom_components.tibber_prices.utils.price import (
|
||||||
calculate_volatility_level,
|
calculate_volatility_level,
|
||||||
)
|
)
|
||||||
from homeassistant.components.sensor import (
|
from homeassistant.components.sensor import (
|
||||||
|
RestoreSensor,
|
||||||
SensorDeviceClass,
|
SensorDeviceClass,
|
||||||
SensorEntity,
|
|
||||||
SensorEntityDescription,
|
SensorEntityDescription,
|
||||||
)
|
)
|
||||||
from homeassistant.const import EntityCategory
|
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
|
MIN_HOURS_FOR_LATER_HALF = 3 # Minimum hours needed to calculate later half average
|
||||||
|
|
||||||
|
|
||||||
class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
|
||||||
"""tibber_prices Sensor class."""
|
"""tibber_prices Sensor class with state restoration."""
|
||||||
|
|
||||||
# Attributes excluded from recorder history
|
# Attributes excluded from recorder history
|
||||||
# See: https://developers.home-assistant.io/docs/core/entity/#excluding-state-attributes-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."""
|
"""When entity is added to hass."""
|
||||||
await super().async_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
|
# Register with coordinator for time-sensitive updates if applicable
|
||||||
if self.entity_description.key in TIME_SENSITIVE_ENTITY_KEYS:
|
if self.entity_description.key in TIME_SENSITIVE_ENTITY_KEYS:
|
||||||
self._time_sensitive_remove_listener = self.coordinator.async_add_time_sensitive_listener(
|
self._time_sensitive_remove_listener = self.coordinator.async_add_time_sensitive_listener(
|
||||||
|
|
@ -196,13 +219,15 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
||||||
self._handle_minute_update
|
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":
|
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":
|
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:
|
async def async_will_remove_from_hass(self) -> None:
|
||||||
"""When entity will be removed from hass."""
|
"""When entity will be removed from hass."""
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue