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>
This commit is contained in:
Paulus Schoutsen
2026-07-07 15:12:23 -04:00
co-authored by Claude Opus 4.8
parent 50a2d16d9d
commit 07a7b918ca
4 changed files with 64 additions and 19 deletions
@@ -5,7 +5,7 @@ from typing import Any
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from . import SandboxProxyEntity, raise_not_proxied
from . import SandboxProxyEntity
def _parse_calendar_date(value: Any) -> datetime.date | datetime.datetime | Any:
@@ -52,11 +52,11 @@ class SandboxCalendarEntity(SandboxProxyEntity, CalendarEntity):
``create_event`` forwards through the standard ``calendar.create_event``
service. The listing query (``async_get_events``) rides the
``calendar.get_events`` ``SupportsResponse`` service. The WS-only event
edits (``calendar/event/update`` / ``delete``) need the request/response
``EntityQuery`` RPC; until that lands they raise. The recurrence-timer
subscription (``calendar/event/subscribe``) is deferred — the
next/current event is not pushed, so ``event`` returns ``None``. See
``calendar.get_events`` ``SupportsResponse`` service; the WS-only event
edits (``calendar/event/update`` / ``delete``) cross via the generic
``EntityQuery`` RPC. The recurrence-timer subscription
(``calendar/event/subscribe``) is deferred — the next/current event is not
pushed, so ``event`` returns ``None``. See
``sandbox/docs/query-shaped-rpcs.md``.
"""
@@ -92,8 +92,14 @@ class SandboxCalendarEntity(SandboxProxyEntity, CalendarEntity):
recurrence_id: str | None = None,
recurrence_range: str | None = None,
) -> None:
"""Raise — ``calendar/event/update`` needs the EntityQuery RPC."""
raise_not_proxied("Updating a calendar event")
"""Forward the WS-only event update through ``EntityQuery``."""
await self._entity_query(
"async_update_event",
uid=uid,
event=event,
recurrence_id=recurrence_id,
recurrence_range=recurrence_range,
)
async def async_delete_event(
self,
@@ -101,5 +107,10 @@ class SandboxCalendarEntity(SandboxProxyEntity, CalendarEntity):
recurrence_id: str | None = None,
recurrence_range: str | None = None,
) -> None:
"""Raise — ``calendar/event/delete`` needs the EntityQuery RPC."""
raise_not_proxied("Deleting a calendar event")
"""Forward the WS-only event delete through ``EntityQuery``."""
await self._entity_query(
"async_delete_event",
uid=uid,
recurrence_id=recurrence_id,
recurrence_range=recurrence_range,
)
@@ -31,7 +31,7 @@ from homeassistant.components.media_player import (
)
from homeassistant.exceptions import HomeAssistantError
from . import SandboxProxyEntity, raise_not_proxied
from . import SandboxProxyEntity
if TYPE_CHECKING:
from ..bridge import SandboxBridge, SandboxEntityDescription
@@ -64,6 +64,18 @@ def _browse_media_from_dict(data: dict[str, Any]) -> BrowseMedia:
)
def _search_media_from_dict(data: dict[str, Any]) -> SearchMedia:
"""Rebuild a :class:`SearchMedia` from its ``as_dict`` shape.
``SearchMedia.as_dict`` holds its results under ``result`` as a list of
``BrowseMedia`` dicts, so the rebuild reuses :func:`_browse_media_from_dict`
per item. ``version`` is constructor-defaulted.
"""
return SearchMedia(
result=[_browse_media_from_dict(item) for item in data.get("result", [])]
)
# pylint: disable-next=home-assistant-enforce-class-module
class SandboxMediaPlayerEntity(SandboxProxyEntity, MediaPlayerEntity):
"""Proxy for a ``media_player`` entity in a sandbox."""
@@ -276,8 +288,24 @@ class SandboxMediaPlayerEntity(SandboxProxyEntity, MediaPlayerEntity):
return _browse_media_from_dict(entity_response)
async def async_search_media(self, query: SearchMediaQuery) -> SearchMedia:
"""Raise — media search is a server-side query, not yet proxied."""
raise_not_proxied("Searching media")
"""Search via ``EntityQuery`` against the real entity.
Forwarded to ``async_internal_search_media`` (which rebuilds the
``SearchMediaQuery`` from flat kwargs on the sandbox side) rather than
``async_search_media``, so the query crosses as plain JSON kwargs.
``media_filter_classes`` cross as their ``MediaClass`` string values.
"""
args: dict[str, Any] = {"search_query": query.search_query}
if query.media_content_type is not None:
args["media_content_type"] = query.media_content_type
if query.media_content_id is not None:
args["media_content_id"] = query.media_content_id
if query.media_filter_classes is not None:
args["media_filter_classes"] = [
getattr(item, "value", item) for item in query.media_filter_classes
]
response = await self._entity_query("async_internal_search_media", **args)
return _search_media_from_dict(response or {})
async def async_clear_playlist(self) -> None:
"""Forward clear_playlist."""
@@ -9,7 +9,7 @@ from homeassistant.components.update import (
UpdateEntityFeature,
)
from . import SandboxProxyEntity, raise_not_proxied
from . import SandboxProxyEntity
if TYPE_CHECKING:
from ..bridge import SandboxBridge, SandboxEntityDescription
@@ -99,5 +99,5 @@ class SandboxUpdateEntity(SandboxProxyEntity, UpdateEntity):
await self._call_service("install", **payload)
async def async_release_notes(self) -> str | None:
"""Raise — ``update/release_notes`` is a WS query, not yet proxied."""
raise_not_proxied("Fetching update release notes")
"""Return the release notes via ``EntityQuery`` (a plain str/None)."""
return await self._entity_query("async_release_notes")
@@ -11,12 +11,17 @@ from homeassistant.components.vacuum import (
VacuumEntityFeature,
)
from . import SandboxProxyEntity, raise_not_proxied
from . import SandboxProxyEntity
if TYPE_CHECKING:
from ..bridge import SandboxBridge, SandboxEntityDescription
def _segment_from_dict(data: dict[str, Any]) -> Segment:
"""Rebuild a :class:`Segment` dataclass from its serialised dict."""
return Segment(id=data["id"], name=data["name"], group=data.get("group"))
# pylint: disable-next=home-assistant-enforce-class-module
class SandboxVacuumEntity(SandboxProxyEntity, StateVacuumEntity):
"""Proxy for a ``vacuum`` entity in a sandbox."""
@@ -94,5 +99,6 @@ class SandboxVacuumEntity(SandboxProxyEntity, StateVacuumEntity):
await self._call_service("send_command", **payload)
async def async_get_segments(self) -> list[Segment]:
"""Raise — ``vacuum/get_segments`` is a WS query, not yet proxied."""
raise_not_proxied("Listing vacuum segments")
"""Return the cleanable segments via ``EntityQuery``."""
response = await self._entity_query("async_get_segments")
return [_segment_from_dict(segment) for segment in response or []]