Add Somfy multi-account (multi-site) authentication - #2168
Conversation
Integration feedback — wiring this branch into Home AssistantI built the Home Assistant 1.
|
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
…tart 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().
…rim 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.
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.
7083041 to
806dfc0
Compare
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.
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.
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.
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.
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.
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.
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.
`_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.
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.
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`.
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.
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.
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.
Summary
Adds Somfy multi-account (multi-site) support — the "multi account sign-in" feature in the current TaHoma app — letting a single Somfy account authenticate and control each of its sites (homes) through pyoverkiz, reusing every existing Overkiz endpoint call unchanged.
Introduces a new region-agnostic
Server.SOMFYbacked by aSomfyAccountAuthStrategy. Legacy per-region servers (SOMFY_EUROPE/AMERICA/OCEANIA) remain for classic single-site password login and the local API.How it works
SOMFY_CLIENT_IDpyoverkiz already stores.country → regionmap mirroring the TaHoma app'sBusinessArea.fromCountry(EMEA/APAC/SNABA). No API field carries the region. Any unresolvable country falls back to EMEA (identical to the app) and logs a warning so the map can be updated.Invitations are intentionally deferred (YAGNI — no HA consumer yet).
Testing
uv run pytest— 579 passed (99 intests/test_auth.py, extensively covering the new strategy).