mirror of
https://github.com/home-assistant/core.git
synced 2026-09-25 17:04:04 -04:00
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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
932b54cc0e
commit
5b0a73ebb5
@@ -65,7 +65,7 @@ make the call. First match wins:
|
||||
|
||||
1. `integration_type == "system"` → **main** (part of the HA runtime; sandboxing is meaningless).
|
||||
2. `domain ∈ ALWAYS_MAIN` → **main** (hand-picked deny-list, each with an inline "why").
|
||||
3. Any platform in `SANDBOX_INCOMPATIBLE_PLATFORMS` → **main** (`stt`, `tts`, `conversation`, `assist_satellite`, `wake_word`, `camera` — audio/byte streams the channel can't ferry).
|
||||
3. Any platform in `SANDBOX_INCOMPATIBLE_PLATFORMS` → **main** (`stt`, `tts`, `conversation`, `assist_satellite`, `wake_word`, `camera` — audio/byte streams the channel can't ferry; plus `todo`, whose To-do panel reads the sync `todo_items` property that also feeds `state`, so it needs a pushed item-list cache the bridge doesn't have yet — see [`docs/query-shaped-rpcs.md`](docs/query-shaped-rpcs.md)).
|
||||
4. Custom (non-built-in) integration → group **`custom`**.
|
||||
5. Otherwise → group **`built-in`**.
|
||||
|
||||
@@ -215,7 +215,7 @@ re-send it and main refreshes the existing proxy in place (no duplicate).
|
||||
Proxy `unique_id`s are prefixed with the source domain (`<domain>:<unique_id>`)
|
||||
so two integrations in one group can't collide.
|
||||
|
||||
On main, `SandboxBridge` instantiates a domain-typed proxy (all **32** domains
|
||||
On main, `SandboxBridge` instantiates a domain-typed proxy (all **31** domains
|
||||
have one under `entity/`) and attaches it via the
|
||||
`EntityComponent.async_register_remote_platform` core hook. Each outbound proxy
|
||||
call sends one RPC; coalescing same-tick calls into a single multi-entity RPC
|
||||
@@ -354,7 +354,7 @@ waits on the websocket transport). See
|
||||
- **State-sharing opt-in consumer** + main-side filtering ([`docs/design-share-states.md`](docs/design-share-states.md)); would let the lockdown helpers (§3) return to sandboxes.
|
||||
- **Cross-sandbox in-process dependencies** — ESPHome serial / BLE proxy, and IR/RF command flows, where one integration depends on another's in-process surface across a sandbox boundary.
|
||||
- **`Context` group attribute** (§10) — a core `Context` field naming which sandbox group originated an action, a richer audit answer than today's `user_id=None`. Context restoration from seen ids, dropping the unused token, and removing the per-group system user all **shipped** (`plans/plan-auth-context.md`); the wire still carries `context_id` only, so the sandbox can never fabricate attribution.
|
||||
- **Query-shaped RPCs** for `calendar` / `todo` / `weather` server-side queries.
|
||||
- **Query-shaped RPCs** for the server-side query / subscribe / WS-only-mutation entity APIs the fire-and-forget bridge can't express — `calendar` (listings, event subscribe/update/delete), `todo` (item list/subscribe/move), `weather` (forecasts), `media_player` (browse/search), `update` (release notes), `vacuum` (segments). They currently raise `HomeAssistantError` instead of silently returning empty. Full catalogue + the two missing primitives (request/response + subscription) in [`docs/query-shaped-rpcs.md`](docs/query-shaped-rpcs.md).
|
||||
- **pip/egress validation** for custom-integration dependencies in the container (§7).
|
||||
|
||||
---
|
||||
|
||||
+12
-5
@@ -154,11 +154,18 @@ took the codebase from Phase 11 to Phase 17. What's still open:
|
||||
--snapshot-update` per integration) and ~70 `created_at` snapshot
|
||||
drifts (fix is integration-side freezegun, or an optional Phase
|
||||
17b clock-pinning fixture on the compat plugin — ~30 LOC).
|
||||
- **`calendar` / `todo` / `weather` query-shaped RPCs.** The Phase
|
||||
13 proxies return empty lists for `async_get_events`, `todo_items`,
|
||||
and `weather.async_forecast_*` because the action-call channel
|
||||
can't express server-side queries. Add a query-shaped RPC if the
|
||||
compat sweep ever surfaces an integration that needs them.
|
||||
- **Query-shaped RPCs (calendar / weather / media_player / update /
|
||||
vacuum).** The fire-and-forget action-call channel can't express
|
||||
server-side queries, subscriptions, or WS-only mutations, so these
|
||||
proxy APIs now **raise `HomeAssistantError`** (via
|
||||
`entity.raise_not_proxied`) instead of silently returning empty.
|
||||
`todo` is a special case: its To-do panel reads the sync `todo_items`
|
||||
property (which feeds `state`), so it can't be a query at all — it's in
|
||||
`SANDBOX_INCOMPATIBLE_PLATFORMS` (routed to main, no proxy) until a
|
||||
push primitive lands. Full catalogue, interim behaviour, and the two
|
||||
missing primitives (request/response + subscription RPC) in
|
||||
[`docs/query-shaped-rpcs.md`](docs/query-shaped-rpcs.md). Planned
|
||||
design: [`plans/plan-query-rpc.md`](plans/plan-query-rpc.md).
|
||||
- **Non-idempotent service handlers** (`ai_task`, `image`).
|
||||
`ALWAYS_MAIN` punt for the sandbox; a future spec on service-handler-level
|
||||
interception or sandbox-aware integration hooks is the long-term
|
||||
|
||||
+23
-7
@@ -614,13 +614,29 @@ the phase that resolved each one.
|
||||
v1 (`sandbox/` + `homeassistant/components/sandbox/` +
|
||||
`tests/components/sandbox/`) was removed ahead of the "shipped a stable
|
||||
release" condition, relying on git history for rollback.
|
||||
- **`calendar` / `todo` / `weather` query-shaped RPCs.** `async_get_events`
|
||||
(calendar), `todo_items` (todo), and `weather.async_forecast_*`
|
||||
return server-side query results the action-call channel can't
|
||||
express. The Phase 13 proxies return empty lists for these; a
|
||||
separate query-shaped RPC is owed if the compat sweep ever surfaces
|
||||
an integration that depends on these surfaces (it hasn't yet — see
|
||||
[`BACKLOG.md`](BACKLOG.md)).
|
||||
- **Query-shaped RPCs** (`calendar` / `weather` / `media_player` /
|
||||
`update` / `vacuum`). Server-side query, subscription, and WS-only
|
||||
mutation APIs the fire-and-forget action-call channel can't express
|
||||
(`async_get_events`, `async_forecast_*`, `async_browse_media` /
|
||||
`async_search_media`, `async_release_notes`, `async_get_segments`,
|
||||
calendar event update/delete). These proxies now **raise
|
||||
`HomeAssistantError`** via `entity.raise_not_proxied` instead of
|
||||
returning empty. The catalogue + the planned request/response design
|
||||
(a generic `EntityQuery` RPC for the service-less ops, reusing the
|
||||
existing `call_service` `return_response` path for the rest) live in
|
||||
[`docs/query-shaped-rpcs.md`](docs/query-shaped-rpcs.md) /
|
||||
[`plans/plan-query-rpc.md`](plans/plan-query-rpc.md). Subscriptions
|
||||
(`weather/subscribe_forecast`, `calendar/event/subscribe`) are
|
||||
deferred. `todo` is now in `SANDBOX_INCOMPATIBLE_PLATFORMS` (routed to
|
||||
main) because its To-do panel reads the sync `todo_items` property
|
||||
that feeds `state` — it needs a pushed item-list cache, not a query.
|
||||
**Caveat (`media_player.browse_media`):** even once wired, a sandboxed
|
||||
player's browse will surface only its **own** sources — the
|
||||
`media_source` tree it normally merges via
|
||||
`media_source.async_browse_media(self.hass, …)` is empty inside the
|
||||
sandbox, because `media_source` runs on main, outside the boundary.
|
||||
Not a bug; closing it needs a cross-boundary hook, pairing with the
|
||||
opt-in sharing work above.
|
||||
- **Diagnostic snapshot drift.** ~30 integrations have
|
||||
`__snapshots__/` files that include `entry.as_dict()` and now show
|
||||
`+ 'sandbox': 'built-in'`. The fix lives in those integrations'
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# Query-shaped RPCs — the unproxied entity-component APIs
|
||||
|
||||
> Status: **gap catalogue + interim behaviour.** The interim behaviour (raise
|
||||
> `HomeAssistantError` instead of silently returning empty) is shipped; the
|
||||
> request/response RPC that actually makes these work is not. Brainstorm the
|
||||
> design against this list.
|
||||
|
||||
## Why these don't ride the existing bridge
|
||||
|
||||
The entity bridge (§8 of [`ARCHITECTURE.md`](../ARCHITECTURE.md)) is
|
||||
**fire-and-forget**: a proxy entity method becomes one
|
||||
`services.async_call(domain, service, target=…)` over `sandbox/call_service`.
|
||||
That shape can *command* the real entity but can't **ask it a question and get
|
||||
an answer back**. Every API below is a server-side query, a subscription, or a
|
||||
WS-only mutation that has no service to forward through — so the proxy can't
|
||||
express it, and until a request/response (and, for two of them,
|
||||
subscription-shaped) RPC lands it raises via `entity.raise_not_proxied(...)`
|
||||
rather than returning empty data that looks real.
|
||||
|
||||
Two distinct missing primitives:
|
||||
|
||||
1. **Request/response RPC** — `sandbox/entity_query` (or per-call): main sends
|
||||
`{sandbox_entity_id, method, args}`, the sandbox invokes the real entity
|
||||
method and returns the serialised result. Covers everything except the
|
||||
subscriptions.
|
||||
2. **Subscription / push RPC** — a `sandbox/entity_subscribe` + push channel for
|
||||
the `*/subscribe` commands (weather forecast, calendar events) and for
|
||||
pushing the `todo` item list into a proxy cache, so the sandbox can stream
|
||||
updates main re-emits to the WS client. (`todo` is routed to main until this
|
||||
lands — see the note below.)
|
||||
|
||||
## The catalogue
|
||||
|
||||
Entrypoint = what a frontend/automation actually calls on main. Entity API =
|
||||
the method/property the core handler invokes on the (proxy) entity. "Forwards"
|
||||
means it already works one-way via a service; everything else now raises.
|
||||
|
||||
| Domain | Entrypoint (service / WS) | Entity API | Shape | Interim |
|
||||
|---|---|---|---|---|
|
||||
| `calendar` | `calendar.get_events` (svc, response) | `async_get_events` | request/response | **raises** |
|
||||
| `calendar` | `calendar/event/subscribe` (WS) | `async_get_events` + recurrence timer | subscription | **raises** (via `async_get_events`) |
|
||||
| `calendar` | `calendar/event/create` (WS) | `async_create_event` | command | forwards (`calendar.create_event` svc) |
|
||||
| `calendar` | `calendar/event/update` (WS) | `async_update_event` | command (WS-only, no svc) | **raises** |
|
||||
| `calendar` | `calendar/event/delete` (WS) | `async_delete_event` | command (WS-only, no svc) | **raises** |
|
||||
| `todo` | *whole platform* | `todo_items` (property) | n/a | **routed to main** (see note) |
|
||||
| `weather` | `weather.get_forecasts` (svc, response) | `async_forecast_{daily,hourly,twice_daily}` | request/response | **raises** |
|
||||
| `weather` | `weather/subscribe_forecast` (WS) | `async_forecast_*` + listeners | subscription | **raises** (via `async_forecast_*`) |
|
||||
| `media_player` | `media_player.browse_media` (svc, response) / `media_player/browse_media` (WS) | `async_browse_media` | request/response | **raises** |
|
||||
| `media_player` | `media_player/search_media` (WS) | `async_search_media` | request/response | **raises** |
|
||||
| `update` | `update/release_notes` (WS) | `async_release_notes` | request/response | **raises** |
|
||||
| `vacuum` | `vacuum/get_segments` (WS) | `async_get_segments` | request/response | **raises** |
|
||||
|
||||
### The `todo` exception — routed to main, not proxied
|
||||
|
||||
`TodoListEntity.state` is `len(self.todo_items)`, so `todo_items` is read on
|
||||
**every state write**, not just on a query. It can't raise (that would break
|
||||
the state machine) and it can't block on a request/response query (it's a sync
|
||||
property). The only honest fix is for the sandbox to **push** the item list
|
||||
into a proxy cache so `todo_items` returns it synchronously — i.e. the
|
||||
subscription/push primitive, which is out of scope this iteration.
|
||||
|
||||
Rather than ship a proxy whose To-do panel silently shows an empty list while
|
||||
looking supported, `todo` is in `SANDBOX_INCOMPATIBLE_PLATFORMS`
|
||||
(`components/sandbox/const.py`) — any integration exposing a `todo` platform
|
||||
routes to main, exactly like `camera`. There is no `todo` proxy. Revisit when
|
||||
the push primitive lands.
|
||||
|
||||
## Not in scope here (handled elsewhere)
|
||||
|
||||
- **`camera`** — excluded entirely by `SANDBOX_INCOMPATIBLE_PLATFORMS` (byte
|
||||
streams the channel can't ferry).
|
||||
- **`todo`** — also `SANDBOX_INCOMPATIBLE_PLATFORMS` (sync-property-feeds-state
|
||||
problem above; needs a push primitive, not a query).
|
||||
- **`image`** — `ALWAYS_MAIN` (non-idempotent pre-dispatch work).
|
||||
- **Static metadata WS commands** — `weather/convertible_units`,
|
||||
`sensor/device_class_convertible_units`, `sensor/numeric_device_classes`,
|
||||
`number/device_class_convertible_units`. These are stateless lookups that run
|
||||
on main and never touch a sandboxed entity; nothing to proxy.
|
||||
|
||||
## Caveat: `media_player.browse_media` won't include media sources
|
||||
|
||||
On a normal install a media player's `async_browse_media` merges in the
|
||||
**`media_source`** tree (local media, TTS-cached clips, etc.) by calling
|
||||
`media_source.async_browse_media(self.hass, …)`. Inside the sandbox `self.hass`
|
||||
is the private, isolated instance — `media_source` runs on **main**, outside
|
||||
the sandbox boundary, so that call has nothing to resolve against. A sandboxed
|
||||
player's browse therefore surfaces **only the player's own sources**; the
|
||||
"Media Sources" branch will be empty for now. Closing this needs a cross-
|
||||
boundary hook (the sandbox would have to call back into main's `media_source`),
|
||||
which belongs with the same opt-in sharing work as the lockdown helpers — out
|
||||
of scope for the query RPC. Document it where the browse proxy is wired so it
|
||||
isn't mistaken for a bug.
|
||||
|
||||
## Response-returning services — a second look
|
||||
|
||||
`calendar.get_events`, `todo.get_items`, `weather.get_forecasts`, and
|
||||
`media_player.browse_media` are registered with `SupportsResponse.ONLY`. They
|
||||
dispatch to the entity method **on main** (against the proxy), so they're
|
||||
covered by the request/response RPC above — but whoever designs that RPC should
|
||||
confirm the service-forwarder path (`ServiceMirror`) also carries a
|
||||
`ServiceResponse` back for any *integration-owned* response service, which is a
|
||||
related but separate hole.
|
||||
@@ -0,0 +1,207 @@
|
||||
# Plan — Query-shaped RPCs (request/response iteration)
|
||||
|
||||
> Source: [`../../.claude/plans/query-rpc/interview.md`](../../.claude/plans/query-rpc/interview.md)
|
||||
> · Gap catalogue: [`../docs/query-shaped-rpcs.md`](../docs/query-shaped-rpcs.md)
|
||||
> · Status notes go to `sandbox/status/STATUS-plan-query-rpc.md` when work lands.
|
||||
|
||||
## Goal
|
||||
|
||||
Let sandbox proxy entities answer server-side **queries** they currently raise
|
||||
`HomeAssistantError` for. Two mechanisms, no new subscription primitive this
|
||||
iteration:
|
||||
|
||||
- **Reuse the existing `call_service` + `return_response` path** for entity
|
||||
methods that have a `SupportsResponse` service.
|
||||
- **Add one generic `EntityQuery` RPC** for the genuinely service-less methods.
|
||||
|
||||
`todo` is **out** — already routed to main via `SANDBOX_INCOMPATIBLE_PLATFORMS`
|
||||
(needs a push-cache, not a query). Subscriptions (`weather/subscribe_forecast`,
|
||||
`calendar/event/subscribe`) stay raising — deferred to a later push iteration.
|
||||
|
||||
## Success criteria
|
||||
|
||||
- [ ] `calendar.get_events`, `weather.get_forecasts`, `media_player.browse_media`
|
||||
return real data through a sandboxed entity.
|
||||
- [ ] `media_player/search_media`, `update/release_notes`, `vacuum/get_segments`,
|
||||
`calendar/event/update`, `calendar/event/delete` work through a sandboxed
|
||||
entity.
|
||||
- [ ] A sandbox-side `HomeAssistantError` / `ServiceValidationError` /
|
||||
`BrowseError` / `SearchError` surfaces on main as the same error shape a
|
||||
local entity would raise (mirrors today's `vol.Invalid` translation).
|
||||
- [ ] Channel-down / sandbox-unavailable degrades to a clean
|
||||
`HomeAssistantError`, never a hang or raw decode error.
|
||||
- [ ] `uv run pytest tests/components/sandbox/` + the client suite green; proto
|
||||
drift guard passes; `uv run prek run` clean.
|
||||
|
||||
## The verified split
|
||||
|
||||
| Op | Mechanism | Return type to rebuild on main |
|
||||
|---|---|---|
|
||||
| `calendar.async_get_events` | service `calendar.get_events` (`return_response`) | `list[CalendarEvent]` |
|
||||
| `weather.async_forecast_{daily,hourly,twice_daily}` | service `weather.get_forecasts` (`return_response`) | `list[Forecast]` (plain TypedDict — trivial) |
|
||||
| `media_player.async_browse_media` | service `media_player.browse_media` (`return_response`) | `BrowseMedia` (recursive) |
|
||||
| `media_player.async_search_media` | **EntityQuery** | `SearchMedia` (holds `list[BrowseMedia]`) |
|
||||
| `update.async_release_notes` | **EntityQuery** | `str | None` (trivial) |
|
||||
| `vacuum.async_get_segments` | **EntityQuery** | `list[Segment]` (dataclass) |
|
||||
| `calendar.async_update_event` | **EntityQuery** | `None` (mutation) |
|
||||
| `calendar.async_delete_event` | **EntityQuery** | `None` (mutation) |
|
||||
|
||||
Rule for future ops: **service path when a `SupportsResponse` service maps to
|
||||
the method; `EntityQuery` only when none exists.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Service-path response queries (no proto change)
|
||||
|
||||
The `return_response` plumbing already exists end-to-end:
|
||||
`bridge.async_call_service(..., return_response=True)` →
|
||||
`_raw_call_service` sets `CallService.return_response` →
|
||||
sandbox `EntryRunner._handle_call_service` runs the service with
|
||||
`return_response=True` and packs `CallServiceResult.response.data`
|
||||
(`entry_runner.py:122`). Only the proxy + rebuild layer is missing.
|
||||
|
||||
- [ ] Add a `return_response` param to `SandboxProxyEntity._call_service`
|
||||
(`entity/__init__.py:164`) — pass it through to `async_call_service`, and
|
||||
when set, return `struct_to_dict(result.response.data)` if
|
||||
`result.HasField("response")` else `{}`. (Today proxies ignore the
|
||||
return; the value is the decoded `CallServiceResult` pb message.)
|
||||
- [ ] **calendar** (`entity/calendar.py`): replace the `async_get_events` raise
|
||||
with a `calendar.get_events` service forward (`return_response=True`,
|
||||
`start_date_time`/`end_date_time` args) and rebuild `list[CalendarEvent]`
|
||||
from the response `events` list. Add a `_calendar_event_from_dict` helper
|
||||
(summary/start/end/description/location/uid/rrule/recurrence_id).
|
||||
- [ ] **weather** (`entity/weather.py`): replace the three `async_forecast_*`
|
||||
raises with a `weather.get_forecasts` service forward (`type=<kind>`,
|
||||
`return_response=True`); the response is keyed by **sandbox** entity_id —
|
||||
unwrap `response[self.description.sandbox_entity_id]["forecast"]` and
|
||||
return it (Forecast is a plain TypedDict, no rebuild).
|
||||
- [ ] **media_player** (`entity/media_player.py`): replace `async_browse_media`
|
||||
raise with a `media_player.browse_media` service forward
|
||||
(`media_content_type`/`media_content_id`, `return_response=True`) and
|
||||
rebuild `BrowseMedia` from the dict (recursive over `children`). Add a
|
||||
`_browse_media_from_dict` helper.
|
||||
**Caveat to document at the call site (and Risk #5):** a sandboxed
|
||||
player's browse will include **only its own sources** — the `media_source`
|
||||
tree it normally merges via `media_source.async_browse_media(self.hass, …)`
|
||||
is empty inside the sandbox, because `media_source` runs on main, outside
|
||||
the sandbox boundary. Not a bug; closing it needs a cross-boundary hook
|
||||
(later, with the opt-in sharing work). See the catalogue caveat.
|
||||
- [ ] Confirm the service response shape on the sandbox side: `browse_media`
|
||||
service returns `BrowseMedia.as_dict()`; `get_events` returns
|
||||
`{"events": [...]}`; `get_forecasts` returns `{<entity_id>: {"forecast": [...]}}`.
|
||||
Pin each in a test fixture.
|
||||
|
||||
## Phase 2 — `EntityQuery` RPC primitive
|
||||
|
||||
Mirror the `call_service` pattern exactly (proto → codec registry → bridge
|
||||
sender + error translation → sandbox handler).
|
||||
|
||||
- [ ] **proto** (`sandbox/proto/sandbox.proto`): add
|
||||
```
|
||||
message EntityQuery {
|
||||
string sandbox_entity_id = 1;
|
||||
string method = 2; // e.g. "async_search_media"
|
||||
google.protobuf.Struct args = 3; // kwargs, dynamic
|
||||
optional string context_id = 4; // same wire-safe id rule as CallService
|
||||
}
|
||||
message EntityQueryResult {
|
||||
google.protobuf.Struct result = 1; // wrapped: {"value": <return>}
|
||||
}
|
||||
```
|
||||
Wrap the return in a `{"value": …}` struct so scalar/list/None returns are
|
||||
all representable (Struct's top level must be an object).
|
||||
- [ ] Regenerate gencode: `bash sandbox/proto/generate.sh` (isolated venv, writes
|
||||
both `_proto` mirrors). Verify `sandbox/proto/check_drift.sh` passes.
|
||||
- [ ] **constants**: add `MSG_ENTITY_QUERY = "sandbox/entity_query"` to both
|
||||
`homeassistant/components/sandbox/protocol.py` and
|
||||
`sandbox/hass_client/hass_client/protocol.py`.
|
||||
- [ ] **registry**: add `"sandbox/entity_query": (pb.EntityQuery, pb.EntityQueryResult)`
|
||||
to `messages.REGISTRY` in **both** mirrors (`messages.py`).
|
||||
- [ ] **bridge sender** (`bridge.py`): add
|
||||
`async_entity_query(*, sandbox_entity_id, method, args, context) -> Any`
|
||||
next to `async_call_service`. Build `pb.EntityQuery`, `channel.call`,
|
||||
translate errors through the existing `_translate_remote_error` /
|
||||
`ChannelClosedError` paths, and return `struct_to_dict(result.result)["value"]`.
|
||||
- [ ] **sandbox handler**: register `MSG_ENTITY_QUERY` in `EntryRunner._wire`
|
||||
(`entry_runner.py:49`). Handler resolves the entity from the private hass
|
||||
by `sandbox_entity_id`, `getattr`s `method`, `await`s it with
|
||||
`struct_to_dict(args)`, wraps `{"value": _serialise(return)}` into
|
||||
`EntityQueryResult`. Raised exceptions propagate as channel error frames —
|
||||
reuse the existing `error_data` packing so `vol.Invalid`/HA errors rebuild
|
||||
on main (confirm `BrowseError`/`SearchError` map to `HomeAssistantError`).
|
||||
- [ ] **proxy helper** (`entity/__init__.py`): add
|
||||
`async def _entity_query(self, method, **args)` calling
|
||||
`self._bridge.async_entity_query(...)` with `self._context`.
|
||||
|
||||
## Phase 3 — Wire the service-less ops onto `EntityQuery`
|
||||
|
||||
Replace each `raise_not_proxied(...)` with an `_entity_query` call + rebuild:
|
||||
|
||||
- [ ] **media_player.async_search_media** → `_entity_query("async_search_media",
|
||||
query=<SearchMediaQuery as dict>)`; rebuild `SearchMedia` (reuse the
|
||||
`_browse_media_from_dict` helper for its `result` list).
|
||||
- [ ] **update.async_release_notes** → `_entity_query("async_release_notes")`;
|
||||
return the str/None directly.
|
||||
- [ ] **vacuum.async_get_segments** → `_entity_query("async_get_segments")`;
|
||||
rebuild `list[Segment]` (dataclass).
|
||||
- [ ] **calendar.async_update_event / async_delete_event** → `_entity_query(...)`
|
||||
forwarding uid/event/recurrence args; ignore the `None` result.
|
||||
- [ ] Sandbox-side `_serialise` must handle each return: `SearchMedia.as_dict()`,
|
||||
`Segment` (dataclass → `dataclasses.asdict`), `BrowseMedia.as_dict()`.
|
||||
|
||||
## Phase 4 — Serialization fidelity + tests
|
||||
|
||||
- [ ] One serializer/deserializer per rich type, with a round-trip unit test:
|
||||
`BrowseMedia` (recursive `children`, `thumbnail`, `media_class`),
|
||||
`CalendarEvent` (rrule/recurrence_id/all-day date vs datetime),
|
||||
`SearchMedia`, `Segment`. Forecast + release_notes are plain → assert
|
||||
pass-through only.
|
||||
- [ ] Extend `tests/components/sandbox/test_domain_proxies.py` (or a new
|
||||
`test_entity_query.py`) with a query case per op: stub the sandbox-side
|
||||
entity method, assert the proxy returns the rebuilt typed object.
|
||||
- [ ] Error-path tests: sandbox method raises `ServiceValidationError` →
|
||||
proxy raises the translated error; channel closed → `HomeAssistantError`.
|
||||
- [ ] Client-side test for the `EntityQuery` handler
|
||||
(`sandbox/hass_client/`): unknown entity_id, unknown method, method raises.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
bash sandbox/proto/check_drift.sh
|
||||
uv run pytest tests/components/sandbox/ --no-cov -q
|
||||
uv run pytest sandbox/hass_client/ -q
|
||||
uv run prek run --files <changed>
|
||||
```
|
||||
|
||||
## Risks / open questions (self-check)
|
||||
|
||||
1. **Recursive `BrowseMedia` size.** A media tree can be large; one Struct
|
||||
round-trip per browse is fine (it's user-initiated, not hot-path), but note
|
||||
it — no coalescing needed here.
|
||||
2. **`as_dict()` vs constructor asymmetry.** `BrowseMedia.as_dict()` is shaped
|
||||
for the frontend (e.g. `children_media_class`, `thumbnail`), not for
|
||||
`BrowseMedia(**d)`. The rebuild helper must map fields explicitly, not
|
||||
splat. Same caution for `CalendarEvent` (dates serialise to ISO strings).
|
||||
This is the highest-effort, highest-risk part — validate with round-trip
|
||||
tests first (Phase 4 before Phase 1/3 rebuild code if needed).
|
||||
3. **Why generic `EntityQuery` over typed messages?** Decided in interview:
|
||||
far fewer proto messages; the cost is serialization lives in hand-written
|
||||
`_serialise`/rebuild helpers rather than the proto schema. Acceptable for a
|
||||
handful of ops; revisit if the surface grows.
|
||||
4. **Does the service path double-validate?** The sandbox re-runs the full
|
||||
`calendar.get_events`/`browse_media` service (schema + handler) against the
|
||||
real entity — that's the point (fidelity), not a bug. Confirm no main-side
|
||||
pre-validation rejects before forwarding.
|
||||
5. **`browse_media` loses the media-source tree.** A sandboxed player's browse
|
||||
shows only its own sources; the `media_source`-backed "Media Sources" branch
|
||||
is empty because `media_source` lives on main, not in the sandbox's private
|
||||
hass. Known limitation, documented at the call site — needs a cross-boundary
|
||||
hook to fix (out of scope, pairs with opt-in sharing).
|
||||
|
||||
## Out of scope (explicit)
|
||||
|
||||
- Subscription/push RPC (`weather/subscribe_forecast`, `calendar/event/subscribe`,
|
||||
and the eventual `todo` item-list push that would un-block `todo`).
|
||||
- Coalescing same-tick queries (mirrors the `call_service` future-opt note).
|
||||
- Integration-owned `SupportsResponse` services through `ServiceMirror` — the
|
||||
related response-carry hole flagged in the catalogue; separate task.
|
||||
Reference in New Issue
Block a user