diff --git a/tests/test_inovelli_blue.py b/tests/test_inovelli_blue.py index 26e6608735..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 @@ -7,6 +8,8 @@ from zigpy.zcl import ClusterType import zhaquirks +from zhaquirks.inovelli import InovelliVZM32SNMMWaveCluster +from zhaquirks.inovelli.types import MMWaveArea, MMWaveTarget zhaquirks.setup() @@ -62,3 +65,87 @@ 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 9-byte target structs.""" + report_target = InovelliVZM32SNMMWaveCluster.ClientCommandDefs.report_target_info.with_compiled_schema().schema + 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), + ], + ) + raw = instance.serialize() + # 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(" 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 + (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 +1412,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..395e300bc7 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.int8s + + class AllLEDEffectType(types.enum8): """All LED effect type for Inovelli Blue Series switch."""