Skip to content

Commit be9b854

Browse files
iMicknlCopilot
andcommitted
Add execute_action_group method and remove other command execution methods. (#1862)
- `client.execute_command()` and `client.execute_commands()` are replaced by `client.execute_action_group()` - `client.execute_action_group()` now supports multiple execution modes (high priority, internal, geolocated) - `client.execute_action_group()` now supports multiple device actions in the same request The current execution methods are poorly typed and do not support concurrent execution across multiple devices, which makes it impossible to properly work around TooManyExecutionsException and TooManyConcurrentRequestsException. The main change is the move from `client.execute_command()` and `client.execute_commands()` to a single `client.execute_action_group()`. An action group takes a list of actions, each of which can include multiple device actions, including multiple commands per action. ```python3 await client.execute_action_group( actions=[ Action( device_url="io://1234-5678-1234/12345678", commands=[ Command(name="down"), Command(name="refresh") ] ) ], label="Execution via Home Assistant" ) ``` New (mode) options like high priority will be possible now: ```python3 await client.execute_action_group( actions=[ Action( device_url="io://1234-5678-1234/12345678", commands=[ Command(name=OverkizCommand.SET_CLOSURE, parameters=[0]) ] ) ], label="Execution via Home Assistant", mode=CommandMode.HIGH_PRIORITY ) ``` This could serve as a foundation for grouping commands that are executed within a short time window, for example when triggered by a scene or automation in Home Assistant. Requests issued close together could be batched and sent as a single action group, reducing the impact of current Overkiz limitations. The open question is where this queue should live: inside this integration itself, or as part of the Home Assistant core implementation. --------- Co-authored-by: Copilot <[email protected]>
1 parent 315d9fa commit be9b854

File tree

8 files changed

+230
-34
lines changed

8 files changed

+230
-34
lines changed

README.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ import time
3939

4040
from pyoverkiz.const import SUPPORTED_SERVERS
4141
from pyoverkiz.client import OverkizClient
42-
from pyoverkiz.enums import Server
42+
from pyoverkiz.models import Action
43+
from pyoverkiz.enums import Server, OverkizCommand
4344

4445
USERNAME = ""
4546
PASSWORD = ""
@@ -61,6 +62,19 @@ async def main() -> None:
6162
print(f"{device.label} ({device.id}) - {device.controllable_name}")
6263
print(f"{device.widget} - {device.ui_class}")
6364

65+
await client.execute_action_group(
66+
actions=[
67+
Action(
68+
device_url="io://1234-5678-1234/12345678",
69+
commands=[
70+
Command(name=OverkizCommand.SET_CLOSURE, parameters=[100])
71+
]
72+
)
73+
],
74+
label="Execution via Python",
75+
# mode=CommandMode.HIGH_PRIORITY
76+
)
77+
6478
while True:
6579
events = await client.fetch_events()
6680
print(events)

pyoverkiz/client.py

Lines changed: 28 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
SOMFY_CLIENT_SECRET,
4040
SUPPORTED_SERVERS,
4141
)
42-
from pyoverkiz.enums import APIType, Server
42+
from pyoverkiz.enums import APIType, CommandMode, Server
4343
from pyoverkiz.exceptions import (
4444
AccessDeniedToGatewayException,
4545
BadCredentialsException,
@@ -71,8 +71,8 @@
7171
UnknownUserException,
7272
)
7373
from pyoverkiz.models import (
74+
Action,
7475
ActionGroup,
75-
Command,
7676
Device,
7777
Event,
7878
Execution,
@@ -87,6 +87,7 @@
8787
State,
8888
)
8989
from pyoverkiz.obfuscate import obfuscate_sensitive_data
90+
from pyoverkiz.serializers import prepare_payload
9091
from pyoverkiz.types import JSON
9192

9293

@@ -630,40 +631,42 @@ async def get_api_version(self) -> str:
630631

