diff --git a/changelog.md b/changelog.md index cc59f982..50fb301a 100644 --- a/changelog.md +++ b/changelog.md @@ -1,6 +1,7 @@ # Version 2024.4.9 (2024-04-16) - Decompose combined parameter +- Return affected entity keys for service calls # Version 2024.4.8 (2024-04-13) diff --git a/hahomematic/caches/dynamic.py b/hahomematic/caches/dynamic.py index fc6941a0..35fcfa2c 100644 --- a/hahomematic/caches/dynamic.py +++ b/hahomematic/caches/dynamic.py @@ -14,6 +14,7 @@ PING_PONG_MISMATCH_COUNT_TTL, ) from hahomematic.const import ( + ENTITY_KEY, EVENT_DATA, EVENT_INSTANCE_NAME, EVENT_INTERFACE_ID, @@ -30,7 +31,7 @@ ) from hahomematic.converter import CONVERTABLE_PARAMETERS, convert_combined_parameter_to_paramset from hahomematic.platforms.device import HmDevice -from hahomematic.support import changed_within_seconds, get_channel_no, get_device_address +from hahomematic.support import changed_within_seconds, get_entity_key _LOGGER: Final = logging.getLogger(__name__) @@ -42,109 +43,82 @@ def __init__(self, interface_id: str) -> None: """Init command cache.""" self._interface_id: Final = interface_id # (paramset_key, device_address, channel_no, parameter) - self._last_send_command: Final[ - dict[tuple[str, str, int | None, str], tuple[Any, datetime]] - ] = {} + self._last_send_command: Final[dict[ENTITY_KEY, tuple[Any, datetime]]] = {} def add_set_value( self, channel_address: str, parameter: str, value: Any, - ) -> None: + ) -> set[ENTITY_KEY]: """Add data from set value command.""" if parameter in CONVERTABLE_PARAMETERS: - self.add_combined_parameter( + return self.add_combined_parameter( parameter=parameter, channel_address=channel_address, combined_parameter=value ) - return - key = ( - ParamsetKey.VALUES.value, - get_device_address(channel_address), - get_channel_no(channel_address), - parameter, + entity_key = get_entity_key( + channel_address=channel_address, + parameter=parameter, ) - self._last_send_command[key] = (value, datetime.now()) + self._last_send_command[entity_key] = (value, datetime.now()) + return {entity_key} def add_put_paramset( - self, - channel_address: str, - paramset_key: str, - values: dict[str, Any], - ) -> None: + self, channel_address: str, paramset_key: str, values: dict[str, Any] + ) -> set[ENTITY_KEY]: """Add data from put paramset command.""" - + entity_keys: set[ENTITY_KEY] = set() for parameter, value in values.items(): - key = ( - paramset_key, - get_device_address(channel_address), - get_channel_no(channel_address), - parameter, + entity_key = get_entity_key( + channel_address=channel_address, + parameter=parameter, ) - self._last_send_command[key] = (value, datetime.now()) + self._last_send_command[entity_key] = (value, datetime.now()) + entity_keys.add(entity_key) + return entity_keys def add_combined_parameter( self, parameter: str, channel_address: str, combined_parameter: str - ) -> None: + ) -> set[ENTITY_KEY]: """Add data from combined parameter.""" if values := convert_combined_parameter_to_paramset( parameter=parameter, cpv=combined_parameter ): - self.add_put_paramset( + return self.add_put_paramset( channel_address=channel_address, - paramset_key=ParamsetKey.VALUES.value, + paramset_key=ParamsetKey.VALUES, values=values, ) + return set() def get_last_value_send( - self, - paramset_key: str, - channel_address: str, - parameter: str, - max_age: int = LAST_COMMAND_SEND_STORE_TIMEOUT, + self, entity_key: ENTITY_KEY, max_age: int = LAST_COMMAND_SEND_STORE_TIMEOUT ) -> Any: """Return the last send values.""" - key = ( - paramset_key, - get_device_address(channel_address), - get_channel_no(channel_address), - parameter, - ) - if result := self._last_send_command.get(key): + if result := self._last_send_command.get(entity_key): value, last_send_dt = result if last_send_dt and changed_within_seconds(last_change=last_send_dt, max_age=max_age): return value self.remove_last_value_send( - paramset_key=paramset_key, - channel_address=channel_address, - parameter=parameter, + entity_key=entity_key, max_age=max_age, ) return None def remove_last_value_send( self, - paramset_key: str, - channel_address: str, - parameter: str, + entity_key: ENTITY_KEY, value: Any = None, max_age: int = LAST_COMMAND_SEND_STORE_TIMEOUT, ) -> None: """Remove the last send value.""" - key = ( - paramset_key, - get_device_address(channel_address), - get_channel_no(channel_address), - parameter, - ) - - if result := self._last_send_command.get(key): + if result := self._last_send_command.get(entity_key): stored_value, last_send_dt = result if not changed_within_seconds(last_change=last_send_dt, max_age=max_age) or ( value is not None and stored_value == value ): - del self._last_send_command[key] + del self._last_send_command[entity_key] class DeviceDetailsCache: diff --git a/hahomematic/central/__init__.py b/hahomematic/central/__init__.py index 81a7d9ce..068aa315 100644 --- a/hahomematic/central/__init__.py +++ b/hahomematic/central/__init__.py @@ -33,6 +33,7 @@ DEFAULT_TLS, DEFAULT_VERIFY_TLS, ENTITY_EVENTS, + ENTITY_KEY, EVENT_AVAILABLE, EVENT_DATA, EVENT_INTERFACE_ID, @@ -60,13 +61,13 @@ from hahomematic.platforms import create_entities_and_append_to_device from hahomematic.platforms.custom.entity import CustomEntity from hahomematic.platforms.device import HmDevice -from hahomematic.platforms.entity import BaseEntity, CallbackEntity +from hahomematic.platforms.entity import BaseParameterEntity, CallbackEntity from hahomematic.platforms.event import GenericEvent from hahomematic.platforms.generic.entity import GenericEntity from hahomematic.platforms.hub import Hub from hahomematic.platforms.hub.button import HmProgramButton from hahomematic.platforms.hub.entity import GenericHubEntity, GenericSystemVariable -from hahomematic.support import check_config, get_device_address, reduce_args +from hahomematic.support import check_config, get_device_address, get_entity_key, reduce_args _LOGGER: Final = logging.getLogger(__name__) @@ -130,7 +131,7 @@ def __init__(self, central_config: CentralConfig) -> None: self._clients: Final[dict[str, hmcl.Client]] = {} # {{channel_address, parameter}, event_handle} self._entity_event_subscriptions: Final[ - dict[tuple[str, str], list[Callable[[Any], Coroutine[Any, Any, None]]]] + dict[ENTITY_KEY, list[Callable[[Any], Coroutine[Any, Any, None]]]] ] = {} # {device_address, device} self._devices: Final[dict[str, HmDevice]] = {} @@ -615,6 +616,23 @@ def get_readable_generic_entities( ) ) + def get_custom_ids_by_entity_keys(self, entity_keys: set[ENTITY_KEY]) -> set[str]: + """Return the custom ids by entity keys.""" + return { + entity.custom_id + for entity in self.get_entities(exclude_no_create=False, registered=True) + if ( + ( + (isinstance(entity, BaseParameterEntity) and entity.entity_key in entity_keys) + or ( + isinstance(entity, CustomEntity) + and entity.has_entity_key(entity_keys=entity_keys) + ) + ) + and entity.custom_id is not None + ) + } + def _get_primary_client(self) -> hmcl.Client | None: """Return the client by interface_id or the first with a virtual remote.""" client: hmcl.Client | None = None @@ -850,9 +868,15 @@ async def event( pong_ts=datetime.strptime(v_timestamp, DATETIME_FORMAT_MILLIS) ) return - if (channel_address, parameter) in self._entity_event_subscriptions: + + entity_key = get_entity_key( + channel_address=channel_address, + parameter=parameter, + ) + + if entity_key in self._entity_event_subscriptions: try: - for callback in self._entity_event_subscriptions[(channel_address, parameter)]: + for callback in self._entity_event_subscriptions[entity_key]: await callback(value) except RuntimeError as rte: # pragma: no cover _LOGGER.debug( @@ -880,17 +904,12 @@ def list_devices(self, interface_id: str) -> list[dict[str, Any]]: ) return result - def add_event_subscription(self, entity: BaseEntity) -> None: + def add_event_subscription(self, entity: BaseParameterEntity) -> None: """Add entity to central event subscription.""" if isinstance(entity, (GenericEntity, GenericEvent)) and entity.supports_events: - if ( - entity.channel_address, - entity.parameter, - ) not in self._entity_event_subscriptions: - self._entity_event_subscriptions[(entity.channel_address, entity.parameter)] = [] - self._entity_event_subscriptions[(entity.channel_address, entity.parameter)].append( - entity.event - ) + if entity.entity_key not in self._entity_event_subscriptions: + self._entity_event_subscriptions[entity.entity_key] = [] + self._entity_event_subscriptions[entity.entity_key].append(entity.event) async def remove_device(self, device: HmDevice) -> None: """Remove device to central collections.""" @@ -907,14 +926,14 @@ async def remove_device(self, device: HmDevice) -> None: self.device_details.remove_device(device=device) del self._devices[device.device_address] - def remove_event_subscription(self, entity: BaseEntity) -> None: + def remove_event_subscription(self, entity: BaseParameterEntity) -> None: """Remove event subscription from central collections.""" if ( isinstance(entity, (GenericEntity, GenericEvent)) and entity.supports_events - and (entity.channel_address, entity.parameter) in self._entity_event_subscriptions + and entity.entity_key in self._entity_event_subscriptions ): - del self._entity_event_subscriptions[(entity.channel_address, entity.parameter)] + del self._entity_event_subscriptions[entity.entity_key] async def execute_program(self, pid: str) -> bool: """Execute a program on CCU / Homegear.""" @@ -978,7 +997,9 @@ def _get_virtual_remote(self, device_address: str) -> HmDevice | None: return virtual_remote return None - def get_generic_entity(self, channel_address: str, parameter: str) -> GenericEntity | None: + def get_generic_entity( + self, channel_address: str, parameter: str, paramset_key: ParamsetKey = ParamsetKey.VALUES + ) -> GenericEntity | None: """Get entity by channel_address and parameter.""" if device := self.get_device(address=channel_address): return device.get_generic_entity(channel_address=channel_address, parameter=parameter) diff --git a/hahomematic/client/__init__.py b/hahomematic/client/__init__.py index 290d1cc6..74ed69ec 100644 --- a/hahomematic/client/__init__.py +++ b/hahomematic/client/__init__.py @@ -14,6 +14,7 @@ from hahomematic.config import CALLBACK_WARN_INTERVAL, RECONNECT_WAIT from hahomematic.const import ( DATETIME_FORMAT_MILLIS, + ENTITY_KEY, EVENT_AVAILABLE, EVENT_SECONDS_SINCE_LAST_EVENT, HOMEGEAR_SERIAL, @@ -422,7 +423,7 @@ async def _set_value( parameter: str, value: Any, rx_mode: str | None = None, - ) -> bool: + ) -> set[ENTITY_KEY]: """Set single value on paramset VALUES.""" try: _LOGGER.debug("SET_VALUE: %s, %s, %s", channel_address, parameter, value) @@ -430,7 +431,8 @@ async def _set_value( await self._proxy.setValue(channel_address, parameter, value, rx_mode) else: await self._proxy.setValue(channel_address, parameter, value) - self._last_value_send_cache.add_set_value( + # store the send value in the last_value_send_cache + return self._last_value_send_cache.add_set_value( channel_address=channel_address, parameter=parameter, value=value ) except BaseHomematicException as ex: @@ -442,8 +444,7 @@ async def _set_value( parameter, value, ) - return False - return True + return set() async def set_value( self, @@ -452,7 +453,7 @@ async def set_value( parameter: str, value: Any, rx_mode: str | None = None, - ) -> bool: + ) -> set[ENTITY_KEY]: """Set single value on paramset VALUES.""" if paramset_key == ParamsetKey.VALUES: return await self._set_value( @@ -499,7 +500,7 @@ async def put_paramset( paramset_key: str, values: dict[str, Any], rx_mode: str | None = None, - ) -> bool: + ) -> set[ENTITY_KEY]: """ Set paramsets manually. @@ -512,7 +513,8 @@ async def put_paramset( await self._proxy.putParamset(channel_address, paramset_key, values, rx_mode) else: await self._proxy.putParamset(channel_address, paramset_key, values) - self._last_value_send_cache.add_put_paramset( + # store the send value in the last_value_send_cache + return self._last_value_send_cache.add_put_paramset( channel_address=channel_address, paramset_key=paramset_key, values=values ) except BaseHomematicException as ex: @@ -524,8 +526,7 @@ async def put_paramset( paramset_key, values, ) - return False - return True + return set() async def fetch_paramset_description( self, channel_address: str, paramset_key: str, save_to_file: bool = True diff --git a/hahomematic/const.py b/hahomematic/const.py index 45fa25c3..6b354a42 100644 --- a/hahomematic/const.py +++ b/hahomematic/const.py @@ -71,6 +71,9 @@ NO_CACHE_ENTRY: Final = "NO_CACHE_ENTRY" +# channel_address, parameter +ENTITY_KEY = tuple[str, str] + class Backend(StrEnum): """Enum with supported hahomematic backends.""" diff --git a/hahomematic/platforms/custom/entity.py b/hahomematic/platforms/custom/entity.py index 295e661d..615d7461 100644 --- a/hahomematic/platforms/custom/entity.py +++ b/hahomematic/platforms/custom/entity.py @@ -8,7 +8,7 @@ import logging from typing import Any, Final, TypeVar, cast -from hahomematic.const import INIT_DATETIME, CallSource, EntityUsage +from hahomematic.const import ENTITY_KEY, INIT_DATETIME, CallSource, EntityUsage from hahomematic.platforms import device as hmd from hahomematic.platforms.custom import definition as hmed from hahomematic.platforms.custom.const import DeviceProfile, Field @@ -298,6 +298,13 @@ def _get_entity(self, field: Field, entity_type: type[_EntityT]) -> _EntityT: NoneTypeEntity(), ) + def has_entity_key(self, entity_keys: set[ENTITY_KEY]) -> bool: + """Return if an entity with one of the entities is part of this entity.""" + result = [ + entity for entity in self._data_entities.values() if entity.entity_key in entity_keys + ] + return len(result) > 0 + class NoneTypeEntity: """Entity to return an empty value.""" diff --git a/hahomematic/platforms/device.py b/hahomematic/platforms/device.py index 59d0e7a5..b1ee71fa 100644 --- a/hahomematic/platforms/device.py +++ b/hahomematic/platforms/device.py @@ -19,6 +19,7 @@ DEFAULT_DEVICE_DESCRIPTIONS_DIR, DEFAULT_PARAMSET_DESCRIPTIONS_DIR, ENTITY_EVENTS, + ENTITY_KEY, IDENTIFIER_SEPARATOR, INIT_DATETIME, NO_CACHE_ENTRY, @@ -41,12 +42,18 @@ from hahomematic.exceptions import BaseHomematicException from hahomematic.platforms.custom import definition as hmed, entity as hmce from hahomematic.platforms.decorators import config_property, value_property -from hahomematic.platforms.entity import BaseEntity, CallbackEntity +from hahomematic.platforms.entity import BaseParameterEntity, CallbackEntity from hahomematic.platforms.event import GenericEvent from hahomematic.platforms.generic.entity import GenericEntity from hahomematic.platforms.support import PayloadMixin, get_device_name from hahomematic.platforms.update import HmUpdate -from hahomematic.support import CacheEntry, Channel, check_or_create_directory, reduce_args +from hahomematic.support import ( + CacheEntry, + Channel, + check_or_create_directory, + get_entity_key, + reduce_args, +) _LOGGER: Final = logging.getLogger(__name__) @@ -75,8 +82,8 @@ def __init__(self, central: hmcu.CentralUnit, interface_id: str, device_address: ) # {channel_no, entity} self._custom_entities: Final[dict[int, hmce.CustomEntity]] = {} - self._generic_entities: Final[dict[tuple[str, str], GenericEntity]] = {} - self._generic_events: Final[dict[tuple[str, str], GenericEvent]] = {} + self._generic_entities: Final[dict[ENTITY_KEY, GenericEntity]] = {} + self._generic_events: Final[dict[ENTITY_KEY, GenericEvent]] = {} self._last_updated: datetime = INIT_DATETIME self._forced_availability: ForcedDeviceAvailability = ForcedDeviceAvailability.NOT_SET self._device_updated_callbacks: Final[list[Callable]] = [] @@ -347,31 +354,31 @@ def get_sub_device_channel(self, channel_no: int) -> int | None: def add_entity(self, entity: CallbackEntity) -> None: """Add a hm entity to a device.""" - if isinstance(entity, BaseEntity): + if isinstance(entity, BaseParameterEntity): self.central.add_event_subscription(entity=entity) if isinstance(entity, GenericEntity): - self._generic_entities[(entity.channel_address, entity.parameter)] = entity + self._generic_entities[entity.entity_key] = entity self.register_device_updated_callback( device_updated_callback=entity.fire_entity_updated_callback ) if isinstance(entity, hmce.CustomEntity): self._custom_entities[entity.channel_no] = entity if isinstance(entity, GenericEvent): - self._generic_events[(entity.channel_address, entity.parameter)] = entity + self._generic_events[entity.entity_key] = entity def remove_entity(self, entity: CallbackEntity) -> None: """Add a hm entity to a device.""" - if isinstance(entity, BaseEntity): + if isinstance(entity, BaseParameterEntity): self.central.remove_event_subscription(entity=entity) if isinstance(entity, GenericEntity): - del self._generic_entities[(entity.channel_address, entity.parameter)] + del self._generic_entities[entity.entity_key] self.unregister_device_updated_callback( device_updated_callback=entity.fire_entity_updated_callback ) if isinstance(entity, hmce.CustomEntity): del self._custom_entities[entity.channel_no] if isinstance(entity, GenericEvent): - del self._generic_events[(entity.channel_address, entity.parameter)] + del self._generic_events[entity.entity_key] entity.fire_device_removed_callback() def clear_collections(self) -> None: @@ -485,11 +492,15 @@ def get_custom_entity(self, channel_no: int) -> hmce.CustomEntity | None: def get_generic_entity(self, channel_address: str, parameter: str) -> GenericEntity | None: """Return an entity from device.""" - return self._generic_entities.get((channel_address, parameter)) + return self._generic_entities.get( + get_entity_key(channel_address=channel_address, parameter=parameter) + ) def get_generic_event(self, channel_address: str, parameter: str) -> GenericEvent | None: """Return a generic event from device.""" - return self._generic_events.get((channel_address, parameter)) + return self._generic_events.get( + get_entity_key(channel_address=channel_address, parameter=parameter) + ) def get_readable_entities(self, paramset_key: ParamsetKey) -> tuple[GenericEntity, ...]: """Return the list of readable master entities.""" diff --git a/hahomematic/platforms/entity.py b/hahomematic/platforms/entity.py index b2331d99..bd222fa7 100644 --- a/hahomematic/platforms/entity.py +++ b/hahomematic/platforms/entity.py @@ -15,6 +15,7 @@ from hahomematic import central as hmcu, client as hmcl, support as hms from hahomematic.async_support import loop_check from hahomematic.const import ( + ENTITY_KEY, EVENT_ADDRESS, EVENT_CHANNEL_NO, EVENT_DEVICE_TYPE, @@ -43,7 +44,7 @@ convert_value, generate_channel_unique_id, ) -from hahomematic.support import reduce_args +from hahomematic.support import get_entity_key, reduce_args _LOGGER: Final = logging.getLogger(__name__) @@ -121,7 +122,7 @@ def available(self) -> bool: @property def custom_id(self) -> str | None: - """Return the central unit.""" + """Return the custom id.""" return self._custom_id @classmethod @@ -483,6 +484,14 @@ def is_un_ignored(self) -> bool: """Return if the parameter is un ignored.""" return self._is_un_ignored + @config_property + def entity_key(self) -> ENTITY_KEY: + """Return entity key value.""" + return get_entity_key( + channel_address=self._channel_address, + parameter=self._parameter, + ) + @config_property def max(self) -> ParameterT: """Return max value.""" @@ -548,11 +557,7 @@ def unconfirmed_last_value_send(self) -> ParameterT | None: """Return the unconfirmed value send for the entity.""" return cast( ParameterT | None, - self._client.last_value_send_cache.get_last_value_send( - paramset_key=self.paramset_key, - channel_address=self.channel_address, - parameter=self.parameter, - ), + self._client.last_value_send_cache.get_last_value_send(entity_key=self.entity_key), ) @value_property @@ -606,7 +611,8 @@ def visible(self) -> bool: def _channel_operation_mode(self) -> str | None: """Return the channel operation mode if available.""" cop: BaseParameterEntity | None = self._device.get_generic_entity( - channel_address=self._channel_address, parameter=Parameter.CHANNEL_OPERATION_MODE + channel_address=self._channel_address, + parameter=Parameter.CHANNEL_OPERATION_MODE, ) if cop and cop.value: return str(cop.value) diff --git a/hahomematic/platforms/generic/entity.py b/hahomematic/platforms/generic/entity.py index d2eda2c7..54cd9e88 100644 --- a/hahomematic/platforms/generic/entity.py +++ b/hahomematic/platforms/generic/entity.py @@ -51,9 +51,7 @@ def usage(self) -> EntityUsage: async def event(self, value: Any) -> None: """Handle event for which this entity has subscribed.""" self.device.client.last_value_send_cache.remove_last_value_send( - paramset_key=self.paramset_key, - channel_address=self.channel_address, - parameter=self.parameter, + entity_key=self.entity_key, value=value, ) old_value, new_value = self.write_value(value=value) diff --git a/hahomematic/support.py b/hahomematic/support.py index 2dc81df0..7fdce2b7 100644 --- a/hahomematic/support.py +++ b/hahomematic/support.py @@ -18,6 +18,7 @@ from hahomematic.const import ( CCU_PASSWORD_PATTERN, + ENTITY_KEY, FILE_DEVICES, FILE_PARAMSETS, IDENTIFIER_SEPARATOR, @@ -170,6 +171,11 @@ def get_channel_no(address: str) -> int | None: return get_split_channel_address(channel_address=address)[1] +def get_entity_key(channel_address: str, parameter: str) -> ENTITY_KEY: + """Return an entity key.""" + return (str(channel_address), str(parameter)) + + @lru_cache(maxsize=2048) def get_split_channel_address(channel_address: str) -> tuple[str, int | None]: """Return the device part of an address.""" diff --git a/hahomematic_support/client_local.py b/hahomematic_support/client_local.py index 124505ff..7e2d7351 100644 --- a/hahomematic_support/client_local.py +++ b/hahomematic_support/client_local.py @@ -13,6 +13,7 @@ from hahomematic.client import _LOGGER, Client, _ClientConfig from hahomematic.const import ( DEFAULT_ENCODING, + ENTITY_KEY, CallSource, InterfaceName, ProductGroup, @@ -196,13 +197,15 @@ async def set_value( parameter: str, value: Any, rx_mode: str | None = None, - ) -> bool: + ) -> set[ENTITY_KEY]: """Set single value on paramset VALUES.""" - await self.central.event(self.interface_id, channel_address, parameter, value) - self._last_value_send_cache.add_set_value( + # store the send value in the last_value_send_cache + result = self._last_value_send_cache.add_set_value( channel_address=channel_address, parameter=parameter, value=value ) - return True + # fire an event to fake the state change for a simple parameter + await self.central.event(self.interface_id, channel_address, parameter, value) + return result async def get_paramset(self, address: str, paramset_key: str) -> Any: """ @@ -247,21 +250,23 @@ async def put_paramset( paramset_key: str, values: Any, rx_mode: str | None = None, - ) -> bool: + ) -> set[ENTITY_KEY]: """ Set paramsets manually. Address is usually the channel_address, but for bidcos devices there is a master paramset at the device. """ + # store the send value in the last_value_send_cache + result = self._last_value_send_cache.add_put_paramset( + channel_address=channel_address, paramset_key=paramset_key, values=values + ) + # fire an event to fake the state change for the content of a paramset for parameter in values: await self.central.event( self.interface_id, channel_address, parameter, values[parameter] ) - self._last_value_send_cache.add_put_paramset( - channel_address=channel_address, paramset_key=paramset_key, values=values - ) - return True + return result async def _load_all_json_files( self,