From f1a958a36412b7288b6dd1d7c8e25f581071464c Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sun, 5 Jul 2026 22:11:30 +0000 Subject: [PATCH 01/20] feat: add Somfy multi-account (multi-site) authentication Add Server.SOMFY with a region-agnostic multi-site auth strategy that lets a single Somfy account authenticate and control each of its sites through pyoverkiz, reusing every existing Overkiz endpoint call. - Password grant + Keycloak (Ginaite) token exchange, no browser/PKCE - Site discovery via the BOB directory; region resolved from a static country->region map mirroring the TaHoma app, with EMEA fallback - Per-site token minting and re-scoping on relogin - Warm-start credentials to skip rediscovery when the site is known --- pyoverkiz/auth/credentials.py | 22 ++ pyoverkiz/auth/factory.py | 14 + pyoverkiz/auth/strategies.py | 349 ++++++++++++++++++- pyoverkiz/const.py | 121 +++++++ pyoverkiz/enums/server.py | 1 + tests/test_auth.py | 620 +++++++++++++++++++++++++++++++++- 6 files changed, 1124 insertions(+), 3 deletions(-) diff --git a/pyoverkiz/auth/credentials.py b/pyoverkiz/auth/credentials.py index 93e8b3d4..7d2372c8 100644 --- a/pyoverkiz/auth/credentials.py +++ b/pyoverkiz/auth/credentials.py @@ -31,6 +31,28 @@ class LocalTokenCredentials(TokenCredentials): """Credentials using a local API token.""" +@dataclass(slots=True) +class SomfyTokenCredentials(Credentials): + """Warm-start credentials for a previously-selected Somfy site. + + Skips the password grant, Keycloak token exchange, and site discovery on + reload: the caller persists the Ginaite ``refresh_token`` plus the selected + site's ``site_oid`` and ``region``, and pyoverkiz mints a site-scoped access + token directly on the first request. ``gateway_id`` is optional bookkeeping + (the id the user selected) and is surfaced via ``selected_gateway``. + + Ginaite rotates the refresh token on refresh, so supply an async + ``on_token_refresh`` callback to re-persist the new refresh token; without + it a rotated token is only kept in memory and a later reload would fail. + """ + + 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.""" diff --git a/pyoverkiz/auth/factory.py b/pyoverkiz/auth/factory.py index 81912a07..5a085b18 100644 --- a/pyoverkiz/auth/factory.py +++ b/pyoverkiz/auth/factory.py @@ -11,6 +11,7 @@ LocalTokenCredentials, RexelOAuthCodeCredentials, RexelTokenCredentials, + SomfyTokenCredentials, TokenCredentials, UsernamePasswordCredentials, ) @@ -24,6 +25,7 @@ RexelAuthStrategy, RexelTokenAuthStrategy, SessionLoginStrategy, + SomfyAccountAuthStrategy, SomfyAuthStrategy, ) from pyoverkiz.enums import APIType, Server @@ -62,6 +64,18 @@ def build_auth_strategy( ssl_context, ) + if server == Server.SOMFY: + # Warm start from a persisted site-scoped refresh token, or cold start + # 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, diff --git a/pyoverkiz/auth/strategies.py b/pyoverkiz/auth/strategies.py index 972c20fc..0e545b1c 100644 --- a/pyoverkiz/auth/strategies.py +++ b/pyoverkiz/auth/strategies.py @@ -5,9 +5,11 @@ import asyncio import base64 import binascii +import datetime import json +import logging import ssl -from collections.abc import Mapping +from collections.abc import Awaitable, Callable, Mapping from http import HTTPStatus from typing import TYPE_CHECKING, Any, cast @@ -21,6 +23,7 @@ LocalTokenCredentials, RexelOAuthCodeCredentials, RexelTokenCredentials, + SomfyTokenCredentials, TokenCredentials, UsernamePasswordCredentials, ) @@ -40,8 +43,17 @@ REXEL_OAUTH_TOKEN_URL, REXEL_REQUIRED_CONSENT, SOMFY_API, + SOMFY_BOB_API_KEY, + SOMFY_BOB_SITE_API, SOMFY_CLIENT_ID, SOMFY_CLIENT_SECRET, + SOMFY_COUNTRY_REGION, + SOMFY_DEFAULT_REGION, + SOMFY_GINAITE_SUBJECT_ISSUER, + SOMFY_GINAITE_SUBJECT_TOKEN_TYPE, + SOMFY_GINAITE_TOKEN_EXCHANGE_GRANT, + SOMFY_GINAITE_TOKEN_URL, + SOMFY_REGION_ENDPOINT, ) from pyoverkiz.exceptions import ( BadCredentialsError, @@ -59,6 +71,8 @@ from pyoverkiz.models import ServerConfig from pyoverkiz.response_handler import check_response +_LOGGER = logging.getLogger(__name__) + MIN_JWT_SEGMENTS = 2 @@ -73,6 +87,40 @@ async def _raise_for_server_error(response: ClientResponse) -> None: await check_response(response) +async def _somfy_password_token( + session: ClientSession, username: str, password: str +) -> dict[str, Any]: + """Perform the Somfy Accounts password grant and return the raw token dict. + + Shared by SomfyAuthStrategy (single-site) and SomfyAccountAuthStrategy + (which feeds the returned access_token into the Keycloak token exchange). + """ + form = FormData( + { + "grant_type": "password", + "client_id": SOMFY_CLIENT_ID, + "client_secret": SOMFY_CLIENT_SECRET, + "username": username, + "password": password, + } + ) + async with session.post( + f"{SOMFY_API}/oauth/oauth/v2/token/jwt", + data=form, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) as response: + await _raise_for_server_error(response) + token = await response.json() + + if token.get("message") == "error.invalid.grant": + raise SomfyBadCredentialsError(token["message"]) + + if not token.get("access_token"): + raise SomfyServiceError("No Somfy access token provided.") + + return cast(dict[str, Any], token) + + class BaseAuthStrategy(AuthStrategy): """Base class for authentication strategies.""" @@ -198,6 +246,15 @@ async def auth_headers(self, path: str | None = None) -> Mapping[str, str]: async def _request_access_token( self, *, grant_type: str, extra_fields: Mapping[str, str] ) -> None: + if grant_type == "password": + token = await _somfy_password_token( + self.session, + self.credentials.username, + self.credentials.password, + ) + self.context.update_from_token(token) + return + form = FormData( { "grant_type": grant_type, @@ -225,6 +282,296 @@ async def _request_access_token( self.context.update_from_token(token) +class SomfyAccountAuthStrategy(BaseAuthStrategy): + """Somfy multi-site auth: Keycloak token exchange + BOB site directory. + + Reuses the Somfy Accounts password grant, exchanges the SSO token for a + Ginaite (Keycloak) token, then lists the account's sites from the BOB + directory. Selecting a site mints a site-scoped token whose Bearer drives + the classic Overkiz enduser API directly (no gateway header needed). + """ + + def __init__( + self, + credentials: UsernamePasswordCredentials | SomfyTokenCredentials, + session: ClientSession, + server: ServerConfig, + ssl_context: ssl.SSLContext | bool, + ) -> None: + """Create a Somfy multi-site strategy with a fresh auth context. + + Accepts either ``UsernamePasswordCredentials`` (cold start: password + grant + token exchange + discovery) or ``SomfyTokenCredentials`` (warm + start: a persisted refresh token scoped to an already-selected site, + skipping all three network round trips). + """ + super().__init__(session, server, ssl_context) + self.credentials = credentials + self.context = AuthContext() + self._sites: list[GatewayCandidate] = [] + self._site_country: dict[str, str] = {} + self._selected_site_oid: str | None = None + self._selected_gateway: str | None = None + self._selected_region: str | None = None + self._endpoint: str | None = None + # Warm-start refresh-token persistence (no-op for cold start). + self._on_token_refresh: Callable[[str], Awaitable[None]] | None = None + self._persisted_refresh_token: str | None = None + + async def login(self) -> None: + """Cold start (password) or warm start (persisted refresh token). + + With ``SomfyTokenCredentials`` this is a warm start: no password grant, + no token exchange, no discovery. We seed the context from the stored + refresh token and site scope, so the first request mints a site-scoped + access token via the existing refresh path. + + With ``UsernamePasswordCredentials`` this is the cold start: password + grant -> token exchange -> discover, then (re-)select a site. Relogin + (e.g. after ``NotAuthenticatedError``) mints a fresh, unscoped Ginaite + token that is not itself expired, so on a multi-site account the + previously-selected gateway must be re-selected to re-apply site + scoping; otherwise the unscoped global token would keep being served + against the still-selected region endpoint. If that gateway is no + longer present after rediscovery, drop the stale selection instead of + silently pointing at a gateway that's gone. + """ + if isinstance(self.credentials, SomfyTokenCredentials): + self._warm_start(self.credentials) + return + + token = await _somfy_password_token( + self.session, self.credentials.username, self.credentials.password + ) + await self._token_exchange(token["access_token"]) + await self.discover_gateways() + + known = {s.gateway_id for s in self._sites} + if self._selected_gateway and self._selected_gateway in known: + self.select_gateway(self._selected_gateway) + elif self._selected_gateway and self._selected_gateway not in known: + self._selected_gateway = None + self._selected_site_oid = None + self._endpoint = None + elif len(self._sites) == 1: + self.select_gateway(self._sites[0].gateway_id) + + def _warm_start(self, credentials: SomfyTokenCredentials) -> None: + """Seed site scope from persisted tokens; skip password/exchange/discover. + + Sets up exactly the state ``select_gateway`` would have produced, then + marks the (absent) access token expired so the first request mints a + site-scoped token via the existing ``refresh_if_needed`` -> ``_refresh`` + path. No network calls happen here. + """ + self.context.refresh_token = credentials.refresh_token + self.context.expires_at = datetime.datetime.now(datetime.UTC) + self._selected_site_oid = credentials.site_oid + self._selected_gateway = credentials.gateway_id + self._selected_region = credentials.region + self._endpoint = SOMFY_REGION_ENDPOINT[credentials.region] + self._on_token_refresh = credentials.on_token_refresh + self._persisted_refresh_token = credentials.refresh_token + + async def _token_exchange(self, sso_access_token: str) -> None: + """Exchange a Somfy Accounts SSO token for a Ginaite token (public client).""" + form = FormData( + { + "grant_type": SOMFY_GINAITE_TOKEN_EXCHANGE_GRANT, + "client_id": SOMFY_CLIENT_ID, + "subject_token": sso_access_token, + "subject_issuer": SOMFY_GINAITE_SUBJECT_ISSUER, + "subject_token_type": SOMFY_GINAITE_SUBJECT_TOKEN_TYPE, + } + ) + async with self.session.post(SOMFY_GINAITE_TOKEN_URL, data=form) as response: + await _raise_for_server_error(response) + if response.status != HTTPStatus.OK: + raise SomfyServiceError( + f"Somfy token exchange failed: {response.status}" + ) + self.context.update_from_token(await response.json()) + + async def discover_gateways(self) -> list[GatewayCandidate]: + """List the account's sites from BOB, flattened to gateway candidates.""" + data = await self._bob_get("sites?withGateways=true&limit=20&offset=0") + candidates: list[GatewayCandidate] = [] + self._site_country = {} + for site in data.get("results", []): + site_oid = str(site["siteOID"]) + label = site.get("name") + country = site.get("country") + for sub in site.get("subSites", []): + external_id = sub.get("externalOID") + for gateway in sub.get("gateways", []): + gateway_id = str(gateway["gatewayId"]) + if country is not None: + self._site_country[gateway_id] = str(country) + candidates.append( + GatewayCandidate( + gateway_id=gateway_id, + home_id=site_oid, + label=label, + external_id=( + str(external_id) if external_id is not None else None + ), + ) + ) + self._sites = candidates + return candidates + + def select_gateway(self, gateway_id: str) -> None: + """Scope subsequent requests to the given gateway's site and region.""" + site = next( + (s for s in self._sites if s.gateway_id == gateway_id), + None, + ) + if site is None: + raise SomfyServiceError(f"Unknown gateway id: {gateway_id}") + + region = self._region_for_country(self._site_country.get(gateway_id)) + + self._selected_gateway = gateway_id + self._selected_site_oid = site.home_id + self._selected_region = region + self._endpoint = SOMFY_REGION_ENDPOINT[region] + # Force the next request to mint a site-scoped token via refresh. + self.context.expires_at = datetime.datetime.now(datetime.UTC) + + @staticmethod + def _region_for_country(country: str | None) -> str: + """Map an ISO country to an Overkiz region, defaulting to EMEA. + + Mirrors the TaHoma app's BusinessArea.fromCountry: known countries map + to their region, and anything unresolvable falls back to EMEA. A country + we cannot resolve (missing, or present but unmapped) is logged, since it + likely means the map needs updating for a newly supported region. + """ + region = SOMFY_COUNTRY_REGION.get(country.upper()) if country else None + if region is None: + _LOGGER.warning( + "Unresolvable Somfy site country %r; falling back to %s region", + country, + SOMFY_DEFAULT_REGION, + ) + return SOMFY_DEFAULT_REGION + return region + + @property + def selected_gateway(self) -> str | None: + """Return the currently selected gateway id, or None.""" + return self._selected_gateway + + def warm_start_credentials( + self, + on_token_refresh: Callable[[str], Awaitable[None]] | None = None, + ) -> SomfyTokenCredentials: + """Snapshot the current session as reusable warm-start credentials. + + Call this after login + gateway selection to persist the state a reload + needs (refresh token + site scope + region), skipping password grant, + token exchange, and discovery next time. Raises if no site is selected + or no refresh token is available yet. + """ + if ( + self._selected_site_oid is None + or self._selected_region is None + or self.context.refresh_token is None + ): + raise SomfyServiceError( + "Cannot snapshot warm-start credentials before a site is " + "selected and a refresh token is available." + ) + return SomfyTokenCredentials( + refresh_token=self.context.refresh_token, + site_oid=self._selected_site_oid, + region=self._selected_region, + gateway_id=self._selected_gateway, + on_token_refresh=on_token_refresh, + ) + + @property + def endpoint(self) -> str: + """Return the resolved per-site endpoint, or the server placeholder.""" + return self._endpoint or self.server.endpoint + + async def refresh_if_needed(self) -> bool: + """Mint/refresh a site-scoped token when expired. + + Raises if a site has been selected but there is no refresh token to + mint the site-scoped token with, rather than silently continuing to + serve the unscoped global token against the site's region endpoint. + """ + if not self.context.is_expired(): + return False + if not self.context.refresh_token: + if self._selected_site_oid: + raise SomfyServiceError( + "Cannot mint a site-scoped Somfy token without a refresh token." + ) + return False + await self._refresh() + return True + + async def _refresh(self) -> None: + """Refresh grant scoped to the selected site (?siteOID).""" + url = SOMFY_GINAITE_TOKEN_URL + if self._selected_site_oid: + url = f"{SOMFY_GINAITE_TOKEN_URL}?siteOID={self._selected_site_oid}" + previous_refresh_token = self.context.refresh_token + form = FormData( + { + "grant_type": "refresh_token", + "client_id": SOMFY_CLIENT_ID, + "refresh_token": cast(str, self.context.refresh_token), + } + ) + async with self.session.post(url, data=form) as response: + await _raise_for_server_error(response) + if response.status != HTTPStatus.OK: + raise SomfyServiceError( + f"Somfy token refresh failed: {response.status}" + ) + self.context.update_from_token(await response.json()) + + # Ginaite may omit refresh_token on a refresh; keep the working one + # rather than dropping to None (which would break the next warm start). + if self.context.refresh_token is None: + self.context.refresh_token = previous_refresh_token + await self._notify_token_refresh() + + async def _notify_token_refresh(self) -> None: + """Let a warm-start caller persist a rotated refresh token (no-op otherwise).""" + if ( + self._on_token_refresh is not None + and self.context.refresh_token is not None + and self.context.refresh_token != self._persisted_refresh_token + ): + self._persisted_refresh_token = self.context.refresh_token + await self._on_token_refresh(self.context.refresh_token) + + async def auth_headers(self, path: str | None = None) -> Mapping[str, str]: + """Return the Bearer header (site-scoped token), or {} before login.""" + if self.context.access_token: + return {"Authorization": f"Bearer {self.context.access_token}"} + return {} + + async def _bob_get(self, path: str) -> dict[str, Any]: + """GET a BOB site-directory resource with Bearer + X-Api-Key.""" + async with self.session.get( + f"{SOMFY_BOB_SITE_API}/{path}", + headers={ + "Authorization": f"Bearer {self.context.access_token}", + "X-Api-Key": SOMFY_BOB_API_KEY, + }, + ssl=self._ssl, + ) as response: + await _raise_for_server_error(response) + if response.status != HTTPStatus.OK: + raise SomfyServiceError(f"BOB request failed: {response.status}") + return cast(dict[str, Any], await response.json()) + + class CozytouchAuthStrategy(SessionLoginStrategy): """Authentication strategy using Cozytouch session-based login.""" diff --git a/pyoverkiz/const.py b/pyoverkiz/const.py index 3fd8ce27..c61fb9d3 100644 --- a/pyoverkiz/const.py +++ b/pyoverkiz/const.py @@ -42,6 +42,117 @@ # OAuth client secrets are public by design (embedded in mobile apps) SOMFY_CLIENT_SECRET = "12k73w1n540g8o4cokg0cw84cog840k84cwggscwg884004kgk" # noqa: S105 +# Somfy multi-site (Keycloak "Ginaite" realm + BOB back-office directory). +# The token exchange reuses SOMFY_CLIENT_ID as a PUBLIC client (no secret). +SOMFY_GINAITE_TOKEN_URL = ( + "https://ginaite-prod.ovkube.net/realms/somfy-tahoma/protocol/openid-connect/token" # noqa: S105 +) +SOMFY_GINAITE_SUBJECT_ISSUER = "somfy-customer" +SOMFY_GINAITE_TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange" # noqa: S105 +SOMFY_GINAITE_SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token" # noqa: S105 + +SOMFY_BOB_SITE_API = "https://backoffice-service.ovkube.net/site-api/public/v1" +SOMFY_BOB_API_KEY = "184638B3FBE874ACD24C14FBD657B" + +# The BOB directory carries no region field, so a site's Overkiz region is +# derived from its ISO 3166-1 alpha-2 country. This mirrors the TaHoma app's +# BusinessArea.fromCountry (com.somfy.homeapp v2.5.1): the region is a static, +# offline lookup — never runtime probing. All three regions' countries are +# enumerated so a genuinely unlisted country can be detected (and logged); it +# still falls back to EMEA, matching the app's fromCountry default. +# Verified live: NL -> EMEA. +SOMFY_DEFAULT_REGION = "EMEA" +SOMFY_REGION_ENDPOINT: MappingProxyType[str, str] = MappingProxyType( + { + "EMEA": "https://ha101-1.overkiz.com/enduser-mobile-web/enduserAPI/", + "APAC": "https://ha201-1.overkiz.com/enduser-mobile-web/enduserAPI/", + "SNABA": "https://ha401-1.overkiz.com/enduser-mobile-web/enduserAPI/", + } +) +SOMFY_COUNTRY_REGION: MappingProxyType[str, str] = MappingProxyType( + { + # Americas — ha401 (SNABA). + "CA": "SNABA", + "US": "SNABA", + "MX": "SNABA", + # Asia-Pacific — ha201 (APAC). + "AU": "APAC", + "HK": "APAC", + "IN": "APAC", + "ID": "APAC", + "JP": "APAC", + "MY": "APAC", + "NZ": "APAC", + "PH": "APAC", + "SG": "APAC", + "TW": "APAC", + "TH": "APAC", + "VN": "APAC", + "KR": "APAC", + "CN": "APAC", + # Europe, Middle East & Africa — ha101 (EMEA). + "AL": "EMEA", + "AD": "EMEA", + "AT": "EMEA", + "BY": "EMEA", + "BE": "EMEA", + "BG": "EMEA", + "HR": "EMEA", + "CY": "EMEA", + "CZ": "EMEA", + "DK": "EMEA", + "EG": "EMEA", + "EE": "EMEA", + "FO": "EMEA", + "FI": "EMEA", + "FR": "EMEA", + "GF": "EMEA", + "PF": "EMEA", + "DE": "EMEA", + "GR": "EMEA", + "GP": "EMEA", + "HU": "EMEA", + "IL": "EMEA", + "IT": "EMEA", + "JE": "EMEA", + "JO": "EMEA", + "KZ": "EMEA", + "KW": "EMEA", + "LV": "EMEA", + "LB": "EMEA", + "LT": "EMEA", + "LU": "EMEA", + "MQ": "EMEA", + "YT": "EMEA", + "MC": "EMEA", + "MA": "EMEA", + "NL": "EMEA", + "NO": "EMEA", + "NC": "EMEA", + "PS": "EMEA", + "PL": "EMEA", + "PT": "EMEA", + "QA": "EMEA", + "IE": "EMEA", + "RE": "EMEA", + "RO": "EMEA", + "RU": "EMEA", + "BL": "EMEA", + "SA": "EMEA", + "RS": "EMEA", + "SK": "EMEA", + "ZA": "EMEA", + "ES": "EMEA", + "SE": "EMEA", + "CH": "EMEA", + "TN": "EMEA", + "TR": "EMEA", + "UA": "EMEA", + "AE": "EMEA", + "GB": "EMEA", + } +) + # Brandt Smart Control middleware (cookie-session Rails API in front of Overkiz) BRANDT_MIDDLEWARE_API = "https://www.smartcontrol-app.com" BRANDT_PARTNER = "brandt-electromenager" @@ -134,6 +245,16 @@ manufacturer="Somfy", api_type=APIType.CLOUD, ), + Server.SOMFY: ServerConfig( + server=Server.SOMFY, + # Region-agnostic multi-site login. The endpoint here is a + # placeholder; SomfyAccountAuthStrategy overrides it per selected + # site once the region is resolved. + name="Somfy", + endpoint="https://ha101-1.overkiz.com/enduser-mobile-web/enduserAPI/", + manufacturer="Somfy", + api_type=APIType.CLOUD, + ), Server.SOMFY_EUROPE: ServerConfig( # alias of https://tahomalink.com server=Server.SOMFY_EUROPE, name="Somfy (Europe)", diff --git a/pyoverkiz/enums/server.py b/pyoverkiz/enums/server.py index 9970bddb..ff08d9f9 100644 --- a/pyoverkiz/enums/server.py +++ b/pyoverkiz/enums/server.py @@ -26,6 +26,7 @@ class Server(StrEnum): REXEL = "rexel" SAUTER_COZYTOUCH = "sauter_cozytouch" SIMU_LIVEIN2 = "simu_livein2" + SOMFY = "somfy" SOMFY_DEVELOPER_MODE = "somfy_developer_mode" SOMFY_EUROPE = "somfy_europe" SOMFY_AMERICA = "somfy_america" diff --git a/tests/test_auth.py b/tests/test_auth.py index e89384a3..736a4029 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,7 +1,7 @@ """Tests for authentication module.""" -# ruff: noqa: S105, S106 -# S105/S106: Test credentials use dummy values. +# ruff: noqa: S105, S106, S107 +# S105/S106/S107: Test credentials use dummy values. from __future__ import annotations @@ -9,6 +9,7 @@ import datetime import importlib.util import json +import logging import sys from unittest.mock import AsyncMock, MagicMock, patch @@ -222,6 +223,38 @@ async def test_build_auth_strategy_somfy(self): assert isinstance(strategy, SomfyAuthStrategy) + @pytest.mark.asyncio + async def test_build_auth_strategy_somfy_multisite(self): + """Server.SOMFY + username/password builds SomfyAccountAuthStrategy.""" + from pyoverkiz.auth.strategies import SomfyAccountAuthStrategy + from pyoverkiz.const import SUPPORTED_SERVERS + + strategy = build_auth_strategy( + server_config=SUPPORTED_SERVERS[Server.SOMFY], + credentials=UsernamePasswordCredentials("user", "pass"), + session=AsyncMock(spec=ClientSession), + ssl_context=True, + ) + + assert isinstance(strategy, SomfyAccountAuthStrategy) + + def test_build_auth_strategy_somfy_token_credentials(self): + """Server.SOMFY + SomfyTokenCredentials builds the warm-start strategy.""" + from pyoverkiz.auth.credentials import SomfyTokenCredentials + from pyoverkiz.auth.strategies import SomfyAccountAuthStrategy + from pyoverkiz.const import SUPPORTED_SERVERS + + strategy = build_auth_strategy( + server_config=SUPPORTED_SERVERS[Server.SOMFY], + credentials=SomfyTokenCredentials( + refresh_token="r", site_oid="s", region="EMEA" + ), + session=AsyncMock(spec=ClientSession), + ssl_context=True, + ) + + assert isinstance(strategy, SomfyAccountAuthStrategy) + @pytest.mark.asyncio async def test_build_auth_strategy_cozytouch(self): """Test building Cozytouch auth strategy.""" @@ -1404,3 +1437,586 @@ def test_rexel_token_strategy_supports_gateway_selection(): creds = RexelTokenCredentials(access_token="static-token") strategy, _ = _build_rexel_token_strategy([], credentials=creds) assert isinstance(strategy, SupportsGatewaySelection) + + +def test_somfy_multisite_constants_and_server(): + """Server.SOMFY and the Ginaite/BOB constants are defined and consistent.""" + from pyoverkiz.const import ( + SOMFY_BOB_API_KEY, + SOMFY_BOB_SITE_API, + SOMFY_COUNTRY_REGION, + SOMFY_GINAITE_SUBJECT_ISSUER, + SOMFY_GINAITE_SUBJECT_TOKEN_TYPE, + SOMFY_GINAITE_TOKEN_EXCHANGE_GRANT, + SOMFY_GINAITE_TOKEN_URL, + SOMFY_REGION_ENDPOINT, + SUPPORTED_SERVERS, + ) + from pyoverkiz.enums import Server + + assert Server.SOMFY == "somfy" + assert SOMFY_GINAITE_TOKEN_URL.endswith("/protocol/openid-connect/token") + assert SOMFY_GINAITE_SUBJECT_ISSUER == "somfy-customer" + assert SOMFY_GINAITE_TOKEN_EXCHANGE_GRANT == ( + "urn:ietf:params:oauth:grant-type:token-exchange" + ) + assert ( + SOMFY_GINAITE_SUBJECT_TOKEN_TYPE + == "urn:ietf:params:oauth:token-type:access_token" + ) + assert SOMFY_BOB_SITE_API.endswith("/site-api/public/v1") + assert SOMFY_BOB_API_KEY == "184638B3FBE874ACD24C14FBD657B" + + # All three regions are enumerated; every mapped region has an endpoint. + assert SOMFY_COUNTRY_REGION["NL"] == "EMEA" + assert SOMFY_COUNTRY_REGION["US"] == "SNABA" + assert SOMFY_COUNTRY_REGION["JP"] == "APAC" + for region in SOMFY_COUNTRY_REGION.values(): + assert region in SOMFY_REGION_ENDPOINT + assert SOMFY_REGION_ENDPOINT["EMEA"] == ( + "https://ha101-1.overkiz.com/enduser-mobile-web/enduserAPI/" + ) + + config = SUPPORTED_SERVERS[Server.SOMFY] + assert config.server == Server.SOMFY + assert config.name == "Somfy" + + +@pytest.mark.asyncio +async def test_somfy_password_token_returns_token_dict(): + """_somfy_password_token posts the password grant and returns the token dict.""" + from unittest.mock import AsyncMock, MagicMock + + from aiohttp import ClientSession + + from pyoverkiz.auth.strategies import _somfy_password_token + + resp = MagicMock() + resp.status = 200 + resp.json = AsyncMock( + return_value={"access_token": "sso-abc", "refresh_token": "r1"} + ) + resp.__aenter__ = AsyncMock(return_value=resp) + resp.__aexit__ = AsyncMock(return_value=None) + session = MagicMock(spec=ClientSession) + session.post = MagicMock(return_value=resp) + + token = await _somfy_password_token(session, "user", "pass") + + assert token["access_token"] == "sso-abc" + + +@pytest.mark.asyncio +async def test_somfy_password_token_bad_credentials(): + """error.invalid.grant maps to SomfyBadCredentialsError.""" + from unittest.mock import AsyncMock, MagicMock + + from aiohttp import ClientSession + + from pyoverkiz.auth.strategies import _somfy_password_token + from pyoverkiz.exceptions import SomfyBadCredentialsError + + resp = MagicMock() + resp.status = 200 + resp.json = AsyncMock(return_value={"message": "error.invalid.grant"}) + resp.__aenter__ = AsyncMock(return_value=resp) + resp.__aexit__ = AsyncMock(return_value=None) + session = MagicMock(spec=ClientSession) + session.post = MagicMock(return_value=resp) + + with pytest.raises(SomfyBadCredentialsError): + await _somfy_password_token(session, "user", "bad") + + +def _build_somfy_multisite_strategy(): + """Return a SomfyAccountAuthStrategy with a MagicMock session.""" + from unittest.mock import MagicMock + + from aiohttp import ClientSession + + from pyoverkiz.auth.credentials import UsernamePasswordCredentials + from pyoverkiz.auth.strategies import SomfyAccountAuthStrategy + from pyoverkiz.const import SUPPORTED_SERVERS + from pyoverkiz.enums import Server + + session = MagicMock(spec=ClientSession) + strategy = SomfyAccountAuthStrategy( + credentials=UsernamePasswordCredentials("user", "pass"), + session=session, + server=SUPPORTED_SERVERS[Server.SOMFY], + ssl_context=True, + ) + return strategy, session + + +def _json_ctx(body, status=200): + """A MagicMock aiohttp response context manager returning `body` as JSON.""" + from unittest.mock import AsyncMock, MagicMock + + resp = MagicMock() + resp.status = status + resp.json = AsyncMock(return_value=body) + resp.text = AsyncMock(return_value=str(body)) + ctx = MagicMock() + ctx.__aenter__ = AsyncMock(return_value=resp) + ctx.__aexit__ = AsyncMock(return_value=None) + return ctx + + +@pytest.mark.asyncio +async def test_somfy_multisite_token_exchange_populates_context(): + """_token_exchange stores the Ginaite access + refresh token.""" + strategy, session = _build_somfy_multisite_strategy() + session.post = MagicMock( + return_value=_json_ctx( + {"access_token": "ginaite-1", "refresh_token": "r-1", "expires_in": 900} + ) + ) + + await strategy._token_exchange("sso-access") + + assert strategy.context.access_token == "ginaite-1" + assert strategy.context.refresh_token == "r-1" + + +@pytest.mark.asyncio +async def test_somfy_multisite_token_exchange_error_raises(): + """A non-200 token exchange raises SomfyServiceError.""" + from pyoverkiz.exceptions import SomfyServiceError + + strategy, session = _build_somfy_multisite_strategy() + session.post = MagicMock(return_value=_json_ctx({"error": "bad"}, status=400)) + + with pytest.raises(SomfyServiceError): + await strategy._token_exchange("sso-access") + + +_BOB_SITES = { + "totalCount": 2, + "results": [ + { + "siteOID": "site-a", + "name": "Mick", + "country": "NL", + "currentUserRoles": [{"roleOID": "owner"}], + "subSites": [ + { + "externalOID": "ext-a", + "type": "SETUP", + "gateways": [{"gatewayId": "2025-0000-0001", "type": 98}], + } + ], + }, + { + "siteOID": "site-b", + "name": "Smientstraat", + "country": "NL", + "currentUserRoles": [{"roleOID": "owner"}], + "subSites": [ + { + "externalOID": "ext-b", + "type": "SETUP", + "gateways": [{"gatewayId": "1225-0000-0002", "type": 29}], + } + ], + }, + ], +} + + +@pytest.mark.asyncio +async def test_somfy_multisite_discover_flattens_sites(): + """discover_gateways returns one GatewayCandidate per gateway across sites.""" + strategy, session = _build_somfy_multisite_strategy() + strategy.context.access_token = "ginaite-1" + session.get = MagicMock(return_value=_json_ctx(_BOB_SITES)) + + candidates = await strategy.discover_gateways() + + assert [c.gateway_id for c in candidates] == ["2025-0000-0001", "1225-0000-0002"] + assert candidates[0].home_id == "site-a" + assert candidates[0].label == "Mick" + assert candidates[0].external_id == "ext-a" + + +@pytest.mark.asyncio +async def test_somfy_multisite_select_resolves_region_endpoint(): + """Selecting a gateway resolves its country to the EMEA endpoint.""" + strategy, session = _build_somfy_multisite_strategy() + strategy.context.access_token = "ginaite-1" + session.get = MagicMock(return_value=_json_ctx(_BOB_SITES)) + await strategy.discover_gateways() + + strategy.select_gateway("2025-0000-0001") + + assert strategy.selected_gateway == "2025-0000-0001" + assert strategy._selected_site_oid == "site-a" + assert strategy.endpoint == ( + "https://ha101-1.overkiz.com/enduser-mobile-web/enduserAPI/" + ) + + +@pytest.mark.asyncio +async def test_somfy_multisite_select_maps_non_default_region(): + """A country in the map resolves to its non-default region endpoint.""" + strategy, session = _build_somfy_multisite_strategy() + strategy.context.access_token = "ginaite-1" + us_site = { + "totalCount": 1, + "results": [ + { + "siteOID": "site-us", + "name": "Denver", + "country": "US", + "currentUserRoles": [{"roleOID": "owner"}], + "subSites": [ + {"externalOID": "ext-us", "gateways": [{"gatewayId": "gw-us"}]} + ], + } + ], + } + session.get = MagicMock(return_value=_json_ctx(us_site)) + await strategy.discover_gateways() + + strategy.select_gateway("gw-us") + + assert strategy.endpoint == ( + "https://ha401-1.overkiz.com/enduser-mobile-web/enduserAPI/" + ) + + +@pytest.mark.asyncio +async def test_somfy_multisite_select_unknown_country_falls_back_to_emea(caplog): + """An unknown country resolves to EMEA and logs a warning (matches the app).""" + strategy, session = _build_somfy_multisite_strategy() + strategy.context.access_token = "ginaite-1" + unknown = { + "totalCount": 1, + "results": [ + { + "siteOID": "site-x", + "name": "Mars", + "country": "ZZ", + "currentUserRoles": [{"roleOID": "owner"}], + "subSites": [ + {"externalOID": "ext-x", "gateways": [{"gatewayId": "gw-x"}]} + ], + } + ], + } + session.get = MagicMock(return_value=_json_ctx(unknown)) + await strategy.discover_gateways() + + with caplog.at_level(logging.WARNING): + strategy.select_gateway("gw-x") + + assert strategy.endpoint == ( + "https://ha101-1.overkiz.com/enduser-mobile-web/enduserAPI/" + ) + assert "ZZ" in caplog.text + assert "EMEA" in caplog.text + + +@pytest.mark.asyncio +async def test_somfy_multisite_select_missing_country_falls_back_to_emea(caplog): + """A site without a country resolves to EMEA and logs a warning.""" + strategy, session = _build_somfy_multisite_strategy() + strategy.context.access_token = "ginaite-1" + missing = { + "totalCount": 1, + "results": [ + { + "siteOID": "site-x", + "name": "Mystery", + "currentUserRoles": [{"roleOID": "owner"}], + "subSites": [ + {"externalOID": "ext-x", "gateways": [{"gatewayId": "gw-x"}]} + ], + } + ], + } + session.get = MagicMock(return_value=_json_ctx(missing)) + await strategy.discover_gateways() + + with caplog.at_level(logging.WARNING): + strategy.select_gateway("gw-x") + + assert strategy.endpoint == ( + "https://ha101-1.overkiz.com/enduser-mobile-web/enduserAPI/" + ) + assert "EMEA" in caplog.text + + +@pytest.mark.asyncio +async def test_somfy_multisite_endpoint_defaults_to_placeholder_before_select(): + """Before selection, endpoint falls back to the server config placeholder.""" + strategy, _ = _build_somfy_multisite_strategy() + assert strategy.endpoint == ( + "https://ha101-1.overkiz.com/enduser-mobile-web/enduserAPI/" + ) + + +@pytest.mark.asyncio +async def test_somfy_multisite_auth_headers(): + """auth_headers returns the Bearer token, or {} when absent (no gateway header).""" + strategy, _ = _build_somfy_multisite_strategy() + assert await strategy.auth_headers() == {} + strategy.context.access_token = "ginaite-1" + headers = await strategy.auth_headers() + assert headers == {"Authorization": "Bearer ginaite-1"} + assert "gatewayId" not in headers + + +@pytest.mark.asyncio +async def test_somfy_multisite_refresh_scopes_to_selected_site(): + """refresh_if_needed posts a refresh grant to the ?siteOID URL.""" + strategy, session = _build_somfy_multisite_strategy() + strategy.context.access_token = "ginaite-1" + strategy.context.refresh_token = "r-1" + session.get = MagicMock(return_value=_json_ctx(_BOB_SITES)) + await strategy.discover_gateways() + strategy.select_gateway("2025-0000-0001") # forces expiry + + posted = _json_ctx({"access_token": "scoped-1", "refresh_token": "r-2"}) + session.post = MagicMock(return_value=posted) + + refreshed = await strategy.refresh_if_needed() + + assert refreshed is True + assert strategy.context.access_token == "scoped-1" + # The refresh URL must carry ?siteOID=. + called_url = session.post.call_args.args[0] + assert "siteOID=site-a" in called_url + + +@pytest.mark.asyncio +async def test_somfy_multisite_refresh_without_refresh_token_raises(): + """No refresh_token after site selection must raise, not silently no-op. + + Without a refresh token, refresh_if_needed() can't mint the site-scoped + token, so it must not return False and let auth_headers() keep serving + the unscoped global token against the site's region endpoint. + """ + from pyoverkiz.exceptions import SomfyServiceError + + strategy, session = _build_somfy_multisite_strategy() + strategy.context.access_token = "ginaite-1" + strategy.context.refresh_token = None + session.get = MagicMock(return_value=_json_ctx(_BOB_SITES)) + await strategy.discover_gateways() + + strategy.select_gateway("2025-0000-0001") # forces expiry, no refresh_token + + with pytest.raises(SomfyServiceError): + await strategy.refresh_if_needed() + + +def _patch_somfy_login_tokens(strategy, *, ginaite_access_token="ginaite-fresh"): + """Patch the password grant + token exchange so login() can run offline. + + Returns a context manager patching the module-level password grant to a + fixed SSO token, and ``strategy._token_exchange`` to install a fresh, + unscoped, non-expired Ginaite token via the real ``update_from_token``. + """ + + def _install_fresh_token(_sso_access_token): + strategy.context.update_from_token( + { + "access_token": ginaite_access_token, + "refresh_token": "r-fresh", + "expires_in": 900, + } + ) + + return ( + patch( + "pyoverkiz.auth.strategies._somfy_password_token", + AsyncMock(return_value={"access_token": "sso-fresh"}), + ), + patch.object( + strategy, + "_token_exchange", + AsyncMock(side_effect=_install_fresh_token), + ), + ) + + +@pytest.mark.asyncio +async def test_somfy_multisite_relogin_rescopes_selected_gateway(): + """Relogin on a multi-site account must re-apply site scoping. + + A relogin mints a fresh, unscoped Ginaite token that is NOT expired + (expires_in=900), so without re-selecting the previously-selected + gateway, refresh_if_needed() would return False and auth_headers() would + serve the unscoped global token against the still-selected region + endpoint. The fix must re-select the gateway so the context is marked + expired again, forcing the next request to mint a site-scoped token. + """ + strategy, session = _build_somfy_multisite_strategy() + strategy.context.access_token = "ginaite-1" + session.get = MagicMock(return_value=_json_ctx(_BOB_SITES)) + await strategy.discover_gateways() + strategy.select_gateway("2025-0000-0001") + emea_endpoint = strategy.endpoint + + patch_password, patch_exchange = _patch_somfy_login_tokens(strategy) + with patch_password, patch_exchange: + await strategy.login() + + assert strategy.selected_gateway == "2025-0000-0001" + assert strategy.endpoint == emea_endpoint + # The fresh token must be treated as stale so the next request re-scopes it. + assert strategy.context.is_expired() + + +@pytest.mark.asyncio +async def test_somfy_multisite_relogin_drops_removed_gateway(): + """If the previously-selected gateway disappears on relogin, drop it. + + Rather than silently keep pointing at a gateway/endpoint that's gone + (e.g. access revoked), the stale selection state must be cleared. + """ + strategy, session = _build_somfy_multisite_strategy() + strategy.context.access_token = "ginaite-1" + session.get = MagicMock(return_value=_json_ctx(_BOB_SITES)) + await strategy.discover_gateways() + strategy.select_gateway("2025-0000-0001") + + reduced_sites = { + "totalCount": 1, + "results": [_BOB_SITES["results"][1]], # only site-b / 1225-0000-0002 + } + session.get = MagicMock(return_value=_json_ctx(reduced_sites)) + + patch_password, patch_exchange = _patch_somfy_login_tokens(strategy) + with patch_password, patch_exchange: + await strategy.login() + + assert strategy.selected_gateway is None + assert strategy._selected_site_oid is None + assert strategy.endpoint == strategy.server.endpoint + + +def _build_somfy_warmstart_strategy(**credential_overrides): + """Return a SomfyAccountAuthStrategy built from SomfyTokenCredentials.""" + from unittest.mock import MagicMock + + from aiohttp import ClientSession + + from pyoverkiz.auth.credentials import SomfyTokenCredentials + from pyoverkiz.auth.strategies import SomfyAccountAuthStrategy + from pyoverkiz.const import SUPPORTED_SERVERS + from pyoverkiz.enums import Server + + creds_kwargs = { + "refresh_token": "stored-r", + "site_oid": "site-b", + "region": "EMEA", + "gateway_id": "1225-0000-0002", + } + creds_kwargs.update(credential_overrides) + session = MagicMock(spec=ClientSession) + strategy = SomfyAccountAuthStrategy( + credentials=SomfyTokenCredentials(**creds_kwargs), + session=session, + server=SUPPORTED_SERVERS[Server.SOMFY], + ssl_context=True, + ) + return strategy, session + + +@pytest.mark.asyncio +async def test_somfy_warmstart_login_seeds_scope_without_http(): + """Warm-start login performs no network calls and seeds the site scope.""" + strategy, session = _build_somfy_warmstart_strategy() + + await strategy.login() + + # No password grant, no token exchange, no discovery. + session.post.assert_not_called() + session.get.assert_not_called() + assert strategy.selected_gateway == "1225-0000-0002" + assert strategy._selected_site_oid == "site-b" + assert strategy.endpoint == ( + "https://ha101-1.overkiz.com/enduser-mobile-web/enduserAPI/" + ) + # Token is marked expired so the first request mints a site-scoped one. + assert strategy.context.is_expired() + assert strategy.context.refresh_token == "stored-r" + + +@pytest.mark.asyncio +async def test_somfy_warmstart_first_refresh_scopes_to_site(): + """The first refresh after warm start mints a token via the ?siteOID URL.""" + strategy, session = _build_somfy_warmstart_strategy() + await strategy.login() + + session.post = MagicMock( + return_value=_json_ctx({"access_token": "scoped-1", "refresh_token": "r-2"}) + ) + refreshed = await strategy.refresh_if_needed() + + assert refreshed is True + assert strategy.context.access_token == "scoped-1" + assert "siteOID=site-b" in session.post.call_args.args[0] + + +@pytest.mark.asyncio +async def test_somfy_warmstart_credentials_roundtrip(): + """warm_start_credentials() snapshots a cold session's reusable state.""" + strategy, session = _build_somfy_multisite_strategy() + strategy.context.access_token = "ginaite-1" + strategy.context.refresh_token = "r-1" + session.get = MagicMock(return_value=_json_ctx(_BOB_SITES)) + await strategy.discover_gateways() + strategy.select_gateway("1225-0000-0002") + + snapshot = strategy.warm_start_credentials() + + assert snapshot.refresh_token == "r-1" + assert snapshot.site_oid == "site-b" + assert snapshot.region == "EMEA" + assert snapshot.gateway_id == "1225-0000-0002" + + +@pytest.mark.asyncio +async def test_somfy_warmstart_credentials_requires_selection(): + """Snapshotting before a site is selected raises rather than half-populating.""" + from pyoverkiz.exceptions import SomfyServiceError + + strategy, _ = _build_somfy_multisite_strategy() + + with pytest.raises(SomfyServiceError): + strategy.warm_start_credentials() + + +@pytest.mark.asyncio +async def test_somfy_warmstart_rotated_refresh_token_notifies(): + """A rotated refresh token fires on_token_refresh so the caller can persist.""" + persisted = [] + + async def _persist(token): + persisted.append(token) + + strategy, session = _build_somfy_warmstart_strategy(on_token_refresh=_persist) + await strategy.login() + + session.post = MagicMock( + return_value=_json_ctx({"access_token": "scoped-1", "refresh_token": "r-rot"}) + ) + await strategy.refresh_if_needed() + + assert persisted == ["r-rot"] + + +@pytest.mark.asyncio +async def test_somfy_warmstart_missing_refresh_token_preserved(): + """A refresh response without a refresh_token keeps the working one.""" + strategy, session = _build_somfy_warmstart_strategy() + await strategy.login() + + # Ginaite may omit refresh_token on refresh; the old one must survive. + session.post = MagicMock(return_value=_json_ctx({"access_token": "scoped-1"})) + await strategy.refresh_if_needed() + + assert strategy.context.refresh_token == "stored-r" From 519cc3f609367e21485dcfcc9cf2d730e7c6812a Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sun, 5 Jul 2026 22:22:19 +0000 Subject: [PATCH 02/20] chore: redact personal site names in tests; condense region-map comment --- pyoverkiz/const.py | 8 +------- tests/test_auth.py | 6 +++--- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/pyoverkiz/const.py b/pyoverkiz/const.py index c61fb9d3..55bde647 100644 --- a/pyoverkiz/const.py +++ b/pyoverkiz/const.py @@ -54,13 +54,7 @@ SOMFY_BOB_SITE_API = "https://backoffice-service.ovkube.net/site-api/public/v1" SOMFY_BOB_API_KEY = "184638B3FBE874ACD24C14FBD657B" -# The BOB directory carries no region field, so a site's Overkiz region is -# derived from its ISO 3166-1 alpha-2 country. This mirrors the TaHoma app's -# BusinessArea.fromCountry (com.somfy.homeapp v2.5.1): the region is a static, -# offline lookup — never runtime probing. All three regions' countries are -# enumerated so a genuinely unlisted country can be detected (and logged); it -# still falls back to EMEA, matching the app's fromCountry default. -# Verified live: NL -> EMEA. +# Site region derived offline from its ISO country, mirroring the TaHoma app's BusinessArea.fromCountry (EMEA fallback). SOMFY_DEFAULT_REGION = "EMEA" SOMFY_REGION_ENDPOINT: MappingProxyType[str, str] = MappingProxyType( { diff --git a/tests/test_auth.py b/tests/test_auth.py index 736a4029..51817e6e 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1596,7 +1596,7 @@ async def test_somfy_multisite_token_exchange_error_raises(): "results": [ { "siteOID": "site-a", - "name": "Mick", + "name": "My Home", "country": "NL", "currentUserRoles": [{"roleOID": "owner"}], "subSites": [ @@ -1609,7 +1609,7 @@ async def test_somfy_multisite_token_exchange_error_raises(): }, { "siteOID": "site-b", - "name": "Smientstraat", + "name": "Holiday Home", "country": "NL", "currentUserRoles": [{"roleOID": "owner"}], "subSites": [ @@ -1635,7 +1635,7 @@ async def test_somfy_multisite_discover_flattens_sites(): assert [c.gateway_id for c in candidates] == ["2025-0000-0001", "1225-0000-0002"] assert candidates[0].home_id == "site-a" - assert candidates[0].label == "Mick" + assert candidates[0].label == "My Home" assert candidates[0].external_id == "ext-a" From e5970adfa69259baefef8a6c1e58d0f7b413d474 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sun, 5 Jul 2026 22:33:54 +0000 Subject: [PATCH 03/20] refactor: parse Somfy BOB sites into typed models; rename warm/cold start to resume/fresh Replace the hand-walked triple-nested dict traversal in discover_gateways with typed BobSite/BobSubSite/BobGateway models parsed by a dedicated BOB cattrs converter, flattened via BobSitesResponse.gateway_candidates(). Carry each site's country on GatewayCandidate, dropping the parallel _site_country side-channel that discover_gateways and select_gateway shared. Also rename the warm/cold-start terminology to the industry-standard resume/fresh-login: SomfyTokenCredentials now yields a resumed session, _warm_start -> _resume_session, warm_start_credentials() -> to_credentials(). --- pyoverkiz/auth/base.py | 1 + pyoverkiz/auth/bob.py | 91 +++++++++++++++++++++++++++++++++++ pyoverkiz/auth/credentials.py | 2 +- pyoverkiz/auth/factory.py | 2 +- pyoverkiz/auth/strategies.py | 62 ++++++++---------------- tests/test_auth.py | 35 +++++++------- 6 files changed, 132 insertions(+), 61 deletions(-) create mode 100644 pyoverkiz/auth/bob.py diff --git a/pyoverkiz/auth/base.py b/pyoverkiz/auth/base.py index 3598bc5e..3a0f53d9 100644 --- a/pyoverkiz/auth/base.py +++ b/pyoverkiz/auth/base.py @@ -66,6 +66,7 @@ class GatewayCandidate: home_id: str | None = None label: str | None = None external_id: str | None = None + country: str | None = None @runtime_checkable diff --git a/pyoverkiz/auth/bob.py b/pyoverkiz/auth/bob.py new file mode 100644 index 00000000..12f6dd9d --- /dev/null +++ b/pyoverkiz/auth/bob.py @@ -0,0 +1,91 @@ +"""Models for the Somfy BOB back-office site directory. + +BOB is a separate service from the Overkiz enduser API, with its own payload +shapes (``siteOID``, ``subSites``, ``externalOID``). It therefore gets its own +tiny cattrs converter here rather than sharing ``pyoverkiz.converter``, which is +scoped to the enduser API and its camelCase convention. +""" + +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 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 + sub_sites: list[BobSubSite] = field(default_factory=list) + + +@dataclass(slots=True) +class BobSitesResponse: + """The ``/sites`` listing, flattened on demand to gateway candidates.""" + + results: list[BobSite] = field(default_factory=list) + + 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, + ) + 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( + 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"), + sub_sites=override(rename="subSites"), + ), + ) + return c + + +bob_converter = _make_bob_converter() diff --git a/pyoverkiz/auth/credentials.py b/pyoverkiz/auth/credentials.py index 7d2372c8..603f3cfb 100644 --- a/pyoverkiz/auth/credentials.py +++ b/pyoverkiz/auth/credentials.py @@ -33,7 +33,7 @@ class LocalTokenCredentials(TokenCredentials): @dataclass(slots=True) class SomfyTokenCredentials(Credentials): - """Warm-start credentials for a previously-selected Somfy site. + """Resume credentials for a previously-selected Somfy site. Skips the password grant, Keycloak token exchange, and site discovery on reload: the caller persists the Ginaite ``refresh_token`` plus the selected diff --git a/pyoverkiz/auth/factory.py b/pyoverkiz/auth/factory.py index 5a085b18..84772685 100644 --- a/pyoverkiz/auth/factory.py +++ b/pyoverkiz/auth/factory.py @@ -65,7 +65,7 @@ def build_auth_strategy( ) if server == Server.SOMFY: - # Warm start from a persisted site-scoped refresh token, or cold start + # Resume from a persisted site-scoped refresh token, or fresh login # from username/password. if not isinstance(credentials, SomfyTokenCredentials): credentials = _ensure_credentials(credentials, UsernamePasswordCredentials) diff --git a/pyoverkiz/auth/strategies.py b/pyoverkiz/auth/strategies.py index 0e545b1c..5b10ef0d 100644 --- a/pyoverkiz/auth/strategies.py +++ b/pyoverkiz/auth/strategies.py @@ -19,6 +19,7 @@ from aiohttp import ClientResponse, ClientSession, FormData from pyoverkiz.auth.base import AuthContext, AuthStrategy, GatewayCandidate +from pyoverkiz.auth.bob import BobSitesResponse, bob_converter from pyoverkiz.auth.credentials import ( LocalTokenCredentials, RexelOAuthCodeCredentials, @@ -300,33 +301,32 @@ def __init__( ) -> None: """Create a Somfy multi-site strategy with a fresh auth context. - Accepts either ``UsernamePasswordCredentials`` (cold start: password - grant + token exchange + discovery) or ``SomfyTokenCredentials`` (warm - start: a persisted refresh token scoped to an already-selected site, - skipping all three network round trips). + Accepts either ``UsernamePasswordCredentials`` (fresh login: password + grant + token exchange + discovery) or ``SomfyTokenCredentials`` + (resumed session: a persisted refresh token scoped to an already-selected + site, skipping all three network round trips). """ super().__init__(session, server, ssl_context) self.credentials = credentials self.context = AuthContext() self._sites: list[GatewayCandidate] = [] - self._site_country: dict[str, str] = {} self._selected_site_oid: str | None = None self._selected_gateway: str | None = None self._selected_region: str | None = None self._endpoint: str | None = None - # Warm-start refresh-token persistence (no-op for cold start). + # Refresh-token persistence for resumed sessions (no-op for fresh login). self._on_token_refresh: Callable[[str], Awaitable[None]] | None = None self._persisted_refresh_token: str | None = None async def login(self) -> None: - """Cold start (password) or warm start (persisted refresh token). + """Fresh login (password) or resumed session (persisted refresh token). - With ``SomfyTokenCredentials`` this is a warm start: no password grant, + With ``SomfyTokenCredentials`` this resumes a session: no password grant, no token exchange, no discovery. We seed the context from the stored refresh token and site scope, so the first request mints a site-scoped access token via the existing refresh path. - With ``UsernamePasswordCredentials`` this is the cold start: password + With ``UsernamePasswordCredentials`` this is a fresh login: password grant -> token exchange -> discover, then (re-)select a site. Relogin (e.g. after ``NotAuthenticatedError``) mints a fresh, unscoped Ginaite token that is not itself expired, so on a multi-site account the @@ -337,7 +337,7 @@ async def login(self) -> None: silently pointing at a gateway that's gone. """ if isinstance(self.credentials, SomfyTokenCredentials): - self._warm_start(self.credentials) + self._resume_session(self.credentials) return token = await _somfy_password_token( @@ -356,7 +356,7 @@ async def login(self) -> None: elif len(self._sites) == 1: self.select_gateway(self._sites[0].gateway_id) - def _warm_start(self, credentials: SomfyTokenCredentials) -> None: + def _resume_session(self, credentials: SomfyTokenCredentials) -> None: """Seed site scope from persisted tokens; skip password/exchange/discover. Sets up exactly the state ``select_gateway`` would have produced, then @@ -395,30 +395,9 @@ async def _token_exchange(self, sso_access_token: str) -> None: async def discover_gateways(self) -> list[GatewayCandidate]: """List the account's sites from BOB, flattened to gateway candidates.""" data = await self._bob_get("sites?withGateways=true&limit=20&offset=0") - candidates: list[GatewayCandidate] = [] - self._site_country = {} - for site in data.get("results", []): - site_oid = str(site["siteOID"]) - label = site.get("name") - country = site.get("country") - for sub in site.get("subSites", []): - external_id = sub.get("externalOID") - for gateway in sub.get("gateways", []): - gateway_id = str(gateway["gatewayId"]) - if country is not None: - self._site_country[gateway_id] = str(country) - candidates.append( - GatewayCandidate( - gateway_id=gateway_id, - home_id=site_oid, - label=label, - external_id=( - str(external_id) if external_id is not None else None - ), - ) - ) - self._sites = candidates - return candidates + response = bob_converter.structure(data, BobSitesResponse) + self._sites = response.gateway_candidates() + return self._sites def select_gateway(self, gateway_id: str) -> None: """Scope subsequent requests to the given gateway's site and region.""" @@ -429,7 +408,7 @@ def select_gateway(self, gateway_id: str) -> None: if site is None: raise SomfyServiceError(f"Unknown gateway id: {gateway_id}") - region = self._region_for_country(self._site_country.get(gateway_id)) + region = self._region_for_country(site.country) self._selected_gateway = gateway_id self._selected_site_oid = site.home_id @@ -462,11 +441,11 @@ def selected_gateway(self) -> str | None: """Return the currently selected gateway id, or None.""" return self._selected_gateway - def warm_start_credentials( + def to_credentials( self, on_token_refresh: Callable[[str], Awaitable[None]] | None = None, ) -> SomfyTokenCredentials: - """Snapshot the current session as reusable warm-start credentials. + """Snapshot the current session as reusable resume credentials. Call this after login + gateway selection to persist the state a reload needs (refresh token + site scope + region), skipping password grant, @@ -479,7 +458,7 @@ def warm_start_credentials( or self.context.refresh_token is None ): raise SomfyServiceError( - "Cannot snapshot warm-start credentials before a site is " + "Cannot snapshot resume credentials before a site is " "selected and a refresh token is available." ) return SomfyTokenCredentials( @@ -534,14 +513,13 @@ async def _refresh(self) -> None: ) self.context.update_from_token(await response.json()) - # Ginaite may omit refresh_token on a refresh; keep the working one - # rather than dropping to None (which would break the next warm start). + # refresh_token is optional in a refresh response (RFC 6749); reuse the old one if absent. if self.context.refresh_token is None: self.context.refresh_token = previous_refresh_token await self._notify_token_refresh() async def _notify_token_refresh(self) -> None: - """Let a warm-start caller persist a rotated refresh token (no-op otherwise).""" + """Let a resuming caller persist a rotated refresh token (no-op otherwise).""" if ( self._on_token_refresh is not None and self.context.refresh_token is not None diff --git a/tests/test_auth.py b/tests/test_auth.py index 51817e6e..ba008c7a 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -239,7 +239,7 @@ async def test_build_auth_strategy_somfy_multisite(self): assert isinstance(strategy, SomfyAccountAuthStrategy) def test_build_auth_strategy_somfy_token_credentials(self): - """Server.SOMFY + SomfyTokenCredentials builds the warm-start strategy.""" + """Server.SOMFY + SomfyTokenCredentials builds the resume strategy.""" from pyoverkiz.auth.credentials import SomfyTokenCredentials from pyoverkiz.auth.strategies import SomfyAccountAuthStrategy from pyoverkiz.const import SUPPORTED_SERVERS @@ -1637,6 +1637,7 @@ async def test_somfy_multisite_discover_flattens_sites(): assert candidates[0].home_id == "site-a" assert candidates[0].label == "My Home" assert candidates[0].external_id == "ext-a" + assert candidates[0].country == "NL" @pytest.mark.asyncio @@ -1897,7 +1898,7 @@ async def test_somfy_multisite_relogin_drops_removed_gateway(): assert strategy.endpoint == strategy.server.endpoint -def _build_somfy_warmstart_strategy(**credential_overrides): +def _build_somfy_resume_strategy(**credential_overrides): """Return a SomfyAccountAuthStrategy built from SomfyTokenCredentials.""" from unittest.mock import MagicMock @@ -1926,9 +1927,9 @@ def _build_somfy_warmstart_strategy(**credential_overrides): @pytest.mark.asyncio -async def test_somfy_warmstart_login_seeds_scope_without_http(): - """Warm-start login performs no network calls and seeds the site scope.""" - strategy, session = _build_somfy_warmstart_strategy() +async def test_somfy_resume_login_seeds_scope_without_http(): + """Resuming a session performs no network calls and seeds the site scope.""" + strategy, session = _build_somfy_resume_strategy() await strategy.login() @@ -1946,9 +1947,9 @@ async def test_somfy_warmstart_login_seeds_scope_without_http(): @pytest.mark.asyncio -async def test_somfy_warmstart_first_refresh_scopes_to_site(): - """The first refresh after warm start mints a token via the ?siteOID URL.""" - strategy, session = _build_somfy_warmstart_strategy() +async def test_somfy_resume_first_refresh_scopes_to_site(): + """The first refresh after resuming mints a token via the ?siteOID URL.""" + strategy, session = _build_somfy_resume_strategy() await strategy.login() session.post = MagicMock( @@ -1962,8 +1963,8 @@ async def test_somfy_warmstart_first_refresh_scopes_to_site(): @pytest.mark.asyncio -async def test_somfy_warmstart_credentials_roundtrip(): - """warm_start_credentials() snapshots a cold session's reusable state.""" +async def test_somfy_resume_credentials_roundtrip(): + """to_credentials() snapshots a fresh session's reusable state.""" strategy, session = _build_somfy_multisite_strategy() strategy.context.access_token = "ginaite-1" strategy.context.refresh_token = "r-1" @@ -1971,7 +1972,7 @@ async def test_somfy_warmstart_credentials_roundtrip(): await strategy.discover_gateways() strategy.select_gateway("1225-0000-0002") - snapshot = strategy.warm_start_credentials() + snapshot = strategy.to_credentials() assert snapshot.refresh_token == "r-1" assert snapshot.site_oid == "site-b" @@ -1980,25 +1981,25 @@ async def test_somfy_warmstart_credentials_roundtrip(): @pytest.mark.asyncio -async def test_somfy_warmstart_credentials_requires_selection(): +async def test_somfy_resume_credentials_requires_selection(): """Snapshotting before a site is selected raises rather than half-populating.""" from pyoverkiz.exceptions import SomfyServiceError strategy, _ = _build_somfy_multisite_strategy() with pytest.raises(SomfyServiceError): - strategy.warm_start_credentials() + strategy.to_credentials() @pytest.mark.asyncio -async def test_somfy_warmstart_rotated_refresh_token_notifies(): +async def test_somfy_resume_rotated_refresh_token_notifies(): """A rotated refresh token fires on_token_refresh so the caller can persist.""" persisted = [] async def _persist(token): persisted.append(token) - strategy, session = _build_somfy_warmstart_strategy(on_token_refresh=_persist) + strategy, session = _build_somfy_resume_strategy(on_token_refresh=_persist) await strategy.login() session.post = MagicMock( @@ -2010,9 +2011,9 @@ async def _persist(token): @pytest.mark.asyncio -async def test_somfy_warmstart_missing_refresh_token_preserved(): +async def test_somfy_resume_missing_refresh_token_preserved(): """A refresh response without a refresh_token keeps the working one.""" - strategy, session = _build_somfy_warmstart_strategy() + strategy, session = _build_somfy_resume_strategy() await strategy.login() # Ginaite may omit refresh_token on refresh; the old one must survive. From dd08d1da821a7561b4716ab17f662d5e0cbe4da1 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sun, 5 Jul 2026 22:36:11 +0000 Subject: [PATCH 04/20] docs: shorten Server.SOMFY placeholder-endpoint comment --- pyoverkiz/const.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pyoverkiz/const.py b/pyoverkiz/const.py index 55bde647..141fbd8e 100644 --- a/pyoverkiz/const.py +++ b/pyoverkiz/const.py @@ -241,10 +241,8 @@ ), Server.SOMFY: ServerConfig( server=Server.SOMFY, - # Region-agnostic multi-site login. The endpoint here is a - # placeholder; SomfyAccountAuthStrategy overrides it per selected - # site once the region is resolved. name="Somfy", + # Placeholder; SomfyAccountAuthStrategy sets the real endpoint per selected site. endpoint="https://ha101-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Somfy", api_type=APIType.CLOUD, From 55e35b9c4d455f01facb53c53c92951d898b16e9 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sun, 5 Jul 2026 22:45:45 +0000 Subject: [PATCH 05/20] feat: expose session resume publicly; document Somfy multi-account; trim docstrings Add SupportsSessionResume + OverkizClient.to_credentials() so the Somfy resume flow works through the public API instead of reaching into the private auth strategy. Document multi-account login and session resume in the getting-started guide. Condense the verbose docstrings/comments added earlier in this branch to one-liners. --- docs/getting-started.md | 71 +++++++++++++++++++++++++++++++++++ pyoverkiz/auth/base.py | 18 ++++++++- pyoverkiz/auth/bob.py | 7 ++-- pyoverkiz/auth/credentials.py | 14 ++----- pyoverkiz/auth/strategies.py | 66 ++++++-------------------------- pyoverkiz/client.py | 26 ++++++++++++- tests/test_client.py | 21 +++++++++++ 7 files changed, 152 insertions(+), 71 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index 45fa3aff..c81d2aff 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -74,6 +74,77 @@ 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: + await client.login() # auto-selects a sole site + + # Accounts with more than one site must select 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)") + + 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`. + + **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) + ``` + === "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). diff --git a/pyoverkiz/auth/base.py b/pyoverkiz/auth/base.py index 3a0f53d9..f736a5f7 100644 --- a/pyoverkiz/auth/base.py +++ b/pyoverkiz/auth/base.py @@ -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) @@ -82,3 +85,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.""" diff --git a/pyoverkiz/auth/bob.py b/pyoverkiz/auth/bob.py index 12f6dd9d..0d403650 100644 --- a/pyoverkiz/auth/bob.py +++ b/pyoverkiz/auth/bob.py @@ -1,9 +1,8 @@ """Models for the Somfy BOB back-office site directory. -BOB is a separate service from the Overkiz enduser API, with its own payload -shapes (``siteOID``, ``subSites``, ``externalOID``). It therefore gets its own -tiny cattrs converter here rather than sharing ``pyoverkiz.converter``, which is -scoped to the enduser API and its camelCase convention. +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 diff --git a/pyoverkiz/auth/credentials.py b/pyoverkiz/auth/credentials.py index 603f3cfb..f67d07ea 100644 --- a/pyoverkiz/auth/credentials.py +++ b/pyoverkiz/auth/credentials.py @@ -33,17 +33,11 @@ class LocalTokenCredentials(TokenCredentials): @dataclass(slots=True) class SomfyTokenCredentials(Credentials): - """Resume credentials for a previously-selected Somfy site. + """Resume credentials for a previously-selected Somfy site (skips login + discovery). - Skips the password grant, Keycloak token exchange, and site discovery on - reload: the caller persists the Ginaite ``refresh_token`` plus the selected - site's ``site_oid`` and ``region``, and pyoverkiz mints a site-scoped access - token directly on the first request. ``gateway_id`` is optional bookkeeping - (the id the user selected) and is surfaced via ``selected_gateway``. - - Ginaite rotates the refresh token on refresh, so supply an async - ``on_token_refresh`` callback to re-persist the new refresh token; without - it a rotated token is only kept in memory and a later reload would fail. + 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) diff --git a/pyoverkiz/auth/strategies.py b/pyoverkiz/auth/strategies.py index 5b10ef0d..1f7eccbc 100644 --- a/pyoverkiz/auth/strategies.py +++ b/pyoverkiz/auth/strategies.py @@ -284,12 +284,10 @@ async def _request_access_token( class SomfyAccountAuthStrategy(BaseAuthStrategy): - """Somfy multi-site auth: Keycloak token exchange + BOB site directory. + """Somfy multi-site auth: password grant -> Keycloak token exchange -> BOB site directory. - Reuses the Somfy Accounts password grant, exchanges the SSO token for a - Ginaite (Keycloak) token, then lists the account's sites from the BOB - directory. Selecting a site mints a site-scoped token whose Bearer drives - the classic Overkiz enduser API directly (no gateway header needed). + Selecting a site mints a site-scoped token whose Bearer drives the classic + Overkiz enduser API directly (no gateway header needed). """ def __init__( @@ -299,13 +297,7 @@ def __init__( server: ServerConfig, ssl_context: ssl.SSLContext | bool, ) -> None: - """Create a Somfy multi-site strategy with a fresh auth context. - - Accepts either ``UsernamePasswordCredentials`` (fresh login: password - grant + token exchange + discovery) or ``SomfyTokenCredentials`` - (resumed session: a persisted refresh token scoped to an already-selected - site, skipping all three network round trips). - """ + """Accept ``UsernamePasswordCredentials`` (fresh login) or ``SomfyTokenCredentials`` (resumed session).""" super().__init__(session, server, ssl_context) self.credentials = credentials self.context = AuthContext() @@ -319,23 +311,7 @@ def __init__( self._persisted_refresh_token: str | None = None async def login(self) -> None: - """Fresh login (password) or resumed session (persisted refresh token). - - With ``SomfyTokenCredentials`` this resumes a session: no password grant, - no token exchange, no discovery. We seed the context from the stored - refresh token and site scope, so the first request mints a site-scoped - access token via the existing refresh path. - - With ``UsernamePasswordCredentials`` this is a fresh login: password - grant -> token exchange -> discover, then (re-)select a site. Relogin - (e.g. after ``NotAuthenticatedError``) mints a fresh, unscoped Ginaite - token that is not itself expired, so on a multi-site account the - previously-selected gateway must be re-selected to re-apply site - scoping; otherwise the unscoped global token would keep being served - against the still-selected region endpoint. If that gateway is no - longer present after rediscovery, drop the stale selection instead of - silently pointing at a gateway that's gone. - """ + """Fresh login (password grant -> exchange -> discover) or resumed session.""" if isinstance(self.credentials, SomfyTokenCredentials): self._resume_session(self.credentials) return @@ -346,6 +322,8 @@ async def login(self) -> None: await self._token_exchange(token["access_token"]) await self.discover_gateways() + # Re-select on relogin to re-scope the fresh unscoped token; drop the + # selection if the gateway is gone after rediscovery. known = {s.gateway_id for s in self._sites} if self._selected_gateway and self._selected_gateway in known: self.select_gateway(self._selected_gateway) @@ -357,13 +335,7 @@ async def login(self) -> None: self.select_gateway(self._sites[0].gateway_id) def _resume_session(self, credentials: SomfyTokenCredentials) -> None: - """Seed site scope from persisted tokens; skip password/exchange/discover. - - Sets up exactly the state ``select_gateway`` would have produced, then - marks the (absent) access token expired so the first request mints a - site-scoped token via the existing ``refresh_if_needed`` -> ``_refresh`` - path. No network calls happen here. - """ + """Seed site scope from persisted tokens (no network); first request mints a scoped token.""" self.context.refresh_token = credentials.refresh_token self.context.expires_at = datetime.datetime.now(datetime.UTC) self._selected_site_oid = credentials.site_oid @@ -419,13 +391,7 @@ def select_gateway(self, gateway_id: str) -> None: @staticmethod def _region_for_country(country: str | None) -> str: - """Map an ISO country to an Overkiz region, defaulting to EMEA. - - Mirrors the TaHoma app's BusinessArea.fromCountry: known countries map - to their region, and anything unresolvable falls back to EMEA. A country - we cannot resolve (missing, or present but unmapped) is logged, since it - likely means the map needs updating for a newly supported region. - """ + """Map an ISO country to a region, warning and defaulting to EMEA if unresolvable.""" region = SOMFY_COUNTRY_REGION.get(country.upper()) if country else None if region is None: _LOGGER.warning( @@ -445,12 +411,9 @@ def to_credentials( self, on_token_refresh: Callable[[str], Awaitable[None]] | None = None, ) -> SomfyTokenCredentials: - """Snapshot the current session as reusable resume credentials. + """Snapshot the session (refresh token + site scope) as resume credentials. - Call this after login + gateway selection to persist the state a reload - needs (refresh token + site scope + region), skipping password grant, - token exchange, and discovery next time. Raises if no site is selected - or no refresh token is available yet. + Raises if no site is selected or no refresh token is available yet. """ if ( self._selected_site_oid is None @@ -475,12 +438,7 @@ def endpoint(self) -> str: return self._endpoint or self.server.endpoint async def refresh_if_needed(self) -> bool: - """Mint/refresh a site-scoped token when expired. - - Raises if a site has been selected but there is no refresh token to - mint the site-scoped token with, rather than silently continuing to - serve the unscoped global token against the site's region endpoint. - """ + """Mint/refresh a site-scoped token when expired; raise if a selected site has no refresh token.""" if not self.context.is_expired(): return False if not self.context.refresh_token: diff --git a/pyoverkiz/client.py b/pyoverkiz/client.py index 93b5f973..d091c4f1 100644 --- a/pyoverkiz/client.py +++ b/pyoverkiz/client.py @@ -10,7 +10,7 @@ from http import HTTPStatus from pathlib import Path from types import TracebackType -from typing import Any, Self, cast +from typing import TYPE_CHECKING, Any, Self, cast import backoff from aiohttp import ( @@ -30,6 +30,7 @@ SupportsGatewaySelection, build_auth_strategy, ) +from pyoverkiz.auth.base import SupportsSessionResume from pyoverkiz.const import SUPPORTED_SERVERS, USER_AGENT from pyoverkiz.converter import converter from pyoverkiz.enums import APIType, ExecutionMode, Protocol, Server @@ -71,6 +72,11 @@ from pyoverkiz.response_handler import check_response from pyoverkiz.serializers import prepare_payload +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + + from pyoverkiz.auth.credentials import SomfyTokenCredentials + _LOGGER = logging.getLogger(__name__) DEFAULT_TIMEOUT = ClientTimeout(total=15, sock_connect=10) @@ -933,6 +939,24 @@ def select_gateway(self, gateway_id: str) -> None: ) self._auth.select_gateway(gateway_id) + def to_credentials( + self, + on_token_refresh: Callable[[str], Awaitable[None]] | None = None, + ) -> SomfyTokenCredentials: + """Snapshot the session as resume credentials, to log in later without a password. + + Call after login and gateway selection. Supply ``on_token_refresh`` to + persist the rotating refresh token. + + Raises: + UnsupportedOperationError: When the server does not support session resume. + """ + if not isinstance(self._auth, SupportsSessionResume): + raise UnsupportedOperationError( + f"{self.server_config.name} does not support session resume." + ) + return self._auth.to_credentials(on_token_refresh) + # ----------------------------------------------------------------------- # Local token management (cloud API) # ----------------------------------------------------------------------- diff --git a/tests/test_client.py b/tests/test_client.py index c556c0b2..5e8ad4a3 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -16,6 +16,7 @@ SupportsGatewaySelection, UsernamePasswordCredentials, ) +from pyoverkiz.auth.base import SupportsSessionResume from pyoverkiz.client import OverkizClient, OverkizClientSettings from pyoverkiz.const import USER_AGENT from pyoverkiz.enums import ( @@ -1397,6 +1398,26 @@ def test_select_gateway_delegates_to_strategy(self, client: OverkizClient) -> No fake_auth.select_gateway.assert_called_once_with("g1") + def test_to_credentials_delegates_to_strategy(self, client: OverkizClient) -> None: + """to_credentials forwards to a resume-capable strategy.""" + fake_auth = MagicMock(spec=SupportsSessionResume) + client._auth = fake_auth + + client.to_credentials() + + fake_auth.to_credentials.assert_called_once() + + def test_to_credentials_raises_for_unsupported_strategy( + self, client: OverkizClient + ) -> None: + """to_credentials raises UnsupportedOperationError when unsupported.""" + # The SOMFY_EUROPE strategy does not implement SupportsSessionResume. + with pytest.raises( + exceptions.UnsupportedOperationError, + match="does not support session resume", + ): + client.to_credentials() + class TestUserAgent: """Tests for the User-Agent header sent by OverkizClient.""" From 102a145595f88b1a8195b282a96a8963a515bd7c Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 6 Jul 2026 13:07:20 +0000 Subject: [PATCH 06/20] feat: offer local API for Server.SOMFY multi-account --- pyoverkiz/const.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyoverkiz/const.py b/pyoverkiz/const.py index 141fbd8e..b3b466ce 100644 --- a/pyoverkiz/const.py +++ b/pyoverkiz/const.py @@ -154,6 +154,7 @@ LOCAL_API_PATH = "/enduser-mobile-web/1/enduserAPI/" SERVERS_WITH_LOCAL_API = [ + Server.SOMFY, Server.SOMFY_EUROPE, Server.SOMFY_OCEANIA, Server.SOMFY_AMERICA, From 806dfc011792d33d5b049f13eed7525602c662d7 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Wed, 8 Jul 2026 15:15:50 +0000 Subject: [PATCH 07/20] fix: map Somfy refresh invalid_grant to SomfyBadCredentialsError A revoked refresh token (e.g. after a password change) returns a 400 invalid_grant on the site-scoped refresh grant. Classify it as bad credentials, mirroring the password grant and CozyTouch strategy, so callers trigger reauth instead of surfacing an unexpected error. --- pyoverkiz/auth/strategies.py | 7 +++++++ tests/test_auth.py | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/pyoverkiz/auth/strategies.py b/pyoverkiz/auth/strategies.py index 1f7eccbc..e10d7f54 100644 --- a/pyoverkiz/auth/strategies.py +++ b/pyoverkiz/auth/strategies.py @@ -466,6 +466,13 @@ async def _refresh(self) -> None: async with self.session.post(url, data=form) as response: await _raise_for_server_error(response) if response.status != HTTPStatus.OK: + # A revoked refresh token (e.g. after a password change) is terminal; + # surface it as bad credentials so callers trigger reauth instead of retrying. + body = await response.json() + if body.get("error") == "invalid_grant": + raise SomfyBadCredentialsError( + body.get("error_description", "invalid_grant") + ) raise SomfyServiceError( f"Somfy token refresh failed: {response.status}" ) diff --git a/tests/test_auth.py b/tests/test_auth.py index ba008c7a..3a530c9d 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1790,6 +1790,29 @@ async def test_somfy_multisite_refresh_scopes_to_selected_site(): assert "siteOID=site-a" in called_url +@pytest.mark.asyncio +async def test_somfy_multisite_refresh_invalid_grant_raises_bad_credentials(): + """A revoked refresh token (400 invalid_grant) maps to SomfyBadCredentialsError so callers reauth.""" + from pyoverkiz.exceptions import SomfyBadCredentialsError + + strategy, session = _build_somfy_multisite_strategy() + strategy.context.access_token = "ginaite-1" + strategy.context.refresh_token = "r-1" + session.get = MagicMock(return_value=_json_ctx(_BOB_SITES)) + await strategy.discover_gateways() + strategy.select_gateway("2025-0000-0001") # forces expiry + + session.post = MagicMock( + return_value=_json_ctx( + {"error": "invalid_grant", "error_description": "token revoked"}, + status=400, + ) + ) + + with pytest.raises(SomfyBadCredentialsError, match="token revoked"): + await strategy.refresh_if_needed() + + @pytest.mark.asyncio async def test_somfy_multisite_refresh_without_refresh_token_raises(): """No refresh_token after site selection must raise, not silently no-op. From 6fe9697bbf1e76049352d847331c86f84ac9c765 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Wed, 12 Aug 2026 13:11:54 +0000 Subject: [PATCH 08/20] fix: keep the rotated Somfy refresh token across a relogin Ginaite rotates the refresh token on every refresh. `_resume_session()` runs again on every relogin (the auth-error retry calls `login()`), where it used to restore the refresh token from the credentials -- the original one, already invalidated by the first rotation. The next refresh then failed with invalid_grant and surfaced as bad credentials, so a recoverable 401 turned into a reauth prompt. Seed the refresh token from the credentials only when the context has none yet, and let the in-memory (rotated) token survive a relogin. --- pyoverkiz/auth/strategies.py | 8 ++++-- tests/test_auth.py | 49 ++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/pyoverkiz/auth/strategies.py b/pyoverkiz/auth/strategies.py index e10d7f54..29f57905 100644 --- a/pyoverkiz/auth/strategies.py +++ b/pyoverkiz/auth/strategies.py @@ -336,14 +336,18 @@ async def login(self) -> None: def _resume_session(self, credentials: SomfyTokenCredentials) -> None: """Seed site scope from persisted tokens (no network); first request mints a scoped token.""" - self.context.refresh_token = credentials.refresh_token + # Seed the refresh token only once. Ginaite rotates it on every refresh, + # so on a relogin the credentials still hold the original (now spent) + # token while the in-memory one is the only valid one. + if self.context.refresh_token is None: + self.context.refresh_token = credentials.refresh_token + self._persisted_refresh_token = credentials.refresh_token self.context.expires_at = datetime.datetime.now(datetime.UTC) self._selected_site_oid = credentials.site_oid self._selected_gateway = credentials.gateway_id self._selected_region = credentials.region self._endpoint = SOMFY_REGION_ENDPOINT[credentials.region] self._on_token_refresh = credentials.on_token_refresh - self._persisted_refresh_token = credentials.refresh_token async def _token_exchange(self, sso_access_token: str) -> None: """Exchange a Somfy Accounts SSO token for a Ginaite token (public client).""" diff --git a/tests/test_auth.py b/tests/test_auth.py index 3a530c9d..c090d717 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -2033,6 +2033,55 @@ async def _persist(token): assert persisted == ["r-rot"] +@pytest.mark.asyncio +async def test_somfy_resume_relogin_keeps_rotated_refresh_token(): + """A relogin must not fall back to the (already spent) stored refresh token. + + The auth-error retry calls login() again. Re-seeding from the credentials + would restore the original refresh token, which Ginaite already invalidated + when it rotated it, turning a recoverable 401 into a reauth prompt. + """ + strategy, session = _build_somfy_resume_strategy() + await strategy.login() + + session.post = MagicMock( + return_value=_json_ctx({"access_token": "scoped-1", "refresh_token": "r-rot"}) + ) + await strategy.refresh_if_needed() + assert strategy.context.refresh_token == "r-rot" + + await strategy.login() + + assert strategy.context.refresh_token == "r-rot" + # Still re-scoped on the next request, and the site scope survives. + assert strategy.context.is_expired() + assert strategy._selected_site_oid == "site-b" + + +@pytest.mark.asyncio +async def test_somfy_resume_relogin_does_not_renotify_rotated_token(): + """Relogin must not re-fire on_token_refresh with an unchanged token.""" + persisted = [] + + async def _persist(token): + persisted.append(token) + + strategy, session = _build_somfy_resume_strategy(on_token_refresh=_persist) + await strategy.login() + session.post = MagicMock( + return_value=_json_ctx({"access_token": "scoped-1", "refresh_token": "r-rot"}) + ) + await strategy.refresh_if_needed() + + await strategy.login() + session.post = MagicMock( + return_value=_json_ctx({"access_token": "scoped-2", "refresh_token": "r-rot"}) + ) + await strategy.refresh_if_needed() + + assert persisted == ["r-rot"] + + @pytest.mark.asyncio async def test_somfy_resume_missing_refresh_token_preserved(): """A refresh response without a refresh_token keeps the working one.""" From 3247df33f101b7de1286b846d986cd8d4e500eab Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Wed, 12 Aug 2026 13:14:55 +0000 Subject: [PATCH 09/20] fix: raise NoGatewaySelectedError when no Somfy site is selected Before a site is selected the Ginaite token is account-wide rather than site-scoped, and `endpoint` is still the region placeholder from the server config. `auth_headers()` handed that token out anyway, so a request on a multi-site account quietly went to an arbitrary region instead of failing. Raise `NoGatewaySelectedError` instead, matching the Rexel strategy, and update the docs example to defer `register_event_listener` until a site is selected. --- docs/getting-started.md | 13 +++++++++++-- pyoverkiz/auth/strategies.py | 14 +++++++++++--- tests/test_auth.py | 23 ++++++++++++++++++++++- 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index c81d2aff..3b9a39bc 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -96,9 +96,11 @@ Use a cloud server when you want to connect through the vendor’s public API. U server=Server.SOMFY, credentials=UsernamePasswordCredentials("you@example.com", "password"), ) as client: - await client.login() # auto-selects a sole site + # 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) - # Accounts with more than one site must select one explicitly. + # 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) @@ -107,6 +109,9 @@ Use a cloud server when you want to connect through the vendor’s public API. U 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()) ``` @@ -114,6 +119,10 @@ Use a cloud server when you want to connect through the vendor’s public API. U `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 diff --git a/pyoverkiz/auth/strategies.py b/pyoverkiz/auth/strategies.py index 29f57905..d5549958 100644 --- a/pyoverkiz/auth/strategies.py +++ b/pyoverkiz/auth/strategies.py @@ -499,9 +499,17 @@ async def _notify_token_refresh(self) -> None: async def auth_headers(self, path: str | None = None) -> Mapping[str, str]: """Return the Bearer header (site-scoped token), or {} before login.""" - if self.context.access_token: - return {"Authorization": f"Bearer {self.context.access_token}"} - return {} + if not self.context.access_token: + return {} + # Without a site the token is still the unscoped account-wide one, and + # `endpoint` is the region placeholder: a request would silently hit an + # arbitrary region rather than the user's site. + if self._selected_site_oid is None: + raise NoGatewaySelectedError( + "Multiple Somfy sites available; call discover_gateways() " + "and select_gateway() before making requests." + ) + return {"Authorization": f"Bearer {self.context.access_token}"} async def _bob_get(self, path: str) -> dict[str, Any]: """GET a BOB site-directory resource with Bearer + X-Api-Key.""" diff --git a/tests/test_auth.py b/tests/test_auth.py index c090d717..9dc1a92f 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1760,14 +1760,35 @@ async def test_somfy_multisite_endpoint_defaults_to_placeholder_before_select(): @pytest.mark.asyncio async def test_somfy_multisite_auth_headers(): """auth_headers returns the Bearer token, or {} when absent (no gateway header).""" - strategy, _ = _build_somfy_multisite_strategy() + strategy, session = _build_somfy_multisite_strategy() assert await strategy.auth_headers() == {} strategy.context.access_token = "ginaite-1" + session.get = MagicMock(return_value=_json_ctx(_BOB_SITES)) + await strategy.discover_gateways() + strategy.select_gateway("2025-0000-0001") + headers = await strategy.auth_headers() + assert headers == {"Authorization": "Bearer ginaite-1"} assert "gatewayId" not in headers +@pytest.mark.asyncio +async def test_somfy_multisite_auth_headers_raises_when_unselected(): + """A token without a selected site must raise, not target an arbitrary region. + + The account-wide token is not site-scoped and `endpoint` is still the region + placeholder, so serving it would quietly talk to the wrong site. + """ + strategy, session = _build_somfy_multisite_strategy() + strategy.context.access_token = "ginaite-1" + session.get = MagicMock(return_value=_json_ctx(_BOB_SITES)) + await strategy.discover_gateways() + + with pytest.raises(NoGatewaySelectedError): + await strategy.auth_headers() + + @pytest.mark.asyncio async def test_somfy_multisite_refresh_scopes_to_selected_site(): """refresh_if_needed posts a refresh grant to the ?siteOID URL.""" From ba25ff8485e208d5301b614fa44a3472c5813c1e Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Wed, 12 Aug 2026 13:16:00 +0000 Subject: [PATCH 10/20] fix: serialize concurrent Somfy token refreshes Every request checks the context for expiry, so requests issued in parallel (`get_diagnostic_data` gathers setup + actionGroups) each started their own refresh grant. Ginaite invalidates the refresh token it rotates, so the loser of that race presented a spent token, got invalid_grant back and reported bad credentials -- a needless reauth prompt on an otherwise healthy session. Guard the refresh with a lock and re-check expiry after acquiring it, so followers reuse the token the winner just minted. --- pyoverkiz/auth/strategies.py | 10 ++++++++- tests/test_auth.py | 41 ++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/pyoverkiz/auth/strategies.py b/pyoverkiz/auth/strategies.py index d5549958..bbbb5f52 100644 --- a/pyoverkiz/auth/strategies.py +++ b/pyoverkiz/auth/strategies.py @@ -309,6 +309,7 @@ def __init__( # Refresh-token persistence for resumed sessions (no-op for fresh login). self._on_token_refresh: Callable[[str], Awaitable[None]] | None = None self._persisted_refresh_token: str | None = None + self._refresh_lock = asyncio.Lock() async def login(self) -> None: """Fresh login (password grant -> exchange -> discover) or resumed session.""" @@ -451,7 +452,14 @@ async def refresh_if_needed(self) -> bool: "Cannot mint a site-scoped Somfy token without a refresh token." ) return False - await self._refresh() + + # Ginaite invalidates the refresh token it rotates, so two concurrent + # refreshes (e.g. requests gathered in parallel) would leave the loser + # holding a spent token and reporting bad credentials. + async with self._refresh_lock: + if not self.context.is_expired(): + return False + await self._refresh() return True async def _refresh(self) -> None: diff --git a/tests/test_auth.py b/tests/test_auth.py index 9dc1a92f..9cf3f4ea 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -5,6 +5,7 @@ from __future__ import annotations +import asyncio import base64 import datetime import importlib.util @@ -2054,6 +2055,46 @@ async def _persist(token): assert persisted == ["r-rot"] +@pytest.mark.asyncio +async def test_somfy_refresh_is_serialized_across_concurrent_requests(): + """Concurrent expired requests must refresh once, not race for the token. + + Requests issued in parallel (``get_diagnostic_data`` gathers two) all see an + expired context. Ginaite invalidates the refresh token it rotates, so a + second concurrent refresh would present a spent token and report bad + credentials, forcing a needless reauth. + """ + strategy, session = _build_somfy_resume_strategy() + await strategy.login() + + resp = MagicMock() + resp.status = 200 + resp.json = AsyncMock( + return_value={ + "access_token": "scoped-1", + "refresh_token": "r-2", + "expires_in": 900, + } + ) + + async def _slow_enter(*_args): + await asyncio.sleep(0) # yield so the second caller reaches the lock + return resp + + ctx = MagicMock() + ctx.__aenter__ = AsyncMock(side_effect=_slow_enter) + ctx.__aexit__ = AsyncMock(return_value=None) + session.post = MagicMock(return_value=ctx) + + results = await asyncio.gather( + strategy.refresh_if_needed(), strategy.refresh_if_needed() + ) + + assert session.post.call_count == 1 + assert sorted(results) == [False, True] + assert strategy.context.refresh_token == "r-2" + + @pytest.mark.asyncio async def test_somfy_resume_relogin_keeps_rotated_refresh_token(): """A relogin must not fall back to the (already spent) stored refresh token. From d518ba201dd9a6dde04b7aac06ac1b2c6c8be0f8 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Wed, 12 Aug 2026 13:17:19 +0000 Subject: [PATCH 11/20] fix: raise a typed error for an unknown persisted Somfy region Resuming a session looked the region up in SOMFY_REGION_ENDPOINT directly, so a value the library no longer knows (a hand-edited or downgraded store) surfaced as a bare KeyError out of login(). Raise SomfyServiceError naming the region and the accepted values instead, and validate before touching any session state. --- pyoverkiz/auth/strategies.py | 10 +++++++++- tests/test_auth.py | 11 +++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/pyoverkiz/auth/strategies.py b/pyoverkiz/auth/strategies.py index bbbb5f52..28d1c945 100644 --- a/pyoverkiz/auth/strategies.py +++ b/pyoverkiz/auth/strategies.py @@ -337,6 +337,13 @@ async def login(self) -> None: def _resume_session(self, credentials: SomfyTokenCredentials) -> None: """Seed site scope from persisted tokens (no network); first request mints a scoped token.""" + endpoint = SOMFY_REGION_ENDPOINT.get(credentials.region) + if endpoint is None: + raise SomfyServiceError( + f"Unknown Somfy region {credentials.region!r}; expected one of " + f"{', '.join(SOMFY_REGION_ENDPOINT)}." + ) + # Seed the refresh token only once. Ginaite rotates it on every refresh, # so on a relogin the credentials still hold the original (now spent) # token while the in-memory one is the only valid one. @@ -344,10 +351,11 @@ def _resume_session(self, credentials: SomfyTokenCredentials) -> None: self.context.refresh_token = credentials.refresh_token self._persisted_refresh_token = credentials.refresh_token self.context.expires_at = datetime.datetime.now(datetime.UTC) + self._selected_site_oid = credentials.site_oid self._selected_gateway = credentials.gateway_id self._selected_region = credentials.region - self._endpoint = SOMFY_REGION_ENDPOINT[credentials.region] + self._endpoint = endpoint self._on_token_refresh = credentials.on_token_refresh async def _token_exchange(self, sso_access_token: str) -> None: diff --git a/tests/test_auth.py b/tests/test_auth.py index 9cf3f4ea..6f653ebc 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1991,6 +1991,17 @@ async def test_somfy_resume_login_seeds_scope_without_http(): assert strategy.context.refresh_token == "stored-r" +@pytest.mark.asyncio +async def test_somfy_resume_unknown_region_raises_typed_error(): + """An unrecognised persisted region raises SomfyServiceError, not KeyError.""" + from pyoverkiz.exceptions import SomfyServiceError + + strategy, _ = _build_somfy_resume_strategy(region="MOON") + + with pytest.raises(SomfyServiceError, match="Unknown Somfy region"): + await strategy.login() + + @pytest.mark.asyncio async def test_somfy_resume_first_refresh_scopes_to_site(): """The first refresh after resuming mints a token via the ?siteOID URL.""" From 8d9830ac49a14caed71ca8182dc2fc7a2a82f7d0 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Wed, 12 Aug 2026 13:17:49 +0000 Subject: [PATCH 12/20] fix: tolerate a non-JSON Somfy refresh error body The refresh error path parsed the body to look for invalid_grant, so a 4xx that is not JSON -- an HTML page from a proxy in front of Ginaite, or an empty body -- raised aiohttp ContentTypeError straight through the typed exception mapping. Read the body leniently and fall back to the status-only SomfyServiceError. --- pyoverkiz/auth/strategies.py | 15 ++++++++++++++- tests/test_auth.py | 20 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/pyoverkiz/auth/strategies.py b/pyoverkiz/auth/strategies.py index 28d1c945..f5e8c38e 100644 --- a/pyoverkiz/auth/strategies.py +++ b/pyoverkiz/auth/strategies.py @@ -88,6 +88,19 @@ async def _raise_for_server_error(response: ClientResponse) -> None: await check_response(response) +async def _json_body(response: ClientResponse) -> dict[str, Any]: + """Return a response body as a dict, or {} if it is not a JSON object. + + Error responses from a proxy in front of a token endpoint may be HTML or + empty, which would otherwise raise while inspecting the error code. + """ + try: + body = await response.json(content_type=None) + except ValueError: + return {} + return body if isinstance(body, dict) else {} + + async def _somfy_password_token( session: ClientSession, username: str, password: str ) -> dict[str, Any]: @@ -488,7 +501,7 @@ async def _refresh(self) -> None: if response.status != HTTPStatus.OK: # A revoked refresh token (e.g. after a password change) is terminal; # surface it as bad credentials so callers trigger reauth instead of retrying. - body = await response.json() + body = await _json_body(response) if body.get("error") == "invalid_grant": raise SomfyBadCredentialsError( body.get("error_description", "invalid_grant") diff --git a/tests/test_auth.py b/tests/test_auth.py index 6f653ebc..6fc10eab 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1835,6 +1835,26 @@ async def test_somfy_multisite_refresh_invalid_grant_raises_bad_credentials(): await strategy.refresh_if_needed() +@pytest.mark.asyncio +async def test_somfy_multisite_refresh_non_json_error_body(): + """A non-JSON refresh error (proxy HTML) still raises a typed Overkiz error.""" + from pyoverkiz.exceptions import SomfyServiceError + + strategy, session = _build_somfy_resume_strategy() + await strategy.login() + + resp = MagicMock() + resp.status = 403 + resp.json = AsyncMock(side_effect=json.JSONDecodeError("nope", "", 0)) + ctx = MagicMock() + ctx.__aenter__ = AsyncMock(return_value=resp) + ctx.__aexit__ = AsyncMock(return_value=None) + session.post = MagicMock(return_value=ctx) + + with pytest.raises(SomfyServiceError, match="403"): + await strategy.refresh_if_needed() + + @pytest.mark.asyncio async def test_somfy_multisite_refresh_without_refresh_token_raises(): """No refresh_token after site selection must raise, not silently no-op. From e653f15aaf7776835b72b2771493047d900a1382 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Wed, 12 Aug 2026 13:18:24 +0000 Subject: [PATCH 13/20] fix: bound the Somfy token lifetime when expires_in is missing expires_at is deliberately parked in the past by select_gateway() and by resuming a session, to force the next request to mint a site-scoped token. update_from_token() only moves it when the response carries expires_in, so a refresh response without one left the context expired forever and every subsequent request ran another refresh grant. Fall back to a short assumed lifetime in that case. --- pyoverkiz/auth/strategies.py | 13 ++++++++++++- tests/test_auth.py | 20 ++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/pyoverkiz/auth/strategies.py b/pyoverkiz/auth/strategies.py index f5e8c38e..86bed675 100644 --- a/pyoverkiz/auth/strategies.py +++ b/pyoverkiz/auth/strategies.py @@ -76,6 +76,9 @@ MIN_JWT_SEGMENTS = 2 +# Assumed lifetime of a Somfy site-scoped token when the response omits expires_in. +SOMFY_FALLBACK_TOKEN_LIFETIME = datetime.timedelta(minutes=5) + async def _raise_for_server_error(response: ClientResponse) -> None: """Map a 5xx token-endpoint response to a typed Overkiz exception. @@ -509,7 +512,15 @@ async def _refresh(self) -> None: raise SomfyServiceError( f"Somfy token refresh failed: {response.status}" ) - self.context.update_from_token(await response.json()) + token = await response.json() + self.context.update_from_token(token) + if "expires_in" not in token: + # Selecting a site (and resuming) parks expires_at in the past to + # force a re-scope, so a response without an expiry would leave + # the context expired and refresh on every single request. + self.context.expires_at = ( + datetime.datetime.now(datetime.UTC) + SOMFY_FALLBACK_TOKEN_LIFETIME + ) # refresh_token is optional in a refresh response (RFC 6749); reuse the old one if absent. if self.context.refresh_token is None: diff --git a/tests/test_auth.py b/tests/test_auth.py index 6fc10eab..f28284d2 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -2186,3 +2186,23 @@ async def test_somfy_resume_missing_refresh_token_preserved(): await strategy.refresh_if_needed() assert strategy.context.refresh_token == "stored-r" + + +@pytest.mark.asyncio +async def test_somfy_refresh_without_expires_in_is_not_immediately_expired(): + """A refresh response without expires_in must not refresh on every request. + + Site selection and resume park expires_at in the past to force a re-scope, so + a response that carries no expiry would leave the context permanently + expired and mint a new token for every single request. + """ + strategy, session = _build_somfy_resume_strategy() + await strategy.login() + assert strategy.context.is_expired() + + session.post = MagicMock(return_value=_json_ctx({"access_token": "scoped-1"})) + await strategy.refresh_if_needed() + + assert not strategy.context.is_expired() + assert await strategy.refresh_if_needed() is False + assert session.post.call_count == 1 From dd561fdd08342cea27979f0d42dabdc3f83a821a Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Wed, 12 Aug 2026 13:20:07 +0000 Subject: [PATCH 14/20] fix: page through the Somfy BOB site listing Discovery requested a single page of 20 sites, so an account with more sites than that silently lost the rest: they never appeared as selectable gateways and there was no hint anything was missing. Parse totalCount and keep requesting pages until the account is covered, capped by a runaway guard that warns when it truncates. --- pyoverkiz/auth/bob.py | 10 ++++- pyoverkiz/auth/strategies.py | 30 +++++++++++++-- pyoverkiz/const.py | 4 ++ tests/test_auth.py | 74 ++++++++++++++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 4 deletions(-) diff --git a/pyoverkiz/auth/bob.py b/pyoverkiz/auth/bob.py index 0d403650..7a2d8beb 100644 --- a/pyoverkiz/auth/bob.py +++ b/pyoverkiz/auth/bob.py @@ -42,9 +42,11 @@ class BobSite: @dataclass(slots=True) class BobSitesResponse: - """The ``/sites`` listing, flattened on demand to gateway candidates.""" + """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.""" @@ -84,6 +86,12 @@ def _make_bob_converter() -> cattrs.Converter: sub_sites=override(rename="subSites"), ), ) + c.register_structure_hook( + BobSitesResponse, + make_dict_structure_fn( + BobSitesResponse, c, total_count=override(rename="totalCount") + ), + ) return c diff --git a/pyoverkiz/auth/strategies.py b/pyoverkiz/auth/strategies.py index 86bed675..429f6c7c 100644 --- a/pyoverkiz/auth/strategies.py +++ b/pyoverkiz/auth/strategies.py @@ -46,6 +46,8 @@ SOMFY_API, SOMFY_BOB_API_KEY, SOMFY_BOB_SITE_API, + SOMFY_BOB_SITES_MAX, + SOMFY_BOB_SITES_PAGE_SIZE, SOMFY_CLIENT_ID, SOMFY_CLIENT_SECRET, SOMFY_COUNTRY_REGION, @@ -395,9 +397,31 @@ async def _token_exchange(self, sso_access_token: str) -> None: async def discover_gateways(self) -> list[GatewayCandidate]: """List the account's sites from BOB, flattened to gateway candidates.""" - data = await self._bob_get("sites?withGateways=true&limit=20&offset=0") - response = bob_converter.structure(data, BobSitesResponse) - self._sites = response.gateway_candidates() + candidates: list[GatewayCandidate] = [] + sites_seen = 0 + total_count = 0 + + for offset in range(0, SOMFY_BOB_SITES_MAX, SOMFY_BOB_SITES_PAGE_SIZE): + data = await self._bob_get( + f"sites?withGateways=true&limit={SOMFY_BOB_SITES_PAGE_SIZE}" + f"&offset={offset}" + ) + page = bob_converter.structure(data, BobSitesResponse) + candidates.extend(page.gateway_candidates()) + sites_seen += len(page.results) + total_count = page.total_count + if not page.results or sites_seen >= total_count: + break + + if sites_seen < total_count: + _LOGGER.warning( + "Somfy account has %s sites but only the first %s were listed; " + "later sites are not selectable", + total_count, + sites_seen, + ) + + self._sites = candidates return self._sites def select_gateway(self, gateway_id: str) -> None: diff --git a/pyoverkiz/const.py b/pyoverkiz/const.py index b3b466ce..01a6452d 100644 --- a/pyoverkiz/const.py +++ b/pyoverkiz/const.py @@ -53,6 +53,10 @@ SOMFY_BOB_SITE_API = "https://backoffice-service.ovkube.net/site-api/public/v1" SOMFY_BOB_API_KEY = "184638B3FBE874ACD24C14FBD657B" +# The site listing is paged (same page size as the TaHoma app); the maximum is a +# runaway guard for an account reporting an implausible site count. +SOMFY_BOB_SITES_PAGE_SIZE = 20 +SOMFY_BOB_SITES_MAX = 200 # Site region derived offline from its ISO country, mirroring the TaHoma app's BusinessArea.fromCountry (EMEA fallback). SOMFY_DEFAULT_REGION = "EMEA" diff --git a/tests/test_auth.py b/tests/test_auth.py index f28284d2..aedf9df0 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1641,6 +1641,80 @@ async def test_somfy_multisite_discover_flattens_sites(): assert candidates[0].country == "NL" +@pytest.mark.asyncio +async def test_somfy_multisite_discover_pages_until_total_count(): + """discover_gateways follows totalCount instead of truncating at one page.""" + strategy, session = _build_somfy_multisite_strategy() + strategy.context.access_token = "ginaite-1" + + def _site(oid, gateway_id): + return { + "siteOID": oid, + "name": oid, + "country": "NL", + "subSites": [ + {"externalOID": f"ext-{oid}", "gateways": [{"gatewayId": gateway_id}]} + ], + } + + page_1 = {"totalCount": 3, "results": [_site("s1", "gw-1"), _site("s2", "gw-2")]} + page_2 = {"totalCount": 3, "results": [_site("s3", "gw-3")]} + session.get = MagicMock(side_effect=[_json_ctx(page_1), _json_ctx(page_2)]) + + with patch("pyoverkiz.auth.strategies.SOMFY_BOB_SITES_PAGE_SIZE", 2): + candidates = await strategy.discover_gateways() + + assert [c.gateway_id for c in candidates] == ["gw-1", "gw-2", "gw-3"] + requested = [call.args[0] for call in session.get.call_args_list] + assert "offset=0" in requested[0] + assert "offset=2" in requested[1] + + +@pytest.mark.asyncio +async def test_somfy_multisite_discover_warns_when_truncated(caplog): + """Hitting the page cap warns rather than silently hiding sites.""" + strategy, session = _build_somfy_multisite_strategy() + strategy.context.access_token = "ginaite-1" + + endless = { + "totalCount": 999, + "results": [ + { + "siteOID": "s", + "name": "s", + "country": "NL", + "subSites": [{"externalOID": "e", "gateways": [{"gatewayId": "gw"}]}], + } + ], + } + session.get = MagicMock(side_effect=lambda *_a, **_kw: _json_ctx(endless)) + + with ( + patch("pyoverkiz.auth.strategies.SOMFY_BOB_SITES_PAGE_SIZE", 1), + patch("pyoverkiz.auth.strategies.SOMFY_BOB_SITES_MAX", 3), + caplog.at_level(logging.WARNING), + ): + candidates = await strategy.discover_gateways() + + assert len(candidates) == 3 + assert "999" in caplog.text + + +@pytest.mark.asyncio +async def test_somfy_multisite_discover_stops_without_total_count(): + """A payload without totalCount is treated as a single complete page.""" + strategy, session = _build_somfy_multisite_strategy() + strategy.context.access_token = "ginaite-1" + session.get = MagicMock( + return_value=_json_ctx({"results": _BOB_SITES["results"]}), + ) + + candidates = await strategy.discover_gateways() + + assert len(candidates) == 2 + assert session.get.call_count == 1 + + @pytest.mark.asyncio async def test_somfy_multisite_select_resolves_region_endpoint(): """Selecting a gateway resolves its country to the EMEA endpoint.""" From d2ac5f43a2f7dd052fe9d47b398c417ffd927a7c Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Wed, 12 Aug 2026 13:21:05 +0000 Subject: [PATCH 15/20] refactor: split the Somfy password grant from the refresh grant `_request_access_token` took a grant_type it then branched on, delegating the password grant to the shared helper and ignoring its own `extra_fields`. Call the helper from `login()` directly and keep a `_refresh()` that only builds the refresh grant, so neither path carries arguments the other needs. --- pyoverkiz/auth/strategies.py | 32 ++++++++------------------------ 1 file changed, 8 insertions(+), 24 deletions(-) diff --git a/pyoverkiz/auth/strategies.py b/pyoverkiz/auth/strategies.py index 429f6c7c..c2ab398e 100644 --- a/pyoverkiz/auth/strategies.py +++ b/pyoverkiz/auth/strategies.py @@ -236,23 +236,17 @@ def __init__( async def login(self) -> None: """Perform login using Somfy OAuth2.""" - await self._request_access_token( - grant_type="password", - extra_fields={ - "username": self.credentials.username, - "password": self.credentials.password, - }, + token = await _somfy_password_token( + self.session, self.credentials.username, self.credentials.password ) + self.context.update_from_token(token) async def refresh_if_needed(self) -> bool: """Refresh Somfy OAuth2 tokens if needed.""" if not self.context.is_expired() or not self.context.refresh_token: return False - await self._request_access_token( - grant_type="refresh_token", - extra_fields={"refresh_token": cast(str, self.context.refresh_token)}, - ) + await self._refresh(self.context.refresh_token) return True async def auth_headers(self, path: str | None = None) -> Mapping[str, str]: @@ -262,24 +256,14 @@ async def auth_headers(self, path: str | None = None) -> Mapping[str, str]: return {} - async def _request_access_token( - self, *, grant_type: str, extra_fields: Mapping[str, str] - ) -> None: - if grant_type == "password": - token = await _somfy_password_token( - self.session, - self.credentials.username, - self.credentials.password, - ) - self.context.update_from_token(token) - return - + async def _refresh(self, refresh_token: str) -> None: + """Exchange a refresh token for a new Somfy access token.""" form = FormData( { - "grant_type": grant_type, + "grant_type": "refresh_token", "client_id": SOMFY_CLIENT_ID, "client_secret": SOMFY_CLIENT_SECRET, - **extra_fields, + "refresh_token": refresh_token, } ) From 684dce87106661b4dda1a9b50cad4dff202bf04c Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Wed, 12 Aug 2026 13:22:31 +0000 Subject: [PATCH 16/20] feat: map the countries the Somfy app's region list omits The map was a verbatim mirror of the TaHoma app's BusinessArea list, which skips plenty of ordinary markets -- Slovenia, Malta, Iceland, most of Africa. Selecting a site in one of those logged a warning about an "unresolvable" country on every login, even though the EMEA fallback was correct. Map the omitted European, Caucasus, Middle Eastern and African countries explicitly. They all resolve to EMEA, so nothing routes differently; the warning goes back to meaning a country we genuinely cannot place. --- pyoverkiz/const.py | 43 ++++++++++++++++++++++++++++++++++++++++++- tests/test_auth.py | 4 ++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/pyoverkiz/const.py b/pyoverkiz/const.py index 01a6452d..bbc16575 100644 --- a/pyoverkiz/const.py +++ b/pyoverkiz/const.py @@ -58,7 +58,7 @@ SOMFY_BOB_SITES_PAGE_SIZE = 20 SOMFY_BOB_SITES_MAX = 200 -# Site region derived offline from its ISO country, mirroring the TaHoma app's BusinessArea.fromCountry (EMEA fallback). +# Site region derived offline from its ISO country, following the TaHoma app's BusinessArea.fromCountry (EMEA fallback). SOMFY_DEFAULT_REGION = "EMEA" SOMFY_REGION_ENDPOINT: MappingProxyType[str, str] = MappingProxyType( { @@ -148,6 +148,47 @@ "UA": "EMEA", "AE": "EMEA", "GB": "EMEA", + # Absent from the app's list, but geographically unambiguous. Mapping them + # keeps the fallback warning a signal rather than routine noise for an + # ordinary market; the resolved region is EMEA either way. + "AM": "EMEA", + "AO": "EMEA", + "AZ": "EMEA", + "BA": "EMEA", + "BH": "EMEA", + "CI": "EMEA", + "CM": "EMEA", + "DZ": "EMEA", + "ET": "EMEA", + "GE": "EMEA", + "GG": "EMEA", + "GH": "EMEA", + "GI": "EMEA", + "IM": "EMEA", + "IQ": "EMEA", + "IS": "EMEA", + "KE": "EMEA", + "KG": "EMEA", + "LI": "EMEA", + "LY": "EMEA", + "MD": "EMEA", + "ME": "EMEA", + "MG": "EMEA", + "MK": "EMEA", + "MT": "EMEA", + "MU": "EMEA", + "NA": "EMEA", + "NG": "EMEA", + "OM": "EMEA", + "SI": "EMEA", + "SM": "EMEA", + "SN": "EMEA", + "TZ": "EMEA", + "UG": "EMEA", + "UZ": "EMEA", + "VA": "EMEA", + "ZM": "EMEA", + "ZW": "EMEA", } ) diff --git a/tests/test_auth.py b/tests/test_auth.py index aedf9df0..f05fb65d 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1472,6 +1472,10 @@ def test_somfy_multisite_constants_and_server(): assert SOMFY_COUNTRY_REGION["NL"] == "EMEA" assert SOMFY_COUNTRY_REGION["US"] == "SNABA" assert SOMFY_COUNTRY_REGION["JP"] == "APAC" + # Countries the app's own list omits still resolve without a warning. + assert SOMFY_COUNTRY_REGION["SI"] == "EMEA" + assert SOMFY_COUNTRY_REGION["IS"] == "EMEA" + assert SOMFY_COUNTRY_REGION["KE"] == "EMEA" for region in SOMFY_COUNTRY_REGION.values(): assert region in SOMFY_REGION_ENDPOINT assert SOMFY_REGION_ENDPOINT["EMEA"] == ( From c7f2e465553c019eed47906e5ded36990ea6514c Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Wed, 12 Aug 2026 13:25:13 +0000 Subject: [PATCH 17/20] test: cover the remaining Somfy auth branches Adds the cases the multi-account work left untested: auto-selecting a sole site, leaving several sites unselected, an unknown gateway id, a failed BOB listing, and refreshing without a selection. Also covers the single-site SomfyAuthStrategy refresh grant, which had no tests before it was split out of `_request_access_token`. --- tests/test_auth.py | 144 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/tests/test_auth.py b/tests/test_auth.py index f05fb65d..f27fa3e9 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1533,6 +1533,85 @@ async def test_somfy_password_token_bad_credentials(): await _somfy_password_token(session, "user", "bad") +def _build_somfy_strategy(): + """Return a single-site SomfyAuthStrategy with a MagicMock session.""" + from pyoverkiz.const import SUPPORTED_SERVERS + + session = MagicMock(spec=ClientSession) + strategy = SomfyAuthStrategy( + credentials=UsernamePasswordCredentials("user", "pass"), + session=session, + server=SUPPORTED_SERVERS[Server.SOMFY_EUROPE], + ssl_context=True, + ) + return strategy, session + + +@pytest.mark.asyncio +async def test_somfy_login_stores_token_from_password_grant(): + """login() feeds the password grant straight into the auth context.""" + strategy, session = _build_somfy_strategy() + session.post = MagicMock( + return_value=_json_ctx( + {"access_token": "a-1", "refresh_token": "r-1", "expires_in": 900} + ) + ) + + await strategy.login() + + assert strategy.context.access_token == "a-1" + assert await strategy.auth_headers() == {"Authorization": "Bearer a-1"} + + +@pytest.mark.asyncio +async def test_somfy_refresh_uses_the_refresh_grant(): + """An expired context with a refresh token exchanges it for a new token.""" + strategy, session = _build_somfy_strategy() + strategy.context.access_token = "a-1" + strategy.context.refresh_token = "r-1" + strategy.context.expires_at = datetime.datetime.now(datetime.UTC) + session.post = MagicMock( + return_value=_json_ctx( + {"access_token": "a-2", "refresh_token": "r-2", "expires_in": 900} + ) + ) + + assert await strategy.refresh_if_needed() is True + assert strategy.context.access_token == "a-2" + assert strategy.context.refresh_token == "r-2" + # Not expired any more, so a second call is a no-op. + assert await strategy.refresh_if_needed() is False + assert session.post.call_count == 1 + + +@pytest.mark.asyncio +async def test_somfy_refresh_invalid_grant_raises_bad_credentials(): + """A rejected refresh token maps to SomfyBadCredentialsError.""" + from pyoverkiz.exceptions import SomfyBadCredentialsError + + strategy, session = _build_somfy_strategy() + strategy.context.refresh_token = "r-1" + strategy.context.expires_at = datetime.datetime.now(datetime.UTC) + session.post = MagicMock(return_value=_json_ctx({"message": "error.invalid.grant"})) + + with pytest.raises(SomfyBadCredentialsError): + await strategy.refresh_if_needed() + + +@pytest.mark.asyncio +async def test_somfy_refresh_without_access_token_raises_service_error(): + """A refresh response with no access token is a service error.""" + from pyoverkiz.exceptions import SomfyServiceError + + strategy, session = _build_somfy_strategy() + strategy.context.refresh_token = "r-1" + strategy.context.expires_at = datetime.datetime.now(datetime.UTC) + session.post = MagicMock(return_value=_json_ctx({"token_type": "bearer"})) + + with pytest.raises(SomfyServiceError): + await strategy.refresh_if_needed() + + def _build_somfy_multisite_strategy(): """Return a SomfyAccountAuthStrategy with a MagicMock session.""" from unittest.mock import MagicMock @@ -2041,6 +2120,71 @@ async def test_somfy_multisite_relogin_drops_removed_gateway(): assert strategy.endpoint == strategy.server.endpoint +@pytest.mark.asyncio +async def test_somfy_multisite_login_auto_selects_sole_site(): + """A single-site account needs no explicit selection to be usable.""" + strategy, session = _build_somfy_multisite_strategy() + sole_site = {"totalCount": 1, "results": [_BOB_SITES["results"][0]]} + session.get = MagicMock(return_value=_json_ctx(sole_site)) + + patch_password, patch_exchange = _patch_somfy_login_tokens(strategy) + with patch_password, patch_exchange: + await strategy.login() + + assert strategy.selected_gateway == "2025-0000-0001" + assert await strategy.auth_headers() == {"Authorization": "Bearer ginaite-fresh"} + + +@pytest.mark.asyncio +async def test_somfy_multisite_login_leaves_multiple_sites_unselected(): + """A multi-site account must not pick a site on the user's behalf.""" + strategy, session = _build_somfy_multisite_strategy() + session.get = MagicMock(return_value=_json_ctx(_BOB_SITES)) + + patch_password, patch_exchange = _patch_somfy_login_tokens(strategy) + with patch_password, patch_exchange: + await strategy.login() + + assert strategy.selected_gateway is None + + +@pytest.mark.asyncio +async def test_somfy_multisite_select_unknown_gateway_raises(): + """Selecting a gateway that discovery never returned raises.""" + from pyoverkiz.exceptions import SomfyServiceError + + strategy, session = _build_somfy_multisite_strategy() + strategy.context.access_token = "ginaite-1" + session.get = MagicMock(return_value=_json_ctx(_BOB_SITES)) + await strategy.discover_gateways() + + with pytest.raises(SomfyServiceError, match="Unknown gateway id"): + strategy.select_gateway("gw-does-not-exist") + + +@pytest.mark.asyncio +async def test_somfy_multisite_bob_error_raises_service_error(): + """A failed BOB site listing raises SomfyServiceError with the status.""" + from pyoverkiz.exceptions import SomfyServiceError + + strategy, session = _build_somfy_multisite_strategy() + strategy.context.access_token = "ginaite-1" + session.get = MagicMock(return_value=_json_ctx({"error": "forbidden"}, status=403)) + + with pytest.raises(SomfyServiceError, match="403"): + await strategy.discover_gateways() + + +@pytest.mark.asyncio +async def test_somfy_multisite_refresh_without_selection_is_a_no_op(): + """An expired unscoped token with no site selected has nothing to refresh.""" + strategy, _ = _build_somfy_multisite_strategy() + strategy.context.access_token = "ginaite-1" + strategy.context.expires_at = datetime.datetime.now(datetime.UTC) + + assert await strategy.refresh_if_needed() is False + + def _build_somfy_resume_strategy(**credential_overrides): """Return a SomfyAccountAuthStrategy built from SomfyTokenCredentials.""" from unittest.mock import MagicMock From 732f67e5fa035bf867fe6024b2fbb7fab8577de8 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Wed, 12 Aug 2026 13:57:30 +0000 Subject: [PATCH 18/20] Do not fail a Somfy request when persisting the rotated token fails The on_token_refresh callback is caller-supplied, so an exception from it (a database hiccup, a full disk) propagated out of the refresh and killed an otherwise successful request. Log it instead: the session keeps working with the rotated token in memory, and only a restart falls back to the spent stored one. The bookkeeping was also updated before awaiting the callback, so a failed store was remembered as persisted and never retried. Record the token only once it is actually stored, so the next rotation tries again. --- pyoverkiz/auth/strategies.py | 23 +++++++++++--- tests/test_auth.py | 61 ++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/pyoverkiz/auth/strategies.py b/pyoverkiz/auth/strategies.py index c2ab398e..48ee1f7f 100644 --- a/pyoverkiz/auth/strategies.py +++ b/pyoverkiz/auth/strategies.py @@ -538,12 +538,25 @@ async def _refresh(self) -> None: async def _notify_token_refresh(self) -> None: """Let a resuming caller persist a rotated refresh token (no-op otherwise).""" if ( - self._on_token_refresh is not None - and self.context.refresh_token is not None - and self.context.refresh_token != self._persisted_refresh_token + self._on_token_refresh is None + or self.context.refresh_token is None + or self.context.refresh_token == self._persisted_refresh_token ): - self._persisted_refresh_token = self.context.refresh_token - await self._on_token_refresh(self.context.refresh_token) + return + + rotated = self.context.refresh_token + try: + await self._on_token_refresh(rotated) + except Exception: + # Persistence is the caller's problem, not this request's: the + # session keeps working with the rotated token, and only a restart + # would fall back to the (now spent) stored one. + _LOGGER.exception("Failed to persist the rotated Somfy refresh token") + return + + # Recorded only once stored, so a failed store is retried on the next + # rotation instead of being remembered as persisted. + self._persisted_refresh_token = rotated async def auth_headers(self, path: str | None = None) -> Mapping[str, str]: """Return the Bearer header (site-scoped token), or {} before login.""" diff --git a/tests/test_auth.py b/tests/test_auth.py index f27fa3e9..e7279e1d 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -2308,6 +2308,67 @@ async def _persist(token): assert persisted == ["r-rot"] +@pytest.mark.asyncio +async def test_somfy_resume_failed_persist_does_not_break_the_request(caplog): + """A raising on_token_refresh must not fail the request that refreshed. + + The rotated token already works in memory, so a caller whose store is + unavailable (in Home Assistant, an entry removed mid-refresh) should get a + logged error, not a failed API call. + """ + + async def _persist(_token): + raise RuntimeError("store unavailable") + + strategy, session = _build_somfy_resume_strategy(on_token_refresh=_persist) + await strategy.login() + + session.post = MagicMock( + return_value=_json_ctx({"access_token": "scoped-1", "refresh_token": "r-rot"}) + ) + + with caplog.at_level(logging.ERROR): + assert await strategy.refresh_if_needed() is True + + assert strategy.context.access_token == "scoped-1" + assert strategy.context.refresh_token == "r-rot" + assert "store unavailable" in caplog.text + + +@pytest.mark.asyncio +async def test_somfy_resume_failed_persist_retries_on_next_rotation(): + """A token is only recorded as persisted once the caller actually stored it. + + Recording it up front would make the next rotation look already-persisted, so + a single failed store would silently stop all further notifications. + """ + persisted = [] + failures = [] + + async def _persist(token): + if not failures: + failures.append(token) + raise RuntimeError("store unavailable") + persisted.append(token) + + strategy, session = _build_somfy_resume_strategy(on_token_refresh=_persist) + await strategy.login() + + session.post = MagicMock( + return_value=_json_ctx({"access_token": "scoped-1", "refresh_token": "r-rot-1"}) + ) + await strategy.refresh_if_needed() + assert persisted == [] + + strategy.context.expires_at = datetime.datetime.now(datetime.UTC) + session.post = MagicMock( + return_value=_json_ctx({"access_token": "scoped-2", "refresh_token": "r-rot-2"}) + ) + await strategy.refresh_if_needed() + + assert persisted == ["r-rot-2"] + + @pytest.mark.asyncio async def test_somfy_refresh_is_serialized_across_concurrent_requests(): """Concurrent expired requests must refresh once, not race for the token. From 241eb32934562fb0f828e3fd190618412a168ea9 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Wed, 12 Aug 2026 13:57:36 +0000 Subject: [PATCH 19/20] Document push vs pull token ownership Somfy and Rexel split refresh work in opposite directions, and nothing said why: a Somfy refresh needs the siteOID scoping and the Ginaite client, which only the library can supply, while Rexel is plain OAuth2 that the host app already owns. Spell out both models, what each callback guarantees, and why there is deliberately no pull option for Somfy. --- docs/getting-started.md | 59 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index 3b9a39bc..cc18f9dc 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -154,6 +154,9 @@ Use a cloud server when you want to connect through the vendor’s public API. U # 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). @@ -312,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 @@ -408,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=` 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. From 7f80cbe957f648a46116730b9472728916a950a7 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Wed, 12 Aug 2026 14:04:50 +0000 Subject: [PATCH 20/20] Surface the account roles for each discovered Somfy site BOB lists sites the account was merely invited to alongside the ones it owns, and currentUserRoles was dropped during parsing, so a shared site was indistinguishable from an owned one. Filtering them out would be wrong twice over. Even the most limited access level keeps control of some devices, so a shared site is expected to work in a reduced form rather than not at all. And a filter would need an allowlist, while only "owner" and "secondary" are fixed wire values -- custom and installer roles arrive as opaque server-issued ids, so an unrecognised role must not be read as no access. Report the roles instead, so a caller can explain a missing scene or device to the user rather than guess. --- pyoverkiz/auth/base.py | 8 +++++++ pyoverkiz/auth/bob.py | 21 ++++++++++++++++++ tests/test_auth.py | 48 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+) diff --git a/pyoverkiz/auth/base.py b/pyoverkiz/auth/base.py index f736a5f7..f53831cc 100644 --- a/pyoverkiz/auth/base.py +++ b/pyoverkiz/auth/base.py @@ -70,6 +70,14 @@ class GatewayCandidate: 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 diff --git a/pyoverkiz/auth/bob.py b/pyoverkiz/auth/bob.py index 7a2d8beb..cbba68c5 100644 --- a/pyoverkiz/auth/bob.py +++ b/pyoverkiz/auth/bob.py @@ -22,6 +22,20 @@ class BobGateway: 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.""" @@ -37,6 +51,7 @@ class BobSite: 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) @@ -57,6 +72,7 @@ def gateway_candidates(self) -> list[GatewayCandidate]: 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 @@ -71,6 +87,10 @@ def _make_bob_converter() -> cattrs.Converter: 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( @@ -83,6 +103,7 @@ def _make_bob_converter() -> cattrs.Converter: BobSite, c, site_oid=override(rename="siteOID"), + roles=override(rename="currentUserRoles"), sub_sites=override(rename="subSites"), ), ) diff --git a/tests/test_auth.py b/tests/test_auth.py index e7279e1d..cfdf2c70 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1722,6 +1722,54 @@ async def test_somfy_multisite_discover_flattens_sites(): assert candidates[0].label == "My Home" assert candidates[0].external_id == "ext-a" assert candidates[0].country == "NL" + assert candidates[0].roles == ["owner"] + + +@pytest.mark.asyncio +async def test_somfy_multisite_discover_reports_roles_without_filtering(): + """Shared sites are surfaced with their roles, not dropped. + + Only ``owner``/``secondary`` are fixed values; a custom or installer role + arrives as an opaque id and must survive parsing just the same. + """ + strategy, session = _build_somfy_multisite_strategy() + strategy.context.access_token = "ginaite-1" + session.get = MagicMock( + return_value=_json_ctx( + { + "totalCount": 3, + "results": [ + { + "siteOID": "site-custom-role", + "currentUserRoles": [ + {"roleOID": "07a4b1e6-0e2f-4d1c-9d38-8a1c4f2b6e55"} + ], + "subSites": [{"gateways": [{"gatewayId": "gw-custom"}]}], + }, + { + "siteOID": "site-no-roles", + "subSites": [{"gateways": [{"gatewayId": "gw-no-roles"}]}], + }, + { + "siteOID": "site-partial", + "currentUserRoles": [{}, {"roleOID": "secondary"}], + "subSites": [{"gateways": [{"gatewayId": "gw-partial"}]}], + }, + ], + } + ) + ) + + candidates = await strategy.discover_gateways() + + assert [c.gateway_id for c in candidates] == [ + "gw-custom", + "gw-no-roles", + "gw-partial", + ] + assert candidates[0].roles == ["07a4b1e6-0e2f-4d1c-9d38-8a1c4f2b6e55"] + assert candidates[1].roles == [] + assert candidates[2].roles == ["secondary"] @pytest.mark.asyncio