631632
@retry_on_too_many_executions
632633
@retry_on_auth_error
633-
async def execute_command(
634+
async def execute_action_group(
634635
self,
635-
device_url: str,
636-
command: Command | str,
636+
actions: list[Action],
637+
mode: CommandMode | None = None,
637638
label: str | None = "python-overkiz-api",
638639
) -> str:
639-
"""Send a command."""
640-
if isinstance(command, str):
641-
command = Command(command)
640+
"""Execute a non-persistent action group.
642641
643-
response: str = await self.execute_commands(device_url, [command], label)
642+
The executed action group does not have to be persisted on the server before use.
643+
Per-session rate-limit : 1 calls per 28min 48s period for all operations of the same category (exec)
644+
"""
645+
# Build a logical (snake_case) payload using model helpers and convert it
646+
# to the exact JSON schema expected by the API (camelCase + small fixes).
647+
payload = {"label": label, "actions": [a.to_payload() for a in actions]}
648+
649+
# Prepare final payload with camelCase keys and special abbreviation handling
650+
final_payload = prepare_payload(payload)
651+
652+
if mode == CommandMode.GEOLOCATED:
653+
url = "exec/apply/geolocated"
654+
elif mode == CommandMode.INTERNAL:
655+
url = "exec/apply/internal"
656+
elif mode == CommandMode.HIGH_PRIORITY:
657+
url = "exec/apply/highPriority"
658+
else:
659+
url = "exec/apply"
644660

645-
return response
661+
response: dict = await self.__post(url, final_payload)
662+
663+
return cast(str, response["execId"])
646664

647665
@retry_on_auth_error
648666
async def cancel_command(self, exec_id: str) -> None:
649667
"""Cancel a running setup-level execution."""
650668
await self.__delete(f"/exec/current/setup/{exec_id}")
651669

652-
@retry_on_auth_error
653-
async def execute_commands(
654-
self,
655-
device_url: str,
656-
commands: list[Command],
657-
label: str | None = "python-overkiz-api",
658-
) -> str:
659-
"""Send several commands in one call."""
660-
payload = {
661-
"label": label,
662-
"actions": [{"deviceURL": device_url, "commands": commands}],
663-
}
664-
response: dict = await self.__post("exec/apply", payload)
665-
return cast(str, response["execId"])
666-
667670
@retry_on_auth_error
668671
async def get_action_groups(self) -> list[ActionGroup]:
669672
"""List the action groups (scenarios)."""

pyoverkiz/models.py

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
UIWidget,
2323
UpdateBoxStatus,
2424
)
25+
from pyoverkiz.enums.command import OverkizCommand, OverkizCommandParam
2526
from pyoverkiz.enums.protocol import Protocol
2627
from pyoverkiz.obfuscate import obfuscate_email, obfuscate_id, obfuscate_string
2728
from pyoverkiz.types import DATA_TYPE_TO_PYTHON, StateType
@@ -465,19 +466,44 @@ def __len__(self) -> int:
465466
get = __getitem__
466467

467468

468-
class Command(dict):
469+
@define(init=False, kw_only=True)
470+
class Command:
469471
"""Represents an OverKiz Command."""
470472

471-
name: str
472-
parameters: list[str | int | float] | None
473+
type: int | None = None
474+
name: OverkizCommand
475+
parameters: list[str | int | float | OverkizCommandParam] | None
473476

474477
def __init__(
475-
self, name: str, parameters: list[str | int | float] | None = None, **_: Any
478+
self,
479+
name: OverkizCommand,
480+
parameters: list[str | int | float | OverkizCommandParam] | None = None,
481+
type: int | None = None,
482+
**_: Any,
476483
):
477484
"""Initialize a command instance and mirror fields into dict base class."""
478485
self.name = name
479486
self.parameters = parameters
480-
dict.__init__(self, name=name, parameters=parameters)
487+
self.type = type
488+
489+
def to_payload(self) -> dict[str, object]:
490+
"""Return a JSON-serializable payload for this command.
491+
492+
The payload uses snake_case keys; the client will convert to camelCase
493+
and apply small key fixes (like `deviceURL`) before sending.
494+
"""
495+
payload: dict[str, object] = {"name": str(self.name)}
496+
497+
if self.type is not None:
498+
payload["type"] = self.type
499+
500+
if self.parameters is not None:
501+
payload["parameters"] = [
502+
p if isinstance(p, (str, int, float, bool)) else str(p)
503+
for p in self.parameters # type: ignore[arg-type]
504+
]
505+
506+
return payload
481507

482508

483509
@define(init=False, kw_only=True)
@@ -600,10 +626,22 @@ class Action:
600626
device_url: str
601627
commands: list[Command]
602628

603-
def __init__(self, device_url: str, commands: list[dict[str, Any]]):
629+
def __init__(self, device_url: str, commands: list[dict[str, Any] | Command]):
604630
"""Initialize Action from API data and convert nested commands."""
605631
self.device_url = device_url
606-
self.commands = [Command(**c) for c in commands] if commands else []
632+
self.commands = [
633+
c if isinstance(c, Command) else Command(**c) for c in commands
634+
]
635+
636+
def to_payload(self) -> dict[str, object]:
637+
"""Return a JSON-serializable payload for this action (snake_case).
638+
639+
The final camelCase conversion is handled by the client.
640+
"""
641+
return {
642+
"device_url": self.device_url,
643+
"commands": [c.to_payload() for c in self.commands],
644+
}
607645

608646

609647
@define(init=False, kw_only=True)

pyoverkiz/serializers.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Helpers for preparing API payloads.
2+
3+
This module centralizes JSON key formatting and any small transport-specific
4+
fixes (like mapping "deviceUrl" -> "deviceURL"). Models should produce
5+
logical snake_case payloads and the client should call `prepare_payload`
6+
before sending the payload to Overkiz.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from typing import Any
12+
13+
import humps
14+
15+
# Small mapping for keys that need special casing beyond simple camelCase.
16+
_ABBREV_MAP: dict[str, str] = {"deviceUrl": "deviceURL"}
17+
18+
19+
def _fix_abbreviations(obj: Any) -> Any:
20+
if isinstance(obj, dict):
21+
out = {}
22+
for k, v in obj.items():
23+
k = _ABBREV_MAP.get(k, k)
24+
out[k] = _fix_abbreviations(v)
25+
return out
26+
if isinstance(obj, list):
27+
return [_fix_abbreviations(i) for i in obj]
28+
return obj
29+
30+
31+
def prepare_payload(payload: Any) -> Any:
32+
"""Convert snake_case payload to API-ready camelCase and apply fixes.
33+
34+
Example:
35+
payload = {"device_url": "x", "commands": [{"name": "close"}]}
36+
=> {"deviceURL": "x", "commands": [{"name": "close"}]}
37+
"""
38+
camel = humps.camelize(payload)
39+
return _fix_abbreviations(camel)

