Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f1a958a
feat: add Somfy multi-account (multi-site) authentication
iMicknl Jul 5, 2026
519cc3f
chore: redact personal site names in tests; condense region-map comment
iMicknl Jul 5, 2026
e5970ad
refactor: parse Somfy BOB sites into typed models; rename warm/cold s…
iMicknl Jul 5, 2026
dd08d1d
docs: shorten Server.SOMFY placeholder-endpoint comment
iMicknl Jul 5, 2026
55e35b9
feat: expose session resume publicly; document Somfy multi-account; t…
iMicknl Jul 5, 2026
102a145
feat: offer local API for Server.SOMFY multi-account
iMicknl Jul 6, 2026
806dfc0
fix: map Somfy refresh invalid_grant to SomfyBadCredentialsError
iMicknl Jul 8, 2026
6fe9697
fix: keep the rotated Somfy refresh token across a relogin
iMicknl Aug 12, 2026
3247df3
fix: raise NoGatewaySelectedError when no Somfy site is selected
iMicknl Aug 12, 2026
ba25ff8
fix: serialize concurrent Somfy token refreshes
iMicknl Aug 12, 2026
d518ba2
fix: raise a typed error for an unknown persisted Somfy region
iMicknl Aug 12, 2026
8d9830a
fix: tolerate a non-JSON Somfy refresh error body
iMicknl Aug 12, 2026
e653f15
fix: bound the Somfy token lifetime when expires_in is missing
iMicknl Aug 12, 2026
dd561fd
fix: page through the Somfy BOB site listing
iMicknl Aug 12, 2026
d2ac5f4
refactor: split the Somfy password grant from the refresh grant
iMicknl Aug 12, 2026
684dce8
feat: map the countries the Somfy app's region list omits
iMicknl Aug 12, 2026
c7f2e46
test: cover the remaining Somfy auth branches
iMicknl Aug 12, 2026
732f67e
Do not fail a Somfy request when persisting the rotated token fails
iMicknl Aug 12, 2026
241eb32
Document push vs pull token ownership
iMicknl Aug 12, 2026
7f80cbe
Surface the account roles for each discovered Somfy site
iMicknl Aug 12, 2026
f21d17e
Merge remote-tracking branch 'origin/main' into feat/somfy-multi-account
iMicknl Aug 12, 2026
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
139 changes: 138 additions & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,89 @@ Use a cloud server when you want to connect through the vendor’s public API. U
asyncio.run(main())
```

=== "Somfy (multi-account cloud)"

Use `Server.SOMFY` with `UsernamePasswordCredentials` when a single Somfy
account owns or is invited to **multiple sites (homes)** — the "multi
account sign-in" feature of the TaHoma app. Unlike the region-specific
`Server.SOMFY_EUROPE`/`SOMFY_AMERICA`/`SOMFY_OCEANIA` servers, `Server.SOMFY`
is region-agnostic: it discovers every site on the account and resolves the
correct regional endpoint for the one you select.

```python
import asyncio

from pyoverkiz.auth.credentials import UsernamePasswordCredentials
from pyoverkiz.client import OverkizClient
from pyoverkiz.enums import Server


async def main() -> None:
async with OverkizClient(
server=Server.SOMFY,
credentials=UsernamePasswordCredentials("you@example.com", "password"),
) as client:
# Skip the event listener: it cannot be registered before a site is
# selected, since requests are scoped to the selected site.
await client.login(register_event_listener=False)

# A sole site is auto-selected; otherwise pick one explicitly.
gateways = await client.discover_gateways()
if len(gateways) > 1:
client.select_gateway(gateways[0].gateway_id)

# Client is now scoped to the selected site and ready to use.
setup = await client.get_setup()
print(f"{len(setup.devices)} device(s)")

# Only needed if you want to poll events.
await client.register_event_listener()

asyncio.run(main())
```

Each `GatewayCandidate` from `discover_gateways()` carries a human-readable
`label` (the site name) and `home_id`, so a multi-site UI can let the user
pick before calling `select_gateway`.

Requests made before a site is selected raise `NoGatewaySelectedError`: the
account-wide token is not site-scoped, so there is no sensible site to talk
to yet.

**Resume without a password.** After selecting a site, call
`client.to_credentials()` to snapshot the session as `SomfyTokenCredentials`
(a refresh token scoped to the selected site). Persist it and pass it back on
the next run to log in without the password grant, token exchange, or
discovery. The refresh token rotates, so supply an `on_token_refresh`
callback to re-persist it.

```python
import asyncio

from pyoverkiz.auth.credentials import SomfyTokenCredentials
from pyoverkiz.client import OverkizClient
from pyoverkiz.enums import Server


async def persist(refresh_token: str) -> None:
# Store the rotated refresh token for next time.
...


async def main(stored: SomfyTokenCredentials) -> None:
async with OverkizClient(server=Server.SOMFY, credentials=stored) as client:
await client.login() # no network round trips
setup = await client.get_setup()
print(f"{len(setup.devices)} device(s)")


