hass-core/homeassistant/components/shelly/entity.py
J. Nick Koston df82567356
Fix shelly available check when device is not initialized (#124182)
* Fix shelly available check when device is not initialized

available needs to check for device.initialized or if the device
is sleepy as calls to status will raise NotInitialized which results
in many unretrieved exceptions while writing state

fixes
```
2024-08-18 09:33:03.757 ERROR (MainThread) [homeassistant] Error doing job: Task exception was never retrieved (None)
Traceback (most recent call last):
  File "/usr/src/homeassistant/homeassistant/helpers/update_coordinator.py", line 258, in _handle_refresh_interval
    await self._async_refresh(log_failures=True, scheduled=True)
  File "/usr/src/homeassistant/homeassistant/helpers/update_coordinator.py", line 453, in _async_refresh
    self.async_update_listeners()
  File "/usr/src/homeassistant/homeassistant/helpers/update_coordinator.py", line 168, in async_update_listeners
    update_callback()
  File "/config/custom_components/shelly/entity.py", line 374, in _update_callback
    self.async_write_ha_state()
  File "/usr/src/homeassistant/homeassistant/helpers/entity.py", line 1005, in async_write_ha_state
    self._async_write_ha_state()
  File "/usr/src/homeassistant/homeassistant/helpers/entity.py", line 1130, in _async_write_ha_state
    self.__async_calculate_state()
  File "/usr/src/homeassistant/homeassistant/helpers/entity.py", line 1067, in __async_calculate_state
    state = self._stringify_state(available)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/src/homeassistant/homeassistant/helpers/entity.py", line 1011, in _stringify_state
    if (state := self.state) is None:
                 ^^^^^^^^^^
  File "/usr/src/homeassistant/homeassistant/components/binary_sensor/__init__.py", line 293, in state
    if (is_on := self.is_on) is None:
                 ^^^^^^^^^^
  File "/config/custom_components/shelly/binary_sensor.py", line 331, in is_on
    return bool(self.attribute_value)
                ^^^^^^^^^^^^^^^^^^^^
  File "/config/custom_components/shelly/entity.py", line 545, in attribute_value
    self._last_value = self.sub_status
                       ^^^^^^^^^^^^^^^
  File "/config/custom_components/shelly/entity.py", line 534, in sub_status
    return self.status[self.entity_description.sub_key]
           ^^^^^^^^^^^
  File "/config/custom_components/shelly/entity.py", line 364, in status
    return cast(dict, self.coordinator.device.status[self.key])
                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/site-packages/aioshelly/rpc_device/device.py", line 390, in status
    raise NotInitialized
aioshelly.exceptions.NotInitialized
```

* tweak

* cover

* fix

* cover

* fixes
2024-08-22 16:14:45 +03:00

675 lines
22 KiB
Python

"""Shelly entity helper."""
from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import Any, cast
from aioshelly.block_device import Block
from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError
from homeassistant.core import HomeAssistant, State, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.entity_registry import RegistryEntry
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import CONF_SLEEP_PERIOD, LOGGER
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
from .utils import (
async_remove_shelly_entity,
get_block_entity_name,
get_rpc_entity_name,
get_rpc_key_instances,
)
@callback
def async_setup_entry_attribute_entities(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
async_add_entities: AddEntitiesCallback,
sensors: Mapping[tuple[str, str], BlockEntityDescription],
sensor_class: Callable,
) -> None:
"""Set up entities for attributes."""
coordinator = config_entry.runtime_data.block
assert coordinator
if coordinator.device.initialized:
async_setup_block_attribute_entities(
hass, async_add_entities, coordinator, sensors, sensor_class
)
else:
async_restore_block_attribute_entities(
hass,
config_entry,
async_add_entities,
coordinator,
sensors,
sensor_class,
)
@callback
def async_setup_block_attribute_entities(
hass: HomeAssistant,
async_add_entities: AddEntitiesCallback,
coordinator: ShellyBlockCoordinator,
sensors: Mapping[tuple[str, str], BlockEntityDescription],
sensor_class: Callable,
) -> None:
"""Set up entities for block attributes."""
entities = []
assert coordinator.device.blocks
for block in coordinator.device.blocks:
for sensor_id in block.sensor_ids:
description = sensors.get((cast(str, block.type), sensor_id))
if description is None:
continue
# Filter out non-existing sensors and sensors without a value
if getattr(block, sensor_id, None) is None:
continue
# Filter and remove entities that according to settings
# should not create an entity
if description.removal_condition and description.removal_condition(
coordinator.device.settings, block
):
domain = sensor_class.__module__.split(".")[-1]
unique_id = f"{coordinator.mac}-{block.description}-{sensor_id}"
async_remove_shelly_entity(hass, domain, unique_id)
else:
entities.append(
sensor_class(coordinator, block, sensor_id, description)
)
if not entities:
return
async_add_entities(entities)
@callback
def async_restore_block_attribute_entities(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
async_add_entities: AddEntitiesCallback,
coordinator: ShellyBlockCoordinator,
sensors: Mapping[tuple[str, str], BlockEntityDescription],
sensor_class: Callable,
) -> None:
"""Restore block attributes entities."""
entities = []
ent_reg = er.async_get(hass)
entries = er.async_entries_for_config_entry(ent_reg, config_entry.entry_id)
domain = sensor_class.__module__.split(".")[-1]
for entry in entries:
if entry.domain != domain:
continue
attribute = entry.unique_id.split("-")[-1]
block_type = entry.unique_id.split("-")[-2].split("_")[0]
if description := sensors.get((block_type, attribute)):
entities.append(
sensor_class(coordinator, None, attribute, description, entry)
)
if not entities:
return
async_add_entities(entities)
@callback
def async_setup_entry_rpc(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
async_add_entities: AddEntitiesCallback,
sensors: Mapping[str, RpcEntityDescription],
sensor_class: Callable,
) -> None:
"""Set up entities for RPC sensors."""
coordinator = config_entry.runtime_data.rpc
assert coordinator
if coordinator.device.initialized:
async_setup_rpc_attribute_entities(
hass, config_entry, async_add_entities, sensors, sensor_class
)
else:
async_restore_rpc_attribute_entities(
hass, config_entry, async_add_entities, coordinator, sensors, sensor_class
)
@callback
def async_setup_rpc_attribute_entities(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
async_add_entities: AddEntitiesCallback,
sensors: Mapping[str, RpcEntityDescription],
sensor_class: Callable,
) -> None:
"""Set up entities for RPC attributes."""
coordinator = config_entry.runtime_data.rpc
assert coordinator
polling_coordinator = None
if not (sleep_period := config_entry.data[CONF_SLEEP_PERIOD]):
polling_coordinator = config_entry.runtime_data.rpc_poll
assert polling_coordinator
entities = []
for sensor_id in sensors:
description = sensors[sensor_id]
key_instances = get_rpc_key_instances(
coordinator.device.status, description.key
)
for key in key_instances:
# Filter non-existing sensors
if description.sub_key not in coordinator.device.status[
key
] and not description.supported(coordinator.device.status[key]):
continue
# Filter and remove entities that according to settings/status
# should not create an entity
if description.removal_condition and description.removal_condition(
coordinator.device.config, coordinator.device.status, key
):
domain = sensor_class.__module__.split(".")[-1]
unique_id = f"{coordinator.mac}-{key}-{sensor_id}"
async_remove_shelly_entity(hass, domain, unique_id)
elif description.use_polling_coordinator:
if not sleep_period:
entities.append(
sensor_class(polling_coordinator, key, sensor_id, description)
)
else:
entities.append(sensor_class(coordinator, key, sensor_id, description))
if not entities:
return
async_add_entities(entities)
@callback
def async_restore_rpc_attribute_entities(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
async_add_entities: AddEntitiesCallback,
coordinator: ShellyRpcCoordinator,
sensors: Mapping[str, RpcEntityDescription],
sensor_class: Callable,
) -> None:
"""Restore block attributes entities."""
entities = []
ent_reg = er.async_get(hass)
entries = er.async_entries_for_config_entry(ent_reg, config_entry.entry_id)
domain = sensor_class.__module__.split(".")[-1]
for entry in entries:
if entry.domain != domain:
continue
key = entry.unique_id.split("-")[-2]
attribute = entry.unique_id.split("-")[-1]
if description := sensors.get(attribute):
entities.append(
sensor_class(coordinator, key, attribute, description, entry)
)
if not entities:
return
async_add_entities(entities)
@callback
def async_setup_entry_rest(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
async_add_entities: AddEntitiesCallback,
sensors: Mapping[str, RestEntityDescription],
sensor_class: Callable,
) -> None:
"""Set up entities for REST sensors."""
coordinator = config_entry.runtime_data.rest
assert coordinator
async_add_entities(
sensor_class(coordinator, sensor_id, sensors[sensor_id])
for sensor_id in sensors
)
@dataclass(frozen=True)
class BlockEntityDescription(EntityDescription):
"""Class to describe a BLOCK entity."""
# BlockEntity does not support UNDEFINED or None,
# restrict the type to str.
name: str = ""
unit_fn: Callable[[dict], str] | None = None
value: Callable[[Any], Any] = lambda val: val
available: Callable[[Block], bool] | None = None
# Callable (settings, block), return true if entity should be removed
removal_condition: Callable[[dict, Block], bool] | None = None
extra_state_attributes: Callable[[Block], dict | None] | None = None
@dataclass(frozen=True, kw_only=True)
class RpcEntityDescription(EntityDescription):
"""Class to describe a RPC entity."""
# BlockEntity does not support UNDEFINED or None,
# restrict the type to str.
name: str = ""
sub_key: str
value: Callable[[Any, Any], Any] | None = None
available: Callable[[dict], bool] | None = None
removal_condition: Callable[[dict, dict, str], bool] | None = None
extra_state_attributes: Callable[[dict, dict], dict | None] | None = None
use_polling_coordinator: bool = False
supported: Callable = lambda _: False
unit: Callable[[dict], str | None] | None = None
options_fn: Callable[[dict], list[str]] | None = None
@dataclass(frozen=True)
class RestEntityDescription(EntityDescription):
"""Class to describe a REST entity."""
# BlockEntity does not support UNDEFINED or None,
# restrict the type to str.
name: str = ""
value: Callable[[dict, Any], Any] | None = None
extra_state_attributes: Callable[[dict], dict | None] | None = None
class ShellyBlockEntity(CoordinatorEntity[ShellyBlockCoordinator]):
"""Helper class to represent a block entity."""
def __init__(self, coordinator: ShellyBlockCoordinator, block: Block) -> None:
"""Initialize Shelly entity."""
super().__init__(coordinator)
self.block = block
self._attr_name = get_block_entity_name(coordinator.device, block)
self._attr_device_info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, coordinator.mac)}
)
self._attr_unique_id = f"{coordinator.mac}-{block.description}"
# pylint: disable-next=hass-missing-super-call
async def async_added_to_hass(self) -> None:
"""When entity is added to HASS."""
self.async_on_remove(self.coordinator.async_add_listener(self._update_callback))
@callback
def _update_callback(self) -> None:
"""Handle device update."""
self.async_write_ha_state()
async def set_state(self, **kwargs: Any) -> Any:
"""Set block state (HTTP request)."""
LOGGER.debug("Setting state for entity %s, state: %s", self.name, kwargs)
try:
return await self.block.set_state(**kwargs)
except DeviceConnectionError as err:
self.coordinator.last_update_success = False
raise HomeAssistantError(
f"Setting state for entity {self.name} failed, state: {kwargs}, error:"
f" {err!r}"
) from err
except InvalidAuthError:
await self.coordinator.async_shutdown_device_and_start_reauth()
class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]):
"""Helper class to represent a rpc entity."""
def __init__(self, coordinator: ShellyRpcCoordinator, key: str) -> None:
"""Initialize Shelly entity."""
super().__init__(coordinator)
self.key = key
self._attr_device_info = {
"connections": {(CONNECTION_NETWORK_MAC, coordinator.mac)}
}
self._attr_unique_id = f"{coordinator.mac}-{key}"
self._attr_name = get_rpc_entity_name(coordinator.device, key)
@property
def available(self) -> bool:
"""Check if device is available and initialized or sleepy."""
coordinator = self.coordinator
return super().available and (
coordinator.device.initialized or bool(coordinator.sleep_period)
)
@property
def status(self) -> dict:
"""Device status by entity key."""
return cast(dict, self.coordinator.device.status[self.key])
# pylint: disable-next=hass-missing-super-call
async def async_added_to_hass(self) -> None:
"""When entity is added to HASS."""
self.async_on_remove(self.coordinator.async_add_listener(self._update_callback))
@callback
def _update_callback(self) -> None:
"""Handle device update."""
self.async_write_ha_state()
async def call_rpc(self, method: str, params: Any) -> Any:
"""Call RPC method."""
LOGGER.debug(
"Call RPC for entity %s, method: %s, params: %s",
self.name,
method,
params,
)
try:
return await self.coordinator.device.call_rpc(method, params)
except DeviceConnectionError as err:
self.coordinator.last_update_success = False
raise HomeAssistantError(
f"Call RPC for {self.name} connection error, method: {method}, params:"
f" {params}, error: {err!r}"
) from err
except RpcCallError as err:
raise HomeAssistantError(
f"Call RPC for {self.name} request error, method: {method}, params:"
f" {params}, error: {err!r}"
) from err
except InvalidAuthError:
await self.coordinator.async_shutdown_device_and_start_reauth()
class ShellyBlockAttributeEntity(ShellyBlockEntity, Entity):
"""Helper class to represent a block attribute."""
entity_description: BlockEntityDescription
def __init__(
self,
coordinator: ShellyBlockCoordinator,
block: Block,
attribute: str,
description: BlockEntityDescription,
) -> None:
"""Initialize sensor."""
super().__init__(coordinator, block)
self.attribute = attribute
self.entity_description = description
self._attr_unique_id: str = f"{super().unique_id}-{self.attribute}"
self._attr_name = get_block_entity_name(
coordinator.device, block, description.name
)
@property
def attribute_value(self) -> StateType:
"""Value of sensor."""
if (value := getattr(self.block, self.attribute)) is None:
return None
return cast(StateType, self.entity_description.value(value))
@property
def available(self) -> bool:
"""Available."""
available = super().available
if not available or not self.entity_description.available or self.block is None:
return available
return self.entity_description.available(self.block)
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the state attributes."""
if self.entity_description.extra_state_attributes is None:
return None
return self.entity_description.extra_state_attributes(self.block)
class ShellyRestAttributeEntity(CoordinatorEntity[ShellyBlockCoordinator]):
"""Class to load info from REST."""
entity_description: RestEntityDescription
def __init__(
self,
coordinator: ShellyBlockCoordinator,
attribute: str,
description: RestEntityDescription,
) -> None:
"""Initialize sensor."""
super().__init__(coordinator)
self.block_coordinator = coordinator
self.attribute = attribute
self.entity_description = description
self._attr_name = get_block_entity_name(
coordinator.device, None, description.name
)
self._attr_unique_id = f"{coordinator.mac}-{attribute}"
self._attr_device_info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, coordinator.mac)}
)
self._last_value = None
@property
def available(self) -> bool:
"""Available."""
return self.block_coordinator.last_update_success
@property
def attribute_value(self) -> StateType:
"""Value of sensor."""
if callable(self.entity_description.value):
self._last_value = self.entity_description.value(
self.block_coordinator.device.status, self._last_value
)
return self._last_value
class ShellyRpcAttributeEntity(ShellyRpcEntity, Entity):
"""Helper class to represent a rpc attribute."""
entity_description: RpcEntityDescription
def __init__(
self,
coordinator: ShellyRpcCoordinator,
key: str,
attribute: str,
description: RpcEntityDescription,
) -> None:
"""Initialize sensor."""
super().__init__(coordinator, key)
self.attribute = attribute
self.entity_description = description
self._attr_unique_id = f"{super().unique_id}-{attribute}"
self._attr_name = get_rpc_entity_name(coordinator.device, key, description.name)
self._last_value = None
id_key = key.split(":")[-1]
self._id = int(id_key) if id_key.isnumeric() else None
if callable(description.unit):
self._attr_native_unit_of_measurement = description.unit(
coordinator.device.config[key]
)
self.option_map: dict[str, str] = {}
self.reversed_option_map: dict[str, str] = {}
if "enum" in key:
titles = self.coordinator.device.config[key]["meta"]["ui"]["titles"]
options = self.coordinator.device.config[key]["options"]
self.option_map = {
opt: (titles[opt] if titles.get(opt) is not None else opt)
for opt in options
}
self.reversed_option_map = {
tit: opt for opt, tit in self.option_map.items()
}
@property
def sub_status(self) -> Any:
"""Device status by entity key."""
return self.status[self.entity_description.sub_key]
@property
def attribute_value(self) -> StateType:
"""Value of sensor."""
if callable(self.entity_description.value):
# using "get" here since subkey might not exist (e.g. "errors" sub_key)
self._last_value = self.entity_description.value(
self.status.get(self.entity_description.sub_key), self._last_value
)
else:
self._last_value = self.sub_status
return self._last_value
@property
def available(self) -> bool:
"""Available."""
available = super().available
if not available or not self.entity_description.available:
return available
return self.entity_description.available(self.sub_status)
class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity):
"""Represent a shelly sleeping block attribute entity."""
# pylint: disable-next=super-init-not-called
def __init__(
self,
coordinator: ShellyBlockCoordinator,
block: Block | None,
attribute: str,
description: BlockEntityDescription,
entry: RegistryEntry | None = None,
) -> None:
"""Initialize the sleeping sensor."""
self.last_state: State | None = None
self.coordinator = coordinator
self.attribute = attribute
self.block: Block | None = block # type: ignore[assignment]
self.entity_description = description
self._attr_device_info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, coordinator.mac)}
)
if block is not None:
self._attr_unique_id = (
f"{self.coordinator.mac}-{block.description}-{attribute}"
)
self._attr_name = get_block_entity_name(
self.coordinator.device, block, self.entity_description.name
)
elif entry is not None:
self._attr_unique_id = entry.unique_id
self._attr_name = cast(str, entry.original_name)
@callback
def _update_callback(self) -> None:
"""Handle device update."""
if self.block is not None or not self.coordinator.device.initialized:
super()._update_callback()
return
_, entity_block, entity_sensor = self._attr_unique_id.split("-")
assert self.coordinator.device.blocks
for block in self.coordinator.device.blocks:
if block.description != entity_block:
continue
for sensor_id in block.sensor_ids:
if sensor_id != entity_sensor:
continue
self.block = block
LOGGER.debug("Entity %s attached to block", self.name)
super()._update_callback()
return
async def async_update(self) -> None:
"""Update the entity."""
LOGGER.info(
"Entity %s comes from a sleeping device, update is not possible",
self.entity_id,
)
class ShellySleepingRpcAttributeEntity(ShellyRpcAttributeEntity):
"""Helper class to represent a sleeping rpc attribute."""
entity_description: RpcEntityDescription
# pylint: disable-next=super-init-not-called
def __init__(
self,
coordinator: ShellyRpcCoordinator,
key: str,
attribute: str,
description: RpcEntityDescription,
entry: RegistryEntry | None = None,
) -> None:
"""Initialize the sleeping sensor."""
self.last_state: State | None = None
self.coordinator = coordinator
self.key = key
self.attribute = attribute
self.entity_description = description
self._attr_device_info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, coordinator.mac)}
)
self._attr_unique_id = self._attr_unique_id = (
f"{coordinator.mac}-{key}-{attribute}"
)
self._last_value = None
if coordinator.device.initialized:
self._attr_name = get_rpc_entity_name(
coordinator.device, key, description.name
)
elif entry is not None:
self._attr_name = cast(str, entry.original_name)
async def async_update(self) -> None:
"""Update the entity."""
LOGGER.info(
"Entity %s comes from a sleeping device, update is not possible",
self.entity_id,
)