diff --git a/tests/test_inovelli.py b/tests/test_inovelli.py index f40765ec7c..634b91c140 100644 --- a/tests/test_inovelli.py +++ b/tests/test_inovelli.py @@ -7,10 +7,12 @@ import zhaquirks from zhaquirks.builder.builder import ReplaceCluster, ReplaceClusterOccurrences from zhaquirks.builder.device import QuirkV2Factory -from zhaquirks.inovelli.builder import INOVELLI_CLUSTER_ID +from zhaquirks.inovelli.builder import INOVELLI_CLUSTER_ID, INOVELLI_MMWAVE_CLUSTER_ID zhaquirks.setup() +INOVELLI_CLUSTER_IDS = (INOVELLI_CLUSTER_ID, INOVELLI_MMWAVE_CLUSTER_ID) + def _inovelli_v2_entries(): """Yield (model, QuirkRegistryEntry) for every Inovelli VZM v2 quirk.""" @@ -28,8 +30,8 @@ def _inovelli_v2_entries(): yield model, entry -def _inovelli_clusters_by_endpoint(entry): - """Map endpoint id to the Inovelli cluster class the quirk installs there. +def _inovelli_clusters_by_endpoint(entry, cluster_id): + """Map endpoint id to the quirk's replacement class for ``cluster_id``. ``.replaces()`` targets a single endpoint, while ``.replace_cluster_occurrences()`` covers every endpoint that already has @@ -39,29 +41,28 @@ def _inovelli_clusters_by_endpoint(entry): clusters = {} for op in entry.zigpy_transforms: if isinstance(op, ReplaceCluster): - cluster_id = ( + op_cluster_id = ( op.cluster.cluster_id if op.cluster_id is None else op.cluster_id ) - if ( - cluster_id == INOVELLI_CLUSTER_ID - and op.cluster_type == ClusterType.Server - ): + if op_cluster_id == cluster_id and op.cluster_type == ClusterType.Server: clusters[op.endpoint_id] = op.cluster elif isinstance(op, ReplaceClusterOccurrences): if ( - op.cluster.cluster_id == INOVELLI_CLUSTER_ID + op.cluster.cluster_id == cluster_id and ClusterType.Server in op.cluster_types ): clusters[None] = op.cluster return clusters -# Number of Inovelli ``0xFC31`` entities each device file is expected to expose. -# Locks the per-model set derived from the ZHA library's registration rules. +# Number of Inovelli manufacturer-cluster (0xFC31 / 0xFC32) entities each device +# file is expected to expose. Locks the per-model set: for VZM30/31/35/36 this is +# the set derived from the ZHA library's registration rules; VZM32-SN additionally +# exposes its mmWave-specific entities. EXPECTED_ENTITY_COUNTS = { "VZM30SN.py": 38, "VZM31SN.py": 40, - "VZM32SN.py": 37, + "VZM32SN.py": 50, # 41 on 0xFC31 + 9 on the 0xFC32 mmWave cluster "VZM35SN.py": 38, "VZM36.py": 32, # 17 on endpoint 1 + 15 on endpoint 2 } @@ -78,25 +79,27 @@ def test_inovelli_quirks_present(): [(m, e) for m, e in _inovelli_v2_entries()], ) def test_inovelli_entity_unique_id_suffix(model, entry): - """Every Inovelli cluster entity keeps the ZHA-native unique_id. + """Every Inovelli cluster entity keeps the ZHA-native unique_id format. ZHA-native entities include the cluster id in their unique_id, but quirks v2 - entities do not. To avoid orphaning existing Home Assistant entities, every - ported entity must carry a ``64561[-]`` suffix (``64561 == 0xFC31``). + entities do not. To avoid orphaning existing Home Assistant entities (and to + stay consistent for the new mmWave entities), every entity must carry a + ``[-]`` suffix (``64561 == 0xFC31``, ``64562 == 0xFC32``). """ definition = entry.zha_device_factory.quirk_definition inovelli_entities = [ - em for em in definition.entity_metadata if em.cluster_id == INOVELLI_CLUSTER_ID + em for em in definition.entity_metadata if em.cluster_id in INOVELLI_CLUSTER_IDS ] assert len(inovelli_entities) == EXPECTED_ENTITY_COUNTS[model] for em in inovelli_entities: suffix = em.unique_id_suffix + cid = em.cluster_id assert suffix is not None - # bare cluster id (internal temperature sensor) or ``64561-`` - assert suffix == str(INOVELLI_CLUSTER_ID) or suffix.startswith( - f"{INOVELLI_CLUSTER_ID}-" - ), f"{model}: unexpected unique_id suffix {suffix!r}" + # bare cluster id (internal temperature sensor) or ``-`` + assert suffix == str(cid) or suffix.startswith(f"{cid}-"), ( + f"{model}: unexpected unique_id suffix {suffix!r} for cluster {cid}" + ) @pytest.mark.parametrize( @@ -109,36 +112,37 @@ def test_inovelli_entity_attribute_names_exist(model, entry): The builder has to pass attribute names as string literals -- no single Inovelli cluster defines all of them, so ``AttributeDefs.x.name`` references are not practical there. A typo'd name would still register an entity with - an intact ``64561-`` unique_id suffix and an unchanged entity count, and - would only surface at runtime as a broken entity. So check each name against - the cluster the quirk actually installs on that entity's endpoint (the VZM36 - has a different one on its light and fan endpoints). + an intact ``-`` unique_id suffix and an unchanged entity count, + and would only surface at runtime as a broken entity. So check each name + against the cluster the quirk actually installs on that entity's endpoint + (the VZM36 has a different one on its light and fan endpoints). """ - clusters = _inovelli_clusters_by_endpoint(entry) - inovelli_entities = [ - em - for em in entry.zha_device_factory.quirk_definition.entity_metadata - if em.cluster_id == INOVELLI_CLUSTER_ID - ] - - for em in inovelli_entities: - attribute_name = getattr(em, "attribute_name", None) - if attribute_name is None: - # Command-based entity (e.g. a button): nothing to look up. Assert - # it really is one, so this branch can never silently swallow an - # attribute-backed entity. - assert hasattr(em, "command_name"), ( - f"{model}: entity {em.unique_id_suffix!r} has neither an " - f"attribute_name nor a command_name" + for cluster_id in INOVELLI_CLUSTER_IDS: + clusters = _inovelli_clusters_by_endpoint(entry, cluster_id) + inovelli_entities = [ + em + for em in entry.zha_device_factory.quirk_definition.entity_metadata + if em.cluster_id == cluster_id + ] + + for em in inovelli_entities: + attribute_name = getattr(em, "attribute_name", None) + if attribute_name is None: + # Command-based entity (e.g. a button): nothing to look up. Assert + # it really is one, so this branch can never silently swallow an + # attribute-backed entity. + assert hasattr(em, "command_name"), ( + f"{model}: entity {em.unique_id_suffix!r} has neither an " + f"attribute_name nor a command_name" + ) + continue + + cluster = clusters.get(em.endpoint_id, clusters.get(None)) + assert cluster is not None, ( + f"{model}: entity {em.unique_id_suffix!r} is on endpoint " + f"{em.endpoint_id}, where the quirk replaces no Inovelli cluster" + ) + assert attribute_name in cluster.attributes_by_name, ( + f"{model}: entity attribute {attribute_name!r} (endpoint " + f"{em.endpoint_id}) does not exist on {cluster.__name__}" ) - continue - - cluster = clusters.get(em.endpoint_id, clusters.get(None)) - assert cluster is not None, ( - f"{model}: entity {em.unique_id_suffix!r} is on endpoint " - f"{em.endpoint_id}, where the quirk replaces no Inovelli cluster" - ) - assert attribute_name in cluster.attributes_by_name, ( - f"{model}: entity attribute {attribute_name!r} (endpoint " - f"{em.endpoint_id}) does not exist on {cluster.__name__}" - ) diff --git a/zhaquirks/inovelli/VZM32SN.py b/zhaquirks/inovelli/VZM32SN.py index cd91758cdf..5d19e6e466 100644 --- a/zhaquirks/inovelli/VZM32SN.py +++ b/zhaquirks/inovelli/VZM32SN.py @@ -55,6 +55,21 @@ # sensor entities .inovelli_internal_temperature() .inovelli_overheated() + # VZM32-SN specific entities (0xFC31) + .inovelli_remote_protection() + .inovelli_vzm32_switch_type() + .inovelli_light_on_presence_behavior() + .inovelli_mmwave_room_size_preset() + # VZM32-SN mmWave cluster entities (0xFC32) + .inovelli_mmwave_height_minimum_floor() + .inovelli_mmwave_height_maximum_ceiling() + .inovelli_mmwave_width_minimum_left() + .inovelli_mmwave_width_maximum_right() + .inovelli_mmwave_depth_minimum_near() + .inovelli_mmwave_depth_maximum_far() + .inovelli_mmwave_detect_sensitivity() + .inovelli_mmwave_detect_trigger() + .inovelli_mmwave_hold_time() .device_automation_triggers(INOVELLI_AUTOMATION_TRIGGERS) .add_to_registry() ) diff --git a/zhaquirks/inovelli/builder.py b/zhaquirks/inovelli/builder.py index 8e19f865e2..3bbaca42ce 100644 --- a/zhaquirks/inovelli/builder.py +++ b/zhaquirks/inovelli/builder.py @@ -29,6 +29,7 @@ from zhaquirks.inovelli import InovelliCluster INOVELLI_CLUSTER_ID = InovelliCluster.cluster_id # 0xFC31 == 64561 +INOVELLI_MMWAVE_CLUSTER_ID = 0xFC32 # 64562, VZM32-SN mmWave cluster class InovelliOutputMode(t.enum1): @@ -98,19 +99,77 @@ class InovelliOverheatedState(t.enum8): Overheated = 0x01 +class InovelliVZM32SwitchType(t.enum8): + """Inovelli VZM32-SN switch type.""" + + Single_Pole = 0x00 + Three_Way_AUX = 0x01 + + +class InovelliLightOnPresenceBehavior(t.enum8): + """Inovelli light on presence behavior.""" + + Disabled = 0x00 + On_When_Occupied_Off_When_Unoccupied = 0x01 + Off_When_Vacant = 0x02 + On_When_Occupied = 0x03 + On_When_Vacant_Off_When_Occupied = 0x04 + On_When_Vacant = 0x05 + Off_When_Occupied = 0x06 + + +class InovelliMmwaveRoomSizePreset(t.enum8): + """Inovelli mmWave room size preset.""" + + Custom = 0x00 + X_Small = 0x01 + Small = 0x02 + Medium = 0x03 + Large = 0x04 + X_Large = 0x05 + + +class InovelliMmwaveSensitivity(t.enum8): + """Inovelli mmWave detection sensitivity.""" + + Low = 0x00 + Medium = 0x01 + High = 0x02 + + +class InovelliMmwaveTargetSpeed(t.enum8): + """Inovelli mmWave target speed.""" + + Low = 0x00 + Medium = 0x01 + Fast = 0x02 + + class InovelliQuirkBuilder(QuirkBuilder): - """``QuirkBuilder`` subclass exposing Inovelli ``0xFC31`` entities. + """``QuirkBuilder`` subclass exposing Inovelli entities. - Each ``inovelli_*`` method adds a single entity that mirrors the metadata of - the entity the ZHA library previously created for it, including its - ``unique_id`` (see module docstring). + Each ``inovelli_*`` method adds a single entity on the Inovelli manufacturer + cluster (``0xFC31``) or, for the VZM32-SN mmWave entities, the mmWave cluster + (``0xFC32``). Entities ported from the ZHA library mirror the metadata and + ``unique_id`` ZHA previously produced (see module docstring); the mmWave + entities are new and use the same ``-`` suffix scheme. """ - def _inovelli_suffix(self, attribute_name: str | None = None) -> str: - """Return the unique_id suffix matching the old ZHA-native entity.""" + def _inovelli_suffix( + self, + attribute_name: str | None = None, + *, + cluster_id: int = INOVELLI_CLUSTER_ID, + ) -> str: + """Return the unique_id suffix matching the old ZHA-native entity. + + ZHA-native entities included the cluster id in their unique_id; replicate + that here so existing entities are preserved (and stay consistent for new + entities on the mmWave cluster). + """ if attribute_name is None: - return f"{INOVELLI_CLUSTER_ID}" - return f"{INOVELLI_CLUSTER_ID}-{attribute_name}" + return f"{cluster_id}" + return f"{cluster_id}-{attribute_name}" def _inovelli_number( self, @@ -120,14 +179,17 @@ def _inovelli_number( max_value: float, fallback_name: str, endpoint_id: int = 1, + cluster_id: int = INOVELLI_CLUSTER_ID, ) -> Self: return self.number( attribute_name=attribute_name, - cluster_id=INOVELLI_CLUSTER_ID, + cluster_id=cluster_id, endpoint_id=endpoint_id, min_value=min_value, max_value=max_value, - unique_id_suffix=self._inovelli_suffix(attribute_name), + unique_id_suffix=self._inovelli_suffix( + attribute_name, cluster_id=cluster_id + ), translation_key=attribute_name, fallback_name=fallback_name, ) @@ -139,12 +201,15 @@ def _inovelli_switch( fallback_name: str, translation_key: str | None = None, endpoint_id: int = 1, + cluster_id: int = INOVELLI_CLUSTER_ID, ) -> Self: return self.switch( attribute_name=attribute_name, - cluster_id=INOVELLI_CLUSTER_ID, + cluster_id=cluster_id, endpoint_id=endpoint_id, - unique_id_suffix=self._inovelli_suffix(attribute_name), + unique_id_suffix=self._inovelli_suffix( + attribute_name, cluster_id=cluster_id + ), translation_key=translation_key or attribute_name, fallback_name=fallback_name, ) @@ -156,13 +221,16 @@ def _inovelli_select( *, fallback_name: str, endpoint_id: int = 1, + cluster_id: int = INOVELLI_CLUSTER_ID, ) -> Self: return self.enum( attribute_name=attribute_name, enum_class=enum_class, - cluster_id=INOVELLI_CLUSTER_ID, + cluster_id=cluster_id, endpoint_id=endpoint_id, - unique_id_suffix=self._inovelli_suffix(attribute_name), + unique_id_suffix=self._inovelli_suffix( + attribute_name, cluster_id=cluster_id + ), translation_key=attribute_name, fallback_name=fallback_name, ) @@ -595,3 +663,139 @@ def inovelli_overheated(self, endpoint_id: int = 1) -> Self: translation_key="overheated", fallback_name="Overheat protection", ) + + # --- VZM32-SN specific entities (0xFC31) ----------------------------- + + def inovelli_remote_protection(self, endpoint_id: int = 1): + """Add the remote protection switch entity.""" + return self._inovelli_switch( + "remote_protection", + fallback_name="Remote protection", + endpoint_id=endpoint_id, + ) + + def inovelli_vzm32_switch_type(self, endpoint_id: int = 1): + """Add the VZM32-SN switch type select entity.""" + return self._inovelli_select( + "switch_type", + InovelliVZM32SwitchType, + fallback_name="Switch type", + endpoint_id=endpoint_id, + ) + + def inovelli_light_on_presence_behavior(self, endpoint_id: int = 1): + """Add the light on presence behavior select entity.""" + return self._inovelli_select( + "light_on_presence_behavior", + InovelliLightOnPresenceBehavior, + fallback_name="Light on presence behavior", + endpoint_id=endpoint_id, + ) + + def inovelli_mmwave_room_size_preset(self, endpoint_id: int = 1): + """Add the mmWave room size preset select entity.""" + return self._inovelli_select( + "mmwave_room_size_preset", + InovelliMmwaveRoomSizePreset, + fallback_name="mmWave room size preset", + endpoint_id=endpoint_id, + ) + + # --- VZM32-SN mmWave cluster entities (0xFC32) ----------------------- + + def inovelli_mmwave_height_minimum_floor(self, endpoint_id: int = 1): + """Add the mmWave minimum height (floor) number entity.""" + return self._inovelli_number( + "mmwave_height_minimum_floor", + min_value=-600, + max_value=600, + fallback_name="mmWave height minimum (floor)", + endpoint_id=endpoint_id, + cluster_id=INOVELLI_MMWAVE_CLUSTER_ID, + ) + + def inovelli_mmwave_height_maximum_ceiling(self, endpoint_id: int = 1): + """Add the mmWave maximum height (ceiling) number entity.""" + return self._inovelli_number( + "mmwave_height_maximum_ceiling", + min_value=-600, + max_value=600, + fallback_name="mmWave height maximum (ceiling)", + endpoint_id=endpoint_id, + cluster_id=INOVELLI_MMWAVE_CLUSTER_ID, + ) + + def inovelli_mmwave_width_minimum_left(self, endpoint_id: int = 1): + """Add the mmWave minimum width (left) number entity.""" + return self._inovelli_number( + "mmwave_width_minimum_left", + min_value=-600, + max_value=600, + fallback_name="mmWave width minimum (left)", + endpoint_id=endpoint_id, + cluster_id=INOVELLI_MMWAVE_CLUSTER_ID, + ) + + def inovelli_mmwave_width_maximum_right(self, endpoint_id: int = 1): + """Add the mmWave maximum width (right) number entity.""" + return self._inovelli_number( + "mmwave_width_maximum_right", + min_value=-600, + max_value=600, + fallback_name="mmWave width maximum (right)", + endpoint_id=endpoint_id, + cluster_id=INOVELLI_MMWAVE_CLUSTER_ID, + ) + + def inovelli_mmwave_depth_minimum_near(self, endpoint_id: int = 1): + """Add the mmWave minimum depth (near) number entity.""" + return self._inovelli_number( + "mmwave_depth_minimum_near", + min_value=0, + max_value=600, + fallback_name="mmWave depth minimum (near)", + endpoint_id=endpoint_id, + cluster_id=INOVELLI_MMWAVE_CLUSTER_ID, + ) + + def inovelli_mmwave_depth_maximum_far(self, endpoint_id: int = 1): + """Add the mmWave maximum depth (far) number entity.""" + return self._inovelli_number( + "mmwave_depth_maximum_far", + min_value=0, + max_value=600, + fallback_name="mmWave depth maximum (far)", + endpoint_id=endpoint_id, + cluster_id=INOVELLI_MMWAVE_CLUSTER_ID, + ) + + def inovelli_mmwave_detect_sensitivity(self, endpoint_id: int = 1): + """Add the mmWave detection sensitivity select entity.""" + return self._inovelli_select( + "mmwave_detect_sensitivity", + InovelliMmwaveSensitivity, + fallback_name="mmWave sensitivity", + endpoint_id=endpoint_id, + cluster_id=INOVELLI_MMWAVE_CLUSTER_ID, + ) + + def inovelli_mmwave_detect_trigger(self, endpoint_id: int = 1): + """Add the mmWave target speed select entity.""" + return self._inovelli_select( + "mmwave_detect_trigger", + InovelliMmwaveTargetSpeed, + fallback_name="mmWave target speed", + endpoint_id=endpoint_id, + cluster_id=INOVELLI_MMWAVE_CLUSTER_ID, + ) + + def inovelli_mmwave_hold_time(self, endpoint_id: int = 1): + """Add the mmWave hold time number entity.""" + return self._inovelli_number( + "mmwave_hold_time", + min_value=0, + max_value=4294967295, + fallback_name="mmWave hold time", + endpoint_id=endpoint_id, + cluster_id=INOVELLI_MMWAVE_CLUSTER_ID, + )