Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ Full documentation is available at **[imicknl.github.io/python-overkiz-api](http
- Somfy TaHoma Switch
- Thermor Cozytouch

\[*] _This server's authentication method isn't supported yet. To use it, obtain an access token (by sniffing the original app) and create a local user on the Overkiz API platform._
\[**] _Requires OAuth credentials provided by Rexel._
**\*** _This server's authentication method isn't supported yet. To use it, obtain an access token (by sniffing the original app) and create a local user on the Overkiz API platform._

**\*\*** _Requires OAuth credentials provided by Rexel._

## Installation

Expand Down
22 changes: 11 additions & 11 deletions docs/action-queue.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ Three commands for three different devices produce three actions in one action g

```python
# These three calls arrive within the delay window:
await client.execute_action_group([Action(device_url="io://1234-5678-1234/12345678", commands=[Command(name=OverkizCommand.CLOSE)])])
await client.execute_action_group([Action(device_url="io://1234-5678-1234/87654321", commands=[Command(name=OverkizCommand.OPEN)])])
await client.execute_action_group([Action(device_url="io://1234-5678-1234/11111111", commands=[Command(name=OverkizCommand.STOP)])])
await client.execute_action_group(actions=[Action(device_url="io://1234-5678-1234/12345678", commands=[Command(name=OverkizCommand.CLOSE)])])
await client.execute_action_group(actions=[Action(device_url="io://1234-5678-1234/87654321", commands=[Command(name=OverkizCommand.OPEN)])])
await client.execute_action_group(actions=[Action(device_url="io://1234-5678-1234/11111111", commands=[Command(name=OverkizCommand.STOP)])])

# Sent as one API call:
# ActionGroup(actions=[
Expand All @@ -35,8 +35,8 @@ await client.execute_action_group([Action(device_url="io://1234-5678-1234/111111
When two calls target the same device, the queue merges their commands into a single action:

```python
await client.execute_action_group([Action(device_url="io://1234-5678-1234/12345678", commands=[Command(name=OverkizCommand.CLOSE)])])
await client.execute_action_group([Action(device_url="io://1234-5678-1234/12345678", commands=[Command(name=OverkizCommand.SET_CLOSURE, parameters=[50])])])
await client.execute_action_group(actions=[Action(device_url="io://1234-5678-1234/12345678", commands=[Command(name=OverkizCommand.CLOSE)])])
await client.execute_action_group(actions=[Action(device_url="io://1234-5678-1234/12345678", commands=[Command(name=OverkizCommand.SET_CLOSURE, parameters=[50])])])

# Sent as one API call:
# ActionGroup(actions=[
Expand All @@ -47,8 +47,8 @@ await client.execute_action_group([Action(device_url="io://1234-5678-1234/123456
### Mixed — both behaviors combined

```python
await client.execute_action_group([Action(device_url="io://1234-5678-1234/12345678", commands=[Command(name=OverkizCommand.CLOSE)])])
await client.execute_action_group([
await client.execute_action_group(actions=[Action(device_url="io://1234-5678-1234/12345678", commands=[Command(name=OverkizCommand.CLOSE)])])
await client.execute_action_group(actions=[
Action(device_url="io://1234-5678-1234/87654321", commands=[Command(name=OverkizCommand.OPEN)]),
Action(device_url="io://1234-5678-1234/12345678", commands=[Command(name=OverkizCommand.SET_CLOSURE, parameters=[50])]),
])
Expand Down Expand Up @@ -91,8 +91,8 @@ action2 = Action(
commands=[Command(name=OverkizCommand.OPEN)],
)

task1 = asyncio.create_task(client.execute_action_group([action1]))
task2 = asyncio.create_task(client.execute_action_group([action2]))
task1 = asyncio.create_task(client.execute_action_group(actions=[action1]))
task2 = asyncio.create_task(client.execute_action_group(actions=[action2]))
exec_id1, exec_id2 = await asyncio.gather(task1, task2)

print(exec_id1 == exec_id2)
Expand Down Expand Up @@ -160,7 +160,7 @@ action = Action(
commands=[Command(name=OverkizCommand.CLOSE)],
)

exec_task = asyncio.create_task(client.execute_action_group([action]))
exec_task = asyncio.create_task(client.execute_action_group(actions=[action]))

# Give it time to enter the queue
await asyncio.sleep(0.05)
Expand Down Expand Up @@ -199,7 +199,7 @@ action = Action(
commands=[Command(name=OverkizCommand.CLOSE)],
)

exec_task = asyncio.create_task(client.execute_action_group([action]))
exec_task = asyncio.create_task(client.execute_action_group(actions=[action]))
await asyncio.sleep(0.01)

pending = client.get_pending_actions_count()
Expand Down
5 changes: 3 additions & 2 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,6 @@ pyOverkiz is an async Python library for interacting with Overkiz-based platform
- Somfy TaHoma Switch
- Thermor Cozytouch

\[*] _This server's authentication method isn't supported yet. To use it, obtain an access token (by sniffing the original app) and create a local user on the Overkiz API platform._
\[**] _Requires OAuth credentials provided by Rexel._
**\*** _This server's authentication method isn't supported yet. To use it, obtain an access token (by sniffing the original app) and create a local user on the Overkiz API platform._

**\*\*** _Requires OAuth credentials provided by Rexel._
2 changes: 2 additions & 0 deletions docs/migration-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ The command execution API has been consolidated into a single method.
)
```

`execute_action_group()` is keyword-only — pass `actions=[...]` (and optional `mode=`/`label=`) by name, not positionally.

v2 also supports sending actions to **multiple devices** in a single call and choosing an `ExecutionMode` (`HIGH_PRIORITY`, `GEOLOCATED`, `INTERNAL`). Execution modes are only supported by the Cloud API; the local API rejects them (see [Somfy-TaHoma-Developer-Mode#227](https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode/issues/227)).

### `Command` is no longer a `dict`
Expand Down
23 changes: 16 additions & 7 deletions pyoverkiz/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,7 @@ async def _execute_action_group_direct(

async def execute_action_group(
self,
*,
actions: list[Action],
mode: ExecutionMode | None = None,
label: str | None = "pyOverkiz",
Expand Down Expand Up @@ -906,17 +907,25 @@ async def get_device_manufacturer_references(
return converter.structure(response, list[DeviceManufacturerReference])

async def discover_gateways(self) -> list[GatewayCandidate]:
"""Discover selectable gateways. Raises TypeError if unsupported."""
"""Discover selectable gateways.

Raises:
UnsupportedOperationError: When the server does not support gateway selection.
"""
if not isinstance(self._auth, SupportsGatewaySelection):
raise TypeError(
raise UnsupportedOperationError(
f"{self.server_config.name} does not support gateway selection."
)
return await self._auth.discover_gateways()

def select_gateway(self, gateway_id: str) -> None:
"""Select the gateway to scope requests to. Raises TypeError if unsupported."""
"""Select the gateway to scope requests to.

Raises:
UnsupportedOperationError: When the server does not support gateway selection.
"""
if not isinstance(self._auth, SupportsGatewaySelection):
raise TypeError(
raise UnsupportedOperationError(
f"{self.server_config.name} does not support gateway selection."
)
self._auth.select_gateway(gateway_id)
Expand Down Expand Up @@ -972,9 +981,9 @@ async def open_local_pairing(self, gateway_id: str) -> Any:
During this window, new tokens can be registered directly on the
gateway without requiring developer mode.

.. warning::
Experimental (preview). This endpoint is not yet fully validated
and its behaviour or signature may change in a future release.
Returns the raw response. Observed as an empty dict on success, but the
shape under other conditions is not yet confirmed, so the response is
passed through as-is.
"""
return await self._post(f"config/{gateway_id}/local/openPairing")

Expand Down
15 changes: 9 additions & 6 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -753,7 +753,7 @@ async def test_execute_action_group_omits_none_fields(self, client: OverkizClien
with patch.object(aiohttp.ClientSession, "post") as mock_post:
mock_post.return_value = resp

exec_id = await client.execute_action_group([action])
exec_id = await client.execute_action_group(actions=[action])

assert exec_id == "exec-123"

Expand Down Expand Up @@ -981,7 +981,7 @@ async def test_execute_action_group_rts_close(self, client: OverkizClient):

with patch.object(aiohttp.ClientSession, "post") as mock_post:
mock_post.return_value = resp
exec_id = await client.execute_action_group([action])
exec_id = await client.execute_action_group(actions=[action])

assert exec_id == "ee7a5676-c68f-43a3-956d-6f5efc745954"
_, kwargs = mock_post.call_args
Expand Down Expand Up @@ -1010,7 +1010,7 @@ async def test_execute_action_group_multiple_rts_devices(

with patch.object(aiohttp.ClientSession, "post") as mock_post:
mock_post.return_value = resp
exec_id = await client.execute_action_group(actions)
exec_id = await client.execute_action_group(actions=actions)

assert exec_id == "aaa-bbb-ccc"
_, kwargs = mock_post.call_args
Expand Down Expand Up @@ -1200,7 +1200,7 @@ async def test_local_execute_action_group_rts_close(

with patch.object(aiohttp.ClientSession, "post") as mock_post:
mock_post.return_value = resp
exec_id = await local_client.execute_action_group([action])
exec_id = await local_client.execute_action_group(actions=[action])

assert exec_id == "45e52d27-3c08-4fd5-87f2-03d650b67f4b"

Expand Down Expand Up @@ -1255,9 +1255,12 @@ async def test_discover_gateways_delegates_to_strategy(
async def test_discover_gateways_raises_for_unsupported_strategy(
self, client: OverkizClient
) -> None:
"""discover_gateways raises TypeError when the strategy lacks the capability."""
"""discover_gateways raises UnsupportedOperationError when the strategy lacks the capability."""
# The default Somfy strategy does not implement SupportsGatewaySelection.
with pytest.raises(TypeError, match="does not support gateway selection"):
with pytest.raises(
exceptions.UnsupportedOperationError,
match="does not support gateway selection",
):
await client.discover_gateways()

def test_select_gateway_delegates_to_strategy(self, client: OverkizClient) -> None:
Expand Down
18 changes: 9 additions & 9 deletions tests/test_client_queue_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ async def test_client_without_queue_executes_immediately():
with patch.object(client, "_post", new_callable=AsyncMock) as mock_post:
mock_post.return_value = {"execId": "exec-123"}

result = await client.execute_action_group([action])
result = await client.execute_action_group(actions=[action])

# Should return exec_id directly (string)
assert isinstance(result, str)
Expand Down Expand Up @@ -62,9 +62,9 @@ async def test_client_with_queue_batches_actions():
mock_post.return_value = {"execId": "exec-batched"}

# Queue multiple actions quickly - start them as tasks to allow batching
task1 = asyncio.create_task(client.execute_action_group([actions[0]]))
task2 = asyncio.create_task(client.execute_action_group([actions[1]]))
task3 = asyncio.create_task(client.execute_action_group([actions[2]]))
task1 = asyncio.create_task(client.execute_action_group(actions=[actions[0]]))
task2 = asyncio.create_task(client.execute_action_group(actions=[actions[1]]))
task3 = asyncio.create_task(client.execute_action_group(actions=[actions[2]]))

# Give them a moment to queue
await asyncio.sleep(0.01)
Expand Down Expand Up @@ -111,7 +111,7 @@ async def test_client_manual_flush():
mock_post.return_value = {"execId": "exec-flushed"}

# Start execution as a task to allow checking pending count
exec_task = asyncio.create_task(client.execute_action_group([action]))
exec_task = asyncio.create_task(client.execute_action_group(actions=[action]))

# Give it a moment to queue
await asyncio.sleep(0.01)
Expand Down Expand Up @@ -151,7 +151,7 @@ async def test_client_close_flushes_queue():
mock_post.return_value = {"execId": "exec-closed"}

# Start execution as a task
exec_task = asyncio.create_task(client.execute_action_group([action]))
exec_task = asyncio.create_task(client.execute_action_group(actions=[action]))

# Give it a moment to queue
await asyncio.sleep(0.01)
Expand Down Expand Up @@ -192,8 +192,8 @@ async def test_client_queue_respects_max_actions():
mock_post.return_value = {"execId": "exec-123"}

# Add 2 actions as tasks to trigger flush
task1 = asyncio.create_task(client.execute_action_group([actions[0]]))
task2 = asyncio.create_task(client.execute_action_group([actions[1]]))
task1 = asyncio.create_task(client.execute_action_group(actions=[actions[0]]))
task2 = asyncio.create_task(client.execute_action_group(actions=[actions[1]]))

# Wait a bit for flush
await asyncio.sleep(0.05)
Expand All @@ -205,7 +205,7 @@ async def test_client_queue_respects_max_actions():
assert exec_id2 == "exec-123"

# Add third action - starts new batch
exec_id3 = await client.execute_action_group([actions[2]])
exec_id3 = await client.execute_action_group(actions=[actions[2]])

# Should have exec_id directly (waited for batch to complete)
assert exec_id3 == "exec-123"
Expand Down
Loading
Loading