diff --git a/README.md b/README.md index e635b6c2..94f949d5 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ async def main() -> None: ) ], label="Execution via Python", - # mode=CommandMode.HIGH_PRIORITY + # mode=ExecutionMode.HIGH_PRIORITY ) while True: diff --git a/docs/core-concepts.md b/docs/core-concepts.md index 901ea942..937697e1 100644 --- a/docs/core-concepts.md +++ b/docs/core-concepts.md @@ -8,9 +8,45 @@ A server describes where the API calls go. A gateway is the physical hub in your The setup describes the current gateway configuration and device inventory. Devices expose metadata like `uiClass` and `widget`, plus a list of current `states`. -## Actions, action groups, and commands +## Commands, actions, and action groups -Commands are sent as `Action` objects, grouped into an action group. Each action targets a device URL and a set of commands with parameters. +The Overkiz API uses a three-level hierarchy to control devices: + +- **Command** — A single instruction for a device, like `open`, `close`, or `setClosure(50)`. A command has a name and optional parameters. +- **Action** — One or more commands targeting a **single device** (identified by its device URL). The gateway allows at most one action per device in each action group. +- **Action group** — A batch of actions submitted to the gateway as a single execution. An action group can target multiple devices at once. + +``` +ActionGroup +├── Action (device A) +│ ├── Command("open") +│ └── Command("setClosure", [50]) +└── Action (device B) + └── Command("close") +``` + +Action groups come in two flavors: + +- **Ad-hoc** — Built on the fly from `Action` and `Command` objects and executed via `execute_action_group()`. +- **Persisted** — Stored on the server (like saved scenes). Retrieved with `get_action_groups()` and executed by OID via `execute_persisted_action_group()`, or scheduled for a future timestamp via `schedule_persisted_action_group()`. + +## Executions + +When an action group is submitted, the server returns an `exec_id` identifying the **execution**. An execution tracks the lifecycle of a submitted action group — from queued, to running, to completed or failed. + +- **Track** running executions with `get_current_executions()` or `get_current_execution(exec_id)`. +- **Cancel** a running execution with `cancel_execution(exec_id)`. +- **Review** past results with `get_execution_history()`. + +Executions run asynchronously on the gateway. State changes are delivered through events, so you typically combine execution with an event listener to know when commands finish. + +## Execution modes + +An optional `ExecutionMode` can be passed when executing an action group: + +- `HIGH_PRIORITY` — Bypasses the normal execution queue. +- `GEOLOCATED` — Triggered by geolocation rules. +- `INTERNAL` — Used for internal/system executions. ## States @@ -18,27 +54,27 @@ States are name/value pairs that represent the current device status, such as cl ## Events and listeners -The API uses an event listener that you register once per session. Fetching events drains the server-side buffer. - -## Execution model - -Commands are executed asynchronously by the platform. You can poll execution state via events or refresh device states after a delay. +The API uses an event listener that you register once per session. Fetching events drains the server-side buffer. Events include execution state changes, device state updates, and other notifications. ## Relationship diagram ``` Client - | - |-- Server (cloud or local) - | - |-- Gateway - | - |-- Setup - | | - | |-- Devices - | | - | |-- States - | |-- Actions -> Commands -> Parameters - | - |-- Event Listener -> Events + │ + ├── Server (cloud or local) + │ + └── Gateway + │ + ├── Setup + │ │ + │ └── Devices + │ │ + │ ├── States (name/value pairs) + │ └── Actions ──► Commands ──► Parameters + │ + ├── Action Groups (persisted) + │ + ├── Executions (running action groups) + │ + └── Event Listener ──► Events ``` diff --git a/docs/device-control.md b/docs/device-control.md index f135a23f..65a3b7ed 100644 --- a/docs/device-control.md +++ b/docs/device-control.md @@ -142,37 +142,36 @@ if device.identifier.is_sub_device: print(f"Sub-device ID: {device.identifier.subsystem_id}") ``` -## Send a command +## Send a single command to a device + +Create an `Action` with the target device URL and one or more `Command` objects, then wrap it in an action group. The method returns an `exec_id` you can use to track or cancel the execution. ```python from pyoverkiz.enums import OverkizCommand from pyoverkiz.models import Action, Command -await client.execute_action_group( +exec_id = await client.execute_action_group( actions=[ Action( device_url="io://1234-5678-1234/12345678", commands=[ - Command( - name=OverkizCommand.SET_CLOSURE, - parameters=[50], - ) + Command(name=OverkizCommand.SET_CLOSURE, parameters=[50]) ], ) - ], - label="Execution: set closure", + ], + label="Set closure to 50%", ) ``` -## Action groups and common patterns +## Send multiple commands to one device -- Use a single action group to batch multiple device commands. +A single action can hold multiple commands. They are executed in order on the device. ```python from pyoverkiz.enums import OverkizCommand, OverkizCommandParam from pyoverkiz.models import Action, Command -await client.execute_action_group( +exec_id = await client.execute_action_group( actions=[ Action( device_url="io://1234-5678-1234/12345678", @@ -180,20 +179,102 @@ await client.execute_action_group( Command( name=OverkizCommand.SET_DEROGATION, parameters=[21.5, OverkizCommandParam.FURTHER_NOTICE], - ) - ], - ), - Action( - device_url="io://1234-5678-1234/12345678", - commands=[ + ), Command( name=OverkizCommand.SET_MODE_TEMPERATURE, parameters=[OverkizCommandParam.MANUAL_MODE, 21.5], - ) + ), ], ) ], - label="Execution: multiple commands", + label="Set temperature derogation", +) +``` + +## Control multiple devices at once + +An action group can contain one action per device. All actions in the group are submitted as a single execution. + +```python +from pyoverkiz.enums import OverkizCommand +from pyoverkiz.models import Action, Command + +exec_id = await client.execute_action_group( + actions=[ + Action( + device_url="io://1234-5678-1234/11111111", + commands=[Command(name=OverkizCommand.CLOSE)], + ), + Action( + device_url="io://1234-5678-1234/22222222", + commands=[Command(name=OverkizCommand.OPEN)], + ), + ], + label="Close blinds, open garage", +) +``` + +!!! note + The gateway allows at most **one action per device** in each action group. + If you need to send commands to the same device in separate executions, use + separate `execute_action_group()` calls. + +## Execution modes + +Pass an `ExecutionMode` to change how the gateway processes the action group: + +```python +from pyoverkiz.enums import ExecutionMode, OverkizCommand +from pyoverkiz.models import Action, Command + +exec_id = await client.execute_action_group( + actions=[ + Action( + device_url="io://1234-5678-1234/12345678", + commands=[Command(name=OverkizCommand.OPEN)], + ) + ], + mode=ExecutionMode.HIGH_PRIORITY, +) +``` + +Available modes: `HIGH_PRIORITY`, `GEOLOCATED`, `INTERNAL`. When omitted, the default execution mode is used. + +## Track and cancel executions + +```python +# List all running executions +executions = await client.get_current_executions() + +# Get a specific execution +execution = await client.get_current_execution(exec_id) + +# Cancel a running execution +await client.cancel_execution(exec_id) + +# Review past executions +history = await client.get_execution_history() +``` + +## Persisted action groups + +Action groups can be stored on the server (like saved scenes). Use these methods to list and execute them: + +```python +import time + +# List all persisted action groups +action_groups = await client.get_action_groups() + +for ag in action_groups: + print(f"{ag.label} (OID: {ag.oid})") + +# Execute a persisted action group by OID +exec_id = await client.execute_persisted_action_group(ag.oid) + +# Schedule for future execution (e.g. 1 hour from now) +trigger_id = await client.schedule_persisted_action_group( + ag.oid, timestamp=int(time.time()) + 3600 ) ``` diff --git a/pyoverkiz/action_queue.py b/pyoverkiz/action_queue.py index f02d2553..288c56af 100644 --- a/pyoverkiz/action_queue.py +++ b/pyoverkiz/action_queue.py @@ -11,7 +11,7 @@ from pyoverkiz.models import Action if TYPE_CHECKING: - from pyoverkiz.enums import CommandMode + from pyoverkiz.enums import ExecutionMode @dataclass(frozen=True, slots=True) @@ -78,7 +78,7 @@ class ActionQueue: The batch is flushed when: - The delay timer expires - The max actions limit is reached - - The command mode changes + - The execution mode changes - The label changes - Manual flush is requested """ @@ -86,7 +86,7 @@ class ActionQueue: def __init__( self, executor: Callable[ - [list[Action], CommandMode | None, str | None], Coroutine[None, None, str] + [list[Action], ExecutionMode | None, str | None], Coroutine[None, None, str] ], delay: float = 0.5, max_actions: int = 20, @@ -102,7 +102,7 @@ def __init__( self._max_actions = max_actions self._pending_actions: list[Action] = [] - self._pending_mode: CommandMode | None = None + self._pending_mode: ExecutionMode | None = None self._pending_label: str | None = None self._pending_waiters: list[QueuedExecution] = [] @@ -121,7 +121,7 @@ def _copy_action(action: Action) -> Action: async def add( self, actions: list[Action], - mode: CommandMode | None = None, + mode: ExecutionMode | None = None, label: str | None = None, ) -> QueuedExecution: """Add actions to the queue. @@ -132,7 +132,7 @@ async def add( Args: actions: Actions to queue. - mode: Command mode, which triggers a flush if it differs from the + mode: Execution mode, which triggers a flush if it differs from the pending mode. label: Label for the action group. @@ -141,7 +141,7 @@ async def add( executes. """ batches_to_execute: list[ - tuple[list[Action], CommandMode | None, str | None, list[QueuedExecution]] + tuple[list[Action], ExecutionMode | None, str | None, list[QueuedExecution]] ] = [] if not actions: @@ -235,7 +235,7 @@ async def _delayed_flush(self) -> None: def _prepare_flush( self, - ) -> tuple[list[Action], CommandMode | None, str | None, list[QueuedExecution]]: + ) -> tuple[list[Action], ExecutionMode | None, str | None, list[QueuedExecution]]: """Prepare a flush by taking snapshot and clearing state (must be called with lock held). Returns a tuple of (actions, mode, label, waiters) that should be executed @@ -266,7 +266,7 @@ def _prepare_flush( async def _execute_batch( self, actions: list[Action], - mode: CommandMode | None, + mode: ExecutionMode | None, label: str | None, waiters: list[QueuedExecution], ) -> None: diff --git a/pyoverkiz/client.py b/pyoverkiz/client.py index 5600b543..fa4024ee 100644 --- a/pyoverkiz/client.py +++ b/pyoverkiz/client.py @@ -22,7 +22,7 @@ from pyoverkiz.action_queue import ActionQueue, ActionQueueSettings from pyoverkiz.auth import AuthStrategy, Credentials, build_auth_strategy from pyoverkiz.const import SUPPORTED_SERVERS -from pyoverkiz.enums import APIType, CommandMode, Server +from pyoverkiz.enums import APIType, ExecutionMode, Server from pyoverkiz.exceptions import ( ExecutionQueueFullError, InvalidEventListenerIdError, @@ -373,7 +373,7 @@ async def get_gateways(self, refresh: bool = False) -> list[Gateway]: @retry_on_auth_error async def get_execution_history(self) -> list[HistoryExecution]: - """List execution history.""" + """List past executions and their outcomes.""" response = await self._get("history/executions") return [HistoryExecution(**h) for h in decamelize(response)] @@ -449,13 +449,13 @@ async def unregister_event_listener(self) -> None: @retry_on_auth_error async def get_current_execution(self, exec_id: str) -> Execution: - """Get an action group execution currently running.""" + """Get a currently running execution by its exec_id.""" response = await self._get(f"exec/current/{exec_id}") return Execution(**decamelize(response)) @retry_on_auth_error async def get_current_executions(self) -> list[Execution]: - """Get all action groups executions currently running.""" + """Get all currently running executions.""" response = await self._get("exec/current") return [Execution(**e) for e in decamelize(response)] @@ -471,7 +471,7 @@ async def get_api_version(self) -> str: async def _execute_action_group_direct( self, actions: list[Action], - mode: CommandMode | None = None, + mode: ExecutionMode | None = None, label: str | None = "python-overkiz-api", ) -> str: """Execute a non-persistent action group directly (internal method). @@ -489,37 +489,32 @@ async def _execute_action_group_direct( async def execute_action_group( self, actions: list[Action], - mode: CommandMode | None = None, + mode: ExecutionMode | None = None, label: str | None = "python-overkiz-api", ) -> str: - """Execute a non-persistent action group. + """Execute an ad-hoc action group built from the given actions. - When action queue is enabled, actions will be batched with other actions - executed within the configured delay window. The method will wait for the - batch to execute and return the exec_id. + An action group is a batch of device actions submitted as a single + execution. Each ``Action`` targets one device and contains one or more + ``Command`` instances (e.g. ``open``, ``setClosure(50)``). The gateway + allows at most one action per device per action group. - Gateways only allow a single action per device in each action group. The - action queue enforces this by merging commands for the same device into - a single action in the batch. + When the action queue is enabled, actions are held for a short delay + and merged with other actions submitted in the same window. Commands + targeting the same device are combined into a single action. The method + blocks until the batch executes and returns the resulting exec_id. - When action queue is disabled, executes immediately and returns exec_id. - - The API is consistent regardless of queue configuration - always returns - exec_id string directly. + When the action queue is disabled, the action group is sent immediately. Args: - actions: List of actions to execute. - mode: Command mode (`GEOLOCATED`, `INTERNAL`, `HIGH_PRIORITY`, - or `None`). - label: Label for the action group. + actions: One or more actions to execute. Each action targets a + single device and holds one or more commands. + mode: Optional execution mode (``HIGH_PRIORITY``, ``GEOLOCATED``, + or ``INTERNAL``). + label: Human-readable label for the execution. Returns: - The `exec_id` string from the executed action group. - - Example: - ```python - exec_id = await client.execute_action_group([action]) - ``` + The ``exec_id`` identifying the execution on the server. """ if self._action_queue: queued = await self._action_queue.add(actions, mode, label) @@ -547,13 +542,13 @@ def get_pending_actions_count(self) -> int: return 0 @retry_on_auth_error - async def cancel_command(self, exec_id: str) -> None: - """Cancel a running setup-level execution.""" + async def cancel_execution(self, exec_id: str) -> None: + """Cancel a running execution by its exec_id.""" await self._delete(f"exec/current/setup/{exec_id}") @retry_on_auth_error async def get_action_groups(self) -> list[ActionGroup]: - """List the action groups (scenarios).""" + """List action groups persisted on the server.""" response = await self._get("actionGroups") return [ActionGroup(**action_group) for action_group in decamelize(response)] @@ -573,14 +568,14 @@ async def get_places(self) -> Place: return Place(**decamelize(response)) @retry_on_auth_error - async def execute_scenario(self, oid: str) -> str: - """Execute a scenario.""" + async def execute_persisted_action_group(self, oid: str) -> str: + """Execute a server-side action group by its OID (see ``get_action_groups``).""" response = await self._post(f"exec/{oid}") return cast(str, response["execId"]) @retry_on_auth_error - async def execute_scheduled_scenario(self, oid: str, timestamp: int) -> str: - """Execute a scheduled scenario.""" + async def schedule_persisted_action_group(self, oid: str, timestamp: int) -> str: + """Schedule a server-side action group for execution at the given timestamp.""" response = await self._post(f"exec/schedule/{oid}/{timestamp}") return cast(str, response["triggerId"]) diff --git a/pyoverkiz/enums/__init__.py b/pyoverkiz/enums/__init__.py index bf6080d1..af98f61a 100644 --- a/pyoverkiz/enums/__init__.py +++ b/pyoverkiz/enums/__init__.py @@ -1,7 +1,7 @@ """Convenience re-exports for the enums package.""" # Explicitly re-export all Enum subclasses to avoid wildcard import issues -from pyoverkiz.enums.command import CommandMode, OverkizCommand, OverkizCommandParam +from pyoverkiz.enums.command import ExecutionMode, OverkizCommand, OverkizCommandParam from pyoverkiz.enums.execution import ( ExecutionState, ExecutionSubType, @@ -18,9 +18,9 @@ __all__ = [ "APIType", - "CommandMode", "DataType", "EventName", + "ExecutionMode", "ExecutionState", "ExecutionSubType", "ExecutionType", diff --git a/pyoverkiz/enums/command.py b/pyoverkiz/enums/command.py index 48a9f23f..66c42ac5 100644 --- a/pyoverkiz/enums/command.py +++ b/pyoverkiz/enums/command.py @@ -766,8 +766,8 @@ class OverkizCommandParam(StrEnum): @unique -class CommandMode(StrEnum): - """Execution mode flags for commands (e.g., high priority or geolocated).""" +class ExecutionMode(StrEnum): + """Execution mode flags (e.g., high priority or geolocated).""" HIGH_PRIORITY = "highPriority" GEOLOCATED = "geolocated" diff --git a/tests/test_action_queue.py b/tests/test_action_queue.py index 94833aaf..7d0029cc 100644 --- a/tests/test_action_queue.py +++ b/tests/test_action_queue.py @@ -6,7 +6,7 @@ import pytest from pyoverkiz.action_queue import ActionQueue, QueuedExecution -from pyoverkiz.enums import CommandMode, OverkizCommand +from pyoverkiz.enums import ExecutionMode, OverkizCommand from pyoverkiz.models import Action, Command @@ -112,7 +112,7 @@ async def test_action_queue_max_actions_flush(mock_executor): @pytest.mark.asyncio async def test_action_queue_mode_change_flush(mock_executor): - """Test that queue flushes when command mode changes.""" + """Test that queue flushes when execution mode changes.""" queue = ActionQueue(executor=mock_executor, delay=0.5) action = Action( @@ -124,7 +124,7 @@ async def test_action_queue_mode_change_flush(mock_executor): queued1 = await queue.add([action], mode=None) # Add action with high priority - should flush previous batch - queued2 = await queue.add([action], mode=CommandMode.HIGH_PRIORITY) + queued2 = await queue.add([action], mode=ExecutionMode.HIGH_PRIORITY) # Wait for both batches exec_id1 = await queued1 diff --git a/utils/generate_enums.py b/utils/generate_enums.py index f5eef648..0f7529a5 100644 --- a/utils/generate_enums.py +++ b/utils/generate_enums.py @@ -569,7 +569,7 @@ async def generate_command_enums() -> None: command_file = Path(__file__).parent.parent / "pyoverkiz" / "enums" / "command.py" content = command_file.read_text() - find_class_start(content, "CommandMode") + find_class_start(content, "ExecutionMode") existing_commands = extract_enum_members(content, "OverkizCommand") existing_params = extract_enum_members(content, "OverkizCommandParam") @@ -656,10 +656,10 @@ async def generate_command_enums() -> None: lines.append("") lines.append("") - # Append CommandMode class - command_mode_start = content.find("@unique\nclass CommandMode") - if command_mode_start != -1: - lines.append(content[command_mode_start:].rstrip()) + # Append ExecutionMode class + execution_mode_start = content.find("@unique\nclass ExecutionMode") + if execution_mode_start != -1: + lines.append(content[execution_mode_start:].rstrip()) lines.append("") # Write to the command.py file