Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions tests/test_inovelli.py

@TheJulianJES TheJulianJES Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll clean this up later. For now, it's good enough that we can merge the quirks and builder 😅

Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""Tests for Inovelli quirks (VZM series)."""

import pytest
from zha.quirks import DEVICE_REGISTRY

import zhaquirks
from zhaquirks.builder.device import QuirkV2Factory
from zhaquirks.inovelli.builder import INOVELLI_CLUSTER_ID

zhaquirks.setup()


def _inovelli_v2_definitions():
"""Yield (model, QuirkDefinition) for every Inovelli VZM v2 quirk."""
seen = set()
for entry in DEVICE_REGISTRY:
factory = entry.zha_device_factory
if not isinstance(factory, QuirkV2Factory):
continue
if "inovelli" not in str(entry.source.file).lower():
continue
model = str(entry.source.file).split("/")[-1]
Comment on lines +22 to +24

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I'll clean up this entire test file next. It's good enough to release an initial version with the ported quirks for HA beta and so we can port #5014.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@zigpy-review-bot Create a "Task" issue, assign + tag me there, and mention what should be improved for the Inovelli quirks/tests. Also look through (comments in) the other two recently merged Inovelli PRs. List everything, possibly use /details blocks.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Created #5218 (type: Task, assigned to you).

It collects the deferred items from this PR, #5014 and #4944: the tests/test_inovelli.py rewrite (with a note on which parts ZHA's snapshots now cover and which don't), the builder cleanups (the kwargs nit, missing -> Self on the VZM32-SN methods, and _inovelli_number() having no way to pass a unit — mmwave_hold_time is exposed with the raw uint32 max), the three 0xFC32 attributes that still have no entity (notably mmwave_target_info_report, without which #4944's report parsing can't be switched on from HA), the target_id int8s change that's still unverified against real hardware, and a section of known limitations for the record.

if model in seen:
continue
seen.add(model)
yield model, factory.quirk_definition


# 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.
EXPECTED_ENTITY_COUNTS = {
"VZM30SN.py": 38,
"VZM31SN.py": 40,
"VZM32SN.py": 37,
"VZM35SN.py": 38,
"VZM36.py": 32, # 17 on endpoint 1 + 15 on endpoint 2
}


def test_inovelli_quirks_present():
"""All five Inovelli VZM device files register a v2 quirk."""
models = {model for model, _ in _inovelli_v2_definitions()}
assert models == set(EXPECTED_ENTITY_COUNTS)


@pytest.mark.parametrize(
("model", "definition"),
[(m, d) for m, d in _inovelli_v2_definitions()],
)
def test_inovelli_entity_unique_id_suffix(model, definition):
"""Every Inovelli cluster entity keeps the ZHA-native unique_id.

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[-<attribute>]`` suffix (``64561 == 0xFC31``).
"""
inovelli_entities = [
em for em in definition.entity_metadata if em.cluster_id == INOVELLI_CLUSTER_ID
]
assert len(inovelli_entities) == EXPECTED_ENTITY_COUNTS[model]
Comment thread
TheJulianJES marked this conversation as resolved.

for em in inovelli_entities:
suffix = em.unique_id_suffix
assert suffix is not None
# bare cluster id (internal temperature sensor) or ``64561-<attribute>``
assert suffix == str(INOVELLI_CLUSTER_ID) or suffix.startswith(
f"{INOVELLI_CLUSTER_ID}-"
), f"{model}: unexpected unique_id suffix {suffix!r}"
46 changes: 44 additions & 2 deletions zhaquirks/inovelli/VZM30SN.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,55 @@

from zigpy.profiles import zha

from zhaquirks.builder import QuirkBuilder
from zhaquirks.inovelli import INOVELLI_AUTOMATION_TRIGGERS, InovelliVZM30SNCluster
from zhaquirks.inovelli.builder import InovelliQuirkBuilder

(
QuirkBuilder("Inovelli", "VZM30-SN")
InovelliQuirkBuilder("Inovelli", "VZM30-SN")
.replaces_endpoint(1, device_type=zha.DeviceType.DIMMABLE_LIGHT)
.replace_cluster_occurrences(InovelliVZM30SNCluster)
# number entities
.inovelli_remote_dimming_up_speed()
.inovelli_local_dimming_up_speed()
.inovelli_remote_dimming_down_speed()
.inovelli_local_dimming_down_speed()
.inovelli_remote_ramp_rate_off_to_on()
.inovelli_local_ramp_rate_off_to_on()
.inovelli_remote_ramp_rate_on_to_off()
.inovelli_local_ramp_rate_on_to_off()
.inovelli_button_delay()
.inovelli_minimum_load_dimming_level()
.inovelli_maximum_load_dimming_level()
.inovelli_auto_shutoff_timer()
.inovelli_local_default_level()
.inovelli_remote_default_level()
.inovelli_startup_default_level()
.inovelli_load_level_indicator_timeout()
.inovelli_default_all_led_on_color()
.inovelli_default_all_led_off_color()
.inovelli_default_all_led_on_intensity()
.inovelli_default_all_led_off_intensity()
.inovelli_double_tap_up_level()
.inovelli_double_tap_down_level()
# switch entities
.inovelli_invert_switch()
.inovelli_smart_bulb_mode()
.inovelli_double_tap_up_enabled()
.inovelli_double_tap_down_enabled()
.inovelli_aux_switch_scenes()
.inovelli_binding_off_to_on_sync_level()
.inovelli_local_protection()
.inovelli_on_off_led_mode()
.inovelli_firmware_progress_led()
.inovelli_relay_click_in_on_off_mode()
.inovelli_disable_clear_notifications_double_tap()
# select entities
.inovelli_output_mode()
.inovelli_led_scaling_mode()
.inovelli_increased_non_neutral_output()
# sensor entities
.inovelli_internal_temperature()
.inovelli_overheated()
.device_automation_triggers(INOVELLI_AUTOMATION_TRIGGERS)
.add_to_registry()
)
48 changes: 46 additions & 2 deletions zhaquirks/inovelli/VZM31SN.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,57 @@

from zigpy.profiles import zha

from zhaquirks.builder import QuirkBuilder
from zhaquirks.inovelli import INOVELLI_AUTOMATION_TRIGGERS, InovelliVZM31SNCluster
from zhaquirks.inovelli.builder import InovelliQuirkBuilder

(
QuirkBuilder("Inovelli", "VZM31-SN")
InovelliQuirkBuilder("Inovelli", "VZM31-SN")
.replaces_endpoint(1, device_type=zha.DeviceType.DIMMABLE_LIGHT)
.replace_cluster_occurrences(InovelliVZM31SNCluster)
# number entities
.inovelli_remote_dimming_up_speed()
.inovelli_local_dimming_up_speed()
.inovelli_remote_dimming_down_speed()
.inovelli_local_dimming_down_speed()
.inovelli_remote_ramp_rate_off_to_on()
.inovelli_local_ramp_rate_off_to_on()
.inovelli_remote_ramp_rate_on_to_off()
.inovelli_local_ramp_rate_on_to_off()
.inovelli_button_delay()
.inovelli_minimum_load_dimming_level()
.inovelli_maximum_load_dimming_level()
.inovelli_auto_shutoff_timer()
.inovelli_local_default_level()
.inovelli_remote_default_level()
.inovelli_startup_default_level()
.inovelli_load_level_indicator_timeout()
.inovelli_default_all_led_on_color()
.inovelli_default_all_led_off_color()
.inovelli_default_all_led_on_intensity()
.inovelli_default_all_led_off_intensity()
.inovelli_double_tap_up_level()
.inovelli_double_tap_down_level()
# switch entities
.inovelli_invert_switch()
.inovelli_smart_bulb_mode()
.inovelli_double_tap_up_enabled()
.inovelli_double_tap_down_enabled()
.inovelli_aux_switch_scenes()
.inovelli_binding_off_to_on_sync_level()
.inovelli_local_protection()
.inovelli_on_off_led_mode()
.inovelli_firmware_progress_led()
.inovelli_relay_click_in_on_off_mode()
.inovelli_disable_clear_notifications_double_tap()
# select entities
.inovelli_output_mode()
.inovelli_switch_type()
.inovelli_led_scaling_mode()
.inovelli_increased_non_neutral_output()
.inovelli_dimming_mode()
# sensor entities
.inovelli_internal_temperature()
.inovelli_overheated()
.device_automation_triggers(INOVELLI_AUTOMATION_TRIGGERS)
.add_to_registry()
)
45 changes: 43 additions & 2 deletions zhaquirks/inovelli/VZM32SN.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,59 @@

from zigpy.profiles import zha

from zhaquirks.builder import QuirkBuilder
from zhaquirks.inovelli import (
INOVELLI_AUTOMATION_TRIGGERS,
InovelliVZM32SNCluster,
InovelliVZM32SNMMWaveCluster,
)
from zhaquirks.inovelli.builder import InovelliQuirkBuilder

(
QuirkBuilder("Inovelli", "VZM32-SN")
InovelliQuirkBuilder("Inovelli", "VZM32-SN")
.replaces_endpoint(1, device_type=zha.DeviceType.DIMMABLE_LIGHT)
.replace_cluster_occurrences(InovelliVZM32SNMMWaveCluster)
.replace_cluster_occurrences(InovelliVZM32SNCluster)
# number entities
.inovelli_remote_dimming_up_speed()
.inovelli_local_dimming_up_speed()
.inovelli_remote_dimming_down_speed()
.inovelli_local_dimming_down_speed()
.inovelli_remote_ramp_rate_off_to_on()
.inovelli_local_ramp_rate_off_to_on()
.inovelli_remote_ramp_rate_on_to_off()
.inovelli_local_ramp_rate_on_to_off()
.inovelli_button_delay()
.inovelli_minimum_load_dimming_level()
.inovelli_maximum_load_dimming_level()
.inovelli_auto_shutoff_timer()
.inovelli_local_default_level()
.inovelli_remote_default_level()
.inovelli_startup_default_level()
.inovelli_load_level_indicator_timeout()
.inovelli_default_all_led_on_color()
.inovelli_default_all_led_off_color()
.inovelli_default_all_led_on_intensity()
.inovelli_default_all_led_off_intensity()
.inovelli_double_tap_up_level()
.inovelli_double_tap_down_level()
# switch entities
.inovelli_invert_switch()
.inovelli_smart_bulb_mode()
.inovelli_double_tap_up_enabled()
.inovelli_double_tap_down_enabled()
.inovelli_aux_switch_scenes()
.inovelli_binding_off_to_on_sync_level()
.inovelli_local_protection()
.inovelli_on_off_led_mode()
.inovelli_firmware_progress_led()
.inovelli_disable_clear_notifications_double_tap()
# select entities
.inovelli_output_mode()
.inovelli_led_scaling_mode()
.inovelli_increased_non_neutral_output()
# sensor entities
.inovelli_internal_temperature()
.inovelli_overheated()
.device_automation_triggers(INOVELLI_AUTOMATION_TRIGGERS)
.add_to_registry()
)
46 changes: 44 additions & 2 deletions zhaquirks/inovelli/VZM35SN.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,60 @@
from zigpy.profiles import zha
from zigpy.zcl import ClusterType

from zhaquirks.builder import QuirkBuilder
from zhaquirks.inovelli import INOVELLI_AUTOMATION_TRIGGERS, InovelliVZM35SNCluster
from zhaquirks.inovelli.builder import InovelliQuirkBuilder

(
QuirkBuilder("Inovelli", "VZM35-SN")
InovelliQuirkBuilder("Inovelli", "VZM35-SN")
.replaces_endpoint(1, device_type=zha.DeviceType.DIMMABLE_LIGHT)
.replace_cluster_occurrences(InovelliVZM35SNCluster)
# ep 3 is missing in zigpy DB for devices paired with an old fw version, add it:
.replaces_endpoint(3, device_type=zha.DeviceType.DIMMER_SWITCH)
# these missing clusters are needed for button presses to generate events:
.replaces(InovelliVZM35SNCluster, endpoint_id=2, cluster_type=ClusterType.Client)
.replaces(InovelliVZM35SNCluster, endpoint_id=3, cluster_type=ClusterType.Client)
# number entities
.inovelli_remote_dimming_up_speed()
.inovelli_local_dimming_up_speed()
.inovelli_remote_dimming_down_speed()
.inovelli_local_dimming_down_speed()
.inovelli_remote_ramp_rate_off_to_on()
.inovelli_local_ramp_rate_off_to_on()
.inovelli_remote_ramp_rate_on_to_off()
.inovelli_local_ramp_rate_on_to_off()
.inovelli_button_delay()
.inovelli_minimum_load_dimming_level()
.inovelli_maximum_load_dimming_level()
.inovelli_auto_shutoff_timer()
.inovelli_local_default_level()
.inovelli_remote_default_level()
.inovelli_startup_default_level()
.inovelli_quick_start_time()
.inovelli_load_level_indicator_timeout()
.inovelli_default_all_led_on_color()
.inovelli_default_all_led_off_color()
.inovelli_default_all_led_on_intensity()
.inovelli_default_all_led_off_intensity()
.inovelli_double_tap_up_level()
.inovelli_double_tap_down_level()
# switch entities
.inovelli_invert_switch()
.inovelli_smart_bulb_mode()
.inovelli_smart_fan_mode()
.inovelli_double_tap_up_enabled()
.inovelli_double_tap_down_enabled()
.inovelli_aux_switch_scenes()
.inovelli_local_protection()
.inovelli_on_off_led_mode()
.inovelli_firmware_progress_led()
.inovelli_disable_clear_notifications_double_tap()
# select entities
.inovelli_output_mode()
.inovelli_fan_switch_type()
.inovelli_fan_led_scaling_mode()
# sensor entities
.inovelli_internal_temperature()
.inovelli_overheated()
.device_automation_triggers(INOVELLI_AUTOMATION_TRIGGERS)
.add_to_registry()
)
38 changes: 36 additions & 2 deletions zhaquirks/inovelli/VZM36.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,52 @@

from zigpy.profiles import zha

from zhaquirks.builder import QuirkBuilder
from zhaquirks.inovelli import (
INOVELLI_AUTOMATION_TRIGGERS,
InovelliVZM36FanCluster,
InovelliVZM36LightCluster,
)
from zhaquirks.inovelli.builder import InovelliQuirkBuilder

(
QuirkBuilder("Inovelli", "VZM36")
InovelliQuirkBuilder("Inovelli", "VZM36")
.replaces_endpoint(1, device_type=zha.DeviceType.DIMMABLE_LIGHT)
.replaces(InovelliVZM36LightCluster)
.replaces(InovelliVZM36FanCluster, endpoint_id=2)
# light endpoint (1) entities
.inovelli_remote_dimming_up_speed()
.inovelli_remote_dimming_down_speed()
.inovelli_remote_ramp_rate_off_to_on()
.inovelli_remote_ramp_rate_on_to_off()
.inovelli_minimum_load_dimming_level()
.inovelli_maximum_load_dimming_level()
.inovelli_auto_shutoff_timer()
.inovelli_remote_default_level()
.inovelli_startup_default_level()
.inovelli_default_all_led_on_color()
.inovelli_default_all_led_on_intensity()
.inovelli_smart_bulb_mode()
.inovelli_output_mode()
.inovelli_increased_non_neutral_output()
.inovelli_dimming_mode()
.inovelli_internal_temperature()
.inovelli_overheated()
# fan endpoint (2) entities
.inovelli_remote_dimming_up_speed(endpoint_id=2)
.inovelli_remote_dimming_down_speed(endpoint_id=2)
.inovelli_remote_ramp_rate_off_to_on(endpoint_id=2)
.inovelli_remote_ramp_rate_on_to_off(endpoint_id=2)
.inovelli_minimum_load_dimming_level(endpoint_id=2)
.inovelli_maximum_load_dimming_level(endpoint_id=2)
.inovelli_auto_shutoff_timer(endpoint_id=2)
.inovelli_remote_default_level(endpoint_id=2)
.inovelli_startup_default_level(endpoint_id=2)
.inovelli_default_all_led_on_color(endpoint_id=2)
.inovelli_default_all_led_on_intensity(endpoint_id=2)
.inovelli_smart_bulb_mode(endpoint_id=2)
.inovelli_output_mode(endpoint_id=2)
.inovelli_internal_temperature(endpoint_id=2)
.inovelli_overheated(endpoint_id=2)
.device_automation_triggers(INOVELLI_AUTOMATION_TRIGGERS)
.add_to_registry()
)
Loading
Loading