mirror of
https://github.com/home-assistant/core.git
synced 2026-09-27 01:46:11 -04:00
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:
co-authored by
Claude Opus 4.8
parent
29f0c31b4f
commit
50a2d16d9d
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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user