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
1047 changed files with 40558 additions and 27015 deletions
+6 -6
View File
@@ -38,7 +38,7 @@ jobs:
base_image_version: ${{ env.BASE_IMAGE_VERSION }}
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -102,7 +102,7 @@ jobs:
os: ubuntu-24.04-arm
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -245,7 +245,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -292,7 +292,7 @@ jobs:
contents: read
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -471,7 +471,7 @@ jobs:
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -518,7 +518,7 @@ jobs:
HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -40,7 +40,7 @@ jobs:
timeout-minutes: 10
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
+11 -11
View File
@@ -31,12 +31,12 @@
# - GITHUB_TOKEN
#
# Custom actions used:
# - actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# - actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
# - 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@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
# - 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@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -133,7 +133,7 @@ jobs:
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- name: Checkout .github and .agents folders
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
sparse-checkout: |
@@ -352,7 +352,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -372,7 +372,7 @@ jobs:
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
} >> "$GITHUB_OUTPUT"
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Create gh-aw temp directory
@@ -961,7 +961,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
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@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1127,7 +1127,7 @@ jobs:
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- name: Checkout repository for patch context
if: needs.agent.outputs.has_patch == 'true'
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
# --- Threat Detection ---
@@ -1325,7 +1325,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
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@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
+24 -24
View File
@@ -98,7 +98,7 @@ jobs:
skip_coverage: ${{ steps.info.outputs.skip_coverage }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Generate partial Python venv restore key
@@ -264,7 +264,7 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Register problem matchers
@@ -291,7 +291,7 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Run zizmor
@@ -318,7 +318,7 @@ jobs:
- script/hassfest/docker/Dockerfile
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Register hadolint problem matcher
@@ -341,7 +341,7 @@ jobs:
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python ${{ matrix.python-version }}
@@ -404,7 +404,7 @@ jobs:
echo "version=$(grep '^uv==' requirements.txt | cut -d'=' -f3)" >> "$GITHUB_OUTPUT"
- name: Set up uv
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
version: ${{ steps.read-uv-version.outputs.version }}
- name: Create Python virtual environment
@@ -469,7 +469,7 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install additional OS dependencies
@@ -512,7 +512,7 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
@@ -548,7 +548,7 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
@@ -576,7 +576,7 @@ jobs:
&& github.event_name == 'pull_request'
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Dependency review
@@ -603,7 +603,7 @@ jobs:
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python ${{ matrix.python-version }}
@@ -654,7 +654,7 @@ jobs:
|| github.event.inputs.pylint-only == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
@@ -707,7 +707,7 @@ jobs:
&& (needs.info.outputs.tests_glob || needs.info.outputs.test_full_suite == 'true')
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
@@ -758,7 +758,7 @@ jobs:
|| github.event.inputs.mypy-only == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
@@ -825,7 +825,7 @@ jobs:
- base
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install additional OS dependencies
@@ -889,7 +889,7 @@ jobs:
group: ${{ fromJson(needs.info.outputs.test_groups) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install additional OS dependencies
@@ -1030,7 +1030,7 @@ jobs:
mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install additional OS dependencies
@@ -1179,7 +1179,7 @@ jobs:
postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install additional OS dependencies
@@ -1317,7 +1317,7 @@ jobs:
if: needs.info.outputs.skip_coverage != 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Download all coverage artifacts
@@ -1326,7 +1326,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'true'
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
fail_ci_if_error: true
flags: full-suite
@@ -1355,7 +1355,7 @@ jobs:
group: ${{ fromJson(needs.info.outputs.test_groups) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install additional OS dependencies
@@ -1476,7 +1476,7 @@ jobs:
- pytest-partial
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Download all coverage artifacts
@@ -1485,7 +1485,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'false'
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
@@ -1513,7 +1513,7 @@ jobs:
with:
pattern: test-results-*
- name: Upload test results to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
report_type: test_results
fail_ci_if_error: true
+3 -3
View File
@@ -23,16 +23,16 @@ jobs:
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
category: "/language:python"
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
+5 -5
View File
@@ -29,7 +29,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -116,7 +116,7 @@ jobs:
os: ubuntu-24.04-arm
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -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
@@ -167,7 +167,7 @@ jobs:
os: ubuntu-24.04-arm
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -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
View File
@@ -96,7 +96,6 @@ homeassistant.components.aprs.*
homeassistant.components.apsystems.*
homeassistant.components.aqualogic.*
homeassistant.components.aquostv.*
homeassistant.components.aqvify.*
homeassistant.components.aranet.*
homeassistant.components.arcam_fmj.*
homeassistant.components.arris_tg2492lg.*
Generated
+2 -8
View File
@@ -162,8 +162,6 @@ CLAUDE.md @home-assistant/core
/tests/components/apsystems/ @mawoka-myblock @SonnenladenGmbH
/homeassistant/components/aquacell/ @Jordi1990
/tests/components/aquacell/ @Jordi1990
/homeassistant/components/aqvify/ @astrandb
/tests/components/aqvify/ @astrandb
/homeassistant/components/aranet/ @aschmitz @thecode @anrijs
/tests/components/aranet/ @aschmitz @thecode @anrijs
/homeassistant/components/arcam_fmj/ @elupus
@@ -576,8 +574,8 @@ CLAUDE.md @home-assistant/core
/tests/components/flo/ @dmulcahey
/homeassistant/components/flume/ @ChrisMandich @bdraco @jeeftor
/tests/components/flume/ @ChrisMandich @bdraco @jeeftor
/homeassistant/components/fluss/ @fluss @Marcello17
/tests/components/fluss/ @fluss @Marcello17
/homeassistant/components/fluss/ @fluss
/tests/components/fluss/ @fluss
/homeassistant/components/flux_led/ @icemanch
/tests/components/flux_led/ @icemanch
/homeassistant/components/forecast_solar/ @klaasnicolaas @frenck
@@ -947,8 +945,6 @@ CLAUDE.md @home-assistant/core
/tests/components/kiosker/ @Claeysson
/homeassistant/components/kitchen_sink/ @home-assistant/core
/tests/components/kitchen_sink/ @home-assistant/core
/homeassistant/components/klik_aan_klik_uit/ @Phunkafizer
/tests/components/klik_aan_klik_uit/ @Phunkafizer
/homeassistant/components/kmtronic/ @dgomes
/tests/components/kmtronic/ @dgomes
/homeassistant/components/knocki/ @joostlek @jgatto1 @JakeBosh
@@ -1086,8 +1082,6 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/mediaroom/ @dgomes
/homeassistant/components/melcloud/ @erwindouna
/tests/components/melcloud/ @erwindouna
/homeassistant/components/melcloud_home/ @erwindouna
/tests/components/melcloud_home/ @erwindouna
/homeassistant/components/melissa/ @kennedyshead
/tests/components/melissa/ @kennedyshead
/homeassistant/components/melnor/ @vanstinator
@@ -116,6 +116,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo
async def async_migrate_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool:
"""Migrate old config entry."""
# This means the user has downgraded from a future version
if entry.version > 2:
return False
# 1.1 Migrate config_entry to add advanced ssl settings
if entry.version == 1 and entry.minor_version == 1:
new_minor_version = 2
@@ -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"],
@@ -65,6 +65,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
async def async_migrate_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
"""Migrate old entry."""
if entry.version > 1:
# This means the user has downgraded from a future version
return False
if entry.version == 1 and entry.minor_version < 3:
if CONF_SITE in entry.data:
# Site in data (wrong place), just move to login data
@@ -6,7 +6,7 @@ from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import slugify
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator, alexa_api_call
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
from .entity import AmazonServiceEntity
# Coordinator is used to centralize the data updates
@@ -49,5 +49,4 @@ class AmazonRoutineButton(AmazonServiceEntity, ButtonEntity):
async def async_press(self) -> None:
"""Handle button press action."""
async with alexa_api_call(self.coordinator):
await self.coordinator.api.call_routine(self._routine)
await self.coordinator.api.call_routine(self._routine)
@@ -1,7 +1,5 @@
"""Support for Alexa Devices."""
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from datetime import timedelta
from aioamazondevices.api import AmazonEchoApi
@@ -21,11 +19,7 @@ from aiohttp import ClientSession
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
HomeAssistantError,
)
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -35,65 +29,6 @@ from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN
SCAN_INTERVAL = 300
@asynccontextmanager
async def alexa_api_call(
coordinator: DataUpdateCoordinator | None = None,
) -> AsyncGenerator[None]:
"""Handle common Alexa API exceptions as HomeAssistantError."""
try:
yield
except CannotAuthenticate as err:
if coordinator:
coordinator.last_update_success = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
except CannotConnect as err:
if coordinator:
coordinator.last_update_success = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_connect_with_error",
translation_placeholders={"error": repr(err)},
) from err
except (CannotRetrieveData, ValueError) as err:
if coordinator:
coordinator.last_update_success = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(err)},
) from err
@asynccontextmanager
async def alexa_config_entry_errors() -> AsyncGenerator[None]:
"""Handle common Alexa API exceptions as ConfigEntry errors."""
try:
yield
except CannotAuthenticate as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
except (CannotConnect, TimeoutError) as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect_with_error",
translation_placeholders={"error": repr(err)},
) from err
except (CannotRetrieveData, ValueError, KeyError, StopIteration) as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(err)},
) from err
type AmazonConfigEntry = ConfigEntry[AmazonDevicesCoordinator]
@@ -178,12 +113,6 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
except ValueError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(err)},
) from err
else:
current_devices = set(data.keys())
if stale_devices := self.previous_devices - current_devices:
@@ -240,8 +169,26 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
async def sync_history_state(self) -> None:
"""Sync history state."""
async with alexa_config_entry_errors():
try:
self._vocal_records = await self.api.sync_history_state()
except CannotAuthenticate as e:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(e)},
) from e
except CannotConnect as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect_with_error",
translation_placeholders={"error": repr(e)},
) from e
except BaseException as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(e)},
) from e
async def history_state_event_handler(
self, vocal_records: dict[str, AmazonVocalRecord]
@@ -257,8 +204,26 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
async def sync_media_state(self) -> None:
"""Sync media state."""
async with alexa_config_entry_errors():
try:
await self.api.sync_media_state()
except CannotAuthenticate as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
except (CannotConnect, TimeoutError) as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect_with_error",
translation_placeholders={"error": repr(err)},
) from err
except (CannotRetrieveData, ValueError) as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(err)},
) from err
async def media_state_event_handler(
self, media_state: dict[str, AmazonMediaState]
@@ -12,18 +12,7 @@ from homeassistant.helpers.device_registry import DeviceEntry
from .coordinator import AmazonConfigEntry
TO_REDACT = {
CONF_NAME,
CONF_PASSWORD,
CONF_USERNAME,
"access_token",
"adp_token",
"device_private_key",
"refresh_token",
"store_authentication_cookie",
"title",
"website_cookies",
}
TO_REDACT = {CONF_PASSWORD, CONF_USERNAME, CONF_NAME, "title"}
async def async_get_config_entry_diagnostics(
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==14.0.3"]
"requirements": ["aioamazondevices==14.0.0"]
}
@@ -22,8 +22,9 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import _LOGGER
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator, alexa_api_call
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
from .entity import AmazonEntity
from .utils import alexa_api_call
PARALLEL_UPDATES = 1
@@ -215,15 +216,16 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
provider = media_type.value if isinstance(media_type, MediaType) else media_type
await self.async_call_alexa_music(media_id, provider)
@alexa_api_call
async def async_call_alexa_music(
self, search_phrase: str, provider_id: str
) -> None:
"""Call alexa music."""
async with alexa_api_call(self.coordinator):
await self.coordinator.api.call_alexa_music(
self.device, search_phrase, provider_id
)
await self.coordinator.api.call_alexa_music(
self.device, search_phrase, provider_id
)
@alexa_api_call
async def async_set_device_volume(self, volume: int) -> None:
"""Set the device volume."""
_LOGGER.debug(
@@ -231,8 +233,7 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
self.device.serial_number,
volume,
)
async with alexa_api_call(self.coordinator):
await self.coordinator.api.set_device_volume(self.device, volume)
await self.coordinator.api.set_device_volume(self.device, volume)
async def async_set_volume_level(self, volume: float) -> None:
"""Set the volume level (0.0 to 1.0)."""
@@ -262,12 +263,12 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
await self.async_set_volume_level(target_volume / 100)
self._prev_volume = None
@alexa_api_call
async def _send_media_command(self, command: AmazonMediaControls) -> None:
_LOGGER.debug(
"Sending media command '%s' to %s", command, self.device.serial_number
)
async with alexa_api_call(self.coordinator):
await self.coordinator.api.send_media_command(self.device, command)
await self.coordinator.api.send_media_command(self.device, command)
async def async_media_stop(self) -> None:
"""Send stop command."""
@@ -12,8 +12,9 @@ from homeassistant.components.notify import NotifyEntity, NotifyEntityDescriptio
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AmazonConfigEntry, alexa_api_call
from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity
from .utils import alexa_api_call
PARALLEL_UPDATES = 1
@@ -79,11 +80,10 @@ class AmazonNotifyEntity(AmazonEntity, NotifyEntity):
entity_description: AmazonNotifyEntityDescription
@alexa_api_call
async def async_send_message(
self, message: str, title: str | None = None, **kwargs: Any
) -> None:
"""Send a message."""
async with alexa_api_call(self.coordinator):
await self.entity_description.method(
self.coordinator.api, self.device, message
)
await self.entity_description.method(self.coordinator.api, self.device, message)
@@ -11,7 +11,7 @@ from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, device_registry as dr
from .const import DOMAIN, INFO_SKILLS_MAPPING
from .coordinator import AmazonConfigEntry, alexa_api_call
from .coordinator import AmazonConfigEntry
ATTR_TEXT_COMMAND = "text_command"
ATTR_SOUND = "sound"
@@ -85,15 +85,13 @@ async def _async_execute_action(call: ServiceCall, attribute: str) -> None:
translation_key="invalid_sound_value",
translation_placeholders={"sound": value},
)
async with alexa_api_call():
await coordinator.api.call_alexa_sound(
coordinator.data[device.serial_number], value
)
await coordinator.api.call_alexa_sound(
coordinator.data[device.serial_number], value
)
elif attribute == ATTR_TEXT_COMMAND:
async with alexa_api_call():
await coordinator.api.call_alexa_text_command(
coordinator.data[device.serial_number], value
)
await coordinator.api.call_alexa_text_command(
coordinator.data[device.serial_number], value
)
elif attribute == ATTR_INFO_SKILL:
info_skill = INFO_SKILLS_MAPPING.get(value)
if info_skill not in ALEXA_INFO_SKILLS:
@@ -102,10 +100,9 @@ async def _async_execute_action(call: ServiceCall, attribute: str) -> None:
translation_key="invalid_info_skill_value",
translation_placeholders={"info_skill": value},
)
async with alexa_api_call():
await coordinator.api.call_alexa_info_skill(
coordinator.data[device.serial_number], info_skill
)
await coordinator.api.call_alexa_info_skill(
coordinator.data[device.serial_number], info_skill
)
async def async_send_sound_notification(call: ServiceCall) -> None:
@@ -14,9 +14,13 @@ from homeassistant.components.switch import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AmazonConfigEntry, alexa_api_call
from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity
from .utils import async_remove_dnd_from_virtual_group, async_update_unique_id
from .utils import (
alexa_api_call,
async_remove_dnd_from_virtual_group,
async_update_unique_id,
)
PARALLEL_UPDATES = 1
@@ -86,6 +90,7 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
entity_description: AmazonSwitchEntityDescription
@alexa_api_call
async def _switch_set_state(self, state: bool) -> None:
"""Set desired switch state."""
method = getattr(self.coordinator.api, self.entity_description.method)
@@ -93,8 +98,7 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
if TYPE_CHECKING:
assert method is not None
async with alexa_api_call(self.coordinator):
await method(self.device, state)
await method(self.device, state)
self.coordinator.data[self.device.serial_number].sensors[
self.entity_description.key
].value = state
@@ -1,19 +1,54 @@
"""Utils for Alexa Devices."""
from collections.abc import Awaitable, Callable, Coroutine
from functools import wraps
from typing import Any, Concatenate
from aioamazondevices.const.devices import SPEAKER_GROUP_FAMILY
from aioamazondevices.const.schedules import (
NOTIFICATION_ALARM,
NOTIFICATION_REMINDER,
NOTIFICATION_TIMER,
)
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
import homeassistant.helpers.entity_registry as er
from .const import _LOGGER, DOMAIN
from .coordinator import AmazonDevicesCoordinator
from .entity import AmazonEntity
def alexa_api_call[_T: AmazonEntity, **_P](
func: Callable[Concatenate[_T, _P], Awaitable[None]],
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]:
"""Catch Alexa API call exceptions."""
@wraps(func)
async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None:
"""Wrap all command methods."""
try:
await func(self, *args, **kwargs)
except CannotConnect as err:
self.coordinator.last_update_success = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_connect_with_error",
translation_placeholders={"error": repr(err)},
) from err
except CannotRetrieveData as err:
self.coordinator.last_update_success = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(err)},
) from err
return cmd_wrapper
async def async_update_unique_id(
@@ -75,6 +75,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AnovaConfigEntry) -> b
"""Migrate entry."""
_LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
if entry.version > 1:
# This means the user has downgraded from a future version
return False
if entry.version == 1 and entry.minor_version == 1:
new_data = {**entry.data}
if CONF_DEVICES in new_data:
@@ -178,6 +178,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AnthropicConfigEntry)
"""Migrate entry."""
LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
if entry.version > 2:
# This means the user has downgraded from a future version
return False
if entry.version == 2 and entry.minor_version == 1:
# Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1
device_registry = dr.async_get(hass)
@@ -1,6 +1,7 @@
"""Coordinator for the Anthropic integration."""
import datetime
import re
import anthropic
@@ -19,12 +20,15 @@ UPDATE_INTERVAL_DISCONNECTED = datetime.timedelta(minutes=1)
type AnthropicConfigEntry = ConfigEntry[AnthropicCoordinator]
_model_short_form = re.compile(r"[^\d]-\d$")
@callback
def model_alias(model_id: str) -> str:
"""Resolve alias from versioned model name."""
if model_id[-2:-1] != "-" and not model_id.endswith("-preview"):
model_id = model_id[:-9]
if model_id.endswith("-4"):
if _model_short_form.search(model_id):
return model_id + "-0"
return model_id
@@ -9,5 +9,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["anthropic==0.108.0"]
"requirements": ["anthropic==0.96.0"]
}
@@ -65,18 +65,18 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
]
self._model_list_cache[entry.entry_id] = model_list
family = (
model.removeprefix("claude-")
.removesuffix("-preview")
.translate(str.maketrans("", "", "0123456789-."))
or "haiku"
)
if "opus" in model:
family = "claude-opus"
elif "sonnet" in model:
family = "claude-sonnet"
else:
family = "claude-haiku"
suggested_model = next(
(
model_option["value"]
for model_option in sorted(
(m for m in model_list if f"claude-{family}" in m["value"]),
(m for m in model_list if family in m["value"]),
key=lambda x: x["value"],
reverse=True,
)
+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"
@@ -1,28 +0,0 @@
"""The Aqvify integration."""
import logging
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import AqvifyConfigEntry, AqvifyCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: AqvifyConfigEntry) -> bool:
"""Set up Aqvify from a config entry."""
coordinator = AqvifyCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: AqvifyConfigEntry) -> bool:
"""Unload Aqvify config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -1,113 +0,0 @@
"""Config flow for the Aqvify integration."""
from collections.abc import Mapping
import logging
from typing import Any
from aiohttp import ClientResponseError
from pyaqvify import AqvifyAPI, AqvifyAuthException
import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_RECONFIGURE,
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.const import CONF_API_KEY
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_API_KEY): str,
}
)
class AqvifyConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Aqvify."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
hub = AqvifyAPI(
user_input[CONF_API_KEY],
websession=async_get_clientsession(self.hass),
)
try:
account_data = await hub.async_get_account_id()
except AqvifyAuthException:
errors["base"] = "invalid_auth"
except ClientResponseError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(account_data.account_id)
if self.source == SOURCE_RECONFIGURE:
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(), data_updates=user_input
)
self._abort_if_unique_id_configured()
return self.async_create_entry(title="Aqvify", data=user_input)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
description_placeholders={
"aqvify_url": "https://app.aqvify.com/User",
},
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle re-authentication confirmation."""
errors = {}
if user_input is not None:
api_client = AqvifyAPI(
user_input[CONF_API_KEY],
websession=async_get_clientsession(self.hass),
)
try:
account_data = await api_client.async_get_account_id()
except AqvifyAuthException:
errors["base"] = "invalid_auth"
except ClientResponseError:
errors["base"] = "cannot_connect"
else:
await self.async_set_unique_id(account_data.account_id)
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data_updates=user_input
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)
async def async_step_reconfigure(
self, user_input: Mapping[str, Any] | None = None
) -> ConfigFlowResult:
"""User initiated reconfiguration."""
return await self.async_step_user()
-3
View File
@@ -1,3 +0,0 @@
"""Constants for the Aqvify integration."""
DOMAIN = "aqvify"
@@ -1,137 +0,0 @@
"""Coordinator for Aqvify integration."""
from dataclasses import dataclass
from datetime import timedelta
import logging
from aiohttp import ClientResponseError
from pyaqvify import AqvifyAPI, AqvifyAuthException, AqvifyDeviceData, AqvifyDevices
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
UPDATE_INTERVAL = timedelta(seconds=60)
type AqvifyConfigEntry = ConfigEntry[AqvifyCoordinator]
@dataclass
class AqvifyCoordinatorData:
"""Data class for storing coordinator data."""
devices: AqvifyDevices
device_data: dict[str, AqvifyDeviceData]
class AqvifyCoordinator(DataUpdateCoordinator[AqvifyCoordinatorData]):
"""Data update coordinator for Aqvify devices."""
config_entry: AqvifyConfigEntry
def __init__(self, hass: HomeAssistant, entry: AqvifyConfigEntry) -> None:
"""Initialize the Aqvify data update coordinator."""
super().__init__(
hass,
logger=_LOGGER,
name=DOMAIN,
update_interval=UPDATE_INTERVAL,
config_entry=entry,
)
self.api_client = AqvifyAPI(
entry.data[CONF_API_KEY], websession=async_get_clientsession(hass)
)
async def _async_setup(self) -> None:
"""Set up the coordinator."""
try:
await self.api_client.async_get_account_id()
except AqvifyAuthException:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_api_key",
) from None
except ClientResponseError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={
"entry": self.config_entry.title,
},
) from err
except TimeoutError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="api_timeout",
translation_placeholders={
"entry": self.config_entry.title,
},
) from err
async def _async_update_data(self) -> AqvifyCoordinatorData:
"""Fetch device state."""
try:
devices = await self.api_client.async_get_devices()
except AqvifyAuthException:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_api_key",
) from None
except ClientResponseError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={
"entry": self.config_entry.title,
},
) from err
except TimeoutError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="api_timeout",
translation_placeholders={
"entry": self.config_entry.title,
},
) from err
device_data = {}
for device in devices.devices.values():
try:
device_key = str(device.device_key)
device_data[
device_key
] = await self.api_client.async_get_device_latest_data(device_key)
except AqvifyAuthException:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_api_key",
) from None
except ClientResponseError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={
"entry": self.config_entry.title,
},
) from err
except TimeoutError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="api_timeout",
translation_placeholders={
"entry": self.config_entry.title,
},
) from err
return AqvifyCoordinatorData(
devices=devices,
device_data=device_data,
)
@@ -1,30 +0,0 @@
"""Diagnostics platform for Aqvify integration."""
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from .coordinator import AqvifyConfigEntry
TO_REDACT = [CONF_API_KEY]
TO_REDACT_AQVIFY = ["name"]
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: AqvifyConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
device_list_raw_data = entry.runtime_data.data.devices.raw
device_data_raw_data = {
key: device.raw_data
for key, device in entry.runtime_data.data.device_data.items()
}
return {
"entry_data": async_redact_data(entry.data, TO_REDACT),
"devices": async_redact_data(device_list_raw_data, TO_REDACT_AQVIFY),
"device_data": device_data_raw_data,
}
-35
View File
@@ -1,35 +0,0 @@
"""Defines a base Aqvify entity."""
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import AqvifyCoordinator
class AqvifyBaseEntity(CoordinatorEntity[AqvifyCoordinator]):
"""Defines a base Aqvify entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: AqvifyCoordinator,
description: EntityDescription,
device_key: str,
) -> None:
"""Initialize the Aqvify entity."""
super().__init__(coordinator)
account_id = self.coordinator.config_entry.unique_id
self.device_key = device_key
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{account_id}_{device_key}")},
name=coordinator.data.devices.devices[device_key].name,
manufacturer="Aqvify",
configuration_url="https://app.aqvify.com",
serial_number=device_key,
)
self._attr_unique_id = f"{account_id}_{device_key}_{description.key}"
self.entity_description = description
@@ -1,12 +0,0 @@
{
"entity": {
"sensor": {
"meter_value": {
"default": "mdi:waves-arrow-up"
},
"water_level": {
"default": "mdi:waves"
}
}
}
}
@@ -1,12 +0,0 @@
{
"domain": "aqvify",
"name": "Aqvify",
"codeowners": ["@astrandb"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/aqvify",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pyaqvify"],
"quality_scale": "bronze",
"requirements": ["pyaqvify==0.0.9"]
}
@@ -1,69 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
No actions in this integration.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
The integration does not provide any actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: |
Entities of this integration do not explicitly subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: todo
integration-owner: todo
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: todo
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: done
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo
-79
View File
@@ -1,79 +0,0 @@
"""Sensor platform for Aqvify integration."""
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from pyaqvify import AqvifyDeviceData
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
StateType,
)
from homeassistant.const import UnitOfLength
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AqvifyConfigEntry
from .entity import AqvifyBaseEntity
# Coordinator is used to centralize the data updates.
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class AqvifySensorEntityDescription(SensorEntityDescription):
"""Description of an Aqvify sensor entity."""
value_fn: Callable[[AqvifyDeviceData], float | int | None]
ENTITIES: tuple[AqvifySensorEntityDescription, ...] = (
AqvifySensorEntityDescription(
key="meter_value",
translation_key="meter_value",
native_unit_of_measurement=UnitOfLength.METERS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.DISTANCE,
suggested_display_precision=2,
value_fn=lambda value: value.meter_value,
),
AqvifySensorEntityDescription(
key="water_level",
translation_key="water_level",
native_unit_of_measurement=UnitOfLength.METERS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.DISTANCE,
suggested_display_precision=2,
value_fn=lambda value: value.water_level,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AqvifyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Aqvify sensor entities from a config entry."""
async_add_entities(
AqvifySensor(entry.runtime_data, description, device_key)
for description in ENTITIES
for device_key in entry.runtime_data.data.devices.devices
)
class AqvifySensor(AqvifyBaseEntity, SensorEntity):
"""Representation of an Aqvify sensor entity."""
entity_description: AqvifySensorEntityDescription
@property
def native_value(self) -> StateType | datetime | None:
"""Return the state of the sensor."""
return self.entity_description.value_fn(
self.coordinator.data.device_data[self.device_key]
)
@@ -1,56 +0,0 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "The entered API key corresponds to a different account."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reauth_confirm": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "[%key:component::aqvify::config::step::user::data_description::api_key%]"
},
"description": "Reauthentication required. Please enter your updated API key."
},
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "Your Aqvify API key"
},
"description": "Navigate to your [Aqvify account]({aqvify_url}), copy your API key, and paste it below."
}
}
},
"entity": {
"sensor": {
"meter_value": {
"name": "Meter value"
},
"water_level": {
"name": "Water level"
}
}
},
"exceptions": {
"api_error": {
"message": "An error occurred while communicating with the Aqvify API for {entry}"
},
"api_timeout": {
"message": "Timeout occurred while communicating with the Aqvify API for {entry}"
},
"invalid_api_key": {
"message": "Invalid API key. Please verify your API key and try to reauthenticate."
}
}
}
@@ -1816,11 +1816,6 @@ class PipelineInput:
await self.run.text_to_speech(tts_input)
except PipelineError as err:
if self.run.tts_stream:
# Clean up TTS stream
self.run.tts_stream.delete()
self.run.tts_stream = None
self.run.process_event(
PipelineEvent(
PipelineEventType.ERROR,
@@ -1890,17 +1885,15 @@ class PipelineInput:
):
prepare_tasks.append(self.run.prepare_recognize_intent(self.session))
if prepare_tasks:
await asyncio.gather(*prepare_tasks)
# Do TTS prepare separately so we don't create a ResultStream if the
# pipeline is invalid.
if (
start_stage_index
<= PIPELINE_STAGE_ORDER.index(PipelineStage.TTS)
<= end_stage_index
):
await self.run.prepare_text_to_speech()
prepare_tasks.append(self.run.prepare_text_to_speech())
if prepare_tasks:
await asyncio.gather(*prepare_tasks)
class PipelinePreferred(CollectionError):
@@ -3,11 +3,8 @@
from dataclasses import asdict
import logging
from pathlib import Path
import re
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,
@@ -167,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,
),
}
],
@@ -205,8 +201,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
def has_no_punctuation(value: list[str]) -> list[str]:
"""Validate result does not contain punctuation."""
for sentence in value:
# Exclude {list_references} which may contain punctuation characters.
sentence = _remove_list_references(sentence)
if (
PUNCTUATION_START.search(sentence)
or PUNCTUATION_END.search(sentence)
@@ -218,21 +212,6 @@ def has_no_punctuation(value: list[str]) -> list[str]:
return value
def _remove_list_references(sentence: str) -> str:
"""Remove {list_references} from a sentence for linting."""
return re.sub(r"(?<!\\)\{[^{}]*\}", "", sentence)
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,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/assist_satellite",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.7.0"]
"requirements": ["hassil==3.6.0"]
}
-10
View File
@@ -24,13 +24,3 @@ OPEN_STATUS: dict[int, str] = {
LIGHT_MAX_KELVINS = 6500 # 154 Mireds
LIGHT_MIN_KELVINS = 2700 # 370 Mireds
CO2_LEVEL: dict[int, str] = {
0: "excellent",
1: "good",
2: "acceptable",
3: "medium",
4: "poor",
5: "unhealthy",
6: "hazardous",
}
+3 -3
View File
@@ -90,10 +90,10 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
if feature.has_tilt:
self._attr_supported_features |= (
CoverEntityFeature.OPEN_TILT | CoverEntityFeature.CLOSE_TILT
CoverEntityFeature.SET_TILT_POSITION
| CoverEntityFeature.OPEN_TILT
| CoverEntityFeature.CLOSE_TILT
)
if feature.is_calibrated:
self._attr_supported_features |= CoverEntityFeature.SET_TILT_POSITION
if feature.tilt_only:
self._attr_supported_features &= ~(
@@ -18,12 +18,6 @@
}
},
"sensor": {
"co2_level": {
"default": "mdi:molecule-co2"
},
"open_status": {
"default": "mdi:window-open"
},
"power_consumption": {
"default": "mdi:lightning-bolt"
}
@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["blebox_uniapi"],
"requirements": ["blebox-uniapi==2.5.5"],
"requirements": ["blebox-uniapi==2.5.4"],
"zeroconf": ["_bbxsrv._tcp.local."]
}
+2 -30
View File
@@ -14,7 +14,6 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_MILLION,
LIGHT_LUX,
PERCENTAGE,
UnitOfApparentPower,
@@ -32,7 +31,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import BleBoxConfigEntry
from .const import CO2_LEVEL, OPEN_STATUS
from .const import OPEN_STATUS
from .coordinator import BleBoxCoordinator
from .entity import BleBoxEntity
@@ -51,25 +50,21 @@ SENSOR_TYPES: tuple[BleBoxSensorEntityDescription, ...] = (
key="pm1",
device_class=SensorDeviceClass.PM1,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="pm2_5",
device_class=SensorDeviceClass.PM25,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="pm10",
device_class=SensorDeviceClass.PM10,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="powerConsumption",
@@ -81,88 +76,65 @@ SENSOR_TYPES: tuple[BleBoxSensorEntityDescription, ...] = (
key="humidity",
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="wind",
device_class=SensorDeviceClass.WIND_SPEED,
native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="illuminance",
device_class=SensorDeviceClass.ILLUMINANCE,
native_unit_of_measurement=LIGHT_LUX,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="forwardActiveEnergy",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
),
BleBoxSensorEntityDescription(
key="reverseActiveEnergy",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
),
BleBoxSensorEntityDescription(
key="reactivePower",
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfReactivePower.VOLT_AMPERE_REACTIVE,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="activePower",
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="apparentPower",
device_class=SensorDeviceClass.APPARENT_POWER,
native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="voltage",
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="current",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="frequency",
device_class=SensorDeviceClass.FREQUENCY,
native_unit_of_measurement=UnitOfFrequency.HERTZ,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="openStatus",
translation_key="open_status",
device_class=SensorDeviceClass.ENUM,
icon="mdi:window-open",
options=list(OPEN_STATUS.values()),
value_fn=lambda v: OPEN_STATUS.get(int(v)) if v is not None else None,
),
BleBoxSensorEntityDescription(
key="co2",
device_class=SensorDeviceClass.CO2,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="co2Definition",
translation_key="co2_level",
device_class=SensorDeviceClass.ENUM,
options=list(CO2_LEVEL.values()),
value_fn=lambda v: CO2_LEVEL.get(int(v)) if v is not None else None,
),
)
@@ -37,18 +37,6 @@
},
"entity": {
"sensor": {
"co2_level": {
"name": "Carbon dioxide level",
"state": {
"acceptable": "Acceptable",
"excellent": "Excellent",
"good": "Good",
"hazardous": "Hazardous",
"medium": "Medium",
"poor": "Poor",
"unhealthy": "Unhealthy"
}
},
"open_status": {
"state": {
"ajar": "Ajar",
+4
View File
@@ -169,7 +169,9 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera):
try:
await self._camera.save_recent_clips(output_dir=file_path)
except OSError as err:
# pylint: disable-next=home-assistant-exception-message-with-translation
raise ServiceValidationError(
str(err),
translation_domain=DOMAIN,
translation_key="cant_write",
) from err
@@ -189,7 +191,9 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera):
try:
await self._camera.video_to_file(filename)
except OSError as err:
# pylint: disable-next=home-assistant-exception-message-with-translation
raise ServiceValidationError(
str(err),
translation_domain=DOMAIN,
translation_key="cant_write",
) from err
+1 -1
View File
@@ -54,7 +54,7 @@
},
"exceptions": {
"cant_write": {
"message": "Can't write to file, check logs for details."
"message": "Can't write to file."
},
"failed_arm": {
"message": "Blink failed to arm camera."
+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"
]
}
@@ -230,6 +230,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) ->
entry.minor_version,
)
if entry.version > 1:
# Downgraded from a future version; cannot migrate.
return False
# 1.1 -> 1.2: Add CONF_HEATING_CIRCUITS. Attempt to discover available
# heating circuits from the device; fall back to [1] (pre-multi-circuit
# default) if the device is unreachable or the endpoint is unsupported.
@@ -183,6 +183,7 @@ class BSBLANClimate(BSBLanCircuitEntity, ClimateEntity):
try:
await self.coordinator.client.thermostat(**data, circuit=self._circuit)
except BSBLANError as err:
# pylint: disable-next=home-assistant-exception-message-with-translation
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_data_error",
@@ -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."]
}
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/chacon_dio",
"iot_class": "cloud_push",
"loggers": ["dio_chacon_api"],
"requirements": ["dio-chacon-wifi-api==1.3.0"]
"requirements": ["dio-chacon-wifi-api==1.2.2"]
}
@@ -87,6 +87,10 @@ async def async_migrate_entry(
) -> bool:
"""Migrate old entry."""
if config_entry.version > 1:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1 and config_entry.minor_version == 1:
device_registry = dr.async_get(hass)
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.7.0", "home-assistant-intents==2026.6.1"]
"requirements": ["hassil==3.6.0", "home-assistant-intents==2026.6.1"]
}
@@ -1,11 +1,8 @@
"""Offer sentence based automation rules."""
from collections.abc import Awaitable, Callable
import re
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,
@@ -34,8 +31,6 @@ TRIGGER_CALLBACK_TYPE = Callable[
def has_no_punctuation(value: list[str]) -> list[str]:
"""Validate result does not contain punctuation."""
for sentence in value:
# Exclude {list_references} which may contain punctuation characters.
sentence = _remove_list_references(sentence)
if (
PUNCTUATION_START.search(sentence)
or PUNCTUATION_END.search(sentence)
@@ -47,21 +42,6 @@ def has_no_punctuation(value: list[str]) -> list[str]:
return value
def _remove_list_references(sentence: str) -> str:
"""Remove {list_references} from a sentence for linting."""
return re.sub(r"(?<!\\)\{[^{}]*\}", "", sentence)
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:
@@ -78,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."]
}
@@ -5,7 +5,7 @@ import logging
from typing import Any
from aiohttp import ClientError, ClientResponseError
from data_grand_lyon_ha import DataGrandLyonClient, TclStop, find_tcl_stop_by_id
from data_grand_lyon_ha import DataGrandLyonClient
import voluptuous as vol
from homeassistant.config_entries import (
@@ -18,12 +18,6 @@ from homeassistant.config_entries import (
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import (
CONF_LINE,
@@ -49,6 +43,13 @@ STEP_RECONFIGURE_SCHEMA = vol.Schema(
}
)
STEP_STOP_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_LINE): str,
vol.Required(CONF_STOP_ID): vol.Coerce(int),
}
)
STEP_VELOV_STATION_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_STATION_ID): vol.Coerce(int),
@@ -178,126 +179,33 @@ class DataGrandLyonConfigFlow(ConfigFlow, domain=DOMAIN):
class StopSubentryFlowHandler(ConfigSubentryFlow):
"""Handle a subentry flow for adding a Data Grand Lyon stop."""
def __init__(self) -> None:
"""Initialize the flow."""
self._stops: list[TclStop] = []
self._selected_stop: TclStop | None = None
self._selected_stop_id: int | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Pick a stop from the list fetched from the API, or enter one manually."""
if not self._stops:
if error := await self._async_load_stops():
return self.async_abort(reason=error)
"""Handle the user step to add a new stop."""
entry = self._get_entry()
errors: dict[str, str] = {}
if user_input is not None:
try:
stop_id = int(user_input[CONF_STOP_ID])
except ValueError:
errors[CONF_STOP_ID] = "invalid_stop_id"
else:
self._selected_stop_id = stop_id
self._selected_stop = find_tcl_stop_by_id(self._stops, stop_id)
return await self.async_step_pick_line()
line = user_input[CONF_LINE]
stop_id = user_input[CONF_STOP_ID]
unique_id = f"{line}_{stop_id}"
options = [
SelectOptionDict(value=str(stop.id), label=_stop_label(stop))
for stop in sorted(
self._stops, key=lambda s: (s.nom, s.commune or "", s.id or 0)
for subentry in entry.subentries.values():
if subentry.unique_id == unique_id:
return self.async_abort(reason="already_configured")
name = f"{line} - Stop {stop_id}"
return self.async_create_entry(
title=name,
data={CONF_LINE: line, CONF_STOP_ID: stop_id},
unique_id=unique_id,
)
]
schema = vol.Schema(
{
vol.Required(CONF_STOP_ID): SelectSelector(
SelectSelectorConfig(
options=options,
mode=SelectSelectorMode.DROPDOWN,
sort=False,
custom_value=True,
)
)
}
)
return self.async_show_form(
step_id="user",
data_schema=schema,
errors=errors,
data_schema=STEP_STOP_DATA_SCHEMA,
)
async def async_step_pick_line(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Pick a line from the selected stop's desserte, or enter one manually."""
assert self._selected_stop_id is not None
if user_input is not None:
return self._create_stop(
line=user_input[CONF_LINE], stop_id=self._selected_stop_id
)
options = self._selected_stop.desserte if self._selected_stop else []
schema = vol.Schema(
{
vol.Required(CONF_LINE): SelectSelector(
SelectSelectorConfig(
options=options,
mode=SelectSelectorMode.DROPDOWN,
custom_value=True,
)
)
}
)
return self.async_show_form(step_id="pick_line", data_schema=schema)
async def _async_load_stops(self) -> str | None:
"""Fetch TCL stops from the API, returning an error key on failure."""
entry = self._get_entry()
session = async_get_clientsession(self.hass)
client = DataGrandLyonClient(
session=session,
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
)
try:
self._stops = await client.get_tcl_stops()
except ClientResponseError as err:
if err.status in (401, 403):
return "invalid_auth"
return "cannot_connect"
except ClientError, TimeoutError:
return "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected error fetching Data Grand Lyon TCL stops")
return "unknown"
return None
def _create_stop(self, line: str, stop_id: int) -> SubentryFlowResult:
"""Create the stop subentry, aborting on duplicate."""
entry = self._get_entry()
unique_id = f"{line}_{stop_id}"
for subentry in entry.subentries.values():
if subentry.unique_id == unique_id:
return self.async_abort(reason="already_configured")
return self.async_create_entry(
title=f"{line} - Stop {stop_id}",
data={CONF_LINE: line, CONF_STOP_ID: stop_id},
unique_id=unique_id,
)
def _stop_label(stop: TclStop) -> str:
label = stop.nom
# variable extracted to please codespell.
address = stop.adresse # codespell:ignore adresse
if address or stop.commune:
label += " (" + ", ".join(filter(None, [address, stop.commune])) + ")"
label += f" - {stop.id}"
return label
class VelovStationSubentryFlowHandler(ConfigSubentryFlow):
"""Handle a subentry flow for adding a Vélo'v station."""
@@ -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"]
}
@@ -46,30 +46,17 @@
"config_subentries": {
"stop": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
},
"entry_type": "Transit stop",
"error": {
"invalid_stop_id": "Stop ID must be a number."
},
"initiate_flow": {
"user": "Add transit stop"
},
"step": {
"pick_line": {
"data": {
"line": "Line"
}
},
"user": {
"data": {
"stop_id": "Stop"
},
"data_description": {
"stop_id": "Search by stop name, address or city, or enter a stop ID directly."
"line": "Line",
"stop_id": "Stop ID"
}
}
}
+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:
@@ -54,6 +54,10 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
config_entry.minor_version,
)
if config_entry.version > 1:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1:
if config_entry.minor_version < 2:
new_options = {**config_entry.options}
@@ -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"
}
}
}
}
+6 -2
View File
@@ -51,7 +51,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> boo
tcp_port=entry.options[CONF_PORT],
udp_port=entry.options[CONF_PORT],
)
queries.append(resolver_ipv4.query_dns(hostname, "A"))
queries.append(resolver_ipv4.query(hostname, "A"))
if entry.data[CONF_IPV6]:
resolver_ipv6 = aiodns.DNSResolver(
@@ -59,7 +59,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> boo
tcp_port=entry.options[CONF_PORT_IPV6],
udp_port=entry.options[CONF_PORT_IPV6],
)
queries.append(resolver_ipv6.query_dns(hostname, "AAAA"))
queries.append(resolver_ipv6.query(hostname, "AAAA"))
async def _close_resolvers() -> None:
if resolver_ipv4 is not None:
@@ -111,6 +111,10 @@ async def async_migrate_entry(
) -> bool:
"""Migrate old entry to a newer version."""
if config_entry.version > 1:
# This means the user has downgraded from a future version
return False
if config_entry.version < 2 and config_entry.minor_version < 2:
_LOGGER.debug(
"Migrating configuration from version %s.%s",
@@ -72,7 +72,7 @@ async def async_validate_hostname(
_resolver = aiodns.DNSResolver(
nameservers=[resolver], udp_port=port, tcp_port=port
)
result = bool(await _resolver.query_dns(hostname, qtype))
result = bool(await _resolver.query(hostname, qtype))
return result
+4 -15
View File
@@ -8,7 +8,6 @@ from typing import TYPE_CHECKING, Literal
import aiodns
from aiodns.error import DNSError
import pycares
from homeassistant.components.sensor import SensorEntity
from homeassistant.const import CONF_NAME, CONF_PORT
@@ -149,7 +148,7 @@ class WanIpSensor(SensorEntity):
response = None
try:
async with asyncio.timeout(10):
response = await self._resolver.query_dns(self.hostname, self.querytype)
response = await self._resolver.query(self.hostname, self.querytype)
except TimeoutError as err:
_LOGGER.debug("Timeout while resolving host: %s", err)
await self._resolver.close()
@@ -158,19 +157,9 @@ class WanIpSensor(SensorEntity):
await self._resolver.close()
if response:
if TYPE_CHECKING:
assert all(
isinstance(res.data, (pycares.ARecordData, pycares.AAAARecordData))
for res in response.answer
)
_ips = []
for res in response.answer:
if TYPE_CHECKING:
assert isinstance(
res.data, (pycares.ARecordData, pycares.AAAARecordData)
)
_ips.append(res.data.addr)
sorted_ips = sort_ips(_ips, querytype=self.querytype)
sorted_ips = sort_ips(
[res.host for res in response], querytype=self.querytype
)
self._attr_native_value = sorted_ips[0]
self._attr_extra_state_attributes["ip_addresses"] = sorted_ips
self._attr_available = True
+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
View File
@@ -7,4 +7,3 @@ from homeassistant.const import Platform
DOMAIN = "duco"
PLATFORMS = [Platform.FAN, Platform.SENSOR]
SCAN_INTERVAL = timedelta(seconds=10)
BOX_NODE_ID = 1
+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
+2 -8
View File
@@ -24,7 +24,7 @@ from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import BOX_NODE_ID, DOMAIN
from .const import DOMAIN
from .coordinator import DucoConfigEntry, DucoCoordinator
from .entity import DucoEntity
@@ -158,13 +158,7 @@ async def async_setup_entry(
# The firmware removes deregistered RF/wired nodes automatically.
# BSRH box sensors that are physically unplugged from the PCB are
# not deregistered by the firmware and will never appear here as stale.
# The BOX node can transiently disappear from the API response, so keep
# node 1 to avoid removing the main controller device.
stale_node_ids = {
node_id
for node_id in known_nodes - coordinator.data.nodes.keys()
if node_id != BOX_NODE_ID
}
stale_node_ids = known_nodes - coordinator.data.nodes.keys()
if stale_node_ids:
device_reg = dr.async_get(hass)
mac = entry.unique_id
+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
@@ -10,7 +10,7 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pyecobee"],
"requirements": ["python-ecobee-api==0.4.1"],
"requirements": ["python-ecobee-api==0.4.0"],
"single_config_entry": true,
"zeroconf": [
{
+1 -1
View File
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["sml"],
"requirements": ["pysml==0.1.8"]
"requirements": ["pysml==0.1.7"]
}
@@ -98,6 +98,10 @@ async def async_migrate_entry(
config_entry.minor_version,
)
if config_entry.version > 1:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1:
new_options = {**config_entry.options}
@@ -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(
@@ -8,25 +8,9 @@ from env_canada import ECAirQuality, ECMap, ECWeather
from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_RADAR_LAYER,
CONF_RADAR_LEGEND,
CONF_RADAR_OPACITY,
CONF_RADAR_RADIUS,
CONF_RADAR_TIMESTAMP,
CONF_STATION,
DEFAULT_RADAR_LAYER,
DEFAULT_RADAR_LEGEND,
DEFAULT_RADAR_OPACITY,
DEFAULT_RADAR_RADIUS,
DEFAULT_RADAR_TIMESTAMP,
DOMAIN,
)
from .const import CONF_STATION
from .coordinator import ECConfigEntry, ECDataUpdateCoordinator, ECRuntimeData
from .services import async_setup_services
DEFAULT_RADAR_UPDATE_INTERVAL = timedelta(minutes=5)
DEFAULT_WEATHER_UPDATE_INTERVAL = timedelta(minutes=5)
@@ -35,14 +19,6 @@ PLATFORMS = [Platform.CAMERA, Platform.SENSOR, Platform.WEATHER]
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Environment Canada services."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) -> bool:
"""Set up EC as config entry."""
@@ -67,15 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) ->
errors = errors + 1
_LOGGER.warning("Unable to retrieve Environment Canada weather")
options = config_entry.options
radar_data = ECMap(
coordinates=(lat, lon),
layer=options.get(CONF_RADAR_LAYER, DEFAULT_RADAR_LAYER),
legend=options.get(CONF_RADAR_LEGEND, DEFAULT_RADAR_LEGEND),
timestamp=options.get(CONF_RADAR_TIMESTAMP, DEFAULT_RADAR_TIMESTAMP),
layer_opacity=int(options.get(CONF_RADAR_OPACITY, DEFAULT_RADAR_OPACITY)),
radius=int(options.get(CONF_RADAR_RADIUS, DEFAULT_RADAR_RADIUS)),
)
radar_data = ECMap(coordinates=(lat, lon), layer="precip_type", legend=False)
radar_coordinator = ECDataUpdateCoordinator(
hass, config_entry, radar_data, "radar", DEFAULT_RADAR_UPDATE_INTERVAL
)
@@ -9,42 +9,17 @@ from env_canada import ECWeather, ec_exc
from env_canada.ec_weather import get_ec_sites_list
import voluptuous as vol
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlowWithReload,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import (
BooleanSelector,
NumberSelector,
NumberSelectorConfig,
NumberSelectorMode,
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import (
CONF_RADAR_LAYER,
CONF_RADAR_LEGEND,
CONF_RADAR_OPACITY,
CONF_RADAR_RADIUS,
CONF_RADAR_TIMESTAMP,
CONF_STATION,
CONF_TITLE,
DEFAULT_RADAR_LAYER,
DEFAULT_RADAR_LEGEND,
DEFAULT_RADAR_OPACITY,
DEFAULT_RADAR_RADIUS,
DEFAULT_RADAR_TIMESTAMP,
DOMAIN,
RADAR_LAYERS,
)
from .const import CONF_STATION, CONF_TITLE, DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -82,14 +57,6 @@ class EnvironmentCanadaConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
_station_codes: list[dict[str, str]] | None = None
@staticmethod
@callback
def async_get_options_flow(
config_entry: ConfigEntry,
) -> OptionsFlowHandler:
"""Return the options flow handler."""
return OptionsFlowHandler()
async def _get_station_codes(self) -> list[dict[str, str]]:
"""Get station codes, cached after first call."""
if self._station_codes is None:
@@ -160,55 +127,3 @@ class EnvironmentCanadaConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user", data_schema=data_schema, errors=errors
)
class OptionsFlowHandler(OptionsFlowWithReload):
"""Handle Environment Canada radar camera options."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the radar camera options."""
if user_input is not None:
return self.async_create_entry(data=user_input)
options = self.config_entry.options
data_schema = vol.Schema(
{
vol.Required(
CONF_RADAR_LAYER,
default=options.get(CONF_RADAR_LAYER, DEFAULT_RADAR_LAYER),
): SelectSelector(
SelectSelectorConfig(
options=RADAR_LAYERS,
translation_key="radar_layer",
)
),
vol.Required(
CONF_RADAR_LEGEND,
default=options.get(CONF_RADAR_LEGEND, DEFAULT_RADAR_LEGEND),
): BooleanSelector(),
vol.Required(
CONF_RADAR_TIMESTAMP,
default=options.get(CONF_RADAR_TIMESTAMP, DEFAULT_RADAR_TIMESTAMP),
): BooleanSelector(),
vol.Required(
CONF_RADAR_OPACITY,
default=options.get(CONF_RADAR_OPACITY, DEFAULT_RADAR_OPACITY),
): NumberSelector(
NumberSelectorConfig(
min=0, max=100, step=1, mode=NumberSelectorMode.SLIDER
)
),
vol.Required(
CONF_RADAR_RADIUS,
default=options.get(CONF_RADAR_RADIUS, DEFAULT_RADAR_RADIUS),
): NumberSelector(
NumberSelectorConfig(
min=10, max=2000, step=10, unit_of_measurement="km"
)
),
}
)
return self.async_show_form(step_id="init", data_schema=data_schema)
@@ -6,19 +6,3 @@ CONF_STATION = "station"
CONF_TITLE = "title"
DOMAIN = "environment_canada"
SERVICE_ENVIRONMENT_CANADA_FORECASTS = "get_forecasts"
CONF_RADAR_LAYER = "radar_layer"
CONF_RADAR_LEGEND = "radar_legend"
CONF_RADAR_TIMESTAMP = "radar_timestamp"
CONF_RADAR_OPACITY = "radar_opacity"
CONF_RADAR_RADIUS = "radar_radius"
RADAR_LAYERS = ["rain", "snow", "precip_type"]
# Defaults preserve the radar behaviour from before the options flow existed:
# the precipitation-type layer with the legend hidden.
DEFAULT_RADAR_LAYER = "precip_type"
DEFAULT_RADAR_LEGEND = False
DEFAULT_RADAR_TIMESTAMP = True
DEFAULT_RADAR_OPACITY = 65
DEFAULT_RADAR_RADIUS = 200
@@ -19,9 +19,6 @@
}
},
"services": {
"get_alerts": {
"service": "mdi:bell-alert"
},
"get_forecasts": {
"service": "mdi:weather-cloudy-clock"
},
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["env_canada"],
"requirements": ["env-canada==0.15.0"]
"requirements": ["env-canada==0.13.2"]
}
@@ -1,56 +0,0 @@
"""Define services for the Environment Canada integration."""
from typing import Any
from env_canada import ECWeather
import voluptuous as vol
from homeassistant.const import ATTR_CONFIG_ENTRY_ID
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, service
from .const import DOMAIN
SERVICE_GET_ALERTS = "get_alerts"
SERVICE_GET_ALERTS_SCHEMA = vol.Schema({vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string})
SNAKE_MAPPING = {
"alertColourLevel": "alert_colour_level",
"expiryTime": "expiry_time",
}
async def _async_get_alerts(call: ServiceCall) -> dict[str, Any]:
"""Return the active alerts."""
entry = service.async_get_config_entry(
call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID]
)
ec: ECWeather | None = entry.runtime_data.weather_coordinator.ec_data
if ec is None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="not_connected",
)
data: dict[str, Any] = ec.alerts
return {
k: [
{SNAKE_MAPPING.get(ik, ik): iv for ik, iv in item.items()}
for item in v["value"]
]
for k, v in data.items()
}
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up the services for the Environment Canada integration."""
hass.services.async_register(
DOMAIN,
SERVICE_GET_ALERTS,
_async_get_alerts,
schema=SERVICE_GET_ALERTS_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
@@ -1,11 +1,3 @@
get_alerts:
fields:
config_entry_id:
required: true
selector:
config_entry:
integration: environment_canada
get_forecasts:
target:
entity:
@@ -112,49 +112,7 @@
}
}
},
"exceptions": {
"not_connected": {
"message": "Environment Canada is not connected"
}
},
"options": {
"step": {
"init": {
"data": {
"radar_layer": "Radar type",
"radar_legend": "Show legend",
"radar_opacity": "Radar opacity",
"radar_radius": "Map radius",
"radar_timestamp": "Show timestamp"
},
"data_description": {
"radar_opacity": "Opacity of the radar layer overlay (0-100)",
"radar_radius": "Radius of the radar map in kilometres"
},
"title": "Radar camera options"
}
}
},
"selector": {
"radar_layer": {
"options": {
"precip_type": "Precipitation type",
"rain": "Rain",
"snow": "Snow"
}
}
},
"services": {
"get_alerts": {
"description": "Retrieves the alerts from the selected weather service.",
"fields": {
"config_entry_id": {
"description": "The Environment Canada service to retrieve alerts from.",
"name": "Environment Canada service"
}
},
"name": "Get alerts"
},
"get_forecasts": {
"description": "Retrieves the forecast from selected weather services.",
"name": "Get forecasts"
@@ -88,6 +88,10 @@ async def async_migrate_entry(
config_entry.minor_version,
)
if config_entry.version > 1 or config_entry.minor_version > 1:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1 and config_entry.minor_version == 1:
new_data = {**config_entry.data}
new_data[CONF_CONNECTION_TYPE] = HTTP
@@ -27,7 +27,6 @@ from epson_projector.const import (
)
from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
@@ -63,7 +62,6 @@ class EpsonProjectorMediaPlayer(MediaPlayerEntity):
_attr_has_entity_name = True
_attr_name = None
_attr_device_class = MediaPlayerDeviceClass.PROJECTOR
_attr_supported_features = (
MediaPlayerEntityFeature.TURN_ON
@@ -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(
@@ -53,6 +53,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Migrate config entry."""
if config_entry.version > 2:
# Downgraded from future
return False
if config_entry.version < 2:
# Move optional fields from data to options in config entry
-2
View File
@@ -8,8 +8,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import FlussApiClientError, FlussConfigEntry
from .entity import FlussEntity
PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
+1 -1
View File
@@ -15,7 +15,7 @@ from .const import DOMAIN
from .coordinator import FlussApiClientError, FlussConfigEntry
from .entity import FlussEntity
PARALLEL_UPDATES = 1
PARALLEL_UPDATES = 0
STATUS_OPEN = "Open"
STATUS_CLOSED = "Closed"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"domain": "fluss",
"name": "Fluss+",
"codeowners": ["@fluss", "@Marcello17"],
"codeowners": ["@fluss"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/fluss",
"iot_class": "cloud_polling",

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