diff --git a/docs/action-queue.md b/docs/action-queue.md index 58f0433f..8da02d90 100644 --- a/docs/action-queue.md +++ b/docs/action-queue.md @@ -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( @@ -49,13 +51,14 @@ 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 @@ -63,9 +66,11 @@ 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 + ), ), ) ``` @@ -75,10 +80,11 @@ 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 @@ -86,7 +92,9 @@ 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( @@ -115,7 +123,9 @@ 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 @@ -123,7 +133,7 @@ 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( diff --git a/docs/device-control.md b/docs/device-control.md index 65a3b7ed..6fa4574b 100644 --- a/docs/device-control.md +++ b/docs/device-control.md @@ -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: @@ -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. diff --git a/docs/migration-v2.md b/docs/migration-v2.md index ba93bf34..c29976d9 100644 --- a/docs/migration-v2.md +++ b/docs/migration-v2.md @@ -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. diff --git a/pyoverkiz/client.py b/pyoverkiz/client.py index df594509..82a6a825 100644 --- a/pyoverkiz/client.py +++ b/pyoverkiz/client.py @@ -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 @@ -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, @@ -38,6 +39,7 @@ ) from pyoverkiz.models import ( Action, + Command, Device, Event, Execution, @@ -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.""" @@ -172,6 +185,7 @@ class OverkizClient: _ssl: ssl.SSLContext | bool = True _auth: AuthStrategy _action_queue: ActionQueue | None = None + settings: OverkizClientSettings def __init__( self, @@ -180,7 +194,7 @@ def __init__( credentials: Credentials, verify_ssl: bool = True, session: ClientSession | None = None, - action_queue: bool | ActionQueueSettings = False, + settings: OverkizClientSettings | None = None, ) -> None: """Constructor. @@ -188,7 +202,7 @@ def __init__( :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) """ self.server_config = self._normalize_server(server) @@ -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( @@ -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 + + 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( @@ -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 diff --git a/pyoverkiz/models.py b/pyoverkiz/models.py index eaa91874..7d6d015b 100644 --- a/pyoverkiz/models.py +++ b/pyoverkiz/models.py @@ -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]) diff --git a/tests/test_client.py b/tests/test_client.py index 13693ab2..a28782e9 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -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, @@ -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 diff --git a/tests/test_client_queue_integration.py b/tests/test_client_queue_integration.py index 667a8858..95833955 100644 --- a/tests/test_client_queue_integration.py +++ b/tests/test_client_queue_integration.py @@ -7,7 +7,7 @@ from pyoverkiz.action_queue import ActionQueueSettings from pyoverkiz.auth import UsernamePasswordCredentials -from pyoverkiz.client import OverkizClient +from pyoverkiz.client import OverkizClient, OverkizClientSettings from pyoverkiz.enums import OverkizCommand, Server from pyoverkiz.models import Action, Command @@ -18,7 +18,6 @@ async def test_client_without_queue_executes_immediately(): client = OverkizClient( server=Server.SOMFY_EUROPE, credentials=UsernamePasswordCredentials("test@example.com", "test"), - action_queue=False, ) action = Action( @@ -48,7 +47,7 @@ async def test_client_with_queue_batches_actions(): client = OverkizClient( server=Server.SOMFY_EUROPE, credentials=UsernamePasswordCredentials("test@example.com", "test"), - action_queue=ActionQueueSettings(delay=0.1), + settings=OverkizClientSettings(action_queue=ActionQueueSettings(delay=0.1)), ) actions = [ @@ -98,7 +97,9 @@ async def test_client_manual_flush(): client = OverkizClient( server=Server.SOMFY_EUROPE, credentials=UsernamePasswordCredentials("test@example.com", "test"), - action_queue=ActionQueueSettings(delay=10.0), # Long delay + settings=OverkizClientSettings( + action_queue=ActionQueueSettings(delay=10.0) + ), # Long delay ) action = Action( @@ -138,7 +139,7 @@ async def test_client_close_flushes_queue(): client = OverkizClient( server=Server.SOMFY_EUROPE, credentials=UsernamePasswordCredentials("test@example.com", "test"), - action_queue=ActionQueueSettings(delay=10.0), + settings=OverkizClientSettings(action_queue=ActionQueueSettings(delay=10.0)), ) action = Action( @@ -171,9 +172,11 @@ async def test_client_queue_respects_max_actions(): client = OverkizClient( server=Server.SOMFY_EUROPE, credentials=UsernamePasswordCredentials("test@example.com", "test"), - action_queue=ActionQueueSettings( - delay=10.0, - max_actions=2, # Max 2 actions + settings=OverkizClientSettings( + action_queue=ActionQueueSettings( + delay=10.0, + max_actions=2, # Max 2 actions + ), ), ) diff --git a/tests/test_client_settings.py b/tests/test_client_settings.py new file mode 100644 index 00000000..d215a3d3 --- /dev/null +++ b/tests/test_client_settings.py @@ -0,0 +1,24 @@ +"""Tests for OverkizClientSettings.""" + +from pyoverkiz.action_queue import ActionQueueSettings +from pyoverkiz.client import OverkizClientSettings + + +def test_defaults(): + """Default settings have no queue and no RTS duration.""" + settings = OverkizClientSettings() + assert settings.action_queue is None + assert settings.rts_command_duration is None + + +def test_with_rts_duration(): + """RTS command duration can be set.""" + settings = OverkizClientSettings(rts_command_duration=0) + assert settings.rts_command_duration == 0 + + +def test_with_action_queue_settings(): + """Passing ActionQueueSettings stores it directly.""" + qs = ActionQueueSettings(delay=1.0, max_actions=10) + settings = OverkizClientSettings(action_queue=qs) + assert settings.action_queue is qs diff --git a/tests/test_models.py b/tests/test_models.py index b365f651..3323b4d2 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -981,3 +981,56 @@ def test_persisted_action_group_id_property_returns_str(self): result = group.id assert isinstance(result, str) assert result == "abc-123" + + +def test_get_command_definition_found(): + """Device.get_command_definition() returns CommandDefinition when command exists.""" + from pyoverkiz.models import CommandDefinition + + device = _make_device( + { + **RAW_DEVICES, + "definition": { + **RAW_DEVICES["definition"], + "commands": [{"commandName": "open", "nparams": 0}], + }, + } + ) + cd = device.get_command_definition("open") + assert cd is not None + assert isinstance(cd, CommandDefinition) + assert cd.nparams == 0 + + +def test_get_command_definition_not_found(): + """Device.get_command_definition() returns None when command doesn't exist.""" + device = _make_device( + { + **RAW_DEVICES, + "definition": { + **RAW_DEVICES["definition"], + "commands": [], + }, + } + ) + assert device.get_command_definition("open") is None + + +def test_get_command_definition_no_definition(): + """Device.get_command_definition() returns None when device has no definition.""" + from pyoverkiz.enums import ProductType + from pyoverkiz.models import States + + device = Device( + attributes=States(), + available=True, + enabled=True, + label="Test", + device_url="io://1234-5678-9012/1", + controllable_name="test", + definition=None, + type=ProductType.ACTUATOR, + widget="SomeWidget", + ui_class="RollerShutter", + ) + assert device.get_command_definition("open") is None diff --git a/tests/test_rts_injection.py b/tests/test_rts_injection.py new file mode 100644 index 00000000..3c253883 --- /dev/null +++ b/tests/test_rts_injection.py @@ -0,0 +1,261 @@ +# tests/test_rts_injection.py +"""Tests for RTS command duration injection in execute_action_group.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest +import pytest_asyncio + +from pyoverkiz.auth.credentials import UsernamePasswordCredentials +from pyoverkiz.client import OverkizClient, OverkizClientSettings +from pyoverkiz.enums import ProductType, Server +from pyoverkiz.models import ( + Action, + Command, + CommandDefinition, + CommandDefinitions, + Definition, + Device, + States, +) + + +def _rts_device( + device_url: str = "rts://1234-5678-9012/1", + commands: list[CommandDefinition] | None = None, +) -> Device: + """Create a minimal RTS Device for testing.""" + if commands is None: + commands = [ + CommandDefinition(command_name="close", nparams=1), + CommandDefinition(command_name="open", nparams=1), + CommandDefinition(command_name="identify", nparams=0), + ] + return Device( + attributes=States(), + available=True, + enabled=True, + label="RTS Blind", + device_url=device_url, + controllable_name="rts:blind", + definition=Definition( + commands=CommandDefinitions(commands), + widget_name="SomeWidget", + ui_class="RollerShutter", + ), + type=ProductType.ACTUATOR, + ) + + +def _io_device(device_url: str = "io://1234-5678-9012/2") -> Device: + """Create a minimal IO device for testing.""" + return Device( + attributes=States(), + available=True, + enabled=True, + label="IO Blind", + device_url=device_url, + controllable_name="io:blind", + definition=Definition( + commands=CommandDefinitions( + [CommandDefinition(command_name="close", nparams=1)] + ), + widget_name="SomeWidget", + ui_class="RollerShutter", + ), + type=ProductType.ACTUATOR, + ) + + +@pytest_asyncio.fixture +async def client_with_rts() -> OverkizClient: + """Client with RTS duration enabled.""" + return OverkizClient( + server=Server.SOMFY_EUROPE, + credentials=UsernamePasswordCredentials("user", "pass"), + settings=OverkizClientSettings(rts_command_duration=0), + ) + + +@pytest_asyncio.fixture +async def client_without_rts() -> OverkizClient: + """Client without RTS duration (default behavior).""" + return OverkizClient( + server=Server.SOMFY_EUROPE, + credentials=UsernamePasswordCredentials("user", "pass"), + ) + + +@pytest.mark.asyncio +async def test_rts_device_gets_duration_appended(client_with_rts): + """RTS device command with room for extra param gets duration appended.""" + client_with_rts.devices = [_rts_device()] + + action = Action( + device_url="rts://1234-5678-9012/1", + commands=[Command(name="close")], + ) + + with patch.object( + client_with_rts, "_execute_action_group_direct", new_callable=AsyncMock + ) as mock_exec: + mock_exec.return_value = "exec-123" + await client_with_rts.execute_action_group([action]) + + called_actions = mock_exec.call_args[0][0] + assert called_actions[0].commands[0].parameters == [0] + + +@pytest.mark.asyncio +async def test_rts_command_already_has_max_params_not_modified(client_with_rts): + """RTS command that already has nparams parameters is not modified.""" + client_with_rts.devices = [_rts_device()] + + action = Action( + device_url="rts://1234-5678-9012/1", + commands=[Command(name="close", parameters=[50])], + ) + + with patch.object( + client_with_rts, "_execute_action_group_direct", new_callable=AsyncMock + ) as mock_exec: + mock_exec.return_value = "exec-123" + await client_with_rts.execute_action_group([action]) + + called_actions = mock_exec.call_args[0][0] + # nparams=1 and already has 1 param — should NOT add another + assert called_actions[0].commands[0].parameters == [50] + + +@pytest.mark.asyncio +async def test_rts_command_with_zero_nparams_not_modified(client_with_rts): + """RTS command with nparams=0 (e.g., identify) is not modified.""" + client_with_rts.devices = [_rts_device()] + + action = Action( + device_url="rts://1234-5678-9012/1", + commands=[Command(name="identify")], + ) + + with patch.object( + client_with_rts, "_execute_action_group_direct", new_callable=AsyncMock + ) as mock_exec: + mock_exec.return_value = "exec-123" + await client_with_rts.execute_action_group([action]) + + called_actions = mock_exec.call_args[0][0] + assert called_actions[0].commands[0].parameters is None + + +@pytest.mark.asyncio +async def test_io_device_not_modified(client_with_rts): + """Non-RTS device commands are never modified, even with rts_command_duration set.""" + client_with_rts.devices = [_io_device()] + + action = Action( + device_url="io://1234-5678-9012/2", + commands=[Command(name="close")], + ) + + with patch.object( + client_with_rts, "_execute_action_group_direct", new_callable=AsyncMock + ) as mock_exec: + mock_exec.return_value = "exec-123" + await client_with_rts.execute_action_group([action]) + + called_actions = mock_exec.call_args[0][0] + assert called_actions[0].commands[0].parameters is None + + +@pytest.mark.asyncio +async def test_no_rts_setting_means_no_injection(client_without_rts): + """When rts_command_duration is None, no injection happens for any device.""" + client_without_rts.devices = [_rts_device()] + + action = Action( + device_url="rts://1234-5678-9012/1", + commands=[Command(name="close")], + ) + + with patch.object( + client_without_rts, "_execute_action_group_direct", new_callable=AsyncMock + ) as mock_exec: + mock_exec.return_value = "exec-123" + await client_without_rts.execute_action_group([action]) + + called_actions = mock_exec.call_args[0][0] + assert called_actions[0].commands[0].parameters is None + + +@pytest.mark.asyncio +async def test_rts_device_not_in_devices_list_skipped(client_with_rts): + """If device URL is not in client.devices, skip injection (no crash).""" + client_with_rts.devices = [] # No devices loaded + + action = Action( + device_url="rts://1234-5678-9012/1", + commands=[Command(name="close")], + ) + + with patch.object( + client_with_rts, "_execute_action_group_direct", new_callable=AsyncMock + ) as mock_exec: + mock_exec.return_value = "exec-123" + await client_with_rts.execute_action_group([action]) + + called_actions = mock_exec.call_args[0][0] + # Not modified — device not found, so we can't know nparams + assert called_actions[0].commands[0].parameters is None + + +@pytest.mark.asyncio +async def test_rts_device_without_command_definitions_skipped(client_with_rts): + """RTS device without command definitions doesn't crash, just skips injection.""" + device = Device( + attributes=States(), + available=True, + enabled=True, + label="RTS No Def", + device_url="rts://1234-5678-9012/1", + controllable_name="rts:blind", + definition=Definition(widget_name="SomeWidget", ui_class="RollerShutter"), + type=ProductType.ACTUATOR, + ) + client_with_rts.devices = [device] + + action = Action( + device_url="rts://1234-5678-9012/1", + commands=[Command(name="close")], + ) + + with patch.object( + client_with_rts, "_execute_action_group_direct", new_callable=AsyncMock + ) as mock_exec: + mock_exec.return_value = "exec-123" + await client_with_rts.execute_action_group([action]) + + called_actions = mock_exec.call_args[0][0] + assert called_actions[0].commands[0].parameters is None + + +@pytest.mark.asyncio +async def test_original_action_not_mutated(client_with_rts): + """Injection creates new Command objects; original actions are not mutated.""" + client_with_rts.devices = [_rts_device()] + + original_cmd = Command(name="close") + action = Action( + device_url="rts://1234-5678-9012/1", + commands=[original_cmd], + ) + + with patch.object( + client_with_rts, "_execute_action_group_direct", new_callable=AsyncMock + ) as mock_exec: + mock_exec.return_value = "exec-123" + await client_with_rts.execute_action_group([action]) + + # Original command should NOT have been mutated + assert original_cmd.parameters is None