1
0
mirror of https://github.com/home-assistant/core.git synced 2026-05-31 20:54:23 +01:00
Files

430 lines
17 KiB
Python

"""Generic entity for the HomematicIP Cloud component."""
import contextlib
import logging
from typing import Any
from homematicip.base.functionalChannels import FunctionalChannel
from homematicip.device import Device
from homematicip.group import Group
from homeassistant.const import ATTR_ID
from homeassistant.core import callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from .const import DOMAIN
from .hap import AsyncHome, HomematicipHAP
_LOGGER = logging.getLogger(__name__)
ATTR_MODEL_TYPE = "model_type"
ATTR_LOW_BATTERY = "low_battery"
ATTR_CONFIG_PENDING = "config_pending"
ATTR_CONNECTION_TYPE = "connection_type"
ATTR_DUTY_CYCLE_REACHED = "duty_cycle_reached"
ATTR_IS_GROUP = "is_group"
# RSSI HAP -> Device
ATTR_RSSI_DEVICE = "rssi_device"
# RSSI Device -> HAP
ATTR_RSSI_PEER = "rssi_peer"
ATTR_SABOTAGE = "sabotage"
ATTR_GROUP_MEMBER_UNREACHABLE = "group_member_unreachable"
ATTR_DEVICE_OVERHEATED = "device_overheated"
ATTR_DEVICE_OVERLOADED = "device_overloaded"
ATTR_DEVICE_UNTERVOLTAGE = "device_undervoltage"
ATTR_EVENT_DELAY = "event_delay"
DEVICE_ATTRIBUTE_ICONS = {
"lowBat": "mdi:battery-outline",
"sabotage": "mdi:shield-alert",
"dutyCycle": "mdi:alert",
"deviceOverheated": "mdi:alert",
"deviceOverloaded": "mdi:alert",
"deviceUndervoltage": "mdi:alert",
"configPending": "mdi:alert-circle",
}
DEVICE_ATTRIBUTES = {
"modelType": ATTR_MODEL_TYPE,
"connectionType": ATTR_CONNECTION_TYPE,
"sabotage": ATTR_SABOTAGE,
"dutyCycle": ATTR_DUTY_CYCLE_REACHED,
"rssiDeviceValue": ATTR_RSSI_DEVICE,
"rssiPeerValue": ATTR_RSSI_PEER,
"deviceOverheated": ATTR_DEVICE_OVERHEATED,
"deviceOverloaded": ATTR_DEVICE_OVERLOADED,
"deviceUndervoltage": ATTR_DEVICE_UNTERVOLTAGE,
"configPending": ATTR_CONFIG_PENDING,
"eventDelay": ATTR_EVENT_DELAY,
"id": ATTR_ID,
}
GROUP_ATTRIBUTES = {
"modelType": ATTR_MODEL_TYPE,
"lowBat": ATTR_LOW_BATTERY,
"sabotage": ATTR_SABOTAGE,
"dutyCycle": ATTR_DUTY_CYCLE_REACHED,
"configPending": ATTR_CONFIG_PENDING,
"unreach": ATTR_GROUP_MEMBER_UNREACHABLE,
}
class HomematicipGenericEntity(Entity):
"""Representation of the HomematicIP generic entity."""
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(
self,
hap: HomematicipHAP,
device,
post: str | None = None,
channel: int | None = None,
is_multi_channel: bool | None = False,
channel_real_index: int | None = None,
*,
feature_id: str,
use_description_name: bool = False,
) -> None:
"""Initialize the generic entity.
When ``use_description_name`` is True, leave ``_attr_name`` unset so
HA's standard name resolution (``EntityDescription.name``,
``device_class``, ``translation_key`` + placeholders) drives the
entity name. Default False keeps the legacy channel/post composition.
"""
self._hap = hap
self._home: AsyncHome = hap.home
self._device = device
self._post = post
self._channel = channel
# channel_real_index represents the actual index of the devices channel.
# Accessing a functionalChannel by the channel parameter
# or array index is unreliable, because the
# functionalChannels array is sorted as strings, not
# numbers.
# For example, channels are ordered as: 1, 10, 11, 12, 2, 3, ...
# Using channel_real_index ensures you reference the correct channel.
self._channel_real_index: int | None = channel_real_index
self._feature_id = feature_id
self._is_multi_channel = is_multi_channel
self.functional_channel = None
with contextlib.suppress(ValueError):
self.functional_channel = self.get_current_channel()
# Marker showing that the HmIP device hase been removed.
self.hmip_device_removed = False
# Compute entity name based on has_entity_name mode.
if not self._attr_has_entity_name:
# Legacy mode (groups, special entities): compose the full name
# including device/group label and home prefix.
self._attr_name = self._compute_legacy_name()
elif not use_description_name:
self._setup_entity_name()
@property
def device_info(self) -> DeviceInfo | None:
"""Return device specific attributes."""
# Only physical devices should be HA devices.
if isinstance(self._device, Device):
device_id = str(self._device.id)
home_id = str(self._device.homeId)
# Include the home name in the device name so that the
# previous "{home} {device}" naming is preserved after
# switching to has_entity_name=True.
device_name = self._device.label
home_name = getattr(self._home, "name", None)
if device_name and home_name:
device_name = f"{home_name} {device_name}"
return DeviceInfo(
identifiers={
# Serial numbers of Homematic IP device
(DOMAIN, device_id)
},
manufacturer=self._device.oem,
model=self._device.modelType,
name=device_name,
sw_version=self._device.firmwareVersion,
# Link to the homematic ip access point.
via_device=(DOMAIN, home_id),
)
return None
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
self._hap.hmip_device_by_entity_id[self.entity_id] = self._device
self._device.on_update(self._async_device_changed)
self._device.on_remove(self._async_device_removed)
@callback
def _async_device_changed(self, *args, **kwargs) -> None:
"""Handle device state changes."""
# Don't update disabled entities
if self.enabled:
_LOGGER.debug("Event %s (%s)", self.name, self._device.modelType)
self.async_write_ha_state()
else:
_LOGGER.debug(
"Device Changed Event for %s (%s) not fired. Entity is disabled",
self.name,
self._device.modelType,
)
async def async_will_remove_from_hass(self) -> None:
"""Run when hmip device will be removed from hass."""
# Only go further if the device/entity should be removed from registries
# due to a removal of the HmIP device.
if self.hmip_device_removed:
try:
del self._hap.hmip_device_by_entity_id[self.entity_id]
self.async_remove_from_registries()
except KeyError as err:
_LOGGER.debug("Error removing HMIP device from registry: %s", err)
@callback
def async_remove_from_registries(self) -> None:
"""Remove entity/device from registry."""
# Remove callback from device.
self._device.remove_callback(self._async_device_changed)
self._device.remove_callback(self._async_device_removed)
if not self.registry_entry:
return
if device_id := self.registry_entry.device_id:
# Remove from device registry.
device_registry = dr.async_get(self.hass)
if device_id in device_registry.devices:
# This will also remove associated entities from entity registry.
device_registry.async_remove_device(device_id)
else: # noqa: PLR5501
# Remove from entity registry.
# Only relevant for entities that do not belong to a device.
if entity_id := self.registry_entry.entity_id:
entity_registry = er.async_get(self.hass)
if entity_id in entity_registry.entities:
entity_registry.async_remove(entity_id)
@callback
def _async_device_removed(self, *args, **kwargs) -> None:
"""Handle hmip device removal."""
# Set marker showing that the HmIP device hase been removed.
self.hmip_device_removed = True
self.hass.async_create_task(
self.async_remove(force_remove=True), eager_start=False
)
def _compute_legacy_name(self) -> str:
"""Compute the full legacy name for entities without has_entity_name.
Used by group entities and other special cases where has_entity_name
is False. Includes device/group label, post suffix, and home prefix.
"""
name = self._device.label or ""
if self._post:
name = f"{name} {self._post}" if name else self._post
home_name = getattr(self._home, "name", None)
if name and home_name:
name = f"{home_name} {name}"
return name
def _setup_entity_name(self) -> None:
"""Set up entity naming for has_entity_name mode.
With has_entity_name=True, HA composes the full friendly name as
"{device_name} {entity_name}". This method sets the appropriate
naming attributes.
For multi-channel entities, channel labels provide _attr_name (dynamic).
For entities with _post, _attr_name is derived from the post suffix,
with the first letter capitalized for display consistency.
For primary entities, HA uses device_class as the name.
"""
# Multi-channel entities: use channel label as entity name.
if self._is_multi_channel and self.functional_channel:
label = getattr(self.functional_channel, "label", None)
if label:
label_str = str(label)
device_label = self._device.label or ""
# Strip device name prefix from channel label to avoid
# duplication when HA composes "{device_name} {entity_name}".
# E.g., device "Licht Flur" + channel "Licht Flur 5" -> "5".
if device_label and label_str.startswith(device_label):
stripped = label_str[len(device_label) :].strip()
if stripped:
self._attr_name = stripped
# Otherwise channel label equals device label (modulo
# whitespace); leave _attr_name unset so HA composes just
# the device name without duplicating it.
return
self._attr_name = label_str
return
# Fallback: use post suffix or generic channel name.
if self._post:
self._attr_name = self._post[0].upper() + self._post[1:]
else:
self._attr_name = f"Channel{self.get_channel_index()}"
return
# Entities with a post suffix: use it as the entity name,
# capitalizing the first letter for display consistency.
if self._post:
self._attr_name = self._post[0].upper() + self._post[1:]
return
# Non-multi-channel entities on devices with multiple channels:
# use the first functional channel's label as name context.
# This preserves names like "Treppe CH" for single-function entities
# on multi-channel devices (e.g., HmIP-BSL switch channel).
functional_channels = getattr(self._device, "functionalChannels", None)
if functional_channels and len(functional_channels) > 1:
ch1 = (
functional_channels.get(1)
if isinstance(functional_channels, dict)
else functional_channels[1]
)
label = getattr(ch1, "label", None) if ch1 else None
if label:
label_str = str(label)
device_label = self._device.label or ""
# Strip device name prefix to avoid duplication.
if device_label and label_str.startswith(device_label):
stripped = label_str[len(device_label) :].strip()
if stripped:
self._attr_name = stripped
# Otherwise channel label equals device label (modulo
# whitespace); leave _attr_name unset.
return
self._attr_name = label_str
return
# Primary entity on device: leave unset so HA derives name from
# device_class or translation_key.
@property
def available(self) -> bool:
"""Return if entity is available."""
return not self._device.unreach
@property
def unique_id(self) -> str:
"""Return a unique ID."""
if not isinstance(self._device, Device):
return f"{self._device.id}_{self._feature_id}"
channel_index = self.get_channel_index()
return f"{self._device.id}_{channel_index}_{self._feature_id}"
@property
def icon(self) -> str | None:
"""Return the icon."""
for attr, icon in DEVICE_ATTRIBUTE_ICONS.items():
if getattr(self._device, attr, None):
return icon
return None
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes of the generic entity."""
state_attr = {}
if isinstance(self._device, Device):
for attr, attr_key in DEVICE_ATTRIBUTES.items():
if attr_value := getattr(self._device, attr, None):
state_attr[attr_key] = attr_value
state_attr[ATTR_IS_GROUP] = False
if isinstance(self._device, Group):
for attr, attr_key in GROUP_ATTRIBUTES.items():
if attr_value := getattr(self._device, attr, None):
state_attr[attr_key] = attr_value
state_attr[ATTR_IS_GROUP] = True
return state_attr
def get_current_channel(self) -> FunctionalChannel:
"""Return the FunctionalChannel for the device.
Resolution priority:
1. For multi-channel entities with a real index, find
channel by index match.
2. For multi-channel entities without a real index, use
the provided channel position.
3. For non multi-channel entities with >1 channels, use
channel at position 1 (index 0 is often a meta/service
channel in HmIP).
Raises ValueError if no suitable channel can be resolved.
"""
functional_channels = getattr(self._device, "functionalChannels", None)
if not functional_channels:
raise ValueError(
f"Device {getattr(self._device, 'id', 'unknown')}"
" has no functionalChannels"
)
# Multi-channel handling
if self._is_multi_channel:
# Prefer real index mapping when provided to avoid
# ordering issues.
if self._channel_real_index is not None:
for channel in functional_channels:
if channel.index == self._channel_real_index:
return channel
raise ValueError(
f"Real channel index"
f" {self._channel_real_index}"
" not found for device"
f" {getattr(self._device, 'id', 'unknown')}"
)
# Fallback: positional channel (already sorted as strings upstream).
if self._channel is not None and 0 <= self._channel < len(
functional_channels
):
return functional_channels[self._channel]
raise ValueError(
f"Channel position {self._channel} invalid for"
f" device {getattr(self._device, 'id', 'unknown')}"
f" (len={len(functional_channels)})"
)
# Single-channel / non multi-channel entity: choose second element if available
if len(functional_channels) > 1:
return functional_channels[1]
return functional_channels[0]
def get_channel_index(self) -> int:
"""Return the correct channel index for this entity.
Prefers channel_real_index if set, otherwise returns channel.
This ensures the correct channel is used even if the
functionalChannels list is not numerically ordered.
"""
if self._channel_real_index is not None:
return self._channel_real_index
if self._channel is not None:
return self._channel
return 1
def get_channel_or_raise(self) -> FunctionalChannel:
"""Return the FunctionalChannel or raise an error if not found."""
if not self.functional_channel:
raise ValueError(
f"No functional channel found for device"
f" {getattr(self._device, 'id', 'unknown')}"
)
return self.functional_channel