Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
87 changes: 87 additions & 0 deletions tests/test_inovelli_blue.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
"""Tests for inovelli blue series manufacturer cluster."""

import struct
from unittest import mock
from unittest.mock import MagicMock

import zigpy.types as t
from zigpy.zcl import ClusterType

import zhaquirks
from zhaquirks.inovelli import InovelliVZM32SNMMWaveCluster
from zhaquirks.inovelli.types import MMWaveArea, MMWaveTarget

zhaquirks.setup()

Expand Down Expand Up @@ -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
Comment on lines +70 to +92

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.

We don't really need all these tests as this somewhat just tests zigpy itself but it's also fine to keep them for now.



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("<hhhhb", 100, 200, -50, 5, 1) + struct.pack(
"<hhhhb", -100, 300, 0, -3, 2
)

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
139 changes: 138 additions & 1 deletion zhaquirks/inovelli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -1377,7 +1380,30 @@ 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.
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
(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,
Expand All @@ -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):
Expand Down
30 changes: 30 additions & 0 deletions zhaquirks/inovelli/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
Loading