Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
21 changes: 17 additions & 4 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,6 +981,10 @@ 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.

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.

.. warning::
Experimental (preview). This endpoint is not yet fully validated
and its behaviour or signature may change in a future release.
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