# `stored` is what you persisted earlier via:
# stored = client.to_credentials(on_token_refresh=persist)
```

pyoverkiz owns this refresh cycle and pushes each rotated token to your
callback — see [Who owns the tokens](#who-owns-the-tokens).

=== "Somfy (local)"

Local authentication requires a token generated via the official mobile app. For details on obtaining a token, refer to [Somfy TaHoma Developer Mode](https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode).
Expand Down Expand Up @@ -232,7 +315,8 @@ Use a cloud server when you want to connect through the vendor’s public API. U
Supply a token in one of two ways:

**Async callback (recommended for long-running apps).** pyoverkiz calls it
before each request, so the owner can refresh and persist transparently.
before each request, so the owner can refresh and persist transparently —
see [Who owns the tokens](#who-owns-the-tokens).

```python
import asyncio
Expand Down Expand Up @@ -328,3 +412,56 @@ Use a cloud server when you want to connect through the vendor’s public API. U

asyncio.run(main())
```

## Who owns the tokens

Two of the servers keep a session alive across restarts without asking for the
password again, and they split the work in opposite directions. Which one applies
is not a preference — it follows from whether *you* are able to perform the
refresh at all.

| | Somfy multi-account (`SomfyTokenCredentials`) | Rexel (`RexelTokenCredentials`) |
| --- | --- | --- |
| Who refreshes | pyoverkiz | you |
| How you're involved | `on_token_refresh(new_token)` is **pushed** to you after each rotation | `access_token_callback()` is **pulled** from you before each request |
| What you store | the rotated refresh token | whatever your OAuth2 implementation needs |

**Somfy pushes, because only pyoverkiz can refresh.** A Somfy site token is
minted by a refresh grant scoped with `?siteOID=<site>` against the Ginaite
realm, and the response only means anything once interpreted as a site-scoped
token. That is internal knowledge, so pyoverkiz performs the refresh itself and
hands you the rotated refresh token to store:

```python
async def persist(refresh_token: str) -> None:
# Called only when the token actually changed. Store it.
...

credentials = client.to_credentials(on_token_refresh=persist)
```

The callback is fired only when the token changed, and only when resuming from
`SomfyTokenCredentials` — during a fresh password login there is nothing to
re-persist yet. If your callback raises, the error is logged and the request
still succeeds: the rotated token keeps working in memory, and the store is
retried on the next rotation. A restart is the only thing that would fall back
to the stale token, so a persistent store failure eventually means reauth.

**Rexel pulls, because you can refresh — and probably already do.** Rexel is
ordinary OAuth2, so a host application (Home Assistant's
`application_credentials` platform, for instance) already authorizes, refreshes
and persists tokens with its own implementation. Duplicating that inside
pyoverkiz would be the wrong answer, so pyoverkiz asks for the current token
whenever it needs one:

```python
async def get_access_token() -> str:
# Refresh upstream if needed, then return a currently-valid token.
...

