Skip to content

Commit 50f4a78

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 <175728472+Copilot@users.noreply.github.com>
1 parent d33beb1 commit 50f4a78

8 files changed

Lines changed: 230 additions & 34 deletions

File tree

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
@@ -40,7 +40,7 @@
4040
SOMFY_CLIENT_SECRET,
4141
SUPPORTED_SERVERS,
4242
)
43-
from pyoverkiz.enums import APIType, Server
43+
from pyoverkiz.enums import APIType, CommandMode, Server
4444
from pyoverkiz.exceptions import (
4545
AccessDeniedToGatewayException,
4646
ActionGroupSetupNotFoundException,
@@ -74,8 +74,8 @@
7474
UnknownUserException,
7575
)
7676
from pyoverkiz.models import (
77+
Action,
7778
ActionGroup,
78-
Command,
7979
Device,
8080
Event,
8181
Execution,
@@ -90,6 +90,7 @@
9090
State,
9191
)
9292
from pyoverkiz.obfuscate import obfuscate_sensitive_data
93+
from pyoverkiz.serializers import prepare_payload
9394
from pyoverkiz.types import JSON
9495

9596
_LOGGER = logging.getLogger(__name__)
@@ -658,40 +659,42 @@ async def get_api_version(self) -> str:
658659

659660
@retry_on_too_many_executions
660661
@retry_on_auth_error
661-
async def execute_command(
662+
async def execute_action_group(
662663
self,
663-
device_url: str,
664-
command: Command | str,
664+
actions: list[Action],
665+
mode: CommandMode | None = None,
665666
label: str | None = "python-overkiz-api",
666667
) -> str:
667-
"""Send a command."""
668-
if isinstance(command, str):
669-
command = Command(command)
668+
"""Execute a non-persistent action group.
670669
671-
response: str = await self.execute_commands(device_url, [command], label)
670+
The executed action group does not have to be persisted on the server before use.
671+
Per-session rate-limit : 1 calls per 28min 48s period for all operations of the same category (exec)
672+
"""
673+
# Build a logical (snake_case) payload using model helpers and convert it
674+
# to the exact JSON schema expected by the API (camelCase + small fixes).
675+
payload = {"label": label, "actions": [a.to_payload() for a in actions]}
676+
677+
# Prepare final payload with camelCase keys and special abbreviation handling
678+
final_payload = prepare_payload(payload)
679+
680+
if mode == CommandMode.GEOLOCATED:
681+
url = "exec/apply/geolocated"
682+
elif mode == CommandMode.INTERNAL:
683+
url = "exec/apply/internal"
684+
elif mode == CommandMode.HIGH_PRIORITY:
685+
url = "exec/apply/highPriority"
686+
else:
687+
url = "exec/apply"
672688

673-
return response
689+
response: dict = await self.__post(url, final_payload)
690+
691+
return cast(str, response["execId"])
674692

675693
@retry_on_auth_error
676694
async def cancel_command(self, exec_id: str) -> None:
677695
"""Cancel a running setup-level execution."""
678696
await self.__delete(f"/exec/current/setup/{exec_id}")
679697

680-
@retry_on_auth_error
681-
async def execute_commands(
682-
self,
683-
device_url: str,
684-
commands: list[Command],
685-
label: str | None = "python-overkiz-api",
686-
) -> str:
687-
"""Send several commands in one call."""
688-
payload = {
689-
"label": label,
690-
"actions": [{"deviceURL": device_url, "commands": commands}],
691-
}
692-
response: dict = await self.__post("exec/apply", payload)
693-
return cast(str, response["execId"])
694-
695698
@retry_on_auth_error
696699
async def get_action_groups(self) -> list[ActionGroup]:
697700
"""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
@@ -470,6 +470,41 @@ async def test_get_setup_options(
470470
for option in options:
471471
assert isinstance(option, Option)
472472

473+
@pytest.mark.asyncio
474+
async def test_execute_action_group_omits_none_fields(self, client: OverkizClient):
475+
"""Ensure `type` and `parameters` that are None are omitted from the request payload."""
476+
from pyoverkiz.enums.command import OverkizCommand
477+
from pyoverkiz.models import Action, Command
478+
479+
action = Action(
480+
"rts://2025-8464-6867/16756006",
481+
[Command(name=OverkizCommand.CLOSE, parameters=None, type=None)],
482+
)
483+
484+
resp = MockResponse('{"execId": "exec-123"}')
485+
486+
with patch.object(aiohttp.ClientSession, "post") as mock_post:
487+
mock_post.return_value = resp
488+
489+
exec_id = await client.execute_action_group([action])
490+
491+
assert exec_id == "exec-123"
492+
493+
assert mock_post.called
494+
_, kwargs = mock_post.call_args
495+
sent_json = kwargs.get("json")
496+
assert sent_json is not None
497+
498+
# The client should have converted payload to camelCase and applied
499+
# abbreviation fixes (deviceURL) before sending.
500+
action_sent = sent_json["actions"][0]
501+
assert action_sent.get("deviceURL") == action.device_url
502+
503+
cmd = action_sent["commands"][0]
504+
assert "type" not in cmd
505+
assert "parameters" not in cmd
506+
assert cmd["name"] == "close"
507+
473508
@pytest.mark.parametrize(
474509
"fixture_name, option_name, instance",
475510
[

tests/test_models.py

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