Skip to content
28 changes: 19 additions & 9 deletions docs/action-queue.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,22 @@ Important limitation:

## Enable with defaults

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

```python
import asyncio

from pyoverkiz.action_queue import ActionQueueSettings
from pyoverkiz.auth import UsernamePasswordCredentials
from pyoverkiz.client import OverkizClient
from pyoverkiz.client import OverkizClientSettings
from pyoverkiz.enums import OverkizCommand, Server
from pyoverkiz.models import Action, Command

client = OverkizClient(
server=Server.SOMFY_EUROPE,
credentials=UsernamePasswordCredentials("user@example.com", "password"),
action_queue=True, # uses defaults
settings=OverkizClientSettings(action_queue=ActionQueueSettings()),
)

action1 = Action(
Expand All @@ -49,23 +51,26 @@ Defaults:

## Advanced settings

If you need to tune batching behavior, pass `ActionQueueSettings`:
If you need to tune batching behavior, pass custom values to `ActionQueueSettings`:

```python
import asyncio

from pyoverkiz.action_queue import ActionQueueSettings
from pyoverkiz.client import OverkizClient
from pyoverkiz.client import OverkizClientSettings
from pyoverkiz.auth import UsernamePasswordCredentials
from pyoverkiz.enums import OverkizCommand, Server
from pyoverkiz.models import Action, Command

