Skip to content

Commit d2b1cb4

Browse files
committed
Merge branch 'v2/main' of github.com:iMicknl/python-overkiz-api into v2/action_queue_improvements
2 parents 51505d9 + d11288e commit d2b1cb4

10 files changed

Lines changed: 511 additions & 35 deletions

docs/action-queue.md

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,22 @@ Important limitation:
1111

1212
## Enable with defaults
1313

14-
Set `action_queue=True` to enable batching with default settings:
14+
Pass `ActionQueueSettings()` via `OverkizClientSettings` to enable batching with default settings:
1515

1616
```python
1717
import asyncio
1818

19+
from pyoverkiz.action_queue import ActionQueueSettings
1920
from pyoverkiz.auth import UsernamePasswordCredentials
2021
from pyoverkiz.client import OverkizClient
22+
from pyoverkiz.client import OverkizClientSettings
2123
from pyoverkiz.enums import OverkizCommand, Server
2224
from pyoverkiz.models import Action, Command
2325

2426
client = OverkizClient(
2527
server=Server.SOMFY_EUROPE,
2628
credentials=UsernamePasswordCredentials("user@example.com", "password"),
27-
action_queue=True, # uses defaults
29+
settings=OverkizClientSettings(action_queue=ActionQueueSettings()),
2830
)
2931

3032
action1 = Action(
@@ -49,23 +51,26 @@ Defaults:
4951

5052
## Advanced settings
5153

52-
If you need to tune batching behavior, pass `ActionQueueSettings`:
54+
If you need to tune batching behavior, pass custom values to `ActionQueueSettings`:
5355

5456
```python
5557
import asyncio
5658

5759
from pyoverkiz.action_queue import ActionQueueSettings
5860
from pyoverkiz.client import OverkizClient
61+
from pyoverkiz.client import OverkizClientSettings
5962
from pyoverkiz.auth import UsernamePasswordCredentials
6063
from pyoverkiz.enums import OverkizCommand, Server
6164
from pyoverkiz.models import Action, Command
6265

