Compare commits

..

121 Commits

Author SHA1 Message Date
Paulus Schoutsen 907a1f7019 sandbox/docs: add doc-audit research notes (ARCHITECTURE/OVERVIEW vs code)
Dated audit snapshots (2026-06-05) cross-checking every concrete name /
RPC / routing rule / table row in ARCHITECTURE.md and OVERVIEW.md against
the implementation. Kept as research artifacts under plans/research/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:32:03 -04:00
Paulus Schoutsen e1f1a7f91c sandbox: STATUS — plan-query-rpc landing note
Record what shipped per phase (service-path + EntityQuery request/response),
what stays deferred (subscription/push primitive, todo, browse_media
media-source caveat), the deviations (search via async_internal_search_media,
JSON-safe sandbox response, callerless raise_not_proxied retained), and the
green verification summary lines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:23:45 -04:00
Paulus Schoutsen 6791c64d59 sandbox/docs: query RPCs are implemented; subscriptions still open
Reflect the shipped request/response query RPCs across the docs: the
server-side query + WS-only mutation entity APIs now answer with real data
(service-path return_response + the generic entity_query RPC), so the
catalogue's status column, ARCHITECTURE §8/§14, OVERVIEW's "still open"
bullet, and the CLAUDE.md follow-up all move from "raises" to "wired". Kept
accurate as still-open: the subscription/push primitive (the */subscribe
one-shot-only rows + todo item-list push) and the media_player.browse_media
media-source caveat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:22:03 -04:00
Paulus Schoutsen 21788fd815 sandbox: test EntityQuery serialization fidelity + error paths
Round-trip rebuild tests for SearchMedia and Segment (the as_dict /
dataclass-asdict-vs-constructor asymmetry), per-op EntityQuery proxy tests
(media search, release notes, vacuum segments, calendar update/delete) that
assert the rebuilt typed object and the forwarded method + args, and the two
error paths: a sandbox-side ServiceValidationError translating to a
HomeAssistantError on main, and a closed channel degrading to a clean
HomeAssistantError. Client-side coverage for the EntityQuery handler:
method invocation + kwarg passing, unknown entity_id, unknown method, and a
raising method propagating its exception type on the error frame.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:19:23 -04:00
Paulus Schoutsen e5f2f8f932 sandbox: wire the service-less query ops onto EntityQuery
Replace the remaining raise_not_proxied stubs with EntityQuery forwards +
typed rebuilds, so every query-shaped entity API now answers with real data:

- media_player.async_search_media -> async_internal_search_media (which
  rebuilds the SearchMediaQuery from flat kwargs on the sandbox side, so the
  query crosses as plain JSON); rebuilds SearchMedia, reusing the BrowseMedia
  helper for its result list.
- update.async_release_notes -> async_release_notes (plain str/None).
- vacuum.async_get_segments -> async_get_segments; rebuilds list[Segment].
- calendar.async_update_event / async_delete_event -> the matching WS-only
  entity methods (None result).

The sandbox-side serialisation is the as_dict-aware JSON encoder already
added with the handler, so SearchMedia/BrowseMedia/Segment cross verbatim.
raise_not_proxied is now callerless but kept exported for the still-deferred
subscription/todo-push primitive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:19:11 -04:00
Paulus Schoutsen 33dab10779 sandbox: add the generic EntityQuery request/response RPC
The fire-and-forget call_service channel can command an entity but can't
ask it a server-side question that has no SupportsResponse service to ride.
Add one generic EntityQuery RPC for those, mirroring the call_service path
end to end (proto -> codec registry -> bridge sender + error translation ->
sandbox handler -> proxy helper):

- proto: EntityQuery {sandbox_entity_id, method, args, context_id} and
  EntityQueryResult {result} (the return wrapped as {"value": ...} so
  scalar/list/None are all representable). Gencode regenerated into both
  _pb2 mirrors; drift guard passes.
- MSG_ENTITY_QUERY constant + REGISTRY entry added to both protocol/messages
  mirrors.
- SandboxBridge.async_entity_query builds the request, remembers the context
  before the id is reduced to a wire value, translates remote/closed errors
  through the existing paths, and unwraps {"value": ...}.
- EntryRunner._handle_entity_query resolves the entity on the private hass,
  invokes the named method with the decoded kwargs, and serialises the return
  through the as_dict-aware JSON encoder; raised HA/voluptuous errors
  propagate as channel error frames so main rebuilds the same shape.
- SandboxProxyEntity._entity_query is the proxy-side companion to
  _call_service.

No proxy op is wired onto it yet — that is the next phase.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:13:17 -04:00
Paulus Schoutsen 98e63bc133 sandbox: proxy response queries via the call_service path
Wire the three query-shaped entity APIs that have a SupportsResponse
service onto the existing call_service + return_response channel, so a
sandboxed entity answers them with real data instead of raising:

- calendar.async_get_events -> calendar.get_events service, rebuilding
  list[CalendarEvent] from the response (explicit field mapping, ISO
  date/datetime parse — not a **dict splat).
- weather.async_forecast_{daily,hourly,twice_daily} -> weather.get_forecasts
  service; Forecast is a plain TypedDict, returned verbatim.
- media_player.async_browse_media -> media_player.browse_media service,
  rebuilding the recursive BrowseMedia from its frontend-shaped as_dict.

SandboxProxyEntity._call_service grows a return_response flag that decodes
the CallServiceResult response into a dict. The sandbox-side call_service
handler now runs rich service responses (e.g. a BrowseMedia object keyed by
entity_id) through the as_dict-aware JSON encoder before packing the Struct,
yielding the exact wire shape main rebuilds from.

Caveat documented at the browse_media call site: a sandboxed player's browse
surfaces only its own sources; the media_source tree is empty inside the
sandbox (media_source runs on main). Round-trip rebuild unit tests cover the
as_dict-vs-constructor asymmetry first (plan Risk #2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:09:33 -04:00
Paulus Schoutsen 6b7d559d8d sandbox/docs: catalogue query-shaped RPC gap + request/response plan
Document the unproxied query/subscribe/WS-only entity APIs, their interim
raise behaviour, and the two missing primitives (request/response +
subscription RPC) in docs/query-shaped-rpcs.md. Add the implementation
plan (plan-query-rpc.md): a generic EntityQuery RPC for the service-less
ops + reuse of the existing call_service return_response path for ops
that have a SupportsResponse service. Note the media_player.browse_media
caveat (no media_source tree inside the sandbox). Cross-reference from
ARCHITECTURE/OVERVIEW/CLAUDE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 07:57:12 -04:00
Paulus Schoutsen 623c569807 sandbox: fail loudly on unproxied query-shaped entity APIs; block todo
The server-side query / subscribe / WS-only-mutation entity APIs the
fire-and-forget call_service bridge can't express (calendar listings +
event update/delete, weather forecasts, media browse/search, update
release notes, vacuum segments) previously returned empty/None silently.
Add entity.raise_not_proxied and have those proxy methods raise
HomeAssistantError instead, so the gap fails loudly until a real query
RPC lands.

todo is a special case: its To-do panel reads the sync todo_items
property that also feeds TodoListEntity.state, so it can't be a query at
all. Route it to main via SANDBOX_INCOMPATIBLE_PLATFORMS and drop the
proxy (matching the camera/image precedent).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 07:56:59 -04:00
Paulus Schoutsen 1a72d6658c sandbox/docs: document translation forwarding; fix OVERVIEW drift
Both docs now describe the translation-forwarding subsystem in the body,
not just the goal: live pull (sandbox/get_translations RPC + provider
overlay) and the picker catalog hook.

- OVERVIEW: add a Translation forwarding section + "where to look" row +
  v1-diff row. Fix pre-existing drift: ALWAYS_MAIN is 24 entries across
  three groups (was listed as 6), failed-sandbox setup is SETUP_ERROR
  (not SETUP_RETRY), and the manager runs no periodic ping loop.
- ARCHITECTURE: add §11 Translation forwarding (renumber following
  sections), list translation.py/catalog.py in §2, and correct the core
  touch surface from three to five hooks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 07:28:45 -04:00
Paulus Schoutsen 2bb6cac651 sandbox/docs: mention translations in the unified view; drop architecture.html
Translation forwarding (live pull-RPC + catalog provider) now puts the
sandboxed integration's translations on main alongside its entities,
services, and events — note it in the OVERVIEW + ARCHITECTURE goals.
Remove the generated architecture.html; the architecture is published to
a gist instead of carrying a rendered artifact in the tree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 07:04:08 -04:00
Paulus Schoutsen b7e58d234b sandbox: STATUS — plan-translation-forwarding Phase A landing note
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 06:32:23 -04:00
Paulus Schoutsen 2085b5348d sandbox: A3 — document the catalog-provider HACS contract
Add sandbox/docs/catalog-provider-contract.md describing the display-only
picker catalog hook: the discoverability gap it closes, the
async_register_sandbox_catalog_provider API, and the contract — separate
from the sha-pinned source resolver, name load-bearing, title_translations
optional, no validation, display-only scope, and how it complements the
Phase B live RPC for the cold picker case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 06:31:03 -04:00
Paulus Schoutsen e95fd93e21 sandbox: A2 — merge catalog into descriptions + title fallback
Wire the A1 catalog hook into the two display paths a sandbox-only custom
falls through today:

- async_get_integration_descriptions (loader.py): append catalog
  descriptors to the custom integration/helper buckets so the add-
  integration picker lists them. On-disk customs carry richer metadata,
  so the disk scan wins on a domain collision.
- _async_get_component_strings (helpers/translation.py): when a domain
  has no on-disk Integration (IntegrationNotFound on main), take its
  "title" from the catalog — a localized title_translations[lang] if
  present, otherwise degrading to the descriptor name.

