Skip to content

Commit 9b2bc1e

Browse files
committed
Enhance OverkizClient and models to support payload serialization with camelCase conversion and omission of None fields
1 parent daf2ab9 commit 9b2bc1e

File tree

6 files changed

+177
-7
lines changed

6 files changed

+177
-7
lines changed

pyoverkiz/client.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
State,
8787
)
8888
from pyoverkiz.obfuscate import obfuscate_sensitive_data
89+
from pyoverkiz.serializers import prepare_payload
8990
from pyoverkiz.types import JSON
9091

9192

@@ -659,10 +660,13 @@ async def execute_action_group(
659660
"""
660661

661662
"""Send several commands in one call"""
662-
payload = {
663-
"label": label,
664-
"actions": actions,
665-
}
663+
664+
# Build a logical (snake_case) payload using model helpers and convert it
665+
# to the exact JSON schema expected by the API (camelCase + small fixes).
666+
payload = {"label": label, "actions": [a.to_payload() for a in actions]}
667+
668+
# Prepare final payload with camelCase keys and special abbreviation handling
669+
final_payload = prepare_payload(payload)
666670

667671
if mode == CommandMode.GEOLOCATED:
668672
url = "exec/apply/geolocated"
@@ -673,7 +677,7 @@ async def execute_action_group(
673677
else:
674678
url = "exec/apply"
675679

676-
response: dict = await self.__post(url, payload)
680+
response: dict = await self.__post(url, final_payload)
677681

678682
return cast(str, response["execId"])
679683

pyoverkiz/models.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,25 @@ def __init__(
438438
self.parameters = parameters
439439
self.type = type
440440

441+
def to_payload(self) -> dict[str, object]:
442+
"""Return a JSON-serializable payload for this command.
443+
444+
The payload uses snake_case keys; the client will convert to camelCase
445+
and apply small key fixes (like `deviceURL`) before sending.
446+
"""
447+
payload: dict[str, object] = {"name": str(self.name)}
448+
449+
if self.type is not None:
450+
payload["type"] = self.type
451+
452+
if self.parameters is not None:
453+
payload["parameters"] = [
454+
p if isinstance(p, (str, int, float, bool)) else str(p)
455+
for p in self.parameters # type: ignore[arg-type]
456+
]
457+
458+
return payload
459+
441460

442461
@define(init=False, kw_only=True)
443462
class Event:
@@ -555,9 +574,21 @@ class Action:
555574
device_url: str
556575
commands: list[Command]
557576

558-
def __init__(self, device_url: str, commands: list[dict[str, Any]]):
577+
def __init__(self, device_url: str, commands: list[dict[str, Any] | Command]):
559578
self.device_url = device_url
560-
self.commands = [Command(**c) for c in commands] if commands else []
579+
self.commands = [
580+
c if isinstance(c, Command) else Command(**c) for c in commands
581+
]
582+
583+
def to_payload(self) -> dict[str, object]:
584+
"""Return a JSON-serializable payload for this action (snake_case).
585+
586+
The final camelCase conversion is handled by the client.
587+
"""
588+
return {
589+
"device_url": self.device_url,
590+
"commands": [c.to_payload() for c in self.commands],
591+
}
561592

562593

563594
@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
@@ -363,6 +363,41 @@ async def test_get_setup_options(
363363
for option in options:
364364
assert isinstance(option, Option)
365365

366+
@pytest.mark.asyncio
367+
async def test_execute_action_group_omits_none_fields(self, client: OverkizClient):
368+
"""Ensure `type` and `parameters` that are None are omitted from the request payload."""
369+
from pyoverkiz.enums.command import OverkizCommand
370+
from pyoverkiz.models import Action, Command
371+
372+
action = Action(
373+
"rts://2025-8464-6867/16756006",
374+
[Command(name=OverkizCommand.CLOSE, parameters=None, type=None)],
375+
)
376+
377+
resp = MockResponse('{"execId": "exec-123"}')
378+
379+
with patch.object(aiohttp.ClientSession, "post") as mock_post:
380+
mock_post.return_value = resp
381+
382+
exec_id = await client.execute_action_group([action])
383+
384+
assert exec_id == "exec-123"
385+
386+
assert mock_post.called
387+
_, kwargs = mock_post.call_args
388+
sent_json = kwargs.get("json")
389+
assert sent_json is not None
390+
391+
# The client should have converted payload to camelCase and applied
392+
# abbreviation fixes (deviceURL) before sending.
393+
action_sent = sent_json["actions"][0]
394+
assert action_sent.get("deviceURL") == action.device_url
395+
396+
cmd = action_sent["commands"][0]
397+
assert "type" not in cmd
398+
assert "parameters" not in cmd
399+
assert cmd["name"] == "close"
400+
366401
@pytest.mark.parametrize(
367402
"fixture_name, option_name, instance",
368403
[

tests/test_models.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,3 +274,31 @@ def test_bad_list_value(self):
274274
state = State(name="state", type=DataType.BOOLEAN, value=False)
275275
with pytest.raises(TypeError):
276276
assert state.value_as_list
277+
278+
279+
def test_command_to_payload_omits_none():
280+
from pyoverkiz.enums.command import OverkizCommand
281+
from pyoverkiz.models import Command
282+
283+
cmd = Command(name=OverkizCommand.CLOSE, parameters=None, type=None)
284+
payload = cmd.to_payload()
285+
286+
assert payload == {"name": "close"}
287+
288+
289+
def test_action_to_payload_and_parameters_conversion():
290+
from pyoverkiz.enums.command import OverkizCommand, OverkizCommandParam
291+
from pyoverkiz.models import Action, Command
292+
293+
cmd = Command(
294+
name=OverkizCommand.SET_LEVEL, parameters=[10, OverkizCommandParam.A], type=1
295+
)
296+
action = Action("rts://2025-8464-6867/16756006", [cmd])
297+
298+
payload = action.to_payload()
299+
300+
assert payload["device_url"] == "rts://2025-8464-6867/16756006"
301+
assert payload["commands"][0]["name"] == "setLevel"
302+
assert payload["commands"][0]["type"] == 1
303+
# parameters should be converted to primitives (enum -> str)
304+
assert payload["commands"][0]["parameters"] == [10, "A"]

tests/test_serializers.py

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

0 commit comments

Comments
 (0)