credentials = RexelTokenCredentials(access_token_callback=get_access_token)
```

There is deliberately no pull option for Somfy: supplying a token yourself would
mean supplying an unscoped one, and requests would silently address the wrong
site.
27 changes: 25 additions & 2 deletions pyoverkiz/auth/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
from __future__ import annotations

import datetime
from collections.abc import Mapping
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass, field
from typing import Any, Protocol, runtime_checkable
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable

if TYPE_CHECKING:
from pyoverkiz.auth.credentials import SomfyTokenCredentials


@dataclass(slots=True)
Expand Down Expand Up @@ -66,6 +69,15 @@ class GatewayCandidate:
home_id: str | None = None
label: str | None = None
external_id: str | None = None
country: str | None = None
# Somfy only. Reported, not acted on: a site the account was merely invited
# to is listed like any other, and even the most limited access level keeps
# control of some devices, so such a site is expected to work in a reduced
# form rather than not at all. Filtering would also need an allowlist, and
# only `owner` and `secondary` are fixed values -- every custom or installer
# role is an opaque id, so unrecognised must not mean unusable. Callers get
# the roles to explain the reduction to a user instead.
roles: list[str] = field(default_factory=list)


@runtime_checkable
Expand All @@ -81,3 +93,14 @@ def select_gateway(self, gateway_id: str) -> None:
@property
def selected_gateway(self) -> str | None:
"""Return the currently selected gateway id, or None."""


@runtime_checkable
class SupportsSessionResume(Protocol):
"""Optional capability: snapshot the session for later resume without re-login."""

def to_credentials(
self,
on_token_refresh: Callable[[str], Awaitable[None]] | None = None,
) -> SomfyTokenCredentials:
"""Return resume credentials for the current session."""
119 changes: 119 additions & 0 deletions pyoverkiz/auth/bob.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""Models for the Somfy BOB back-office site directory.

A separate service from the Overkiz enduser API, with its own payload shapes
and casing, so it gets its own small cattrs converter rather than sharing
``pyoverkiz.converter``.
"""

from __future__ import annotations

from dataclasses import dataclass, field

import cattrs
from cattrs.gen import make_dict_structure_fn, override

from pyoverkiz.auth.base import GatewayCandidate


@dataclass(slots=True)
class BobGateway:
"""A gateway entry under a sub-site."""

gateway_id: str


@dataclass(slots=True)
class BobRole:
"""The authenticated account's role on a site.

Only ``owner`` and ``secondary`` are fixed wire values (the TaHoma app
labels them Administrator and Resident); every other value is a
server-issued role id -- custom roles, or the ``pro_full``/``pro_read``
installer roles -- which the app lumps together under Guest. There is no
client-side id-to-name table, so these stay opaque strings.
"""

role_oid: str | None = None


@dataclass(slots=True)
class BobSubSite:
"""A sub-site (setup) grouping one or more gateways."""

external_id: str | None = None
gateways: list[BobGateway] = field(default_factory=list)


@dataclass(slots=True)
class BobSite:
"""A site (home) the account owns or was invited to."""

site_oid: str
name: str | None = None
country: str | None = None
roles: list[BobRole] = field(default_factory=list)
sub_sites: list[BobSubSite] = field(default_factory=list)


@dataclass(slots=True)
class BobSitesResponse:
"""One page of the ``/sites`` listing, flattened on demand to gateway candidates."""

results: list[BobSite] = field(default_factory=list)
# Sites on the account, not in this page; 0 when BOB omits it.
total_count: int = 0

def gateway_candidates(self) -> list[GatewayCandidate]:
"""Flatten the site -> sub-site -> gateway tree into candidates."""
return [
GatewayCandidate(
gateway_id=gateway.gateway_id,
home_id=site.site_oid,
label=site.name,
external_id=sub.external_id,
country=site.country,
roles=[role.role_oid for role in site.roles if role.role_oid],
)
for site in self.results
for sub in site.sub_sites
for gateway in sub.gateways
]


def _make_bob_converter() -> cattrs.Converter:
# Converter (not GenConverter) so unknown BOB keys are dropped for forward-compat.
c = cattrs.Converter()
c.register_structure_hook(
BobGateway,
make_dict_structure_fn(BobGateway, c, gateway_id=override(rename="gatewayId")),
)
c.register_structure_hook(
BobRole,
make_dict_structure_fn(BobRole, c, role_oid=override(rename="roleOID")),
)
c.register_structure_hook(
BobSubSite,
make_dict_structure_fn(
BobSubSite, c, external_id=override(rename="externalOID")
),
)
c.register_structure_hook(
BobSite,
make_dict_structure_fn(
BobSite,
c,
site_oid=override(rename="siteOID"),
roles=override(rename="currentUserRoles"),
sub_sites=override(rename="subSites"),
),
)
c.register_structure_hook(
BobSitesResponse,
make_dict_structure_fn(
BobSitesResponse, c, total_count=override(rename="totalCount")
),
)
return c


bob_converter = _make_bob_converter()
16 changes: 16 additions & 0 deletions pyoverkiz/auth/credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,22 @@ class LocalTokenCredentials(TokenCredentials):
"""Credentials using a local API token."""


@dataclass(slots=True)
class SomfyTokenCredentials(Credentials):
"""Resume credentials for a previously-selected Somfy site (skips login + discovery).

Persist the ``refresh_token`` plus the site's ``site_oid`` and ``region``.
The refresh token rotates, so supply ``on_token_refresh`` to re-persist it;
otherwise a later reload fails.
"""

refresh_token: str = field(repr=False)
site_oid: str
region: str
gateway_id: str | None = None
on_token_refresh: Callable[[str], Awaitable[None]] | None = None


@dataclass(slots=True)
class RexelOAuthCodeCredentials(Credentials):
"""Credentials using Rexel OAuth2 authorization code with PKCE."""
Expand Down
14 changes: 14 additions & 0 deletions pyoverkiz/auth/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
LocalTokenCredentials,
RexelOAuthCodeCredentials,
RexelTokenCredentials,
SomfyTokenCredentials,
TokenCredentials,
UsernamePasswordCredentials,
)
Expand All @@ -24,6 +25,7 @@
RexelAuthStrategy,
RexelTokenAuthStrategy,
SessionLoginStrategy,
SomfyAccountAuthStrategy,
SomfyAuthStrategy,
)
from pyoverkiz.enums import APIType, Server
Expand Down Expand Up @@ -62,6 +64,18 @@ def build_auth_strategy(
ssl_context,
)

if server == Server.SOMFY:
# Resume from a persisted site-scoped refresh token, or fresh login
# from username/password.
if not isinstance(credentials, SomfyTokenCredentials):
credentials = _ensure_credentials(credentials, UsernamePasswordCredentials)
return SomfyAccountAuthStrategy(
credentials,
session,
server_config,
ssl_context,
)

if server in {
Server.ATLANTIC_COZYTOUCH,
Server.THERMOR_COZYTOUCH,
Expand Down
Loading