Skip to content
Open
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
49 changes: 49 additions & 0 deletions tests/test_ikea.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,3 +250,52 @@ def mock_read(attributes, manufacturer=None):
# check log output if we expect a warning
if expect_log_warning:
assert f"sw_build_id is not a number: {firmware} for device" in caplog.text


@pytest.mark.parametrize(
"move_cmd,move_mode,stop_cmd,expected_event",
[
(None, None, 0x07, None), # stop without prior move
(0x05, 0, 0x07, "move_up_release"), # move up + stop (0x07)
(0x05, 0, 0x03, "move_up_release"), # move up + stop (0x03)
(0x01, 1, 0x03, "move_down_release"), # move down + stop (0x03)
(0x01, 1, 0x07, "move_down_release"), # move down + stop (0x07)
],
)
async def test_bilresa_direction_tracking(
move_cmd, move_mode, stop_cmd, expected_event
):
"""Test Bilresa remote direction tracking for long press releases."""
from unittest.mock import patch

from zhaquirks.ikea import IkeaBilresaLevelControl

# Create a mock endpoint
class MockEndpoint:
def __init__(self):
self.device = None
self.endpoint_id = 1

endpoint = MockEndpoint()
level_cluster = IkeaBilresaLevelControl(endpoint, is_server=False)

# Mock listener_event to capture event calls
with patch.object(level_cluster, "listener_event") as mock_listener:
# Send move command if provided
if move_cmd is not None:
hdr_move = foundation.ZCLHeader.cluster(tsn=1, command_id=move_cmd)
level_cluster.handle_cluster_request(hdr_move, [move_mode, 83])

# Send stop command
hdr_stop = foundation.ZCLHeader.cluster(tsn=2, command_id=stop_cmd)
level_cluster.handle_cluster_request(hdr_stop, [])

# Verify expected event
if expected_event is None:
mock_listener.assert_not_called()
else:
mock_listener.assert_called_once()
args = mock_listener.call_args[0]
assert args[0] == "zha_send_event"
assert args[1] == expected_event
assert args[2] == []
38 changes: 36 additions & 2 deletions zhaquirks/ikea/__init__.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
"""Ikea module."""

import logging
from typing import Any, Optional, Union

from zigpy.quirks import CustomCluster
import zigpy.types as t
from zigpy.zcl import foundation
from zigpy.zcl.clusters.general import Basic, PowerConfiguration, Scenes
from zigpy.zcl.clusters.general import Basic, LevelControl, PowerConfiguration, Scenes
from zigpy.zcl.foundation import BaseCommandDefs

from zhaquirks import EventableCluster
from zhaquirks.const import BatterySize
from zhaquirks.const import ZHA_SEND_EVENT, BatterySize

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -52,6 +53,39 @@ class ServerCommandDefs(Scenes.ServerCommandDefs):
)


class IkeaBilresaLevelControl(CustomCluster, LevelControl):
"""Custom LevelControl cluster for IKEA remotes to track direction."""

def __init__(self, *args, **kwargs):
"""Initialize instance."""
super().__init__(*args, **kwargs)
self._last_move_direction = None

def handle_cluster_request(
self,
hdr: foundation.ZCLHeader,
args: list[Any],
*,
dst_addressing: Optional[
Union[t.Addressing.Group, t.Addressing.IEEE, t.Addressing.NWK]
] = None,
) -> None:
"""Handle cluster specific commands.

Track move commands to remember direction for stop commands
"""
if hdr.command_id in (0x01, 0x05):
move_mode = args[0]
self._last_move_direction = move_mode
elif hdr.command_id in (0x03, 0x07) and self._last_move_direction is not None:
event = (
"move_up_release"
if self._last_move_direction == 0
else "move_down_release"
)
self.listener_event(ZHA_SEND_EVENT, event, [])


class ShortcutV1Cluster(EventableCluster):
"""Ikea Shortcut Button Cluster Variant 1."""

Expand Down
92 changes: 92 additions & 0 deletions zhaquirks/ikea/bilresa2btn.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""IKEA Bilresa 2 button remote control."""

from zigpy.quirks.v2 import CustomDeviceV2, QuirkBuilder
from zigpy.zcl import ClusterType

from zhaquirks.const import (
CLUSTER_ID,
COMMAND,
COMMAND_MOVE,
COMMAND_OFF,
COMMAND_ON,
COMMAND_PRESS,
DIM_DOWN,
DIM_UP,
DOUBLE_PRESS,
ENDPOINT_ID,
LONG_PRESS,
LONG_RELEASE,
PARAMS,
SHORT_PRESS,
TURN_OFF,
TURN_ON,
)
from zhaquirks.ikea import IKEA, IkeaBilresaLevelControl, ScenesCluster


class IkeaBilresa2ButtonRemote(CustomDeviceV2):
"""Custom device for IKEA Bilresa 2 button remote."""


(
QuirkBuilder(IKEA, "09B9")
.replaces(ScenesCluster, cluster_type=ClusterType.Client)
.replace_cluster_occurrences(IkeaBilresaLevelControl)
.device_automation_triggers(
{
(SHORT_PRESS, TURN_ON): {
COMMAND: COMMAND_ON,
CLUSTER_ID: 6,
ENDPOINT_ID: 1,
},
(LONG_PRESS, DIM_UP): {
COMMAND: COMMAND_MOVE,
CLUSTER_ID: 8,
ENDPOINT_ID: 1,
PARAMS: {"move_mode": 0},
},
(LONG_RELEASE, DIM_UP): {
COMMAND: "move_up_release",
CLUSTER_ID: 8,
ENDPOINT_ID: 1,
},
(SHORT_PRESS, TURN_OFF): {
COMMAND: COMMAND_OFF,
CLUSTER_ID: 6,
ENDPOINT_ID: 1,
},
(LONG_PRESS, DIM_DOWN): {
COMMAND: COMMAND_MOVE,
CLUSTER_ID: 8,
ENDPOINT_ID: 1,
PARAMS: {"move_mode": 1},
},
(LONG_RELEASE, DIM_DOWN): {
COMMAND: "move_down_release",
CLUSTER_ID: 8,
ENDPOINT_ID: 1,
},
(DOUBLE_PRESS, DIM_UP): {
COMMAND: COMMAND_PRESS,
CLUSTER_ID: 5,
ENDPOINT_ID: 1,
PARAMS: {
"param1": 256,
"param2": 13,
"param3": 0,
},
},
(DOUBLE_PRESS, DIM_DOWN): {
COMMAND: COMMAND_PRESS,
CLUSTER_ID: 5,
ENDPOINT_ID: 1,
PARAMS: {
"param1": 257,
"param2": 13,
"param3": 0,
},
},
}
)
.add_to_registry()
)
Loading