client = OverkizClient(
server=Server.SOMFY_EUROPE,
credentials=UsernamePasswordCredentials("user@example.com", "password"),
action_queue=ActionQueueSettings(
delay=0.5, # seconds to wait before auto-flush
max_actions=20, # auto-flush when this count is reached
settings=OverkizClientSettings(
action_queue=ActionQueueSettings(
delay=0.5, # seconds to wait before auto-flush
max_actions=20, # auto-flush when this count is reached
),
),
)
```
Expand All @@ -75,18 +80,21 @@ client = OverkizClient(
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.

```python
from pyoverkiz.action_queue import ActionQueueSettings
import asyncio

from pyoverkiz.action_queue import ActionQueueSettings
from pyoverkiz.client import OverkizClient
from pyoverkiz.client import OverkizClientSettings
from pyoverkiz.auth import UsernamePasswordCredentials
from pyoverkiz.enums import OverkizCommand, Server
from pyoverkiz.models import Action, Command

client = OverkizClient(
server=Server.SOMFY_EUROPE,
credentials=UsernamePasswordCredentials("user@example.com", "password"),
action_queue=ActionQueueSettings(delay=10.0), # long delay
settings=OverkizClientSettings(
action_queue=ActionQueueSettings(delay=10.0), # long delay
),
)

action = Action(
Expand Down Expand Up @@ -115,15 +123,17 @@ Why this matters:
`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.

```python
from pyoverkiz.action_queue import ActionQueueSettings
from pyoverkiz.client import OverkizClient
from pyoverkiz.client import OverkizClientSettings
from pyoverkiz.auth import UsernamePasswordCredentials
from pyoverkiz.enums import OverkizCommand, Server
from pyoverkiz.models import Action, Command

client = OverkizClient(
server=Server.SOMFY_EUROPE,
credentials=UsernamePasswordCredentials("user@example.com", "password"),
action_queue=True,
settings=OverkizClientSettings(action_queue=ActionQueueSettings()),
)

action = Action(
Expand Down
32 changes: 32 additions & 0 deletions docs/device-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,19 @@ firmware = device.select_first_attribute_value([
print(f"Firmware: {firmware}")
```

#### Get command definition

```python
devices = await client.get_devices()
device = devices[0]

# Get the command definition for a single command
cmd_def = device.get_command_definition(OverkizCommand.OPEN)
if cmd_def:
print(f"Command: {cmd_def.command_name}")
print(f"Number of parameters: {cmd_def.nparams}")
```

#### Access device identifier

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

## RTS command duration

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`).

```python
from pyoverkiz.auth.credentials import UsernamePasswordCredentials
from pyoverkiz.client import OverkizClient
from pyoverkiz.client import OverkizClientSettings
from pyoverkiz.enums import Server

client = OverkizClient(
server=Server.SOMFY_EUROPE,
credentials=UsernamePasswordCredentials("user@example.com", "password"),
settings=OverkizClientSettings(rts_command_duration=0),
)
```

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.

## Limitations and rate limits

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.
Expand Down
3 changes: 3 additions & 0 deletions docs/migration-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,10 @@ Several enum members have been renamed for consistent `UPPER_SNAKE_CASE` or to f

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

- **Client settings** — behavioral configuration is now grouped in `OverkizClientSettings`, passed via the `settings` parameter. This replaces standalone constructor parameters like `action_queue`.
- **Action queue** — batch device executions automatically. See the [action queue guide](action-queue.md).
- **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).
- **Device helpers** — `Device.get_command_definition()` for looking up command metadata.
- **Reference endpoints** — query server metadata: `get_reference_ui_classes()`, `get_reference_ui_widgets()`, `get_reference_ui_profile()`, `get_reference_controllable_types()`, etc.
- **Firmware management** — `get_devices_not_up_to_date()`, `get_device_firmware_status()`, `update_device_firmware()`.
- **boto3 lazy import** — `boto3` is only imported when the Nexity auth strategy is actually used.
84 changes: 67 additions & 17 deletions pyoverkiz/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import logging
import ssl
import urllib.parse
from dataclasses import dataclass
from http import HTTPStatus
from pathlib import Path
from types import TracebackType
Expand All @@ -25,7 +26,7 @@
from pyoverkiz.auth import AuthStrategy, Credentials, build_auth_strategy
from pyoverkiz.const import SUPPORTED_SERVERS
from pyoverkiz.converter import converter
from pyoverkiz.enums import APIType, ExecutionMode, Server
from pyoverkiz.enums import APIType, ExecutionMode, Protocol, Server
from pyoverkiz.exceptions import (
ExecutionQueueFullError,
InvalidEventListenerIdError,
Expand All @@ -38,6 +39,7 @@
)
from pyoverkiz.models import (
Action,
Command,
Device,
Event,
Execution,
Expand Down Expand Up @@ -160,6 +162,17 @@ def _create_local_ssl_context() -> ssl.SSLContext:
SSL_CONTEXT_LOCAL_API = _create_local_ssl_context()


@dataclass(frozen=True, slots=True)
class OverkizClientSettings:
"""Behavioral configuration for OverkizClient.

All fields are optional and default to passive behavior.
"""

action_queue: ActionQueueSettings | None = None
rts_command_duration: int | None = None


class OverkizClient:
"""Interface class for the Overkiz API."""

Expand All @@ -172,6 +185,7 @@ class OverkizClient:
_ssl: ssl.SSLContext | bool = True
_auth: AuthStrategy
_action_queue: ActionQueue | None = None
settings: OverkizClientSettings

def __init__(
self,
Expand All @@ -180,15 +194,15 @@ def __init__(
credentials: Credentials,
verify_ssl: bool = True,
session: ClientSession | None = None,
action_queue: bool | ActionQueueSettings = False,
settings: OverkizClientSettings | None = None,
) -> None:
"""Constructor.

:param server: ServerConfig
:param credentials: Credentials for authentication
:param verify_ssl: Enable SSL certificate verification
:param session: optional ClientSession
:param action_queue: enable batching or provide queue settings (default False)
:param settings: behavioral settings for the client (default None)
Comment thread
iMicknl marked this conversation as resolved.
"""
self.server_config = self._normalize_server(server)

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

# Initialize action queue if enabled
queue_settings: ActionQueueSettings | None
if isinstance(action_queue, ActionQueueSettings):
queue_settings = action_queue
elif isinstance(action_queue, bool):
queue_settings = ActionQueueSettings() if action_queue else None
else:
raise TypeError(
"action_queue must be a bool or ActionQueueSettings, "
f"got {type(action_queue).__name__}"
)
self.settings = settings or OverkizClientSettings()

if queue_settings:
queue_settings.validate()
if self.settings.action_queue:
self.settings.action_queue.validate()
self._action_queue = ActionQueue(
executor=self._execute_action_group_direct,
settings=queue_settings,
settings=self.settings.action_queue,
)

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

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

def _apply_rts_duration(self, actions: list[Action]) -> list[Action]:
"""Set the execution duration for RTS commands that support it.

The default execution duration for RTS devices is 30 seconds, which
blocks consecutive commands. This injects the configured duration
(typically 0) into commands that accept it, based on the device
command definition (nparams).
"""
duration = self.settings.rts_command_duration
if duration is None:
return actions

Comment thread
iMicknl marked this conversation as resolved.
device_index: dict[str, Device] = {d.device_url: d for d in self.devices}

result: list[Action] = []
for action in actions:
device = device_index.get(action.device_url)

if device is None or device.identifier.protocol != Protocol.RTS:
result.append(action)
continue

updated_commands: list[Command] = []
for cmd in action.commands:
cmd_def = device.get_command_definition(str(cmd.name))
current_count = len(cmd.parameters) if cmd.parameters else 0

if cmd_def and current_count < cmd_def.nparams:
updated_commands.append(
Command(
name=cmd.name,
parameters=[*(cmd.parameters or []), duration],
type=cmd.type,
)
)
else:
updated_commands.append(cmd)

result.append(
Action(device_url=action.device_url, commands=updated_commands)
)

return result

@retry_on_too_many_executions
@retry_on_auth_error
async def _execute_action_group_direct(
Expand Down Expand Up @@ -544,6 +592,8 @@ async def execute_action_group(
Returns:
The ``exec_id`` identifying the execution on the server.
"""
actions = self._apply_rts_duration(actions)

if self._action_queue:
queued = await self._action_queue.add(actions, mode, label)
return await queued
Expand Down
8 changes: 8 additions & 0 deletions pyoverkiz/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,14 @@ def select_first_command(self, commands: list[str | OverkizCommand]) -> str | No
return None
return self.definition.commands.select(commands)

def get_command_definition(
self, command: str | OverkizCommand
) -> CommandDefinition | None:
"""Return the CommandDefinition for a command, or None if unavailable."""
if self.definition is None:
return None
return self.definition.commands.get(str(command))

def get_state_value(self, state: str) -> StateType | None:
"""Get value of a single state, or None if not found or None."""
return self.states.select_value([state])
Expand Down
34 changes: 33 additions & 1 deletion tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@
import pytest

from pyoverkiz import exceptions
from pyoverkiz.client import OverkizClient
from pyoverkiz.action_queue import ActionQueueSettings
from pyoverkiz.auth import UsernamePasswordCredentials
from pyoverkiz.client import OverkizClient, OverkizClientSettings
from pyoverkiz.enums import (
APIType,
DataType,
ExecutionState,
ExecutionSubType,
ExecutionType,
Server,
)
from pyoverkiz.models import (
Action,
Expand Down Expand Up @@ -1203,3 +1206,32 @@ async def test_local_schedule_persisted_action_group_unknown_object(
await local_client.schedule_persisted_action_group(
"00000000-0000-0000-0000-000000000000", 9999999999
)


class TestOverkizClientSettings:
"""Tests for the OverkizClientSettings integration with OverkizClient."""

def test_client_with_settings_none(self, client: OverkizClient) -> None:
"""Client without settings has no action queue and no RTS duration."""
assert client._action_queue is None
assert client.settings.rts_command_duration is None

@pytest.mark.asyncio
async def test_client_with_rts_duration(self) -> None:
"""Client stores RTS command duration from settings."""
client = OverkizClient(
server=Server.SOMFY_EUROPE,
credentials=UsernamePasswordCredentials("user", "pass"),
settings=OverkizClientSettings(rts_command_duration=0),
)
assert client.settings.rts_command_duration == 0

@pytest.mark.asyncio
async def test_client_with_action_queue_via_settings(self) -> None:
"""Client creates action queue from settings."""
client = OverkizClient(
server=Server.SOMFY_EUROPE,
credentials=UsernamePasswordCredentials("user", "pass"),
settings=OverkizClientSettings(action_queue=ActionQueueSettings()),
)
assert client._action_queue is not None
Loading
Loading