Tests: catalog entry appears in descriptions with picker name + defaults
+ helper-bucket routing; on-disk custom wins a collision; title fallback
uses title_translations and degrades to name when absent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 06:30:10 -04:00
Paulus Schoutsen 07dcf64357 sandbox: A1 — catalog-provider hook for picker discoverability
Add a separate, display-only catalog hook so a custom integration whose
code lives only in a sandbox (never on main's disk) can be listed and
named in the add-integration picker without spawning a sandbox.

Core (homeassistant/loader.py) owns the registry because core consumes
it: SandboxIntegrationDescriptor, SandboxCatalogProvider,
DATA_SANDBOX_CATALOG_PROVIDERS, async_register_sandbox_catalog_provider,
async_get_sandbox_catalog. This mirrors the Phase B translation-provider
precedent (hook + consumer co-located in core).

homeassistant/components/sandbox/catalog.py re-exports the hook so HACS
registers through a sandbox namespace parallel to the source resolver —
but the catalog stays deliberately separate from the sha-pinned, security-
critical source resolver: it is eager, enumerable and cosmetic only.

Wired into descriptions + title fallback in A2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 06:27:04 -04:00
Paulus Schoutsen 9da2dfa714 sandbox: STATUS — plan-translation-forwarding Phase B landing note
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 06:19:33 -04:00
Paulus Schoutsen bb32e859f1 sandbox: B4 — main-side translation provider impl + registration
Implement SandboxTranslationProvider and register it into core's translation
hook from async_setup (unregistered on stop). For each requested component it:

- resolves the owning sandbox group — a loaded entry's .sandbox field wins,
  else the live SandboxFlowProxy of a brand-new custom's in-progress flow
  (new sandbox_group accessor on the proxy);
- carves out built-ins (Integration.is_built_in ⇒ main reads its byte-identical
  disk copy, never the wire);
- batches each group's custom domains into one get_translations RPC per
  language (5s timeout), and degrades to empty strings on a down/closed/slow
  channel so the cache-lock overlay never blocks the frontend.

router.async_unload_entry now invalidates a sandboxed entry's cached
translations, so a reload at a new integration-source ref re-pulls fresh
strings on the next fetch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 06:17:46 -04:00
Paulus Schoutsen 8142996b08 sandbox: B3 — core translation provider hook + overlay + invalidation
Add a sandbox-agnostic seam to the translation cache, mirroring the
sandbox.sources source-resolver convention:

- async_register_sandbox_translation_provider(hass, provider): a HassKey-backed
  registry with an unregister callback. The provider is awaited inside the
  cache load and returns {language: {domain: raw_strings}} for only the domains
  it owns.
- _TranslationCache._async_load overlays the provider result onto
  translation_by_language_strings after async_get_integrations and before
  _build_category_cache, so sandboxed strings flow through the same flatten /
  English-fallback / loaded machinery as on-disk strings. A custom sandboxed
  domain (IntegrationNotFound on main) thus stops resolving to {}.
- _TranslationCache.async_invalidate + async_invalidate_translations wrapper:
  the first eviction API (translations were never unloaded), called by the
  sandbox when a custom integration is re-fetched at a new ref.

Core never raises on a provider; degrade-to-empty is the provider's contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 06:12:52 -04:00
Paulus Schoutsen 5ffbe73ae2 sandbox: B2 — get_translations runtime handler + string loader
Register a sandbox/get_translations handler in SandboxRuntime. It loads raw
translation strings for the requested domains from the sandbox's own
filesystem (built-in from the bundled package, custom from the fetched
<config>/custom_components/<domain>) by reusing core's
_async_get_component_strings against the sandbox-private hass — which also
pre-fills 'title' from integration.name. Main cannot run that fallback for a
custom domain because it holds no Integration, so the title must be injected
here. Replies with {language, strings: {domain: raw dict}}.

Tests cover built-in title pass-through, custom title injection, the empty
case, the Struct packing, and the no-flow-runner guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 06:09:18 -04:00
Paulus Schoutsen af129cb26a sandbox: B1 — get_translations wire protocol
Add the sandbox/get_translations message pair to the control-channel proto
and regenerate the checked-in gencode for both no-cross-import mirrors.
Mirror MSG_GET_TRANSLATIONS in both protocol.py files and register the
message pair in both messages.py REGISTRY copies.

Request {language, domains[]}; result {language, strings: {domain: raw
strings.json dict}} — main batches a group's custom domains into one call;
built-in domains never cross the wire.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 06:06:41 -04:00
Paulus Schoutsen 7533080597 sandbox/plans: add translation-forwarding brainstorm + plan
Brainstorm → plan for forwarding a sandboxed integration's translations
into main: live pull-RPC (Phase B) for running integrations + a catalog
provider (Phase A) for picker discoverability. Includes interview,
research notes, scratchpad, and the phased plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 05:54:20 -04:00
Paulus Schoutsen 87c7fb5b46 sandbox: PLAN_RUNNER — brief is just piped, no file references
Rewrote the briefing so it never frames the brief as a file or mentions
the former tempfile handoff: compose it, pipe it straight into the session
(heredoc), claude-screen pastes it as one message. Dropped the file-pipe
example and the "no tempfile dance" aside.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 05:52:23 -04:00
Paulus Schoutsen ccadaf1236 sandbox: PLAN_RUNNER — drop file-handoff framing from briefing
Now that claude-screen pastes multi-line directly, the brief no longer
needs a tempfile + "Read /tmp/X" pointer. Step 1 reads "Compose the brief"
(source it from a heredoc or any scratch file) instead of "Write the
brief"; step 2 shows both heredoc and file pipes. The brief is just stdin,
not a handoff artifact.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 05:50:45 -04:00
Paulus Schoutsen 3fced25724 sandbox: PLAN_RUNNER — STATUS marker now lives under status/
Follow-up to the status/ reorg: the brief's STATUS path and the monitor
until-loop both point at sandbox/status/STATUS-<plan>.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 05:49:02 -04:00
Paulus Schoutsen 0ae2eef60a sandbox: move STATUS files into status/
Tidy the directory: the 29 per-phase + per-plan landing records
(STATUS-phase-*.md, STATUS-plan-*.md) move out of the sandbox/ root into
sandbox/status/ (git mv, blame preserved). Live current-state docs
(CLAUDE.md, README.md, OVERVIEW.md, FOLLOWUPS.md, architecture.html, the
docker-compose harness comment) now point at status/. Historical records
(the STATUS bodies themselves, plans/*.md, plan.md) keep their original
text by convention.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 05:45:38 -04:00
Paulus Schoutsen caa52e2823 sandbox: PLAN_RUNNER — multi-line briefs now paste directly, no tempfile
claude-screen pastes multi-line prompts directly: bracketed-paste markers
keep embedded newlines literal, and the submit \r is sent as a separate
keystroke a beat later (the concatenated \r was what raced the paste and
submitted mid-prompt). Verified live — a 3-line prompt lands as one
message. Doc no longer mentions any file-handoff.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 05:39:07 -04:00
Paulus Schoutsen 5b35d4b20e sandbox: PLAN_RUNNER — claude-screen now auto-handles multi-line briefs
The single-line file-handoff is now built into claude-screen itself (it
detects a newline in the piped prompt, stashes the brief to a tempfile,
and pastes a pointer). So the doc just pipes the brief straight in; the
manual "write to /tmp + echo a single line" dance is gone.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 05:36:09 -04:00
Paulus Schoutsen fa28e7630a sandbox: PLAN_RUNNER — plans MUST be executed via the phx:work skill
Make explicit that each sub-session steps through its plan with the
phx:work skill (task-by-task with per-step compile/test verification),
not ad-hoc edits. Added as a brief hard rule + a why-this-shape bullet.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 05:31:20 -04:00
Paulus Schoutsen b7e3a36002 sandbox: add PLAN_RUNNER.md — the per-plan sub-session workflow
Documents the loop used to build this batch: write a brief to a tempfile,
spawn a fresh Claude in a screen window via single-line file-handoff, watch
for a STATUS marker, verify independently, push, kill the window. Captures
the gotchas that bit (single-line stdin, prompt-submit confirmation,
prefix-match window names, orchestrator-only push).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 05:30:32 -04:00
Paulus Schoutsen 1cef20237f sandbox/tests: rename test_phase1_spike_late_additions_pin_to_main
Drop the build-phase scaffolding from the test name; it just verifies ai_task
and image pin to ALWAYS_MAIN. -> test_ai_task_and_image_pin_to_main.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 04:53:32 -04:00
Paulus Schoutsen 3c60b2b1a2 sandbox: trim the compat lane to one runner
The one-shot full cross-sweep that produced the original backlog
(run_compat_full.py + categorize_failures.py + generate_backlog.py) and its
machine-generated outputs (COMPAT_FULL.md/.csv, COMPAT_LATEST.md, COMPAT.csv,
BACKLOG_FAILURES.json) were Phase-16 measurement scaffolding; the gate is long
cleared. Keep the single ongoing runner (run_compat.py) and the two curated
summaries (COMPAT.md, BACKLOG.md). Git-ignore the per-run machine output so it
stops being checked in. Living docs updated; recover the full-sweep tooling
from git history if a fresh tree-wide sweep is ever needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 04:48:53 -04:00
Paulus Schoutsen 66f96e9438 sandbox/bridge: route the service forwarder through _raw_call_service
The registered-service forwarder (_build_service_forwarder._forward) rebuilt its
own pb.CallService request and duplicated the ChannelRemoteError/
ChannelClosedError translation that _raw_call_service already does. With the
batcher gone, _raw_call_service is the single low-level send helper — have
_forward call it and keep only its response-extraction logic. No behaviour
change (the channel-closed error message is now the shared generic one).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 04:46:09 -04:00
Paulus Schoutsen 11e97c62ea sandbox: remove the Option A/B spike harness
The spike (hass_client/spike/: bridge_a, bridge_b, rig, synthetic_light,
transport + tests/components/sandbox/test_spike.py) was a one-off bake-off to
choose between entity-bridge designs. Option B was chosen and shipped long ago;
nothing in production imports the spike, only its own test did. Delete it.

docs/entity-bridge-decision.md keeps the rationale and the measured numbers as
the decision record, with a note that the harness is recoverable from git
history.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 04:45:07 -04:00
Paulus Schoutsen ecc8384382 sandbox: drop the call-service batcher; keep the first iteration simple
Each proxy entity service call now forwards as its own single
`sandbox/call_service` RPC. The per-loop-tick coalescing batcher
(_CallServiceBatcher / _BatchBucket) added complexity the first iteration
doesn't need, so it is removed; async_call_service calls _raw_call_service
directly. Behaviour is unchanged except a multi-entity area call now pays one
RPC per entity instead of one coalesced RPC.

Coalescing same-tick calls is recorded as a future optimisation in
docs/FOLLOWUPS.md (with the 200-light perf benchmark that validated it). Living
docs updated; the phase-history records are left as-is.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:21:29 -04:00
Paulus Schoutsen 34d0a533c7 sandbox/bridge: correct misleading 'fire-and-forget' wording
A service call is never fire-and-forget: each batched caller awaits the
coalesced RPC's completion via its future, which resolves with the result or
the raised error, so every caller learns when its call finished. Batching only
shares the *wire* call, not the await; only a response *value* can't be
coalesced (hence the response bypass). Wording-only; no behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:04:44 -04:00
Paulus Schoutsen bd90dcd7dc sandbox: drop development-phase references from code
The final deliverable should not carry the scaffolding of the phases it was
built in. Reword comments, docstrings, and generated-output strings that named
build phases (Phase N / T1-T3 / Phase A1-A2) to describe what the code does,
and rename the phase-numbered test files:

  test_phase4_subprocess  -> test_subprocess
  test_phase9_shutdown    -> test_shutdown
  test_phase13_proxies    -> test_domain_proxies
  test_phase14            -> test_schema_and_unload
  test_phase19_devices    -> test_device_registry

Comments/docstrings/filenames only; no logic changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:28:48 -04:00
Paulus Schoutsen 43310e8c21 sandbox/bridge: use orjson helper for batch key; bypass batcher for responses
- Replace a stray function-local `import json` (json.dumps behind a bogus
  'keeps json off the integration boot path' noqa) with HA's orjson
  json_bytes_sorted helper for the call-service batch key.
- A response-returning entity call now bypasses the per-tick batcher.
  Coalescing forces every caller in a bucket to share one combined response,
  which is wrong when a caller needs its own value; response calls go out as
  their own single-entity RPC. The batcher is now fire-and-forget only, so its
  dead return_response plumbing is dropped.
- Also removes development-phase references from this file's docstrings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:28:20 -04:00
Paulus Schoutsen a9781cca14 sandbox: document the current auth design (no credential + context restore)
Rewrite docs/auth-scoping-decision.md to lead with the shipped design: the
sandbox holds no credential and cannot fabricate a Context; main restores
attribution from a TTL cache of contexts it issued and falls back to
user_id=None. The reverted, never-shipped scoped-token mechanism is kept as a
clearly-marked appendix for whenever the sandbox->main websocket lands. Update
the CLAUDE.md pointer to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:28:08 -04:00
Paulus Schoutsen cf535a086f sandbox: drop V2 naming from living docs
Rename identifiers (SandboxV2Data->SandboxData, DATA_SANDBOX_V2->
DATA_SANDBOX, SandboxV2Error->SandboxError), env vars (SANDBOX_V2_*->
SANDBOX_*), and stale sandbox_v2/ paths in the current-state docs
(OVERVIEW, README, CLAUDE, plan, architecture.html, COMPAT*, docs/*),
and reword prose that named the current sandbox "v2". Historical
STATUS-phase-*/plans/* records are left intact as point-in-time history.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:56:28 -04:00
Paulus Schoutsen d20ed216cb sandbox: drop remaining V2 naming from live code and config
The rename sweep missed several identifiers, env vars, and the
pre-commit drift-guard hook (whose entry/files paths still pointed at
the non-existent sandbox_v2/ tree, leaving the hook broken). Rename:

- SandboxV2Data -> SandboxData, DATA_SANDBOX_V2 -> DATA_SANDBOX,
  SandboxV2Error -> SandboxError (+ all references and tests)
- SANDBOX_V2_ERRORS_DIR/TRANSPORT/SOCKET_PATH -> SANDBOX_* env vars
- pre-commit hook id/entry/files: sandbox_v2/proto -> sandbox/proto
- stale sandbox_v2 paths and 'v2' wording in .dockerignore + scripts

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:50:32 -04:00
Paulus Schoutsen afc45ae34b sandbox: drop unreleased Phase-7 scopes back-compat shim
The reverted Phase-7 auth-scoping mechanism never shipped, so no
real auth store carries a legacy "scopes" key. Remove the defensive
pop in AuthStore (RefreshToken is built by explicit field mapping, so
unknown keys are ignored anyway) and its test. Reword the
ConfigEntry.sandbox load comment to state the real reason the key is
optional (non-sandboxed entries omit it) instead of referencing an
unreleased phase.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:44:26 -04:00
Paulus Schoutsen 83a0c28229 Merge remote-tracking branch 'origin/dev' into sandbox
# Conflicts:
#	pyproject.toml
2026-06-04 14:26:43 -04:00
Paulus Schoutsen 0dd9252d73 sandbox: architecture doc review polish
Review feedback on ARCHITECTURE.md:
- Goal (§1) now names storage alongside setup/flow/entities/services/
  events, and adds a short statelessness line (storage routes to main,
  code is fetched at startup → wipe-and-restart safe).
- Auth (§10) trimmed to describe the current design — no credential, no
  user — instead of narrating the token's removal. The removal history
  lives in the changelog where it belongs.
- Dropped the "(the Iron Law: never monkey-patch private internals)"
  parenthetical in §11; the plain "declared public hook rather than a
  reach into private internals" already carries the point.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 06:11:02 -04:00
Paulus Schoutsen fd2c319e1b sandbox: STATUS for plan-auth-context (token + system user gone, context restored)
Landing notes: how the context cache was seeded (forwarder + entity-call
path), the 15-min TTL bound, confirmation the token + system user are fully
gone (greps), test results, and doc updates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:39:00 -04:00
Paulus Schoutsen 83cc4d4a07 sandbox: docs for plan-auth-context (token + system user gone, context restored)
Reconcile the architecture docs with the plan-auth-context landing:

- ARCHITECTURE.md §2/§5/§8/§10/§13 + changelog: auth.py removed from the
  component table; the spawn command no longer carries --token; §8 documents
  the implemented context restoration (TTL cache, own-id minting, ULID-trust
  reasoning); §10 rewritten — no token, no system user, the future
  Context-group-attribute note retained.
- OVERVIEW.md: auth comparison row, the spawn-command blocks, the
  EventMirror context paragraph, the auth section (now "no credential" +
  a Context-restoration subsection), and the file-pointer table.
- FOLLOWUPS.md: a plan-auth-context narrative entry; the open follow-ups now
  describe the fresh-credential-when-WS-lands work and the Context group
  attribute idea.
- auth-scoping-decision.md / CLAUDE.md: note the token + system user are now
  also gone (already SUPERSEDED).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:37:58 -04:00
Paulus Schoutsen 6206489b5f sandbox: drop unused token + system user, restore context attribution
plan-auth-context Parts A/B/C — a design-review follow-up. The sandbox is
not an authenticated principal inside main and must never be able to author
a Context.

Part A — drop the unused token. The manager minted a per-group system-user
access token and passed it on --token; the runtime stored it
(SandboxRuntime.token) and never used it (no connection back to main to
authenticate). Removed end-to-end: --token argv (manager._default_command),
the token_factory wiring, SandboxRuntime.token field/param + --token CLI
arg, and SANDBOX_TOKEN in the Docker entrypoint / compose / docs.

Part C — drop the per-group system user. auth.py is deleted entirely
(async_issue_sandbox_access_token + async_get_or_create_sandbox_user gone),
along with bridge._async_system_user_id / _system_user_id. A genuinely
sandbox-originated context is now user_id=None — the honest shape, since no
user authored it.

Part B — context-id restoration. The bridge now seeds a context_id→Context
cache at every main→sandbox call-down site: the service forwarder (_forward)
and the proxy entity's service call (async_call_service, which threads the
entity's live Context). _resolve_context returns a cached Context verbatim
for a known id (restoring the original parent_id / user_id), so a
user-initiated action's attribution survives the round-trip. An unknown or
expired id mints a brand-new Context(user_id=None) with main's own trusted
id — never the sandbox-supplied ULID, whose embedded timestamp main cannot
trust (recorder/logbook order by it); the sandbox string is a cache key
only. The cache is bounded by a 15-minute TTL (lazy front-pruning, plus a
sanity count backstop); a miss is always safe.

Tests: known-id restore end-to-end via the forwarder; unknown→fresh with no
adopted id; no-forgery (the wire proto has no parent_id/user_id field); TTL
expiry degrades to a fresh context; spawn argv no longer carries --token.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:37:44 -04:00
Paulus Schoutsen 5d5c1eca6e sandbox: refine plan-auth-context Part B — 15-min TTL + ULID trust
User refinement 2026-06-03:
- Bound the context cache by TIME (15-min TTL), not size. Volume is tiny
  (only main→sandbox service-call contexts, echoed back within seconds).
- For an unknown context_id, main must mint a BRAND-NEW Context with its
  OWN id — never adopt the sandbox's id. context_ids are ULIDs with an
  embedded timestamp and main cannot trust the sandbox's clock (a crafted
  ULID could back/forward-date events; recorder/logbook order by it). The
  sandbox-supplied id is only a cache key, never the resulting Context's
  identity. This corrects T2's current Context(id=context_id, ...) for
  unknown ids.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 11:23:43 -04:00
Paulus Schoutsen 2ad24f9111 sandbox: lock plan-auth-context Part C — drop the per-group system user
User decision 2026-06-03: drop the sandbox system user entirely;
sandbox-originated contexts use user_id=None (no reason for the sandbox
to be a user right now). Future-work note recorded: a Context with a
group attribute is the better long-term answer for audit attribution,
but needs a core Context field change and waits until it's needed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 11:17:00 -04:00
Paulus Schoutsen 7c0308e60c sandbox: design-review fixes — schema fallback, group ownership, auth/context
From review feedback on the architecture doc:

1. register_service schema serialisation: broaden serialize_schema's
   fallback from `except (ValueError, TypeError)` to any exception (with a
   warning log). An exotic custom validator could raise other types and
   propagate, dropping the whole service registration on main. Now it
   always degrades to schema=None — main registers the service, the
   sandbox validates. Added test_schema_bridge.py covering the broad path.

2. Clarify in ARCHITECTURE.md that MAIN alone decides the sandbox group:
   the group comes from main's classify(), the proxy overwrites
   create_result["sandbox"] with the main-determined value, and the wire
   FlowResult has no group field. The sandbox can shape its own forms but
   cannot influence storage/routing. (Code was already correct; the doc
   wording was loose.)

3. Note the --token is unused (sandbox is not an authenticated principal
   inside HA) and slated to drop; the per-group system user's only live
   use is context attribution, under reconsideration.

4. Document the intended context model: wire carries context_id only;
   main restores parent_id/user_id from a seen-id cache so the sandbox can
   never fabricate attribution; unknown ids get a fresh no-parent context.

Points 3+4 captured as a buildable follow-up: plans/plan-auth-context.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 11:10:05 -04:00
Paulus Schoutsen ec709db2f4 sandbox: final architecture document (current state + changelog)
A self-contained, final-state architecture reference for the sandbox:
goal, components, routing, protobuf channel + pluggable transports,
lifecycle, config-flow forwarding, statelessness/integration-source,
entity/service/event bridging, store routing, auth, core-HA touch
surface, testing/Docker, and out-of-scope/future work. Changelog at the
bottom summarises the closing batch (contextvar, strip-auth-scopes,
fidelity, lockdown, transport, ephemeral-sources, docker, rename).

Distinct from OVERVIEW.md (source-linked depth) and architecture.html
(phase-by-phase historical artifact) — this is the current-state-only
narrative.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 10:20:57 -04:00
Paulus Schoutsen 7b8b31afa5 sandbox: STATUS for plan-rename-sandbox (rename complete) 2026-06-03 10:16:54 -04:00
Paulus Schoutsen 9cd52e950e sandbox_v2: docs reconciliation for the rename (Phase E)
- whats-changed.md (batch tracker): added the `sandbox_v2` → `sandbox` rename
  as a TL;DR bullet + a breaking-change entry (pre-release, wipe-and-restart).
- sandbox/CLAUDE.md: reworded the intro to current-state ("the sandbox rewrite,
  formerly sandbox_v2"); clarified that the removed v1 previously occupied the
  same paths the rewrite now lives at.

Historical records (STATUS-*.md, plans/*.md, docs/auth-scoping-decision.md)
keep their sandbox_v2 mentions intact — they document work done against those
paths.
2026-06-03 10:14:52 -04:00
Paulus Schoutsen 5bab9f867b sandbox_v2: drop hassfest IGNORE_INTEGRATIONS_WITH_ERRORS (Phase C)
The IGNORE_INTEGRATIONS_WITH_ERRORS = {"sandbox"} set tolerated v1's hassfest
violations while v2 stabilised. v1 is gone and the renamed integration (former
v2) is hassfest-clean, so the set + the two conditionals consulting it are
removed — keeping it would mask real errors in the renamed `sandbox`.

hassfest validate + generate: 0 invalid integrations, no generated-file changes
(sandbox has no config_flow → absent from config_flows.py; the NO_QUALITY_SCALE
entry was renamed by the Phase B sweep).
2026-06-03 10:10:36 -04:00
Paulus Schoutsen cd02466612 sandbox_v2: sweep identifiers sandbox_v2 → sandbox + regen protobuf
Phase B of plan-rename-sandbox. Mechanical identifier sweep + the structural
fixups the rename forced (tree compiles + both suites pass at the end):

- Bare-token sweep `sandbox_v2` → `sandbox` across all code + current-state
  docs (excluding historical STATUS-*.md, plans/*.md, auth-scoping-decision.md
  and the generated _pb2 gencode). Channel message strings, storage-key
  namespace, client_id prefix, manifest domain, logger names all move.
- Prose sweep `Sandbox v2` → `Sandbox` (covers the `Sandbox v2: ` system-user
  name prefix → `Sandbox: `).
- Protobuf: renamed sandbox_v2.proto → sandbox.proto (package `sandbox`) and
  REGENERATED gencode (sandbox_v2_pb2 → sandbox_pb2) in both mirrors via the
  isolated-venv recipe; removed the old _pb2 files. Drift guard clean.
- Name-collision fix forced by the rename: the client had both the impl module
  `hass_client/sandbox.py` (exports SandboxRuntime) AND the `-m` launcher
  subpackage `hass_client/sandbox_v2/`. Renaming the launcher to `sandbox`
  collides with the impl module, so merged them — sandbox.py is now
  `hass_client/sandbox/__init__.py` (parent-relative imports rewritten to
  absolute `hass_client.*` per ruff TID252) with the launcher's __main__.py
  kept. `python -m hass_client.sandbox` and
  `from hass_client.sandbox import SandboxRuntime` both work.
- Docker entrypoint/compose/docs → `python -m hass_client.sandbox`.
- Client distribution renamed `hass-client-v2` → `hass-client` (import package
  `hass_client` unchanged; matches the egg-info already installed).

Tests green: HA-side 201 passed, client 70 passed. prek clean on changed set.
2026-06-03 10:10:36 -04:00
Paulus Schoutsen 107cb8b38e sandbox_v2: rename directories sandbox_v2 → sandbox (git mv)
Phase A of plan-rename-sandbox. Pure renames via git mv to preserve blame:
  homeassistant/components/sandbox_v2          → homeassistant/components/sandbox
  tests/components/sandbox_v2                   → tests/components/sandbox
  sandbox_v2                                    → sandbox
  sandbox/hass_client/hass_client/sandbox_v2   → sandbox/hass_client/hass_client/sandbox
  sandbox/proto/sandbox_v2.proto               → sandbox/proto/sandbox.proto

The untracked tests/testing_config/.storage/sandbox_v2 dir is a runtime test
artifact (not tracked); left as-is. The tree does NOT import or pass tests
after this commit — Phase B sweeps every sandbox_v2 identifier + regenerates
the protobuf gencode.
2026-06-03 09:43:33 -04:00
Paulus Schoutsen 4e982e34ca sandbox_v2: docker tracker tick + STATUS
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:33:52 -04:00
Paulus Schoutsen 1224f16df1 sandbox_v2: test Dockerfile + unix-socket compose harness
Multi-stage python:3.14-slim image that runs the hass_client sandbox
runtime (python -m hass_client.sandbox_v2). Installs homeassistant +
hass_client into a venv; no pre-baked integration requirements (runtime
pip-installs them on demand), no git (codeload tarball fetch), non-root,
no volumes, no healthcheck, env-driven entrypoint via tini. Closes the
pip/egress runtime gap flagged by plan-ephemeral-sources: the container
is where pip + network egress live.

Transport caveat: unix socket (T3) today, websocket (T4) later — not a
remote-ready artifact. The docker-compose.test.yml captures the intended
same-host unix-socket harness but does not run against today's manager
(private mkdtemp socket path + spawn-not-attach model); both gaps are
documented, not hacked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:32:48 -04:00
Paulus Schoutsen 1b1e954a4f sandbox_v2: ephemeral-sources docs + tracker + STATUS
Docs sweep for the stateless-sandbox feature (d4b7aef732): protocol.py
integration_source field, OVERVIEW entry-lifecycle + statelessness
section, CLAUDE.md resolver-hook contract + sha-pin rule + pip/egress
follow-up, architecture.html fetch-before-setup, whats-changed box ticked.

The sub-session wrote these files but didn't land the second commit;
committing them here. STATUS flags two honest follow-ups: tree-vs-ref
verification trusts GitHub's content-addressed codeload URL rather than a
full git-tree-hash; async_process_requirements (pip for custom deps) is
unconfirmed in the bare-HA sandbox — pairs with plan-docker.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 09:25:06 -04:00
Paulus Schoutsen d4b7aef732 sandbox_v2: stateless sandboxes — push integration source on entry_setup
Make sandboxes hold no integration code: main attaches a typed
IntegrationSource to EntrySetup (builtin no-op, or a git source pinned to
an exact commit sha), and the sandbox fetches custom (HACS) code into
<config>/custom_components/<domain> before async_setup.

- proto: IntegrationSource sub-message + EntrySetup.integration_source (10);
  both _pb2 mirrors regenerated.
- core sources.py: registered-resolver hook (async_register_sandbox_source_
  resolver) keeping core HACS-agnostic; builtin short-circuit; a custom
  domain with no resolver raises. Resolver pins ref to a sha (no core I/O).
- router: _entry_setup_payload resolves + sets integration_source.
- client sources.py: codeload-tarball fetch (injectable primitive),
  process-lifetime (url, ref) cache, manifest.json verification; wired into
  entry_runner before setup.
- tests: resolver registry + payload (HA side), tarball fetch/cache/verify
  with local fixtures (client side). No network in any test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:23:33 -04:00
Paulus Schoutsen c92348b931 sandbox_v2: STATUS for transport T3+T5 (effort complete)
T3 (1eaa79d261) + T5 (42560c6cd0) landed. Unix socket transport
(opt-in via SandboxManager(transport="unix"); stdio remains default),
ws:// rejected with NotImplementedError, wire-protocol docs current.
191 + 62 tests green; drift guard clean. T1→T2→T3→T5 complete; T4 (WS)
out of scope. UnixSocketTransport is StreamTransport-over-unix-streams
(no new class). Socket lives in a short tempdir to dodge the ~108-char
sun_path limit; teardown force-closes accepted clients to avoid a
wait_closed() hang.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 09:11:50 -04:00
Paulus Schoutsen 42560c6cd0 sandbox_v2: transport cleanup + docs (transport T5)
Bring the wire-protocol docs to the current protobuf + pluggable-transport
reality and tick the transport trackers.

* OVERVIEW.md / architecture.html: rewrite the transport row, the spawn
  prose, and the channel section to describe the three-layer
  Channel/Codec/Transport split, ProtobufCodec as the production wire,
  the Ready-frame handshake (no stdout text marker), length-prefixed
  framing, and stdio + unix transports (websocket reserved/future). Drop
  the stale --url ws:// example and JSON-line wording.
* channel.py docstrings (both mirrors): ProtobufCodec is the production
  codec; JsonCodec is the registry-free channel-core test/debug wire.
* protocol.py docstring: messages are typed protobuf (REGISTRY +
  sandbox_v2.proto); the payload shapes listed are the logical contract.
* sandbox.py: SandboxRuntime docstrings note the --url-selected transport
  (stdio default, unix opt-in, ws reserved).
* whats-changed.md: tick the protobuf-wire + typed-handlers boxes (T2
  360e454330) and pluggable-transports box (T3 1eaa79d261).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:10:36 -04:00
Paulus Schoutsen 1eaa79d261 sandbox_v2: unix socket transport (transport T3)
Add an opt-in unix-domain-socket control-channel transport alongside the
default stdio transport. The manager opens a listening unix socket, passes
its path to the subprocess as --url unix://<path>, and the runtime dials
back; the manager is the server. Both transports reuse StreamTransport's
length-prefixed framing, so no dedicated unix transport class is needed.

* Manager: SandboxManager(transport="stdio"|"unix") (default stdio,
  unchanged behavior). _run_one splits into stdio/unix paths sharing a
  _supervise_until_exit helper; the unix path creates the socket in a
  short per-attempt tempdir (sidesteps the ~108-char sun_path limit),
  races accept against early exit, and force-closes lingering accepted
  connections (server.close_clients) so wait_closed cannot hang.
* CommandFactory is now (group, url) -> argv; the manager owns the
  transport and hands the factory the right --url.
* Runtime: --url scheme selects the transport — stdio:// (default /
  absent), unix://<path>, or ws://|wss:// (reserved, rejected with a
  clear not-implemented error). New _transport_scheme + _open_unix_channel.
* Tests: unix round-trip + socket cleanup (core), scheme selection + ws
  rejection + unix round-trip (client); existing factories updated to the
  (group, url) signature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:03:36 -04:00
Paulus Schoutsen f03474c029 sandbox_v2: STATUS for transport T2 (protobuf wire shipped)
T2 landed as 360e454330 (64 files, +3762/-1046). Sub-session report:
- default production codec = ProtobufCodec; ~20 handlers + ~69 test
  sites converted atomically; 189 + 53 tests green; prek + drift guard clean.
- 4 reasoned deviations (bare Channel ctor keeps JsonCodec with proto
  built explicitly at production sites; JsonCodec stays registry-free for
  channel-core tests; grpcio-tools out of project deps via throwaway venv;
  sandbox-side context cache deferred until a consumer needs it).
- One gotcha fixed: a test stub returning a plain dict hung the router's
  untimed channel.call under ProtobufCodec — relevant for T3/T5.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 08:28:09 -04:00
Paulus Schoutsen 360e454330 sandbox_v2: protobuf wire + typed handlers (transport T2)
Atomic switch of the control channel from JSON dicts to typed protobuf
messages, completing transport T2 on top of T1's Transport/Codec seam.

- Codec owns the registry: each side builds a type -> (request_cls,
  result_cls) map from its own _proto mirror and constructs
  ProtobufCodec(registry). The concurrency-critical Channel core stays
  fully codec-agnostic; response frames now carry `type` so the stateless
  codec resolves the result class on both encode and decode.

- Proto refinements (locked 2026-06-03): EntityDescription wraps EntityInfo
  (identity: Description + DeviceInfo) and InitialState (state +
  capabilities + attributes); ServiceResponse is a typed envelope inside
  CallServiceResult (proto3 optional, no has_response bool); StateChanged
  is flattened and carries optional context_id; FireEvent carries optional
  context_id. Dynamic fields cross as Struct/ListValue.

- Context security model: the sandbox only ever sends a context_id string;
  parent_id / user_id never cross the wire. Main resolves the id to its own
  authoritative Context via SandboxBridge._resolve_context — reusing a
  cached Context or minting a fresh one attributed to the sandbox system
  user with no parent_id — for state_changed, fire_event and call_service.

- Generated _pb2 mirrors checked into both no-cross-import trees; regen via
  sandbox_v2/proto/generate.sh (isolated venv so the protobuf==6.32.0 pin is
  never bumped). Drift guard wired as a manual-stage prek hook that degrades
  gracefully when uv is absent.

- Default codec is protobuf (manager + runtime channel construction);
  JsonCodec is retained registry-free as the test wire for the channel-core
  tests. protobuf added to the client pyproject + the HA manifest
  requirements; grpcio-tools stays out of the project venv by design.

- ~20 handlers converted to typed messages across bridge.py, entry_runner,
  flow_runner, entity_bridge, service/event mirrors, sandbox_bridge and the
  schema bridge; ~69 test call/push sites translated with no assertion
  loosening (semantics shifts forced by proto presence are commented).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 08:25:58 -04:00
Paulus 43eb0ca426 sandbox_v2: lock T2 proto refinements + reaffirm WS out of scope
User direction 2026-06-03 — capture before T2 launches.

Proto schema changes:
- Group fields the way HA organizes them. EntityDescription (wire) gets
  an EntityInfo sub-message (HA's EntityDescription dataclass fields +
  DeviceInfo) and an InitialState sub-message (initial state +
  capabilities + initial attributes). Nested EntityInfo.Description
  avoids the recursive-name clash.
- ServiceResponse is now a typed message (was Struct in the draft); the
  dynamic payload sits inside it as a Struct field. CallServiceResult
  drops the has_response boolean in favor of proto3 `optional`.
- StateChanged gains an optional context_id (was missing entirely).

Context discipline (security):
- parent_id and user_id are NEVER serialized on outbound messages from
  sandbox. The wire carries context_id only.
- Sandbox keeps a local context_id -> Context cache main populates when
  relevant (e.g. main pushing a state-changed for a context the sandbox
  needs).
- Main resolves context_id to its authoritative Context at dispatch.
  If no such Context exists, main mints one attributed to the sandbox's
  system user (no parent_id) and registers it.

WebSocket transport is now flagged COMPLETELY OUT OF SCOPE for this
effort (was "deferred"). T1's Transport Protocol is shape-compatible
with a future WebSocketTransport, but no WS code/deps/auth surface
lands in this batch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 06:55:06 -04:00
Paulus 7c77d915d9 sandbox_v2: lock codec-owns-registry decision in plan-transport
T1's STATUS surfaced one design refinement to ratify before T2 coding:
the type -> (request_cls, result_cls) protobuf registry lives on the
codec, not on Channel.register. Ratified 2026-06-03.

The argument (from T1's sub-session): keeping the pairing in the codec
preserves the plan's stated safety property — the concurrency-critical
Channel core stays codec-agnostic. ProtobufCodec(registry) / JsonCodec(registry)
on each side; Channel.register signature unchanged.

For responses to be decodable without per-call state, the proto Frame
envelope carries `type` on response frames too (already a field; just
populate it).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 06:42:56 -04:00
Paulus 0d64a7e484 sandbox_v2: STATUS for plan-transport (T1 shipped; T2-T5 handoff)
T1 (Transport/Codec seam + Ready frame) shipped green at 8389f7ad96.
T2 (protobuf wire + typed handlers) is an atomic big-bang — flipping the
default codec to protobuf and switching ~20 handlers to typed messages
forces ~69 wire-call test sites to convert in lockstep, so it cannot land
in safe green increments the way T1 was designed to. Rather than ram a
big-bang through and risk a broken tree (or silently weakened test
assertions during a 69-site rewrite), this STATUS hands off:

* the cleared codegen toolchain gate + verified recipe (isolated venv,
  grpcio-tools 1.80.0, gencode min-runtime 6.31.1 ⊆ pinned 6.32.0)
* the resolved T2 design — including the response-typing solution the
  plan left implicit (carry `type` on response frames) and a refinement
  to keep the request/result class registry in the codec (Channel core
  stays codec-agnostic) for the parent to approve
* the full T2 file/test work breakdown, and the small T3/T5 shapes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 06:30:34 -04:00
Paulus 8389f7ad96 sandbox_v2: Transport/Codec seam (transport T1)
Split the control channel into three layers so the wire format and the
byte transport can each be swapped without touching the concurrency-
critical dispatch core:

* Channel — dispatch core (pending map, inflight semaphore, register/
  call/push/close); speaks Frame objects, never raw bytes.
* Codec (Protocol) + JsonCodec — Frame <-> bytes. JsonCodec is
  line-compatible with the old wire shape.
* Transport (Protocol) + StreamTransport — whole frame blobs over a
  reader/writer pair using a 4-byte big-endian length prefix (caps frame
  size at 16 MiB and aborts the channel on overflow). Channel.from_transport
  is the drop-in seam for a future WebSocketTransport.

Replaces the stdout text marker (sandbox_v2:ready) with a MSG_READY
*frame* sent as the channel's first message; the manager registers a
handler for it and flips to "running" on arrival, so stdout now carries
nothing but channel frames. Net behavior identical — still JSON, still
stdio — only the framing and handshake changed.

Both channel.py mirrors and protocol.py mirrors updated in lockstep.
Handshake/marker test assertions updated; added coverage for the
from_transport seam via an in-memory queue transport.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 06:23:54 -04:00
Paulus a0732f3e09 sandbox_v2: tick whats-changed + docs sweep + STATUS (fidelity batch close)
Tick the 6 batch boxes in whats-changed.md with their commit SHAs. Refresh
current-state docs the 6 changes affect: OVERVIEW (upsert + registry-event
resend, unique_id prefix, vol.Invalid rebuild, real selectors/sections),
README + architecture.html (--group -> --name run snippets). Add the batch
STATUS file. Historical records (STATUS-phase-*, interview, plan-v1-removal,
FOLLOWUPS) left intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 06:08:42 -04:00
Paulus f66e7e4034 sandbox_v2: blanket ALWAYS_MAIN for ~18 helpers (fidelity appendix / point 1)
Broad readers (template, group, homekit) and source-entity helpers
(min_max, statistics, trend, threshold, derivative, integration,
utility_meter, filter, mold_indicator, bayesian, generic_thermostat,
generic_hygrostat, switch_as_x, history_stats, proximity) read foreign
entities / registries a sandboxed integration can't see under lockdown,
so pin them to main. prometheus/alert are config_flow:false (YAML-only)
and already stay on main, so they're not added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 06:05:07 -04:00
Paulus 9480436982 sandbox_v2: lossless data_schema reconstruction (fidelity #4)
reconstruct_schema now rebuilds real Selector and data_entry_flow.section
objects instead of collapsing them to a pass-through validator, so when the
flow manager re-serialises main's schema for the frontend it reproduces the
sandbox's original list verbatim (selectors keep their widget). The
serialize-side _has_data_schema fallback now logs the dropped schema's repr
at warning so the lossy path is visible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 06:03:31 -04:00
Paulus c5c7e4adcb sandbox_v2: make register_entity an idempotent upsert (fidelity #6)
Client EntityBridge now listens on EVENT_ENTITY_REGISTRY_UPDATED and
EVENT_DEVICE_REGISTRY_UPDATED and re-describes + re-sends MSG_REGISTER_ENTITY
for tracked entities, guarded by a description hash to avoid event storms.
Main's _handle_register_entity updates the existing proxy in place (refreshing
the mirrored _attr_* fields and the DeviceEntry) instead of adding a duplicate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 06:00:31 -04:00
Paulus 3833290b16 sandbox_v2: prefix proxy entity unique_id with source domain (fidelity #5)
Proxies all register under the shared sandbox_v2 platform_name, so the
entity-registry uniqueness key (domain, "sandbox_v2", unique_id) collided
when two integrations in one group reused a unique_id. Namespace the proxy
unique_id as f"{source_domain}:{unique_id}". None unique_ids stay None.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 05:49:51 -04:00
Paulus fd05b17a25 sandbox_v2: reconstruct vol.Invalid across the bridge (fidelity #7)
Carry a structured error_data field on the error frame for vol.Invalid /
vol.MultipleInvalid so main rebuilds the real exception with its path
intact instead of flattening to TypeError. Falls back to the class-name
mapping when error_data is absent (older/edge frames).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 05:47:30 -04:00
Paulus 969834845b sandbox_v2: rename CLI flag --group to --name (fidelity #2)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 05:43:14 -04:00
Paulus 8bf3abdc3c sandbox_v2: tick whats-changed for strip-auth-scopes + STATUS marker
Tick the RefreshToken.scopes-removed breaking-change checkbox with the
code-commit SHA (5141f96ebe) and add the landing notes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 05:39:25 -04:00
Paulus 5141f96ebe sandbox_v2: strip RefreshToken.scopes from core; sandbox token goes plain
Phase 7's RefreshToken.scopes + websocket-dispatcher enforcement was
built for a sandbox->main websocket that never shipped, so no code path
ever exercised the scope check end-to-end. Revert the whole mechanism
from core HA and keep the sandbox on a plain system-user token.

Phase A (core revert, lockstep):
- auth/models.py: delete the RefreshToken.scopes field.
- auth/__init__.py + auth/auth_store.py: delete the scopes= parameter
  and the on-disk serialize/deserialize of the scopes key. The load
  path now pops a legacy scopes key silently (option A: no migration,
  no storage-version bump) so pre-existing scoped tokens load fine.
- websocket_api/connection.py: delete self.scopes, the _scope_allows
  helper, and the async_handle enforcement branch.

Phase B (sandbox helper):
- sandbox_v2/auth.py: delete SANDBOX_TOKEN_SCOPES; identify the refresh
  token by the one-token-per-system-user invariant instead of matching
  a scope set. System-user token type is unchanged.

Tests:
- Delete tests/components/websocket_api/test_scopes.py.
- Delete the scoped-token round-trip cases from tests/auth/test_init.py.
- Add a regression test that an on-disk token with a legacy scopes key
  loads without error and drops the field.
- Update sandbox_v2 test_auth assertions to the plain-token contract.

Phase C (docs): mark auth-scoping-decision.md SUPERSEDED; drop the
auth row from the core-HA-modified lists in CLAUDE.md / architecture
.html; rewrite the scoped-auth sections in OVERVIEW.md and
architecture.html; add a re-introduce follow-up in FOLLOWUPS.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 05:38:09 -04:00
Paulus 3bf251eb83 sandbox_v2: tick whats-changed for A2 + STATUS marker
A2 landed (commit 4c85363668). Mark the monkey-patch-removed item done
in the batch landing tracker and check in the sub-session's STATUS report.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 04:41:03 -04:00
Paulus 4c85363668 sandbox_v2: delete RemoteStore; route writes via contextvar (Phase A2)
Phase A2 of plan-sandbox-context: remove the module-level `Store`
rebinding now that the `current_sandbox` contextvar (A1) is the single
source of truth for sandbox Store IO.

Load-bearing correctness fix (surfaced by A1's STATUS): the contextvar
save branch moves DOWN from `Store.async_save` to
`Store._async_write_data`. `async_delay_save` and the FINAL_WRITE flush
bypass `async_save` entirely — they funnel through
`_async_handle_write_data` -> `_async_write_data`. While `RemoteStore`
existed it overrode `_async_write_data` and masked this; deleting it
would have silently routed delayed/final-write saves to the sandbox
tempdir. Branching at `_async_write_data` covers async_save,
async_delay_save, and FINAL_WRITE uniformly. The redundant `async_save`
branch is removed.

Deletions:
- `hass_client/remote_store.py` (the subclass + installer)
- `hass_client/tests/test_remote_store.py` (covered by the contextvar
  tests + the new delayed-save regression test)
- the `install_remote_store` call/teardown in `SandboxRuntime.run`
- the explicit `data.store` swap in `_load_restore_state` (the
  contextvar reaches the import-captured `Store` reference)

New regression test `test_delayed_save_flushes_through_bridge` asserts
`async_delay_save` + EVENT_HOMEASSISTANT_FINAL_WRITE route through the
bridge. Docs (CLAUDE.md, OVERVIEW.md, FOLLOWUPS.md, architecture.html)
rewritten around the contextvar.

Tests: 190 core (sandbox_v2 + storage + restore_state) + 50 client all
green; prek clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 04:39:34 -04:00
Paulus 19adbba726 sandbox_v2: plan batch (contextvar / strip-auth-scopes / rename) + decisions
Three new plans queued ahead of fidelity/transport/ephemeral/docker:
- plan-sandbox-context: replace install_remote_store monkey-patch with a
  current_sandbox ContextVar in homeassistant/helpers/, set by the runtime
  before warm-load. Same primitive will later carry cross-sandbox IR/RF
  calls. Refined via phx:plan; Q1/Q2/Q3 locked (defer IR/RF, A1+A2 split,
  docstring+assertion guard).
- plan-strip-auth-scopes: revert Phase 7's RefreshToken.scopes mechanism
  from core HA. No consumer shipped; on-disk scopes key dropped silently
  on load. Re-introduces when the sandbox->main WS transport lands.
- plan-rename-sandbox (last): rename sandbox_v2 -> sandbox once v1 is fully
  gone, including hassfest IGNORE cleanup.

Decisions locked 2026-06-03:
- builtin lockdown: (a) blanket ALWAYS_MAIN for Category A+B helpers.
- ephemeral-sources resolver: (c) generic resolver hook.

STATUS-plan-sandbox-context-A1.md added (sub-session report). The report
surfaced a correctness prerequisite for A2: async_delay_save and the
FINAL_WRITE flush bypass async_save and go through _async_write_data
directly. A2 must therefore move the contextvar save branch down to
_async_write_data before deleting RemoteStore, or delayed saves would
silently land in the sandbox tempdir. The plan's A2 section now spells
this out.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 04:22:40 -04:00
Paulus d0bbd34028 sandbox_v2: route Store IO via current_sandbox contextvar (Phase A1)
Add a `current_sandbox` ContextVar in core HA (homeassistant/helpers/
sandbox_context.py) that `Store.async_load/save/remove` read at call
time to route storage IO to main, replacing the module-level
`Store` rebinding done by `install_remote_store`. Reading the
contextvar inside each IO method is a single source of truth
regardless of how `Store` was imported, so it reaches the helpers
that captured the original `Store` at module load (restore_state,
the registries) — which the rebinding never could.

This is the additive half (Phase A1): the contextvar branch is added
alongside the existing `install_remote_store`, both paths active. The
contextvar branch is the first line of each IO method, so it serves
the IO; `RemoteStore` + the `_load_restore_state` workaround stay until
A2 deletes them once A1 bakes on dev.

- helpers/sandbox_context.py: `current_sandbox` ContextVar + the
  `SandboxBridge` Protocol (store methods only; IR/RF deferred).
- helpers/storage.py: `_async_load_data` fetches the wrapped envelope
  via the bridge when the contextvar is set (migration block unchanged
  — design choice B); `async_save`/`async_remove` early-return through
  the bridge.
- hass_client/sandbox_bridge.py: `ChannelSandboxBridge` implementing the
  three store methods over MSG_STORE_LOAD/SAVE/REMOVE (bodies lifted from
  RemoteStore, incl. the orjson preserialise on save).
- hass_client/sandbox.py: build the bridge and `current_sandbox.set`
  before warm-load + handler registration; assert it was unset first
  (Risk #3); reset the token on teardown.
- hass_client/tests/test_sandbox_bridge.py: the five Phase A1 tests plus
  a direct ChannelSandboxBridge wire-mapping test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 04:20:04 -04:00
Paulus Schoutsen e4e0fbef54 sandbox_v2: add planning docs for next batch
Plans for the post-Phase-20 work: protocol-fidelity batch (CLI rename,
lossless data_schema, entity unique_id prefixing, idempotent register_entity,
vol.Invalid reconstruction), transport/protobuf rewrite, built-in lockdown +
breakage research, stateless sandboxes (push integration source), test
Dockerfile, and a broadcast "what changed" digest. Includes the brainstorm
interview notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 04:23:50 -04:00
Paulus Schoutsen 4d0c0e7626 sandbox_v2: remove v1 implementation
The numeric compat gate (Phase 17: 99.67% full sweep, 99.97% v1 baseline)
is met. Removing v1 ahead of the "v2 shipped a stable release" condition,
relying on git history for rollback.

Deletes homeassistant/components/sandbox, tests/components/sandbox, and the
top-level sandbox/ dev dir; regenerates config_flows.py (drops the v1
"sandbox" entry); updates current-state v2 docs (historical STATUS-phase-*
records left intact).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 04:16:44 -04:00
Paulus Schoutsen 317afd9739 sandbox_v2: drop unwired share_* surface + design doc (Phase 20)
Phase 7 introduced `SharingConfig` (`share_states`,
`share_entity_registry`, `share_areas`) on the runtime + the matching
`SandboxGroupConfig` on the manager + `--share-*` CLI flags +
`DEFAULT_GROUP_CONFIGS` defaults, intended for a future subscription
consumer that observes main's state stream. The consumer never landed.
~40 LOC of dead surface across five files plus an entire test module
(`test_sharing_config.py`, 7 tests). Carrying unwired flags risks
readers assuming functionality that isn't there — Phase 16's failure
categoriser had to specifically call this out.

Removed:
- `SharingConfig` + `sharing=` constructor param + `__all__` entry
  (`sandbox_v2/hass_client/hass_client/sandbox.py`).
- `--share-states`/`--share-entity-registry`/`--share-areas` argparser
  entries (`__main__.py`).
- `SandboxGroupConfig`, `DEFAULT_GROUP_CONFIGS`, `group_config()`
  accessor, and `--share-*` argv expansion in `_default_command`
  (`homeassistant/components/sandbox_v2/manager.py`).
- `sharing=` parameter on the in-process plugin.
- `test_sharing_config.py` (whole file).
- `test_manager.py` group_config tests.
- Sharing assertions in `test_sandbox_runtime.py`.

Replaced with `sandbox_v2/docs/design-share-states.md` — the contract
for the future consumer: goal, entity_id alignment constraint
(sandbox-side automations referencing `light.kitchen` must see main's
actual entity_id, not whatever the sandbox's local EntityRegistry
would have generated), `share/subscribe_*` mechanism sketch, per-
sandbox allow-list filtering on main, and the open questions
(direction, read-only semantics, device/area mirroring as P19
follow-on, fan-out perf).

`OVERVIEW.md`, `CLAUDE.md`, `docs/FOLLOWUPS.md`, and
`generate_backlog.py`'s `dependencies-not-shared` description all
repoint at the new design doc.

No core HA files touched. 140 + 47 tests passing (hass_client drops
the 7 sharing-config tests; HA-side drops 2 group_config tests).

plan.md updated with Phase 18/19/20 phase blocks +  ticks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 06:20:27 -04:00
Paulus Schoutsen 7270a52be7 sandbox_v2: bridge device_info → main's device_registry (Phase 19)
Sandboxed entities that carry `device_info` now produce matching
`DeviceEntry` rows in main's `device_registry`, linked to the
sandboxed `config_entry_id`. Area assignment now propagates through
HA's standard device → entity inheritance path (Phase 5's entity
bridge alone left the entity registered without a device_id, so the
device_registry was empty for sandboxed integrations).

Sandbox side (`hass_client/entity_bridge.py`):
- `_serialise_device_info` flattens `DeviceInfo`'s TypedDict shapes
  into JSON-safe lists/strings (identifiers/connections as lists of
  two-element lists, via_device as list, entry_type as `StrEnum.value`,
  configuration_url as string).
- `_describe_entity` appends a `device_info` key to the wire payload
  when the entity exposes one.

Main side (`homeassistant/components/sandbox_v2/`):
- `SandboxEntityDescription` gains `device_info` / `device_id` fields.
- `from_payload` runs `_deserialise_device_info` to rebuild typed shapes.
- `_handle_register_entity` pre-creates the `DeviceEntry` via
  `dr.async_get_or_create(config_entry_id=description.entry_id,
  **device_info)`, pins the returned `device.id` on the description.
- Proxy base sets `_attr_device_info` so `EntityPlatform.async_add_entities`
  reuses the same `DeviceEntry` (idempotent on identifiers/connections)
  and wires `entity.device_entry`. No per-domain proxy edit needed —
  all 32 inherit from the base.

No new core HA changes (`device_registry.async_get_or_create` is
already public).

Tests:
- `tests/components/sandbox_v2/test_phase19_devices.py` — six end-to-
  end cases (DeviceEntry creation + entry-id linkage, proxy device_id
  propagation, backwards-compat with payloads omitting device_info,
  area assignment surfacing, invalid device_info rejection, payload
  round-trip).
- `sandbox_v2/hass_client/tests/test_entity_bridge.py` — three new
  cases.

140 + 54 tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 06:19:36 -04:00
Paulus Schoutsen 39dc4c912f sandbox_v2 docs: note cross-sandbox in-process dependency follow-up
ESPHome serial / BLE proxy (and Broadlink-style IR/RF) are coupled
in-process today: setup-time enumeration + send-calls happen via
Python calls/events the bridge doesn't cross. Pure-built-in pairs are
fine (same `built-in` sandbox group); a built-in producer paired with
a custom-integration consumer would split across `built-in`/`custom`
and break.

Captured the constraint + two fix shapes (classifier "co-locate with
X" hint vs extending Phase 6's event mirror beyond `<owned_domain>_*`)
in the three places that track open follow-ups:

- `sandbox_v2/CLAUDE.md` — Open follow-ups
- `sandbox_v2/docs/FOLLOWUPS.md` — Still open
- `sandbox_v2/OVERVIEW.md` — Where the design is still open

IR/RF is the simpler case (one-way command flow, no bidirectional
stream or enumeration) but still needs dedicated cross-sandbox routing
to land the consumer's send-call on the producer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 06:18:04 -04:00
Paulus Schoutsen b28e6502a3 tests: sandbox_v2 integration tests
Tests for the HA-core side of sandbox_v2 (the client-side
hass_client/tests/ shipped with the previous commit).

134 tests across:
- test_classifier.py — manifest-based routing rules.
- test_router.py — flow create / setup / unload intercepts.
- test_manager.py — subprocess lifecycle + crash/restart + token factory.
- test_proxy_flow.py — `SandboxFlowProxy` + flow marshalling.
- test_channel.py — concurrent channel dispatcher + close semantics.
- test_bridge.py — entity / service / event mirror handlers on main.
- test_phase4_subprocess.py — real-subprocess flow handshake.
- test_phase9_shutdown.py — graceful shutdown + restore_state hand-off.
- test_phase13_proxies.py — parametrised smoke per supported entity domain.
- test_phase14.py — flow schema bridge + unique_id propagation +
  async_unload core hook + perf benchmark.
- test_store.py — `_SandboxStoreServer` path scoping + key validation.
- test_init.py — `SandboxV2Data` shape + integration wiring.
- test_auth.py — sandbox-scoped access token issuance.
- test_testing_plugins.py — in-process + subprocess pytest plugins +
  autotag fixture.
- test_spike.py — Phase 1 entity-bridge spike (Option A vs B).
- test_perf.py — 200-light area-call batching benchmark.
- _helpers.py — shared `make_channel_pair` test helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:42:55 -04:00
Paulus Schoutsen e3aafaedb1 Add sandbox_v2 client library, docs, and compat sweep tooling
The client-library side of sandbox v2, plus the full architecture +
phase-by-phase narrative + per-failure compat tooling.

`sandbox_v2/hass_client/` is a separate uv-managed Python package that
the HA-core sandbox_v2 integration spawns as a subprocess per sandbox
group. It hosts a private `HomeAssistant`, drives each sandboxed
integration's `ConfigFlow` and `async_setup_entry`, mirrors entity /
service / event registrations back to main over a stdio JSON-line
`Channel`, and routes Store reads/writes through main via `RemoteStore`.

`sandbox_v2/docs/`:
- `entity-bridge-decision.md` — Phase 1 spike: why Option B
  (action-call forwarding via `sandbox_v2/call_service`).
- `auth-scoping-decision.md` — Phase 7: why `RefreshToken.scopes` is
  a generic primitive (vs a sandbox-private subclass).
- `FOLLOWUPS.md` — narrative of Phases 12–17 (concurrent dispatcher,
  28-domain proxy fill-in, flow-schema bridge, baseline compat sweep,
  cross-integration BACKLOG generation, `ConfigEntry.sandbox` field).

Compat sweep tooling:
- `run_compat.py` — Phase 15: v1's 37-integration baseline runner;
  output to `COMPAT.md` (curated) + `COMPAT.csv`.
- `run_compat_full.py` — Phase 16: 807-integration cross-sweep at
  asyncio concurrency=6 (~12 min wall); output to `COMPAT_FULL.md`
  + `COMPAT_FULL.csv`.
- `categorize_failures.py` — regex-rule failure categoriser feeding
  `BACKLOG.md` + `BACKLOG_FAILURES.json`.
- `generate_backlog.py` — auto-draft skeleton for BACKLOG.md.

Headline result (after Phase 17): 99.67% test-level pass rate across
807 integrations; baseline 99.97%. Both clear the 99.5% v1-removal
threshold.

`sandbox_v2/STATUS-phase-{3..18}.md` are the authoritative landing
notes for each phase — every "Things to flag" surfaced is in there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:42:36 -04:00
Paulus Schoutsen 9f32319481 Add sandbox_v2 integration (HA-core side)
The HA-core side of the sandbox v2 rewrite: routing, lifecycle, flow
forwarding, entity bridging, service/event mirroring, scoped auth,
opt-in data sharing, Store routing, graceful shutdown.

Lives at `homeassistant/components/sandbox_v2/`. Designed alongside the
client library at `sandbox_v2/`; see `sandbox_v2/OVERVIEW.md` for the
full architecture and `sandbox_v2/docs/FOLLOWUPS.md` for the phase-by-
phase narrative.

Built on the core hooks added in the preceding commits:
`ConfigEntries.router` + `ConfigEntry.sandbox` + `RefreshToken.scopes`
+ `EntityComponent.async_register_remote_platform`.

32 domain proxy classes under `entity/` cover every entity domain v2
supports. Bridge translates each proxy method into a
`sandbox_v2/call_service` RPC via a per-loop-tick batcher (coalesces
multi-entity area calls into single RPCs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:41:48 -04:00
Paulus Schoutsen ddd9c5ab61 hassfest: tolerate sandbox v1 errors; add sandbox_v2 to NO_QUALITY_SCALE
Adds an `IGNORE_INTEGRATIONS_WITH_ERRORS` set to hassfest's main loop
so v1 sandbox's pre-existing hassfest gates (CONFIG_SCHEMA, manifest
version, missing services.yaml, mypy signature drift in entity proxies)
don't block validation of the rest of the tree. v1 is being superseded
by sandbox_v2 (see `sandbox_v2/OVERVIEW.md`) — accepting v1's existing
state for now is preferable to either fixing every gate in code that
will be removed, or skipping hooks.

Also adds `sandbox_v2` to `NO_QUALITY_SCALE` (internal integration)
and ships an empty `sandbox_v2/services.yaml` placeholder — `bridge.py`
calls `hass.services.async_register` dynamically per sandboxed
integration; those services are owned by the sandboxed integrations.

`homeassistant/generated/config_flows.py` is regenerated to include
`sandbox` (v1 had drifted out of the registry).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:41:18 -04:00
Paulus Schoutsen 4936885598 config_entries + entity_component: hooks for runtime-routed integrations
Three small additive surfaces that the sandbox_v2 integration plugs
into. Each is additive and a no-op when nothing registers against it.

config_entries.py:
- `ConfigEntries.router: ConfigEntryRouter | None` attribute + the
  `ConfigEntryRouter` Protocol. Consulted from three sites:
  `ConfigEntriesFlowManager.async_create_flow`, `ConfigEntries.async_setup`,
  and `ConfigEntries.async_unload`. Returning `None` falls through to
  the existing path.
- `ConfigEntry.sandbox: str | None` optional field. Carries the routing
  tag without polluting `entry.data`. Persisted via `as_dict` /
  `as_storage_fragment` only when non-None; read via `dict.get` so
  pre-existing stored entries load with `sandbox=None`. Mutable via
  `ConfigEntries.async_update_entry(entry, sandbox=)`. `ConfigFlowResult`
  gains a `sandbox` TypedDict key the framework reads at entry
  construction (same plumbing shape as `minor_version` / `options` /
  `subentries`).

entity_component.py:
- `EntityComponent.async_register_remote_platform(config_entry, platform)`
  lets sandbox_v2 attach a pre-built remote `EntityPlatform` without
  re-discovering the local integration. Mirrors `async_setup_entry`'s
  `_platforms[entry_id] = platform` assignment as a public hook.

Tests:
- `MockConfigEntry` picks up a `sandbox=` kwarg threaded through to
  `ConfigEntry.__init__`.
- Six new `test_config_entries.py` cases for the `sandbox` field:
  default-none + omitted-from-storage, persisted-when-set, round-trip,
  absent-from-storage-loads-as-none, async_update_entry-sets-sandbox,
  cannot-be-set-directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:25:54 -04:00
Paulus Schoutsen 67fff835b2 auth: optional scopes on RefreshToken + dispatcher enforcement
Adds an optional `scopes: frozenset[str] | None` attribute to
`RefreshToken` and threads it through `AuthManager.async_create_refresh_token`
and `AuthStore` (sorted list on disk, optional on read — no version bump).

`ActiveConnection` reads scopes off the connecting token and a new
`_scope_allows` helper in the websocket dispatcher rejects out-of-scope
commands with `ERR_UNAUTHORIZED`. Existing unscoped tokens (`scopes is
None`) are unaffected — the gate is a no-op for them.

This is the primitive the sandbox_v2 integration uses to issue
namespace-scoped tokens (`{"sandbox_v2/", "auth/current_user"}`) to
sandbox subprocesses, so a sandbox-resident integration cannot escalate
to the rest of the websocket API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:25:31 -04:00
Paulus Schoutsen 7b19a3a71b Update SANDBOX_COMPAT for newly-installable deps
After 'uv pip install -r requirements_ha.txt' (which pulls in
requirements_all.txt), the integrations previously listed as
'Not Tested (missing dependencies)' import and run:

  - rest: 10/10 pass        (needed xmltodict)
  - logbook: 55/55 pass      (needed sqlalchemy + numpy + turbojpeg)
  - command_line: 7/7 pass
  - trend: 9/9 pass

Promote them into the main pass table; the totals now read 35 of 37
fully pass, 955/957 tests (99.8%).

conversation imports too (hassil was already in pyproject.toml deps
but the report listed it as missing) but 8 of 21 tests fail and the
run deadlocks at tests 20-21 — moved into a new 'Newly runnable, still
investigating' section instead of the pass table.

Add a Setup section pointing at requirements_ha.txt and the pyitachip2ir
macOS caveat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 14:12:21 -04:00
Paulus Schoutsen 7994744bea Add requirements_ha.txt to pull in HA Core integration deps
The sandbox client's pyproject.toml only carries the minimal set of
packages needed to run the client library and its own tests. Running
HA Core's per-integration test suites through the sandbox plugin needs
the full integration dependency tree (hassil for conversation,
xmltodict for rest, sqlalchemy+numpy+turbojpeg for logbook, …).

requirements_ha.txt pulls in ../../requirements_all.txt and
../../requirements_test.txt with paths relative to the file, so it
keeps working from any cwd. Comment notes the macOS pyitachip2ir
build caveat and the workaround.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:44:30 -04:00
Paulus Schoutsen e9e5bda3f6 Drop .sh from doc references to the test runner
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:31:30 -04:00
Paulus Schoutsen 3d807de32d Remove obsolete run_all_sandbox_tests.sh
The shell version required a manually-prepared
/tmp/all_integrations.txt and used a perl-based timeout shim.
run_all_sandbox_tests.py auto-discovers integrations from the core
tests directory and uses subprocess timeouts, so the .sh is no longer
needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 12:43:45 -04:00
Paulus Schoutsen fa60ef5477 Consolidate sandbox docs: fold ARCHITECTURE.md into OVERVIEW.md
architecture.html already covers system diagrams, flow diagrams, file
structure, websocket API, key classes, and test results, so the prose
deep-dive in ARCHITECTURE.md was largely overlapping. Keep the bits
that weren't already in OVERVIEW.md and drop the rest:

- Startup sequence (host startup, sandbox process startup, host/sandbox
  entity platform setup) as a new section after High-Level Flow.
- The RemoteLightEntity worked example plus the static/dynamic property
  caching rationale, inside Entity Platform Architecture.
- Entity Method Compatibility (which domains already expose async
  wrappers; the cover.toggle gap).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 12:42:18 -04:00
Paulus Schoutsen 3046996869 Add sandbox/README.md as the directory's overview
Pointers to OVERVIEW.md, ARCHITECTURE.md, architecture.html, the
test driver scripts, and SANDBOX_COMPAT.md; quick-start for running
the sandbox client and the core test suites through it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:59:46 -04:00
Paulus Schoutsen 9930d7dad4 Consolidate sandbox docs and test drivers under core/sandbox/
Move ARCHITECTURE.md, OVERVIEW.md, CLAUDE.md, the architecture HTML,
the test-runner scripts and TEST_RESULTS.csv into this directory next
to the hass_client subtree, so the entire sandbox project lives on the
sandbox branch of core (only the HA integration at
homeassistant/components/sandbox/ stays put for HA's loader).

Adjust the relative paths the moved files used to point at the old
sibling checkouts:
- hass_client/pyproject.toml: uv source homeassistant -> ../..
- run_all_sandbox_tests.{py,sh}: cd into ./hass_client and walk to
  ../../tests/components/ for the core test suites
- analyze_failures.py: write TEST_RESULTS.csv next to the script

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:34:54 -04:00
Paulus Schoutsen e18dd7e906 Add 'sandbox/hass_client/' from commit '8f1a294efecab03343748950da428bd18d92fffe'
git-subtree-dir: sandbox/hass_client
git-subtree-mainline: d12fb7814a
git-subtree-split: 8f1a294efe
2026-05-23 11:32:40 -04:00
Paulus Schoutsen d12fb7814a Replace subscribe_service_calls with explicit register/call/result API
Restructure the sandbox websocket API around three commands instead of
a single event subscription: sandbox/register_service registers a
proxy service on the host that forwards calls into the sandbox,
sandbox/call_service lets the sandbox invoke a host service while
preserving its context, and sandbox/service_call_result returns the
sandbox's response back to the originating host caller.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:31:47 -04:00
Paulus Schoutsen 8e6be68fe3 Remove per-domain platform setup files
These 32 files (light.py, sensor.py, etc.) each only registered an
async_add_entities callback. Now that RemoteHostEntityPlatform adds
proxy entities directly to the EntityComponent, they are dead code.

Also removes the unused register_platform_callback and
AddEntitiesCallback from SandboxEntityManager.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-23 11:31:47 -04:00
Paulus Schoutsen c1a71bed25 Add RemoteHostEntityPlatform for sandbox entities
Replace the async_forward_entry_setups + per-domain platform file
approach with RemoteHostEntityPlatform. This EntityPlatform subclass
is added directly to the domain's EntityComponent and manages proxy
entities without needing 32 identical platform files.

The platform is created on-demand when the first entity for a domain
is registered by the sandbox.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-23 11:31:47 -04:00
Paulus Schoutsen ee82ca9677 Support sandbox grouping by string option value
Config entries can now set options["sandbox"] = "group_name" to be
assigned to a named sandbox group. Entries sharing the same group
string run in the same sandbox process. The sandbox config entry
discovers group members via entry.data["group"].

The explicit entries list (entry.data["entries"]) still works for
test infrastructure compatibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-23 11:31:47 -04:00
Paulus Schoutsen b51067d37d Refactor sandbox entity proxies into entity/ package
Split the monolithic entity.py (1900 lines) into a per-platform
package structure under entity/. Each domain gets its own file,
making the codebase easier to navigate and extend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-23 11:31:47 -04:00
Paulus Schoutsen 12f24ac6bf Add device_tracker and todo proxy entity support
Brings total supported platforms to 32. Device tracker supports
both TrackerEntity (GPS) and ScannerEntity (router/BLE).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-23 11:31:46 -04:00
Paulus Schoutsen 6b92011cae Add proxy entity support for 24 additional HA platforms
Implements sandbox proxy entities for: alarm_control_panel, button,
calendar, climate, cover, date, datetime, fan, humidifier, lawn_mower,
lock, media_player, notify, number, remote, select, siren, text, time,
update, vacuum, valve, water_heater, weather.

Total supported platforms: 30 (up from 6).

Each proxy class caches state from sandbox pushes and forwards service
calls back to the sandbox via the existing websocket command channel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-23 11:31:46 -04:00
Paulus Schoutsen c88253752f Add proxy entity support for all Hue platforms
Adds SandboxBinarySensorEntity, SandboxSensorEntity, SandboxSwitchEntity,
SandboxSceneEntity, and SandboxEventEntity proxy classes. Also adds
device_class and state_class to entity registration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-23 11:31:46 -04:00
Paulus Schoutsen 4f43b99540 Add sandbox integration with entity proxy architecture
Implements the sandbox integration that manages config entries running
in isolated processes. Proxy entities on the host forward service calls
to sandbox processes via websocket and cache state pushed back.

Supports entity, device, and area targeting for service calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-23 11:31:46 -04:00
Paulus Schoutsen 8f1a294efe Extract HybridServiceRegistry and improve sandbox error translation
Move HybridServiceRegistry out of runtime.py into its own
sandbox_service_registry.py module, expand the websocket API error
translator to handle ServiceNotSupported and sandbox/call_service, and
extend conftest_sandbox with additional fixtures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:31:00 -04:00
Paulus Schoutsen f07d650de8 Remove per-domain platform setup files
These 32 files (light.py, sensor.py, etc.) each only registered an
async_add_entities callback. Now that RemoteHostEntityPlatform adds
proxy entities directly to the EntityComponent, they are dead code.

Also removes the unused register_platform_callback from
SandboxEntityManager.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-16 09:30:59 -04:00
Paulus Schoutsen f494fa2909 Add RemoteClientEntityPlatform for sandbox entity interception
New class that wraps an EntityPlatform on the sandbox side to intercept
async_add_entities calls. When an integration adds entities, they are:
1. Added locally as normal
2. Registered with the host via sandbox/register_entity
3. State changes forwarded to the host
4. Method calls from the host dispatched to local entities

This replaces the post-setup iteration approach in SandboxEntityBridge
with a clean intercept at the async_add_entities boundary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-16 09:29:40 -04:00
Paulus Schoutsen b81a221c20 Add Hue and Picnic as tested config-entry integrations
Both pass fully through the real sandbox websocket:
- Philips Hue: 112 tests (lights, sensors, switches, scenes, device
  triggers, services, config flow, diagnostics)
- Picnic: 40 tests (sensors, services, todo)

Validates that the full config entry path works: async_setup_entry,
entity platforms, device registry, mocked HTTP APIs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-15 21:02:12 -04:00
Paulus Schoutsen f852c33cf8 Fix host HA teardown and service fallback, expand to 33 integrations
Three fixes:
- Stop host HA explicitly after tests to cancel lingering timers that
  caused verify_cleanup teardown errors (scene, todo, etc.)
- Guard HybridServiceRegistry remote fallback: only try remote for
  services that exist in the remote cache, preventing wrong
  ServiceNotFound errors in nested service calls
- Remove manual INSTANCES.remove; let async_stop handle cleanup

31 of 33 integrations fully pass (878/880 tests, 99.8%).
The 2 remaining failures are pre-existing logbook platform issues.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-15 18:18:04 -04:00
Paulus Schoutsen 7b60f912a7 Fix schedule test hangs by detecting freezer fixture and falling back
Tests using pytest-freezer's `freezer.move_to()` hang when a live
websocket is active because time jumps break async heartbeat timers.
Detect the freezer fixture in pytest_runtest_setup and fall back to
the base plugin (no websocket) for those tests.

All 9 input helper integrations now pass (189/189 tests).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-15 18:05:35 -04:00
Paulus Schoutsen da978415a8 Add sandbox test infrastructure for running core tests through websocket
New pytest plugin (hass_client.testing.conftest_sandbox) that boots a host
HA Core with websocket_api + sandbox integration, creates a sandbox auth
token, and connects a RemoteHomeAssistant to it via a live websocket. This
allows running the full HA Core input_boolean test suite (16/16 tests)
through a real sandbox round-trip.

Key pieces:
- conftest_sandbox.py: pytest plugin that patches async_test_home_assistant
  to create host + sandbox HA instances with real TCP websocket
- conftest.py: adds core/tests to sys.path for test infrastructure imports
- pyproject.toml: point homeassistant dep at local core checkout, add test deps

Usage: pytest -p hass_client.testing.conftest_sandbox \
              ../core/tests/components/input_boolean/test_init.py

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-15 17:33:54 -04:00
Paulus Schoutsen 64750386cb Add sandbox client and end-to-end tests
SandboxClient connects to HA Core via a sandbox token, fetches assigned
config entries, sets up input helper integrations locally, registers
entities back to the host, pushes state changes, and subscribes to
service call forwarding.

Three e2e tests validate: token/instance creation, state updates, and
unload cleanup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-15 17:33:42 -04:00
Paulus Schoutsen 0c45d006f7 Add sandbox websocket API methods and fix RemoteHomeAssistant.__new__
Add sandbox API methods to HomeAssistantAPI for communicating with HA Core's
sandbox integration: get_entries, update_entry, register/update/remove device,
register/update/remove entity, update_state, and subscribe_service_calls.

Override __new__ on RemoteHomeAssistant to accept extra keyword arguments,
since HomeAssistant.__new__ has a strict (config_dir: str) signature that
rejects the remote_config kwarg in Python 3.14.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-15 17:33:34 -04:00
Paulus Schoutsen cd81c61509 WIP 2026-04-01 09:51:35 -04:00
Paulus Schoutsen 81bca02aed Expand core and helper test compatibility 2026-03-18 12:52:17 +09:00
Paulus Schoutsen cc2428c2b5 Initial hass-client compatibility harness 2026-03-18 11:56:47 +09:00
554 changed files with 38619 additions and 7712 deletions
+7 -7
View File
@@ -36,7 +36,7 @@
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
# - github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
# - github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
#
# Container images used:
# - ghcr.io/github/gh-aw-firewall/agent:0.25.46
@@ -90,7 +90,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -352,7 +352,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -961,7 +961,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1100,7 +1100,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1325,7 +1325,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1383,7 +1383,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
+2 -2
View File
@@ -137,7 +137,7 @@ jobs:
sed -i "/uv/d" requirements_diff.txt
- name: Build wheels
uses: home-assistant/wheels@34957438948e0b3dcde73c77750643dadae594f5 # 2026.06.0
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
@@ -195,7 +195,7 @@ jobs:
sed -i "/uv/d" requirements_diff.txt
- name: Build wheels
uses: home-assistant/wheels@34957438948e0b3dcde73c77750643dadae594f5 # 2026.06.0
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
+15 -1
View File
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.15
rev: v0.15.14
hooks:
- id: ruff-check
args:
@@ -64,6 +64,17 @@ repos:
files: ^(homeassistant|tests|script)/.+\.py$
- repo: local
hooks:
# Drift guard for the checked-in sandbox protobuf gencode. Manual
# stage only (grpcio-tools is not a project dep, so it bootstraps a
# throwaway venv and degrades gracefully when uv is absent): run with
# `prek run --hook-stage manual sandbox-proto-drift` or in a CI lane.
- id: sandbox-proto-drift
name: sandbox protobuf gencode drift guard
entry: sandbox/proto/check_drift.sh
language: script
pass_filenames: false
stages: [manual]
files: ^sandbox/proto/sandbox\.proto$
# Run mypy through our wrapper script in order to get the possible
# pyenv and/or virtualenv activated; it may not have been e.g. if
# committing from a GUI tool that was not launched from an activated
@@ -75,6 +86,9 @@ repos:
require_serial: true
types_or: [python, pyi]
files: ^(homeassistant|pylint)/.+\.(py|pyi)$
# Checked-in protobuf gencode (sandbox): the .py + .pyi pair trips
# mypy's duplicate-module check, and it is machine-generated anyway.
exclude: _pb2\.(py|pyi)$
- id: pylint
name: pylint
entry: script/run-in-env.sh pylint --ignore-missing-annotations=y
@@ -1,10 +1,6 @@
"""The AirVisual Pro integration."""
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -29,12 +25,6 @@ class AirVisualProEntity(CoordinatorEntity[AirVisualProCoordinator]):
"""Return device registry information for this entity."""
return DeviceInfo(
identifiers={(DOMAIN, self.coordinator.data["serial_number"])},
connections={
(
CONNECTION_NETWORK_MAC,
format_mac(self.coordinator.data["status"]["mac_address"]),
)
},
manufacturer="AirVisual",
model=self.coordinator.data["status"]["model"],
name=self.coordinator.data["settings"]["node_name"],
+1
View File
@@ -59,6 +59,7 @@ ATTR_EXTERNAL_URL = "external_url"
ATTR_INTERNAL_URL = "internal_url"
ATTR_LOCATION_NAME = "location_name"
ATTR_INSTALLATION_TYPE = "installation_type"
ATTR_REQUIRES_API_PASSWORD = "requires_api_password"
ATTR_UUID = "uuid"
ATTR_VERSION = "version"
@@ -5,8 +5,6 @@ import logging
from pathlib import Path
from typing import Any
from hassil.parse_expression import parse_sentence
from hassil.parser import ParseError
from hassil.util import (
PUNCTUATION_END,
PUNCTUATION_END_WORD,
@@ -166,7 +164,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
[cv.string],
has_one_non_empty_item,
has_no_punctuation,
is_valid_sentence,
),
}
],
@@ -215,17 +212,6 @@ def has_no_punctuation(value: list[str]) -> list[str]:
return value
def is_valid_sentence(value: list[str]) -> list[str]:
"""Validate result can be parsed by hassil."""
for sentence in value:
try:
parse_sentence(sentence)
except ParseError as err:
raise vol.Invalid(f"invalid sentence: {err}") from err
return value
def has_one_non_empty_item(value: list[str]) -> list[str]:
"""Validate result has at least one item."""
if len(value) < 1:
+6 -12
View File
@@ -6,7 +6,6 @@ These APIs are the only documented way to interact with the bluetooth integratio
import asyncio
from asyncio import Future
from collections.abc import Callable, Iterable
from contextlib import ExitStack
from typing import TYPE_CHECKING, cast
from bleak import BleakScanner
@@ -179,20 +178,15 @@ async def async_process_advertisements(
if not done.done() and callback(service_info):
done.set_result(service_info)
manager = _get_manager(hass)
with ExitStack() as stack:
unload = manager.async_register_callback(
_async_discovered_device, match_dict, mode
)
stack.callback(unload)
if mode == BluetoothScanningMode.ACTIVE:
task = hass.async_create_task(manager.async_request_active_scan(timeout))
stack.callback(task.cancel)
unload = _get_manager(hass).async_register_callback(
_async_discovered_device, match_dict, mode, scan_duration=timeout
)
try:
async with asyncio.timeout(timeout):
return await done
finally:
unload()
@hass_callback
@@ -21,6 +21,6 @@
"bluetooth-auto-recovery==1.6.4",
"bluetooth-data-tools==1.29.18",
"dbus-fast==5.0.16",
"habluetooth==6.8.3"
"habluetooth==6.8.1"
]
}
+18 -10
View File
@@ -1,19 +1,18 @@
"""The Brands integration."""
from collections import deque
from collections.abc import Container, Mapping
from http import HTTPStatus
import logging
from pathlib import Path
from random import SystemRandom
import time
from typing import Any, Final, override
from typing import Any, Final
from aiohttp import ClientError, web
from aiohttp import ClientError, hdrs, web
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
from homeassistant.core import HomeAssistant, callback, valid_domain
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -109,18 +108,23 @@ def _read_brand_file(brand_dir: Path, image: str) -> bytes | None:
class _BrandsBaseView(HomeAssistantView):
"""Base view for serving brand images."""
use_query_token_for_auth = True
requires_auth = False
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the view."""
self._hass = hass
self._cache_dir = Path(hass.config.cache_path(DOMAIN))
@callback
@override
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
"""Return valid auth tokens, which can be used for query token authentication."""
return self._hass.data[DOMAIN]
def _authenticate(self, request: web.Request) -> None:
"""Authenticate the request using Bearer token or query token."""
access_tokens: deque[str] = self._hass.data[DOMAIN]
authenticated = (
request[KEY_AUTHENTICATED] or request.query.get("token") in access_tokens
)
if not authenticated:
if hdrs.AUTHORIZATION in request.headers:
raise web.HTTPUnauthorized
raise web.HTTPForbidden
async def _serve_from_custom_integration(
self,
@@ -236,6 +240,8 @@ class BrandsIntegrationView(_BrandsBaseView):
image: str,
) -> web.Response:
"""Handle GET request for an integration brand image."""
self._authenticate(request)
if not valid_domain(domain) or image not in ALLOWED_IMAGES:
return web.Response(status=HTTPStatus.NOT_FOUND)
@@ -268,6 +274,8 @@ class BrandsHardwareView(_BrandsBaseView):
image: str,
) -> web.Response:
"""Handle GET request for a hardware brand image."""
self._authenticate(request)
if not CATEGORY_RE.match(category):
return web.Response(status=HTTPStatus.NOT_FOUND)
# Hardware images have dynamic names like "manufacturer_model.png"
@@ -8,6 +8,6 @@
"iot_class": "local_push",
"loggers": ["aiostreammagic"],
"quality_scale": "platinum",
"requirements": ["aiostreammagic==2.13.2"],
"requirements": ["aiostreammagic==2.13.1"],
"zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."]
}
+18 -14
View File
@@ -2,7 +2,7 @@
import asyncio
import collections
from collections.abc import Awaitable, Callable, Container, Coroutine, Mapping
from collections.abc import Awaitable, Callable, Coroutine
from contextlib import suppress
from dataclasses import asdict, dataclass
from datetime import datetime, timedelta
@@ -12,16 +12,16 @@ import logging
import os
from random import SystemRandom
import time
from typing import Any, Final, final, override
from typing import Any, Final, final
from aiohttp import web
from aiohttp import hdrs, web
import attr
from propcache.api import cached_property, under_cached_property
import voluptuous as vol
from webrtc_models import RTCIceCandidateInit
from homeassistant.components import websocket_api
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
from homeassistant.components.media_player import (
ATTR_MEDIA_CONTENT_ID,
ATTR_MEDIA_CONTENT_TYPE,
@@ -776,26 +776,30 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
class CameraView(HomeAssistantView):
"""Base CameraView."""
use_query_token_for_auth = True
requires_auth = False
def __init__(self, component: EntityComponent[Camera]) -> None:
"""Initialize a basic camera view."""
self.component = component
@callback
@override
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
"""Return valid auth tokens, which can be used for query token authentication."""
if (camera := self.component.get_entity(match_info["entity_id"])) is None:
return ()
return camera.access_tokens
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
"""Start a GET request."""
if (camera := self.component.get_entity(entity_id)) is None:
raise web.HTTPNotFound
authenticated = (
request[KEY_AUTHENTICATED]
or request.query.get("token") in camera.access_tokens
)
if not authenticated:
# Attempt with invalid bearer token, raise unauthorized
# so ban middleware can handle it.
if hdrs.AUTHORIZATION in request.headers:
raise web.HTTPUnauthorized
# Invalid sigAuth or camera access token
raise web.HTTPForbidden
if not camera.is_on:
_LOGGER.debug("Camera is off")
raise web.HTTPServiceUnavailable
@@ -3,8 +3,6 @@
from collections.abc import Awaitable, Callable
from typing import Any
from hassil.parse_expression import parse_sentence
from hassil.parser import ParseError
from hassil.recognize import RecognizeResult
from hassil.util import (
PUNCTUATION_END,
@@ -44,17 +42,6 @@ def has_no_punctuation(value: list[str]) -> list[str]:
return value
def is_valid_sentence(value: list[str]) -> list[str]:
"""Validate result can be parsed by hassil."""
for sentence in value:
try:
parse_sentence(sentence)
except ParseError as err:
raise vol.Invalid(f"invalid sentence: {err}") from err
return value
def has_one_non_empty_item(value: list[str]) -> list[str]:
"""Validate result has at least one item."""
if len(value) < 1:
@@ -71,11 +58,7 @@ TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend(
{
vol.Required(CONF_PLATFORM): DOMAIN,
vol.Required(CONF_COMMAND): vol.All(
cv.ensure_list,
[cv.string],
has_one_non_empty_item,
has_no_punctuation,
is_valid_sentence,
cv.ensure_list, [cv.string], has_one_non_empty_item, has_no_punctuation
),
}
)
@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pydaikin"],
"requirements": ["pydaikin==2.18.1"],
"requirements": ["pydaikin==2.17.2"],
"zeroconf": ["_dkapi._tcp.local."]
}
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "platinum",
"requirements": ["data-grand-lyon-ha==0.8.0"]
"requirements": ["data-grand-lyon-ha==0.7.0"]
}
+2 -20
View File
@@ -5,7 +5,6 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -28,19 +27,7 @@ async def async_setup_entry(
BinarySensorDeviceClass.MOISTURE,
),
DemoBinarySensor(
"binary_2",
"Movement Backyard",
True,
BinarySensorDeviceClass.MOTION,
),
DemoBinarySensor(
"binary_3",
"Outside Temperature",
False,
BinarySensorDeviceClass.BATTERY_CHARGING,
device_id="sensor_1",
entity_category=EntityCategory.DIAGNOSTIC,
entity_name="Battery Charging",
"binary_2", "Movement Backyard", True, BinarySensorDeviceClass.MOTION
),
]
)
@@ -59,9 +46,6 @@ class DemoBinarySensor(BinarySensorEntity):
device_name: str,
state: bool,
device_class: BinarySensorDeviceClass,
device_id: str | None = None,
entity_category: EntityCategory | None = None,
entity_name: str | None = None,
) -> None:
"""Initialize the demo sensor."""
self._unique_id = unique_id
@@ -70,12 +54,10 @@ class DemoBinarySensor(BinarySensorEntity):
self._attr_device_info = DeviceInfo(
identifiers={
# Serial numbers are unique identifiers within a specific domain
(DOMAIN, device_id or unique_id)
(DOMAIN, self.unique_id)
},
name=device_name,
)
self._attr_entity_category = entity_category
self._attr_name = entity_name
@property
def unique_id(self) -> str:
@@ -22,7 +22,6 @@ from .const import ( # noqa: F401
ATTR_LOCATION_NAME,
ATTR_MAC,
ATTR_SOURCE_TYPE,
ATTR_TRACKING_TYPE,
CONF_ASSOCIATED_ZONE,
CONF_CONSIDER_HOME,
CONF_NEW_DEVICE_DEFAULTS,
@@ -37,7 +36,6 @@ from .const import ( # noqa: F401
PLATFORM_TYPE_LEGACY,
SCAN_INTERVAL,
SourceType,
TrackingType,
)
from .entity import ( # noqa: F401
BaseScannerEntity,
@@ -25,18 +25,6 @@ class SourceType(StrEnum):
BLUETOOTH_LE = "bluetooth_le"
class TrackingType(StrEnum):
"""Tracking type for device trackers.
Describes how the tracker determines presence: by the device's geographic
position (e.g. GPS) or by its connection to a known endpoint (e.g. a router
or beacon associated with a zone).
"""
CONNECTION = "connection"
POSITION = "position"
CONF_SCAN_INTERVAL: Final = "interval_seconds"
SCAN_INTERVAL: Final = timedelta(seconds=12)
@@ -59,7 +47,6 @@ ATTR_IN_ZONES: Final = "in_zones"
ATTR_LOCATION_NAME: Final = "location_name"
ATTR_MAC: Final = "mac"
ATTR_SOURCE_TYPE: Final = "source_type"
ATTR_TRACKING_TYPE: Final = "tracking_type"
ATTR_CONSIDER_HOME: Final = "consider_home"
ATTR_IP: Final = "ip"
@@ -48,13 +48,11 @@ from .const import (
ATTR_IP,
ATTR_MAC,
ATTR_SOURCE_TYPE,
ATTR_TRACKING_TYPE,
CONF_ASSOCIATED_ZONE,
CONNECTED_DEVICE_REGISTERED,
DOMAIN,
LOGGER,
SourceType,
TrackingType,
)
_LOGGER = logging.getLogger(__name__)
@@ -240,9 +238,6 @@ class TrackerEntity(
"""Base class for a tracked device."""
entity_description: TrackerEntityDescription
_attr_capability_attributes: dict[str, Any] = {
ATTR_TRACKING_TYPE: TrackingType.POSITION
}
_attr_in_zones: list[str] | None = None
_attr_latitude: float | None = None
_attr_location_accuracy: float = 0
@@ -416,9 +411,6 @@ class BaseScannerEntity(BaseTrackerEntity):
addresses being used to identify the device.
"""
_attr_capability_attributes: dict[str, Any] = {
ATTR_TRACKING_TYPE: TrackingType.CONNECTION
}
_scanner_option_associated_zone: str = zone.ENTITY_ID_HOME
_scanner_option_associated_zone_unsub: CALLBACK_TYPE | None = None
@@ -40,13 +40,6 @@
"gps": "GPS",
"router": "Router"
}
},
"tracking_type": {
"name": "Tracking type",
"state": {
"connection": "Connection",
"position": "Position"
}
}
}
}
+49 -3
View File
@@ -13,6 +13,7 @@ from dsmr_parser.clients.rfxtrx_protocol import (
from dsmr_parser.objects import DSMRObject
import voluptuous as vol
from homeassistant.components import usb
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
@@ -22,7 +23,6 @@ from homeassistant.config_entries import (
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_PROTOCOL, CONF_TYPE
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.selector import SerialPortSelector
from .const import (
CONF_DSMR_VERSION,
@@ -37,6 +37,8 @@ from .const import (
RFXTRX_DSMR_PROTOCOL,
)
CONF_MANUAL_PATH = "Enter Manually"
class DSMRConnection:
"""Test the connection to DSMR and receive telegram to read serial ids."""
@@ -163,6 +165,8 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1
_dsmr_version: str | None = None
@staticmethod
@callback
def async_get_options_flow(
@@ -218,13 +222,34 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
"""Step when setting up serial configuration."""
errors: dict[str, str] = {}
if user_input is not None:
data = await self.async_validate_dsmr(user_input, errors)
user_selection = user_input[CONF_PORT]
if user_selection == CONF_MANUAL_PATH:
self._dsmr_version = user_input[CONF_DSMR_VERSION]
return await self.async_step_setup_serial_manual_path()
dev_path = user_selection
validate_data = {
CONF_PORT: dev_path,
CONF_DSMR_VERSION: user_input[CONF_DSMR_VERSION],
}
data = await self.async_validate_dsmr(validate_data, errors)
if not errors:
return self.async_create_entry(title=data[CONF_PORT], data=data)
ports = await usb.async_scan_serial_ports(self.hass)
list_of_ports = {
port.device: f"{port.device} - {port.description or 'n/a'}"
f", s/n: {port.serial_number or 'n/a'}"
+ (f" - {port.manufacturer}" if port.manufacturer else "")
for port in ports
}
list_of_ports[CONF_MANUAL_PATH] = CONF_MANUAL_PATH
schema = vol.Schema(
{
vol.Required(CONF_PORT): SerialPortSelector(),
vol.Required(CONF_PORT): vol.In(list_of_ports),
vol.Required(CONF_DSMR_VERSION): vol.In(DSMR_VERSIONS),
}
)
@@ -234,6 +259,27 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_setup_serial_manual_path(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Select path manually."""
if user_input is not None:
validate_data = {
CONF_PORT: user_input[CONF_PORT],
CONF_DSMR_VERSION: self._dsmr_version,
}
errors: dict[str, str] = {}
data = await self.async_validate_dsmr(validate_data, errors)
if not errors:
return self.async_create_entry(title=data[CONF_PORT], data=data)
schema = vol.Schema({vol.Required(CONF_PORT): str})
return self.async_show_form(
step_id="setup_serial_manual_path",
data_schema=schema,
)
async def async_validate_dsmr(
self, input_data: dict[str, Any], errors: dict[str, str]
) -> dict[str, Any]:
@@ -26,6 +26,12 @@
},
"title": "[%key:common::config_flow::data::device%]"
},
"setup_serial_manual_path": {
"data": {
"port": "[%key:common::config_flow::data::usb_path%]"
},
"title": "[%key:common::config_flow::data::path%]"
},
"user": {
"data": {
"type": "Connection type"
+1 -7
View File
@@ -3,7 +3,7 @@
from dataclasses import asdict
from typing import Any
from duco_connectivity.exceptions import DucoConnectionError, DucoError
from duco_connectivity.exceptions import DucoConnectionError
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_HOST
@@ -52,12 +52,6 @@ async def async_get_config_entry_diagnostics(
translation_domain=DOMAIN,
translation_key="connection_error",
) from err
except DucoError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"error": repr(err)},
) from err
api_info: dict[str, Any] = {"public_api_version": api_info_obj.public_api_version}
if api_info_obj.reported_api_version is not None:
+3 -3
View File
@@ -1,6 +1,6 @@
"""Base entity for the Duco integration."""
from duco_connectivity.models import Node, NodeType
from duco_connectivity.models import Node
from homeassistant.const import ATTR_VIA_DEVICE
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
@@ -25,7 +25,7 @@ class DucoEntity(CoordinatorEntity[DucoCoordinator]):
identifiers={(DOMAIN, f"{mac}_{node.node_id}")},
manufacturer="Duco",
model=coordinator.board_info.box_name
if node.general.node_type == NodeType.BOX
if node.general.node_type == "BOX"
else node.general.node_type,
name=node.general.name or f"Node {node.node_id}",
)
@@ -34,7 +34,7 @@ class DucoEntity(CoordinatorEntity[DucoCoordinator]):
"connections": {(CONNECTION_NETWORK_MAC, mac)},
"serial_number": coordinator.board_info.serial_board_box,
}
if node.general.node_type == NodeType.BOX
if node.general.node_type == "BOX"
else {ATTR_VIA_DEVICE: (DOMAIN, f"{mac}_1")}
)
self._attr_device_info = device_info
+1 -1
View File
@@ -59,7 +59,7 @@
"name": "Target flow level"
},
"time_state_end": {
"name": "State end time"
"name": "Mode end time"
},
"ventilation_state": {
"name": "Ventilation state",
@@ -9,6 +9,7 @@ Warnungen vor markantem Wetter (Stufe 2) # codespell:ignore vor
Wetterwarnungen (Stufe 1)
"""
from datetime import UTC, datetime
from typing import Any
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
@@ -16,7 +17,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
from .const import (
ADVANCE_WARNING_SENSOR,
@@ -100,7 +100,7 @@ class DwdWeatherWarningsSensor(
if warnings is None:
return []
now = dt_util.utcnow()
now = datetime.now(UTC) # pylint: disable=home-assistant-enforce-utcnow
return [warning for warning in warnings if warning[API_ATTR_WARNING_END] > now]
@property
@@ -26,7 +26,6 @@ from homeassistant.helpers.event import (
async_track_state_change_event,
async_track_time_interval,
)
from homeassistant.util import dt as dt_util
from .const import (
CONF_DEVICE_NAME,
@@ -222,7 +221,8 @@ def update_listeners(hass: HomeAssistant, entry: EnergyIDConfigEntry) -> None:
):
try:
value = float(current_state.state)
timestamp = current_state.last_updated or dt_util.utcnow()
# pylint: disable-next=home-assistant-enforce-utcnow
timestamp = current_state.last_updated or dt.datetime.now(dt.UTC)
client.get_or_create_sensor(energyid_key).update(value, timestamp)
except ValueError, TypeError:
_LOGGER.debug(
@@ -166,8 +166,6 @@ class RuntimeEntryData:
)
loaded_platforms: set[Platform] = field(default_factory=set)
platform_load_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
# Set once the first connection has finished scanner setup or teardown.
first_connect_done: asyncio.Event = field(default_factory=asyncio.Event)
_storage_contents: StoreData | None = None
_pending_storage: Callable[[], StoreData] | None = None
assist_pipeline_update_callbacks: list[CALLBACK_TYPE] = field(default_factory=list)
+1 -22
View File
@@ -1,12 +1,11 @@
"""Manager for esphome devices."""
import asyncio
import base64
from functools import partial
import logging
import secrets
import struct
from typing import TYPE_CHECKING, Any, Final, NamedTuple
from typing import TYPE_CHECKING, Any, NamedTuple
from aioesphomeapi import (
APIClient,
@@ -107,9 +106,6 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__)
# Max time to wait at startup for a BLE proxy to register its scanner.
STARTUP_SCANNER_WAIT: Final = 3.0
LOG_LEVEL_TO_LOGGER = {
LogLevel.LOG_LEVEL_NONE: logging.DEBUG,
LogLevel.LOG_LEVEL_ERROR: logging.ERROR,
@@ -681,8 +677,6 @@ class ESPHomeManager:
hass, device_info.bluetooth_mac_address or device_info.mac_address
)
entry_data.first_connect_done.set()
if device_info.voice_assistant_feature_flags_compat(api_version) and (
Platform.ASSIST_SATELLITE not in entry_data.loaded_platforms
):
@@ -994,21 +988,6 @@ class ESPHomeManager:
await reconnect_logic.start()
# Wait for a cached BLE proxy to register its scanner before finishing setup.
if (
device_info := entry_data.device_info
) is not None and device_info.bluetooth_proxy_feature_flags_compat(
entry_data.api_version
):
try:
async with asyncio.timeout(STARTUP_SCANNER_WAIT):
await entry_data.first_connect_done.wait()
except TimeoutError:
_LOGGER.debug(
"%s: Timed out waiting for Bluetooth scanner to register",
self.host,
)
@callback
def _async_setup_device_registry(
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/forecast_solar",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["forecast-solar==5.0.1"]
"requirements": ["forecast-solar==5.0.0"]
}
@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260527.5"]
"requirements": ["home-assistant-frontend==20260527.4"]
}
@@ -1,20 +1,17 @@
"""The Gardena Bluetooth integration."""
from contextlib import suppress
import logging
from bleak.backends.device import BLEDevice
from gardena_bluetooth.client import CachedConnection, Client
from gardena_bluetooth.const import ScanService
from gardena_bluetooth.parse import ManufacturerData, ProductType
from habluetooth import BluetoothServiceInfoBleak
from gardena_bluetooth.const import ProductType
from gardena_bluetooth.scan import async_get_manufacturer_data
from homeassistant.components import bluetooth
from homeassistant.const import CONF_ADDRESS, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import CONF_PRODUCT_TYPE
from .coordinator import (
DeviceUnavailable,
GardenaBluetoothConfigEntry,
@@ -33,79 +30,6 @@ PLATFORMS: list[Platform] = [
]
LOGGER = logging.getLogger(__name__)
DISCONNECT_DELAY = 5
PRODUCTS_SCAN_TIMEOUT = 10
PRODUCT_TYPE_TIMEOUT = 30
async def async_get_product(hass: HomeAssistant, address: str) -> ManufacturerData:
"""Get manufacturer data for the given address via active scan."""
data = ManufacturerData()
def _data_callback(info: BluetoothServiceInfoBleak) -> bool:
LOGGER.debug("Processing advertisement from %s: %s", info.address, info)
if info.device.address != address:
return False
data.update(info.manufacturer_data.get(ManufacturerData.company, b""))
return data.product_type is not ProductType.UNKNOWN
with suppress(TimeoutError):
await bluetooth.async_process_advertisements(
hass,
_data_callback,
bluetooth.BluetoothCallbackMatcher(
address=address, manufacturer_id=ManufacturerData.company
),
mode=bluetooth.BluetoothScanningMode.ACTIVE,
timeout=PRODUCT_TYPE_TIMEOUT,
)
return data
async def async_get_products(hass: HomeAssistant) -> dict[str, ManufacturerData]:
"""Get all products that are currently advertising."""
products: dict[str, ManufacturerData] = {}
def _data_callback(info: BluetoothServiceInfoBleak) -> bool:
LOGGER.debug("Processing advertisement from %s: %s", info.address, info)
if ScanService not in info.service_uuids:
return False
raw = info.manufacturer_data.get(ManufacturerData.company, b"")
if (data := products.get(info.device.address)) is None:
data = ManufacturerData()
products[info.device.address] = data
data.update(raw)
return False
with suppress(TimeoutError):
await bluetooth.async_process_advertisements(
hass,
_data_callback,
bluetooth.BluetoothCallbackMatcher(
manufacturer_id=ManufacturerData.company
),
mode=bluetooth.BluetoothScanningMode.ACTIVE,
timeout=PRODUCTS_SCAN_TIMEOUT,
)
return products
async def async_migrate_product_type(
hass: HomeAssistant, entry: GardenaBluetoothConfigEntry
) -> GardenaBluetoothConfigEntry:
"""Discover product type for old entries and upgrade them to minor version 2."""
mfg = await async_get_product(hass, entry.data[CONF_ADDRESS])
if mfg.product_type is ProductType.UNKNOWN:
raise ConfigEntryNotReady("Unable to find product type")
hass.config_entries.async_update_entry(
entry,
data={**entry.data, CONF_PRODUCT_TYPE: mfg.product_type.name},
minor_version=2,
)
return entry
def get_connection(hass: HomeAssistant, address: str) -> CachedConnection:
@@ -127,11 +51,16 @@ async def async_setup_entry(
) -> bool:
"""Set up Gardena Bluetooth from a config entry."""
if entry.minor_version < 2:
entry = await async_migrate_product_type(hass, entry)
address = entry.data[CONF_ADDRESS]
product_type = ProductType[entry.data[CONF_PRODUCT_TYPE]]
try:
mfg_data = await async_get_manufacturer_data({address})
except TimeoutError as exc:
raise ConfigEntryNotReady("Unable to find product type") from exc
product_type = mfg_data[address].product_type
if product_type is ProductType.UNKNOWN:
raise ConfigEntryNotReady("Unable to find product type")
client = Client(get_connection(hass, address), product_type)
@@ -4,18 +4,22 @@ import logging
from typing import Any
from gardena_bluetooth.client import Client
from gardena_bluetooth.const import PRODUCT_NAMES, DeviceInformation
from gardena_bluetooth.const import PRODUCT_NAMES, DeviceInformation, ScanService
from gardena_bluetooth.exceptions import CharacteristicNotFound, CommunicationFailure
from gardena_bluetooth.parse import ManufacturerData, ProductType
from gardena_bluetooth.scan import async_get_manufacturer_data
import voluptuous as vol
from homeassistant.components.bluetooth import BluetoothServiceInfo
from homeassistant.components.bluetooth import (
BluetoothServiceInfo,
async_discovered_service_info,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ADDRESS
from homeassistant.data_entry_flow import AbortFlow
from . import async_get_product, async_get_products, get_connection
from .const import CONF_PRODUCT_TYPE, DOMAIN
from . import get_connection
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -29,16 +33,26 @@ _SUPPORTED_PRODUCT_TYPES = {
}
def _is_supported(discovery_info: BluetoothServiceInfo):
"""Check if device is supported."""
if ScanService not in discovery_info.service_uuids:
return False
if discovery_info.manufacturer_data.get(ManufacturerData.company) is None:
_LOGGER.debug("Missing manufacturer data: %s", discovery_info)
return False
return True
class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Gardena Bluetooth."""
VERSION = 1
MINOR_VERSION = 2
def __init__(self) -> None:
"""Initialize the config flow."""
self.devices: dict[str, str] = {}
self.address: str | None
self.devices: dict[str, ManufacturerData] = {}
async def async_read_data(self):
"""Try to connect to device and extract information."""
@@ -54,23 +68,20 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
finally:
await client.disconnect()
assert self.address in self.devices
return {
CONF_ADDRESS: self.address,
CONF_PRODUCT_TYPE: self.devices[self.address].product_type.name,
}
return {CONF_ADDRESS: self.address}
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfo
) -> ConfigFlowResult:
"""Handle the bluetooth discovery step."""
_LOGGER.debug("Discovered device: %s", discovery_info)
mfg = await async_get_product(self.hass, discovery_info.address)
self.devices[discovery_info.address] = mfg
if mfg.product_type not in _SUPPORTED_PRODUCT_TYPES:
data = await async_get_manufacturer_data({discovery_info.address})
product_type = data[discovery_info.address].product_type
if product_type not in _SUPPORTED_PRODUCT_TYPES:
return self.async_abort(reason="no_devices_found")
self.address = discovery_info.address
self.devices = {discovery_info.address: PRODUCT_NAMES[product_type]}
await self.async_set_unique_id(self.address)
self._abort_if_unique_id_configured()
return await self.async_step_confirm()
@@ -80,7 +91,7 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Confirm discovery."""
assert self.address
title = PRODUCT_NAMES[self.devices[self.address].product_type]
title = self.devices[self.address]
if user_input is not None:
data = await self.async_read_data()
@@ -106,25 +117,31 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured()
return await self.async_step_confirm()
current = self._async_current_ids(include_ignore=False)
self.devices = await async_get_products(self.hass)
current_addresses = self._async_current_ids(include_ignore=False)
candidates = set()
for discovery_info in async_discovered_service_info(self.hass):
address = discovery_info.address
if address in current_addresses or not _is_supported(discovery_info):
continue
candidates.add(address)
data = await async_get_manufacturer_data(candidates)
for address, mfg_data in data.items():
if mfg_data.product_type not in _SUPPORTED_PRODUCT_TYPES:
continue
self.devices[address] = PRODUCT_NAMES[mfg_data.product_type]
# Keep selection sorted by address to ensure stable tests
devices = {
address: PRODUCT_NAMES[data.product_type]
for address in sorted(self.devices)
if address not in current
and (data := self.devices[address]).product_type in _SUPPORTED_PRODUCT_TYPES
}
self.devices = dict(sorted(self.devices.items(), key=lambda x: x[0]))
if not devices:
if not self.devices:
return self.async_abort(reason="no_devices_found")
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_ADDRESS): vol.In(devices),
vol.Required(CONF_ADDRESS): vol.In(self.devices),
},
),
)
@@ -1,4 +1,3 @@
"""Constants for the Gardena Bluetooth integration."""
DOMAIN = "gardena_bluetooth"
CONF_PRODUCT_TYPE = "product_type"
@@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(__name__)
class GoodweFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a Goodwe config flow."""
VERSION = 2
MINOR_VERSION = 2
async def async_handle_successful_connection(
self,
@@ -135,10 +135,6 @@
"description": "The {integration_title} integration is being removed as it depends on system packages that can only be installed on systems running a deprecated architecture. To resolve this, remove the {domain} entry from your configuration.yaml file and restart Home Assistant.",
"title": "The {integration_title} integration is being removed"
},
"deprecated_trigger_behavior": {
"description": "An automation, script or template entity uses the trigger behavior option `{deprecated_behavior}`, which has been renamed to `{new_behavior}`. The old value still works for now, but support for it will be removed in a future release.\n\nTo fix this issue, edit the affected automations and scripts and change the behavior option from `{deprecated_behavior}` to `{new_behavior}`, then restart Home Assistant.",
"title": "Deprecated trigger behavior option in use"
},
"deprecated_yaml": {
"description": "Configuring {integration_title} using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the `{domain}` configuration from your configuration.yaml file and restart Home Assistant to fix this issue.",
"title": "The {integration_title} YAML configuration is being removed"
+23 -19
View File
@@ -2,21 +2,20 @@
import asyncio
import collections
from collections.abc import Container, Mapping
from contextlib import suppress
from dataclasses import dataclass
from datetime import datetime, timedelta
import logging
import os
from random import SystemRandom
from typing import Final, final, override
from typing import Final, final
from aiohttp import web
from aiohttp import hdrs, web
import httpx
from propcache.api import cached_property
import voluptuous as vol
from homeassistant.components.http import KEY_HASS, HomeAssistantView
from homeassistant.components.http import KEY_AUTHENTICATED, KEY_HASS, HomeAssistantView
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONTENT_TYPE_MULTIPART, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import (
@@ -315,28 +314,33 @@ class ImageView(HomeAssistantView):
"""View to serve an image."""
name = "api:image:image"
use_query_token_for_auth = True
requires_auth = False
url = "/api/image_proxy/{entity_id}"
def __init__(self, component: EntityComponent[ImageEntity]) -> None:
"""Initialize an image view."""
self.component = component
@callback
@override
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
"""Return valid auth tokens, which can be used for query token authentication."""
if (image_entity := self.component.get_entity(match_info["entity_id"])) is None:
return ()
return image_entity.access_tokens
@callback
def _get_image_entity(self, entity_id: str) -> ImageEntity:
"""Get image entity from request."""
async def _authenticate_request(
self, request: web.Request, entity_id: str
) -> ImageEntity:
"""Authenticate request and return image entity."""
if (image_entity := self.component.get_entity(entity_id)) is None:
raise web.HTTPNotFound
authenticated = (
request[KEY_AUTHENTICATED]
or request.query.get("token") in image_entity.access_tokens
)
if not authenticated:
# Attempt with invalid bearer token, raise unauthorized
# so ban middleware can handle it.
if hdrs.AUTHORIZATION in request.headers:
raise web.HTTPUnauthorized
# Invalid sigAuth or image entity access token
raise web.HTTPForbidden
return image_entity
async def head(self, request: web.Request, entity_id: str) -> web.Response:
@@ -345,7 +349,7 @@ class ImageView(HomeAssistantView):
This is sent by some DLNA renderers, like Samsung ones, prior to sending
the GET request.
"""
image_entity = self._get_image_entity(entity_id)
image_entity = await self._authenticate_request(request, entity_id)
# Don't use `handle` as we don't care about the stream case, we only want
# to verify that the image exists.
@@ -361,7 +365,7 @@ class ImageView(HomeAssistantView):
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
"""Start a GET request."""
image_entity = self._get_image_entity(entity_id)
image_entity = await self._authenticate_request(request, entity_id)
return await self.handle(request, image_entity)
async def handle(
@@ -8,6 +8,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "platinum",
"requirements": ["indevolt-api==1.8.5"],
"requirements": ["indevolt-api==1.8.3"],
"zeroconf": [{ "name": "igen_fw*", "type": "_http._tcp.local." }]
}
@@ -30,7 +30,7 @@ RT_ACTION_SERVICE_SCHEMA: Final = vol.Schema(
),
vol.Required("power"): vol.All(
vol.Coerce(int),
vol.Range(min=0, max=2400),
vol.Range(min=1, max=2400),
),
}
)
@@ -18,7 +18,7 @@ charge:
required: true
selector:
number:
min: 0
min: 1
max: 2400
step: 1
unit_of_measurement: "W"
@@ -43,7 +43,7 @@ discharge:
required: true
selector:
number:
min: 0
min: 1
max: 2400
step: 1
unit_of_measurement: "W"
@@ -72,11 +72,6 @@ BUTTONS: tuple[KioskerButtonEntityDescription, ...] = (
translation_key="screensaver_interact",
action_fn=lambda api: api.screensaver_interact(),
),
KioskerButtonEntityDescription(
key="blackoutClear",
translation_key="blackout_clear",
action_fn=lambda api: api.blackout_clear(),
),
)
@@ -15,9 +15,6 @@
}
},
"button": {
"blackout_clear": {
"default": "mdi:monitor"
},
"clear_cache": {
"default": "mdi:cached"
},
@@ -57,9 +57,6 @@
}
},
"button": {
"blackout_clear": {
"name": "Clear blackout"
},
"clear_cache": {
"name": "Clear cache"
},
@@ -8,19 +8,21 @@ import serialx
import ultraheat_api
import voluptuous as vol
from homeassistant.components import usb
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_DEVICE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.selector import SerialPortSelector
from .const import DOMAIN, ULTRAHEAT_TIMEOUT
_LOGGER = logging.getLogger(__name__)
CONF_MANUAL_PATH = "Enter Manually"
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_DEVICE): SerialPortSelector(),
vol.Required(CONF_DEVICE): str,
}
)
@@ -37,6 +39,9 @@ class LandisgyrConfigFlow(ConfigFlow, domain=DOMAIN):
errors = {}
if user_input is not None:
if user_input[CONF_DEVICE] == CONF_MANUAL_PATH:
return await self.async_step_setup_serial_manual_path()
dev_path = user_input[CONF_DEVICE]
_LOGGER.debug("Using this path : %s", dev_path)
@@ -45,8 +50,30 @@ class LandisgyrConfigFlow(ConfigFlow, domain=DOMAIN):
except CannotConnect:
errors["base"] = "cannot_connect"
ports = await get_usb_ports(self.hass)
ports[CONF_MANUAL_PATH] = CONF_MANUAL_PATH
schema = vol.Schema({vol.Required(CONF_DEVICE): vol.In(ports)})
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
async def async_step_setup_serial_manual_path(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Set path manually."""
errors = {}
if user_input is not None:
dev_path = user_input[CONF_DEVICE]
try:
return await self.validate_and_create_entry(dev_path)
except CannotConnect:
errors["base"] = "cannot_connect"
schema = vol.Schema({vol.Required(CONF_DEVICE): str})
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
step_id="setup_serial_manual_path",
data_schema=schema,
errors=errors,
)
async def validate_and_create_entry(self, dev_path):
@@ -84,5 +111,24 @@ class LandisgyrConfigFlow(ConfigFlow, domain=DOMAIN):
return data.model, data.device_number
async def get_usb_ports(hass: HomeAssistant) -> dict[str, str]:
"""Return a dict of USB ports and their friendly names."""
ports = await usb.async_scan_serial_ports(hass)
port_descriptions = {}
for port in ports:
if isinstance(port, usb.USBDevice):
human_name = usb.human_readable_device_name(
port.device,
port.serial_number,
port.manufacturer,
port.description,
port.vid,
port.pid,
)
port_descriptions[port.device] = human_name
return port_descriptions
class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""
@@ -4,5 +4,5 @@ from datetime import timedelta
DOMAIN = "landisgyr_heat_meter"
ULTRAHEAT_TIMEOUT = 60 # reading the IR port can take some time
ULTRAHEAT_TIMEOUT = 30 # reading the IR port can take some time
POLLING_INTERVAL = timedelta(days=1) # Polling is only daily to prevent battery drain.
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/landisgyr_heat_meter",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["ultraheat-api==0.6.1"]
"requirements": ["ultraheat-api==0.6.0"]
}
@@ -7,6 +7,11 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"step": {
"setup_serial_manual_path": {
"data": {
"device": "[%key:common::config_flow::data::usb_path%]"
}
},
"user": {
"data": {
"device": "Select device"
+1 -1
View File
@@ -51,7 +51,7 @@ class LinkPlayBaseEntity(Entity):
)
self._attr_device_info = dr.DeviceInfo(
configuration_url=str(bridge.endpoint),
configuration_url=bridge.endpoint,
connections=connections,
hw_version=bridge.device.properties["hardware"],
identifiers={(DOMAIN, bridge.device.uuid)},
@@ -2,7 +2,7 @@
import asyncio
import collections
from collections.abc import Callable, Container, Mapping
from collections.abc import Callable
from contextlib import suppress
import datetime as dt
from enum import StrEnum
@@ -12,7 +12,7 @@ import hashlib
from http import HTTPStatus
import logging
import secrets
from typing import Any, Final, Required, TypedDict, final, override
from typing import Any, Final, Required, TypedDict, final
from urllib.parse import quote, urlparse
import aiohttp
@@ -24,7 +24,7 @@ import voluptuous as vol
from yarl import URL
from homeassistant.components import websocket_api
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
from homeassistant.components.websocket_api import ERR_NOT_SUPPORTED, ERR_UNKNOWN_ERROR
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( # noqa: F401
@@ -50,7 +50,7 @@ from homeassistant.const import ( # noqa: F401
STATE_PLAYING,
STATE_STANDBY,
)
from homeassistant.core import HomeAssistant, SupportsResponse, callback
from homeassistant.core import HomeAssistant, SupportsResponse
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity import Entity, EntityDescription
@@ -1249,7 +1249,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
class MediaPlayerImageView(HomeAssistantView):
"""Media player view to serve an image."""
use_query_token_for_auth = True
requires_auth = False
url = "/api/media_player_proxy/{entity_id}"
name = "api:media_player:image"
extra_urls = [
@@ -1262,15 +1262,6 @@ class MediaPlayerImageView(HomeAssistantView):
"""Initialize a media player view."""
self.component = component
@callback
@override
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
"""Return valid auth tokens, which can be used for query token authentication."""
if (player := self.component.get_entity(match_info["entity_id"])) is None:
return ()
return (player.access_token,)
async def get(
self,
request: web.Request,
@@ -1280,9 +1271,21 @@ class MediaPlayerImageView(HomeAssistantView):
) -> web.Response:
"""Start a get request."""
if (player := self.component.get_entity(entity_id)) is None:
return web.Response(status=HTTPStatus.NOT_FOUND)
status = (
HTTPStatus.NOT_FOUND
if request[KEY_AUTHENTICATED]
else HTTPStatus.UNAUTHORIZED
)
return web.Response(status=status)
assert isinstance(player, MediaPlayerEntity)
authenticated = (
request[KEY_AUTHENTICATED]
or request.query.get("token") == player.access_token
)
if not authenticated:
return web.Response(status=HTTPStatus.UNAUTHORIZED)
if media_content_type and media_content_id:
media_image_id = request.query.get("media_image_id")
@@ -14,16 +14,9 @@ from mitsubishi_comfort.exceptions import AuthenticationError, DeviceConnectionE
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import (
CONF_ADDRESSES,
DEFAULT_CONNECT_TIMEOUT,
DEFAULT_RESPONSE_TIMEOUT,
DOMAIN,
PLATFORMS,
)
from .const import DEFAULT_CONNECT_TIMEOUT, DEFAULT_RESPONSE_TIMEOUT, DOMAIN, PLATFORMS
from .coordinator import MitsubishiComfortConfigEntry, MitsubishiComfortCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -32,14 +25,13 @@ _LOGGER = logging.getLogger(__name__)
def _make_device(
info: DeviceInfo,
serial: str,
address: str,
session,
) -> IndoorUnit | KumoStation:
"""Create the appropriate device instance from DeviceInfo."""
cls = IndoorUnit if info.is_indoor_unit else KumoStation
return cls(
name=info.label,
address=address,
address=info.address,
password_b64=info.password,
crypto_serial_hex=info.crypto_serial,
serial=serial,
@@ -72,39 +64,12 @@ async def async_setup_entry(
translation_key="no_devices",
)
# The cloud provides each device's MAC but never its LAN IP. Register every
# device with its MAC so the manifest's "registered_devices" DHCP matcher
# tracks it; DHCP discovery then supplies the IP via async_step_dhcp.
device_registry = dr.async_get(hass)
owned_macs = {dr.format_mac(info.mac) for info in devices.values()}
for serial, info in devices.items():
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, serial)},
connections={(dr.CONNECTION_NETWORK_MAC, dr.format_mac(info.mac))},
manufacturer="Mitsubishi",
name=info.label,
serial_number=serial,
)
# Resolved IPs are stored keyed by MAC. Drop any for devices that are no
# longer on the account.
stored: dict[str, str] = entry.data.get(CONF_ADDRESSES, {})
addresses = {mac: ip for mac, ip in stored.items() if mac in owned_macs}
if addresses != stored:
hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_ADDRESSES: addresses}
)
coordinators: dict[str, MitsubishiComfortCoordinator] = {}
for serial, info in devices.items():
address = addresses.get(dr.format_mac(info.mac))
if not address or not info.password or not info.crypto_serial:
# No LAN address yet: the device is registered, so DHCP discovery
# supplies its IP and reloads the entry to add it.
_LOGGER.debug("Device %s has no known LAN address yet", info.label)
if not info.address or not info.password or not info.crypto_serial:
_LOGGER.warning("Device %s missing credentials, skipping", info.label)
continue
device = _make_device(info, serial, address, session)
device = _make_device(info, serial, session)
coordinators[serial] = MitsubishiComfortCoordinator(
hass, entry, device, info.mac
)
@@ -9,11 +9,9 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import CONF_ADDRESSES, DOMAIN
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -73,41 +71,3 @@ class MitsubishiComfortConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user", data_schema=USER_SCHEMA, errors=errors
)
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle a registered device discovered on the local network via DHCP.
The cloud API never returns a device's LAN IP, so DHCP discovery is the
source of addresses. Each device is registered with its MAC during setup,
so "registered_devices" discovery only fires for our own devices: record
the IP on the owning entry and reload to set the device up or recover a
changed IP.
"""
mac = dr.format_mac(discovery_info.macaddress)
device = dr.async_get(self.hass).async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, mac)}
)
entry = next(
(
entry
for entry in self._async_current_entries(include_ignore=False)
if device is not None and entry.entry_id in device.config_entries
),
None,
)
if entry is None:
return self.async_abort(reason="already_configured")
addresses = entry.data.get(CONF_ADDRESSES, {})
if addresses.get(mac) != discovery_info.ip:
self.hass.config_entries.async_update_entry(
entry,
data={
**entry.data,
CONF_ADDRESSES: {**addresses, mac: discovery_info.ip},
},
)
self.hass.config_entries.async_schedule_reload(entry.entry_id)
return self.async_abort(reason="already_configured")
@@ -7,13 +7,6 @@ from homeassistant.const import Platform
DOMAIN: Final = "mitsubishi_comfort"
PLATFORMS: Final = [Platform.CLIMATE]
# Config entry data key holding the per-device LAN address cache, keyed by the
# device's formatted MAC. The cloud API only returns each device's MAC, never
# its LAN IP, so addresses are resolved from DHCP discovery and persisted here
# to survive restarts without re-discovery.
CONF_ADDRESSES: Final = "addresses"
DEFAULT_SCAN_INTERVAL = timedelta(seconds=60)
DEFAULT_CONNECT_TIMEOUT: Final = 1.2
DEFAULT_RESPONSE_TIMEOUT: Final = 8.0
@@ -3,10 +3,9 @@
"name": "Mitsubishi Comfort",
"codeowners": ["@nikolairahimi"],
"config_flow": true,
"dhcp": [{ "registered_devices": true }],
"documentation": "https://www.home-assistant.io/integrations/mitsubishi_comfort",
"integration_type": "hub",
"iot_class": "local_polling",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["mitsubishi-comfort==0.3.0"]
}
@@ -56,7 +56,7 @@ rules:
icon-translations: todo
reconfiguration-flow: todo
dynamic-devices: todo
discovery-update-info: done
discovery-update-info: todo
repair-issues: todo
docs-use-cases: done
docs-supported-devices: done
@@ -504,8 +504,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Can be removed with HA Core 2027.1
new_entry_data = entry.data.copy()
new_entry_data[CONF_PROTOCOL] = PROTOCOL_5
# Create temporary certificate files from entry
await async_create_certificate_temp_files(hass, new_entry_data)
# Try the connection with protocol version 5
# And update the protocol if successful
if await hass.async_add_executor_job(
+14 -13
View File
@@ -70,6 +70,13 @@ from homeassistant.config_entries import (
SubentryFlowResult,
)
from homeassistant.const import (
ATTR_CONFIGURATION_URL,
ATTR_HW_VERSION,
ATTR_MANUFACTURER,
ATTR_MODEL,
ATTR_MODEL_ID,
ATTR_NAME,
ATTR_SW_VERSION,
CONF_BRIGHTNESS,
CONF_CLIENT_ID,
CONF_CODE,
@@ -80,8 +87,6 @@ from homeassistant.const import (
CONF_ENTITY_CATEGORY,
CONF_HOST,
CONF_MODE,
CONF_MODEL,
CONF_MODEL_ID,
CONF_NAME,
CONF_OPTIMISTIC,
CONF_OPTIONS,
@@ -176,7 +181,6 @@ from .const import (
CONF_COMMAND_ON_TEMPLATE,
CONF_COMMAND_TEMPLATE,
CONF_COMMAND_TOPIC,
CONF_CONFIGURATION_URL,
CONF_CONTENT_TYPE,
CONF_CURRENT_HUMIDITY_TEMPLATE,
CONF_CURRENT_HUMIDITY_TOPIC,
@@ -217,12 +221,10 @@ from .const import (
CONF_HUMIDITY_MIN,
CONF_HUMIDITY_STATE_TEMPLATE,
CONF_HUMIDITY_STATE_TOPIC,
CONF_HW_VERSION,
CONF_IMAGE_ENCODING,
CONF_IMAGE_TOPIC,
CONF_KEEPALIVE,
CONF_LAST_RESET_VALUE_TEMPLATE,
CONF_MANUFACTURER,
CONF_MAX,
CONF_MAX_KELVIN,
CONF_MESSAGE_EXPIRY_INTERVAL,
@@ -315,7 +317,6 @@ from .const import (
CONF_SUPPORT_VOLUME_SET,
CONF_SUPPORTED_COLOR_MODES,
CONF_SUPPORTED_FEATURES,
CONF_SW_VERSION,
CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE,
CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC,
CONF_SWING_HORIZONTAL_MODE_LIST,
@@ -3796,17 +3797,17 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
},
}
MQTT_DEVICE_PLATFORM_FIELDS = {
CONF_NAME: PlatformField(selector=TEXT_SELECTOR, required=True),
CONF_SW_VERSION: PlatformField(
ATTR_NAME: PlatformField(selector=TEXT_SELECTOR, required=True),
ATTR_SW_VERSION: PlatformField(
selector=TEXT_SELECTOR, required=False, section="advanced_settings"
),
CONF_HW_VERSION: PlatformField(
ATTR_HW_VERSION: PlatformField(
selector=TEXT_SELECTOR, required=False, section="advanced_settings"
),
CONF_MODEL: PlatformField(selector=TEXT_SELECTOR, required=False),
CONF_MODEL_ID: PlatformField(selector=TEXT_SELECTOR, required=False),
CONF_MANUFACTURER: PlatformField(selector=TEXT_SELECTOR, required=False),
CONF_CONFIGURATION_URL: PlatformField(
ATTR_MODEL: PlatformField(selector=TEXT_SELECTOR, required=False),
ATTR_MODEL_ID: PlatformField(selector=TEXT_SELECTOR, required=False),
ATTR_MANUFACTURER: PlatformField(selector=TEXT_SELECTOR, required=False),
ATTR_CONFIGURATION_URL: PlatformField(
selector=TEXT_SELECTOR, required=False, validator=cv.url, error="invalid_url"
),
CONF_QOS: PlatformField(
@@ -2,9 +2,31 @@
import voluptuous as vol
from homeassistant.const import Platform
from homeassistant.const import (
CONF_CLIENT_ID,
CONF_DISCOVERY,
CONF_PASSWORD,
CONF_PORT,
CONF_PROTOCOL,
CONF_USERNAME,
Platform,
)
from homeassistant.helpers import config_validation as cv
from .const import (
CONF_BIRTH_MESSAGE,
CONF_BROKER,
CONF_CERTIFICATE,
CONF_CLIENT_CERT,
CONF_CLIENT_KEY,
CONF_DISCOVERY_PREFIX,
CONF_KEEPALIVE,
CONF_TLS_INSECURE,
CONF_WILL_MESSAGE,
)
DEFAULT_TLS_PROTOCOL = "auto"
CONFIG_SCHEMA_BASE = vol.Schema(
{
Platform.ALARM_CONTROL_PANEL.value: vol.All(cv.ensure_list, [dict]),
@@ -38,3 +60,29 @@ CONFIG_SCHEMA_BASE = vol.Schema(
Platform.WATER_HEATER.value: vol.All(cv.ensure_list, [dict]),
}
)
CLIENT_KEY_AUTH_MSG = (
"client_key and client_cert must both be present in the MQTT broker configuration"
)
DEPRECATED_CONFIG_KEYS = [
CONF_BIRTH_MESSAGE,
CONF_BROKER,
CONF_CLIENT_ID,
CONF_DISCOVERY,
CONF_DISCOVERY_PREFIX,
CONF_KEEPALIVE,
CONF_PASSWORD,
CONF_PORT,
CONF_PROTOCOL,
CONF_TLS_INSECURE,
CONF_USERNAME,
CONF_WILL_MESSAGE,
]
DEPRECATED_CERTIFICATE_CONFIG_KEYS = [
CONF_CERTIFICATE,
CONF_CLIENT_CERT,
CONF_CLIENT_KEY,
]
@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/ollama",
"integration_type": "service",
"iot_class": "local_polling",
"requirements": ["ollama==0.6.2"]
"requirements": ["ollama==0.5.1"]
}
@@ -8,7 +8,13 @@ import pyotgw.vars as gw_vars
from serial import SerialException
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE, CONF_ID, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.const import (
CONF_DEVICE,
CONF_ID,
CONF_NAME,
EVENT_HOMEASSISTANT_STOP,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, device_registry as dr
@@ -94,6 +100,7 @@ class OpenThermGatewayHub:
self.hass = hass
self.device_path = config_entry.data[CONF_DEVICE]
self.hub_id = config_entry.data[CONF_ID]
self.name = config_entry.data[CONF_NAME]
self.options = config_entry.options
self.config_entry_id = config_entry.entry_id
self.update_signal = f"{DATA_OPENTHERM_GW}_{self.hub_id}_update"
@@ -152,14 +159,11 @@ class OpenThermGatewayHub:
_LOGGER.debug("Received report: %s", status)
async_dispatcher_send(self.hass, self.update_signal, status)
boiler_manufacturer = status[OpenThermDataSource.BOILER].get(
gw_vars.DATA_SLAVE_MEMBERID
)
dev_reg.async_update_device(
boiler_device.id,
manufacturer=str(boiler_manufacturer)
if boiler_manufacturer is not None
else None,
manufacturer=status[OpenThermDataSource.BOILER].get(
gw_vars.DATA_SLAVE_MEMBERID
),
model_id=status[OpenThermDataSource.BOILER].get(
gw_vars.DATA_SLAVE_PRODUCT_TYPE
),
@@ -171,14 +175,11 @@ class OpenThermGatewayHub:
),
)
thermostat_manufacturer = status[OpenThermDataSource.THERMOSTAT].get(
gw_vars.DATA_MASTER_MEMBERID
)
dev_reg.async_update_device(
thermostat_device.id,
manufacturer=str(thermostat_manufacturer)
if thermostat_manufacturer is not None
else None,
manufacturer=status[OpenThermDataSource.THERMOSTAT].get(
gw_vars.DATA_MASTER_MEMBERID
),
model_id=status[OpenThermDataSource.THERMOSTAT].get(
gw_vars.DATA_MASTER_PRODUCT_TYPE
),
@@ -17,6 +17,7 @@ from homeassistant.config_entries import (
from homeassistant.const import (
CONF_DEVICE,
CONF_ID,
CONF_NAME,
PRECISION_HALVES,
PRECISION_TENTHS,
PRECISION_WHOLE,
@@ -53,8 +54,9 @@ class OpenThermGwConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle config flow initiation."""
if info:
name = info[CONF_NAME]
device = info[CONF_DEVICE]
gw_id = cv.slugify(info[CONF_ID])
gw_id = cv.slugify(info.get(CONF_ID, name))
entries = [e.data for e in self._async_current_entries()]
@@ -81,7 +83,7 @@ class OpenThermGwConfigFlow(ConfigFlow, domain=DOMAIN):
except ConnectionError, SerialException:
return self._show_form({"base": "cannot_connect"})
return self._create_entry(gw_id, device)
return self._create_entry(gw_id, name, device)
return self._show_form()
@@ -97,17 +99,20 @@ class OpenThermGwConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="init",
data_schema=vol.Schema(
{
# Name field is no longer allowed in config flow schemas
# pylint: disable-next=home-assistant-config-flow-name-field
vol.Required(CONF_NAME): str,
vol.Required(CONF_DEVICE): str,
vol.Required(CONF_ID): str,
vol.Optional(CONF_ID): str,
}
),
errors=errors or {},
)
def _create_entry(self, gw_id, device):
def _create_entry(self, gw_id, name, device):
"""Create entry for the OpenTherm Gateway device."""
return self.async_create_entry(
title="OpenTherm Gateway", data={CONF_ID: gw_id, CONF_DEVICE: device}
title=name, data={CONF_ID: gw_id, CONF_DEVICE: device, CONF_NAME: name}
)
@@ -14,7 +14,8 @@
"init": {
"data": {
"device": "Path or URL",
"id": "ID"
"id": "ID",
"name": "[%key:common::config_flow::data::name%]"
}
}
}
@@ -9,5 +9,5 @@
"iot_class": "cloud_polling",
"loggers": ["opower"],
"quality_scale": "platinum",
"requirements": ["opower==0.18.3"]
"requirements": ["opower==0.18.2"]
}
+25 -32
View File
@@ -4,21 +4,18 @@ from collections import defaultdict
from dataclasses import dataclass
from aiohttp import ClientError
from pyoverkiz.auth.credentials import (
LocalTokenCredentials,
UsernamePasswordCredentials,
)
from pyoverkiz.client import OverkizClient
from pyoverkiz.enums import APIType, OverkizState, Server, UIClass, UIWidget
from pyoverkiz.const import SUPPORTED_SERVERS
from pyoverkiz.enums import APIType, OverkizState, UIClass, UIWidget
from pyoverkiz.exceptions import (
BadCredentialsError,
MaintenanceError,
NoSuchTokenError,
NotAuthenticatedError,
TooManyRequestsError,
BadCredentialsException,
MaintenanceException,
NotAuthenticatedException,
NotSuchTokenException,
TooManyRequestsException,
)
from pyoverkiz.models import Device, PersistedActionGroup
from pyoverkiz.utils import create_local_server_config
from pyoverkiz.models import Device, OverkizServer, Scenario
from pyoverkiz.utils import generate_local_server
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@@ -61,7 +58,7 @@ class HomeAssistantOverkizData:
coordinator: OverkizDataUpdateCoordinator
platforms: defaultdict[Platform, list[Device]]
scenarios: list[PersistedActionGroup]
scenarios: list[Scenario]
type OverkizDataConfigEntry = ConfigEntry[HomeAssistantOverkizData]
@@ -93,7 +90,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry)
hass,
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
server=entry.data[CONF_HUB],
server=SUPPORTED_SERVERS[entry.data[CONF_HUB]],
)
try:
@@ -103,20 +100,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry)
# Local API does expose scenarios, but they are not functional.
# Tracked in https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode/issues/21
if api_type == APIType.CLOUD:
scenarios = await client.get_action_groups()
scenarios = await client.get_scenarios()
else:
scenarios = []
except (
BadCredentialsError,
NoSuchTokenError,
NotAuthenticatedError,
BadCredentialsException,
NotSuchTokenException,
NotAuthenticatedException,
) as exception:
raise ConfigEntryAuthFailed("Invalid authentication") from exception
except TooManyRequestsError as exception:
except TooManyRequestsException as exception:
raise ConfigEntryNotReady("Too many requests, try again later") from exception
except (TimeoutError, ClientError) as exception:
raise ConfigEntryNotReady("Failed to connect") from exception
except MaintenanceError as exception:
except MaintenanceException as exception:
raise ConfigEntryNotReady("Server is down for maintenance") from exception
coordinator = OverkizDataUpdateCoordinator(
@@ -176,13 +173,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry)
identifiers={(DOMAIN, gateway.id)},
model=gateway.type.beautify_name if gateway.type else None,
model_id=str(gateway.type),
manufacturer=client.server_config.manufacturer,
manufacturer=client.server.manufacturer,
name=gateway.type.beautify_name if gateway.type else gateway.id,
sw_version=gateway.connectivity.protocol_version,
hw_version=f"{gateway.type}:{gateway.sub_type}"
if gateway.type and gateway.sub_type
else None,
configuration_url=client.server_config.configuration_url,
configuration_url=client.server.configuration_url,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -217,9 +214,6 @@ async def _async_migrate_strenum_unique_ids(
"""Migrate entities to the StrEnum-style unique IDs."""
entity_registry = er.async_get(hass)
# Map enum members renamed in pyoverkiz 2.0 to their current names.
renamed_enum_members = {"TSKALARM_CONTROLLER": "TSK_ALARM_CONTROLLER"}
@callback
def update_unique_id(entry: er.RegistryEntry) -> dict[str, str] | None:
# Python 3.11 treats (str, Enum) and StrEnum
@@ -235,7 +229,6 @@ async def _async_migrate_strenum_unique_ids(
("OverkizState", "UIWidget", "UIClass")
):
state = key.split(".")[1]
state = renamed_enum_members.get(state, state)
new_key = ""
if key.startswith("UIClass"):
@@ -283,15 +276,17 @@ def create_local_client(
session = async_create_clientsession(hass, verify_ssl=verify_ssl)
return OverkizClient(
server=create_local_server_config(host=host),
credentials=LocalTokenCredentials(token),
username="",
password="",
token=token,
session=session,
server=generate_local_server(host=host),
verify_ssl=verify_ssl,
)
def create_cloud_client(
hass: HomeAssistant, username: str, password: str, server: Server
hass: HomeAssistant, username: str, password: str, server: OverkizServer
) -> OverkizClient:
"""Create Overkiz cloud client."""
# To allow users with multiple accounts/hubs, we create a
@@ -299,7 +294,5 @@ def create_cloud_client(
session = async_create_clientsession(hass)
return OverkizClient(
server=server,
credentials=UsernamePasswordCredentials(username, password),
session=session,
username=username, password=password, session=session, server=server
)
@@ -144,7 +144,7 @@ ALARM_DESCRIPTIONS: list[OverkizAlarmDescription] = [
# Disabled by default since all Overkiz hubs have this
# virtual device, but only a few users actually use this.
OverkizAlarmDescription(
key=UIWidget.TSK_ALARM_CONTROLLER,
key=UIWidget.TSKALARM_CONTROLLER,
entity_registry_enabled_default=False,
supported_features=(
AlarmControlPanelEntityFeature.ARM_AWAY
@@ -165,7 +165,7 @@ async def async_setup_entry(
description,
)
for state in device.definition.states
if (description := SUPPORTED_STATES.get(state))
if (description := SUPPORTED_STATES.get(state.qualified_name))
)
async_add_entities(entities)
+1 -1
View File
@@ -120,7 +120,7 @@ async def async_setup_entry(
description,
)
for command in device.definition.commands
if (description := SUPPORTED_COMMANDS.get(command))
if (description := SUPPORTED_COMMANDS.get(command.command_name))
)
async_add_entities(entities)
@@ -115,13 +115,12 @@ async def async_setup_entry(
# Match devices based on the widget and protocol.
# #ie Hitachi Air To Air Heat Pumps
entities_based_on_widget_and_protocol: list[Entity] = [
WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY[device.widget][
device.identifier.protocol
](device.device_url, data.coordinator)
WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY[device.widget][device.protocol](
device.device_url, data.coordinator
)
for device in data.platforms[Platform.CLIMATE]
if device.widget in WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY
and device.identifier.protocol
in WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY[device.widget]
and device.protocol in WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY[device.widget]
]
async_add_entities(
@@ -157,7 +157,7 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint(
@property
def target_temperature(self) -> float | None:
"""Return the temperature."""
if state := self.device.states.get(OverkizState.CORE_TARGET_TEMPERATURE):
if state := self.device.states[OverkizState.CORE_TARGET_TEMPERATURE]:
return state.value_as_float
return None
@@ -165,9 +165,7 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint(
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if self.temperature_device is not None and (
temperature := self.temperature_device.states.get(
OverkizState.CORE_TEMPERATURE
)
temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]
):
return temperature.value_as_float
return None
@@ -104,9 +104,7 @@ class AtlanticElectricalTowelDryer(OverkizEntity, ClimateEntity):
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if self.temperature_device is not None and (
temperature := self.temperature_device.states.get(
OverkizState.CORE_TEMPERATURE
)
temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]
):
return cast(float, temperature.value)
@@ -67,9 +67,7 @@ class AtlanticHeatRecoveryVentilation(OverkizEntity, ClimateEntity):
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if self.temperature_device is not None and (
temperature := self.temperature_device.states.get(
OverkizState.CORE_TEMPERATURE
)
temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]
):
return cast(float, temperature.value)
@@ -106,9 +106,7 @@ class AtlanticPassAPCHeatingZone(OverkizEntity, ClimateEntity):
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if self.temperature_device is not None and (
temperature := self.temperature_device.states.get(
OverkizState.CORE_TEMPERATURE
)
temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]
):
return cast(float, temperature.value)
@@ -74,7 +74,7 @@ class EvoHomeController(OverkizEntity, ClimateEntity):
def preset_mode(self) -> str | None:
"""Return the current preset mode, e.g., home, away, temp."""
if (
state := self.device.states.get(OverkizState.RAMSES_RAMSES_OPERATING_MODE)
state := self.device.states[OverkizState.RAMSES_RAMSES_OPERATING_MODE]
) and state.value_as_str in OVERKIZ_TO_PRESET_MODES:
return OVERKIZ_TO_PRESET_MODES[state.value_as_str]
@@ -114,13 +114,13 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity):
def hvac_mode(self) -> HVACMode:
"""Return hvac operation ie. heat, cool mode."""
if (
main_op_state := self.device.states.get(MAIN_OPERATION_STATE)
main_op_state := self.device.states[MAIN_OPERATION_STATE]
) and main_op_state.value_as_str:
if main_op_state.value_as_str.lower() == OverkizCommandParam.OFF:
return HVACMode.OFF
if (
mode_change_state := self.device.states.get(MODE_CHANGE_STATE)
mode_change_state := self.device.states[MODE_CHANGE_STATE]
) and mode_change_state.value_as_str:
sanitized_value = mode_change_state.value_as_str.lower()
return OVERKIZ_TO_HVAC_MODES[sanitized_value]
@@ -140,7 +140,7 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity):
@property
def fan_mode(self) -> str | None:
"""Return the fan setting."""
if (state := self.device.states.get(FAN_SPEED_STATE)) and state.value_as_str:
if (state := self.device.states[FAN_SPEED_STATE]) and state.value_as_str:
return OVERKIZ_TO_FAN_MODES[state.value_as_str]
return None
@@ -157,7 +157,7 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity):
@property
def swing_mode(self) -> str | None:
"""Return the swing setting."""
if (state := self.device.states.get(SWING_STATE)) and state.value_as_str:
if (state := self.device.states[SWING_STATE]) and state.value_as_str:
return OVERKIZ_TO_SWING_MODES[state.value_as_str]
return None
@@ -170,7 +170,7 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity):
def target_temperature(self) -> int | None:
"""Return the temperature."""
if (
temperature := self.device.states.get(OverkizState.CORE_TARGET_TEMPERATURE)
temperature := self.device.states[OverkizState.CORE_TARGET_TEMPERATURE]
) and temperature.value_as_int:
return temperature.value_as_int
@@ -179,9 +179,7 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity):
@property
def current_temperature(self) -> int | None:
"""Return current temperature."""
if (
state := self.device.states.get(ROOM_TEMPERATURE_STATE)
) and state.value_as_int:
if (state := self.device.states[ROOM_TEMPERATURE_STATE]) and state.value_as_int:
return state.value_as_int
return None
@@ -194,7 +192,7 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity):
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode, e.g., home, away, temp."""
if (state := self.device.states.get(LEAVE_HOME_STATE)) and state.value_as_str:
if (state := self.device.states[LEAVE_HOME_STATE]) and state.value_as_str:
if state.value_as_str == OverkizCommandParam.ON:
return PRESET_HOLIDAY_MODE
@@ -224,7 +222,7 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity):
"""
if value:
return value
state = self.device.states.get(state_name)
state = self.device.states[state_name]
if state and state.value_as_str:
return state.value_as_str
return fallback_value
@@ -118,13 +118,13 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
def hvac_mode(self) -> HVACMode:
"""Return hvac operation ie. heat, cool mode."""
if (
main_op_state := self.device.states.get(OverkizState.OVP_MAIN_OPERATION)
main_op_state := self.device.states[OverkizState.OVP_MAIN_OPERATION]
) and main_op_state.value_as_str:
if main_op_state.value_as_str.lower() == OverkizCommandParam.OFF:
return HVACMode.OFF
if (
mode_change_state := self.device.states.get(OverkizState.OVP_MODE_CHANGE)
mode_change_state := self.device.states[OverkizState.OVP_MODE_CHANGE]
) and mode_change_state.value_as_str:
# The OVP protocol has 'auto cooling' and 'auto heating' values
# that are equivalent to the HLRRWIFI protocol without spaces
@@ -147,7 +147,7 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
def fan_mode(self) -> str | None:
"""Return the fan setting."""
if (
state := self.device.states.get(OverkizState.OVP_FAN_SPEED)
state := self.device.states[OverkizState.OVP_FAN_SPEED]
) and state.value_as_str:
return OVERKIZ_TO_FAN_MODES[state.value_as_str]
@@ -160,9 +160,7 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
@property
def swing_mode(self) -> str | None:
"""Return the swing setting."""
if (
state := self.device.states.get(OverkizState.OVP_SWING)
) and state.value_as_str:
if (state := self.device.states[OverkizState.OVP_SWING]) and state.value_as_str:
return OVERKIZ_TO_SWING_MODES[state.value_as_str]
return None
@@ -175,7 +173,7 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
def target_temperature(self) -> int | None:
"""Return the target temperature."""
if (
temperature := self.device.states.get(OverkizState.CORE_TARGET_TEMPERATURE)
temperature := self.device.states[OverkizState.CORE_TARGET_TEMPERATURE]
) and temperature.value_as_int:
return temperature.value_as_int
@@ -185,7 +183,7 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
def current_temperature(self) -> int | None:
"""Return current temperature."""
if (
state := self.device.states.get(OverkizState.OVP_ROOM_TEMPERATURE)
state := self.device.states[OverkizState.OVP_ROOM_TEMPERATURE]
) and state.value_as_int:
return state.value_as_int
@@ -199,7 +197,7 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
def preset_mode(self) -> str | None:
"""Return the current preset mode, e.g., home, away, temp."""
if (
state := self.device.states.get(OverkizState.CORE_HOLIDAYS_MODE)
state := self.device.states[OverkizState.CORE_HOLIDAYS_MODE]
) and state.value_as_str:
if state.value_as_str == OverkizCommandParam.ON:
return PRESET_HOLIDAY_MODE
@@ -227,7 +225,7 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
def auto_manu_mode(self) -> str | None:
"""Return auto/manu mode."""
if (
state := self.device.states.get(OverkizState.CORE_AUTO_MANU_MODE)
state := self.device.states[OverkizState.CORE_AUTO_MANU_MODE]
) and state.value_as_str:
return state.value_as_str
return None
@@ -237,7 +235,7 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
def temperature_change(self) -> int | None:
"""Return temperature change state."""
if (
state := self.device.states.get(OverkizState.OVP_TEMPERATURE_CHANGE)
state := self.device.states[OverkizState.OVP_TEMPERATURE_CHANGE]
) and state.value_as_int:
return state.value_as_int
@@ -268,7 +266,7 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
"""
if value:
return value
if (state := self.device.states.get(state_name)) is not None and (
if (state := self.device.states[state_name]) is not None and (
value := state.value_as_str
) is not None:
return value
@@ -60,7 +60,7 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity):
def hvac_mode(self) -> HVACMode:
"""Return hvac operation ie. heat, cool mode."""
if (
state := self.device.states.get(OverkizState.MODBUS_AUTO_MANU_MODE_ZONE_1)
state := self.device.states[OverkizState.MODBUS_AUTO_MANU_MODE_ZONE_1]
) and state.value_as_str:
return OVERKIZ_TO_HVAC_MODE[state.value_as_str]
@@ -76,7 +76,7 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity):
def preset_mode(self) -> str | None:
"""Return the current preset mode, e.g., home, away, temp."""
if (
state := self.device.states.get(OverkizState.MODBUS_YUTAKI_TARGET_MODE)
state := self.device.states[OverkizState.MODBUS_YUTAKI_TARGET_MODE]
) and state.value_as_str:
return OVERKIZ_TO_PRESET_MODE[state.value_as_str]
@@ -91,9 +91,9 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity):
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
current_temperature = self.device.states.get(
current_temperature = self.device.states[
OverkizState.MODBUS_ROOM_AMBIENT_TEMPERATURE_STATUS_ZONE_1
)
]
if current_temperature:
return current_temperature.value_as_float
@@ -103,9 +103,9 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity):
@property
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
target_temperature = self.device.states.get(
target_temperature = self.device.states[
OverkizState.MODBUS_THERMOSTAT_SETTING_CONTROL_ZONE_1
)
]
if target_temperature:
return target_temperature.value_as_float
@@ -99,14 +99,14 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
@property
def hvac_mode(self) -> HVACMode:
"""Return hvac operation i.e. heat, cool mode."""
state = self.device.states.get(OverkizState.CORE_ON_OFF)
state = self.device.states[OverkizState.CORE_ON_OFF]
if state and state.value_as_str == OverkizCommandParam.OFF:
return HVACMode.OFF
if (
state := self.device.states.get(
state := self.device.states[
OverkizState.OVP_HEATING_TEMPERATURE_INTERFACE_ACTIVE_MODE
)
]
) and state.value_as_str:
return OVERKIZ_TO_HVAC_MODES[state.value_as_str]
@@ -127,9 +127,9 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
def preset_mode(self) -> str | None:
"""Return the current preset mode, e.g., home, away, temp."""
if (
state := self.device.states.get(
state := self.device.states[
OverkizState.OVP_HEATING_TEMPERATURE_INTERFACE_SETPOINT_MODE
)
]
) and state.value_as_str:
return OVERKIZ_TO_PRESET_MODES[state.value_as_str]
return None
@@ -145,9 +145,9 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
def hvac_action(self) -> HVACAction | None:
"""Return the current running hvac operation if supported."""
if (
current_operation := self.device.states.get(
current_operation := self.device.states[
OverkizState.OVP_HEATING_TEMPERATURE_INTERFACE_OPERATING_MODE
)
]
) and current_operation.value_as_str:
return OVERKIZ_TO_HVAC_ACTION[current_operation.value_as_str]
@@ -167,7 +167,7 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
if mode not in MAP_PRESET_TEMPERATURES:
return None
if state := self.device.states.get(MAP_PRESET_TEMPERATURES[mode]):
if state := self.device.states[MAP_PRESET_TEMPERATURES[mode]]:
return state.value_as_float
return None
@@ -175,9 +175,7 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if self.temperature_device is not None and (
temperature := self.temperature_device.states.get(
OverkizState.CORE_TEMPERATURE
)
temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]
):
return temperature.value_as_float
return None
@@ -187,9 +185,9 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
temperature = kwargs[ATTR_TEMPERATURE]
if (
mode := self.device.states.get(
mode := self.device.states[
OverkizState.OVP_HEATING_TEMPERATURE_INTERFACE_SETPOINT_MODE
)
]
) and mode.value_as_str:
await self.executor.async_execute_command(
SETPOINT_MODE_TO_OVERKIZ_COMMAND[mode.value_as_str], temperature
@@ -40,10 +40,10 @@ OVERKIZ_TO_PRESET_MODES: dict[OverkizCommandParam, str] = {
PRESET_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_PRESET_MODES.items()}
TARGET_TEMP_TO_OVERKIZ = {
PRESET_HOME: OverkizState.SOMFYTHERMOSTAT_AT_HOME_TARGET_TEMPERATURE,
PRESET_AWAY: OverkizState.SOMFYTHERMOSTAT_AWAY_MODE_TARGET_TEMPERATURE,
PRESET_FREEZE: OverkizState.SOMFYTHERMOSTAT_FREEZE_MODE_TARGET_TEMPERATURE,
PRESET_NIGHT: OverkizState.SOMFYTHERMOSTAT_SLEEPING_MODE_TARGET_TEMPERATURE,
PRESET_HOME: OverkizState.SOMFY_THERMOSTAT_AT_HOME_TARGET_TEMPERATURE,
PRESET_AWAY: OverkizState.SOMFY_THERMOSTAT_AWAY_MODE_TARGET_TEMPERATURE,
PRESET_FREEZE: OverkizState.SOMFY_THERMOSTAT_FREEZE_MODE_TARGET_TEMPERATURE,
PRESET_NIGHT: OverkizState.SOMFY_THERMOSTAT_SLEEPING_MODE_TARGET_TEMPERATURE,
}
# controllableName is somfythermostat:SomfyThermostatTemperatureSensor
@@ -88,9 +88,9 @@ class SomfyThermostat(OverkizEntity, ClimateEntity):
def preset_mode(self) -> str:
"""Return the current preset mode, e.g., home, away, temp."""
if self.hvac_mode == HVACMode.AUTO:
state_key = OverkizState.SOMFYTHERMOSTAT_HEATING_MODE
state_key = OverkizState.SOMFY_THERMOSTAT_HEATING_MODE
else:
state_key = OverkizState.SOMFYTHERMOSTAT_DEROGATION_HEATING_MODE
state_key = OverkizState.SOMFY_THERMOSTAT_DEROGATION_HEATING_MODE
if state := self.executor.select_state(state_key):
return OVERKIZ_TO_PRESET_MODES[OverkizCommandParam(cast(str, state))]
@@ -101,9 +101,7 @@ class SomfyThermostat(OverkizEntity, ClimateEntity):
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if self.temperature_device is not None and (
temperature := self.temperature_device.states.get(
OverkizState.CORE_TEMPERATURE
)
temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]
):
return cast(float, temperature.value)
return None
@@ -91,9 +91,7 @@ class ValveHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if self.temperature_device is not None and (
temperature := self.temperature_device.states.get(
OverkizState.CORE_TEMPERATURE
)
temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]
):
return temperature.value_as_float
+29 -32
View File
@@ -4,25 +4,21 @@ from collections.abc import Mapping
from typing import Any, cast
from aiohttp import ClientConnectorCertificateError, ClientError
from pyoverkiz.auth.credentials import (
LocalTokenCredentials,
UsernamePasswordCredentials,
)
from pyoverkiz.client import OverkizClient
from pyoverkiz.const import SERVERS_WITH_LOCAL_API, SUPPORTED_SERVERS
from pyoverkiz.enums import APIType, Server
from pyoverkiz.exceptions import (
BadCredentialsError,
CozyTouchBadCredentialsError,
MaintenanceError,
NoSuchTokenError,
NotAuthenticatedError,
TooManyAttemptsBannedError,
TooManyRequestsError,
UnknownUserError,
BadCredentialsException,
CozyTouchBadCredentialsException,
MaintenanceException,
NotAuthenticatedException,
NotSuchTokenException,
TooManyAttemptsBannedException,
TooManyRequestsException,
UnknownUserException,
)
from pyoverkiz.obfuscate import obfuscate_id
from pyoverkiz.utils import create_local_server_config, is_overkiz_gateway
from pyoverkiz.utils import generate_local_server, is_overkiz_gateway
import voluptuous as vol
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
@@ -62,18 +58,19 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
self.hass, verify_ssl=user_input[CONF_VERIFY_SSL]
)
client = OverkizClient(
server=create_local_server_config(host=user_input[CONF_HOST]),
credentials=LocalTokenCredentials(user_input[CONF_TOKEN]),
username="",
password="",
token=user_input[CONF_TOKEN],
session=session,
server=generate_local_server(host=user_input[CONF_HOST]),
verify_ssl=user_input[CONF_VERIFY_SSL],
)
else: # APIType.CLOUD
session = async_create_clientsession(self.hass)
client = OverkizClient(
server=user_input[CONF_HUB],
credentials=UsernamePasswordCredentials(
user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
),
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
server=SUPPORTED_SERVERS[user_input[CONF_HUB]],
session=session,
)
@@ -152,9 +149,9 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
try:
await self.async_validate_input(user_input)
except TooManyRequestsError:
except TooManyRequestsException:
errors["base"] = "too_many_requests"
except (BadCredentialsError, NotAuthenticatedError) as exception:
except (BadCredentialsException, NotAuthenticatedException) as exception:
# If authentication with CozyTouch auth server is
# valid, but token is invalid for Overkiz API
# server, the hardware is not supported.
@@ -162,18 +159,18 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
Server.ATLANTIC_COZYTOUCH,
Server.SAUTER_COZYTOUCH,
Server.THERMOR_COZYTOUCH,
} and not isinstance(exception, CozyTouchBadCredentialsError):
} and not isinstance(exception, CozyTouchBadCredentialsException):
description_placeholders["unsupported_device"] = "CozyTouch"
errors["base"] = "unsupported_hardware"
else:
errors["base"] = "invalid_auth"
except TimeoutError, ClientError:
errors["base"] = "cannot_connect"
except MaintenanceError:
except MaintenanceException:
errors["base"] = "server_in_maintenance"
except TooManyAttemptsBannedError:
except TooManyAttemptsBannedException:
errors["base"] = "too_many_attempts"
except UnknownUserError:
except UnknownUserException:
# If the user has no supported CozyTouch devices on
# the Overkiz API server. Login will return unknown user.
if user_input[CONF_HUB] in {
@@ -242,12 +239,12 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
try:
user_input = await self.async_validate_input(user_input)
except TooManyRequestsError:
except TooManyRequestsException:
errors["base"] = "too_many_requests"
except (
BadCredentialsError,
NoSuchTokenError,
NotAuthenticatedError,
BadCredentialsException,
NotSuchTokenException,
NotAuthenticatedException,
):
errors["base"] = "invalid_auth"
except ClientConnectorCertificateError as exception:
@@ -256,11 +253,11 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
except (TimeoutError, ClientError) as exception:
errors["base"] = "cannot_connect"
LOGGER.debug(exception)
except MaintenanceError:
except MaintenanceException:
errors["base"] = "server_in_maintenance"
except TooManyAttemptsBannedError:
except TooManyAttemptsBannedException:
errors["base"] = "too_many_attempts"
except UnknownUserError:
except UnknownUserException:
# Somfy Protect accounts are not supported since they don't use
# the Overkiz API server. Login will return unknown user.
description_placeholders["unsupported_device"] = "Somfy Protect"
+1 -1
View File
@@ -118,7 +118,7 @@ OVERKIZ_DEVICE_TO_PLATFORM: dict[UIClass | UIWidget, Platform | None] = {
UIWidget.STATELESS_ALARM_CONTROLLER: Platform.SWITCH,
UIWidget.STATEFUL_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL,
UIWidget.STATELESS_EXTERIOR_HEATING: Platform.SWITCH,
UIWidget.TSK_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL,
UIWidget.TSKALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL,
UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: Platform.CLIMATE,
}
+33 -39
View File
@@ -9,23 +9,15 @@ from aiohttp import ClientConnectorError, ServerDisconnectedError
from pyoverkiz.client import OverkizClient
from pyoverkiz.enums import EventName, ExecutionState, Protocol
from pyoverkiz.exceptions import (
BadCredentialsError,
InvalidEventListenerIdError,
MaintenanceError,
NotAuthenticatedError,
ServiceUnavailableError,
TooManyConcurrentRequestsError,
TooManyRequestsError,
)
from pyoverkiz.models import (
Device,
DeviceEvent,
DeviceRemovedEvent,
DeviceStateChangedEvent,
ExecutionRegisteredEvent,
ExecutionStateChangedEvent,
Place,
BadCredentialsException,
InvalidEventListenerIdException,
MaintenanceException,
NotAuthenticatedException,
ServiceUnavailableException,
TooManyConcurrentRequestsException,
TooManyRequestsException,
)
from pyoverkiz.models import Device, Event, Place
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
@@ -38,9 +30,8 @@ if TYPE_CHECKING:
from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES, LOGGER, UPDATE_INTERVAL
# Events are a discriminated union; each handler narrows to its own subtype.
EVENT_HANDLERS: Registry[
str, Callable[[OverkizDataUpdateCoordinator, Any], Coroutine[Any, Any, None]]
str, Callable[[OverkizDataUpdateCoordinator, Event], Coroutine[Any, Any, None]]
] = Registry()
@@ -77,7 +68,7 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
self._default_update_interval = UPDATE_INTERVAL
self.is_stateless = all(
device.identifier.protocol in (Protocol.RTS, Protocol.INTERNAL)
device.protocol in (Protocol.RTS, Protocol.INTERNAL)
for device in devices
if device.widget not in IGNORED_OVERKIZ_DEVICES
and device.ui_class not in IGNORED_OVERKIZ_DEVICES
@@ -87,17 +78,17 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
"""Fetch Overkiz data via event listener."""
try:
events = await self.client.fetch_events()
except (BadCredentialsError, NotAuthenticatedError) as exception:
except (BadCredentialsException, NotAuthenticatedException) as exception:
raise ConfigEntryAuthFailed("Invalid authentication.") from exception
except TooManyConcurrentRequestsError as exception:
except TooManyConcurrentRequestsException as exception:
raise UpdateFailed("Too many concurrent requests.") from exception
except TooManyRequestsError as exception:
except TooManyRequestsException as exception:
raise UpdateFailed("Too many requests, try again later.") from exception
except MaintenanceError as exception:
except MaintenanceException as exception:
raise UpdateFailed("Server is down for maintenance.") from exception
except ServiceUnavailableError as exception:
except ServiceUnavailableException as exception:
raise UpdateFailed("Server is unavailable.") from exception
except InvalidEventListenerIdError as exception:
except InvalidEventListenerIdException as exception:
raise UpdateFailed(exception) from exception
except (TimeoutError, ClientConnectorError) as exception:
LOGGER.debug("Failed to connect", exc_info=True)
@@ -109,9 +100,9 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
try:
await self.client.login()
self.devices = await self._get_devices()
except (BadCredentialsError, NotAuthenticatedError) as exception:
except (BadCredentialsException, NotAuthenticatedException) as exception:
raise ConfigEntryAuthFailed("Invalid authentication.") from exception
except TooManyRequestsError as exception:
except TooManyRequestsException as exception:
raise UpdateFailed("Too many requests, try again later.") from exception
return self.devices
@@ -153,27 +144,27 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
@EVENT_HANDLERS.register(EventName.DEVICE_AVAILABLE)
async def on_device_available(
coordinator: OverkizDataUpdateCoordinator, event: DeviceEvent
coordinator: OverkizDataUpdateCoordinator, event: Event
) -> None:
"""Handle device available event."""
if event.device_url in coordinator.devices:
if event.device_url and event.device_url in coordinator.devices:
coordinator.devices[event.device_url].available = True
@EVENT_HANDLERS.register(EventName.DEVICE_UNAVAILABLE)
@EVENT_HANDLERS.register(EventName.DEVICE_DISABLED)
async def on_device_unavailable_disabled(
coordinator: OverkizDataUpdateCoordinator, event: DeviceEvent
coordinator: OverkizDataUpdateCoordinator, event: Event
) -> None:
"""Handle device unavailable / disabled event."""
if event.device_url in coordinator.devices:
if event.device_url and event.device_url in coordinator.devices:
coordinator.devices[event.device_url].available = False
@EVENT_HANDLERS.register(EventName.DEVICE_CREATED)
@EVENT_HANDLERS.register(EventName.DEVICE_UPDATED)
async def on_device_created_updated(
coordinator: OverkizDataUpdateCoordinator, event: DeviceEvent
coordinator: OverkizDataUpdateCoordinator, event: Event
) -> None:
"""Handle device unavailable / disabled event."""
coordinator.hass.async_create_task(
@@ -183,10 +174,10 @@ async def on_device_created_updated(
@EVENT_HANDLERS.register(EventName.DEVICE_STATE_CHANGED)
async def on_device_state_changed(
coordinator: OverkizDataUpdateCoordinator, event: DeviceStateChangedEvent
coordinator: OverkizDataUpdateCoordinator, event: Event
) -> None:
"""Handle device state changed event."""
if event.device_url not in coordinator.devices:
if not event.device_url or event.device_url not in coordinator.devices:
return
for state in event.device_states:
@@ -196,9 +187,12 @@ async def on_device_state_changed(
@EVENT_HANDLERS.register(EventName.DEVICE_REMOVED)
async def on_device_removed(
coordinator: OverkizDataUpdateCoordinator, event: DeviceRemovedEvent
coordinator: OverkizDataUpdateCoordinator, event: Event
) -> None:
"""Handle device removed event."""
if not event.device_url:
return
base_device_url = event.device_url.split("#")[0]
registry = dr.async_get(coordinator.hass)
@@ -207,16 +201,16 @@ async def on_device_removed(
):
registry.async_remove_device(registered_device.id)
if event.device_url in coordinator.devices:
if event.device_url and event.device_url in coordinator.devices:
del coordinator.devices[event.device_url]
@EVENT_HANDLERS.register(EventName.EXECUTION_REGISTERED)
async def on_execution_registered(
coordinator: OverkizDataUpdateCoordinator, event: ExecutionRegisteredEvent
coordinator: OverkizDataUpdateCoordinator, event: Event
) -> None:
"""Handle execution registered event."""
if event.exec_id not in coordinator.executions:
if event.exec_id and event.exec_id not in coordinator.executions:
coordinator.executions[event.exec_id] = {}
if not coordinator.is_stateless:
@@ -225,7 +219,7 @@ async def on_execution_registered(
@EVENT_HANDLERS.register(EventName.EXECUTION_STATE_CHANGED)
async def on_execution_state_changed(
coordinator: OverkizDataUpdateCoordinator, event: ExecutionStateChangedEvent
coordinator: OverkizDataUpdateCoordinator, event: Event
) -> None:
"""Handle execution changed event."""
if event.exec_id in coordinator.executions and event.new_state in [
+5 -7
View File
@@ -631,7 +631,7 @@ class OverkizCover(OverkizDescriptiveEntity, CoverEntity):
"""
state_name = self.entity_description.current_position_state
if not state_name or not (state := self.device.states.get(state_name)):
if not state_name or not (state := self.device.states[state_name]):
return None
position = state.value_as_int
@@ -645,9 +645,9 @@ class OverkizCover(OverkizDescriptiveEntity, CoverEntity):
state_name,
)
if fallback_state := self.device.states.get(
if fallback_state := self.device.states[
OverkizState.CORE_MEMORIZED_1_POSITION
):
]:
position = fallback_state.value_as_int
else:
return None
@@ -661,9 +661,7 @@ class OverkizCover(OverkizDescriptiveEntity, CoverEntity):
state_name,
)
if fallback_state := self.device.states.get(
OverkizState.CORE_TARGET_CLOSURE
):
if fallback_state := self.device.states[OverkizState.CORE_TARGET_CLOSURE]:
position = fallback_state.value_as_int
else:
return None
@@ -709,7 +707,7 @@ class OverkizCover(OverkizDescriptiveEntity, CoverEntity):
"""
state_name = self.entity_description.current_tilt_position_state
if state_name and (state := self.device.states.get(state_name)):
if state_name and (state := self.device.states[state_name]):
position = state.value_as_int
if position is None:
return None
@@ -19,13 +19,13 @@ async def async_get_config_entry_diagnostics(
client = entry.runtime_data.coordinator.client
data = {
**await client.get_diagnostic_data(),
"setup": await client.get_diagnostic_data(),
"server": entry.data[CONF_HUB],
"api_type": entry.data.get(CONF_API_TYPE, APIType.CLOUD),
}
# Only Overkiz cloud servers expose an endpoint with execution history
if client.server_config.api_type == APIType.CLOUD:
if client.api_type == APIType.CLOUD:
execution_history = [
repr(execution) for execution in await client.get_execution_history()
]
@@ -49,13 +49,13 @@ async def async_get_device_diagnostics(
"device_url": obfuscate_id(device_url),
"model": device.model,
},
**await client.get_diagnostic_data(),
"setup": await client.get_diagnostic_data(),
"server": entry.data[CONF_HUB],
"api_type": entry.data.get(CONF_API_TYPE, APIType.CLOUD),
}
# Only Overkiz cloud servers expose an endpoint with execution history
if client.server_config.api_type == APIType.CLOUD:
if client.api_type == APIType.CLOUD:
data["execution_history"] = [
repr(execution)
for execution in await client.get_execution_history()
+3 -3
View File
@@ -49,7 +49,7 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]):
# Workaround: local API may incorrectly report
# available=False (Somfy-TaHoma-Developer-Mode#217)
if self.coordinator.client.server_config.api_type != APIType.LOCAL:
if self.coordinator.client.api_type != APIType.LOCAL:
return False
if status_state := self.device.states.get(OverkizState.CORE_STATUS):
@@ -85,7 +85,7 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]):
manufacturer = (
self.executor.select_attribute(OverkizAttribute.CORE_MANUFACTURER)
or self.executor.select_state(OverkizState.CORE_MANUFACTURER_NAME)
or self.coordinator.client.server_config.manufacturer
or self.coordinator.client.server.manufacturer
)
model = (
@@ -116,7 +116,7 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]):
hw_version=self.device.controllable_name,
suggested_area=suggested_area,
via_device=(DOMAIN, self.executor.get_gateway_id()),
configuration_url=self.coordinator.client.server_config.configuration_url,
configuration_url=self.coordinator.client.server.configuration_url,
)
+23 -25
View File
@@ -1,11 +1,11 @@
"""Class for helpers and communication with the OverKiz API."""
from typing import Any
from typing import Any, cast
from urllib.parse import urlparse
from pyoverkiz.enums import OverkizCommand, Protocol
from pyoverkiz.exceptions import BaseOverkizError
from pyoverkiz.models import Action, Command, Device, StateDefinition
from pyoverkiz.exceptions import BaseOverkizException
from pyoverkiz.models import Command, Device, StateDefinition
from pyoverkiz.types import StateType as OverkizStateType
from homeassistant.exceptions import HomeAssistantError
@@ -56,15 +56,15 @@ class OverkizExecutor:
def select_definition_state(self, *states: str) -> StateDefinition | None:
"""Select first existing definition state in a list of states."""
for state_name in states:
if state_name in self.device.definition.states:
return self.device.definition.states[state_name]
for existing_state in self.device.definition.states:
if existing_state.qualified_name in states:
return existing_state
return None
def select_state(self, *states: str) -> OverkizStateType:
"""Select first existing active state in a list of states."""
for state in states:
if current_state := self.device.states.get(state):
if current_state := self.device.states[state]:
return current_state.value
return None
@@ -76,7 +76,7 @@ class OverkizExecutor:
def select_attribute(self, *attributes: str) -> OverkizStateType:
"""Select first existing active state in a list of states."""
for attribute in attributes:
if current_attribute := self.device.attributes.get(attribute):
if current_attribute := self.device.attributes[attribute]:
return current_attribute.value
return None
@@ -94,23 +94,19 @@ class OverkizExecutor:
# Set the execution duration to 0 seconds for RTS devices on supported commands
# Default execution duration is 30 seconds and will block consecutive commands
if (
self.device.identifier.protocol == Protocol.RTS
self.device.protocol == Protocol.RTS
and command_name not in COMMANDS_WITHOUT_DELAY
):
parameters.append(0)
try:
exec_id = await self.coordinator.client.execute_action_group(
label="Home Assistant",
actions=[
Action(
device_url=self.device.device_url,
commands=[Command(name=command_name, parameters=parameters)],
)
],
exec_id = await self.coordinator.client.execute_command(
self.device.device_url,
Command(command_name, parameters),
"Home Assistant",
)
# Catch Overkiz exceptions to support `continue_on_error` functionality
except BaseOverkizError as exception:
except BaseOverkizException as exception:
raise HomeAssistantError(exception) from exception
# ExecutionRegisteredEvent doesn't contain the
@@ -146,16 +142,18 @@ class OverkizExecutor:
return True
# Retrieve executions initiated outside Home Assistant via API
executions = await self.coordinator.client.get_current_executions()
executions = cast(Any, await self.coordinator.client.get_current_executions())
# executions.action_group is typed incorrectly in the upstream library
# or the below code is incorrect.
exec_id = next(
(
execution.id
for execution in executions
if execution.action_group
for action in reversed(execution.action_group.actions)
for command in action.commands
if action.device_url == self.device.device_url
and command.name in commands_to_cancel
# Reverse dictionary to cancel the last added execution
for action in reversed(execution.action_group.get("actions"))
for command in action.get("commands")
if action.get("device_url") == self.device.device_url
and command.get("name") in commands_to_cancel
),
None,
)
@@ -168,7 +166,7 @@ class OverkizExecutor:
async def async_cancel_execution(self, exec_id: str) -> None:
"""Cancel running execution via execution id."""
await self.coordinator.client.cancel_execution(exec_id)
await self.coordinator.client.cancel_command(exec_id)
def get_gateway_id(self) -> str:
"""Retrieve gateway id from device url.
@@ -12,8 +12,8 @@
"documentation": "https://www.home-assistant.io/integrations/overkiz",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["boto3", "botocore", "pyoverkiz", "s3transfer"],
"requirements": ["pyoverkiz[nexity]==2.0.0"],
"loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"],
"requirements": ["pyoverkiz==1.20.4"],
"zeroconf": [
{
"name": "gateway*",
+1 -1
View File
@@ -213,7 +213,7 @@ async def async_setup_entry(
description,
)
for state in device.definition.states
if (description := SUPPORTED_STATES.get(state))
if (description := SUPPORTED_STATES.get(state.qualified_name))
)
async_add_entities(entities)
+3 -3
View File
@@ -3,7 +3,7 @@
from typing import Any
from pyoverkiz.client import OverkizClient
from pyoverkiz.models import PersistedActionGroup
from pyoverkiz.models import Scenario
from homeassistant.components.scene import Scene
from homeassistant.core import HomeAssistant
@@ -28,7 +28,7 @@ async def async_setup_entry(
class OverkizScene(Scene):
"""Representation of an Overkiz Scene."""
def __init__(self, scenario: PersistedActionGroup, client: OverkizClient) -> None:
def __init__(self, scenario: Scenario, client: OverkizClient) -> None:
"""Initialize the scene."""
self.scenario = scenario
self.client = client
@@ -37,4 +37,4 @@ class OverkizScene(Scene):
async def async_activate(self, **kwargs: Any) -> None:
"""Activate the scene."""
await self.client.execute_persisted_action_group(self.scenario.oid)
await self.client.execute_scenario(self.scenario.oid)
+1 -1
View File
@@ -144,7 +144,7 @@ async def async_setup_entry(
description,
)
for state in device.definition.states
if (description := SUPPORTED_STATES.get(state))
if (description := SUPPORTED_STATES.get(state.qualified_name))
)
async_add_entities(entities)
+3 -3
View File
@@ -550,7 +550,7 @@ async def async_setup_entry(
description,
)
for state in device.definition.states
if (description := SUPPORTED_STATES.get(state))
if (description := SUPPORTED_STATES.get(state.qualified_name))
)
async_add_entities(entities)
@@ -597,12 +597,12 @@ class OverkizStateSensor(OverkizDescriptiveEntity, SensorEntity):
return default_unit
attrs = self.device.attributes
if (unit := attrs.get(f"{state.name}MeasuredValueType")) and (
if (unit := attrs[f"{state.name}MeasuredValueType"]) and (
unit_value := unit.value_as_str
):
return OVERKIZ_UNIT_TO_HA.get(unit_value, default_unit)
if (unit := attrs.get(OverkizAttribute.CORE_MEASURED_VALUE_TYPE)) and (
if (unit := attrs[OverkizAttribute.CORE_MEASURED_VALUE_TYPE]) and (
unit_value := unit.value_as_str
):
ha_unit = OVERKIZ_UNIT_TO_HA.get(unit_value, default_unit)
@@ -48,9 +48,7 @@ class AtlanticDomesticHotWaterProductionV2IOComponent(OverkizEntity, WaterHeater
def min_temp(self) -> float:
"""Return the minimum temperature."""
min_temp = self.device.states.get(
OverkizState.CORE_MINIMAL_TEMPERATURE_MANUAL_MODE
)
min_temp = self.device.states[OverkizState.CORE_MINIMAL_TEMPERATURE_MANUAL_MODE]
if min_temp:
return cast(float, min_temp.value_as_float)
return DEFAULT_MIN_TEMP
@@ -59,9 +57,7 @@ class AtlanticDomesticHotWaterProductionV2IOComponent(OverkizEntity, WaterHeater
def max_temp(self) -> float:
"""Return the maximum temperature."""
max_temp = self.device.states.get(
OverkizState.CORE_MAXIMAL_TEMPERATURE_MANUAL_MODE
)
max_temp = self.device.states[OverkizState.CORE_MAXIMAL_TEMPERATURE_MANUAL_MODE]
if max_temp:
return cast(float, max_temp.value_as_float)
return DEFAULT_MAX_TEMP
@@ -156,9 +156,7 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity):
@property
def min_temp(self) -> float:
"""Return the minimum temperature."""
min_temp = self.device.states.get(
OverkizState.CORE_MINIMAL_TEMPERATURE_MANUAL_MODE
)
min_temp = self.device.states[OverkizState.CORE_MINIMAL_TEMPERATURE_MANUAL_MODE]
if min_temp:
return cast(float, min_temp.value_as_float)
return DEFAULT_MIN_TEMP
@@ -166,9 +164,7 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity):
@property
def max_temp(self) -> float:
"""Return the maximum temperature."""
max_temp = self.device.states.get(
OverkizState.CORE_MAXIMAL_TEMPERATURE_MANUAL_MODE
)
max_temp = self.device.states[OverkizState.CORE_MAXIMAL_TEMPERATURE_MANUAL_MODE]
if max_temp:
return cast(float, max_temp.value_as_float)
return DEFAULT_MAX_TEMP
@@ -176,14 +172,14 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity):
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
current_temperature = self.device.states.get(
current_temperature = self.device.states[
OverkizState.IO_MIDDLE_WATER_TEMPERATURE
)
]
if current_temperature:
return current_temperature.value_as_float
current_temperature = self.device.states.get(
current_temperature = self.device.states[
OverkizState.MODBUSLINK_MIDDLE_WATER_TEMPERATURE
)
]
if current_temperature:
return current_temperature.value_as_float
return None
@@ -192,21 +188,19 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity):
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
target_temperature = self.device.states.get(
target_temperature = self.device.states[
OverkizState.CORE_WATER_TARGET_TEMPERATURE
)
]
if target_temperature:
return target_temperature.value_as_float
target_temperature = self.device.states.get(
target_temperature = self.device.states[
OverkizState.CORE_TARGET_DWH_TEMPERATURE
)
]
if target_temperature:
return target_temperature.value_as_float
target_temperature = self.device.states.get(
OverkizState.CORE_TARGET_TEMPERATURE
)
target_temperature = self.device.states[OverkizState.CORE_TARGET_TEMPERATURE]
if target_temperature:
return target_temperature.value_as_float
@@ -215,9 +209,9 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity):
@property
def target_temperature_high(self) -> float | None:
"""Return the highbound target temperature we try to reach."""
target_temperature_high = self.device.states.get(
target_temperature_high = self.device.states[
OverkizState.CORE_MAXIMAL_TEMPERATURE_MANUAL_MODE
)
]
if target_temperature_high:
return target_temperature_high.value_as_float
return None
@@ -225,9 +219,9 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity):
@property
def target_temperature_low(self) -> float | None:
"""Return the lowbound target temperature we try to reach."""
target_temperature_low = self.device.states.get(
target_temperature_low = self.device.states[
OverkizState.CORE_MINIMAL_TEMPERATURE_MANUAL_MODE
)
]
if target_temperature_low:
return target_temperature_low.value_as_float
return None
@@ -45,7 +45,7 @@ class HitachiDHW(OverkizEntity, WaterHeaterEntity):
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
current_temperature = self.device.states.get(OverkizState.CORE_DHW_TEMPERATURE)
current_temperature = self.device.states[OverkizState.CORE_DHW_TEMPERATURE]
if current_temperature and current_temperature.value_as_int:
return float(current_temperature.value_as_int)
@@ -55,9 +55,9 @@ class HitachiDHW(OverkizEntity, WaterHeaterEntity):
@property
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
target_temperature = self.device.states.get(
target_temperature = self.device.states[
OverkizState.MODBUS_CONTROL_DHW_SETTING_TEMPERATURE
)
]
if target_temperature and target_temperature.value_as_int:
return float(target_temperature.value_as_int)
@@ -74,11 +74,11 @@ class HitachiDHW(OverkizEntity, WaterHeaterEntity):
@property
def current_operation(self) -> str | None:
"""Return current operation ie. eco, electric, performance, ..."""
modbus_control = self.device.states.get(OverkizState.MODBUS_CONTROL_DHW)
modbus_control = self.device.states[OverkizState.MODBUS_CONTROL_DHW]
if modbus_control and modbus_control.value_as_str == OverkizCommandParam.STOP:
return STATE_OFF
current_mode = self.device.states.get(OverkizState.MODBUS_DHW_MODE)
current_mode = self.device.states[OverkizState.MODBUS_DHW_MODE]
if current_mode and current_mode.value_as_str in OVERKIZ_TO_OPERATION_MODE:
return OVERKIZ_TO_OPERATION_MODE[current_mode.value_as_str]
+26 -36
View File
@@ -11,10 +11,8 @@ from homeassistant.components import persistent_notification, websocket_api
from homeassistant.components.device_tracker import (
ATTR_IN_ZONES,
ATTR_SOURCE_TYPE,
ATTR_TRACKING_TYPE,
DOMAIN as DEVICE_TRACKER_DOMAIN,
SourceType,
TrackingType,
)
from homeassistant.components.zone import ENTITY_ID_HOME
from homeassistant.const import (
@@ -462,7 +460,7 @@ class Person(
"""Register device trackers."""
await super().async_added_to_hass()
if state := await self.async_get_last_state():
self._parse_source_state(state)
self._parse_source_state(state, state)
if self.hass.is_running:
# Update person now if hass is already running.
@@ -512,32 +510,39 @@ class Person(
@callback
def _update_state(self) -> None:
"""Update the state."""
latest_connected = latest_legacy_home = latest_not_home = latest_gps = None
latest_non_gps_home = latest_not_home = latest_gps = latest = coordinates = None
for entity_id in self._config[CONF_DEVICE_TRACKERS]:
state = self.hass.states.get(entity_id)
if not state or state.state in IGNORE_STATES:
continue
if state.attributes.get(
ATTR_TRACKING_TYPE
) == TrackingType.CONNECTION and state.attributes.get(ATTR_IN_ZONES):
latest_connected = _get_latest(latest_connected, state)
elif state.attributes.get(ATTR_SOURCE_TYPE) == SourceType.GPS:
if state.attributes.get(ATTR_SOURCE_TYPE) == SourceType.GPS:
latest_gps = _get_latest(latest_gps, state)
elif state.state == STATE_HOME:
# Legacy scanner without tracking type
latest_legacy_home = _get_latest(latest_legacy_home, state)
latest_non_gps_home = _get_latest(latest_non_gps_home, state)
else:
latest_not_home = _get_latest(latest_not_home, state)
# A scanner (e.g. a router or beacon) that reports
# being in a zone is the most reliable presence signal, so it
# takes precedence over everything else.
latest = latest_connected or latest_legacy_home or latest_gps or latest_not_home
if latest_non_gps_home:
latest = latest_non_gps_home
if (
latest_non_gps_home.attributes.get(ATTR_LATITUDE) is None
and latest_non_gps_home.attributes.get(ATTR_LONGITUDE) is None
and (home_zone := self.hass.states.get(ENTITY_ID_HOME))
):
coordinates = home_zone
else:
coordinates = latest_non_gps_home
elif latest_gps:
latest = latest_gps
coordinates = latest_gps
else:
latest = latest_not_home
coordinates = latest_not_home
if latest:
self._parse_source_state(latest)
if latest and coordinates:
self._parse_source_state(latest, coordinates)
else:
self._attr_state = None
self._source = None
@@ -550,33 +555,18 @@ class Person(
self.async_write_ha_state()
@callback
def _parse_source_state(self, state: State) -> None:
def _parse_source_state(self, state: State, coordinates: State) -> None:
"""Parse source state and set person attributes.
This is a device tracker state or the restored person state.
"""
self._attr_state = state.state
self._source = state.entity_id
self._latitude = state.attributes.get(ATTR_LATITUDE)
self._longitude = state.attributes.get(ATTR_LONGITUDE)
self._gps_accuracy = state.attributes.get(ATTR_GPS_ACCURACY)
self._latitude = coordinates.attributes.get(ATTR_LATITUDE)
self._longitude = coordinates.attributes.get(ATTR_LONGITUDE)
self._gps_accuracy = coordinates.attributes.get(ATTR_GPS_ACCURACY)
self._in_zones = state.attributes.get(ATTR_IN_ZONES, [])
# A legacy scanner (one that doesn't report in_zones) reports "home"
# without coordinates. Use the home zone's coordinates for backwards
# compatibility with legacy zone conditions and triggers. Modern
# trackers report in_zones and keep their own (possibly absent)
# coordinates.
if (
ATTR_IN_ZONES not in state.attributes
and state.state == STATE_HOME
and self._latitude is None
and self._longitude is None
and (home_zone := self.hass.states.get(ENTITY_ID_HOME)) is not None
):
self._latitude = home_zone.attributes.get(ATTR_LATITUDE)
self._longitude = home_zone.attributes.get(ATTR_LONGITUDE)
@callback
def _update_extra_state_attributes(self) -> None:
"""Update extra state attributes."""
+2 -2
View File
@@ -155,9 +155,9 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity):
)
@property
def current_temperature(self) -> float | None:
def current_temperature(self) -> float:
"""Return the current temperature."""
return self.device["sensors"].get("temperature")
return self.device["sensors"]["temperature"]
@property
def target_temperature(self) -> float:
@@ -8,6 +8,6 @@
"iot_class": "local_polling",
"loggers": ["plugwise"],
"quality_scale": "platinum",
"requirements": ["plugwise==1.11.4"],
"requirements": ["plugwise==1.11.3"],
"zeroconf": ["_plugwise._tcp.local."]
}
@@ -63,7 +63,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PortainerConfigEntry) ->
api_url=entry.data[CONF_URL],
api_key=entry.data[CONF_API_TOKEN],
session=session,
request_timeout=120,
request_timeout=60,
max_retries=API_MAX_RETRIES,
)
@@ -15,5 +15,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["tesla_powerwall"],
"requirements": ["tesla-powerwall==0.5.3"]
"requirements": ["tesla-powerwall==0.5.2"]
}
@@ -25,7 +25,7 @@ rules:
status: exempt
comment: Integration is polling and does not subscribe to events.
unique-config-entry: done
entity-unique-id: todo
entity-unique-id: done
docs-installation-instructions:
status: todo
comment: |
+3 -16
View File
@@ -7,7 +7,7 @@ from random import uniform
from time import time
from typing import Any
from reolink_aio.api import DUAL_LENS_DUAL_MOTION_MODELS, RETRY_ATTEMPTS
from reolink_aio.api import RETRY_ATTEMPTS
from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError
from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform
@@ -210,19 +210,6 @@ async def async_setup_entry(
connections={(dr.CONNECTION_NETWORK_MAC, host.api.mac_address)},
)
if host.api.is_nvr and host.api.model in DUAL_LENS_DUAL_MOTION_MODELS:
# ensure the camera device is setup before
# the lens sub-devices that use via_device
if host.api.supported(0, "UID"):
camera_dev_id = f"{host.unique_id}_{host.api.camera_uid(0)}"
else:
camera_dev_id = f"{host.unique_id}_ch0"
device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={(DOMAIN, camera_dev_id)},
via_device=(DOMAIN, host.unique_id),
)
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
return True
@@ -436,8 +423,8 @@ def migrate_entity_ids(
device_reg.async_update_device(device.id, new_identifiers=new_identifiers)
break
if ch is None or is_chime or device_uid[1].startswith("lens"):
continue # Do not consider the NVR itself, chimes or lens sub-devices
if ch is None or is_chime:
continue # Do not consider the NVR itself or chimes
# Check for wrongfully added MAC of the NVR/Hub to the camera
# Can be removed in HA 2025.12
@@ -4,6 +4,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from reolink_aio.api import (
DUAL_LENS_DUAL_MOTION_MODELS,
FACE_DETECTION_TYPE,
PACKAGE_DETECTION_TYPE,
PERSON_DETECTION_TYPE,
@@ -70,7 +71,6 @@ BINARY_PUSH_SENSORS = (
key="motion",
cmd_id=33,
device_class=BinarySensorDeviceClass.MOTION,
lens_entity=True,
value=lambda api, ch: api.motion_detected(ch),
supported=lambda api, ch: api.supported(ch, "motion_detection"),
),
@@ -78,7 +78,6 @@ BINARY_PUSH_SENSORS = (
key=FACE_DETECTION_TYPE,
cmd_id=33,
translation_key="face",
lens_entity=True,
value=lambda api, ch: api.ai_detected(ch, FACE_DETECTION_TYPE),
supported=lambda api, ch: api.ai_supported(ch, FACE_DETECTION_TYPE),
),
@@ -86,7 +85,6 @@ BINARY_PUSH_SENSORS = (
key=PERSON_DETECTION_TYPE,
cmd_id=[33, 600, 696],
translation_key="person",
lens_entity=True,
value=lambda api, ch: api.ai_detected(ch, PERSON_DETECTION_TYPE),
supported=lambda api, ch: api.ai_supported(ch, PERSON_DETECTION_TYPE),
),
@@ -94,7 +92,6 @@ BINARY_PUSH_SENSORS = (
key=VEHICLE_DETECTION_TYPE,
cmd_id=[33, 600, 696],
translation_key="vehicle",
lens_entity=True,
value=lambda api, ch: api.ai_detected(ch, VEHICLE_DETECTION_TYPE),
supported=lambda api, ch: api.ai_supported(ch, VEHICLE_DETECTION_TYPE),
),
@@ -102,7 +99,6 @@ BINARY_PUSH_SENSORS = (
key="non-motor_vehicle",
cmd_id=[600, 696],
translation_key="non-motor_vehicle",
lens_entity=True,
value=lambda api, ch: api.ai_detected(ch, "non-motor vehicle"),
supported=lambda api, ch: api.supported(ch, "ai_non-motor vehicle"),
),
@@ -110,7 +106,6 @@ BINARY_PUSH_SENSORS = (
key=PET_DETECTION_TYPE,
cmd_id=[33, 600, 696],
translation_key="pet",
lens_entity=True,
value=lambda api, ch: api.ai_detected(ch, PET_DETECTION_TYPE),
supported=lambda api, ch: (
api.ai_supported(ch, PET_DETECTION_TYPE)
@@ -121,7 +116,6 @@ BINARY_PUSH_SENSORS = (
key=PET_DETECTION_TYPE,
cmd_id=[33, 600, 696],
translation_key="animal",
lens_entity=True,
value=lambda api, ch: api.ai_detected(ch, PET_DETECTION_TYPE),
supported=lambda api, ch: api.supported(ch, "ai_animal"),
),
@@ -129,7 +123,6 @@ BINARY_PUSH_SENSORS = (
key=PACKAGE_DETECTION_TYPE,
cmd_id=[33, 600, 696],
translation_key="package",
lens_entity=True,
value=lambda api, ch: api.ai_detected(ch, PACKAGE_DETECTION_TYPE),
supported=lambda api, ch: api.ai_supported(ch, PACKAGE_DETECTION_TYPE),
),
@@ -362,6 +355,13 @@ class ReolinkBinarySensorEntity(ReolinkChannelCoordinatorEntity, BinarySensorEnt
self.entity_description = entity_description
super().__init__(reolink_data, channel)
if self._host.api.model in DUAL_LENS_DUAL_MOTION_MODELS:
if entity_description.translation_key is not None:
key = entity_description.translation_key
else:
key = entity_description.key
self._attr_translation_key = f"{key}_lens_{self._channel}"
@property
def is_on(self) -> bool:
"""State of the sensor."""

Some files were not shown because too many files have changed in this diff Show More