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>
This commit is contained in:
Paulus Schoutsen
2026-07-07 15:12:23 -04:00
co-authored by Claude Opus 4.8
parent 29f0c31b4f
commit 50a2d16d9d
12 changed files with 304 additions and 127 deletions
File diff suppressed because one or more lines are too long
@@ -183,6 +183,24 @@ class CallServiceResult(_message.Message):
response: ServiceResponse
def __init__(self, response: _Optional[_Union[ServiceResponse, _Mapping]] = ...) -> None: ...
class EntityQuery(_message.Message):
__slots__ = ("sandbox_entity_id", "method", "args", "context_id")
SANDBOX_ENTITY_ID_FIELD_NUMBER: _ClassVar[int]
METHOD_FIELD_NUMBER: _ClassVar[int]
ARGS_FIELD_NUMBER: _ClassVar[int]
CONTEXT_ID_FIELD_NUMBER: _ClassVar[int]
sandbox_entity_id: str
method: str
args: _struct_pb2.Struct
context_id: str
def __init__(self, sandbox_entity_id: _Optional[str] = ..., method: _Optional[str] = ..., args: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., context_id: _Optional[str] = ...) -> None: ...
class EntityQueryResult(_message.Message):
__slots__ = ("result",)
RESULT_FIELD_NUMBER: _ClassVar[int]
result: _struct_pb2.Struct
def __init__(self, result: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
class GetTranslations(_message.Message):
__slots__ = ("language", "domains")
LANGUAGE_FIELD_NUMBER: _ClassVar[int]
@@ -65,6 +65,7 @@ from .const import UNIQUE_ID_SEPARATOR
from .messages import dict_to_struct, listvalue_to_list, struct_to_dict
from .protocol import (
MSG_CALL_SERVICE,
MSG_ENTITY_QUERY,
MSG_FIRE_EVENT,
MSG_REGISTER_ENTITY,
MSG_REGISTER_SERVICE,
@@ -243,6 +244,43 @@ class SandboxBridge:
return_response=return_response,
)
async def async_entity_query(
self,
*,
sandbox_entity_id: str,
method: str,
args: dict[str, Any],
context: Context | None = None,
) -> Any:
"""Forward one server-side entity query to the sandbox as a single RPC.
The companion to :meth:`async_call_service` for the query-shaped entity
APIs that have no ``SupportsResponse`` service to ride (media search,
update release notes, vacuum segments, the WS-only calendar event
edits). ``method`` names the real entity method; ``args`` are its
kwargs. Like a service call the ``context`` is remembered before its id
is reduced to a bare wire value, errors translate through the same
:func:`_translate_remote_error` / ``ChannelClosedError`` paths, and the
wrapped ``{"value": …}`` return is unwrapped.
"""
self._remember_context(context)
request = pb.EntityQuery(
sandbox_entity_id=sandbox_entity_id,
method=method,
args=dict_to_struct(args),
)
if context is not None:
request.context_id = context.id
try:
result = await self.channel.call(MSG_ENTITY_QUERY, request)
except ChannelRemoteError as err:
raise _translate_remote_error(err) from err
except ChannelClosedError as err:
raise HomeAssistantError(
f"Sandbox {self.group!r} channel closed mid-query"
) from err
return struct_to_dict(result.result).get("value")
async def _raw_call_service(
self,
*,
@@ -197,6 +197,23 @@ class SandboxProxyEntity(Entity):
return struct_to_dict(result.response.data)
return {}
async def _entity_query(self, method: str, **args: Any) -> Any:
"""Forward a server-side entity query to the sandbox.
The request/response companion to :meth:`_call_service` for the
query-shaped entity APIs that have no ``SupportsResponse`` service to
ride. ``method`` names the real entity method to invoke on the sandbox
side; ``args`` are its kwargs. Returns the deserialised return value
(``None`` for mutations). ``self._context`` is forwarded so attribution
survives exactly as it does for a service call.
"""
return await self._bridge.async_entity_query(
sandbox_entity_id=self.description.sandbox_entity_id,
method=method,
args=args,
context=self._context,
)
# Lazy import to avoid a circular dependency at module import time
# (bridge imports build_proxy → entity imports proxies → proxies import
@@ -38,6 +38,7 @@ REGISTRY: dict[str, tuple[type[Message], type[Message] | None]] = {
"sandbox/entry_setup": (pb.EntrySetup, pb.EntrySetupResult),
"sandbox/entry_unload": (pb.EntryUnload, pb.EntryUnloadResult),
"sandbox/call_service": (pb.CallService, pb.CallServiceResult),
"sandbox/entity_query": (pb.EntityQuery, pb.EntityQueryResult),
"sandbox/get_translations": (pb.GetTranslations, pb.GetTranslationsResult),
"sandbox/shutdown": (pb.Shutdown, pb.ShutdownResult),
"sandbox/ping": (pb.Ping, pb.PingResult),
@@ -32,6 +32,13 @@ Main → Sandbox calls:
the main→sandbox service mirroring path). Payload mirrors a
``ServiceCall``: ``(domain, service, target, service_data, context,
return_response)``. Returns either ``None`` or a service-response dict.
* ``sandbox/entity_query`` — generic request/response RPC for the
server-side entity queries with no ``SupportsResponse`` service to ride
(media search, update release notes, vacuum segments, the WS-only calendar
event edits). Payload ``{sandbox_entity_id, method, args, context_id}``;
the sandbox resolves the entity, invokes ``method`` with ``args`` as kwargs,
and returns the serialised result wrapped as ``{"value": <return>}``.
Ops that map to a ``SupportsResponse`` service use ``call_service`` instead.
* ``sandbox/get_translations`` — pull a sandboxed integration's frontend
translation strings. Payload ``{language, domains: [str]}`` (main batches
every owned custom domain of one group into a single request). Response
@@ -100,6 +107,7 @@ MSG_READY: Final = "sandbox/ready"
MSG_ENTRY_SETUP: Final = "sandbox/entry_setup"
MSG_ENTRY_UNLOAD: Final = "sandbox/entry_unload"
MSG_CALL_SERVICE: Final = "sandbox/call_service"
MSG_ENTITY_QUERY: Final = "sandbox/entity_query"
MSG_GET_TRANSLATIONS: Final = "sandbox/get_translations"
MSG_SHUTDOWN: Final = "sandbox/shutdown"
@@ -117,6 +125,7 @@ MSG_STORE_REMOVE: Final = "sandbox/store_remove"
__all__ = [
"MSG_CALL_SERVICE",
"MSG_ENTITY_QUERY",
"MSG_ENTRY_SETUP",
"MSG_ENTRY_UNLOAD",
"MSG_FIRE_EVENT",
File diff suppressed because one or more lines are too long
@@ -183,6 +183,24 @@ class CallServiceResult(_message.Message):
response: ServiceResponse
def __init__(self, response: _Optional[_Union[ServiceResponse, _Mapping]] = ...) -> None: ...
class EntityQuery(_message.Message):
__slots__ = ("sandbox_entity_id", "method", "args", "context_id")
SANDBOX_ENTITY_ID_FIELD_NUMBER: _ClassVar[int]
METHOD_FIELD_NUMBER: _ClassVar[int]
ARGS_FIELD_NUMBER: _ClassVar[int]
CONTEXT_ID_FIELD_NUMBER: _ClassVar[int]
sandbox_entity_id: str
method: str
args: _struct_pb2.Struct
context_id: str
def __init__(self, sandbox_entity_id: _Optional[str] = ..., method: _Optional[str] = ..., args: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., context_id: _Optional[str] = ...) -> None: ...
class EntityQueryResult(_message.Message):
__slots__ = ("result",)
RESULT_FIELD_NUMBER: _ClassVar[int]
result: _struct_pb2.Struct
def __init__(self, result: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
class GetTranslations(_message.Message):
__slots__ = ("language", "domains")
LANGUAGE_FIELD_NUMBER: _ClassVar[int]
@@ -14,6 +14,9 @@ from typing import Any
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_component import DATA_INSTANCES
from homeassistant.helpers.json import json_bytes
from homeassistant.util.json import json_loads
@@ -21,7 +24,12 @@ from ._proto import sandbox_pb2 as pb
from .approved_domains import ApprovedDomains
from .channel import Channel
from .messages import dict_to_struct, struct_to_dict
from .protocol import MSG_CALL_SERVICE, MSG_ENTRY_SETUP, MSG_ENTRY_UNLOAD
from .protocol import (
MSG_CALL_SERVICE,
MSG_ENTITY_QUERY,
MSG_ENTRY_SETUP,
MSG_ENTRY_UNLOAD,
)
from .sources import FetchPrimitive, SandboxSourceError, async_ensure_integration_source
_LOGGER = logging.getLogger(__name__)
@@ -53,6 +61,7 @@ class EntryRunner:
channel.register(MSG_ENTRY_SETUP, self._handle_entry_setup)
channel.register(MSG_ENTRY_UNLOAD, self._handle_entry_unload)
channel.register(MSG_CALL_SERVICE, self._handle_call_service)
channel.register(MSG_ENTITY_QUERY, self._handle_entity_query)
async def _handle_entry_setup(self, msg: pb.EntrySetup) -> pb.EntrySetupResult:
"""Build a :class:`ConfigEntry`, register it, and call async_setup."""
@@ -153,6 +162,41 @@ class EntryRunner:
)
return pb.CallServiceResult()
async def _handle_entity_query(self, msg: pb.EntityQuery) -> pb.EntityQueryResult:
"""Invoke a server-side entity method and return its serialised result.
Resolves the entity on the private hass by ``sandbox_entity_id``,
``getattr``s the named method, and awaits it with the decoded kwargs.
The return is wrapped as ``{"value": …}`` and run through the same
``as_dict``-aware JSON encoder used for service responses, so rich
types (``SearchMedia``, ``BrowseMedia``, ``Segment`` dataclasses)
cross verbatim. A raised exception (``ServiceValidationError`` /
``BrowseError`` / ``SearchError`` / ``HomeAssistantError`` /
``vol.Invalid``) propagates as a channel error frame, exactly like
``call_service``, so main rebuilds the same error shape.
"""
entity = _resolve_entity(self.hass, msg.sandbox_entity_id)
method = getattr(entity, msg.method, None)
if not callable(method):
raise HomeAssistantError(
f"entity_query: {msg.sandbox_entity_id!r} has no method"
f" {msg.method!r}"
)
value = await method(**struct_to_dict(msg.args))
result = pb.EntityQueryResult()
result.result.CopyFrom(dict_to_struct(_json_safe({"value": value})))
return result
def _resolve_entity(hass: HomeAssistant, entity_id: str) -> Entity:
"""Return the live entity object for ``entity_id`` or raise."""
domain = entity_id.split(".", 1)[0]
component = hass.data.get(DATA_INSTANCES, {}).get(domain)
entity = component.get_entity(entity_id) if component is not None else None
if entity is None:
raise HomeAssistantError(f"entity_query: unknown entity_id {entity_id!r}")
return entity
def _json_safe(result: Any) -> dict[str, Any]:
"""Coerce a service response into a plain JSON-safe dict.
@@ -38,6 +38,7 @@ REGISTRY: dict[str, tuple[type[Message], type[Message] | None]] = {
"sandbox/entry_setup": (pb.EntrySetup, pb.EntrySetupResult),
"sandbox/entry_unload": (pb.EntryUnload, pb.EntryUnloadResult),
"sandbox/call_service": (pb.CallService, pb.CallServiceResult),
"sandbox/entity_query": (pb.EntityQuery, pb.EntityQueryResult),
"sandbox/get_translations": (pb.GetTranslations, pb.GetTranslationsResult),
"sandbox/shutdown": (pb.Shutdown, pb.ShutdownResult),
"sandbox/ping": (pb.Ping, pb.PingResult),
@@ -18,6 +18,7 @@ MSG_READY: Final = "sandbox/ready"
MSG_ENTRY_SETUP: Final = "sandbox/entry_setup"
MSG_ENTRY_UNLOAD: Final = "sandbox/entry_unload"
MSG_CALL_SERVICE: Final = "sandbox/call_service"
MSG_ENTITY_QUERY: Final = "sandbox/entity_query"
MSG_GET_TRANSLATIONS: Final = "sandbox/get_translations"
MSG_SHUTDOWN: Final = "sandbox/shutdown"
@@ -35,6 +36,7 @@ MSG_STORE_REMOVE: Final = "sandbox/store_remove"
__all__ = [
"MSG_CALL_SERVICE",
"MSG_ENTITY_QUERY",
"MSG_ENTRY_SETUP",
"MSG_ENTRY_UNLOAD",
"MSG_FIRE_EVENT",
+21
View File
@@ -153,6 +153,27 @@ message CallServiceResult {
optional ServiceResponse response = 1; // unset when no response returned
}
// --- entity_query (main -> sandbox) ---------------------------------------
// Generic request/response RPC for the server-side entity queries that have
// NO SupportsResponse service to ride (media search, release notes, vacuum
// segments, the WS-only calendar event edits). Main names the entity + method
// and passes the kwargs; the sandbox invokes the real entity method and
// returns the serialised result. Ops that DO map to a SupportsResponse
// service use call_service instead — see docs/query-shaped-rpcs.md.
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 {
// Wrapped as {"value": <return>} so scalar / list / None returns are all
// representable (a Struct's top level must be an object).
google.protobuf.Struct result = 1;
}
// --- get_translations (main -> sandbox) -----------------------------------
// Pull a sandboxed integration's frontend translation strings. Main issues