tests/test_client.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,41 @@ async def test_get_setup_options(
379379
for option in options:
380380
assert isinstance(option, Option)
381381

382+
@pytest.mark.asyncio
383+
async def test_execute_action_group_omits_none_fields(self, client: OverkizClient):
384+
"""Ensure `type` and `parameters` that are None are omitted from the request payload."""
385+
from pyoverkiz.enums.command import OverkizCommand
386+
from pyoverkiz.models import Action, Command
387+
388+
action = Action(
389+
"rts://2025-8464-6867/16756006",
390+
[Command(name=OverkizCommand.CLOSE, parameters=None, type=None)],
391+
)
392+
393+
resp = MockResponse('{"execId": "exec-123"}')
394+
395+
with patch.object(aiohttp.ClientSession, "post") as mock_post:
396+
mock_post.return_value = resp
397+
398+
exec_id = await client.execute_action_group([action])
399+
400+
assert exec_id == "exec-123"
401+
402+
assert mock_post.called
403+
_, kwargs = mock_post.call_args
404+
sent_json = kwargs.get("json")
405+
assert sent_json is not None
406+
407+
# The client should have converted payload to camelCase and applied
408+
# abbreviation fixes (deviceURL) before sending.
409+
action_sent = sent_json["actions"][0]
410+
assert action_sent.get("deviceURL") == action.device_url
411+
412+
cmd = action_sent["commands"][0]
413+
assert "type" not in cmd
414+
assert "parameters" not in cmd
415+
assert cmd["name"] == "close"
416+
382417
@pytest.mark.parametrize(
383418
"fixture_name, option_name, instance",
384419
[

tests/test_models.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,3 +300,33 @@ def test_bad_list_value(self):
300300
state = State(name="state", type=DataType.BOOLEAN, value=False)
301301
with pytest.raises(TypeError):
302302
assert state.value_as_list
303+
304+
305+
def test_command_to_payload_omits_none():
306+
"""Command.to_payload omits None fields from the resulting payload."""
307+
from pyoverkiz.enums.command import OverkizCommand
308+
from pyoverkiz.models import Command
309+
310+
cmd = Command(name=OverkizCommand.CLOSE, parameters=None, type=None)
311+
payload = cmd.to_payload()
312+
313+
assert payload == {"name": "close"}
314+
315+
316+
def test_action_to_payload_and_parameters_conversion():
317+
"""Action.to_payload converts nested Command enums to primitives."""
318+
from pyoverkiz.enums.command import OverkizCommand, OverkizCommandParam
319+
from pyoverkiz.models import Action, Command
320+
321+
cmd = Command(
322+
name=OverkizCommand.SET_LEVEL, parameters=[10, OverkizCommandParam.A], type=1
323+
)
324+
action = Action("rts://2025-8464-6867/16756006", [cmd])
325+
326+
payload = action.to_payload()
327+
328+
assert payload["device_url"] == "rts://2025-8464-6867/16756006"
329+
assert payload["commands"][0]["name"] == "setLevel"
330+
assert payload["commands"][0]["type"] == 1
331+
# parameters should be converted to primitives (enum -> str)
332+
assert payload["commands"][0]["parameters"] == [10, "A"]

tests/test_serializers.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Tests for pyoverkiz.serializers."""
2+
3+
from __future__ import annotations
4+
5+
from pyoverkiz.serializers import prepare_payload
6+
7+
8+
def test_prepare_payload_camelizes_and_fixes_device_url():
9+
"""Test that prepare_payload converts snake_case to camelCase and fixes abbreviations."""
10+
payload = {
11+
"label": "test",
12+
"actions": [{"device_url": "rts://1/2", "commands": [{"name": "close"}]}],
13+
}
14+
15+
final = prepare_payload(payload)
16+
17+
assert final["label"] == "test"
18+
assert "deviceURL" in final["actions"][0]
19+
assert final["actions"][0]["deviceURL"] == "rts://1/2"
20+
21+
22+
def test_prepare_payload_nested_lists_and_dicts():
23+
"""Test that prepare_payload handles nested lists and dicts correctly."""
24+
payload = {
25+
"actions": [
26+
{
27+
"device_url": "rts://1/2",
28+
"commands": [{"name": "setLevel", "parameters": [10, "A"]}],
29+
}
30+
]
31+
}
32+
33+
final = prepare_payload(payload)
34+
35+
cmd = final["actions"][0]["commands"][0]
36+
assert cmd["name"] == "setLevel"
37+
assert cmd["parameters"] == [10, "A"]

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)