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
82 changes: 82 additions & 0 deletions tests/test_inovelli_blue.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -62,3 +64,83 @@ 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 10-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 * 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
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.int16s

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.

target_id should very likely be types.int8s, not int16s. Z2M's report_target_info converter in inovelli.ts reads each target as a 9-byte record (x/y/z/dop int16 + id int8, stride = 9, "Per Inovelli cluster docs"). At int16s this struct is 10 bytes, so multi-target reports misalign after the first target. Please verify against a real report_target_info capture (attribute 0x006B mmwave_target_info_report must be enabled) — the roundtrip test can't catch this since it uses the same struct on both sides.



class AllLEDEffectType(types.enum8):
"""All LED effect type for Inovelli Blue Series switch."""

Expand Down
Loading