From 1c3dfeb71e2e0be206ebb55086f7d20a113dd4ea Mon Sep 17 00:00:00 2001 From: Thomas Mueller Date: Thu, 23 Apr 2026 12:12:07 -0500 Subject: [PATCH 1/3] Inovelli VZM32-SN: add mmWave area commands and reports The VZM32-SN mmWave cluster (0xFC32) exposes three area-definition commands (interference, detection, stay) and five device-to-coordinator reports that were previously only defined in zigbee-herdsman-converters' inovelli.ts. This commit brings them to zha-device-handlers: zhaquirks/inovelli/types.py - MMWaveArea struct: x/y/z min/max bounds in mm - MMWaveTarget struct: x, y, z, doppler, id for live target reports zhaquirks/inovelli/__init__.py - MMWaveControlId: add Reset_detection_area (0x04) and Clear_stay_areas (0x05) to match the full device command set - InovelliVZM32SNMMWaveCluster.ServerCommandDefs: add set_interference_area (0x01), set_detection_area (0x02), set_stay_area (0x03). Docstring notes the community-reported v1.00 set_stay_area xMin/xMax swap+negate bug and the pre-compensation workaround. - InovelliVZM32SNMMWaveCluster.ClientCommandDefs: new class with the five device-originated reports. anyone_in_reporting_area (0x00) for per-area occupancy, report_target_info (0x01) for live position streams using t.List[MMWaveTarget], and report_interference_area / report_detection_area / report_stay_area (0x02-0x04) as readback responses to mmwave_control_command(Obtain_areas). Command names are snake_case per zha-device-handlers convention; payload layouts match zigbee-herdsman-converters. tests/test_inovelli_blue.py - Round-trip tests for set_stay_area, report_stay_area, report_target_info (variable-length), and anyone_in_reporting_area. --- tests/test_inovelli_blue.py | 84 +++++++++++++++++++++ zhaquirks/inovelli/__init__.py | 134 ++++++++++++++++++++++++++++++++- zhaquirks/inovelli/types.py | 30 ++++++++ 3 files changed, 247 insertions(+), 1 deletion(-) diff --git a/tests/test_inovelli_blue.py b/tests/test_inovelli_blue.py index 26e6608735..4005385f45 100644 --- a/tests/test_inovelli_blue.py +++ b/tests/test_inovelli_blue.py @@ -7,6 +7,8 @@ from zigpy.zcl import ClusterType import zhaquirks +from zhaquirks.inovelli import InovelliVZM32SNMMWaveCluster +from zhaquirks.inovelli.types import MMWaveArea, MMWaveTarget zhaquirks.setup() @@ -62,3 +64,85 @@ class Listener: "led_effect_complete_ALL_LEDS", {"notification_type": "ALL_LEDS", "command_id": 36}, ) + + +def test_vzm32_mmwave_set_stay_area_roundtrip(): + """set_stay_area (server command 0x03) serializes bounds as little-endian int16s.""" + set_stay = InovelliVZM32SNMMWaveCluster.ServerCommandDefs.set_stay_area.with_compiled_schema().schema + payload = set_stay( + area_id=1, + x_min=-18, + x_max=600, + y_min=0, + y_max=425, + z_min=-116, + z_max=135, + ) + raw = payload.serialize() + # area_id(1) | x_min(-18=EEFF) | x_max(600=5802) | y_min(0=0000) + # | y_max(425=A901) | z_min(-116=8CFF) | z_max(135=8700) + assert raw == bytes.fromhex("01eeff58020000a9018cff8700") + + parsed, rest = set_stay.deserialize(raw) + assert rest == b"" + assert parsed.area_id == 1 + assert parsed.x_min == -18 + assert parsed.x_max == 600 + assert parsed.z_max == 135 + + +def test_vzm32_mmwave_report_stay_area_roundtrip(): + """report_stay_area (client command 0x04) decodes count + 4 MMWaveArea structs.""" + report_stay = InovelliVZM32SNMMWaveCluster.ClientCommandDefs.report_stay_area.with_compiled_schema().schema + empty = MMWaveArea(x_min=0, x_max=0, y_min=0, y_max=0, z_min=0, z_max=0) + a1 = MMWaveArea(x_min=-18, x_max=600, y_min=0, y_max=425, z_min=-116, z_max=135) + a2 = MMWaveArea(x_min=-300, x_max=300, y_min=-100, y_max=500, z_min=-50, z_max=250) + instance = report_stay(count=2, area_1=a1, area_2=a2, area_3=empty, area_4=empty) + raw = instance.serialize() + # 1 byte count + 4 areas * 12 bytes each + assert len(raw) == 1 + 4 * 12 + + parsed, rest = report_stay.deserialize(raw) + assert rest == b"" + assert parsed.count == 2 + assert parsed.area_1.x_min == -18 and parsed.area_1.x_max == 600 + assert parsed.area_2.y_max == 500 + assert parsed.area_3 == empty + assert parsed.area_4 == empty + + +def test_vzm32_mmwave_report_target_info_variable_length(): + """report_target_info (client 0x01) reads a list of 10-byte target structs.""" + report_target = InovelliVZM32SNMMWaveCluster.ClientCommandDefs.report_target_info.with_compiled_schema().schema + # The compiled schema wraps t.List[MMWaveTarget] in an AnonymousList class. + targets_type = next(f.type for f in report_target.fields if f.name == "targets") + targets = targets_type( + [ + MMWaveTarget(x=100, y=200, z=-50, dop=5, target_id=1), + MMWaveTarget(x=-100, y=300, z=0, dop=-3, target_id=2), + ] + ) + instance = report_target(target_num=2, targets=targets) + raw = instance.serialize() + # 1 byte target_num + 2 targets * 10 bytes + assert len(raw) == 1 + 2 * 10 + + parsed, rest = report_target.deserialize(raw) + assert rest == b"" + assert parsed.target_num == 2 + assert len(parsed.targets) == 2 + assert parsed.targets[0].x == 100 + assert parsed.targets[1].target_id == 2 + + +def test_vzm32_mmwave_anyone_in_reporting_area(): + """anyone_in_reporting_area (client 0x00) carries 4 per-area occupancy bytes.""" + anyone = InovelliVZM32SNMMWaveCluster.ClientCommandDefs.anyone_in_reporting_area.with_compiled_schema().schema + instance = anyone(area_1=1, area_2=0, area_3=1, area_4=0) + raw = instance.serialize() + assert raw == bytes([1, 0, 1, 0]) + + parsed, rest = anyone.deserialize(raw) + assert rest == b"" + assert parsed.area_1 == 1 and parsed.area_2 == 0 + assert parsed.area_3 == 1 and parsed.area_4 == 0 diff --git a/zhaquirks/inovelli/__init__.py b/zhaquirks/inovelli/__init__.py index 7bb503d716..163012a059 100644 --- a/zhaquirks/inovelli/__init__.py +++ b/zhaquirks/inovelli/__init__.py @@ -36,6 +36,7 @@ TRIPLE_PRESS, ZHA_SEND_EVENT, ) +from zhaquirks.inovelli.types import MMWaveArea, MMWaveTarget _LOGGER = logging.getLogger(__name__) INOVELLI_VZM31SN_CLUSTER_ID = 64561 @@ -1304,6 +1305,8 @@ class MMWaveControlId(t.enum8): Auto_generate_interference_area = 0x01 Obtain_areas = 0x02 Clear_interference_area = 0x03 + Reset_detection_area = 0x04 + Clear_stay_areas = 0x05 class InovelliVZM32SNMMWaveCluster(CustomCluster): @@ -1377,7 +1380,25 @@ class AttributeDefs(BaseAttributeDefs): ) class ServerCommandDefs(BaseCommandDefs): - """Server command definitions.""" + """Server command definitions. + + Commands 0x01-0x03 (set_{interference,detection,stay}_area) match + Z2M's zigbee-herdsman-converters inovelli.ts definitions for the + same cluster (setInterferenceArea / setDetectionArea / setStayArea). + Each command defines one of the device's four configurable areas, + identified by area_id (0-3), with min/max bounds in millimeters on + the x (width), y (depth), and z (height) axes. + + Note: firmware v1.00 has a reported bug on set_stay_area where the + x_min and x_max parameters are swapped and sign-inverted on write + (community report: + https://community.inovelli.com/t/zigbee-motion-switch-project-linus-bug-enhancement-thread/20438/251). + Callers who need asymmetric x-axis stay zones on v1.00 can + pre-compensate by sending x_min=-b, x_max=-a to end up with a + stored range of (a, b). Symmetric zones (x range [-n, +n]) are + self-correcting and need no compensation. Status of the bug in + v1.01/v1.02 beta firmware is undocumented. + """ mmwave_control_command = ZCLCommandDef( id=0x00, @@ -1386,6 +1407,117 @@ class ServerCommandDefs(BaseCommandDefs): }, is_manufacturer_specific=True, ) + set_interference_area = ZCLCommandDef( + id=0x01, + schema={ + "area_id": t.uint8_t, + "x_min": t.int16s, + "x_max": t.int16s, + "y_min": t.int16s, + "y_max": t.int16s, + "z_min": t.int16s, + "z_max": t.int16s, + }, + is_manufacturer_specific=True, + ) + set_detection_area = ZCLCommandDef( + id=0x02, + schema={ + "area_id": t.uint8_t, + "x_min": t.int16s, + "x_max": t.int16s, + "y_min": t.int16s, + "y_max": t.int16s, + "z_min": t.int16s, + "z_max": t.int16s, + }, + is_manufacturer_specific=True, + ) + set_stay_area = ZCLCommandDef( + id=0x03, + schema={ + "area_id": t.uint8_t, + "x_min": t.int16s, + "x_max": t.int16s, + "y_min": t.int16s, + "y_max": t.int16s, + "z_min": t.int16s, + "z_max": t.int16s, + }, + is_manufacturer_specific=True, + ) + + class ClientCommandDefs(BaseCommandDefs): + """Client command definitions. + + These are reports sent from the device to the coordinator. The three + area reports (report_interference_area / report_detection_area / + report_stay_area) are emitted in response to a server command_id=0x00 + with control_id=Obtain_areas; each returns all four configured areas + for that category plus a `count` of how many slots are populated. + + report_target_info streams live target positions when the + mmwave_target_info_report attribute (0x006B) is enabled; each target + in the `targets` list is an int16 (x, y, z, dop, target_id) tuple. + + anyone_in_reporting_area is emitted asynchronously on stay-area + occupancy transitions, giving per-area occupancy state. + + Names and payload layouts match zigbee-herdsman-converters' + `inovelli.ts` for the same cluster. + """ + + anyone_in_reporting_area = ZCLCommandDef( + id=0x00, + schema={ + "area_1": t.uint8_t, + "area_2": t.uint8_t, + "area_3": t.uint8_t, + "area_4": t.uint8_t, + }, + is_manufacturer_specific=True, + ) + report_target_info = ZCLCommandDef( + id=0x01, + schema={ + "target_num": t.uint8_t, + "targets": t.List[MMWaveTarget], + }, + is_manufacturer_specific=True, + ) + report_interference_area = ZCLCommandDef( + id=0x02, + schema={ + "count": t.uint8_t, + "area_1": MMWaveArea, + "area_2": MMWaveArea, + "area_3": MMWaveArea, + "area_4": MMWaveArea, + }, + is_manufacturer_specific=True, + ) + report_detection_area = ZCLCommandDef( + id=0x03, + schema={ + "count": t.uint8_t, + "area_1": MMWaveArea, + "area_2": MMWaveArea, + "area_3": MMWaveArea, + "area_4": MMWaveArea, + }, + is_manufacturer_specific=True, + ) + report_stay_area = ZCLCommandDef( + id=0x04, + schema={ + "count": t.uint8_t, + "area_1": MMWaveArea, + "area_2": MMWaveArea, + "area_3": MMWaveArea, + "area_4": MMWaveArea, + }, + is_manufacturer_specific=True, + ) class InovelliVZM35SNCluster(InovelliCluster): diff --git a/zhaquirks/inovelli/types.py b/zhaquirks/inovelli/types.py index 01f5d0ef14..5fde401e54 100644 --- a/zhaquirks/inovelli/types.py +++ b/zhaquirks/inovelli/types.py @@ -3,6 +3,36 @@ from zigpy import types +class MMWaveArea(types.Struct): + """A single mmWave sensor area definition. + + Used for interference, detection, and stay areas on the VZM32-SN. Bounds + are in millimeters on the x (width/left-right), y (depth/near-far), and + z (height/floor-ceiling) axes, relative to the switch. + """ + + x_min: types.int16s + x_max: types.int16s + y_min: types.int16s + y_max: types.int16s + z_min: types.int16s + z_max: types.int16s + + +class MMWaveTarget(types.Struct): + """A single detected target reported by the mmWave radar. + + Positions are in millimeters. dop is the Doppler velocity and target_id is + a device-assigned tracking identifier. + """ + + x: types.int16s + y: types.int16s + z: types.int16s + dop: types.int16s + target_id: types.int16s + + class AllLEDEffectType(types.enum8): """All LED effect type for Inovelli Blue Series switch.""" From 20aaa66190e142b65d851c88932dcb7a02856047 Mon Sep 17 00:00:00 2001 From: Thomas Mueller Date: Sat, 9 May 2026 08:24:05 -0500 Subject: [PATCH 2/3] Address Copilot review feedback on VZM32-SN mmWave commands - ServerCommandDefs docstring: clarify the area_id indexing convention rather than treating it as inconsistent. Wire-level area_id is 0-indexed (0..3) per inovelli.ts, while report payloads use area_1..area_4 field names to match Inovelli's user-facing 1-indexed labeling. Document the mapping explicitly so callers don't read it as an off-by-one bug. - test_vzm32_mmwave_report_target_info_variable_length: drop the schema-introspection trick that grabbed the compiled list type from report_target.fields. zigpy's Struct coerces a plain Python list into the t.List[MMWaveTarget] field, so the test no longer reaches into zigpy internals. --- tests/test_inovelli_blue.py | 10 ++++------ zhaquirks/inovelli/__init__.py | 11 ++++++++--- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/tests/test_inovelli_blue.py b/tests/test_inovelli_blue.py index 4005385f45..f7ad6464ae 100644 --- a/tests/test_inovelli_blue.py +++ b/tests/test_inovelli_blue.py @@ -114,15 +114,13 @@ def test_vzm32_mmwave_report_stay_area_roundtrip(): def test_vzm32_mmwave_report_target_info_variable_length(): """report_target_info (client 0x01) reads a list of 10-byte target structs.""" report_target = InovelliVZM32SNMMWaveCluster.ClientCommandDefs.report_target_info.with_compiled_schema().schema - # The compiled schema wraps t.List[MMWaveTarget] in an AnonymousList class. - targets_type = next(f.type for f in report_target.fields if f.name == "targets") - targets = targets_type( - [ + instance = report_target( + target_num=2, + targets=[ MMWaveTarget(x=100, y=200, z=-50, dop=5, target_id=1), MMWaveTarget(x=-100, y=300, z=0, dop=-3, target_id=2), - ] + ], ) - instance = report_target(target_num=2, targets=targets) raw = instance.serialize() # 1 byte target_num + 2 targets * 10 bytes assert len(raw) == 1 + 2 * 10 diff --git a/zhaquirks/inovelli/__init__.py b/zhaquirks/inovelli/__init__.py index 163012a059..0e38ba03f1 100644 --- a/zhaquirks/inovelli/__init__.py +++ b/zhaquirks/inovelli/__init__.py @@ -1385,9 +1385,14 @@ class ServerCommandDefs(BaseCommandDefs): Commands 0x01-0x03 (set_{interference,detection,stay}_area) match Z2M's zigbee-herdsman-converters inovelli.ts definitions for the same cluster (setInterferenceArea / setDetectionArea / setStayArea). - Each command defines one of the device's four configurable areas, - identified by area_id (0-3), with min/max bounds in millimeters on - the x (width), y (depth), and z (height) axes. + Each command defines one of the device's four configurable areas. + The wire-level area_id is 0-indexed (0..3) per inovelli.ts, while the + corresponding report payloads (report_interference_area / + report_detection_area / report_stay_area in ClientCommandDefs) expose + the same areas as fields named area_1..area_4 to match Inovelli's + user-facing 1-indexed labeling. Mapping: area_id=0 -> area_1, ..., + area_id=3 -> area_4. Bounds are in millimeters on the x (width), + y (depth), and z (height) axes. Note: firmware v1.00 has a reported bug on set_stay_area where the x_min and x_max parameters are swapped and sign-inverted on write From 5052ba856ba673fc6b202eef5d78f978bb64f81f Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 29 Jul 2026 06:06:08 +0200 Subject: [PATCH 3/3] Fix MMWaveTarget target_id wire format to int8s Each target in a report_target_info frame is a 9-byte record (x/y/z/dop as int16 plus id as int8, per the Inovelli cluster docs and Z2M's converter stride). With int16s the struct was 10 bytes, misaligning every target after the first in multi-target reports. Pin the exact wire layout in the test so the format is no longer roundtrip-only. --- tests/test_inovelli_blue.py | 11 ++++++++--- zhaquirks/inovelli/types.py | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/test_inovelli_blue.py b/tests/test_inovelli_blue.py index f7ad6464ae..daf5075f47 100644 --- a/tests/test_inovelli_blue.py +++ b/tests/test_inovelli_blue.py @@ -1,5 +1,6 @@ """Tests for inovelli blue series manufacturer cluster.""" +import struct from unittest import mock from unittest.mock import MagicMock @@ -112,7 +113,7 @@ def test_vzm32_mmwave_report_stay_area_roundtrip(): def test_vzm32_mmwave_report_target_info_variable_length(): - """report_target_info (client 0x01) reads a list of 10-byte target structs.""" + """report_target_info (client 0x01) reads a list of 9-byte target structs.""" report_target = InovelliVZM32SNMMWaveCluster.ClientCommandDefs.report_target_info.with_compiled_schema().schema instance = report_target( target_num=2, @@ -122,8 +123,12 @@ def test_vzm32_mmwave_report_target_info_variable_length(): ], ) raw = instance.serialize() - # 1 byte target_num + 2 targets * 10 bytes - assert len(raw) == 1 + 2 * 10 + # 1 byte target_num + 2 targets * 9 bytes (x/y/z/dop int16 + id int8, + # matching Z2M's stride for this report) + assert len(raw) == 1 + 2 * 9 + assert raw == bytes([2]) + struct.pack("