Skip to content

Add Somfy multi-account (multi-site) authentication - #2168

Open
iMicknl wants to merge 21 commits into
mainfrom
feat/somfy-multi-account
Open

Add Somfy multi-account (multi-site) authentication#2168
iMicknl wants to merge 21 commits into
mainfrom
feat/somfy-multi-account

Conversation

@iMicknl

@iMicknl iMicknl commented Jul 5, 2026

Copy link
Copy Markdown
Owner

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.SOMFY backed by a SomfyAccountAuthStrategy. Legacy per-region servers (SOMFY_EUROPE/AMERICA/OCEANIA) remain for classic single-site password login and the local API.

How it works

  • Password grant + Keycloak (Ginaite) token exchange — no browser, no PKCE, no redirect URI; fits the existing username/password config flow. Reuses the SOMFY_CLIENT_ID pyoverkiz already stores.
  • Site discovery via the BOB site directory; sites/sub-sites/gateways are flattened into gateway candidates.
  • Region resolved client-side from a static country → region map mirroring the TaHoma app's BusinessArea.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.
  • Per-site token minting — a site-scoped Ginaite token is a plain Bearer the classic Overkiz enduser API accepts, so all existing endpoint calls work unchanged. Tokens expire in 900s; re-scoping happens on relogin.
  • Warm-start credentials to skip rediscovery when the target site is already known.

Invitations are intentionally deferred (YAGNI — no HA consumer yet).

Testing

  • uv run pytest — 579 passed (99 in tests/test_auth.py, extensively covering the new strategy).
  • Pre-commit hooks (ruff, mypy, ty) pass.

@iMicknl
iMicknl requested a review from tetienne as a code owner July 5, 2026 22:12
Copilot AI review requested due to automatic review settings July 5, 2026 22:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions github-actions Bot added the feature New feature or capability label Jul 5, 2026
@iMicknl iMicknl linked an issue Jul 5, 2026 that may be closed by this pull request
@iMicknl iMicknl changed the title feat: add Somfy multi-account (multi-site) authentication Add Somfy multi-account (multi-site) authentication Jul 5, 2026
@iMicknl

iMicknl commented Jul 6, 2026

Copy link
Copy Markdown
Owner Author

Integration feedback — wiring this branch into Home Assistant

I built the Home Assistant Server.SOMFY config flow + runtime setup against this branch (token-only persistence, discover_gateways()select_gateway()to_credentials(), on_token_refresh write-back). Overall the design maps cleanly onto HA and I'd like to finalize it. A few things are worth resolving on the library side first, ordered by impact.

1. _resume_session region lookup can raise an uncaught KeyError 🐛

self._endpoint = SOMFY_REGION_ENDPOINT[credentials.region]  # strategies.py

On resume, region comes straight from persisted config-entry storage. A stale/corrupt/renamed region string makes this a bare KeyError raised inside client.login(). On the HA side that call sits in async_setup_entry's try block, which only catches typed exceptions (BadCredentialsError, TooManyRequestsError, …) — so a KeyError escapes as an ungraceful setup crash instead of a clean retry/reauth.

Note the selection path (_region_for_country) is already defensive — .get() + warning + EMEA fallback. The resume path should match it. Suggest:

  • SOMFY_REGION_ENDPOINT.get(region) and raise a typed SomfyServiceError on miss, and/or
  • make region a StrEnum so it's validated on the way back in.

This is the highest-value change: it converts a latent crash into a typed, catchable error.

2. Expose selected_gateway on the client + document post-login() state

The property exists on the strategy but isn't surfaced on OverkizClient. Because of that the integration re-derives the "how many gateways came back / did login() already auto-select one" logic the library already does internally. Surfacing client.selected_gateway — plus a short note on exactly what state login() leaves behind for single vs. multi-site — lets integrators call to_credentials() straight after login() in the sole-site case without re-running discovery/select.

3. Make the rotation footgun louder than a docstring

Omitting on_token_refresh works silently until the first refresh-token rotation, then a later resume fails. It's only documented on SomfyTokenCredentials. A one-time warning on the first rotation when no callback is wired would save integrators a confusing debugging session.

4. Clarify the legacy SOMFY_EUROPE vs. new SOMFY end state (the main open question)

SomfyAuthStrategy (legacy per-region, single-site, password-on-disk) and SomfyAccountAuthStrategy (new, multi-site, token-only) now coexist, and both SOMFY_EUROPE and SOMFY appear in HA's server picker — so a user sees "Somfy Europe" and "Somfy" side by side with no guidance on which to pick. The library can't fix the picker, but the intended trajectory should be documented: is SOMFY_EUROPE (and AMERICA/OCEANIA) deprecated in favor of SOMFY? That decision drives whether HA needs a migration/repair flow, so pinning it down here unblocks the integration side.


The new-strategy test coverage is genuinely thorough (resume roundtrip, rotation-notify, region fallback, relogin-rescope) — no gaps there. #1 is the only functional concern; the rest are ergonomics/docs.

iMicknl added 7 commits July 10, 2026 15:00
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.
@iMicknl
iMicknl force-pushed the feat/somfy-multi-account branch from 7083041 to 806dfc0 Compare July 10, 2026 13:00
iMicknl added 14 commits August 12, 2026 13:11
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature or capability

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement Somfy multi-account (Ginaite/BOB) authentication flow

2 participants