6366
client = OverkizClient(
6467
server=Server.SOMFY_EUROPE,
6568
credentials=UsernamePasswordCredentials("user@example.com", "password"),
66-
action_queue=ActionQueueSettings(
67-
delay=0.5, # seconds to wait before auto-flush
68-
max_actions=20, # auto-flush when this count is reached
69+
settings=OverkizClientSettings(
70+
action_queue=ActionQueueSettings(
71+
delay=0.5, # seconds to wait before auto-flush
72+
max_actions=20, # auto-flush when this count is reached
73+
),
6974
),
7075
)
7176
```
@@ -75,18 +80,21 @@ client = OverkizClient(
7580
Normally, queued actions are sent after the delay window or when `max_actions` is reached. Call `flush_action_queue()` to force the queue to execute immediately, which is useful when you want to send any pending actions without waiting for the delay timer to expire.
7681

7782
```python
78-
from pyoverkiz.action_queue import ActionQueueSettings
7983
import asyncio
8084

85+
from pyoverkiz.action_queue import ActionQueueSettings
8186
from pyoverkiz.client import OverkizClient
87+
from pyoverkiz.client import OverkizClientSettings
8288
from pyoverkiz.auth import UsernamePasswordCredentials
8389
from pyoverkiz.enums import OverkizCommand, Server
8490
from pyoverkiz.models import Action, Command
8591

8692
client = OverkizClient(
8793
server=Server.SOMFY_EUROPE,
8894
credentials=UsernamePasswordCredentials("user@example.com", "password"),
89-
action_queue=ActionQueueSettings(delay=10.0), # long delay
95+
settings=OverkizClientSettings(
96+
action_queue=ActionQueueSettings(delay=10.0), # long delay
97+
),
9098
)
9199

92100
action = Action(
@@ -115,15 +123,17 @@ Why this matters:
115123
`get_pending_actions_count()` returns a snapshot of how many actions are currently queued. Because the queue can change concurrently (and the method does not acquire the queue lock), the value is approximate. Use it for logging, diagnostics, or UI hints—not for critical control flow.
116124

117125
```python
126+
from pyoverkiz.action_queue import ActionQueueSettings
118127
from pyoverkiz.client import OverkizClient
128+
from pyoverkiz.client import OverkizClientSettings
119129
from pyoverkiz.auth import UsernamePasswordCredentials
120130
from pyoverkiz.enums import OverkizCommand, Server
121131
from pyoverkiz.models import Action, Command
122132

123133
client = OverkizClient(
124134
server=Server.SOMFY_EUROPE,
125135
credentials=UsernamePasswordCredentials("user@example.com", "password"),
126-
action_queue=True,
136+
settings=OverkizClientSettings(action_queue=ActionQueueSettings()),
127137
)
128138

129139
action = Action(

docs/device-control.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,19 @@ firmware = device.select_first_attribute_value([
123123
print(f"Firmware: {firmware}")
124124
```
125125

126+
#### Get command definition
127+
128+
```python
129+
devices = await client.get_devices()
130+
device = devices[0]
131+
132+
# Get the command definition for a single command
133+
cmd_def = device.get_command_definition(OverkizCommand.OPEN)
134+
if cmd_def:
135+
print(f"Command: {cmd_def.command_name}")
136+
print(f"Number of parameters: {cmd_def.nparams}")
137+
```
138+
126139
#### Access device identifier
127140

128141
Device URLs are automatically parsed into structured identifier components for easier access:
@@ -278,6 +291,25 @@ trigger_id = await client.schedule_persisted_action_group(
278291
)
279292
```
280293

294+
## RTS command duration
295+
296+
RTS devices have a default execution duration of 30 seconds, which blocks consecutive commands until the duration expires. To avoid this, you can configure `rts_command_duration` in `OverkizClientSettings`. The client will automatically inject the configured duration into RTS commands that support it, based on the command definition (`nparams`).
297+
298+
```python
299+
from pyoverkiz.auth.credentials import UsernamePasswordCredentials
300+
from pyoverkiz.client import OverkizClient
301+
from pyoverkiz.client import OverkizClientSettings
302+
from pyoverkiz.enums import Server
303+
304+
client = OverkizClient(
305+
server=Server.SOMFY_EUROPE,
306+
credentials=UsernamePasswordCredentials("user@example.com", "password"),
307+
settings=OverkizClientSettings(rts_command_duration=0),
308+
)
309+
```
310+
311+
With `rts_command_duration=0`, the execution duration is set to 0 seconds for supported commands, allowing consecutive commands to be sent without delay. Commands that don't accept a duration parameter (like `identify` or `test`) are left unchanged.
312+
281313
## Limitations and rate limits
282314

283315
Gateways impose limits on how many executions can run or be queued simultaneously. If the execution queue is full, the API will raise an `ExecutionQueueFullError`. Most gateways allow up to 10 concurrent executions.

docs/migration-v2.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,10 @@ Several enum members have been renamed for consistent `UPPER_SNAKE_CASE` or to f
291291

292292
These are not breaking, but worth knowing about when migrating:
293293

294+
- **Client settings** — behavioral configuration is now grouped in `OverkizClientSettings`, passed via the `settings` parameter. This replaces standalone constructor parameters like `action_queue`.
294295
- **Action queue** — batch device executions automatically. See the [action queue guide](action-queue.md).
296+
- **RTS command duration** — automatically inject execution duration into RTS commands to prevent the default 30-second blocking behavior. See [RTS command duration](device-control.md#rts-command-duration).
297+
- **Device helpers**`Device.get_command_definition()` for looking up command metadata.
295298
- **Reference endpoints** — query server metadata: `get_reference_ui_classes()`, `get_reference_ui_widgets()`, `get_reference_ui_profile()`, `get_reference_controllable_types()`, etc.
296299
- **Firmware management**`get_devices_not_up_to_date()`, `get_device_firmware_status()`, `update_device_firmware()`.
297300
- **boto3 lazy import**`boto3` is only imported when the Nexity auth strategy is actually used.

pyoverkiz/client.py

Lines changed: 67 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import logging
77
import ssl
88
import urllib.parse
9+
from dataclasses import dataclass
910
from http import HTTPStatus
1011
from pathlib import Path
1112
from types import TracebackType
@@ -25,7 +26,7 @@
2526
from pyoverkiz.auth import AuthStrategy, Credentials, build_auth_strategy
2627
from pyoverkiz.const import SUPPORTED_SERVERS
2728
from pyoverkiz.converter import converter
28-
from pyoverkiz.enums import APIType, ExecutionMode, Server
29+
from pyoverkiz.enums import APIType, ExecutionMode, Protocol, Server
2930
from pyoverkiz.exceptions import (
3031
ExecutionQueueFullError,
3132
InvalidEventListenerIdError,
@@ -38,6 +39,7 @@
3839
)
3940
from pyoverkiz.models import (
4041
Action,
42+
Command,
4143
Device,
4244
Event,
4345
Execution,
@@ -160,6 +162,17 @@ def _create_local_ssl_context() -> ssl.SSLContext:
160162
SSL_CONTEXT_LOCAL_API = _create_local_ssl_context()
161163

162164

165+
@dataclass(frozen=True, slots=True)
166+
class OverkizClientSettings:
167+
"""Behavioral configuration for OverkizClient.
168+
169+
All fields are optional and default to passive behavior.
170+
"""
171+
172+
action_queue: ActionQueueSettings | None = None
173+
rts_command_duration: int | None = None
174+
175+
163176
class OverkizClient:
164177
"""Interface class for the Overkiz API."""
165178

@@ -172,6 +185,7 @@ class OverkizClient:
172185
_ssl: ssl.SSLContext | bool = True
173186
_auth: AuthStrategy
174187
_action_queue: ActionQueue | None = None
188+
settings: OverkizClientSettings
175189

176190
def __init__(
177191
self,
@@ -180,15 +194,15 @@ def __init__(
180194
credentials: Credentials,
181195
verify_ssl: bool = True,
182196
session: ClientSession | None = None,
183-
action_queue: bool | ActionQueueSettings = False,
197+
settings: OverkizClientSettings | None = None,
184198
) -> None:
185199
"""Constructor.
186200
187201
:param server: ServerConfig
188202
:param credentials: Credentials for authentication
189203
:param verify_ssl: Enable SSL certificate verification
190204
:param session: optional ClientSession
191-
:param action_queue: enable batching or provide queue settings (default False)
205+
:param settings: behavioral settings for the client (default None)
192206
"""
193207
self.server_config = self._normalize_server(server)
194208

@@ -206,23 +220,13 @@ def __init__(
206220
# Use the prebuilt SSL context with disabled strict validation for local API.
207221
self._ssl = SSL_CONTEXT_LOCAL_API
208222

209-
# Initialize action queue if enabled
210-
queue_settings: ActionQueueSettings | None
211-
if isinstance(action_queue, ActionQueueSettings):
212-
queue_settings = action_queue
213-
elif isinstance(action_queue, bool):
214-
queue_settings = ActionQueueSettings() if action_queue else None
215-
else:
216-
raise TypeError(
217-
"action_queue must be a bool or ActionQueueSettings, "
218-
f"got {type(action_queue).__name__}"
219-
)
223+
self.settings = settings or OverkizClientSettings()
220224

221-
if queue_settings:
222-
queue_settings.validate()
225+
if self.settings.action_queue:
226+
self.settings.action_queue.validate()
223227
self._action_queue = ActionQueue(
224228
executor=self._execute_action_group_direct,
225-
settings=queue_settings,
229+
settings=self.settings.action_queue,
226230
)
227231

228232
self._auth = build_auth_strategy(
@@ -494,6 +498,50 @@ async def get_api_version(self) -> str:
494498

495499
return cast(str, response["protocolVersion"])
496500

501+
def _apply_rts_duration(self, actions: list[Action]) -> list[Action]:
502+
"""Set the execution duration for RTS commands that support it.
503+
504+
The default execution duration for RTS devices is 30 seconds, which
505+
blocks consecutive commands. This injects the configured duration
506+
(typically 0) into commands that accept it, based on the device
507+
command definition (nparams).
508+
"""
509+
duration = self.settings.rts_command_duration
510+
if duration is None:
511+
return actions
512+
513+
device_index: dict[str, Device] = {d.device_url: d for d in self.devices}
514+
515+
result: list[Action] = []
516+
for action in actions:
517+
device = device_index.get(action.device_url)
518+
519+
if device is None or device.identifier.protocol != Protocol.RTS:
520+
result.append(action)
521+
continue
522+
523+
updated_commands: list[Command] = []
524+
for cmd in action.commands:
525+
cmd_def = device.get_command_definition(str(cmd.name))
526+
current_count = len(cmd.parameters) if cmd.parameters else 0
527+
528+
if cmd_def and current_count < cmd_def.nparams:
529+
updated_commands.append(
530+
Command(
531+
name=cmd.name,
532+
parameters=[*(cmd.parameters or []), duration],
533+
type=cmd.type,
534+
)
535+
)
536+
else:
537+
updated_commands.append(cmd)
538+
539+
result.append(
540+
Action(device_url=action.device_url, commands=updated_commands)
541+
)
542+
543+
return result
544+
497545
@retry_on_too_many_executions
498546
@retry_on_auth_error
499547
async def _execute_action_group_direct(
@@ -544,6 +592,8 @@ async def execute_action_group(
544592
Returns:
545593
The ``exec_id`` identifying the execution on the server.
546594
"""
595+
actions = self._apply_rts_duration(actions)
596+
547597
if self._action_queue:
548598
queued = await self._action_queue.add(actions, mode, label)
549599
return await queued

pyoverkiz/models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,14 @@ def select_first_command(self, commands: list[str | OverkizCommand]) -> str | No
440440
return None
441441
return self.definition.commands.select(commands)
442442

443+
def get_command_definition(
444+
self, command: str | OverkizCommand
445+
) -> CommandDefinition | None:
446+
"""Return the CommandDefinition for a command, or None if unavailable."""
447+
if self.definition is None:
448+
return None
449+
return self.definition.commands.get(str(command))
450+
443451
def get_state_value(self, state: str) -> StateType | None:
444452
"""Get value of a single state, or None if not found or None."""
445453
return self.states.select_value([state])

tests/test_client.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,16 @@
1010
import pytest
1111

1212
from pyoverkiz import exceptions
13-
from pyoverkiz.client import OverkizClient
13+
from pyoverkiz.action_queue import ActionQueueSettings
14+
from pyoverkiz.auth import UsernamePasswordCredentials
15+
from pyoverkiz.client import OverkizClient, OverkizClientSettings
1416
from pyoverkiz.enums import (
1517
APIType,
1618
DataType,
1719
ExecutionState,
1820
ExecutionSubType,
1921
ExecutionType,
22+
Server,
2023
)
2124
from pyoverkiz.models import (
2225
Action,
@@ -1203,3 +1206,32 @@ async def test_local_schedule_persisted_action_group_unknown_object(
12031206
await local_client.schedule_persisted_action_group(
12041207
"00000000-0000-0000-0000-000000000000", 9999999999
12051208
)
1209+
1210+
1211+
class TestOverkizClientSettings:
1212+
"""Tests for the OverkizClientSettings integration with OverkizClient."""
1213+
1214+
def test_client_with_settings_none(self, client: OverkizClient) -> None:
1215+
"""Client without settings has no action queue and no RTS duration."""
1216+
assert client._action_queue is None
1217+
assert client.settings.rts_command_duration is None
1218+
1219+
@pytest.mark.asyncio
1220+
async def test_client_with_rts_duration(self) -> None:
1221+
"""Client stores RTS command duration from settings."""
1222+
client = OverkizClient(
1223+
server=Server.SOMFY_EUROPE,
1224+
credentials=UsernamePasswordCredentials("user", "pass"),
1225+
settings=OverkizClientSettings(rts_command_duration=0),
1226+
)
1227+
assert client.settings.rts_command_duration == 0
1228+
1229+
@pytest.mark.asyncio
1230+
async def test_client_with_action_queue_via_settings(self) -> None:
1231+
"""Client creates action queue from settings."""
1232+
client = OverkizClient(
1233+
server=Server.SOMFY_EUROPE,
1234+
credentials=UsernamePasswordCredentials("user", "pass"),
1235+
settings=OverkizClientSettings(action_queue=ActionQueueSettings()),
1236+
)
1237+
assert client._action_queue is not None

0 commit comments

Comments
 (0)