diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f2e140bb..7122dd74 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -12,7 +12,7 @@ // Use 'forwardPorts' to make a list of ports inside the container available locally. // "forwardPorts": [], // Use 'postCreateCommand' to run commands after the container is created. - "postCreateCommand": "uv sync && uv run pre-commit install", + "postCreateCommand": "uv sync --all-extras --dev && uv run prek install", // Configure tool-specific properties. "customizations": { "vscode": { diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..c1183a52 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,56 @@ +name: docs + +on: + release: + types: + - created + pull_request: + paths: + - docs/** + - mkdocs.yml + - pyproject.toml + - README.md + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Set up uv + uses: astral-sh/setup-uv@v3 + + - name: Install docs dependencies + run: uv sync --all-extras --dev + + - name: Build site + run: uv run mkdocs build + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: site + + deploy: + if: github.event_name == 'release' + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 9dbf2a70..36f99058 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v6 @@ -35,7 +35,7 @@ jobs: - name: Install the project run: uv sync --all-extras --dev - - name: Run pre-commit + - name: Run prek (pre-commit checks) env: SKIP: ${{ github.ref == 'refs/heads/main' && 'no-commit-to-branch' || '' }} - run: uv run pre-commit run --show-diff-on-failure --color=always --all-files + run: uv run prek run --show-diff-on-failure --color=always --all-files diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5e7115e6..be8b74d5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,7 +25,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: "3.x" + python-version: "3.12" - name: Retrieve version from tag name id: retrieve-version diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0fd3b6e1..c5335458 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v6 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ac60a34d..afe85803 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.14.10 + rev: v0.15.11 hooks: # Run the linter. - id: ruff-check @@ -16,14 +16,14 @@ repos: - id: trailing-whitespace - id: end-of-file-fixer - id: check-json - exclude: .devcontainer + exclude: .devcontainer|.vscode - id: check-yaml - id: check-added-large-files - id: no-commit-to-branch stages: [pre-commit] args: [--branch, main] - repo: https://github.com/rhysd/actionlint - rev: v1.7.7 + rev: v1.7.12 hooks: - id: actionlint - repo: local diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..93673b00 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,18 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "label": "Docs: Serve (development)", + "type": "shell", + "command": "uv run mkdocs serve", + "problemMatcher": [] + }, + { + "label": "Docs: Build", + "type": "shell", + "command": "uv run mkdocs build --clean" + } + ] +} diff --git a/AGENTS.md b/AGENTS.md index fc9d0842..2cf0ced4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,10 +29,10 @@ Always use full type annotations, generics, and other modern practices. # Install all dependencies: uv sync - # Run linting (with ruff), pre-commit checks and type checking (with mypy). + # Run linting (with ruff), prek (pre-commit alternative) checks and type checking (with mypy). # Note when you run this, ruff will auto-format and sort imports, resolving any # linter warnings about import ordering: - uv run pre-commit run --all-files + uv run prek run --all-files # Run tests: uv run pytest diff --git a/README.md b/README.md index b9b9b7c4..94f949d5 100644 --- a/README.md +++ b/README.md @@ -35,11 +35,11 @@ pip install pyoverkiz ```python import asyncio -import time -from pyoverkiz.const import SUPPORTED_SERVERS +from pyoverkiz.auth.credentials import UsernamePasswordCredentials from pyoverkiz.client import OverkizClient -from pyoverkiz.enums import Server +from pyoverkiz.models import Action, Command +from pyoverkiz.enums import Server, OverkizCommand USERNAME = "" PASSWORD = "" @@ -47,25 +47,35 @@ PASSWORD = "" async def main() -> None: async with OverkizClient( - USERNAME, PASSWORD, server=SUPPORTED_SERVERS[Server.SOMFY_EUROPE] + server=Server.SOMFY_EUROPE, + credentials=UsernamePasswordCredentials(USERNAME, PASSWORD), ) as client: - try: - await client.login() - except Exception as exception: # pylint: disable=broad-except - print(exception) - return + await client.login() devices = await client.get_devices() for device in devices: - print(f"{device.label} ({device.id}) - {device.controllable_name}") + print(f"{device.label} ({device.device_url}) - {device.controllable_name}") print(f"{device.widget} - {device.ui_class}") + await client.execute_action_group( + actions=[ + Action( + device_url="io://1234-5678-1234/12345678", + commands=[ + Command(name=OverkizCommand.SET_CLOSURE, parameters=[100]) + ] + ) + ], + label="Execution via Python", + # mode=ExecutionMode.HIGH_PRIORITY + ) + while True: events = await client.fetch_events() print(events) - time.sleep(2) + await asyncio.sleep(2) asyncio.run(main()) @@ -75,15 +85,11 @@ asyncio.run(main()) ```python import asyncio -import time -import aiohttp +from pyoverkiz.auth.credentials import LocalTokenCredentials from pyoverkiz.client import OverkizClient -from pyoverkiz.const import SUPPORTED_SERVERS, OverkizServer -from pyoverkiz.enums import Server +from pyoverkiz.utils import create_local_server_config -USERNAME = "" -PASSWORD = "" LOCAL_GATEWAY = "gateway-xxxx-xxxx-xxxx.local" # or use the IP address of your gateway VERIFY_SSL = True # set verify_ssl to False if you don't use the .local hostname @@ -91,27 +97,14 @@ VERIFY_SSL = True # set verify_ssl to False if you don't use the .local hostnam async def main() -> None: token = "" # generate your token via the Somfy app and include it here - # Local Connection - session = aiohttp.ClientSession( - connector=aiohttp.TCPConnector(verify_ssl=VERIFY_SSL) - ) - async with OverkizClient( - username="", - password="", - token=token, - session=session, + server=create_local_server_config(host=LOCAL_GATEWAY), + credentials=LocalTokenCredentials(token), verify_ssl=VERIFY_SSL, - server=OverkizServer( - name="Somfy TaHoma (local)", - endpoint=f"https://{LOCAL_GATEWAY}:8443/enduser-mobile-web/1/enduserAPI/", - manufacturer="Somfy", - configuration_url=None, - ), ) as client: await client.login() - print("Local API connection succesfull!") + print("Local API connection successful!") print(await client.get_api_version()) @@ -122,14 +115,14 @@ async def main() -> None: print(devices) for device in devices: - print(f"{device.label} ({device.id}) - {device.controllable_name}") + print(f"{device.label} ({device.device_url}) - {device.controllable_name}") print(f"{device.widget} - {device.ui_class}") while True: events = await client.fetch_events() print(events) - time.sleep(2) + await asyncio.sleep(2) asyncio.run(main()) @@ -158,7 +151,7 @@ If you use Visual Studio Code with Docker or GitHub Codespaces, you can take adv - Install [uv](https://docs.astral.sh/uv/getting-started/installation). - Clone this repository and navigate to it: `cd python-overkiz-api` -- Initialize the project with `uv sync`, then run `uv run pre-commit install` +- Initialize the project with `uv sync`, then run `uv run prek install` #### Tests diff --git a/docs/action-queue.md b/docs/action-queue.md new file mode 100644 index 00000000..8a55c6b2 --- /dev/null +++ b/docs/action-queue.md @@ -0,0 +1,210 @@ +# Action queue + +The action queue automatically groups rapid, consecutive calls to `execute_action_group()` into a single ActionGroup execution. This minimizes the number of API calls and helps prevent rate limiting issues, such as `TooManyRequestsError`, `TooManyConcurrentRequestsError`, `TooManyExecutionsError`, or `ExecutionQueueFullError` which can occur if actions are sent individually in quick succession. + +## How batching and merging works + +The Overkiz API uses three levels of nesting: + +- **Command** — a single device instruction (e.g. `close`, `setClosure(50)`) +- **Action** — one device URL + one or more commands +- **ActionGroup** — a batch of actions submitted as a single API call + +The gateway enforces **one action per device** in each action group. The queue handles this automatically: when multiple actions target the same `device_url`, their commands are merged into a single action while preserving order. + +### Different devices — no merging needed + +Three commands for three different devices produce three actions in one action group: + +```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)])]) + +# Sent as one API call: +# ActionGroup(actions=[ +# Action(device_url="io://…/12345678", commands=[close]), +# Action(device_url="io://…/87654321", commands=[open]), +# Action(device_url="io://…/11111111", commands=[stop]), +# ]) +``` + +### Same device — commands are merged + +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])])]) + +# Sent as one API call: +# ActionGroup(actions=[ +# Action(device_url="io://…/12345678", commands=[close, setClosure(50)]), +# ]) +``` + +### 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([ + 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])]), +]) + +# Sent as one API call: +# ActionGroup(actions=[ +# Action(device_url="io://…/12345678", commands=[close, setClosure(50)]), # merged +# Action(device_url="io://…/87654321", commands=[open]), +# ]) +``` + +The original action objects passed to `execute_action_group()` are never mutated — the queue works on internal copies. + +## Enable with defaults + +Pass `ActionQueueSettings()` via `OverkizClientSettings` to enable batching with default settings: + +```python +import asyncio + +from pyoverkiz.action_queue import ActionQueueSettings +from pyoverkiz.auth import UsernamePasswordCredentials +from pyoverkiz.client import OverkizClient +from pyoverkiz.client import OverkizClientSettings +from pyoverkiz.enums import OverkizCommand, Server +from pyoverkiz.models import Action, Command + +client = OverkizClient( + server=Server.SOMFY_EUROPE, + credentials=UsernamePasswordCredentials("user@example.com", "password"), + settings=OverkizClientSettings(action_queue=ActionQueueSettings()), +) + +action1 = Action( + device_url="io://1234-5678-1234/12345678", + commands=[Command(name=OverkizCommand.CLOSE)], +) +action2 = Action( + device_url="io://1234-5678-1234/87654321", + commands=[Command(name=OverkizCommand.OPEN)], +) + +task1 = asyncio.create_task(client.execute_action_group([action1])) +task2 = asyncio.create_task(client.execute_action_group([action2])) +exec_id1, exec_id2 = await asyncio.gather(task1, task2) + +print(exec_id1 == exec_id2) +``` + +Defaults: +- `delay=0.5` +- `max_actions=20` + +## Advanced settings + +If you need to tune batching behavior, pass custom values to `ActionQueueSettings`: + +```python +import asyncio + +from pyoverkiz.action_queue import ActionQueueSettings +from pyoverkiz.client import OverkizClient +from pyoverkiz.client import OverkizClientSettings +from pyoverkiz.auth import UsernamePasswordCredentials +from pyoverkiz.enums import OverkizCommand, Server +from pyoverkiz.models import Action, Command + +client = OverkizClient( + server=Server.SOMFY_EUROPE, + credentials=UsernamePasswordCredentials("user@example.com", "password"), + settings=OverkizClientSettings( + action_queue=ActionQueueSettings( + delay=0.5, # seconds to wait before auto-flush + max_actions=20, # auto-flush when this count is reached + ), + ), +) +``` + +## `flush_action_queue()` (force immediate execution) + +Normally, queued actions are sent after the delay window or when `max_actions` is reached. Call `flush_action_queue()` to force the queue to execute immediately, which is useful when you want to send any pending actions without waiting for the delay timer to expire. + +```python +import asyncio + +from pyoverkiz.action_queue import ActionQueueSettings +from pyoverkiz.client import OverkizClient +from pyoverkiz.client import OverkizClientSettings +from pyoverkiz.auth import UsernamePasswordCredentials +from pyoverkiz.enums import OverkizCommand, Server +from pyoverkiz.models import Action, Command + +client = OverkizClient( + server=Server.SOMFY_EUROPE, + credentials=UsernamePasswordCredentials("user@example.com", "password"), + settings=OverkizClientSettings( + action_queue=ActionQueueSettings(delay=10.0), # long delay + ), +) + +action = Action( + device_url="io://1234-5678-1234/12345678", + commands=[Command(name=OverkizCommand.CLOSE)], +) + +exec_task = asyncio.create_task(client.execute_action_group([action])) + +# Give it time to enter the queue +await asyncio.sleep(0.05) + +# Force immediate execution instead of waiting 10 seconds +await client.flush_action_queue() + +exec_id = await exec_task +print(exec_id) +``` + +Why this matters: +- It lets you keep a long delay for batching, but still force a quick execution when a user interaction demands it. +- Useful before shutdown to avoid leaving actions waiting in the queue. + +## `get_pending_actions_count()` (best-effort count) + +`get_pending_actions_count()` returns a snapshot of how many actions are currently queued. Because the queue can change concurrently (and the method does not acquire the queue lock), the value is approximate. Use it for logging, diagnostics, or UI hints—not for critical control flow. + +```python +from pyoverkiz.action_queue import ActionQueueSettings +from pyoverkiz.client import OverkizClient +from pyoverkiz.client import OverkizClientSettings +from pyoverkiz.auth import UsernamePasswordCredentials +from pyoverkiz.enums import OverkizCommand, Server +from pyoverkiz.models import Action, Command + +client = OverkizClient( + server=Server.SOMFY_EUROPE, + credentials=UsernamePasswordCredentials("user@example.com", "password"), + settings=OverkizClientSettings(action_queue=ActionQueueSettings()), +) + +action = Action( + device_url="io://1234-5678-1234/12345678", + commands=[Command(name=OverkizCommand.CLOSE)], +) + +exec_task = asyncio.create_task(client.execute_action_group([action])) +await asyncio.sleep(0.01) + +pending = client.get_pending_actions_count() +print(f"Pending actions (approx): {pending}") + +exec_id = await exec_task +print(exec_id) +``` + +Why it’s best-effort: +- Actions may flush automatically while you read the count. +- New actions may be added concurrently by other tasks. +- The count can be briefly stale, so avoid using it to decide whether you must flush or not. diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 00000000..f10e1f8f --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,13 @@ +::: pyoverkiz.client.OverkizClient + +::: pyoverkiz.models + options: + show_source: false + +::: pyoverkiz.enums + options: + show_source: false + +::: pyoverkiz.exceptions + options: + show_source: false diff --git a/docs/apiDocDyn.js b/docs/api/apiDocDyn.js similarity index 100% rename from docs/apiDocDyn.js rename to docs/api/apiDocDyn.js diff --git a/docs/apiDocStyle.css b/docs/api/apiDocStyle.css similarity index 90% rename from docs/apiDocStyle.css rename to docs/api/apiDocStyle.css index 53eb1aec..8bc84794 100644 --- a/docs/apiDocStyle.css +++ b/docs/api/apiDocStyle.css @@ -95,6 +95,12 @@ td.pr { text-align: center; } +.m_patch .m { + color: white; + background-color: #88008f; + text-align: center; +} + .m_get .p { color: black; background-color: #e7f0f7; @@ -123,6 +129,13 @@ td.pr { border: 1px #efd5d6 solid; } +.m_patch .p { + color: black; + background-color: #f4e8f5; + text-align: left; + border: 1px #e9d5ef solid; +} + .m_get .d { color: black; background-color: #ffffff; @@ -151,6 +164,13 @@ td.pr { border: 1px #efd5d6 solid; } +.m_patch .d { + color: black; + background-color: #ffffff; + text-align: left; + border: 1px #eed5ef solid; +} + .m { border-top-right-radius: 5px; border-bottom-right-radius: 5px; @@ -160,7 +180,7 @@ td.pr { pre.m_desc, pre.c_desc { white-space: pre-wrap; word-wrap:break-word; - margin-left: 10px; + margin-left: 10px; } th { @@ -241,6 +261,18 @@ span.list:hover, span.expand:hover, span#listAll:hover, span#expandAll:hover, sp font-style: italic; } +.rateLimited { + margin-left: 10px; + color: orange; + font-style: italic; +} + +.scoped { + margin-left: 10px; + color: #3344FF; + font-style: italic; +} + span.list:hover, span.expand:hover { color: #6e6f70; } @@ -261,6 +293,10 @@ span.list:hover, span.expand:hover { color: #a3201c; } +.fs:hover .rel.m_patch { + color: #88008f; +} + .c_det { display: none; } diff --git a/docs/tahoma_api.html b/docs/api/index.html similarity index 57% rename from docs/tahoma_api.html rename to docs/api/index.html index 0796eded..927291ff 100644 --- a/docs/tahoma_api.html +++ b/docs/api/index.html @@ -1,16 +1,11 @@ + + + - - - - - - - API Documentation - End-User RESTful API - - - + + +