Skip to content

Commit cc9878a

Browse files
committed
feat: implement RTS duration injection in execute_action_group (#766)
Add _apply_rts_duration method that inspects each action's device URL protocol and appends rts_command_duration to RTS commands whose CommandDefinition.nparams allows an extra parameter. Original actions are never mutated. This replaces the hardcoded COMMANDS_WITHOUT_DELAY exclusion list previously needed in Home Assistant.
1 parent 11f5103 commit cc9878a

2 files changed

Lines changed: 308 additions & 1 deletion

File tree

pyoverkiz/client.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from pyoverkiz.client_settings import OverkizClientSettings
2727
from pyoverkiz.const import SUPPORTED_SERVERS
2828
from pyoverkiz.converter import converter
29-
from pyoverkiz.enums import APIType, ExecutionMode, Server
29+
from pyoverkiz.enums import APIType, ExecutionMode, Protocol, Server
3030
from pyoverkiz.exceptions import (
3131
ExecutionQueueFullError,
3232
InvalidEventListenerIdError,
@@ -39,6 +39,7 @@
3939
)
4040
from pyoverkiz.models import (
4141
Action,
42+
Command,
4243
Device,
4344
Event,
4445
Execution,
@@ -490,6 +491,48 @@ async def get_api_version(self) -> str:
490491

491492
return cast(str, response["protocolVersion"])
492493

494+
def _apply_rts_duration(self, actions: list[Action]) -> list[Action]:
495+
"""Append rts_command_duration to RTS commands that accept an extra parameter.
496+
497+
Returns a new list of actions with modified commands where applicable.
498+
The original actions are not mutated.
499+
"""
500+
if self._rts_command_duration is None:
501+
return actions
502+
503+
device_index: dict[str, Device] = {d.device_url: d for d in self.devices}
504+
505+
result: list[Action] = []
506+
for action in actions:
507+
device = device_index.get(action.device_url)
508+
509+
if device is None or device.identifier.protocol != Protocol.RTS:
510+
result.append(action)
511+
continue
512+
513+
new_commands: list[Command] = []
514+
for cmd in action.commands:
515+
cmd_def = device.get_command_definition(str(cmd.name))
516+
current_count = len(cmd.parameters) if cmd.parameters else 0
517+
518+
if cmd_def and current_count < cmd_def.nparams:
519+
new_commands.append(
520+
Command(
521+
name=cmd.name,
522+
parameters=[
523+
*(cmd.parameters or []),
524+
self._rts_command_duration,
525+
],
526+
type=cmd.type,
527+
)
528+
)
529+
else:
530+
new_commands.append(cmd)
531+
532+
result.append(Action(device_url=action.device_url, commands=new_commands))
533+
534+
return result
535+
493536
@retry_on_too_many_executions
494537
@retry_on_auth_error
495538
async def _execute_action_group_direct(
@@ -540,6 +583,8 @@ async def execute_action_group(
540583
Returns:
541584
The ``exec_id`` identifying the execution on the server.
542585
"""
586+
actions = self._apply_rts_duration(actions)
587+
543588
if self._action_queue:
544589
queued = await self._action_queue.add(actions, mode, label)
545590
return await queued

tests/test_rts_injection.py

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
# tests/test_rts_injection.py
2+
"""Tests for RTS command duration injection in execute_action_group."""
3+
4+
from __future__ import annotations
5+
6+
from unittest.mock import AsyncMock, patch
7+
8+
import pytest
9+
import pytest_asyncio
10+
11+
from pyoverkiz.auth.credentials import UsernamePasswordCredentials
12+
from pyoverkiz.client import OverkizClient
13+
from pyoverkiz.client_settings import OverkizClientSettings
14+
from pyoverkiz.enums import ProductType, Server
15+
from pyoverkiz.models import (
16+
Action,
17+
Command,
18+
CommandDefinition,
19+
CommandDefinitions,
20+
Definition,
21+
Device,
22+
States,
23+
)
24+
25+
26+
def _rts_device(
27+
device_url: str = "rts://1234-5678-9012/1",
28+
commands: list[CommandDefinition] | None = None,
29+
) -> Device:
30+
"""Create a minimal RTS Device for testing."""
31+
if commands is None:
32+
commands = [
33+
CommandDefinition(command_name="close", nparams=1),
34+
CommandDefinition(command_name="open", nparams=1),
35+
CommandDefinition(command_name="identify", nparams=0),
36+
]
37+
return Device(
38+
attributes=States(),
39+
available=True,
40+
enabled=True,
41+
label="RTS Blind",
42+
device_url=device_url,
43+
controllable_name="rts:blind",
44+
definition=Definition(
45+
commands=CommandDefinitions(commands),
46+
widget_name="SomeWidget",
47+
ui_class="RollerShutter",
48+
),
49+
type=ProductType.ACTUATOR,
50+
)
51+
52+
53+
def _io_device(device_url: str = "io://1234-5678-9012/2") -> Device:
54+
"""Create a minimal IO device for testing."""
55+
return Device(
56+
attributes=States(),
57+
available=True,
58+
enabled=True,
59+
label="IO Blind",
60+
device_url=device_url,
61+
controllable_name="io:blind",
62+
definition=Definition(
63+
commands=CommandDefinitions(
64+
[CommandDefinition(command_name="close", nparams=1)]
65+
),
66+
widget_name="SomeWidget",
67+
ui_class="RollerShutter",
68+
),
69+
type=ProductType.ACTUATOR,
70+
)
71+
72+
73+
@pytest_asyncio.fixture
74+
async def client_with_rts() -> OverkizClient:
75+
"""Client with RTS duration enabled."""
76+
return OverkizClient(
77+
server=Server.SOMFY_EUROPE,
78+
credentials=UsernamePasswordCredentials("user", "pass"),
79+
settings=OverkizClientSettings(rts_command_duration=0),
80+
)
81+
82+
83+
@pytest_asyncio.fixture
84+
async def client_without_rts() -> OverkizClient:
85+
"""Client without RTS duration (default behavior)."""
86+
return OverkizClient(
87+
server=Server.SOMFY_EUROPE,
88+
credentials=UsernamePasswordCredentials("user", "pass"),
89+
)
90+
91+
92+
@pytest.mark.asyncio
93+
async def test_rts_device_gets_duration_appended(client_with_rts):
94+
"""RTS device command with room for extra param gets duration appended."""
95+
client_with_rts.devices = [_rts_device()]
96+
97+
action = Action(
98+
device_url="rts://1234-5678-9012/1",
99+
commands=[Command(name="close")],
100+
)
101+
102+
with patch.object(
103+
client_with_rts, "_execute_action_group_direct", new_callable=AsyncMock
104+
) as mock_exec:
105+
mock_exec.return_value = "exec-123"
106+
await client_with_rts.execute_action_group([action])
107+
108+
called_actions = mock_exec.call_args[0][0]
109+
assert called_actions[0].commands[0].parameters == [0]
110+
111+
112+
@pytest.mark.asyncio
113+
async def test_rts_command_already_has_max_params_not_modified(client_with_rts):
114+
"""RTS command that already has nparams parameters is not modified."""
115+
client_with_rts.devices = [_rts_device()]
116+
117+
action = Action(
118+
device_url="rts://1234-5678-9012/1",
119+
commands=[Command(name="close", parameters=[50])],
120+
)
121+
122+
with patch.object(
123+
client_with_rts, "_execute_action_group_direct", new_callable=AsyncMock
124+
) as mock_exec:
125+
mock_exec.return_value = "exec-123"
126+
await client_with_rts.execute_action_group([action])
127+
128+
called_actions = mock_exec.call_args[0][0]
129+
# nparams=1 and already has 1 param — should NOT add another
130+
assert called_actions[0].commands[0].parameters == [50]
131+
132+
133+
@pytest.mark.asyncio
134+
async def test_rts_command_with_zero_nparams_not_modified(client_with_rts):
135+
"""RTS command with nparams=0 (e.g., identify) is not modified."""
136+
client_with_rts.devices = [_rts_device()]
137+
138+
action = Action(
139+
device_url="rts://1234-5678-9012/1",
140+
commands=[Command(name="identify")],
141+
)
142+
143+
with patch.object(
144+
client_with_rts, "_execute_action_group_direct", new_callable=AsyncMock
145+
) as mock_exec:
146+
mock_exec.return_value = "exec-123"
147+
await client_with_rts.execute_action_group([action])
148+
149+
called_actions = mock_exec.call_args[0][0]
150+
assert called_actions[0].commands[0].parameters is None
151+
152+
153+
@pytest.mark.asyncio
154+
async def test_io_device_not_modified(client_with_rts):
155+
"""Non-RTS device commands are never modified, even with rts_command_duration set."""
156+
client_with_rts.devices = [_io_device()]
157+
158+
action = Action(
159+
device_url="io://1234-5678-9012/2",
160+
commands=[Command(name="close")],
161+
)
162+
163+
with patch.object(
164+
client_with_rts, "_execute_action_group_direct", new_callable=AsyncMock
165+
) as mock_exec:
166+
mock_exec.return_value = "exec-123"
167+
await client_with_rts.execute_action_group([action])
168+
169+
called_actions = mock_exec.call_args[0][0]
170+
assert called_actions[0].commands[0].parameters is None
171+
172+
173+
@pytest.mark.asyncio
174+
async def test_no_rts_setting_means_no_injection(client_without_rts):
175+
"""When rts_command_duration is None, no injection happens for any device."""
176+
client_without_rts.devices = [_rts_device()]
177+
178+
action = Action(
179+
device_url="rts://1234-5678-9012/1",
180+
commands=[Command(name="close")],
181+
)
182+
183+
with patch.object(
184+
client_without_rts, "_execute_action_group_direct", new_callable=AsyncMock
185+
) as mock_exec:
186+
mock_exec.return_value = "exec-123"
187+
await client_without_rts.execute_action_group([action])
188+
189+
called_actions = mock_exec.call_args[0][0]
190+
assert called_actions[0].commands[0].parameters is None
191+
192+
193+
@pytest.mark.asyncio
194+
async def test_rts_device_not_in_devices_list_skipped(client_with_rts):
195+
"""If device URL is not in client.devices, skip injection (no crash)."""
196+
client_with_rts.devices = [] # No devices loaded
197+
198+
action = Action(
199+
device_url="rts://1234-5678-9012/1",
200+
commands=[Command(name="close")],
201+
)
202+
203+
with patch.object(
204+
client_with_rts, "_execute_action_group_direct", new_callable=AsyncMock
205+
) as mock_exec:
206+
mock_exec.return_value = "exec-123"
207+
await client_with_rts.execute_action_group([action])
208+
209+
called_actions = mock_exec.call_args[0][0]
210+
# Not modified — device not found, so we can't know nparams
211+
assert called_actions[0].commands[0].parameters is None
212+
213+
214+
@pytest.mark.asyncio
215+
async def test_rts_device_without_definition_skipped(client_with_rts):
216+
"""RTS device without definition doesn't crash, just skips injection."""
217+
device = Device(
218+
attributes=States(),
219+
available=True,
220+
enabled=True,
221+
label="RTS No Def",
222+
device_url="rts://1234-5678-9012/1",
223+
controllable_name="rts:blind",
224+
definition=Definition(widget_name="SomeWidget", ui_class="RollerShutter"),
225+
type=ProductType.ACTUATOR,
226+
)
227+
client_with_rts.devices = [device]
228+
229+
action = Action(
230+
device_url="rts://1234-5678-9012/1",
231+
commands=[Command(name="close")],
232+
)
233+
234+
with patch.object(
235+
client_with_rts, "_execute_action_group_direct", new_callable=AsyncMock
236+
) as mock_exec:
237+
mock_exec.return_value = "exec-123"
238+
await client_with_rts.execute_action_group([action])
239+
240+
called_actions = mock_exec.call_args[0][0]
241+
assert called_actions[0].commands[0].parameters is None
242+
243+
244+
@pytest.mark.asyncio
245+
async def test_original_action_not_mutated(client_with_rts):
246+
"""Injection creates new Command objects; original actions are not mutated."""
247+
client_with_rts.devices = [_rts_device()]
248+
249+
original_cmd = Command(name="close")
250+
action = Action(
251+
device_url="rts://1234-5678-9012/1",
252+
commands=[original_cmd],
253+
)
254+
255+
with patch.object(
256+
client_with_rts, "_execute_action_group_direct", new_callable=AsyncMock
257+
) as mock_exec:
258+
mock_exec.return_value = "exec-123"
259+
await client_with_rts.execute_action_group([action])
260+
261+
# Original command should NOT have been mutated
262+
assert original_cmd.parameters is None

0 commit comments

Comments
 (0)